projectstore-claude 0.29.3 → 0.30.0
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/README.md +13 -12
- package/bin/projectstore-claude.mjs +2 -2
- package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
- package/node_modules/projectstore/.claude-plugin/plugin.json +1 -1
- package/node_modules/projectstore/README.md +153 -77
- package/node_modules/projectstore/commands/bind.md +6 -17
- package/node_modules/projectstore/commands/doctor.md +12 -7
- package/node_modules/projectstore/commands/statusline.md +1 -1
- package/node_modules/projectstore/commands/story.md +15 -2
- package/node_modules/projectstore/docs/extending.md +11 -6
- package/node_modules/projectstore/docs/getting-started.md +21 -1
- package/node_modules/projectstore/docs/harnesses.md +13 -10
- package/node_modules/projectstore/docs/how-it-works.md +6 -4
- package/node_modules/projectstore/harnesses/claude-code.json +16 -15
- package/node_modules/projectstore/harnesses/codex.json +12 -13
- package/node_modules/projectstore/hooks/band.tsx +48 -20
- package/node_modules/projectstore/hooks/pre-compact.mjs +7 -10
- package/node_modules/projectstore/hooks/session-rules.mjs +14 -8
- package/node_modules/projectstore/hooks/session-start.mjs +50 -18
- package/node_modules/projectstore/hooks/session-stop.mjs +8 -6
- package/node_modules/projectstore/package.json +11 -2
- package/node_modules/projectstore/scripts/band-shared.mjs +141 -24
- package/node_modules/projectstore/scripts/band.mjs +72 -21
- package/node_modules/projectstore/scripts/binding.mjs +616 -85
- package/node_modules/projectstore/scripts/build-adapters.mjs +73 -29
- package/node_modules/projectstore/scripts/cli.mjs +1015 -185
- package/node_modules/projectstore/scripts/codemap.mjs +22 -7
- package/node_modules/projectstore/scripts/convergence.mjs +253 -83
- package/node_modules/projectstore/scripts/diff-refs.mjs +14 -8
- package/node_modules/projectstore/scripts/doctor-report.mjs +91 -23
- package/node_modules/projectstore/scripts/doctor.mjs +1317 -371
- package/node_modules/projectstore/scripts/draft.mjs +5 -2
- package/node_modules/projectstore/scripts/fetch-shell.mjs +188 -39
- package/node_modules/projectstore/scripts/graph.mjs +19 -13
- package/node_modules/projectstore/scripts/harness.mjs +123 -48
- package/node_modules/projectstore/scripts/install-harness.mjs +3532 -618
- package/node_modules/projectstore/scripts/kanban.mjs +2 -1
- package/node_modules/projectstore/scripts/lib.mjs +694 -159
- package/node_modules/projectstore/scripts/mcp.mjs +386 -105
- package/node_modules/projectstore/scripts/portable-registration.mjs +224 -51
- package/node_modules/projectstore/scripts/provenance.mjs +26 -10
- package/node_modules/projectstore/scripts/query.mjs +378 -80
- package/node_modules/projectstore/scripts/reconcile.mjs +55 -18
- package/node_modules/projectstore/scripts/run-host.mjs +62 -17
- package/node_modules/projectstore/scripts/scaffold.mjs +89 -28
- package/node_modules/projectstore/scripts/setup.mjs +987 -0
- package/node_modules/projectstore/scripts/stages.mjs +5 -1
- package/node_modules/projectstore/scripts/statusline-launcher.mjs +36 -11
- package/node_modules/projectstore/scripts/statusline.mjs +32 -14
- package/node_modules/projectstore/scripts/story-section.mjs +39 -17
- package/node_modules/projectstore/scripts/surfaces.mjs +416 -95
- package/node_modules/projectstore/scripts/term.mjs +382 -97
- package/node_modules/projectstore/scripts/tokens.mjs +106 -28
- package/node_modules/projectstore/scripts/touch-session.mjs +109 -70
- package/node_modules/projectstore/scripts/version-guard.mjs +74 -27
- package/node_modules/projectstore/scripts/worktree.mjs +16 -5
- package/package.json +3 -3
|
@@ -71,12 +71,14 @@ async function main() {
|
|
|
71
71
|
|
|
72
72
|
if (!electEmitter(proj, sid)) return;
|
|
73
73
|
appendEntryLog(proj, { at: new Date().toISOString(), session_id: sid, score, via: "Stop" });
|
|
74
|
-
process.stdout.write(
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
74
|
+
process.stdout.write(
|
|
75
|
+
JSON.stringify({
|
|
76
|
+
hookSpecificOutput: {
|
|
77
|
+
hookEventName: "Stop",
|
|
78
|
+
additionalContext: entryReminderText(score),
|
|
79
|
+
},
|
|
80
|
+
}) + "\n",
|
|
81
|
+
);
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
main().catch(() => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "projectstore",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
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",
|
|
@@ -66,6 +66,15 @@
|
|
|
66
66
|
"shells": "node packaging/shells.mjs --write",
|
|
67
67
|
"shells:check": "node packaging/shells.mjs --check",
|
|
68
68
|
"shells:build": "node packaging/shells.mjs --build --out dist",
|
|
69
|
-
"release:check": "npm run adapters:check && npm run guard && npm run shells:check && npm test && npm pack --dry-run"
|
|
69
|
+
"release:check": "npm run adapters:check && npm run guard && npm run shells:check && npm run lint && npm run format:check && npm test && npm pack --dry-run",
|
|
70
|
+
"lint": "eslint --max-warnings 0 bin scripts \"hooks/*.mjs\" \"packaging/**/*.mjs\" \"tests/**/*.mjs\" eslint.config.mjs",
|
|
71
|
+
"format": "prettier --write bin scripts \"hooks/*.mjs\" \"packaging/**/*.mjs\" \"tests/**/*.mjs\" eslint.config.mjs",
|
|
72
|
+
"format:check": "prettier --check bin scripts \"hooks/*.mjs\" \"packaging/**/*.mjs\" \"tests/**/*.mjs\" eslint.config.mjs"
|
|
73
|
+
},
|
|
74
|
+
"devDependencies": {
|
|
75
|
+
"@eslint/js": "10.0.1",
|
|
76
|
+
"eslint": "10.12.0",
|
|
77
|
+
"eslint-plugin-n": "18.4.1",
|
|
78
|
+
"prettier": "3.9.10"
|
|
70
79
|
}
|
|
71
80
|
}
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
// Spec: the band above the prompt — rows, heartbeat, command and the status
|
|
12
12
|
// line's yield (the contract numbers below are that spec's).
|
|
13
13
|
|
|
14
|
+
import { stageOf } from "./stages.mjs";
|
|
15
|
+
|
|
14
16
|
// ─── The heartbeat (contracts 10, 11) ──────────────────────────────────
|
|
15
17
|
// While a session's band draws, the module keeps a small file fresh in the
|
|
16
18
|
// session-state directory; the status line yields its own line only while
|
|
@@ -25,7 +27,7 @@ export const HEARTBEAT_WINDOW_MS = 30_000;
|
|
|
25
27
|
// as one. Null for an id that cannot name a file.
|
|
26
28
|
export function heartbeatFile(sessionId) {
|
|
27
29
|
if (typeof sessionId !== "string" || !sessionId) return null;
|
|
28
|
-
if (/[
|
|
30
|
+
if (/[/\\\0]/.test(sessionId) || sessionId === "." || sessionId === "..") return null;
|
|
29
31
|
return `.band-${sessionId}.json`;
|
|
30
32
|
}
|
|
31
33
|
|
|
@@ -63,13 +65,34 @@ export function heartbeatNext(lastRender, wasDrawing, now) {
|
|
|
63
65
|
// templates/<lang>/strings.json, the English file the fallback per key.
|
|
64
66
|
|
|
65
67
|
export const BAND_STRING_KEYS = Object.freeze([
|
|
66
|
-
"band_this_session",
|
|
67
|
-
"
|
|
68
|
-
"
|
|
69
|
-
"
|
|
70
|
-
"
|
|
71
|
-
"
|
|
72
|
-
"
|
|
68
|
+
"band_this_session",
|
|
69
|
+
"band_pinned",
|
|
70
|
+
"band_goal",
|
|
71
|
+
"band_story",
|
|
72
|
+
"band_agent",
|
|
73
|
+
"band_done",
|
|
74
|
+
"band_in_work",
|
|
75
|
+
"band_backlog",
|
|
76
|
+
"band_stories",
|
|
77
|
+
"band_no_stories",
|
|
78
|
+
"band_more",
|
|
79
|
+
"band_hint",
|
|
80
|
+
"band_ended_done",
|
|
81
|
+
"band_ended_stopped",
|
|
82
|
+
"band_verb_critic",
|
|
83
|
+
"band_verb_reviewer",
|
|
84
|
+
"band_verb_planner",
|
|
85
|
+
"band_verb_clerk",
|
|
86
|
+
"band_verb_librarian",
|
|
87
|
+
"band_verb_archaeologist",
|
|
88
|
+
"band_verb_other",
|
|
89
|
+
"band_step_reads",
|
|
90
|
+
"band_step_writes",
|
|
91
|
+
"band_step_edits",
|
|
92
|
+
"band_step_searches",
|
|
93
|
+
"band_step_runs",
|
|
94
|
+
"band_step_web",
|
|
95
|
+
"band_step_vault",
|
|
73
96
|
]);
|
|
74
97
|
|
|
75
98
|
// The band's words for a language: the English file's, each overlaid by the
|
|
@@ -140,20 +163,54 @@ const byName = (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
|
140
163
|
export async function storyFiles(fs, epicDir) {
|
|
141
164
|
const out = [];
|
|
142
165
|
const stories = `${epicDir}/stories`;
|
|
143
|
-
let entries
|
|
144
|
-
try {
|
|
166
|
+
let entries;
|
|
167
|
+
try {
|
|
168
|
+
entries = await fs.list(stories);
|
|
169
|
+
} catch {
|
|
170
|
+
entries = [];
|
|
171
|
+
}
|
|
145
172
|
for (const e of [...entries].sort(byName)) {
|
|
146
173
|
if (e.kind === "file" && e.name.endsWith(".md")) out.push(`${stories}/${e.name}`);
|
|
147
|
-
else if (e.kind === "dir" && (await fs.exists(`${stories}/${e.name}/README.md`)))
|
|
174
|
+
else if (e.kind === "dir" && (await fs.exists(`${stories}/${e.name}/README.md`)))
|
|
175
|
+
out.push(`${stories}/${e.name}/README.md`);
|
|
176
|
+
}
|
|
177
|
+
let beside;
|
|
178
|
+
try {
|
|
179
|
+
beside = await fs.list(epicDir);
|
|
180
|
+
} catch {
|
|
181
|
+
beside = [];
|
|
148
182
|
}
|
|
149
|
-
let beside = [];
|
|
150
|
-
try { beside = await fs.list(epicDir); } catch { beside = []; }
|
|
151
183
|
for (const e of [...beside].sort(byName)) {
|
|
152
184
|
if (e.kind === "file" && e.name.startsWith("story-") && e.name.endsWith(".md")) out.push(`${epicDir}/${e.name}`);
|
|
153
185
|
}
|
|
154
186
|
return out;
|
|
155
187
|
}
|
|
156
188
|
|
|
189
|
+
// ─── This session's story (contract 8) ─────────────────────────────────
|
|
190
|
+
// The session pointer names its story by a key; the band finds that key among
|
|
191
|
+
// the epic's story files. One function for both, so a folder story and a
|
|
192
|
+
// story beside epic.md are named the way they are found.
|
|
193
|
+
|
|
194
|
+
// The key of a story file from its path below the epic folder:
|
|
195
|
+
// `stories/<x>.md` and `stories/<x>/README.md` are `<x>`, `story-<x>.md`
|
|
196
|
+
// beside epic.md is `story-<x>`; anything else is not a story (null).
|
|
197
|
+
export function storyKeyOf(relToEpic) {
|
|
198
|
+
const rel = String(relToEpic || "");
|
|
199
|
+
let m = rel.match(/^stories\/([^/]+)\.md$/);
|
|
200
|
+
if (m) return m[1];
|
|
201
|
+
m = rel.match(/^stories\/([^/]+)\/README\.md$/);
|
|
202
|
+
if (m) return m[1];
|
|
203
|
+
m = rel.match(/^(story-[^/]+)\.md$/);
|
|
204
|
+
return m ? m[1] : null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Whether a story with this status is work the session is moving: in work or
|
|
208
|
+
// backlog. Done, closed, parked, superseded, deferred or no status — not.
|
|
209
|
+
export function storyIsCurrent(status) {
|
|
210
|
+
const stage = stageOf(status);
|
|
211
|
+
return stage === "active" || stage === "backlog";
|
|
212
|
+
}
|
|
213
|
+
|
|
157
214
|
// An epic id the band may put in a path: one segment the board would list
|
|
158
215
|
// (not hidden, not a `_templates`-style folder).
|
|
159
216
|
export function isEpicName(id) {
|
|
@@ -165,12 +222,26 @@ export function isEpicName(id) {
|
|
|
165
222
|
// marks and variation selectors none.
|
|
166
223
|
|
|
167
224
|
const WIDE = [
|
|
168
|
-
[0x1100, 0x115f],
|
|
169
|
-
[
|
|
170
|
-
[
|
|
171
|
-
[
|
|
225
|
+
[0x1100, 0x115f],
|
|
226
|
+
[0x2e80, 0x303e],
|
|
227
|
+
[0x3041, 0x33ff],
|
|
228
|
+
[0x3400, 0x4dbf],
|
|
229
|
+
[0x4e00, 0x9fff],
|
|
230
|
+
[0xa000, 0xa4cf],
|
|
231
|
+
[0xac00, 0xd7a3],
|
|
232
|
+
[0xf900, 0xfaff],
|
|
233
|
+
[0xfe30, 0xfe4f],
|
|
234
|
+
[0xff00, 0xff60],
|
|
235
|
+
[0xffe0, 0xffe6],
|
|
236
|
+
[0x1f300, 0x1f64f],
|
|
237
|
+
[0x1f900, 0x1f9ff],
|
|
238
|
+
[0x20000, 0x3fffd],
|
|
239
|
+
];
|
|
240
|
+
const ZERO = [
|
|
241
|
+
[0x0300, 0x036f],
|
|
242
|
+
[0x200b, 0x200f],
|
|
243
|
+
[0xfe00, 0xfe0f],
|
|
172
244
|
];
|
|
173
|
-
const ZERO = [[0x0300, 0x036f], [0x200b, 0x200f], [0xfe00, 0xfe0f]];
|
|
174
245
|
const within = (cp, ranges) => ranges.some(([a, b]) => cp >= a && cp <= b);
|
|
175
246
|
|
|
176
247
|
export function cellWidth(text) {
|
|
@@ -228,6 +299,38 @@ export function progressLayout(counts, hint, bodyColumns) {
|
|
|
228
299
|
return { bar: room >= BAR_MIN ? Math.min(BAR_MAX, room) : 0, hint: false };
|
|
229
300
|
}
|
|
230
301
|
|
|
302
|
+
// The first characters of `text` that fit `cells`, with `…` when it was cut.
|
|
303
|
+
export function cutCells(text, cells) {
|
|
304
|
+
const s = String(text);
|
|
305
|
+
if (cellWidth(s) <= cells) return s;
|
|
306
|
+
let out = "";
|
|
307
|
+
let w = 0;
|
|
308
|
+
for (const ch of s) {
|
|
309
|
+
const cw = cellWidth(ch);
|
|
310
|
+
if (w + cw > cells - 1) break;
|
|
311
|
+
out += ch;
|
|
312
|
+
w += cw;
|
|
313
|
+
}
|
|
314
|
+
return `${out}…`;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ` · <status>`, `_` and `-` shown as spaces; "" with no status.
|
|
318
|
+
export function storyTail(status) {
|
|
319
|
+
return status ? ` · ${String(status).replace(/[_-]/g, " ")}` : "";
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// The story row: ` <label> <title> · <status>`, at most bodyColumns − 3
|
|
323
|
+
// cells — the room the progress row's formula leaves (the box's padding, then
|
|
324
|
+
// the strict fit). The status is never cut: when the row is longer, the title
|
|
325
|
+
// is cut with `…` (to `…` alone at worst).
|
|
326
|
+
export function storyLayout(label, title, status, bodyColumns) {
|
|
327
|
+
const tail = storyTail(status);
|
|
328
|
+
// No width from the host: draw the title whole and let the host truncate.
|
|
329
|
+
if (!Number.isFinite(bodyColumns)) return { title: String(title), tail };
|
|
330
|
+
const room = bodyColumns - 2 - 3 - cellWidth(`${label} `) - cellWidth(tail) - 1;
|
|
331
|
+
return { title: cutCells(title, Math.max(1, room)), tail };
|
|
332
|
+
}
|
|
333
|
+
|
|
231
334
|
// ─── The agent row (contract 12) ───────────────────────────────────────
|
|
232
335
|
|
|
233
336
|
export const AGENT_PREFIX = "projectstore:";
|
|
@@ -249,7 +352,8 @@ export function subjectPath(prompt, vault, kindFolders) {
|
|
|
249
352
|
const text = String(prompt || "");
|
|
250
353
|
const found = [];
|
|
251
354
|
if (vault) {
|
|
252
|
-
for (const m of text.matchAll(new RegExp(`${escRe(vault)}/([^\\s\`'")\\]]+?\\.md)`, "g")))
|
|
355
|
+
for (const m of text.matchAll(new RegExp(`${escRe(vault)}/([^\\s\`'")\\]]+?\\.md)`, "g")))
|
|
356
|
+
found.push({ at: m.index, rel: m[1] });
|
|
253
357
|
}
|
|
254
358
|
const kinds = (kindFolders || []).filter(Boolean).map(escRe);
|
|
255
359
|
if (kinds.length) {
|
|
@@ -283,9 +387,14 @@ const vaultRel = (vault, p) => (vault && p.startsWith(`${vault}/`) ? p.slice(vau
|
|
|
283
387
|
|
|
284
388
|
// `node /x/reconcile.mjs --a` reads `reconcile`, `git diff --stat` reads `git diff`.
|
|
285
389
|
export function commandWord(command) {
|
|
286
|
-
const words = String(command)
|
|
390
|
+
const words = String(command)
|
|
391
|
+
.replace(/^(\s*cd\s+("[^"]*"|'[^']*'|\S+)\s*&&\s*)+/, "")
|
|
392
|
+
.trim()
|
|
393
|
+
.split(/\s+/)
|
|
394
|
+
.filter((x) => !/^\w+=/.test(x));
|
|
287
395
|
const [cmd = "", arg = ""] = words.map((x) => x.replace(/^["']|["']$/g, ""));
|
|
288
|
-
if (/^(node|python3?|bash|sh|npx|bun|deno|tsx)$/.test(lastSegment(cmd)) && arg && !arg.startsWith("-"))
|
|
396
|
+
if (/^(node|python3?|bash|sh|npx|bun|deno|tsx)$/.test(lastSegment(cmd)) && arg && !arg.startsWith("-"))
|
|
397
|
+
return lastSegment(arg).replace(/\.(m?js|cjs|py|sh|ts)$/, "");
|
|
289
398
|
return /^[a-z][\w:-]*$/.test(arg) ? `${lastSegment(cmd)} ${arg}` : lastSegment(cmd);
|
|
290
399
|
}
|
|
291
400
|
|
|
@@ -295,10 +404,16 @@ const QUIET = /^(echo|printf|true|sleep|:)(\s|$)/;
|
|
|
295
404
|
// nothing worth a row (an echo, a tool the band does not name).
|
|
296
405
|
export function stepOf(call, vault, w) {
|
|
297
406
|
const tool = typeof call.tool === "string" ? call.tool : "";
|
|
298
|
-
const path =
|
|
407
|
+
const path =
|
|
408
|
+
typeof call.file_path === "string"
|
|
409
|
+
? call.file_path
|
|
410
|
+
: typeof call.notebook_path === "string"
|
|
411
|
+
? call.notebook_path
|
|
412
|
+
: null;
|
|
299
413
|
if (tool === "Read" && path) return `${w.band_step_reads} ${vaultRel(vault, path)}`;
|
|
300
414
|
if (tool === "Write" && path) return `${w.band_step_writes} ${vaultRel(vault, path)}`;
|
|
301
|
-
if ((tool === "Edit" || tool === "MultiEdit" || tool === "NotebookEdit") && path)
|
|
415
|
+
if ((tool === "Edit" || tool === "MultiEdit" || tool === "NotebookEdit") && path)
|
|
416
|
+
return `${w.band_step_edits} ${vaultRel(vault, path)}`;
|
|
302
417
|
if (tool === "Grep" || tool === "Glob") {
|
|
303
418
|
if (typeof call.pattern !== "string") return w.band_step_searches;
|
|
304
419
|
return `${w.band_step_searches} ${call.pattern.length > 32 ? call.pattern.slice(0, 31) + "…" : call.pattern}`;
|
|
@@ -323,7 +438,9 @@ export function leadAgent(agents, now) {
|
|
|
323
438
|
const order = new Map(live.map((a, i) => [a, i]));
|
|
324
439
|
const later = (a, b) => order.get(b) - order.get(a);
|
|
325
440
|
const running = live.filter((a) => a.status === "running").sort((a, b) => b.startedAt - a.startedAt || later(a, b));
|
|
326
|
-
const ended = live
|
|
441
|
+
const ended = live
|
|
442
|
+
.filter((a) => a.status !== "running")
|
|
443
|
+
.sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0) || later(a, b));
|
|
327
444
|
const lead = running[0] || ended[0] || null;
|
|
328
445
|
return { lead, others: lead ? live.length - 1 : 0, live };
|
|
329
446
|
}
|
|
@@ -31,20 +31,38 @@ export function bandKeys(cfg) {
|
|
|
31
31
|
export function epicIds(cfg, root) {
|
|
32
32
|
if (!cfg || !cfg.vault_path) return [];
|
|
33
33
|
let folder;
|
|
34
|
-
try {
|
|
34
|
+
try {
|
|
35
|
+
folder = folderByKind(loadLayout(cfg.layout || "engineering", root), "epic");
|
|
36
|
+
} catch {
|
|
37
|
+
folder = null;
|
|
38
|
+
}
|
|
35
39
|
if (!folder) return [];
|
|
36
40
|
const dir = join(cfg.vault_path, folder.path);
|
|
37
|
-
let names
|
|
38
|
-
try {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
41
|
+
let names;
|
|
42
|
+
try {
|
|
43
|
+
names = readdirSync(dir);
|
|
44
|
+
} catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
return names
|
|
48
|
+
.filter((n) => {
|
|
49
|
+
if (!isEpicName(n)) return false;
|
|
50
|
+
try {
|
|
51
|
+
return statSync(join(dir, n)).isDirectory();
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
.sort();
|
|
43
57
|
}
|
|
44
58
|
|
|
45
59
|
function readBinding(path) {
|
|
46
60
|
if (!existsSync(path)) return { raw: null, corrupt: false };
|
|
47
|
-
try {
|
|
61
|
+
try {
|
|
62
|
+
return { raw: JSON.parse(readFileSync(path, "utf8")), corrupt: false };
|
|
63
|
+
} catch {
|
|
64
|
+
return { raw: null, corrupt: true };
|
|
65
|
+
}
|
|
48
66
|
}
|
|
49
67
|
|
|
50
68
|
// What a write would do: the binding's path, its band keys before and after,
|
|
@@ -53,9 +71,20 @@ function readBinding(path) {
|
|
|
53
71
|
export function planBand(projectDir, sub, arg, { env = process.env, root } = {}) {
|
|
54
72
|
const path = harnessConfigPath(projectDir, env);
|
|
55
73
|
const { raw, corrupt } = readBinding(path);
|
|
56
|
-
if (corrupt)
|
|
74
|
+
if (corrupt)
|
|
75
|
+
return {
|
|
76
|
+
path,
|
|
77
|
+
refusal: {
|
|
78
|
+
code: "UNREADABLE",
|
|
79
|
+
message: `${path} is not valid JSON — fix or remove it first; nothing is written`,
|
|
80
|
+
},
|
|
81
|
+
};
|
|
57
82
|
// A bare `{ band: {} }` file would read as a binding: never create one.
|
|
58
|
-
if (!raw)
|
|
83
|
+
if (!raw)
|
|
84
|
+
return {
|
|
85
|
+
path,
|
|
86
|
+
refusal: { code: "UNBOUND", message: `${projectDir} is not bound to a vault; there is no band to switch` },
|
|
87
|
+
};
|
|
59
88
|
const before = bandKeys(raw);
|
|
60
89
|
const next = { ...(raw.band && typeof raw.band === "object" ? raw.band : {}) };
|
|
61
90
|
if (sub === "on") next.enabled = true;
|
|
@@ -63,7 +92,14 @@ export function planBand(projectDir, sub, arg, { env = process.env, root } = {})
|
|
|
63
92
|
else if (sub === "epic" && arg) {
|
|
64
93
|
const ids = epicIds(raw, root);
|
|
65
94
|
const hit = ids.find((id) => id.toLowerCase() === String(arg).toLowerCase());
|
|
66
|
-
if (!hit)
|
|
95
|
+
if (!hit)
|
|
96
|
+
return {
|
|
97
|
+
path,
|
|
98
|
+
refusal: {
|
|
99
|
+
code: "UNKNOWN_EPIC",
|
|
100
|
+
message: `no epic ${arg}${ids.length ? ` — epics: ${ids.join(", ")}` : " — the vault has no epics"}`,
|
|
101
|
+
},
|
|
102
|
+
};
|
|
67
103
|
next.epic = hit;
|
|
68
104
|
} else if (sub === "epic") delete next.epic;
|
|
69
105
|
else return { path, refusal: { code: "USAGE", message: `band takes ${SUBCOMMANDS.join(", ")}` } };
|
|
@@ -83,10 +119,12 @@ export function applyBand(plan) {
|
|
|
83
119
|
const REASONS = Object.freeze({
|
|
84
120
|
harness: "this harness has no band",
|
|
85
121
|
off: "switched off (projectstore band on)",
|
|
86
|
-
session_unknown:
|
|
122
|
+
session_unknown:
|
|
123
|
+
"this session is unknown here (no session id from the host), so neither its epic nor its heartbeat can be read",
|
|
87
124
|
pin_missing: "the pinned epic no longer exists",
|
|
88
125
|
no_epic: "this session has no epic, and none is pinned",
|
|
89
|
-
not_drawing:
|
|
126
|
+
not_drawing:
|
|
127
|
+
"the module is not drawing (modules switched off by the host, an older Claude Code, or a crashed module)",
|
|
90
128
|
});
|
|
91
129
|
|
|
92
130
|
// Why the band is or is not drawing in this session, read-only. The session
|
|
@@ -105,7 +143,11 @@ export function bandStatus(projectDir, cfg, { env = process.env, now = Date.now(
|
|
|
105
143
|
} catch {}
|
|
106
144
|
const name = heartbeatFile(sessionId);
|
|
107
145
|
if (name) {
|
|
108
|
-
try {
|
|
146
|
+
try {
|
|
147
|
+
heartbeat = heartbeatState(JSON.parse(readFileSync(join(stateDir(projectDir), name), "utf8")), now);
|
|
148
|
+
} catch {
|
|
149
|
+
heartbeat = "none";
|
|
150
|
+
}
|
|
109
151
|
}
|
|
110
152
|
}
|
|
111
153
|
const ids = epicIds(cfg, root);
|
|
@@ -146,11 +188,20 @@ export function renderBandStatus(s) {
|
|
|
146
188
|
}
|
|
147
189
|
|
|
148
190
|
export function renderBandPlan(plan, sub, arg) {
|
|
149
|
-
const what =
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
191
|
+
const what =
|
|
192
|
+
sub === "on"
|
|
193
|
+
? "switch the band on"
|
|
194
|
+
: sub === "off"
|
|
195
|
+
? "switch the band off"
|
|
196
|
+
: arg
|
|
197
|
+
? `pin the band to ${plan.after.epic}`
|
|
198
|
+
: "release the pinned epic";
|
|
199
|
+
return (
|
|
200
|
+
[
|
|
201
|
+
`band ${sub}${arg ? ` ${arg}` : ""} — ${plan.path}`,
|
|
202
|
+
` ${what}`,
|
|
203
|
+
` enabled: ${plan.before.enabled ? "on" : "off"} → ${plan.after.enabled ? "on" : "off"}`,
|
|
204
|
+
` pinned: ${plan.before.epic || "—"} → ${plan.after.epic || "—"}`,
|
|
205
|
+
].join("\n") + "\n"
|
|
206
|
+
);
|
|
156
207
|
}
|