projectstore-claude 0.29.0 โ†’ 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 (28) hide show
  1. package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
  2. package/node_modules/projectstore/.claude-plugin/plugin.json +1 -1
  3. package/node_modules/projectstore/commands/review.md +1 -1
  4. package/node_modules/projectstore/harnesses/claude-code.json +17 -2
  5. package/node_modules/projectstore/harnesses/codex.json +20 -4
  6. package/node_modules/projectstore/hooks/pre-compact.mjs +2 -1
  7. package/node_modules/projectstore/hooks/session-rules.mjs +19 -7
  8. package/node_modules/projectstore/hooks/session-start.mjs +20 -18
  9. package/node_modules/projectstore/package.json +2 -2
  10. package/node_modules/projectstore/scripts/binding.mjs +3 -3
  11. package/node_modules/projectstore/scripts/build-adapters.mjs +43 -13
  12. package/node_modules/projectstore/scripts/cli.mjs +3 -3
  13. package/node_modules/projectstore/scripts/codemap.mjs +2 -2
  14. package/node_modules/projectstore/scripts/doctor.mjs +110 -58
  15. package/node_modules/projectstore/scripts/draft.mjs +4 -3
  16. package/node_modules/projectstore/scripts/graph.mjs +3 -2
  17. package/node_modules/projectstore/scripts/harness.mjs +89 -0
  18. package/node_modules/projectstore/scripts/kanban.mjs +3 -2
  19. package/node_modules/projectstore/scripts/lib.mjs +69 -8
  20. package/node_modules/projectstore/scripts/mcp.mjs +2 -2
  21. package/node_modules/projectstore/scripts/query.mjs +2 -1
  22. package/node_modules/projectstore/scripts/reconcile.mjs +4 -3
  23. package/node_modules/projectstore/scripts/story-section.mjs +2 -1
  24. package/node_modules/projectstore/scripts/surfaces.mjs +2 -1
  25. package/node_modules/projectstore/scripts/term.mjs +3 -1
  26. package/node_modules/projectstore/scripts/touch-session.mjs +6 -1
  27. package/node_modules/projectstore/scripts/worktree.mjs +12 -7
  28. package/package.json +2 -2
@@ -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.29.0",
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.29.0",
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",
@@ -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,
@@ -464,9 +464,24 @@
464
464
  "interactive_approval_tool": true,
465
465
  "permission_decision_hook": "PreToolUse",
466
466
  "subagent_tool_allowlist": true,
467
- "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)."
468
472
  },
469
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
+ },
470
485
  "update_instructions": [
471
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+):",
472
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",
@@ -310,6 +321,11 @@
310
321
  ],
311
322
  "docs": "https://github.com/SmartAndPoint/ProjectStore#install--one-message"
312
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
+ },
313
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.",
314
330
  "agent_translation": {
315
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.29.0",
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")
@@ -105,13 +122,15 @@ contract. A configured model is consumed by the role-orchestration skills on
105
122
  their next spawn; no restart is needed.`,
106
123
  },
107
124
  bind: {
108
- 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>].",
109
126
  body: `Bind the current project through the core; never write the binding by
110
127
  hand.
111
128
 
112
- 1. Resolve the requested vault path. If it exists, use \`bind\`; if the user
113
- explicitly asks to create it, use \`init\`. Ask for layout and language only
114
- 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.
115
134
  2. Show the resolved project, vault, verb, layout and language. Ask for explicit
116
135
  approval. Naming the vault is the core's non-interactive confirmation.
117
136
  3. Run \`node "\${PROJECTSTORE_CORE_ROOT}/bin/projectstore.mjs" <bind|init>
@@ -151,10 +170,21 @@ Codex application's own task and terminal UI.`,
151
170
  },
152
171
  };
153
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
+
154
184
  function renderCommand(file) {
155
185
  const { data, body } = frontmatter(read(file), file);
156
186
  const verb = file.split("/").pop().replace(/\.md$/, "");
157
- const name = `projectstore-${verb}`;
187
+ const name = renderedName("commands", verb);
158
188
  const override = COMMAND_OVERRIDES[verb];
159
189
  if (override) return [join("skills", name, "SKILL.md"), skill(name, override.description, override.body)];
160
190
  const hint = data["argument-hint"] ? ` Arguments: ${data["argument-hint"]}.` : "";
@@ -165,14 +195,14 @@ function renderPassive(file) {
165
195
  const { data, body } = frontmatter(read(file), file);
166
196
  const dir = file.split("/").at(-2);
167
197
  const name = data.name || dir;
168
- 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}*`);
169
199
  return [join("skills", name, "SKILL.md"), skill(name, data.description || name, body)];
170
200
  }
171
201
 
172
202
  function renderRole(file) {
173
203
  const { data, body } = frontmatter(read(file), file);
174
204
  const role = file.split("/").pop().replace(/\.md$/, "");
175
- const name = `projectstore-${role}`;
205
+ const name = renderedName("agents", role);
176
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`;
177
207
  const description = (data.description || `Run the ProjectStore ${role} role in a fresh collaboration agent.`)
178
208
  .replace(/^(?:Opus|Sonnet) \(max-effort\)\s+/, "");
@@ -203,7 +233,7 @@ export function renderCodexAdapter(root = ROOT) {
203
233
  ...files(join(root, "commands")).map(renderCommand),
204
234
  ...files(join(root, "agents")).map(renderRole),
205
235
  ...readdirSync(join(root, "skills"), { withFileTypes: true })
206
- .filter((e) => e.isDirectory() && e.name.startsWith("projectstore-"))
236
+ .filter((e) => e.isDirectory() && e.name.startsWith(NAMESPACE))
207
237
  .sort((a, b) => a.name.localeCompare(b.name))
208
238
  .map((e) => renderPassive(join(root, "skills", e.name, "SKILL.md"))),
209
239
  renderHooks(manifest, root),
@@ -217,8 +247,8 @@ export function renderCodexAdapter(root = ROOT) {
217
247
  for (const [rel, text] of out) {
218
248
  const sourceTokens = [SOURCE.runtime.plugin_root_env, SOURCE.runtime.project_dir_env, SOURCE.display_name, SOURCE.runtime.harness_dir + "/"].filter(Boolean);
219
249
  const sourceAgentEnvs = (SOURCE.runtime.agent_overrides || []).map((row) => row.env).filter(Boolean);
220
- 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`);
221
- 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`);
222
252
  }
223
253
  return out;
224
254
  }
@@ -43,7 +43,7 @@ import { fileURLToPath } from "node:url";
43
43
  import { parseArgs } from "node:util";
44
44
  import * as term from "./term.mjs";
45
45
  import { projectRootDeclared, childEnv, harnessIds, harnessForOverlay, pinPluginRoot } from "./harness.mjs";
46
- import { readConfigAt, readOverlayAt, resolveAgentModel, writeOverlayAt, overlayId, layoutRoster } from "./lib.mjs";
46
+ import { readConfigAt, readOverlayAt, resolveAgentModel, writeOverlayAt, overlayId, layoutRoster, commandForm } from "./lib.mjs";
47
47
  import { READ_OPERATIONS, LINEAGE_KINDS, LINEAGE_DEFAULT_DEPTH, SEARCH_DEFAULT_LIMIT, GRAPH_EDGE_CAP, DIRECTIONS } from "./query.mjs";
48
48
  // binding.mjs is a write module imported statically where the install family
49
49
  // is lazy: it is a dependency-free leaf with no side effects, so the MCP
@@ -173,7 +173,7 @@ export const VERBS = Object.freeze([
173
173
  run: runBind(false),
174
174
  }),
175
175
  Object.freeze({
176
- verb: "init", summary: "init <vault> โ€” create the vault directory and bind to it; the layout's folders come from /projectstore:scaffold.",
176
+ verb: "init", summary: `init <vault> โ€” create the vault directory and bind to it; the layout's folders come from ${commandForm("scaffold")}.`,
177
177
  module: "./binding.mjs", wraps: "new", how: "import", output: "envelope", writes: true, requiresBinding: false, mcp: Object.freeze([]),
178
178
  options: [opt("layout", "<name>", `the layout (default ${DEFAULT_LAYOUT})`), opt("language", "<code>", `the template language (default ${DEFAULT_LANGUAGE})`), opt("rebind", false, "an already bound project: create the new vault and point the project at it; every other setting is kept"), JSON_OPT],
179
179
  run: runBind(true),
@@ -401,7 +401,7 @@ export async function run(argv, { env = process.env, cwd = process.cwd(), stdin
401
401
  }
402
402
  const project = resolveProject({ project: values.project, env, cwd });
403
403
  const cfg = readConfigAt(project);
404
- if (row.requiresBinding && !cfg) return fail(verb, project, `${project} is not bound to a vault โ€” run /projectstore:bind <vault> in a session, or \`projectstore bind <vault>\` (\`projectstore init <vault>\` also creates the vault).`, 3);
404
+ if (row.requiresBinding && !cfg) return fail(verb, project, `${project} is not bound to a vault โ€” run ${commandForm("bind", { args: "<vault>", env })} in a session, or \`projectstore bind <vault>\` (\`projectstore init <vault>\` also creates the vault).`, 3);
405
405
  try {
406
406
  return await row.run({ row, values, positionals: positionals.slice(1), cfg, project, env, cwd, stdin, stdout, stderr, ask });
407
407
  } catch (e) {
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { existsSync, readFileSync, readdirSync } from "node:fs";
12
12
  import { join } from "node:path";
13
- import { readConfig, loadLayout, folderByKind, parseFrontmatter, nowIso, isMain } from "./lib.mjs";
13
+ import { readConfig, loadLayout, folderByKind, parseFrontmatter, nowIso, isMain, commandForm } from "./lib.mjs";
14
14
 
15
15
  function die(msg) {
16
16
  process.stderr.write(`projectstore/codemap: ${msg}\n`);
@@ -29,7 +29,7 @@ function parseRefs(raw) {
29
29
 
30
30
  function main() {
31
31
  const cfg = readConfig();
32
- if (!cfg) die("No projectstore config. Run /projectstore:bind first.");
32
+ if (!cfg) die(`No projectstore config. Run ${commandForm("bind")} first.`);
33
33
  const layout = loadLayout(cfg.layout);
34
34
  const folder = folderByKind(layout, "epic");
35
35
  if (!folder) die("Layout has no epic folder โ€” nothing to map.");