projectstore-claude 0.30.4 โ†’ 0.30.5

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.
@@ -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.30.4",
15
+ "version": "0.30.5",
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.30.4",
4
+ "version": "0.30.5",
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",
@@ -76,6 +76,13 @@ corrupt pointer shows an error marker, never a false "no work". The renderer als
76
76
  drops a `.last-render.json` breadcrumb so `doctor` can detect session-id
77
77
  divergence between the status line process and the hooks.
78
78
 
79
+ A session that works the vault through **git worktrees** โ€” a `.worktrees/x/`
80
+ folder inside it, or a checkout elsewhere โ€” gets the same pointer: the hooks
81
+ recognise any worktree of the vault's repository by reading git's own files, and
82
+ the pointer remembers which copy the story lives in, so the band and the status
83
+ line read the story there until it is merged and closed on the bound vault.
84
+ Edits made through the shell are not seen; a Read of the story sets the pointer.
85
+
79
86
  ## Surviving `/compact`
80
87
 
81
88
  Continuity across a compaction is delivered by **SessionStart**, not by
@@ -115,7 +115,7 @@
115
115
  "_root_placeholder_literal_comment": "true = the harness passes the placeholder through as literal text and the installer must substitute an absolute path; false = the harness expands it. Claude Code expands ${CLAUDE_PLUGIN_ROOT}, so false. Measured value for Codex comes from the spike."
116
116
  },
117
117
  "output_channels": {
118
- "_comment": "Per-event output channels (generation spec, contract 17). `fields` are the hookSpecificOutput keys the event honours; `system_message` is the top-level systemMessage; `decision` names the decision object where one exists. `evidence` is `measured` only where this repository's own hooks observe the fact, `documented` where the Claude Code hooks reference says so and we never emit it.",
118
+ "_comment": "Per-event output channels (generation spec, contract 17). `fields` are the hookSpecificOutput keys the event honours; `system_message` is the top-level systemMessage; `decision` names the decision object where one exists; an optional `input_fields` lists the stdin keys; Stop's `carrier` is the shape the entry reminder's Stop carrier prints (additionalContext | block | null) โ€” our choice where both are honoured. `evidence` is `measured` only where this repository's own hooks observe the fact, `documented` where the Claude Code hooks reference says so and we never emit it.",
119
119
  "SessionStart": {
120
120
  "fields": [
121
121
  "additionalContext"
@@ -179,9 +179,10 @@
179
179
  ],
180
180
  "system_message": null,
181
181
  "decision": "block",
182
+ "carrier": "additionalContext",
182
183
  "evidence": "measured",
183
184
  "min_version": "2.1.163",
184
- "note": "additionalContext on Stop CONTINUES the turn (2.1.163 changelog) โ€” hooks/session-stop.mjs runs a create-then-emit election because of it. Below 2.1.163 only decision:block + reason exists."
185
+ "note": "additionalContext on Stop CONTINUES the turn (2.1.163 changelog) โ€” hooks/session-stop.mjs runs a create-then-emit election because of it. Below 2.1.163 only decision:block + reason exists. Both shapes are honoured here; the carrier is context, so the reminder reads as context, not as a new turn."
185
186
  },
186
187
  "SubagentStop": {
187
188
  "fields": [
@@ -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.
@@ -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.5",
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",
@@ -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}`);
@@ -102,6 +102,7 @@ import {
102
102
  packageCommand,
103
103
  invocation,
104
104
  harnessForOverlay,
105
+ activeHarness,
105
106
  } from "./harness.mjs";
106
107
  import { effortSpec } from "./models.mjs";
107
108
 
@@ -943,17 +944,22 @@ export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir()
943
944
  // 2026-10-03 amendment, finding 7).
944
945
  const remedy = layoutRemedy(proj, { root, home });
945
946
  // The block and its move are the source harness's โ€” a pre-0.28 binding
946
- // and that harness's command โ€” so its fact under any other listener.
947
+ // and that harness's command โ€” so its fact under any other listener. The
948
+ // move writes the source harness's overlay, whoever listens: since the
949
+ // overlay follows the shell that bundles the core (layout spec, contract 2,
950
+ // as amended 2026-10-15), the listener's `where` would name another file.
951
+ const src = sourceHarness();
952
+ const dest = relative(proj, layoutPaths(proj).overlay(src?.runtime?.overlay || src?.id));
947
953
  out.push(
948
954
  aboutHarness(
949
955
  finding(
950
956
  "install",
951
957
  "warn",
952
958
  "agents-in-binding",
953
- `${b} still carries an agents block โ€” since 0.28 the models live in ${where} (the layout ADR); nothing reads it there. ${remedyWords(remedy, proj, "Move it")}`,
959
+ `${b} still carries an agents block โ€” since 0.28 the models live in ${dest} (the layout ADR); nothing reads it there. ${remedyWords(remedy, proj, "Move it")}`,
954
960
  b,
955
961
  ),
956
- sourceHarness(),
962
+ src,
957
963
  speaker,
958
964
  ),
959
965
  );
@@ -1362,10 +1368,15 @@ export function checkOverrideCopies(proj, home = homedir(), harness = speakingHa
1362
1368
  // The variable names come from the manifest (runtime.agent_overrides); the
1363
1369
  // overlay is read only when one is set, so a clean startup reads nothing.
1364
1370
  export function checkEnvEffort(proj = null) {
1371
+ // The variable is the active harness's (agentOverrides keys on detection), so
1372
+ // the overlay it outranks is that harness's too โ€” not the listener's, which
1373
+ // in a shell's tree can be another harness (layout spec, contract 2, as
1374
+ // amended 2026-10-15).
1375
+ const h = activeHarness();
1365
1376
  return agentOverrides()
1366
1377
  .filter((o) => o.kind === "effort")
1367
1378
  .map((o) => {
1368
- const ov = proj ? readOverlayAt(proj) : null;
1379
+ const ov = proj ? readOverlayAt(proj, h?.runtime?.overlay || h?.id) : null;
1369
1380
  const set = ov
1370
1381
  ? [
1371
1382
  ...(ov.efforts.default ? ["agents.default.effort"] : []),
@@ -411,6 +411,46 @@ export function activeHarness(env = process.env, dir = MANIFEST_DIR) {
411
411
  return loadHarness(detectHarnessId(env, dir), dir) || sourceHarness(dir);
412
412
  }
413
413
 
414
+ // โ”€โ”€โ”€ The Stop carrier (generation spec, contract 17; entry spec, contract 14,
415
+ // as amended 2026-10-15) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
416
+ //
417
+ // The shape the entry reminder's Stop carrier prints, from the manifest of the
418
+ // harness that IDENTIFIES itself โ€” a forced harness, a parent's decision, a
419
+ // strong signal or a session marker. Never detection's cwd folder, weak ranking or source
420
+ // fallback: a wrong answer here is a hook the host refuses (Codex's Stop schema
421
+ // denies hookSpecificOutput, measured 2026-10-15), and a refused print would
422
+ // still spend the session's reminder. Nothing identified, or no carrier
423
+ // measured, is null: the hook stays silent.
424
+ export const STOP_CARRIERS = Object.freeze(["additionalContext", "block"]);
425
+ export function stopCarrier(env = process.env, dir = MANIFEST_DIR) {
426
+ const id = identifiedHarnessId(env, dir);
427
+ const carrier = id ? loadHarness(id, dir)?.output_channels?.Stop?.carrier : null;
428
+ return STOP_CARRIERS.includes(carrier) ? carrier : null;
429
+ }
430
+
431
+ // What is wrong with a manifest's Stop row, or null. The carrier is our choice
432
+ // and must be one its row supports: context needs `additionalContext` among the
433
+ // honoured fields, a block needs the block decision, and a carrier is only
434
+ // ever chosen from a measured row. Shared by the manifest check and its
435
+ // planted fixtures.
436
+ export function stopCarrierProblem(manifest) {
437
+ const row = manifest?.output_channels?.Stop;
438
+ if (row === null || row === undefined) return null;
439
+ if (typeof row !== "object") return "output_channels.Stop is not an object";
440
+ if (!("carrier" in row)) return "output_channels.Stop has no carrier (null when none is measured)";
441
+ if (![...STOP_CARRIERS, null].includes(row.carrier))
442
+ return `output_channels.Stop.carrier ${JSON.stringify(row.carrier)} is not one of ${STOP_CARRIERS.join(", ")} or null`;
443
+ if (![null, "block"].includes(row.decision))
444
+ return `output_channels.Stop.decision ${JSON.stringify(row.decision)} is not null or "block"`;
445
+ if (row.carrier === null) return null;
446
+ if (row.evidence !== "measured") return "output_channels.Stop.carrier is set on a row that is not measured";
447
+ if (row.carrier === "additionalContext" && !(row.fields || []).includes("additionalContext"))
448
+ return "output_channels.Stop.carrier is additionalContext, but its fields do not honour it";
449
+ if (row.carrier === "block" && row.decision !== "block")
450
+ return 'output_channels.Stop.carrier is block, but its decision is not "block"';
451
+ return null;
452
+ }
453
+
414
454
  // โ”€โ”€โ”€ Who a message is for (generation spec, contract 18) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
415
455
  //
416
456
  // The core prints for ONE harness at a time, and a command it names must be
@@ -765,9 +805,20 @@ export function shellHarnessId(env = process.env, dir = MANIFEST_DIR) {
765
805
 
766
806
  // The overlay a harness reads: <project>/.projectstore/harness/<overlay>.json โ€”
767
807
  // the manifest's runtime.overlay, the harness id by convention (the layout ADR,
768
- // decision 3). Null only when no manifest at all can be found.
769
- export function overlayId(env = process.env, dir = MANIFEST_DIR) {
770
- const h = activeHarness(env, dir);
808
+ // decision 3). Whose (layout spec, contract 2, as amended 2026-10-15): the
809
+ // harness that identifies itself; else the shell that bundles this core; else
810
+ // the shell the environment names; else detectHarnessId's answer (the cwd's
811
+ // harness folder, the weak ranking, the source harness). Contract 18's middle
812
+ // rungs with detection's tail: a rendered skill's exec tool carries no variable
813
+ // of its harness, and the cwd's folder would answer for it. --harness stays the
814
+ // caller's (cli.mjs runAgents). Null only when no manifest at all can be found.
815
+ export function overlayId(env = process.env, dir = MANIFEST_DIR, { core } = {}) {
816
+ const id =
817
+ identifiedHarnessId(env, dir) ||
818
+ bundlingShellHarnessId({ core, dir }) ||
819
+ shellHarnessId(env, dir) ||
820
+ detectHarnessId(env, dir);
821
+ const h = loadHarness(id, dir) || sourceHarness(dir);
771
822
  return h?.runtime?.overlay || h?.id || null;
772
823
  }
773
824
 
@@ -24,7 +24,7 @@ import { join, dirname, basename, resolve, relative, isAbsolute } from "node:pat
24
24
  import { fileURLToPath } from "node:url";
25
25
  import { hostname, homedir } from "node:os";
26
26
  import { createHash, randomUUID } from "node:crypto";
27
- import { parseFrontmatter } from "./stages.mjs";
27
+ import { parseFrontmatter, stageOf } from "./stages.mjs";
28
28
  import { storyKeyOf } from "./band-shared.mjs";
29
29
  import {
30
30
  projectRoot as harnessProjectRoot,
@@ -53,6 +53,7 @@ import {
53
53
  packageCommand,
54
54
  whichOnPath,
55
55
  hostSessionId,
56
+ stopCarrier,
56
57
  } from "./harness.mjs";
57
58
 
58
59
  // โ”€โ”€โ”€ Paths โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@@ -184,6 +185,7 @@ export {
184
185
  overlayId,
185
186
  speakingHarness,
186
187
  whichOnPath,
188
+ stopCarrier,
187
189
  };
188
190
 
189
191
  // A hook's payload carries the `cwd` of the session that fired it, and on a
@@ -2581,10 +2583,16 @@ export async function gatherVaultFacts(cfg, opts = {}) {
2581
2583
  return;
2582
2584
  }
2583
2585
  const entries = await readActivityAsync(vault, sessionId, readFile);
2584
- const rel = entries
2585
- .filter((e) => e && typeof e.path === "string" && isInsideVault(e.path, vault))
2586
- .map((e) => e.path.slice(vault.length + 1))
2587
- .filter(Boolean);
2586
+ // Through the vault's worktrees too: a worktree's copy reads as its path
2587
+ // inside the vault, once, newest first.
2588
+ const rel = [
2589
+ ...new Set(
2590
+ entries
2591
+ .filter((e) => e && typeof e.path === "string")
2592
+ .map((e) => vaultLocation(e.path, vault)?.rel)
2593
+ .filter(Boolean),
2594
+ ),
2595
+ ];
2588
2596
  continuity.paths = rel;
2589
2597
  continuity.total = rel.length;
2590
2598
  continuity.artifact = resolveInFlightArtifact(entries, layout, vault);
@@ -2983,8 +2991,10 @@ export function resolveInFlightArtifact(activity, layout, vaultPath) {
2983
2991
  // the newest one โ€” no sort, and no second definition of "newest".
2984
2992
  for (const e of activity) {
2985
2993
  if (!e || !e.path || !isWriteTool(e.tool)) continue;
2986
- if (!isInsideVault(e.path, root)) continue;
2987
- const rel = e.path.slice(root.length + 1);
2994
+ // A worktree's copy counts as the vault, by its path inside that copy.
2995
+ const loc = vaultLocation(e.path, root);
2996
+ if (!loc || !loc.rel) continue;
2997
+ const rel = loc.rel;
2988
2998
  if (folders.some((p) => rel === p || rel.startsWith(p + "/"))) return rel;
2989
2999
  }
2990
3000
  return null;
@@ -2996,6 +3006,132 @@ export function isInsideVault(filePath, vaultPath) {
2996
3006
  return norm === vaultPath || norm.startsWith(vaultPath + "/");
2997
3007
  }
2998
3008
 
3009
+ // โ”€โ”€โ”€ Where a path sits in the vault, worktrees included โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
3010
+ //
3011
+ // (PS-PRESENCE: "A session working in a worktree of the vault gets its epic
3012
+ // and story on the bandโ€ฆ", issue #87.) A vault edited only through git
3013
+ // worktrees โ€” `<vault>/.worktrees/x/` or a checkout elsewhere โ€” was invisible
3014
+ // to every check that matched a prefix on vault_path. vaultLocation answers
3015
+ // `{ root, rel }`: the copy of the vault that holds the file (the bound
3016
+ // checkout, or a worktree's copy) and the path inside it; null when the file
3017
+ // is not in the vault.
3018
+ //
3019
+ // The prefix still answers first and is never taken away: a path under
3020
+ // vault_path is in the vault as today, nested foreign repositories included.
3021
+ // The resolver only ADDS placements, by reading git's own files on disk โ€” it
3022
+ // runs on every tool call, so it spawns nothing:
3023
+ // * a `.git` directory is its own common dir;
3024
+ // * a `.git` file says `gitdir: <path>` (relative to the file's directory for
3025
+ // submodules and worktree.useRelativePaths); the gitdir's `commondir`
3026
+ // (relative to the gitdir) names the common dir, and without one โ€” a
3027
+ // --separate-git-dir checkout, a submodule โ€” the gitdir is the common dir;
3028
+ // * both sides go through realpath: git writes /private/tmp where a vault may
3029
+ // be bound as /tmp or through a symlink.
3030
+ // A worktree belongs to the vault when its common dir is the vault's AND the
3031
+ // file lies under the worktree's copy of the vault's place in its repository
3032
+ // (`vaultSub`): a vault kept as a folder of the project's repository shares
3033
+ // the project's common dir, and a project worktree's src/ is not the vault.
3034
+ // Anything unreadable answers with the prefix alone.
3035
+
3036
+ const VAULT_REPO = new Map();
3037
+ export function resetVaultLocation() {
3038
+ VAULT_REPO.clear();
3039
+ }
3040
+
3041
+ function gitIdentity(entry) {
3042
+ try {
3043
+ if (statSync(entry).isDirectory()) {
3044
+ const real = realpathSync(entry);
3045
+ return { gitdir: real, common: real };
3046
+ }
3047
+ const m = readFileSync(entry, "utf8").match(/^gitdir:\s*(.+?)\s*$/m);
3048
+ if (!m) return null;
3049
+ const gitdir = resolve(dirname(entry), m[1]);
3050
+ let common = gitdir;
3051
+ try {
3052
+ common = resolve(gitdir, readFileSync(join(gitdir, "commondir"), "utf8").trim());
3053
+ } catch {}
3054
+ return { gitdir: realpathSync(gitdir), common: realpathSync(common) };
3055
+ } catch {
3056
+ return null;
3057
+ }
3058
+ }
3059
+
3060
+ // The nearest directory at or above `start` holding a `.git` entry; with
3061
+ // `below`, only directories strictly inside it are considered.
3062
+ function nearestGit(start, below = null) {
3063
+ let d = start;
3064
+ for (;;) {
3065
+ if (below !== null && !d.startsWith(below + "/")) return null;
3066
+ if (existsSync(join(d, ".git"))) return d;
3067
+ const up = dirname(d);
3068
+ if (up === d) return null;
3069
+ d = up;
3070
+ }
3071
+ }
3072
+
3073
+ function vaultRepo(vault) {
3074
+ if (VAULT_REPO.has(vault)) return VAULT_REPO.get(vault);
3075
+ let out = null;
3076
+ try {
3077
+ const real = realpathSync(vault);
3078
+ const top = nearestGit(real);
3079
+ const id = top ? gitIdentity(join(top, ".git")) : null;
3080
+ if (id) out = { ...id, sub: real === top ? "" : real.slice(top.length + 1) };
3081
+ } catch {}
3082
+ VAULT_REPO.set(vault, out);
3083
+ return out;
3084
+ }
3085
+
3086
+ // The bound vault's path as the resolver compares it: no trailing slash (a
3087
+ // hand-edited binding may carry one).
3088
+ export function vaultRootOf(vaultPath) {
3089
+ return vaultPath && vaultPath.length > 1 ? vaultPath.replace(/\/+$/, "") : vaultPath;
3090
+ }
3091
+
3092
+ export function vaultLocation(filePath, vaultPath, { cwd } = {}) {
3093
+ if (!filePath || !vaultPath) return null;
3094
+ const vault = vaultRootOf(vaultPath);
3095
+ // resolve() also folds `//` and `..`, so a root is always a clean path.
3096
+ const norm = resolve(cwd || process.cwd(), filePath);
3097
+ const under = (p, root) => p === root || p.startsWith(root + "/");
3098
+ const relOf = (p, root) => (p === root ? "" : p.slice(root.length + 1));
3099
+ const copyOf = (top, repo) => (repo.sub ? join(top, repo.sub) : top);
3100
+
3101
+ if (under(norm, vault)) {
3102
+ // A worktree kept inside the vault folder answers for its own copy.
3103
+ const top = nearestGit(dirname(norm), vault);
3104
+ if (top) {
3105
+ const repo = vaultRepo(vault);
3106
+ const id = repo ? gitIdentity(join(top, ".git")) : null;
3107
+ if (id && id.common === repo.common && id.gitdir !== repo.gitdir) {
3108
+ const root = copyOf(top, repo);
3109
+ if (under(norm, root)) return { root, rel: relOf(norm, root) };
3110
+ }
3111
+ }
3112
+ return { root: vault, rel: relOf(norm, vault) };
3113
+ }
3114
+
3115
+ // Outside the prefix: a new file's walk starts at its nearest existing parent.
3116
+ let start = dirname(norm);
3117
+ while (!existsSync(start)) {
3118
+ const up = dirname(start);
3119
+ if (up === start) return null;
3120
+ start = up;
3121
+ }
3122
+ const top = nearestGit(start);
3123
+ if (!top) return null;
3124
+ const repo = vaultRepo(vault);
3125
+ if (!repo) return null;
3126
+ const id = gitIdentity(join(top, ".git"));
3127
+ if (!id || id.common !== repo.common) return null;
3128
+ const root = copyOf(top, repo);
3129
+ if (!under(norm, root)) return null;
3130
+ // The bound checkout spelled another way (/private/var for /var, a symlink).
3131
+ if (id.gitdir === repo.gitdir) return { root: vault, rel: relOf(norm, root) };
3132
+ return { root, rel: relOf(norm, root) };
3133
+ }
3134
+
2999
3135
  // โ”€โ”€โ”€ Per-session project-side state (statusline pointer โ€” ADR-006) โ”€โ”€โ”€โ”€โ”€
3000
3136
  //
3001
3137
  // <project>/.claude/.projectstore/state/<session_id>.json holds this
@@ -3094,13 +3230,29 @@ export function writeSessionState(projectDir, sessionId, patch) {
3094
3230
  // written as null (every reader treats it as absent): a touch of another
3095
3231
  // epic clears the previous epic's story, and an unreadable story names no
3096
3232
  // stale title. Null when the file is not under an epic.
3097
- export function pointerPatchFor(vaultPath, filePath, cur, read = (p) => readFileSync(p, "utf8")) {
3098
- const m = filePath.slice(vaultPath.length + 1).match(/^epics\/([^/]+)\/(.+)$/);
3233
+ //
3234
+ // The file may sit in a worktree of the vault (vaultLocation): the epic is
3235
+ // matched on its path inside that copy, epic.md is read from the same copy,
3236
+ // and `story_root` records the copy โ€” null for the bound checkout โ€” so the
3237
+ // band and SessionStart's refresh read the story where it lives. It is written
3238
+ // with the story fields: on a story touch, or when the epic changes. A touch
3239
+ // of the same epic's other files (reading the bound checkout's epic.md for
3240
+ // its goal) leaves it, or the band would look for the story where it is not.
3241
+ export function pointerPatchFor(
3242
+ vaultPath,
3243
+ filePath,
3244
+ cur,
3245
+ read = (p) => readFileSync(p, "utf8"),
3246
+ { loc = vaultLocation(filePath, vaultPath) } = {},
3247
+ ) {
3248
+ if (!loc) return null;
3249
+ const m = loc.rel.match(/^epics\/([^/]+)\/(.+)$/);
3099
3250
  if (!m) return null;
3100
3251
  const [, epicId, inEpic] = m;
3252
+ const storyRoot = loc.root === vaultRootOf(vaultPath) ? null : loc.root;
3101
3253
  const patch = { active_epic: epicId };
3102
3254
  try {
3103
- const { data } = parseFrontmatter(read(join(vaultPath, "epics", epicId, "epic.md")));
3255
+ const { data } = parseFrontmatter(read(join(loc.root, "epics", epicId, "epic.md")));
3104
3256
  if (data.title) patch.epic_title = String(data.title);
3105
3257
  } catch {}
3106
3258
  const key = storyKeyOf(inEpic);
@@ -3112,8 +3264,9 @@ export function pointerPatchFor(vaultPath, filePath, cur, read = (p) => readFile
3112
3264
  patch.active_story = key;
3113
3265
  patch.story_title = data && data.title ? String(data.title) : null;
3114
3266
  patch.story_status = data && data.status ? String(data.status) : null;
3267
+ patch.story_root = storyRoot;
3115
3268
  } else if (!cur || cur.active_epic !== epicId) {
3116
- Object.assign(patch, { active_story: null, story_title: null, story_status: null });
3269
+ Object.assign(patch, { active_story: null, story_title: null, story_status: null, story_root: storyRoot });
3117
3270
  }
3118
3271
  return patch;
3119
3272
  }
@@ -3140,12 +3293,14 @@ export function storyFileFor(vaultPath, epicId, key) {
3140
3293
  export function refreshSessionPointer(projectDir, cfg, path, { env = process.env } = {}) {
3141
3294
  const vault = cfg.vault_path;
3142
3295
  const abs = isAbsolute(path) ? resolve(path) : join(vault, path);
3143
- const rel = abs.startsWith(`${vault}/`) ? abs.slice(vault.length + 1) : null;
3296
+ // A worktree of the vault is the vault too: a close made there refreshes.
3297
+ const loc = vaultLocation(abs, vault);
3298
+ const rel = loc && loc.rel ? loc.rel : null;
3144
3299
  if (!rel) return { refusal: { code: "NOT_IN_VAULT", message: `${path} is not inside the bound vault (${vault})` } };
3145
3300
  if (!existsSync(abs)) return { refusal: { code: "NOT_FOUND", message: `${rel} does not exist in the vault` } };
3146
3301
  const sessionId = hostSessionId(env);
3147
3302
  const cur = sessionId ? readSessionState(projectDir, sessionId) : null;
3148
- const patch = pointerPatchFor(vault, abs, cur);
3303
+ const patch = pointerPatchFor(vault, abs, cur, undefined, { loc });
3149
3304
  if (!patch || !patch.active_story)
3150
3305
  return { refusal: { code: "NOT_A_STORY", message: `${rel} is not a story file under an epic` } };
3151
3306
  if (!sessionId) return { session: null, refreshed: false, reason: "no-session", path: rel };
@@ -3159,6 +3314,7 @@ export function refreshSessionPointer(projectDir, cfg, path, { env = process.env
3159
3314
  active_story: next.active_story ?? null,
3160
3315
  story_title: next.story_title ?? null,
3161
3316
  story_status: next.story_status ?? null,
3317
+ story_root: next.story_root ?? null,
3162
3318
  },
3163
3319
  };
3164
3320
  }
@@ -3174,35 +3330,51 @@ export function refreshSessionPointer(projectDir, cfg, path, { env = process.env
3174
3330
  export async function refreshStoryPointer(projectDir, vaultPath, sessionId, opts = {}) {
3175
3331
  const cur = readSessionState(projectDir, sessionId);
3176
3332
  if (!cur || !cur.active_epic || !cur.active_story) return "unchanged";
3177
- const file = storyFileFor(vaultPath, cur.active_epic, cur.active_story);
3178
- if (!file && !existsSync(join(vaultPath, "epics", cur.active_epic, "epic.md"))) return "unknown";
3179
- let title = null;
3180
- let status = null;
3181
- if (file) {
3333
+ // The copy the story was last touched in (a worktree of the vault), while it
3334
+ // exists; a removed worktree falls back to the bound checkout.
3335
+ const root =
3336
+ typeof cur.story_root === "string" && cur.story_root && cur.story_root !== vaultRootOf(vaultPath)
3337
+ ? cur.story_root
3338
+ : null;
3339
+ const rootLive = root ? existsSync(root) : false;
3340
+ const mainFile = storyFileFor(vaultPath, cur.active_epic, cur.active_story);
3341
+ const rootFile = rootLive ? storyFileFor(root, cur.active_epic, cur.active_story) : null;
3342
+ let patch;
3343
+ if (!mainFile && !rootFile) {
3344
+ const epicIn = (r) => existsSync(join(r, "epics", cur.active_epic, "epic.md"));
3345
+ if (!epicIn(vaultPath) && !(rootLive && epicIn(root))) return "unknown";
3346
+ patch = { active_story: null, story_title: null, story_status: null, story_root: rootLive ? root : null };
3347
+ } else {
3182
3348
  const readFile = opts.readFile || ((p) => readFileAsync(p, "utf8"));
3183
3349
  let timer = null;
3184
3350
  const deadline = new Promise((resolve) => {
3185
3351
  timer = setTimeout(() => resolve(null), opts.budgetMs ?? 200);
3186
3352
  });
3187
- let text;
3353
+ const readOne = (f) =>
3354
+ f
3355
+ ? Promise.resolve()
3356
+ .then(() => readFile(f))
3357
+ .then(String, () => null)
3358
+ : Promise.resolve("");
3359
+ let texts;
3188
3360
  try {
3189
- text = await Promise.race([
3190
- Promise.resolve()
3191
- .then(() => readFile(file))
3192
- .then(String, () => null),
3193
- deadline,
3194
- ]);
3361
+ texts = await Promise.race([Promise.all([readOne(mainFile), readOne(rootFile)]), deadline]);
3195
3362
  } finally {
3196
3363
  if (timer) clearTimeout(timer);
3197
3364
  }
3198
- if (text === null) return "unknown";
3199
- const { data } = parseFrontmatter(text);
3200
- title = data.title ? String(data.title) : null;
3201
- status = data.status ? String(data.status) : null;
3202
- }
3203
- const patch = file
3204
- ? { story_title: title, story_status: status }
3205
- : { active_story: null, story_title: null, story_status: null };
3365
+ if (texts === null || texts.some((t) => t === null)) return "unknown";
3366
+ const fields = (text) => {
3367
+ const { data } = parseFrontmatter(text);
3368
+ return { title: data.title ? String(data.title) : null, status: data.status ? String(data.status) : null };
3369
+ };
3370
+ const main = mainFile ? fields(texts[0]) : null;
3371
+ const copy = rootFile ? fields(texts[1]) : null;
3372
+ // A story closed on the bound checkout wins over a stale worktree copy
3373
+ // (the merge brought `done`); otherwise the copy being worked wins.
3374
+ const useMain = main && (!copy || stageOf(main.status) === "done");
3375
+ const pick = useMain ? main : copy;
3376
+ patch = { story_title: pick.title, story_status: pick.status, story_root: useMain ? null : root };
3377
+ }
3206
3378
  const same = Object.entries(patch).every(([k, v]) => (cur[k] ?? null) === v);
3207
3379
  if (same) return "unchanged";
3208
3380
  writeSessionState(projectDir, sessionId, patch);
@@ -3290,7 +3462,6 @@ export const ENTRY_IGNORE = [
3290
3462
 
3291
3463
  export function isSourcePath(absPath, projectDir, vaultPath) {
3292
3464
  if (!absPath || !projectDir) return false;
3293
- if (vaultPath && isInsideVault(absPath, vaultPath)) return false;
3294
3465
  const root = projectDir.endsWith("/") ? projectDir.slice(0, -1) : projectDir;
3295
3466
  if (!absPath.startsWith(root + "/")) return false;
3296
3467
  // Matched project-relative, never absolute: SOURCE_IGNORE is
@@ -3298,7 +3469,10 @@ export function isSourcePath(absPath, projectDir, vaultPath) {
3298
3469
  // project that merely lives under a directory called `build`.
3299
3470
  const rel = absPath.slice(root.length + 1);
3300
3471
  if (!rel) return false;
3301
- return !ENTRY_IGNORE.some((re) => re.test(rel));
3472
+ if (ENTRY_IGNORE.some((re) => re.test(rel))) return false;
3473
+ // Last, because it walks the disk: a file in the vault โ€” a worktree's copy
3474
+ // of it included (contract 1, as amended 2026-10-15) โ€” is never source.
3475
+ return !(vaultPath && vaultLocation(absPath, vaultPath));
3302
3476
  }
3303
3477
 
3304
3478
  export function scoreDir(projectDir, sessionId) {
@@ -3383,7 +3557,14 @@ export function listVaultStoryFiles(vaultPath) {
3383
3557
  export async function resolveOpenStory(vaultPath, opts = {}) {
3384
3558
  const budgetMs = opts.budgetMs ?? 200;
3385
3559
  const readFile = opts.readFile || ((p) => readFileAsync(p, "utf8"));
3386
- const files = listVaultStoryFiles(vaultPath);
3560
+ // The session's own worktree copy first (its pointer's story_root), then the
3561
+ // bound checkout: a story open where this session works it counts as open.
3562
+ const storyRoot = opts.storyRoot;
3563
+ const own =
3564
+ typeof storyRoot === "string" && storyRoot && storyRoot !== vaultRootOf(vaultPath) && existsSync(storyRoot)
3565
+ ? listVaultStoryFiles(storyRoot)
3566
+ : [];
3567
+ const files = [...own, ...listVaultStoryFiles(vaultPath)];
3387
3568
  if (!files.length) return false;
3388
3569
 
3389
3570
  let timer = null;
@@ -3581,12 +3762,12 @@ export const ENTRY_THRESHOLD = 3;
3581
3762
  // Normative text. Three properties it must keep, whatever the wording: it shows
3582
3763
  // the evidence (the count), it names the action, and it grants the exit โ€” so a
3583
3764
  // false positive costs a glance rather than an argument.
3584
- export function entryReminderText(n) {
3765
+ export function entryReminderText(n, { env = process.env } = {}) {
3585
3766
  return [
3586
3767
  `**projectstore**: this session has written to ${n} source files and no story`,
3587
3768
  "is in progress. If this is feature-sized work, open it in the vault before",
3588
- `going further โ€” \`${commandForm("story", { args: '<EPIC> "<title>"' })}\`, or`,
3589
- `\`${commandForm("epic")}\` if it needs a new one. If it is a one-off fix, carry`,
3769
+ `going further โ€” \`${commandForm("story", { args: '<EPIC> "<title>"', env })}\`, or`,
3770
+ `\`${commandForm("epic", { env })}\` if it needs a new one. If it is a one-off fix, carry`,
3590
3771
  "on โ€” this fires once.",
3591
3772
  ].join("\n");
3592
3773
  }
@@ -33,6 +33,7 @@
33
33
  // user's tool call.
34
34
 
35
35
  import { existsSync } from "node:fs";
36
+ import { isAbsolute, resolve } from "node:path";
36
37
  import {
37
38
  readConfig,
38
39
  readStdinJson,
@@ -43,7 +44,8 @@ import {
43
44
  sessionFilePath,
44
45
  projectRoot,
45
46
  appendActivity,
46
- isInsideVault,
47
+ vaultLocation,
48
+ vaultRootOf,
47
49
  pointerPatchFor,
48
50
  readSessionState,
49
51
  writeSessionState,
@@ -91,7 +93,7 @@ function layoutOnce(cfg) {
91
93
  return LAYOUT;
92
94
  }
93
95
 
94
- function anchorOffer(cfg, proj, sid, filePath, toolName, isSubagent, sessionsDir = null) {
96
+ function anchorOffer(cfg, proj, sid, rel, toolName, isSubagent, sessionsDir = null) {
95
97
  // The two gates. The pointer patch below deliberately has neither: it answers
96
98
  // "what am I looking at", for which a Read is a fine answer. A NAME is a
97
99
  // claim about what the session is DOING, so a review session that greps
@@ -103,7 +105,6 @@ function anchorOffer(cfg, proj, sid, filePath, toolName, isSubagent, sessionsDir
103
105
  // tally and logs nothing for one (generation spec, contract 18).
104
106
  if (!speakingHarness()?.capabilities?.session_rename) return null;
105
107
 
106
- const rel = filePath.slice(cfg.vault_path.length + 1);
107
108
  let hit;
108
109
  try {
109
110
  hit = anchorKeyOf(rel, layoutOnce(cfg));
@@ -181,11 +182,11 @@ function anchorOffer(cfg, proj, sid, filePath, toolName, isSubagent, sessionsDir
181
182
  // old status, and a close is the last touch a story gets (ADR-006 decision 1,
182
183
  // amended 2026-10-12). Any other tool's (a Read) is made on PreToolUse.
183
184
  // Returns the nudge's line, or null.
184
- function patchPointer(cfg, proj, sid, vaultPaths, toolName) {
185
+ function patchPointer(cfg, proj, sid, placed, toolName) {
185
186
  const cur = readSessionState(proj, sid) || {};
186
187
  let patch = null;
187
- for (const filePath of vaultPaths) {
188
- const p = pointerPatchFor(cfg.vault_path, filePath, { ...cur, ...patch });
188
+ for (const { path, loc } of placed) {
189
+ const p = pointerPatchFor(cfg.vault_path, path, { ...cur, ...patch }, undefined, { loc });
189
190
  if (p) patch = { ...(patch || {}), ...p };
190
191
  }
191
192
 
@@ -193,7 +194,12 @@ function patchPointer(cfg, proj, sid, vaultPaths, toolName) {
193
194
  if (isWriteTool(toolName || "") && cfg.guard !== "off") {
194
195
  const last = cur.nudged_at ? Date.parse(cur.nudged_at) : 0;
195
196
  if (Date.now() - last > NUDGE_INTERVAL_MS) {
196
- nudge = `projectstore: vault file edited directly โ€” if this bypassed a ${commandForm("*")} command, run ${commandForm("reconcile")} (or doctor) afterwards so the board/indexes stay in sync.`;
197
+ // A reconcile acts on the bound vault, so an edit in a worktree's copy
198
+ // is reconciled there once it is merged, not now.
199
+ const inCopy = placed.some(({ loc }) => loc.root !== vaultRootOf(cfg.vault_path));
200
+ nudge = inCopy
201
+ ? `projectstore: vault file edited in a worktree of the vault โ€” if this bypassed a ${commandForm("*")} command, run ${commandForm("reconcile")} in the bound vault after the merge so the board/indexes stay in sync.`
202
+ : `projectstore: vault file edited directly โ€” if this bypassed a ${commandForm("*")} command, run ${commandForm("reconcile")} (or doctor) afterwards so the board/indexes stay in sync.`;
197
203
  patch = { ...(patch || {}), nudged_at: new Date().toISOString() };
198
204
  }
199
205
  }
@@ -246,7 +252,7 @@ async function entryBranch(cfg, proj, sid, filePath, input) {
246
252
 
247
253
  let verdict = readOpenStoryCache(proj, sid);
248
254
  if (verdict === null) {
249
- verdict = await resolveOpenStory(cfg.vault_path);
255
+ verdict = await resolveOpenStory(cfg.vault_path, { storyRoot: readSessionState(proj, sid)?.story_root });
250
256
  writeOpenStoryCache(proj, sid, verdict);
251
257
  }
252
258
  // Reached either from a fresh sweep that hit its budget with reads still
@@ -312,7 +318,16 @@ async function main() {
312
318
  const filePaths = toolPaths(input);
313
319
  if (!filePaths.length) return;
314
320
 
315
- const vaultPaths = filePaths.filter((p) => isInsideVault(p, cfg.vault_path));
321
+ // The vault side: any copy of the vault (a worktree included), with a
322
+ // relative tool path (Codex's apply_patch) taken against the payload's cwd.
323
+ // The entry rule below keeps the raw paths.
324
+ const cwd = input.cwd || proj;
325
+ const placed = [];
326
+ for (const p of filePaths) {
327
+ const abs = isAbsolute(p) ? p : resolve(cwd, p);
328
+ const loc = vaultLocation(abs, cfg.vault_path);
329
+ if (loc) placed.push({ path: abs, loc });
330
+ }
316
331
  const writes = isWriteTool(input.tool_name || "");
317
332
 
318
333
  if (event === "PostToolUse") {
@@ -321,9 +336,9 @@ async function main() {
321
336
  // false" is a failed call; every other shape passes.
322
337
  const response = input.tool_response;
323
338
  const failed = response && typeof response === "object" && response.success === false;
324
- if (writes && !failed && vaultPaths.length) {
339
+ if (writes && !failed && placed.length) {
325
340
  try {
326
- const nudge = patchPointer(cfg, proj, sid, vaultPaths, input.tool_name);
341
+ const nudge = patchPointer(cfg, proj, sid, placed, input.tool_name);
327
342
  if (nudge) messages.push(nudge);
328
343
  } catch {}
329
344
  }
@@ -345,12 +360,12 @@ async function main() {
345
360
 
346
361
  const messages = [];
347
362
  // A Read's pointer patch; a write's waits for PostToolUse (patchPointer).
348
- if (!writes && vaultPaths.length) {
363
+ if (!writes && placed.length) {
349
364
  try {
350
- patchPointer(cfg, proj, sid, vaultPaths, input.tool_name);
365
+ patchPointer(cfg, proj, sid, placed, input.tool_name);
351
366
  } catch {}
352
367
  }
353
- for (const filePath of vaultPaths) {
368
+ for (const { path: filePath, loc } of placed) {
354
369
  try {
355
370
  appendActivity(cfg.vault_path, sid, filePath, input.tool_name);
356
371
  } catch {}
@@ -360,7 +375,7 @@ async function main() {
360
375
  cfg,
361
376
  proj,
362
377
  sid,
363
- filePath,
378
+ loc.rel,
364
379
  input.tool_name,
365
380
  Boolean(input.agent_id || input.agent_type),
366
381
  process.env.PROJECTSTORE_SESSIONS_DIR || null,
@@ -16,7 +16,15 @@
16
16
  // running it before a manual publish does not, because publish #1 is not
17
17
  // tag-triggered and nothing else covers it.
18
18
  //
19
- // Exit 0 when everything agrees, 1 on disagreement or a malformed manifest.
19
+ // Then, with or without --tag, that the version has its release notes
20
+ // (PS-HARNESS: "A release tag shows on every surfaceโ€ฆ"): the file
21
+ // packaging/release-notes/v<version>.md, headed `# v<version> โ€” <title>`, with
22
+ // a body. The release workflow makes the GitHub Release from that file, so a
23
+ // version without one is refused here โ€” in the release PR, by test.yml and
24
+ // `npm run release:check` โ€” rather than on the tag.
25
+ //
26
+ // Exit 0 when everything agrees, 1 on disagreement, a malformed manifest or
27
+ // missing notes.
20
28
  // Output is JSON on stdout, always, so a workflow step can quote it.
21
29
 
22
30
  import { readFileSync, existsSync, readdirSync } from "node:fs";
@@ -198,6 +206,44 @@ export function checkVersions({ root = ROOT, tag = null } = {}) {
198
206
  };
199
207
  }
200
208
 
209
+ // The release notes (PS-HARNESS: "A release tag shows on every surfaceโ€ฆ").
210
+ // Under packaging/, which never ships: the notes are the GitHub Release's
211
+ // source, read from the repository by the release workflow.
212
+ export const RELEASE_NOTES_DIR = "packaging/release-notes";
213
+ export const releaseNotesPath = (version) => `${RELEASE_NOTES_DIR}/v${version}.md`;
214
+
215
+ // The heading is the release's title as GitHub shows it, `v0.30.4 โ€” <title>`
216
+ // (an em dash, as every release title since v0.6.0 carries), and the body is
217
+ // what the release says. Both must be there: a heading alone is the "tagged
218
+ // without release notes" of 0.28.0 again.
219
+ export function checkReleaseNotes({ root = ROOT, version }) {
220
+ const rel = releaseNotesPath(version);
221
+ const refuse = (what) => ({ ok: false, error: `${rel}: ${what}`, notes: rel });
222
+ if (!existsSync(resolve(root, rel))) return refuse("missing โ€” the release workflow makes the GitHub Release from it");
223
+ const [first = "", ...rest] = readFileSync(resolve(root, rel), "utf8").split("\n");
224
+ const heading = first.replace(/\r$/, "");
225
+ // The version the heading names, up to a space or the dash: `# v0.30.4โ€”x`
226
+ // names 0.30.4 and fails the shape, not the version.
227
+ const named = /^# v([^\s\u2014]+)/.exec(heading)?.[1];
228
+ if (named !== undefined && named !== version) return refuse(`the heading names v${named}, not v${version}`);
229
+ const prefix = `# v${version} โ€” `;
230
+ if (!heading.startsWith(prefix) || !heading.slice(prefix.length).trim())
231
+ return refuse(`the first line must be "# v${version} โ€” <title>"`);
232
+ if (!rest.join("\n").trim()) return refuse("no body under the heading โ€” the notes are the release's text");
233
+ return { ok: true, notes: rel };
234
+ }
235
+
236
+ // The whole guard: the versions, then the notes for the version they agree
237
+ // on. `checkVersions` stays as it was, so its callers (the packaging and
238
+ // shells contracts) keep their scratch roots without notes.
239
+ export function runGuard({ root = ROOT, tag = null } = {}) {
240
+ const v = checkVersions({ root, tag });
241
+ if (!v.ok) return v;
242
+ const n = checkReleaseNotes({ root, version: v.version });
243
+ if (!n.ok) return { ...n, version: v.version, checked: v.checked };
244
+ return { ...v, notes: n.notes };
245
+ }
246
+
201
247
  // Everything at the repository root that is deliberately NOT in the tarball.
202
248
  // This list is the whole point of the tree check below: `npm pack` can only
203
249
  // report what `files[]` already allows, so comparing pack output against the
@@ -303,7 +349,7 @@ async function main(argv) {
303
349
  return;
304
350
  }
305
351
 
306
- const result = checkVersions({ tag });
352
+ const result = runGuard({ tag });
307
353
  process.stdout.write(JSON.stringify(result) + "\n");
308
354
  if (!result.ok) process.exit(1);
309
355
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectstore-claude",
3
- "version": "0.30.4",
3
+ "version": "0.30.5",
4
4
  "description": "projectstore for Claude Code: the core pinned and bundled, the harness fixed. A release artifact; the way in is npx projectstore setup",
5
5
  "keywords": [
6
6
  "projectstore",
@@ -37,7 +37,7 @@
37
37
  "README.md"
38
38
  ],
39
39
  "dependencies": {
40
- "projectstore": "=0.30.4"
40
+ "projectstore": "=0.30.5"
41
41
  },
42
42
  "bundleDependencies": [
43
43
  "projectstore"