qualia-framework 3.4.0 → 4.0.0

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 (42) hide show
  1. package/README.md +96 -51
  2. package/agents/builder.md +25 -14
  3. package/agents/plan-checker.md +29 -16
  4. package/agents/planner.md +33 -24
  5. package/agents/research-synthesizer.md +25 -12
  6. package/agents/roadmapper.md +89 -84
  7. package/agents/verifier.md +11 -2
  8. package/bin/cli.js +13 -2
  9. package/bin/install.js +28 -5
  10. package/bin/qualia-ui.js +267 -1
  11. package/bin/state.js +377 -52
  12. package/bin/statusline.js +40 -20
  13. package/docs/erp-contract.md +23 -2
  14. package/guide.md +84 -21
  15. package/hooks/auto-update.js +54 -70
  16. package/hooks/branch-guard.js +64 -6
  17. package/hooks/migration-guard.js +85 -10
  18. package/hooks/pre-compact.js +28 -4
  19. package/hooks/pre-deploy-gate.js +46 -6
  20. package/hooks/pre-push.js +94 -27
  21. package/hooks/session-start.js +6 -0
  22. package/package.json +1 -1
  23. package/skills/qualia/SKILL.md +3 -1
  24. package/skills/qualia-build/SKILL.md +40 -5
  25. package/skills/qualia-handoff/SKILL.md +87 -12
  26. package/skills/qualia-idk/SKILL.md +155 -3
  27. package/skills/qualia-map/SKILL.md +4 -4
  28. package/skills/qualia-milestone/SKILL.md +122 -79
  29. package/skills/qualia-new/SKILL.md +151 -230
  30. package/skills/qualia-optimize/SKILL.md +4 -4
  31. package/skills/qualia-plan/SKILL.md +14 -9
  32. package/skills/qualia-quick/SKILL.md +1 -1
  33. package/skills/qualia-report/SKILL.md +12 -0
  34. package/skills/qualia-verify/SKILL.md +59 -5
  35. package/templates/help.html +98 -31
  36. package/templates/journey.md +113 -0
  37. package/templates/plan.md +56 -11
  38. package/templates/requirements.md +82 -22
  39. package/templates/roadmap.md +41 -14
  40. package/templates/tracking.json +12 -1
  41. package/tests/runner.js +560 -0
  42. package/tests/state.test.sh +40 -0
package/tests/runner.js CHANGED
@@ -814,6 +814,390 @@ waves: 1
814
814
  fs.rmSync(tmpDir, { recursive: true, force: true });
815
815
  }
816
816
  });
817
+
818
+ // ─── v3.4.2: init guard ────────────────────────────────
819
+ it("init refuses to clobber an existing project (no --force)", () => {
820
+ const tmpDir = makeProject();
821
+ try {
822
+ const r = spawnSync(process.execPath, [
823
+ path.join(BIN, "state.js"), "init",
824
+ "--project", "TestProject",
825
+ "--phases", '[{"name":"X","goal":"Y"}]',
826
+ ], { encoding: "utf8", cwd: tmpDir, timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
827
+ assert.equal(r.status, 1);
828
+ const out = JSON.parse(r.stdout);
829
+ assert.equal(out.error, "ALREADY_INITIALIZED");
830
+ } finally {
831
+ fs.rmSync(tmpDir, { recursive: true, force: true });
832
+ }
833
+ });
834
+
835
+ it("init --force overwrites an existing project (preserves lifetime)", () => {
836
+ const tmpDir = makeProject();
837
+ try {
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.
841
+ const c = spawnSync(process.execPath, [
842
+ path.join(BIN, "state.js"), "close-milestone", "--force",
843
+ ], { encoding: "utf8", cwd: tmpDir, timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
844
+ assert.equal(c.status, 0);
845
+ const tBefore = JSON.parse(fs.readFileSync(path.join(tmpDir, ".planning", "tracking.json"), "utf8"));
846
+ assert.ok(tBefore.lifetime.milestones_completed >= 1);
847
+
848
+ const r = spawnSync(process.execPath, [
849
+ path.join(BIN, "state.js"), "init",
850
+ "--project", "TestProject",
851
+ "--phases", '[{"name":"NewFoundation","goal":"X"}]',
852
+ "--force",
853
+ ], { encoding: "utf8", cwd: tmpDir, timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
854
+ assert.equal(r.status, 0);
855
+ const tAfter = JSON.parse(fs.readFileSync(path.join(tmpDir, ".planning", "tracking.json"), "utf8"));
856
+ assert.equal(tAfter.lifetime.milestones_completed, tBefore.lifetime.milestones_completed);
857
+ assert.equal(tAfter.phase, 1);
858
+ assert.equal(tAfter.status, "setup");
859
+ } finally {
860
+ fs.rmSync(tmpDir, { recursive: true, force: true });
861
+ }
862
+ });
863
+
864
+ // ─── v3.4.2: close-milestone idempotency ───────────────
865
+ it("close-milestone refuses double-close (idempotency)", () => {
866
+ const tmpDir = makeProject();
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.
870
+ const r1 = spawnSync(process.execPath, [
871
+ path.join(BIN, "state.js"), "close-milestone", "--force",
872
+ ], { encoding: "utf8", cwd: tmpDir, timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
873
+ assert.equal(r1.status, 0);
874
+ const out1 = JSON.parse(r1.stdout);
875
+ assert.equal(out1.lifetime.milestones_completed, 1);
876
+
877
+ // Manually rewind milestone counter to simulate a re-run on the same closed milestone.
878
+ // (Real close-milestone advances t.milestone, so a true double-close requires
879
+ // putting milestone back to its prior value.)
880
+ const tFile = path.join(tmpDir, ".planning", "tracking.json");
881
+ const t = JSON.parse(fs.readFileSync(tFile, "utf8"));
882
+ t.milestone = out1.closed_milestone; // rewind
883
+ fs.writeFileSync(tFile, JSON.stringify(t, null, 2) + "\n");
884
+
885
+ // Second close (without --force) must fail with ALREADY_CLOSED, which
886
+ // is checked BEFORE the readiness guards in cmdCloseMilestone.
887
+ const r2 = spawnSync(process.execPath, [
888
+ path.join(BIN, "state.js"), "close-milestone",
889
+ ], { encoding: "utf8", cwd: tmpDir, timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
890
+ assert.equal(r2.status, 1);
891
+ const out2 = JSON.parse(r2.stdout);
892
+ assert.equal(out2.error, "ALREADY_CLOSED");
893
+ } finally {
894
+ fs.rmSync(tmpDir, { recursive: true, force: true });
895
+ }
896
+ });
897
+
898
+ it("close-milestone --force allows re-close", () => {
899
+ const tmpDir = makeProject();
900
+ try {
901
+ const r1 = spawnSync(process.execPath, [
902
+ path.join(BIN, "state.js"), "close-milestone", "--force",
903
+ ], { encoding: "utf8", cwd: tmpDir, timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
904
+ assert.equal(r1.status, 0);
905
+
906
+ const tFile = path.join(tmpDir, ".planning", "tracking.json");
907
+ const t = JSON.parse(fs.readFileSync(tFile, "utf8"));
908
+ t.milestone = JSON.parse(r1.stdout).closed_milestone;
909
+ fs.writeFileSync(tFile, JSON.stringify(t, null, 2) + "\n");
910
+
911
+ const r2 = spawnSync(process.execPath, [
912
+ path.join(BIN, "state.js"), "close-milestone", "--force",
913
+ ], { encoding: "utf8", cwd: tmpDir, timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
914
+ assert.equal(r2.status, 0);
915
+ const out2 = JSON.parse(r2.stdout);
916
+ assert.equal(out2.lifetime.milestones_completed, 2);
917
+ } finally {
918
+ fs.rmSync(tmpDir, { recursive: true, force: true });
919
+ }
920
+ });
921
+
922
+ // ─── v3.4.2: backfill never reduces lifetime (Math.max) ─
923
+ it("backfill-lifetime never reduces existing counters", () => {
924
+ const tmpDir = makeProject();
925
+ try {
926
+ // Seed lifetime with high values (simulating prior close-milestone)
927
+ const tFile = path.join(tmpDir, ".planning", "tracking.json");
928
+ const t = JSON.parse(fs.readFileSync(tFile, "utf8"));
929
+ t.lifetime.tasks_completed = 100;
930
+ t.lifetime.phases_completed = 20;
931
+ fs.writeFileSync(tFile, JSON.stringify(t, null, 2) + "\n");
932
+
933
+ // Backfill on a project with NO completed phases would compute 0/0
934
+ const r = spawnSync(process.execPath, [
935
+ path.join(BIN, "state.js"), "backfill-lifetime",
936
+ ], { encoding: "utf8", cwd: tmpDir, timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
937
+ assert.equal(r.status, 0);
938
+ const tAfter = JSON.parse(fs.readFileSync(tFile, "utf8"));
939
+ assert.equal(tAfter.lifetime.tasks_completed, 100, "backfill must NOT reduce tasks_completed");
940
+ assert.equal(tAfter.lifetime.phases_completed, 20, "backfill must NOT reduce phases_completed");
941
+ } finally {
942
+ fs.rmSync(tmpDir, { recursive: true, force: true });
943
+ }
944
+ });
945
+
946
+ // ─── v3.4.2: atomic write leaves no .tmp file ──────────
947
+ it("transition leaves no .tmp file on success (atomic write)", () => {
948
+ const tmpDir = makeProject();
949
+ try {
950
+ makeValidPlan(tmpDir, 1);
951
+ const r = runState(["transition", "--to", "planned"], tmpDir);
952
+ assert.equal(r.status, 0);
953
+ const planning = path.join(tmpDir, ".planning");
954
+ const tmps = fs.readdirSync(planning).filter(f => f.includes(".tmp."));
955
+ assert.equal(tmps.length, 0, `Stale .tmp files: ${tmps.join(", ")}`);
956
+ } finally {
957
+ fs.rmSync(tmpDir, { recursive: true, force: true });
958
+ }
959
+ });
960
+
961
+ // ─── v3.6.0: tracking.json schema additions ────────────
962
+ it("init writes new schema fields (team_id, project_id, build_count, etc.)", () => {
963
+ const tmpDir = makeProject();
964
+ try {
965
+ const t = JSON.parse(fs.readFileSync(path.join(tmpDir, ".planning", "tracking.json"), "utf8"));
966
+ // New v3.6 fields (default to empty / 0, but must be present)
967
+ assert.ok("team_id" in t, "team_id missing");
968
+ assert.ok("project_id" in t, "project_id missing");
969
+ assert.ok("git_remote" in t, "git_remote missing");
970
+ assert.ok("session_started_at" in t, "session_started_at missing");
971
+ assert.ok("last_pushed_at" in t, "last_pushed_at missing");
972
+ assert.ok("build_count" in t, "build_count missing");
973
+ assert.ok("deploy_count" in t, "deploy_count missing");
974
+ assert.ok("submitted_by" in t, "submitted_by missing");
975
+ assert.ok("last_closed_milestone" in t.lifetime, "lifetime.last_closed_milestone missing");
976
+ } finally {
977
+ fs.rmSync(tmpDir, { recursive: true, force: true });
978
+ }
979
+ });
980
+
981
+ it("init --force defensively hydrates partial lifetime (no NaN)", () => {
982
+ const tmpDir = makeProject();
983
+ try {
984
+ // Write a partial lifetime that's missing keys
985
+ const tFile = path.join(tmpDir, ".planning", "tracking.json");
986
+ const t = JSON.parse(fs.readFileSync(tFile, "utf8"));
987
+ t.lifetime = { tasks_completed: 5 }; // partial — missing other keys
988
+ fs.writeFileSync(tFile, JSON.stringify(t, null, 2) + "\n");
989
+
990
+ const r = spawnSync(process.execPath, [
991
+ path.join(BIN, "state.js"), "init",
992
+ "--project", "TestProject",
993
+ "--phases", '[{"name":"X","goal":"Y"}]',
994
+ "--force",
995
+ ], { encoding: "utf8", cwd: tmpDir, timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
996
+ assert.equal(r.status, 0);
997
+ const tAfter = JSON.parse(fs.readFileSync(tFile, "utf8"));
998
+ // Original partial value preserved
999
+ assert.equal(tAfter.lifetime.tasks_completed, 5);
1000
+ // Missing keys defaulted to 0, never NaN
1001
+ assert.equal(tAfter.lifetime.phases_completed, 0);
1002
+ assert.equal(tAfter.lifetime.milestones_completed, 0);
1003
+ assert.equal(tAfter.lifetime.total_phases, 0);
1004
+ assert.equal(tAfter.lifetime.last_closed_milestone, 0);
1005
+ assert.ok(!Number.isNaN(tAfter.lifetime.phases_completed));
1006
+ } finally {
1007
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1008
+ }
1009
+ });
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
+
1170
+ // ─── v3.5.0: CRLF tolerance in parseStateMd ────────────
1171
+ it("parseStateMd tolerates CRLF line endings (Windows-edited STATE.md)", () => {
1172
+ const tmpDir = makeProject();
1173
+ try {
1174
+ const stateFile = path.join(tmpDir, ".planning", "STATE.md");
1175
+ const lf = fs.readFileSync(stateFile, "utf8");
1176
+ // Simulate Windows editor save: convert all \n to \r\n
1177
+ const crlf = lf.replace(/\n/g, "\r\n");
1178
+ fs.writeFileSync(stateFile, crlf);
1179
+ const r = runState(["check"], tmpDir);
1180
+ assert.equal(r.status, 0, `check failed on CRLF STATE.md: ${r.stdout} ${r.stderr}`);
1181
+ const out = JSON.parse(r.stdout);
1182
+ assert.equal(out.phase_name, "Foundation", "phase_name must NOT contain trailing \\r");
1183
+ assert.equal(out.status, "setup", "status must NOT contain trailing \\r");
1184
+ } finally {
1185
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1186
+ }
1187
+ });
1188
+
1189
+ // ─── v3.4.2: lock file is released after mutation ──────
1190
+ it("transition releases the .state.lock", () => {
1191
+ const tmpDir = makeProject();
1192
+ try {
1193
+ makeValidPlan(tmpDir, 1);
1194
+ runState(["transition", "--to", "planned"], tmpDir);
1195
+ const lockExists = fs.existsSync(path.join(tmpDir, ".planning", ".state.lock"));
1196
+ assert.equal(lockExists, false, "lock file should be released after transition");
1197
+ } finally {
1198
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1199
+ }
1200
+ });
817
1201
  });
818
1202
 
819
1203
  // ═══════════════════════════════════════════════════════════
@@ -912,6 +1296,53 @@ describe("Hooks", () => {
912
1296
  assert.match(content, /last_commit/);
913
1297
  });
914
1298
 
1299
+ // v3.4.2: behavioral test — the stamp must actually mutate tracking.json
1300
+ // AND create a real commit so the push includes it.
1301
+ it("pre-push.js mutates tracking.json AND commits the stamp", () => {
1302
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "qualia-push-real-"));
1303
+ try {
1304
+ // Init a real git repo
1305
+ const gitOpts = { cwd: tmpDir, encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] };
1306
+ spawnSync("git", ["init", "--initial-branch=main"], gitOpts);
1307
+ spawnSync("git", ["config", "user.email", "test@example.com"], gitOpts);
1308
+ spawnSync("git", ["config", "user.name", "Test"], gitOpts);
1309
+ spawnSync("git", ["config", "commit.gpgsign", "false"], gitOpts);
1310
+
1311
+ // Seed .planning/tracking.json + an initial commit
1312
+ fs.mkdirSync(path.join(tmpDir, ".planning"));
1313
+ const tFile = path.join(tmpDir, ".planning", "tracking.json");
1314
+ fs.writeFileSync(tFile, JSON.stringify({
1315
+ project: "test", phase: 1, status: "setup", last_commit: "OLD", last_updated: "2020-01-01T00:00:00Z",
1316
+ }, null, 2) + "\n");
1317
+ spawnSync("git", ["add", "."], gitOpts);
1318
+ spawnSync("git", ["commit", "-m", "seed", "--no-verify"], gitOpts);
1319
+
1320
+ const headBefore = spawnSync("git", ["rev-parse", "HEAD"], gitOpts).stdout.trim();
1321
+
1322
+ // Run the hook
1323
+ const r = spawnSync(process.execPath, [path.join(HOOKS, "pre-push.js")], {
1324
+ encoding: "utf8", cwd: tmpDir, timeout: 10000, stdio: ["pipe", "pipe", "pipe"],
1325
+ });
1326
+ assert.equal(r.status, 0, `pre-push exited ${r.status}: ${r.stderr}`);
1327
+
1328
+ // tracking.json must have been mutated
1329
+ const t = JSON.parse(fs.readFileSync(tFile, "utf8"));
1330
+ assert.notEqual(t.last_commit, "OLD", "last_commit should have been updated");
1331
+ assert.notEqual(t.last_updated, "2020-01-01T00:00:00Z", "last_updated should have been updated");
1332
+ assert.match(t.last_updated, /^\d{4}-\d{2}-\d{2}T/);
1333
+
1334
+ // A NEW commit must exist (this is the smoking-gun fix from v3.4.2)
1335
+ const headAfter = spawnSync("git", ["rev-parse", "HEAD"], gitOpts).stdout.trim();
1336
+ assert.notEqual(headAfter, headBefore, "pre-push must commit the stamp so it ships with the push");
1337
+
1338
+ // The new commit must be authored by the bot, not the user
1339
+ const author = spawnSync("git", ["log", "-1", "--format=%an <%ae>"], gitOpts).stdout.trim();
1340
+ assert.match(author, /Qualia Framework/);
1341
+ } finally {
1342
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1343
+ }
1344
+ });
1345
+
915
1346
  it("pre-push.js exits 0 with no tracking.json", () => {
916
1347
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "qualia-push-"));
917
1348
  try {
@@ -1303,6 +1734,135 @@ describe("Hooks", () => {
1303
1734
  fs.rmSync(tmpDir, { recursive: true, force: true });
1304
1735
  }
1305
1736
  });
1737
+
1738
+ // v3.5.0: refspec bypass — EMPLOYEE on a feature branch trying to push
1739
+ // `feature/x:main` MUST be blocked, even though current branch isn't main.
1740
+ it("branch-guard: EMPLOYEE refspec push to main -> blocked", () => {
1741
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "qualia-bg-"));
1742
+ try {
1743
+ const projDir = path.join(tmpDir, "proj");
1744
+ fs.mkdirSync(projDir, { recursive: true });
1745
+ fs.mkdirSync(path.join(tmpDir, ".claude"), { recursive: true });
1746
+ spawnSync("git", ["init", "-q"], { cwd: projDir });
1747
+ spawnSync("git", ["checkout", "-b", "feature/x", "-q"], { cwd: projDir, stdio: "pipe" });
1748
+ fs.writeFileSync(path.join(tmpDir, ".claude", ".qualia-config.json"), JSON.stringify({ role: "EMPLOYEE" }));
1749
+ // Send Claude Code hook payload via stdin
1750
+ const payload = JSON.stringify({
1751
+ tool_input: { command: "git push origin feature/x:main" },
1752
+ });
1753
+ const r = spawnSync(process.execPath, [path.join(HOOKS, "branch-guard.js")], {
1754
+ encoding: "utf8", cwd: projDir, timeout: 5000,
1755
+ env: { ...process.env, HOME: tmpDir, USERPROFILE: tmpDir },
1756
+ input: payload,
1757
+ stdio: ["pipe", "pipe", "pipe"],
1758
+ });
1759
+ assert.equal(r.status, 2, "refspec push to main must be blocked for EMPLOYEE");
1760
+ } finally {
1761
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1762
+ }
1763
+ });
1764
+
1765
+ it("branch-guard: EMPLOYEE refspec push to master -> blocked", () => {
1766
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "qualia-bg-"));
1767
+ try {
1768
+ const projDir = path.join(tmpDir, "proj");
1769
+ fs.mkdirSync(projDir, { recursive: true });
1770
+ fs.mkdirSync(path.join(tmpDir, ".claude"), { recursive: true });
1771
+ spawnSync("git", ["init", "-q"], { cwd: projDir });
1772
+ spawnSync("git", ["checkout", "-b", "feature/x", "-q"], { cwd: projDir, stdio: "pipe" });
1773
+ fs.writeFileSync(path.join(tmpDir, ".claude", ".qualia-config.json"), JSON.stringify({ role: "EMPLOYEE" }));
1774
+ const payload = JSON.stringify({
1775
+ tool_input: { command: "git push origin HEAD:master" },
1776
+ });
1777
+ const r = spawnSync(process.execPath, [path.join(HOOKS, "branch-guard.js")], {
1778
+ encoding: "utf8", cwd: projDir, timeout: 5000,
1779
+ env: { ...process.env, HOME: tmpDir, USERPROFILE: tmpDir },
1780
+ input: payload,
1781
+ stdio: ["pipe", "pipe", "pipe"],
1782
+ });
1783
+ assert.equal(r.status, 2, "refspec push to master must be blocked for EMPLOYEE");
1784
+ } finally {
1785
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1786
+ }
1787
+ });
1788
+
1789
+ it("branch-guard: OWNER refspec push to main -> allowed", () => {
1790
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "qualia-bg-"));
1791
+ try {
1792
+ const projDir = path.join(tmpDir, "proj");
1793
+ fs.mkdirSync(projDir, { recursive: true });
1794
+ fs.mkdirSync(path.join(tmpDir, ".claude"), { recursive: true });
1795
+ spawnSync("git", ["init", "-q"], { cwd: projDir });
1796
+ spawnSync("git", ["checkout", "-b", "feature/x", "-q"], { cwd: projDir, stdio: "pipe" });
1797
+ fs.writeFileSync(path.join(tmpDir, ".claude", ".qualia-config.json"), JSON.stringify({ role: "OWNER" }));
1798
+ const payload = JSON.stringify({
1799
+ tool_input: { command: "git push origin feature/x:main" },
1800
+ });
1801
+ const r = spawnSync(process.execPath, [path.join(HOOKS, "branch-guard.js")], {
1802
+ encoding: "utf8", cwd: projDir, timeout: 5000,
1803
+ env: { ...process.env, HOME: tmpDir, USERPROFILE: tmpDir },
1804
+ input: payload,
1805
+ stdio: ["pipe", "pipe", "pipe"],
1806
+ });
1807
+ assert.equal(r.status, 0, "OWNER may push to main via refspec");
1808
+ } finally {
1809
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1810
+ }
1811
+ });
1812
+
1813
+ // v3.5.0: migration-guard — comments stripped before pattern match
1814
+ it("migration-guard: commented-out DROP TABLE is NOT blocked", () => {
1815
+ const r = runHook("migration-guard.js", {
1816
+ tool_input: {
1817
+ file_path: "supabase/migrations/001_init.sql",
1818
+ content: "-- DROP TABLE old_users; (rolled back, kept for reference)\nCREATE TABLE foo (id uuid) WITH (security_invoker = true);\nALTER TABLE foo ENABLE ROW LEVEL SECURITY;",
1819
+ },
1820
+ });
1821
+ assert.equal(r.status, 0, `commented DROP should not block: ${r.stdout || r.stderr}`);
1822
+ });
1823
+
1824
+ // v3.5.0: migration-guard — new destructive patterns
1825
+ it("migration-guard: ALTER TABLE DROP COLUMN -> blocked", () => {
1826
+ const r = runHook("migration-guard.js", {
1827
+ tool_input: { file_path: "supabase/migrations/002.sql", content: "ALTER TABLE users DROP COLUMN ssn;" },
1828
+ });
1829
+ assert.equal(r.status, 2, "ALTER TABLE DROP COLUMN must block");
1830
+ });
1831
+
1832
+ it("migration-guard: DROP DATABASE -> blocked", () => {
1833
+ const r = runHook("migration-guard.js", {
1834
+ tool_input: { file_path: "supabase/migrations/003.sql", content: "DROP DATABASE production;" },
1835
+ });
1836
+ assert.equal(r.status, 2, "DROP DATABASE must block");
1837
+ });
1838
+
1839
+ it("migration-guard: UPDATE without WHERE -> blocked", () => {
1840
+ const r = runHook("migration-guard.js", {
1841
+ tool_input: { file_path: "supabase/migrations/004.sql", content: "UPDATE users SET email = NULL;" },
1842
+ });
1843
+ assert.equal(r.status, 2, "UPDATE without WHERE must block");
1844
+ });
1845
+
1846
+ it("migration-guard: GRANT TO PUBLIC -> blocked", () => {
1847
+ const r = runHook("migration-guard.js", {
1848
+ tool_input: { file_path: "supabase/migrations/005.sql", content: "GRANT ALL ON users TO PUBLIC;" },
1849
+ });
1850
+ assert.equal(r.status, 2, "GRANT TO PUBLIC must block");
1851
+ });
1852
+
1853
+ it("migration-guard: CREATE TEMP TABLE without RLS -> NOT blocked", () => {
1854
+ const r = runHook("migration-guard.js", {
1855
+ tool_input: { file_path: "supabase/migrations/006.sql", content: "CREATE TEMP TABLE scratch (id int);" },
1856
+ });
1857
+ assert.equal(r.status, 0, "TEMP tables should be exempt from the RLS requirement");
1858
+ });
1859
+
1860
+ it("migration-guard: MigrationModal.tsx is NOT scanned", () => {
1861
+ const r = runHook("migration-guard.js", {
1862
+ tool_input: { file_path: "src/components/MigrationModal.tsx", content: "DROP TABLE users;" },
1863
+ });
1864
+ assert.equal(r.status, 0, "files with 'migration' in the name but not in a migrations/ dir should not be scanned");
1865
+ });
1306
1866
  });
1307
1867
 
1308
1868
  // ═══════════════════════════════════════════════════════════
@@ -895,6 +895,46 @@ else
895
895
  fail_case "first-time init lifetime" "got=$RESULT expected=1,0,0,0,0"
896
896
  fi
897
897
 
898
+ # ─── Backfill lifetime ───────────────────────────────────
899
+ echo ""
900
+ echo "backfill-lifetime:"
901
+
902
+ # 50. backfill-lifetime reconstructs from completed phases
903
+ TMP=$(make_project)
904
+ make_valid_plan "$TMP" 1
905
+ touch "$TMP/.planning/phase-1-verification.md"
906
+ (cd "$TMP" && $NODE "$STATE_JS" transition --to planned >/dev/null 2>&1)
907
+ (cd "$TMP" && $NODE "$STATE_JS" transition --to built --tasks-done 3 --tasks-total 3 >/dev/null 2>&1)
908
+ (cd "$TMP" && $NODE "$STATE_JS" transition --to verified --verification pass >/dev/null 2>&1)
909
+ # Wipe lifetime to simulate pre-v3.4.0 state
910
+ $NODE -e "
911
+ const t = JSON.parse(require('fs').readFileSync('$TMP/.planning/tracking.json','utf8'));
912
+ t.lifetime = { tasks_completed: 0, phases_completed: 0, milestones_completed: 0, total_phases: 0 };
913
+ require('fs').writeFileSync('$TMP/.planning/tracking.json', JSON.stringify(t, null, 2));
914
+ "
915
+ OUT=$(cd "$TMP" && $NODE "$STATE_JS" backfill-lifetime 2>&1)
916
+ EXIT=$?
917
+ if [ "$EXIT" -eq 0 ] \
918
+ && echo "$OUT" | grep -q '"action": "backfill-lifetime"' \
919
+ && echo "$OUT" | grep -q '"phases_completed": 1' \
920
+ && echo "$OUT" | grep -q '"tasks_completed": 1'; then
921
+ pass "backfill-lifetime reconstructs 1 phase, 1 task from plan file"
922
+ else
923
+ fail_case "backfill-lifetime" "exit=$EXIT out=$OUT"
924
+ fi
925
+
926
+ # 51. backfill-lifetime is idempotent
927
+ OUT2=$(cd "$TMP" && $NODE "$STATE_JS" backfill-lifetime 2>&1)
928
+ RESULT=$($NODE -e "
929
+ const t = JSON.parse(require('fs').readFileSync('$TMP/.planning/tracking.json','utf8'));
930
+ console.log([t.lifetime.tasks_completed, t.lifetime.phases_completed].join(','));
931
+ ")
932
+ if [ "$RESULT" = "1,1" ]; then
933
+ pass "backfill-lifetime is idempotent (same result on re-run)"
934
+ else
935
+ fail_case "backfill idempotent" "got=$RESULT"
936
+ fi
937
+
898
938
  # ─── Summary ─────────────────────────────────────────────
899
939
  echo ""
900
940
  echo "=== Results: $PASS passed, $FAIL failed ==="