create-cadmo 0.2.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 +1 -1
- package/index.js +2 -0
- package/package.json +2 -2
- package/templates/cadmo-grammar.mjs +88 -0
- package/templates/claude-commands/cadmo-done.md +3 -0
- package/templates/claude-commands/cadmo-gate.md +3 -0
- package/templates/claude-commands/cadmo-spec.md +3 -0
- package/templates/plan.md +26 -21
- package/templates/spec-drift-workflow.yml +18 -4
- package/templates/spec-drift.mjs +131 -52
- package/templates/spec.md +38 -24
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
|
];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-cadmo",
|
|
3
|
-
"version": "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",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"create-cadmo": "index.js"
|
|
30
30
|
},
|
|
31
31
|
"engines": {
|
|
32
|
-
"node": ">=
|
|
32
|
+
"node": ">=18"
|
|
33
33
|
},
|
|
34
34
|
"scripts": {
|
|
35
35
|
"test": "node --test"
|
|
@@ -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
|
+
}
|
package/templates/plan.md
CHANGED
|
@@ -1,21 +1,26 @@
|
|
|
1
|
-
# <Demand name> — plan (internal)
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
## Objective
|
|
6
|
-
What changes in the world when this is done (1–3 lines).
|
|
7
|
-
|
|
8
|
-
##
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
3
|
-
#
|
|
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.
|
|
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,15 @@ jobs:
|
|
|
16
19
|
with:
|
|
17
20
|
node-version: "20"
|
|
18
21
|
- name: specs must change with the code they govern
|
|
19
|
-
run:
|
|
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"
|
package/templates/spec-drift.mjs
CHANGED
|
@@ -1,32 +1,49 @@
|
|
|
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
|
-
*
|
|
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:
|
|
11
6
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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).
|
|
14
28
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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 .]
|
|
21
35
|
*
|
|
22
36
|
* Escape hatch (audited, not silent): if a change genuinely doesn't alter any
|
|
23
37
|
* documented rule, say so where reviewers can see it — e.g. run the CI step
|
|
24
|
-
* only when the
|
|
38
|
+
* only when the commit message does NOT contain "spec-drift: skip — <reason>".
|
|
25
39
|
*/
|
|
26
40
|
import { execFileSync } from 'node:child_process';
|
|
41
|
+
import crypto from 'node:crypto';
|
|
27
42
|
import fs from 'node:fs';
|
|
28
43
|
import path from 'node:path';
|
|
44
|
+
import { frontMatter, parseWatches as watchesOf, parseReviewed as reviewedOf, globToRegex } from './cadmo-grammar.mjs';
|
|
29
45
|
|
|
46
|
+
const BS = String.fromCharCode(92);
|
|
30
47
|
const args = process.argv.slice(2);
|
|
31
48
|
function opt(name, fallback) {
|
|
32
49
|
const i = args.indexOf(name);
|
|
@@ -34,6 +51,12 @@ function opt(name, fallback) {
|
|
|
34
51
|
}
|
|
35
52
|
const BASE = opt('--base', 'origin/main');
|
|
36
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
|
+
}
|
|
37
60
|
|
|
38
61
|
// --- collect specs that declare `watches:` in front matter ---
|
|
39
62
|
function* mdFiles(dir) {
|
|
@@ -45,40 +68,82 @@ function* mdFiles(dir) {
|
|
|
45
68
|
}
|
|
46
69
|
}
|
|
47
70
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
let
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
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))));
|
|
64
110
|
}
|
|
65
|
-
return
|
|
111
|
+
return h.digest('hex');
|
|
66
112
|
}
|
|
67
113
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (ch === '*' || ch === '/') esc += ch;
|
|
75
|
-
else if ('.+^$(){}|[]?'.includes(ch) || ch === BS) esc += BS + ch;
|
|
76
|
-
else esc += ch;
|
|
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 })));
|
|
77
120
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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);
|
|
82
147
|
}
|
|
83
148
|
|
|
84
149
|
// --- changed files vs base ---
|
|
@@ -93,8 +158,9 @@ try {
|
|
|
93
158
|
|
|
94
159
|
const specs = [];
|
|
95
160
|
for (const f of mdFiles(ROOT)) {
|
|
96
|
-
const
|
|
97
|
-
|
|
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) });
|
|
98
164
|
}
|
|
99
165
|
|
|
100
166
|
if (!specs.length) {
|
|
@@ -104,17 +170,30 @@ if (!specs.length) {
|
|
|
104
170
|
|
|
105
171
|
const changedSet = new Set(changed);
|
|
106
172
|
let drift = 0;
|
|
107
|
-
|
|
173
|
+
let suspect = 0;
|
|
174
|
+
for (const { spec, watches, reviewed } of specs) {
|
|
108
175
|
const regexes = watches.map(globToRegex);
|
|
109
|
-
const hits = changed.filter(c => regexes.some(r => r.test(c)));
|
|
176
|
+
const hits = changed.filter((c) => regexes.some((r) => r.test(c)));
|
|
110
177
|
if (hits.length && !changedSet.has(spec)) {
|
|
111
178
|
drift++;
|
|
112
179
|
console.error(`DRIFT: ${hits.join(', ')} changed, but ${spec} (which watches ${watches.join(', ')}) did not change in this diff.`);
|
|
113
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
|
+
}
|
|
114
190
|
}
|
|
115
191
|
|
|
116
|
-
if (drift) {
|
|
117
|
-
|
|
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.`);
|
|
118
197
|
process.exit(1);
|
|
119
198
|
}
|
|
120
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
##
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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.
|