archgen-skill 0.0.5 → 0.0.7

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 (39) hide show
  1. package/README.md +20 -7
  2. package/bin/archgen.mjs +142 -15
  3. package/lib/block.js +52 -11
  4. package/lib/bootstrap.js +107 -0
  5. package/lib/config.js +3 -2
  6. package/lib/doctor.js +197 -77
  7. package/lib/init.js +77 -19
  8. package/lib/install.js +79 -7
  9. package/lib/mcp-server.js +241 -0
  10. package/lib/migrations/001-stamp-provenance.mjs +15 -8
  11. package/lib/migrations/index.mjs +19 -8
  12. package/lib/restore.js +52 -16
  13. package/lib/spawn-npm.js +99 -0
  14. package/lib/store.js +175 -28
  15. package/lib/uninstall-project.js +38 -7
  16. package/lib/version.js +20 -4
  17. package/package.json +3 -3
  18. package/vendor/skills/archgen/SKILL.md +363 -213
  19. package/vendor/skills/archgen/archgen.config.json +1 -1
  20. package/vendor/skills/archgen/references/code-standards.md +2 -2
  21. package/vendor/skills/archgen/assets/mcp-template.json +0 -42
  22. package/vendor/skills/archgen/assets/skill-registry.json +0 -24
  23. package/vendor/skills/archgen/references/artifact-templates.md +0 -406
  24. package/vendor/skills/archgen/references/interview.md +0 -275
  25. package/vendor/skills/archgen/references/mcp-and-skills.md +0 -117
  26. package/vendor/skills/archgen/references/orchestration.md +0 -292
  27. package/vendor/skills/archgen/references/platforms.md +0 -138
  28. package/vendor/skills/archgen/scripts/doc-index.mjs +0 -457
  29. package/vendor/skills/archgen/scripts/impact.mjs +0 -77
  30. package/vendor/skills/archgen/scripts/lib/graph.mjs +0 -222
  31. package/vendor/skills/archgen/scripts/lib/plan-coverage.mjs +0 -32
  32. package/vendor/skills/archgen/scripts/lib/scope.mjs +0 -65
  33. package/vendor/skills/archgen/scripts/lib/yaml.mjs +0 -492
  34. package/vendor/skills/archgen/scripts/next-tasks.mjs +0 -55
  35. package/vendor/skills/archgen/scripts/plan-graph.mjs +0 -329
  36. package/vendor/skills/archgen/scripts/set-status.mjs +0 -82
  37. package/vendor/skills/archgen/scripts/update-agents.mjs +0 -213
  38. package/vendor/skills/archgen/scripts/validate.mjs +0 -87
  39. package/vendor/skills/archgen/scripts/verify-plan.mjs +0 -70
@@ -1,329 +0,0 @@
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,82 +0,0 @@
1
- #!/usr/bin/env node
2
- // set-status.mjs — atomic, comment-preserving task status mutator.
3
- // Usage: node set-status.mjs <tasks.yaml> <taskId> <status> [--force]
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 O_EXCL temp-file + rename so concurrent readers never see partial
7
- // files (same-wave parallel workers + crash mid-write both safe: the original
8
- // stays byte-intact until the rename lands; a failure or kill before the rename
9
- // leaves at worst an orphaned .tmp- sibling, never a half-written tasks.yaml),
10
- // and re-emits the file's dominant line-ending convention (CRLF stays CRLF).
11
- // Exit codes: 0 ok · 4 usage/refusal/parse/IO errors.
12
- import { readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
13
- import { parseYaml, stringifyYaml } from './lib/yaml.mjs';
14
-
15
- const STATUSES = ['pending', 'ready', 'running', 'blocked', 'done', 'failed'];
16
- const [, , file, taskId, nextStatus, ...extra] = process.argv;
17
- const force = extra.includes('--force');
18
-
19
- if (!file || !taskId || !nextStatus) {
20
- console.error('usage: set-status.mjs <tasks.yaml> <taskId> <status> [--force]');
21
- process.exit(4);
22
- }
23
- if (!STATUSES.includes(nextStatus)) {
24
- console.error(`invalid status '${nextStatus}' (allowed: ${STATUSES.join('|')})`);
25
- process.exit(4);
26
- }
27
-
28
- let raw;
29
- try {
30
- raw = readFileSync(file, 'utf8');
31
- } catch {
32
- console.error(`cannot read ${file} (missing or unreadable)`);
33
- process.exit(4);
34
- }
35
- // Detect the file's dominant line ending BEFORE parsing so the rewrite keeps
36
- // the author's convention instead of churning every line CRLF<->LF.
37
- const crlfCount = (raw.match(/\r\n/g) ?? []).length;
38
- const loneLfCount = (raw.match(/(?<!\r)\n/g) ?? []).length;
39
- const eol = crlfCount > loneLfCount ? '\r\n' : '\n';
40
-
41
- let data, comments;
42
- try {
43
- ({ data, comments } = parseYaml(raw, { filename: file }));
44
- } catch (e) {
45
- console.error(`${file}: unparseable YAML (${String(e?.message ?? e).replace(/^[^:]*:\d+:\s*/, '')})`);
46
- process.exit(4);
47
- }
48
- const tasks = data?.tasks;
49
- if (!Array.isArray(tasks)) { console.error(`${file}: missing 'tasks:' sequence`); process.exit(4); }
50
-
51
- const task = tasks.find((t) => t && t.id === taskId);
52
- if (!task) { console.error(`task '${taskId}' not found in ${file}`); process.exit(4); }
53
-
54
- const prev = task.status ?? 'pending';
55
- if (prev === 'done' && nextStatus !== 'done' && !force) {
56
- console.error(`refusing ${prev} -> ${nextStatus} for '${taskId}' without --force (reopening completed work needs explicit intent)`);
57
- process.exit(4);
58
- }
59
-
60
- task.status = nextStatus;
61
- // O_EXCL ('wx') guarantees the temp path is freshly created — a stale leftover
62
- // with the same pid+timestamp is never silently truncated and overwritten.
63
- const content = stringifyYaml(data, comments, { eol });
64
- let tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
65
- for (let attempt = 0; ; attempt++) {
66
- try {
67
- writeFileSync(tmp, content, { flag: 'wx' });
68
- break;
69
- } catch (e) {
70
- if (e?.code === 'EEXIST' && attempt < 10) { tmp = `${file}.tmp-${process.pid}-${Date.now()}-${attempt + 1}`; continue; }
71
- console.error(`${file}: cannot write temp file (${e?.code ?? e?.message ?? e})`);
72
- process.exit(4);
73
- }
74
- }
75
- try {
76
- renameSync(tmp, file); // atomic on POSIX: readers observe old-or-new, never partial
77
- } catch (e) {
78
- try { unlinkSync(tmp); } catch { /* original intact; leftover tmp is harmless */ }
79
- console.error(`${file}: atomic rename failed (${e?.code ?? e?.message ?? e}) — original left untouched`);
80
- process.exit(4);
81
- }
82
- console.log(`${taskId}: ${prev} -> ${nextStatus}`);
@@ -1,213 +0,0 @@
1
- #!/usr/bin/env node
2
- // update-agents.mjs — keeps the AGENTS.md features registry in sync with .archgen/.
3
- // Usage: node update-agents.mjs <projectRoot> [--slug <s>] [--status <s>] [--prune]
4
- // Scans .archgen/*/tasks.yaml and rewrites the table between the
5
- // <!-- archgen:features:start --> / <!-- archgen:features:end --> markers in
6
- // <projectRoot>/AGENTS.md, preserving every byte outside the block (BOM and
7
- // CRLF line endings included). A missing AGENTS.md gets a minimal scaffold; an
8
- // existing file without markers gets them appended. Rows whose feature folder
9
- // disappeared are pruned by default (--prune is accepted as the explicit
10
- // no-op spelling of the same behavior). Writes atomically via temp + rename.
11
- // Exit codes: 0 ok · 2 no .archgen/ dir · 4 usage/IO/marker errors.
12
- import { existsSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
13
- import { join } from 'node:path';
14
- import { parseYaml } from './lib/yaml.mjs';
15
-
16
- const START_MARKER = '<!-- archgen:features:start -->';
17
- const END_MARKER = '<!-- archgen:features:end -->';
18
- const HEADER = '| Feature | Status | Updated |';
19
- const SEPARATOR = '| --- | --- | --- |';
20
- const DISPLAY_STATUSES = ['planned', 'in progress', 'done', 'blocked', 'unknown'];
21
- // Task-level statuses set-status.mjs may write; anything else in tasks.yaml
22
- // means the file is not a contract we can read -> row falls back to unknown.
23
- const TASK_STATUSES = new Set(['pending', 'ready', 'running', 'blocked', 'done', 'failed']);
24
-
25
- // ---- arguments ------------------------------------------------------------
26
- const [projectRoot, ...flags] = process.argv.slice(2);
27
- let slug = null;
28
- let status = null;
29
- let pruneFlag = false; // pruning is always on; the flag just makes it explicit
30
- for (let i = 0; i < flags.length; i++) {
31
- const arg = flags[i];
32
- if (arg === '--slug' || arg === '--status') {
33
- const value = flags[++i];
34
- if (value === undefined) { console.error(`${arg} requires a value`); process.exit(4); }
35
- if (arg === '--slug') slug = value;
36
- else status = value;
37
- } else if (arg === '--prune') {
38
- pruneFlag = true;
39
- } else {
40
- console.error(`unknown argument '${arg}'`);
41
- process.exit(4);
42
- }
43
- }
44
- if (!projectRoot) {
45
- console.error('usage: update-agents.mjs <projectRoot> [--slug <s>] [--status <s>] [--prune]');
46
- process.exit(4);
47
- }
48
- if ((slug === null) !== (status === null)) {
49
- console.error('--slug and --status must be passed together');
50
- process.exit(4);
51
- }
52
- if (status !== null && !DISPLAY_STATUSES.includes(status)) {
53
- console.error(`invalid status '${status}' (allowed: ${DISPLAY_STATUSES.join('|')})`);
54
- process.exit(4);
55
- }
56
- if (slug !== null && !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(slug)) {
57
- console.error(`invalid slug '${slug}' (start alphanumeric; then letters, digits, '.', '_', '-')`);
58
- process.exit(4);
59
- }
60
-
61
- // ---- scan .archgen/*/tasks.yaml -------------------------------------------
62
- const archgenDir = join(projectRoot, '.archgen');
63
- if (!existsSync(archgenDir)) {
64
- console.error(`no .archgen/ directory under ${projectRoot} — nothing to register (generate a plan first)`);
65
- process.exit(2);
66
- }
67
-
68
- /** Date -> YYYY-MM-DD (local time, zero-padded). */
69
- function fmtDate(d) {
70
- const mm = String(d.getMonth() + 1).padStart(2, '0');
71
- const dd = String(d.getDate()).padStart(2, '0');
72
- return `${d.getFullYear()}-${mm}-${dd}`;
73
- }
74
-
75
- /**
76
- * Derive the one-word registry status from a parsed tasks.yaml `tasks`
77
- * sequence. Precedence: unreadable -> unknown; any blocked -> blocked; all
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.
81
- */
82
- function deriveStatus(tasks) {
83
- if (!Array.isArray(tasks)) return 'unknown';
84
- const statuses = [];
85
- for (const t of tasks) {
86
- if (t === null || typeof t !== 'object' || Array.isArray(t)) return 'unknown';
87
- const s = String(t.status ?? 'pending').trim().toLowerCase(); // SKILL.md leaves status unset until wave resolution
88
- if (!TASK_STATUSES.has(s)) return 'unknown';
89
- statuses.push(s);
90
- }
91
- if (statuses.length === 0) return 'planned'; // scaffolded folder, no entries yet
92
- if (statuses.includes('blocked')) return 'blocked';
93
- if (statuses.every((s) => s === 'failed')) return 'blocked'; // all-failed feature: nothing is progressing
94
- if (statuses.every((s) => s === 'done')) return 'done';
95
- if (statuses.every((s) => s === 'pending' || s === 'ready')) return 'planned';
96
- return 'in progress';
97
- }
98
-
99
- /** @type {Map<string, {feature: string, status: string, updated: string}>} */
100
- const rows = new Map();
101
- for (const ent of readdirSync(archgenDir, { withFileTypes: true })) {
102
- if (!ent.isDirectory()) continue;
103
- const tasksPath = join(archgenDir, ent.name, 'tasks.yaml');
104
- if (!existsSync(tasksPath)) continue; // the scan target is exactly */tasks.yaml
105
- let derived = 'unknown';
106
- try {
107
- const { data } = parseYaml(readFileSync(tasksPath, 'utf8'), { filename: tasksPath });
108
- derived = deriveStatus(data?.tasks);
109
- } catch {
110
- derived = 'unknown'; // unparseable YAML is data about the feature, not a crash
111
- }
112
- rows.set(ent.name, { feature: ent.name, status: derived, updated: fmtDate(statSync(tasksPath).mtime) });
113
- }
114
-
115
- // ---- upsert override (--slug/--status, run right after generating) --------
116
- if (slug !== null) {
117
- const existing = rows.get(slug);
118
- if (existing) existing.status = status;
119
- else rows.set(slug, { feature: slug, status, updated: fmtDate(new Date()) });
120
- }
121
-
122
- const ordered = [...rows.values()].sort((a, b) => (a.feature < b.feature ? -1 : a.feature > b.feature ? 1 : 0));
123
-
124
- // ---- render -----------------------------------------------------------------
125
- function renderTable(eol) {
126
- const lines = [HEADER, SEPARATOR, ...ordered.map((r) => `| ${r.feature} | ${r.status} | ${r.updated} |`)];
127
- return lines.join(eol) + eol;
128
- }
129
-
130
- /** Parse the CURRENT registry rows out of an existing AGENTS.md (or null). */
131
- function oldRowsFrom(raw) {
132
- const si = raw.indexOf(START_MARKER);
133
- if (si === -1) return null;
134
- const ei = raw.indexOf(END_MARKER, si + START_MARKER.length);
135
- if (ei === -1) return null;
136
- const old = new Map();
137
- for (const line of raw.slice(si + START_MARKER.length, ei).split(/\r?\n/)) {
138
- const t = line.trim();
139
- if (!t.startsWith('|') || t === HEADER || t === SEPARATOR) continue;
140
- const cells = t.split('|').slice(1, -1).map((c) => c.trim());
141
- if (!cells[0]) continue;
142
- old.set(cells[0], { status: cells[1] ?? '', updated: cells[2] ?? '' });
143
- }
144
- return old;
145
- }
146
-
147
- const agentsPath = join(projectRoot, 'AGENTS.md');
148
- let raw = null;
149
- try { raw = readFileSync(agentsPath, 'utf8'); } catch { /* missing -> scaffold below */ }
150
- const oldRows = raw === null ? new Map() : (oldRowsFrom(raw) ?? new Map());
151
-
152
- let out;
153
- let banner;
154
- if (raw === null) {
155
- // Missing AGENTS.md: minimal scaffold — H1, pointer paragraph, markers+table.
156
- banner = `created ${agentsPath}`;
157
- out = '# Agent Guide\n'
158
- + '\n'
159
- + 'This project uses the archgen skill at `.agents/skills/archgen/SKILL.md` for architecture generation and autonomous task execution.\n'
160
- + '\n'
161
- + START_MARKER + '\n'
162
- + renderTable('\n')
163
- + END_MARKER + '\n';
164
- } else if (!raw.includes(START_MARKER)) {
165
- // Existing user file: append newline + markers + table; prior bytes untouched.
166
- banner = `appended features registry to ${agentsPath}`;
167
- const eol = raw.includes('\r\n') ? '\r\n' : '\n';
168
- out = raw + eol + START_MARKER + eol + renderTable(eol) + END_MARKER + '\n';
169
- } else {
170
- // Markers present: replace ONLY the span between them; every outside byte
171
- // (BOM, prose, trailing sections, CRLF endings) survives verbatim.
172
- const si = raw.indexOf(START_MARKER);
173
- const ei = raw.indexOf(END_MARKER, si + START_MARKER.length);
174
- if (ei === -1) {
175
- console.error(`${agentsPath}: found ${START_MARKER} but no ${END_MARKER} — repair the markers by hand`);
176
- process.exit(4);
177
- }
178
- banner = `updated features registry in ${agentsPath}`;
179
- const eol = raw.includes('\r\n') ? '\r\n' : '\n';
180
- out = raw.slice(0, si + START_MARKER.length) + eol + renderTable(eol) + raw.slice(ei);
181
- }
182
-
183
- // ---- human summary ----------------------------------------------------------
184
- const notes = [];
185
- for (const feature of oldRows.keys()) {
186
- if (!rows.has(feature)) notes.push(`pruned: ${feature} (missing .archgen/${feature}/tasks.yaml)`);
187
- }
188
- let unchanged = 0;
189
- for (const r of ordered) {
190
- const prev = oldRows.get(r.feature);
191
- if (!prev) notes.push(`added: ${r.feature} (${r.status})`);
192
- else if (prev.status !== r.status) notes.push(`updated: ${r.feature} (${prev.status || '?'} -> ${r.status})`);
193
- else if (prev.updated !== r.updated) notes.push(`updated: ${r.feature} (${r.updated})`);
194
- else unchanged++;
195
- }
196
-
197
- // O_EXCL ('wx') guarantees the temp path is freshly created; the original
198
- // AGENTS.md stays byte-intact until the rename lands (crash mid-write leaves
199
- // at worst an orphaned .tmp- sibling, never a half-written registry).
200
- const tmp = `${agentsPath}.tmp-${process.pid}-${Date.now()}`;
201
- try {
202
- writeFileSync(tmp, out, { flag: 'wx' });
203
- renameSync(tmp, agentsPath); // atomic on POSIX: readers observe old-or-new, never partial
204
- } catch (e) {
205
- try { unlinkSync(tmp); } catch { /* original intact; leftover tmp is harmless */ }
206
- console.error(`${agentsPath}: atomic write failed (${e?.code ?? e?.message ?? e}) — original left untouched`);
207
- process.exit(4);
208
- }
209
-
210
- console.log(banner);
211
- for (const n of notes) console.log(n);
212
- if (unchanged) console.log(`unchanged: ${unchanged}`);
213
- console.log(`registry: ${ordered.length} feature${ordered.length === 1 ? '' : 's'}`);