create-cadmo 0.1.0 → 0.3.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.
package/README.md CHANGED
@@ -13,5 +13,7 @@ Drops into the current directory, never overwriting anything:
13
13
  - `cadmo/spec.md` — the rules, in the client's language, criteria first
14
14
  - `cadmo/plan.md` — the internal route: tasks, constraints, how to validate
15
15
  - `cadmo/decision.md` — architecture decisions with a "when to reconsider" trigger
16
+ - `cadmo/spec-drift.mjs` + a ready CI workflow — **specs must change with the code they watch** (the drift guard)
17
+ - `.claude/commands/cadmo-*.md` — `/cadmo-gate`, `/cadmo-spec`, `/cadmo-done` slash commands (when Claude Code is detected, or `--claude`). **Pick one source for the commands:** if you install the [Cadmo plugin](https://github.com/tiagotorres91/cadmo) (`/plugin install cadmo@cadmo`, which provides `/cadmo:gate` etc.), run the scaffolder with `--no-claude` — otherwise you'll have both spellings.
16
18
 
17
- The method, docs and protocols: **https://github.com/tiagotorres91/cadmo** · **https://cadmo.dev**
19
+ The method, docs and protocols: **https://github.com/tiagotorres91/cadmo**
package/index.js CHANGED
@@ -1,44 +1,97 @@
1
- #!/usr/bin/env node
2
- /*
3
- * create-cadmo — scaffold the Cadmo method into the current project.
4
- * No dependencies. Never overwrites an existing file.
5
- * https://github.com/tiagotorres91/cadmo
6
- */
7
- const fs = require('fs');
8
- const path = require('path');
9
-
10
- const here = (...p) => path.join(__dirname, 'templates', ...p);
11
- const cwd = process.cwd();
12
-
13
- const FILES = [
14
- // [source in package, destination in project]
15
- ['AGENTS.md', 'AGENTS.md'],
16
- ['value-gate.md', path.join('cadmo', 'value-gate.md')],
17
- ['spec.md', path.join('cadmo', 'spec.md')],
18
- ['plan.md', path.join('cadmo', 'plan.md')],
19
- ['decision.md', path.join('cadmo', 'decision.md')],
20
- ];
21
-
22
- console.log('\n Cadmo write it down before you build it.\n');
23
-
24
- let placed = 0, skipped = 0;
25
- for (const [src, dest] of FILES) {
26
- const target = path.join(cwd, dest);
27
- if (fs.existsSync(target)) {
28
- console.log(` = kept ${dest} (already exists not touched)`);
29
- skipped++;
30
- continue;
31
- }
32
- fs.mkdirSync(path.dirname(target), { recursive: true });
33
- fs.copyFileSync(here(src), target);
34
- console.log(` + created ${dest}`);
35
- placed++;
36
- }
37
-
38
- console.log(`\n ${placed} file(s) created, ${skipped} kept.\n`);
39
- console.log(' Next steps:');
40
- console.log(' 1. Fill in AGENTS.md — the local map your AI pair reads first');
41
- console.log(' (using CLAUDE.md? link or rename it same idea).');
42
- console.log(' 2. For your next relevant feature: cadmo/value-gate.md (5 lines, before any spec).');
43
- console.log(' 3. For any 3+ step task: copy cadmo/plan.md — acceptance criteria before code.');
44
- console.log('\n The method in one page: https://github.com/tiagotorres91/cadmo\n');
1
+ #!/usr/bin/env node
2
+ /*
3
+ * create-cadmo — scaffold the Cadmo method into the current project.
4
+ * No dependencies. Never overwrites an existing file.
5
+ * https://github.com/tiagotorres91/cadmo
6
+ */
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+
10
+ const args = process.argv.slice(2);
11
+ const DRY = args.includes('--dry-run');
12
+ const FORCE_CLAUDE = args.includes('--claude');
13
+ const NO_CLAUDE = args.includes('--no-claude');
14
+
15
+ if (args.includes('--help') || args.includes('-h')) {
16
+ console.log(`
17
+ create-cadmo — drop the Cadmo method into the current project.
18
+
19
+ Usage: npm create cadmo [-- --dry-run] [-- --claude | --no-claude]
20
+
21
+ --dry-run show what would be created, create nothing
22
+ --claude also install Claude Code slash commands (/cadmo-gate, /cadmo-spec, /cadmo-done)
23
+ --no-claude skip the Claude Code commands even if CLAUDE.md/.claude is detected
24
+
25
+ Creates (never overwrites):
26
+ AGENTS.md the local map your AI pair reads first
27
+ cadmo/value-gate.md 5 lines before any spec: is it worth building?
28
+ cadmo/spec.md the rules, client language, criteria first
29
+ cadmo/plan.md the internal route: tasks, constraints, validation
30
+ cadmo/decision.md ADR with a "when to reconsider" trigger
31
+ cadmo/spec-drift.mjs the drift guard: specs must change with the code they watch
32
+ cadmo/cadmo-grammar.mjs the shared front-matter/glob grammar the guard imports
33
+ cadmo/spec-drift-workflow.yml ready CI workflow (move into .github/workflows/)
34
+ .claude/commands/cadmo-*.md (when Claude Code is detected, or --claude)
35
+
36
+ The method in one page: https://github.com/tiagotorres91/cadmo
37
+ `);
38
+ process.exit(0);
39
+ }
40
+
41
+ const here = (...p) => path.join(__dirname, 'templates', ...p);
42
+ const cwd = process.cwd();
43
+
44
+ const FILES = [
45
+ ['AGENTS.md', 'AGENTS.md'],
46
+ ['value-gate.md', path.join('cadmo', 'value-gate.md')],
47
+ ['spec.md', path.join('cadmo', 'spec.md')],
48
+ ['plan.md', path.join('cadmo', 'plan.md')],
49
+ ['decision.md', path.join('cadmo', 'decision.md')],
50
+ ['cadmo-grammar.mjs', path.join('cadmo', 'cadmo-grammar.mjs')],
51
+ ['spec-drift.mjs', path.join('cadmo', 'spec-drift.mjs')],
52
+ ['spec-drift-workflow.yml', path.join('cadmo', 'spec-drift-workflow.yml')],
53
+ ];
54
+
55
+ const hasClaude = FORCE_CLAUDE || (!NO_CLAUDE &&
56
+ (fs.existsSync(path.join(cwd, 'CLAUDE.md')) || fs.existsSync(path.join(cwd, '.claude'))));
57
+
58
+ const CLAUDE_FILES = [
59
+ [path.join('claude-commands', 'cadmo-gate.md'), path.join('.claude', 'commands', 'cadmo-gate.md')],
60
+ [path.join('claude-commands', 'cadmo-spec.md'), path.join('.claude', 'commands', 'cadmo-spec.md')],
61
+ [path.join('claude-commands', 'cadmo-done.md'), path.join('.claude', 'commands', 'cadmo-done.md')],
62
+ ];
63
+
64
+ const plan = hasClaude ? FILES.concat(CLAUDE_FILES) : FILES;
65
+
66
+ console.log(`\n Cadmo — write it down before you build it.${DRY ? ' (dry run)' : ''}\n`);
67
+
68
+ let placed = 0, skipped = 0;
69
+ for (const [src, dest] of plan) {
70
+ const target = path.join(cwd, dest);
71
+ if (fs.existsSync(target)) {
72
+ console.log(` = kept ${dest} (already exists — not touched)`);
73
+ skipped++;
74
+ continue;
75
+ }
76
+ if (DRY) {
77
+ console.log(` ~ would create ${dest}`);
78
+ } else {
79
+ fs.mkdirSync(path.dirname(target), { recursive: true });
80
+ fs.copyFileSync(here(src), target);
81
+ console.log(` + created ${dest}`);
82
+ }
83
+ placed++;
84
+ }
85
+
86
+ console.log(`\n ${placed} file(s) ${DRY ? 'would be created' : 'created'}, ${skipped} kept.\n`);
87
+ if (!hasClaude && !NO_CLAUDE) {
88
+ console.log(' (No CLAUDE.md/.claude detected — run with --claude to also install /cadmo-* slash commands.)');
89
+ }
90
+ console.log(' Next steps:');
91
+ console.log(' 1. Fill in AGENTS.md — the local map your AI pair reads first');
92
+ console.log(' (using CLAUDE.md? link or rename it — same idea).');
93
+ console.log(' 2. For your next relevant feature: cadmo/value-gate.md (5 lines, before any spec).');
94
+ console.log(' 3. For any 3+ step task: copy cadmo/plan.md — acceptance criteria before code.');
95
+ console.log(' 4. Turn on the drift guard: move cadmo/spec-drift-workflow.yml to .github/workflows/');
96
+ console.log(' and add a `watches:` front matter to your first spec.');
97
+ console.log('\n The method in one page: https://github.com/tiagotorres91/cadmo\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-cadmo",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Scaffold the Cadmo method into your project \u2014 a right-sized method for building software with AI as your pair.",
5
5
  "keywords": [
6
6
  "cadmo",
@@ -13,7 +13,7 @@
13
13
  "claude",
14
14
  "scaffold"
15
15
  ],
16
- "homepage": "https://cadmo.dev",
16
+ "homepage": "https://github.com/tiagotorres91/cadmo",
17
17
  "repository": {
18
18
  "type": "git",
19
19
  "url": "git+https://github.com/tiagotorres91/cadmo.git"
@@ -29,6 +29,9 @@
29
29
  "create-cadmo": "index.js"
30
30
  },
31
31
  "engines": {
32
- "node": ">=16"
32
+ "node": ">=18"
33
+ },
34
+ "scripts": {
35
+ "test": "node --test"
33
36
  }
34
37
  }
@@ -0,0 +1,88 @@
1
+ // cadmo-grammar — THE single grammar for Cadmo front matter and watch-globs.
2
+ // Every consumer (spec-drift, cadmo-score, the plugin's Stop hook, the scaffolded
3
+ // copy) uses THIS logic; the copies are byte-identical and guarded by
4
+ // scripts/check-template-sync.sh. Round-4 review found three divergent
5
+ // reimplementations (one carrying an already-fixed globstar bug) — hence this file.
6
+ //
7
+ // Accepted `watches:` forms (all equivalent):
8
+ // watches: | watches: | watches: [a/**, b.py] | watches: a/**
9
+ // - a/** | - a/** |
10
+ // - b.py | - b.py |
11
+ // (indented block, unindented block, inline array, single inline value)
12
+
13
+ export function frontMatter(text) {
14
+ if (!text.startsWith('---')) return null;
15
+ const end = text.indexOf('\n---', 3);
16
+ if (end < 0) return null;
17
+ return { fm: text.slice(3, end), end };
18
+ }
19
+
20
+ function unquote(s) {
21
+ return s.trim().replace(/^["']|["']$/g, '');
22
+ }
23
+
24
+ export function parseWatches(text) {
25
+ const parsed = frontMatter(text);
26
+ if (!parsed) return null;
27
+ const lines = parsed.fm.split(/\r?\n/);
28
+ const watches = [];
29
+ let inWatches = false;
30
+ for (const line of lines) {
31
+ const head = line.match(/^watches:\s*(.*)$/);
32
+ if (head) {
33
+ inWatches = true;
34
+ const inline = head[1].trim();
35
+ if (inline.startsWith('[')) {
36
+ for (const part of inline.replace(/^\[|\]\s*$/g, '').split(',')) {
37
+ const v = unquote(part);
38
+ if (v) watches.push(v);
39
+ }
40
+ inWatches = false; // inline array is complete on this line
41
+ } else if (inline) {
42
+ watches.push(unquote(inline)); // single inline value
43
+ inWatches = false;
44
+ }
45
+ continue;
46
+ }
47
+ if (inWatches) {
48
+ const item = line.match(/^\s*-\s+(.+?)\s*$/); // indented OR unindented list item
49
+ if (item) watches.push(unquote(item[1]));
50
+ else if (line.trim() !== '') inWatches = false; // next key ends the block
51
+ }
52
+ }
53
+ return watches.length ? watches : null;
54
+ }
55
+
56
+ export function parseReviewed(text) {
57
+ const parsed = frontMatter(text);
58
+ if (!parsed) return null;
59
+ for (const line of parsed.fm.split(/\r?\n/)) {
60
+ const m = line.match(/^reviewed:\s*(\S+)\s*$/);
61
+ if (m) return m[1];
62
+ }
63
+ return null;
64
+ }
65
+
66
+ // Glob -> RegExp. `*` matches within one path segment; `**` matches zero or more
67
+ // segments (gitignore semantics: src/**/auth.js matches src/auth.js).
68
+ // Zero-backslash implementation (escaping via char classes / fromCharCode).
69
+ const BS = String.fromCharCode(92);
70
+ export function globToRegex(glob) {
71
+ let esc = '';
72
+ for (const ch of glob) {
73
+ if (ch === '*' || ch === '/') esc += ch;
74
+ else if ('.+^$(){}|[]?'.includes(ch) || ch === BS) esc += BS + ch;
75
+ else esc += ch;
76
+ }
77
+ esc = esc.split('**').join('__DSTAR__');
78
+ esc = esc.split('*').join('[^/]*');
79
+ esc = esc.split('/__DSTAR__/').join('/(?:.*/)?'); // a/**/b -> a/b and a/x/y/b
80
+ esc = esc.split('__DSTAR__/').join('(?:.*/)?'); // **/b -> b and x/b
81
+ esc = esc.split('/__DSTAR__').join('(?:/.*)?'); // a/** -> a and a/x
82
+ esc = esc.split('__DSTAR__').join('.*'); // bare **
83
+ return new RegExp('^' + esc + '$');
84
+ }
85
+
86
+ export function anyMatch(watches, file) {
87
+ return watches.some((w) => globToRegex(w).test(file));
88
+ }
@@ -0,0 +1,20 @@
1
+ ---
2
+ description: Run the definition of done honestly — checked against evidence (run the commands), not memory
3
+ ---
4
+ # /cadmo-done — the definition of done, checked honestly
5
+
6
+ Run the definition of done for: **$ARGUMENTS** (or the work we just finished).
7
+
8
+ Check each item **against evidence, not memory** — run the commands, don't assume:
9
+
10
+ - [ ] Build/tests pass — run them now and show me the output
11
+ - [ ] **Tested functionally** — the real flow (browser, curl, CLI run), not just linters
12
+ - [ ] Validated in staging before production (when there is one)
13
+ - [ ] Acceptance criteria from the spec/plan — checked one by one, quoted
14
+ - [ ] Spec updated if a rule changed (same commit) — run `node cadmo/spec-drift.mjs --base origin/main` if present
15
+ - [ ] Anything untested? Say it explicitly: "I didn't test X"
16
+ - [ ] Touched auth/input/data/secrets? → flag that an adversarial security pass is due before shipping
17
+
18
+ Then the **process check** (the choreography, not the code): was there a plan before execution? criteria before code? If any answer is no, say so plainly — this checklist exists to be failed honestly, not passed cosmetically.
19
+
20
+ Finish with a verdict: **ready to ship** / **ready except <items>** / **not ready because <items>**.
@@ -0,0 +1,16 @@
1
+ ---
2
+ description: Open a value gate — is this worth building? (a 5-line business case before any spec)
3
+ ---
4
+ # /cadmo-gate — open a value gate
5
+
6
+ Open a value gate for: **$ARGUMENTS**
7
+
8
+ Work with me (the human) to fill the five lines — copy `cadmo/value-gate.md` next to the demand, then:
9
+
10
+ 1. **Problem (measurable):** ask me what hurts today; push for a number (time lost, error rate, money).
11
+ 2. **Why this solution:** make me compare against at least one cheaper alternative (a rule? a process change? doing nothing?).
12
+ 3. **Success metric (business):** a number that will prove value after delivery — reject "it works" as a metric.
13
+ 4. **Data readiness:** check the actual sources (tables, exports, APIs) — 🟢/🟡/🔴 with the caveat named.
14
+ 5. **Verdict:** recommend GO / NO-GO / defer, with one sentence of reasoning — but the verdict is mine to make.
15
+
16
+ Keep it to ~5 lines of content. If the work is trivial (a fix, a tweak), tell me it doesn't need a gate and stop.
@@ -0,0 +1,16 @@
1
+ ---
2
+ description: Write the spec — acceptance criteria first, for the client to validate before any code
3
+ ---
4
+ # /cadmo-spec — write the spec, criteria first
5
+
6
+ Write the spec for: **$ARGUMENTS**
7
+
8
+ Use `cadmo/spec.md` as the skeleton. Rules:
9
+
10
+ 1. **Goal in the client's language** — 2-5 lines a non-developer validates. No jargon.
11
+ 2. **Acceptance criteria before anything else** — each one observable and specific. For critical ones (money, data, integrations, security) use EARS: *"WHEN <trigger>, THE SYSTEM SHALL <behavior>"* — they become tests almost word for word.
12
+ 3. **Out of scope** — name what this does NOT cover.
13
+ 4. **Impact on stable specs** — which existing documented rules will absorb this on delivery. If this spec should be *watched* against code, add the `watches:` front matter (see `cadmo/spec-drift.mjs`).
14
+ 5. Then pair it with a plan (`cadmo/plan.md`) — technical route, tasks, how to validate. Don't repeat the criteria there.
15
+
16
+ Stop after the spec and ask me to validate it **before** writing any code. If it changes money, data or an external integration, remind me the client (or their proxy) validates it first.
package/templates/plan.md CHANGED
@@ -1,21 +1,26 @@
1
- # <Demand name> — plan (internal)
2
-
3
- Pairs with `spec.md` (acceptance criteria live there don't repeat them).
4
-
5
- ## Objective
6
- What changes in the world when this is done (1–3 lines).
7
-
8
- ## Investigation (optional)
9
- What was tried/considered before and why it was discarded (3–6 lines) avoids re-investigating dead ends.
10
-
11
- ## Context & constraints
12
- Current state, decisions already made, what NOT to change.
13
-
14
- ## Technical plan
15
- Files/tables/endpoints touched; approach; alternatives discarded.
16
-
17
- ## Tasks
18
- - [ ] Small, verifiable (status kept here — any session resumes without getting lost)
19
-
20
- ## How to validate
21
- Commands/flows that prove the acceptance criteria (build, tests, e2e, real manual flow).
1
+ # <Demand name> — plan (internal)
2
+
3
+ For new-and-relevant work this pairs with `spec.md`; for a standard 3+ step fix it stands alone.
4
+
5
+ ## Objective
6
+ What changes in the world when this is done (1–3 lines).
7
+
8
+ ## Acceptance criteria
9
+ - [ ] Observable and specific ("login returns a 24h JWT", not "user logs in")
10
+ - [ ] If there IS a separate `spec.md`, the criteria live THERE — don't duplicate; delete this section.
11
+ If this plan stands alone (no spec), this is their home — criteria before code, always.
12
+
13
+ ## Investigation (optional)
14
+ What was tried/considered before and why it was discarded (3–6 lines) — avoids re-investigating dead ends.
15
+
16
+ ## Context & constraints
17
+ Current state, decisions already made, what NOT to change.
18
+
19
+ ## Technical plan
20
+ Files/tables/endpoints touched; approach; alternatives discarded.
21
+
22
+ ## Tasks
23
+ - [ ] Small, verifiable (status kept here — any session resumes without getting lost)
24
+
25
+ ## How to validate
26
+ Commands/flows that prove the acceptance criteria (build, tests, e2e, real manual flow).
@@ -0,0 +1,33 @@
1
+ # Example workflow — copy to .github/workflows/spec-drift.yml in your project.
2
+ # Fails when a file watched by a spec changes and the spec doesn't change with it.
3
+ # Works on pull_request AND on push (so it fires even if you ship straight to main).
4
+ # Audited escape hatch (never silent): write "spec-drift: skip -- <reason>" in the
5
+ # latest commit message.
6
+ name: spec-drift
7
+ on:
8
+ pull_request:
9
+ push:
10
+ branches: [main]
11
+ jobs:
12
+ drift:
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ with:
17
+ fetch-depth: 0
18
+ - uses: actions/setup-node@v4
19
+ with:
20
+ node-version: "20"
21
+ - name: specs must change with the code they govern
22
+ run: |
23
+ if git log -1 --format=%B | grep -qi 'spec-drift: skip'; then
24
+ echo "spec-drift: skip found in commit message — skipping."
25
+ exit 0
26
+ fi
27
+ if [ "${{ github.event_name }}" = "pull_request" ]; then
28
+ BASE="origin/${{ github.base_ref }}"
29
+ else
30
+ BASE="${{ github.event.before }}"
31
+ git cat-file -e "${BASE}^{commit}" 2>/dev/null || BASE="$(git rev-parse HEAD~1)"
32
+ fi
33
+ node cadmo/spec-drift.mjs --base "$BASE"
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * spec-drift — the rule that keeps documentation honest.
4
+ *
5
+ * Two layers, both zero-dependency:
6
+ *
7
+ * DRIFT (v1): a spec declares which files implement it (`watches:`); if those
8
+ * files change in the diff and the spec doesn't change in the same diff → DRIFT.
9
+ * Cheap, but shallow: co-change in a diff proves the spec was TOUCHED, not that
10
+ * its content is still TRUE.
11
+ *
12
+ * SUSPECT (v2, opt-in): a "reviewed-state" layer. `--stamp <spec>` records a
13
+ * sha256 over the current content of the spec's watched files as `reviewed: <hash>`
14
+ * — a human affirming "I re-read this code and this spec is still true." The check
15
+ * recomputes that hash; a mismatch reports SUSPECT even if the spec was edited.
16
+ * Honesty note: the hash proves bytes unchanged, not semantic truth — a human
17
+ * still has to actually re-read before re-stamping. The full-length sha256 is
18
+ * an honesty marker, not an adversarial control: for adversarial assurance use
19
+ * signed tags / Sigstore (see cadmo-validate's N2/N3 levels).
20
+ *
21
+ * SUSPECT scoping ("checks activate by surface touched"): by default, SUSPECT only
22
+ * fires for specs whose WATCHED SET intersects the current diff — an innocent
23
+ * README-only PR must not fail CI for a stamp a previous merge skipped. Use
24
+ * `--suspect-all` for the full repo-state check (push-to-main / scheduled runs).
25
+ *
26
+ * A spec WITHOUT a `reviewed:` key gets the DRIFT check only — SUSPECT is opt-in
27
+ * (right-sizing: pay the re-read discipline only where it earns its keep).
28
+ *
29
+ * Grammar (front matter forms, glob semantics) lives in ./cadmo-grammar.mjs —
30
+ * the SINGLE grammar shared by spec-drift, cadmo-score and the plugin hook.
31
+ *
32
+ * Usage:
33
+ * node spec-drift.mjs [--base origin/main] [--dir .] [--suspect-all]
34
+ * node spec-drift.mjs --stamp <spec.md> [--dir .]
35
+ *
36
+ * Escape hatch (audited, not silent): if a change genuinely doesn't alter any
37
+ * documented rule, say so where reviewers can see it — e.g. run the CI step
38
+ * only when the commit message does NOT contain "spec-drift: skip — <reason>".
39
+ */
40
+ import { execFileSync } from 'node:child_process';
41
+ import crypto from 'node:crypto';
42
+ import fs from 'node:fs';
43
+ import path from 'node:path';
44
+ import { frontMatter, parseWatches as watchesOf, parseReviewed as reviewedOf, globToRegex } from './cadmo-grammar.mjs';
45
+
46
+ const BS = String.fromCharCode(92);
47
+ const args = process.argv.slice(2);
48
+ function opt(name, fallback) {
49
+ const i = args.indexOf(name);
50
+ return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
51
+ }
52
+ const BASE = opt('--base', 'origin/main');
53
+ const ROOT = path.resolve(opt('--dir', '.'));
54
+ const STAMP = opt('--stamp', null);
55
+ const SUSPECT_ALL = args.includes('--suspect-all');
56
+ if (args.includes('--stamp') && !STAMP) {
57
+ console.error('spec-drift --stamp: missing <spec.md> argument.');
58
+ process.exit(2);
59
+ }
60
+
61
+ // --- collect specs that declare `watches:` in front matter ---
62
+ function* mdFiles(dir) {
63
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
64
+ if (e.name.startsWith('.') || e.name === 'node_modules') continue;
65
+ const p = path.join(dir, e.name);
66
+ if (e.isDirectory()) yield* mdFiles(p);
67
+ else if (e.name.endsWith('.md')) yield p;
68
+ }
69
+ }
70
+
71
+ const readSpec = (file) => fs.readFileSync(file, 'utf8');
72
+
73
+ // Add or update a top-level `reviewed:` key, preserving everything else
74
+ // (including the file's EOL style — a CRLF file must not gain a lone LF line).
75
+ function setReviewed(text, hash) {
76
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
77
+ const parsed = frontMatter(text);
78
+ const rest = text.slice(parsed.end); // '\n---' … onwards
79
+ let lines = parsed.fm.split(/\r?\n/);
80
+ let found = false;
81
+ lines = lines.map((l) => {
82
+ if (/^reviewed:\s*/.test(l)) { found = true; return `reviewed: ${hash}`; }
83
+ return l;
84
+ });
85
+ if (!found) {
86
+ if (lines.length && lines[lines.length - 1] === '') lines[lines.length - 1] = `reviewed: ${hash}`;
87
+ else lines.push(`reviewed: ${hash}`);
88
+ }
89
+ return '---' + lines.join(eol) + rest;
90
+ }
91
+
92
+ // --- reviewed-state hashing ---
93
+ // Line endings are normalized so a CRLF working tree (git autocrlf) and the
94
+ // LF-stored HEAD blob hash identically. Watched files are text source.
95
+ function norm(buf) {
96
+ return buf.toString('utf8').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
97
+ }
98
+
99
+ function matched(watches, files) {
100
+ const regexes = watches.map(globToRegex);
101
+ return files.filter((f) => regexes.some((r) => r.test(f))).sort();
102
+ }
103
+
104
+ function hashWatchedDisk(watches) {
105
+ const tracked = execFileSync('git', ['ls-files'], { cwd: ROOT, encoding: 'utf8' }).split('\n').filter(Boolean);
106
+ const h = crypto.createHash('sha256');
107
+ for (const f of matched(watches, tracked)) {
108
+ h.update(f); h.update('\n');
109
+ h.update(norm(fs.readFileSync(path.join(ROOT, f))));
110
+ }
111
+ return h.digest('hex');
112
+ }
113
+
114
+ function hashWatchedHead(watches) {
115
+ const tracked = execFileSync('git', ['ls-tree', '-r', 'HEAD', '--name-only'], { cwd: ROOT, encoding: 'utf8' }).split('\n').filter(Boolean);
116
+ const h = crypto.createHash('sha256');
117
+ for (const f of matched(watches, tracked)) {
118
+ h.update(f); h.update('\n');
119
+ h.update(norm(execFileSync('git', ['show', `HEAD:${f}`], { cwd: ROOT })));
120
+ }
121
+ return h.digest('hex');
122
+ }
123
+
124
+ // --- --stamp mode: re-affirm a spec after re-reading its watched files ---
125
+ if (STAMP) {
126
+ const specPath = path.resolve(ROOT, STAMP);
127
+ if (!fs.existsSync(specPath)) {
128
+ console.error(`spec-drift --stamp: no such spec: ${STAMP}`);
129
+ process.exit(2);
130
+ }
131
+ const watches = watchesOf(readSpec(specPath));
132
+ if (!watches) {
133
+ console.error(`spec-drift --stamp: ${STAMP} has no \`watches:\` front matter — nothing to stamp.`);
134
+ process.exit(2);
135
+ }
136
+ const hash = hashWatchedDisk(watches);
137
+ // Footgun guard: stamping uncommitted content records a hash HEAD can never match.
138
+ try {
139
+ const head = hashWatchedHead(watches);
140
+ if (head !== hash) {
141
+ console.error('spec-drift --stamp: WARNING — watched files differ between working tree and HEAD; you are stamping UNCOMMITTED content. Commit the watched changes (or stamp after committing) or the check will report SUSPECT.');
142
+ }
143
+ } catch { /* no HEAD yet (fresh repo) — nothing to compare */ }
144
+ fs.writeFileSync(specPath, setReviewed(readSpec(specPath), hash));
145
+ console.log(`spec-drift: stamped ${STAMP} — reviewed: ${hash}`);
146
+ process.exit(0);
147
+ }
148
+
149
+ // --- changed files vs base ---
150
+ let changed;
151
+ try {
152
+ changed = execFileSync('git', ['diff', '--name-only', `${BASE}...HEAD`], { cwd: ROOT, encoding: 'utf8' })
153
+ .split('\n').filter(Boolean);
154
+ } catch (e) {
155
+ console.error(`spec-drift: cannot diff against ${BASE} — ${e.message}`);
156
+ process.exit(2);
157
+ }
158
+
159
+ const specs = [];
160
+ for (const f of mdFiles(ROOT)) {
161
+ const text = readSpec(f);
162
+ const watches = watchesOf(text);
163
+ if (watches) specs.push({ spec: path.relative(ROOT, f).split(BS).join('/'), watches, reviewed: reviewedOf(text) });
164
+ }
165
+
166
+ if (!specs.length) {
167
+ console.log('spec-drift: no specs with a `watches:` front matter found — nothing to check.');
168
+ process.exit(0);
169
+ }
170
+
171
+ const changedSet = new Set(changed);
172
+ let drift = 0;
173
+ let suspect = 0;
174
+ for (const { spec, watches, reviewed } of specs) {
175
+ const regexes = watches.map(globToRegex);
176
+ const hits = changed.filter((c) => regexes.some((r) => r.test(c)));
177
+ if (hits.length && !changedSet.has(spec)) {
178
+ drift++;
179
+ console.error(`DRIFT: ${hits.join(', ')} changed, but ${spec} (which watches ${watches.join(', ')}) did not change in this diff.`);
180
+ }
181
+ // SUSPECT: opt-in via `reviewed:`, and diff-scoped unless --suspect-all —
182
+ // only a diff that touches this spec's watched surface puts it on trial here.
183
+ if (reviewed && (SUSPECT_ALL || hits.length)) {
184
+ const now = hashWatchedHead(watches);
185
+ if (now !== reviewed) {
186
+ suspect++;
187
+ console.error(`SUSPECT: ${spec} was reviewed at ${reviewed.slice(0, 12)}… but its watched files hash ${now.slice(0, 12)}… at HEAD — the code changed since the last review. Re-read the watched files, then re-run: node spec-drift.mjs --stamp ${spec}`);
188
+ }
189
+ }
190
+ }
191
+
192
+ if (drift || suspect) {
193
+ const parts = [];
194
+ if (drift) parts.push(`${drift} drifting`);
195
+ if (suspect) parts.push(`${suspect} suspect`);
196
+ console.error(`\nspec-drift: ${parts.join(', ')}. Update the spec in the same change (or state why no documented rule changed, visibly); re-stamp any SUSPECT spec after actually re-reading its watched files — the hash matches bytes, only you can vouch for the meaning.`);
197
+ process.exit(1);
198
+ }
199
+ console.log(`spec-drift: ${specs.length} spec(s) checked against ${changed.length} changed file(s) — in sync ✓`);
package/templates/spec.md CHANGED
@@ -1,24 +1,38 @@
1
- # Spec <name>
2
-
3
- **Status:** draft — awaiting validation (<who>) · **Demand:** <link/id>
4
-
5
- ## Goal & context
6
- The business problem and what changes in the world (2–5 lines, in the client's language).
7
- (Relevant work already passed the value gate its success metric becomes an acceptance criterion here.)
8
-
9
- ## Acceptance criteria
10
- - [ ] Simple ones in free form
11
- - [ ] Critical ones (money / data / integration / security) in EARS:
12
- WHEN <trigger>, THE SYSTEM SHALL <behavior>
13
-
14
- ## Data (AI features only)
15
- Source, owner, access, quality/bias, corpus refresh plan.
16
-
17
- ## Risks (critical or AI demands only)
18
- Risk response. For AI: corpus drift, provider API change, cost, regulation.
19
-
20
- ## Out of scope
21
- What this delivery does NOT cover (controlled expectations).
22
-
23
- ## Impact on stable specs
24
- Which sections of the system/domain specs will be updated on delivery (the absorption).
1
+ <!-- To enforce this spec against code, add real YAML front matter as the VERY FIRST lines:
2
+ ---
3
+ watches:
4
+ - src/the-area-this-governs/**
5
+ - api/the-endpoint.py
6
+ ---
7
+ Then spec-drift fails any change to those files that doesn't update this spec. -->
8
+
9
+ # Spec — <name>
10
+
11
+ **Status:** draft awaiting validation (<who>) · **Demand:** <link/id>
12
+
13
+ ## Goal & context
14
+ The business problem and what changes in the world (2–5 lines, in the client's language).
15
+ (Relevant work already passed the value gate — its success metric becomes an acceptance criterion here.)
16
+
17
+ ## Acceptance criteria
18
+ - [ ] Simple ones in free form
19
+ - [ ] Critical ones (money / data / integration / security) in EARS:
20
+ WHEN <trigger>, THE SYSTEM SHALL <behavior>
21
+
22
+ ## Data (AI features only)
23
+ Source, owner, access, quality/bias, corpus refresh plan.
24
+
25
+ ## Risks (critical or AI demands only)
26
+ Risk → response. For AI: corpus drift, provider API change, cost, regulation.
27
+
28
+ ## Out of scope
29
+ What this delivery does NOT cover (controlled expectations).
30
+
31
+ ## Impact on stable specs
32
+ Which sections of the system/domain specs will be updated on delivery (the absorption).
33
+
34
+ ## Impact on existing data (only if this touches persisted state)
35
+ Does this change data that already exists — a DB schema/migration, saved user records, files a
36
+ prior version produced? Name what existing data is affected and the migration/backfill plan (or
37
+ "none — new data only"). A rule that changes *how* something is computed does not retroactively
38
+ fix rows already written; say how the old ones are handled.