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/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
@@ -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")" "this file stays under 25 lines"
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")" "this file stays under 25 lines"
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 ]