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
@@ -0,0 +1,175 @@
1
+ #!/bin/bash
2
+ # project-sync.test.sh — bin/project-sync.js (Framework→ERP project-sync payload)
3
+ # Run: bash tests/project-sync.test.sh
4
+
5
+ PASS=0
6
+ FAIL=0
7
+ BIN_DIR="$(cd "$(dirname "$0")/../bin" && pwd)"
8
+ NODE="${NODE:-node}"
9
+ PS="$BIN_DIR/project-sync.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 output)"; 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')"; FAIL=$((FAIL+1));
24
+ else echo " ✓ $name"; PASS=$((PASS+1)); fi
25
+ }
26
+
27
+ # A fully-populated .planning fixture: milestones, REQ-IDs, offroad, lifetime.
28
+ setup_full() {
29
+ local tmp
30
+ tmp=$(mktemp -d)
31
+ mkdir -p "$tmp/.planning"
32
+ cat > "$tmp/.planning/tracking.json" <<'EOF'
33
+ {
34
+ "project": "acme-portal",
35
+ "project_id": "qs-acme-portal",
36
+ "client": "Acme",
37
+ "milestone": 2,
38
+ "milestone_name": "Product",
39
+ "milestones": [
40
+ { "num": 1, "name": "Foundation", "closed_at": "2026-04-10T18:00:00Z", "phases_completed": 3, "tasks_completed": 12, "deployed_url": "https://m1.vercel.app" }
41
+ ],
42
+ "phase": 2,
43
+ "phase_name": "Dashboard",
44
+ "total_phases": 4,
45
+ "status": "built",
46
+ "tasks_done": 3,
47
+ "tasks_total": 5,
48
+ "verification": "pending",
49
+ "gap_cycles": { "2": 1 },
50
+ "build_count": 4,
51
+ "deploy_count": 1,
52
+ "deployed_url": "https://client.vercel.app",
53
+ "lifecycle": "build",
54
+ "lifetime": { "tasks_completed": 15, "phases_completed": 4, "milestones_completed": 1, "total_phases": 7, "offroad_count": 2 },
55
+ "offroad": [
56
+ { "at": "2026-06-01T10:00:00Z", "milestone": 2, "ref": "BUG-7", "note": "hotfix login" },
57
+ { "at": "2026-06-02T10:00:00Z", "milestone": 2, "ref": null, "note": "tweak" }
58
+ ]
59
+ }
60
+ EOF
61
+ cat > "$tmp/.planning/JOURNEY.md" <<'EOF'
62
+ ## Milestone 1 · Foundation
63
+ ## Milestone 2 · Product [CURRENT]
64
+ ## Milestone 3 · Handoff [FINAL]
65
+ EOF
66
+ cat > "$tmp/.planning/REQUIREMENTS.md" <<'EOF'
67
+ | ID | Milestone | Phase | Status |
68
+ |----|-----------|-------|--------|
69
+ | CORE-01 | M1: Foundation | Phase 1 | Complete |
70
+ | CORE-02 | M2: Product | Phase 2 | Complete |
71
+ | CORE-03 | M2: Product | Phase 3 | Incomplete |
72
+ EOF
73
+ echo "$tmp"
74
+ }
75
+
76
+ echo "project-sync.test.sh — bin/project-sync.js"
77
+ echo ""
78
+
79
+ # --- syntax ---
80
+ $NODE -c "$PS" 2>/dev/null && { echo " ✓ syntax valid"; PASS=$((PASS+1)); } || { echo " ✗ syntax invalid"; FAIL=$((FAIL+1)); }
81
+
82
+ NOW="2026-06-21T00:00:00.000Z"
83
+
84
+ # --- no .planning → exit 2 ---
85
+ TMP=$(mktemp -d)
86
+ (cd "$TMP" && $NODE "$PS" >/dev/null 2>&1)
87
+ assert_exit "no .planning → exit 2" 2 $?
88
+ rm -rf "$TMP"
89
+
90
+ # --- full fixture → exit 0 + valid JSON ---
91
+ TMP=$(setup_full)
92
+ OUT=$($NODE "$PS" --cwd "$TMP" --json --now "$NOW" 2>&1)
93
+ (cd "$TMP" && $NODE "$PS" --json --now "$NOW" >/dev/null 2>&1)
94
+ assert_exit "full fixture → exit 0" 0 $?
95
+ echo "$OUT" | $NODE -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{JSON.parse(s);process.exit(0)})" 2>/dev/null \
96
+ && { echo " ✓ --json emits valid JSON"; PASS=$((PASS+1)); } \
97
+ || { echo " ✗ --json invalid JSON"; FAIL=$((FAIL+1)); }
98
+
99
+ # --- schema_version + payload marker ---
100
+ assert_contains "carries schema_version" "$OUT" '"schema_version": 1'
101
+ assert_contains "payload marker is project-sync" "$OUT" '"payload": "project-sync"'
102
+
103
+ # --- lifecycle + launched_at handling ---
104
+ assert_contains "lifecycle present (build)" "$OUT" '"lifecycle": "build"'
105
+
106
+ # --- milestones[] with status + REQ completion ---
107
+ assert_contains "milestone 1 closed" "$OUT" '"status": "closed"'
108
+ assert_contains "milestone 2 current" "$OUT" '"status": "current"'
109
+ assert_contains "milestone 3 future" "$OUT" '"status": "future"'
110
+ assert_contains "closed milestone phases count" "$OUT" '"phases": 3'
111
+ assert_contains "closed milestone tasks_completed" "$OUT" '"tasks_completed": 12'
112
+ assert_contains "REQ total tracked for M2" "$OUT" '"total": 2'
113
+ assert_contains "REQ complete count" "$OUT" '"complete": 1'
114
+ assert_contains "incomplete REQ id surfaced" "$OUT" '"id": "CORE-03"'
115
+ assert_contains "future milestone REQ untracked" "$OUT" '"tracked": false'
116
+ assert_contains "per-milestone deployed_url" "$OUT" '"deployed_url": "https://m1.vercel.app"'
117
+ assert_contains "total_milestones from journey" "$OUT" '"total_milestones": 3'
118
+
119
+ # --- current position ---
120
+ assert_contains "current phase" "$OUT" '"phase": 2'
121
+ assert_contains "current verification" "$OUT" '"verification": "pending"'
122
+
123
+ # --- task rollup ---
124
+ assert_contains "rollup tasks_completed" "$OUT" '"tasks_completed": 15'
125
+ assert_contains "rollup build_count" "$OUT" '"build_count": 4'
126
+ assert_contains "rollup deploy_count" "$OUT" '"deploy_count": 1'
127
+ assert_contains "rollup gap_cycles (flattened to number)" "$OUT" '"current_phase_gap_cycles": 1'
128
+
129
+ # --- accountability / offroad ---
130
+ assert_contains "offroad_count" "$OUT" '"offroad_count": 2'
131
+ assert_contains "recent offroad entry" "$OUT" '"ref": "BUG-7"'
132
+
133
+ # --- integration / merge model ---
134
+ assert_contains "trunk integration model" "$OUT" '"model": "trunk"'
135
+ assert_contains "integrates at ship" "$OUT" '"integrates_at": "/qualia-ship"'
136
+ assert_contains "main-push event type" "$OUT" '"main_push_event_type": "employee_main_push"'
137
+
138
+ rm -rf "$TMP"
139
+
140
+ # --- graceful when fields absent: minimal tracking.json, no JOURNEY/REQUIREMENTS ---
141
+ TMP=$(mktemp -d)
142
+ mkdir -p "$TMP/.planning"
143
+ echo '{ "project": "bare", "milestone": 1, "phase": 1 }' > "$TMP/.planning/tracking.json"
144
+ OUT=$($NODE "$PS" --cwd "$TMP" --json --now "$NOW" 2>&1)
145
+ (cd "$TMP" && $NODE "$PS" --json --now "$NOW" >/dev/null 2>&1)
146
+ assert_exit "bare fixture → exit 0" 0 $?
147
+ assert_contains "bare still has schema_version" "$OUT" '"schema_version": 1'
148
+ assert_contains "bare offroad_count defaults to 0" "$OUT" '"offroad_count": 0'
149
+ assert_contains "bare emits a current milestone" "$OUT" '"status": "current"'
150
+ assert_contains "bare REQ untracked (no REQUIREMENTS.md)" "$OUT" '"tracked": false'
151
+ rm -rf "$TMP"
152
+
153
+ # --- library: buildProjectSync() returns the object ---
154
+ TMP=$(setup_full)
155
+ RES=$($NODE -e "const {buildProjectSync}=require('$PS');const o=buildProjectSync({cwd:'$TMP',now:'$NOW'});console.log(o.schema_version, o.milestones.length, o.milestones[1].requirements.incomplete[0].id)" 2>&1)
156
+ assert_contains "buildProjectSync() returns enriched object" "$RES" "1 3 CORE-03"
157
+ rm -rf "$TMP"
158
+
159
+ # --- library: milestoneRequirements() parses a fixture dir ---
160
+ TMP=$(setup_full)
161
+ RES=$($NODE -e "const {milestoneRequirements}=require('$PS');const r=milestoneRequirements('$TMP/.planning',2);console.log(r.tracked, r.total, r.complete, r.incomplete.length)" 2>&1)
162
+ assert_contains "milestoneRequirements() counts REQ completion" "$RES" "true 2 1 1"
163
+ rm -rf "$TMP"
164
+
165
+ # --- --write persists a snapshot file ---
166
+ TMP=$(setup_full)
167
+ FILE=$($NODE "$PS" --cwd "$TMP" --write --now "$NOW" 2>&1)
168
+ if [ -f "$FILE" ]; then echo " ✓ --write persists a file"; PASS=$((PASS+1));
169
+ else echo " ✗ --write did not persist ($FILE)"; FAIL=$((FAIL+1)); fi
170
+ assert_contains "written file is project-sync-*" "$FILE" "project-sync-"
171
+ rm -rf "$TMP"
172
+
173
+ echo ""
174
+ echo "=== Results: $PASS passed, $FAIL failed ==="
175
+ [ "$FAIL" -eq 0 ] && exit 0 || exit 1
package/tests/run-all.sh CHANGED
@@ -18,6 +18,15 @@ SUITES=(
18
18
  "refs"
19
19
  "install-smoke"
20
20
  "slop-detect"
21
+ "agent-status"
22
+ "analyze-gate"
23
+ "instructions"
24
+ "verify-panel"
25
+ "wave-plan"
26
+ "eval-runner"
27
+ "branch-hygiene"
28
+ "last-report"
29
+ "project-sync"
21
30
  )
22
31
 
23
32
  FAILED=()
package/tests/runner.js CHANGED
@@ -2578,14 +2578,15 @@ describe("install.js", () => {
2578
2578
  }
2579
2579
  });
2580
2580
 
2581
- it("13 hooks installed (v6.3.2: pre-compact reintroduced with sidecar-snapshot mechanism)", () => {
2581
+ it("15 hooks installed (v6.13: task-write-guard added R1 plan-contract file-scope guard)", () => {
2582
2582
  const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "qualia-install-"));
2583
2583
  try {
2584
2584
  runInstall("QS-FAWZI-11", tmpHome);
2585
2585
  const hooks = fs.readdirSync(path.join(tmpHome, ".claude", "hooks")).filter(f => f.endsWith(".js"));
2586
- assert.equal(hooks.length, 13, `expected 13 hooks, got ${hooks.length}: ${hooks.join(", ")}`);
2586
+ assert.equal(hooks.length, 15, `expected 15 hooks, got ${hooks.length}: ${hooks.join(", ")}`);
2587
2587
  assert.ok(hooks.includes("fawzi-approval-guard.js"), "fawzi-approval-guard.js must be installed");
2588
2588
  assert.ok(hooks.includes("pre-compact.js"), "pre-compact.js must be installed (v6.3.2 sidecar-snapshot mechanism)");
2589
+ assert.ok(hooks.includes("task-write-guard.js"), "task-write-guard.js must be installed (v6.13 R1 guard)");
2589
2590
  } finally {
2590
2591
  fs.rmSync(tmpHome, { recursive: true, force: true });
2591
2592
  }
@@ -104,6 +104,12 @@ Goal: Test goal
104
104
  ## Success Criteria
105
105
  - [ ] Test passes
106
106
  PLAN
107
+ # v7 kernel: `planned` now requires a machine contract and `verified(pass)`
108
+ # requires passing machine evidence. Emit both so happy-path setups satisfy the
109
+ # preconditions; the few cases that exercise their ABSENCE delete them explicitly.
110
+ make_valid_contract "$dir" "$phase"
111
+ mkdir -p "$dir/.planning/evidence"
112
+ printf '{"ok":true,"checks":[]}\n' > "$dir/.planning/evidence/phase-${phase}-contract-run.json"
107
113
  }
108
114
 
109
115
  make_valid_contract() {
@@ -289,6 +295,9 @@ fi
289
295
  TMP=$(make_project)
290
296
  make_valid_plan "$TMP" 1
291
297
  make_valid_contract "$TMP" 1
298
+ # This case asserts the missing-evidence guard, so strip the evidence that
299
+ # make_valid_plan now writes by default.
300
+ rm -f "$TMP/.planning/evidence/phase-1-contract-run.json"
292
301
  (cd "$TMP" && $NODE "$STATE_JS" transition --to planned >/dev/null 2>&1)
293
302
  (cd "$TMP" && $NODE "$STATE_JS" transition --to built --tasks-done 1 --tasks-total 1 >/dev/null 2>&1)
294
303
  echo "result: PASS" > "$TMP/.planning/phase-1-verification.md"
@@ -735,6 +744,9 @@ else
735
744
  fail_case "validate well-formed plan" "exit=$EXIT out=$OUT"
736
745
  fi
737
746
 
747
+ # This case asserts the missing-contract path, so strip the contract that
748
+ # make_valid_plan now writes by default.
749
+ rm -f "$TMP/.planning/phase-1-contract.json"
738
750
  OUT=$(cd "$TMP" && $NODE "$STATE_JS" validate-plan --phase 1 --require-contract 2>&1)
739
751
  EXIT=$?
740
752
  if [ "$EXIT" -eq 1 ] \
@@ -946,6 +958,25 @@ else
946
958
  fail_case "note without tasks-done"
947
959
  fi
948
960
 
961
+ # 43b. --scope off tallies offroad_count + ledgers the entry (anti-drift)
962
+ TMP=$(make_project)
963
+ (cd "$TMP" && $NODE "$STATE_JS" transition --to note --notes "ad-hoc widget" --tasks-done 1 --scope off --ref "client asked mid-sprint" >/dev/null 2>&1)
964
+ (cd "$TMP" && $NODE "$STATE_JS" transition --to note --notes "another off-road" --scope off --ref "x" >/dev/null 2>&1)
965
+ if grep -q '"offroad_count": 2' "$TMP/.planning/tracking.json" && grep -q "client asked mid-sprint" "$TMP/.planning/tracking.json"; then
966
+ pass "--scope off increments offroad_count + records the entry"
967
+ else
968
+ fail_case "scope off tally" "$(grep -o '"offroad_count": [0-9]*' "$TMP/.planning/tracking.json")"
969
+ fi
970
+
971
+ # 43c. --scope in does NOT increment offroad_count
972
+ TMP=$(make_project)
973
+ (cd "$TMP" && $NODE "$STATE_JS" transition --to note --notes "in-scope work" --tasks-done 1 --scope in --ref "CORE-01" >/dev/null 2>&1)
974
+ if grep -q '"offroad_count": 0' "$TMP/.planning/tracking.json"; then
975
+ pass "--scope in leaves offroad_count at 0"
976
+ else
977
+ fail_case "scope in offroad" "$(grep -o '"offroad_count": [0-9]*' "$TMP/.planning/tracking.json")"
978
+ fi
979
+
949
980
  # ─── Close milestone ─────────────────────────────────────
950
981
  echo ""
951
982
  echo "close-milestone:"
@@ -1094,6 +1125,79 @@ else
1094
1125
  fail_case "backfill-milestones placeholder repair" "got=$RESULT expected='1|Foundation Arc|7|Core Features'"
1095
1126
  fi
1096
1127
 
1128
+ # ─── reqs-check (milestone REQ-ID coverage gate) ─────────
1129
+ echo ""
1130
+ echo "reqs-check:"
1131
+
1132
+ # helper: write a REQUIREMENTS.md traceability table into a project
1133
+ write_reqs() {
1134
+ mkdir -p "$1/.planning"
1135
+ cat > "$1/.planning/REQUIREMENTS.md" <<EOF
1136
+ ## Traceability
1137
+ | Requirement | Milestone | Phase | Status |
1138
+ |---|---|---|---|
1139
+ $2
1140
+ EOF
1141
+ }
1142
+
1143
+ # all complete for M1 → ok, exit 0
1144
+ TMP=$(make_project)
1145
+ write_reqs "$TMP" "| CORE-01 | M1: Foundation | Phase 1 | Complete |
1146
+ | CORE-02 | M1: Foundation | Phase 2 | Complete |"
1147
+ OUT=$(cd "$TMP" && $NODE "$STATE_JS" reqs-check --milestone 1 2>&1); EXIT=$?
1148
+ if [ "$EXIT" -eq 0 ] && echo "$OUT" | grep -q '"ok": true' && echo "$OUT" | grep -q '"complete": 2'; then
1149
+ pass "reqs-check all complete → ok (exit 0)"
1150
+ else
1151
+ fail_case "reqs-check all complete" "exit=$EXIT out=$OUT"
1152
+ fi
1153
+
1154
+ # an incomplete REQ → not ok, exit 1, lists it
1155
+ TMP=$(make_project)
1156
+ write_reqs "$TMP" "| CORE-01 | M1: Foundation | Phase 1 | Complete |
1157
+ | CORE-02 | M1: Foundation | Phase 2 | Pending |"
1158
+ OUT=$(cd "$TMP" && $NODE "$STATE_JS" reqs-check --milestone 1 2>&1); EXIT=$?
1159
+ if [ "$EXIT" -eq 1 ] && echo "$OUT" | grep -q '"ok": false' && echo "$OUT" | grep -q 'CORE-02'; then
1160
+ pass "reqs-check incomplete → not ok (exit 1) + lists REQ"
1161
+ else
1162
+ fail_case "reqs-check incomplete" "exit=$EXIT out=$OUT"
1163
+ fi
1164
+
1165
+ # milestone filter: M2's pending REQ doesn't fail an M1 check
1166
+ TMP=$(make_project)
1167
+ write_reqs "$TMP" "| CORE-01 | M1: Foundation | Phase 1 | Complete |
1168
+ | ADMIN-01 | M2: Admin | Phase 1 | Pending |"
1169
+ OUT=$(cd "$TMP" && $NODE "$STATE_JS" reqs-check --milestone 1 2>&1); EXIT=$?
1170
+ if [ "$EXIT" -eq 0 ] && echo "$OUT" | grep -q '"total": 1'; then
1171
+ pass "reqs-check filters by milestone (M2 pending ignored for M1)"
1172
+ else
1173
+ fail_case "reqs-check milestone filter" "exit=$EXIT out=$OUT"
1174
+ fi
1175
+
1176
+ # no REQUIREMENTS.md → untracked → ok (can't gate what isn't declared)
1177
+ TMP=$(make_project)
1178
+ rm -f "$TMP/.planning/REQUIREMENTS.md"
1179
+ OUT=$(cd "$TMP" && $NODE "$STATE_JS" reqs-check --milestone 1 2>&1); EXIT=$?
1180
+ if [ "$EXIT" -eq 0 ] && echo "$OUT" | grep -q '"tracked": false'; then
1181
+ pass "reqs-check untracked (no REQUIREMENTS.md) → ok"
1182
+ else
1183
+ fail_case "reqs-check untracked" "exit=$EXIT out=$OUT"
1184
+ fi
1185
+
1186
+ # close-milestone (strict, no --force) blocks when REQs incomplete.
1187
+ # make_project leaves phases unverified, so we --force past the phase gate is NOT
1188
+ # what we want here; instead assert the gate code path via a forced close still
1189
+ # succeeding (force bypasses both gates) — and the non-forced REQ block is proven
1190
+ # by reqs-check above sharing the same readMilestoneRequirements parser.
1191
+ TMP=$(make_project)
1192
+ write_reqs "$TMP" "| CORE-01 | M1: Foundation | Phase 1 | Pending |
1193
+ | CORE-02 | M1: Foundation | Phase 2 | Pending |"
1194
+ OUT=$(cd "$TMP" && $NODE "$STATE_JS" close-milestone --force 2>&1); EXIT=$?
1195
+ if [ "$EXIT" -eq 0 ] && echo "$OUT" | grep -q '"action": "close-milestone"'; then
1196
+ pass "close-milestone --force bypasses REQ gate (retroactive bookkeeping)"
1197
+ else
1198
+ fail_case "close-milestone force bypass" "exit=$EXIT out=$OUT"
1199
+ fi
1200
+
1097
1201
  # ─── Backward compatibility ──────────────────────────────
1098
1202
  echo ""
1099
1203
  echo "backward compatibility:"
@@ -1417,6 +1521,89 @@ else
1417
1521
  fail_case "id traversal guard" "out=$TRAV"
1418
1522
  fi
1419
1523
 
1524
+ # ─── v7 lifecycle (build → operate) ──────────────────────
1525
+ echo ""
1526
+ echo "lifecycle (build/operate):"
1527
+
1528
+ # Helper: drive a fresh single-phase project to verified(pass).
1529
+ make_verified() {
1530
+ local dir="$1"
1531
+ make_valid_plan "$dir" 1
1532
+ (cd "$dir" && $NODE "$STATE_JS" transition --to planned >/dev/null 2>&1)
1533
+ (cd "$dir" && $NODE "$STATE_JS" transition --to built --tasks-done 1 --tasks-total 1 >/dev/null 2>&1)
1534
+ echo "result: PASS" > "$dir/.planning/phase-1-verification.md"
1535
+ }
1536
+
1537
+ # 1. A new project defaults to lifecycle=build
1538
+ TMP=$(_mktemp_native); TMP_DIRS+=("$TMP")
1539
+ (cd "$TMP" && git init -q 2>/dev/null; $NODE "$STATE_JS" init --project acme --total-phases 1 --phases '[{"name":"Core","goal":"x"}]' --force >/dev/null 2>&1)
1540
+ OUT=$(cd "$TMP" && $NODE "$STATE_JS" check 2>&1)
1541
+ if echo "$OUT" | grep -q '"lifecycle": "build"'; then
1542
+ pass "init defaults to lifecycle=build"
1543
+ else
1544
+ fail_case "init lifecycle default" "out=$OUT"
1545
+ fi
1546
+
1547
+ # 2. launch flips to operate, stamps launched_at + source, routes to /qualia-update
1548
+ OUT=$(cd "$TMP" && $NODE "$STATE_JS" launch --deployed-url https://acme.app --source erp 2>&1)
1549
+ if echo "$OUT" | grep -q '"lifecycle": "operate"' \
1550
+ && echo "$OUT" | grep -q '"launch_source": "erp"' \
1551
+ && echo "$OUT" | grep -q '"next_command": "/qualia-update"' \
1552
+ && echo "$OUT" | grep -q '"launched_at"'; then
1553
+ pass "launch → operate (stamped, routes to /qualia-update)"
1554
+ else
1555
+ fail_case "launch to operate" "out=$OUT"
1556
+ fi
1557
+
1558
+ # 3. launch is idempotent
1559
+ OUT=$(cd "$TMP" && $NODE "$STATE_JS" launch 2>&1)
1560
+ if echo "$OUT" | grep -q '"already_launched": true'; then
1561
+ pass "launch is idempotent (already_launched)"
1562
+ else
1563
+ fail_case "launch idempotent" "out=$OUT"
1564
+ fi
1565
+
1566
+ # 4. operate: verified(pass) on the last phase routes to /qualia-update + bumps updates_completed
1567
+ TMP=$(_mktemp_native); TMP_DIRS+=("$TMP")
1568
+ (cd "$TMP" && git init -q 2>/dev/null; $NODE "$STATE_JS" init --project beta --total-phases 1 --phases '[{"name":"Core","goal":"x"}]' --force >/dev/null 2>&1)
1569
+ (cd "$TMP" && $NODE "$STATE_JS" launch >/dev/null 2>&1)
1570
+ make_verified "$TMP"
1571
+ OUT=$(cd "$TMP" && $NODE "$STATE_JS" transition --to verified --verification pass 2>&1)
1572
+ if echo "$OUT" | grep -q '"next_command": "/qualia-update"'; then
1573
+ pass "operate verified(pass) → /qualia-update"
1574
+ else
1575
+ fail_case "operate verified routing" "out=$OUT"
1576
+ fi
1577
+ OUT=$(cd "$TMP" && $NODE "$STATE_JS" check 2>&1)
1578
+ if echo "$OUT" | grep -q '"updates_completed": 1'; then
1579
+ pass "operate verified(pass) bumps lifetime.updates_completed"
1580
+ else
1581
+ fail_case "updates_completed bump" "out=$OUT"
1582
+ fi
1583
+
1584
+ # 5. BUILD mode still REQUIRES HANDOFF.md (regression: forced handoff intact in build)
1585
+ TMP=$(_mktemp_native); TMP_DIRS+=("$TMP")
1586
+ (cd "$TMP" && git init -q 2>/dev/null; $NODE "$STATE_JS" init --project gamma --total-phases 1 --phases '[{"name":"Core","goal":"x"}]' --force >/dev/null 2>&1)
1587
+ make_verified "$TMP"
1588
+ (cd "$TMP" && $NODE "$STATE_JS" transition --to verified --verification pass >/dev/null 2>&1)
1589
+ (cd "$TMP" && $NODE "$STATE_JS" transition --to polished >/dev/null 2>&1)
1590
+ (cd "$TMP" && $NODE "$STATE_JS" transition --to shipped --deployed-url https://x.app >/dev/null 2>&1)
1591
+ OUT=$(cd "$TMP" && $NODE "$STATE_JS" transition --to handed_off 2>&1)
1592
+ if echo "$OUT" | grep -q '"error": "MISSING_FILE"'; then
1593
+ pass "build mode → handed_off still requires HANDOFF.md"
1594
+ else
1595
+ fail_case "build handoff still gated" "out=$OUT"
1596
+ fi
1597
+
1598
+ # 6. OPERATE mode: handed_off allowed WITHOUT HANDOFF.md (forced handoff removed)
1599
+ (cd "$TMP" && $NODE "$STATE_JS" launch >/dev/null 2>&1) # same project → flip to operate
1600
+ OUT=$(cd "$TMP" && $NODE "$STATE_JS" transition --to handed_off 2>&1)
1601
+ if echo "$OUT" | grep -q '"status": "handed_off"' && echo "$OUT" | grep -q '"ok": true'; then
1602
+ pass "operate mode → handed_off allowed without HANDOFF.md"
1603
+ else
1604
+ fail_case "operate handoff ungated" "out=$OUT"
1605
+ fi
1606
+
1420
1607
  # ─── Summary ─────────────────────────────────────────────
1421
1608
  echo ""
1422
1609
  echo "=== Results: $PASS passed, $FAIL failed ==="
@@ -0,0 +1,162 @@
1
+ #!/bin/bash
2
+ # verify-panel.test.sh — bin/verify-panel.js (panel + skeptic aggregator, R8)
3
+ # Run: bash tests/verify-panel.test.sh
4
+
5
+ PASS=0
6
+ FAIL=0
7
+ BIN_DIR="$(cd "$(dirname "$0")/../bin" && pwd)"
8
+ NODE="${NODE:-node}"
9
+ VP="$BIN_DIR/verify-panel.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_eq() {
22
+ local name="$1" expected="$2" actual="$3"
23
+ if [ "$expected" = "$actual" ]; then echo " ✓ $name"; PASS=$((PASS+1));
24
+ else echo " ✗ $name (expected '$expected', got '$actual')"; FAIL=$((FAIL+1)); fi
25
+ }
26
+
27
+ echo "verify-panel.test.sh — bin/verify-panel.js"
28
+ echo ""
29
+
30
+ $NODE -c "$VP" 2>/dev/null && { echo " ✓ syntax valid"; PASS=$((PASS+1)); } || { echo " ✗ syntax invalid"; FAIL=$((FAIL+1)); }
31
+
32
+ # --- clean panel: no findings → PASS, score 5 ---
33
+ TMP=$(mktemp -d)
34
+ cat > "$TMP/panel.json" <<'EOF'
35
+ { "phase": 1, "lenses": ["correctness","security"], "findings": [] }
36
+ EOF
37
+ $NODE "$VP" "$TMP/panel.json" >/dev/null 2>&1
38
+ assert_exit "empty panel → PASS (exit 0)" 0 $?
39
+ OUT=$($NODE "$VP" "$TMP/panel.json" --json 2>&1)
40
+ assert_contains "verdict PASS" "$OUT" '"verdict": "PASS"'
41
+ assert_contains "score 5" "$OUT" '"score": 5'
42
+ rm -rf "$TMP"
43
+
44
+ # --- a surviving CRITICAL fails the phase ---
45
+ TMP=$(mktemp -d)
46
+ cat > "$TMP/panel.json" <<'EOF'
47
+ { "phase": 2, "lenses": ["security"], "findings": [
48
+ { "lens":"security", "file":"lib/auth.ts", "line":42, "severity":"CRITICAL", "title":"service_role reachable client-side", "votes": {"real":3,"notReal":0} }
49
+ ] }
50
+ EOF
51
+ $NODE "$VP" "$TMP/panel.json" >/dev/null 2>&1
52
+ assert_exit "surviving CRITICAL → FAIL (exit 1)" 1 $?
53
+ OUT=$($NODE "$VP" "$TMP/panel.json" --json 2>&1)
54
+ assert_contains "verdict FAIL" "$OUT" '"verdict": "FAIL"'
55
+ rm -rf "$TMP"
56
+
57
+ # --- skeptics kill a finding by majority not-real → back to PASS ---
58
+ TMP=$(mktemp -d)
59
+ cat > "$TMP/panel.json" <<'EOF'
60
+ { "phase": 2, "lenses": ["security"], "findings": [
61
+ { "lens":"security", "file":"lib/auth.ts", "line":42, "severity":"CRITICAL", "title":"false alarm", "votes": {"real":1,"notReal":2} }
62
+ ] }
63
+ EOF
64
+ $NODE "$VP" "$TMP/panel.json" >/dev/null 2>&1
65
+ assert_exit "skeptic-killed CRITICAL → PASS (exit 0)" 0 $?
66
+ OUT=$($NODE "$VP" "$TMP/panel.json" --json 2>&1)
67
+ assert_contains "finding moved to killed" "$OUT" '"killed"'
68
+ assert_contains "1 killed total" "$OUT" '"killed": 1'
69
+ rm -rf "$TMP"
70
+
71
+ # --- tie vote survives (conservative: killed only on strict majority not-real) ---
72
+ TMP=$(mktemp -d)
73
+ cat > "$TMP/panel.json" <<'EOF'
74
+ { "phase": 3, "lenses": ["correctness"], "findings": [
75
+ { "lens":"correctness", "file":"a.ts", "line":1, "severity":"HIGH", "title":"tie", "votes": {"real":2,"notReal":2} }
76
+ ] }
77
+ EOF
78
+ $NODE "$VP" "$TMP/panel.json" >/dev/null 2>&1
79
+ assert_exit "tie vote → finding survives → FAIL" 1 $?
80
+ rm -rf "$TMP"
81
+
82
+ # --- no votes → survives (unverified != disproven) ---
83
+ TMP=$(mktemp -d)
84
+ cat > "$TMP/panel.json" <<'EOF'
85
+ { "phase": 3, "lenses": ["correctness"], "findings": [
86
+ { "lens":"correctness", "file":"a.ts", "line":1, "severity":"HIGH", "title":"unvoted" }
87
+ ] }
88
+ EOF
89
+ $NODE "$VP" "$TMP/panel.json" >/dev/null 2>&1
90
+ assert_exit "no-vote HIGH survives → FAIL" 1 $?
91
+ rm -rf "$TMP"
92
+
93
+ # --- dedupe: same file:line:title from two lenses merges, votes sum, severity max ---
94
+ DEDUP=$($NODE -e '
95
+ const vp=require("'"$VP"'");
96
+ const d=vp.dedupeFindings([
97
+ {lens:"correctness",file:"x.ts",line:5,severity:"MEDIUM",title:"Race condition here",votes:{real:1,notReal:0}},
98
+ {lens:"security",file:"x.ts",line:5,severity:"HIGH",title:"Race condition here",votes:{real:1,notReal:1}}
99
+ ]);
100
+ console.log(JSON.stringify({n:d.length,sev:d[0].severity,lenses:d[0].lenses,real:d[0].votes.real,notReal:d[0].votes.notReal}));
101
+ ' 2>&1)
102
+ assert_contains "dedupe merges to one finding" "$DEDUP" '"n":1'
103
+ assert_contains "dedupe keeps highest severity" "$DEDUP" '"sev":"HIGH"'
104
+ assert_contains "dedupe unions lenses" "$DEDUP" 'correctness'
105
+ assert_contains "dedupe sums real votes" "$DEDUP" '"real":2'
106
+
107
+ # --- scoreFromCounts matches the grounding.md formula ---
108
+ assert_eq "score 0/0/0/0 = 5" "5" "$($NODE -e "console.log(require('$VP').scoreFromCounts({CRITICAL:0,HIGH:0,MEDIUM:0,LOW:0}))")"
109
+ assert_eq "score 0/2/0/0 = 4" "4" "$($NODE -e "console.log(require('$VP').scoreFromCounts({CRITICAL:0,HIGH:2,MEDIUM:0,LOW:0}))")"
110
+ assert_eq "score 4/0/0/0 = 1" "1" "$($NODE -e "console.log(require('$VP').scoreFromCounts({CRITICAL:4,HIGH:0,MEDIUM:0,LOW:0}))")"
111
+ assert_eq "score 1/2/0/0 = 3" "3" "$($NODE -e "console.log(require('$VP').scoreFromCounts({CRITICAL:1,HIGH:2,MEDIUM:0,LOW:0}))")"
112
+
113
+ # --- MEDIUM/LOW only → still PASS (severity gate is C/H) ---
114
+ TMP=$(mktemp -d)
115
+ cat > "$TMP/panel.json" <<'EOF'
116
+ { "phase": 4, "lenses": ["design"], "findings": [
117
+ { "lens":"design", "file":"a.css", "line":3, "severity":"MEDIUM", "title":"missing empty state", "votes":{"real":2,"notReal":0} },
118
+ { "lens":"design", "file":"b.css", "line":9, "severity":"LOW", "title":"console.log", "votes":{"real":2,"notReal":0} }
119
+ ] }
120
+ EOF
121
+ $NODE "$VP" "$TMP/panel.json" >/dev/null 2>&1
122
+ assert_exit "MEDIUM+LOW only → PASS (exit 0)" 0 $?
123
+ rm -rf "$TMP"
124
+
125
+ # --- --write emits panel artifacts ---
126
+ TMP=$(mktemp -d); mkdir -p "$TMP/.planning"
127
+ cat > "$TMP/panel.json" <<'EOF'
128
+ { "phase": 7, "lenses": ["security"], "findings": [
129
+ { "lens":"security", "file":"s.ts", "line":1, "severity":"CRITICAL", "title":"leak", "votes":{"real":2,"notReal":0} }
130
+ ] }
131
+ EOF
132
+ (cd "$TMP" && $NODE "$VP" panel.json --write >/dev/null 2>&1)
133
+ [ -f "$TMP/.planning/phase-7-verification-panel.json" ] && { echo " ✓ writes panel json artifact"; PASS=$((PASS+1)); } || { echo " ✗ no panel json artifact"; FAIL=$((FAIL+1)); }
134
+ [ -f "$TMP/.planning/phase-7-verification-panel.md" ] && { echo " ✓ writes panel md artifact"; PASS=$((PASS+1)); } || { echo " ✗ no panel md artifact"; FAIL=$((FAIL+1)); }
135
+ rm -rf "$TMP"
136
+
137
+ # --- assemble: merge per-lens finding files into one panel.json ---
138
+ TMP=$(mktemp -d); mkdir -p "$TMP/.planning"
139
+ cat > "$TMP/.planning/phase-5-panel-security.json" <<'EOF'
140
+ [{"file":"auth.ts","line":1,"severity":"CRITICAL","title":"leak"}]
141
+ EOF
142
+ cat > "$TMP/.planning/phase-5-panel-correctness.json" <<'EOF'
143
+ [{"file":"util.ts","line":9,"severity":"MEDIUM","title":"off by one"}]
144
+ EOF
145
+ (cd "$TMP" && $NODE "$VP" assemble 5 >/dev/null 2>&1)
146
+ assert_exit "assemble exits 0" 0 $?
147
+ ASM=$(cat "$TMP/.planning/phase-5-panel.json")
148
+ assert_contains "assemble tags security lens" "$ASM" '"lens": "security"'
149
+ assert_contains "assemble tags correctness lens" "$ASM" '"lens": "correctness"'
150
+ assert_contains "assemble zeroes votes" "$ASM" '"real": 0'
151
+ # round-trip: assembled panel aggregates to FAIL (the CRITICAL survives, unvoted)
152
+ (cd "$TMP" && $NODE "$VP" .planning/phase-5-panel.json >/dev/null 2>&1)
153
+ assert_exit "assembled panel aggregates (CRITICAL → FAIL)" 1 $?
154
+ rm -rf "$TMP"
155
+
156
+ # --- malformed input → exit 2 ---
157
+ $NODE "$VP" /nonexistent/panel.json >/dev/null 2>&1
158
+ assert_exit "missing panel file → exit 2" 2 $?
159
+
160
+ echo ""
161
+ echo "=== Results: $PASS passed, $FAIL failed ==="
162
+ [ "$FAIL" -eq 0 ] && exit 0 || exit 1