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.
- package/README.md +1 -1
- package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
- package/node_modules/projectstore/.claude-plugin/plugin.json +2 -1
- package/node_modules/projectstore/README.md +27 -7
- package/node_modules/projectstore/commands/bar.md +30 -0
- package/node_modules/projectstore/commands/bind.md +14 -7
- package/node_modules/projectstore/commands/scaffold.md +24 -16
- package/node_modules/projectstore/commands/status.md +1 -1
- package/node_modules/projectstore/commands/statusline.md +1 -1
- package/node_modules/projectstore/docs/alongside.md +167 -0
- package/node_modules/projectstore/docs/extending.md +17 -3
- package/node_modules/projectstore/docs/getting-started.md +18 -9
- package/node_modules/projectstore/docs/harnesses.md +1 -1
- package/node_modules/projectstore/docs/how-it-works.md +7 -0
- package/node_modules/projectstore/harnesses/claude-code.json +18 -3
- package/node_modules/projectstore/harnesses/codex.json +12 -1
- package/node_modules/projectstore/hooks/band-state.d.ts +44 -0
- package/node_modules/projectstore/hooks/band.tsx +594 -0
- package/node_modules/projectstore/hooks/hooks.json +1 -0
- package/node_modules/projectstore/hooks/session-start.mjs +6 -2
- package/node_modules/projectstore/package.json +2 -1
- package/node_modules/projectstore/scaffold/headings.json +8 -0
- package/node_modules/projectstore/scripts/band-shared.mjs +329 -0
- package/node_modules/projectstore/scripts/band.mjs +156 -0
- package/node_modules/projectstore/scripts/binding.mjs +157 -17
- package/node_modules/projectstore/scripts/build-adapters.mjs +26 -7
- package/node_modules/projectstore/scripts/cli.mjs +545 -179
- package/node_modules/projectstore/scripts/convergence.mjs +468 -0
- package/node_modules/projectstore/scripts/doctor-report.mjs +185 -0
- package/node_modules/projectstore/scripts/doctor.mjs +80 -62
- package/node_modules/projectstore/scripts/fetch-shell.mjs +283 -0
- package/node_modules/projectstore/scripts/harness.mjs +178 -7
- package/node_modules/projectstore/scripts/install-harness.mjs +1488 -257
- package/node_modules/projectstore/scripts/kanban.mjs +4 -20
- package/node_modules/projectstore/scripts/lib.mjs +257 -58
- package/node_modules/projectstore/scripts/portable-registration.mjs +62 -15
- package/node_modules/projectstore/scripts/query.mjs +83 -12
- package/node_modules/projectstore/scripts/run-host.mjs +94 -0
- package/node_modules/projectstore/scripts/scaffold.mjs +183 -0
- package/node_modules/projectstore/scripts/stages.mjs +80 -0
- package/node_modules/projectstore/scripts/statusline.mjs +25 -2
- package/node_modules/projectstore/scripts/surfaces.mjs +78 -13
- package/node_modules/projectstore/scripts/term.mjs +729 -39
- package/node_modules/projectstore/scripts/version-guard.mjs +5 -0
- package/node_modules/projectstore/templates/de/strings.json +63 -1
- package/node_modules/projectstore/templates/de/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/en/strings.json +63 -1
- package/node_modules/projectstore/templates/en/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/es/strings.json +63 -1
- package/node_modules/projectstore/templates/es/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/fr/strings.json +63 -1
- package/node_modules/projectstore/templates/fr/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/ru/strings.json +63 -1
- package/node_modules/projectstore/templates/ru/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/zh/strings.json +63 -1
- package/node_modules/projectstore/templates/zh/vault-readme.md.tmpl +13 -0
- 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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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.
|
|
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) {
|