projectstore-claude 0.30.4 → 0.30.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/node_modules/projectstore/.claude-plugin/marketplace.json +2 -2
- package/node_modules/projectstore/.claude-plugin/plugin.json +6 -2
- package/node_modules/projectstore/.mcp.json +1 -2
- package/node_modules/projectstore/README.md +65 -16
- package/node_modules/projectstore/agents/archaeologist.md +1 -1
- package/node_modules/projectstore/agents/librarian.md +1 -1
- package/node_modules/projectstore/agents/planner.md +1 -1
- package/node_modules/projectstore/commands/doctor.md +14 -11
- package/node_modules/projectstore/commands/reconcile.md +1 -1
- package/node_modules/projectstore/commands/review.md +2 -2
- package/node_modules/projectstore/commands/story.md +1 -1
- package/node_modules/projectstore/docs/getting-started.md +3 -4
- package/node_modules/projectstore/docs/harnesses.md +1 -1
- package/node_modules/projectstore/docs/how-it-works.md +7 -0
- package/node_modules/projectstore/docs/images/icon.png +0 -0
- package/node_modules/projectstore/harnesses/claude-code.json +61 -11
- package/node_modules/projectstore/harnesses/codex.json +45 -15
- package/node_modules/projectstore/hooks/band.tsx +19 -8
- package/node_modules/projectstore/hooks/hooks.json +6 -6
- package/node_modules/projectstore/hooks/session-start.mjs +55 -24
- package/node_modules/projectstore/hooks/session-stop.mjs +43 -20
- package/node_modules/projectstore/package.json +11 -12
- package/node_modules/projectstore/scripts/band-shared.mjs +17 -2
- package/node_modules/projectstore/scripts/band.mjs +20 -2
- package/node_modules/projectstore/scripts/binding.mjs +1 -1
- package/node_modules/projectstore/scripts/build-adapters.mjs +45 -4
- package/node_modules/projectstore/scripts/cli.mjs +74 -1373
- package/node_modules/projectstore/scripts/convergence.mjs +59 -16
- package/node_modules/projectstore/scripts/doctor.mjs +199 -36
- package/node_modules/projectstore/scripts/harness.mjs +88 -6
- package/node_modules/projectstore/scripts/install-handlers.mjs +925 -0
- package/node_modules/projectstore/scripts/install-harness.mjs +129 -2081
- package/node_modules/projectstore/scripts/install-layout.mjs +453 -0
- package/node_modules/projectstore/scripts/install-preview.mjs +747 -0
- package/node_modules/projectstore/scripts/lib.mjs +387 -107
- package/node_modules/projectstore/scripts/mcp-server.mjs +33 -0
- package/node_modules/projectstore/scripts/mcp.mjs +60 -60
- package/node_modules/projectstore/scripts/model-spec.mjs +24 -0
- package/node_modules/projectstore/scripts/models.mjs +6 -10
- package/node_modules/projectstore/scripts/query.mjs +7 -6
- package/node_modules/projectstore/scripts/read-verbs.mjs +277 -0
- package/node_modules/projectstore/scripts/setup.mjs +62 -12
- package/node_modules/projectstore/scripts/statusline-launcher.mjs +36 -3
- package/node_modules/projectstore/scripts/surfaces.mjs +18 -5
- package/node_modules/projectstore/scripts/touch-session.mjs +30 -15
- package/node_modules/projectstore/scripts/verbs.mjs +1202 -0
- package/node_modules/projectstore/scripts/version-guard.mjs +57 -7
- package/package.json +3 -3
- package/node_modules/projectstore/docs/images/loop-light.svg +0 -94
- package/node_modules/projectstore/docs/images/team-light.svg +0 -79
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
],
|
|
30
30
|
"shared_env_reason": "Codex sets Claude Code's plugin-root names too, for compatibility — measured in every one of the 759 captured hook payloads. Without declaring them, a Codex session scores a strong detection hit for claude-code, ties with codex, and loses the tie to manifest filename order. A variable two harnesses both set identifies neither, so these are demoted to weak for every manifest (generation spec, contract 1, amended 2026-09-08).",
|
|
31
31
|
"session_env": [],
|
|
32
|
-
"session_env_reason": "Deliberately empty, though measured. Codex's exec tool sets CODEX_THREAD_ID and CODEX_SESSION_ID in every command (measured 2026-10-05, codex-cli 0.160.0; CODEX_SANDBOX* depend on the sandbox mode), and CODEX_HOME never appeared in a hook environment. Declaring them would identify a session, but session_env also defers the registration and the layout move inside a session (insideHostSession), which would stop the doctor skill's upgrade --json fix from running there.
|
|
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.
|
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
"hooks": [
|
|
7
7
|
{
|
|
8
8
|
"type": "command",
|
|
9
|
-
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/session-start.mjs"
|
|
9
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-start.mjs\""
|
|
10
10
|
},
|
|
11
11
|
{
|
|
12
12
|
"type": "command",
|
|
13
|
-
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/session-rules.mjs"
|
|
13
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-rules.mjs\""
|
|
14
14
|
}
|
|
15
15
|
]
|
|
16
16
|
}
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"hooks": [
|
|
21
21
|
{
|
|
22
22
|
"type": "command",
|
|
23
|
-
"command": "node ${CLAUDE_PLUGIN_ROOT}/scripts/touch-session.mjs"
|
|
23
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/touch-session.mjs\""
|
|
24
24
|
}
|
|
25
25
|
]
|
|
26
26
|
}
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"hooks": [
|
|
32
32
|
{
|
|
33
33
|
"type": "command",
|
|
34
|
-
"command": "node ${CLAUDE_PLUGIN_ROOT}/scripts/touch-session.mjs"
|
|
34
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/touch-session.mjs\""
|
|
35
35
|
}
|
|
36
36
|
]
|
|
37
37
|
}
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"hooks": [
|
|
42
42
|
{
|
|
43
43
|
"type": "command",
|
|
44
|
-
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/session-stop.mjs"
|
|
44
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-stop.mjs\""
|
|
45
45
|
}
|
|
46
46
|
]
|
|
47
47
|
}
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"hooks": [
|
|
52
52
|
{
|
|
53
53
|
"type": "command",
|
|
54
|
-
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/pre-compact.mjs"
|
|
54
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pre-compact.mjs\""
|
|
55
55
|
}
|
|
56
56
|
]
|
|
57
57
|
}
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
adoptHookInput,
|
|
26
26
|
projectRoot,
|
|
27
27
|
layoutPaths,
|
|
28
|
+
cachePaths,
|
|
28
29
|
pickExisting,
|
|
29
30
|
activeHarnessId,
|
|
30
31
|
ensureStateRoot,
|
|
@@ -46,31 +47,48 @@ import {
|
|
|
46
47
|
import { runStartupChecks } from "../scripts/doctor.mjs";
|
|
47
48
|
import { resolveBinding, bindingOfferText } from "../scripts/worktree.mjs";
|
|
48
49
|
|
|
49
|
-
// The
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
50
|
+
// The welcome has been seen, by this harness, when either marker exists: the
|
|
51
|
+
// project's (state/<id>/welcomed, or the legacy .claude/.projectstore-welcomed
|
|
52
|
+
// while the window is open) or the machine's (the user-level cache's
|
|
53
|
+
// welcomed/<id>). Both branches read both; only the write follows the binding
|
|
54
|
+
// (the story "Opening a project that is not bound writes nothing into it…",
|
|
55
|
+
// decisions 1 and 2). So a project that holds a marker an unbound session of
|
|
56
|
+
// 0.30.5 or earlier wrote is not welcomed again.
|
|
57
|
+
//
|
|
58
|
+
// The machine's marker is null with no cache root, and a home the platform
|
|
59
|
+
// cannot answer for must not break startup either (skeleton spec, contract 17).
|
|
60
|
+
function machineMarker(id) {
|
|
61
|
+
try {
|
|
62
|
+
return cachePaths().welcomed(id);
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
54
66
|
}
|
|
55
|
-
function
|
|
56
|
-
|
|
67
|
+
function welcomeSeen(proj, id) {
|
|
68
|
+
const p = layoutPaths(proj);
|
|
69
|
+
const machine = machineMarker(id);
|
|
70
|
+
return existsSync(pickExisting(p.welcomed(id), p.legacy.welcomed)) || (machine !== null && existsSync(machine));
|
|
57
71
|
}
|
|
58
72
|
|
|
59
|
-
// One-time orientation packet shown when projectstore first loads
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
73
|
+
// One-time orientation packet shown when projectstore first loads. A bound
|
|
74
|
+
// project shows it once per project; a project that is not bound shows it once
|
|
75
|
+
// on this machine, because a user-scope install is on in every repository the
|
|
76
|
+
// person opens, and writing a marker into each of them is a write into
|
|
77
|
+
// projects nobody set up. A project can already be bound when the welcome
|
|
78
|
+
// fires — a fresh install into a project someone bound first is the ordinary
|
|
63
79
|
// case, not a corner (measured 2026-09-06: the first line a new user read told
|
|
64
80
|
// them to bind a project whose vault the install preview had just named). So
|
|
65
|
-
// the one instruction it carries branches on the binding
|
|
66
|
-
// the same message.
|
|
81
|
+
// the one instruction it carries branches on the binding, and so does the
|
|
82
|
+
// footer that says how often it appears; everything else is the same message.
|
|
67
83
|
function buildWelcome(cfg = null) {
|
|
68
84
|
const start =
|
|
69
85
|
cfg && cfg.vault_path
|
|
70
86
|
? `**Already bound**: this project's vault is \`${truncFront(String(cfg.vault_path), PATH_CELL)}\`. Ask for what you want — the agent picks up commands like \`${commandForm("adr")}\` and \`${commandForm("epic")}\` from the conversation, and you approve every write. If the vault has no folders yet, \`${commandForm("scaffold")}\` lays them out.`
|
|
71
87
|
: `**To start using it**: run \`${commandForm("bind", { args: "<vault-path>" })}\` and point it at an Obsidian vault (or any folder). After that, the agent will pick up commands like \`${commandForm("adr")}\` and \`${commandForm("epic")}\` from the conversation; you only approve the writes.`;
|
|
72
88
|
return [
|
|
73
|
-
|
|
89
|
+
cfg
|
|
90
|
+
? "# 👋 projectstore is loaded for the first time in this project"
|
|
91
|
+
: "# 👋 projectstore is loaded for the first time on this machine",
|
|
74
92
|
"",
|
|
75
93
|
"**What it does**: turns the conversation's decisions into a structured Obsidian-friendly markdown vault — ADRs, epics, stories, runbooks, research. Agent-maintained, you approve every write.",
|
|
76
94
|
"",
|
|
@@ -80,7 +98,7 @@ function buildWelcome(cfg = null) {
|
|
|
80
98
|
// a git marketplace's toggle means nothing to a harness installed from npm.
|
|
81
99
|
...updateHint().welcome,
|
|
82
100
|
"",
|
|
83
|
-
"_This message appears once per project._",
|
|
101
|
+
cfg ? "_This message appears once per project._" : "_This message appears once on this machine._",
|
|
84
102
|
"",
|
|
85
103
|
"_If projectstore helps you ship, a [GitHub star](https://github.com/SmartAndPoint/ProjectStore) helps others discover it. No pressure._",
|
|
86
104
|
"",
|
|
@@ -88,17 +106,30 @@ function buildWelcome(cfg = null) {
|
|
|
88
106
|
}
|
|
89
107
|
|
|
90
108
|
function showWelcomeOnce(proj, cfg = null) {
|
|
91
|
-
|
|
109
|
+
const id = activeHarnessId();
|
|
110
|
+
if (welcomeSeen(proj, id)) return "";
|
|
92
111
|
const text = buildWelcome(cfg);
|
|
93
112
|
try {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
113
|
+
let marker = null;
|
|
114
|
+
if (cfg) {
|
|
115
|
+
// Bound: the project's marker, beside the launcher (layout spec contract
|
|
116
|
+
// 5). The state directory's own .gitignore comes with it, header
|
|
117
|
+
// included, and no sessions/: it is how a project uninstall recognises
|
|
118
|
+
// this marker as ours (install spec contract 13, as amended by the
|
|
119
|
+
// shell-fetch story).
|
|
120
|
+
ensureStateRoot(proj);
|
|
121
|
+
marker = layoutPaths(proj).welcomed(id);
|
|
122
|
+
} else {
|
|
123
|
+
// Not bound: the machine's marker, and nothing in the project. With no
|
|
124
|
+
// cache root (no usable home) nothing is written and the welcome shows
|
|
125
|
+
// again next time — startup never breaks over it (skeleton spec,
|
|
126
|
+
// contract 17).
|
|
127
|
+
marker = machineMarker(id);
|
|
128
|
+
}
|
|
129
|
+
if (marker) {
|
|
130
|
+
mkdirSync(dirname(marker), { recursive: true });
|
|
131
|
+
writeFileSync(marker, new Date().toISOString() + "\n", "utf8");
|
|
132
|
+
}
|
|
102
133
|
} catch {}
|
|
103
134
|
return text;
|
|
104
135
|
}
|
|
@@ -8,20 +8,31 @@
|
|
|
8
8
|
// hands the writing to subagents — whose calls count toward the score but are
|
|
9
9
|
// never the audience — and then answers the user. Stop fires exactly there.
|
|
10
10
|
//
|
|
11
|
-
//
|
|
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.6",
|
|
4
4
|
"description": "Your agent runs the project through a verified loop: task → artifact (ADR / spec / epic / story) → adversarial critic → backlog → planner → reviewer → done. Plain markdown in git — any model can pick the project up tomorrow.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"project-management",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"agentic",
|
|
18
18
|
"ai-agents"
|
|
19
19
|
],
|
|
20
|
-
"homepage": "https://
|
|
20
|
+
"homepage": "https://projectstore.dev",
|
|
21
21
|
"bugs": {
|
|
22
22
|
"url": "https://github.com/SmartAndPoint/ProjectStore/issues"
|
|
23
23
|
},
|
|
@@ -60,21 +60,20 @@
|
|
|
60
60
|
"scripts": {
|
|
61
61
|
"test": "node --test --import ./tests/fixtures/hermetic.mjs tests/*.test.mjs",
|
|
62
62
|
"guard": "node scripts/version-guard.mjs",
|
|
63
|
+
"directory:check": "node packaging/directory-guard.mjs",
|
|
63
64
|
"packlist": "node scripts/version-guard.mjs --write-packlist",
|
|
64
65
|
"adapters": "node scripts/build-adapters.mjs --write",
|
|
65
66
|
"adapters:check": "node scripts/build-adapters.mjs --check",
|
|
66
67
|
"shells": "node packaging/shells.mjs --write",
|
|
67
68
|
"shells:check": "node packaging/shells.mjs --check",
|
|
68
69
|
"shells:build": "node packaging/shells.mjs --build --out dist",
|
|
69
|
-
"release:check": "npm run adapters:check && npm run guard && npm run shells:check && npm run lint && npm run format:check && npm test && npm pack --dry-run",
|
|
70
|
-
"lint": "
|
|
71
|
-
"
|
|
72
|
-
"
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
"
|
|
76
|
-
"
|
|
77
|
-
"eslint-plugin-n": "18.4.1",
|
|
78
|
-
"prettier": "3.9.10"
|
|
70
|
+
"release:check": "npm run adapters:check && npm run guard && npm run directory:check && npm run shells:check && npm run lint && npm run format:check && npm test && npm pack --dry-run",
|
|
71
|
+
"lint:setup": "npm ci --prefix tools/lint",
|
|
72
|
+
"prelint": "node tools/lint/setup-check.mjs",
|
|
73
|
+
"preformat": "node tools/lint/setup-check.mjs",
|
|
74
|
+
"preformat:check": "node tools/lint/setup-check.mjs",
|
|
75
|
+
"lint": "node tools/lint/node_modules/eslint/bin/eslint.js --max-warnings 0 bin scripts \"hooks/*.mjs\" \"packaging/**/*.mjs\" \"tests/**/*.mjs\" eslint.config.mjs tools/lint/eslint.config.mjs tools/lint/setup-check.mjs",
|
|
76
|
+
"format": "node tools/lint/node_modules/prettier/bin/prettier.cjs --write bin scripts \"hooks/*.mjs\" \"packaging/**/*.mjs\" \"tests/**/*.mjs\" eslint.config.mjs tools/lint/eslint.config.mjs tools/lint/setup-check.mjs",
|
|
77
|
+
"format:check": "node tools/lint/node_modules/prettier/bin/prettier.cjs --check bin scripts \"hooks/*.mjs\" \"packaging/**/*.mjs\" \"tests/**/*.mjs\" eslint.config.mjs tools/lint/eslint.config.mjs tools/lint/setup-check.mjs"
|
|
79
78
|
}
|
|
80
79
|
}
|
|
@@ -383,7 +383,21 @@ export function phraseOf(role, subject, description, w) {
|
|
|
383
383
|
}
|
|
384
384
|
|
|
385
385
|
const lastSegment = (p) => String(p).replace(/\/+$/, "").split("/").pop();
|
|
386
|
-
|
|
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}`);
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
// project's decisions, not the vault's.
|
|
21
21
|
//
|
|
22
22
|
// Never reads ambient cwd or ambient env: the caller resolves the project
|
|
23
|
-
// (
|
|
23
|
+
// (verbs.mjs's resolveProject) and supplies the author. Normative: the CLI
|
|
24
24
|
// story's slice-2 plan (PS-CORE); the fresh config's keys mirror
|
|
25
25
|
// commands/bind.md step 3 (a test pins the two together).
|
|
26
26
|
//
|