qualia-framework 7.2.2 → 7.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.
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env bash
2
+ # plugin-manifest.test.sh — validates the experimental Claude Code plugin manifests.
3
+ # Asserts .claude-plugin/plugin.json and .claude-plugin/marketplace.json are valid
4
+ # JSON, declare their required fields, pin the same version as package.json, and that
5
+ # every component path they reference exists in the repo. This is additive: it does
6
+ # NOT touch the npx installer flow (bin/install.js + settings.json wiring), which is
7
+ # covered by install-smoke.test.sh.
8
+
9
+ set -u
10
+
11
+ PASS=0
12
+ FAIL=0
13
+
14
+ pass() {
15
+ echo " ✓ $1"
16
+ PASS=$((PASS + 1))
17
+ }
18
+
19
+ fail_case() {
20
+ echo " ✗ $1"
21
+ [ -n "${2:-}" ] && echo " $2"
22
+ FAIL=$((FAIL + 1))
23
+ }
24
+
25
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
26
+ NODE="${NODE:-node}"
27
+ PLUGIN="$ROOT/.claude-plugin/plugin.json"
28
+ MARKET="$ROOT/.claude-plugin/marketplace.json"
29
+ HOOKSJSON="$ROOT/hooks/hooks.json"
30
+
31
+ echo "plugin-manifest.test.sh — plugin + marketplace manifests"
32
+ echo ""
33
+
34
+ # 1. plugin.json is valid JSON
35
+ if [ -f "$PLUGIN" ] && "$NODE" -e "JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'))" "$PLUGIN" 2>/dev/null; then
36
+ pass "plugin.json exists and is valid JSON"
37
+ else
38
+ fail_case "plugin.json missing or invalid JSON" "$PLUGIN"
39
+ fi
40
+
41
+ # 2. marketplace.json is valid JSON
42
+ if [ -f "$MARKET" ] && "$NODE" -e "JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'))" "$MARKET" 2>/dev/null; then
43
+ pass "marketplace.json exists and is valid JSON"
44
+ else
45
+ fail_case "marketplace.json missing or invalid JSON" "$MARKET"
46
+ fi
47
+
48
+ # 3. hooks/hooks.json is valid JSON
49
+ if [ -f "$HOOKSJSON" ] && "$NODE" -e "JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'))" "$HOOKSJSON" 2>/dev/null; then
50
+ pass "hooks/hooks.json exists and is valid JSON"
51
+ else
52
+ fail_case "hooks/hooks.json missing or invalid JSON" "$HOOKSJSON"
53
+ fi
54
+
55
+ # 4. plugin.json required field: name (and the expected identity)
56
+ PLUGIN_NAME=$("$NODE" -e "console.log(require(process.argv[1]).name || '')" "$PLUGIN" 2>/dev/null)
57
+ if [ "$PLUGIN_NAME" = "qualia-framework" ]; then
58
+ pass "plugin.json declares name=qualia-framework"
59
+ else
60
+ fail_case "plugin.json name field wrong" "got='$PLUGIN_NAME'"
61
+ fi
62
+
63
+ # 5. plugin.json version pinned to package.json version
64
+ PKG_VERSION=$("$NODE" -e "console.log(require(process.argv[1]).version)" "$ROOT/package.json")
65
+ PLUGIN_VERSION=$("$NODE" -e "console.log(require(process.argv[1]).version || '')" "$PLUGIN" 2>/dev/null)
66
+ if [ "$PLUGIN_VERSION" = "$PKG_VERSION" ]; then
67
+ pass "plugin.json version matches package.json ($PKG_VERSION)"
68
+ else
69
+ fail_case "plugin.json version drift" "package=$PKG_VERSION plugin=$PLUGIN_VERSION"
70
+ fi
71
+
72
+ # 6. plugin.json hooks path resolves to an existing file
73
+ PLUGIN_HOOKS_REL=$("$NODE" -e "console.log(require(process.argv[1]).hooks || '')" "$PLUGIN" 2>/dev/null)
74
+ PLUGIN_HOOKS_ABS="$ROOT/${PLUGIN_HOOKS_REL#./}"
75
+ if [ -n "$PLUGIN_HOOKS_REL" ] && [ -f "$PLUGIN_HOOKS_ABS" ]; then
76
+ pass "plugin.json hooks path ($PLUGIN_HOOKS_REL) exists"
77
+ else
78
+ fail_case "plugin.json hooks path missing" "ref='$PLUGIN_HOOKS_REL' abs='$PLUGIN_HOOKS_ABS'"
79
+ fi
80
+
81
+ # 7. Every hook file referenced in hooks/hooks.json exists at hooks/<file>.js
82
+ MISSING_HOOKS=$("$NODE" - "$HOOKSJSON" "$ROOT" <<'NODE'
83
+ const fs = require('fs');
84
+ const path = require('path');
85
+ const [, , hooksJson, root] = process.argv;
86
+ const data = JSON.parse(fs.readFileSync(hooksJson, 'utf8'));
87
+ const missing = [];
88
+ const seen = new Set();
89
+ for (const event of Object.keys(data)) {
90
+ for (const block of data[event]) {
91
+ for (const h of (block.hooks || [])) {
92
+ const cmd = h.command || '';
93
+ const m = cmd.match(/\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/([A-Za-z0-9._-]+\.js)/);
94
+ if (!m) { missing.push('UNPARSEABLE: ' + cmd); continue; }
95
+ const file = m[1];
96
+ if (seen.has(file)) continue;
97
+ seen.add(file);
98
+ if (!fs.existsSync(path.join(root, 'hooks', file))) missing.push(file);
99
+ }
100
+ }
101
+ }
102
+ process.stdout.write(missing.join(','));
103
+ NODE
104
+ )
105
+ if [ -z "$MISSING_HOOKS" ]; then
106
+ pass "every hook referenced in hooks/hooks.json exists in hooks/"
107
+ else
108
+ fail_case "hooks/hooks.json references missing/unparseable hook files" "$MISSING_HOOKS"
109
+ fi
110
+
111
+ # 8. All hooks/hooks.json commands use the ${CLAUDE_PLUGIN_ROOT} substitution (no absolute ~/.claude paths leaked)
112
+ if ! grep -q "\.claude/hooks" "$HOOKSJSON" && grep -q "CLAUDE_PLUGIN_ROOT" "$HOOKSJSON"; then
113
+ pass "hooks/hooks.json uses \${CLAUDE_PLUGIN_ROOT}, no leaked ~/.claude paths"
114
+ else
115
+ fail_case "hooks/hooks.json has leaked absolute paths or missing CLAUDE_PLUGIN_ROOT"
116
+ fi
117
+
118
+ # 9. marketplace.json required fields: name, owner.name, plugins[]
119
+ MKT_OK=$("$NODE" - "$MARKET" <<'NODE'
120
+ const fs = require('fs');
121
+ const m = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
122
+ const ok = !!m.name && m.owner && !!m.owner.name && Array.isArray(m.plugins) && m.plugins.length > 0;
123
+ process.stdout.write(ok ? 'ok' : 'bad');
124
+ NODE
125
+ )
126
+ if [ "$MKT_OK" = "ok" ]; then
127
+ pass "marketplace.json has required name + owner.name + plugins[]"
128
+ else
129
+ fail_case "marketplace.json missing required fields"
130
+ fi
131
+
132
+ # 10. marketplace plugin entry: required name + source, version matches, source path resolves
133
+ MKT_PLUGIN=$("$NODE" - "$MARKET" "$ROOT" "$PKG_VERSION" <<'NODE'
134
+ const fs = require('fs');
135
+ const path = require('path');
136
+ const [, , market, root, pkgVersion] = process.argv;
137
+ const m = JSON.parse(fs.readFileSync(market, 'utf8'));
138
+ const p = (m.plugins || []).find(x => x.name === 'qualia-framework');
139
+ if (!p) { process.stdout.write('no-entry'); process.exit(0); }
140
+ if (!p.source) { process.stdout.write('no-source'); process.exit(0); }
141
+ // source './' resolves against the marketplace root (repo root); must be a dir containing .claude-plugin/
142
+ const srcAbs = path.resolve(root, p.source);
143
+ if (!fs.existsSync(path.join(srcAbs, '.claude-plugin', 'plugin.json'))) { process.stdout.write('source-unresolved'); process.exit(0); }
144
+ if (p.version && p.version !== pkgVersion) { process.stdout.write('version-drift:' + p.version); process.exit(0); }
145
+ process.stdout.write('ok');
146
+ NODE
147
+ )
148
+ if [ "$MKT_PLUGIN" = "ok" ]; then
149
+ pass "marketplace plugin entry has name+source, source resolves, version matches"
150
+ else
151
+ fail_case "marketplace plugin entry invalid" "$MKT_PLUGIN"
152
+ fi
153
+
154
+ # 11. .claude-plugin/ is in the npm package files list (ships in the tarball)
155
+ if "$NODE" -e "process.exit((require(process.argv[1]).files||[]).includes('.claude-plugin/')?0:1)" "$ROOT/package.json"; then
156
+ pass "package.json files[] includes .claude-plugin/ (ships in tarball)"
157
+ else
158
+ fail_case "package.json files[] missing .claude-plugin/"
159
+ fi
160
+
161
+ echo ""
162
+ echo "Results: $PASS passed, $FAIL failed"
163
+
164
+ if [ "$FAIL" -gt 0 ]; then
165
+ exit 1
166
+ fi
167
+
168
+ exit 0
@@ -217,6 +217,70 @@ assert_count "hooks" "$HOOK_COUNT" "$FRAMEWORK_ROOT/README.md" '[0-9]+ hooks'
217
217
  assert_count "rules" "$RULE_COUNT" "$FRAMEWORK_ROOT/README.md" '[0-9]+ (installed )?rules'
218
218
  assert_count "skills" "$SKILL_COUNT" "$FRAMEWORK_ROOT/guide.md" '[0-9]+ active skills'
219
219
 
220
+ # ── Doc/code coherence guard ──────────────────────────────────────────────────
221
+ # A doc that promises a `qualia-framework <cmd>` CLI command or a `/qualia-*`
222
+ # skill must point at one that actually exists. The authoritative check lives in
223
+ # bin/qualia-doctor.js (gateDocCoherence) — it resolves the real surface from
224
+ # cli.js dispatch + command-surface.js. We drive that gate here so the test and
225
+ # the doctor never diverge, then pin the three specific contradictions the
226
+ # v7.2.2 doc audit found so they can't silently come back.
227
+ COHERENCE_STATUS=$("$NODE" -e '
228
+ const m = require(process.argv[1]);
229
+ const r = m.gateDocCoherence(m.repoRoot());
230
+ process.stdout.write(r.status + "\t" + r.detail);
231
+ ' "$FRAMEWORK_ROOT/bin/qualia-doctor.js" 2>/dev/null)
232
+ COHERENCE_RESULT="${COHERENCE_STATUS%%$'\t'*}"
233
+ COHERENCE_DETAIL="${COHERENCE_STATUS#*$'\t'}"
234
+ if [ "$COHERENCE_RESULT" = "PASS" ]; then
235
+ pass "doctor doc/code coherence gate → $COHERENCE_DETAIL"
236
+ else
237
+ fail_case "doctor doc/code coherence gate ($COHERENCE_RESULT)" "$COHERENCE_DETAIL"
238
+ fi
239
+
240
+ # Contradiction 1: TROUBLESHOOTING.md must not send anyone to `qualia-framework
241
+ # erp-status` (no such dispatch case — the real one is `erp-flush show`).
242
+ if grep -qE 'qualia-framework[[:space:]]+erp-status' "$FRAMEWORK_ROOT/TROUBLESHOOTING.md"; then
243
+ fail_case "TROUBLESHOOTING.md erp-status" "references nonexistent 'qualia-framework erp-status' — use 'erp-flush show'"
244
+ else
245
+ pass "TROUBLESHOOTING.md no longer references nonexistent 'qualia-framework erp-status'"
246
+ fi
247
+ if grep -qE 'qualia-framework[[:space:]]+erp-flush[[:space:]]+show' "$FRAMEWORK_ROOT/TROUBLESHOOTING.md"; then
248
+ pass "TROUBLESHOOTING.md points queue-depth check at real 'erp-flush show'"
249
+ else
250
+ fail_case "TROUBLESHOOTING.md erp-flush show" "queue-depth instruction should use 'qualia-framework erp-flush show'"
251
+ fi
252
+
253
+ # Contradiction 2: onboarding.html version stamp must match package.json, not
254
+ # the stale v6.2.7 it carried.
255
+ PKG_VERSION=$("$NODE" -e 'process.stdout.write(require(process.argv[1]).version)' "$FRAMEWORK_ROOT/package.json")
256
+ if grep -qE "Qualia Framework v${PKG_VERSION//./\\.}" "$FRAMEWORK_ROOT/docs/onboarding.html"; then
257
+ pass "docs/onboarding.html version stamp = package v$PKG_VERSION"
258
+ else
259
+ fail_case "docs/onboarding.html version stamp" "footer must read 'Qualia Framework v$PKG_VERSION' (package version)"
260
+ fi
261
+ if grep -qE 'Qualia Framework v6\.2\.7' "$FRAMEWORK_ROOT/docs/onboarding.html"; then
262
+ fail_case "docs/onboarding.html stale version" "still stamps the retired v6.2.7"
263
+ else
264
+ pass "docs/onboarding.html no longer stamps stale v6.2.7"
265
+ fi
266
+
267
+ # Contradiction 3: AGENTS.md is GENERATED from templates/instructions.md and is
268
+ # guarded against drift by tests/instructions.test.sh (compile --check). The
269
+ # "under 25 lines" self-claim it carries is a known inaccuracy (the file is 29
270
+ # lines), but it can only be corrected at the canonical source + recompile — not
271
+ # by editing AGENTS.md directly, which would break the drift guard. Here we just
272
+ # pin that the claim must be fixed in the TEMPLATE, not the generated file, so a
273
+ # future fix lands in the right place. (Tracked, non-blocking.)
274
+ TEMPLATE="$FRAMEWORK_ROOT/templates/instructions.md"
275
+ AGENTS_LINES=$(wc -l < "$FRAMEWORK_ROOT/AGENTS.md" | tr -d ' ')
276
+ if [ -f "$TEMPLATE" ] && grep -qE 'under 25 lines' "$TEMPLATE" && [ "$AGENTS_LINES" -ge 25 ]; then
277
+ echo " ! AGENTS.md/template still claim 'under 25 lines' but generated file is $AGENTS_LINES lines"
278
+ echo " fix at templates/instructions.md then 'node bin/compile-instructions.js' (out of this task's allowlist)"
279
+ pass "AGENTS.md line-budget claim flagged at its canonical source (templates/instructions.md)"
280
+ else
281
+ pass "AGENTS.md line-budget self-claim is truthful ($AGENTS_LINES lines)"
282
+ fi
283
+
220
284
  echo ""
221
285
  echo "Results: $PASS passed, $FAIL failed"
222
286
 
package/tests/run-all.sh CHANGED
@@ -38,6 +38,7 @@ SUITES=(
38
38
  "r6-golden"
39
39
  "memory-loop"
40
40
  "journey-spine"
41
+ "plugin-manifest"
41
42
  )
42
43
 
43
44
  FAILED=()
@@ -1639,6 +1639,180 @@ else
1639
1639
  fail_case "A5 breaker" "rp2=$RP2 rp3=$RP3"
1640
1640
  fi
1641
1641
 
1642
+ # ─── FIX #1: nextCommand() threads lifecycle on the INCREMENT path ──────────
1643
+ echo ""
1644
+ echo "lifecycle threading on increment routing (FIX #1):"
1645
+
1646
+ # 72. operate + increments: a launched project migrated to increments must keep
1647
+ # operate routing. The increment transition call site previously dropped the
1648
+ # lifecycle arg, so the LAST phase verified(pass) reverted to the build-mode
1649
+ # /qualia-polish chain instead of /qualia-update. This is the regression.
1650
+ TMP=$(make_project)
1651
+ make_valid_plan "$TMP" 1
1652
+ make_valid_plan "$TMP" 2
1653
+ echo "result: PASS" > "$TMP/.planning/phase-1-verification.md"
1654
+ echo "result: PASS" > "$TMP/.planning/phase-2-verification.md"
1655
+ (cd "$TMP" && $NODE "$STATE_JS" launch >/dev/null 2>&1) # lifecycle → operate (in tracking.json)
1656
+ (cd "$TMP" && $NODE "$STATE_JS" migrate >/dev/null 2>&1) # legacy → increments (lifecycle persists)
1657
+ a5op() { (cd "$TMP" && $NODE "$STATE_JS" transition --to "$1" ${2:+--verification "$2"} ${3:+$3} --id "$4" 2>&1); }
1658
+ # Drive inc-0001 to verified(pass) — NOT the last increment, routes to next plan.
1659
+ a5op planned "" "" inc-0001-foundation >/dev/null 2>&1
1660
+ a5op built "" "--tasks-done 1 --tasks-total 1" inc-0001-foundation >/dev/null 2>&1
1661
+ a5op verified pass "" inc-0001-foundation >/dev/null 2>&1
1662
+ # Drive inc-0002 (the LAST increment) to verified(pass) — operate routes to update.
1663
+ a5op planned "" "" inc-0002-core >/dev/null 2>&1
1664
+ a5op built "" "--tasks-done 1 --tasks-total 1" inc-0002-core >/dev/null 2>&1
1665
+ VLAST=$(cd "$TMP" && $NODE "$STATE_JS" transition --to verified --verification pass --id inc-0002-core 2>&1)
1666
+ if echo "$VLAST" | grep -q '"next_command": "/qualia-update"'; then
1667
+ pass "operate increment: last-phase verified(pass) → /qualia-update (lifecycle threaded, not /qualia-polish)"
1668
+ else
1669
+ fail_case "operate increment routing (FIX #1)" "out=$VLAST"
1670
+ fi
1671
+
1672
+ # 73. operate + increments: `check` (cmdCheckIncrement) surfaces the operate
1673
+ # next_command too — the read-side router must agree with the transition.
1674
+ CKOP=$(cd "$TMP" && $NODE "$STATE_JS" check 2>&1)
1675
+ if echo "$CKOP" | grep -q '"next_command": "/qualia-update"'; then
1676
+ pass "operate increment: check surfaces /qualia-update (cmdCheckIncrement threads lifecycle)"
1677
+ else
1678
+ fail_case "operate increment check routing (FIX #1)" "out=$CKOP"
1679
+ fi
1680
+
1681
+ # 74. REGRESSION: a BUILD-mode increment project still routes to /qualia-polish
1682
+ # on the last verified(pass) — undefined/build lifecycle must behave as today.
1683
+ TMP=$(make_project)
1684
+ make_valid_plan "$TMP" 1
1685
+ make_valid_plan "$TMP" 2
1686
+ echo "result: PASS" > "$TMP/.planning/phase-1-verification.md"
1687
+ echo "result: PASS" > "$TMP/.planning/phase-2-verification.md"
1688
+ (cd "$TMP" && $NODE "$STATE_JS" migrate >/dev/null 2>&1) # no launch → lifecycle=build
1689
+ b5() { (cd "$TMP" && $NODE "$STATE_JS" transition --to "$1" ${2:+--verification "$2"} ${3:+$3} --id "$4" 2>&1); }
1690
+ b5 planned "" "" inc-0001-foundation >/dev/null 2>&1
1691
+ b5 built "" "--tasks-done 1 --tasks-total 1" inc-0001-foundation >/dev/null 2>&1
1692
+ b5 verified pass "" inc-0001-foundation >/dev/null 2>&1
1693
+ b5 planned "" "" inc-0002-core >/dev/null 2>&1
1694
+ b5 built "" "--tasks-done 1 --tasks-total 1" inc-0002-core >/dev/null 2>&1
1695
+ VBUILD=$(cd "$TMP" && $NODE "$STATE_JS" transition --to verified --verification pass --id inc-0002-core 2>&1)
1696
+ if echo "$VBUILD" | grep -q '"next_command": "/qualia-polish"'; then
1697
+ pass "build increment: last-phase verified(pass) → /qualia-polish (no operate regression)"
1698
+ else
1699
+ fail_case "build increment routing regression (FIX #1)" "out=$VBUILD"
1700
+ fi
1701
+
1702
+ # 75. UNRECOGNIZED_STATUS: an unknown status must NOT self-recommend /qualia
1703
+ # (no router loop) — nextCommand()'s default branch emits a diagnostic.
1704
+ # Corrupt STATE.md to a bogus status so the public `check` routes via default.
1705
+ TMP=$(_mktemp_native); TMP_DIRS+=("$TMP")
1706
+ (cd "$TMP" && git init -q 2>/dev/null; $NODE "$STATE_JS" init --project delta --total-phases 1 --phases '[{"name":"Core","goal":"x"}]' --force >/dev/null 2>&1)
1707
+ # Corrupt the status to an unknown value in STATE.md so cmdCheck routes via the default branch.
1708
+ $NODE -e "
1709
+ const fs=require('fs'), p='$TMP/.planning/STATE.md';
1710
+ let md=fs.readFileSync(p,'utf8');
1711
+ md=md.replace(/(\*\*Status\*\*:\s*)\w+/i,'\$1bogus_status').replace(/(^status:\s*)\w+/im,'\$1bogus_status');
1712
+ fs.writeFileSync(p,md);
1713
+ " 2>/dev/null
1714
+ UNK=$(cd "$TMP" && $NODE "$STATE_JS" check 2>&1)
1715
+ if echo "$UNK" | grep -q 'UNRECOGNIZED_STATUS' && ! echo "$UNK" | grep -q '"next_command": "/qualia"'; then
1716
+ pass "unknown status → UNRECOGNIZED_STATUS diagnostic (no /qualia self-recommend loop)"
1717
+ else
1718
+ fail_case "unrecognized status handling (FIX #1)" "out=$UNK"
1719
+ fi
1720
+
1721
+ # ─── Scope-drift gate (ITEM #5) ──────────────────────────
1722
+ # analyze-gate.js is now ENFORCED at the planned→built seam (was SKILL prose).
1723
+ # A strict-profile HIGH finding (a scope acceptance criterion the plan silently
1724
+ # dropped) refuses the advance and writes a trace; a clean plan advances; the
1725
+ # gate's own failure (unreadable contract) is fail-soft (does not block).
1726
+ echo ""
1727
+ echo "scope-drift gate (planned→built):"
1728
+
1729
+ # Build a phase-1 plan + contract that DROPS one of two scope acceptance criteria.
1730
+ # AC2 ("export compliance reconciliation spreadsheet to PDF") shares NO tokens
1731
+ # with the contract, so analyze-gate flags it HIGH (uncovered-scope-ac).
1732
+ make_drift_plan() {
1733
+ local dir="$1"
1734
+ printf '# Phase 1\n## Task 1 — Login\n**Done when:** Login form renders\n' > "$dir/.planning/phase-1-plan.md"
1735
+ cat > "$dir/.planning/phase-1-contract.json" <<'JSON'
1736
+ {"version":1,"phase":1,"goal":"Build login form","why":"auth","success_criteria":["Login form renders"],"tasks":[{"id":"T1","title":"Login form","wave":1,"depends_on":[],"persona":"none","files_modify":[],"files_create":[],"files_delete":[],"acceptance_criteria":["Login form renders"],"action":"Create login form","context_files":[],"verification":[]}]}
1737
+ JSON
1738
+ cat > "$dir/.planning/phase-1-context.md" <<'MD'
1739
+ ## Acceptance Criteria
1740
+ - AC1 — Login form renders
1741
+ - AC2 — Administrators can export quarterly compliance reconciliation spreadsheet to PDF
1742
+ MD
1743
+ mkdir -p "$dir/.planning/evidence"
1744
+ printf '{"ok":true,"checks":[]}\n' > "$dir/.planning/evidence/phase-1-contract-run.json"
1745
+ }
1746
+
1747
+ # A1. strict profile + HIGH drift finding → planned→built is REFUSED (SCOPE_DRIFT).
1748
+ TMP=$(make_project)
1749
+ make_drift_plan "$TMP"
1750
+ (cd "$TMP" && $NODE "$STATE_JS" transition --to planned --phase 1 >/dev/null 2>&1)
1751
+ TRACE_DIR="${QUALIA_HOME:-$HOME/.claude}/.qualia-traces"
1752
+ TRACE_FILE="$TRACE_DIR/$(date +%Y-%m-%d).jsonl"
1753
+ TRACE_BEFORE=0
1754
+ [ -f "$TRACE_FILE" ] && TRACE_BEFORE=$(grep -c '"hook":"scope-drift"' "$TRACE_FILE" 2>/dev/null || echo 0)
1755
+ OUT=$(cd "$TMP" && $NODE "$STATE_JS" transition --to built --phase 1 --tasks-done 1 --tasks-total 1 2>&1)
1756
+ EXIT=$?
1757
+ if [ "$EXIT" -ne 0 ] \
1758
+ && echo "$OUT" | grep -q '"error": "SCOPE_DRIFT"' \
1759
+ && echo "$OUT" | grep -q 'compliance reconciliation spreadsheet'; then
1760
+ pass "strict + HIGH scope-drift → planned→built REFUSED (offending dropped AC named)"
1761
+ else
1762
+ fail_case "strict scope-drift block" "exit=$EXIT out=$OUT"
1763
+ fi
1764
+
1765
+ # A2. the refusal also wrote a scope-drift 'block' trace (gate left a trail).
1766
+ TRACE_AFTER=0
1767
+ [ -f "$TRACE_FILE" ] && TRACE_AFTER=$(grep -c '"hook":"scope-drift"' "$TRACE_FILE" 2>/dev/null || echo 0)
1768
+ if [ "$TRACE_AFTER" -gt "$TRACE_BEFORE" ] \
1769
+ && grep -q '"hook":"scope-drift","result":"block"' "$TRACE_FILE" 2>/dev/null; then
1770
+ pass "scope-drift block writes a .qualia-traces entry"
1771
+ else
1772
+ fail_case "scope-drift trace written" "before=$TRACE_BEFORE after=$TRACE_AFTER"
1773
+ fi
1774
+
1775
+ # A3. --force overrides the scope-drift block (senior waiver / standard profile).
1776
+ OUT=$(cd "$TMP" && $NODE "$STATE_JS" transition --to built --phase 1 --tasks-done 1 --tasks-total 1 --force 2>&1)
1777
+ if echo "$OUT" | grep -q '"status": "built"'; then
1778
+ pass "scope-drift is forceable: --force advances past the block"
1779
+ else
1780
+ fail_case "scope-drift --force override" "out=$OUT"
1781
+ fi
1782
+
1783
+ # A4. a CLEAN plan (no dropped AC) advances planned→built normally.
1784
+ TMP=$(make_project)
1785
+ printf '# Phase 1\n## Task 1 — Login\n**Done when:** Login form renders\n' > "$TMP/.planning/phase-1-plan.md"
1786
+ cat > "$TMP/.planning/phase-1-contract.json" <<'JSON'
1787
+ {"version":1,"phase":1,"goal":"Login form renders","why":"auth","success_criteria":["Login form renders"],"tasks":[{"id":"T1","title":"Login form renders","wave":1,"depends_on":[],"persona":"none","files_modify":[],"files_create":[],"files_delete":[],"acceptance_criteria":["Login form renders"],"action":"Create login form renders","context_files":[],"verification":[]}]}
1788
+ JSON
1789
+ printf '## Acceptance Criteria\n- AC1 — Login form renders\n' > "$TMP/.planning/phase-1-context.md"
1790
+ (cd "$TMP" && $NODE "$STATE_JS" transition --to planned --phase 1 >/dev/null 2>&1)
1791
+ OUT=$(cd "$TMP" && $NODE "$STATE_JS" transition --to built --phase 1 --tasks-done 1 --tasks-total 1 2>&1)
1792
+ EXIT=$?
1793
+ if [ "$EXIT" -eq 0 ] && echo "$OUT" | grep -q '"status": "built"'; then
1794
+ pass "clean plan (scope covered) advances planned→built"
1795
+ else
1796
+ fail_case "clean plan advances" "exit=$EXIT out=$OUT"
1797
+ fi
1798
+
1799
+ # A5. FAIL-SOFT: when analyze-gate's own input is broken (unreadable contract),
1800
+ # the gate must NOT block the build — it fails soft and advances.
1801
+ TMP=$(make_project)
1802
+ make_drift_plan "$TMP"
1803
+ (cd "$TMP" && $NODE "$STATE_JS" transition --to planned --phase 1 >/dev/null 2>&1)
1804
+ # Corrupt the contract AFTER reaching planned so the drift gate can't parse it.
1805
+ echo 'NOT JSON {{{' > "$TMP/.planning/phase-1-contract.json"
1806
+ OUT=$(cd "$TMP" && $NODE "$STATE_JS" transition --to built --phase 1 --tasks-done 1 --tasks-total 1 2>&1)
1807
+ EXIT=$?
1808
+ if [ "$EXIT" -eq 0 ] \
1809
+ && echo "$OUT" | grep -q '"status": "built"' \
1810
+ && ! echo "$OUT" | grep -q 'SCOPE_DRIFT'; then
1811
+ pass "scope-drift gate is fail-soft: unreadable contract does NOT block the build"
1812
+ else
1813
+ fail_case "scope-drift fail-soft" "exit=$EXIT out=$OUT"
1814
+ fi
1815
+
1642
1816
  # ─── Summary ─────────────────────────────────────────────
1643
1817
  echo ""
1644
1818
  echo "=== Results: $PASS passed, $FAIL failed ==="