projectstore-claude 0.30.3 โ 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.
- package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
- package/node_modules/projectstore/.claude-plugin/plugin.json +1 -1
- package/node_modules/projectstore/commands/agents.md +5 -2
- package/node_modules/projectstore/commands/doctor.md +4 -0
- package/node_modules/projectstore/docs/how-it-works.md +7 -0
- package/node_modules/projectstore/harnesses/claude-code.json +3 -2
- package/node_modules/projectstore/harnesses/codex.json +45 -15
- package/node_modules/projectstore/hooks/band.tsx +19 -8
- package/node_modules/projectstore/hooks/session-stop.mjs +43 -20
- package/node_modules/projectstore/package.json +1 -1
- package/node_modules/projectstore/scripts/band-shared.mjs +17 -2
- package/node_modules/projectstore/scripts/band.mjs +20 -2
- package/node_modules/projectstore/scripts/convergence.mjs +56 -1
- package/node_modules/projectstore/scripts/doctor.mjs +56 -10
- package/node_modules/projectstore/scripts/harness.mjs +68 -12
- package/node_modules/projectstore/scripts/install-harness.mjs +153 -14
- package/node_modules/projectstore/scripts/lib.mjs +271 -54
- package/node_modules/projectstore/scripts/setup.mjs +7 -5
- package/node_modules/projectstore/scripts/surfaces.mjs +36 -9
- package/node_modules/projectstore/scripts/touch-session.mjs +30 -15
- package/node_modules/projectstore/scripts/version-guard.mjs +48 -2
- package/package.json +2 -2
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"name": "projectstore",
|
|
13
13
|
"displayName": "projectstore",
|
|
14
14
|
"description": "๐ Your agent runs the project through a verified loop: task โ artifact (ADR ยท spec ยท epic ยท story) โ adversarial critic โ backlog โ planner โ reviewer โ done. Plain markdown in an Obsidian-friendly vault, every write approved by you โ and any model can pick the project up tomorrow.",
|
|
15
|
-
"version": "0.30.
|
|
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
|
+
"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",
|
|
@@ -12,7 +12,10 @@ subcommand (`.projectstore/projectstore.json`; else point to `/projectstore:bind
|
|
|
12
12
|
1. **Ask** via AskUserQuestion: "Register projectstore's agents in
|
|
13
13
|
CLAUDE.md/AGENTS.md so every session routes to them (critic after
|
|
14
14
|
authoring artifacts, planner before implementing, reviewer before commit)?
|
|
15
|
-
[Yes / No]". On No, stop.
|
|
15
|
+
[Yes / No]". On No, stop. When the block is already there marked newer
|
|
16
|
+
than this copy's template (doctor's info, or `status` showing
|
|
17
|
+
`ours-newer`), name both numbers in the question: registering writes this
|
|
18
|
+
copy's older block over the one marked vN.
|
|
16
19
|
2. **Run the verb** and print its output verbatim:
|
|
17
20
|
```bash
|
|
18
21
|
node "${CLAUDE_PLUGIN_ROOT}/bin/projectstore.mjs" install --harness claude-code --surface agents_block --project "${CLAUDE_PROJECT_DIR}"
|
|
@@ -53,7 +56,7 @@ subcommand (`.projectstore/projectstore.json`; else point to `/projectstore:bind
|
|
|
53
56
|
|
|
54
57
|
Run `node "${CLAUDE_PLUGIN_ROOT}/bin/projectstore.mjs" plan --json --surface agents_block --project "${CLAUDE_PROJECT_DIR}"`
|
|
55
58
|
and report each item's `state` from `result.items[]` โ the bin wraps the plan in its envelope (`ours-current`, `ours-stale` with its reason,
|
|
56
|
-
`ours-absent`, or a refusal). Then:
|
|
59
|
+
`ours-newer` โ marked above this copy's template: install, upgrade and setup leave it, and a named run such as `register` writes this copy's over it โ `ours-absent`, or a refusal). Then:
|
|
57
60
|
|
|
58
61
|
- Block: present in which file, marker version vs the installed template, agent
|
|
59
62
|
names vs the layout roster.
|
|
@@ -42,6 +42,10 @@ You are running projectstore diagnostics (ADR-005: umbrella doctor).
|
|
|
42
42
|
the verb adds the import. For another harness the finding names its own
|
|
43
43
|
`--harness`; relay that command. Never Edit or Write the block yourself โ
|
|
44
44
|
the verb is its only writer (install spec, contract 6).
|
|
45
|
+
- `agents-block` **info** that the block is "marked vN, newer than this
|
|
46
|
+
copy's vM" โ **report only.** Install, upgrade and setup leave such a block as it is (install spec, contract 6 as amended
|
|
47
|
+
2026-10-14). Relay the finding and its command verbatim; run it only if
|
|
48
|
+
the user asks, because it writes this copy's older block over the one marked vN.
|
|
45
49
|
- `statusline` issues โ offer running `/projectstore:statusline on|off`,
|
|
46
50
|
which installs or removes the entry and the launcher behind a preview
|
|
47
51
|
(the SessionStart hook only refreshes an entry that already exists), and
|
|
@@ -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.
|
|
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
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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": "
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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":
|
|
170
|
+
"decision": "block",
|
|
171
|
+
"carrier": "block",
|
|
157
172
|
"evidence": "measured",
|
|
158
|
-
"note": "
|
|
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":
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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 = `${
|
|
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.
|
|
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
|
-
//
|
|
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
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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.
|
|
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
|
-
|
|
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))
|
|
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}`);
|
|
@@ -56,6 +56,7 @@ import {
|
|
|
56
56
|
writeFileAtomic,
|
|
57
57
|
envFlag,
|
|
58
58
|
isEphemeralRoot,
|
|
59
|
+
COPY_UPDATE_STEPS,
|
|
59
60
|
} from "./lib.mjs";
|
|
60
61
|
import { loadHarness, loadHarnesses } from "./harness.mjs";
|
|
61
62
|
import { analysePortableRegistration, portableRegistrationPaths } from "./portable-registration.mjs";
|
|
@@ -348,6 +349,34 @@ function runArgs({ harnesses = [], surfaces = null, register = true } = {}) {
|
|
|
348
349
|
].join(" ");
|
|
349
350
|
}
|
|
350
351
|
|
|
352
|
+
// The run a deferral sends to a terminal (the install spec, contract 6, as
|
|
353
|
+
// amended 2026-10-14): the run's own verb for one harness, its --surface
|
|
354
|
+
// selection, --global for a global removal, and setup's --channel npm when
|
|
355
|
+
// that was the answer for the harness whose channel it is (setup's rule 13 โ
|
|
356
|
+
// a keep is never repeated). `plan` writes nothing, so it names install. One
|
|
357
|
+
// builder for a deferred row's reason and setup's DONE, so the two agree.
|
|
358
|
+
export function terminalRun({
|
|
359
|
+
verb = "install",
|
|
360
|
+
harness = null,
|
|
361
|
+
version = null,
|
|
362
|
+
surfaces = null,
|
|
363
|
+
globalRemoval = false,
|
|
364
|
+
channel = null,
|
|
365
|
+
} = {}) {
|
|
366
|
+
const run = verb === "plan" ? "install" : verb;
|
|
367
|
+
const hostRegistration = Object.values(harness?.surfaces || {}).some(
|
|
368
|
+
(s) => s && typeof s === "object" && s.format === "host-plugin-registration",
|
|
369
|
+
);
|
|
370
|
+
const args = [
|
|
371
|
+
runArgs({ harnesses: harness ? [harness.id] : [], surfaces }),
|
|
372
|
+
globalRemoval ? "--global" : "",
|
|
373
|
+
run === "setup" && channel === "npm" && hostRegistration ? "--channel npm" : "",
|
|
374
|
+
]
|
|
375
|
+
.filter(Boolean)
|
|
376
|
+
.join(" ");
|
|
377
|
+
return coreCommand(run, { version, args });
|
|
378
|
+
}
|
|
379
|
+
|
|
351
380
|
// Stop when the registry's latest is at least a newer copy this project runs
|
|
352
381
|
// โ the newest such copy is named. A copy the latest does not reach, or no
|
|
353
382
|
// answer, proceeds: the rows say which case it was (proceedCase). `read` is
|
|
@@ -577,9 +606,15 @@ export async function readKeepPlan(copy, { harness, projectDir, env, run, budget
|
|
|
577
606
|
...(Array.isArray(result.refusals) ? result.refusals : []),
|
|
578
607
|
...items.filter((i) => i.action === "refuse").map((i) => i.reason),
|
|
579
608
|
].filter((x) => typeof x === "string" && x);
|
|
609
|
+
// A deferred row's reason may carry its run on the lines after its prose
|
|
610
|
+
// (the install spec, contract 6, as amended 2026-10-14): only the prose is
|
|
611
|
+
// quoted inside our own sentence, never another copy's command.
|
|
580
612
|
const deferred = items
|
|
581
613
|
.filter((i) => i.deferred === true)
|
|
582
|
-
.map((i) => ({
|
|
614
|
+
.map((i) => ({
|
|
615
|
+
surface: String(i.surface ?? ""),
|
|
616
|
+
reason: typeof i.reason === "string" ? i.reason.split("\n")[0] : null,
|
|
617
|
+
}));
|
|
583
618
|
return {
|
|
584
619
|
argv,
|
|
585
620
|
envelope: true,
|
|
@@ -603,6 +638,26 @@ export function ownUpgrade(copy, harness) {
|
|
|
603
638
|
};
|
|
604
639
|
}
|
|
605
640
|
|
|
641
|
+
// Whether a kept copy is behind the running package (the install spec,
|
|
642
|
+
// contract 21, rule 9, as amended 2026-10-14): semver precedence, rc
|
|
643
|
+
// included. Never for a copy or a run with no semver version recorded โ a
|
|
644
|
+
// recorded "unknown" or a sha must not rank.
|
|
645
|
+
export function copyBehind(copy, version) {
|
|
646
|
+
return isSemver(copy?.version) && isSemver(version) && cmpPrecedence(copy.version, version) < 0;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// What the keep says when its copy is behind: the project's surfaces are at
|
|
650
|
+
// the running version until the copy updates, and the update in the copy's
|
|
651
|
+
// channel. With a step, the copy's own upgrade runs after this run's agents
|
|
652
|
+
// block, and a copy shipped before this rule plans the block back.
|
|
653
|
+
export function behindAdvice(copy, version, { step = false } = {}) {
|
|
654
|
+
const head = `${copy.key} ${copy.version} is older than this package (${version})`;
|
|
655
|
+
const middle = step
|
|
656
|
+
? `its own upgrade runs after this run's agents block and may plan the block back to its own version; until it updates, its doctor may call the block stale, and the next run may plan the block again`
|
|
657
|
+
: `until it updates, the agents block and anything else this run writes for the project are at ${version}, and its own doctor may call the block stale`;
|
|
658
|
+
return `${head}: ${middle}. Update it (${COPY_UPDATE_STEPS}).`;
|
|
659
|
+
}
|
|
660
|
+
|
|
606
661
|
// This checkout's npm registration enabled beside the kept copy: the keep
|
|
607
662
|
// changes no registration, so it stays; its uninstall (rule 4) drops it.
|
|
608
663
|
export function npmRowBeside({ harness, id, surface }) {
|