projectstore-claude 0.29.3 → 0.29.4

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 (53) hide show
  1. package/bin/projectstore-claude.mjs +1 -1
  2. package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
  3. package/node_modules/projectstore/.claude-plugin/plugin.json +1 -1
  4. package/node_modules/projectstore/README.md +17 -0
  5. package/node_modules/projectstore/commands/bind.md +6 -17
  6. package/node_modules/projectstore/commands/doctor.md +12 -7
  7. package/node_modules/projectstore/commands/statusline.md +1 -1
  8. package/node_modules/projectstore/commands/story.md +15 -2
  9. package/node_modules/projectstore/docs/how-it-works.md +5 -3
  10. package/node_modules/projectstore/harnesses/claude-code.json +5 -5
  11. package/node_modules/projectstore/harnesses/codex.json +4 -5
  12. package/node_modules/projectstore/hooks/band.tsx +48 -20
  13. package/node_modules/projectstore/hooks/pre-compact.mjs +7 -10
  14. package/node_modules/projectstore/hooks/session-rules.mjs +14 -8
  15. package/node_modules/projectstore/hooks/session-start.mjs +50 -18
  16. package/node_modules/projectstore/hooks/session-stop.mjs +8 -6
  17. package/node_modules/projectstore/package.json +11 -2
  18. package/node_modules/projectstore/scripts/band-shared.mjs +141 -24
  19. package/node_modules/projectstore/scripts/band.mjs +72 -21
  20. package/node_modules/projectstore/scripts/binding.mjs +616 -85
  21. package/node_modules/projectstore/scripts/build-adapters.mjs +73 -29
  22. package/node_modules/projectstore/scripts/cli.mjs +1015 -185
  23. package/node_modules/projectstore/scripts/codemap.mjs +22 -7
  24. package/node_modules/projectstore/scripts/convergence.mjs +253 -83
  25. package/node_modules/projectstore/scripts/diff-refs.mjs +14 -8
  26. package/node_modules/projectstore/scripts/doctor-report.mjs +91 -23
  27. package/node_modules/projectstore/scripts/doctor.mjs +1317 -371
  28. package/node_modules/projectstore/scripts/draft.mjs +5 -2
  29. package/node_modules/projectstore/scripts/fetch-shell.mjs +106 -20
  30. package/node_modules/projectstore/scripts/graph.mjs +19 -13
  31. package/node_modules/projectstore/scripts/harness.mjs +123 -48
  32. package/node_modules/projectstore/scripts/install-harness.mjs +3511 -616
  33. package/node_modules/projectstore/scripts/kanban.mjs +2 -1
  34. package/node_modules/projectstore/scripts/lib.mjs +694 -159
  35. package/node_modules/projectstore/scripts/mcp.mjs +386 -105
  36. package/node_modules/projectstore/scripts/portable-registration.mjs +224 -51
  37. package/node_modules/projectstore/scripts/provenance.mjs +26 -10
  38. package/node_modules/projectstore/scripts/query.mjs +378 -80
  39. package/node_modules/projectstore/scripts/reconcile.mjs +55 -18
  40. package/node_modules/projectstore/scripts/run-host.mjs +62 -17
  41. package/node_modules/projectstore/scripts/scaffold.mjs +89 -28
  42. package/node_modules/projectstore/scripts/setup.mjs +987 -0
  43. package/node_modules/projectstore/scripts/stages.mjs +5 -1
  44. package/node_modules/projectstore/scripts/statusline-launcher.mjs +36 -11
  45. package/node_modules/projectstore/scripts/statusline.mjs +32 -14
  46. package/node_modules/projectstore/scripts/story-section.mjs +39 -17
  47. package/node_modules/projectstore/scripts/surfaces.mjs +416 -95
  48. package/node_modules/projectstore/scripts/term.mjs +382 -97
  49. package/node_modules/projectstore/scripts/tokens.mjs +106 -28
  50. package/node_modules/projectstore/scripts/touch-session.mjs +109 -70
  51. package/node_modules/projectstore/scripts/version-guard.mjs +74 -27
  52. package/node_modules/projectstore/scripts/worktree.mjs +16 -5
  53. package/package.json +2 -2
@@ -13,7 +13,7 @@
13
13
  // open. Bounded and O(1) in vault size by construction.
14
14
 
15
15
  import { existsSync, writeFileSync, mkdirSync } from "node:fs";
16
- import { join, dirname } from "node:path";
16
+ import { dirname } from "node:path";
17
17
  import {
18
18
  readConfig,
19
19
  gatherVaultFacts,
@@ -30,6 +30,7 @@ import {
30
30
  ensureStateRoot,
31
31
  syncStatusLine,
32
32
  cleanupStaleSessionState,
33
+ refreshStoryPointer,
33
34
  armReminder,
34
35
  truncEnd,
35
36
  truncFront,
@@ -64,9 +65,10 @@ function welcomedMarkerWritePath(proj) {
64
65
  // the one instruction it carries branches on the binding; everything else is
65
66
  // the same message.
66
67
  function buildWelcome(cfg = null) {
67
- const start = cfg && cfg.vault_path
68
- ? `**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.`
69
- : `**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.`;
68
+ const start =
69
+ cfg && cfg.vault_path
70
+ ? `**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
+ : `**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.`;
70
72
  return [
71
73
  "# 👋 projectstore is loaded for the first time in this project",
72
74
  "",
@@ -178,15 +180,25 @@ async function main() {
178
180
  // an unbound one that is not a worktree pays a single ~10 ms probe.
179
181
  let binding = null;
180
182
  if (!cfg) {
181
- try { binding = resolveBinding(proj); } catch {}
183
+ try {
184
+ binding = resolveBinding(proj);
185
+ } catch {}
182
186
  }
183
187
  const welcome = showWelcomeOnce(proj, cfg);
184
188
  // The person's channel carries one instruction, and it has to be the right
185
- // one: a bound project is told what it is bound to, not to bind again.
189
+ // one: a bound project is told what it is bound to, not to bind again. A
190
+ // command stands alone on its line, the update hint on lines of its own
191
+ // after it (the story "User-facing text says where to run…", rules 4 and 5).
186
192
  const welcomeSystemMessage = welcome
187
- ? (cfg && cfg.vault_path
193
+ ? cfg && cfg.vault_path
188
194
  ? `👋 projectstore: first-run welcome shown. Bound to ${cfg.vault_path}. ${updateHint().line}`
189
- : `👋 projectstore: first-run welcome shown. Start with ${commandForm("bind", { args: "<vault-path>" })}. ${updateHint().line}`)
195
+ : [
196
+ "👋 projectstore: first-run welcome shown. Start with:",
197
+ commandForm("bind", { args: "<vault-path>" }),
198
+ updateHint().line,
199
+ ]
200
+ .filter(Boolean)
201
+ .join("\n")
190
202
  : null;
191
203
 
192
204
  if (!cfg) {
@@ -196,8 +208,10 @@ async function main() {
196
208
  // whose parent is already bound. Ordering is the cheap fix; the small
197
209
  // redundancy is accepted rather than papered over with copy that will rot.
198
210
  const offer = binding && binding.state === "inheritable" ? bindingOfferText(binding) : "";
211
+ // What binding does first, then the command alone on its line: an
212
+ // in-session form that can carry the parent's vault path (rule 4).
199
213
  const offerSystemMessage = offer
200
- ? `projectstore: this worktree is unbound — ${inheritForm(binding.vaultPath, { layout: binding.layout, language: binding.language })} ${bindInherits() ? "adopts the binding of" : "binds it to the vault, layout and language of"} the checkout it was forked from.`
214
+ ? `projectstore: this worktree is unbound. ${bindInherits() ? "Adopt the binding of" : "Bind it to the vault, layout and language of"} the checkout it was forked from, in the session:\n${inheritForm(binding.vaultPath, { layout: binding.layout, language: binding.language })}`
201
215
  : null;
202
216
  const body = offer + (welcome || "");
203
217
  if (body) {
@@ -213,10 +227,23 @@ async function main() {
213
227
  // Opt-in status line: keep settings.local.json pointed at this plugin
214
228
  // version's statusline.mjs (self-heals on update). Best-effort; a settings
215
229
  // write must never break session-context injection.
216
- try { syncStatusLine(cfg, proj); } catch {}
230
+ try {
231
+ syncStatusLine(cfg, proj);
232
+ } catch {}
217
233
  // Statusline-feature housekeeping, like syncStatusLine — must run even when
218
234
  // auto_inject=false (touch-session writes pointers regardless of it).
219
- try { cleanupStaleSessionState(proj); } catch {}
235
+ try {
236
+ cleanupStaleSessionState(proj);
237
+ } catch {}
238
+ // This session's story fields from the story file, once (ADR-006, amended
239
+ // 2026-10-12): a pointer written before a close, or a story closed elsewhere
240
+ // since, stops naming finished work. A bounded read; a lost race writes
241
+ // nothing.
242
+ if (sid) {
243
+ try {
244
+ await refreshStoryPointer(proj, cfg.vault_path, sid);
245
+ } catch {}
246
+ }
220
247
 
221
248
  // Arm BEFORE the auto_inject gate. The entry reminder's markers must be
222
249
  // re-armed after a compaction whether or not this session injects context —
@@ -231,7 +258,9 @@ async function main() {
231
258
  // context while its marker persists on disk. Arming lets it fire once more;
232
259
  // the cap of two is enforced by the election, not here.
233
260
  if (sid && (input?.source === "compact" || input?.source === "clear")) {
234
- try { armReminder(proj, sid); } catch {}
261
+ try {
262
+ armReminder(proj, sid);
263
+ } catch {}
235
264
  }
236
265
 
237
266
  if (cfg.auto_inject === false) {
@@ -272,23 +301,26 @@ async function main() {
272
301
  }
273
302
  }
274
303
 
275
- // Cheap install-only doctor subset (ADR-005): one line, only when N > 0;
276
- // aborted past its budget rather than reporting a false "clean".
304
+ // Cheap install-only doctor subset (ADR-005): one part, only when N > 0;
305
+ // aborted past its budget rather than reporting a false "clean". Its doctor
306
+ // form stands alone on its line, as every command of this line does.
277
307
  let doctorMsg = null;
278
308
  let offers = [];
279
309
  try {
280
310
  const r = runStartupChecks(cfg, proj);
281
311
  if (r.skipped) {
282
- doctorMsg = `projectstore doctor: startup checks skipped — run ${commandForm("doctor")}`;
312
+ doctorMsg = `projectstore doctor: startup checks skipped. Run them in the session:\n${commandForm("doctor")}`;
283
313
  } else if (r.count > 0) {
284
- doctorMsg = `projectstore doctor: ${r.count} install issue(s) — run ${commandForm("doctor")}`;
314
+ doctorMsg = `projectstore doctor: ${r.count} install issue(s). See them in the session:\n${commandForm("doctor")}`;
285
315
  }
286
316
  // Offers (doctor's OFFER_CHECKS): one-time steps a user should see once,
287
317
  // e.g. the re-stamp after a plugin update — not issues, not silent.
288
318
  offers = (r.offers || []).map((m) => `projectstore: ${m}`);
289
319
  } catch {}
290
- const systemMessage =
291
- [welcomeSystemMessage, doctorMsg, ...offers].filter(Boolean).join(" · ") || null;
320
+ // Each part on lines of its own: an offer ends with its command alone on
321
+ // its line, and a part joined after it with ` · ` would put text after the
322
+ // command (the story "User-facing text says where to run…", rule 4).
323
+ const systemMessage = [welcomeSystemMessage, doctorMsg, ...offers].filter(Boolean).join("\n") || null;
292
324
 
293
325
  if (gatherError) {
294
326
  emit(
@@ -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.29.4",
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
  }