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
@@ -0,0 +1,457 @@
1
+ #!/usr/bin/env node
2
+ // doc-index.mjs — deterministic markdown-artifact lens over `.archgen/<slug>/`:
3
+ // overview tree, backlink queries, reference-integrity gate, freshness audit,
4
+ // Mermaid inventory.
5
+ // Usage:
6
+ // node doc-index.mjs <slug-dir> overview JSON
7
+ // node doc-index.mjs <slug-dir> --refs-to <token> backlink JSON
8
+ // node doc-index.mjs <slug-dir> --validate integrity JSON (exit 1 when broken)
9
+ // node doc-index.mjs <slug-dir> --stale freshness JSON
10
+ // node doc-index.mjs <slug-dir> --diagrams Mermaid inventory JSON
11
+ // Any mode accepts `--file <path>` (repo-root-relative or slug-relative) to
12
+ // scope it to one document. Paths in output are repo-root-relative and
13
+ // slash-normalized; identical input yields byte-identical stdout.
14
+ // Scope-hardened: refuses any input outside .archgen/<slug>/ (see lib/scope.mjs).
15
+ // Exit codes: 0 ok · 1 broken references (--validate) · 2 structurally invalid
16
+ // tasks.yaml during --validate (duplicate task id / dangling depends_on, via
17
+ // GraphError) · 4 invalid input/flags.
18
+ //
19
+ // DEDUP GUARANTEES (stateless idempotency): identical input yields byte-
20
+ // identical output AND zero duplicated entities inside one output. The file
21
+ // inventory dedups by fs.realpathSync — a symlinked doc aliasing another
22
+ // tracked doc, case-colliding spellings on case-insensitive filesystems, and
23
+ // `/./` path noise all collapse to ONE entry; the canonical repo-root-relative
24
+ // spelling kept is the non-symlink one when the group contains it, else the
25
+ // lexicographically first (overview reports how many via `duplicatesSkipped`).
26
+ // --validate counts UNIQUE refs in `checked.*` and lists each broken ref ONCE,
27
+ // at its first occurrence line; duplicate FR-id DEFINITIONS inside docs/prd.md
28
+ // are their own broken kind 'dup-definition' (ambiguity is surfaced, never
29
+ // silently resolved). By deliberate contrast, --refs-to backlinks list EVERY
30
+ // occurrence line — that mode is a "where is this mentioned" lens, not a gate.
31
+ //
32
+ // SCALE GUARDS: all traversals are iterative (explicit stack — no recursion on
33
+ // tree depth); overview returns at most the first 100 files, sorted, with
34
+ // `truncated:true` above that (totalFiles still reports the true deduped
35
+ // count) so agents are never silently surprised by deterministic truncation;
36
+ // fence pairing follows CommonMark (an info-carrying ``` inside an open fence
37
+ // is content, not an opener), which keeps nested-fence blocks from producing
38
+ // phantom diagram entries or false titles.
39
+ import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
40
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path';
41
+ import { parseYaml } from './lib/yaml.mjs';
42
+ import { buildGraph, GraphError } from './lib/graph.mjs';
43
+ import { resolveSlugInput, ScopeError } from './lib/scope.mjs';
44
+
45
+ const USAGE = 'usage: doc-index.mjs <slug-dir> [--refs-to <token>] [--validate] [--stale] [--diagrams] [--file <path>]';
46
+ // Overview emits at most this many files (sorted); above it, truncated:true.
47
+ const OVERVIEW_MAX_FILES = 100;
48
+ function die(msg, code = 4) { console.error(msg); process.exit(code); }
49
+
50
+ // --- argument parsing -------------------------------------------------------
51
+ const argv = process.argv.slice(2);
52
+ const opts = { input: null, refsTo: null, validate: false, stale: false, diagrams: false, file: null };
53
+ for (let i = 0; i < argv.length; i++) {
54
+ const a = argv[i];
55
+ if (a === '--refs-to') opts.refsTo = argv[++i];
56
+ else if (a === '--validate') opts.validate = true;
57
+ else if (a === '--stale') opts.stale = true;
58
+ else if (a === '--diagrams') opts.diagrams = true;
59
+ else if (a === '--file') opts.file = argv[++i];
60
+ else if (a.startsWith('--')) die(`${USAGE} — unknown option '${a}'`);
61
+ else if (opts.input === null) opts.input = a;
62
+ else die(`${USAGE} — unexpected extra argument '${a}'`);
63
+ }
64
+ if (!opts.input) die(USAGE);
65
+ if (opts.refsTo === undefined || opts.file === undefined) die(`${USAGE} — --refs-to/--file require a value`);
66
+ if (opts.refsTo === '') die(`${USAGE} — --refs-to requires a non-empty token`);
67
+ const modes = [opts.refsTo !== null, opts.validate, opts.stale, opts.diagrams].filter(Boolean).length;
68
+ if (modes > 1) die(`${USAGE} — --refs-to/--validate/--stale/--diagrams are mutually exclusive`);
69
+
70
+ // --- input resolution -------------------------------------------------------
71
+ // The scope gate is the ONLY way in: the slug dir must be an immediate child of
72
+ // a `.archgen` segment (symlinks judged by their real target); everything below
73
+ // runs off the resolved absolute root.
74
+ let scoped;
75
+ try {
76
+ scoped = resolveSlugInput(opts.input);
77
+ } catch (e) {
78
+ if (e instanceof ScopeError) die(`doc-index: ${e.message}`);
79
+ die(`cannot read ${opts.input} (missing or not a directory)`);
80
+ }
81
+ const slugDir = scoped.root;
82
+ const repoRoot = dirname(slugDir); // the `.archgen` directory — output vocabulary stays root-relative
83
+ const slug = scoped.slug;
84
+
85
+ /** Repo-root-relative, slash-normalized path (never absolute in output). */
86
+ const relFromRoot = (abs) => relative(repoRoot, abs).split(sep).join('/');
87
+ const relFromSlug = (abs) => relative(slugDir, abs).split(sep).join('/');
88
+
89
+ // Optional --file scoping: accept repo-root-relative (the output vocabulary) or
90
+ // slug-relative paths; anything resolving outside the slug directory is refused.
91
+ // The hit is judged by real path so a symlink inside the slug cannot smuggle in
92
+ // out-of-scope content either.
93
+ let fileScope = null; // slug-relative posix path or null
94
+ if (opts.file !== null) {
95
+ const candidates = [join(repoRoot, opts.file), join(slugDir, opts.file)];
96
+ const hit = candidates.find((p) => { try { return statSync(p).isFile(); } catch { return false; } });
97
+ if (!hit) die(`cannot read --file ${opts.file} (missing or not a file)`);
98
+ let hitReal;
99
+ try { hitReal = realpathSync(hit); } catch { hitReal = resolve(hit); }
100
+ if (!hitReal.startsWith(slugDir + sep)) die(`--file ${opts.file} is outside ${opts.input}`);
101
+ fileScope = relFromSlug(hit);
102
+ }
103
+
104
+ // --- shared engine ----------------------------------------------------------
105
+ const MD_RE = /\.md$/i;
106
+ const YAML_RE = /\.ya?ml$/i;
107
+
108
+ /** Read text as UTF-8 with BOM stripped and CRLF/CR normalized to LF. */
109
+ function readText(abs) {
110
+ let t;
111
+ try { t = readFileSync(abs, 'utf8'); } catch { return null; }
112
+ if (t.charCodeAt(0) === 0xfeff) t = t.slice(1);
113
+ return t.replace(/\r\n?/g, '\n');
114
+ }
115
+
116
+ /** Depth-first collect of files matching `filter`, dot-directories skipped,
117
+ * DEDUPLICATED by real path, sorted lexicographically by slug-relative path —
118
+ * the determinism backbone.
119
+ *
120
+ * Dedup contract (A3): every tracked document is indexed/emitted exactly once
121
+ * regardless of how many directory entries reach it. Candidates are keyed by
122
+ * fs.realpathSync, which collapses symlink aliases (a doc symlinked inside the
123
+ * slug pointing at another tracked doc) and case-colliding spellings on
124
+ * case-insensitive filesystems; `/./` noise never survives realpath either.
125
+ * The canonical spelling kept per group is the non-symlink entry when one
126
+ * exists (the file's own name beats an alias), else the lexicographically
127
+ * first candidate — deterministic under identical input, always.
128
+ * Dangling symlinks (realpath throws) are kept and keyed by their spelled
129
+ * path: they alias nothing, so they cannot be duplicates of anything.
130
+ */
131
+ function walkFiles(filter) {
132
+ const candidates = [];
133
+ const stack = [''];
134
+ while (stack.length) {
135
+ const rel = stack.pop();
136
+ for (const e of readdirSync(rel ? join(slugDir, rel) : slugDir, { withFileTypes: true })) {
137
+ if (e.name.startsWith('.')) continue;
138
+ const r = rel ? `${rel}/${e.name}` : e.name;
139
+ if (e.isDirectory()) stack.push(r);
140
+ else if (filter.test(e.name)) candidates.push(r);
141
+ }
142
+ }
143
+ const chosen = new Map(); // real key -> { rel, isLink }
144
+ let duplicatesSkipped = 0;
145
+ for (const r of candidates.sort()) {
146
+ const abs = join(slugDir, r);
147
+ let key;
148
+ let isLink;
149
+ try {
150
+ key = realpathSync(abs);
151
+ isLink = key !== join(slugDir, r); // slugDir is already fully resolved (scope gate)
152
+ } catch {
153
+ key = resolve(abs); // dangling symlink: keep, keyed by spelled path
154
+ isLink = false;
155
+ }
156
+ const prev = chosen.get(key);
157
+ if (!prev) { chosen.set(key, { rel: r, isLink }); continue; }
158
+ duplicatesSkipped++;
159
+ if (prev.isLink && !isLink) chosen.set(key, { rel: r, isLink }); // real spelling wins over alias
160
+ }
161
+ return { files: [...chosen.values()].map((v) => v.rel).sort(), duplicatesSkipped };
162
+ }
163
+
164
+ // Whole-word token matcher: a hit must not be glued to word characters on
165
+ // either side, but path punctuation (`/`, `.`, `-`) may border it — so
166
+ // "0001" finds "decisions/0001-postgres-over-mongo.md" while "TASK-05"
167
+ // still refuses to match "TASK-055" or "XTASK-05".
168
+ function wholeWordSource(token) {
169
+ const esc = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
170
+ return `(?<![A-Za-z0-9_])${esc}(?![A-Za-z0-9_])`;
171
+ }
172
+ const TASK_ID_SOURCE = '(?<![A-Za-z0-9_])TASK-\\d+(?![A-Za-z0-9_])';
173
+ const FR_ID_SOURCE = '(?<![A-Za-z0-9_])FR-[A-Z0-9]+-\\d+(?![A-Za-z0-9_])';
174
+
175
+ // Artifact-relative cross-references (artifact-templates.md rule 5): a slash-
176
+ // carrying path ending in a doc/yaml extension, e.g. `../architecture.yaml`.
177
+ // Bare same-dir names are only trusted inside explicit markdown links
178
+ // (`](c4-container.md)`) — prose mentions like "tasks.yaml" resolve
179
+ // file-relatively and would be false positives.
180
+ const ARTIFACT_PATH_RE = /(?<![A-Za-z0-9_./-])((?:\.\.?\/)?[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+\.(?:md|markdown|ya?ml))(?![A-Za-z0-9_/-])/g;
181
+ const MD_LINK_RE = /\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
182
+ const GENERATED_AT_RE = /generated_at:\s*(\S+)/;
183
+
184
+ /**
185
+ * Parse one markdown document: ATX headings (H1-H3 reported, any level kept
186
+ * for diagram titles), fence bookkeeping, and artifact-path references.
187
+ * Headings and links ignore fenced code; line numbers are 1-based post-CRLF
188
+ * normalization.
189
+ */
190
+ function parseMarkdown(text) {
191
+ const lines = text.split('\n');
192
+ const headings = [];
193
+ const allHeadings = [];
194
+ const fences = [];
195
+ const links = [];
196
+ let inFence = false;
197
+ for (let i = 0; i < lines.length; i++) {
198
+ const line = lines[i];
199
+ if (/^ {0,3}```/.test(line)) {
200
+ const info = line.replace(/^ {0,3}```/, '').trim();
201
+ // CommonMark pairing (nested-fence guard): inside an open fence only a
202
+ // BARE ``` closes it — an info-carrying ``` (e.g. a ```mermaid example
203
+ // quoted inside a ```text block) is CONTENT, never an opener. This keeps
204
+ // nested fences from producing phantom diagram entries or false titles.
205
+ if (inFence) {
206
+ if (info === '') { fences.push({ line: i + 1, info: null }); inFence = false; }
207
+ } else {
208
+ fences.push({ line: i + 1, info });
209
+ inFence = true;
210
+ }
211
+ continue;
212
+ }
213
+ if (inFence) continue;
214
+ const h = /^(#{1,6})\s+(.+?)\s*$/.exec(line);
215
+ if (h) {
216
+ const entry = { level: h[1].length, text: h[2], line: i + 1 };
217
+ allHeadings.push(entry);
218
+ if (entry.level <= 3) headings.push(entry);
219
+ continue;
220
+ }
221
+ ARTIFACT_PATH_RE.lastIndex = 0;
222
+ let m;
223
+ while ((m = ARTIFACT_PATH_RE.exec(line)) !== null) links.push({ line: i + 1, target: m[1] });
224
+ MD_LINK_RE.lastIndex = 0;
225
+ let lm;
226
+ while ((lm = MD_LINK_RE.exec(line)) !== null) {
227
+ const t = lm[1];
228
+ if (/^[a-z][a-z0-9+.-]*:/i.test(t) || t.startsWith('#')) continue; // scheme'd or pure anchor
229
+ if (/\.(md|markdown|ya?ml)$/i.test(t.split('#')[0])) links.push({ line: i + 1, target: t });
230
+ }
231
+ }
232
+ // A target reachable by both extractors counts once.
233
+ const seen = new Set();
234
+ const uniqueLinks = links.filter((l) => {
235
+ const k = `${l.line}\u0000${l.target}`;
236
+ if (seen.has(k)) return false;
237
+ seen.add(k);
238
+ return true;
239
+ });
240
+ return { lines, headings, allHeadings, fences, links: uniqueLinks, balanced: fences.length % 2 === 0 };
241
+ }
242
+
243
+ /** Every markdown doc under the slug (or just the --file scope), pre-parsed.
244
+ * Returns { docs, duplicatesSkipped } so overview can report collapsed aliases. */
245
+ function collectDocs() {
246
+ const { files, duplicatesSkipped } = walkFiles(MD_RE);
247
+ const docs = files
248
+ .filter((r) => fileScope === null || r === fileScope)
249
+ .map((r) => {
250
+ const abs = join(slugDir, r);
251
+ const text = readText(abs) ?? '';
252
+ return { rel: r, abs, ...parseMarkdown(text) };
253
+ });
254
+ return { docs, duplicatesSkipped };
255
+ }
256
+
257
+ const wordCount = (lines) => lines.join(' ').split(/\s+/).filter(Boolean).length;
258
+
259
+ // --- modes ------------------------------------------------------------------
260
+ if (opts.validate || opts.stale) {
261
+ // Both gated modes need tasks.yaml: validate cross-checks its ids, stale
262
+ // derives the freshness threshold from it. Absent/unparseable = bad input.
263
+ const tasksRel = 'tasks.yaml';
264
+ const tasksAbs = join(slugDir, tasksRel);
265
+ let raw;
266
+ try { raw = readFileSync(tasksAbs, 'utf8'); } catch { die(`cannot read ${tasksRel} in ${opts.input} (missing or unreadable)`); }
267
+ let taskIds = new Set();
268
+ let tasks = null; // hoisted: the validate branch's structural gate needs it
269
+ if (opts.validate) {
270
+ let data;
271
+ try { ({ data } = parseYaml(raw, { filename: tasksRel })); } catch (e) {
272
+ die(`${tasksRel}: unparseable YAML (${String(e?.message ?? e).replace(/^[^:]*:\d+:\s*/, '')})`);
273
+ }
274
+ tasks = data?.tasks;
275
+ if (!Array.isArray(tasks)) die(`${tasksRel}: missing 'tasks:' sequence`);
276
+ taskIds = new Set(tasks.map((t) => String(t?.id)));
277
+ }
278
+
279
+ if (opts.stale) {
280
+ // Freshness stamp: the file's `generated_at:` provenance comment when
281
+ // present, else its filesystem mtime. Threshold = max(tasks.yaml
282
+ // generated_at, tasks.yaml mtime) — the newest claim tasks.yaml makes.
283
+ const stampOf = (abs) => {
284
+ const text = readText(abs);
285
+ if (text) {
286
+ for (const line of text.split('\n')) {
287
+ const m = GENERATED_AT_RE.exec(line);
288
+ if (m && Number.isFinite(Date.parse(m[1]))) return m[1];
289
+ }
290
+ }
291
+ return new Date(statSync(abs).mtimeMs).toISOString();
292
+ };
293
+ const threshold = Math.max(
294
+ (() => { const m = GENERATED_AT_RE.exec(raw); const t = m ? Date.parse(m[1]) : NaN; return Number.isFinite(t) ? t : -Infinity; })(),
295
+ statSync(tasksAbs).mtimeMs,
296
+ );
297
+ const stale = walkFiles(/\.md$|\.ya?ml$/i)
298
+ .files
299
+ .filter((r) => r !== tasksRel && (fileScope === null || r === fileScope))
300
+ .map((r) => ({ file: relFromRoot(join(slugDir, r)), generatedAt: stampOf(join(slugDir, r)) }))
301
+ .filter((e) => Date.parse(e.generatedAt) < threshold);
302
+ console.log(JSON.stringify({ newestSource: 'tasks.yaml', stale }, null, 2));
303
+ } else {
304
+ // Structural gate first: a tasks.yaml with duplicate task ids or dangling
305
+ // depends_on is unexecutable — surface it as exit 2 (GraphError class),
306
+ // naming the offending id, before any reference scanning.
307
+ try {
308
+ buildGraph(tasks);
309
+ } catch (e) {
310
+ if (e instanceof GraphError) die(e.message, 2);
311
+ throw e;
312
+ }
313
+ // --validate: five broken classes — unknown TASK-nn, unresolved FR-id,
314
+ // dangling artifact link, unbalanced (odd) mermaid/code fences, and
315
+ // 'dup-definition' (the same FR id defined on 2+ lines inside prd.md is
316
+ // ambiguity, and highest-quality data forbids silently picking one).
317
+ // Plus non-fatal `warnings` (heading-level sanity) that never move `ok`
318
+ // or the exit code — only the verifier owns pass/fail.
319
+ //
320
+ // Counting semantics: `checked.*` counts UNIQUE refs; every entry in
321
+ // `broken[]` appears exactly ONCE, at its first occurrence line. (--refs-to
322
+ // intentionally lists EVERY occurrence line instead — lens vs gate.)
323
+ const { docs } = collectDocs();
324
+ const broken = [];
325
+ const warnings = [];
326
+ const checked = { taskRefs: 0, frRefs: 0, links: 0, mermaidFences: 0 };
327
+
328
+ // ref -> { n, first } : occurrence COUNT plus FIRST site (docs arrive
329
+ // path-sorted and lines ascend, so `first` is deterministic).
330
+ const taskOcc = new Map();
331
+ const frDefOcc = new Map(); // occurrences inside docs/prd.md define
332
+ const frRefOcc = new Map(); // everywhere else references
333
+ const PRD_REL = 'docs/prd.md';
334
+ for (const doc of docs) {
335
+ const isPrd = doc.rel === PRD_REL;
336
+ const bump = (sink, ref, at) => {
337
+ const e = sink.get(ref);
338
+ if (e) e.n++;
339
+ else sink.set(ref, { n: 1, first: at });
340
+ };
341
+ const scan = (source, sink) => {
342
+ const re = new RegExp(source, 'g');
343
+ for (let i = 0; i < doc.lines.length; i++) {
344
+ re.lastIndex = 0;
345
+ let m;
346
+ while ((m = re.exec(doc.lines[i])) !== null) bump(sink, m[0], { file: relFromRoot(doc.abs), line: i + 1 });
347
+ }
348
+ };
349
+ scan(TASK_ID_SOURCE, taskOcc);
350
+ scan(FR_ID_SOURCE, isPrd ? frDefOcc : frRefOcc);
351
+ checked.taskRefs = taskOcc.size;
352
+ checked.frRefs = frRefOcc.size;
353
+
354
+ // Links: dedup by (file, target) — the same link repeated in one file is
355
+ // ONE ref: counted once in checked.links, reported once if broken.
356
+ const linkSeen = new Set();
357
+ for (const link of doc.links) {
358
+ const lkey = `${doc.rel}\u0000${link.target}`;
359
+ if (linkSeen.has(lkey)) continue;
360
+ linkSeen.add(lkey);
361
+ checked.links++;
362
+ const target = link.target.split('#')[0];
363
+ if (target && !existsSync(resolve(dirname(doc.abs), target))) {
364
+ broken.push({ kind: 'link', ref: link.target, file: relFromRoot(doc.abs), line: link.line });
365
+ }
366
+ }
367
+
368
+ checked.mermaidFences += doc.fences.length;
369
+ if (!doc.balanced) {
370
+ broken.push({
371
+ kind: 'mermaid',
372
+ ref: `unbalanced fences (${doc.fences.length})`,
373
+ file: relFromRoot(doc.abs),
374
+ line: doc.fences[doc.fences.length - 1].line,
375
+ });
376
+ }
377
+
378
+ // Cheap heading-level sanity: an H3 with no preceding H2 in the same
379
+ // file signals a skipped structural level (H1 titles don't count as
380
+ // section context). Warning only — never fatal.
381
+ let seenH2 = false;
382
+ for (const h of doc.headings) {
383
+ if (h.level === 2) seenH2 = true;
384
+ else if (h.level === 3 && !seenH2) warnings.push({ kind: 'heading-skip', file: relFromRoot(doc.abs), line: h.line });
385
+ }
386
+ }
387
+ for (const [ref, occ] of taskOcc) if (!taskIds.has(ref)) broken.push({ kind: 'task', ref, ...occ.first });
388
+ for (const [ref, occ] of frRefOcc) if (!frDefOcc.has(ref)) broken.push({ kind: 'fr', ref, ...occ.first });
389
+ for (const [ref, occ] of frDefOcc) if (occ.n > 1) broken.push({ kind: 'dup-definition', ref, ...occ.first });
390
+ broken.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line || (a.kind < b.kind ? -1 : a.kind > b.kind ? 1 : a.ref < b.ref ? -1 : 1)));
391
+ warnings.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line));
392
+ const ok = broken.length === 0;
393
+ console.log(JSON.stringify({ ok, checked, broken, warnings }, null, 2));
394
+ process.exit(ok ? 0 : 1);
395
+ }
396
+ } else if (opts.refsTo !== null) {
397
+ // Backlink query: any line in any markdown artifact containing the token as
398
+ // a whole word — TASK-nn, FR-<MODULE>-nn, ADR NNNN[-slug], or a relative
399
+ // artifact path alike; the matcher is uniform by design.
400
+ // INTENTIONAL asymmetry vs --validate: this lens lists EVERY occurrence
401
+ // line (a "where is this mentioned" report), while --validate reports each
402
+ // unique broken ref once at its first occurrence. Do not "dedup" this mode.
403
+ const re = new RegExp(wholeWordSource(opts.refsTo));
404
+ const matches = [];
405
+ for (const doc of collectDocs().docs) {
406
+ for (let i = 0; i < doc.lines.length; i++) {
407
+ if (re.test(doc.lines[i])) matches.push({ file: relFromRoot(doc.abs), line: i + 1, text: doc.lines[i].trim() });
408
+ }
409
+ }
410
+ console.log(JSON.stringify({ ref: opts.refsTo, matches }, null, 2));
411
+ } else if (opts.diagrams) {
412
+ const diagrams = [];
413
+ for (const doc of collectDocs().docs) {
414
+ for (let k = 0; k < doc.fences.length; k++) {
415
+ const f = doc.fences[k];
416
+ if (f.info !== 'mermaid') continue;
417
+ const closer = doc.fences[k + 1]; // fence toggling pairs opens with closes
418
+ const endLine = closer ? closer.line : doc.lines.length + 1;
419
+ const body = doc.lines.slice(f.line, endLine - 1);
420
+ const comment = body.map((l) => /^\s*%%\s?(.*)$/.exec(l)).find(Boolean);
421
+ const heading = [...doc.allHeadings].reverse().find((h) => h.line < f.line);
422
+ diagrams.push({
423
+ file: relFromRoot(doc.abs),
424
+ startLine: f.line,
425
+ title: heading ? heading.text : comment ? comment[1].trim() : null,
426
+ nodeCount: body.filter((l) => /^\s*[A-Za-z0-9_]+[[{(]/.test(l)).length,
427
+ balanced: Boolean(closer),
428
+ });
429
+ }
430
+ }
431
+ console.log(JSON.stringify({ diagrams }, null, 2));
432
+ } else {
433
+ // Overview (scale guard): the deduped inventory is always computed in full
434
+ // and sorted, but only the FIRST 100 files are emitted — with
435
+ // `truncated:true` whenever anything was cut — so agents reading the JSON
436
+ // are never silently surprised by deterministic truncation. totalFiles
437
+ // reports the true post-dedup count either way.
438
+ const { docs, duplicatesSkipped } = collectDocs();
439
+ const allFiles = docs.map((doc) => ({
440
+ path: relFromRoot(doc.abs),
441
+ title: doc.headings.find((h) => h.level === 1)?.text ?? basename(doc.rel),
442
+ headings: doc.headings,
443
+ wordCount: wordCount(doc.lines),
444
+ }));
445
+ const truncated = allFiles.length > OVERVIEW_MAX_FILES;
446
+ const files = truncated ? allFiles.slice(0, OVERVIEW_MAX_FILES) : allFiles;
447
+ console.log(JSON.stringify({ slug, files, totalFiles: allFiles.length, duplicatesSkipped, truncated }, null, 2));
448
+ }
449
+
450
+ // ---------------------------------------------------------------------------
451
+ // WHY doc-index.mjs EXISTS: the codegraph-family indexers our users already run
452
+ // verifiably do not cover this ground — audited 8 tools, Aug 2026: none extract
453
+ // markdown headings or YAML task semantics (installed codegraph v1.0.1 indexes
454
+ // neither .md nor .yaml), so everything archgen writes into .archgen/<slug>/ is
455
+ // invisible to every symbol graph. This script is archgen's deterministic
456
+ // markdown layer: zero dependencies, byte-stable JSON, safe to gate CI on.
457
+ // ---------------------------------------------------------------------------
@@ -8,7 +8,20 @@ import { parseYaml } from './lib/yaml.mjs';
8
8
  const [, , file, target] = process.argv;
9
9
  if (!file || !target) { console.error('usage: impact.mjs <tasks.yaml> <taskId-or-artifactPath>'); process.exit(4); }
10
10
 
11
- const { data } = parseYaml(readFileSync(file, 'utf8'), { filename: file });
11
+ let raw;
12
+ try {
13
+ raw = readFileSync(file, 'utf8');
14
+ } catch {
15
+ console.error(`cannot read ${file} (missing or unreadable)`);
16
+ process.exit(4);
17
+ }
18
+ let data;
19
+ try {
20
+ ({ data } = parseYaml(raw, { filename: file }));
21
+ } catch (e) {
22
+ console.error(`${file}: unparseable YAML (${String(e?.message ?? e).replace(/^[^:]*:\d+:\s*/, '')})`);
23
+ process.exit(4);
24
+ }
12
25
  const tasks = data?.tasks;
13
26
  if (!Array.isArray(tasks)) { console.error(`${file}: missing 'tasks:' sequence`); process.exit(4); }
14
27
 
@@ -113,16 +113,95 @@ export function computeWaves(byId, prerequisites) {
113
113
  }
114
114
 
115
115
  /** First ownership conflict inside a single wave, or null.
116
- * Ownership globs are treated as opaque strings for equality here — two tasks
117
- * in the same wave must not claim the SAME literal glob (conservative rule that
118
- * needs no glob-engine dependency). */
116
+ * Two rules, both conservative:
117
+ * 1. Literal equality — two tasks must never claim the SAME glob string.
118
+ * 2. Glob intersection — when a '**' is involved, patterns whose non-glob
119
+ * prefix segments overlap compatibly are flagged (e.g. "src/**" vs a
120
+ * TypeScript-scoped variant of it). When segment comparison is uncertain
121
+ * (wildcards), the pair IS flagged: false positives merely force a rename;
122
+ * false negatives let parallel workers corrupt each other's files.
123
+ */
119
124
  export function findOwnershipConflict(wave) {
120
125
  const seen = new Map();
126
+ const claimed = [];
121
127
  for (const t of wave) {
122
128
  for (const g of t.file_ownership ?? []) {
123
129
  if (seen.has(g)) return { glob: g, a: seen.get(g), b: t.id };
130
+ for (const prev of claimed) {
131
+ if (globsMayOverlap(prev.glob, g)) return { glob: g, a: prev.id, b: t.id };
132
+ }
124
133
  seen.set(g, t.id);
134
+ claimed.push({ glob: g, id: t.id });
125
135
  }
126
136
  }
127
137
  return null;
128
138
  }
139
+
140
+ /** Conservative may-intersect test for two ownership globs. */
141
+ function globsMayOverlap(a, b) {
142
+ if (a === b) return true;
143
+ // Deepen the check only when a recursive '**' is involved; plain literal
144
+ // globs keep the cheap equality rule above.
145
+ if (!a.includes('**') && !b.includes('**')) return false;
146
+ const as = a.split('/');
147
+ const bs = b.split('/');
148
+ const n = Math.min(as.length, bs.length);
149
+ for (let i = 0; i < n; i++) {
150
+ const x = as[i];
151
+ const y = bs[i];
152
+ if (x === y) continue;
153
+ if (x === '**' || y === '**') return true; // '**' swallows any divergence below it
154
+ if (x.includes('*') || y.includes('*') || x.includes('?') || y.includes('?')) return true; // uncertain -> flag
155
+ return false; // both segments literal and different: paths diverge here
156
+ }
157
+ return true; // one pattern is a segment-prefix of the other
158
+ }
159
+
160
+ // --- additive (hardening pass): dedup normalization + quality surfacing -----
161
+
162
+ /** Collapse redundant depends_on entries BEFORE graph construction so no
163
+ * downstream consumer can ever see the same edge twice (idempotency: identical
164
+ * input ⇒ byte-identical output with zero duplicated entities).
165
+ * First occurrence wins; order otherwise preserved. Tasks without an array
166
+ * depends_on pass through untouched.
167
+ * @param {Array<any>} tasks
168
+ * @returns {{tasks: Array<any>, duplicatesCollapsed: number}} */
169
+ export function dedupeDependencies(tasks) {
170
+ let duplicatesCollapsed = 0;
171
+ const normalized = tasks.map((t) => {
172
+ if (!Array.isArray(t.depends_on)) return t;
173
+ const seen = new Set();
174
+ const deps = [];
175
+ for (const d of t.depends_on) {
176
+ if (seen.has(d)) { duplicatesCollapsed++; continue; }
177
+ seen.add(d);
178
+ deps.push(d);
179
+ }
180
+ return deps.length === t.depends_on.length ? t : { ...t, depends_on: deps };
181
+ });
182
+ return { tasks: normalized, duplicatesCollapsed };
183
+ }
184
+
185
+ /** Data-quality facts computed from parsed data WITHOUT judging them — the
186
+ * verifier owns pass/fail; indexers only surface facts deterministically.
187
+ * - selfDeps: tasks whose depends_on includes their own id (note: such a task
188
+ * is also a 1-node cycle, so the CLI cycle gate normally exits 2 first;
189
+ * the field keeps the shape uniform for lib-level consumers).
190
+ * - emptyOwnership: missing or empty file_ownership (parallel workers would
191
+ * have no disjointness contract).
192
+ * - blankAcceptance: missing or empty acceptance (no objective done-criteria).
193
+ * @param {Array<any>} tasks
194
+ * @returns {{selfDeps: number, emptyOwnership: number, blankAcceptance: number}} */
195
+ export function computeQualityStats(tasks) {
196
+ let selfDeps = 0;
197
+ let emptyOwnership = 0;
198
+ let blankAcceptance = 0;
199
+ for (const t of tasks) {
200
+ if (Array.isArray(t.depends_on) && t.depends_on.includes(t.id)) selfDeps++;
201
+ const own = t.file_ownership;
202
+ if (!Array.isArray(own) || own.length === 0) emptyOwnership++;
203
+ const acc = t.acceptance;
204
+ if (!Array.isArray(acc) || acc.length === 0) blankAcceptance++;
205
+ }
206
+ return { selfDeps, emptyOwnership, blankAcceptance };
207
+ }
@@ -0,0 +1,65 @@
1
+ // scope.mjs — the single entry gate for plan-graph.mjs and doc-index.mjs.
2
+ //
3
+ // WHY THIS EXISTS: these indexers read whatever tree they are pointed at. An
4
+ // agent following injected instructions in a README, a prompt, or a hostile
5
+ // tasks.yaml could otherwise aim them at arbitrary project source, dotfiles,
6
+ // or home-directory content and have the tooling enumerate it. Pinning every
7
+ // input to `.archgen/<slug>/` guarantees both tools only ever read archgen's
8
+ // own artifact space — nothing else, no escape hatch.
9
+ //
10
+ // resolveSlugInput(rawInput) -> { kind: 'dir'|'tasks', root: <absolute slug dir>, slug: <dirname> }
11
+ // Rules:
12
+ // - fs.realpathSync the input first: symlinks are resolved to their target,
13
+ // so `ln -s ~/secrets .archgen/x` style escapes are defeated (the real
14
+ // path outside .archgen fails the segment check).
15
+ // - The REAL path must contain a path segment exactly `.archgen` (segment
16
+ // equality — `.archgenx/` does not count) such that the target is either
17
+ // (a) a directory immediately under that `.archgen` segment (the slug
18
+ // dir), or (b) a file named `tasks.yaml` directly inside such a slug dir.
19
+ // - Everything else throws ScopeError. Missing/unreadable inputs surface as
20
+ // the underlying realpath/stat error so callers keep their own messages.
21
+ import { realpathSync, statSync } from 'node:fs';
22
+ import { basename, dirname, sep } from 'node:path';
23
+
24
+ export class ScopeError extends Error {
25
+ constructor(input) {
26
+ super(`'${input}' is outside .archgen scope — plan-graph/doc-index index only .archgen/<slug>/ plans`);
27
+ this.name = 'ScopeError';
28
+ }
29
+ }
30
+
31
+ /** Split an absolute real path into segments, separator-aware on all platforms
32
+ * (win32 accepts both `\` and `/`; posix treats `\` as a normal filename char). */
33
+ function segmentsOf(abs) {
34
+ return process.platform === 'win32' ? abs.split(/[\\/]+/).filter(Boolean) : abs.split(sep).filter(Boolean);
35
+ }
36
+
37
+ export function resolveSlugInput(rawInput) {
38
+ let real;
39
+ try {
40
+ real = realpathSync(rawInput); // follows symlinks: judge the TARGET, not the link
41
+ } catch (e) {
42
+ e.input = rawInput;
43
+ throw e; // missing/unreadable: caller renders its own "cannot read" message
44
+ }
45
+ const st = statSync(real);
46
+ const parts = segmentsOf(real);
47
+
48
+ // Scan EVERY `.archgen` segment; accept when any of them makes the target an
49
+ // immediate slug dir (a) or its direct tasks.yaml (b). Last-wins is fine in
50
+ // practice but any-match is the honest reading of the rule for nested oddities.
51
+ for (let i = 0; i < parts.length; i++) {
52
+ if (parts[i] !== '.archgen') continue;
53
+ const depthFromArchgen = parts.length - 1 - i; // segments after this `.archgen`
54
+ if (st.isDirectory() && depthFromArchgen === 1) {
55
+ // (a) `<...>/.archgen/<slug>` — the slug dir itself
56
+ return { kind: 'dir', root: real, slug: parts[i + 1] };
57
+ }
58
+ if (st.isFile() && basename(real) === 'tasks.yaml' && depthFromArchgen === 2) {
59
+ // (b) `<...>/.archgen/<slug>/tasks.yaml` — direct tasks.yaml mode
60
+ const root = dirname(real);
61
+ return { kind: 'tasks', root, slug: basename(root) };
62
+ }
63
+ }
64
+ throw new ScopeError(rawInput);
65
+ }