projectstore-claude 0.29.2 → 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.
- package/README.md +1 -1
- package/bin/projectstore-claude.mjs +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 +44 -7
- package/node_modules/projectstore/commands/bar.md +30 -0
- package/node_modules/projectstore/commands/bind.md +16 -20
- package/node_modules/projectstore/commands/doctor.md +12 -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 +2 -2
- package/node_modules/projectstore/commands/story.md +15 -2
- 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 +12 -3
- package/node_modules/projectstore/harnesses/claude-code.json +23 -8
- package/node_modules/projectstore/harnesses/codex.json +16 -6
- package/node_modules/projectstore/hooks/band-state.d.ts +44 -0
- package/node_modules/projectstore/hooks/band.tsx +622 -0
- package/node_modules/projectstore/hooks/hooks.json +1 -0
- package/node_modules/projectstore/hooks/pre-compact.mjs +7 -10
- package/node_modules/projectstore/hooks/session-rules.mjs +14 -8
- package/node_modules/projectstore/hooks/session-start.mjs +56 -20
- package/node_modules/projectstore/hooks/session-stop.mjs +8 -6
- package/node_modules/projectstore/package.json +12 -2
- package/node_modules/projectstore/scaffold/headings.json +8 -0
- package/node_modules/projectstore/scripts/band-shared.mjs +446 -0
- package/node_modules/projectstore/scripts/band.mjs +207 -0
- package/node_modules/projectstore/scripts/binding.mjs +733 -62
- package/node_modules/projectstore/scripts/build-adapters.mjs +98 -35
- package/node_modules/projectstore/scripts/cli.mjs +1463 -267
- package/node_modules/projectstore/scripts/codemap.mjs +22 -7
- package/node_modules/projectstore/scripts/convergence.mjs +638 -0
- package/node_modules/projectstore/scripts/diff-refs.mjs +14 -8
- package/node_modules/projectstore/scripts/doctor-report.mjs +253 -0
- package/node_modules/projectstore/scripts/doctor.mjs +1384 -420
- package/node_modules/projectstore/scripts/draft.mjs +5 -2
- package/node_modules/projectstore/scripts/fetch-shell.mjs +369 -0
- package/node_modules/projectstore/scripts/graph.mjs +19 -13
- package/node_modules/projectstore/scripts/harness.mjs +283 -37
- package/node_modules/projectstore/scripts/install-harness.mjs +4701 -575
- package/node_modules/projectstore/scripts/kanban.mjs +6 -21
- package/node_modules/projectstore/scripts/lib.mjs +933 -199
- package/node_modules/projectstore/scripts/mcp.mjs +386 -105
- package/node_modules/projectstore/scripts/portable-registration.mjs +276 -56
- package/node_modules/projectstore/scripts/provenance.mjs +26 -10
- package/node_modules/projectstore/scripts/query.mjs +441 -72
- package/node_modules/projectstore/scripts/reconcile.mjs +55 -18
- package/node_modules/projectstore/scripts/run-host.mjs +139 -0
- package/node_modules/projectstore/scripts/scaffold.mjs +244 -0
- package/node_modules/projectstore/scripts/setup.mjs +987 -0
- package/node_modules/projectstore/scripts/stages.mjs +84 -0
- package/node_modules/projectstore/scripts/statusline-launcher.mjs +36 -11
- package/node_modules/projectstore/scripts/statusline.mjs +55 -14
- package/node_modules/projectstore/scripts/story-section.mjs +39 -17
- package/node_modules/projectstore/scripts/surfaces.mjs +485 -99
- package/node_modules/projectstore/scripts/term.mjs +1027 -52
- package/node_modules/projectstore/scripts/tokens.mjs +106 -28
- package/node_modules/projectstore/scripts/touch-session.mjs +109 -70
- package/node_modules/projectstore/scripts/version-guard.mjs +79 -27
- package/node_modules/projectstore/scripts/worktree.mjs +16 -5
- 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
|
@@ -29,7 +29,6 @@
|
|
|
29
29
|
import { existsSync, readFileSync } from "node:fs";
|
|
30
30
|
import { join, basename } from "node:path";
|
|
31
31
|
import { spawnSync } from "node:child_process";
|
|
32
|
-
import { resolve } from "node:path";
|
|
33
32
|
import {
|
|
34
33
|
readConfig,
|
|
35
34
|
readConfigAt,
|
|
@@ -40,7 +39,8 @@ import {
|
|
|
40
39
|
slugIdentity,
|
|
41
40
|
displayNumberOf,
|
|
42
41
|
compareArtifactOrder,
|
|
43
|
-
writeFileAtomic,
|
|
42
|
+
writeFileAtomic,
|
|
43
|
+
isMain,
|
|
44
44
|
commandForm,
|
|
45
45
|
} from "./lib.mjs";
|
|
46
46
|
import { childEnv } from "./harness.mjs";
|
|
@@ -63,11 +63,19 @@ function runGenerator(script) {
|
|
|
63
63
|
env: _env || childEnv(process.env, { projectRoot: _projectDir || projectRoot() }),
|
|
64
64
|
});
|
|
65
65
|
if (r.status !== 0) return { error: (r.stderr || "generator failed").trim() };
|
|
66
|
-
try {
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(r.stdout);
|
|
68
|
+
} catch {
|
|
69
|
+
return { error: "unparseable generator output" };
|
|
70
|
+
}
|
|
67
71
|
}
|
|
68
72
|
|
|
69
73
|
const normalize = (s) =>
|
|
70
|
-
s
|
|
74
|
+
s
|
|
75
|
+
.split("\n")
|
|
76
|
+
.filter((l) => !l.startsWith("generated_at:"))
|
|
77
|
+
.join("\n")
|
|
78
|
+
.trimEnd();
|
|
71
79
|
|
|
72
80
|
const errMsg = (e) => String((e && e.message) || e);
|
|
73
81
|
|
|
@@ -78,9 +86,9 @@ const errMsg = (e) => String((e && e.message) || e);
|
|
|
78
86
|
// cannot provide them; the class-wide selectors skip, like bare invocation.
|
|
79
87
|
export function resolveSelection(layout, onlyRaw) {
|
|
80
88
|
const validSelectors = () =>
|
|
81
|
-
[layout.kanban ? "kanban" : null, "codemap", "graph", "indexes",
|
|
82
|
-
|
|
83
|
-
.
|
|
89
|
+
[layout.kanban ? "kanban" : null, "codemap", "graph", "indexes", ...layout.folders.map((f) => `indexes=${f.path}`)]
|
|
90
|
+
.filter(Boolean)
|
|
91
|
+
.join(", ");
|
|
84
92
|
if (onlyRaw == null) {
|
|
85
93
|
return {
|
|
86
94
|
explicit: false,
|
|
@@ -91,7 +99,10 @@ export function resolveSelection(layout, onlyRaw) {
|
|
|
91
99
|
};
|
|
92
100
|
}
|
|
93
101
|
const sel = { explicit: true, kanban: false, codemap: false, graph: false, indexes: [] };
|
|
94
|
-
const parts = String(onlyRaw)
|
|
102
|
+
const parts = String(onlyRaw)
|
|
103
|
+
.split(",")
|
|
104
|
+
.map((s) => s.trim())
|
|
105
|
+
.filter(Boolean);
|
|
95
106
|
if (!parts.length) return { error: `empty --only selection — valid: ${validSelectors()}` };
|
|
96
107
|
for (const raw of parts) {
|
|
97
108
|
if (raw === "kanban") {
|
|
@@ -118,7 +129,8 @@ export function resolveSelection(layout, onlyRaw) {
|
|
|
118
129
|
return { error: `no folder "${p}" in this layout — valid: ${validSelectors()}` };
|
|
119
130
|
}
|
|
120
131
|
const existing = sel.indexes.find((x) => x.path === p);
|
|
121
|
-
if (existing)
|
|
132
|
+
if (existing)
|
|
133
|
+
existing.named = true; // upgrade, whatever the order
|
|
122
134
|
else sel.indexes.push({ path: p, named: true });
|
|
123
135
|
} else {
|
|
124
136
|
return { error: `unknown selector "${raw}" — valid: ${validSelectors()}` };
|
|
@@ -156,7 +168,7 @@ const SKIP = {
|
|
|
156
168
|
function derivedTarget(script, explicit = false, fallbackPath = null) {
|
|
157
169
|
const g = runGenerator(script);
|
|
158
170
|
if (g.error) return { ...(fallbackPath ? { path: fallbackPath } : {}), error: g.error };
|
|
159
|
-
let onDisk
|
|
171
|
+
let onDisk;
|
|
160
172
|
try {
|
|
161
173
|
onDisk = existsSync(g.path) ? readFileSync(g.path, "utf8") : null;
|
|
162
174
|
} catch (e) {
|
|
@@ -195,7 +207,8 @@ export function rebuildIndexRows(original, folder, artifacts) {
|
|
|
195
207
|
const inFolder = artifacts.filter((a) =>
|
|
196
208
|
folder.kind === "epic"
|
|
197
209
|
? a.kind === "epic" && a.rel.startsWith(`${folder.path}/`)
|
|
198
|
-
: a.kind === folder.kind && a.rel === `${folder.path}/${basename(a.rel)}
|
|
210
|
+
: a.kind === folder.kind && a.rel === `${folder.path}/${basename(a.rel)}`,
|
|
211
|
+
);
|
|
199
212
|
// Ordering per SPEC-002 contract 8: date ascending (date:, else created:),
|
|
200
213
|
// display number then slug as tiebreak — the grandfathered ADR-001…N order
|
|
201
214
|
// survives because same-date groups tiebreak by number.
|
|
@@ -292,7 +305,8 @@ export function writeIndexWithRetry(readmePath, rebuild, { attempts = 3 } = {})
|
|
|
292
305
|
// shrinks the staleness window to microseconds (spec contract 1).
|
|
293
306
|
function applyDerived(script, explicit, fallbackPath) {
|
|
294
307
|
const t = derivedTarget(script, explicit, fallbackPath);
|
|
295
|
-
if (t.error)
|
|
308
|
+
if (t.error)
|
|
309
|
+
return { ...(t.path ? { path: t.path } : {}), changed: t.changed === true, written: false, error: t.error };
|
|
296
310
|
if (t.skipped) return { path: t.path, changed: false, written: false, skipped: t.skipped, stats: t.stats };
|
|
297
311
|
if (!t.changed) return { path: t.path, changed: false, written: false, stats: t.stats };
|
|
298
312
|
try {
|
|
@@ -307,7 +321,11 @@ export function runReconcile({ write = false, only = null, projectDir = null, en
|
|
|
307
321
|
_projectDir = projectDir;
|
|
308
322
|
_env = env;
|
|
309
323
|
const cfg = projectDir ? readConfigAt(projectDir) : readConfig();
|
|
310
|
-
if (!cfg) {
|
|
324
|
+
if (!cfg) {
|
|
325
|
+
const e = new Error(`No projectstore config. Run ${commandForm("bind")} first.`);
|
|
326
|
+
e.code = "UNBOUND";
|
|
327
|
+
throw e;
|
|
328
|
+
}
|
|
311
329
|
const layout = loadLayout(cfg.layout);
|
|
312
330
|
const sel = resolveSelection(layout, only);
|
|
313
331
|
if (sel.error) throw new Error(sel.error);
|
|
@@ -334,15 +352,24 @@ export function runReconcile({ write = false, only = null, projectDir = null, en
|
|
|
334
352
|
const kanbanPath = layout.kanban ? join(cfg.vault_path, layout.kanban.file || "kanban.md") : null;
|
|
335
353
|
if (!sel.kanban) out.kanban = { skipped: "not selected" };
|
|
336
354
|
else if (!layout.kanban) out.kanban = { skipped: "layout has no kanban" };
|
|
337
|
-
else
|
|
355
|
+
else
|
|
356
|
+
out.kanban = write
|
|
357
|
+
? applyDerived("kanban.mjs", sel.explicit, kanbanPath)
|
|
358
|
+
: derivedTarget("kanban.mjs", sel.explicit, kanbanPath);
|
|
338
359
|
|
|
339
360
|
const codemapPath = join(cfg.vault_path, "code-map.md");
|
|
340
361
|
if (!sel.codemap) out.codemap = { skipped: "not selected" };
|
|
341
|
-
else
|
|
362
|
+
else
|
|
363
|
+
out.codemap = write
|
|
364
|
+
? applyDerived("codemap.mjs", sel.explicit, codemapPath)
|
|
365
|
+
: derivedTarget("codemap.mjs", sel.explicit, codemapPath);
|
|
342
366
|
|
|
343
367
|
const graphPath = join(cfg.vault_path, "graph.md");
|
|
344
368
|
if (!sel.graph) out.graph = { skipped: "not selected" };
|
|
345
|
-
else
|
|
369
|
+
else
|
|
370
|
+
out.graph = write
|
|
371
|
+
? applyDerived("graph.mjs", sel.explicit, graphPath)
|
|
372
|
+
: derivedTarget("graph.mjs", sel.explicit, graphPath);
|
|
346
373
|
|
|
347
374
|
out.indexes = [];
|
|
348
375
|
for (const { path: folderPath, named } of sel.indexes) {
|
|
@@ -359,7 +386,13 @@ export function runReconcile({ write = false, only = null, projectDir = null, en
|
|
|
359
386
|
if (r.unusable) {
|
|
360
387
|
// Pre-flighted named targets can only reach this via a race.
|
|
361
388
|
if (named) {
|
|
362
|
-
out.indexes.push({
|
|
389
|
+
out.indexes.push({
|
|
390
|
+
path: readmePath,
|
|
391
|
+
folder: folder.path,
|
|
392
|
+
changed: false,
|
|
393
|
+
written: false,
|
|
394
|
+
error: r.unusable,
|
|
395
|
+
});
|
|
363
396
|
}
|
|
364
397
|
continue;
|
|
365
398
|
}
|
|
@@ -374,7 +407,11 @@ export function runReconcile({ write = false, only = null, projectDir = null, en
|
|
|
374
407
|
const t = rebuildIndex(cfg, folder, artifacts);
|
|
375
408
|
if (t === null) {
|
|
376
409
|
if (named) {
|
|
377
|
-
out.indexes.push({
|
|
410
|
+
out.indexes.push({
|
|
411
|
+
path: readmePath,
|
|
412
|
+
folder: folder.path,
|
|
413
|
+
error: "absent or no managed index table (changed since pre-flight)",
|
|
414
|
+
});
|
|
378
415
|
}
|
|
379
416
|
continue;
|
|
380
417
|
}
|
|
@@ -0,0 +1,139 @@
|
|
|
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(
|
|
43
|
+
bin,
|
|
44
|
+
argv,
|
|
45
|
+
{ env = process.env, cwd = process.cwd(), budget = HOST_BUDGET_MS, spawn = nodeSpawn, shell = false } = {},
|
|
46
|
+
) {
|
|
47
|
+
return new Promise((settle) => {
|
|
48
|
+
const out = [],
|
|
49
|
+
err = [];
|
|
50
|
+
let child = null,
|
|
51
|
+
done = false,
|
|
52
|
+
timer = null,
|
|
53
|
+
drain = null,
|
|
54
|
+
exited = null;
|
|
55
|
+
const release = () => {
|
|
56
|
+
for (const s of [child?.stdout, child?.stderr]) {
|
|
57
|
+
try {
|
|
58
|
+
s?.destroy();
|
|
59
|
+
} catch {}
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
child?.unref?.();
|
|
63
|
+
} catch {}
|
|
64
|
+
};
|
|
65
|
+
const finish = (r) => {
|
|
66
|
+
if (done) return;
|
|
67
|
+
done = true;
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
clearTimeout(drain);
|
|
70
|
+
settle({ status: null, signal: null, stdout: out.join(""), stderr: err.join(""), error: undefined, ...r });
|
|
71
|
+
};
|
|
72
|
+
try {
|
|
73
|
+
child = spawn(bin, argv, { env, cwd, stdio: ["ignore", "pipe", "pipe"], shell });
|
|
74
|
+
} catch (e) {
|
|
75
|
+
finish({ error: e });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
for (const [s, sink] of [
|
|
79
|
+
[child.stdout, out],
|
|
80
|
+
[child.stderr, err],
|
|
81
|
+
]) {
|
|
82
|
+
if (!s) continue;
|
|
83
|
+
s.setEncoding("utf8");
|
|
84
|
+
s.on("data", (d) => sink.push(d));
|
|
85
|
+
// A stream that ends after the exit may be the last one the drain waits on.
|
|
86
|
+
s.on("end", () => {
|
|
87
|
+
if (exited && drained()) finish(exited);
|
|
88
|
+
});
|
|
89
|
+
s.on("error", () => {});
|
|
90
|
+
}
|
|
91
|
+
const drained = () => [child.stdout, child.stderr].every((s) => !s || s.readableEnded || s.destroyed);
|
|
92
|
+
child.on("error", (e) => {
|
|
93
|
+
finish({ error: e, status: null });
|
|
94
|
+
release();
|
|
95
|
+
});
|
|
96
|
+
child.on("exit", (code, signal) => {
|
|
97
|
+
exited = { status: code, signal };
|
|
98
|
+
if (drained()) {
|
|
99
|
+
finish(exited);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
drain = setTimeout(() => {
|
|
103
|
+
finish(exited);
|
|
104
|
+
release();
|
|
105
|
+
}, DRAIN_MS);
|
|
106
|
+
});
|
|
107
|
+
timer = setTimeout(() => {
|
|
108
|
+
try {
|
|
109
|
+
child.kill("SIGTERM");
|
|
110
|
+
} catch {}
|
|
111
|
+
finish({
|
|
112
|
+
status: null,
|
|
113
|
+
signal: "SIGTERM",
|
|
114
|
+
error: Object.assign(new Error(`${bin} ran past its ${budget} ms budget and was stopped`), {
|
|
115
|
+
code: "ETIMEDOUT",
|
|
116
|
+
}),
|
|
117
|
+
});
|
|
118
|
+
release();
|
|
119
|
+
}, budget);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// What Node cannot say: spawnSync reports a working directory that does not
|
|
124
|
+
// exist and a binary not on PATH with the same `spawnSync <bin> ENOENT`
|
|
125
|
+
// (measured 2026-10-05), and spawn the same `spawn <bin> ENOENT`. The
|
|
126
|
+
// filesystem tells them apart, in this order (the install spec, contract 19);
|
|
127
|
+
// any other error passes through as Node wrote it.
|
|
128
|
+
export function startFailure(err, { bin, resolved, cwd }) {
|
|
129
|
+
let dir = false;
|
|
130
|
+
try {
|
|
131
|
+
dir = statSync(cwd).isDirectory();
|
|
132
|
+
} catch {}
|
|
133
|
+
if (!dir)
|
|
134
|
+
return `${bin} could not start: the working directory ${JSON.stringify(String(cwd))} is not an existing directory`;
|
|
135
|
+
if (!resolved) return `${bin} could not start: it was not found on PATH`;
|
|
136
|
+
if (["ENOENT", "EACCES", "ENOEXEC"].includes(err?.code))
|
|
137
|
+
return `${bin} could not start: ${resolved} exists but could not be run (${err.code})`;
|
|
138
|
+
return String(err?.message || err);
|
|
139
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
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 {
|
|
32
|
+
pluginRoot,
|
|
33
|
+
loadLayout,
|
|
34
|
+
loadTemplate,
|
|
35
|
+
loadStrings,
|
|
36
|
+
renderTemplate,
|
|
37
|
+
writeFileAtomic,
|
|
38
|
+
commandForm,
|
|
39
|
+
fromTerminalIn,
|
|
40
|
+
} from "./lib.mjs";
|
|
41
|
+
import { packageCommand } from "./harness.mjs";
|
|
42
|
+
|
|
43
|
+
const README = "README.md";
|
|
44
|
+
|
|
45
|
+
const kindsOf = (layout) => [...new Set((layout.folders || []).map((f) => f.kind))];
|
|
46
|
+
const usable = (v) => typeof v === "string" && v.trim() !== "";
|
|
47
|
+
const pathKind = (p) => {
|
|
48
|
+
try {
|
|
49
|
+
return statSync(p).isDirectory() ? "dir" : "file";
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// What a layout needs from a language before anything is written: the vault
|
|
56
|
+
// README's template, the folder README's when a folder asks for one, and a
|
|
57
|
+
// name and a description for every kind the layout's folders declare —
|
|
58
|
+
// `diagram` included, though no command creates one. planScaffold refuses on
|
|
59
|
+
// exactly this list and doctor's checkLayoutTemplates warns on it, so the two
|
|
60
|
+
// cannot disagree about what a language lacks.
|
|
61
|
+
export function missingScaffoldRequirements(layout, lang, root = pluginRoot()) {
|
|
62
|
+
const missing = [];
|
|
63
|
+
const templates = ["vault-readme", ...((layout.folders || []).some((f) => f.readme) ? ["folder-readme"] : [])];
|
|
64
|
+
for (const name of templates) {
|
|
65
|
+
if (!existsSync(join(root, "templates", String(lang), `${name}.md.tmpl`))) {
|
|
66
|
+
missing.push({
|
|
67
|
+
what: "template",
|
|
68
|
+
name,
|
|
69
|
+
message: `language "${lang}" has no ${name} template (templates/${lang}/${name}.md.tmpl)`,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const strings = loadStrings(lang, root);
|
|
74
|
+
for (const kind of kindsOf(layout)) {
|
|
75
|
+
const s = strings && strings.folders && strings.folders[kind];
|
|
76
|
+
if (!s || !usable(s.name) || !usable(s.description)) {
|
|
77
|
+
missing.push({
|
|
78
|
+
what: "string",
|
|
79
|
+
name: kind,
|
|
80
|
+
message: `language "${lang}" has no name and description for the folder kind "${kind}" (templates/${lang}/strings.json → folders.${kind})`,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return missing;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The vault README's folder table: a row per layout folder, its name linked
|
|
88
|
+
// to the folder's README (to the folder, when the layout asks for none) and
|
|
89
|
+
// its description — the same strings the folder READMEs carry, so the two
|
|
90
|
+
// never call one folder by two names.
|
|
91
|
+
function folderRows(layout, strings) {
|
|
92
|
+
return layout.folders
|
|
93
|
+
.map((f) => {
|
|
94
|
+
const s = strings.folders[f.kind];
|
|
95
|
+
return `| [${s.name}](${f.path}/${f.readme ? README : ""}) | ${s.description} |`;
|
|
96
|
+
})
|
|
97
|
+
.join("\n");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// What `scaffold` would write into `vault`. A vault that does not exist yet
|
|
101
|
+
// is planned as all `create` — what `init` plans before its mkdir. Refusal
|
|
102
|
+
// codes: LAYOUT, TEMPLATE, STRINGS, NOT_A_DIRECTORY, FOLDER_IS_FILE; every
|
|
103
|
+
// one is exit 1 upstream, and any one means nothing is written. A `create`
|
|
104
|
+
// row carries the bytes it would write; an `exists` row carries none.
|
|
105
|
+
export function planScaffold(vault, { layout, language, root = pluginRoot() } = {}) {
|
|
106
|
+
const refusals = [];
|
|
107
|
+
const rows = [];
|
|
108
|
+
const plan = { vault, layout, language, rows, creates: 0, refusals, ok: false };
|
|
109
|
+
let spec;
|
|
110
|
+
try {
|
|
111
|
+
spec = loadLayout(layout, root);
|
|
112
|
+
if (!spec || !Array.isArray(spec.folders)) throw new Error("it declares no folders");
|
|
113
|
+
} catch (e) {
|
|
114
|
+
refusals.push({ code: "LAYOUT", message: `the layout "${layout}" does not load: ${e.message}` });
|
|
115
|
+
return plan;
|
|
116
|
+
}
|
|
117
|
+
for (const m of missingScaffoldRequirements(spec, language, root))
|
|
118
|
+
refusals.push({ code: m.what === "template" ? "TEMPLATE" : "STRINGS", message: m.message });
|
|
119
|
+
if (pathKind(vault) === "file")
|
|
120
|
+
refusals.push({ code: "NOT_A_DIRECTORY", message: `${vault} is a file, not a vault directory` });
|
|
121
|
+
if (refusals.length) return plan;
|
|
122
|
+
const strings = loadStrings(language, root);
|
|
123
|
+
const folderTemplate = spec.folders.some((f) => f.readme) ? loadTemplate(language, "folder-readme", root) : null;
|
|
124
|
+
const row = (type, rel, render = null) => {
|
|
125
|
+
const abs = join(vault, rel);
|
|
126
|
+
const action = existsSync(abs) ? "exists" : "create";
|
|
127
|
+
rows.push({ type, rel, abs, action, ...(render && action === "create" ? { content: render() } : {}) });
|
|
128
|
+
};
|
|
129
|
+
for (const f of spec.folders) {
|
|
130
|
+
if (pathKind(join(vault, f.path)) === "file")
|
|
131
|
+
refusals.push({
|
|
132
|
+
code: "FOLDER_IS_FILE",
|
|
133
|
+
message: `${f.path} exists in ${vault} as a file — the layout's ${f.kind} folder cannot be created there`,
|
|
134
|
+
});
|
|
135
|
+
row("folder", f.path);
|
|
136
|
+
if (f.readme) {
|
|
137
|
+
const s = strings.folders[f.kind];
|
|
138
|
+
row("readme", `${f.path}/${README}`, () =>
|
|
139
|
+
renderTemplate(folderTemplate, { folder_name: s.name, folder_description: s.description }),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// Last, and from the basename alone: nothing path-dependent beyond the
|
|
144
|
+
// directory's name, so a vault made here and one made by `init` elsewhere
|
|
145
|
+
// under the same name are byte-identical.
|
|
146
|
+
row("vault-readme", README, () =>
|
|
147
|
+
renderTemplate(loadTemplate(language, "vault-readme", root), {
|
|
148
|
+
vault_name: basename(vault),
|
|
149
|
+
folder_rows: folderRows(spec, strings),
|
|
150
|
+
}),
|
|
151
|
+
);
|
|
152
|
+
plan.creates = rows.filter((r) => r.action === "create").length;
|
|
153
|
+
plan.ok = refusals.length === 0;
|
|
154
|
+
return plan;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Writes the plan's `create` rows and nothing else: every folder first —
|
|
158
|
+
// writeFileAtomic never mkdirs — then each README, its absence checked again
|
|
159
|
+
// at the moment of writing, so a file that appeared after the plan (another
|
|
160
|
+
// session, a hand) is reported as there and never rewritten. The vault root is
|
|
161
|
+
// the caller's to make: `init` makes it, and a binding whose vault vanished is
|
|
162
|
+
// refused upstream, never recreated here behind the person's back.
|
|
163
|
+
export function applyScaffold(plan) {
|
|
164
|
+
if (!plan.ok)
|
|
165
|
+
throw Object.assign(new Error(plan.refusals.map((r) => r.message).join("; ")), {
|
|
166
|
+
code: plan.refusals[0] ? plan.refusals[0].code : "REFUSED",
|
|
167
|
+
});
|
|
168
|
+
if (pathKind(plan.vault) !== "dir")
|
|
169
|
+
throw Object.assign(new Error(`${plan.vault} does not exist — scaffold never creates the vault itself`), {
|
|
170
|
+
code: "MISSING",
|
|
171
|
+
});
|
|
172
|
+
const made = new Set();
|
|
173
|
+
for (const r of plan.rows) {
|
|
174
|
+
if (r.type !== "folder" || r.action !== "create" || existsSync(r.abs)) continue;
|
|
175
|
+
mkdirSync(r.abs, { recursive: true });
|
|
176
|
+
made.add(r.rel);
|
|
177
|
+
}
|
|
178
|
+
for (const r of plan.rows) {
|
|
179
|
+
if (r.type === "folder" || r.action !== "create" || existsSync(r.abs)) continue;
|
|
180
|
+
writeFileAtomic(r.abs, r.content);
|
|
181
|
+
made.add(r.rel);
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
created: plan.rows.filter((r) => made.has(r.rel)).map((r) => r.rel),
|
|
185
|
+
exists: plan.rows.filter((r) => !made.has(r.rel)).map((r) => r.rel),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// The result a front-end reports: the rows and what became of them, never a
|
|
190
|
+
// file body. `path` is vault-relative.
|
|
191
|
+
export function scaffoldResult(plan, done = null) {
|
|
192
|
+
return {
|
|
193
|
+
vault_path: plan.vault,
|
|
194
|
+
layout: plan.layout,
|
|
195
|
+
language: plan.language,
|
|
196
|
+
rows: plan.rows.map((r) => ({ type: r.type, path: r.rel, action: r.action })),
|
|
197
|
+
creates: plan.creates,
|
|
198
|
+
wrote: Boolean(done),
|
|
199
|
+
created: done ? done.created : [],
|
|
200
|
+
exists: done ? done.exists : plan.rows.filter((r) => r.action === "exists").map((r) => r.rel),
|
|
201
|
+
refusals: plan.refusals,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const shown = (r) => (r.type === "folder" ? `${r.rel}/` : r.rel);
|
|
206
|
+
|
|
207
|
+
// The plan as text. A bare plan ends with the command that writes it, in both
|
|
208
|
+
// forms — the session's and the core's through npx, since a git-marketplace
|
|
209
|
+
// install has no bin on PATH — and with `--write`, because at a terminal that
|
|
210
|
+
// form shows this plan again and asks. Each stands alone on its line after a
|
|
211
|
+
// blank one, and the lead names `project`, where the terminal's form runs
|
|
212
|
+
// (the story "User-facing text says where to run…", rules 1, 4 and 6).
|
|
213
|
+
// `asking`: the question follows on the same stream, so the closing lines are
|
|
214
|
+
// left out. After an apply, what was created and what was already there.
|
|
215
|
+
export function renderScaffoldPlan(plan, done = null, { env = process.env, asking = false, project = null } = {}) {
|
|
216
|
+
if (!plan.ok) return [...plan.refusals.map((r) => r.message), "Nothing written."].join("\n") + "\n";
|
|
217
|
+
const where = `${plan.vault} (layout ${plan.layout}, language ${plan.language})`;
|
|
218
|
+
if (done) {
|
|
219
|
+
const lines = [
|
|
220
|
+
`Scaffolded ${where}`,
|
|
221
|
+
...plan.rows.filter((r) => done.created.includes(r.rel)).map((r) => ` created ${shown(r)}`),
|
|
222
|
+
];
|
|
223
|
+
lines.push(
|
|
224
|
+
done.exists.length
|
|
225
|
+
? `Created ${done.created.length}; ${done.exists.length} already there, left as ${done.exists.length === 1 ? "it was" : "they were"}.`
|
|
226
|
+
: `Created ${done.created.length}.`,
|
|
227
|
+
);
|
|
228
|
+
return lines.join("\n") + "\n";
|
|
229
|
+
}
|
|
230
|
+
const lines = [`Scaffold ${where}`, ...plan.rows.map((r) => ` ${r.action} ${shown(r)}`), ""];
|
|
231
|
+
const there = plan.rows.length - plan.creates;
|
|
232
|
+
if (!plan.creates) lines.push("Nothing to create: every folder and README the layout declares is there.");
|
|
233
|
+
else {
|
|
234
|
+
lines.push(`${plan.creates} to create${there ? `; ${there} already there, never rewritten` : ""}.`);
|
|
235
|
+
if (!asking)
|
|
236
|
+
lines.push(
|
|
237
|
+
`To write them, in a session or ${project ? fromTerminalIn(project) : "from a terminal in this project"}:`,
|
|
238
|
+
"",
|
|
239
|
+
` ${commandForm("scaffold", { env })}`,
|
|
240
|
+
` ${packageCommand([], "scaffold", { args: "--write" })}`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
return lines.join("\n") + "\n";
|
|
244
|
+
}
|