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.
- package/README.md +10 -0
- package/bin/archgen.mjs +23 -0
- package/lib/block.js +94 -9
- package/lib/config.js +96 -0
- package/lib/doctor.js +123 -26
- package/lib/init.js +3 -2
- package/lib/install.js +105 -14
- package/lib/migrate.js +88 -0
- package/lib/migrations/001-stamp-provenance.mjs +77 -0
- package/lib/migrations/index.mjs +170 -0
- package/lib/restore.js +239 -0
- package/lib/store.js +27 -12
- package/lib/uninstall-project.js +6 -3
- package/lib/version-stamp.js +47 -0
- package/package.json +37 -9
- package/vendor/skills/archgen/SKILL.md +242 -74
- package/vendor/skills/archgen/archgen.config.json +16 -0
- package/vendor/skills/archgen/assets/skill-registry.json +4 -3
- package/vendor/skills/archgen/references/artifact-templates.md +251 -7
- package/vendor/skills/archgen/references/interview.md +180 -16
- package/vendor/skills/archgen/references/mcp-and-skills.md +88 -17
- package/vendor/skills/archgen/references/orchestration.md +102 -39
- package/vendor/skills/archgen/references/platforms.md +1 -14
- package/vendor/skills/archgen/scripts/doc-index.mjs +457 -0
- package/vendor/skills/archgen/scripts/impact.mjs +14 -1
- package/vendor/skills/archgen/scripts/lib/graph.mjs +82 -3
- package/vendor/skills/archgen/scripts/lib/scope.mjs +65 -0
- package/vendor/skills/archgen/scripts/lib/yaml.mjs +19 -3
- package/vendor/skills/archgen/scripts/plan-graph.mjs +329 -0
- package/vendor/skills/archgen/scripts/set-status.mjs +26 -6
- package/vendor/skills/archgen/scripts/update-agents.mjs +4 -2
- package/vendor/skills/archgen/scripts/validate.mjs +18 -1
- 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
|
-
|
|
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(
|
|
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
|
-
//
|
|
5
|
-
//
|
|
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
|
-
|
|
23
|
-
|
|
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 (
|
|
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
|
-
*
|
|
79
|
-
*
|
|
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
|
-
|
|
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
|
|
64
|
+
else issues.push(`internal check failure: ${e?.message ?? String(e)}`);
|
|
60
65
|
}
|
|
61
66
|
}
|
|
62
67
|
|