qualia-framework 4.0.0 → 4.0.5

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 (47) hide show
  1. package/CLAUDE.md +23 -11
  2. package/agents/plan-checker.md +1 -1
  3. package/agents/roadmapper.md +10 -5
  4. package/bin/cli.js +139 -17
  5. package/bin/install.js +47 -47
  6. package/bin/qualia-ui.js +2 -2
  7. package/bin/state.js +126 -9
  8. package/bin/statusline.js +63 -38
  9. package/docs/erp-contract.md +49 -2
  10. package/guide.md +1 -1
  11. package/hooks/migration-guard.js +23 -9
  12. package/hooks/pre-compact.js +39 -11
  13. package/hooks/pre-deploy-gate.js +3 -4
  14. package/hooks/pre-push.js +6 -3
  15. package/hooks/session-start.js +8 -8
  16. package/package.json +1 -1
  17. package/rules/frontend.md +5 -13
  18. package/skills/qualia/SKILL.md +5 -0
  19. package/skills/qualia-build/SKILL.md +10 -0
  20. package/skills/qualia-debug/SKILL.md +6 -0
  21. package/skills/qualia-design/SKILL.md +9 -1
  22. package/skills/qualia-discuss/SKILL.md +6 -0
  23. package/skills/qualia-handoff/SKILL.md +5 -0
  24. package/skills/qualia-help/SKILL.md +18 -4
  25. package/skills/qualia-idk/SKILL.md +6 -0
  26. package/skills/qualia-learn/SKILL.md +6 -0
  27. package/skills/qualia-map/SKILL.md +7 -0
  28. package/skills/qualia-milestone/SKILL.md +6 -0
  29. package/skills/qualia-new/SKILL.md +31 -4
  30. package/skills/qualia-optimize/SKILL.md +8 -0
  31. package/skills/qualia-pause/SKILL.md +5 -0
  32. package/skills/qualia-plan/SKILL.md +11 -1
  33. package/skills/qualia-polish/SKILL.md +8 -0
  34. package/skills/qualia-quick/SKILL.md +7 -0
  35. package/skills/qualia-report/SKILL.md +146 -60
  36. package/skills/qualia-research/SKILL.md +7 -0
  37. package/skills/qualia-resume/SKILL.md +3 -0
  38. package/skills/qualia-review/SKILL.md +7 -0
  39. package/skills/qualia-ship/SKILL.md +5 -0
  40. package/skills/qualia-skill-new/SKILL.md +6 -0
  41. package/skills/qualia-task/SKILL.md +8 -1
  42. package/skills/qualia-test/SKILL.md +7 -0
  43. package/skills/qualia-verify/SKILL.md +8 -0
  44. package/templates/help.html +4 -4
  45. package/templates/tracking.json +1 -0
  46. package/tests/hooks.test.sh +5 -5
  47. package/tests/runner.js +310 -3
package/tests/runner.js CHANGED
@@ -1198,6 +1198,194 @@ 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
+ });
1291
+
1292
+ // ─── v4.0.4: next-report-id ────────────────────────────────
1293
+ it("next-report-id returns QS-REPORT-01 on fresh project and increments", () => {
1294
+ const tmpDir = makeProject();
1295
+ try {
1296
+ const r1 = spawnSync(process.execPath,
1297
+ [path.join(BIN, "state.js"), "next-report-id"],
1298
+ { encoding: "utf8", cwd: tmpDir, timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
1299
+ assert.equal(r1.status, 0, `next-report-id failed: ${r1.stderr || r1.stdout}`);
1300
+ const j1 = JSON.parse(r1.stdout);
1301
+ assert.equal(j1.report_id, "QS-REPORT-01");
1302
+ assert.equal(j1.report_seq, 1);
1303
+ assert.equal(j1.peeked, false);
1304
+
1305
+ const r2 = spawnSync(process.execPath,
1306
+ [path.join(BIN, "state.js"), "next-report-id"],
1307
+ { encoding: "utf8", cwd: tmpDir, timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
1308
+ const j2 = JSON.parse(r2.stdout);
1309
+ assert.equal(j2.report_id, "QS-REPORT-02");
1310
+ assert.equal(j2.report_seq, 2);
1311
+ } finally {
1312
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1313
+ }
1314
+ });
1315
+
1316
+ it("next-report-id --peek does NOT increment the counter", () => {
1317
+ const tmpDir = makeProject();
1318
+ try {
1319
+ const r1 = spawnSync(process.execPath,
1320
+ [path.join(BIN, "state.js"), "next-report-id", "--peek"],
1321
+ { encoding: "utf8", cwd: tmpDir, timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
1322
+ const j1 = JSON.parse(r1.stdout);
1323
+ assert.equal(j1.report_id, "QS-REPORT-01");
1324
+ assert.equal(j1.peeked, true);
1325
+
1326
+ // Peek again — should still return QS-REPORT-01 since nothing incremented
1327
+ const r2 = spawnSync(process.execPath,
1328
+ [path.join(BIN, "state.js"), "next-report-id", "--peek"],
1329
+ { encoding: "utf8", cwd: tmpDir, timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
1330
+ const j2 = JSON.parse(r2.stdout);
1331
+ assert.equal(j2.report_id, "QS-REPORT-01");
1332
+ assert.equal(j2.report_seq, 1);
1333
+
1334
+ // On-disk report_seq should still be 0
1335
+ const t = JSON.parse(fs.readFileSync(path.join(tmpDir, ".planning", "tracking.json"), "utf8"));
1336
+ assert.ok(!t.report_seq || t.report_seq === 0,
1337
+ `report_seq should remain 0 after peek, got ${t.report_seq}`);
1338
+ } finally {
1339
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1340
+ }
1341
+ });
1342
+
1343
+ // ─── v4.0.4: close-milestone pre-populates next milestone_name from JOURNEY.md
1344
+ it("close-milestone pre-populates next milestone_name from JOURNEY.md", () => {
1345
+ const tmpDir = makeProject();
1346
+ try {
1347
+ // Write JOURNEY.md with Milestone 2 definition
1348
+ fs.writeFileSync(path.join(tmpDir, ".planning", "JOURNEY.md"), `# Journey
1349
+
1350
+ ## Milestone 1 · Foundation [CURRENT]
1351
+ Exit: scaffolding done
1352
+
1353
+ ## Milestone 2 · Core Features
1354
+ Exit: auth + dashboard
1355
+
1356
+ ## Milestone 3 · Handoff [FINAL]
1357
+ Exit: client takeover
1358
+ `);
1359
+ const r = spawnSync(process.execPath,
1360
+ [path.join(BIN, "state.js"), "close-milestone", "--force"],
1361
+ { encoding: "utf8", cwd: tmpDir, timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
1362
+ assert.equal(r.status, 0, `close-milestone failed: ${r.stderr || r.stdout}`);
1363
+
1364
+ const t = JSON.parse(fs.readFileSync(path.join(tmpDir, ".planning", "tracking.json"), "utf8"));
1365
+ assert.equal(t.milestone, 2);
1366
+ assert.equal(t.milestone_name, "Core Features",
1367
+ `milestone_name should be pre-populated from JOURNEY.md, got '${t.milestone_name}'`);
1368
+ } finally {
1369
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1370
+ }
1371
+ });
1372
+
1373
+ it("close-milestone leaves milestone_name blank when JOURNEY.md is missing", () => {
1374
+ const tmpDir = makeProject();
1375
+ try {
1376
+ // No JOURNEY.md — milestone_name should fall back to blank (legacy behavior)
1377
+ const r = spawnSync(process.execPath,
1378
+ [path.join(BIN, "state.js"), "close-milestone", "--force"],
1379
+ { encoding: "utf8", cwd: tmpDir, timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
1380
+ assert.equal(r.status, 0);
1381
+
1382
+ const t = JSON.parse(fs.readFileSync(path.join(tmpDir, ".planning", "tracking.json"), "utf8"));
1383
+ assert.equal(t.milestone_name, "",
1384
+ "milestone_name must be blank when JOURNEY.md is absent (fallback unchanged)");
1385
+ } finally {
1386
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1387
+ }
1388
+ });
1201
1389
  });
1202
1390
 
1203
1391
  // ═══════════════════════════════════════════════════════════
@@ -1455,7 +1643,7 @@ describe("Hooks", () => {
1455
1643
  const r = spawnSync(process.execPath, [path.join(HOOKS, "pre-deploy-gate.js")], {
1456
1644
  encoding: "utf8", cwd: tmpDir, timeout: 10000, stdio: ["pipe", "pipe", "pipe"],
1457
1645
  });
1458
- assert.equal(r.status, 1);
1646
+ assert.equal(r.status, 2, "PreToolUse hook must exit 2 to block");
1459
1647
  const combined = r.stdout + r.stderr;
1460
1648
  assert.match(combined, /BLOCKED/);
1461
1649
  assert.match(combined, /service_role/);
@@ -1472,7 +1660,7 @@ describe("Hooks", () => {
1472
1660
  const r = spawnSync(process.execPath, [path.join(HOOKS, "pre-deploy-gate.js")], {
1473
1661
  encoding: "utf8", cwd: tmpDir, timeout: 10000, stdio: ["pipe", "pipe", "pipe"],
1474
1662
  });
1475
- assert.equal(r.status, 1);
1663
+ assert.equal(r.status, 2, "PreToolUse hook must exit 2 to block");
1476
1664
  } finally {
1477
1665
  fs.rmSync(tmpDir, { recursive: true, force: true });
1478
1666
  }
@@ -1607,7 +1795,7 @@ describe("Hooks", () => {
1607
1795
  const r = spawnSync(process.execPath, [path.join(HOOKS, "pre-deploy-gate.js")], {
1608
1796
  encoding: "utf8", cwd: tmpDir, timeout: 10000, stdio: ["pipe", "pipe", "pipe"],
1609
1797
  });
1610
- assert.equal(r.status, 1);
1798
+ assert.equal(r.status, 2, "PreToolUse hook must exit 2 to block");
1611
1799
  } finally {
1612
1800
  fs.rmSync(tmpDir, { recursive: true, force: true });
1613
1801
  }
@@ -1843,6 +2031,28 @@ describe("Hooks", () => {
1843
2031
  assert.equal(r.status, 2, "UPDATE without WHERE must block");
1844
2032
  });
1845
2033
 
2034
+ // v4.0.2: per-statement scan (previously a WHERE in ANY later statement
2035
+ // made an unsafe DELETE pass).
2036
+ it("migration-guard: DELETE FROM followed by unrelated SELECT WHERE -> blocked", () => {
2037
+ const r = runHook("migration-guard.js", {
2038
+ tool_input: {
2039
+ file_path: "supabase/migrations/004b.sql",
2040
+ content: "DELETE FROM users;\nSELECT * FROM logs WHERE ts > NOW();",
2041
+ },
2042
+ });
2043
+ assert.equal(r.status, 2, "per-statement scan must still catch the DELETE without WHERE");
2044
+ });
2045
+
2046
+ it("migration-guard: UPDATE SET without WHERE followed by unrelated WHERE -> blocked", () => {
2047
+ const r = runHook("migration-guard.js", {
2048
+ tool_input: {
2049
+ file_path: "supabase/migrations/004c.sql",
2050
+ content: "UPDATE accounts SET active = true;\nSELECT id FROM sessions WHERE expires > NOW();",
2051
+ },
2052
+ });
2053
+ assert.equal(r.status, 2, "per-statement scan must catch the UPDATE without WHERE");
2054
+ });
2055
+
1846
2056
  it("migration-guard: GRANT TO PUBLIC -> blocked", () => {
1847
2057
  const r = runHook("migration-guard.js", {
1848
2058
  tool_input: { file_path: "supabase/migrations/005.sql", content: "GRANT ALL ON users TO PUBLIC;" },
@@ -2232,6 +2442,63 @@ describe("qualia-ui.js", () => {
2232
2442
  fs.rmSync(tmpHome, { recursive: true, force: true });
2233
2443
  }
2234
2444
  });
2445
+
2446
+ // ─── v4 regression: journey-tree renders without crashing ───
2447
+ // Previously crashed with "Cannot access 'projectName' before initialization"
2448
+ // because a const shadowed the fallback function inside its own initializer.
2449
+ it("journey-tree renders milestones without crashing", () => {
2450
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "qualia-jt-"));
2451
+ try {
2452
+ fs.mkdirSync(path.join(tmpDir, ".planning"), { recursive: true });
2453
+ fs.writeFileSync(
2454
+ path.join(tmpDir, ".planning", "JOURNEY.md"),
2455
+ "# JOURNEY\n\n## Milestone 1 · Foundation\n\nWhy now.\n\n## Milestone 2 · Handoff\n\nDeliver.\n"
2456
+ );
2457
+ fs.writeFileSync(
2458
+ path.join(tmpDir, ".planning", "tracking.json"),
2459
+ JSON.stringify({ project: "jtproj", milestone: 1, milestones: [] })
2460
+ );
2461
+ fs.writeFileSync(
2462
+ path.join(tmpDir, ".planning", "STATE.md"),
2463
+ "---\nproject: jtproj\nphase: 1\nstatus: planning\nmilestone: 1\n---\n"
2464
+ );
2465
+ const r = runUI(["journey-tree"], { cwd: tmpDir, home: tmpDir });
2466
+ assert.equal(r.status, 0, `journey-tree crashed: ${r.stderr}`);
2467
+ const clean = stripAnsi(r.stdout);
2468
+ assert.match(clean, /JOURNEY/);
2469
+ assert.match(clean, /M1 · Foundation/);
2470
+ assert.match(clean, /M2 · Handoff/);
2471
+ assert.match(clean, /\[CURRENT\]/);
2472
+ } finally {
2473
+ fs.rmSync(tmpDir, { recursive: true, force: true });
2474
+ }
2475
+ });
2476
+
2477
+ // ─── v4 regression: journey-tree uses projectName() fallback when frontmatter missing ───
2478
+ // Would previously throw ReferenceError because `const projectName` shadowed the
2479
+ // function name inside its own initializer. Fallback resolves to basename(cwd).
2480
+ it("journey-tree uses projectName() fallback when no project: in JOURNEY frontmatter", () => {
2481
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "qualia-jt-fallback-"));
2482
+ try {
2483
+ fs.mkdirSync(path.join(tmpDir, ".planning"), { recursive: true });
2484
+ fs.writeFileSync(
2485
+ path.join(tmpDir, ".planning", "JOURNEY.md"),
2486
+ "# JOURNEY\n\n## Milestone 1 · Foundation\n\nWhy now.\n\n## Milestone 2 · Handoff\n\nLast.\n"
2487
+ );
2488
+ fs.writeFileSync(
2489
+ path.join(tmpDir, ".planning", "tracking.json"),
2490
+ JSON.stringify({ project: "ignored-by-fallback", milestone: 1 })
2491
+ );
2492
+ const r = runUI(["journey-tree"], { cwd: tmpDir, home: tmpDir });
2493
+ assert.equal(r.status, 0, `journey-tree crashed: ${r.stderr}`);
2494
+ const clean = stripAnsi(r.stdout);
2495
+ // Fallback is path.basename(cwd) — whatever the tmp dir is named.
2496
+ assert.match(clean, new RegExp(path.basename(tmpDir)));
2497
+ assert.match(clean, /M1 · Foundation/);
2498
+ } finally {
2499
+ fs.rmSync(tmpDir, { recursive: true, force: true });
2500
+ }
2501
+ });
2235
2502
  });
2236
2503
 
2237
2504
  // ═══════════════════════════════════════════════════════════
@@ -2440,6 +2707,46 @@ describe("install.js", () => {
2440
2707
  }
2441
2708
  });
2442
2709
 
2710
+ // v4.0.2: reinstall merges hooks instead of clobbering.
2711
+ it("re-install preserves user-added hooks in settings.json", () => {
2712
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "qualia-install-"));
2713
+ try {
2714
+ // Fresh install first, then inject a user-owned hook, then reinstall.
2715
+ runInstall("QS-FAWZI-01", tmpHome);
2716
+ const settingsPath = path.join(tmpHome, ".claude", "settings.json");
2717
+ const before = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
2718
+
2719
+ // Add a user hook to PreToolUse that is not a Qualia command.
2720
+ const userHook = {
2721
+ matcher: "Bash",
2722
+ hooks: [
2723
+ { type: "command", command: "echo user-owned-pre-tool-hook", timeout: 3 },
2724
+ ],
2725
+ };
2726
+ before.hooks.PreToolUse = [userHook, ...(before.hooks.PreToolUse || [])];
2727
+ fs.writeFileSync(settingsPath, JSON.stringify(before, null, 2));
2728
+
2729
+ const r = runInstall("QS-FAWZI-01", tmpHome);
2730
+ assert.equal(r.status, 0);
2731
+ const after = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
2732
+ const allCmds = [];
2733
+ for (const block of after.hooks.PreToolUse || []) {
2734
+ for (const h of (block.hooks || [])) allCmds.push(String(h.command || ""));
2735
+ }
2736
+ assert.ok(
2737
+ allCmds.some((c) => c.includes("user-owned-pre-tool-hook")),
2738
+ `user hook was clobbered by reinstall. Commands: ${allCmds.join(" | ")}`
2739
+ );
2740
+ // And Qualia hooks should still be there.
2741
+ assert.ok(
2742
+ allCmds.some((c) => c.includes("branch-guard.js")),
2743
+ "Qualia hooks must still be present after reinstall"
2744
+ );
2745
+ } finally {
2746
+ fs.rmSync(tmpHome, { recursive: true, force: true });
2747
+ }
2748
+ });
2749
+
2443
2750
  it("templates copied to qualia-templates/", () => {
2444
2751
  const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "qualia-install-"));
2445
2752
  try {