qualia-framework 6.9.2 → 6.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/AGENTS.md +8 -5
  2. package/CHANGELOG.md +208 -0
  3. package/CLAUDE.md +3 -1
  4. package/agents/roadmapper.md +16 -14
  5. package/agents/verifier.md +1 -1
  6. package/bin/agent-status.js +264 -0
  7. package/bin/analyze-gate.js +318 -0
  8. package/bin/branch-hygiene.js +135 -0
  9. package/bin/command-surface.js +2 -0
  10. package/bin/compile-instructions.js +82 -0
  11. package/bin/eval-runner.js +218 -0
  12. package/bin/host-adapters.js +72 -12
  13. package/bin/install.js +27 -17
  14. package/bin/last-report.js +207 -0
  15. package/bin/project-sync.js +315 -0
  16. package/bin/report-payload.js +7 -0
  17. package/bin/runtime-manifest.js +8 -0
  18. package/bin/state.js +257 -12
  19. package/bin/verify-panel.js +294 -0
  20. package/bin/wave-plan.js +211 -0
  21. package/docs/EMPLOYEE-QUICKSTART.md +3 -3
  22. package/docs/erp-contract.md +168 -0
  23. package/docs/qualia-manual.html +5 -5
  24. package/hooks/branch-guard.js +133 -63
  25. package/hooks/pre-deploy-gate.js +38 -0
  26. package/hooks/task-write-guard.js +165 -0
  27. package/package.json +3 -2
  28. package/rules/codex-goal.md +28 -26
  29. package/rules/infrastructure.md +1 -1
  30. package/skills/qualia/SKILL.md +6 -0
  31. package/skills/qualia-build/SKILL.md +39 -7
  32. package/skills/qualia-eval/SKILL.md +83 -0
  33. package/skills/qualia-feature/SKILL.md +20 -4
  34. package/skills/qualia-fix/SKILL.md +13 -1
  35. package/skills/qualia-milestone/SKILL.md +12 -6
  36. package/skills/qualia-new/REFERENCE.md +6 -4
  37. package/skills/qualia-new/SKILL.md +27 -15
  38. package/skills/qualia-plan/SKILL.md +2 -2
  39. package/skills/qualia-report/SKILL.md +10 -0
  40. package/skills/qualia-scope/SKILL.md +3 -3
  41. package/skills/qualia-ship/SKILL.md +37 -4
  42. package/skills/qualia-update/SKILL.md +100 -0
  43. package/skills/qualia-verify/SKILL.md +51 -24
  44. package/templates/instructions.md +32 -0
  45. package/templates/journey.md +2 -2
  46. package/templates/project-discovery.md +30 -23
  47. package/templates/requirements.md +7 -7
  48. package/tests/agent-status.test.sh +153 -0
  49. package/tests/analyze-gate.test.sh +170 -0
  50. package/tests/bin.test.sh +5 -4
  51. package/tests/branch-hygiene.test.sh +93 -0
  52. package/tests/eval-runner.test.sh +147 -0
  53. package/tests/hooks.test.sh +218 -17
  54. package/tests/install-smoke.test.sh +4 -3
  55. package/tests/instructions.test.sh +109 -0
  56. package/tests/last-report.test.sh +156 -0
  57. package/tests/lib.test.sh +2 -2
  58. package/tests/project-sync.test.sh +175 -0
  59. package/tests/run-all.sh +9 -0
  60. package/tests/runner.js +3 -2
  61. package/tests/state.test.sh +187 -0
  62. package/tests/verify-panel.test.sh +162 -0
  63. package/tests/wave-plan.test.sh +153 -0
  64. package/skills/qualia-discuss/SKILL.md +0 -222
@@ -96,29 +96,78 @@ TMP=$(setup_guard_repo main OWNER)
96
96
  assert_exit "OWNER on main → allowed" 0 $?
97
97
  rm -rf "$TMP"
98
98
 
99
- # EMPLOYEE on main → blocked (exit 2)
99
+ # EMPLOYEE on main → ALLOWED + counted + notice (v6.10 policy: accountability, not block)
100
100
  TMP=$(setup_guard_repo main EMPLOYEE)
101
101
  OUT=$(cd "$TMP/proj" && HOME="$TMP" $NODE "$HOOKS_DIR/branch-guard.js" 2>&1)
102
102
  RC=$?
103
- if [ "$RC" -eq 2 ] && echo "$OUT" | grep -q "BLOCKED" && echo "$OUT" | grep -q "main"; then
104
- echo " EMPLOYEE on main blocked (BLOCKED in stdout)"
103
+ if [ "$RC" -eq 0 ] \
104
+ && echo "$OUT" | grep -q "NOTICE" \
105
+ && grep -q "employee_main_push" "$TMP/.claude/.main-push-events.json" 2>/dev/null \
106
+ && grep -q '"total": 1' "$TMP/.claude/.main-push-events.json" 2>/dev/null; then
107
+ echo " ✓ EMPLOYEE on main → allowed + recorded + notice"
108
+ PASS=$((PASS + 1))
109
+ else
110
+ echo " ✗ EMPLOYEE on main → allowed+recorded (exit=$RC out=$OUT)"
111
+ FAIL=$((FAIL + 1))
112
+ fi
113
+ rm -rf "$TMP"
114
+
115
+ # EMPLOYEE repeated main push → per-employee count increments (framework counts)
116
+ TMP=$(setup_guard_repo main EMPLOYEE)
117
+ (cd "$TMP/proj" && HOME="$TMP" $NODE "$HOOKS_DIR/branch-guard.js" >/dev/null 2>&1)
118
+ (cd "$TMP/proj" && HOME="$TMP" $NODE "$HOOKS_DIR/branch-guard.js" >/dev/null 2>&1)
119
+ if grep -q '"total": 2' "$TMP/.claude/.main-push-events.json" 2>/dev/null; then
120
+ echo " ✓ EMPLOYEE repeated main push → count increments to 2"
121
+ PASS=$((PASS + 1))
122
+ else
123
+ echo " ✗ EMPLOYEE repeated main push → count increments"
124
+ FAIL=$((FAIL + 1))
125
+ fi
126
+ rm -rf "$TMP"
127
+
128
+ # EMPLOYEE refspec push to main from a feature branch → recorded
129
+ TMP=$(setup_guard_repo feature/xyz EMPLOYEE)
130
+ OUT=$(cd "$TMP/proj" && printf '%s' '{"tool_input":{"command":"git push origin feature/xyz:main"}}' | HOME="$TMP" $NODE "$HOOKS_DIR/branch-guard.js" 2>&1)
131
+ RC=$?
132
+ if [ "$RC" -eq 0 ] && grep -q "employee_main_push" "$TMP/.claude/.main-push-events.json" 2>/dev/null; then
133
+ echo " ✓ EMPLOYEE refspec push to :main → allowed + recorded"
105
134
  PASS=$((PASS + 1))
106
135
  else
107
- echo " ✗ EMPLOYEE on main → blocked (exit=$RC)"
136
+ echo " ✗ EMPLOYEE refspec push to :main → recorded (exit=$RC)"
108
137
  FAIL=$((FAIL + 1))
109
138
  fi
110
139
  rm -rf "$TMP"
111
140
 
112
- # EMPLOYEE on master → blocked
141
+ # EMPLOYEE on master → allowed + recorded
113
142
  TMP=$(setup_guard_repo master EMPLOYEE)
114
143
  (cd "$TMP/proj" && HOME="$TMP" $NODE "$HOOKS_DIR/branch-guard.js" >/dev/null 2>&1)
115
- assert_exit "EMPLOYEE on master → blocked" 2 $?
144
+ assert_exit "EMPLOYEE on master → allowed (recorded)" 0 $?
116
145
  rm -rf "$TMP"
117
146
 
118
- # EMPLOYEE on feature branch → allowed
147
+ # EMPLOYEE on feature branch → allowed, NOT recorded
119
148
  TMP=$(setup_guard_repo feature/xyz EMPLOYEE)
120
149
  (cd "$TMP/proj" && HOME="$TMP" $NODE "$HOOKS_DIR/branch-guard.js" >/dev/null 2>&1)
121
- assert_exit "EMPLOYEE on feature/xyz → allowed" 0 $?
150
+ RC=$?
151
+ if [ "$RC" -eq 0 ] && [ ! -f "$TMP/.claude/.main-push-events.json" ]; then
152
+ echo " ✓ EMPLOYEE on feature/xyz → allowed, not recorded"
153
+ PASS=$((PASS + 1))
154
+ else
155
+ echo " ✗ EMPLOYEE on feature/xyz → allowed/not-recorded (exit=$RC)"
156
+ FAIL=$((FAIL + 1))
157
+ fi
158
+ rm -rf "$TMP"
159
+
160
+ # OWNER on main → allowed, NOT recorded (OWNER pushes are unremarkable)
161
+ TMP=$(setup_guard_repo main OWNER)
162
+ (cd "$TMP/proj" && HOME="$TMP" $NODE "$HOOKS_DIR/branch-guard.js" >/dev/null 2>&1)
163
+ RC=$?
164
+ if [ "$RC" -eq 0 ] && [ ! -f "$TMP/.claude/.main-push-events.json" ]; then
165
+ echo " ✓ OWNER on main → allowed, not recorded"
166
+ PASS=$((PASS + 1))
167
+ else
168
+ echo " ✗ OWNER on main → allowed/not-recorded (exit=$RC)"
169
+ FAIL=$((FAIL + 1))
170
+ fi
122
171
  rm -rf "$TMP"
123
172
 
124
173
  # OWNER on feature branch → allowed
@@ -127,31 +176,38 @@ TMP=$(setup_guard_repo feature/xyz OWNER)
127
176
  assert_exit "OWNER on feature/xyz → allowed" 0 $?
128
177
  rm -rf "$TMP"
129
178
 
130
- # Missing config → fails closed (block, exit 2)
179
+ # Missing config → allowed (the hook never blocks now)
131
180
  TMP=$(mktemp -d)
132
181
  mkdir -p "$TMP/proj"
133
- (cd "$TMP/proj" && git init -q && git checkout -b feature/x -q 2>/dev/null)
182
+ (cd "$TMP/proj" && git init -q && git checkout -b main -q 2>/dev/null)
134
183
  # NO .claude/.qualia-config.json
135
184
  (cd "$TMP/proj" && HOME="$TMP" $NODE "$HOOKS_DIR/branch-guard.js" >/dev/null 2>&1)
136
- assert_exit "missing config → blocked (fails closed)" 2 $?
185
+ assert_exit "missing config → allowed (never blocks)" 0 $?
137
186
  rm -rf "$TMP"
138
187
 
139
- # Malformed config JSON → fails closed
188
+ # Malformed config JSON → allowed
140
189
  TMP=$(mktemp -d)
141
190
  mkdir -p "$TMP/proj" "$TMP/.claude"
142
- (cd "$TMP/proj" && git init -q && git checkout -b feature/x -q 2>/dev/null)
191
+ (cd "$TMP/proj" && git init -q && git checkout -b main -q 2>/dev/null)
143
192
  echo 'not json{' > "$TMP/.claude/.qualia-config.json"
144
193
  (cd "$TMP/proj" && HOME="$TMP" $NODE "$HOOKS_DIR/branch-guard.js" >/dev/null 2>&1)
145
- assert_exit "malformed config JSON → blocked" 2 $?
194
+ assert_exit "malformed config JSON → allowed" 0 $?
146
195
  rm -rf "$TMP"
147
196
 
148
- # Empty role field → fails closed
197
+ # Empty role field → allowed, not recorded (not a known EMPLOYEE)
149
198
  TMP=$(mktemp -d)
150
199
  mkdir -p "$TMP/proj" "$TMP/.claude"
151
- (cd "$TMP/proj" && git init -q && git checkout -b feature/x -q 2>/dev/null)
200
+ (cd "$TMP/proj" && git init -q && git checkout -b main -q 2>/dev/null)
152
201
  echo '{"role":""}' > "$TMP/.claude/.qualia-config.json"
153
202
  (cd "$TMP/proj" && HOME="$TMP" $NODE "$HOOKS_DIR/branch-guard.js" >/dev/null 2>&1)
154
- assert_exit "empty role field → blocked" 2 $?
203
+ RC=$?
204
+ if [ "$RC" -eq 0 ] && [ ! -f "$TMP/.claude/.main-push-events.json" ]; then
205
+ echo " ✓ empty role → allowed, not recorded"
206
+ PASS=$((PASS + 1))
207
+ else
208
+ echo " ✗ empty role → allowed/not-recorded (exit=$RC)"
209
+ FAIL=$((FAIL + 1))
210
+ fi
155
211
  rm -rf "$TMP"
156
212
 
157
213
  # --- fawzi-approval-guard.js ---
@@ -382,6 +438,72 @@ RC=$?
382
438
  assert_exit "regular page.tsx with service_role → blocked (exit 2)" 2 $RC
383
439
  rm -rf "$TMP"
384
440
 
441
+ # --- pre-deploy-gate: anti-slop gate (slop-detect.mjs wired into ship) ---
442
+ # Build a tmp QUALIA_HOME carrying bin/slop-detect.mjs so the gate can find it.
443
+ SLOP_SRC="$(cd "$(dirname "$0")/../bin" && pwd)/slop-detect.mjs"
444
+
445
+ # Purple-blue gradient (CRITICAL design tell) → blocked with anti-slop diagnostic
446
+ TMP=$(mktemp -d)
447
+ QH=$(mktemp -d)
448
+ mkdir -p "$QH/bin" "$TMP/app"
449
+ cp "$SLOP_SRC" "$QH/bin/slop-detect.mjs"
450
+ echo 'export default function P(){return <div className="bg-gradient-to-r from-blue-500 to-purple-600">hi</div>;}' > "$TMP/app/page.tsx"
451
+ OUT=$( (cd "$TMP" && QUALIA_HOME="$QH" $NODE "$HOOKS_DIR/pre-deploy-gate.js") 2>&1 )
452
+ RC=$?
453
+ if [ "$RC" -eq 2 ] && echo "$OUT" | grep -qi "anti-slop"; then
454
+ echo " ✓ purple-blue gradient → blocked by anti-slop gate (exit 2)"
455
+ PASS=$((PASS + 1))
456
+ else
457
+ echo " ✗ purple-blue gradient → anti-slop block (exit=$RC out=$OUT)"
458
+ FAIL=$((FAIL + 1))
459
+ fi
460
+ rm -rf "$TMP" "$QH"
461
+
462
+ # Clean frontend file → anti-slop passes → exit 0 with "Anti-slop" check shown
463
+ TMP=$(mktemp -d)
464
+ QH=$(mktemp -d)
465
+ mkdir -p "$QH/bin" "$TMP/app"
466
+ cp "$SLOP_SRC" "$QH/bin/slop-detect.mjs"
467
+ echo 'export default function P(){return <div className="bg-stone-50 text-ink">hi</div>;}' > "$TMP/app/page.tsx"
468
+ OUT=$( (cd "$TMP" && QUALIA_HOME="$QH" $NODE "$HOOKS_DIR/pre-deploy-gate.js") 2>&1 )
469
+ RC=$?
470
+ if [ "$RC" -eq 0 ] && echo "$OUT" | grep -q "Anti-slop"; then
471
+ echo " ✓ clean frontend → anti-slop passes (exit 0)"
472
+ PASS=$((PASS + 1))
473
+ else
474
+ echo " ✗ clean frontend → anti-slop passes (exit=$RC out=$OUT)"
475
+ FAIL=$((FAIL + 1))
476
+ fi
477
+ rm -rf "$TMP" "$QH"
478
+
479
+ # QUALIA_SKIP_SLOP=1 by non-OWNER → blocked (OWNER-only escape)
480
+ TMP=$(mktemp -d)
481
+ QH=$(mktemp -d)
482
+ mkdir -p "$QH/bin" "$TMP/app"
483
+ cp "$SLOP_SRC" "$QH/bin/slop-detect.mjs"
484
+ echo '{"role":"EMPLOYEE"}' > "$QH/.qualia-config.json"
485
+ echo 'export default function P(){return <div className="bg-gradient-to-r from-blue-500 to-purple-600">x</div>;}' > "$TMP/app/page.tsx"
486
+ OUT=$( (cd "$TMP" && QUALIA_SKIP_SLOP=1 QUALIA_HOME="$QH" $NODE "$HOOKS_DIR/pre-deploy-gate.js") 2>&1 )
487
+ RC=$?
488
+ if [ "$RC" -eq 2 ] && echo "$OUT" | grep -q "OWNER-only"; then
489
+ echo " ✓ QUALIA_SKIP_SLOP by non-OWNER → blocked (OWNER-only)"
490
+ PASS=$((PASS + 1))
491
+ else
492
+ echo " ✗ QUALIA_SKIP_SLOP non-OWNER block (exit=$RC out=$OUT)"
493
+ FAIL=$((FAIL + 1))
494
+ fi
495
+ rm -rf "$TMP" "$QH"
496
+
497
+ # Scanner absent (older install) → gate skips silently → exit 0
498
+ TMP=$(mktemp -d)
499
+ QH=$(mktemp -d)
500
+ mkdir -p "$TMP/app"
501
+ echo 'export default function P(){return <div className="bg-gradient-to-r from-blue-500 to-purple-600">x</div>;}' > "$TMP/app/page.tsx"
502
+ OUT=$( (cd "$TMP" && QUALIA_HOME="$QH" $NODE "$HOOKS_DIR/pre-deploy-gate.js") 2>&1 )
503
+ RC=$?
504
+ assert_exit "anti-slop gate skips when scanner absent (exit 0)" 0 $RC
505
+ rm -rf "$TMP" "$QH"
506
+
385
507
  # --- session-start.js — must exit 0 always ---
386
508
  echo ""
387
509
  echo "session-start:"
@@ -595,6 +717,85 @@ else
595
717
  fi
596
718
  rm -rf "$TMP"
597
719
 
720
+ # --- task-write-guard.js (R1 — runtime plan-contract file-scope guard) ---
721
+ echo ""
722
+ echo "task-write-guard:"
723
+
724
+ AS_BIN="$(cd "$(dirname "$0")/../bin" && pwd)/agent-status.js"
725
+
726
+ # setup_twg_project → tmp dir with a phase-1 contract (T1 declares src/a.ts +
727
+ # src/b.ts) and an active RUNNING status for T1. Prints the dir; caller rm -rf.
728
+ setup_twg_project() {
729
+ local tmp
730
+ tmp=$(mktemp -d)
731
+ mkdir -p "$tmp/.planning"
732
+ cat > "$tmp/.planning/phase-1-contract.json" <<'EOF'
733
+ {
734
+ "version": 1,
735
+ "phase": 1,
736
+ "tasks": [
737
+ { "id": "T1", "wave": 1, "files_modify": ["src/a.ts"], "files_create": ["src/b.ts"] }
738
+ ]
739
+ }
740
+ EOF
741
+ echo "$tmp"
742
+ }
743
+ twg() { echo "{\"tool_input\":{\"file_path\":\"$2\"}}" | (cd "$1" && $NODE "$HOOKS_DIR/task-write-guard.js" >/dev/null 2>&1); }
744
+
745
+ # No active build → guard is a no-op even on an undeclared file
746
+ TMP=$(setup_twg_project)
747
+ twg "$TMP" "src/evil.ts"
748
+ assert_exit "no active build → allowed (no-op)" 0 $?
749
+ rm -rf "$TMP"
750
+
751
+ # Active build (T1 RUNNING) + write to a DECLARED file → allowed
752
+ TMP=$(setup_twg_project)
753
+ $NODE "$AS_BIN" write T1 RUNNING --phase 1 --wave 1 --cwd "$TMP" >/dev/null 2>&1
754
+ twg "$TMP" "src/a.ts"
755
+ assert_exit "active build + declared file → allowed" 0 $?
756
+
757
+ # Active build + write to an UNDECLARED file → blocked (exit 2)
758
+ OUT=$(echo '{"tool_input":{"file_path":"src/evil.ts"}}' | (cd "$TMP" && $NODE "$HOOKS_DIR/task-write-guard.js" 2>&1))
759
+ RC=$?
760
+ if [ "$RC" -eq 2 ] && echo "$OUT" | grep -q "task-write-guard" && echo "$OUT" | grep -q "evil.ts"; then
761
+ echo " ✓ active build + undeclared file → blocked with diagnostic"
762
+ PASS=$((PASS + 1))
763
+ else
764
+ echo " ✗ active build + undeclared file → blocked (exit=$RC out=$OUT)"
765
+ FAIL=$((FAIL + 1))
766
+ fi
767
+
768
+ # files_create entries count as declared
769
+ twg "$TMP" "src/b.ts"
770
+ assert_exit "active build + declared files_create → allowed" 0 $?
771
+
772
+ # .planning/ and .agent-status/ paths are always writable during a build
773
+ twg "$TMP" ".planning/phase-1-verification.md"
774
+ assert_exit "active build + .planning path → allowed" 0 $?
775
+ twg "$TMP" ".agent-status/T1.json"
776
+ assert_exit "active build + .agent-status path → allowed" 0 $?
777
+
778
+ # OWNER escape hatch overrides the block
779
+ echo '{"tool_input":{"file_path":"src/evil.ts"}}' | (cd "$TMP" && QUALIA_ALLOW_OUTSIDE_CONTRACT=1 $NODE "$HOOKS_DIR/task-write-guard.js" >/dev/null 2>&1)
780
+ assert_exit "QUALIA_ALLOW_OUTSIDE_CONTRACT=1 → allowed despite undeclared" 0 $?
781
+
782
+ # Absolute path to an undeclared file still blocks
783
+ echo "{\"tool_input\":{\"file_path\":\"$TMP/src/evil.ts\"}}" | (cd "$TMP" && $NODE "$HOOKS_DIR/task-write-guard.js" >/dev/null 2>&1)
784
+ assert_exit "active build + absolute undeclared path → blocked" 2 $?
785
+
786
+ # Once T1 flips to DONE (no RUNNING), the guard goes quiet again
787
+ $NODE "$AS_BIN" write T1 DONE --commit abc --cwd "$TMP" >/dev/null 2>&1
788
+ twg "$TMP" "src/evil.ts"
789
+ assert_exit "build finished (no RUNNING) → allowed" 0 $?
790
+ rm -rf "$TMP"
791
+
792
+ # Active build but no contract present → fail open (allowed)
793
+ TMP=$(mktemp -d)
794
+ $NODE "$AS_BIN" write T1 RUNNING --phase 1 --cwd "$TMP" >/dev/null 2>&1
795
+ twg "$TMP" "src/anything.ts"
796
+ assert_exit "active build + no contract → allowed (fail open)" 0 $?
797
+ rm -rf "$TMP"
798
+
598
799
  echo ""
599
800
  echo "=== Results: $PASS passed, $FAIL failed ==="
600
801
  [ "$FAIL" -eq 0 ] && exit 0 || exit 1
@@ -124,11 +124,12 @@ else
124
124
  fi
125
125
 
126
126
  if [ -d "$HOME_DIR/.claude/hooks" ] \
127
- && [ "$(find "$HOME_DIR/.claude/hooks" -maxdepth 1 -name '*.js' | wc -l | tr -d ' ')" = "14" ] \
127
+ && [ "$(find "$HOME_DIR/.claude/hooks" -maxdepth 1 -name '*.js' | wc -l | tr -d ' ')" = "15" ] \
128
128
  && [ -f "$HOME_DIR/.claude/hooks/fawzi-approval-guard.js" ] \
129
129
  && [ -f "$HOME_DIR/.claude/hooks/pre-compact.js" ] \
130
- && [ -f "$HOME_DIR/.claude/hooks/usage-capture.js" ]; then
131
- pass "packaged install has 14 hooks including usage-capture (v6.9.1 telemetry)"
130
+ && [ -f "$HOME_DIR/.claude/hooks/usage-capture.js" ] \
131
+ && [ -f "$HOME_DIR/.claude/hooks/task-write-guard.js" ]; then
132
+ pass "packaged install has 15 hooks including task-write-guard (v6.13)"
132
133
  else
133
134
  fail_case "packaged hook set mismatch"
134
135
  fi
@@ -0,0 +1,109 @@
1
+ #!/bin/bash
2
+ # instructions.test.sh — single-source instruction compile (R4) + host adapter
3
+ # contract (R5). Run: bash tests/instructions.test.sh
4
+
5
+ PASS=0
6
+ FAIL=0
7
+ FRAMEWORK_DIR="$(cd "$(dirname "$0")/.." && pwd)"
8
+ NODE="${NODE:-node}"
9
+ HA="$FRAMEWORK_DIR/bin/host-adapters.js"
10
+ CI="$FRAMEWORK_DIR/bin/compile-instructions.js"
11
+
12
+ assert_exit() {
13
+ local name="$1" expected="$2" actual="$3"
14
+ if [ "$expected" = "$actual" ]; then echo " ✓ $name"; PASS=$((PASS+1));
15
+ else echo " ✗ $name (expected exit $expected, got $actual)"; FAIL=$((FAIL+1)); fi
16
+ }
17
+ assert_eq() {
18
+ local name="$1" expected="$2" actual="$3"
19
+ if [ "$expected" = "$actual" ]; then echo " ✓ $name"; PASS=$((PASS+1));
20
+ else echo " ✗ $name (expected '$expected', got '$actual')"; FAIL=$((FAIL+1)); fi
21
+ }
22
+ assert_contains() {
23
+ local name="$1" hay="$2" needle="$3"
24
+ if echo "$hay" | grep -qF "$needle"; then echo " ✓ $name"; PASS=$((PASS+1));
25
+ else echo " ✗ $name (missing '$needle')"; FAIL=$((FAIL+1)); fi
26
+ }
27
+ refute_contains() {
28
+ local name="$1" hay="$2" needle="$3"
29
+ if echo "$hay" | grep -qF "$needle"; then echo " ✗ $name (found '$needle')"; FAIL=$((FAIL+1));
30
+ else echo " ✓ $name"; PASS=$((PASS+1)); fi
31
+ }
32
+
33
+ echo "instructions.test.sh — single-source compile (R4) + host contract (R5)"
34
+ echo ""
35
+
36
+ # syntax
37
+ $NODE -c "$HA" 2>/dev/null && { echo " ✓ host-adapters syntax valid"; PASS=$((PASS+1)); } || { echo " ✗ host-adapters syntax"; FAIL=$((FAIL+1)); }
38
+ $NODE -c "$CI" 2>/dev/null && { echo " ✓ compile-instructions syntax valid"; PASS=$((PASS+1)); } || { echo " ✗ compile-instructions syntax"; FAIL=$((FAIL+1)); }
39
+
40
+ # --- R4: drift guard — committed files match the canonical ---
41
+ $NODE "$CI" --check >/dev/null 2>&1
42
+ assert_exit "compile-instructions --check passes (no drift)" 0 $?
43
+
44
+ # A canonical edit not reflected in the artifacts is caught by --check.
45
+ TMPCANON=$(mktemp)
46
+ cp "$FRAMEWORK_DIR/templates/instructions.md" "$TMPCANON"
47
+ printf '\nDRIFT-MARKER\n' >> "$FRAMEWORK_DIR/templates/instructions.md"
48
+ $NODE "$CI" --check >/dev/null 2>&1
49
+ RC=$?
50
+ cp "$TMPCANON" "$FRAMEWORK_DIR/templates/instructions.md" # restore immediately
51
+ rm -f "$TMPCANON"
52
+ assert_exit "drift guard fails (exit 1) when canonical edited but not recompiled" 1 $RC
53
+ # confirm restore worked
54
+ $NODE "$CI" --check >/dev/null 2>&1
55
+ assert_exit "canonical restored, --check green again" 0 $?
56
+
57
+ # --- R4: both artifacts share an identical body, differ only in footer ---
58
+ CLAUDE_BODY=$(sed -n '/# Qualia Framework/,/state router tells you the next command/p' "$FRAMEWORK_DIR/CLAUDE.md")
59
+ AGENTS_BODY=$(sed -n '/# Qualia Framework/,/state router tells you the next command/p' "$FRAMEWORK_DIR/AGENTS.md")
60
+ if [ "$CLAUDE_BODY" = "$AGENTS_BODY" ]; then
61
+ echo " ✓ CLAUDE.md and AGENTS.md bodies are identical (no drift)"; PASS=$((PASS+1));
62
+ else
63
+ echo " ✗ CLAUDE.md and AGENTS.md bodies diverge"; FAIL=$((FAIL+1));
64
+ fi
65
+ # host-specific footer survives
66
+ assert_contains "CLAUDE.md keeps Claude footer" "$(cat "$FRAMEWORK_DIR/CLAUDE.md")" "this file stays under 25 lines"
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"
69
+ # both carry the generated header + role placeholder for install to fill
70
+ assert_contains "CLAUDE.md has generated header" "$(head -1 "$FRAMEWORK_DIR/CLAUDE.md")" "GENERATED from templates/instructions.md"
71
+ assert_contains "AGENTS.md keeps {{ROLE}} for install" "$(cat "$FRAMEWORK_DIR/AGENTS.md")" "{{ROLE}}"
72
+
73
+ # --- R5: adapter contract is the single source of per-host facts ---
74
+ assert_eq "claude instructionFile" "CLAUDE.md" "$($NODE -e "console.log(require('$HA').adapter('claude').instructionFile)")"
75
+ assert_eq "codex instructionFile" "AGENTS.md" "$($NODE -e "console.log(require('$HA').adapter('codex').instructionFile)")"
76
+ assert_eq "claude configFile" "settings.json" "$($NODE -e "console.log(require('$HA').adapter('claude').configFile)")"
77
+ assert_eq "codex configFile" "config.toml" "$($NODE -e "console.log(require('$HA').adapter('codex').configFile)")"
78
+ assert_eq "codex agentExt" ".toml" "$($NODE -e "console.log(require('$HA').adapter('codex').agentExt)")"
79
+ assert_eq "unknown host throws" "throws" "$($NODE -e "try{require('$HA').adapter('cursor');console.log('no')}catch(e){console.log('throws')}")"
80
+
81
+ # --- R5: stripHostBlocks keeps only the matching host's block ---
82
+ SRC='before
83
+ <!--QUALIA-HOST claude-->
84
+ CLAUDE_ONLY
85
+ <!--/QUALIA-HOST-->
86
+ <!--QUALIA-HOST codex-->
87
+ CODEX_ONLY
88
+ <!--/QUALIA-HOST-->
89
+ after'
90
+ CLAUDE_OUT=$($NODE -e "console.log(require('$HA').stripHostBlocks(process.argv[1],'claude'))" "$SRC")
91
+ assert_contains "claude strip keeps CLAUDE_ONLY" "$CLAUDE_OUT" "CLAUDE_ONLY"
92
+ refute_contains "claude strip drops CODEX_ONLY" "$CLAUDE_OUT" "CODEX_ONLY"
93
+ CODEX_OUT=$($NODE -e "console.log(require('$HA').stripHostBlocks(process.argv[1],'codex'))" "$SRC")
94
+ assert_contains "codex strip keeps CODEX_ONLY" "$CODEX_OUT" "CODEX_ONLY"
95
+ refute_contains "codex strip drops CLAUDE_ONLY" "$CODEX_OUT" "CLAUDE_ONLY"
96
+
97
+ # --- R5: compileInstructions applies naming but leaves tokens/role for install ---
98
+ NAMED=$($NODE -e "console.log(require('$HA').compileInstructions('Use Claude Code now. Role: {{ROLE}} at \${QUALIA_BIN}','codex'))")
99
+ assert_contains "codex compile swaps the product name" "$NAMED" "Use Codex now"
100
+ assert_contains "codex compile leaves {{ROLE}} for install" "$NAMED" "{{ROLE}}"
101
+ assert_contains "codex compile leaves \${QUALIA_BIN} token for install" "$NAMED" '${QUALIA_BIN}'
102
+
103
+ # --- R5: renderText still resolves paths (regression) ---
104
+ RENDERED=$($NODE -e "console.log(require('$HA').renderText('bin at \${QUALIA_BIN}','claude'))")
105
+ assert_contains "renderText resolves QUALIA_BIN" "$RENDERED" "/.claude/bin"
106
+
107
+ echo ""
108
+ echo "=== Results: $PASS passed, $FAIL failed ==="
109
+ [ "$FAIL" -eq 0 ] && exit 0 || exit 1
@@ -0,0 +1,156 @@
1
+ #!/bin/bash
2
+ # last-report.test.sh — bin/last-report.js (surface the latest session report)
3
+ # Run: bash tests/last-report.test.sh
4
+
5
+ PASS=0
6
+ FAIL=0
7
+ BIN_DIR="$(cd "$(dirname "$0")/../bin" && pwd)"
8
+ NODE="${NODE:-node}"
9
+ LR="$BIN_DIR/last-report.js"
10
+
11
+ assert_exit() {
12
+ local name="$1" expected="$2" actual="$3"
13
+ if [ "$expected" = "$actual" ]; then echo " ✓ $name"; PASS=$((PASS+1));
14
+ else echo " ✗ $name (expected exit $expected, got $actual)"; FAIL=$((FAIL+1)); fi
15
+ }
16
+ assert_contains() {
17
+ local name="$1" hay="$2" needle="$3"
18
+ if echo "$hay" | grep -qF "$needle"; then echo " ✓ $name"; PASS=$((PASS+1));
19
+ else echo " ✗ $name (missing '$needle' in: $hay)"; FAIL=$((FAIL+1)); fi
20
+ }
21
+ assert_not_contains() {
22
+ local name="$1" hay="$2" needle="$3"
23
+ if echo "$hay" | grep -qF "$needle"; then echo " ✗ $name (unexpected '$needle' in: $hay)"; FAIL=$((FAIL+1));
24
+ else echo " ✓ $name"; PASS=$((PASS+1)); fi
25
+ }
26
+
27
+ # Write a well-formed report at $1/.planning/reports/report-$2.md with summary $3, next $4
28
+ write_report() {
29
+ local root="$1" datepart="$2" summary="$3" next="$4" date="$5"
30
+ mkdir -p "$root/.planning/reports"
31
+ cat > "$root/.planning/reports/report-$datepart.md" <<EOF
32
+ # Session Report — $date
33
+
34
+ **Project:** demo
35
+ **Employee:** T
36
+ **Branch:** main
37
+ **Date:** $date
38
+
39
+ ## What Was Done
40
+ - $summary
41
+
42
+ ## Blockers
43
+ None.
44
+
45
+ ## Next Steps
46
+ 1. $next
47
+
48
+ ## Commits (1)
49
+ - abc123 seed
50
+ EOF
51
+ }
52
+
53
+ echo "last-report.test.sh — bin/last-report.js"
54
+ echo ""
55
+
56
+ $NODE -c "$LR" 2>/dev/null && { echo " ✓ syntax valid"; PASS=$((PASS+1)); } || { echo " ✗ syntax invalid"; FAIL=$((FAIL+1)); }
57
+
58
+ # --- no reports dir → exit 1, found:false ---
59
+ TMP=$(mktemp -d)
60
+ (cd "$TMP" && $NODE "$LR" >/dev/null 2>&1)
61
+ assert_exit "no reports dir → exit 1" 1 $?
62
+ OUT=$(cd "$TMP" && $NODE "$LR" --json 2>&1)
63
+ assert_contains "json found:false when none" "$OUT" '"found": false'
64
+ OUT=$(cd "$TMP" && $NODE "$LR" 2>&1)
65
+ assert_contains "human: nothing to surface" "$OUT" "No session reports"
66
+ rm -rf "$TMP"
67
+
68
+ # --- single report → extracts date + summary + next, exit 0 ---
69
+ TMP=$(mktemp -d)
70
+ write_report "$TMP" "2026-05-28" "Built the login flow" "Wire the dashboard" "2026-05-28"
71
+ (cd "$TMP" && $NODE "$LR" >/dev/null 2>&1)
72
+ assert_exit "single report → exit 0" 0 $?
73
+ OUT=$(cd "$TMP" && $NODE "$LR" 2>&1)
74
+ assert_contains "human shows date" "$OUT" "2026-05-28"
75
+ assert_contains "human shows summary" "$OUT" "Built the login flow"
76
+ assert_contains "human shows next" "$OUT" "Wire the dashboard"
77
+ assert_contains "human has 'Last session' prefix" "$OUT" "Last session"
78
+ rm -rf "$TMP"
79
+
80
+ # --- json shape ---
81
+ TMP=$(mktemp -d)
82
+ write_report "$TMP" "2026-05-28" "Built the login flow" "Wire the dashboard" "2026-05-28"
83
+ OUT=$(cd "$TMP" && $NODE "$LR" --json 2>&1)
84
+ assert_contains "json found:true" "$OUT" '"found": true'
85
+ assert_contains "json file field" "$OUT" '"file": "report-2026-05-28.md"'
86
+ assert_contains "json date field" "$OUT" '"date": "2026-05-28"'
87
+ assert_contains "json summary field" "$OUT" '"summary": "Built the login flow"'
88
+ assert_contains "json next field" "$OUT" '"next": "Wire the dashboard"'
89
+ rm -rf "$TMP"
90
+
91
+ # --- multiple reports → picks the newest BY DATE (not file order) ---
92
+ TMP=$(mktemp -d)
93
+ write_report "$TMP" "2026-05-28" "Older work" "Old next" "2026-05-28"
94
+ write_report "$TMP" "2026-06-15" "Newest work" "New next" "2026-06-15"
95
+ write_report "$TMP" "2026-06-01" "Middle work" "Mid next" "2026-06-01"
96
+ OUT=$(cd "$TMP" && $NODE "$LR" --json 2>&1)
97
+ assert_contains "picks newest date file" "$OUT" '"file": "report-2026-06-15.md"'
98
+ assert_contains "picks newest summary" "$OUT" "Newest work"
99
+ assert_not_contains "does not pick older summary" "$OUT" "Older work"
100
+ rm -rf "$TMP"
101
+
102
+ # --- dated suffix filenames (report-2026-06-20-session2.md) parse correctly ---
103
+ TMP=$(mktemp -d)
104
+ write_report "$TMP" "2026-06-10" "Earlier" "e" "2026-06-10"
105
+ write_report "$TMP" "2026-06-20-session2" "Suffixed newest" "s" "2026-06-20"
106
+ OUT=$(cd "$TMP" && $NODE "$LR" --json 2>&1)
107
+ assert_contains "suffixed filename wins by date" "$OUT" "Suffixed newest"
108
+ assert_contains "suffixed date extracted" "$OUT" '"date": "2026-06-20"'
109
+ rm -rf "$TMP"
110
+
111
+ # --- --now makes age_days deterministic ---
112
+ TMP=$(mktemp -d)
113
+ write_report "$TMP" "2026-06-01" "Work" "Next" "2026-06-01"
114
+ OUT=$(cd "$TMP" && $NODE "$LR" --json --now 2026-06-11T00:00:00Z 2>&1)
115
+ assert_contains "age_days deterministic (10)" "$OUT" '"age_days": 10'
116
+ OUT=$(cd "$TMP" && $NODE "$LR" --now 2026-06-11T00:00:00Z 2>&1)
117
+ assert_contains "human age uses --now" "$OUT" "10d ago"
118
+ rm -rf "$TMP"
119
+
120
+ # --- library: latestReport() returns structured object ---
121
+ TMP=$(mktemp -d)
122
+ write_report "$TMP" "2026-06-01" "Lib work" "Lib next" "2026-06-01"
123
+ RES=$($NODE -e "console.log(require('$LR').latestReport('$TMP').summary)" 2>&1)
124
+ assert_contains "latestReport() returns summary" "$RES" "Lib work"
125
+ RES=$($NODE -e "console.log(require('$LR').latestReport('$TMP',{now:'2026-06-06T00:00:00Z'}).age_days)" 2>&1)
126
+ assert_contains "latestReport() honors now opt (5)" "$RES" "5"
127
+ rm -rf "$TMP"
128
+
129
+ # --- malformed / empty report handled gracefully (no crash, still exit 0) ---
130
+ TMP=$(mktemp -d)
131
+ mkdir -p "$TMP/.planning/reports"
132
+ : > "$TMP/.planning/reports/report-2026-06-02.md" # completely empty
133
+ (cd "$TMP" && $NODE "$LR" >/dev/null 2>&1)
134
+ assert_exit "empty report → exit 0 (found by filename)" 0 $?
135
+ OUT=$(cd "$TMP" && $NODE "$LR" --json 2>&1)
136
+ assert_contains "empty report found:true" "$OUT" '"found": true'
137
+ assert_contains "empty report date from filename" "$OUT" '"date": "2026-06-02"'
138
+ rm -rf "$TMP"
139
+
140
+ # --- malformed report (no headings, just prose) still yields a summary ---
141
+ TMP=$(mktemp -d)
142
+ mkdir -p "$TMP/.planning/reports"
143
+ printf 'just some freeform notes about the day\n' > "$TMP/.planning/reports/report-2026-06-03.md"
144
+ OUT=$(cd "$TMP" && $NODE "$LR" --json 2>&1)
145
+ assert_contains "malformed report falls back to first line" "$OUT" "just some freeform notes"
146
+ rm -rf "$TMP"
147
+
148
+ # --- bad argument → exit 2 ---
149
+ TMP=$(mktemp -d)
150
+ (cd "$TMP" && $NODE "$LR" --bogus >/dev/null 2>&1)
151
+ assert_exit "unknown flag → exit 2" 2 $?
152
+ rm -rf "$TMP"
153
+
154
+ echo ""
155
+ echo "=== Results: $PASS passed, $FAIL failed ==="
156
+ [ "$FAIL" -eq 0 ] && exit 0 || exit 1
package/tests/lib.test.sh CHANGED
@@ -507,7 +507,7 @@ TMP=$(mktmp)
507
507
  mkdir -p "$TMP/home/.claude/bin" "$TMP/home/.claude/hooks" "$TMP/home/.claude/knowledge/daily-log" "$TMP/home/.claude/qualia-design" "$TMP/home/.claude/agents" "$TMP/home/.claude/qualia-templates" "$TMP/project"
508
508
  echo '{"installed_by":"Test","role":"OWNER","version":"6.3.0","erp":{"enabled":false}}' > "$TMP/home/.claude/.qualia-config.json"
509
509
  touch "$TMP/home/.claude/CLAUDE.md" "$TMP/home/.claude/settings.json"
510
- for f in runtime-manifest.js command-surface.js host-adapters.js state.js qualia-ui.js statusline.js knowledge.js knowledge-flush.js state-ledger.js plan-contract.js contract-runner.js harness-eval.js trust-score.js agent-runs.js slop-detect.mjs erp-retry.js work-packet.js report-payload.js project-snapshot.js codex-goal.js planning-hygiene.js prune-deprecated.js learning-candidates.js status-snapshot.js security-scan.js auto-report.js; do
510
+ for f in runtime-manifest.js command-surface.js host-adapters.js state.js qualia-ui.js statusline.js knowledge.js knowledge-flush.js state-ledger.js plan-contract.js contract-runner.js agent-status.js analyze-gate.js verify-panel.js wave-plan.js eval-runner.js branch-hygiene.js last-report.js harness-eval.js trust-score.js agent-runs.js slop-detect.mjs erp-retry.js work-packet.js report-payload.js project-snapshot.js project-sync.js codex-goal.js planning-hygiene.js prune-deprecated.js learning-candidates.js status-snapshot.js security-scan.js auto-report.js; do
511
511
  touch "$TMP/home/.claude/bin/$f"
512
512
  done
513
513
  for h in session-start.js auto-update.js branch-guard.js pre-push.js pre-deploy-gate.js migration-guard.js git-guardrails.js stop-session-log.js fawzi-approval-guard.js vercel-account-guard.js env-empty-guard.js supabase-destructive-guard.js; do
@@ -622,7 +622,7 @@ TMP=$(mktmp)
622
622
  mkdir -p "$TMP/.claude/bin" "$TMP/.claude/hooks" "$TMP/.claude/knowledge/daily-log" "$TMP/.claude/qualia-design" "$TMP/.claude/agents" "$TMP/.claude/qualia-templates" "$TMP/project/.planning"
623
623
  echo '{"installed_by":"Test","role":"OWNER","erp":{"enabled":false}}' > "$TMP/.claude/.qualia-config.json"
624
624
  touch "$TMP/.claude/CLAUDE.md" "$TMP/.claude/settings.json"
625
- for f in runtime-manifest.js command-surface.js host-adapters.js state.js qualia-ui.js statusline.js knowledge.js knowledge-flush.js state-ledger.js plan-contract.js contract-runner.js harness-eval.js trust-score.js agent-runs.js slop-detect.mjs erp-retry.js work-packet.js report-payload.js project-snapshot.js codex-goal.js planning-hygiene.js prune-deprecated.js learning-candidates.js status-snapshot.js security-scan.js auto-report.js; do
625
+ for f in runtime-manifest.js command-surface.js host-adapters.js state.js qualia-ui.js statusline.js knowledge.js knowledge-flush.js state-ledger.js plan-contract.js contract-runner.js agent-status.js analyze-gate.js verify-panel.js wave-plan.js eval-runner.js branch-hygiene.js last-report.js harness-eval.js trust-score.js agent-runs.js slop-detect.mjs erp-retry.js work-packet.js report-payload.js project-snapshot.js project-sync.js codex-goal.js planning-hygiene.js prune-deprecated.js learning-candidates.js status-snapshot.js security-scan.js auto-report.js; do
626
626
  touch "$TMP/.claude/bin/$f"
627
627
  done
628
628
  for h in session-start.js auto-update.js branch-guard.js pre-push.js pre-deploy-gate.js migration-guard.js git-guardrails.js stop-session-log.js fawzi-approval-guard.js vercel-account-guard.js env-empty-guard.js supabase-destructive-guard.js; do