qualia-framework 4.0.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 (45) hide show
  1. package/CLAUDE.md +23 -11
  2. package/agents/plan-checker.md +1 -1
  3. package/bin/cli.js +18 -13
  4. package/bin/install.js +34 -45
  5. package/bin/qualia-ui.js +2 -2
  6. package/bin/state.js +74 -7
  7. package/bin/statusline.js +4 -1
  8. package/docs/erp-contract.md +12 -0
  9. package/guide.md +1 -1
  10. package/hooks/migration-guard.js +23 -9
  11. package/hooks/pre-compact.js +39 -11
  12. package/hooks/pre-deploy-gate.js +3 -4
  13. package/hooks/pre-push.js +6 -3
  14. package/hooks/session-start.js +8 -8
  15. package/package.json +1 -1
  16. package/rules/frontend.md +5 -13
  17. package/skills/qualia/SKILL.md +5 -0
  18. package/skills/qualia-build/SKILL.md +10 -0
  19. package/skills/qualia-debug/SKILL.md +6 -0
  20. package/skills/qualia-design/SKILL.md +9 -1
  21. package/skills/qualia-discuss/SKILL.md +6 -0
  22. package/skills/qualia-handoff/SKILL.md +5 -0
  23. package/skills/qualia-help/SKILL.md +18 -4
  24. package/skills/qualia-idk/SKILL.md +6 -0
  25. package/skills/qualia-learn/SKILL.md +6 -0
  26. package/skills/qualia-map/SKILL.md +7 -0
  27. package/skills/qualia-milestone/SKILL.md +6 -0
  28. package/skills/qualia-new/SKILL.md +13 -1
  29. package/skills/qualia-optimize/SKILL.md +8 -0
  30. package/skills/qualia-pause/SKILL.md +5 -0
  31. package/skills/qualia-plan/SKILL.md +11 -1
  32. package/skills/qualia-polish/SKILL.md +8 -0
  33. package/skills/qualia-quick/SKILL.md +7 -0
  34. package/skills/qualia-report/SKILL.md +5 -0
  35. package/skills/qualia-research/SKILL.md +7 -0
  36. package/skills/qualia-resume/SKILL.md +3 -0
  37. package/skills/qualia-review/SKILL.md +7 -0
  38. package/skills/qualia-ship/SKILL.md +5 -0
  39. package/skills/qualia-skill-new/SKILL.md +6 -0
  40. package/skills/qualia-task/SKILL.md +8 -1
  41. package/skills/qualia-test/SKILL.md +7 -0
  42. package/skills/qualia-verify/SKILL.md +8 -0
  43. package/templates/help.html +4 -4
  44. package/tests/hooks.test.sh +5 -5
  45. package/tests/runner.js +212 -3
package/tests/runner.js CHANGED
@@ -1198,6 +1198,96 @@ waves: 1
1198
1198
  fs.rmSync(tmpDir, { recursive: true, force: true });
1199
1199
  }
1200
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
+ });
1201
1291
  });
1202
1292
 
1203
1293
  // ═══════════════════════════════════════════════════════════
@@ -1455,7 +1545,7 @@ describe("Hooks", () => {
1455
1545
  const r = spawnSync(process.execPath, [path.join(HOOKS, "pre-deploy-gate.js")], {
1456
1546
  encoding: "utf8", cwd: tmpDir, timeout: 10000, stdio: ["pipe", "pipe", "pipe"],
1457
1547
  });
1458
- assert.equal(r.status, 1);
1548
+ assert.equal(r.status, 2, "PreToolUse hook must exit 2 to block");
1459
1549
  const combined = r.stdout + r.stderr;
1460
1550
  assert.match(combined, /BLOCKED/);
1461
1551
  assert.match(combined, /service_role/);
@@ -1472,7 +1562,7 @@ describe("Hooks", () => {
1472
1562
  const r = spawnSync(process.execPath, [path.join(HOOKS, "pre-deploy-gate.js")], {
1473
1563
  encoding: "utf8", cwd: tmpDir, timeout: 10000, stdio: ["pipe", "pipe", "pipe"],
1474
1564
  });
1475
- assert.equal(r.status, 1);
1565
+ assert.equal(r.status, 2, "PreToolUse hook must exit 2 to block");
1476
1566
  } finally {
1477
1567
  fs.rmSync(tmpDir, { recursive: true, force: true });
1478
1568
  }
@@ -1607,7 +1697,7 @@ describe("Hooks", () => {
1607
1697
  const r = spawnSync(process.execPath, [path.join(HOOKS, "pre-deploy-gate.js")], {
1608
1698
  encoding: "utf8", cwd: tmpDir, timeout: 10000, stdio: ["pipe", "pipe", "pipe"],
1609
1699
  });
1610
- assert.equal(r.status, 1);
1700
+ assert.equal(r.status, 2, "PreToolUse hook must exit 2 to block");
1611
1701
  } finally {
1612
1702
  fs.rmSync(tmpDir, { recursive: true, force: true });
1613
1703
  }
@@ -1843,6 +1933,28 @@ describe("Hooks", () => {
1843
1933
  assert.equal(r.status, 2, "UPDATE without WHERE must block");
1844
1934
  });
1845
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
+
1846
1958
  it("migration-guard: GRANT TO PUBLIC -> blocked", () => {
1847
1959
  const r = runHook("migration-guard.js", {
1848
1960
  tool_input: { file_path: "supabase/migrations/005.sql", content: "GRANT ALL ON users TO PUBLIC;" },
@@ -2232,6 +2344,63 @@ describe("qualia-ui.js", () => {
2232
2344
  fs.rmSync(tmpHome, { recursive: true, force: true });
2233
2345
  }
2234
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
+ });
2235
2404
  });
2236
2405
 
2237
2406
  // ═══════════════════════════════════════════════════════════
@@ -2440,6 +2609,46 @@ describe("install.js", () => {
2440
2609
  }
2441
2610
  });
2442
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
+
2443
2652
  it("templates copied to qualia-templates/", () => {
2444
2653
  const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "qualia-install-"));
2445
2654
  try {