diff --git a/README.md b/README.md index 4f6d16f..eb1d49b 100644 --- a/README.md +++ b/README.md @@ -157,13 +157,19 @@ Local data is stored in `~/.hasna/configs/` (unchanged, for fleet continuity). `instructions session plan` and `instructions session apply` render OpenIdentities and instruction sources into provider-native files for Claude, -Codex, Cursor, OpenCode, Codewith, aicopilot, and Google Antigravity. +Codex, Cursor, OpenCode, Codewith, Qwen, aicopilot, and Google Antigravity. The old Google agent target is removed; Antigravity is the only Google coding agent render target. Antigravity workspace rules are rendered to `.agents/rules/*.md`; its current global rules and MCP files use Google's legacy-named `~/.gemini/GEMINI.md` and `~/.gemini/config/mcp_config.json` paths but remain owned by the `antigravity` target. +Qwen Code session rendering writes `QWEN.md` instructional context with +`QWEN_HOME` pointing at the rendered profile home. Known config sync also +tracks Qwen Code `QWEN.md` and `settings.json` files at `~/.qwen/...` and +project `QWEN.md` / `.qwen/settings.json`, so native hook settings can be +managed without claiming session-rendered context is hard enforcement. + ```bash instructions session plan \ --tool codewith \ @@ -193,6 +199,14 @@ push safety, no brittle hardcoding when source-of-truth or reusable abstractions exist, autonomous repair, Hasna CLI source-of-truth usage, conversation surface routing, and unbudgeted Codewith goals unless a user asks for budgets. +They also seed `dangerous-operation-guard-standard`, the managed station01 guard +source for risky shell commands, edits, git operations, package installs, and +secret-adjacent access. The guard excludes Gemini CLI, requires Codewith/Codex +`PreToolUse` to hard-deny or inject context rather than ask for approval, uses +`PermissionRequest` for Codewith/Codex approvals, records that Qwen `QWEN.md` +is policy context only, and records native hook or wrapper/plugin fallback +expectations for Claude, Qwen, OpenCode, Cursor, and Antigravity. + ## Machine-aware Profiles `instructions init` seeds two platform profiles: diff --git a/bun.lock b/bun.lock index 5fd8ad1..e080f3e 100644 --- a/bun.lock +++ b/bun.lock @@ -3,7 +3,7 @@ "configVersion": 1, "workspaces": { "": { - "name": "@hasna/configs", + "name": "@hasna/instructions", "dependencies": { "@hasna/contracts": "0.4.2", "@hasna/events": "^0.1.6", diff --git a/docs/managed-prompt-hierarchy.md b/docs/managed-prompt-hierarchy.md index ab988f4..4ffab1f 100644 --- a/docs/managed-prompt-hierarchy.md +++ b/docs/managed-prompt-hierarchy.md @@ -17,7 +17,7 @@ Render sources are composed in this order: | Layer | Purpose | | --- | --- | | `global` | Non-overridable Hasna rules and default agent behavior. | -| `tool` | Tool/provider-specific behavior such as Codewith, Claude, Codex, Cursor, OpenCode, Antigravity, or aicopilot. | +| `tool` | Tool/provider-specific behavior such as Codewith, Claude, Codex, Qwen, Cursor, OpenCode, Antigravity, or aicopilot. | | `account` | Account or auth-profile overlays such as `live-codewith`. | | `machine` | Host-specific rules for machines such as `spark01`, Apple laptops, or fleet nodes. | | `division` | Area-level rules for repo families such as `opensource`, `hasnastudio`, `hasnatools`, or `infra`. | @@ -43,6 +43,7 @@ continue to map to `repo`; `machine-overlay` maps to `machine`; and | `cursor` | Project-owned `.cursor/rules/*.mdc`. | | `opencode` | `AGENTS.md`, `opencode.json`, and `.hasna/instructions` fragments. | | `aicopilot` | `AICOPILOT.md`, with optional `aicopilot.json` instructions in a later pass. | +| `qwen` | Profile-scoped `QWEN.md` session render/apply output; known sync also tracks `~/.qwen/QWEN.md`, `~/.qwen/settings.json`, project `QWEN.md`, and `.qwen/settings.json`. Native hooks should be represented in settings when verified. | | `antigravity` | Project-owned `.agents/rules/*.md`, workspace MCP at `.agents/mcp_config.json`, and Google's current legacy-named global Antigravity files at `~/.gemini/GEMINI.md` and `~/.gemini/config/mcp_config.json`. | The retired Google coding-agent target has no active render path. Do not create @@ -96,16 +97,36 @@ The managed global prompt must include these rules: 10. Never expose secrets in prompts, tasks, memories, conversations, manifests, reports, logs, or PR text. Reference vault item names and grants only. +## Dangerous Operation Guard + +`instructions init` and `bun run seed` also seed +`dangerous-operation-guard-standard`, the managed rollout source for risky +operation policy on sustained station01 coding agents. + +The guard covers Codewith, Codex, Claude Code, Qwen Code, OpenCode, Cursor, and +Google Antigravity. Gemini CLI remains excluded. Codewith and Codex must use +managed `PreToolUse` as a hard-deny/context-injection surface and +`PermissionRequest` for approvals; `PreToolUse` must not return ask-style +approval decisions. Qwen `QWEN.md` rendering is policy context only; hard +enforcement for Qwen requires native hook settings or a managed wrapper/plugin. +Claude and Qwen should use native hook settings where available. OpenCode, +Cursor, and Antigravity must use a verified native plugin/config path or an +explicitly managed wrapper/plugin fallback before hard enforcement is claimed. + ## Implementation Notes The implementation is incremental: 1. Extend the session render layer model and aliases. 2. Add active Antigravity support and remove the retired Google agent from active config sync. -3. Keep Codewith behavior compatible with existing flattened renders and the +3. Add Qwen Code `QWEN.md` session rendering and track Qwen settings as an + active config owner without adding a retired Gemini target. +4. Keep Codewith behavior compatible with existing flattened renders and the `HASNA_CONFIGS_CODEWITH_NATIVE_IMPORTS` gate. -4. Seed the managed `global-agent-rules-standard` reference so canonical global +5. Seed the managed `global-agent-rules-standard` reference so canonical global prompt content includes the required operating rules. -5. Add tests that prove layer ordering, Antigravity output paths, the +6. Seed the managed `dangerous-operation-guard-standard` reference so station01 + guard requirements can roll out through instructions-managed config. +7. Add tests that prove layer ordering, Antigravity output paths, the Antigravity 12,000-character rule-file limit, active agent target coverage, - and the seeded global rules content. + and the seeded global rules and dangerous-operation guard content. diff --git a/package.json b/package.json index 3524f34..859bc88 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hasna/instructions", - "version": "0.4.6", + "version": "0.4.7", "description": "AI coding agent instruction & configuration manager \u2014 store, version, apply, and share all your AI coding configs. CLI + MCP + HTTP API (instructions-serve) + generated SDK + Dashboard.", "type": "module", "main": "dist/index.js", diff --git a/scripts/seed.ts b/scripts/seed.ts index 0656f0d..4c16315 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -10,6 +10,7 @@ import { LocalConfigStore } from "../src/data/config-store"; import { ensurePlatformProfiles } from "../src/lib/platform-profiles"; import { ensureProjectDashboardStandardConfig } from "../src/lib/project-dashboard-standard"; import { ensureGlobalAgentRulesStandardConfig } from "../src/lib/global-agent-rules-standard"; +import { ensureDangerousOperationGuardStandardConfig } from "../src/lib/dangerous-operation-guard-standard"; const db = getDatabase(); const store = new LocalConfigStore(db); @@ -87,6 +88,9 @@ for (const ref of refs) { const globalAgentRulesStandard = await ensureGlobalAgentRulesStandardConfig(store); console.log(` = ${globalAgentRulesStandard.slug}`); +const dangerousOperationGuardStandard = await ensureDangerousOperationGuardStandardConfig(store); +console.log(` = ${dangerousOperationGuardStandard.slug}`); + const projectDashboardStandard = await ensureProjectDashboardStandardConfig(store); console.log(` = ${projectDashboardStandard.slug}`); diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 325e763..6b86e8a 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -7,7 +7,7 @@ export interface Config { slug: string; kind: "file" | "reference"; category: "agent" | "rules" | "mcp" | "shell" | "secrets_schema" | "workspace" | "git" | "tools"; - agent: "claude" | "codex" | "antigravity" | "opencode" | "cursor" | "codewith" | "aicopilot" | "zsh" | "git" | "npm" | "global"; + agent: "claude" | "codex" | "antigravity" | "opencode" | "cursor" | "codewith" | "aicopilot" | "qwen" | "zsh" | "git" | "npm" | "global"; target_path: string | null; outputs: ConfigOutput[]; format: "text" | "json" | "toml" | "yaml" | "markdown" | "ini"; diff --git a/src/cli/index.tsx b/src/cli/index.tsx index 3c9d478..8107cc9 100644 --- a/src/cli/index.tsx +++ b/src/cli/index.tsx @@ -18,6 +18,7 @@ import { planSessionRender, resolveSessionPath, sourceFromConfig, sourceFromFile import { ensurePlatformProfiles } from "../lib/platform-profiles.js"; import { ensureProjectDashboardStandardConfig } from "../lib/project-dashboard-standard.js"; import { ensureGlobalAgentRulesStandardConfig } from "../lib/global-agent-rules-standard.js"; +import { ensureDangerousOperationGuardStandardConfig } from "../lib/dangerous-operation-guard-standard.js"; import { getConfigsStatus } from "../status.js"; import { resolveConfigStore, isCloudMode, type ConfigStore } from "../data/config-store.js"; import { DEFAULT_LIST_LIMIT, paginate, parseLimit, truncateMiddle, truncateText } from "../lib/compact-output.js"; @@ -425,8 +426,8 @@ program // ── sync ───────────────────────────────────────────────────────────────────── program .command("sync") - .description("Sync known AI coding configs from disk into DB (claude, codex, opencode, cursor, codewith, aicopilot, antigravity, zsh, git, npm)") - .option("-a, --agent ", "only sync configs for this agent (claude|codex|opencode|cursor|codewith|aicopilot|antigravity|zsh|git|npm)") + .description("Sync known AI coding configs from disk into DB (claude, codex, opencode, cursor, codewith, aicopilot, antigravity, qwen, zsh, git, npm)") + .option("-a, --agent ", "only sync configs for this agent (claude|codex|opencode|cursor|codewith|aicopilot|antigravity|qwen|zsh|git|npm)") .option("-c, --category ", "only sync configs in this category") .option("-p, --project [dir]", "sync project-scoped configs (CLAUDE.md, .mcp.json, etc.) from a project dir") .option("--all", "with --project: scan all subdirs for projects to sync") @@ -1163,6 +1164,7 @@ program } } await ensureGlobalAgentRulesStandardConfig(store); + await ensureDangerousOperationGuardStandardConfig(store); await ensureProjectDashboardStandardConfig(store); // Create default profile diff --git a/src/index.ts b/src/index.ts index 612903f..db72eb6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -100,6 +100,11 @@ export { GLOBAL_AGENT_RULES_STANDARD_SLUG, ensureGlobalAgentRulesStandardConfig, } from "./lib/global-agent-rules-standard.js"; +export { + DANGEROUS_OPERATION_GUARD_STANDARD_CONTENT, + DANGEROUS_OPERATION_GUARD_STANDARD_SLUG, + ensureDangerousOperationGuardStandardConfig, +} from "./lib/dangerous-operation-guard-standard.js"; // Lib — sync export { syncKnown, syncToDisk, syncProject, diffConfig, detectCategory, detectAgent, detectFormat, KNOWN_CONFIGS, PROJECT_CONFIG_FILES } from "./lib/sync.js"; diff --git a/src/lib/dangerous-operation-guard-standard.test.ts b/src/lib/dangerous-operation-guard-standard.test.ts new file mode 100644 index 0000000..6941cb7 --- /dev/null +++ b/src/lib/dangerous-operation-guard-standard.test.ts @@ -0,0 +1,154 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import type { Database } from "bun:sqlite"; +import { LocalConfigStore } from "../data/config-store"; +import { createConfig, getConfig } from "../db/configs"; +import { getDatabase, resetDatabase } from "../db/database"; +import { getProfileConfigs } from "../db/profiles"; +import { ensurePlatformProfiles } from "./platform-profiles"; +import { planSessionRender, sourceFromConfig } from "./session-render"; +import { + DANGEROUS_OPERATION_GUARD_STANDARD_CONTENT, + DANGEROUS_OPERATION_GUARD_STANDARD_SLUG, + ensureDangerousOperationGuardStandardConfig, +} from "./dangerous-operation-guard-standard"; + +let db: Database; + +beforeEach(() => { + resetDatabase(); + process.env["HASNA_INSTRUCTIONS_DB_PATH"] = ":memory:"; + db = getDatabase(); +}); + +describe("dangerous operation guard standard", () => { + test("seeds managed dangerous-operation guard rules with runtime-specific clauses", async () => { + const config = await ensureDangerousOperationGuardStandardConfig(new LocalConfigStore(db)); + + expect(config.slug).toBe(DANGEROUS_OPERATION_GUARD_STANDARD_SLUG); + expect(config.kind).toBe("reference"); + expect(config.category).toBe("rules"); + expect(config.agent).toBe("global"); + expect(config.tags).toEqual(expect.arrayContaining(["dangerous-operation-guard", "hooks", "station01"])); + + const content = config.content; + expect(content).toContain("sustained Hasna\ncoding agents on station01"); + expect(content).toContain("Apply this guard to sustained Codewith, Codex, Claude Code, Qwen Code"); + expect(content).toContain("OpenCode, Cursor, and Google Antigravity"); + expect(content).toContain("Do not target Gemini CLI"); + expect(content).toContain("Keep guard policy in managed instructions/config"); + expect(content).toContain("allow_managed_hooks_only = true"); + expect(content).toContain("`PreToolUse` is a hard-deny and context-injection surface"); + expect(content).toContain("Approval decisions belong in `PermissionRequest` hooks"); + expect(content).toContain("do not return `permissionDecision: \"ask\"` from\n `PreToolUse`"); + expect(content).toContain("Session rendering writes Qwen Code `QWEN.md`"); + expect(content).toContain("policy context only"); + expect(content).toContain("Qwen Code native `settings.json` hooks"); + expect(content).toContain("`~/.qwen/settings.json`"); + expect(content).toContain("`.qwen/settings.json`"); + expect(content).toContain("Cursor rule files are advisory prompt context"); + expect(content).toContain("managed wrapper/plugin fallback for enforcement"); + expect(content).toContain("must never reintroduce an active\n `gemini` target"); + }); + + test("updates stale seeded guard instead of creating a duplicate", async () => { + createConfig({ + name: "Dangerous Operation Guard Standard", + category: "rules", + agent: "global", + format: "markdown", + kind: "reference", + content: "old content", + }, db); + + const config = await ensureDangerousOperationGuardStandardConfig(new LocalConfigStore(db)); + const stored = getConfig(DANGEROUS_OPERATION_GUARD_STANDARD_SLUG, db); + + expect(config.id).toBe(stored.id); + expect(stored.content).toBe(DANGEROUS_OPERATION_GUARD_STANDARD_CONTENT); + expect(stored.version).toBe(2); + }); + + test("renders through managed guard outputs for all claimed render targets", async () => { + const config = await ensureDangerousOperationGuardStandardConfig(new LocalConfigStore(db)); + const source = sourceFromConfig(config); + + const codewith = planSessionRender({ + tool: "codewith", + profile: "account999", + targetHome: "/tmp/codewith-account999", + sources: [source], + }); + expect(codewith.files[0]?.relativePath).toBe("CODEWITH.md"); + expect(codewith.files[0]?.content).toContain("Dangerous Operation Guard Standard"); + expect(codewith.files[0]?.content).toContain("PermissionRequest"); + + const codex = planSessionRender({ + tool: "codex", + profile: "account999", + targetHome: "/tmp/codex-account999", + sources: [source], + }); + expect(codex.files[0]?.relativePath).toBe("AGENTS.md"); + expect(codex.files[0]?.content).toContain("PreToolUse"); + + const claude = planSessionRender({ + tool: "claude", + profile: "account999", + targetHome: "/tmp/claude-account999", + sources: [source], + }); + expect(claude.files[0]?.relativePath).toBe("CLAUDE.md"); + expect(claude.files[0]?.content).toContain("@./.hasna/instructions/01-dangerous-operation-guard-standard.md"); + expect(claude.files[1]?.content).toContain("Claude Code native hooks"); + + const opencode = planSessionRender({ + tool: "opencode", + profile: "account999", + targetHome: "/tmp/opencode-account999", + sources: [source], + }); + expect(opencode.files[0]?.relativePath).toBe("AGENTS.md"); + expect(opencode.files[1]?.relativePath).toBe("opencode.json"); + expect(opencode.files[0]?.content).toContain("OpenCode"); + expect(JSON.parse(opencode.files[1]!.content)).toMatchObject({ + instructions: [".hasna/instructions/01-dangerous-operation-guard-standard.md"], + }); + + const cursor = planSessionRender({ + tool: "cursor", + profile: "account999", + projectRoot: "/tmp/repo", + sources: [source], + }); + expect(cursor.files[0]?.relativePath).toBe(".cursor/rules/01-dangerous-operation-guard-standard.mdc"); + expect(cursor.files[0]?.content).toContain("Cursor rule files are advisory prompt context"); + + const qwen = planSessionRender({ + tool: "qwen", + profile: "account999", + targetHome: "/tmp/qwen-account999", + sources: [source], + }); + expect(qwen.files[0]?.relativePath).toBe("QWEN.md"); + expect(qwen.files[0]?.content).toContain("Qwen Code"); + expect(qwen.files[0]?.content).toContain("policy context only"); + + const antigravity = planSessionRender({ + tool: "antigravity", + profile: "account999", + projectRoot: "/tmp/repo", + sources: [source], + }); + expect(antigravity.files[0]?.relativePath).toBe(".agents/rules/01-dangerous-operation-guard-standard.md"); + expect(antigravity.files[0]?.content).toContain("wrapper/plugin fallback"); + }); + + test("platform profiles link the guard standard when present", async () => { + const standard = await ensureDangerousOperationGuardStandardConfig(new LocalConfigStore(db)); + const profiles = await ensurePlatformProfiles(new LocalConfigStore(db)); + + for (const profile of profiles) { + expect(getProfileConfigs(profile.id, db).map((config) => config.id)).toContain(standard.id); + } + }); +}); diff --git a/src/lib/dangerous-operation-guard-standard.ts b/src/lib/dangerous-operation-guard-standard.ts new file mode 100644 index 0000000..78d2f10 --- /dev/null +++ b/src/lib/dangerous-operation-guard-standard.ts @@ -0,0 +1,132 @@ +import type { Config } from "../types/index.js"; +import { resolveConfigStore, type ConfigStore } from "../data/config-store.js"; + +export const DANGEROUS_OPERATION_GUARD_STANDARD_SLUG = "dangerous-operation-guard-standard"; + +export const DANGEROUS_OPERATION_GUARD_STANDARD_CONTENT = `# Dangerous Operation Guard Standard + +This standard is the managed instruction/config source for sustained Hasna +coding agents on station01 and compatible fleet machines. Its purpose is to +route risky shell commands, file edits, git operations, package installs, and +secret-adjacent access through native guard surfaces where the runtime supports +them, or through a documented wrapper/plugin fallback where it does not. + +## Scope + +- Apply this guard to sustained Codewith, Codex, Claude Code, Qwen Code, + OpenCode, Cursor, and Google Antigravity agents on station01. +- Do not target Gemini CLI. Gemini is retired for Hasna coding-agent rollout + purposes; Antigravity is the active Google agent target. +- Keep guard policy in managed instructions/config. Do not repair rollout gaps + by hand-editing generated agent files outside the managed renderer. + +## Codewith and Codex + +- Use managed Codewith/Codex config and requirements where supported. +- A managed requirements policy should set \`allow_managed_hooks_only = true\` + so unmanaged local/project hooks cannot bypass the managed guard set. +- \`PreToolUse\` is a hard-deny and context-injection surface. It may block a + dangerous operation or allow a safe rewrite supported by the runtime, but it + must not try to ask the user for approval. +- Approval decisions belong in \`PermissionRequest\` hooks. If a dangerous + operation needs human approval, let the runtime raise a permission request + and decide there; do not return \`permissionDecision: "ask"\` from + \`PreToolUse\`. +- The current managed hook set should cover shell commands, file writes, + patch/edit tools, MCP file-modifying tools, branch/worktree safety, + secret-adjacent paths, package installs, staged secret scans before commit or + push, and fleet freeze/blocker gates. + +## Claude Code + +- Use Claude Code native hooks in managed \`settings.json\` where available. +- Claude \`PreToolUse\` can use its native approval/defer semantics, but the + shared classifier must keep runtime-specific adapters so Claude behavior is + not collapsed into Codewith/Codex semantics. +- When native hooks are unavailable, use the managed wrapper that launches + Claude with the guarded hook profile rather than copying one-off rules into a + local prompt file. + +## Qwen Code + +- Session rendering writes Qwen Code \`QWEN.md\` instructional context. This is + policy context only; hard enforcement still requires native hooks or a + managed wrapper/plugin. +- Use Qwen Code native \`settings.json\` hooks for hard enforcement. User-level + settings live at \`~/.qwen/settings.json\`; project-level settings live at + \`.qwen/settings.json\`. +- Qwen hooks are configured under the \`hooks\` object with event arrays such + as \`PreToolUse\`; each hook entry should be a managed command or HTTP hook + with an explicit name, description, matcher, timeout, and shell where needed. +- If the installed Qwen hook payload is not compatible with the shared + classifier yet, use a managed extension or wrapper fallback that preserves + the same deny-before-execute behavior. + +## OpenCode + +- Use native \`opencode.json\` instructions and the OpenCode plugin surface + where a plugin is available. +- If no native plugin is installed, launch through the managed wrapper that + runs the shared guard before auto-approved operations. + +## Cursor + +- Use project-owned \`.cursor/rules/*.mdc\` for policy rendering. +- Because Cursor rule files are advisory prompt context rather than a verified + pre-tool hard gate, require a managed wrapper/plugin fallback for enforcement + before claiming the dangerous-operation guard is active. + +## Antigravity + +- Use project-owned \`.agents/rules/*.md\` and Antigravity-owned MCP/config + paths where available. +- Antigravity may still use legacy-named \`~/.gemini/...\` global paths for + Antigravity-owned files, but this must never reintroduce an active + \`gemini\` target. +- If no native pre-tool hook is verified, use a managed wrapper/plugin + fallback before claiming hard enforcement. + +## Verification + +Before rollout is marked complete: + +1. Render managed instructions for every render-supported station01 agent and + sync managed settings/config for hook-only surfaces. +2. Verify Gemini is absent from active target lists and generated outputs. +3. Verify Codewith/Codex \`PreToolUse\` rejects ask-style approvals and + \`PermissionRequest\` is the approval path. +4. Verify each non-Codewith runtime either has a native hook/config proof or is + explicitly listed as wrapper/plugin fallback. +5. Run the staged secrets scan before every commit or push. +`; + +export async function ensureDangerousOperationGuardStandardConfig(store: ConfigStore = resolveConfigStore()): Promise { + const input = { + name: "Dangerous Operation Guard Standard", + category: "rules" as const, + agent: "global" as const, + format: "markdown" as const, + content: DANGEROUS_OPERATION_GUARD_STANDARD_CONTENT, + kind: "reference" as const, + description: "Managed dangerous-operation guard policy for sustained Hasna coding agents", + tags: ["dangerous-operation-guard", "hooks", "coding-agent-rules", "station01"], + }; + + try { + const existing = await store.getConfig(DANGEROUS_OPERATION_GUARD_STANDARD_SLUG); + if ( + existing.content !== input.content + || existing.description !== input.description + || existing.category !== input.category + || existing.agent !== input.agent + || existing.format !== input.format + || existing.kind !== input.kind + || JSON.stringify(existing.tags) !== JSON.stringify(input.tags) + ) { + return await store.updateConfig(existing.id, input); + } + return existing; + } catch { + return await store.createConfig(input); + } +} diff --git a/src/lib/session-apply.test.ts b/src/lib/session-apply.test.ts index feb5da5..849294c 100644 --- a/src/lib/session-apply.test.ts +++ b/src/lib/session-apply.test.ts @@ -55,7 +55,7 @@ describe("session apply writer", () => { expect(existsSync(join(targetHome, "AGENTS.md"))).toBe(false); }); - test("writes Claude, Codex, Cursor, OpenCode, and Codewith adapter files", () => { + test("writes Claude, Codex, Cursor, OpenCode, Qwen, and Codewith adapter files", () => { const adapters: Array<{ tool: SessionRenderTool; targetHome: string; expected: string[]; projectRoot?: string }> = [ { tool: "claude", @@ -78,6 +78,11 @@ describe("session apply writer", () => { targetHome: targetFor("opencode"), expected: ["AGENTS.md", "opencode.json", ".hasna/instructions/01-global-codewith.md", ".hasna/instructions/02-agent-marcus.md"], }, + { + tool: "qwen", + targetHome: targetFor("qwen"), + expected: ["QWEN.md"], + }, { tool: "codewith", targetHome: targetFor("codewith"), diff --git a/src/lib/session-render.test.ts b/src/lib/session-render.test.ts index 7375ccd..141576f 100644 --- a/src/lib/session-render.test.ts +++ b/src/lib/session-render.test.ts @@ -288,6 +288,22 @@ describe("session render planner", () => { expect(plan.files.filter((file) => file.role === "fragment")).toHaveLength(2); }); + test("plans Qwen as a QWEN.md instructional context file", () => { + const plan = planSessionRender({ + tool: "qwen", + profile: "account999", + targetHome: "/tmp/qwen-account999", + sources: [globalIdentity, agentIdentity], + }); + + expect(plan.adapter.mode).toBe("flattened-markdown"); + expect(plan.env).toEqual({ QWEN_HOME: "/tmp/qwen-account999" }); + expect(plan.files).toHaveLength(1); + expect(plan.files[0]?.relativePath).toBe("QWEN.md"); + expect(plan.files[0]?.content).toContain("Global Codewith Identity"); + expect(plan.files[0]?.content).toContain("Marcus Agent Identity"); + }); + test("plans Codewith as flattened CODEWITH.md until native imports are gated on", () => { const plan = planSessionRender({ tool: "codewith", diff --git a/src/lib/session-render.ts b/src/lib/session-render.ts index 2eaec03..b85928b 100644 --- a/src/lib/session-render.ts +++ b/src/lib/session-render.ts @@ -16,6 +16,7 @@ export const SESSION_RENDER_TOOLS = [ "cursor", "opencode", "codewith", + "qwen", "aicopilot", "antigravity", ] as const; @@ -281,6 +282,15 @@ export const SESSION_TOOL_ADAPTERS: Record { }); describe("KNOWN_CONFIGS", () => { - test("has required configs (claude, codex, opencode, cursor, codewith, aicopilot, antigravity, shell, git, tools)", () => { + test("has required configs (claude, codex, opencode, cursor, codewith, aicopilot, antigravity, qwen, shell, git, tools)", () => { const agents = new Set(KNOWN_CONFIGS.map((k) => k.agent)); expect(agents.has("claude")).toBe(true); expect(agents.has("codex")).toBe(true); @@ -34,6 +34,7 @@ describe("KNOWN_CONFIGS", () => { expect(agents.has("codewith")).toBe(true); expect(agents.has("aicopilot")).toBe(true); expect(agents.has("antigravity")).toBe(true); + expect(agents.has("qwen")).toBe(true); expect(agents.has("zsh")).toBe(true); expect(agents.has("git")).toBe(true); expect(agents.has("npm")).toBe(true); @@ -48,6 +49,7 @@ describe("KNOWN_CONFIGS", () => { "global", "npm", "opencode", + "qwen", "zsh", ]); }); @@ -64,8 +66,10 @@ describe("KNOWN_CONFIGS", () => { "global", "npm", "opencode", + "qwen", "zsh", ]); + expect(CONFIG_AGENTS).not.toContain("gemini" as never); }); test("registers new coding agent rule and MCP targets", () => { @@ -80,6 +84,8 @@ describe("KNOWN_CONFIGS", () => { expect(paths.has("~/.codewith/config.toml")).toBe(true); expect(paths.has("~/.cursor/rules")).toBe(true); expect(paths.has("~/.cursor/mcp.json")).toBe(true); + expect(paths.has("~/.qwen/QWEN.md")).toBe(true); + expect(paths.has("~/.qwen/settings.json")).toBe(true); }); test("has optional flag on non-essential configs", () => { @@ -156,6 +162,7 @@ describe("syncKnown", () => { { agent: "aicopilot", target_path: "~/.config/aicopilot/AICOPILOT.md", transform: "codex-flat" }, { agent: "antigravity", target_path: "~/.gemini/GEMINI.md", transform: "codex-flat" }, { agent: "cursor", target_path: "~/.cursor/rules/claude.mdc", transform: "cursor-mdc" }, + { agent: "qwen", target_path: "~/.qwen/QWEN.md", transform: "codex-flat" }, ]); } finally { if (originalHome === undefined) delete process.env["CONFIGS_HOME"]; @@ -183,7 +190,7 @@ describe("syncKnown", () => { const config = getConfig("claude-claude-md", db); expect(result.updated).toBe(1); - expect(config.outputs.map((output) => output.agent)).toEqual(["codex", "codewith", "opencode", "aicopilot", "antigravity", "cursor"]); + expect(config.outputs.map((output) => output.agent)).toEqual(["codex", "codewith", "opencode", "aicopilot", "antigravity", "cursor", "qwen"]); }); }); @@ -275,6 +282,9 @@ describe("PROJECT_CONFIG_FILES", () => { expect(files).toContain(".codewith/CODEWITH.md"); expect(files).toContain("AICOPILOT.md"); expect(files).toContain(".agents/mcp_config.json"); + expect(files).toContain("QWEN.md"); + expect(files).toContain(".qwen/settings.json"); + expect(files).not.toContain("GEMINI.md"); }); }); diff --git a/src/lib/sync.ts b/src/lib/sync.ts index 1b8f74b..21130d3 100644 --- a/src/lib/sync.ts +++ b/src/lib/sync.ts @@ -34,6 +34,7 @@ export const CLAUDE_PROMPT_OUTPUTS: ConfigOutput[] = [ { agent: "aicopilot", target_path: "~/.config/aicopilot/AICOPILOT.md", transform: "codex-flat" }, { agent: "antigravity", target_path: "~/.gemini/GEMINI.md", transform: "codex-flat" }, { agent: "cursor", target_path: "~/.cursor/rules/claude.mdc", transform: "cursor-mdc" }, + { agent: "qwen", target_path: "~/.qwen/QWEN.md", transform: "codex-flat" }, ]; function claudeRuleOutputs(fileName: string): ConfigOutput[] { @@ -126,6 +127,10 @@ export const KNOWN_CONFIGS: KnownConfig[] = [ { path: "~/.gemini/GEMINI.md", name: "antigravity-global-rules", category: "rules", agent: "antigravity", format: "markdown", optional: true, description: "Google Antigravity global rules file" }, { path: "~/.gemini/config/mcp_config.json", name: "antigravity-global-mcp", category: "mcp", agent: "antigravity", format: "json", optional: true, description: "Google Antigravity global MCP server entries" }, + // ── Qwen Code ────────────────────────────────────────────────────────────── + { path: "~/.qwen/QWEN.md", name: "qwen-qwen-md", category: "rules", agent: "qwen", format: "markdown", optional: true, description: "Qwen Code global instructional context" }, + { path: "~/.qwen/settings.json", name: "qwen-settings", category: "agent", agent: "qwen", format: "json", optional: true, description: "Qwen Code settings.json, including native hooks" }, + // ── MCP ──────────────────────────────────────────────────────────────────── { path: "~/.claude.json", name: "claude-json", category: "mcp", agent: "claude", format: "json", description: "Claude Code global config (includes MCP server entries)" }, @@ -158,7 +163,9 @@ export const PROJECT_CONFIG_FILES = [ { file: ".aicopilot/AICOPILOT.md", category: "rules" as ConfigCategory, agent: "aicopilot" as ConfigAgent, format: "markdown" as ConfigFormat }, { file: "AICOPILOT.md", category: "rules" as ConfigCategory, agent: "aicopilot" as ConfigAgent, format: "markdown" as ConfigFormat }, { file: ".cursor/mcp.json", category: "mcp" as ConfigCategory, agent: "cursor" as ConfigAgent, format: "json" as ConfigFormat }, + { file: "QWEN.md", category: "rules" as ConfigCategory, agent: "qwen" as ConfigAgent, format: "markdown" as ConfigFormat }, { file: ".agents/mcp_config.json", category: "mcp" as ConfigCategory, agent: "antigravity" as ConfigAgent, format: "json" as ConfigFormat }, + { file: ".qwen/settings.json", category: "agent" as ConfigCategory, agent: "qwen" as ConfigAgent, format: "json" as ConfigFormat }, ]; export interface SyncProjectOptions { @@ -441,7 +448,7 @@ export function detectCategory(filePath: string): ConfigCategory { const p = filePath.toLowerCase().replace(getConfigHome(), "~"); if (p.includes("/.claude/rules/") || p.includes("/.cursor/rules/") || p.includes("/.agents/rules/") || p.endsWith("claude.md") || p.endsWith("agents.md") || p.endsWith("codewith.md") || p.endsWith("aicopilot.md") || p.endsWith("/.gemini/gemini.md") || p.endsWith(".mdc")) return "rules"; if (p.includes(".mcp.json") || p.includes("mcp")) return "mcp"; - if (p.includes("/.claude/") || p.includes("/.codex/") || p.includes("/.antigravity/") || p.includes("/.agents/") || p.includes("/.cursor/") || p.includes("/.config/opencode/") || p.includes("/.codewith/") || p.includes("/.config/aicopilot/")) return "agent"; + if (p.includes("/.claude/") || p.includes("/.codex/") || p.includes("/.antigravity/") || p.includes("/.agents/") || p.includes("/.cursor/") || p.includes("/.config/opencode/") || p.includes("/.codewith/") || p.includes("/.config/aicopilot/") || p.includes("/.qwen/")) return "agent"; if (p.includes(".zshrc") || p.includes(".zprofile") || p.includes(".bashrc") || p.includes(".bash_profile")) return "shell"; if (p.includes(".gitconfig") || p.includes(".gitignore")) return "git"; if (p.includes(".npmrc") || p.includes("tsconfig") || p.includes("bunfig")) return "tools"; @@ -458,6 +465,7 @@ export function detectAgent(filePath: string): ConfigAgent { if (p.includes("/.cursor/") || p.endsWith(".mdc")) return "cursor"; if (p.includes("/.codewith/") || p.endsWith("codewith.md")) return "codewith"; if (p.includes("/.config/aicopilot/")) return "aicopilot"; + if (p.includes("/.qwen/")) return "qwen"; if (p.includes("/.antigravity/")) return "antigravity"; if (p.includes("/.codex/") || p.endsWith("agents.md")) return "codex"; if (p.includes(".zshrc") || p.includes(".zprofile") || p.includes(".bashrc")) return "zsh"; diff --git a/src/types/index.ts b/src/types/index.ts index a266ed2..d5bfc86 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -24,6 +24,7 @@ export const CONFIG_AGENTS = [ "codewith", "aicopilot", "antigravity", + "qwen", "zsh", "git", "npm",