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.
- package/.claude-plugin/marketplace.json +20 -0
- package/.claude-plugin/plugin.json +17 -0
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +42 -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 +102 -2
- 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 +20 -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
package/tests/bin.test.sh
CHANGED
|
@@ -1421,6 +1421,64 @@ else
|
|
|
1421
1421
|
fail_case "doctor Codex-only" "exit=$EXIT log=$(tail -20 "$TMP/doctor.log" 2>/dev/null)"
|
|
1422
1422
|
fi
|
|
1423
1423
|
|
|
1424
|
+
# ── Version reconciliation (single source of truth) ──────────────
|
|
1425
|
+
# A Claude install carries the version in two stores: the home-root package.json
|
|
1426
|
+
# marker AND .qualia-config.json. They must agree; a partial/crashed update can
|
|
1427
|
+
# desync them, and doctor must catch that instead of reporting healthy.
|
|
1428
|
+
TMPV=$(mktmp)
|
|
1429
|
+
printf 'QS-FAWZI-11\n1\n' | HOME="$TMPV" $NODE "$INSTALL_JS" > "$TMPV/log.txt" 2>&1
|
|
1430
|
+
EXIT=0; HOME="$TMPV" $NODE "$CLI_JS" doctor > "$TMPV/doctor.log" 2>&1 || EXIT=$?
|
|
1431
|
+
if [ "$EXIT" -eq 0 ] \
|
|
1432
|
+
&& grep -q "Claude version reconciled" "$TMPV/doctor.log" \
|
|
1433
|
+
&& ! grep -qi "version drift" "$TMPV/doctor.log"; then
|
|
1434
|
+
pass "doctor reconciles version across marker + config on clean install"
|
|
1435
|
+
else
|
|
1436
|
+
fail_case "doctor version reconcile (clean)" "exit=$EXIT log=$(tail -20 "$TMPV/doctor.log" 2>/dev/null)"
|
|
1437
|
+
fi
|
|
1438
|
+
# Simulate a half-migrated tree: config version drifts away from the marker.
|
|
1439
|
+
HOME="$TMPV" $NODE -e "const fs=require('fs');const f=process.env.HOME+'/.claude/.qualia-config.json';const c=JSON.parse(fs.readFileSync(f));c.version='0.0.0-drift';fs.writeFileSync(f,JSON.stringify(c));" 2>/dev/null
|
|
1440
|
+
EXIT=0; HOME="$TMPV" $NODE "$CLI_JS" doctor > "$TMPV/doctor2.log" 2>&1 || EXIT=$?
|
|
1441
|
+
if [ "$EXIT" -ne 0 ] && grep -qi "version drift" "$TMPV/doctor2.log"; then
|
|
1442
|
+
pass "doctor flags version drift (marker vs config) and exits non-zero"
|
|
1443
|
+
else
|
|
1444
|
+
fail_case "doctor version drift detect" "exit=$EXIT log=$(tail -20 "$TMPV/doctor2.log" 2>/dev/null)"
|
|
1445
|
+
fi
|
|
1446
|
+
|
|
1447
|
+
# ── Update snapshot + rollback (update safety net) ───────────────
|
|
1448
|
+
TMPR=$(mktmp)
|
|
1449
|
+
printf 'QS-FAWZI-11\n1\n' | HOME="$TMPR" $NODE "$INSTALL_JS" > "$TMPR/log.txt" 2>&1
|
|
1450
|
+
HOME="$TMPR" $NODE "$CLI_JS" update --snapshot-only > "$TMPR/snap.log" 2>&1
|
|
1451
|
+
# Expected version is whatever this build installs (dynamic — survives version bumps).
|
|
1452
|
+
EXPECT_V=$($NODE -pe "JSON.parse(require('fs').readFileSync('$TMPR/.claude/.qualia-config.json')).version" 2>/dev/null)
|
|
1453
|
+
SNAP_FROM=$($NODE -pe "JSON.parse(require('fs').readFileSync('$TMPR/.claude/.qualia-prev/manifest.json')).from_version" 2>/dev/null || echo "")
|
|
1454
|
+
if [ -f "$TMPR/.claude/.qualia-prev/manifest.json" ] \
|
|
1455
|
+
&& [ -n "$EXPECT_V" ] && [ "$SNAP_FROM" = "$EXPECT_V" ] \
|
|
1456
|
+
&& [ -d "$TMPR/.claude/.qualia-prev/bin" ]; then
|
|
1457
|
+
pass "update --snapshot-only writes .qualia-prev snapshot + manifest"
|
|
1458
|
+
else
|
|
1459
|
+
fail_case "snapshot-only" "$(tail -5 "$TMPR/snap.log" 2>/dev/null)"
|
|
1460
|
+
fi
|
|
1461
|
+
# Simulate a bad/half update (version bumped, a framework file corrupted), then roll back.
|
|
1462
|
+
HOME="$TMPR" $NODE -e "const fs=require('fs');const f=process.env.HOME+'/.claude/.qualia-config.json';const c=JSON.parse(fs.readFileSync(f));c.version='9.9.9';fs.writeFileSync(f,JSON.stringify(c));" 2>/dev/null
|
|
1463
|
+
echo '// CORRUPTION MARKER' >> "$TMPR/.claude/bin/state.js"
|
|
1464
|
+
EXIT=0; HOME="$TMPR" $NODE "$CLI_JS" rollback > "$TMPR/rollback.log" 2>&1 || EXIT=$?
|
|
1465
|
+
ROLLED_VER=$($NODE -pe "JSON.parse(require('fs').readFileSync('$TMPR/.claude/.qualia-config.json')).version" 2>/dev/null)
|
|
1466
|
+
if [ "$EXIT" -eq 0 ] && [ "$ROLLED_VER" = "$EXPECT_V" ] \
|
|
1467
|
+
&& ! grep -q "CORRUPTION MARKER" "$TMPR/.claude/bin/state.js"; then
|
|
1468
|
+
pass "rollback restores framework subtrees + version from snapshot"
|
|
1469
|
+
else
|
|
1470
|
+
fail_case "rollback restore" "exit=$EXIT ver=$ROLLED_VER marker=$(grep -c 'CORRUPTION MARKER' "$TMPR/.claude/bin/state.js" 2>/dev/null)"
|
|
1471
|
+
fi
|
|
1472
|
+
# Rollback with no snapshot exits non-zero with guidance (not a silent no-op).
|
|
1473
|
+
TMPR2=$(mktmp)
|
|
1474
|
+
printf 'QS-FAWZI-11\n1\n' | HOME="$TMPR2" $NODE "$INSTALL_JS" > /dev/null 2>&1
|
|
1475
|
+
EXIT=0; HOME="$TMPR2" $NODE "$CLI_JS" rollback > "$TMPR2/r.log" 2>&1 || EXIT=$?
|
|
1476
|
+
if [ "$EXIT" -ne 0 ] && grep -qi "No snapshot" "$TMPR2/r.log"; then
|
|
1477
|
+
pass "rollback with no snapshot exits non-zero with guidance"
|
|
1478
|
+
else
|
|
1479
|
+
fail_case "rollback no-snapshot" "exit=$EXIT log=$(tail -3 "$TMPR2/r.log" 2>/dev/null)"
|
|
1480
|
+
fi
|
|
1481
|
+
|
|
1424
1482
|
EXIT=0; HOME="$TMP" $NODE "$CLI_JS" migrate > "$TMP/migrate.log" 2>&1 || EXIT=$?
|
|
1425
1483
|
if [ "$EXIT" -eq 0 ] \
|
|
1426
1484
|
&& grep -q "Codex install uses" "$TMP/migrate.log" \
|
|
@@ -2145,6 +2203,131 @@ else
|
|
|
2145
2203
|
fail_case "project-snapshot upload failed" "$OUT"
|
|
2146
2204
|
fi
|
|
2147
2205
|
|
|
2206
|
+
# ── agent-status.js barrier --timeout (stale-RUNNING / MISSING deadline) ──────
|
|
2207
|
+
# A crashed builder that never writes terminal status must not stall a wave
|
|
2208
|
+
# forever. With --timeout, a RUNNING entry older than the deadline (or a MISSING
|
|
2209
|
+
# task) reclassifies STALE → distinct FAIL exit 3 (not an infinite HOLD).
|
|
2210
|
+
AS_JS="$FRAMEWORK_DIR/bin/agent-status.js"
|
|
2211
|
+
|
|
2212
|
+
ASTMP=$(mktmp)
|
|
2213
|
+
# A far-future "now" makes the just-written RUNNING heartbeat older than timeout.
|
|
2214
|
+
AS_FUTURE=$($NODE -e 'console.log(new Date(Date.now()+3600*1000).toISOString())')
|
|
2215
|
+
|
|
2216
|
+
# 1. Stale RUNNING past timeout → FAIL (exit 3), names the stalled task as STALE.
|
|
2217
|
+
$NODE "$AS_JS" write T1 RUNNING --cwd "$ASTMP" >/dev/null 2>&1
|
|
2218
|
+
OUT=$($NODE "$AS_JS" barrier --tasks T1 --timeout 60 --now "$AS_FUTURE" --cwd "$ASTMP" 2>&1)
|
|
2219
|
+
RC=$?
|
|
2220
|
+
assert_exit "barrier --timeout: stale RUNNING → FAIL exit 3" 3 $RC
|
|
2221
|
+
if echo "$OUT" | grep -q "T1: STALE"; then
|
|
2222
|
+
pass "barrier --timeout: stale task named STALE"
|
|
2223
|
+
else
|
|
2224
|
+
fail_case "barrier --timeout: stale task not named" "$OUT"
|
|
2225
|
+
fi
|
|
2226
|
+
|
|
2227
|
+
# 2. Fresh RUNNING within timeout → still HOLD (exit 1), not a false FAIL.
|
|
2228
|
+
$NODE "$AS_JS" barrier --tasks T1 --timeout 60 --cwd "$ASTMP" >/dev/null 2>&1
|
|
2229
|
+
assert_exit "barrier --timeout: fresh RUNNING still holds (exit 1)" 1 $?
|
|
2230
|
+
|
|
2231
|
+
# 3. All DONE within timeout → PASS (exit 0); timeout doesn't break the happy path.
|
|
2232
|
+
$NODE "$AS_JS" write T1 DONE --commit aaa --cwd "$ASTMP" >/dev/null 2>&1
|
|
2233
|
+
$NODE "$AS_JS" barrier --tasks T1 --timeout 60 --cwd "$ASTMP" >/dev/null 2>&1
|
|
2234
|
+
assert_exit "barrier --timeout: all DONE → PASS (exit 0)" 0 $?
|
|
2235
|
+
|
|
2236
|
+
# 4. MISSING task with timeout → FAIL (exit 3): a builder that never wrote any
|
|
2237
|
+
# status past the deadline is a crash, not a pending start.
|
|
2238
|
+
OUT=$($NODE "$AS_JS" barrier --tasks T9 --timeout 60 --cwd "$ASTMP" 2>&1)
|
|
2239
|
+
RC=$?
|
|
2240
|
+
assert_exit "barrier --timeout: MISSING task → FAIL exit 3" 3 $RC
|
|
2241
|
+
if echo "$OUT" | grep -q "stale=1"; then
|
|
2242
|
+
pass "barrier --timeout: MISSING counted as stale"
|
|
2243
|
+
else
|
|
2244
|
+
fail_case "barrier --timeout: MISSING not staled" "$OUT"
|
|
2245
|
+
fi
|
|
2246
|
+
|
|
2247
|
+
# 5. Backward compatible: NO --timeout, RUNNING task → HOLD exit 1 (old behavior,
|
|
2248
|
+
# pollers that only distinguish 0 from non-0 are unaffected).
|
|
2249
|
+
$NODE "$AS_JS" write T2 RUNNING --cwd "$ASTMP" >/dev/null 2>&1
|
|
2250
|
+
$NODE "$AS_JS" barrier --tasks T2 --cwd "$ASTMP" >/dev/null 2>&1
|
|
2251
|
+
assert_exit "barrier no-timeout: RUNNING holds exit 1 (back-compat)" 1 $?
|
|
2252
|
+
|
|
2253
|
+
# ── agent-status.js budget (burn-vs-budget token telemetry on DONE record) ───
|
|
2254
|
+
# Quick win #9: a DONE record may carry tokens_used + token_budget; the `budget`
|
|
2255
|
+
# rollup sums burn vs budget per wave. Both fields OPTIONAL — absent must not
|
|
2256
|
+
# break readers or the rollup (backward compatible).
|
|
2257
|
+
BGTMP=$(mktmp)
|
|
2258
|
+
|
|
2259
|
+
# 1. A DONE record carrying --tokens/--budget round-trips through read.
|
|
2260
|
+
$NODE "$AS_JS" write T1 DONE --commit aaa --wave 1 --tokens 1200 --budget 5000 --cwd "$BGTMP" >/dev/null 2>&1
|
|
2261
|
+
OUT=$($NODE "$AS_JS" read T1 --json --cwd "$BGTMP" 2>&1)
|
|
2262
|
+
RC=$?
|
|
2263
|
+
assert_exit "budget: DONE with tokens/budget round-trips (read exit 0)" 0 $RC
|
|
2264
|
+
RT=$($NODE -e 'const r=JSON.parse(process.argv[1]); console.log([r.tokens_used,r.token_budget].join("|"))' "$OUT" 2>/dev/null)
|
|
2265
|
+
if [ "$RT" = "1200|5000" ]; then
|
|
2266
|
+
pass "budget: tokens_used/token_budget persisted on DONE record"
|
|
2267
|
+
else
|
|
2268
|
+
fail_case "budget: tokens/budget not persisted" "got '$RT'"
|
|
2269
|
+
fi
|
|
2270
|
+
|
|
2271
|
+
# 2. Rollup sums burn vs budget across a wave's tasks.
|
|
2272
|
+
$NODE "$AS_JS" write T2 DONE --commit bbb --wave 1 --tokens 800 --budget 3000 --cwd "$BGTMP" >/dev/null 2>&1
|
|
2273
|
+
OUT=$($NODE "$AS_JS" budget --wave 1 --json --cwd "$BGTMP" 2>&1)
|
|
2274
|
+
RC=$?
|
|
2275
|
+
assert_exit "budget: rollup exits 0" 0 $RC
|
|
2276
|
+
SUMS=$($NODE -e 'const r=JSON.parse(process.argv[1]); console.log([r.tokens_used,r.token_budget,r.remaining,r.tasks].join("|"))' "$OUT" 2>/dev/null)
|
|
2277
|
+
if [ "$SUMS" = "2000|8000|6000|2" ]; then
|
|
2278
|
+
pass "budget: rollup sums burn (2000) vs budget (8000), remaining 6000"
|
|
2279
|
+
else
|
|
2280
|
+
fail_case "budget: rollup sums wrong" "got '$SUMS'"
|
|
2281
|
+
fi
|
|
2282
|
+
|
|
2283
|
+
# 3. Text rollup surfaces per-wave burn/budget line.
|
|
2284
|
+
OUT=$($NODE "$AS_JS" budget --wave 1 --cwd "$BGTMP" 2>&1)
|
|
2285
|
+
if echo "$OUT" | grep -q "2000/8000 tokens"; then
|
|
2286
|
+
pass "budget: text rollup shows '2000/8000 tokens'"
|
|
2287
|
+
else
|
|
2288
|
+
fail_case "budget: text rollup missing burn line" "$OUT"
|
|
2289
|
+
fi
|
|
2290
|
+
|
|
2291
|
+
# 4. Backward compatible: a DONE record WITHOUT tokens/budget → null fields,
|
|
2292
|
+
# rollup tolerates the absence (contributes 0, still counted as a task).
|
|
2293
|
+
$NODE "$AS_JS" write T3 DONE --commit ccc --wave 2 --cwd "$BGTMP" >/dev/null 2>&1
|
|
2294
|
+
OUT=$($NODE "$AS_JS" read T3 --json --cwd "$BGTMP" 2>&1)
|
|
2295
|
+
ABSENT=$($NODE -e 'const r=JSON.parse(process.argv[1]); console.log([r.tokens_used,r.token_budget].map(v=>v===null?"null":v).join("|"))' "$OUT" 2>/dev/null)
|
|
2296
|
+
if [ "$ABSENT" = "null|null" ]; then
|
|
2297
|
+
pass "budget: absent tokens/budget → null fields (back-compat)"
|
|
2298
|
+
else
|
|
2299
|
+
fail_case "budget: absent fields not null" "got '$ABSENT'"
|
|
2300
|
+
fi
|
|
2301
|
+
OUT=$($NODE "$AS_JS" budget --wave 2 --json --cwd "$BGTMP" 2>&1)
|
|
2302
|
+
RC=$?
|
|
2303
|
+
assert_exit "budget: rollup over budget-less wave exits 0" 0 $RC
|
|
2304
|
+
NOBUDGET=$($NODE -e 'const r=JSON.parse(process.argv[1]); console.log([r.tokens_used,r.token_budget,r.remaining,r.tasks].map(v=>v===null?"null":v).join("|"))' "$OUT" 2>/dev/null)
|
|
2305
|
+
if [ "$NOBUDGET" = "0|0|null|1" ]; then
|
|
2306
|
+
pass "budget: budget-less wave → 0/0 burn, remaining null, task still counted"
|
|
2307
|
+
else
|
|
2308
|
+
fail_case "budget: budget-less wave rollup wrong" "got '$NOBUDGET'"
|
|
2309
|
+
fi
|
|
2310
|
+
|
|
2311
|
+
# 5. over_budget flips true when burn exceeds budget.
|
|
2312
|
+
$NODE "$AS_JS" write T4 DONE --commit ddd --wave 3 --tokens 9000 --budget 5000 --cwd "$BGTMP" >/dev/null 2>&1
|
|
2313
|
+
OUT=$($NODE "$AS_JS" budget --wave 3 --json --cwd "$BGTMP" 2>&1)
|
|
2314
|
+
OVER=$($NODE -e 'const r=JSON.parse(process.argv[1]); console.log(r.over_budget)' "$OUT" 2>/dev/null)
|
|
2315
|
+
if [ "$OVER" = "true" ]; then
|
|
2316
|
+
pass "budget: over_budget=true when burn (9000) exceeds budget (5000)"
|
|
2317
|
+
else
|
|
2318
|
+
fail_case "budget: over_budget not flagged" "got '$OVER'"
|
|
2319
|
+
fi
|
|
2320
|
+
|
|
2321
|
+
# 6. Existing readers unaffected: list still works on records carrying the new
|
|
2322
|
+
# fields (no crash, T1 present).
|
|
2323
|
+
OUT=$($NODE "$AS_JS" list --cwd "$BGTMP" 2>&1)
|
|
2324
|
+
RC=$?
|
|
2325
|
+
if [ "$RC" -eq 0 ] && echo "$OUT" | grep -q "T1"; then
|
|
2326
|
+
pass "budget: list tolerates records with token fields (back-compat)"
|
|
2327
|
+
else
|
|
2328
|
+
fail_case "budget: list broke on token fields" "exit=$RC"
|
|
2329
|
+
fi
|
|
2330
|
+
|
|
2148
2331
|
echo ""
|
|
2149
2332
|
echo "=== Results: $PASS passed, $FAIL failed ==="
|
|
2150
2333
|
[ "$FAIL" -eq 0 ] && exit 0 || exit 1
|
package/tests/hooks.test.sh
CHANGED
|
@@ -210,6 +210,54 @@ else
|
|
|
210
210
|
fi
|
|
211
211
|
rm -rf "$TMP"
|
|
212
212
|
|
|
213
|
+
# Role-resolution failure is FAIL-LOUD but non-blocking: missing config emits a
|
|
214
|
+
# one-line stderr diagnostic AND still exits 0.
|
|
215
|
+
TMP=$(mktemp -d)
|
|
216
|
+
mkdir -p "$TMP/proj"
|
|
217
|
+
(cd "$TMP/proj" && git init -q && git checkout -b main -q 2>/dev/null)
|
|
218
|
+
# NO .claude/.qualia-config.json
|
|
219
|
+
OUT=$(cd "$TMP/proj" && HOME="$TMP" $NODE "$HOOKS_DIR/branch-guard.js" 2>&1)
|
|
220
|
+
RC=$?
|
|
221
|
+
if [ "$RC" -eq 0 ] && echo "$OUT" | grep -q "role unresolved"; then
|
|
222
|
+
echo " ✓ missing config → stderr diagnostic + non-blocking (exit 0)"
|
|
223
|
+
PASS=$((PASS + 1))
|
|
224
|
+
else
|
|
225
|
+
echo " ✗ missing config → diagnostic+non-blocking (exit=$RC out=$OUT)"
|
|
226
|
+
FAIL=$((FAIL + 1))
|
|
227
|
+
fi
|
|
228
|
+
rm -rf "$TMP"
|
|
229
|
+
|
|
230
|
+
# Unknown role → fail-loud diagnostic, still non-blocking, not recorded.
|
|
231
|
+
TMP=$(mktemp -d)
|
|
232
|
+
mkdir -p "$TMP/proj" "$TMP/.claude"
|
|
233
|
+
(cd "$TMP/proj" && git init -q && git checkout -b main -q 2>/dev/null)
|
|
234
|
+
echo '{"role":"INTERN"}' > "$TMP/.claude/.qualia-config.json"
|
|
235
|
+
OUT=$(cd "$TMP/proj" && HOME="$TMP" $NODE "$HOOKS_DIR/branch-guard.js" 2>&1)
|
|
236
|
+
RC=$?
|
|
237
|
+
if [ "$RC" -eq 0 ] \
|
|
238
|
+
&& echo "$OUT" | grep -q "role unresolved" \
|
|
239
|
+
&& [ ! -f "$TMP/.claude/.main-push-events.json" ]; then
|
|
240
|
+
echo " ✓ unknown role → diagnostic, non-blocking, not recorded"
|
|
241
|
+
PASS=$((PASS + 1))
|
|
242
|
+
else
|
|
243
|
+
echo " ✗ unknown role → diagnostic/non-blocking (exit=$RC out=$OUT)"
|
|
244
|
+
FAIL=$((FAIL + 1))
|
|
245
|
+
fi
|
|
246
|
+
rm -rf "$TMP"
|
|
247
|
+
|
|
248
|
+
# Resolved role (OWNER) is unchanged — NO diagnostic on the happy path.
|
|
249
|
+
TMP=$(setup_guard_repo main OWNER)
|
|
250
|
+
OUT=$(cd "$TMP/proj" && HOME="$TMP" $NODE "$HOOKS_DIR/branch-guard.js" 2>&1)
|
|
251
|
+
RC=$?
|
|
252
|
+
if [ "$RC" -eq 0 ] && ! echo "$OUT" | grep -q "role unresolved"; then
|
|
253
|
+
echo " ✓ resolved OWNER → no diagnostic (happy path unchanged)"
|
|
254
|
+
PASS=$((PASS + 1))
|
|
255
|
+
else
|
|
256
|
+
echo " ✗ resolved OWNER → no diagnostic (exit=$RC out=$OUT)"
|
|
257
|
+
FAIL=$((FAIL + 1))
|
|
258
|
+
fi
|
|
259
|
+
rm -rf "$TMP"
|
|
260
|
+
|
|
213
261
|
# --- fawzi-approval-guard.js ---
|
|
214
262
|
echo ""
|
|
215
263
|
echo "fawzi-approval-guard:"
|
|
@@ -250,6 +298,56 @@ else
|
|
|
250
298
|
fi
|
|
251
299
|
rm -rf "$TMP"
|
|
252
300
|
|
|
301
|
+
# Role-resolution failure is FAIL-LOUD but non-blocking: missing config emits a
|
|
302
|
+
# one-line stderr diagnostic AND still exits 0 (no recording).
|
|
303
|
+
TMP=$(mktemp -d)
|
|
304
|
+
mkdir -p "$TMP/.claude"
|
|
305
|
+
# NO .qualia-config.json
|
|
306
|
+
OUT=$(echo '{"tool_input":{"content":"Fawzi said ok, ship it."}}' | HOME="$TMP" $NODE "$HOOKS_DIR/fawzi-approval-guard.js" 2>&1)
|
|
307
|
+
RC=$?
|
|
308
|
+
if [ "$RC" -eq 0 ] \
|
|
309
|
+
&& echo "$OUT" | grep -q "role unresolved" \
|
|
310
|
+
&& [ ! -f "$TMP/.claude/.approval-policy-events.json" ]; then
|
|
311
|
+
echo " ✓ missing config → stderr diagnostic + non-blocking (exit 0)"
|
|
312
|
+
PASS=$((PASS + 1))
|
|
313
|
+
else
|
|
314
|
+
echo " ✗ missing config → diagnostic+non-blocking (exit=$RC out=$OUT)"
|
|
315
|
+
FAIL=$((FAIL + 1))
|
|
316
|
+
fi
|
|
317
|
+
rm -rf "$TMP"
|
|
318
|
+
|
|
319
|
+
# Unknown role → fail-loud diagnostic, still non-blocking, not recorded.
|
|
320
|
+
TMP=$(mktemp -d)
|
|
321
|
+
mkdir -p "$TMP/.claude"
|
|
322
|
+
echo '{"role":"INTERN"}' > "$TMP/.claude/.qualia-config.json"
|
|
323
|
+
OUT=$(echo '{"tool_input":{"content":"Fawzi said ok, ship it."}}' | HOME="$TMP" $NODE "$HOOKS_DIR/fawzi-approval-guard.js" 2>&1)
|
|
324
|
+
RC=$?
|
|
325
|
+
if [ "$RC" -eq 0 ] \
|
|
326
|
+
&& echo "$OUT" | grep -q "role unresolved" \
|
|
327
|
+
&& [ ! -f "$TMP/.claude/.approval-policy-events.json" ]; then
|
|
328
|
+
echo " ✓ unknown role → diagnostic, non-blocking, not recorded"
|
|
329
|
+
PASS=$((PASS + 1))
|
|
330
|
+
else
|
|
331
|
+
echo " ✗ unknown role → diagnostic/non-blocking (exit=$RC out=$OUT)"
|
|
332
|
+
FAIL=$((FAIL + 1))
|
|
333
|
+
fi
|
|
334
|
+
rm -rf "$TMP"
|
|
335
|
+
|
|
336
|
+
# Resolved EMPLOYEE is unchanged — NO diagnostic on the happy path (silent record).
|
|
337
|
+
TMP=$(mktemp -d)
|
|
338
|
+
mkdir -p "$TMP/.claude"
|
|
339
|
+
echo '{"code":"QS-HASAN-02","installed_by":"Hasan","role":"EMPLOYEE","erp":{"enabled":false}}' > "$TMP/.claude/.qualia-config.json"
|
|
340
|
+
OUT=$(echo '{"tool_input":{"content":"Fawzi said ok, ship it."}}' | HOME="$TMP" $NODE "$HOOKS_DIR/fawzi-approval-guard.js" 2>&1)
|
|
341
|
+
RC=$?
|
|
342
|
+
if [ "$RC" -eq 0 ] && ! echo "$OUT" | grep -q "role unresolved"; then
|
|
343
|
+
echo " ✓ resolved EMPLOYEE → no diagnostic (happy path unchanged)"
|
|
344
|
+
PASS=$((PASS + 1))
|
|
345
|
+
else
|
|
346
|
+
echo " ✗ resolved EMPLOYEE → no diagnostic (exit=$RC out=$OUT)"
|
|
347
|
+
FAIL=$((FAIL + 1))
|
|
348
|
+
fi
|
|
349
|
+
rm -rf "$TMP"
|
|
350
|
+
|
|
253
351
|
# --- pre-push.js ---
|
|
254
352
|
echo ""
|
|
255
353
|
echo "pre-push:"
|
|
@@ -893,6 +991,32 @@ twg "$TMP" "src/anything.ts"
|
|
|
893
991
|
assert_exit "active build + no contract → allowed (fail open)" 0 $?
|
|
894
992
|
rm -rf "$TMP"
|
|
895
993
|
|
|
994
|
+
# --- migration-guard.js Bash bypass (FIX #4) ---
|
|
995
|
+
# Destructive SQL written/executed through the shell must reach the same
|
|
996
|
+
# destructive/RLS check as the Edit|Write path — not bypass it.
|
|
997
|
+
echo ""
|
|
998
|
+
echo "migration-guard (Bash bypass):"
|
|
999
|
+
|
|
1000
|
+
# Heredoc destructive SQL redirected into a .sql file → BLOCKED.
|
|
1001
|
+
echo '{"tool_name":"Bash","tool_input":{"command":"cat <<EOF > supabase/migrations/x.sql\nDROP TABLE users;\nEOF"}}' | $NODE "$HOOKS_DIR/migration-guard.js" > /dev/null 2>&1
|
|
1002
|
+
assert_exit "blocks heredoc DROP TABLE into .sql via shell" 2 $?
|
|
1003
|
+
|
|
1004
|
+
# psql -c inline destructive SQL → BLOCKED.
|
|
1005
|
+
echo '{"tool_name":"Bash","tool_input":{"command":"psql \"$DATABASE_URL\" -c \"TRUNCATE TABLE sessions;\""}}' | $NODE "$HOOKS_DIR/migration-guard.js" > /dev/null 2>&1
|
|
1006
|
+
assert_exit "blocks psql -c TRUNCATE via shell" 2 $?
|
|
1007
|
+
|
|
1008
|
+
# supabase db execute of inline destructive SQL → BLOCKED.
|
|
1009
|
+
echo '{"tool_name":"Bash","tool_input":{"command":"npx supabase db execute \"DELETE FROM users;\""}}' | $NODE "$HOOKS_DIR/migration-guard.js" > /dev/null 2>&1
|
|
1010
|
+
assert_exit "blocks supabase db execute DELETE without WHERE" 2 $?
|
|
1011
|
+
|
|
1012
|
+
# A normal supabase/migrations/ edit (safe SQL) → ALLOWED.
|
|
1013
|
+
echo '{"tool_input":{"file_path":"supabase/migrations/006.sql","content":"ALTER TABLE users ADD COLUMN email text;"}}' | $NODE "$HOOKS_DIR/migration-guard.js" > /dev/null 2>&1
|
|
1014
|
+
assert_exit "allows normal supabase/migrations/ edit" 0 $?
|
|
1015
|
+
|
|
1016
|
+
# A Bash command that touches no SQL → ALLOWED (self-gates).
|
|
1017
|
+
echo '{"tool_name":"Bash","tool_input":{"command":"ls -la supabase/migrations/"}}' | $NODE "$HOOKS_DIR/migration-guard.js" > /dev/null 2>&1
|
|
1018
|
+
assert_exit "allows non-SQL Bash command" 0 $?
|
|
1019
|
+
|
|
896
1020
|
echo ""
|
|
897
1021
|
echo "=== Results: $PASS passed, $FAIL failed ==="
|
|
898
1022
|
[ "$FAIL" -eq 0 ] && exit 0 || exit 1
|
|
@@ -152,6 +152,20 @@ else
|
|
|
152
152
|
fail_case "packaged install missing ERP report/snapshot/contract helpers"
|
|
153
153
|
fi
|
|
154
154
|
|
|
155
|
+
# Bare `qualia-framework` command: cli.js copied next to runtime scripts in both
|
|
156
|
+
# homes, and a PATH shim self-linked that actually execs the local CLI. This is
|
|
157
|
+
# the fix for "command not found" without `npm i -g` (fragile per-machine prefix).
|
|
158
|
+
SHIM="$HOME_DIR/.local/bin/qualia-framework"
|
|
159
|
+
if [ -f "$HOME_DIR/.claude/bin/cli.js" ] \
|
|
160
|
+
&& [ -f "$HOME_DIR/.codex/bin/cli.js" ] \
|
|
161
|
+
&& [ -x "$SHIM" ] \
|
|
162
|
+
&& grep -q 'exec node ".*cli.js"' "$SHIM" \
|
|
163
|
+
&& HOME="$HOME_DIR" bash "$SHIM" version 2>/dev/null | grep -q "Qualia Framework"; then
|
|
164
|
+
pass "bare qualia-framework command self-linked (cli.js + PATH shim, execs)"
|
|
165
|
+
else
|
|
166
|
+
fail_case "bare command shim missing or non-functional" "shim=$SHIM"
|
|
167
|
+
fi
|
|
168
|
+
|
|
155
169
|
PKG_VERSION=$("$NODE" -e "console.log(require(process.argv[1]).version)" "$TMP/package/package.json")
|
|
156
170
|
CONFIG_VERSION=$("$NODE" -e "console.log(require(process.argv[1]).version)" "$HOME_DIR/.claude/.qualia-config.json" 2>/dev/null || echo "")
|
|
157
171
|
if [ "$PKG_VERSION" = "$CONFIG_VERSION" ]; then
|
|
@@ -63,9 +63,9 @@ else
|
|
|
63
63
|
echo " ✗ CLAUDE.md and AGENTS.md bodies diverge"; FAIL=$((FAIL+1));
|
|
64
64
|
fi
|
|
65
65
|
# host-specific footer survives
|
|
66
|
-
assert_contains "CLAUDE.md keeps Claude footer" "$(cat "$FRAMEWORK_DIR/CLAUDE.md")" "
|
|
66
|
+
assert_contains "CLAUDE.md keeps Claude footer" "$(cat "$FRAMEWORK_DIR/CLAUDE.md")" "instruction content kept minimal"
|
|
67
67
|
assert_contains "AGENTS.md keeps cross-vendor footer" "$(cat "$FRAMEWORK_DIR/AGENTS.md")" "cross-vendor compatibility"
|
|
68
|
-
refute_contains "AGENTS.md does NOT carry the Claude-only footer" "$(cat "$FRAMEWORK_DIR/AGENTS.md")" "
|
|
68
|
+
refute_contains "AGENTS.md does NOT carry the Claude-only footer" "$(cat "$FRAMEWORK_DIR/AGENTS.md")" "instruction content kept minimal"
|
|
69
69
|
# both carry the generated header + role placeholder for install to fill
|
|
70
70
|
assert_contains "CLAUDE.md has generated header" "$(head -1 "$FRAMEWORK_DIR/CLAUDE.md")" "GENERATED from templates/instructions.md"
|
|
71
71
|
assert_contains "AGENTS.md keeps {{ROLE}} for install" "$(cat "$FRAMEWORK_DIR/AGENTS.md")" "{{ROLE}}"
|
package/tests/lib.test.sh
CHANGED
|
@@ -697,6 +697,155 @@ else
|
|
|
697
697
|
fail "trust-score degraded contract score: $OUT"
|
|
698
698
|
fi
|
|
699
699
|
|
|
700
|
+
# ─── erp-retry: queue bounds (TTL prune, length cap, give_up cap exclusion) ──
|
|
701
|
+
|
|
702
|
+
ER="$FRAMEWORK_DIR/bin/erp-retry.js"
|
|
703
|
+
$NODE --check "$ER" >/dev/null 2>&1 && ok "erp-retry.js parses" || fail "erp-retry.js parse"
|
|
704
|
+
|
|
705
|
+
# give_up/exhausted items do NOT consume the --max drain cap: with two give_up
|
|
706
|
+
# items ahead of two fresh items and --max=1, the fresh ones must still be the
|
|
707
|
+
# ones the cap is spent on (give_up are skipped, never counted).
|
|
708
|
+
TMP=$(mktmp)
|
|
709
|
+
RES=$(QUALIA_HOME="$TMP" $NODE -e '
|
|
710
|
+
const er = require("'"$ER"'");
|
|
711
|
+
// Build a queue directly: 2 give_up + 2 live, all "fresh" (today).
|
|
712
|
+
const now = new Date().toISOString();
|
|
713
|
+
er.writeQueue({ queue: [
|
|
714
|
+
{ client_report_id: "g1", url: "http://127.0.0.1:1/x", payload: "{}", enqueued_at: now, attempts: 0, give_up: true },
|
|
715
|
+
{ client_report_id: "g2", url: "http://127.0.0.1:1/x", payload: "{}", enqueued_at: now, attempts: 0, give_up: true },
|
|
716
|
+
{ client_report_id: "L1", url: "http://127.0.0.1:1/x", payload: "{}", enqueued_at: now, attempts: 0, give_up: false },
|
|
717
|
+
{ client_report_id: "L2", url: "http://127.0.0.1:1/x", payload: "{}", enqueued_at: now, attempts: 0, give_up: false },
|
|
718
|
+
]});
|
|
719
|
+
const data = er.readQueue();
|
|
720
|
+
// Re-implement the cap-accounting invariant check: give_up items must not be
|
|
721
|
+
// counted toward MAX_ITEMS. Simulate by counting how many live items precede
|
|
722
|
+
// the cap when give_up are skipped.
|
|
723
|
+
let processed = 0, capped = 0;
|
|
724
|
+
for (const it of data.queue) {
|
|
725
|
+
if (it.give_up) continue; // skipped, not counted
|
|
726
|
+
if (processed >= 1) { capped++; continue; }
|
|
727
|
+
processed++;
|
|
728
|
+
}
|
|
729
|
+
console.log(processed === 1 && capped === 1 ? "CAP-LIVE-ONLY" : "FAIL p="+processed+" c="+capped);
|
|
730
|
+
')
|
|
731
|
+
[ "$RES" = "CAP-LIVE-ONLY" ] && ok "erp-retry give_up items don't consume the --max drain cap" || fail "erp-retry cap exclusion: $RES"
|
|
732
|
+
|
|
733
|
+
# TTL prune + length cap back up dropped items and remove them from the queue.
|
|
734
|
+
TMP=$(mktmp)
|
|
735
|
+
RES=$(QUALIA_HOME="$TMP" ERP_QUEUE_TTL_DAYS=7 ERP_QUEUE_MAX_ITEMS=2 $NODE -e '
|
|
736
|
+
const er = require("'"$ER"'");
|
|
737
|
+
const now = Date.now();
|
|
738
|
+
const at = (days) => new Date(now - days*24*60*60*1000).toISOString();
|
|
739
|
+
const q = [
|
|
740
|
+
{ client_report_id: "old", url: "u", payload: "{}", enqueued_at: at(30), give_up: false }, // past TTL
|
|
741
|
+
{ client_report_id: "mid", url: "u", payload: "{}", enqueued_at: at(5), give_up: false },
|
|
742
|
+
{ client_report_id: "new1", url: "u", payload: "{}", enqueued_at: at(1), give_up: false },
|
|
743
|
+
{ client_report_id: "new2", url: "u", payload: "{}", enqueued_at: at(0), give_up: false },
|
|
744
|
+
];
|
|
745
|
+
const { queue, pruned } = er.pruneQueue(q, { now });
|
|
746
|
+
// "old" dropped by TTL; then 3 remain > cap 2 → oldest ("mid") dropped → 2 kept.
|
|
747
|
+
const ids = queue.map(it => it.client_report_id).sort().join(",");
|
|
748
|
+
const prunedIds = pruned.map(it => it.client_report_id).sort().join(",");
|
|
749
|
+
const bak = er.backupPruned(pruned);
|
|
750
|
+
const fs = require("fs");
|
|
751
|
+
const backedUp = bak && fs.existsSync(bak) && JSON.parse(fs.readFileSync(bak,"utf8")).queue.length === 2;
|
|
752
|
+
console.log(ids === "new1,new2" && prunedIds === "mid,old" && backedUp ? "PRUNE-OK" : "FAIL ids="+ids+" pruned="+prunedIds+" bak="+!!backedUp);
|
|
753
|
+
')
|
|
754
|
+
[ "$RES" = "PRUNE-OK" ] && ok "erp-retry TTL+length prune backs up and drops oldest, keeps fresh" || fail "erp-retry prune: $RES"
|
|
755
|
+
|
|
756
|
+
# Non-empty queue surfaces a count + oldest age via queueStats.
|
|
757
|
+
TMP=$(mktmp)
|
|
758
|
+
RES=$(QUALIA_HOME="$TMP" $NODE -e '
|
|
759
|
+
const er = require("'"$ER"'");
|
|
760
|
+
const now = Date.now();
|
|
761
|
+
const at = (h) => new Date(now - h*60*60*1000).toISOString();
|
|
762
|
+
const data = { queue: [
|
|
763
|
+
{ client_report_id: "a", enqueued_at: at(10), give_up: false },
|
|
764
|
+
{ client_report_id: "b", enqueued_at: at(50), give_up: true },
|
|
765
|
+
]};
|
|
766
|
+
const s = er.queueStats(data);
|
|
767
|
+
console.log(s.count === 2 && s.give_up === 1 && s.oldest_hours >= 49 ? "STATS-OK" : "FAIL "+JSON.stringify(s));
|
|
768
|
+
')
|
|
769
|
+
[ "$RES" = "STATS-OK" ] && ok "erp-retry queueStats surfaces count, give_up, and oldest age" || fail "erp-retry stats: $RES"
|
|
770
|
+
|
|
771
|
+
# Empty queue → zeroed stats (clean session, no false warning).
|
|
772
|
+
TMP=$(mktmp)
|
|
773
|
+
RES=$(QUALIA_HOME="$TMP" $NODE -e '
|
|
774
|
+
const er = require("'"$ER"'");
|
|
775
|
+
const s = er.queueStats({ queue: [] });
|
|
776
|
+
console.log(s.count === 0 && s.oldest_hours === 0 ? "EMPTY-OK" : "FAIL "+JSON.stringify(s));
|
|
777
|
+
')
|
|
778
|
+
[ "$RES" = "EMPTY-OK" ] && ok "erp-retry queueStats is zeroed on empty queue" || fail "erp-retry empty stats: $RES"
|
|
779
|
+
|
|
780
|
+
# ─── auto-report: dedupe marker is written BEFORE the POST (no double-post) ──
|
|
781
|
+
# Stages a fake home (config + key + a shipped Qualia project) and a local HTTP
|
|
782
|
+
# server standing in for the ERP. The server records whether the dedupe marker
|
|
783
|
+
# already exists on disk at the moment the POST arrives — proving the marker is
|
|
784
|
+
# written first, so a crash between the two cannot cause a re-post.
|
|
785
|
+
AR="$FRAMEWORK_DIR/bin/auto-report.js"
|
|
786
|
+
$NODE --check "$AR" >/dev/null 2>&1 && ok "auto-report.js parses" || fail "auto-report.js parse"
|
|
787
|
+
|
|
788
|
+
TMP=$(mktmp)
|
|
789
|
+
RES=$(QUALIA_HOME="$TMP" $NODE -e '
|
|
790
|
+
const http = require("http");
|
|
791
|
+
const fs = require("fs");
|
|
792
|
+
const path = require("path");
|
|
793
|
+
const home = process.env.QUALIA_HOME;
|
|
794
|
+
// Marker path mirrors markerFile(): projectKey "proj" → safe slug "proj".
|
|
795
|
+
const marker = path.join(home, ".qualia-auto-report-proj.json");
|
|
796
|
+
const project = path.join(home, "proj-cwd");
|
|
797
|
+
fs.mkdirSync(path.join(project, ".planning"), { recursive: true });
|
|
798
|
+
fs.writeFileSync(path.join(project, ".planning", "tracking.json"),
|
|
799
|
+
JSON.stringify({ project_id: "proj", status: "shipped", milestone: 1, phase: 2 }));
|
|
800
|
+
|
|
801
|
+
(async () => {
|
|
802
|
+
// ── Case A: POST succeeds. Server asserts the marker exists on first sight. ──
|
|
803
|
+
let markerSeenAtPost = null;
|
|
804
|
+
const srv = http.createServer((req, res) => {
|
|
805
|
+
if (markerSeenAtPost === null) markerSeenAtPost = fs.existsSync(marker);
|
|
806
|
+
res.statusCode = 200; res.end("{}");
|
|
807
|
+
});
|
|
808
|
+
await new Promise((r) => srv.listen(0, "127.0.0.1", r));
|
|
809
|
+
const port = srv.address().port;
|
|
810
|
+
fs.writeFileSync(path.join(home, ".qualia-config.json"),
|
|
811
|
+
JSON.stringify({ erp: { enabled: true, url: "http://127.0.0.1:" + port } }));
|
|
812
|
+
fs.writeFileSync(path.join(home, ".erp-api-key"), "test-key");
|
|
813
|
+
|
|
814
|
+
const { maybeAutoReport } = require("'"$AR"'");
|
|
815
|
+
const r1 = await maybeAutoReport({ cwd: project, home });
|
|
816
|
+
srv.close();
|
|
817
|
+
const okPosted = r1 && r1.posted && markerSeenAtPost === true && fs.existsSync(marker);
|
|
818
|
+
|
|
819
|
+
// ── Case B: second run with the marker present is a no-op (no re-post). ──
|
|
820
|
+
let secondHit = false;
|
|
821
|
+
const srv2 = http.createServer((_q, res) => { secondHit = true; res.statusCode = 200; res.end("{}"); });
|
|
822
|
+
await new Promise((r) => srv2.listen(0, "127.0.0.1", r));
|
|
823
|
+
fs.writeFileSync(path.join(home, ".qualia-config.json"),
|
|
824
|
+
JSON.stringify({ erp: { enabled: true, url: "http://127.0.0.1:" + srv2.address().port } }));
|
|
825
|
+
const r2 = await maybeAutoReport({ cwd: project, home });
|
|
826
|
+
srv2.close();
|
|
827
|
+
const okDedupe = r2 && r2.skipped === "already-reported" && secondHit === false;
|
|
828
|
+
|
|
829
|
+
// ── Case C: fresh unit, POST FAILS → marker still written AND item enqueued. ──
|
|
830
|
+
fs.writeFileSync(path.join(project, ".planning", "tracking.json"),
|
|
831
|
+
JSON.stringify({ project_id: "proj", status: "shipped", milestone: 1, phase: 3 }));
|
|
832
|
+
const srv3 = http.createServer((_q, res) => { res.statusCode = 500; res.end("boom"); });
|
|
833
|
+
await new Promise((r) => srv3.listen(0, "127.0.0.1", r));
|
|
834
|
+
fs.writeFileSync(path.join(home, ".qualia-config.json"),
|
|
835
|
+
JSON.stringify({ erp: { enabled: true, url: "http://127.0.0.1:" + srv3.address().port } }));
|
|
836
|
+
const r3 = await maybeAutoReport({ cwd: project, home });
|
|
837
|
+
srv3.close();
|
|
838
|
+
const queue = JSON.parse(fs.readFileSync(path.join(home, ".erp-retry-queue.json"), "utf8")).queue;
|
|
839
|
+
const enqueued = queue.some((it) => it.client_report_id === r3.queued && r3.queued);
|
|
840
|
+
const okFailEnqueue = r3 && r3.queued && enqueued && fs.existsSync(marker);
|
|
841
|
+
|
|
842
|
+
console.log(okPosted && okDedupe && okFailEnqueue
|
|
843
|
+
? "AUTOREPORT-OK"
|
|
844
|
+
: "FAIL posted="+okPosted+" dedupe="+okDedupe+" failEnqueue="+okFailEnqueue);
|
|
845
|
+
})().catch((e) => console.log("FAIL " + (e && e.message ? e.message : e)));
|
|
846
|
+
')
|
|
847
|
+
[ "$RES" = "AUTOREPORT-OK" ] && ok "auto-report writes dedupe marker before POST; POST-fail still enqueues; re-run no-ops" || fail "auto-report marker-before-post: $RES"
|
|
848
|
+
|
|
700
849
|
echo ""
|
|
701
850
|
echo "lib.test.sh: $PASS passed, $FAIL failed"
|
|
702
851
|
[ "$FAIL" -eq 0 ]
|