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.
Files changed (57) hide show
  1. package/README.md +13 -12
  2. package/bin/projectstore-claude.mjs +2 -2
  3. package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
  4. package/node_modules/projectstore/.claude-plugin/plugin.json +1 -1
  5. package/node_modules/projectstore/README.md +153 -77
  6. package/node_modules/projectstore/commands/bind.md +6 -17
  7. package/node_modules/projectstore/commands/doctor.md +12 -7
  8. package/node_modules/projectstore/commands/statusline.md +1 -1
  9. package/node_modules/projectstore/commands/story.md +15 -2
  10. package/node_modules/projectstore/docs/extending.md +11 -6
  11. package/node_modules/projectstore/docs/getting-started.md +21 -1
  12. package/node_modules/projectstore/docs/harnesses.md +13 -10
  13. package/node_modules/projectstore/docs/how-it-works.md +6 -4
  14. package/node_modules/projectstore/harnesses/claude-code.json +16 -15
  15. package/node_modules/projectstore/harnesses/codex.json +12 -13
  16. package/node_modules/projectstore/hooks/band.tsx +48 -20
  17. package/node_modules/projectstore/hooks/pre-compact.mjs +7 -10
  18. package/node_modules/projectstore/hooks/session-rules.mjs +14 -8
  19. package/node_modules/projectstore/hooks/session-start.mjs +50 -18
  20. package/node_modules/projectstore/hooks/session-stop.mjs +8 -6
  21. package/node_modules/projectstore/package.json +11 -2
  22. package/node_modules/projectstore/scripts/band-shared.mjs +141 -24
  23. package/node_modules/projectstore/scripts/band.mjs +72 -21
  24. package/node_modules/projectstore/scripts/binding.mjs +616 -85
  25. package/node_modules/projectstore/scripts/build-adapters.mjs +73 -29
  26. package/node_modules/projectstore/scripts/cli.mjs +1015 -185
  27. package/node_modules/projectstore/scripts/codemap.mjs +22 -7
  28. package/node_modules/projectstore/scripts/convergence.mjs +253 -83
  29. package/node_modules/projectstore/scripts/diff-refs.mjs +14 -8
  30. package/node_modules/projectstore/scripts/doctor-report.mjs +91 -23
  31. package/node_modules/projectstore/scripts/doctor.mjs +1317 -371
  32. package/node_modules/projectstore/scripts/draft.mjs +5 -2
  33. package/node_modules/projectstore/scripts/fetch-shell.mjs +188 -39
  34. package/node_modules/projectstore/scripts/graph.mjs +19 -13
  35. package/node_modules/projectstore/scripts/harness.mjs +123 -48
  36. package/node_modules/projectstore/scripts/install-harness.mjs +3532 -618
  37. package/node_modules/projectstore/scripts/kanban.mjs +2 -1
  38. package/node_modules/projectstore/scripts/lib.mjs +694 -159
  39. package/node_modules/projectstore/scripts/mcp.mjs +386 -105
  40. package/node_modules/projectstore/scripts/portable-registration.mjs +224 -51
  41. package/node_modules/projectstore/scripts/provenance.mjs +26 -10
  42. package/node_modules/projectstore/scripts/query.mjs +378 -80
  43. package/node_modules/projectstore/scripts/reconcile.mjs +55 -18
  44. package/node_modules/projectstore/scripts/run-host.mjs +62 -17
  45. package/node_modules/projectstore/scripts/scaffold.mjs +89 -28
  46. package/node_modules/projectstore/scripts/setup.mjs +987 -0
  47. package/node_modules/projectstore/scripts/stages.mjs +5 -1
  48. package/node_modules/projectstore/scripts/statusline-launcher.mjs +36 -11
  49. package/node_modules/projectstore/scripts/statusline.mjs +32 -14
  50. package/node_modules/projectstore/scripts/story-section.mjs +39 -17
  51. package/node_modules/projectstore/scripts/surfaces.mjs +416 -95
  52. package/node_modules/projectstore/scripts/term.mjs +382 -97
  53. package/node_modules/projectstore/scripts/tokens.mjs +106 -28
  54. package/node_modules/projectstore/scripts/touch-session.mjs +109 -70
  55. package/node_modules/projectstore/scripts/version-guard.mjs +74 -27
  56. package/node_modules/projectstore/scripts/worktree.mjs +16 -5
  57. 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(JSON.stringify({
75
- hookSpecificOutput: {
76
- hookEventName: "Stop",
77
- additionalContext: entryReminderText(score),
78
- },
79
- }) + "\n");
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.29.3",
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 (/[\/\\\0]/.test(sessionId) || sessionId === "." || sessionId === "..") return null;
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", "band_pinned", "band_goal", "band_story", "band_agent",
67
- "band_done", "band_in_work", "band_backlog", "band_stories", "band_no_stories",
68
- "band_more", "band_hint", "band_ended_done", "band_ended_stopped",
69
- "band_verb_critic", "band_verb_reviewer", "band_verb_planner", "band_verb_clerk",
70
- "band_verb_librarian", "band_verb_archaeologist", "band_verb_other",
71
- "band_step_reads", "band_step_writes", "band_step_edits", "band_step_searches",
72
- "band_step_runs", "band_step_web", "band_step_vault",
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 { entries = await fs.list(stories); } catch { entries = []; }
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`))) out.push(`${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], [0x2e80, 0x303e], [0x3041, 0x33ff], [0x3400, 0x4dbf],
169
- [0x4e00, 0x9fff], [0xa000, 0xa4cf], [0xac00, 0xd7a3], [0xf900, 0xfaff],
170
- [0xfe30, 0xfe4f], [0xff00, 0xff60], [0xffe0, 0xffe6], [0x1f300, 0x1f64f],
171
- [0x1f900, 0x1f9ff], [0x20000, 0x3fffd],
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"))) found.push({ at: m.index, rel: m[1] });
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).replace(/^(\s*cd\s+("[^"]*"|'[^']*'|\S+)\s*&&\s*)+/, "").trim().split(/\s+/).filter((x) => !/^\w+=/.test(x));
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("-")) return lastSegment(arg).replace(/\.(m?js|cjs|py|sh|ts)$/, "");
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 = typeof call.file_path === "string" ? call.file_path : typeof call.notebook_path === "string" ? call.notebook_path : null;
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) return `${w.band_step_edits} ${vaultRel(vault, 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.filter((a) => a.status !== "running").sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0) || later(a, b));
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 { folder = folderByKind(loadLayout(cfg.layout || "engineering", root), "epic"); } catch { folder = null; }
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 { names = readdirSync(dir); } catch { return []; }
39
- return names.filter((n) => {
40
- if (!isEpicName(n)) return false;
41
- try { return statSync(join(dir, n)).isDirectory(); } catch { return false; }
42
- }).sort();
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 { return { raw: JSON.parse(readFileSync(path, "utf8")), corrupt: false }; } catch { return { raw: null, corrupt: true }; }
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) return { path, refusal: { code: "UNREADABLE", message: `${path} is not valid JSON — fix or remove it first; nothing is written` } };
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) return { path, refusal: { code: "UNBOUND", message: `${projectDir} is not bound to a vault; there is no band to switch` } };
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) return { path, refusal: { code: "UNKNOWN_EPIC", message: `no epic ${arg}${ids.length ? ` — epics: ${ids.join(", ")}` : " — the vault has no epics"}` } };
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: "this session is unknown here (no session id from the host), so neither its epic nor its heartbeat can be read",
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: "the module is not drawing (modules switched off by the host, an older Claude Code, or a crashed module)",
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 { heartbeat = heartbeatState(JSON.parse(readFileSync(join(stateDir(projectDir), name), "utf8")), now); } catch { heartbeat = "none"; }
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 = sub === "on" ? "switch the band on" : sub === "off" ? "switch the band off" : arg ? `pin the band to ${plan.after.epic}` : "release the pinned epic";
150
- return [
151
- `band ${sub}${arg ? ` ${arg}` : ""} — ${plan.path}`,
152
- ` ${what}`,
153
- ` enabled: ${plan.before.enabled ? "on" : "off"} → ${plan.after.enabled ? "on" : "off"}`,
154
- ` pinned: ${plan.before.epic || "—"} → ${plan.after.epic || "—"}`,
155
- ].join("\n") + "\n";
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
  }