create-anpunkit 2.1.0 → 2.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.
Files changed (68) hide show
  1. package/bin/cli.js +2 -2
  2. package/package.json +2 -3
  3. package/template/.claude/agents/debugger.md +1 -1
  4. package/template/.claude/agents/e2e-runner.md +56 -29
  5. package/template/.claude/agents/implementer.md +14 -8
  6. package/template/.claude/agents/infra-provisioner.md +53 -59
  7. package/template/.claude/agents/planner.md +99 -30
  8. package/template/.claude/agents/researcher.md +16 -1
  9. package/template/.claude/agents/spec-author.md +126 -0
  10. package/template/.claude/agents/synthesizer.md +1 -1
  11. package/template/.claude/agents/test-author.md +81 -63
  12. package/template/.claude/anpunkit-manifest.json +66 -107
  13. package/template/.claude/commands/infra.md +7 -3
  14. package/template/.claude/commands/log-decision.md +1 -1
  15. package/template/.claude/commands/log-issue.md +1 -1
  16. package/template/.claude/commands/overview.md +55 -13
  17. package/template/.claude/commands/phase.md +153 -81
  18. package/template/.claude/commands/quick.md +1 -1
  19. package/template/.claude/commands/replan.md +1 -1
  20. package/template/.claude/commands/store-wisdom.md +1 -1
  21. package/template/.claude/commands/synthesize.md +1 -1
  22. package/template/.claude/commands/unstuck.md +1 -1
  23. package/template/.claude/hooks/session-start.sh +5 -11
  24. package/template/.claude/ref/compression.md +56 -0
  25. package/template/.claude/skills/karpathy-guidelines/SKILL.md +1 -1
  26. package/template/.gitattributes +1 -0
  27. package/template/AGENTS.md +264 -130
  28. package/template/CLAUDE.md +5 -9
  29. package/template/README.md +116 -136
  30. package/template/commands.src/infra.md +7 -3
  31. package/template/commands.src/log-decision.md +1 -1
  32. package/template/commands.src/log-issue.md +1 -1
  33. package/template/commands.src/overview.md +55 -13
  34. package/template/commands.src/phase.md +153 -81
  35. package/template/commands.src/quick.md +1 -1
  36. package/template/commands.src/replan.md +1 -1
  37. package/template/commands.src/store-wisdom.md +1 -1
  38. package/template/commands.src/synthesize.md +1 -1
  39. package/template/commands.src/unstuck.md +1 -1
  40. package/template/docs/DESIGN_LOG.md +414 -9
  41. package/template/knowledge/azure.md +161 -0
  42. package/template/knowledge/webapp.md +265 -0
  43. package/template/scripts/spec-conformance.sh +93 -0
  44. package/template/scripts/spec-staleness.sh +115 -0
  45. package/template/setup.sh +30 -111
  46. package/template/tests/helpers/spec-assert.py +162 -0
  47. package/template/tests/helpers/spec-assert.ts +158 -0
  48. package/template/.claude/hooks/cursor-session-start.sh +0 -17
  49. package/template/.claude/skills/caveman/SKILL.md +0 -39
  50. package/template/.cursor/commands/infra.md +0 -82
  51. package/template/.cursor/commands/log-decision.md +0 -29
  52. package/template/.cursor/commands/log-issue.md +0 -23
  53. package/template/.cursor/commands/overview.md +0 -145
  54. package/template/.cursor/commands/phase.md +0 -249
  55. package/template/.cursor/commands/quick.md +0 -25
  56. package/template/.cursor/commands/replan.md +0 -73
  57. package/template/.cursor/commands/store-wisdom.md +0 -191
  58. package/template/.cursor/commands/synthesize.md +0 -22
  59. package/template/.cursor/commands/unstuck.md +0 -36
  60. package/template/.cursor/hooks.json +0 -14
  61. package/template/.cursor/rules/anpunkit.md +0 -11
  62. package/template/anpunkit.png +0 -0
  63. package/template/docker-compose.test.yml +0 -34
  64. package/template/e2e/global-setup.ts +0 -57
  65. package/template/index.html +0 -340
  66. package/template/playwright.config.ts +0 -28
  67. package/template/scripts/auth-setup.sh +0 -51
  68. package/template/scripts/e2e-stack.sh +0 -65
@@ -0,0 +1,265 @@
1
+ # knowledge/webapp.md — matured web-app practice (preinstalled)
2
+
3
+ > **What this file is.** Use-case knowledge for browser-facing web apps,
4
+ > consulted by `researcher` (STEP 0.5) when `knowledge/webapp.md` appears in
5
+ > the `knowledge_docs:` list of `docs/OVERVIEW.md`. Selection is deterministic
6
+ > — declared at `/overview`, never inferred (hard rule 10).
7
+ >
8
+ > **Templates below are materialized per-project** — copied to the stated path
9
+ > the first time the phase work needs them. Once materialized they are
10
+ > user-owned project files: adjust freely, the kit never overwrites them.
11
+
12
+ ## Browser boundary practice (E2E_KIND: browser)
13
+
14
+ - `ui` spec cases carry a `fixtures/<case-id>-ui.json` descriptor with a
15
+ closed, kit-versioned assert vocabulary: `visible`, `text-equals`,
16
+ `enabled`, `count`. `e2e-runner` EMITS Playwright specs from descriptors —
17
+ it never authors blind from prose.
18
+ - Each emitted spec block carries a `// spec: <case-id>` citation
19
+ (`spec-conformance.sh` checks it).
20
+ - EVIDENCE (hard rule 13): at each UI-existence assertion, capture a
21
+ screenshot regardless of pass/fail to
22
+ `docs/evidence/e2e-phase-<n>/<case-id>-<element-slug>.png` — one shot per
23
+ asserted element, on green as well as red.
24
+ - `ui` cases have NO mock mirror — the browser IS the boundary.
25
+
26
+ ## E2E stack ritual
27
+
28
+ Two target modes, recorded in `docs/INFRA.md`:
29
+
30
+ - **azure-deployed** (`E2E_STACK_EXTERNAL=1` in `.env.test`): tests hit the
31
+ deployed app; `e2e-stack.sh up` only health-checks reachability.
32
+ - **local-docker**: `e2e-stack.sh up` builds + starts the compose stack and
33
+ polls health.
34
+
35
+ Sequence, always: `bash scripts/e2e-stack.sh up` -> `npx playwright test` ->
36
+ `bash scripts/e2e-stack.sh down`. Stack didn't come up -> classify
37
+ **STACK NOT READY** (not a bug; no debug budget).
38
+
39
+ ## Web deploy ritual (DEPLOY_KIND: cloud-deploy)
40
+
41
+ - Deploy task lives in the final phase (DEPLOY_KIND completion, hard rules).
42
+ - After deploy: record the deployed base URL in `docs/INFRA.md`
43
+ (`## Deployed base URL`) and confirm `docs/ENDPOINTS.md` entries resolve
44
+ against it; run a `/health` (or equivalent) check before calling it done.
45
+
46
+ ## .gitignore lines a materialized stack needs
47
+
48
+ ```
49
+ e2e/.auth/
50
+ playwright-report/
51
+ test-results/
52
+ ```
53
+
54
+ ---
55
+
56
+ ## Templates
57
+
58
+ ### `playwright.config.ts` — materialize to project root
59
+
60
+ ```ts
61
+ // playwright.config.ts
62
+ import { defineConfig, devices } from '@playwright/test';
63
+
64
+ export default defineConfig({
65
+ testDir: './e2e',
66
+ globalSetup: './e2e/global-setup.ts',
67
+ fullyParallel: false,
68
+ forbidOnly: !!process.env.CI,
69
+ retries: process.env.CI ? 1 : 0,
70
+ workers: 1,
71
+ reporter: [['list'], ['html', { open: 'never' }]],
72
+ use: {
73
+ baseURL: process.env.E2E_BASE_URL || 'http://localhost:8080',
74
+ storageState: 'e2e/.auth/state.json',
75
+ trace: 'retain-on-failure',
76
+ screenshot: 'only-on-failure',
77
+ actionTimeout: 15_000,
78
+ },
79
+ projects: [
80
+ { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
81
+ ],
82
+ webServer: process.env.E2E_STACK_EXTERNAL ? undefined : {
83
+ command: 'bash scripts/e2e-stack.sh up',
84
+ url: process.env.E2E_BASE_URL || 'http://localhost:8080',
85
+ timeout: 180_000,
86
+ reuseExistingServer: !process.env.CI,
87
+ },
88
+ });
89
+ ```
90
+
91
+ ### `e2e/global-setup.ts` — materialize to `e2e/`
92
+
93
+ Entra ID ROPC token injection (Azure/MSAL apps — see `knowledge/azure.md` for
94
+ the auth practice). Known gap: the localStorage keys (`e2e.access_token`,
95
+ `e2e.id_token`) are a stub convention — align them with your app's real MSAL
96
+ cache keys when materializing.
97
+
98
+ ```ts
99
+ // e2e/global-setup.ts
100
+ import { chromium } from '@playwright/test';
101
+ import * as fs from 'fs';
102
+ import * as path from 'path';
103
+
104
+ const AUTH_DIR = path.join(__dirname, '.auth');
105
+ const STATE = path.join(AUTH_DIR, 'state.json');
106
+
107
+ async function fetchRopcToken() {
108
+ const tenant = reqEnv('E2E_TENANT_ID');
109
+ const body = new URLSearchParams({
110
+ grant_type: 'password',
111
+ client_id: reqEnv('E2E_CLIENT_ID'),
112
+ username: reqEnv('E2E_TEST_USER'),
113
+ password: reqEnv('E2E_TEST_PASSWORD'),
114
+ scope: `openid profile ${reqEnv('E2E_SCOPE')}`,
115
+ });
116
+ const url = `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`;
117
+ const res = await fetch(url, {
118
+ method: 'POST',
119
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
120
+ body,
121
+ });
122
+ if (!res.ok) {
123
+ const txt = await res.text();
124
+ throw new Error(
125
+ `ROPC token fetch failed (${res.status}). ` +
126
+ `Check: ROPC enabled on app registration, test user exists, ` +
127
+ `MFA excluded via Conditional Access. Response: ${txt}`
128
+ );
129
+ }
130
+ return res.json() as Promise<{ access_token: string; id_token: string; expires_in: number }>;
131
+ }
132
+
133
+ function reqEnv(k: string): string {
134
+ const v = process.env[k];
135
+ if (!v) throw new Error(`Missing env ${k} — run /infra to regenerate .env.test`);
136
+ return v;
137
+ }
138
+
139
+ async function globalSetup() {
140
+ fs.mkdirSync(AUTH_DIR, { recursive: true });
141
+ const token = await fetchRopcToken();
142
+ const baseURL = reqEnv('E2E_BASE_URL');
143
+ const browser = await chromium.launch();
144
+ const page = await browser.newPage();
145
+ await page.goto(baseURL);
146
+ await page.evaluate((t) => {
147
+ localStorage.setItem('e2e.access_token', t.access_token);
148
+ localStorage.setItem('e2e.id_token', t.id_token);
149
+ }, token);
150
+ await page.context().storageState({ path: STATE });
151
+ await browser.close();
152
+ console.log('E2E auth ready — real Entra token injected, MFA UI bypassed.');
153
+ }
154
+
155
+ export default globalSetup;
156
+ ```
157
+
158
+ ### `docker-compose.test.yml` — materialize to project root
159
+
160
+ ```yaml
161
+ # docker-compose.test.yml — E2E test stack (local-docker mode).
162
+ # TEMPLATE — adjust build contexts and ports for your project layout.
163
+ services:
164
+ backend:
165
+ build:
166
+ context: ./backend # <-- adjust
167
+ environment:
168
+ SQL_SERVER: ${E2E_SQL_SERVER}
169
+ SQL_DB: ${E2E_SQL_DB}
170
+ SQL_USER: ${E2E_SQL_USER}
171
+ SQL_PASSWORD: ${E2E_SQL_PASSWORD}
172
+ TENANT_ID: ${E2E_TENANT_ID}
173
+ CLIENT_ID: ${E2E_CLIENT_ID}
174
+ STORAGE_CONN: ${E2E_STORAGE_CONN:-}
175
+ ports:
176
+ - "8081:8081"
177
+ healthcheck:
178
+ test: ["CMD", "curl", "-fs", "http://localhost:8081/health"]
179
+ interval: 5s
180
+ timeout: 3s
181
+ retries: 12
182
+
183
+ webapp:
184
+ build:
185
+ context: ./frontend # <-- adjust
186
+ environment:
187
+ API_BASE_URL: http://backend:8081
188
+ TENANT_ID: ${E2E_TENANT_ID}
189
+ CLIENT_ID: ${E2E_CLIENT_ID}
190
+ ports:
191
+ - "8080:80"
192
+ depends_on:
193
+ backend:
194
+ condition: service_healthy
195
+ ```
196
+
197
+ ### `scripts/e2e-stack.sh` — materialize to `scripts/`
198
+
199
+ ```bash
200
+ #!/usr/bin/env bash
201
+ # scripts/e2e-stack.sh — E2E test stack lifecycle.
202
+ set -euo pipefail
203
+ cd "$(dirname "$0")/.."
204
+
205
+ CMD="${1:-}"
206
+ COMPOSE="docker-compose.test.yml"
207
+ ENVFILE=".env.test"
208
+
209
+ [ -f "$ENVFILE" ] || { echo "missing $ENVFILE — run /infra to generate it"; exit 1; }
210
+ set -a; . "./$ENVFILE"; set +a
211
+
212
+ if [ "${E2E_STACK_EXTERNAL:-}" = "1" ]; then
213
+ echo "[e2e-stack] E2E_STACK_EXTERNAL=1 — targeting deployed Azure app at ${E2E_BASE_URL:-<unset>}"
214
+ case "$CMD" in
215
+ up)
216
+ echo "[e2e-stack] Verifying Azure app is reachable..."
217
+ for i in $(seq 1 10); do
218
+ if curl -fs "${E2E_BASE_URL:-http://localhost:8080}/health" >/dev/null 2>&1 \
219
+ || curl -fs "${E2E_BASE_URL:-http://localhost:8080}" >/dev/null 2>&1; then
220
+ echo "[e2e-stack] Azure app reachable."
221
+ exit 0
222
+ fi
223
+ sleep 3
224
+ done
225
+ echo "[e2e-stack] WARNING: Azure app not reachable. Classify as AZURE UNAVAILABLE."
226
+ exit 1
227
+ ;;
228
+ down)
229
+ echo "[e2e-stack] External mode — nothing to stop."
230
+ exit 0
231
+ ;;
232
+ *)
233
+ echo "usage: e2e-stack.sh up|down"
234
+ exit 1
235
+ ;;
236
+ esac
237
+ fi
238
+
239
+ if docker compose version >/dev/null 2>&1; then DC="docker compose"; else DC="docker-compose"; fi
240
+
241
+ case "$CMD" in
242
+ up)
243
+ echo "[e2e-stack] building + starting local app containers..."
244
+ $DC -f "$COMPOSE" --env-file "$ENVFILE" up -d --build
245
+ for i in $(seq 1 30); do
246
+ if curl -fs "${E2E_BASE_URL:-http://localhost:8080}" >/dev/null 2>&1; then
247
+ echo "[e2e-stack] up."
248
+ exit 0
249
+ fi
250
+ sleep 3
251
+ done
252
+ echo "[e2e-stack] ERROR: app did not become healthy — STACK NOT READY"
253
+ exit 1
254
+ ;;
255
+ down)
256
+ echo "[e2e-stack] stopping local containers..."
257
+ $DC -f "$COMPOSE" --env-file "$ENVFILE" down
258
+ echo "[e2e-stack] down."
259
+ ;;
260
+ *)
261
+ echo "usage: e2e-stack.sh up|down"
262
+ exit 1
263
+ ;;
264
+ esac
265
+ ```
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env bash
2
+ # spec-conformance.sh — block GREEN until the phase spec is fully filled and every
3
+ # case is verified by a boundary test (v2.2). Runs between RED and GREEN, replacing
4
+ # the v2.1 human TEST REVIEW gate. (§5.55, rule 18)
5
+ #
6
+ # Two checks, both loud-fail (nonzero), no side effects:
7
+ # 1. NO `TBD` remains in docs/spec-phase-<n>.md or any fixture it references.
8
+ # 2. EVERY case-id in the spec table is cited by a test via `# spec: <case-id>`
9
+ # (or `// spec: <case-id>`) under tests/ or e2e/.
10
+ #
11
+ # bash scripts/spec-conformance.sh <phase-n>
12
+ #
13
+ # The case→test citation here, plus the placement convention (contract/transition
14
+ # tests live in tests/regression/), is what closes the rule-14 chain: reachable
15
+ # transition → filled case (rule 14) → cited boundary test (this) → regression corpus.
16
+ set -euo pipefail
17
+ cd "${CLAUDE_PROJECT_DIR:-.}"
18
+
19
+ N="${1:-}"
20
+ [ -n "$N" ] || { echo "spec-conformance: usage: spec-conformance.sh <phase-n>" >&2; exit 2; }
21
+
22
+ SPEC="docs/spec-phase-${N}.md"
23
+ [ -f "$SPEC" ] || { echo "spec-conformance: $SPEC not found." >&2; exit 2; }
24
+
25
+ FAIL=0
26
+
27
+ # ---- 1. no TBD left in the spec ----
28
+ if grep -nE '\bTBD\b' "$SPEC" >/dev/null 2>&1; then
29
+ echo "spec-conformance: FAIL — unfilled 'TBD' marker(s) remain in $SPEC:" >&2
30
+ grep -nE '\bTBD\b' "$SPEC" | sed 's/^/ /' >&2
31
+ echo " Every case must be filled (or escalated as CASE-SET-DIVERGENCE) before GREEN." >&2
32
+ FAIL=1
33
+ fi
34
+
35
+ # ---- referenced fixtures: must exist + carry no TBD ----
36
+ FIXREFS="$(grep -oE 'fixtures/[A-Za-z0-9_./-]+\.json' "$SPEC" | sort -u || true)"
37
+ if [ -n "$FIXREFS" ]; then
38
+ while IFS= read -r fx; do
39
+ [ -n "$fx" ] || continue
40
+ if [ ! -f "$fx" ]; then
41
+ echo "spec-conformance: FAIL — referenced fixture missing: $fx" >&2
42
+ FAIL=1
43
+ elif grep -nE '\bTBD\b' "$fx" >/dev/null 2>&1; then
44
+ echo "spec-conformance: FAIL — 'TBD' marker in fixture: $fx" >&2
45
+ FAIL=1
46
+ fi
47
+ done <<EOF
48
+ $FIXREFS
49
+ EOF
50
+ fi
51
+
52
+ # ---- 2. every case-id cited by a boundary test ----
53
+ # case-ids = the FIRST table-column cell of each row, matching PH<n>-... exactly.
54
+ # (Parse the column, not a free grep — else `fixtures/PH1-ORDER-01-input.json`
55
+ # would yield a bogus `PH1-ORDER-01-input` case-id.)
56
+ CASE_IDS="$(grep -E '^\|' "$SPEC" \
57
+ | awk -F'|' '{ id=$2; gsub(/^[ \t]+|[ \t]+$/,"",id); print id }' \
58
+ | grep -E "^PH${N}-[A-Za-z0-9_-]+$" | sort -u || true)"
59
+ if [ -z "$CASE_IDS" ]; then
60
+ echo "spec-conformance: WARN — no PH${N}-* case-ids found in $SPEC (empty spec?)." >&2
61
+ fi
62
+
63
+ SEARCH_DIRS=""
64
+ [ -d tests ] && SEARCH_DIRS="$SEARCH_DIRS tests"
65
+ [ -d e2e ] && SEARCH_DIRS="$SEARCH_DIRS e2e"
66
+
67
+ if [ -n "$CASE_IDS" ]; then
68
+ if [ -z "$SEARCH_DIRS" ]; then
69
+ echo "spec-conformance: FAIL — case-ids exist but no tests/ or e2e/ dir to cite them." >&2
70
+ FAIL=1
71
+ else
72
+ while IFS= read -r cid; do
73
+ [ -n "$cid" ] || continue
74
+ # cite convention: `spec: <case-id>` with a non-id char (or EOL) after, so
75
+ # PH2-ORDER-01 does not match PH2-ORDER-011.
76
+ if grep -rEq -- "spec:[[:space:]]*${cid}([^0-9A-Za-z_-]|$)" $SEARCH_DIRS 2>/dev/null; then
77
+ :
78
+ else
79
+ echo "spec-conformance: FAIL — case ${cid} has no boundary test citing it (# spec: ${cid})." >&2
80
+ FAIL=1
81
+ fi
82
+ done <<EOF
83
+ $CASE_IDS
84
+ EOF
85
+ fi
86
+ fi
87
+
88
+ if [ "$FAIL" -ne 0 ]; then
89
+ echo "spec-conformance: BLOCKED — fix the above before GREEN." >&2
90
+ exit 1
91
+ fi
92
+ echo "spec-conformance: OK — phase ${N} spec fully filled; every case-id cited by a boundary test."
93
+ exit 0
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env bash
2
+ # spec-staleness.sh — guard the per-phase spec header against upstream drift (v2.2).
3
+ #
4
+ # The behavioral contract docs/spec-phase-<n>.md carries a GENERATED header that
5
+ # transcludes the phase's PLAN.md `- acceptance:` line + the in-scope DATAFLOW.md
6
+ # transition rows, plus an embedded hash. If PLAN.md / DATAFLOW.md change after the
7
+ # skeleton was stamped (e.g. via /replan), the stored hash no longer matches the
8
+ # upstream → the spec must be regenerated/re-filled before SPEC REVIEW. (§5.55, rule 17)
9
+ #
10
+ # Hashing lives ONLY here (no duplication in agent prompts):
11
+ # bash scripts/spec-staleness.sh stamp <n> # compute + embed the hash (at /overview, on regenerate)
12
+ # bash scripts/spec-staleness.sh check <n> # default: recompute + compare; nonzero on drift
13
+ # bash scripts/spec-staleness.sh <n> # alias for: check <n>
14
+ #
15
+ # set -euo pipefail; nonzero = loud fail; `stamp` is the only side effect.
16
+ set -euo pipefail
17
+ cd "${CLAUDE_PROJECT_DIR:-.}"
18
+
19
+ # ---- args ----
20
+ SUB="check"
21
+ case "${1:-}" in
22
+ stamp) SUB="stamp"; shift;;
23
+ check) SUB="check"; shift;;
24
+ esac
25
+ N="${1:-}"
26
+ [ -n "$N" ] || { echo "spec-staleness: usage: spec-staleness.sh [stamp|check] <phase-n>" >&2; exit 2; }
27
+
28
+ SPEC="docs/spec-phase-${N}.md"
29
+ PLAN="docs/PLAN.md"
30
+ DATAFLOW="docs/DATAFLOW.md"
31
+ [ -f "$SPEC" ] || { echo "spec-staleness: $SPEC not found." >&2; exit 2; }
32
+ [ -f "$PLAN" ] || { echo "spec-staleness: $PLAN not found." >&2; exit 2; }
33
+
34
+ # ---- sha256 of stdin (portable: sha256sum -> shasum -> node) ----
35
+ sha_stdin() {
36
+ if command -v sha256sum >/dev/null 2>&1; then sha256sum | cut -d' ' -f1
37
+ elif command -v shasum >/dev/null 2>&1; then shasum -a 256 | cut -d' ' -f1
38
+ else node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>console.log(require('crypto').createHash('sha256').update(d).digest('hex')))"
39
+ fi
40
+ }
41
+
42
+ # normalize: NFC-ish arrow unify (→ -> ->), collapse spaces, trim. Content-sensitive,
43
+ # whitespace-insensitive — so cosmetic reflow does not trip the guard, content does.
44
+ norm() { sed -e 's/→/->/g' -e 's/[[:space:]]\{1,\}/ /g' -e 's/^ //' -e 's/ $//'; }
45
+
46
+ # ---- extract this phase's block from PLAN.md (## Phase <n>: ... up to next ## Phase) ----
47
+ phase_block() {
48
+ awk -v n="$N" '
49
+ $0 ~ ("^## Phase " n ":") {inblk=1; print; next}
50
+ inblk && /^## Phase / {inblk=0}
51
+ inblk {print}
52
+ ' "$PLAN"
53
+ }
54
+
55
+ BLOCK="$(phase_block || true)"
56
+ [ -n "$BLOCK" ] || { echo "spec-staleness: no '## Phase ${N}:' block in $PLAN." >&2; exit 2; }
57
+
58
+ # acceptance line(s) + dataflow line, normalized
59
+ ACC_LINE="$(printf '%s\n' "$BLOCK" | grep -E '^- acceptance:' | norm || true)"
60
+ DF_LINE="$(printf '%s\n' "$BLOCK" | grep -E '^- dataflow:' | norm || true)"
61
+
62
+ # in-scope transition tokens (from->to) named on the dataflow line
63
+ TOKENS="$(printf '%s\n' "$DF_LINE" | norm | grep -oE '[A-Za-z0-9_]+->[A-Za-z0-9_]+' | sort -u || true)"
64
+
65
+ # matching DATAFLOW.md data-rows (table rows naming an in-scope transition)
66
+ DF_ROWS=""
67
+ if [ -n "$TOKENS" ] && [ -f "$DATAFLOW" ]; then
68
+ while IFS= read -r tok; do
69
+ [ -n "$tok" ] || continue
70
+ rows="$(grep -E '^\|' "$DATAFLOW" | norm | grep -F "$tok" || true)"
71
+ DF_ROWS="${DF_ROWS}${rows}
72
+ "
73
+ done <<EOF
74
+ $TOKENS
75
+ EOF
76
+ fi
77
+ DF_ROWS="$(printf '%s\n' "$DF_ROWS" | grep -v '^$' | sort -u || true)"
78
+
79
+ # ---- the upstream signature + its hash ----
80
+ SIG="$(printf 'ACC:%s\nDF:%s\nROWS:\n%s\n' "$ACC_LINE" "$DF_LINE" "$DF_ROWS")"
81
+ WANT="$(printf '%s' "$SIG" | sha_stdin)"
82
+
83
+ HASH_RE='^<!-- spec-hash: .* -->$'
84
+ HAVE="$(grep -E "$HASH_RE" "$SPEC" | head -1 | sed -E 's/^<!-- spec-hash: (.*) -->$/\1/' || true)"
85
+
86
+ if [ "$SUB" = "stamp" ]; then
87
+ if grep -qE "$HASH_RE" "$SPEC"; then
88
+ tmp="$(mktemp)"
89
+ awk -v h="$WANT" '
90
+ /^<!-- spec-hash: .* -->$/ && !done { print "<!-- spec-hash: " h " -->"; done=1; next }
91
+ { print }
92
+ ' "$SPEC" > "$tmp" && mv "$tmp" "$SPEC"
93
+ else
94
+ echo "spec-staleness: $SPEC has no '<!-- spec-hash: ... -->' header line to stamp." >&2
95
+ exit 2
96
+ fi
97
+ echo "spec-staleness: stamped phase ${N} -> ${WANT}"
98
+ exit 0
99
+ fi
100
+
101
+ # ---- check ----
102
+ if [ -z "$HAVE" ] || [ "$HAVE" = "PENDING" ]; then
103
+ echo "spec-staleness: FAIL — phase ${N} spec hash is unstamped (${HAVE:-missing})." >&2
104
+ echo " Run: bash scripts/spec-staleness.sh stamp ${N}" >&2
105
+ exit 1
106
+ fi
107
+ if [ "$HAVE" != "$WANT" ]; then
108
+ echo "spec-staleness: FAIL — phase ${N} spec is STALE." >&2
109
+ echo " Upstream PLAN.md acceptance / DATAFLOW.md rows changed since the skeleton was stamped." >&2
110
+ echo " stored=${HAVE} current=${WANT}" >&2
111
+ echo " Regenerate the skeleton header from current PLAN/DATAFLOW, re-stamp, and re-fill." >&2
112
+ exit 1
113
+ fi
114
+ echo "spec-staleness: OK — phase ${N} spec header matches upstream (${WANT})."
115
+ exit 0