projectstore-claude 0.29.2 → 0.29.3

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 +1 -1
  2. package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
  3. package/node_modules/projectstore/.claude-plugin/plugin.json +2 -1
  4. package/node_modules/projectstore/README.md +27 -7
  5. package/node_modules/projectstore/commands/bar.md +30 -0
  6. package/node_modules/projectstore/commands/bind.md +14 -7
  7. package/node_modules/projectstore/commands/scaffold.md +24 -16
  8. package/node_modules/projectstore/commands/status.md +1 -1
  9. package/node_modules/projectstore/commands/statusline.md +1 -1
  10. package/node_modules/projectstore/docs/alongside.md +167 -0
  11. package/node_modules/projectstore/docs/extending.md +17 -3
  12. package/node_modules/projectstore/docs/getting-started.md +18 -9
  13. package/node_modules/projectstore/docs/harnesses.md +1 -1
  14. package/node_modules/projectstore/docs/how-it-works.md +7 -0
  15. package/node_modules/projectstore/harnesses/claude-code.json +18 -3
  16. package/node_modules/projectstore/harnesses/codex.json +12 -1
  17. package/node_modules/projectstore/hooks/band-state.d.ts +44 -0
  18. package/node_modules/projectstore/hooks/band.tsx +594 -0
  19. package/node_modules/projectstore/hooks/hooks.json +1 -0
  20. package/node_modules/projectstore/hooks/session-start.mjs +6 -2
  21. package/node_modules/projectstore/package.json +2 -1
  22. package/node_modules/projectstore/scaffold/headings.json +8 -0
  23. package/node_modules/projectstore/scripts/band-shared.mjs +329 -0
  24. package/node_modules/projectstore/scripts/band.mjs +156 -0
  25. package/node_modules/projectstore/scripts/binding.mjs +157 -17
  26. package/node_modules/projectstore/scripts/build-adapters.mjs +26 -7
  27. package/node_modules/projectstore/scripts/cli.mjs +545 -179
  28. package/node_modules/projectstore/scripts/convergence.mjs +468 -0
  29. package/node_modules/projectstore/scripts/doctor-report.mjs +185 -0
  30. package/node_modules/projectstore/scripts/doctor.mjs +80 -62
  31. package/node_modules/projectstore/scripts/fetch-shell.mjs +283 -0
  32. package/node_modules/projectstore/scripts/harness.mjs +178 -7
  33. package/node_modules/projectstore/scripts/install-harness.mjs +1488 -257
  34. package/node_modules/projectstore/scripts/kanban.mjs +4 -20
  35. package/node_modules/projectstore/scripts/lib.mjs +257 -58
  36. package/node_modules/projectstore/scripts/portable-registration.mjs +62 -15
  37. package/node_modules/projectstore/scripts/query.mjs +83 -12
  38. package/node_modules/projectstore/scripts/run-host.mjs +94 -0
  39. package/node_modules/projectstore/scripts/scaffold.mjs +183 -0
  40. package/node_modules/projectstore/scripts/stages.mjs +80 -0
  41. package/node_modules/projectstore/scripts/statusline.mjs +25 -2
  42. package/node_modules/projectstore/scripts/surfaces.mjs +78 -13
  43. package/node_modules/projectstore/scripts/term.mjs +729 -39
  44. package/node_modules/projectstore/scripts/version-guard.mjs +5 -0
  45. package/node_modules/projectstore/templates/de/strings.json +63 -1
  46. package/node_modules/projectstore/templates/de/vault-readme.md.tmpl +13 -0
  47. package/node_modules/projectstore/templates/en/strings.json +63 -1
  48. package/node_modules/projectstore/templates/en/vault-readme.md.tmpl +13 -0
  49. package/node_modules/projectstore/templates/es/strings.json +63 -1
  50. package/node_modules/projectstore/templates/es/vault-readme.md.tmpl +13 -0
  51. package/node_modules/projectstore/templates/fr/strings.json +63 -1
  52. package/node_modules/projectstore/templates/fr/vault-readme.md.tmpl +13 -0
  53. package/node_modules/projectstore/templates/ru/strings.json +63 -1
  54. package/node_modules/projectstore/templates/ru/vault-readme.md.tmpl +13 -0
  55. package/node_modules/projectstore/templates/zh/strings.json +63 -1
  56. package/node_modules/projectstore/templates/zh/vault-readme.md.tmpl +13 -0
  57. package/package.json +2 -2
@@ -54,6 +54,11 @@ import {
54
54
  import { walkVaultFiles } from "./doctor.mjs";
55
55
  import { buildGraph } from "./graph.mjs";
56
56
  import { findStories, statusToColumn } from "./kanban.mjs";
57
+ // status's text screen is drawn with the presentation primitives; term.mjs
58
+ // has no side effect at import, so the MCP server's graph gains nothing it
59
+ // could trip on (cli.mjs imports it already).
60
+ import { PLAIN, painter, icon, heading, kv, rows, wrap, commandBlock } from "./term.mjs";
61
+ import { invokedAs } from "./harness.mjs";
57
62
 
58
63
  export const SEARCH_DEFAULT_LIMIT = 20;
59
64
  export const SEARCH_HARD_CAP = 100;
@@ -202,19 +207,85 @@ export function status(cfg, { project = null } = {}) {
202
207
  return out;
203
208
  }
204
209
 
205
- export function renderStatus(r) {
206
- if (!r.bound) return `Not bound${r.project ? ` — ${r.project}` : ""}. Run ${commandForm("bind", { args: "<vault>" })} in a session.\n`;
207
- const lines = [`Vault: ${r.vault_path}${r.vault_exists ? "" : " (missing)"}`, `Layout: ${r.layout} · language: ${r.language} · auto_inject: ${r.auto_inject} · approval_mode: ${r.approval_mode} · spec_policy: ${r.spec_policy} · lifecycle_gates: ${r.lifecycle_gates}`];
208
- if (r.stories && r.stories.status !== "ok") lines.push(`Stories: not counted — ${r.stories.error}`);
209
- else if (r.stories) {
210
- lines.push(`Stories: ${r.stories.total} on the board — ${Object.entries(r.stories.by_status).map(([k, v]) => `${k} ${v}`).join(", ") || "none"}${r.stories.off_board_total ? `; ${r.stories.off_board_total} off it (${Object.entries(r.stories.off_board).map(([k, v]) => `${k} ${v}`).join(", ")})` : ""}`);
211
- if (r.stories.in_progress_total) {
212
- lines.push(`In progress (${r.stories.in_progress_total}):`);
213
- for (const s of r.stories.in_progress) lines.push(` ${s.epic}: ${s.title} — ${s.path}${s.started_at ? ` (since ${s.started_at})` : ""}`);
214
- } else lines.push("In progress: nothing");
210
+ // Stands in for a space wrap() must not break at; given back after it.
211
+ const GLUE = "";
212
+
213
+ // A story's start as a date: a date stays as written; a timestamp is read in
214
+ // the reader's own zone. Anything else prints as it is.
215
+ export function startDate(at) {
216
+ const s = String(at);
217
+ if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
218
+ const d = /^\d{4}-\d{2}-\d{2}T/.test(s) ? new Date(s) : null;
219
+ if (!d || Number.isNaN(d.getTime())) return s;
220
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
221
+ }
222
+
223
+ // status's screen (presentation spec contract 12), from status()'s result as
224
+ // it is — the envelope is untouched. The binding as key–value lines; the
225
+ // board's counts on one line; the stories in progress grouped by epic, each a
226
+ // row of its start, its title and its path, the list ending with `+N more —
227
+ // see <the kanban view's path>` when status() listed fewer than it counted;
228
+ // then the views' freshness and the sessions. No badge: that is the install
229
+ // family's and doctor's. `caps` is the stream's (term.mjs; plain by default):
230
+ // prose wraps, a long title wraps under itself and a row's note moves on a
231
+ // live terminal only, so a pipe gets one line per row. `env` names the
232
+ // commands when nothing is bound: the session's form for the listening
233
+ // harness, and the terminal's as this run was invoked (a shell's own name).
234
+ export function renderStatus(r, { caps: c = PLAIN, env = process.env } = {}) {
235
+ const paint = painter(c);
236
+ const dash = icon(c, "dash"), dot = icon(c, "dot");
237
+ const prose = (text, indent = "") => indent + wrap(text, c.live ? c.width : 0, indent + " ");
238
+ // A heading whose counts wrap only between them, after the dot: a count is
239
+ // never parted from its word. Each part's spaces are glued for wrap() and
240
+ // given back after it.
241
+ const counted = (title, parts) => {
242
+ const glued = parts.map((x) => (Array.isArray(x) ? [x[0].split(" ").join(GLUE), x[1]] : String(x).split(" ").join(GLUE)));
243
+ return prose(heading(c, title, glued).split(` ${dot} `).join(`${GLUE}${dot} `)).split(GLUE).join(" ");
244
+ };
245
+ if (!r.bound) {
246
+ return [
247
+ prose(`Not bound${r.project ? ` ${dash} ${r.project}` : ""}`),
248
+ "",
249
+ prose("Next: bind it to a vault, in a session or from a terminal:"),
250
+ ...commandBlock(c, [commandForm("bind", { args: "<vault>", env }), `${invokedAs(env).cmd} bind <vault>`]),
251
+ ].join("\n") + "\n";
252
+ }
253
+ const missing = r.vault_exists ? "" : ` ${paint("attention", `${icon(c, "warning")} missing`)}`;
254
+ const binding = [["vault_path", `${r.vault_path}${missing}`], ["layout", r.layout], ["language", r.language], ["auto_inject", r.auto_inject], ["approval_mode", r.approval_mode], ["spec_policy", r.spec_policy], ["lifecycle_gates", r.lifecycle_gates]];
255
+ const lines = [heading(c, "Binding"), ...kv(c, binding.filter(([, v]) => v !== null && v !== undefined))];
256
+ const s = r.stories;
257
+ if (s && s.status !== "ok") lines.push("", prose(heading(c, "Stories", [[`not counted ${dash} ${s.error}`, "attention"]])));
258
+ else if (s) {
259
+ const counts = [`${s.total} on the board`, ...Object.entries(s.by_status).map(([k, n]) => `${n} ${k}`)];
260
+ if (s.off_board_total) counts.push([`${s.off_board_total} off the board (${Object.entries(s.off_board).map(([k, n]) => `${k} ${n}`).join(", ")})`, "explanation"]);
261
+ lines.push("", counted("Stories", counts));
262
+ const listed = s.in_progress || [];
263
+ lines.push("", heading(c, "In progress", [s.in_progress_total ? String(s.in_progress_total) : ["nothing", "explanation"]]));
264
+ const byEpic = new Map();
265
+ for (const x of listed) {
266
+ const epic = x.epic || "";
267
+ if (!byEpic.has(epic)) byEpic.set(epic, []);
268
+ byEpic.get(epic).push(x);
269
+ }
270
+ for (const [epic, xs] of byEpic) {
271
+ if (epic) lines.push(` ${paint("emphasis", epic)}`);
272
+ // A row per story (contract 5): the dot, the day it started, its title
273
+ // and its path. On a live terminal a title wider than the row wraps at
274
+ // its spaces, under itself, and a path that does not fit moves beneath
275
+ // it, at the same column. A story with no title is its path, never
276
+ // wrapped.
277
+ lines.push(...rows(c, xs.map((x) => ({ glyph: "dot", role: "explanation", action: x.started_at ? startDate(x.started_at) : "", target: x.title || x.path, prose: Boolean(x.title), note: x.title ? x.path : "" })), { indent: 4 }));
278
+ }
279
+ const more = (s.in_progress_total || 0) - listed.length;
280
+ const kanban = r.views && r.views.kanban ? r.views.kanban.path : null;
281
+ if (more > 0) lines.push(` ${paint("explanation", `+${more} more${kanban ? ` ${dash} see ${kanban}` : ""}`)}`);
282
+ }
283
+ if (r.views) {
284
+ // A view that is stale or missing wants a reconcile: both in the attention role.
285
+ const state = (v) => (!v.exists ? ["missing", "attention"] : v.stale === null ? ["unknown", "explanation"] : v.stale ? ["stale", "attention"] : ["fresh", "done"]);
286
+ lines.push("", counted("Views", Object.values(r.views).map((v) => { const [word, role] = state(v); return `${v.path} ${paint(role, word)}`; })));
215
287
  }
216
- if (r.views) lines.push(`Views: ${Object.entries(r.views).map(([k, v]) => `${k} ${!v.exists ? "missing" : v.stale === null ? "unknown" : v.stale ? "stale" : "fresh"}`).join(" · ")}`);
217
- if (r.sessions) lines.push(`Sessions active: ${r.sessions.active}`);
288
+ if (r.sessions) lines.push(heading(c, "Sessions", [`${r.sessions.active} active`]));
218
289
  return lines.join("\n") + "\n";
219
290
  }
220
291
 
@@ -0,0 +1,94 @@
1
+ // projectstore — run-host.mjs: one asynchronous runner for a host command.
2
+ //
3
+ // The install verbs ran every child through spawnSync, which blocks the event
4
+ // loop: nothing can animate while it waits. The shell fetch of install and
5
+ // upgrade (the story "Install and upgrade fetch a shell-rooted harness at the
6
+ // bin's own version, once per run; plan and uninstall never fetch") is the
7
+ // first child a person watches for seconds, so it gets a runner that yields:
8
+ // a spawn with stdin ignored and both outputs piped, resolved with spawnSync's
9
+ // own result shape so a caller written for one reads the other. APPLY's host
10
+ // commands move onto it in a later story (the APPLY story adds `signal`).
11
+ //
12
+ // runHost(bin, argv, { env, cwd, budget, spawn, shell })
13
+ // → Promise<{ status, signal, stdout, stderr, error }>
14
+ // startFailure(err, { bin, resolved, cwd }) → what Node could not say
15
+ // HOST_BUDGET_MS → the outer bound, 120 s
16
+ //
17
+ // Leaf module: node built-ins only. Only install-harness.mjs and
18
+ // fetch-shell.mjs import it; hooks, doctor, surfaces and lib never do (the
19
+ // portability suite asserts it), so the SessionStart graph never loads it.
20
+
21
+ import { spawn as nodeSpawn } from "node:child_process";
22
+ import { statSync } from "node:fs";
23
+
24
+ // The bound every host command gets, ours, outside whatever the child sets
25
+ // for itself (npm's per-request timeouts, retried, can outlast it).
26
+ export const HOST_BUDGET_MS = 120000;
27
+
28
+ // How long the pipes may drain after the child exits. A grandchild that keeps
29
+ // a pipe open must not hold the run: `close` waited 3 s for one where `exit`
30
+ // came at 99 ms (measured 2026-10-09).
31
+ const DRAIN_MS = 200;
32
+
33
+ // spawnSync's shape, asynchronously: `status` is the exit code, or null when
34
+ // the child never started or was stopped; `error` carries the start failure
35
+ // (never close's -2), or ETIMEDOUT when the budget ran out. Output is decoded
36
+ // as UTF-8 by the streams themselves, so a character split across two writes
37
+ // stays whole (measured). The budget's SIGTERM settles at once: a child that
38
+ // ignores it, or a grandchild still writing, is left to the OS — its pipes are
39
+ // destroyed and its handle unref'd, so nothing of it keeps this process open.
40
+ // `shell` exists for win32, where npm is npm.cmd and runs only through a
41
+ // shell; that path is unverified, as the install spec says of Windows.
42
+ export function runHost(bin, argv, { env = process.env, cwd = process.cwd(), budget = HOST_BUDGET_MS, spawn = nodeSpawn, shell = false } = {}) {
43
+ return new Promise((settle) => {
44
+ const out = [], err = [];
45
+ let child = null, done = false, timer = null, drain = null, exited = null;
46
+ const release = () => {
47
+ for (const s of [child?.stdout, child?.stderr]) { try { s?.destroy(); } catch {} }
48
+ try { child?.unref?.(); } catch {}
49
+ };
50
+ const finish = (r) => {
51
+ if (done) return;
52
+ done = true;
53
+ clearTimeout(timer);
54
+ clearTimeout(drain);
55
+ settle({ status: null, signal: null, stdout: out.join(""), stderr: err.join(""), error: undefined, ...r });
56
+ };
57
+ try { child = spawn(bin, argv, { env, cwd, stdio: ["ignore", "pipe", "pipe"], shell }); }
58
+ catch (e) { finish({ error: e }); return; }
59
+ for (const [s, sink] of [[child.stdout, out], [child.stderr, err]]) {
60
+ if (!s) continue;
61
+ s.setEncoding("utf8");
62
+ s.on("data", (d) => sink.push(d));
63
+ // A stream that ends after the exit may be the last one the drain waits on.
64
+ s.on("end", () => { if (exited && drained()) finish(exited); });
65
+ s.on("error", () => {});
66
+ }
67
+ const drained = () => [child.stdout, child.stderr].every((s) => !s || s.readableEnded || s.destroyed);
68
+ child.on("error", (e) => { finish({ error: e, status: null }); release(); });
69
+ child.on("exit", (code, signal) => {
70
+ exited = { status: code, signal };
71
+ if (drained()) { finish(exited); return; }
72
+ drain = setTimeout(() => { finish(exited); release(); }, DRAIN_MS);
73
+ });
74
+ timer = setTimeout(() => {
75
+ try { child.kill("SIGTERM"); } catch {}
76
+ finish({ status: null, signal: "SIGTERM", error: Object.assign(new Error(`${bin} ran past its ${budget} ms budget and was stopped`), { code: "ETIMEDOUT" }) });
77
+ release();
78
+ }, budget);
79
+ });
80
+ }
81
+
82
+ // What Node cannot say: spawnSync reports a working directory that does not
83
+ // exist and a binary not on PATH with the same `spawnSync <bin> ENOENT`
84
+ // (measured 2026-10-05), and spawn the same `spawn <bin> ENOENT`. The
85
+ // filesystem tells them apart, in this order (the install spec, contract 19);
86
+ // any other error passes through as Node wrote it.
87
+ export function startFailure(err, { bin, resolved, cwd }) {
88
+ let dir = false;
89
+ try { dir = statSync(cwd).isDirectory(); } catch {}
90
+ if (!dir) return `${bin} could not start: the working directory ${JSON.stringify(String(cwd))} is not an existing directory`;
91
+ if (!resolved) return `${bin} could not start: it was not found on PATH`;
92
+ if (["ENOENT", "EACCES", "ENOEXEC"].includes(err?.code)) return `${bin} could not start: ${resolved} exists but could not be run (${err.code})`;
93
+ return String(err?.message || err);
94
+ }
@@ -0,0 +1,183 @@
1
+ // projectstore — scaffold.mjs
2
+ //
3
+ // A vault's skeleton — the layout's folders, their README indexes and the
4
+ // vault's own README — as a plan and an apply: the one place it is written,
5
+ // for the CLI's `scaffold` verb, for `init` (binding.mjs) and, through the
6
+ // bin, for the session command and the Codex skill. So a vault made in a
7
+ // session, by `init` and by `setup` is the same bytes (the front-door ADR's
8
+ // decision 7). Before this module a session wrote the READMEs itself, picking
9
+ // each folder's name and description "based on the folder kind": two sessions
10
+ // wrote two vaults, and the SessionStart skeleton's Purpose column, which
11
+ // reads a folder's README prose, repeated whatever words the model chose.
12
+ // The names and descriptions are data now: templates/<lang>/strings.json →
13
+ // folders.<kind>, in the binding's language, with no English fallback.
14
+ //
15
+ // The shape is binding.mjs's. planScaffold() is pure over the filesystem: one
16
+ // row per folder, per folder README and for the vault README, each `create`
17
+ // or `exists`, and the reasons it refuses before anything is written.
18
+ // applyScaffold() writes the `create` rows and nothing else. A file that
19
+ // exists is never rewritten, whatever its content: the index rows inside a
20
+ // README are reconcile's, and a README edited by hand is the person's. "The
21
+ // layout's folders are missing" means the plan has a `create` row — a folder
22
+ // that is there without its README counts, because reconcile drops an index
23
+ // it cannot find without a word (the locale spec, contract 5).
24
+ //
25
+ // A leaf: lib.mjs and node built-ins only. binding.mjs imports this module,
26
+ // so the reverse import would be a cycle. Normative: the story "Scaffold is a
27
+ // core verb, and init creates a whole vault…" (PS-CORE).
28
+
29
+ import { existsSync, statSync, mkdirSync } from "node:fs";
30
+ import { join, basename } from "node:path";
31
+ import { pluginRoot, loadLayout, loadTemplate, loadStrings, renderTemplate, writeFileAtomic, commandForm } from "./lib.mjs";
32
+
33
+ const README = "README.md";
34
+
35
+ const kindsOf = (layout) => [...new Set((layout.folders || []).map((f) => f.kind))];
36
+ const usable = (v) => typeof v === "string" && v.trim() !== "";
37
+ const pathKind = (p) => { try { return statSync(p).isDirectory() ? "dir" : "file"; } catch { return null; } };
38
+
39
+ // What a layout needs from a language before anything is written: the vault
40
+ // README's template, the folder README's when a folder asks for one, and a
41
+ // name and a description for every kind the layout's folders declare —
42
+ // `diagram` included, though no command creates one. planScaffold refuses on
43
+ // exactly this list and doctor's checkLayoutTemplates warns on it, so the two
44
+ // cannot disagree about what a language lacks.
45
+ export function missingScaffoldRequirements(layout, lang, root = pluginRoot()) {
46
+ const missing = [];
47
+ const templates = ["vault-readme", ...((layout.folders || []).some((f) => f.readme) ? ["folder-readme"] : [])];
48
+ for (const name of templates) {
49
+ if (!existsSync(join(root, "templates", String(lang), `${name}.md.tmpl`))) {
50
+ missing.push({ what: "template", name, message: `language "${lang}" has no ${name} template (templates/${lang}/${name}.md.tmpl)` });
51
+ }
52
+ }
53
+ const strings = loadStrings(lang, root);
54
+ for (const kind of kindsOf(layout)) {
55
+ const s = strings && strings.folders && strings.folders[kind];
56
+ if (!s || !usable(s.name) || !usable(s.description)) {
57
+ missing.push({ what: "string", name: kind, message: `language "${lang}" has no name and description for the folder kind "${kind}" (templates/${lang}/strings.json → folders.${kind})` });
58
+ }
59
+ }
60
+ return missing;
61
+ }
62
+
63
+ // The vault README's folder table: a row per layout folder, its name linked
64
+ // to the folder's README (to the folder, when the layout asks for none) and
65
+ // its description — the same strings the folder READMEs carry, so the two
66
+ // never call one folder by two names.
67
+ function folderRows(layout, strings) {
68
+ return layout.folders.map((f) => {
69
+ const s = strings.folders[f.kind];
70
+ return `| [${s.name}](${f.path}/${f.readme ? README : ""}) | ${s.description} |`;
71
+ }).join("\n");
72
+ }
73
+
74
+ // What `scaffold` would write into `vault`. A vault that does not exist yet
75
+ // is planned as all `create` — what `init` plans before its mkdir. Refusal
76
+ // codes: LAYOUT, TEMPLATE, STRINGS, NOT_A_DIRECTORY, FOLDER_IS_FILE; every
77
+ // one is exit 1 upstream, and any one means nothing is written. A `create`
78
+ // row carries the bytes it would write; an `exists` row carries none.
79
+ export function planScaffold(vault, { layout, language, root = pluginRoot() } = {}) {
80
+ const refusals = [];
81
+ const rows = [];
82
+ const plan = { vault, layout, language, rows, creates: 0, refusals, ok: false };
83
+ let spec;
84
+ try {
85
+ spec = loadLayout(layout, root);
86
+ if (!spec || !Array.isArray(spec.folders)) throw new Error("it declares no folders");
87
+ } catch (e) {
88
+ refusals.push({ code: "LAYOUT", message: `the layout "${layout}" does not load: ${e.message}` });
89
+ return plan;
90
+ }
91
+ for (const m of missingScaffoldRequirements(spec, language, root)) refusals.push({ code: m.what === "template" ? "TEMPLATE" : "STRINGS", message: m.message });
92
+ if (pathKind(vault) === "file") refusals.push({ code: "NOT_A_DIRECTORY", message: `${vault} is a file, not a vault directory` });
93
+ if (refusals.length) return plan;
94
+ const strings = loadStrings(language, root);
95
+ const folderTemplate = spec.folders.some((f) => f.readme) ? loadTemplate(language, "folder-readme", root) : null;
96
+ const row = (type, rel, render = null) => {
97
+ const abs = join(vault, rel);
98
+ const action = existsSync(abs) ? "exists" : "create";
99
+ rows.push({ type, rel, abs, action, ...(render && action === "create" ? { content: render() } : {}) });
100
+ };
101
+ for (const f of spec.folders) {
102
+ if (pathKind(join(vault, f.path)) === "file") refusals.push({ code: "FOLDER_IS_FILE", message: `${f.path} exists in ${vault} as a file — the layout's ${f.kind} folder cannot be created there` });
103
+ row("folder", f.path);
104
+ if (f.readme) {
105
+ const s = strings.folders[f.kind];
106
+ row("readme", `${f.path}/${README}`, () => renderTemplate(folderTemplate, { folder_name: s.name, folder_description: s.description }));
107
+ }
108
+ }
109
+ // Last, and from the basename alone: nothing path-dependent beyond the
110
+ // directory's name, so a vault made here and one made by `init` elsewhere
111
+ // under the same name are byte-identical.
112
+ row("vault-readme", README, () => renderTemplate(loadTemplate(language, "vault-readme", root), { vault_name: basename(vault), folder_rows: folderRows(spec, strings) }));
113
+ plan.creates = rows.filter((r) => r.action === "create").length;
114
+ plan.ok = refusals.length === 0;
115
+ return plan;
116
+ }
117
+
118
+ // Writes the plan's `create` rows and nothing else: every folder first —
119
+ // writeFileAtomic never mkdirs — then each README, its absence checked again
120
+ // at the moment of writing, so a file that appeared after the plan (another
121
+ // session, a hand) is reported as there and never rewritten. The vault root is
122
+ // the caller's to make: `init` makes it, and a binding whose vault vanished is
123
+ // refused upstream, never recreated here behind the person's back.
124
+ export function applyScaffold(plan) {
125
+ if (!plan.ok) throw Object.assign(new Error(plan.refusals.map((r) => r.message).join("; ")), { code: plan.refusals[0] ? plan.refusals[0].code : "REFUSED" });
126
+ if (pathKind(plan.vault) !== "dir") throw Object.assign(new Error(`${plan.vault} does not exist — scaffold never creates the vault itself`), { code: "MISSING" });
127
+ const made = new Set();
128
+ for (const r of plan.rows) {
129
+ if (r.type !== "folder" || r.action !== "create" || existsSync(r.abs)) continue;
130
+ mkdirSync(r.abs, { recursive: true });
131
+ made.add(r.rel);
132
+ }
133
+ for (const r of plan.rows) {
134
+ if (r.type === "folder" || r.action !== "create" || existsSync(r.abs)) continue;
135
+ writeFileAtomic(r.abs, r.content);
136
+ made.add(r.rel);
137
+ }
138
+ return {
139
+ created: plan.rows.filter((r) => made.has(r.rel)).map((r) => r.rel),
140
+ exists: plan.rows.filter((r) => !made.has(r.rel)).map((r) => r.rel),
141
+ };
142
+ }
143
+
144
+ // The result a front-end reports: the rows and what became of them, never a
145
+ // file body. `path` is vault-relative.
146
+ export function scaffoldResult(plan, done = null) {
147
+ return {
148
+ vault_path: plan.vault, layout: plan.layout, language: plan.language,
149
+ rows: plan.rows.map((r) => ({ type: r.type, path: r.rel, action: r.action })),
150
+ creates: plan.creates, wrote: Boolean(done),
151
+ created: done ? done.created : [],
152
+ exists: done ? done.exists : plan.rows.filter((r) => r.action === "exists").map((r) => r.rel),
153
+ refusals: plan.refusals,
154
+ };
155
+ }
156
+
157
+ const shown = (r) => (r.type === "folder" ? `${r.rel}/` : r.rel);
158
+
159
+ // The plan as text. A bare plan ends with the command that writes it, in both
160
+ // forms — the session's and the bin's, since a git-marketplace install has no
161
+ // bin on PATH — and with `--write`, because at a terminal that form shows this
162
+ // plan again and asks. `asking`: the question follows on the same stream, so
163
+ // the closing line is left out. After an apply, what was created and what was
164
+ // already there.
165
+ export function renderScaffoldPlan(plan, done = null, { env = process.env, asking = false } = {}) {
166
+ if (!plan.ok) return [...plan.refusals.map((r) => r.message), "Nothing written."].join("\n") + "\n";
167
+ const where = `${plan.vault} (layout ${plan.layout}, language ${plan.language})`;
168
+ if (done) {
169
+ const lines = [`Scaffolded ${where}`, ...plan.rows.filter((r) => done.created.includes(r.rel)).map((r) => ` created ${shown(r)}`)];
170
+ lines.push(done.exists.length
171
+ ? `Created ${done.created.length}; ${done.exists.length} already there, left as ${done.exists.length === 1 ? "it was" : "they were"}.`
172
+ : `Created ${done.created.length}.`);
173
+ return lines.join("\n") + "\n";
174
+ }
175
+ const lines = [`Scaffold ${where}`, ...plan.rows.map((r) => ` ${r.action} ${shown(r)}`), ""];
176
+ const there = plan.rows.length - plan.creates;
177
+ if (!plan.creates) lines.push("Nothing to create: every folder and README the layout declares is there.");
178
+ else {
179
+ lines.push(`${plan.creates} to create${there ? `; ${there} already there, never rewritten` : ""}.`);
180
+ if (!asking) lines.push(`To write them: ${commandForm("scaffold", { env })} in a session, or \`projectstore scaffold --write\`.`);
181
+ }
182
+ return lines.join("\n") + "\n";
183
+ }
@@ -0,0 +1,80 @@
1
+ // projectstore — stages.mjs: how a story's status reads on the board.
2
+ //
3
+ // One table for two readers: the kanban board (scripts/kanban.mjs) and the
4
+ // band above the prompt (hooks/band.tsx), which counts an epic's stories by
5
+ // the same rule so the band and kanban.md never disagree. The band is a Claude
6
+ // Code hooks module and runs with no Node, so this file imports nothing at
7
+ // all — keep it that way.
8
+
9
+ // Statuses that describe work which is not on the board by definition: it was
10
+ // replaced, postponed, or set aside. Putting them in a column would claim the
11
+ // work is queued, which is the opposite of what the status says.
12
+ export const NOT_ACTIONABLE = new Set(["superseded", "deferred", "parked"]);
13
+
14
+ // A lower-cased status to its board column. Exact match; anything the table
15
+ // does not name is Backlog.
16
+ export function statusToColumn(status) {
17
+ const m = {
18
+ planned: "Backlog",
19
+ todo: "ToDo",
20
+ "to-do": "ToDo",
21
+ "in-progress": "In Progress",
22
+ in_progress: "In Progress",
23
+ "in progress": "In Progress",
24
+ review: "Review",
25
+ done: "Done",
26
+ closed: "Done",
27
+ };
28
+ return m[status] || "Backlog";
29
+ }
30
+
31
+ // The band's three stages, folded from the board's columns.
32
+ export const COLUMN_STAGE = Object.freeze({
33
+ Done: "done",
34
+ "In Progress": "active",
35
+ Review: "active",
36
+ ToDo: "backlog",
37
+ Backlog: "backlog",
38
+ });
39
+
40
+ // A story's stage from its raw frontmatter status, as the board reads it:
41
+ // lower-cased; no status, or a status the board leaves off, counts nowhere
42
+ // (null).
43
+ export function stageOf(raw) {
44
+ if (raw === null || raw === undefined || raw === "") return null;
45
+ const status = String(raw).toLowerCase();
46
+ if (NOT_ACTIONABLE.has(status)) return null;
47
+ return COLUMN_STAGE[statusToColumn(status)] || "backlog";
48
+ }
49
+
50
+ // Raw statuses → { done, active, backlog }.
51
+ export function foldStages(statuses) {
52
+ const out = { done: 0, active: 0, backlog: 0 };
53
+ for (const raw of statuses) {
54
+ const stage = stageOf(raw);
55
+ if (stage) out[stage]++;
56
+ }
57
+ return out;
58
+ }
59
+
60
+ // Line-based frontmatter: `key: value` scalars between the opening `---` and
61
+ // the next one. Quoted values are JSON-decoded (titles that hold quotes),
62
+ // `null` is null; block sequences are invisible to it.
63
+ export function parseFrontmatter(md) {
64
+ const m = md.match(/^---\n([\s\S]*?)\n---/);
65
+ if (!m) return { data: {}, body: md };
66
+ const data = {};
67
+ for (const line of m[1].split("\n")) {
68
+ const kv = line.match(/^(\w+):\s*(.*)$/);
69
+ if (!kv) continue;
70
+ let v = kv[2].trim();
71
+ if (v === "null") v = null;
72
+ else if (v.startsWith('"') && v.endsWith('"')) {
73
+ // JSON.parse round-trips escaped scalars from renderTemplate's _json
74
+ // form (titles containing quotes); fall back to the bare strip.
75
+ try { v = JSON.parse(v); } catch { v = v.slice(1, -1); }
76
+ }
77
+ data[kv[1]] = v;
78
+ }
79
+ return { data, body: md.slice(m[0].length) };
80
+ }
@@ -25,6 +25,13 @@
25
25
  // cold-start line. The renderer drops a .last-render.json breadcrumb so
26
26
  // doctor can detect session_id divergence between hook and statusLine.
27
27
  //
28
+ // While this session's band above the prompt is drawing (hooks/band.tsx keeps
29
+ // a heartbeat fresh in the session-state directory), our line gives way to it:
30
+ // composed, the base HUD prints alone; standalone, the badge and the 📚
31
+ // segment drop. A missing, unreadable or stale heartbeat changes nothing, so
32
+ // a band that cannot draw always gives the line back (the band ADR, decision
33
+ // 5; spec contract 11).
34
+ //
28
35
  // Everything is best-effort; on any error emit what we have and exit 0.
29
36
 
30
37
  import { readFileSync, existsSync } from "node:fs";
@@ -41,6 +48,7 @@ import {
41
48
  claudeHome,
42
49
  writeFileAtomic,
43
50
  } from "./lib.mjs";
51
+ import { heartbeatFile, heartbeatYields } from "./band-shared.mjs";
44
52
 
45
53
  const SELF = fileURLToPath(import.meta.url);
46
54
  const SEP = " · ";
@@ -153,6 +161,19 @@ function bookSegment(cfg, proj, input, strings) {
153
161
  return seg;
154
162
  }
155
163
 
164
+ // Whether this session's band is drawing now. Read after bookSegment has
165
+ // written the breadcrumb, so doctor keeps its evidence while the band is up;
166
+ // any failure reads as "no band" and the line stays.
167
+ function bandYields(proj, sid) {
168
+ try {
169
+ const name = heartbeatFile(sid);
170
+ if (!name) return false;
171
+ return heartbeatYields(JSON.parse(readFileSync(join(stateDir(proj), name), "utf8")), Date.now());
172
+ } catch {
173
+ return false;
174
+ }
175
+ }
176
+
156
177
  // The base statusLine command we compose with — highest-precedence entry BELOW
157
178
  // our own local one: project settings.json, else user settings.json.
158
179
  function discoverBaseCommand(projectDir) {
@@ -210,8 +231,10 @@ function main() {
210
231
  let book = null;
211
232
  try { book = cfg && cfg.vault_path ? bookSegment(cfg, projectDir, input, strings) : null; } catch { book = null; }
212
233
 
234
+ const yielded = Boolean(cfg && cfg.vault_path && bandYields(projectDir, input.session_id));
235
+
213
236
  const showVersion = !(cfg && cfg.statusline && cfg.statusline.show_version === false);
214
- const ver = showVersion ? pluginVersion() : null;
237
+ const ver = showVersion && !yielded ? pluginVersion() : null;
215
238
  const badge = ver ? `[PS#${ver}] ` : "";
216
239
 
217
240
  const baseCmd = discoverBaseCommand(projectDir);
@@ -220,7 +243,7 @@ function main() {
220
243
  // "Never blank" must be total: even if the resolver threw (book === null on a
221
244
  // bound project), fall back to the cold-start line rather than vanishing.
222
245
  const bookOut =
223
- cfg && cfg.vault_path ? book || BOOK + strings.statusline_no_work : null;
246
+ cfg && cfg.vault_path && !yielded ? book || BOOK + strings.statusline_no_work : null;
224
247
 
225
248
  const lines = [];
226
249
  if (baseOut) {