qualia-framework 7.2.1 → 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.
- package/.claude-plugin/marketplace.json +20 -0
- package/.claude-plugin/plugin.json +17 -0
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +56 -0
- package/CLAUDE.md +1 -1
- package/README.md +17 -4
- package/TROUBLESHOOTING.md +8 -7
- package/agents/verifier.md +1 -1
- package/bin/agent-status.js +115 -11
- package/bin/auto-report.js +15 -7
- package/bin/cli.js +173 -4
- package/bin/erp-retry.js +92 -8
- package/bin/install.js +134 -6
- package/bin/qualia-doctor.js +115 -1
- package/bin/state.js +102 -13
- package/bin/verify-panel.js +409 -0
- package/docs/onboarding.html +1 -1
- package/hooks/branch-guard.js +19 -5
- package/hooks/fawzi-approval-guard.js +16 -3
- package/hooks/hooks.json +60 -0
- package/hooks/migration-guard.js +143 -66
- package/hooks/session-start.js +27 -0
- package/package.json +3 -1
- package/skills/qualia/SKILL.md +26 -13
- package/skills/qualia-build/SKILL.md +20 -9
- package/skills/qualia-verify/SKILL.md +43 -5
- package/templates/instructions.md +2 -2
- package/tests/bin.test.sh +183 -0
- package/tests/hooks.test.sh +124 -0
- package/tests/install-smoke.test.sh +14 -0
- package/tests/instructions.test.sh +2 -2
- package/tests/lib.test.sh +149 -0
- package/tests/plugin-manifest.test.sh +168 -0
- package/tests/refs.test.sh +64 -0
- package/tests/run-all.sh +1 -0
- package/tests/state.test.sh +174 -0
- package/tests/verify-panel.test.sh +236 -0
|
@@ -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
|
package/tests/refs.test.sh
CHANGED
|
@@ -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
package/tests/state.test.sh
CHANGED
|
@@ -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 ==="
|