qualia-framework 3.6.0 → 4.0.3

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 (56) hide show
  1. package/CLAUDE.md +23 -11
  2. package/README.md +96 -51
  3. package/agents/builder.md +25 -14
  4. package/agents/plan-checker.md +29 -16
  5. package/agents/planner.md +33 -24
  6. package/agents/research-synthesizer.md +25 -12
  7. package/agents/roadmapper.md +89 -84
  8. package/agents/verifier.md +11 -2
  9. package/bin/cli.js +18 -13
  10. package/bin/install.js +34 -45
  11. package/bin/qualia-ui.js +267 -1
  12. package/bin/state.js +164 -12
  13. package/bin/statusline.js +4 -1
  14. package/docs/erp-contract.md +12 -0
  15. package/guide.md +85 -22
  16. package/hooks/migration-guard.js +23 -9
  17. package/hooks/pre-compact.js +39 -11
  18. package/hooks/pre-deploy-gate.js +3 -4
  19. package/hooks/pre-push.js +6 -3
  20. package/hooks/session-start.js +8 -8
  21. package/package.json +1 -1
  22. package/rules/frontend.md +5 -13
  23. package/skills/qualia/SKILL.md +8 -1
  24. package/skills/qualia-build/SKILL.md +49 -4
  25. package/skills/qualia-debug/SKILL.md +6 -0
  26. package/skills/qualia-design/SKILL.md +9 -1
  27. package/skills/qualia-discuss/SKILL.md +6 -0
  28. package/skills/qualia-handoff/SKILL.md +92 -12
  29. package/skills/qualia-help/SKILL.md +18 -4
  30. package/skills/qualia-idk/SKILL.md +166 -0
  31. package/skills/qualia-learn/SKILL.md +6 -0
  32. package/skills/qualia-map/SKILL.md +7 -0
  33. package/skills/qualia-milestone/SKILL.md +128 -79
  34. package/skills/qualia-new/SKILL.md +163 -230
  35. package/skills/qualia-optimize/SKILL.md +8 -0
  36. package/skills/qualia-pause/SKILL.md +5 -0
  37. package/skills/qualia-plan/SKILL.md +25 -10
  38. package/skills/qualia-polish/SKILL.md +8 -0
  39. package/skills/qualia-quick/SKILL.md +7 -0
  40. package/skills/qualia-report/SKILL.md +17 -0
  41. package/skills/qualia-research/SKILL.md +7 -0
  42. package/skills/qualia-resume/SKILL.md +3 -0
  43. package/skills/qualia-review/SKILL.md +7 -0
  44. package/skills/qualia-ship/SKILL.md +5 -0
  45. package/skills/qualia-skill-new/SKILL.md +6 -0
  46. package/skills/qualia-task/SKILL.md +8 -1
  47. package/skills/qualia-test/SKILL.md +7 -0
  48. package/skills/qualia-verify/SKILL.md +65 -3
  49. package/templates/help.html +4 -4
  50. package/templates/journey.md +113 -0
  51. package/templates/plan.md +56 -11
  52. package/templates/requirements.md +82 -22
  53. package/templates/roadmap.md +41 -14
  54. package/templates/tracking.json +2 -0
  55. package/tests/hooks.test.sh +5 -5
  56. package/tests/runner.js +381 -7
package/tests/runner.js CHANGED
@@ -835,9 +835,11 @@ waves: 1
835
835
  it("init --force overwrites an existing project (preserves lifetime)", () => {
836
836
  const tmpDir = makeProject();
837
837
  try {
838
- // Seed lifetime via close-milestone first
838
+ // Seed lifetime via close-milestone first. --force bypasses the v4
839
+ // readiness guards (MILESTONE_NOT_READY) since this test doesn't
840
+ // exercise the verification flow — it's focused on lifetime preservation.
839
841
  const c = spawnSync(process.execPath, [
840
- path.join(BIN, "state.js"), "close-milestone",
842
+ path.join(BIN, "state.js"), "close-milestone", "--force",
841
843
  ], { encoding: "utf8", cwd: tmpDir, timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
842
844
  assert.equal(c.status, 0);
843
845
  const tBefore = JSON.parse(fs.readFileSync(path.join(tmpDir, ".planning", "tracking.json"), "utf8"));
@@ -863,8 +865,10 @@ waves: 1
863
865
  it("close-milestone refuses double-close (idempotency)", () => {
864
866
  const tmpDir = makeProject();
865
867
  try {
868
+ // First close uses --force to bypass v4 readiness guards — this test
869
+ // focuses on the ALREADY_CLOSED sentinel, not phase-verification gates.
866
870
  const r1 = spawnSync(process.execPath, [
867
- path.join(BIN, "state.js"), "close-milestone",
871
+ path.join(BIN, "state.js"), "close-milestone", "--force",
868
872
  ], { encoding: "utf8", cwd: tmpDir, timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
869
873
  assert.equal(r1.status, 0);
870
874
  const out1 = JSON.parse(r1.stdout);
@@ -878,6 +882,8 @@ waves: 1
878
882
  t.milestone = out1.closed_milestone; // rewind
879
883
  fs.writeFileSync(tFile, JSON.stringify(t, null, 2) + "\n");
880
884
 
885
+ // Second close (without --force) must fail with ALREADY_CLOSED, which
886
+ // is checked BEFORE the readiness guards in cmdCloseMilestone.
881
887
  const r2 = spawnSync(process.execPath, [
882
888
  path.join(BIN, "state.js"), "close-milestone",
883
889
  ], { encoding: "utf8", cwd: tmpDir, timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
@@ -893,7 +899,7 @@ waves: 1
893
899
  const tmpDir = makeProject();
894
900
  try {
895
901
  const r1 = spawnSync(process.execPath, [
896
- path.join(BIN, "state.js"), "close-milestone",
902
+ path.join(BIN, "state.js"), "close-milestone", "--force",
897
903
  ], { encoding: "utf8", cwd: tmpDir, timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
898
904
  assert.equal(r1.status, 0);
899
905
 
@@ -1002,6 +1008,165 @@ waves: 1
1002
1008
  }
1003
1009
  });
1004
1010
 
1011
+ // ─── v4.0.0: milestone readiness guards + milestones[] summary ─
1012
+ it("close-milestone refuses unverified phases (MILESTONE_NOT_READY)", () => {
1013
+ const tmpDir = makeProject();
1014
+ try {
1015
+ // No phases verified yet — close-milestone (without --force) must refuse.
1016
+ const r = spawnSync(process.execPath, [
1017
+ path.join(BIN, "state.js"), "close-milestone",
1018
+ ], { encoding: "utf8", cwd: tmpDir, timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
1019
+ assert.equal(r.status, 1);
1020
+ const out = JSON.parse(r.stdout);
1021
+ assert.equal(out.error, "MILESTONE_NOT_READY");
1022
+ } finally {
1023
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1024
+ }
1025
+ });
1026
+
1027
+ it("close-milestone refuses single-phase milestones (MILESTONE_TOO_SMALL)", () => {
1028
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "qualia-single-"));
1029
+ try {
1030
+ const init = spawnSync(process.execPath, [
1031
+ path.join(BIN, "state.js"), "init",
1032
+ "--project", "SingleProject",
1033
+ "--phases", '[{"name":"Only","goal":"Y"}]',
1034
+ ], { encoding: "utf8", cwd: tmpDir, timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
1035
+ assert.equal(init.status, 0);
1036
+
1037
+ // Single-phase milestone — even if the phase were verified, the size
1038
+ // guard catches it first. A milestone needs ≥ 2 phases without --force.
1039
+ const r = spawnSync(process.execPath, [
1040
+ path.join(BIN, "state.js"), "close-milestone",
1041
+ ], { encoding: "utf8", cwd: tmpDir, timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
1042
+ assert.equal(r.status, 1);
1043
+ const out = JSON.parse(r.stdout);
1044
+ assert.equal(out.error, "MILESTONE_TOO_SMALL");
1045
+ } finally {
1046
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1047
+ }
1048
+ });
1049
+
1050
+ it("close-milestone appends a summary to milestones[]", () => {
1051
+ const tmpDir = makeProject();
1052
+ try {
1053
+ const r = spawnSync(process.execPath, [
1054
+ path.join(BIN, "state.js"), "close-milestone", "--force",
1055
+ ], { encoding: "utf8", cwd: tmpDir, timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
1056
+ assert.equal(r.status, 0);
1057
+
1058
+ const tFile = path.join(tmpDir, ".planning", "tracking.json");
1059
+ const t = JSON.parse(fs.readFileSync(tFile, "utf8"));
1060
+ assert.ok(Array.isArray(t.milestones), "milestones[] must exist");
1061
+ assert.equal(t.milestones.length, 1);
1062
+ const m1 = t.milestones[0];
1063
+ assert.equal(m1.num, 1);
1064
+ assert.ok(m1.total_phases >= 2, "total_phases should reflect seeded phases");
1065
+ assert.ok(typeof m1.closed_at === "string" && m1.closed_at.length > 0);
1066
+ } finally {
1067
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1068
+ }
1069
+ });
1070
+
1071
+ it("milestone summary captures cumulative tasks_completed, not current phase only", () => {
1072
+ const tmpDir = makeProject();
1073
+ try {
1074
+ // Simulate 2 phases each with 3 tasks verified pass. This bumps
1075
+ // lifetime.tasks_completed to 6. The milestone close summary should
1076
+ // reflect 6, not just 3 (the last phase's tasks_done).
1077
+ for (const phase of [1, 2]) {
1078
+ const planFile = path.join(tmpDir, ".planning", `phase-${phase}-plan.md`);
1079
+ fs.writeFileSync(planFile, `---
1080
+ phase: ${phase}
1081
+ goal: "x"
1082
+ tasks: 1
1083
+ waves: 1
1084
+ ---
1085
+
1086
+ ## Task 1 — x
1087
+ **Wave:** 1
1088
+ **Files:** x.ts
1089
+ **Depends on:** none
1090
+ **Acceptance Criteria:**
1091
+ - ok
1092
+ `);
1093
+ const verFile = path.join(tmpDir, ".planning", `phase-${phase}-verification.md`);
1094
+ // Plan → built → verified
1095
+ let r = spawnSync(process.execPath, [path.join(BIN, "state.js"), "transition", "--to", "planned", "--phase", String(phase)],
1096
+ { encoding: "utf8", cwd: tmpDir, timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
1097
+ assert.equal(r.status, 0, `planned transition failed for phase ${phase}: ${r.stderr || r.stdout}`);
1098
+ r = spawnSync(process.execPath, [path.join(BIN, "state.js"), "transition", "--to", "built", "--phase", String(phase), "--tasks-done", "3", "--tasks-total", "3"],
1099
+ { encoding: "utf8", cwd: tmpDir, timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
1100
+ assert.equal(r.status, 0, `built transition failed for phase ${phase}: ${r.stderr || r.stdout}`);
1101
+ fs.writeFileSync(verFile, "result: PASS");
1102
+ r = spawnSync(process.execPath, [path.join(BIN, "state.js"), "transition", "--to", "verified", "--phase", String(phase), "--verification", "pass"],
1103
+ { encoding: "utf8", cwd: tmpDir, timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
1104
+ assert.equal(r.status, 0, `verified transition failed for phase ${phase}: ${r.stderr || r.stdout}`);
1105
+ }
1106
+
1107
+ // Close milestone
1108
+ const r = spawnSync(process.execPath, [path.join(BIN, "state.js"), "close-milestone"],
1109
+ { encoding: "utf8", cwd: tmpDir, timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
1110
+ assert.equal(r.status, 0, `close-milestone failed: ${r.stderr || r.stdout}`);
1111
+
1112
+ const t = JSON.parse(fs.readFileSync(path.join(tmpDir, ".planning", "tracking.json"), "utf8"));
1113
+ assert.equal(t.lifetime.tasks_completed, 6, "lifetime should have 6 tasks (2 phases × 3 tasks)");
1114
+ assert.equal(t.milestones.length, 1);
1115
+ assert.equal(t.milestones[0].tasks_completed, 6, "milestone summary should cumulate all 6 tasks, not just the last phase's 3");
1116
+ assert.equal(t.milestones[0].phases_completed, 2);
1117
+ } finally {
1118
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1119
+ }
1120
+ });
1121
+
1122
+ it("build_count bumps on each 'built' transition", () => {
1123
+ const tmpDir = makeProject();
1124
+ try {
1125
+ const tFile = path.join(tmpDir, ".planning", "tracking.json");
1126
+ const before = JSON.parse(fs.readFileSync(tFile, "utf8")).build_count || 0;
1127
+
1128
+ fs.writeFileSync(path.join(tmpDir, ".planning", "phase-1-plan.md"), `---
1129
+ phase: 1
1130
+ goal: "x"
1131
+ tasks: 1
1132
+ waves: 1
1133
+ ---
1134
+
1135
+ ## Task 1 — x
1136
+ **Wave:** 1
1137
+ **Files:** x.ts
1138
+ **Depends on:** none
1139
+ **Acceptance Criteria:**
1140
+ - ok
1141
+ `);
1142
+ spawnSync(process.execPath, [path.join(BIN, "state.js"), "transition", "--to", "planned", "--phase", "1"],
1143
+ { encoding: "utf8", cwd: tmpDir, timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
1144
+ const r = spawnSync(process.execPath, [path.join(BIN, "state.js"), "transition", "--to", "built", "--phase", "1", "--tasks-done", "1", "--tasks-total", "1"],
1145
+ { encoding: "utf8", cwd: tmpDir, timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
1146
+ assert.equal(r.status, 0);
1147
+
1148
+ const after = JSON.parse(fs.readFileSync(tFile, "utf8")).build_count || 0;
1149
+ assert.equal(after, before + 1, "build_count should bump on 'built' transition");
1150
+ } finally {
1151
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1152
+ }
1153
+ });
1154
+
1155
+ it("check exposes milestones[] and milestone_name in output", () => {
1156
+ const tmpDir = makeProject();
1157
+ try {
1158
+ const r = spawnSync(process.execPath, [
1159
+ path.join(BIN, "state.js"), "check",
1160
+ ], { encoding: "utf8", cwd: tmpDir, timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
1161
+ assert.equal(r.status, 0);
1162
+ const out = JSON.parse(r.stdout);
1163
+ assert.ok(Array.isArray(out.milestones), "check must expose milestones[]");
1164
+ assert.ok(typeof out.milestone_name === "string", "check must expose milestone_name");
1165
+ } finally {
1166
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1167
+ }
1168
+ });
1169
+
1005
1170
  // ─── v3.5.0: CRLF tolerance in parseStateMd ────────────
1006
1171
  it("parseStateMd tolerates CRLF line endings (Windows-edited STATE.md)", () => {
1007
1172
  const tmpDir = makeProject();
@@ -1033,6 +1198,96 @@ waves: 1
1033
1198
  fs.rmSync(tmpDir, { recursive: true, force: true });
1034
1199
  }
1035
1200
  });
1201
+
1202
+ // ─── v4 regression: deploy_count actually increments on shipped ───
1203
+ it("transition --to shipped increments deploy_count", () => {
1204
+ const tmpDir = makeProject();
1205
+ try {
1206
+ // Walk both phases through verified, then polished, then shipped.
1207
+ makeValidPlan(tmpDir, 1);
1208
+ runState(["transition", "--to", "planned"], tmpDir);
1209
+ runState(["transition", "--to", "built", "--tasks-done", "1", "--tasks-total", "1"], tmpDir);
1210
+ fs.writeFileSync(path.join(tmpDir, ".planning", "phase-1-verification.md"), "# pass\n");
1211
+ runState(["transition", "--to", "verified", "--verification", "pass"], tmpDir);
1212
+
1213
+ makeValidPlan(tmpDir, 2);
1214
+ runState(["transition", "--to", "planned"], tmpDir);
1215
+ runState(["transition", "--to", "built", "--tasks-done", "1", "--tasks-total", "1"], tmpDir);
1216
+ fs.writeFileSync(path.join(tmpDir, ".planning", "phase-2-verification.md"), "# pass\n");
1217
+ runState(["transition", "--to", "verified", "--verification", "pass"], tmpDir);
1218
+ runState(["transition", "--to", "polished"], tmpDir);
1219
+
1220
+ const before = JSON.parse(fs.readFileSync(path.join(tmpDir, ".planning", "tracking.json"), "utf8"));
1221
+ assert.equal(parseInt(before.deploy_count) || 0, 0, "deploy_count starts at 0");
1222
+
1223
+ const r = runState(["transition", "--to", "shipped", "--deployed-url", "https://x.test"], tmpDir);
1224
+ assert.equal(r.status, 0, `shipped transition failed: ${r.stdout} ${r.stderr}`);
1225
+ const after = JSON.parse(fs.readFileSync(path.join(tmpDir, ".planning", "tracking.json"), "utf8"));
1226
+ assert.equal(parseInt(after.deploy_count), 1, "deploy_count must increment to 1");
1227
+ assert.equal(after.deployed_url, "https://x.test");
1228
+ } finally {
1229
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1230
+ }
1231
+ });
1232
+
1233
+ // ─── v4.0.2: write-ahead journal recovery ─────────────────
1234
+ // Simulate a crashed previous mutator by dropping a .state.journal file
1235
+ // with pre-transition snapshots of STATE.md and tracking.json. The next
1236
+ // mutator invocation must restore both files from the journal and remove it.
1237
+ it("recovers STATE.md + tracking.json from .state.journal on next mutator", () => {
1238
+ const tmpDir = makeProject();
1239
+ try {
1240
+ const statePath = path.join(tmpDir, ".planning", "STATE.md");
1241
+ const trackPath = path.join(tmpDir, ".planning", "tracking.json");
1242
+ const journalPath = path.join(tmpDir, ".planning", ".state.journal");
1243
+
1244
+ const origState = fs.readFileSync(statePath, "utf8");
1245
+ const origTracking = fs.readFileSync(trackPath, "utf8");
1246
+
1247
+ // Corrupt STATE.md and tracking.json to simulate a half-completed write.
1248
+ fs.writeFileSync(statePath, "# CORRUPTED\n");
1249
+ fs.writeFileSync(trackPath, '{"corrupt":true}\n');
1250
+
1251
+ // Drop a journal that would have been written before the corruption.
1252
+ fs.writeFileSync(journalPath, JSON.stringify({
1253
+ ts: new Date().toISOString(),
1254
+ pid: 99999,
1255
+ state: origState,
1256
+ tracking: origTracking,
1257
+ }));
1258
+
1259
+ // Any mutator should trigger recovery. Use `fix` (a cheap mutator).
1260
+ const r = runState(["fix"], tmpDir);
1261
+ // Not asserting r.status — fix may succeed or report nothing to fix.
1262
+ // What matters: STATE.md and tracking.json were restored and journal is gone.
1263
+ assert.equal(fs.existsSync(journalPath), false, "journal must be removed after recovery");
1264
+ const recoveredState = fs.readFileSync(statePath, "utf8");
1265
+ const recoveredTracking = fs.readFileSync(trackPath, "utf8");
1266
+ assert.ok(recoveredState.includes("Current Position") || recoveredState === origState,
1267
+ "STATE.md must be restored from journal");
1268
+ assert.notStrictEqual(recoveredTracking, '{"corrupt":true}\n',
1269
+ "tracking.json must no longer be the corrupted snapshot");
1270
+ } finally {
1271
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1272
+ }
1273
+ });
1274
+
1275
+ // ─── v4.0.2: corrupt journal is tolerated, not fatal ──────
1276
+ it("corrupt .state.journal is cleared without crashing mutator", () => {
1277
+ const tmpDir = makeProject();
1278
+ try {
1279
+ const journalPath = path.join(tmpDir, ".planning", ".state.journal");
1280
+ fs.writeFileSync(journalPath, "{not valid json");
1281
+ const r = runState(["check"], tmpDir);
1282
+ // check is read-only so it won't recover; use a mutator.
1283
+ runState(["fix"], tmpDir);
1284
+ assert.equal(fs.existsSync(journalPath), false,
1285
+ "corrupt journal must be cleaned up so we don't loop on recovery");
1286
+ assert.equal(r.status, 0, "check should still work with a stray journal file");
1287
+ } finally {
1288
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1289
+ }
1290
+ });
1036
1291
  });
1037
1292
 
1038
1293
  // ═══════════════════════════════════════════════════════════
@@ -1290,7 +1545,7 @@ describe("Hooks", () => {
1290
1545
  const r = spawnSync(process.execPath, [path.join(HOOKS, "pre-deploy-gate.js")], {
1291
1546
  encoding: "utf8", cwd: tmpDir, timeout: 10000, stdio: ["pipe", "pipe", "pipe"],
1292
1547
  });
1293
- assert.equal(r.status, 1);
1548
+ assert.equal(r.status, 2, "PreToolUse hook must exit 2 to block");
1294
1549
  const combined = r.stdout + r.stderr;
1295
1550
  assert.match(combined, /BLOCKED/);
1296
1551
  assert.match(combined, /service_role/);
@@ -1307,7 +1562,7 @@ describe("Hooks", () => {
1307
1562
  const r = spawnSync(process.execPath, [path.join(HOOKS, "pre-deploy-gate.js")], {
1308
1563
  encoding: "utf8", cwd: tmpDir, timeout: 10000, stdio: ["pipe", "pipe", "pipe"],
1309
1564
  });
1310
- assert.equal(r.status, 1);
1565
+ assert.equal(r.status, 2, "PreToolUse hook must exit 2 to block");
1311
1566
  } finally {
1312
1567
  fs.rmSync(tmpDir, { recursive: true, force: true });
1313
1568
  }
@@ -1442,7 +1697,7 @@ describe("Hooks", () => {
1442
1697
  const r = spawnSync(process.execPath, [path.join(HOOKS, "pre-deploy-gate.js")], {
1443
1698
  encoding: "utf8", cwd: tmpDir, timeout: 10000, stdio: ["pipe", "pipe", "pipe"],
1444
1699
  });
1445
- assert.equal(r.status, 1);
1700
+ assert.equal(r.status, 2, "PreToolUse hook must exit 2 to block");
1446
1701
  } finally {
1447
1702
  fs.rmSync(tmpDir, { recursive: true, force: true });
1448
1703
  }
@@ -1678,6 +1933,28 @@ describe("Hooks", () => {
1678
1933
  assert.equal(r.status, 2, "UPDATE without WHERE must block");
1679
1934
  });
1680
1935
 
1936
+ // v4.0.2: per-statement scan (previously a WHERE in ANY later statement
1937
+ // made an unsafe DELETE pass).
1938
+ it("migration-guard: DELETE FROM followed by unrelated SELECT WHERE -> blocked", () => {
1939
+ const r = runHook("migration-guard.js", {
1940
+ tool_input: {
1941
+ file_path: "supabase/migrations/004b.sql",
1942
+ content: "DELETE FROM users;\nSELECT * FROM logs WHERE ts > NOW();",
1943
+ },
1944
+ });
1945
+ assert.equal(r.status, 2, "per-statement scan must still catch the DELETE without WHERE");
1946
+ });
1947
+
1948
+ it("migration-guard: UPDATE SET without WHERE followed by unrelated WHERE -> blocked", () => {
1949
+ const r = runHook("migration-guard.js", {
1950
+ tool_input: {
1951
+ file_path: "supabase/migrations/004c.sql",
1952
+ content: "UPDATE accounts SET active = true;\nSELECT id FROM sessions WHERE expires > NOW();",
1953
+ },
1954
+ });
1955
+ assert.equal(r.status, 2, "per-statement scan must catch the UPDATE without WHERE");
1956
+ });
1957
+
1681
1958
  it("migration-guard: GRANT TO PUBLIC -> blocked", () => {
1682
1959
  const r = runHook("migration-guard.js", {
1683
1960
  tool_input: { file_path: "supabase/migrations/005.sql", content: "GRANT ALL ON users TO PUBLIC;" },
@@ -2067,6 +2344,63 @@ describe("qualia-ui.js", () => {
2067
2344
  fs.rmSync(tmpHome, { recursive: true, force: true });
2068
2345
  }
2069
2346
  });
2347
+
2348
+ // ─── v4 regression: journey-tree renders without crashing ───
2349
+ // Previously crashed with "Cannot access 'projectName' before initialization"
2350
+ // because a const shadowed the fallback function inside its own initializer.
2351
+ it("journey-tree renders milestones without crashing", () => {
2352
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "qualia-jt-"));
2353
+ try {
2354
+ fs.mkdirSync(path.join(tmpDir, ".planning"), { recursive: true });
2355
+ fs.writeFileSync(
2356
+ path.join(tmpDir, ".planning", "JOURNEY.md"),
2357
+ "# JOURNEY\n\n## Milestone 1 · Foundation\n\nWhy now.\n\n## Milestone 2 · Handoff\n\nDeliver.\n"
2358
+ );
2359
+ fs.writeFileSync(
2360
+ path.join(tmpDir, ".planning", "tracking.json"),
2361
+ JSON.stringify({ project: "jtproj", milestone: 1, milestones: [] })
2362
+ );
2363
+ fs.writeFileSync(
2364
+ path.join(tmpDir, ".planning", "STATE.md"),
2365
+ "---\nproject: jtproj\nphase: 1\nstatus: planning\nmilestone: 1\n---\n"
2366
+ );
2367
+ const r = runUI(["journey-tree"], { cwd: tmpDir, home: tmpDir });
2368
+ assert.equal(r.status, 0, `journey-tree crashed: ${r.stderr}`);
2369
+ const clean = stripAnsi(r.stdout);
2370
+ assert.match(clean, /JOURNEY/);
2371
+ assert.match(clean, /M1 · Foundation/);
2372
+ assert.match(clean, /M2 · Handoff/);
2373
+ assert.match(clean, /\[CURRENT\]/);
2374
+ } finally {
2375
+ fs.rmSync(tmpDir, { recursive: true, force: true });
2376
+ }
2377
+ });
2378
+
2379
+ // ─── v4 regression: journey-tree uses projectName() fallback when frontmatter missing ───
2380
+ // Would previously throw ReferenceError because `const projectName` shadowed the
2381
+ // function name inside its own initializer. Fallback resolves to basename(cwd).
2382
+ it("journey-tree uses projectName() fallback when no project: in JOURNEY frontmatter", () => {
2383
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "qualia-jt-fallback-"));
2384
+ try {
2385
+ fs.mkdirSync(path.join(tmpDir, ".planning"), { recursive: true });
2386
+ fs.writeFileSync(
2387
+ path.join(tmpDir, ".planning", "JOURNEY.md"),
2388
+ "# JOURNEY\n\n## Milestone 1 · Foundation\n\nWhy now.\n\n## Milestone 2 · Handoff\n\nLast.\n"
2389
+ );
2390
+ fs.writeFileSync(
2391
+ path.join(tmpDir, ".planning", "tracking.json"),
2392
+ JSON.stringify({ project: "ignored-by-fallback", milestone: 1 })
2393
+ );
2394
+ const r = runUI(["journey-tree"], { cwd: tmpDir, home: tmpDir });
2395
+ assert.equal(r.status, 0, `journey-tree crashed: ${r.stderr}`);
2396
+ const clean = stripAnsi(r.stdout);
2397
+ // Fallback is path.basename(cwd) — whatever the tmp dir is named.
2398
+ assert.match(clean, new RegExp(path.basename(tmpDir)));
2399
+ assert.match(clean, /M1 · Foundation/);
2400
+ } finally {
2401
+ fs.rmSync(tmpDir, { recursive: true, force: true });
2402
+ }
2403
+ });
2070
2404
  });
2071
2405
 
2072
2406
  // ═══════════════════════════════════════════════════════════
@@ -2275,6 +2609,46 @@ describe("install.js", () => {
2275
2609
  }
2276
2610
  });
2277
2611
 
2612
+ // v4.0.2: reinstall merges hooks instead of clobbering.
2613
+ it("re-install preserves user-added hooks in settings.json", () => {
2614
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "qualia-install-"));
2615
+ try {
2616
+ // Fresh install first, then inject a user-owned hook, then reinstall.
2617
+ runInstall("QS-FAWZI-01", tmpHome);
2618
+ const settingsPath = path.join(tmpHome, ".claude", "settings.json");
2619
+ const before = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
2620
+
2621
+ // Add a user hook to PreToolUse that is not a Qualia command.
2622
+ const userHook = {
2623
+ matcher: "Bash",
2624
+ hooks: [
2625
+ { type: "command", command: "echo user-owned-pre-tool-hook", timeout: 3 },
2626
+ ],
2627
+ };
2628
+ before.hooks.PreToolUse = [userHook, ...(before.hooks.PreToolUse || [])];
2629
+ fs.writeFileSync(settingsPath, JSON.stringify(before, null, 2));
2630
+
2631
+ const r = runInstall("QS-FAWZI-01", tmpHome);
2632
+ assert.equal(r.status, 0);
2633
+ const after = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
2634
+ const allCmds = [];
2635
+ for (const block of after.hooks.PreToolUse || []) {
2636
+ for (const h of (block.hooks || [])) allCmds.push(String(h.command || ""));
2637
+ }
2638
+ assert.ok(
2639
+ allCmds.some((c) => c.includes("user-owned-pre-tool-hook")),
2640
+ `user hook was clobbered by reinstall. Commands: ${allCmds.join(" | ")}`
2641
+ );
2642
+ // And Qualia hooks should still be there.
2643
+ assert.ok(
2644
+ allCmds.some((c) => c.includes("branch-guard.js")),
2645
+ "Qualia hooks must still be present after reinstall"
2646
+ );
2647
+ } finally {
2648
+ fs.rmSync(tmpHome, { recursive: true, force: true });
2649
+ }
2650
+ });
2651
+
2278
2652
  it("templates copied to qualia-templates/", () => {
2279
2653
  const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "qualia-install-"));
2280
2654
  try {