archgen-skill 0.0.3 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +10 -0
  2. package/bin/archgen.mjs +23 -0
  3. package/lib/block.js +94 -9
  4. package/lib/config.js +96 -0
  5. package/lib/doctor.js +123 -26
  6. package/lib/init.js +3 -2
  7. package/lib/install.js +105 -14
  8. package/lib/migrate.js +88 -0
  9. package/lib/migrations/001-stamp-provenance.mjs +77 -0
  10. package/lib/migrations/index.mjs +170 -0
  11. package/lib/restore.js +239 -0
  12. package/lib/store.js +27 -12
  13. package/lib/uninstall-project.js +6 -3
  14. package/lib/version-stamp.js +47 -0
  15. package/package.json +37 -9
  16. package/vendor/skills/archgen/SKILL.md +242 -74
  17. package/vendor/skills/archgen/archgen.config.json +16 -0
  18. package/vendor/skills/archgen/assets/skill-registry.json +4 -3
  19. package/vendor/skills/archgen/references/artifact-templates.md +251 -7
  20. package/vendor/skills/archgen/references/interview.md +180 -16
  21. package/vendor/skills/archgen/references/mcp-and-skills.md +88 -17
  22. package/vendor/skills/archgen/references/orchestration.md +102 -39
  23. package/vendor/skills/archgen/references/platforms.md +1 -14
  24. package/vendor/skills/archgen/scripts/doc-index.mjs +457 -0
  25. package/vendor/skills/archgen/scripts/impact.mjs +14 -1
  26. package/vendor/skills/archgen/scripts/lib/graph.mjs +82 -3
  27. package/vendor/skills/archgen/scripts/lib/scope.mjs +65 -0
  28. package/vendor/skills/archgen/scripts/lib/yaml.mjs +19 -3
  29. package/vendor/skills/archgen/scripts/plan-graph.mjs +329 -0
  30. package/vendor/skills/archgen/scripts/set-status.mjs +26 -6
  31. package/vendor/skills/archgen/scripts/update-agents.mjs +4 -2
  32. package/vendor/skills/archgen/scripts/validate.mjs +18 -1
  33. package/vendor/skills/archgen/scripts/verify-plan.mjs +8 -3
@@ -32,7 +32,11 @@ const INDENT_UNIT = 2;
32
32
  */
33
33
  export function parseYaml(text, opts = {}) {
34
34
  const filename = opts.filename ?? '<input>';
35
- const lines = text.split('\n');
35
+ // Normalize CRLF: split on '\n' leaves a stray '\r' at the end of every line
36
+ // in Windows-authored files; stripping it here keeps scalar values and
37
+ // comment texts free of carriage returns so stringify can never double them
38
+ // up when re-emitting with the file's dominant EOL (see stringifyYaml eol).
39
+ const lines = text.split('\n').map((l) => (l.endsWith('\r') ? l.slice(0, -1) : l));
36
40
  // Strip a single trailing newline artifact so the last real line is processed.
37
41
  if (lines.length && lines[lines.length - 1] === '') lines.pop();
38
42
 
@@ -120,6 +124,11 @@ function parseMapping(lines, pos, indent, ctx, basePath = []) {
120
124
  const m = matchKey(c.text);
121
125
  if (!m) fail(ctx, pos.i + 1, `expected 'key:' mapping entry, got: ${c.bare.slice(0, 40)}`);
122
126
  assertKeyWellFormed(ctx, pos.i + 1, m);
127
+ // Duplicate keys are a corruption class (real YAML parsers reject them):
128
+ // silent last-wins hides data from every downstream check.
129
+ if (Object.prototype.hasOwnProperty.call(out, m.key)) {
130
+ fail(ctx, pos.i + 1, `duplicate key '${m.key}' in mapping`);
131
+ }
123
132
  const keyPath = [...basePath, m.key];
124
133
  const key = consumePendingAndParseKey(ctx, c, pos.i + 1, keyPath);
125
134
  pos.i++;
@@ -295,6 +304,9 @@ function parseScalarOrFlow(v, ctx, lineNo) {
295
304
  const m = matchKey(part);
296
305
  if (!m) fail(ctx, lineNo, `flow mapping entry must be 'key: value', got: ${part}`);
297
306
  assertKeyWellFormed(ctx, lineNo, m);
307
+ if (Object.prototype.hasOwnProperty.call(obj, m.key)) {
308
+ fail(ctx, lineNo, `duplicate key '${m.key}' in flow mapping`);
309
+ }
298
310
  const val = part.slice(m.end).trim();
299
311
  if (val.startsWith('{')) fail(ctx, lineNo, `nested flow mappings are not supported: ${val}`);
300
312
  obj[m.key] = parseScalarOrFlow(val, ctx, lineNo);
@@ -384,8 +396,12 @@ function flushPendingInto(comments, pending, path, _inline) {
384
396
  * Serialize data back to the YAML subset, re-emitting comments positionally.
385
397
  * @param {any} data
386
398
  * @param {YamlComment[]} [comments]
399
+ * @param {{eol?: string}} [opts] `eol` joins lines with the given line ending
400
+ * (default '\n'); callers that detected a CRLF source pass '\r\n' so the
401
+ * rewrite preserves the file's dominant convention byte-for-byte.
387
402
  */
388
- export function stringifyYaml(data, comments = []) {
403
+ export function stringifyYaml(data, comments = [], opts = {}) {
404
+ const eol = opts.eol ?? '\n';
389
405
  const out = [];
390
406
  const used = new Set();
391
407
  emitValue(out, data, 0, comments, used, []);
@@ -393,7 +409,7 @@ export function stringifyYaml(data, comments = []) {
393
409
  for (let i = 0; i < comments.length; i++) {
394
410
  if (!used.has(i) && comments[i].path === null) out.push(comments[i].text);
395
411
  }
396
- return out.join('\n') + '\n';
412
+ return out.join(eol) + eol;
397
413
  }
398
414
 
399
415
  function preComments(out, comments, used, path) {
@@ -0,0 +1,329 @@
1
+ #!/usr/bin/env node
2
+ // plan-graph.mjs — dependency-graph lens over a plan: overview, task neighborhood,
3
+ // Mermaid rendering, module filtering.
4
+ // Usage:
5
+ // node plan-graph.mjs <slug-dir-or-tasks.yaml> overview JSON
6
+ // node plan-graph.mjs <...> --node TASK-03 neighborhood JSON
7
+ // node plan-graph.mjs <...> --node TASK-03 --mermaid [--status] neighborhood Mermaid
8
+ // node plan-graph.mjs <...> --mermaid [--status] whole-graph Mermaid
9
+ // node plan-graph.mjs <...> --module <name> module filter JSON
10
+ // Input is either a `.archgen/<slug>/` directory (finds tasks.yaml inside; also
11
+ // reads architecture.yaml modules when present) or a direct path to tasks.yaml.
12
+ // Scope-hardened: refuses any input outside .archgen/<slug>/ (see lib/scope.mjs).
13
+ // Exit codes: 0 ok · 2 cycle / duplicate task id / dangling depends_on
14
+ // (GraphError class) · 4 invalid input/file/flags or oversized render request.
15
+ //
16
+ // DEDUP GUARANTEES (stateless idempotency): identical input yields byte-
17
+ // identical output AND zero duplicated entities inside one output. Duplicate
18
+ // depends_on entries (`[A, A]`) collapse to one edge before graph build —
19
+ // overview reports how many via `duplicatesCollapsed`, edgeCount counts unique
20
+ // edges, and Mermaid can never emit the same `X --> Y` line twice. Duplicate
21
+ // task ids are a hard GraphError → exit 2 naming the id.
22
+ //
23
+ // SCALE GUARDS: every traversal is iterative (explicit stack/queue — no
24
+ // recursion on graph depth); whole-graph `--mermaid` refuses graphs over 250
25
+ // nodes (exit 4 with a scope-it hint; --node neighborhood and --module views
26
+ // stay exempt); JSON emits each title once per node object, never per edge.
27
+ import { readFileSync } from 'node:fs';
28
+ import { join } from 'node:path';
29
+ import { parseYaml } from './lib/yaml.mjs';
30
+ import { buildGraph, findCycle, computeWaves, dedupeDependencies, computeQualityStats, GraphError } from './lib/graph.mjs';
31
+ import { resolveSlugInput, ScopeError } from './lib/scope.mjs';
32
+
33
+ const USAGE = 'usage: plan-graph.mjs <slug-dir-or-tasks.yaml> [--node <id>] [--mermaid] [--status] [--module <name>]';
34
+ // Whole-graph Mermaid above this node count is refused: the text would flood an
35
+ // LLM context without adding information. Scoped renders stay exempt.
36
+ const MERMAID_WHOLE_MAX_NODES = 250;
37
+ function die(msg, code = 4) { console.error(msg); process.exit(code); }
38
+
39
+ // --- argument parsing -------------------------------------------------------
40
+ const argv = process.argv.slice(2);
41
+ const opts = { input: null, node: null, mermaid: false, status: false, module: null };
42
+ for (let i = 0; i < argv.length; i++) {
43
+ const a = argv[i];
44
+ if (a === '--node') opts.node = argv[++i];
45
+ else if (a === '--mermaid') opts.mermaid = true;
46
+ else if (a === '--status') opts.status = true;
47
+ else if (a === '--module') opts.module = argv[++i];
48
+ else if (a.startsWith('--')) die(`${USAGE} — unknown option '${a}'`);
49
+ else if (opts.input === null) opts.input = a;
50
+ else die(`${USAGE} — unexpected extra argument '${a}'`);
51
+ }
52
+ if (!opts.input) die(USAGE);
53
+ if (opts.node === undefined || opts.module === undefined) die(`${USAGE} — --node/--module require a value`);
54
+ if (opts.node === '' || opts.module === '') die(`${USAGE} — --node/--module require a non-empty value`);
55
+ if (opts.module !== null && (opts.node !== null || opts.mermaid)) die(`${USAGE} — --module cannot combine with --node/--mermaid`);
56
+ if (opts.status && !opts.mermaid) die(`${USAGE} — --status requires --mermaid`);
57
+
58
+ // --- input resolution -------------------------------------------------------
59
+ // The scope gate is the ONLY way in: directory input must be an immediate child
60
+ // of a `.archgen` segment, direct-file input must be that slug dir's own
61
+ // tasks.yaml; symlinks are judged by their real target. Everything inside runs
62
+ // off the resolved absolute root.
63
+ let scoped;
64
+ try {
65
+ scoped = resolveSlugInput(opts.input);
66
+ } catch (e) {
67
+ if (e instanceof ScopeError) die(`plan-graph: ${e.message}`);
68
+ die(`cannot read ${opts.input} (missing or unreadable)`);
69
+ }
70
+ const planDir = scoped.root; // absolute slug dir for BOTH kinds (tasks.yaml sits directly inside it)
71
+ const tasksFile = join(planDir, 'tasks.yaml');
72
+ const slug = scoped.slug;
73
+
74
+ let raw;
75
+ try {
76
+ raw = readFileSync(tasksFile, 'utf8');
77
+ } catch {
78
+ die(`cannot read ${tasksFile} (missing or unreadable)`);
79
+ }
80
+ let data;
81
+ try {
82
+ ({ data } = parseYaml(raw, { filename: tasksFile }));
83
+ } catch (e) {
84
+ die(`${tasksFile}: unparseable YAML (${String(e?.message ?? e).replace(/^[^:]*:\d+:\s*/, '')})`);
85
+ }
86
+ const tasks = data?.tasks;
87
+ if (!Array.isArray(tasks)) die(`${tasksFile}: missing 'tasks:' sequence`);
88
+
89
+ // --- dedup normalization (A1) -----------------------------------------------
90
+ // Collapse duplicate depends_on entries BEFORE graph build so every downstream
91
+ // view (adjacency, waves, Mermaid, counts) sees each edge exactly once.
92
+ const { tasks: uniqueTasks, duplicatesCollapsed } = dedupeDependencies(tasks);
93
+ const quality = computeQualityStats(uniqueTasks); // facts only — never fails
94
+
95
+ // --- shared engine ----------------------------------------------------------
96
+ let byId, prerequisites, dependents;
97
+ try {
98
+ ({ byId, prerequisites, dependents } = buildGraph(uniqueTasks));
99
+ } catch (e) {
100
+ // GraphError class = structurally invalid graph (duplicate task id, dangling
101
+ // depends_on): exit 2 like cycles — the plan is unexecutable, not merely a
102
+ // bad flag. The message names the offending id.
103
+ if (e instanceof GraphError) die(e.message, 2);
104
+ throw e;
105
+ }
106
+ const cycle = findCycle(prerequisites);
107
+ if (cycle.length) die(`dependency cycle detected: ${cycle.join(' -> ')}`, 2);
108
+ const { waves } = computeWaves(byId, prerequisites);
109
+
110
+ // --- helpers ----------------------------------------------------------------
111
+ const byIdAsc = (a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
112
+
113
+ /** BFS hop-distances from start across an adjacency map (start itself at 0). */
114
+ function bfsDistances(start, adjacency) {
115
+ const dist = new Map([[start, 0]]);
116
+ const queue = [start];
117
+ while (queue.length) {
118
+ const cur = queue.shift();
119
+ for (const next of adjacency.get(cur) ?? []) {
120
+ if (!dist.has(next)) { dist.set(next, dist.get(cur) + 1); queue.push(next); }
121
+ }
122
+ }
123
+ return dist;
124
+ }
125
+
126
+ /** Transitive neighborhood at distance >= 1, sorted by distance then id. */
127
+ function neighborhood(start, adjacency) {
128
+ return [...bfsDistances(start, adjacency)]
129
+ .filter(([id]) => id !== start)
130
+ .map(([id, distance]) => ({ id, title: byId.get(id).title, status: byId.get(id).status, distance }))
131
+ .sort((a, b) => a.distance - b.distance || byIdAsc(a, b));
132
+ }
133
+
134
+ // Local copy of lib/graph.mjs's internal glob-overlap heuristic: it is not
135
+ // exported upstream and existing files must not be modified, so --module keeps
136
+ // the exact conservative rules here rather than inventing divergent ones.
137
+ function globsMayOverlap(a, b) {
138
+ if (a === b) return true;
139
+ // Deepen the check only when a recursive '**' is involved; plain literal
140
+ // globs keep the cheap equality rule above.
141
+ if (!a.includes('**') && !b.includes('**')) return false;
142
+ const as = a.split('/');
143
+ const bs = b.split('/');
144
+ const n = Math.min(as.length, bs.length);
145
+ for (let i = 0; i < n; i++) {
146
+ const x = as[i];
147
+ const y = bs[i];
148
+ if (x === y) continue;
149
+ if (x === '**' || y === '**') return true; // '**' swallows any divergence below it
150
+ if (x.includes('*') || y.includes('*') || x.includes('?') || y.includes('?')) return true; // uncertain -> flag
151
+ return false; // both segments literal and different: paths diverge here
152
+ }
153
+ return true; // one pattern is a segment-prefix of the other
154
+ }
155
+
156
+ // architecture.yaml is optional enrichment; a PRESENT-but-unparseable one is
157
+ // refused loudly — silent data loss is the corruption class this repo guards
158
+ // against everywhere else (see lib/yaml.mjs).
159
+ function readModuleNames(dir) {
160
+ if (!dir) return null;
161
+ const p = join(dir, 'architecture.yaml');
162
+ let archRaw;
163
+ try {
164
+ archRaw = readFileSync(p, 'utf8');
165
+ } catch {
166
+ return null; // absent: fine, overview simply omits modules
167
+ }
168
+ let parsed;
169
+ try {
170
+ parsed = parseYaml(archRaw, { filename: p }).data;
171
+ } catch (e) {
172
+ die(`${p}: unparseable YAML (${String(e?.message ?? e).replace(/^[^:]*:\d+:\s*/, '')})`);
173
+ }
174
+ const mods = parsed?.modules;
175
+ if (!Array.isArray(mods)) return null;
176
+ return mods.map((m) => m?.name).filter((n) => typeof n === 'string' && n !== '').sort();
177
+ }
178
+
179
+ // --- Mermaid rendering ------------------------------------------------------
180
+ // Tasteful, professional palette; only done/running/failed get classes — every
181
+ // other status (pending, ready, blocked) renders with Mermaid's default look.
182
+ const MERMAID_CLASSES = {
183
+ done: 'fill:#e8f5e9,stroke:#2e7d32,stroke-width:1.5px',
184
+ running: 'fill:#e3f2fd,stroke:#1565c0,stroke-width:1.5px',
185
+ failed: 'fill:#ffebee,stroke:#c62828,stroke-width:1.5px',
186
+ };
187
+ const FOCUS_CLASS = 'fill:#fff8e1,stroke:#b45309,stroke-width:2px';
188
+
189
+ const esc = (s) => String(s).replace(/"/g, "'");
190
+ const shortTitle = (t) => {
191
+ const s = String(t.title ?? '');
192
+ return s.length > 36 ? `${s.slice(0, 35).trimEnd()}…` : s;
193
+ };
194
+ const nodeDecl = (t) => `${t.id}["${esc(t.id)} ${esc(shortTitle(t))}"]`;
195
+
196
+ /** classDef lines for the given class names, in fixed order (focus first). */
197
+ function classDefLines(classNames) {
198
+ const lines = [];
199
+ if (classNames.includes('focus')) lines.push(` classDef focus ${FOCUS_CLASS};`);
200
+ for (const cls of Object.keys(MERMAID_CLASSES)) {
201
+ if (classNames.includes(cls)) lines.push(` classDef ${cls} ${MERMAID_CLASSES[cls]};`);
202
+ }
203
+ return lines;
204
+ }
205
+
206
+ /** One `class` statement per styled status over `ids`, sorted — deterministic. */
207
+ function statusClassLines(ids) {
208
+ const lines = [];
209
+ for (const cls of Object.keys(MERMAID_CLASSES)) {
210
+ const members = ids.filter((id) => byId.get(id)?.status === cls);
211
+ if (members.length) lines.push(` class ${members.join(',')} ${cls};`);
212
+ }
213
+ return lines;
214
+ }
215
+
216
+ /** Deduped [prerequisite, dependent] pairs, sorted lexicographically. */
217
+ function allEdges() {
218
+ const seen = new Set();
219
+ const edges = [];
220
+ for (const [prereq, deps] of dependents) {
221
+ for (const dep of deps) {
222
+ const key = `${prereq}\u0000${dep}`;
223
+ if (seen.has(key)) continue;
224
+ seen.add(key);
225
+ edges.push([prereq, dep]);
226
+ }
227
+ }
228
+ return edges.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : 1));
229
+ }
230
+
231
+ function wholeMermaid() {
232
+ const ids = uniqueTasks.map((t) => t.id).sort();
233
+ const lines = [
234
+ 'flowchart LR',
235
+ ...(opts.status ? classDefLines(Object.keys(MERMAID_CLASSES)) : []),
236
+ ...ids.map((id) => ` ${nodeDecl(byId.get(id))}`),
237
+ // allEdges() is deduped by construction (and normalized upstream), so no
238
+ // `X --> Y` line can ever repeat here.
239
+ ...allEdges().map(([a, b]) => ` ${a} --> ${b}`),
240
+ ];
241
+ if (opts.status) lines.push(...statusClassLines(ids));
242
+ return lines.join('\n');
243
+ }
244
+
245
+ function neighborhoodMermaid(focus) {
246
+ const up = [...bfsDistances(focus.id, prerequisites)].filter(([id]) => id !== focus.id).map(([id]) => id).sort();
247
+ const down = [...bfsDistances(focus.id, dependents)].filter(([id]) => id !== focus.id).map(([id]) => id).sort();
248
+ const lines = ['flowchart LR', ...classDefLines(opts.status ? ['focus', ...Object.keys(MERMAID_CLASSES)] : ['focus'])];
249
+ if (up.length) lines.push(' subgraph upstream', ...up.map((id) => ` ${nodeDecl(byId.get(id))}`), ' end');
250
+ lines.push(` ${nodeDecl(focus)}`);
251
+ if (down.length) lines.push(' subgraph downstream', ...down.map((id) => ` ${nodeDecl(byId.get(id))}`), ' end');
252
+ const visible = new Set([focus.id, ...up, ...down]);
253
+ lines.push(...allEdges()
254
+ .filter(([a, b]) => visible.has(a) && visible.has(b))
255
+ .map(([a, b]) => ` ${a} --> ${b}`));
256
+ lines.push(` class ${focus.id} focus;`);
257
+ if (opts.status) lines.push(...statusClassLines([...up, ...down]));
258
+ return lines.join('\n');
259
+ }
260
+
261
+ // --- modes ------------------------------------------------------------------
262
+ if (opts.node !== null) {
263
+ const t = byId.get(opts.node);
264
+ if (!t) die(`no task '${opts.node}' in ${tasksFile}`);
265
+ if (opts.mermaid) {
266
+ console.log(neighborhoodMermaid(t));
267
+ } else {
268
+ console.log(JSON.stringify({
269
+ id: t.id,
270
+ title: t.title,
271
+ status: t.status,
272
+ upstream: neighborhood(t.id, prerequisites),
273
+ downstream: neighborhood(t.id, dependents),
274
+ // Peers only — the node itself is already the subject of the report.
275
+ sameWave: (waves.find((w) => w.some((n) => n.id === t.id)) ?? [])
276
+ .filter((n) => n.id !== t.id).map((n) => n.id).sort(),
277
+ ownedFiles: [...(t.file_ownership ?? [])].sort(),
278
+ acceptanceCount: Array.isArray(t.acceptance) ? t.acceptance.length : 0,
279
+ artifacts: [...(t.artifacts ?? [])].sort(),
280
+ }, null, 2));
281
+ }
282
+ } else if (opts.mermaid) {
283
+ // Scale guard (B6): whole-graph renders above the node ceiling are refused
284
+ // with an actionable hint; --node neighborhood and --module views below stay
285
+ // exempt because their output is bounded by the scope, not the graph.
286
+ const nodeCount = uniqueTasks.length;
287
+ if (nodeCount > MERMAID_WHOLE_MAX_NODES) {
288
+ die(`graph too large to render whole (${nodeCount} nodes) — scope it: --node <id> | --module <name> | --file <path>`);
289
+ }
290
+ console.log(wholeMermaid());
291
+ } else if (opts.module !== null) {
292
+ // Module filter: a task plausibly touches module <name> when any ownership
293
+ // glob may overlap "<name>/**" — conservative, same rules as the same-wave
294
+ // clash detector in lib/graph.mjs.
295
+ const pattern = `${opts.module}/**`;
296
+ const matched = uniqueTasks
297
+ .filter((t) => (t.file_ownership ?? []).some((g) => globsMayOverlap(g, pattern)))
298
+ .sort(byIdAsc);
299
+ console.log(JSON.stringify({
300
+ module: opts.module,
301
+ tasks: matched.map((t) => ({ id: t.id, title: t.title, status: t.status, file_ownership: t.file_ownership })),
302
+ }, null, 2));
303
+ } else {
304
+ const out = {
305
+ slug,
306
+ taskCount: uniqueTasks.length,
307
+ // Unique edges post-dedup (raw depends_on entries that collapsed are
308
+ // reported separately as duplicatesCollapsed).
309
+ edgeCount: uniqueTasks.reduce((n, t) => n + (Array.isArray(t.depends_on) ? t.depends_on.length : 0), 0),
310
+ duplicatesCollapsed,
311
+ // Facts, not verdicts — verify-plan.mjs owns pass/fail.
312
+ quality,
313
+ waves: waves.map((w) => w.map((t) => ({ id: t.id, status: t.status }))),
314
+ roots: uniqueTasks.filter((t) => prerequisites.get(t.id).length === 0).map((t) => t.id).sort(),
315
+ leaves: uniqueTasks.filter((t) => (dependents.get(t.id) ?? []).length === 0).map((t) => t.id).sort(),
316
+ };
317
+ const modules = readModuleNames(planDir);
318
+ if (modules) out.modules = modules;
319
+ console.log(JSON.stringify(out, null, 2));
320
+ }
321
+
322
+ // ---------------------------------------------------------------------------
323
+ // WHY plan-graph.mjs EXISTS: generic code indexers (GitHub code nav, Sourcegraph,
324
+ // CodeGraph-style SQLite MCP servers) model source symbols, not YAML task-DAG edges
325
+ // (ast-grep parses YAML ASTs but stays stateless search), so every YAML-DAG system —
326
+ // GitLab needs, Buildkite depends_on, Concourse passed, Argo dependencies — ships its
327
+ // own schema-specific extractor. This script is archgen's; its Mermaid text renders
328
+ // natively on GitHub/GitLab markdown with zero dependencies.
329
+ // ---------------------------------------------------------------------------
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  // set-status.mjs — atomic, comment-preserving task status mutator.
3
3
  // Usage: node set-status.mjs <tasks.yaml> <taskId> <status> [--force]
4
- // Illegal transitions (done -> pending|ready) require --force. Exit 4 on refusal.
5
- // Writes via temp-file + rename so concurrent readers never see partial files.
4
+ // ANY transition out of 'done' requires --force (reopening completed work —
5
+ // directly or via a failed/blocked hop — needs explicit intent). Exit 4 on refusal.
6
+ // Writes via temp-file + rename so concurrent readers never see partial files,
7
+ // and re-emits the file's dominant line-ending convention (CRLF stays CRLF).
6
8
  import { readFileSync, writeFileSync, renameSync } from 'node:fs';
7
9
  import { parseYaml, stringifyYaml } from './lib/yaml.mjs';
8
10
 
@@ -19,8 +21,26 @@ if (!STATUSES.includes(nextStatus)) {
19
21
  process.exit(4);
20
22
  }
21
23
 
22
- const raw = readFileSync(file, 'utf8');
23
- const { data, comments } = parseYaml(raw, { filename: file });
24
+ let raw;
25
+ try {
26
+ raw = readFileSync(file, 'utf8');
27
+ } catch {
28
+ console.error(`cannot read ${file} (missing or unreadable)`);
29
+ process.exit(4);
30
+ }
31
+ // Detect the file's dominant line ending BEFORE parsing so the rewrite keeps
32
+ // the author's convention instead of churning every line CRLF<->LF.
33
+ const crlfCount = (raw.match(/\r\n/g) ?? []).length;
34
+ const loneLfCount = (raw.match(/(?<!\r)\n/g) ?? []).length;
35
+ const eol = crlfCount > loneLfCount ? '\r\n' : '\n';
36
+
37
+ let data, comments;
38
+ try {
39
+ ({ data, comments } = parseYaml(raw, { filename: file }));
40
+ } catch (e) {
41
+ console.error(`${file}: unparseable YAML (${String(e?.message ?? e).replace(/^[^:]*:\d+:\s*/, '')})`);
42
+ process.exit(4);
43
+ }
24
44
  const tasks = data?.tasks;
25
45
  if (!Array.isArray(tasks)) { console.error(`${file}: missing 'tasks:' sequence`); process.exit(4); }
26
46
 
@@ -28,13 +48,13 @@ const task = tasks.find((t) => t && t.id === taskId);
28
48
  if (!task) { console.error(`task '${taskId}' not found in ${file}`); process.exit(4); }
29
49
 
30
50
  const prev = task.status ?? 'pending';
31
- if ((prev === 'done') && (nextStatus === 'pending' || nextStatus === 'ready') && !force) {
51
+ if (prev === 'done' && nextStatus !== 'done' && !force) {
32
52
  console.error(`refusing ${prev} -> ${nextStatus} for '${taskId}' without --force (reopening completed work needs explicit intent)`);
33
53
  process.exit(4);
34
54
  }
35
55
 
36
56
  task.status = nextStatus;
37
57
  const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
38
- writeFileSync(tmp, stringifyYaml(data, comments));
58
+ writeFileSync(tmp, stringifyYaml(data, comments, { eol }));
39
59
  renameSync(tmp, file); // atomic on POSIX: readers observe old-or-new, never partial
40
60
  console.log(`${taskId}: ${prev} -> ${nextStatus}`);
@@ -75,8 +75,9 @@ function fmtDate(d) {
75
75
  /**
76
76
  * Derive the one-word registry status from a parsed tasks.yaml `tasks`
77
77
  * sequence. Precedence: unreadable -> unknown; any blocked -> blocked; all
78
- * done -> done; all pending/ready -> planned; everything else (any running,
79
- * or a done/pending/failed mix) -> in progress.
78
+ * failed -> blocked (dead work is never "in progress"); all done -> done;
79
+ * all pending/ready -> planned; everything else (any running, or a
80
+ * done/pending/failed mix) -> in progress.
80
81
  */
81
82
  function deriveStatus(tasks) {
82
83
  if (!Array.isArray(tasks)) return 'unknown';
@@ -89,6 +90,7 @@ function deriveStatus(tasks) {
89
90
  }
90
91
  if (statuses.length === 0) return 'planned'; // scaffolded folder, no entries yet
91
92
  if (statuses.includes('blocked')) return 'blocked';
93
+ if (statuses.every((s) => s === 'failed')) return 'blocked'; // all-failed feature: nothing is progressing
92
94
  if (statuses.every((s) => s === 'done')) return 'done';
93
95
  if (statuses.every((s) => s === 'pending' || s === 'ready')) return 'planned';
94
96
  return 'in progress';
@@ -11,10 +11,18 @@ import { buildGraph, findCycle, GraphError } from './lib/graph.mjs';
11
11
  const STATUSES = ['pending', 'ready', 'running', 'blocked', 'done', 'failed'];
12
12
  const REQUIRED = ['id', 'title', 'file_ownership'];
13
13
  const KNOWN_KEYS = new Set(['id', 'title', 'status', 'depends_on', 'parallel_group', 'file_ownership', 'artifacts', 'acceptance']);
14
+ // Mirrors schemas/tasks.schema.json `id.pattern` — ids are strings, never
15
+ // numeric-coerced scalars, or set-status/next-tasks cannot address the task.
16
+ const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
14
17
 
15
18
  const [, , file, ...rest] = process.argv;
16
19
  if (!file) { console.error('usage: validate.mjs <tasks.yaml> [--plan <dir>]'); process.exit(1); }
17
20
  const planDirIdx = rest.indexOf('--plan');
21
+ if (planDirIdx >= 0 && rest[planDirIdx + 1] === undefined) {
22
+ // '--plan' with no value must never silently skip coverage checks.
23
+ console.error('usage: validate.mjs <tasks.yaml> [--plan <dir>] (--plan requires a directory value)');
24
+ process.exit(2);
25
+ }
18
26
  const planDir = planDirIdx >= 0 ? rest[planDirIdx + 1] : null;
19
27
 
20
28
  /** @type {string[]} errors */ const errors = [];
@@ -42,7 +50,16 @@ for (const [i, t] of tasks.entries()) {
42
50
  const where = `tasks[${i}] (${t?.id ?? '<no-id>'})`;
43
51
  if (t === null || typeof t !== 'object' || Array.isArray(t)) { errors.push(`${where}: not a mapping`); continue; }
44
52
  for (const k of Object.keys(t)) if (!KNOWN_KEYS.has(k)) warnings.push(`${where}: unknown key '${k}' (ignored)`);
45
- for (const r of REQUIRED) if (t[r] === undefined) errors.push(`${where}: missing required field '${r}'`);
53
+ // Required means present AND non-empty: `id:` parses to null, which is not
54
+ // undefined — an explicit empty value must fail, not bypass the check.
55
+ for (const r of REQUIRED) {
56
+ const v = t[r];
57
+ if (v === undefined) errors.push(`${where}: missing required field '${r}'`);
58
+ else if (v === null || (typeof v === 'string' && v.trim() === '')) errors.push(`${where}: required field '${r}' is empty`);
59
+ }
60
+ if (t.id !== undefined && t.id !== null && (typeof t.id !== 'string' || !ID_PATTERN.test(t.id))) {
61
+ errors.push(`${where}: invalid id '${t.id}' (must be a string matching ^[A-Za-z0-9][A-Za-z0-9._-]*$)`);
62
+ }
46
63
  if (t.status !== undefined && !STATUSES.includes(t.status)) errors.push(`${where}: invalid status '${t.status}' (allowed: ${STATUSES.join('|')})`);
47
64
  if (Array.isArray(t.file_ownership) && t.file_ownership.length === 0) errors.push(`${where}: file_ownership must be non-empty`);
48
65
  if (t.depends_on !== undefined && !Array.isArray(t.depends_on)) errors.push(`${where}: depends_on must be a list`);
@@ -11,7 +11,7 @@ import { checkPlanCoverage } from './lib/plan-coverage.mjs';
11
11
 
12
12
  const [, , file, ...rest] = process.argv;
13
13
  const planIdx = rest.indexOf('--plan');
14
- if (!file || planIdx < 0) {
14
+ if (!file || planIdx < 0 || !rest[planIdx + 1]) {
15
15
  console.error('usage: verify-plan.mjs <tasks.yaml> --plan <plans-dir>');
16
16
  process.exit(2);
17
17
  }
@@ -35,10 +35,13 @@ if (issues.length === 0) {
35
35
  if (cycle.length) issues.push(`dependency cycle: ${cycle.join(' -> ')}`);
36
36
 
37
37
  // Every task must carry acceptance criteria — the verifier refuses work
38
- // that cannot be objectively checked later.
38
+ // that cannot be objectively checked later. Entries must be non-blank
39
+ // strings: whitespace-only or empty entries are placeholders, not criteria.
39
40
  for (const t of tasks) {
40
41
  if (!Array.isArray(t.acceptance) || t.acceptance.length === 0) {
41
42
  issues.push(`task '${t.id}' has no acceptance criteria`);
43
+ } else if (t.acceptance.some((a) => typeof a !== 'string' || a.trim() === '')) {
44
+ issues.push(`task '${t.id}' has empty or whitespace-only acceptance criteria`);
42
45
  }
43
46
  if (!Array.isArray(t.file_ownership) || t.file_ownership.length === 0) {
44
47
  issues.push(`task '${t.id}' has empty file_ownership`);
@@ -55,8 +58,10 @@ if (issues.length === 0) {
55
58
 
56
59
  issues.push(...checkPlanCoverage(planDir, tasks));
57
60
  } catch (e) {
61
+ // Exotic inputs must surface as a clean ISSUES verdict, never a raw stack:
62
+ // the verifier is a gate, and gates report findings, not crashes.
58
63
  if (e instanceof GraphError) issues.push(e.message);
59
- else throw e;
64
+ else issues.push(`internal check failure: ${e?.message ?? String(e)}`);
60
65
  }
61
66
  }
62
67