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.
- package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
- package/node_modules/projectstore/.claude-plugin/plugin.json +1 -1
- 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/doctor.mjs +15 -4
- package/node_modules/projectstore/scripts/harness.mjs +54 -3
- package/node_modules/projectstore/scripts/lib.mjs +220 -39
- 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",
|
|
@@ -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}`);
|
|
@@ -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 ${
|
|
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
|
-
|
|
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).
|
|
769
|
-
|
|
770
|
-
|
|
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
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
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
|
-
|
|
2987
|
-
const
|
|
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
|
-
|
|
3098
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
3199
|
-
const
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
185
|
+
function patchPointer(cfg, proj, sid, placed, toolName) {
|
|
185
186
|
const cur = readSessionState(proj, sid) || {};
|
|
186
187
|
let patch = null;
|
|
187
|
-
for (const
|
|
188
|
-
const p = pointerPatchFor(cfg.vault_path,
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
339
|
+
if (writes && !failed && placed.length) {
|
|
325
340
|
try {
|
|
326
|
-
const nudge = patchPointer(cfg, proj, sid,
|
|
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 &&
|
|
363
|
+
if (!writes && placed.length) {
|
|
349
364
|
try {
|
|
350
|
-
patchPointer(cfg, proj, sid,
|
|
365
|
+
patchPointer(cfg, proj, sid, placed, input.tool_name);
|
|
351
366
|
} catch {}
|
|
352
367
|
}
|
|
353
|
-
for (const filePath of
|
|
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
|
-
|
|
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
|
-
//
|
|
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 =
|
|
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.
|
|
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.
|
|
40
|
+
"projectstore": "=0.30.5"
|
|
41
41
|
},
|
|
42
42
|
"bundleDependencies": [
|
|
43
43
|
"projectstore"
|