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.
- package/CLAUDE.md +23 -11
- package/agents/plan-checker.md +1 -1
- package/agents/roadmapper.md +10 -5
- package/bin/cli.js +139 -17
- package/bin/install.js +47 -47
- package/bin/qualia-ui.js +2 -2
- package/bin/state.js +126 -9
- package/bin/statusline.js +63 -38
- package/docs/erp-contract.md +49 -2
- package/guide.md +1 -1
- package/hooks/migration-guard.js +23 -9
- package/hooks/pre-compact.js +39 -11
- package/hooks/pre-deploy-gate.js +3 -4
- package/hooks/pre-push.js +6 -3
- package/hooks/session-start.js +8 -8
- package/package.json +1 -1
- package/rules/frontend.md +5 -13
- package/skills/qualia/SKILL.md +5 -0
- package/skills/qualia-build/SKILL.md +10 -0
- package/skills/qualia-debug/SKILL.md +6 -0
- package/skills/qualia-design/SKILL.md +9 -1
- package/skills/qualia-discuss/SKILL.md +6 -0
- package/skills/qualia-handoff/SKILL.md +5 -0
- package/skills/qualia-help/SKILL.md +18 -4
- package/skills/qualia-idk/SKILL.md +6 -0
- package/skills/qualia-learn/SKILL.md +6 -0
- package/skills/qualia-map/SKILL.md +7 -0
- package/skills/qualia-milestone/SKILL.md +6 -0
- package/skills/qualia-new/SKILL.md +31 -4
- package/skills/qualia-optimize/SKILL.md +8 -0
- package/skills/qualia-pause/SKILL.md +5 -0
- package/skills/qualia-plan/SKILL.md +11 -1
- package/skills/qualia-polish/SKILL.md +8 -0
- package/skills/qualia-quick/SKILL.md +7 -0
- package/skills/qualia-report/SKILL.md +146 -60
- package/skills/qualia-research/SKILL.md +7 -0
- package/skills/qualia-resume/SKILL.md +3 -0
- package/skills/qualia-review/SKILL.md +7 -0
- package/skills/qualia-ship/SKILL.md +5 -0
- package/skills/qualia-skill-new/SKILL.md +6 -0
- package/skills/qualia-task/SKILL.md +8 -1
- package/skills/qualia-test/SKILL.md +7 -0
- package/skills/qualia-verify/SKILL.md +8 -0
- package/templates/help.html +4 -4
- package/templates/tracking.json +1 -0
- package/tests/hooks.test.sh +5 -5
- 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,
|
|
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,
|
|
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,
|
|
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 {
|