create-cadmo 0.2.0 → 0.4.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
@@ -14,6 +14,6 @@ Drops into the current directory, never overwriting anything:
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
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`)
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.
18
18
 
19
19
  The method, docs and protocols: **https://github.com/tiagotorres91/cadmo**
package/index.js CHANGED
@@ -29,6 +29,7 @@ if (args.includes('--help') || args.includes('-h')) {
29
29
  cadmo/plan.md the internal route: tasks, constraints, validation
30
30
  cadmo/decision.md ADR with a "when to reconsider" trigger
31
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
32
33
  cadmo/spec-drift-workflow.yml ready CI workflow (move into .github/workflows/)
33
34
  .claude/commands/cadmo-*.md (when Claude Code is detected, or --claude)
34
35
 
@@ -46,6 +47,7 @@ const FILES = [
46
47
  ['spec.md', path.join('cadmo', 'spec.md')],
47
48
  ['plan.md', path.join('cadmo', 'plan.md')],
48
49
  ['decision.md', path.join('cadmo', 'decision.md')],
50
+ ['cadmo-grammar.mjs', path.join('cadmo', 'cadmo-grammar.mjs')],
49
51
  ['spec-drift.mjs', path.join('cadmo', 'spec-drift.mjs')],
50
52
  ['spec-drift-workflow.yml', path.join('cadmo', 'spec-drift-workflow.yml')],
51
53
  ];
@@ -83,7 +85,7 @@ for (const [src, dest] of plan) {
83
85
 
84
86
  console.log(`\n ${placed} file(s) ${DRY ? 'would be created' : 'created'}, ${skipped} kept.\n`);
85
87
  if (!hasClaude && !NO_CLAUDE) {
86
- console.log(' (No CLAUDE.md/.claude detected — run with --claude to also install /cadmo-* slash commands.)');
88
+ console.log(' (No CLAUDE.md/.claude folder detected — run: npm create cadmo -- --claude (the -- matters: npm swallows flags without it) to also install the /cadmo-* slash commands.)');
87
89
  }
88
90
  console.log(' Next steps:');
89
91
  console.log(' 1. Fill in AGENTS.md — the local map your AI pair reads first');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-cadmo",
3
- "version": "0.2.0",
3
+ "version": "0.4.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",
@@ -29,7 +29,7 @@
29
29
  "create-cadmo": "index.js"
30
30
  },
31
31
  "engines": {
32
- "node": ">=16"
32
+ "node": ">=18"
33
33
  },
34
34
  "scripts": {
35
35
  "test": "node --test"
@@ -15,17 +15,18 @@
15
15
  ## Where things live
16
16
  - Specs / decisions: <path>
17
17
  - Plans: <path>
18
- - Demands / task board: <where>
18
+ - Demands / task board: <where — GitHub issues works; "none yet" is honest>
19
19
 
20
20
  ## How to verify (the harness — run before saying "done")
21
21
  - Build/type-check: `<command>`
22
22
  - Tests: `<command>`
23
23
  - End-to-end / smoke: `<command>`
24
- - Staging URL: <url> · Production: <url>
24
+ - Staging URL: <url — or "none: ships as a package / binary / store build"> · Production: <url or delivery channel>
25
25
 
26
26
  ## Gates
27
27
  - Plan approved before building; production ships only with explicit human approval.
28
28
  - <any project-specific rule or gotcha>
29
29
 
30
30
  ## The method
31
- Opening choreography, definition of done, the layers — see the Cadmo method (link).
31
+ Opening choreography, definition of done, the layers — see the Cadmo method:
32
+ https://github.com/tiagotorres91/cadmo/blob/main/docs/method.md
@@ -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
+ }
@@ -1,3 +1,6 @@
1
+ ---
2
+ description: Run the definition of done honestly — checked against evidence (run the commands), not memory
3
+ ---
1
4
  # /cadmo-done — the definition of done, checked honestly
2
5
 
3
6
  Run the definition of done for: **$ARGUMENTS** (or the work we just finished).
@@ -8,7 +11,7 @@ Check each item **against evidence, not memory** — run the commands, don't ass
8
11
  - [ ] **Tested functionally** — the real flow (browser, curl, CLI run), not just linters
9
12
  - [ ] Validated in staging before production (when there is one)
10
13
  - [ ] Acceptance criteria from the spec/plan — checked one by one, quoted
11
- - [ ] Spec updated if a rule changed (same commit) — run `node cadmo/spec-drift.mjs --base origin/main` if present
14
+ - [ ] Spec updated if a rule changed (same commit) — run `node cadmo/spec-drift.mjs` if present (no --base needed: it auto-resolves, remote or not)
12
15
  - [ ] Anything untested? Say it explicitly: "I didn't test X"
13
16
  - [ ] Touched auth/input/data/secrets? → flag that an adversarial security pass is due before shipping
14
17
 
@@ -1,3 +1,6 @@
1
+ ---
2
+ description: Open a value gate — is this worth building? (a 5-line business case before any spec)
3
+ ---
1
4
  # /cadmo-gate — open a value gate
2
5
 
3
6
  Open a value gate for: **$ARGUMENTS**
@@ -1,3 +1,6 @@
1
+ ---
2
+ description: Write the spec — acceptance criteria first, for the client to validate before any code
3
+ ---
1
4
  # /cadmo-spec — write the spec, criteria first
2
5
 
3
6
  Write the spec for: **$ARGUMENTS**
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).
@@ -1,12 +1,15 @@
1
1
  # Example workflow — copy to .github/workflows/spec-drift.yml in your project.
2
- # Fails a PR when a file watched by a spec changes and the spec doesn't change with it.
3
- # Escape hatch (audited, never silent): write "spec-drift: skip <reason>" in the PR body.
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): "spec-drift: skip -- <reason>" in the PR body
5
+ # (pull_request runs) or in the latest commit message (push runs).
4
6
  name: spec-drift
5
7
  on:
6
8
  pull_request:
9
+ push:
10
+ branches: [main]
7
11
  jobs:
8
12
  drift:
9
- if: "${{ !contains(github.event.pull_request.body, 'spec-drift: skip') }}"
10
13
  runs-on: ubuntu-latest
11
14
  steps:
12
15
  - uses: actions/checkout@v4
@@ -16,4 +19,24 @@ jobs:
16
19
  with:
17
20
  node-version: "20"
18
21
  - name: specs must change with the code they govern
19
- run: node cadmo/spec-drift.mjs --base origin/${{ github.base_ref }}
22
+ env:
23
+ # env var, never inline interpolation — a PR body is attacker-controlled text
24
+ PR_BODY: ${{ github.event.pull_request.body }}
25
+ run: |
26
+ if [ "${{ github.event_name }}" = "pull_request" ]; then
27
+ if printf '%s' "$PR_BODY" | grep -qi 'spec-drift: skip'; then
28
+ echo "spec-drift: skip found in the PR body — skipping."
29
+ exit 0
30
+ fi
31
+ BASE="origin/${{ github.base_ref }}"
32
+ else
33
+ if git log -1 --format=%B | grep -qi 'spec-drift: skip'; then
34
+ echo "spec-drift: skip found in commit message — skipping."
35
+ exit 0
36
+ fi
37
+ BASE="${{ github.event.before }}"
38
+ # first push of a branch/repo: event.before is all-zeros -> last commit -> empty tree
39
+ git cat-file -e "${BASE}^{commit}" 2>/dev/null \
40
+ || BASE="$(git rev-parse HEAD~1 2>/dev/null || echo 4b825dc642cb6eb9a060e54bf8d69288fbee4904)"
41
+ fi
42
+ node cadmo/spec-drift.mjs --base "$BASE"
@@ -1,39 +1,86 @@
1
1
  #!/usr/bin/env node
2
2
  /*
3
- * spec-drift — the rule that keeps documentation honest:
4
- * a spec declares which files implement it; if those files change and the spec
5
- * doesn't change in the same diff, the check fails.
3
+ * spec-drift — the rule that keeps documentation honest.
6
4
  *
7
- * No dependencies. How it works:
8
- * 1. Scans the repo for .md files with a `watches:` list in their front matter.
9
- * 2. Diffs the current branch against --base (default: origin/main).
10
- * 3. For each spec: if a watched file changed and the spec didn't → DRIFT.
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.
11
31
  *
12
32
  * Usage:
13
- * node spec-drift.mjs [--base origin/main] [--dir .]
33
+ * node spec-drift.mjs [--base <ref>] [--dir .] [--suspect-all]
34
+ * node spec-drift.mjs --stamp <spec.md> [--dir .]
14
35
  *
15
- * Spec front matter example:
16
- * ---
17
- * watches:
18
- * - src/billing/**
19
- * - api/invoices.py
20
- * ---
36
+ * With no --base, the first existing ref wins: origin/main → main (when you're
37
+ * on a different branch) → HEAD~1 → the empty tree (single-commit repo: the
38
+ * whole initial commit is the diff). A solo repo with no remote just works.
21
39
  *
22
40
  * Escape hatch (audited, not silent): if a change genuinely doesn't alter any
23
- * documented rule, say so where reviewers can see it — e.g. run the CI step
24
- * only when the PR body does NOT contain "spec-drift: skip — <reason>".
41
+ * documented rule, say so where reviewers can see it — the shipped workflow
42
+ * honors "spec-drift: skip — <reason>" in the PR body (pull_request runs) or
43
+ * in the latest commit message (push runs).
25
44
  */
26
45
  import { execFileSync } from 'node:child_process';
46
+ import crypto from 'node:crypto';
27
47
  import fs from 'node:fs';
28
48
  import path from 'node:path';
49
+ import { frontMatter, parseWatches as watchesOf, parseReviewed as reviewedOf, globToRegex } from './cadmo-grammar.mjs';
29
50
 
51
+ const BS = String.fromCharCode(92);
30
52
  const args = process.argv.slice(2);
31
53
  function opt(name, fallback) {
32
54
  const i = args.indexOf(name);
33
55
  return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
34
56
  }
35
- const BASE = opt('--base', 'origin/main');
36
57
  const ROOT = path.resolve(opt('--dir', '.'));
58
+ // sha of git's empty tree — diffing against it means "everything is new"
59
+ const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
60
+ function refExists(ref) {
61
+ try {
62
+ execFileSync('git', ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], { cwd: ROOT, stdio: ['ignore', 'ignore', 'ignore'] });
63
+ return true;
64
+ } catch { return false; }
65
+ }
66
+ function resolveBase() {
67
+ if (refExists('origin/main')) return 'origin/main';
68
+ let branch = '';
69
+ try {
70
+ branch = execFileSync('git', ['symbolic-ref', '--short', 'HEAD'], { cwd: ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
71
+ } catch { /* detached HEAD */ }
72
+ if (branch !== 'main' && refExists('main')) return 'main';
73
+ if (refExists('HEAD~1')) return 'HEAD~1';
74
+ return EMPTY_TREE;
75
+ }
76
+ const BASE_GIVEN = opt('--base', null);
77
+ const BASE = BASE_GIVEN || resolveBase();
78
+ const STAMP = opt('--stamp', null);
79
+ const SUSPECT_ALL = args.includes('--suspect-all');
80
+ if (args.includes('--stamp') && !STAMP) {
81
+ console.error('spec-drift --stamp: missing <spec.md> argument.');
82
+ process.exit(2);
83
+ }
37
84
 
38
85
  // --- collect specs that declare `watches:` in front matter ---
39
86
  function* mdFiles(dir) {
@@ -45,56 +92,113 @@ function* mdFiles(dir) {
45
92
  }
46
93
  }
47
94
 
48
- function parseWatches(file) {
49
- const text = fs.readFileSync(file, 'utf8');
50
- if (!text.startsWith('---')) return null;
51
- const end = text.indexOf('\n---', 3);
52
- if (end < 0) return null;
53
- const fm = text.slice(3, end);
54
- const lines = fm.split('\n');
55
- const watches = [];
56
- let inWatches = false;
57
- for (const line of lines) {
58
- if (/^watches:\s*$/.test(line)) { inWatches = true; continue; }
59
- if (inWatches) {
60
- const m = line.match(/^\s+-\s+(.+?)\s*$/);
61
- if (m) watches.push(m[1].replace(/^["']|["']$/g, ''));
62
- else if (!/^\s/.test(line)) inWatches = false;
63
- }
95
+ const readSpec = (file) => fs.readFileSync(file, 'utf8');
96
+
97
+ // Add or update a top-level `reviewed:` key, preserving everything else
98
+ // (including the file's EOL style — a CRLF file must not gain a lone LF line).
99
+ function setReviewed(text, hash) {
100
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
101
+ const parsed = frontMatter(text);
102
+ const rest = text.slice(parsed.end); // '\n---' … onwards
103
+ let lines = parsed.fm.split(/\r?\n/);
104
+ let found = false;
105
+ lines = lines.map((l) => {
106
+ if (/^reviewed:\s*/.test(l)) { found = true; return `reviewed: ${hash}`; }
107
+ return l;
108
+ });
109
+ if (!found) {
110
+ if (lines.length && lines[lines.length - 1] === '') lines[lines.length - 1] = `reviewed: ${hash}`;
111
+ else lines.push(`reviewed: ${hash}`);
64
112
  }
65
- return watches.length ? watches : null;
113
+ return '---' + lines.join(eol) + rest;
66
114
  }
67
115
 
68
- // --- minimal glob → regex (supports ** and *) ---
69
- function globToRegex(glob) {
70
- // zero-backslash implementation: escape specials via char classes, never via backslash
71
- const BS = String.fromCharCode(92);
72
- let esc = '';
73
- for (const ch of glob) {
74
- if (ch === '*' || ch === '/') esc += ch;
75
- else if ('.+^$(){}|[]?'.includes(ch) || ch === BS) esc += BS + ch;
76
- else esc += ch;
116
+ // --- reviewed-state hashing ---
117
+ // Line endings are normalized so a CRLF working tree (git autocrlf) and the
118
+ // LF-stored HEAD blob hash identically. Watched files are text source.
119
+ function norm(buf) {
120
+ return buf.toString('utf8').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
121
+ }
122
+
123
+ function matched(watches, files) {
124
+ const regexes = watches.map(globToRegex);
125
+ return files.filter((f) => regexes.some((r) => r.test(f))).sort();
126
+ }
127
+
128
+ function hashWatchedDisk(watches) {
129
+ const tracked = execFileSync('git', ['ls-files'], { cwd: ROOT, encoding: 'utf8' }).split('\n').filter(Boolean);
130
+ const h = crypto.createHash('sha256');
131
+ for (const f of matched(watches, tracked)) {
132
+ h.update(f); h.update('\n');
133
+ h.update(norm(fs.readFileSync(path.join(ROOT, f))));
134
+ }
135
+ return h.digest('hex');
136
+ }
137
+
138
+ function hashWatchedHead(watches) {
139
+ const tracked = execFileSync('git', ['ls-tree', '-r', 'HEAD', '--name-only'], { cwd: ROOT, encoding: 'utf8' }).split('\n').filter(Boolean);
140
+ const h = crypto.createHash('sha256');
141
+ for (const f of matched(watches, tracked)) {
142
+ h.update(f); h.update('\n');
143
+ h.update(norm(execFileSync('git', ['show', `HEAD:${f}`], { cwd: ROOT })));
144
+ }
145
+ return h.digest('hex');
146
+ }
147
+
148
+ // --- --stamp mode: re-affirm a spec after re-reading its watched files ---
149
+ if (STAMP) {
150
+ const specPath = path.resolve(ROOT, STAMP);
151
+ if (!fs.existsSync(specPath)) {
152
+ console.error(`spec-drift --stamp: no such spec: ${STAMP}`);
153
+ process.exit(2);
77
154
  }
78
- esc = esc.split('**').join('__DSTAR__');
79
- esc = esc.split('*').join('[^/]*');
80
- esc = esc.split('__DSTAR__').join('.*');
81
- return new RegExp('^' + esc + '$');
155
+ const watches = watchesOf(readSpec(specPath));
156
+ if (!watches) {
157
+ console.error(`spec-drift --stamp: ${STAMP} has no \`watches:\` front matter — nothing to stamp.`);
158
+ process.exit(2);
159
+ }
160
+ const hash = hashWatchedDisk(watches);
161
+ // Footgun guard: stamping uncommitted content records a hash HEAD can never match.
162
+ try {
163
+ const head = hashWatchedHead(watches);
164
+ if (head !== hash) {
165
+ 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.');
166
+ }
167
+ } catch { /* no HEAD yet (fresh repo) — nothing to compare */ }
168
+ fs.writeFileSync(specPath, setReviewed(readSpec(specPath), hash));
169
+ console.log(`spec-drift: stamped ${STAMP} — reviewed: ${hash}`);
170
+ process.exit(0);
82
171
  }
83
172
 
84
173
  // --- changed files vs base ---
174
+ // The empty tree has no merge base, so it gets a two-dot diff; refs get three-dot.
175
+ const RANGE = BASE === EMPTY_TREE ? [BASE, 'HEAD'] : [`${BASE}...HEAD`];
85
176
  let changed;
86
177
  try {
87
- changed = execFileSync('git', ['diff', '--name-only', `${BASE}...HEAD`], { cwd: ROOT, encoding: 'utf8' })
178
+ changed = execFileSync('git', ['diff', '--name-only', ...RANGE], { cwd: ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
88
179
  .split('\n').filter(Boolean);
180
+ if (!BASE_GIVEN) {
181
+ console.log(`spec-drift: no --base given — diffing against ${BASE === EMPTY_TREE ? 'the empty tree (single-commit repo: the whole initial commit)' : BASE}.`);
182
+ }
89
183
  } catch (e) {
90
- console.error(`spec-drift: cannot diff against ${BASE} ${e.message}`);
184
+ const detail = (e.stderr ? e.stderr.toString() : e.message).split(/\r?\n/).find(Boolean) || '';
185
+ console.error(`spec-drift: cannot diff against ${BASE} — ${detail}`);
186
+ console.error('spec-drift: is this a git repository with at least one commit? Pass --base <ref> to pick the comparison point explicitly.');
91
187
  process.exit(2);
92
188
  }
93
189
 
94
190
  const specs = [];
95
191
  for (const f of mdFiles(ROOT)) {
96
- const watches = parseWatches(f);
97
- if (watches) specs.push({ spec: path.relative(ROOT, f).split(String.fromCharCode(92)).join('/'), watches });
192
+ const text = readSpec(f);
193
+ const watches = watchesOf(text);
194
+ const rel = path.relative(ROOT, f).split(BS).join('/');
195
+ if (watches) {
196
+ specs.push({ spec: rel, watches, reviewed: reviewedOf(text) });
197
+ } else if (text.startsWith('<!--') && /^watches:/m.test(text)) {
198
+ // the template ships with a leading HTML comment; front matter placed after
199
+ // it is invisible to the grammar — a spec that LOOKS guarded but isn't.
200
+ console.error(`WARNING: ${rel} has a watches: line but does not start with --- (front matter must be the very first bytes — delete the leading comment). This spec is NOT being checked.`);
201
+ }
98
202
  }
99
203
 
100
204
  if (!specs.length) {
@@ -104,17 +208,30 @@ if (!specs.length) {
104
208
 
105
209
  const changedSet = new Set(changed);
106
210
  let drift = 0;
107
- for (const { spec, watches } of specs) {
211
+ let suspect = 0;
212
+ for (const { spec, watches, reviewed } of specs) {
108
213
  const regexes = watches.map(globToRegex);
109
- const hits = changed.filter(c => regexes.some(r => r.test(c)));
214
+ const hits = changed.filter((c) => regexes.some((r) => r.test(c)));
110
215
  if (hits.length && !changedSet.has(spec)) {
111
216
  drift++;
112
217
  console.error(`DRIFT: ${hits.join(', ')} changed, but ${spec} (which watches ${watches.join(', ')}) did not change in this diff.`);
113
218
  }
219
+ // SUSPECT: opt-in via `reviewed:`, and diff-scoped unless --suspect-all —
220
+ // only a diff that touches this spec's watched surface puts it on trial here.
221
+ if (reviewed && (SUSPECT_ALL || hits.length)) {
222
+ const now = hashWatchedHead(watches);
223
+ if (now !== reviewed) {
224
+ suspect++;
225
+ 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}`);
226
+ }
227
+ }
114
228
  }
115
229
 
116
- if (drift) {
117
- console.error(`\nspec-drift: ${drift} spec(s) out of sync. Update the spec in the same change — or state why no documented rule changed (visibly, where reviewers can see it).`);
230
+ if (drift || suspect) {
231
+ const parts = [];
232
+ if (drift) parts.push(`${drift} drifting`);
233
+ if (suspect) parts.push(`${suspect} suspect`);
234
+ 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.`);
118
235
  process.exit(1);
119
236
  }
120
237
  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.