@mlmcps/ml-specs 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/.claude-plugin/marketplace.json +15 -0
  2. package/LICENSE +21 -0
  3. package/README.md +106 -0
  4. package/ml-specs/.claude-plugin/plugin.json +11 -0
  5. package/ml-specs/.mcp.json +17 -0
  6. package/ml-specs/CHANGELOG.md +860 -0
  7. package/ml-specs/LICENSE +21 -0
  8. package/ml-specs/README.md +347 -0
  9. package/ml-specs/agents/coder.md +76 -0
  10. package/ml-specs/agents/developer.md +78 -0
  11. package/ml-specs/agents/pr-author.md +36 -0
  12. package/ml-specs/agents/reviewer.md +65 -0
  13. package/ml-specs/agents/scanner.md +66 -0
  14. package/ml-specs/agents/spec-author.md +91 -0
  15. package/ml-specs/agents/spec-reviewer.md +59 -0
  16. package/ml-specs/commands/code.md +29 -0
  17. package/ml-specs/commands/fix.md +67 -0
  18. package/ml-specs/commands/nfr.md +114 -0
  19. package/ml-specs/commands/pr.md +32 -0
  20. package/ml-specs/commands/repo-adopt.md +86 -0
  21. package/ml-specs/commands/repo-doctor.md +57 -0
  22. package/ml-specs/commands/repo-estate.md +79 -0
  23. package/ml-specs/commands/repo-impact.md +77 -0
  24. package/ml-specs/commands/repo-init.md +155 -0
  25. package/ml-specs/commands/repo-refresh.md +58 -0
  26. package/ml-specs/commands/repo-rollout.md +84 -0
  27. package/ml-specs/commands/repo-status.md +59 -0
  28. package/ml-specs/commands/spec-advance.md +81 -0
  29. package/ml-specs/commands/spec-build.md +66 -0
  30. package/ml-specs/commands/spec-fanout.md +64 -0
  31. package/ml-specs/commands/spec-review.md +24 -0
  32. package/ml-specs/commands/spec-verify.md +55 -0
  33. package/ml-specs/commands/spec.md +73 -0
  34. package/ml-specs/hooks/hooks.json +23 -0
  35. package/ml-specs/hooks/knowledge-drift.sh +43 -0
  36. package/ml-specs/hooks/secret-scan.sh +56 -0
  37. package/ml-specs/mcp/README.md +173 -0
  38. package/ml-specs/mcp/ml-specs-server.mjs +708 -0
  39. package/ml-specs/mcp/ml-specs-server.test.mjs +225 -0
  40. package/ml-specs/package.json +44 -0
  41. package/ml-specs/scripts/branch-policy.mjs +71 -0
  42. package/ml-specs/scripts/fix-specs.mjs +289 -0
  43. package/ml-specs/scripts/lib/cli.mjs +43 -0
  44. package/ml-specs/scripts/lib/estate.mjs +108 -0
  45. package/ml-specs/scripts/lib/http.mjs +73 -0
  46. package/ml-specs/scripts/lib/knowledge.mjs +91 -0
  47. package/ml-specs/scripts/lib/knowledge.test.mjs +70 -0
  48. package/ml-specs/scripts/lib/nfr.mjs +119 -0
  49. package/ml-specs/scripts/lib/nfr.test.mjs +89 -0
  50. package/ml-specs/scripts/lib/policy.mjs +114 -0
  51. package/ml-specs/scripts/lib/policy.test.mjs +139 -0
  52. package/ml-specs/scripts/lib/scm.mjs +189 -0
  53. package/ml-specs/scripts/lib/scm.test.mjs +155 -0
  54. package/ml-specs/scripts/lib/specs.mjs +192 -0
  55. package/ml-specs/scripts/lib/specs.test.mjs +69 -0
  56. package/ml-specs/scripts/lib/trace.mjs +90 -0
  57. package/ml-specs/scripts/lib/trace.test.mjs +110 -0
  58. package/ml-specs/scripts/lib/tracker.mjs +257 -0
  59. package/ml-specs/scripts/lib/tracker.test.mjs +177 -0
  60. package/ml-specs/scripts/nfr-compile.mjs +120 -0
  61. package/ml-specs/scripts/scripts.test.mjs +182 -0
  62. package/ml-specs/scripts/spec-brief.mjs +127 -0
  63. package/ml-specs/scripts/spec-dashboard.mjs +331 -0
  64. package/ml-specs/scripts/spec-fanout.mjs +120 -0
  65. package/ml-specs/scripts/spec-gate.mjs +329 -0
  66. package/ml-specs/scripts/spec-trace.mjs +91 -0
  67. package/ml-specs/scripts/survey-estate.mjs +230 -0
  68. package/ml-specs/scripts/tracker-sync.mjs +91 -0
  69. package/ml-specs/skills/knowledge-retrieval/SKILL.md +75 -0
  70. package/ml-specs/templates/CLAUDE.fragment.md +87 -0
  71. package/ml-specs/templates/ci/azure-pipelines-spec-gate.yml +64 -0
  72. package/ml-specs/templates/ci/knowledge-check.mjs +176 -0
  73. package/ml-specs/templates/ci/knowledge-layer.yml +35 -0
  74. package/ml-specs/templates/ci/spec-gate.yml +69 -0
  75. package/ml-specs/templates/docs/ARCHITECTURE.template.md +110 -0
  76. package/ml-specs/templates/docs/ESTATE.template.md +55 -0
  77. package/ml-specs/templates/docs/PATTERNS.template.md +80 -0
  78. package/ml-specs/templates/gitattributes +18 -0
  79. package/ml-specs/templates/hooks/settings.hooks.example.json +32 -0
  80. package/ml-specs/templates/mcp/.mcp.json +34 -0
  81. package/ml-specs/templates/settings.json +18 -0
  82. package/ml-specs/templates/specs/AGENTS.md +57 -0
  83. package/ml-specs/templates/specs/README.md +104 -0
  84. package/ml-specs/templates/specs/TEMPLATE.md +114 -0
  85. package/ml-specs/templates/standards/.mlskills.json +24 -0
  86. package/package.json +36 -0
@@ -0,0 +1,329 @@
1
+ #!/usr/bin/env node
2
+ // Check the MECHANICAL half of a /spec-advance lifecycle gate.
3
+ // Pure Node, no dependencies. Read-only. No network calls — nothing leaves this machine.
4
+ //
5
+ // node spec-gate.mjs specs/0001-foo.md # gate for the next status
6
+ // node spec-gate.mjs specs/0001-foo.md --to Verified # gate for a named target
7
+ // node spec-gate.mjs specs/0001-foo.md --json # machine-readable
8
+ // node spec-gate.mjs specs/0001-foo.md --root /path/to/repo
9
+ //
10
+ // Why this exists: /spec-advance's gates are half judgement and half bookkeeping, and the
11
+ // bookkeeping half was being done by a model re-reading the spec and globbing for test files.
12
+ // That is slow, costs tokens per run, and is the half most likely to be done sloppily — a
13
+ // named-but-missing test file is the most common way a status ends up claiming evidence that
14
+ // isn't there. A script does it exactly, every time, for free.
15
+ //
16
+ // What it deliberately does NOT decide: whether the human approved in conversation, whether a
17
+ // §8 question is blocking, whether the reviewer was satisfied, whether the suite ran green.
18
+ // Those are reported as MANUAL. A script that guessed at them would be worse than no script,
19
+ // because its PASS would get believed.
20
+ //
21
+ // Exit codes: 0 = no mechanical gate failed · 1 = at least one FAIL · 2 = could not run.
22
+
23
+ import { readFileSync, existsSync, readdirSync } from 'node:fs';
24
+ import { join, basename, relative, resolve } from 'node:path';
25
+ import { execFileSync } from 'node:child_process';
26
+ import { LIFECYCLE, listSpecs } from './lib/specs.mjs';
27
+
28
+ // ---------------------------------------------------------------------------- args
29
+
30
+ // Walked rather than searched, so a positional that happens to equal a flag's value
31
+ // (e.g. `--root specs` then `specs/0001-foo.md`) is still read as the positional.
32
+ const TAKES_VALUE = new Set(['--root', '--to']);
33
+ const opts = { '--root': process.cwd(), '--to': null };
34
+ let specArg = null;
35
+ {
36
+ const argv = process.argv.slice(2);
37
+ for (let i = 0; i < argv.length; i++) {
38
+ const a = argv[i];
39
+ if (TAKES_VALUE.has(a)) { opts[a] = argv[++i] ?? null; continue; }
40
+ if (a === '--json') { opts['--json'] = true; continue; }
41
+ if (a.startsWith('--')) continue;
42
+ if (specArg === null) specArg = a;
43
+ }
44
+ }
45
+
46
+ const ROOT = resolve(opts['--root'] ?? process.cwd());
47
+ const JSON_OUT = opts['--json'] === true;
48
+
49
+ if (!specArg) {
50
+ console.error('usage: spec-gate.mjs <spec-file> [--to <Status>] [--root <dir>] [--json]');
51
+ process.exit(2);
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------- load
55
+
56
+ const specRel = relative(ROOT, resolve(ROOT, specArg)).split('\\').join('/');
57
+ const specAbs = join(ROOT, specRel);
58
+ if (!existsSync(specAbs)) {
59
+ console.error(`spec-gate: no such file: ${specRel}`);
60
+ process.exit(2);
61
+ }
62
+
63
+ // Parse via the shared implementation so this agrees with /repo-status, /repo-doctor, the
64
+ // dashboard and the MCP server about what a spec says. Two parsers would have drifted.
65
+ const spec = listSpecs(ROOT).find((s) => s.file === specRel);
66
+ if (!spec) {
67
+ console.error(`spec-gate: ${specRel} is not a spec file (expected specs/NNNN-slug.md)`);
68
+ process.exit(2);
69
+ }
70
+
71
+ const text = readFileSync(specAbs, 'utf8');
72
+ const current = spec.status;
73
+ const target = opts['--to'] ?? (current && LIFECYCLE.indexOf(current) < LIFECYCLE.length - 1
74
+ ? LIFECYCLE[LIFECYCLE.indexOf(current) + 1]
75
+ : null);
76
+
77
+ const gates = [];
78
+ // Evidence is capped so a badly-drifted spec can't bury the verdict, but a silent cap would
79
+ // read as "that's all of it" — so say when there is more.
80
+ const CAP = 10;
81
+ const add = (name, verdict, detail, evidence = []) => {
82
+ const shown = evidence.slice(0, CAP);
83
+ if (evidence.length > CAP) shown.push(`… and ${evidence.length - CAP} more`);
84
+ gates.push({ name, verdict, detail, evidence: shown, evidenceTotal: evidence.length });
85
+ };
86
+
87
+ // ---------------------------------------------------------------------------- helpers
88
+
89
+ function git(args) {
90
+ try {
91
+ return execFileSync('git', args, { cwd: ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ /** The body of a numbered spec section, e.g. section(6) → everything under "## 6. …". */
98
+ function section(n) {
99
+ const lines = text.split('\n');
100
+ const start = lines.findIndex((l) => new RegExp(`^#{1,4}\\s*${n}[.)]?\\s`).test(l));
101
+ if (start === -1) return null;
102
+ const level = (lines[start].match(/^#+/) ?? ['##'])[0].length;
103
+ let end = lines.length;
104
+ for (let i = start + 1; i < lines.length; i++) {
105
+ const m = lines[i].match(/^(#+)\s/);
106
+ if (m && m[1].length <= level) { end = i; break; }
107
+ }
108
+ return lines.slice(start + 1, end).join('\n');
109
+ }
110
+
111
+ const SKIP_DIRS = new Set([
112
+ '.git', 'node_modules', 'target', 'build', 'dist', 'out', 'vendor', '.venv', 'venv',
113
+ '__pycache__', '.next', '.nuxt', 'coverage', '.gradle', '.idea', 'bin', 'obj',
114
+ ]);
115
+
116
+ /** Bounded index of basename → relative paths. Built once, only if a gate needs it. */
117
+ let fileIndex = null;
118
+ function indexFiles() {
119
+ if (fileIndex) return fileIndex;
120
+ fileIndex = new Map();
121
+ let budget = 60000; // enough for a large monorepo, bounded so this can't hang a CI job
122
+ const walk = (dir) => {
123
+ if (budget <= 0) return;
124
+ let entries;
125
+ try { entries = readdirSync(join(ROOT, dir), { withFileTypes: true }); } catch { return; }
126
+ for (const e of entries) {
127
+ if (budget <= 0) return;
128
+ if (e.name.startsWith('.') && e.name !== '.github') continue;
129
+ const rel = dir ? `${dir}/${e.name}` : e.name;
130
+ if (e.isDirectory()) {
131
+ if (SKIP_DIRS.has(e.name)) continue;
132
+ walk(rel);
133
+ } else {
134
+ budget--;
135
+ if (!fileIndex.has(e.name)) fileIndex.set(e.name, []);
136
+ fileIndex.get(e.name).push(rel);
137
+ }
138
+ }
139
+ };
140
+ walk('');
141
+ return fileIndex;
142
+ }
143
+
144
+ const TEST_EXT = 'java|kt|kts|ts|tsx|js|jsx|mjs|cjs|py|go|rb|cs|php|scala|swift|rs|dart|ex|exs';
145
+
146
+ /**
147
+ * Pull the things a §6 test-plan table claims exist. Two shapes, because projects write both:
148
+ * * a path or filename with an extension — user.service.spec.ts, src/__tests__/foo.test.js
149
+ * * a bare Java/C#-style test class name — CouponExpiryIntegrationTest
150
+ * Anything else in the cell (prose, a criterion id, a method name) is ignored on purpose:
151
+ * a false "missing test" would block a legitimate transition, which is worse than a miss.
152
+ */
153
+ function claimedTests(body) {
154
+ const out = new Set();
155
+ for (const m of body.matchAll(new RegExp(`[A-Za-z0-9_./\\\\-]+\\.(?:${TEST_EXT})\\b`, 'g'))) {
156
+ out.add(m[0].split('\\').join('/'));
157
+ }
158
+ for (const m of body.matchAll(/\b([A-Z][A-Za-z0-9]*(?:Test|Tests|Spec|IT|TestCase))\b/g)) {
159
+ out.add(m[1]);
160
+ }
161
+ return [...out];
162
+ }
163
+
164
+ function resolveTest(token) {
165
+ if (/[./]/.test(token) && token.includes('/')) {
166
+ if (existsSync(join(ROOT, token))) return token;
167
+ // A spec often writes a path relative to a module root rather than the repo root.
168
+ const hits = indexFiles().get(basename(token)) ?? [];
169
+ const suffix = hits.find((p) => p.endsWith(token));
170
+ if (suffix) return suffix;
171
+ return hits[0] ?? null;
172
+ }
173
+ if (/\.(?:[a-z]+)$/i.test(token)) {
174
+ return (indexFiles().get(token) ?? [])[0] ?? null;
175
+ }
176
+ // Bare class name — find a source file whose basename matches.
177
+ for (const [name, paths] of indexFiles()) {
178
+ if (name.replace(new RegExp(`\\.(?:${TEST_EXT})$`), '') === token) return paths[0];
179
+ }
180
+ return null;
181
+ }
182
+
183
+ // ---------------------------------------------------------------------------- gates
184
+
185
+ // 0. The transition itself.
186
+ if (!current) {
187
+ add('lifecycle', 'FAIL', `Status cell holds no lifecycle word: ${JSON.stringify(spec.rawStatus ?? '(empty)')}`);
188
+ } else if (!target) {
189
+ add('lifecycle', 'FAIL', `${current} is the last status — nothing to advance to`);
190
+ } else if (!LIFECYCLE.includes(target)) {
191
+ add('lifecycle', 'FAIL', `"${target}" is not a lifecycle status (${LIFECYCLE.join(' → ')})`);
192
+ } else {
193
+ const from = LIFECYCLE.indexOf(current);
194
+ const to = LIFECYCLE.indexOf(target);
195
+ if (to === from) add('lifecycle', 'FAIL', `already ${current}`);
196
+ else if (to < from) add('lifecycle', 'MANUAL', `moving backwards ${current} → ${target} — allowed, but it must add a Revisions row and un-tick criteria that no longer hold`);
197
+ else if (to - from > 1) add('lifecycle', 'FAIL', `cannot skip: ${current} → ${target} passes ${LIFECYCLE.slice(from + 1, to).join(', ')} — run the gates in order`);
198
+ else add('lifecycle', 'PASS', `${current} → ${target}`);
199
+ }
200
+
201
+ const forward = current && target && LIFECYCLE.indexOf(target) > LIFECYCLE.indexOf(current);
202
+
203
+ // 1. Draft → Approved: unfilled template, and whatever §8 still holds.
204
+ if (forward && target === 'Approved') {
205
+ const placeholders = [];
206
+ text.split('\n').forEach((line, i) => {
207
+ if (/^\s*<!--/.test(line)) return;
208
+ for (const m of line.matchAll(/<([a-z][a-z0-9 _/-]{2,40})>/gi)) {
209
+ if (/:\/\//.test(m[1])) continue;
210
+ placeholders.push(`${specRel}:${i + 1} <${m[1]}>`);
211
+ }
212
+ });
213
+ if (placeholders.length) {
214
+ add('placeholders', 'FAIL', `${placeholders.length} unfilled template placeholder(s)`, placeholders);
215
+ } else {
216
+ add('placeholders', 'PASS', 'no template placeholders left');
217
+ }
218
+
219
+ const s8 = section(8);
220
+ if (s8 === null) {
221
+ add('section-8', 'MANUAL', 'no section 8 found — confirm the spec has an open-questions section');
222
+ } else {
223
+ const bullets = s8.split('\n').filter((l) => /^\s*[-*]\s+\S/.test(l) && !/^\s*[-*]\s+(none|n\/a|—)\b/i.test(l));
224
+ if (bullets.length === 0) add('section-8', 'PASS', 'section 8 holds no open questions');
225
+ else add('section-8', 'MANUAL', `section 8 holds ${bullets.length} open question(s) — judge whether any is blocking (would change an API shape, data model, error code, scope boundary, or compatibility)`, bullets.map((b) => b.trim()));
226
+ }
227
+
228
+ add('human-approval', 'MANUAL', 'the human must approve in the conversation — a script cannot witness that');
229
+ }
230
+
231
+ // 2. Approved → Implemented, and still required at Verified: criteria + the named tests.
232
+ if (forward && (target === 'Implemented' || target === 'Verified')) {
233
+ if (spec.acTotal === 0) {
234
+ add('criteria', 'FAIL', 'no acceptance criteria — there is nothing to verify against');
235
+ } else if (spec.acChecked < spec.acTotal) {
236
+ add('criteria', 'FAIL', `${spec.acTotal - spec.acChecked} of ${spec.acTotal} acceptance criteria unchecked`);
237
+ } else {
238
+ add('criteria', 'PASS', `all ${spec.acTotal} acceptance criteria checked`);
239
+ }
240
+
241
+ const s6 = section(6);
242
+ if (s6 === null) {
243
+ add('tests-exist', 'FAIL', 'no section 6 (test plan) found — nothing names the tests');
244
+ } else {
245
+ const claimed = claimedTests(s6);
246
+ if (claimed.length === 0) {
247
+ add('tests-exist', 'FAIL', 'section 6 names no test file or test class — a test plan that names nothing cannot be checked');
248
+ } else {
249
+ const missing = [];
250
+ const found = [];
251
+ for (const t of claimed) {
252
+ const hit = resolveTest(t);
253
+ if (hit) found.push(`${t} → ${hit}`);
254
+ else missing.push(t);
255
+ }
256
+ if (missing.length) {
257
+ add('tests-exist', 'FAIL', `${missing.length} of ${claimed.length} named test(s) do not exist on disk`, missing);
258
+ } else {
259
+ add('tests-exist', 'PASS', `all ${claimed.length} named test(s) exist on disk`, found);
260
+ }
261
+ }
262
+ }
263
+ }
264
+
265
+ // 3. Implemented → Verified: the parts only a run can establish.
266
+ if (forward && target === 'Verified') {
267
+ add('suite-green', 'MANUAL', 'the §6.1 full suite must have run green end to end in this session — if it cannot be run here, refuse the transition');
268
+ add('adversarial-review', 'MANUAL', 'a clean /spec-verify must have marked every criterion satisfied, with a functional/E2E test for each user-facing or contract-level one');
269
+ }
270
+
271
+ // 4. Verified → Archived: is the branch actually merged?
272
+ if (forward && target === 'Archived') {
273
+ const branch = spec.branch;
274
+ if (!branch) {
275
+ add('branch-merged', 'FAIL', 'no Branch recorded in the header table — cannot check whether it merged');
276
+ } else if (!git(['rev-parse', '--is-inside-work-tree'])) {
277
+ add('branch-merged', 'MANUAL', 'not a git work tree — confirm the merge by hand');
278
+ } else {
279
+ const head = git(['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD']);
280
+ const def = (head && head.replace(/^origin\//, ''))
281
+ ?? ['main', 'master', 'develop'].find((b) => git(['rev-parse', '--verify', '--quiet', b]));
282
+ const clean = branch.replace(/[`*]/g, '').trim();
283
+ if (!def) {
284
+ add('branch-merged', 'MANUAL', `could not determine the default branch — check \`git branch --merged\` for ${clean} by hand`);
285
+ } else {
286
+ const merged = (git(['branch', '--merged', def]) ?? '')
287
+ .split('\n').map((l) => l.replace(/^[*+ ]+/, '').trim());
288
+ if (merged.includes(clean)) {
289
+ add('branch-merged', 'PASS', `${clean} is merged into ${def}`);
290
+ } else {
291
+ const contains = git(['branch', '--contains', clean]);
292
+ add('branch-merged', 'FAIL', `${clean} is not in \`git branch --merged ${def}\``,
293
+ contains ? [`branches containing it: ${contains.replace(/\s+/g, ' ').trim()}`] : []);
294
+ }
295
+ }
296
+ }
297
+ add('archive-move', 'MANUAL', `on pass, \`git mv ${specRel} specs/archive/${basename(specRel)}\` and fix links that pointed at it — the number is never reused`);
298
+ }
299
+
300
+ // ---------------------------------------------------------------------------- report
301
+
302
+ const failed = gates.filter((g) => g.verdict === 'FAIL');
303
+ const manual = gates.filter((g) => g.verdict === 'MANUAL');
304
+
305
+ if (JSON_OUT) {
306
+ console.log(JSON.stringify({
307
+ spec: specRel, title: spec.title, current, target,
308
+ ok: failed.length === 0, gates,
309
+ }, null, 2));
310
+ } else {
311
+ const mark = { PASS: '✓', FAIL: '✗', MANUAL: '·' };
312
+ console.log(`${specRel} — ${spec.title}`);
313
+ console.log(`${current ?? '(no status)'} → ${target ?? '(none)'}\n`);
314
+ for (const g of gates) {
315
+ console.log(` ${mark[g.verdict]} ${g.verdict.padEnd(6)} ${g.name.padEnd(18)} ${g.detail}`);
316
+ for (const e of g.evidence) console.log(` ${e}`);
317
+ }
318
+ console.log();
319
+ if (failed.length) {
320
+ console.log(`✗ ${failed.length} gate(s) failed — do not write the status. Report which, and the one command that produces the missing evidence.`);
321
+ } else {
322
+ console.log('✓ no mechanical gate failed.');
323
+ }
324
+ if (manual.length) {
325
+ console.log(`· ${manual.length} gate(s) need judgement — this script does not decide them.`);
326
+ }
327
+ }
328
+
329
+ process.exit(failed.length ? 1 : 0);
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ // Verify the ID chain from ticket to test case for one spec, or every spec.
3
+ // Pure Node, no dependencies. Read-only. No network calls — nothing leaves this machine.
4
+ //
5
+ // node spec-trace.mjs # every spec in the repo
6
+ // node spec-trace.mjs specs/0031-foo.md # one spec
7
+ // node spec-trace.mjs 0031 # by id
8
+ // node spec-trace.mjs --json # machine-readable
9
+ // node spec-trace.mjs --root /path/to/repo
10
+ //
11
+ // Exit code 1 if any chain is broken, so it can gate a merge.
12
+ //
13
+ // Why this exists: "which requirement is covered by which test, and did it ship?"
14
+ // is the question an auditor asks and the one a repo usually cannot answer without
15
+ // a week of archaeology. It only stays answerable if every artifact carries the
16
+ // spec's id — and the way that silently stops being true is a hand-typed branch
17
+ // name. This checks it in milliseconds, for free, on every push.
18
+ //
19
+ // It deliberately distinguishes BROKEN from UNVERIFIABLE. A ticket reference a
20
+ // human typed into the header table cannot be checked from inside the repo; saying
21
+ // so is honest, and quietly counting it as passing would make the report a lie.
22
+
23
+ import { listSpecs } from './lib/specs.mjs';
24
+ import { buildChain, verifyChain, renderChain } from './lib/trace.mjs';
25
+
26
+ const argv = process.argv.slice(2);
27
+ const flag = (name, fallback = null) => {
28
+ const i = argv.indexOf(`--${name}`);
29
+ return i === -1 ? fallback : (argv[i + 1] ?? fallback);
30
+ };
31
+ const has = (name) => argv.includes(`--${name}`);
32
+
33
+ const root = flag('root', process.cwd());
34
+ const json = has('json');
35
+ const target = argv.find((a) => !a.startsWith('--') && argv[argv.indexOf(a) - 1] !== '--root');
36
+
37
+ const C = process.stdout.isTTY && !json
38
+ ? { dim: (s) => `\x1b[2m${s}\x1b[0m`, red: (s) => `\x1b[31m${s}\x1b[0m`,
39
+ green: (s) => `\x1b[32m${s}\x1b[0m`, bold: (s) => `\x1b[1m${s}\x1b[0m` }
40
+ : { dim: (s) => s, red: (s) => s, green: (s) => s, bold: (s) => s };
41
+
42
+ const all = listSpecs(root);
43
+ const specs = target
44
+ ? all.filter((s) => s.id === target || s.file.endsWith(target) || s.file === target)
45
+ : all;
46
+
47
+ if (specs.length === 0) {
48
+ console.error(target ? `no spec matching "${target}" under ${root}` : `no specs under ${root}`);
49
+ process.exit(1);
50
+ }
51
+
52
+ const results = specs.map((spec) => {
53
+ const chain = buildChain(spec);
54
+ const report = verifyChain(chain);
55
+ return { spec, chain, report };
56
+ });
57
+
58
+ if (json) {
59
+ console.log(JSON.stringify({
60
+ root,
61
+ ok: results.every((r) => r.report.ok),
62
+ specs: results.map(({ spec, chain, report }) => ({
63
+ id: spec.id,
64
+ title: spec.title,
65
+ status: spec.status,
66
+ ok: report.ok,
67
+ links: chain.links.map((l) => ({ kind: l.kind, ref: l.ref, origin: l.origin })),
68
+ broken: report.broken.map((b) => ({ kind: b.link.kind, ref: b.link.ref, reason: b.reason, found: b.found })),
69
+ unverifiable: report.unverifiable.map((l) => ({ kind: l.kind, ref: l.ref })),
70
+ })),
71
+ }, null, 2));
72
+ } else {
73
+ for (const { spec, chain, report } of results) {
74
+ console.log(`${C.bold(`SPEC-${spec.id}`)} ${spec.title} ${C.dim(spec.status ?? 'unknown')}`);
75
+ console.log(` ${renderChain(chain)}`);
76
+ console.log(report.ok ? ` ${C.green('chain intact')}` : ` ${C.red(`${report.broken.length} broken link(s)`)}`);
77
+ for (const b of report.broken) {
78
+ console.log(` ${C.red('x')} ${b.link.kind} ${b.link.ref} ${C.dim(`(${b.reason}, found ${b.found ?? 'nothing'})`)}`);
79
+ }
80
+ for (const u of report.unverifiable) {
81
+ console.log(` ${C.dim(`? ${u.kind} ${u.ref} — hand-typed, cannot be verified from the repo`)}`);
82
+ }
83
+ console.log();
84
+ }
85
+ const broken = results.filter((r) => !r.report.ok).length;
86
+ console.log(broken === 0
87
+ ? C.green(` ${results.length} spec(s), every chain intact`)
88
+ : C.red(` ${broken} of ${results.length} spec(s) have a broken chain`));
89
+ }
90
+
91
+ process.exit(results.every((r) => r.report.ok) ? 0 : 1);
@@ -0,0 +1,230 @@
1
+ #!/usr/bin/env node
2
+ // Survey candidate repos before onboarding the toolkit into them.
3
+ // Pure Node, no dependencies, read-only, no network calls.
4
+ //
5
+ // node survey-estate.mjs ../ # survey every git repo under a parent dir
6
+ // node survey-estate.mjs ../svc-a ../svc-b # or name them explicitly
7
+ // node survey-estate.mjs ../ --json # machine-readable
8
+ //
9
+ // Why this exists: /repo-init is the most expensive single operation in the toolkit — a full
10
+ // codebase scan. Running it blindly across an estate burns a lot of tokens and produces a pile
11
+ // of unreviewed knowledge layers. This does the CHEAP, MECHANICAL part first — what's there,
12
+ // what state it's in, how much cross-service surface it has — so the expensive part is aimed
13
+ // rather than sprayed. It reads files; it never writes.
14
+ //
15
+ // Every judgment here is a heuristic and is labelled as one. It tells you where to look, not
16
+ // what to conclude.
17
+
18
+ import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
19
+ import { join, basename, resolve } from 'node:path';
20
+ import { execFileSync } from 'node:child_process';
21
+
22
+ const args = process.argv.slice(2);
23
+ const asJson = args.includes('--json');
24
+ const targets = args.filter((a) => !a.startsWith('--'));
25
+ if (targets.length === 0) {
26
+ console.error('usage: survey-estate.mjs <parent-dir | repo...> [--json]');
27
+ process.exit(1);
28
+ }
29
+
30
+ const SKIP_DIRS = new Set([
31
+ 'node_modules', '.git', 'target', 'build', 'dist', 'vendor', '.next', '.venv', 'venv',
32
+ '__pycache__', '.gradle', '.idea', 'coverage', 'out', 'bin', 'obj', '.terraform',
33
+ ]);
34
+ const SOURCE_EXT = new Set([
35
+ 'js', 'mjs', 'cjs', 'jsx', 'ts', 'tsx', 'java', 'kt', 'py', 'go', 'rb', 'cs', 'php', 'scala',
36
+ ]);
37
+ const MAX_FILES = 4000; // bounded: a survey must stay cheap even on a monorepo
38
+ const MAX_BYTES = 200_000;
39
+
40
+ // Manifest → stack. First match wins.
41
+ const MANIFESTS = [
42
+ ['pom.xml', 'Java/Maven'],
43
+ ['build.gradle', 'Java/Gradle'],
44
+ ['build.gradle.kts', 'Kotlin/Gradle'],
45
+ ['package.json', 'Node/JS'],
46
+ ['pyproject.toml', 'Python'],
47
+ ['requirements.txt', 'Python'],
48
+ ['go.mod', 'Go'],
49
+ ['Gemfile', 'Ruby'],
50
+ ['composer.json', 'PHP'],
51
+ ['Cargo.toml', 'Rust'],
52
+ ['pubspec.yaml', 'Dart/Flutter'],
53
+ ];
54
+
55
+ // Cross-service surface. These are the edges /repo-estate and /repo-impact need — a repo with
56
+ // many of them is worth onboarding early, because it unlocks answers for its peers too.
57
+ const EDGE_PATTERNS = {
58
+ events: /@SqsListener|@KafkaListener|@RabbitListener|SqsClient|SnsClient|KafkaProducer|kafkajs|amqplib|@nestjs\/microservices|PubSub\(/,
59
+ httpClients: /@FeignClient|axios\.create|new HttpClient|RestTemplate|WebClient\.builder|got\.extend|httpx\.Client/,
60
+ contracts: /openapi|swagger|\.proto\b|graphql/i,
61
+ };
62
+
63
+ const git = (cwd, ...a) => {
64
+ try {
65
+ return execFileSync('git', ['-C', cwd, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
66
+ } catch {
67
+ return '';
68
+ }
69
+ };
70
+
71
+ function walk(root) {
72
+ const files = [];
73
+ const stack = [root];
74
+ while (stack.length && files.length < MAX_FILES) {
75
+ const dir = stack.pop();
76
+ let entries;
77
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { continue; }
78
+ for (const e of entries) {
79
+ if (e.name.startsWith('.') && e.name !== '.github') continue;
80
+ if (SKIP_DIRS.has(e.name)) continue;
81
+ const full = join(dir, e.name);
82
+ if (e.isDirectory()) stack.push(full);
83
+ else files.push(full);
84
+ if (files.length >= MAX_FILES) break;
85
+ }
86
+ }
87
+ return files;
88
+ }
89
+
90
+ function surveyRepo(path) {
91
+ const name = basename(path);
92
+ const isRepo = git(path, 'rev-parse', '--is-inside-work-tree') === 'true';
93
+
94
+ const stack = MANIFESTS.find(([f]) => existsSync(join(path, f)));
95
+ const has = (p) => existsSync(join(path, p));
96
+
97
+ // Knowledge-layer state decides init vs adopt vs nothing-to-do.
98
+ const claude = has('CLAUDE.md');
99
+ const patterns = has('docs/PATTERNS.md');
100
+ const architecture = has('docs/ARCHITECTURE.md') || has('docs/architecture');
101
+ const specs = has('specs');
102
+ const specCount = specs
103
+ ? readdirSync(join(path, 'specs')).filter((f) => /^\d{4}[a-z]?-.*\.md$/.test(f)).length
104
+ : 0;
105
+ // Other tools' agent instructions — these are the repos where /repo-init would clobber.
106
+ const otherAgentDocs = ['AGENTS.md', '.cursorrules', '.github/copilot-instructions.md']
107
+ .filter(has);
108
+
109
+ let layer;
110
+ if (claude && patterns && architecture) layer = 'full';
111
+ else if (claude || patterns || architecture) layer = 'partial';
112
+ else if (otherAgentDocs.length) layer = 'other-tool';
113
+ else layer = 'none';
114
+
115
+ // Activity — a dormant repo is rarely worth the scan.
116
+ const lastCommit = isRepo ? git(path, 'log', '-1', '--format=%ad', '--date=short') : '';
117
+ const recent = isRepo
118
+ ? Number(git(path, 'rev-list', '--count', '--since=90.days', 'HEAD') || 0)
119
+ : 0;
120
+ const dirty = isRepo ? git(path, 'status', '--porcelain').split('\n').filter(Boolean).length : 0;
121
+
122
+ // Edge signals.
123
+ const edges = { events: 0, httpClients: 0, contracts: 0 };
124
+ let scanned = 0;
125
+ for (const f of walk(path)) {
126
+ const ext = f.split('.').pop().toLowerCase();
127
+ const isContractFile = /\.(proto|graphql)$/i.test(f) || /openapi|swagger/i.test(basename(f));
128
+ if (!SOURCE_EXT.has(ext) && !isContractFile) continue;
129
+ let text;
130
+ try {
131
+ if (statSync(f).size > MAX_BYTES) continue;
132
+ text = readFileSync(f, 'utf8');
133
+ } catch { continue; }
134
+ scanned++;
135
+ for (const [k, re] of Object.entries(EDGE_PATTERNS)) if (re.test(text)) edges[k]++;
136
+ }
137
+
138
+ // Recommendation. Deliberately conservative: anything ambiguous goes to a human.
139
+ let action, why;
140
+ if (!isRepo) { action = 'skip'; why = 'not a git repo'; }
141
+ else if (!stack) { action = 'review'; why = 'no recognised manifest — may not be a service'; }
142
+ else if (layer === 'full') { action = 'refresh'; why = 'already onboarded; /repo-refresh keeps it current'; }
143
+ else if (layer === 'partial' || layer === 'other-tool') { action = 'adopt'; why = `existing docs (${[...(claude ? ['CLAUDE.md'] : []), ...(patterns ? ['PATTERNS'] : []), ...otherAgentDocs].join(', ')}) — /repo-init would clobber them`; }
144
+ else if (recent === 0) { action = 'review'; why = `no commits in 90 days (last: ${lastCommit || 'unknown'}) — dormant?`; }
145
+ else { action = 'init'; why = 'no knowledge layer, active repo'; }
146
+
147
+ return {
148
+ name, path, isRepo,
149
+ stack: stack ? stack[1] : null,
150
+ layer, claude, patterns, architecture, specs: specCount, otherAgentDocs,
151
+ lastCommit, commits90d: recent, dirty,
152
+ edges, edgeTotal: edges.events + edges.httpClients + edges.contracts,
153
+ filesScanned: scanned, truncated: scanned >= MAX_FILES,
154
+ action, why,
155
+ };
156
+ }
157
+
158
+ // --- resolve targets --------------------------------------------------------
159
+
160
+ // Expand each target independently: a git repo is itself; any other directory is treated as a
161
+ // parent and scanned one level for repos. Doing this per-target rather than only when exactly
162
+ // one was given means `survey ~/work ~/other` works, and — the case that actually bit — a
163
+ // parent directory still expands when the shell appended stray words to the command.
164
+ const repos = [];
165
+ const missing = [];
166
+ for (const t of targets) {
167
+ const p = resolve(t);
168
+ if (!existsSync(p)) { missing.push(t); continue; }
169
+ if (existsSync(join(p, '.git'))) { repos.push(p); continue; }
170
+ let children;
171
+ try { children = readdirSync(p, { withFileTypes: true }); } catch { missing.push(t); continue; }
172
+ const found = children
173
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.') && existsSync(join(p, e.name, '.git')))
174
+ .map((e) => join(p, e.name));
175
+ if (found.length) repos.push(...found);
176
+ else missing.push(t);
177
+ }
178
+
179
+ if (missing.length) {
180
+ // zsh does not treat `#` as a comment interactively, so a pasted trailing comment arrives as
181
+ // arguments. Say so plainly instead of reporting six English words as "not a git repo".
182
+ console.error(`Not a repo and no repos inside: ${missing.join(', ')}`);
183
+ console.error('If you pasted a trailing `# comment`, zsh passed those words as arguments — drop it.\n');
184
+ }
185
+
186
+ if (repos.length === 0) {
187
+ console.error('No git repos found. Point at a parent directory, or name the repos.');
188
+ process.exit(1);
189
+ }
190
+
191
+ const results = repos.map(surveyRepo);
192
+
193
+ if (asJson) {
194
+ console.log(JSON.stringify(results, null, 2));
195
+ process.exit(0);
196
+ }
197
+
198
+ // --- report -----------------------------------------------------------------
199
+
200
+ const ORDER = { init: 0, adopt: 1, review: 2, refresh: 3, skip: 4 };
201
+ results.sort((a, b) => (ORDER[a.action] - ORDER[b.action]) || (b.edgeTotal - a.edgeTotal));
202
+
203
+ const pad = (s, n) => String(s ?? '').padEnd(n).slice(0, n);
204
+ console.log(`Surveyed ${results.length} repo(s). Read-only — nothing was modified.\n`);
205
+ console.log(`${pad('REPO', 26)} ${pad('ACTION', 8)} ${pad('STACK', 14)} ${pad('LAYER', 10)} ${pad('SPECS', 6)} ${pad('90d', 5)} ${pad('EDGES', 6)}`);
206
+ console.log('─'.repeat(84));
207
+ for (const r of results) {
208
+ console.log(
209
+ `${pad(r.name, 26)} ${pad(r.action, 8)} ${pad(r.stack ?? '—', 14)} ${pad(r.layer, 10)} ` +
210
+ `${pad(r.specs || '—', 6)} ${pad(r.commits90d, 5)} ${pad(r.edgeTotal || '—', 6)}`,
211
+ );
212
+ }
213
+
214
+ console.log('\nWhy, and what needs a human:');
215
+ for (const r of results) {
216
+ console.log(` ${r.name}: ${r.why}`);
217
+ if (r.dirty) console.log(` ⚠ ${r.dirty} uncommitted change(s) — onboard on a clean tree so the diff is reviewable`);
218
+ if (r.truncated) console.log(` note: file scan hit the ${MAX_FILES}-file cap; edge counts are a floor, not a total`);
219
+ }
220
+
221
+ const init = results.filter((r) => r.action === 'init');
222
+ const adopt = results.filter((r) => r.action === 'adopt');
223
+ console.log(`\nSuggested first wave (most cross-service surface — onboarding these unlocks /repo-impact for their peers):`);
224
+ for (const r of [...init, ...adopt].sort((a, b) => b.edgeTotal - a.edgeTotal).slice(0, 3)) {
225
+ console.log(` ${r.name} (${r.edgeTotal} edge signals: ${r.edges.events} event, ${r.edges.httpClients} http-client, ${r.edges.contracts} contract)`);
226
+ }
227
+ console.log(`\n${init.length} to /repo-init, ${adopt.length} to /repo-adopt, ` +
228
+ `${results.filter((r) => r.action === 'review').length} needing a decision, ` +
229
+ `${results.filter((r) => r.action === 'refresh').length} already onboarded.`);
230
+ console.log('Edge counts are FILE counts matching known patterns — a signal of where the contracts are, not a contract inventory.');