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.
- package/CLAUDE.md +23 -11
- package/README.md +96 -51
- package/agents/builder.md +25 -14
- package/agents/plan-checker.md +29 -16
- package/agents/planner.md +33 -24
- package/agents/research-synthesizer.md +25 -12
- package/agents/roadmapper.md +89 -84
- package/agents/verifier.md +11 -2
- package/bin/cli.js +18 -13
- package/bin/install.js +34 -45
- package/bin/qualia-ui.js +267 -1
- package/bin/state.js +164 -12
- package/bin/statusline.js +4 -1
- package/docs/erp-contract.md +12 -0
- package/guide.md +85 -22
- 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 +8 -1
- package/skills/qualia-build/SKILL.md +49 -4
- 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 +92 -12
- package/skills/qualia-help/SKILL.md +18 -4
- package/skills/qualia-idk/SKILL.md +166 -0
- package/skills/qualia-learn/SKILL.md +6 -0
- package/skills/qualia-map/SKILL.md +7 -0
- package/skills/qualia-milestone/SKILL.md +128 -79
- package/skills/qualia-new/SKILL.md +163 -230
- package/skills/qualia-optimize/SKILL.md +8 -0
- package/skills/qualia-pause/SKILL.md +5 -0
- package/skills/qualia-plan/SKILL.md +25 -10
- package/skills/qualia-polish/SKILL.md +8 -0
- package/skills/qualia-quick/SKILL.md +7 -0
- package/skills/qualia-report/SKILL.md +17 -0
- 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 +65 -3
- package/templates/help.html +4 -4
- package/templates/journey.md +113 -0
- package/templates/plan.md +56 -11
- package/templates/requirements.md +82 -22
- package/templates/roadmap.md +41 -14
- package/templates/tracking.json +2 -0
- package/tests/hooks.test.sh +5 -5
- 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,
|
|
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,
|
|
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,
|
|
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 {
|