@tianhai/pi-workflow-kit 2.8.1 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,399 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Build review evidence from explicit, immutable Git bounds.
4
+ *
5
+ * Usage: node build-review-packet.mjs --project <root> --design <path> --progress <path> --scope feature|requirement [--requirement N]
6
+ */
7
+ import { spawnSync } from "node:child_process";
8
+ import { constants, lstatSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
9
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
10
+ import { parseArgs } from "node:util";
11
+
12
+ class PacketError extends Error {}
13
+
14
+ /** Keep original lines for evidence; inspect headings only outside fenced examples. */
15
+ class Document {
16
+ constructor(path) {
17
+ // A strict decode, as Python's open(encoding="utf-8") is: reading as utf8 would replace
18
+ // invalid bytes and build a packet from damaged evidence.
19
+ this.lines = new TextDecoder("utf-8", { fatal: true }).decode(readFileSync(path)).split(/(?<=\n)/);
20
+ this.headings = [];
21
+ this.visible = new Set();
22
+ let fence = null;
23
+ this.lines.forEach((line, index) => {
24
+ const stripped = line.replace(/\r?\n$/, "");
25
+ const marker = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(stripped);
26
+ if (fence) {
27
+ if (marker && marker[1][0] === fence[0] && marker[1].length >= fence.length && !marker[2].trim()) fence = null;
28
+ return;
29
+ }
30
+ if (marker) {
31
+ fence = marker[1];
32
+ return;
33
+ }
34
+ this.visible.add(index);
35
+ const heading = /^ {0,3}(#{1,6})\s+(.+?)\s*#*\s*$/.exec(stripped);
36
+ if (heading) this.headings.push([index, heading[1].length, heading[2]]);
37
+ });
38
+ if (fence) throw new PacketError(`${basename(path)}: unclosed code fence`);
39
+ }
40
+
41
+ section(title, level = 2, required = false) {
42
+ const matches = this.headings.filter(([, depth, text]) => depth === level && text === title);
43
+ if (matches.length > 1 || (required && matches.length === 0)) {
44
+ throw new PacketError(`expected one ${title} section; found ${matches.length}`);
45
+ }
46
+ if (matches.length === 0) return null;
47
+ const start = matches[0][0];
48
+ const end = this.headings.find(([index, depth]) => index > start && depth <= level)?.[0] ?? this.lines.length;
49
+ return [start, end];
50
+ }
51
+
52
+ text(start, end) {
53
+ return this.lines.slice(start, end).join("");
54
+ }
55
+ }
56
+
57
+ function git(project, ...args) {
58
+ const result = spawnSync("git", ["-C", String(project), ...args], {
59
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0" },
60
+ encoding: "utf8",
61
+ });
62
+ if (result.status !== 0) {
63
+ throw new PacketError(`git ${args.join(" ")} failed: ${(result.stderr ?? "").trim()}`);
64
+ }
65
+ return result.stdout;
66
+ }
67
+
68
+ const isSymlink = (path) => {
69
+ try {
70
+ return lstatSync(path).isSymbolicLink();
71
+ } catch {
72
+ return false;
73
+ }
74
+ };
75
+
76
+ function resolveInput(project, value) {
77
+ const path = isAbsolute(value) ? value : join(project, value);
78
+ return realpathSync(path);
79
+ }
80
+
81
+ /** POSIX-style relative segments, or null when the child is outside the parent. */
82
+ function relativeParts(child, parent) {
83
+ const rel = relative(parent, child);
84
+ if (!rel || rel === "" || rel.startsWith("..")) return null;
85
+ return rel.split(/[\\/]/);
86
+ }
87
+
88
+ function validateDestination(path) {
89
+ if (isSymlink(path)) throw new PacketError("packet destination must be a regular file, not a symlink or directory");
90
+ try {
91
+ if (!lstatSync(path).isFile()) {
92
+ throw new PacketError("packet destination must be a regular file, not a symlink or directory");
93
+ }
94
+ } catch (error) {
95
+ if (error instanceof PacketError) throw error;
96
+ }
97
+ }
98
+
99
+ function paths(args) {
100
+ const project = realpathSync(resolve(args.project));
101
+ if (resolve(git(project, "rev-parse", "--show-toplevel").trim()) !== project) {
102
+ throw new PacketError("--project must be the Git worktree root");
103
+ }
104
+ for (const value of [args.design, args.progress]) {
105
+ const supplied = resolve(isAbsolute(value) ? value : join(project, value));
106
+ const lexicalProject = resolve(args.project);
107
+ let rel = relativeParts(supplied, lexicalProject);
108
+ if (!rel) rel = relativeParts(supplied, project);
109
+ if (!rel) throw new PacketError(`${value} is outside the project`);
110
+ let current = project;
111
+ for (const part of rel) {
112
+ current = join(current, part);
113
+ if (isSymlink(current)) throw new PacketError("design/progress paths must not traverse symlinks");
114
+ }
115
+ }
116
+ const design = resolveInput(project, args.design);
117
+ const progress = resolveInput(project, args.progress);
118
+ const relativeTopic = relativeParts(design, join(project, "docs", "plans"));
119
+ if (
120
+ !relativeTopic ||
121
+ relativeTopic.length !== 2 ||
122
+ relativeTopic[0] === "completed" ||
123
+ !design.endsWith("-design.md")
124
+ ) {
125
+ throw new PacketError("design must be docs/plans/<topic>/<leaf>-design.md in an active topic");
126
+ }
127
+ const leaf = basename(design).slice(0, -"-design.md".length);
128
+ if (!leaf || progress !== join(dirname(design), `${leaf}-progress.md`)) {
129
+ throw new PacketError("progress must be the matching <leaf>-progress.md beside the design");
130
+ }
131
+ for (const directory of [
132
+ join(project, "docs"),
133
+ join(project, "docs", "plans"),
134
+ join(project, "docs", "plans", relativeTopic[0]),
135
+ ]) {
136
+ if (isSymlink(directory)) throw new PacketError("symlink topic directories are not writable packet destinations");
137
+ }
138
+ const suffix = args.scope === "feature" ? "" : `-r${args.requirement}`;
139
+ const destination = join(dirname(design), `${leaf}-review-packet${suffix}.md`);
140
+ validateDestination(destination);
141
+ return { project, design, progress, destination };
142
+ }
143
+
144
+ function requirements(document) {
145
+ const region = document.section("Requirements", 2, true);
146
+ const headings = [];
147
+ for (const [index, depth, title] of document.headings) {
148
+ const match = /^R([1-9][0-9]*):\s*\S/.exec(title);
149
+ if (depth === 3 && /^R[0-9]/.test(title) && !match) throw new PacketError("malformed requirement heading");
150
+ if (depth === 3 && match) {
151
+ if (!(region[0] < index && index < region[1])) {
152
+ throw new PacketError("requirement heading outside Requirements section");
153
+ }
154
+ headings.push([Number(match[1]), index]);
155
+ }
156
+ }
157
+ const numbers = headings.map(([number]) => number);
158
+ if (numbers.length === 0 || new Set(numbers).size !== numbers.length) {
159
+ throw new PacketError("missing or duplicate requirement headings");
160
+ }
161
+ return { headings, region };
162
+ }
163
+
164
+ function progressRows(document, project, design) {
165
+ const firstHeading = document.headings.find(([, depth]) => depth === 2)?.[0] ?? document.lines.length;
166
+ const pointers = [...document.visible]
167
+ .filter((index) => index < firstHeading && document.lines[index].startsWith("Design: "))
168
+ .map((index) => document.lines[index].trim().slice(8));
169
+ if (pointers.length !== 1 || resolveInput(project, pointers[0]) !== design) {
170
+ throw new PacketError("progress Design header must identify the selected design exactly once");
171
+ }
172
+ const [start, end] = document.section("Requirements", 2, true);
173
+ const lines = [];
174
+ for (let index = start + 1; index < end; index += 1) {
175
+ if (document.visible.has(index) && document.lines[index].trim()) lines.push(document.lines[index].trim());
176
+ }
177
+ if (lines.length < 3) throw new PacketError("missing Requirements table");
178
+
179
+ const cells = (line) => {
180
+ if (!line.startsWith("|") || !line.endsWith("|")) throw new PacketError("malformed Requirements table row");
181
+ return line
182
+ .split(/(?<!\\)\|/)
183
+ .slice(1, -1)
184
+ .map((cell) => cell.trim());
185
+ };
186
+
187
+ const header = cells(lines[0]);
188
+ if (header.join("|") !== ["#", "Done", "Requirement", "Per-req ceremony", "Commit"].join("|")) {
189
+ throw new PacketError("Requirements table must have #, Done, Requirement, Per-req ceremony, Commit columns");
190
+ }
191
+ const separator = cells(lines[1]);
192
+ if (separator.length !== 5 || !separator.every((cell) => /^:?-+:?$/.test(cell))) {
193
+ throw new PacketError("malformed Requirements table separator");
194
+ }
195
+ const rows = new Map();
196
+ for (const line of lines.slice(2)) {
197
+ const row = cells(line);
198
+ if (row.length !== 5 || !/^[1-9][0-9]*$/.test(row[0])) throw new PacketError("malformed Requirements table row");
199
+ const number = Number(row[0]);
200
+ if (rows.has(number)) throw new PacketError(`duplicate R${number} progress row`);
201
+ if (!["", "⬜", "🔄", "✅", "❌", "⏭"].includes(row[1])) throw new PacketError(`R${number}: invalid Done cell`);
202
+ rows.set(number, row);
203
+ }
204
+ return rows;
205
+ }
206
+
207
+ function bounds(project, rows, headings, args) {
208
+ const numbers = headings.map(([number]) => number);
209
+ const position = args.scope === "requirement" ? numbers.indexOf(args.requirement) : null;
210
+ const relevant = position === null ? numbers : numbers.slice(Math.max(0, position - 1), position + 1);
211
+ if ([...rows.keys()].some((number) => !numbers.includes(number))) {
212
+ throw new PacketError("progress contains requirements absent from the design");
213
+ }
214
+ const recorded = new Map();
215
+ let previous = null;
216
+ const currentHead = git(project, "rev-parse", "--verify", "HEAD^{commit}").trim();
217
+ for (const number of relevant) {
218
+ if (!rows.has(number)) throw new PacketError(`R${number}: missing progress row`);
219
+ const row = rows.get(number);
220
+ const value = row[4];
221
+ if (value === "" || value === "—" || value === "-") {
222
+ if (!["❌", "⏭"].includes(row[1])) throw new PacketError(`R${number}: missing Commit entry`);
223
+ recorded.set(number, []);
224
+ continue;
225
+ }
226
+ // An ordered whitespace-separated list supports review-fix commits without losing the
227
+ // first feature commit. Other formats are ambiguous, never mined for hashes.
228
+ const entries = value.split(/\s+/).filter(Boolean);
229
+ if (!entries.every((entry) => /^[0-9a-fA-F]{7,64}$/.test(entry))) {
230
+ throw new PacketError(`R${number}: Commit must contain only ordered hexadecimal commit IDs`);
231
+ }
232
+ const commits = [];
233
+ for (const entry of entries) {
234
+ const commit = git(project, "rev-parse", "--verify", `${entry}^{commit}`).trim();
235
+ if (previous) {
236
+ if (previous === commit) throw new PacketError("duplicate or overlapping recorded commits");
237
+ git(project, "merge-base", "--is-ancestor", previous, commit);
238
+ }
239
+ git(project, "merge-base", "--is-ancestor", commit, currentHead);
240
+ commits.push(commit);
241
+ previous = commit;
242
+ }
243
+ recorded.set(number, commits);
244
+ }
245
+ if (args.scope === "feature") {
246
+ const first = [...recorded.values()].find((commits) => commits.length > 0)?.[0] ?? null;
247
+ if (!first) throw new PacketError("no recorded commits; cannot infer a feature base");
248
+ return [git(project, "rev-parse", "--verify", `${first}^`).trim(), currentHead];
249
+ }
250
+ const selected = recorded.get(args.requirement);
251
+ if (!selected?.length) throw new PacketError("selected requirement has no recorded bound");
252
+ if (position === 0) return [git(project, "rev-parse", "--verify", `${selected[0]}^`).trim(), selected.at(-1)];
253
+ const prior = recorded.get(numbers[position - 1]);
254
+ if (!prior?.length) throw new PacketError("previous requirement has no recorded bound; cannot guess a range");
255
+ return [prior.at(-1), selected.at(-1)];
256
+ }
257
+
258
+ function evidence(document, headings, region, args) {
259
+ const feature = document.section("Feature acceptance");
260
+ if (feature && feature[0] < region[1]) throw new PacketError("Feature acceptance must follow Requirements");
261
+ let start;
262
+ let end;
263
+ if (args.scope === "feature") {
264
+ start = headings[0][1];
265
+ end = feature ? feature[0] : document.lines.length;
266
+ } else {
267
+ const position = headings.findIndex(([number]) => number === args.requirement);
268
+ start = headings[position][1];
269
+ end = position + 1 < headings.length ? headings[position + 1][1] : region[1];
270
+ }
271
+ const criteria = document.text(start, end);
272
+ let acceptance = "";
273
+ if (feature) {
274
+ const stop =
275
+ document.headings.find(
276
+ ([index, depth, title]) =>
277
+ feature[0] < index && index < feature[1] && depth === 3 && title.startsWith("Feature review:"),
278
+ )?.[0] ?? feature[1];
279
+ acceptance = document.text(feature[0], stop);
280
+ }
281
+ const notes = [];
282
+ for (const [index, depth, title] of document.headings) {
283
+ if (depth === 3 && title === "Production-risk notes" && start <= index && index < end) {
284
+ const stop =
285
+ document.headings.find(([i, d, t]) => i > index && (d <= 2 || (d === 3 && /^R[0-9]+:/.test(t))))?.[0] ??
286
+ document.lines.length;
287
+ notes.push(document.text(index, Math.min(stop, end)));
288
+ }
289
+ }
290
+ const risk = document.section("Production-risk areas");
291
+ if (risk) notes.push(document.text(risk[0], risk[1]));
292
+ return { criteria, acceptance, notes: notes.join("") };
293
+ }
294
+
295
+ function build(args) {
296
+ const { project, design: designPath, progress: progressPath, destination } = paths(args);
297
+ const design = new Document(designPath);
298
+ const { headings, region } = requirements(design);
299
+ if (args.scope === "requirement" && !headings.some(([number]) => number === args.requirement)) {
300
+ throw new PacketError("selected requirement is absent from the design");
301
+ }
302
+ const rows = progressRows(new Document(progressPath), project, designPath);
303
+ const [base, head] = bounds(project, rows, headings, args);
304
+ const { criteria, acceptance, notes } = evidence(design, headings, region, args);
305
+ const span = `${base}..${head}`;
306
+ const pathspec = ["--", ".", ":(glob,exclude)**/*-review-packet*.md"];
307
+ const sections = [
308
+ ["Commits", git(project, "log", "--oneline", "--no-decorate", span)],
309
+ ["Changed files", git(project, "diff", "--no-ext-diff", "--no-textconv", "--stat", span, ...pathspec)],
310
+ ["Acceptance criteria (verbatim from the design doc)", criteria],
311
+ ["Feature acceptance (verbatim)", acceptance],
312
+ ["Production-risk notes (verbatim, if any)", notes],
313
+ ["Diff", git(project, "diff", "--no-ext-diff", "--no-textconv", "--no-color", span, ...pathspec)],
314
+ ];
315
+ const label = args.scope === "feature" ? "feature review" : `requirement R${args.requirement} review`;
316
+ // The blank line between Base and Head is load-bearing: CommonMark merges consecutive
317
+ // plain lines into one paragraph in every preview.
318
+ let packet = `# Review packet: ${basename(dirname(designPath))} — ${label}\n\nBase: ${base}\n\nHead: ${head}\n\n`;
319
+ for (const [title, content] of sections) packet += `## ${title}\n\n${content}\n`;
320
+
321
+ // Every parse and Git read finishes before a temporary file exists, so a failure in
322
+ // assembly leaves an earlier complete packet intact. Replacement is same-directory.
323
+ let temporary = null;
324
+ try {
325
+ validateDestination(destination);
326
+ temporary = join(dirname(destination), `.${basename(destination)}.${process.pid}${Date.now()}`);
327
+ writeFileSync(temporary, packet, {
328
+ encoding: "utf8",
329
+ flag: constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY,
330
+ });
331
+ validateDestination(destination);
332
+ renameSync(temporary, destination);
333
+ temporary = null;
334
+ } finally {
335
+ if (temporary) {
336
+ try {
337
+ unlinkSync(temporary);
338
+ } catch {
339
+ // The temporary file is already gone.
340
+ }
341
+ }
342
+ }
343
+ console.log(`Wrote ${destination}\nRange: ${base}..${head}`);
344
+ }
345
+
346
+ if (process.argv.includes("-h") || process.argv.includes("--help")) {
347
+ console.log(`usage: node build-review-packet.mjs --project <root> --design <path> --progress <path> --scope feature|requirement [--requirement N]
348
+
349
+ Build review evidence from explicit, immutable Git bounds, with Node's standard library only.
350
+
351
+ --project resolves from the caller cwd and must be a Git worktree root. --design and --progress are absolute or relative to --project, not the skill or cwd. Output is <leaf>-review-packet[-rN].md beside the matched docs in docs/plans/<topic>/. Commit cells contain ordered whitespace-separated hexadecimal commit IDs, not revision expressions.`);
352
+ process.exit(0);
353
+ }
354
+
355
+ const { values } = (() => {
356
+ try {
357
+ return parseArgs({
358
+ args: process.argv.slice(2),
359
+ allowPositionals: false,
360
+ options: {
361
+ project: { type: "string" },
362
+ design: { type: "string" },
363
+ progress: { type: "string" },
364
+ scope: { type: "string" },
365
+ requirement: { type: "string" },
366
+ },
367
+ });
368
+ } catch (error) {
369
+ console.error(`error: ${error.message}`);
370
+ process.exit(2);
371
+ }
372
+ })();
373
+ for (const required of ["project", "design", "progress", "scope"]) {
374
+ if (!values[required]) {
375
+ console.error(`error: --${required} is required`);
376
+ process.exit(2);
377
+ }
378
+ }
379
+ if (!["feature", "requirement"].includes(values.scope)) {
380
+ console.error("error: --scope must be feature or requirement");
381
+ process.exit(2);
382
+ }
383
+ const requirement = values.requirement === undefined ? undefined : Number(values.requirement);
384
+ if ((values.scope === "requirement") !== (requirement !== undefined)) {
385
+ console.error("error: --requirement N is required only for --scope requirement");
386
+ process.exit(2);
387
+ }
388
+ try {
389
+ build({
390
+ project: values.project,
391
+ design: values.design,
392
+ progress: values.progress,
393
+ scope: values.scope,
394
+ requirement,
395
+ });
396
+ } catch (error) {
397
+ console.error(`error: ${error.message}`);
398
+ process.exit(1);
399
+ }
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The progress file's structural slots, written in one place.
4
+ *
5
+ * Every command validates the whole table before touching the file and writes atomically
6
+ * (a temp file plus a rename), so a refusal leaves the file byte-identical. There is no
7
+ * bypass flag: the point of the helper is that a hand edit is no longer needed.
8
+ *
9
+ * Usage:
10
+ * node progress.mjs set --file <progress.md> --phase <value>
11
+ * node progress.mjs set-setup --file <progress.md> --value <pending|done|n/a>
12
+ * node progress.mjs set-row --file <progress.md> --requirement <n> [--done <glyph>] [--commit <hash>]
13
+ * node progress.mjs append-summary --file <progress.md> --requirement <n> --built <text> [--deviated <text>] [--evidence <text>]
14
+ */
15
+ import { readFileSync, renameSync, writeFileSync } from "node:fs";
16
+ import { parseArgs } from "node:util";
17
+
18
+ const DONE_GLYPHS = ["⬜", "🔄", "✅", "❌", "⏭"];
19
+ const SETUP_VALUES = ["pending", "done", "n/a"];
20
+ const PHASE = /^(e2e-written|reviewing|ship-paused|done|implementing \((\d+)\/(\d+)\))$/;
21
+
22
+ const REQUIREMENT_HEADER = "| # | Done | Requirement | Per-req ceremony | Commit |";
23
+ const SUMMARY_HEADER = "| R# | Requirement | How it was built | Deviated? | Evidence |";
24
+
25
+ /** Every row of a markdown table, with its line index and its trimmed cells. */
26
+ function tableRows(lines, header) {
27
+ const start = lines.indexOf(header);
28
+ if (start === -1) return null;
29
+ const rows = [];
30
+ for (let i = start + 2; i < lines.length; i += 1) {
31
+ const line = lines[i];
32
+ if (!line.startsWith("|")) break;
33
+ rows.push({
34
+ index: i,
35
+ // Split on unescaped pipes only, so prose the helper escaped as \| survives a reparse.
36
+ cells: line
37
+ .split(/(?<!\\)\|/)
38
+ .slice(1, -1)
39
+ .map((cell) => cell.trim()),
40
+ });
41
+ }
42
+ return rows;
43
+ }
44
+
45
+ function fail(message) {
46
+ console.error(`progress.mjs: ${message}`);
47
+ process.exit(1);
48
+ }
49
+
50
+ /** Parse and validate the whole file before any command runs. */
51
+ function load(filePath) {
52
+ let text;
53
+ try {
54
+ text = readFileSync(filePath, "utf8");
55
+ } catch (error) {
56
+ fail(`cannot read ${filePath}: ${error.message}`);
57
+ }
58
+ const lines = text.split("\n");
59
+ const phaseIndex = lines.findIndex((line) => line.startsWith("Feature phase:"));
60
+ if (phaseIndex === -1) fail(`${filePath}: no Feature phase line`);
61
+ const setupIndex = lines.findIndex((line) => line.startsWith("Setup:"));
62
+ if (setupIndex === -1) fail(`${filePath}: no Setup line`);
63
+ const lastUpdatedIndex = lines.findIndex((line) => line.startsWith("Last updated:"));
64
+ if (lastUpdatedIndex === -1) fail(`${filePath}: no Last updated line`);
65
+ const requirements = tableRows(lines, REQUIREMENT_HEADER);
66
+ if (!requirements) fail(`${filePath}: no Requirements table with its header row`);
67
+ const summary = tableRows(lines, SUMMARY_HEADER);
68
+ if (!summary) fail(`${filePath}: no Execution summary table with its header row`);
69
+ for (const row of requirements) {
70
+ if (row.cells.length !== 5) {
71
+ fail(`${filePath}: line ${row.index + 1} is not a five-cell requirement row`);
72
+ }
73
+ }
74
+ for (const row of summary) {
75
+ if (row.cells.length !== 5) {
76
+ fail(`${filePath}: line ${row.index + 1} is not a five-cell summary row`);
77
+ }
78
+ }
79
+ return { filePath, lines, phaseIndex, setupIndex, lastUpdatedIndex, requirements, summary };
80
+ }
81
+
82
+ const findRow = (rows, requirement, filePath, label) => {
83
+ const row = rows.find((candidate) => candidate.cells[0] === String(requirement));
84
+ if (!row) fail(`${filePath}: no ${label} row for requirement ${requirement}`);
85
+ return row;
86
+ };
87
+
88
+ const requirementRow = (cells) => `| ${cells.join(" | ")} |`;
89
+ const stamp = () => new Date().toISOString();
90
+
91
+ function save(state) {
92
+ state.lines[state.lastUpdatedIndex] = `Last updated: ${stamp()}`;
93
+ const temporary = `${state.filePath}.tmp`;
94
+ writeFileSync(temporary, state.lines.join("\n"));
95
+ renameSync(temporary, state.filePath);
96
+ }
97
+
98
+ const { values, positionals } = (() => {
99
+ try {
100
+ return parseArgs({
101
+ args: process.argv.slice(2),
102
+ allowPositionals: true,
103
+ options: {
104
+ file: { type: "string" },
105
+ phase: { type: "string" },
106
+ value: { type: "string" },
107
+ requirement: { type: "string" },
108
+ done: { type: "string" },
109
+ commit: { type: "string" },
110
+ built: { type: "string" },
111
+ deviated: { type: "string" },
112
+ evidence: { type: "string" },
113
+ },
114
+ });
115
+ } catch (error) {
116
+ fail(error.message);
117
+ }
118
+ })();
119
+
120
+ const command = positionals[0];
121
+ if (!command || !values.file) {
122
+ fail("usage: progress.mjs <set|set-setup|set-row|append-summary> --file <progress.md> …");
123
+ }
124
+ const state = load(values.file);
125
+
126
+ if (command === "set") {
127
+ const phase = values.phase ?? "";
128
+ const match = PHASE.exec(phase);
129
+ if (!match) {
130
+ fail(
131
+ `${values.file}: phase "${phase}" is not one of e2e-written, implementing (k/N), reviewing, ship-paused, done`,
132
+ );
133
+ }
134
+ if (match[2] !== undefined && Number(match[2]) > Number(match[3])) {
135
+ fail(
136
+ `${values.file}: phase "implementing (${match[2]}/${match[3]})" requires k <= N — accepted: e2e-written, implementing (k/N), reviewing, ship-paused, done`,
137
+ );
138
+ }
139
+ state.lines[state.phaseIndex] = `Feature phase: ${phase}`;
140
+ } else if (command === "set-setup") {
141
+ const value = values.value ?? "";
142
+ if (!SETUP_VALUES.includes(value)) {
143
+ fail(`${values.file}: setup "${value}" is not one of ${SETUP_VALUES.join(", ")}`);
144
+ }
145
+ state.lines[state.setupIndex] = `Setup: ${value}`;
146
+ } else if (command === "set-row") {
147
+ const row = findRow(state.requirements, values.requirement, values.file, "requirement");
148
+ const cells = [...row.cells];
149
+ if (values.done !== undefined) {
150
+ if (!DONE_GLYPHS.includes(values.done)) {
151
+ fail(`${values.file}: done "${values.done}" is not one of ${DONE_GLYPHS.join(" ")}`);
152
+ }
153
+ cells[1] = values.done;
154
+ }
155
+ if (values.commit !== undefined) {
156
+ const existing = cells[4] === "—" ? "" : cells[4];
157
+ const recorded = existing.split(/\s+/).filter(Boolean);
158
+ if (recorded.includes(values.commit)) fail(`${values.file}: commit ${values.commit} is already recorded`);
159
+ cells[4] = [...recorded, values.commit].join(" ");
160
+ }
161
+ state.lines[row.index] = requirementRow(cells);
162
+ } else if (command === "append-summary") {
163
+ const row = findRow(state.summary, values.requirement, values.file, "execution summary");
164
+ const [, , built, deviated, evidence] = row.cells;
165
+ if (built || deviated || evidence) {
166
+ fail(`${values.file}: requirement ${values.requirement} already has an execution summary`);
167
+ }
168
+ const cellText = (text) => String(text ?? "—").replace(/\|/g, "\\|");
169
+ state.lines[row.index] = requirementRow([
170
+ row.cells[0],
171
+ row.cells[1],
172
+ cellText(values.built),
173
+ cellText(values.deviated),
174
+ cellText(values.evidence),
175
+ ]);
176
+ } else {
177
+ fail(`unknown command "${command}"`);
178
+ }
179
+
180
+ save(state);
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Print one anchored section of a reference file, so a step loads the section it needs
4
+ * instead of the whole document.
5
+ *
6
+ * The anchor is the heading slug: lowercased, spaces to dashes, punctuation dropped.
7
+ * The section runs from its heading to the next heading of the same or higher level.
8
+ *
9
+ * Usage: node ref.mjs <reference-path>#<anchor>
10
+ */
11
+ import { readFileSync } from "node:fs";
12
+
13
+ const HEADING = /^(#{1,6})\s+(.+?)\s*$/;
14
+
15
+ /** The heading slug a markdown link anchor is written against. */
16
+ export function slug(heading) {
17
+ return heading
18
+ .toLowerCase()
19
+ .replace(/[^\p{L}\p{N}\s-]/gu, "")
20
+ .trim()
21
+ .replace(/\s+/g, "-");
22
+ }
23
+
24
+ /** Every heading in a document, with its level, slug, and line index. */
25
+ export function headings(lines) {
26
+ const found = [];
27
+ for (const [index, line] of lines.entries()) {
28
+ const match = HEADING.exec(line);
29
+ if (match) found.push({ index, level: match[1].length, slug: slug(match[2]), text: match[2] });
30
+ }
31
+ return found;
32
+ }
33
+
34
+ export function section(lines, anchor) {
35
+ const all = headings(lines);
36
+ const start = all.find((heading) => heading.slug === anchor);
37
+ if (!start) return null;
38
+ const next = all.find((heading) => heading.index > start.index && heading.level <= start.level);
39
+ const body = lines.slice(start.index, next ? next.index : lines.length);
40
+ while (body.length && body[body.length - 1].trim() === "") body.pop();
41
+ return { text: body.join("\n"), available: all.map((heading) => heading.slug) };
42
+ }
43
+
44
+ function fail(message) {
45
+ console.error(`ref.mjs: ${message}`);
46
+ process.exit(1);
47
+ }
48
+
49
+ if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) {
50
+ const target = process.argv[2];
51
+ if (!target) fail("usage: ref.mjs <reference-path>#<anchor>");
52
+ const hash = target.lastIndexOf("#");
53
+ if (hash === -1 || hash === target.length - 1) fail(`${target}: pass an anchor, as <reference-path>#<anchor>`);
54
+ const path = target.slice(0, hash);
55
+ const anchor = target.slice(hash + 1);
56
+ let text;
57
+ try {
58
+ text = readFileSync(path, "utf8");
59
+ } catch (error) {
60
+ fail(`cannot read ${path}: ${error.message}`);
61
+ }
62
+ const found = section(text.split("\n"), anchor);
63
+ if (!found) {
64
+ const available =
65
+ headings(text.split("\n"))
66
+ .map((heading) => heading.slug)
67
+ .join(", ") || "(none)";
68
+ fail(`${path}: no heading matches "#${anchor}" — available: ${available}`);
69
+ }
70
+ console.log(found.text);
71
+ }