projectstore-claude 0.28.2 → 0.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +3 -1
  2. package/bin/projectstore-claude.mjs +14 -9
  3. package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
  4. package/node_modules/projectstore/.claude-plugin/plugin.json +1 -1
  5. package/node_modules/projectstore/README.md +2 -2
  6. package/node_modules/projectstore/commands/review.md +1 -1
  7. package/node_modules/projectstore/harnesses/claude-code.json +20 -2
  8. package/node_modules/projectstore/harnesses/codex.json +24 -4
  9. package/node_modules/projectstore/hooks/pre-compact.mjs +2 -1
  10. package/node_modules/projectstore/hooks/session-rules.mjs +19 -7
  11. package/node_modules/projectstore/hooks/session-start.mjs +20 -18
  12. package/node_modules/projectstore/package.json +2 -2
  13. package/node_modules/projectstore/scripts/binding.mjs +3 -3
  14. package/node_modules/projectstore/scripts/build-adapters.mjs +51 -18
  15. package/node_modules/projectstore/scripts/cli.mjs +176 -29
  16. package/node_modules/projectstore/scripts/codemap.mjs +2 -2
  17. package/node_modules/projectstore/scripts/doctor.mjs +110 -58
  18. package/node_modules/projectstore/scripts/draft.mjs +4 -3
  19. package/node_modules/projectstore/scripts/graph.mjs +3 -2
  20. package/node_modules/projectstore/scripts/harness.mjs +89 -0
  21. package/node_modules/projectstore/scripts/install-harness.mjs +336 -93
  22. package/node_modules/projectstore/scripts/kanban.mjs +3 -2
  23. package/node_modules/projectstore/scripts/lib.mjs +69 -8
  24. package/node_modules/projectstore/scripts/mcp.mjs +2 -2
  25. package/node_modules/projectstore/scripts/query.mjs +2 -1
  26. package/node_modules/projectstore/scripts/reconcile.mjs +4 -3
  27. package/node_modules/projectstore/scripts/story-section.mjs +2 -1
  28. package/node_modules/projectstore/scripts/surfaces.mjs +2 -1
  29. package/node_modules/projectstore/scripts/term.mjs +149 -0
  30. package/node_modules/projectstore/scripts/touch-session.mjs +6 -1
  31. package/node_modules/projectstore/scripts/worktree.mjs +12 -7
  32. package/package.json +2 -2
package/README.md CHANGED
@@ -6,7 +6,9 @@ The Claude Code installer for [projectstore](https://www.npmjs.com/package/proje
6
6
  npx projectstore-claude install --project "$PWD"
7
7
  ```
8
8
 
9
- It registers the plugin for that checkout at the host's local scope, previews every write and every host command before it runs, and asks for nothing else — naming the shell is the confirmation. Restart Claude Code afterwards.
9
+ It registers the plugin for that checkout at the host's local scope. It prints its plan first — every write and every host command, verbatim — and at a terminal asks `Apply N changes? [Y/n]` before anything runs; then each step as it runs, and what to do next. Without a terminal (a script, CI, an agent's tool) naming the shell is the confirmation; `--json` never asks. Restart Claude Code afterwards.
10
+
11
+ - See before you write: `npx projectstore-claude plan --project "$PWD"` prints the same plan and writes nothing; `--verbose` adds every row's reasoning; `npx projectstore-claude <verb> --help` lists a verb's options with examples.
10
12
 
11
13
  - Upgrade, or pin: `npx projectstore-claude@<version> upgrade --project "$PWD"` — the version you name is the version you run.
12
14
  - Uninstall: `npx projectstore-claude uninstall --project "$PWD"` — forgets the registration for that checkout and nothing else; your vault is plain markdown and stays yours.
@@ -8,8 +8,10 @@
8
8
  // `--harness claude-code` inserted after a verb that takes it. Every other
9
9
  // argument passes through, so `projectstore-claude <verb> …` is exactly
10
10
  // `projectstore <verb> --harness claude-code …` — the same preview, the
11
- // same files, the same exit code. Naming the shell is the confirmation the
12
- // core's install gate asks for, exactly as naming --harness is.
11
+ // same files, the same exit code. Naming the shell names the harness, exactly
12
+ // as --harness does: without a terminal that is the confirmation the core's
13
+ // install gate asks for; at a terminal the core shows the plan and asks (the
14
+ // install spec, contract 9 as amended 2026-10-04).
13
15
  import { existsSync } from "node:fs";
14
16
  import { spawnSync } from "node:child_process";
15
17
  import { constants as osConstants } from "node:os";
@@ -72,17 +74,20 @@ if (!core) {
72
74
  process.stderr.write(`${SHELL}: ${fixed.error}\n`);
73
75
  process.exitCode = 2;
74
76
  } else {
75
- // stdio inherited: the core's install gate asks on a terminal and refuses
76
- // without one, so the child must see the real stdin and stdout. No
77
- // timeout — the child waits on a human at the preview. exitCode, not
78
- // exit(): the core's own bin says why (a pending write on a pipe).
77
+ // stdio inherited: the core's install gate shows the plan and asks on a
78
+ // terminal, and refuses a bare run without one, so the child must see the
79
+ // real stdin and stdout. No timeout — the child waits on a human at the
80
+ // question. exitCode, not exit(): the core's own bin says why (a pending
81
+ // write on a pipe). PROJECTSTORE_SHELL lets the core's --help speak this
82
+ // shell's name; nothing the core plans or writes reads it.
79
83
  const r = spawnSync(process.execPath, [core, ...fixed.argv], {
80
84
  stdio: "inherit",
81
- env: { ...process.env, PROJECTSTORE_DISTRIBUTION_ROOT: root },
85
+ env: { ...process.env, PROJECTSTORE_DISTRIBUTION_ROOT: root, PROJECTSTORE_SHELL: SHELL },
82
86
  });
83
87
  if (r.error) process.stderr.write(`${SHELL}: ${r.error.message}\n`);
84
- // A signal is relayed the shell way (128 + its number): Ctrl-C at the
85
- // preview is 130 here as it would be on the core itself.
88
+ // A signal is relayed the shell way (128 + its number): the core reads
89
+ // its answer in the terminal's line mode, so Ctrl-C at the question is a
90
+ // real SIGINT — 130 here as it would be on the core itself.
86
91
  process.exitCode = r.status ?? (r.signal ? 128 + (osConstants.signals[r.signal] || 0) : 2);
87
92
  }
88
93
  }
@@ -12,7 +12,7 @@
12
12
  "name": "projectstore",
13
13
  "displayName": "projectstore",
14
14
  "description": "📚 Your agent runs the project through a verified loop: task → artifact (ADR · spec · epic · story) → adversarial critic → backlog → planner → reviewer → done. Plain markdown in an Obsidian-friendly vault, every write approved by you — and any model can pick the project up tomorrow.",
15
- "version": "0.28.2",
15
+ "version": "0.29.1",
16
16
  "author": {
17
17
  "name": "Evgenii Konev",
18
18
  "email": "ekonev@smartandpoint.com",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "projectstore",
3
3
  "displayName": "projectstore",
4
- "version": "0.28.2",
4
+ "version": "0.29.1",
5
5
  "description": "Your agent runs the project through a verified loop: task → artifact (ADR / spec / epic / story) → adversarial critic → backlog → planner → reviewer → done. Plain markdown in git — any model can pick the project up tomorrow.",
6
6
  "author": {
7
7
  "name": "Evgenii Konev @ SmartAndPoint",
@@ -94,7 +94,7 @@ Contributors: `git clone` this repo, then `claude --plugin-dir ./ProjectStore`.
94
94
  npx projectstore-claude install --project "$PWD"
95
95
  ```
96
96
 
97
- The same tree is published to npm as [`projectstore`](https://www.npmjs.com/package/projectstore) — one source package carrying every harness's manifest — and `projectstore-claude` is its Claude Code shell: the core pinned at the same version and bundled inside, the harness fixed, so the one command has the same shape on every harness. It registers the plugin with Claude Code: it writes a small local marketplace of its own under your Claude home, then drives `claude plugin marketplace add` / `plugin install` **at local scope**, so the registration lands in this checkout's `.claude/settings.local.json` and nowhere else. Every host command is printed before it runs; naming the harness is the confirmation. Restart Claude Code afterwards. A git-marketplace copy already enabled for the checkout is silenced there (not globally) so the plugin does not load twice; `uninstall` turns it back on. Pin or upgrade with `npx projectstore-claude@<version> upgrade --project "$PWD"` — the version you name is the version you run. The core's low-level form, `npx projectstore <verb> --harness claude-code …`, is exactly what the shell runs. bun works the same on the packed bin.
97
+ The same tree is published to npm as [`projectstore`](https://www.npmjs.com/package/projectstore) — one source package carrying every harness's manifest — and `projectstore-claude` is its Claude Code shell: the core pinned at the same version and bundled inside, the harness fixed, so the one command has the same shape on every harness. It registers the plugin with Claude Code: it writes a small local marketplace of its own under your Claude home, then drives `claude plugin marketplace add` / `plugin install` **at local scope**, so the registration lands in this checkout's `.claude/settings.local.json` and nowhere else. It prints its plan first — every file it writes and every host command, verbatim — and at a terminal asks `Apply N changes? [Y/n]`; then it shows each step as it runs and what to do next. Without a terminal (a script, CI, an agent's tool) naming the harness is the confirmation. `plan` prints the same plan and writes nothing; `--verbose` adds every row's reasoning; `npx projectstore-claude <verb> --help` lists a verb's options with examples. Restart Claude Code afterwards. A git-marketplace copy already enabled for the checkout is silenced there (not globally) so the plugin does not load twice; `uninstall` turns it back on. Pin or upgrade with `npx projectstore-claude@<version> upgrade --project "$PWD"` — the version you name is the version you run. The core's low-level form, `npx projectstore <verb> --harness claude-code …`, is exactly what the shell runs. bun works the same on the packed bin.
98
98
 
99
99
  **Codex has its own shell with the same one-command shape:**
100
100
  `npx projectstore-codex install --project "$PWD"`. It carries a Codex
@@ -120,7 +120,7 @@ The package also carries a `bin`. Without a session — in CI, or in a shell —
120
120
 
121
121
  ```
122
122
  npx projectstore doctor --json
123
- npx projectstore install --harness claude-code # the low-level form the shell runs: previews, then writes the agents block and the status line; naming the harness is the confirmation, there is no --yes
123
+ npx projectstore install --harness claude-code # the low-level form the shell runs: shows the plan, asks at a terminal, then writes; without a terminal naming the harness is the confirmation — there is no --yes
124
124
  npx projectstore reconcile --write --only kanban
125
125
  ```
126
126
 
@@ -23,7 +23,7 @@ You are running a peer review on a projectstore artifact.
23
23
 
24
24
  5. **Gather domain context**: read the vault's top-level `README.md` and the folder README of the artifact's parent (e.g. `adr/README.md`). Keep both short — they're context for the critic, not the focus.
25
25
 
26
- 6. **Spawn the critic agent**. Prefer this plugin's own `projectstore:critic` (purpose-built fresh-context critic, no sycophancy; named `projectstore:projectstore-critic` before v0.13). If unavailable, fall back to `oh-my-claudecode:critic`, then `general-purpose`. Use this exact prompt template:
26
+ 6. **Spawn the critic agent**. Prefer this plugin's own `projectstore:critic` (purpose-built fresh-context critic, no sycophancy). If unavailable, fall back to `oh-my-claudecode:critic`, then `general-purpose`. Use this exact prompt template:
27
27
 
28
28
  ```
29
29
  You are a critic-mode reviewer. You have ONLY the artifact and the
@@ -1,5 +1,5 @@
1
1
  {
2
- "_comment": "Harness capability manifest — Claude Code. This is the SOURCE layout (generation spec, contract 2): the plugin's own commands/, agents/, skills/ and hooks/hooks.json are already in Claude Code's shapes, so nothing is emitted for it (emit: false). The manifest still exists in full because the portability lint reads every harness's vocabulary, and a token only Claude Code understands is only detectable as unmapped in another harness's tree if Claude Code's own names are written down somewhere. It carries no `lint` block and an empty `rewrites` because linting runs over emitted trees only. Shape contributed by Maxim Podreshetnikov (PR #13); `verified`, `output_channels`, `scope_reason`, `root_placeholder_literal` and `agent_overrides` are the spec's additions.",
2
+ "_comment": "Harness capability manifest — Claude Code. This is the SOURCE layout (generation spec, contract 2): the plugin's own commands/, agents/, skills/ and hooks/hooks.json are already in Claude Code's shapes, so nothing is emitted for it (emit: false). The manifest still exists in full because the portability lint reads every harness's vocabulary, and a token only Claude Code understands is only detectable as unmapped in another harness's tree if Claude Code's own names are written down somewhere. It carries no `lint` block and an empty `rewrites` because linting runs over emitted trees only; its `ui_vocabulary` lists the UI affordances that mean nothing elsewhere, read by the generator's leak check and by the runtime vocabulary lint over what the core prints for another harness (generation spec, contracts 2 and 18). Shape contributed by Maxim Podreshetnikov (PR #13); `verified`, `output_channels`, `scope_reason`, `root_placeholder_literal` and `agent_overrides` are the spec's additions.",
3
3
  "id": "claude-code",
4
4
  "display_name": "Claude Code",
5
5
  "emit": false,
@@ -20,6 +20,9 @@
20
20
  "/plugin marketplace add SmartAndPoint/ProjectStore && /plugin install projectstore@SmartAndPoint (git marketplace, user-wide)",
21
21
  "restart Claude Code"
22
22
  ],
23
+ "next": [
24
+ "restart Claude Code in this project"
25
+ ],
23
26
  "notes": [
24
27
  "Git marketplace only: auto-update is OFF by default for third-party plugins: /plugin -> Marketplaces -> SmartAndPoint -> toggle auto-update. The npm registration updates when you run `npx projectstore-claude upgrade --project <dir>`.",
25
28
  "Pin a git-marketplace release with: /plugin marketplace add SmartAndPoint/ProjectStore#<tag>; pin the npm registration with npx projectstore-claude@<version>.",
@@ -461,9 +464,24 @@
461
464
  "interactive_approval_tool": true,
462
465
  "permission_decision_hook": "PreToolUse",
463
466
  "subagent_tool_allowlist": true,
464
- "subagent_model_per_invocation": true
467
+ "subagent_model_per_invocation": true,
468
+ "session_rename": "/rename <name>",
469
+ "session_rename_reason": "Claude Code's own command for naming a session; the session-name offer prints it filled (generation spec, contract 18). A harness without one gets no offer.",
470
+ "bind_inherit": true,
471
+ "bind_inherit_reason": "commands/bind.md adopts the binding of the checkout a worktree was forked from (`bind --inherit`), so an unbound worktree is told to run that. A harness whose bind cannot inherit is told the explicit vault instead (generation spec, contract 18)."
465
472
  },
466
473
  "rewrites": [],
474
+ "ui_vocabulary": [
475
+ "/plugin",
476
+ "/reload-plugins",
477
+ "AskUserQuestion"
478
+ ],
479
+ "ui_vocabulary_reason": "This harness's UI affordances and tool names: words that mean nothing on another harness. The generator refuses them in another harness's rendered tree, and the runtime vocabulary lint refuses them in what the core prints for another harness (generation spec, contracts 6 and 18). A word here must be followed by a non-word character to count, so `/plugins/` in a path is not `/plugin`.",
480
+ "update_hint": {
481
+ "line": "See /plugin → Marketplaces to enable auto-update.",
482
+ "welcome": ["**About future updates**: Claude Code does NOT auto-update third-party marketplaces by default. To get notified of new releases (v0.7+):", "1. Open `/plugin` → **Marketplaces** tab.", "2. Find **SmartAndPoint**.", "3. Toggle **auto-update** on.", "", "Without it, you'd run `/plugin marketplace update SmartAndPoint` manually. See https://github.com/SmartAndPoint/ProjectStore#updates for details."],
483
+ "reason": "What the first-run welcome and its one-line notice say about updates (generation spec, contract 18): `line` ends the notice, `welcome` is the welcome's update block. update_instructions is the long-form guidance; this is the part a session prints, kept here so no hook names a harness's update path."
484
+ },
467
485
  "update_instructions": [
468
486
  "**About future updates**: installed from the git marketplace, Claude Code does NOT auto-update third-party marketplaces by default (an npm registration updates with `npx projectstore-claude upgrade --project <dir>`). To get notified of new releases (v0.7+):",
469
487
  "1. Open `/plugin` → **Marketplaces** tab.",
@@ -29,7 +29,7 @@
29
29
  ],
30
30
  "shared_env_reason": "Codex sets Claude Code's plugin-root names too, for compatibility — measured in every one of the 759 captured hook payloads. Without declaring them, a Codex session scores a strong detection hit for claude-code, ties with codex, and loses the tie to manifest filename order. A variable two harnesses both set identifies neither, so these are demoted to weak for every manifest (generation spec, contract 1, amended 2026-09-08).",
31
31
  "session_env": [],
32
- "session_env_reason": "Unmeasured. CODEX_HOME never appeared in a hook environment — it is a shell-profile signal, not a session marker — and no equivalent of CLAUDECODE was observed."
32
+ "session_env_reason": "Deliberately empty, though measured. Codex's exec tool sets CODEX_THREAD_ID and CODEX_SESSION_ID in every command (measured 2026-10-05, codex-cli 0.160.0; CODEX_SANDBOX* depend on the sandbox mode), and CODEX_HOME never appeared in a hook environment. Declaring them would identify a session, but session_env also defers the registration and the layout move inside a session (insideHostSession), which would stop the doctor skill's upgrade --json fix from running there. Splitting identity from deferral is the question of *Codex hook contract measured in a live session* (PS-HARNESS); until then a core run from a rendered skill is identified by the shell that bundles it (generation spec, contract 18)."
33
33
  },
34
34
  "tools": {
35
35
  "write_tools": [
@@ -161,7 +161,10 @@
161
161
  "kind": "host",
162
162
  "scope": "user",
163
163
  "scope_reason": "Codex spawns subagents through a tool (collaborationspawn_agent), not by loading a plugin's agents/ directory.",
164
- "why_unsupported": "Measured: told to run projectstore:critic, Codex spawned six of its own generic subagents named after our roster and briefed them itself; none of our agents/*.md prose reached them. No route from a plugin's agents/ entry to that mechanism has been found (roadmap B4)."
164
+ "why_unsupported": "Measured: told to run projectstore:critic, Codex spawned six of its own generic subagents named after our roster and briefed them itself; none of our agents/*.md prose reached them. No route from a plugin's agents/ entry to that mechanism has been found (roadmap B4).",
165
+ "rendered_as": "skill",
166
+ "rendered_name": "projectstore-<name>",
167
+ "rendered_reason": "Each role is rendered as a skill of this name (the generator reads this key), so a role is called through skills.invocation: $projectstore-critic. Messages name roles this way (generation spec, contract 18)."
165
168
  },
166
169
  "agents_block": {
167
170
  "supported": true,
@@ -186,7 +189,10 @@
186
189
  "kind": "host",
187
190
  "scope": "user",
188
191
  "scope_reason": "Codex has no registrable root slash command; the /prompts: mechanism is deprecated.",
189
- "why_unsupported": "Shipping commands/ is actively harmful here: Codex rewrites them into skills itself and keeps ${CLAUDE_PLUGIN_ROOT} in the body, producing entry points that exit 1 with Cannot find module '/bin/projectstore.mjs' (reproduced). The generator renders them as skills instead (roadmap B5)."
192
+ "why_unsupported": "Shipping commands/ is actively harmful here: Codex rewrites them into skills itself and keeps ${CLAUDE_PLUGIN_ROOT} in the body, producing entry points that exit 1 with Cannot find module '/bin/projectstore.mjs' (reproduced). The generator renders them as skills instead (roadmap B5).",
193
+ "rendered_as": "skill",
194
+ "rendered_name": "projectstore-<name>",
195
+ "rendered_reason": "Each command is rendered as a skill of this name (the generator reads this key), so a command is called through skills.invocation: $projectstore-doctor. Messages name commands this way (generation spec, contract 18)."
190
196
  },
191
197
  "hooks": {
192
198
  "supported": true,
@@ -286,7 +292,12 @@
286
292
  }
287
293
  }
288
294
  },
289
- "capabilities": {},
295
+ "capabilities": {
296
+ "session_rename": null,
297
+ "session_rename_reason": "No session-rename command is known for Codex, so it gets no session-name offer (generation spec, contract 18).",
298
+ "bind_inherit": false,
299
+ "bind_inherit_reason": "The rendered bind skill and the CLI's bind verb have no inherit path; only Claude Code's command does. An unbound worktree is told to bind the parent checkout's vault explicitly. A follow-up story gives the core an inherit path and flips this."
300
+ },
290
301
  "rewrites": [],
291
302
  "install": {
292
303
  "mechanism": "the package's portable plugin root, registered through its distribution shell and Codex's own marketplace CLI",
@@ -298,6 +309,10 @@
298
309
  "restart Codex and approve the ProjectStore hooks when prompted",
299
310
  "run `$projectstore-bind <vault-path>` in Codex"
300
311
  ],
312
+ "next": [
313
+ "restart Codex from a terminal and approve the ProjectStore hooks when it asks",
314
+ "in an unbound project, run `$projectstore-bind <vault-path>` in Codex"
315
+ ],
301
316
  "notes": [
302
317
  "The installation is user-global because Codex's marketplace, plugin row and cache live in CODEX_HOME; the AGENTS.md block is project-local.",
303
318
  "Upgrade with `npx projectstore-codex@<version> upgrade --project \"$PWD\"`; the named package version is also the plugin version.",
@@ -306,6 +321,11 @@
306
321
  ],
307
322
  "docs": "https://github.com/SmartAndPoint/ProjectStore#install--one-message"
308
323
  },
324
+ "update_hint": {
325
+ "line": "Update with `{upgrade}`.",
326
+ "welcome": ["**About future updates**: run `{upgrade}` from a terminal, then restart Codex and approve any hook it asks about.", "", "See https://github.com/SmartAndPoint/ProjectStore#updates for details."],
327
+ "reason": "What the first-run welcome and its one-line notice say about updates (generation spec, contract 18): `line` ends the notice, `welcome` is the welcome's update block, and `{upgrade}` is filled with this harness's shell command at latest, so what is printed runs as printed. update_instructions is the long-form guidance; this is the part a session prints, kept here so no hook names a harness's update path."
328
+ },
309
329
  "update_instructions": "Run `npx projectstore-codex@<version> upgrade --project <dir>`. The installer atomically replaces its stable source and `codex plugin add projectstore@projectstore-npx` materialises the new version; this was measured across two dev builds on codex-cli 0.153.4. Restart Codex afterwards so the new skills and hooks are loaded.",
310
330
  "agent_translation": {
311
331
  "carries_model": false,
@@ -31,6 +31,7 @@ import {
31
31
  readActivityAsync,
32
32
  resolveInFlightArtifact,
33
33
  pathCell,
34
+ commandForm,
34
35
  } from "../scripts/lib.mjs";
35
36
 
36
37
  // Exits after the flush, never before: process.exit does not drain a pending
@@ -110,7 +111,7 @@ async function main() {
110
111
  } else {
111
112
  // True on every path: plugin commands are installation-scoped and not
112
113
  // gated by auto_inject.
113
- parts.push("run /projectstore:status to reorient");
114
+ parts.push(`run ${commandForm("status")} to reorient`);
114
115
  }
115
116
 
116
117
  emit(`projectstore: compacting — ${parts.join(", ")}`);
@@ -16,22 +16,34 @@
16
16
  // Spec: "Entry-rule detection: the score, the open-story predicate, and the
17
17
  // delivery seams", contract 17.
18
18
 
19
- import { readConfig, readStdinJson, adoptHookInput } from "../scripts/lib.mjs";
19
+ import { readConfig, readStdinJson, adoptHookInput, roleForm, sharedRoleForm } from "../scripts/lib.mjs";
20
20
 
21
- // Kept well under the cap; asserted by a test rather than by intention.
22
- const RULES = `# projectstore — standing rules for this session
21
+ // Kept well under the cap; asserted by a test rather than by intention. The
22
+ // roles are named the way the session's harness calls them (generation spec,
23
+ // contract 18). The shared AGENTS.md block names them in the source harness's
24
+ // form until that block is per-harness, so a session whose form differs is
25
+ // told once that both name one role — otherwise the second rule below would
26
+ // have it report the block as a contradiction every session.
27
+ function rules() {
28
+ const critic = roleForm("critic"), reviewer = roleForm("reviewer");
29
+ const shared = sharedRoleForm("critic");
30
+ const bridge = shared === critic
31
+ ? ""
32
+ : `\n\nThe \`AGENTS.md\` block may name roles as \`${shared}\`; that is the same role, called \`${critic}\` here.`;
33
+ return `# projectstore — standing rules for this session
23
34
 
24
35
  **Artifact-first order.** A feature-sized request opens a vault artifact before
25
36
  it opens an editor: analysis → placement (which epic, which story) → an ADR
26
- and/or spec when the "how" is non-trivial → \`projectstore:critic\` → only then
27
- implementation → \`projectstore:reviewer\`. "Feature-sized" is not a judgement
37
+ and/or spec when the "how" is non-trivial → \`${critic}\` → only then
38
+ implementation → \`${reviewer}\`. "Feature-sized" is not a judgement
28
39
  call about how the request was phrased — it is about what the work touches. If
29
40
  you are about to write across several source files, open the story first.
30
41
 
31
42
  **Report instruction conflicts; do not arbitrate them.** If a session-level or
32
43
  harness-level instruction contradicts these rules or the \`AGENTS.md\`
33
44
  registration block, say so and ask which wins. Resolving it silently is how the
34
- contradiction becomes invisible to the person who could have settled it.`;
45
+ contradiction becomes invisible to the person who could have settled it.${bridge}`;
46
+ }
35
47
 
36
48
  function emit(additionalContext) {
37
49
  process.stdout.write(JSON.stringify({
@@ -55,7 +67,7 @@ function main() {
55
67
  // Honour auto_inject: a session that opted out of context injection is
56
68
  // exactly the case for which the AGENTS.md block remains the durable copy.
57
69
  if (cfg.auto_inject === false) return;
58
- emit(RULES);
70
+ emit(rules());
59
71
  }
60
72
 
61
73
  try { main(); } catch {
@@ -36,6 +36,11 @@ import {
36
36
  PATH_CELL,
37
37
  ERROR_CELL,
38
38
  TITLE_CELL,
39
+ commandForm,
40
+ updateHint,
41
+ speakingDisplayName,
42
+ inheritForm,
43
+ bindInherits,
39
44
  } from "../scripts/lib.mjs";
40
45
  import { runStartupChecks } from "../scripts/doctor.mjs";
41
46
  import { resolveBinding, bindingOfferText } from "../scripts/worktree.mjs";
@@ -60,8 +65,8 @@ function welcomedMarkerWritePath(proj) {
60
65
  // the same message.
61
66
  function buildWelcome(cfg = null) {
62
67
  const start = cfg && cfg.vault_path
63
- ? `**Already bound**: this project's vault is \`${truncFront(String(cfg.vault_path), PATH_CELL)}\`. Ask for what you want — the agent picks up commands like \`/projectstore:adr\` and \`/projectstore:epic\` from the conversation, and you approve every write. If the vault has no folders yet, \`/projectstore:scaffold\` lays them out.`
64
- : "**To start using it**: run `/projectstore:bind <vault-path>` and point it at an Obsidian vault (or any folder). After that, the agent will pick up commands like `/projectstore:adr` and `/projectstore:epic` from the conversation; you only approve the writes.";
68
+ ? `**Already bound**: this project's vault is \`${truncFront(String(cfg.vault_path), PATH_CELL)}\`. Ask for what you want — the agent picks up commands like \`${commandForm("adr")}\` and \`${commandForm("epic")}\` from the conversation, and you approve every write. If the vault has no folders yet, \`${commandForm("scaffold")}\` lays them out.`
69
+ : `**To start using it**: run \`${commandForm("bind", { args: "<vault-path>" })}\` and point it at an Obsidian vault (or any folder). After that, the agent will pick up commands like \`${commandForm("adr")}\` and \`${commandForm("epic")}\` from the conversation; you only approve the writes.`;
65
70
  return [
66
71
  "# 👋 projectstore is loaded for the first time in this project",
67
72
  "",
@@ -69,12 +74,9 @@ function buildWelcome(cfg = null) {
69
74
  "",
70
75
  start,
71
76
  "",
72
- "**About future updates**: Claude Code does NOT auto-update third-party marketplaces by default. To get notified of new releases (v0.7+):",
73
- "1. Open `/plugin` → **Marketplaces** tab.",
74
- "2. Find **SmartAndPoint**.",
75
- "3. Toggle **auto-update** on.",
76
- "",
77
- "Without it, you'd run `/plugin marketplace update SmartAndPoint` manually. See https://github.com/SmartAndPoint/ProjectStore#updates for details.",
77
+ // The update advice is the harness's own (its manifest's update_hint):
78
+ // a git marketplace's toggle means nothing to a harness installed from npm.
79
+ ...updateHint().welcome,
78
80
  "",
79
81
  "_This message appears once per project._",
80
82
  "",
@@ -128,7 +130,7 @@ function buildOthersWarning(others) {
128
130
  "",
129
131
  `## ⚠️ Multi-session warning — ${others.length} other projectstore session(s) active on this vault`,
130
132
  "",
131
- "Another Claude Code session is currently working on the same vault.",
133
+ `Another ${speakingDisplayName()} session is currently working on the same vault.`,
132
134
  "Active session(s):",
133
135
  "",
134
136
  ];
@@ -143,13 +145,13 @@ function buildOthersWarning(others) {
143
145
  );
144
146
  }
145
147
  if (others.length > SIBLING_CAP) {
146
- lines.push(`- …and ${others.length - SIBLING_CAP} more — run \`/projectstore:status\``);
148
+ lines.push(`- …and ${others.length - SIBLING_CAP} more — run \`${commandForm("status")}\``);
147
149
  }
148
150
  lines.push(
149
151
  "",
150
152
  "**Before creating new ADRs / epics / stories / research:**",
151
- "1. Run `/projectstore:search <topic-keywords>` to check for in-flight artifacts on the same topic.",
152
- "2. Run `/projectstore:status` to see what artifacts have been touched recently.",
153
+ `1. Run \`${commandForm("search", { args: "<topic-keywords>" })}\` to check for in-flight artifacts on the same topic.`,
154
+ `2. Run \`${commandForm("status")}\` to see what artifacts have been touched recently.`,
153
155
  "3. After creation, the plugin re-checks file existence right before write — collisions are detected, but topic / number reservation across sessions is on you and the other agent to coordinate.",
154
156
  "",
155
157
  );
@@ -179,8 +181,8 @@ async function main() {
179
181
  // one: a bound project is told what it is bound to, not to bind again.
180
182
  const welcomeSystemMessage = welcome
181
183
  ? (cfg && cfg.vault_path
182
- ? `👋 projectstore: first-run welcome shown. Bound to ${cfg.vault_path}. See /plugin → Marketplaces to enable auto-update.`
183
- : "👋 projectstore: first-run welcome shown. Start with /projectstore:bind <vault-path>. See /plugin → Marketplaces to enable auto-update.")
184
+ ? `👋 projectstore: first-run welcome shown. Bound to ${cfg.vault_path}. ${updateHint().line}`
185
+ : `👋 projectstore: first-run welcome shown. Start with ${commandForm("bind", { args: "<vault-path>" })}. ${updateHint().line}`)
184
186
  : null;
185
187
 
186
188
  if (!cfg) {
@@ -191,7 +193,7 @@ async function main() {
191
193
  // redundancy is accepted rather than papered over with copy that will rot.
192
194
  const offer = binding && binding.state === "inheritable" ? bindingOfferText(binding) : "";
193
195
  const offerSystemMessage = offer
194
- ? "projectstore: this worktree is unbound — /projectstore:bind --inherit adopts the binding of the checkout it was forked from."
196
+ ? `projectstore: this worktree is unbound — ${inheritForm(binding.vaultPath, { layout: binding.layout, language: binding.language })} ${bindInherits() ? "adopts the binding of" : "binds it to the vault, layout and language of"} the checkout it was forked from.`
195
197
  : null;
196
198
  const body = offer + (welcome || "");
197
199
  if (body) {
@@ -273,9 +275,9 @@ async function main() {
273
275
  try {
274
276
  const r = runStartupChecks(cfg, proj);
275
277
  if (r.skipped) {
276
- doctorMsg = "projectstore doctor: startup checks skipped — run /projectstore:doctor";
278
+ doctorMsg = `projectstore doctor: startup checks skipped — run ${commandForm("doctor")}`;
277
279
  } else if (r.count > 0) {
278
- doctorMsg = `projectstore doctor: ${r.count} install issue(s) — run /projectstore:doctor`;
280
+ doctorMsg = `projectstore doctor: ${r.count} install issue(s) — run ${commandForm("doctor")}`;
279
281
  }
280
282
  // Offers (doctor's OFFER_CHECKS): one-time steps a user should see once,
281
283
  // e.g. the re-stamp after a plugin update — not issues, not silent.
@@ -287,7 +289,7 @@ async function main() {
287
289
  if (gatherError) {
288
290
  emit(
289
291
  welcome +
290
- `# projectstore: vault load failed\n\n${truncEnd(String(gatherError.message), ERROR_CELL)}\n\nFix \`.projectstore/projectstore.json\` or run \`/projectstore:bind <path>\` again.`,
292
+ `# projectstore: vault load failed\n\n${truncEnd(String(gatherError.message), ERROR_CELL)}\n\nFix \`.projectstore/projectstore.json\` or run \`${commandForm("bind", { args: "<path>" })}\` again.`,
291
293
  systemMessage,
292
294
  );
293
295
  return;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectstore",
3
- "version": "0.28.2",
3
+ "version": "0.29.1",
4
4
  "description": "Your agent runs the project through a verified loop: task → artifact (ADR / spec / epic / story) → adversarial critic → backlog → planner → reviewer → done. Plain markdown in git — any model can pick the project up tomorrow.",
5
5
  "keywords": [
6
6
  "project-management",
@@ -57,7 +57,7 @@
57
57
  "access": "public"
58
58
  },
59
59
  "scripts": {
60
- "test": "node --test tests/*.test.mjs",
60
+ "test": "node --test --import ./tests/fixtures/hermetic.mjs tests/*.test.mjs",
61
61
  "guard": "node scripts/version-guard.mjs",
62
62
  "packlist": "node scripts/version-guard.mjs --write-packlist",
63
63
  "adapters": "node scripts/build-adapters.mjs --write",
@@ -27,7 +27,7 @@
27
27
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, realpathSync } from "node:fs";
28
28
  import { join, resolve, isAbsolute, dirname } from "node:path";
29
29
  import { homedir } from "node:os";
30
- import { writeFileAtomic, pluginRoot, ensureRuntimeDir, layoutPaths } from "./lib.mjs";
30
+ import { writeFileAtomic, pluginRoot, ensureRuntimeDir, layoutPaths, commandForm } from "./lib.mjs";
31
31
  import { configPath as harnessConfigPath } from "./harness.mjs";
32
32
 
33
33
  export const DEFAULT_LAYOUT = "engineering";
@@ -154,12 +154,12 @@ export function renderBindPlan(p, done = null) {
154
154
  const lines = [];
155
155
  if (!p.ok) { for (const r of p.refusals) lines.push(r.message); return lines.join("\n") + "\n"; }
156
156
  if (p.state === "same") {
157
- lines.push(`Already bound to ${p.vault}${p.ignored.length ? ` — --${p.ignored.join(" and --")} ignored: a change of ${p.ignored.join("/")} is not a rebind (edit the config, or rebind to another vault)` : ""}. Run /projectstore:scaffold to (re)create the layout, or \`projectstore status\` to inspect it.`);
157
+ lines.push(`Already bound to ${p.vault}${p.ignored.length ? ` — --${p.ignored.join(" and --")} ignored: a change of ${p.ignored.join("/")} is not a rebind (edit the config, or rebind to another vault)` : ""}. Run ${commandForm("scaffold")} to (re)create the layout, or \`projectstore status\` to inspect it.`);
158
158
  return lines.join("\n") + "\n";
159
159
  }
160
160
  if (done && done.created_vault) lines.push(`Created ${p.vault}`);
161
161
  lines.push(`Wrote ${p.configPath}${p.state === "different" ? ` (rebind from ${p.before.vault_path}; kept: ${p.keptKeys.join(", ") || "nothing else"})` : ""}`);
162
162
  lines.push(` vault_path: ${p.vault}`, ` layout: ${p.layout}`, ` language: ${p.language}`);
163
- if (done) lines.push("", p.vaultExists && !done.created_vault ? "Next: `projectstore status`, or /projectstore:scaffold if the vault has no layout folders yet." : "Next: /projectstore:scaffold in a session creates the layout's folders and READMEs.");
163
+ if (done) lines.push("", p.vaultExists && !done.created_vault ? `Next: \`projectstore status\`, or ${commandForm("scaffold")} if the vault has no layout folders yet.` : `Next: ${commandForm("scaffold")} in a session creates the layout's folders and READMEs.`);
164
164
  return lines.join("\n") + "\n";
165
165
  }
@@ -6,11 +6,28 @@
6
6
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from "node:fs";
7
7
  import { dirname, join, relative, resolve } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
- import { emittingHarnesses, sourceHarness } from "./harness.mjs";
9
+ import { emittingHarnesses, sourceHarness, invocation, uiWordPatterns } from "./harness.mjs";
10
10
  import { writeFileAtomic } from "./lib.mjs";
11
11
 
12
12
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
13
13
  const SOURCE = sourceHarness();
14
+ // The source harness's UI affordances, from its manifest (ui_vocabulary): the
15
+ // same list the runtime vocabulary lint reads (generation spec, contract 18).
16
+ // The source's command form and the target's skill namespace, from the
17
+ // manifests (generation spec, contract 18): the body rewrite, the passive-skill
18
+ // name rule and the leak check read these, so a renamed form cannot leave
19
+ // directory names and in-body references disagreeing.
20
+ const escapeRe = (t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
21
+ const SOURCE_COMMAND_PREFIX = SOURCE.surfaces.commands.invocation.split("<name>")[0];
22
+ const SOURCE_COMMAND = new RegExp(`${escapeRe(SOURCE_COMMAND_PREFIX)}([a-z0-9-]+|\\*)`, "g");
23
+ const SOURCE_UI = uiWordPatterns(SOURCE);
24
+ // The source's role form (`projectstore:<name>`) for each of its roles, by file
25
+ // name: a rendered skill that says "spawn projectstore:critic" names a role the
26
+ // way only the source harness calls it. Never inside another form
27
+ // (`/projectstore:`, `$projectstore-`), never a longer name.
28
+ const ROLE_SUFFIX = SOURCE.surfaces.agents.file.replace("<name>", "");
29
+ const SOURCE_ROLES = readdirSync(join(ROOT, SOURCE.surfaces.agents.dir)).filter((f) => f.endsWith(ROLE_SUFFIX)).map((f) => f.slice(0, -ROLE_SUFFIX.length)).sort();
30
+ const SOURCE_ROLE = new RegExp(`(?<![\\w/$:-])${escapeRe(SOURCE.surfaces.agents.invocation.split("<name>")[0])}(${SOURCE_ROLES.map(escapeRe).join("|")})(?![\\w-])`, "g");
14
31
  const TARGETS = emittingHarnesses();
15
32
  if (TARGETS.length !== 1) throw new Error(`this renderer currently requires exactly one emitting harness; found ${TARGETS.length}`);
16
33
  const TARGET = TARGETS[0];
@@ -61,8 +78,8 @@ function rewriteBody(body) {
61
78
  .replaceAll(sourceWord, TARGET.id)
62
79
  .replaceAll(SOURCE.surfaces.agents_block.reads_natively, TARGET.surfaces.agents_block.reads_natively)
63
80
  .replaceAll(SOURCE.runtime.harness_dir + "/", TARGET.runtime.harness_dir + "/")
64
- .replaceAll("/projectstore:*", "$projectstore-*")
65
- .replace(/\/projectstore:([a-z0-9-]+)/g, "$projectstore-$1")
81
+ .replace(SOURCE_COMMAND, (_, name) => invocation(TARGET, name, { kind: "commands" }))
82
+ .replace(SOURCE_ROLE, (_, name) => invocation(TARGET, name, { kind: "agents" }))
66
83
  .replaceAll("AskUserQuestion", "the harness's user-input mechanism")
67
84
  .replaceAll("Read tool", "file-reading tool")
68
85
  .replaceAll("Write tool", "file-writing tool")
@@ -80,10 +97,11 @@ const COMMAND_OVERRIDES = {
80
97
 
81
98
  ## register / unregister
82
99
 
83
- Preview the requested change and ask for explicit approval. On approval, run the
84
- core's \`install\` or \`uninstall\` verb with \`--harness codex --surface
85
- agents_block --project "$PWD"\`. Print its output verbatim. Never edit the
86
- managed block by hand.
100
+ Preview the requested change with \`plan --harness codex --surface agents_block
101
+ --project "$PWD"\` and ask for explicit approval. On approval, run the core's
102
+ \`install\` or \`uninstall\` verb with \`--harness codex --surface agents_block
103
+ --project "$PWD" --json\` — \`--json\` never waits on a terminal's question — and
104
+ report the envelope's result. Never edit the managed block by hand.
87
105
 
88
106
  ## status
89
107
 
@@ -104,13 +122,15 @@ contract. A configured model is consumed by the role-orchestration skills on
104
122
  their next spawn; no restart is needed.`,
105
123
  },
106
124
  bind: {
107
- description: "Bind this project to an existing ProjectStore vault, or initialize and bind a new vault, using the harness-neutral core. Arguments: [vault-path].",
125
+ description: "Bind this project to an existing ProjectStore vault, or initialize and bind a new vault, using the harness-neutral core. Arguments: [vault-path] [--layout <name>] [--language <code>].",
108
126
  body: `Bind the current project through the core; never write the binding by
109
127
  hand.
110
128
 
111
- 1. Resolve the requested vault path. If it exists, use \`bind\`; if the user
112
- explicitly asks to create it, use \`init\`. Ask for layout and language only
113
- when the user has not supplied them.
129
+ 1. Resolve the requested vault path, and the layout and language when the
130
+ request passes \`--layout <name>\` or \`--language <code>\`. If the vault
131
+ exists, use \`bind\`; if the user explicitly asks to create it, use
132
+ \`init\`. Ask for layout and language only when the request has not
133
+ supplied them.
114
134
  2. Show the resolved project, vault, verb, layout and language. Ask for explicit
115
135
  approval. Naming the vault is the core's non-interactive confirmation.
116
136
  3. Run \`node "\${PROJECTSTORE_CORE_ROOT}/bin/projectstore.mjs" <bind|init>
@@ -134,7 +154,9 @@ arguments and \`--json\`. Summarize every finding without re-deriving it.
134
154
  When \`--fix\` is absent, remain read-only. When it is present, separate fixes
135
155
  by owner: derived vault views use \`$projectstore-reconcile\`; Codex plugin or
136
156
  agents-block drift uses the core's \`upgrade --harness codex\` path. Preview
137
- each mutation and ask for explicit approval before running it. Unsupported
157
+ each mutation with \`plan --harness codex\` and ask for explicit approval before
158
+ running it; then run \`upgrade --harness codex --json\`, whose envelope is the
159
+ result — \`--json\` never waits on a terminal's question. Unsupported
138
160
  surfaces remain unsupported; do not create host configuration by hand. Never
139
161
  claim a fix after a non-zero exit.`,
140
162
  },
@@ -148,10 +170,21 @@ Codex application's own task and terminal UI.`,
148
170
  },
149
171
  };
150
172
 
173
+ // The rendered skills' namespace: the part of rendered_name before the name.
174
+ const NAMESPACE = (TARGET.surfaces?.commands?.rendered_name || "").split("<name>")[0];
175
+
176
+ // A command or role becomes the skill its manifest names (rendered_name), the
177
+ // same name every message calls it by (generation spec, contract 18).
178
+ const renderedName = (kind, name) => {
179
+ const tpl = TARGET.surfaces?.[kind]?.rendered_name;
180
+ if (!tpl) throw new Error(`${TARGET.id}: surfaces.${kind}.rendered_name is required to render ${kind} as skills`);
181
+ return tpl.split("<name>").join(name);
182
+ };
183
+
151
184
  function renderCommand(file) {
152
185
  const { data, body } = frontmatter(read(file), file);
153
186
  const verb = file.split("/").pop().replace(/\.md$/, "");
154
- const name = `projectstore-${verb}`;
187
+ const name = renderedName("commands", verb);
155
188
  const override = COMMAND_OVERRIDES[verb];
156
189
  if (override) return [join("skills", name, "SKILL.md"), skill(name, override.description, override.body)];
157
190
  const hint = data["argument-hint"] ? ` Arguments: ${data["argument-hint"]}.` : "";
@@ -162,14 +195,14 @@ function renderPassive(file) {
162
195
  const { data, body } = frontmatter(read(file), file);
163
196
  const dir = file.split("/").at(-2);
164
197
  const name = data.name || dir;
165
- if (!name.startsWith("projectstore-")) throw new Error(`${relative(ROOT, file)}: skill name must be projectstore-*`);
198
+ if (!name.startsWith(NAMESPACE)) throw new Error(`${relative(ROOT, file)}: skill name must be ${NAMESPACE}*`);
166
199
  return [join("skills", name, "SKILL.md"), skill(name, data.description || name, body)];
167
200
  }
168
201
 
169
202
  function renderRole(file) {
170
203
  const { data, body } = frontmatter(read(file), file);
171
204
  const role = file.split("/").pop().replace(/\.md$/, "");
172
- const name = `projectstore-${role}`;
205
+ const name = renderedName("agents", role);
173
206
  const orchestration = `## Codex orchestration\n\nThis is a role-orchestration skill, not a native agent registration. Resolve the\nrole model by running:\n\n\`\`\`bash\nnode "\${PROJECTSTORE_CORE_ROOT}/bin/projectstore.mjs" agents model ${role} --json --project "$PWD"\n\`\`\`\n\nSpawn a collaboration agent for the bounded task. If the result names a model,\npass that model and use an empty or bounded context fork; otherwise inherit the\ncurrent model. Do not pass a reasoning-effort override: per-role effort belongs\nto a separate accepted story. Give the spawned agent the role contract below\nand the exact artifact/diff it must inspect. Wait for its final result.\n\n## Role contract\n\n`;
174
207
  const description = (data.description || `Run the ProjectStore ${role} role in a fresh collaboration agent.`)
175
208
  .replace(/^(?:Opus|Sonnet) \(max-effort\)\s+/, "");
@@ -200,7 +233,7 @@ export function renderCodexAdapter(root = ROOT) {
200
233
  ...files(join(root, "commands")).map(renderCommand),
201
234
  ...files(join(root, "agents")).map(renderRole),
202
235
  ...readdirSync(join(root, "skills"), { withFileTypes: true })
203
- .filter((e) => e.isDirectory() && e.name.startsWith("projectstore-"))
236
+ .filter((e) => e.isDirectory() && e.name.startsWith(NAMESPACE))
204
237
  .sort((a, b) => a.name.localeCompare(b.name))
205
238
  .map((e) => renderPassive(join(root, "skills", e.name, "SKILL.md"))),
206
239
  renderHooks(manifest, root),
@@ -214,8 +247,8 @@ export function renderCodexAdapter(root = ROOT) {
214
247
  for (const [rel, text] of out) {
215
248
  const sourceTokens = [SOURCE.runtime.plugin_root_env, SOURCE.runtime.project_dir_env, SOURCE.display_name, SOURCE.runtime.harness_dir + "/"].filter(Boolean);
216
249
  const sourceAgentEnvs = (SOURCE.runtime.agent_overrides || []).map((row) => row.env).filter(Boolean);
217
- if (sourceTokens.some((token) => text.includes(token)) || sourceAgentEnvs.some((token) => text.includes(token)) || /\$ARGUMENTS|\/projectstore:|\bAskUserQuestion\b/.test(text)) throw new Error(`${rel}: source-harness vocabulary leaked into ${TARGET.display_name} adapter`);
218
- if (/\/reload-plugins\b|\/plugin(?:\s|\b)|harness\/codex-code\.json|\b(?:opus|sonnet|fable)\b/i.test(text)) throw new Error(`${rel}: source-harness model, environment or UI semantics leaked into ${TARGET.display_name} adapter`);
250
+ if (sourceTokens.some((token) => text.includes(token)) || sourceAgentEnvs.some((token) => text.includes(token)) || text.includes(SOURCE_COMMAND_PREFIX) || new RegExp(SOURCE_ROLE.source).test(text) || /\$ARGUMENTS/.test(text)) throw new Error(`${rel}: source-harness vocabulary leaked into ${TARGET.display_name} adapter`);
251
+ if (SOURCE_UI.some(({ re }) => new RegExp(re.source).test(text)) || /harness\/codex-code\.json|\b(?:opus|sonnet|fable)\b/i.test(text)) throw new Error(`${rel}: source-harness model, environment or UI semantics leaked into ${TARGET.display_name} adapter`);
219
252
  }
220
253
  return out;
221
254
  }