projectstore-claude 0.30.4 → 0.30.6

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 (50) hide show
  1. package/node_modules/projectstore/.claude-plugin/marketplace.json +2 -2
  2. package/node_modules/projectstore/.claude-plugin/plugin.json +6 -2
  3. package/node_modules/projectstore/.mcp.json +1 -2
  4. package/node_modules/projectstore/README.md +65 -16
  5. package/node_modules/projectstore/agents/archaeologist.md +1 -1
  6. package/node_modules/projectstore/agents/librarian.md +1 -1
  7. package/node_modules/projectstore/agents/planner.md +1 -1
  8. package/node_modules/projectstore/commands/doctor.md +14 -11
  9. package/node_modules/projectstore/commands/reconcile.md +1 -1
  10. package/node_modules/projectstore/commands/review.md +2 -2
  11. package/node_modules/projectstore/commands/story.md +1 -1
  12. package/node_modules/projectstore/docs/getting-started.md +3 -4
  13. package/node_modules/projectstore/docs/harnesses.md +1 -1
  14. package/node_modules/projectstore/docs/how-it-works.md +7 -0
  15. package/node_modules/projectstore/docs/images/icon.png +0 -0
  16. package/node_modules/projectstore/harnesses/claude-code.json +61 -11
  17. package/node_modules/projectstore/harnesses/codex.json +45 -15
  18. package/node_modules/projectstore/hooks/band.tsx +19 -8
  19. package/node_modules/projectstore/hooks/hooks.json +6 -6
  20. package/node_modules/projectstore/hooks/session-start.mjs +55 -24
  21. package/node_modules/projectstore/hooks/session-stop.mjs +43 -20
  22. package/node_modules/projectstore/package.json +11 -12
  23. package/node_modules/projectstore/scripts/band-shared.mjs +17 -2
  24. package/node_modules/projectstore/scripts/band.mjs +20 -2
  25. package/node_modules/projectstore/scripts/binding.mjs +1 -1
  26. package/node_modules/projectstore/scripts/build-adapters.mjs +45 -4
  27. package/node_modules/projectstore/scripts/cli.mjs +74 -1373
  28. package/node_modules/projectstore/scripts/convergence.mjs +59 -16
  29. package/node_modules/projectstore/scripts/doctor.mjs +199 -36
  30. package/node_modules/projectstore/scripts/harness.mjs +88 -6
  31. package/node_modules/projectstore/scripts/install-handlers.mjs +925 -0
  32. package/node_modules/projectstore/scripts/install-harness.mjs +129 -2081
  33. package/node_modules/projectstore/scripts/install-layout.mjs +453 -0
  34. package/node_modules/projectstore/scripts/install-preview.mjs +747 -0
  35. package/node_modules/projectstore/scripts/lib.mjs +387 -107
  36. package/node_modules/projectstore/scripts/mcp-server.mjs +33 -0
  37. package/node_modules/projectstore/scripts/mcp.mjs +60 -60
  38. package/node_modules/projectstore/scripts/model-spec.mjs +24 -0
  39. package/node_modules/projectstore/scripts/models.mjs +6 -10
  40. package/node_modules/projectstore/scripts/query.mjs +7 -6
  41. package/node_modules/projectstore/scripts/read-verbs.mjs +277 -0
  42. package/node_modules/projectstore/scripts/setup.mjs +62 -12
  43. package/node_modules/projectstore/scripts/statusline-launcher.mjs +36 -3
  44. package/node_modules/projectstore/scripts/surfaces.mjs +18 -5
  45. package/node_modules/projectstore/scripts/touch-session.mjs +30 -15
  46. package/node_modules/projectstore/scripts/verbs.mjs +1202 -0
  47. package/node_modules/projectstore/scripts/version-guard.mjs +57 -7
  48. package/package.json +3 -3
  49. package/node_modules/projectstore/docs/images/loop-light.svg +0 -94
  50. package/node_modules/projectstore/docs/images/team-light.svg +0 -79
@@ -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": "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). The deferral half of that split is registration_defer_env below (the maintainer's decision of 2026-10-08).",
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. It stays empty, decided 2026-10-15 at the close of *Codex hook contract measured in a live session* (PS-HARNESS): a core run from a rendered skill is identified by the shell that bundles it — what it prints (generation spec, contract 18) and which overlay it reads (layout spec, contract 2, as amended 2026-10-15). The deferral half of that split is registration_defer_env below (the maintainer's decision of 2026-10-08).",
33
33
  "registration_defer_env": [
34
34
  "CODEX_THREAD_ID"
35
35
  ],
@@ -72,9 +72,9 @@
72
72
  "_root_placeholder_literal_comment": "true: Codex substitutes nothing in a hook command string. It does not need to — the hook process receives PLUGIN_ROOT in its environment (measured). The generator therefore renders an absolute interpreter and an absolute script path; a bare `node` is a PATH gamble."
73
73
  },
74
74
  "output_channels": {
75
- "_comment": "Per-event payload fields, captured from live firings. `fields` here are the STDIN keys the event delivers — Codex's documented output channels (additionalContext and a decision object) are not yet measured, so `decision` stays null and `system_message` null rather than false.",
75
+ "_comment": "Per-event channels (generation spec, contract 17, as amended 2026-10-15). `fields` are the hookSpecificOutput keys the event honours — the output side, as on every manifest; `input_fields` are the stdin keys it delivers, captured from live firings (759 on 2026-09-07/08; Run 4 of *Codex hook contract measured in a live session*, codex-cli 0.162.1, 2026-10-15, traces t1–t7). `decision` names the decision object where one exists; Stop's `carrier` is the shape the entry reminder's Stop carrier prints (additionalContext | block | null). `system_message` stays null: documented, not measured. `evidence` is about the output side.",
76
76
  "SessionStart": {
77
- "fields": [
77
+ "input_fields": [
78
78
  "session_id",
79
79
  "transcript_path",
80
80
  "cwd",
@@ -83,13 +83,16 @@
83
83
  "permission_mode",
84
84
  "source"
85
85
  ],
86
+ "fields": [
87
+ "additionalContext"
88
+ ],
86
89
  "system_message": null,
87
90
  "decision": null,
88
91
  "evidence": "measured",
89
- "note": "additionalContext reaches the model: asked to name the vault and count its ADRs without reading files, the model answered from the skeleton and was right."
92
+ "note": "additionalContext reaches the model: asked to name the vault and count its ADRs without reading files, the model answered from the skeleton and was right; Run 4 saw it as a developer-role message at `startup` and again after `compact` (t1, t2:7)."
90
93
  },
91
94
  "UserPromptSubmit": {
92
- "fields": [
95
+ "input_fields": [
93
96
  "session_id",
94
97
  "turn_id",
95
98
  "transcript_path",
@@ -99,12 +102,16 @@
99
102
  "permission_mode",
100
103
  "prompt"
101
104
  ],
105
+ "fields": [
106
+ "additionalContext"
107
+ ],
102
108
  "system_message": null,
103
109
  "decision": null,
104
- "evidence": "measured"
110
+ "evidence": "measured",
111
+ "note": "additionalContext reaches the model (Run 4, t1). Not registered by projectstore."
105
112
  },
106
113
  "PreToolUse": {
107
- "fields": [
114
+ "input_fields": [
108
115
  "session_id",
109
116
  "turn_id",
110
117
  "transcript_path",
@@ -116,13 +123,16 @@
116
123
  "tool_input",
117
124
  "tool_use_id"
118
125
  ],
126
+ "fields": [
127
+ "additionalContext"
128
+ ],
119
129
  "system_message": null,
120
130
  "decision": null,
121
- "evidence": "measured",
122
- "note": "372 firings. Codex has a dedicated PermissionRequest event, so whether a decision returned from PreToolUse is honoured is unmeasured and must not be assumed."
131
+ "evidence": "documented",
132
+ "note": "input_fields measured (372 firings; Run 4 t1:3); tool_name is `apply_patch` for edits and `Bash` for shell commands. Codex has a dedicated PermissionRequest event, so whether a decision returned from PreToolUse is honoured is unmeasured and must not be assumed."
123
133
  },
124
134
  "PostToolUse": {
125
- "fields": [
135
+ "input_fields": [
126
136
  "session_id",
127
137
  "turn_id",
128
138
  "transcript_path",
@@ -135,13 +145,16 @@
135
145
  "tool_response",
136
146
  "tool_use_id"
137
147
  ],
148
+ "fields": [
149
+ "additionalContext"
150
+ ],
138
151
  "system_message": null,
139
152
  "decision": null,
140
153
  "evidence": "measured",
141
- "note": "371 firings. tool_response is NOT one shape: 368 strings and 3 content-block arrays (all webrun). Claude Code sends an object. Nothing may depend on it."
154
+ "note": "371 firings. tool_response is NOT one shape: 368 strings and 3 content-block arrays (all webrun). Claude Code sends an object. Nothing may depend on it. additionalContext reaches the model (Run 4, t1); PostToolUse does not fire for a failed apply_patch (t1:5)."
142
155
  },
143
156
  "Stop": {
144
- "fields": [
157
+ "input_fields": [
145
158
  "session_id",
146
159
  "turn_id",
147
160
  "transcript_path",
@@ -152,12 +165,29 @@
152
165
  "stop_hook_active",
153
166
  "last_assistant_message"
154
167
  ],
168
+ "fields": [],
155
169
  "system_message": null,
156
- "decision": null,
170
+ "decision": "block",
171
+ "carrier": "block",
157
172
  "evidence": "measured",
158
- "note": "stop_hook_active is present, so hooks/session-stop.mjs's loop guard ports unchanged. last_assistant_message has no Claude Code counterpart."
173
+ "note": "Codex's Stop output schema denies unknown fields: hookSpecificOutput is refused with `hook: Stop Failed` (Run 4, t3c). decision:block with a reason is honoured: the reason enters the same turn as a user-role <hook_prompt> (rollout item HookPrompt), and Stop fires again with stop_hook_active true (t4:3, t4:4). In a headless `codex exec` the model's answer to it becomes the run's last message; another hook's continue:false takes precedence. last_assistant_message has no Claude Code counterpart."
159
174
  },
160
- "PreCompact": null
175
+ "PreCompact": {
176
+ "input_fields": [
177
+ "session_id",
178
+ "turn_id",
179
+ "transcript_path",
180
+ "cwd",
181
+ "hook_event_name",
182
+ "model",
183
+ "trigger"
184
+ ],
185
+ "fields": [],
186
+ "system_message": null,
187
+ "decision": null,
188
+ "evidence": "documented",
189
+ "note": "trigger is `auto` on an automatic compaction, and PostCompact follows (Run 4, t2:5–6). No permission_mode. Whether a systemMessage reaches anyone on Codex is unmeasured."
190
+ }
161
191
  },
162
192
  "surfaces": {
163
193
  "agents": {
@@ -25,6 +25,7 @@ import {
25
25
  AGENT_PREFIX,
26
26
  LINGER_MS,
27
27
  countsText,
28
+ focusRoots,
28
29
  goalForms,
29
30
  goalOf,
30
31
  heartbeatFile,
@@ -98,7 +99,7 @@ type Band = {
98
99
  shownKey: string
99
100
  stories: Map<string, { sig: string } & Stories>
100
101
  shipped: Map<string, Json>
101
- place: { vault: string | null; kinds: string[] }
102
+ place: { vault: string | null; kinds: string[]; roots: string[] }
102
103
  }
103
104
 
104
105
  function newBand(): Band {
@@ -112,7 +113,7 @@ function newBand(): Band {
112
113
  shownKey: '',
113
114
  stories: new Map(),
114
115
  shipped: new Map(),
115
- place: { vault: null, kinds: [] },
116
+ place: { vault: null, kinds: [], roots: [] },
116
117
  }
117
118
  }
118
119
 
@@ -260,7 +261,7 @@ async function scan($: EngineInterface, b: Band, root: string, sid: string | nul
260
261
  const vault = typeof binding.vault_path === 'string' ? binding.vault_path : null
261
262
  if (!vault) return null
262
263
  const lay = await layoutOf($, b, typeof binding.layout === 'string' ? binding.layout : 'engineering')
263
- b.place = { vault, kinds: lay.kinds }
264
+ b.place = { vault, kinds: lay.kinds, roots: [vault] }
264
265
  const band: Json = binding.band && typeof binding.band === 'object' ? binding.band : {}
265
266
  if (band.enabled === false || !lay.epics) return null
266
267
  const lang = typeof binding.language === 'string' ? binding.language : 'en'
@@ -273,14 +274,24 @@ async function scan($: EngineInterface, b: Band, root: string, sid: string | nul
273
274
  if (pointer === UNREADABLE) return null
274
275
  const own = pointer && isEpicName(pointer.active_epic) ? (pointer.active_epic as string) : null
275
276
  const pin = isEpicName(band.epic) ? (band.epic as string) : null
276
- let focus: { id: string; why: 'this' | 'pinned' } | null = null
277
- if (own && (await isDir($, `${vault}/${lay.epics}/${own}`))) focus = { id: own, why: 'this' }
278
- else if (pin && (await isDir($, `${vault}/${lay.epics}/${pin}`))) focus = { id: pin, why: 'pinned' }
277
+ // This session's epic is read from the copy of the vault its story lives in
278
+ // (the pointer's story_root, a worktree) when that copy has the epic, else
279
+ // from the vault; the pin always from the vault (issue #87).
280
+ const roots = focusRoots(pointer as Json | null, vault)
281
+ b.place = { vault, kinds: lay.kinds, roots }
282
+ let focus: { id: string; why: 'this' | 'pinned'; base: string } | null = null
283
+ if (own)
284
+ for (const base of roots)
285
+ if (await isDir($, `${base}/${lay.epics}/${own}`)) {
286
+ focus = { id: own, why: 'this', base }
287
+ break
288
+ }
289
+ if (!focus && pin && (await isDir($, `${vault}/${lay.epics}/${pin}`))) focus = { id: pin, why: 'pinned', base: vault }
279
290
  if (!focus) return null
280
291
 
281
292
  // The focused epic's epic.md that cannot be read draws nothing: never the
282
293
  // pin in its place.
283
- const epicDir = `${vault}/${lay.epics}/${focus.id}`
294
+ const epicDir = `${focus.base}/${lay.epics}/${focus.id}`
284
295
  const text = await readText($, `${epicDir}/epic.md`)
285
296
  if (text === null) return null
286
297
  const fm = parseFrontmatter(text).data as Json
@@ -594,7 +605,7 @@ export const register: Register = on => {
594
605
  try {
595
606
  const agent = (await read($, crew)).find(a => a.id === id)
596
607
  const v = agent?.status === 'running' ? await read($, view) : null
597
- const step = agent?.status === 'running' && v ? stepOf(e as unknown as Json, b.place.vault, v.words) : null
608
+ const step = agent?.status === 'running' && v ? stepOf(e as unknown as Json, b.place.roots, v.words) : null
598
609
  if (step && (await read($, steps))[id] !== step) await update($, steps, d => ({ ...d, [id]: step }))
599
610
  } catch {
600
611
  // A step the band failed to note never holds up the tool.
@@ -6,11 +6,11 @@
6
6
  "hooks": [
7
7
  {
8
8
  "type": "command",
9
- "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/session-start.mjs"
9
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-start.mjs\""
10
10
  },
11
11
  {
12
12
  "type": "command",
13
- "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/session-rules.mjs"
13
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-rules.mjs\""
14
14
  }
15
15
  ]
16
16
  }
@@ -20,7 +20,7 @@
20
20
  "hooks": [
21
21
  {
22
22
  "type": "command",
23
- "command": "node ${CLAUDE_PLUGIN_ROOT}/scripts/touch-session.mjs"
23
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/touch-session.mjs\""
24
24
  }
25
25
  ]
26
26
  }
@@ -31,7 +31,7 @@
31
31
  "hooks": [
32
32
  {
33
33
  "type": "command",
34
- "command": "node ${CLAUDE_PLUGIN_ROOT}/scripts/touch-session.mjs"
34
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/touch-session.mjs\""
35
35
  }
36
36
  ]
37
37
  }
@@ -41,7 +41,7 @@
41
41
  "hooks": [
42
42
  {
43
43
  "type": "command",
44
- "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/session-stop.mjs"
44
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-stop.mjs\""
45
45
  }
46
46
  ]
47
47
  }
@@ -51,7 +51,7 @@
51
51
  "hooks": [
52
52
  {
53
53
  "type": "command",
54
- "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/pre-compact.mjs"
54
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pre-compact.mjs\""
55
55
  }
56
56
  ]
57
57
  }
@@ -25,6 +25,7 @@ import {
25
25
  adoptHookInput,
26
26
  projectRoot,
27
27
  layoutPaths,
28
+ cachePaths,
28
29
  pickExisting,
29
30
  activeHarnessId,
30
31
  ensureStateRoot,
@@ -46,31 +47,48 @@ import {
46
47
  import { runStartupChecks } from "../scripts/doctor.mjs";
47
48
  import { resolveBinding, bindingOfferText } from "../scripts/worktree.mjs";
48
49
 
49
- // The marker lives under the harness's state directory; a legacy marker at
50
- // .claude/.projectstore-welcomed still counts while the window is open.
51
- function welcomedMarkerPath(proj) {
52
- const p = layoutPaths(proj);
53
- return pickExisting(p.welcomed(activeHarnessId()), p.legacy.welcomed);
50
+ // The welcome has been seen, by this harness, when either marker exists: the
51
+ // project's (state/<id>/welcomed, or the legacy .claude/.projectstore-welcomed
52
+ // while the window is open) or the machine's (the user-level cache's
53
+ // welcomed/<id>). Both branches read both; only the write follows the binding
54
+ // (the story "Opening a project that is not bound writes nothing into it…",
55
+ // decisions 1 and 2). So a project that holds a marker an unbound session of
56
+ // 0.30.5 or earlier wrote is not welcomed again.
57
+ //
58
+ // The machine's marker is null with no cache root, and a home the platform
59
+ // cannot answer for must not break startup either (skeleton spec, contract 17).
60
+ function machineMarker(id) {
61
+ try {
62
+ return cachePaths().welcomed(id);
63
+ } catch {
64
+ return null;
65
+ }
54
66
  }
55
- function welcomedMarkerWritePath(proj) {
56
- return layoutPaths(proj).welcomed(activeHarnessId());
67
+ function welcomeSeen(proj, id) {
68
+ const p = layoutPaths(proj);
69
+ const machine = machineMarker(id);
70
+ return existsSync(pickExisting(p.welcomed(id), p.legacy.welcomed)) || (machine !== null && existsSync(machine));
57
71
  }
58
72
 
59
- // One-time orientation packet shown when projectstore first loads in a project.
60
- // Idempotent via a marker file at <project>/.claude/.projectstore-welcomed.
61
- // The welcome fires once per project, and a project can already be bound when
62
- // it does — a fresh install into a project someone bound first is the ordinary
73
+ // One-time orientation packet shown when projectstore first loads. A bound
74
+ // project shows it once per project; a project that is not bound shows it once
75
+ // on this machine, because a user-scope install is on in every repository the
76
+ // person opens, and writing a marker into each of them is a write into
77
+ // projects nobody set up. A project can already be bound when the welcome
78
+ // fires — a fresh install into a project someone bound first is the ordinary
63
79
  // case, not a corner (measured 2026-09-06: the first line a new user read told
64
80
  // them to bind a project whose vault the install preview had just named). So
65
- // the one instruction it carries branches on the binding; everything else is
66
- // the same message.
81
+ // the one instruction it carries branches on the binding, and so does the
82
+ // footer that says how often it appears; everything else is the same message.
67
83
  function buildWelcome(cfg = null) {
68
84
  const start =
69
85
  cfg && cfg.vault_path
70
86
  ? `**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.`
71
87
  : `**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.`;
72
88
  return [
73
- "# 👋 projectstore is loaded for the first time in this project",
89
+ cfg
90
+ ? "# 👋 projectstore is loaded for the first time in this project"
91
+ : "# 👋 projectstore is loaded for the first time on this machine",
74
92
  "",
75
93
  "**What it does**: turns the conversation's decisions into a structured Obsidian-friendly markdown vault — ADRs, epics, stories, runbooks, research. Agent-maintained, you approve every write.",
76
94
  "",
@@ -80,7 +98,7 @@ function buildWelcome(cfg = null) {
80
98
  // a git marketplace's toggle means nothing to a harness installed from npm.
81
99
  ...updateHint().welcome,
82
100
  "",
83
- "_This message appears once per project._",
101
+ cfg ? "_This message appears once per project._" : "_This message appears once on this machine._",
84
102
  "",
85
103
  "_If projectstore helps you ship, a [GitHub star](https://github.com/SmartAndPoint/ProjectStore) helps others discover it. No pressure._",
86
104
  "",
@@ -88,17 +106,30 @@ function buildWelcome(cfg = null) {
88
106
  }
89
107
 
90
108
  function showWelcomeOnce(proj, cfg = null) {
91
- if (existsSync(welcomedMarkerPath(proj))) return "";
109
+ const id = activeHarnessId();
110
+ if (welcomeSeen(proj, id)) return "";
92
111
  const text = buildWelcome(cfg);
93
112
  try {
94
- const marker = welcomedMarkerWritePath(proj);
95
- // The state directory's own .gitignore, header included, even in an
96
- // unbound project — and no sessions/ there: it is how a project uninstall
97
- // recognises this marker as ours (install spec contract 13, as amended by
98
- // the shell-fetch story).
99
- ensureStateRoot(proj);
100
- mkdirSync(dirname(marker), { recursive: true });
101
- writeFileSync(marker, new Date().toISOString() + "\n", "utf8");
113
+ let marker = null;
114
+ if (cfg) {
115
+ // Bound: the project's marker, beside the launcher (layout spec contract
116
+ // 5). The state directory's own .gitignore comes with it, header
117
+ // included, and no sessions/: it is how a project uninstall recognises
118
+ // this marker as ours (install spec contract 13, as amended by the
119
+ // shell-fetch story).
120
+ ensureStateRoot(proj);
121
+ marker = layoutPaths(proj).welcomed(id);
122
+ } else {
123
+ // Not bound: the machine's marker, and nothing in the project. With no
124
+ // cache root (no usable home) nothing is written and the welcome shows
125
+ // again next time — startup never breaks over it (skeleton spec,
126
+ // contract 17).
127
+ marker = machineMarker(id);
128
+ }
129
+ if (marker) {
130
+ mkdirSync(dirname(marker), { recursive: true });
131
+ writeFileSync(marker, new Date().toISOString() + "\n", "utf8");
132
+ }
102
133
  } catch {}
103
134
  return text;
104
135
  }
@@ -8,20 +8,31 @@
8
8
  // hands the writing to subagents — whose calls count toward the score but are
9
9
  // never the audience — and then answers the user. Stop fires exactly there.
10
10
  //
11
- // Two things about Stop that shape this file:
11
+ // What Stop may print is the harness's, read from its manifest
12
+ // (output_channels.Stop.carrier — generation spec, contract 17; entry spec,
13
+ // contract 14, as amended 2026-10-15):
12
14
  //
13
- // * `additionalContext` on Stop CONTINUES the turn. So this hook must run the
14
- // same create-then-emit election as the PostToolUse branch, not merely read
15
- // the markers: a guard that only checks would emit, the turn would continue,
16
- // the model would finish again, Stop would fire again — forever. Creating
17
- // the marker before emitting is what makes the second firing impossible.
18
- // * `stop_hook_active` is set while a Stop hook is already driving the turn.
19
- // Honouring it is belt and braces; the election is the load-bearing guard.
15
+ // * Claude Code (2.1.163+) honours `additionalContext` on Stop, and it
16
+ // CONTINUES the turn: the reminder arrives as context.
17
+ // * Codex refuses `hookSpecificOutput` on Stop — its Stop schema denies
18
+ // unknown fields, and the hook reads `Failed` — and honours only a block
19
+ // decision, whose reason enters the same turn as a user-role hook prompt
20
+ // (measured 2026-10-15, codex-cli 0.162.1).
21
+ // * The carrier comes from the harness that IDENTIFIES itself (forced, a
22
+ // parent's decision, a strong signal or a session marker); nothing
23
+ // identified, or no carrier measured, is silence. It is read before the
24
+ // vault and before the election, so a harness that cannot carry the
25
+ // reminder never spends it.
26
+ //
27
+ // Either channel continues the turn, so the model finishes again and Stop
28
+ // fires again. That is why this hook runs the same create-then-emit election
29
+ // as the PostToolUse branch, not merely a read of the markers: creating the
30
+ // marker before emitting is what makes the second firing impossible.
31
+ // `stop_hook_active` (true on the continued Stop — measured on Codex) is belt
32
+ // and braces; the election is the load-bearing guard.
20
33
  //
21
34
  // Not SubagentStop: it fires once per subagent and its continuation belongs to
22
35
  // the subagent, which is the actor that cannot open a story.
23
- //
24
- // Requires Claude Code 2.1.163+, where additionalContext on Stop is honoured.
25
36
 
26
37
  import {
27
38
  readConfig,
@@ -37,6 +48,8 @@ import {
37
48
  appendEntryLog,
38
49
  entryReminderText,
39
50
  ENTRY_THRESHOLD,
51
+ stopCarrier,
52
+ readSessionState,
40
53
  } from "../scripts/lib.mjs";
41
54
 
42
55
  async function main() {
@@ -46,6 +59,10 @@ async function main() {
46
59
  // — fd 0 is empty on a second read.
47
60
  const input = adoptHookInput(readStdinJson());
48
61
 
62
+ // Which shape this harness accepts, before anything is read or written.
63
+ const carrier = stopCarrier();
64
+ if (!carrier) return;
65
+
49
66
  const cfg = readConfig();
50
67
  if (!cfg || cfg.guard === "off") return;
51
68
 
@@ -62,7 +79,8 @@ async function main() {
62
79
 
63
80
  let verdict = readOpenStoryCache(proj, sid);
64
81
  if (verdict === null) {
65
- verdict = await resolveOpenStory(cfg.vault_path);
82
+ // The session's own worktree copy counts too (its pointer's story_root).
83
+ verdict = await resolveOpenStory(cfg.vault_path, { storyRoot: readSessionState(proj, sid)?.story_root });
66
84
  writeOpenStoryCache(proj, sid, verdict);
67
85
  }
68
86
  // Nothing written to stdout yet, so exiting here cannot truncate anything.
@@ -70,15 +88,20 @@ async function main() {
70
88
  if (verdict !== false) return;
71
89
 
72
90
  if (!electEmitter(proj, sid)) return;
73
- appendEntryLog(proj, { at: new Date().toISOString(), session_id: sid, score, via: "Stop" });
74
- process.stdout.write(
75
- JSON.stringify({
76
- hookSpecificOutput: {
77
- hookEventName: "Stop",
78
- additionalContext: entryReminderText(score),
79
- },
80
- }) + "\n",
81
- );
91
+ appendEntryLog(proj, { at: new Date().toISOString(), session_id: sid, score, via: "Stop", channel: carrier });
92
+ const text = entryReminderText(score);
93
+ // A block prints the bytes Codex honoured — one object, no newline (Run 4,
94
+ // t4); context prints what it always printed.
95
+ if (carrier === "block") process.stdout.write(JSON.stringify({ decision: "block", reason: text }));
96
+ else
97
+ process.stdout.write(
98
+ JSON.stringify({
99
+ hookSpecificOutput: {
100
+ hookEventName: "Stop",
101
+ additionalContext: text,
102
+ },
103
+ }) + "\n",
104
+ );
82
105
  }
83
106
 
84
107
  main().catch(() => {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectstore",
3
- "version": "0.30.4",
3
+ "version": "0.30.6",
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",
@@ -17,7 +17,7 @@
17
17
  "agentic",
18
18
  "ai-agents"
19
19
  ],
20
- "homepage": "https://github.com/SmartAndPoint/ProjectStore#readme",
20
+ "homepage": "https://projectstore.dev",
21
21
  "bugs": {
22
22
  "url": "https://github.com/SmartAndPoint/ProjectStore/issues"
23
23
  },
@@ -60,21 +60,20 @@
60
60
  "scripts": {
61
61
  "test": "node --test --import ./tests/fixtures/hermetic.mjs tests/*.test.mjs",
62
62
  "guard": "node scripts/version-guard.mjs",
63
+ "directory:check": "node packaging/directory-guard.mjs",
63
64
  "packlist": "node scripts/version-guard.mjs --write-packlist",
64
65
  "adapters": "node scripts/build-adapters.mjs --write",
65
66
  "adapters:check": "node scripts/build-adapters.mjs --check",
66
67
  "shells": "node packaging/shells.mjs --write",
67
68
  "shells:check": "node packaging/shells.mjs --check",
68
69
  "shells:build": "node packaging/shells.mjs --build --out dist",
69
- "release:check": "npm run adapters:check && npm run guard && npm run shells:check && npm run lint && npm run format:check && npm test && npm pack --dry-run",
70
- "lint": "eslint --max-warnings 0 bin scripts \"hooks/*.mjs\" \"packaging/**/*.mjs\" \"tests/**/*.mjs\" eslint.config.mjs",
71
- "format": "prettier --write bin scripts \"hooks/*.mjs\" \"packaging/**/*.mjs\" \"tests/**/*.mjs\" eslint.config.mjs",
72
- "format:check": "prettier --check bin scripts \"hooks/*.mjs\" \"packaging/**/*.mjs\" \"tests/**/*.mjs\" eslint.config.mjs"
73
- },
74
- "devDependencies": {
75
- "@eslint/js": "10.0.1",
76
- "eslint": "10.12.0",
77
- "eslint-plugin-n": "18.4.1",
78
- "prettier": "3.9.10"
70
+ "release:check": "npm run adapters:check && npm run guard && npm run directory:check && npm run shells:check && npm run lint && npm run format:check && npm test && npm pack --dry-run",
71
+ "lint:setup": "npm ci --prefix tools/lint",
72
+ "prelint": "node tools/lint/setup-check.mjs",
73
+ "preformat": "node tools/lint/setup-check.mjs",
74
+ "preformat:check": "node tools/lint/setup-check.mjs",
75
+ "lint": "node tools/lint/node_modules/eslint/bin/eslint.js --max-warnings 0 bin scripts \"hooks/*.mjs\" \"packaging/**/*.mjs\" \"tests/**/*.mjs\" eslint.config.mjs tools/lint/eslint.config.mjs tools/lint/setup-check.mjs",
76
+ "format": "node tools/lint/node_modules/prettier/bin/prettier.cjs --write bin scripts \"hooks/*.mjs\" \"packaging/**/*.mjs\" \"tests/**/*.mjs\" eslint.config.mjs tools/lint/eslint.config.mjs tools/lint/setup-check.mjs",
77
+ "format:check": "node tools/lint/node_modules/prettier/bin/prettier.cjs --check bin scripts \"hooks/*.mjs\" \"packaging/**/*.mjs\" \"tests/**/*.mjs\" eslint.config.mjs tools/lint/eslint.config.mjs tools/lint/setup-check.mjs"
79
78
  }
80
79
  }
@@ -383,7 +383,21 @@ export function phraseOf(role, subject, description, w) {
383
383
  }
384
384
 
385
385
  const lastSegment = (p) => String(p).replace(/\/+$/, "").split("/").pop();
386
- const vaultRel = (vault, p) => (vault && p.startsWith(`${vault}/`) ? p.slice(vault.length + 1) : lastSegment(p));
386
+ // A path inside one of the roots (a worktree's copy of the vault first, then
387
+ // the vault) reads as its path in there; anything else as its last segment.
388
+ const vaultRel = (roots, p) => {
389
+ for (const r of [].concat(roots || [])) if (r && p.startsWith(`${r}/`)) return p.slice(r.length + 1);
390
+ return lastSegment(p);
391
+ };
392
+
393
+ // The roots this session's paths are read against: the copy of the vault its
394
+ // pointer's story lives in (story_root, a worktree — PS-PRESENCE, issue #87),
395
+ // then the bound vault. The band never resolves git itself; it reads this.
396
+ export function focusRoots(pointer, vault) {
397
+ const own = pointer && typeof pointer.story_root === "string" && pointer.story_root ? pointer.story_root : null;
398
+ if (!vault) return own ? [own] : [];
399
+ return own && own !== vault ? [own, vault] : [vault];
400
+ }
387
401
 
388
402
  // `node /x/reconcile.mjs --a` reads `reconcile`, `git diff --stat` reads `git diff`.
389
403
  export function commandWord(command) {
@@ -401,7 +415,8 @@ export function commandWord(command) {
401
415
  const QUIET = /^(echo|printf|true|sleep|:)(\s|$)/;
402
416
 
403
417
  // An agent's last step from its latest tool call, or null when the call says
404
- // nothing worth a row (an echo, a tool the band does not name).
418
+ // nothing worth a row (an echo, a tool the band does not name). `vault` is the
419
+ // vault path or the roots focusRoots gives.
405
420
  export function stepOf(call, vault, w) {
406
421
  const tool = typeof call.tool === "string" ? call.tool : "";
407
422
  const path =
@@ -116,6 +116,9 @@ export function applyBand(plan) {
116
116
  return { wrote: true };
117
117
  }
118
118
 
119
+ const SESSION_HINT =
120
+ "a Read or an edit of a story file through a file tool sets this session's epic and story; edits made through the shell are not seen";
121
+
119
122
  const REASONS = Object.freeze({
120
123
  harness: "this harness has no band",
121
124
  off: "switched off (projectstore band on)",
@@ -135,11 +138,20 @@ export function bandStatus(projectDir, cfg, { env = process.env, now = Date.now(
135
138
  const keys = bandKeys(cfg);
136
139
  const sessionId = hostSessionId(env, harness);
137
140
  let sessionEpic = null;
141
+ let sessionStory = null;
142
+ let sessionRoot = null;
138
143
  let heartbeat = "none";
139
144
  if (sessionId) {
140
145
  try {
141
146
  const p = JSON.parse(readFileSync(sessionStatePath(projectDir, sessionId), "utf8"));
142
- if (p && isEpicName(p.active_epic)) sessionEpic = p.active_epic;
147
+ if (p && isEpicName(p.active_epic)) {
148
+ sessionEpic = p.active_epic;
149
+ if (typeof p.active_story === "string" && p.active_story)
150
+ sessionStory = typeof p.story_title === "string" && p.story_title ? p.story_title : p.active_story;
151
+ // The copy of the vault the story lives in, when it is a worktree's.
152
+ // A removed worktree is not named: the band and the refresh fall back too.
153
+ if (typeof p.story_root === "string" && p.story_root && existsSync(p.story_root)) sessionRoot = p.story_root;
154
+ }
143
155
  } catch {}
144
156
  const name = heartbeatFile(sessionId);
145
157
  if (name) {
@@ -169,6 +181,11 @@ export function bandStatus(projectDir, cfg, { env = process.env, now = Date.now(
169
181
  pin_exists: pinExists,
170
182
  session_id: sessionId,
171
183
  session_epic: sessionEpic,
184
+ session_story: sessionStory,
185
+ session_root: sessionRoot,
186
+ // How a session gets its epic, whenever it has none — pinned or not
187
+ // (issue #87 had a pin and no word on how to set its own).
188
+ session_hint: sessionId && !sessionEpic ? SESSION_HINT : null,
172
189
  heartbeat,
173
190
  drawing,
174
191
  yielding: drawing,
@@ -180,7 +197,8 @@ export function bandStatus(projectDir, cfg, { env = process.env, now = Date.now(
180
197
  export function renderBandStatus(s) {
181
198
  const lines = [
182
199
  `band: ${s.enabled ? "on" : "off"}${s.pin ? ` · pinned ${s.pin}${s.pin_exists ? "" : " (missing)"}` : ""}`,
183
- `session: ${s.session_id ? `${s.session_id.slice(0, 8)}… · ${s.session_epic ? `epic ${s.session_epic}` : "no epic of its own"}` : "session unknown"}`,
200
+ `session: ${s.session_id ? `${s.session_id.slice(0, 8)}… · ${s.session_epic ? `epic ${s.session_epic}${s.session_story ? ` · story ${s.session_story}` : ""}${s.session_root ? ` · in ${s.session_root}` : ""}` : "no epic of its own"}` : "session unknown"}`,
201
+ ...(s.session_hint ? [` ${s.session_hint}`] : []),
184
202
  `heartbeat: ${s.heartbeat} · the status line ${s.yielding ? "is giving way to the band" : "shows its own line"}`,
185
203
  ];
186
204
  if (s.reason_text) lines.push(`not drawing: ${s.reason_text}`);
@@ -20,7 +20,7 @@
20
20
  // project's decisions, not the vault's.
21
21
  //
22
22
  // Never reads ambient cwd or ambient env: the caller resolves the project
23
- // (cli.mjs's resolveProject) and supplies the author. Normative: the CLI
23
+ // (verbs.mjs's resolveProject) and supplies the author. Normative: the CLI
24
24
  // story's slice-2 plan (PS-CORE); the fresh config's keys mirror
25
25
  // commands/bind.md step 3 (a test pins the two together).
26
26
  //