qualia-framework 6.14.0 → 7.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 (72) hide show
  1. package/AGENTS.md +8 -5
  2. package/CHANGELOG.md +316 -0
  3. package/CLAUDE.md +3 -1
  4. package/agents/roadmapper.md +16 -14
  5. package/bin/agent-status.js +24 -11
  6. package/bin/batch-plan.js +111 -0
  7. package/bin/branch-hygiene.js +135 -0
  8. package/bin/command-surface.js +2 -0
  9. package/bin/compile-instructions.js +82 -0
  10. package/bin/design-tokens.js +131 -0
  11. package/bin/erp-event.js +177 -0
  12. package/bin/erp-retry.js +12 -1
  13. package/bin/eval-runner.js +218 -0
  14. package/bin/host-adapters.js +84 -12
  15. package/bin/install.js +44 -13
  16. package/bin/knowledge-flush.js +6 -3
  17. package/bin/last-report.js +207 -0
  18. package/bin/project-sync.js +315 -0
  19. package/bin/recall.js +172 -0
  20. package/bin/repo-map.js +188 -0
  21. package/bin/runtime-manifest.js +12 -0
  22. package/bin/state.js +112 -1
  23. package/bin/vault-access.js +82 -0
  24. package/bin/verify-panel.js +294 -0
  25. package/bin/wave-plan.js +211 -0
  26. package/docs/erp-contract.md +180 -0
  27. package/mcp/memory-mcp/server.js +257 -0
  28. package/package.json +6 -3
  29. package/qualia-design/design-dials.md +72 -0
  30. package/qualia-design/design-reference.md +24 -0
  31. package/rules/access.md +42 -0
  32. package/rules/codex-goal.md +28 -26
  33. package/rules/infrastructure.md +1 -1
  34. package/skills/qualia/SKILL.md +6 -0
  35. package/skills/qualia-build/SKILL.md +43 -9
  36. package/skills/qualia-eval/SKILL.md +83 -0
  37. package/skills/qualia-feature/SKILL.md +20 -4
  38. package/skills/qualia-fix/SKILL.md +13 -1
  39. package/skills/qualia-map/SKILL.md +15 -0
  40. package/skills/qualia-milestone/SKILL.md +12 -6
  41. package/skills/qualia-new/REFERENCE.md +6 -4
  42. package/skills/qualia-new/SKILL.md +41 -15
  43. package/skills/qualia-plan/SKILL.md +2 -2
  44. package/skills/qualia-polish/SKILL.md +3 -2
  45. package/skills/qualia-recall/SKILL.md +76 -0
  46. package/skills/qualia-report/SKILL.md +10 -0
  47. package/skills/qualia-scope/SKILL.md +3 -3
  48. package/skills/qualia-ship/SKILL.md +34 -4
  49. package/skills/qualia-update/SKILL.md +4 -0
  50. package/skills/qualia-verify/SKILL.md +53 -24
  51. package/templates/DESIGN.md +15 -0
  52. package/templates/instructions.md +32 -0
  53. package/templates/journey.md +1 -1
  54. package/templates/project-discovery.md +30 -23
  55. package/templates/requirements.md +7 -7
  56. package/tests/agent-status.test.sh +15 -0
  57. package/tests/batch-plan.test.sh +56 -0
  58. package/tests/branch-hygiene.test.sh +93 -0
  59. package/tests/design-tokens.test.sh +53 -0
  60. package/tests/erp-event.test.sh +78 -0
  61. package/tests/eval-runner.test.sh +147 -0
  62. package/tests/instructions.test.sh +109 -0
  63. package/tests/last-report.test.sh +156 -0
  64. package/tests/lib.test.sh +29 -4
  65. package/tests/project-sync.test.sh +175 -0
  66. package/tests/recall.test.sh +91 -0
  67. package/tests/repo-map.test.sh +70 -0
  68. package/tests/run-all.sh +12 -0
  69. package/tests/runner.js +363 -33
  70. package/tests/state.test.sh +92 -0
  71. package/tests/verify-panel.test.sh +162 -0
  72. package/tests/wave-plan.test.sh +153 -0
package/tests/runner.js CHANGED
@@ -105,6 +105,62 @@ Goal: Test goal
105
105
  fs.writeFileSync(path.join(dir, ".planning", `phase-${phase}-plan.md`), plan);
106
106
  }
107
107
 
108
+ // Helper: write a compiled machine contract + passing machine evidence.
109
+ // v7 kernel: `planned` requires phase-N-contract.json and `verified(pass)`
110
+ // requires passing evidence at evidence/phase-N-contract-run.json. Tests that
111
+ // exercise the ABSENCE of these (MISSING_CONTRACT/MISSING_FILE) must NOT call
112
+ // this. Mirrors make_valid_contract in tests/state.test.sh.
113
+ function makeValidContract(dir, phase) {
114
+ phase = phase || 1;
115
+ const planning = path.join(dir, ".planning");
116
+ const contract = {
117
+ version: 1,
118
+ phase,
119
+ goal: "Test goal",
120
+ why: "Exercise machine evidence",
121
+ generated_at: "2026-05-23T00:00:00.000Z",
122
+ generated_by: "manual",
123
+ source_plan_hash: "",
124
+ success_criteria: ["Machine check passes"],
125
+ tasks: [
126
+ {
127
+ id: "T1",
128
+ title: "Machine check",
129
+ wave: 1,
130
+ depends_on: [],
131
+ persona: "none",
132
+ files_modify: [],
133
+ files_create: [],
134
+ files_delete: [],
135
+ acceptance_criteria: ["Node command exits 0"],
136
+ action: "Run deterministic evidence check",
137
+ context_files: [],
138
+ verification: [
139
+ {
140
+ type: "command-exit",
141
+ command: "node",
142
+ args: ["-e", "process.exit(0)"],
143
+ expected_exit: 0,
144
+ timeout_ms: 5000,
145
+ },
146
+ ],
147
+ },
148
+ ],
149
+ };
150
+ fs.writeFileSync(path.join(planning, `phase-${phase}-contract.json`), JSON.stringify(contract, null, 2));
151
+ fs.mkdirSync(path.join(planning, "evidence"), { recursive: true });
152
+ fs.writeFileSync(
153
+ path.join(planning, "evidence", `phase-${phase}-contract-run.json`),
154
+ '{"ok":true,"checks":[]}\n'
155
+ );
156
+ }
157
+
158
+ // Helper: plan + contract + evidence in one call (the common happy-path setup).
159
+ function makePlannablePhase(dir, phase) {
160
+ makeValidPlan(dir, phase);
161
+ makeValidContract(dir, phase);
162
+ }
163
+
108
164
  // Helper: strip ANSI escape codes
109
165
  function stripAnsi(str) {
110
166
  return str.replace(/\x1b\[[0-9;]*m/g, "");
@@ -274,8 +330,11 @@ describe("CLI", () => {
274
330
  it("set-erp-key saves key and enables ERP", () => {
275
331
  const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "qualia-cli-"));
276
332
  try {
277
- const r = run("cli.js", ["set-erp-key", "test-erp-key-12345"], {
333
+ // v5.0: the key is read from stdin, not a positional arg (positional
334
+ // args leak into shell history). Pipe it in.
335
+ const r = run("cli.js", ["set-erp-key"], {
278
336
  env: { HOME: tmpHome, USERPROFILE: tmpHome },
337
+ input: "test-erp-key-12345",
279
338
  });
280
339
  assert.equal(r.status, 0);
281
340
  const keyPath = path.join(tmpHome, ".claude", ".erp-api-key");
@@ -308,9 +367,12 @@ describe("State Machine", () => {
308
367
 
309
368
  it("init creates state and tracking files", () => {
310
369
  withTempPlanning((tmpDir) => {
370
+ // `test-proj` trips the SUSPICIOUS_NAME guard (test-like name). This test
371
+ // intentionally uses a test name, so --force past the guard. (The name is
372
+ // still echoed back verbatim, so the project assertion is unchanged.)
311
373
  const r = spawnSync(NODE, [
312
374
  path.join(BIN, "state.js"), "init",
313
- "--project", "test-proj",
375
+ "--project", "test-proj", "--force",
314
376
  "--phases", '[{"name":"Foundation","goal":"Auth"}]',
315
377
  ], { encoding: "utf8", cwd: tmpDir, timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
316
378
  assert.equal(r.status, 0);
@@ -389,7 +451,7 @@ describe("State Machine", () => {
389
451
  it("setup -> planned succeeds with plan file", () => {
390
452
  const tmpDir = makeProject();
391
453
  try {
392
- makeValidPlan(tmpDir, 1);
454
+ makePlannablePhase(tmpDir, 1);
393
455
  const r = runState(["transition", "--to", "planned"], tmpDir);
394
456
  assert.equal(r.status, 0);
395
457
  const out = JSON.parse(r.stdout);
@@ -404,7 +466,7 @@ describe("State Machine", () => {
404
466
  it("planned -> built records tasks_done/tasks_total", () => {
405
467
  const tmpDir = makeProject();
406
468
  try {
407
- makeValidPlan(tmpDir, 1);
469
+ makePlannablePhase(tmpDir, 1);
408
470
  runState(["transition", "--to", "planned"], tmpDir);
409
471
  const r = runState(["transition", "--to", "built", "--tasks-done", "5", "--tasks-total", "5"], tmpDir);
410
472
  assert.equal(r.status, 0);
@@ -422,7 +484,7 @@ describe("State Machine", () => {
422
484
  it("built -> verified(pass) auto-advances to phase 2", () => {
423
485
  const tmpDir = makeProject();
424
486
  try {
425
- makeValidPlan(tmpDir, 1);
487
+ makePlannablePhase(tmpDir, 1);
426
488
  runState(["transition", "--to", "planned"], tmpDir);
427
489
  runState(["transition", "--to", "built", "--tasks-done", "5", "--tasks-total", "5"], tmpDir);
428
490
  fs.writeFileSync(path.join(tmpDir, ".planning", "phase-1-verification.md"), "pass");
@@ -440,7 +502,7 @@ describe("State Machine", () => {
440
502
  it("built -> verified(fail) stays on same phase", () => {
441
503
  const tmpDir = makeProject();
442
504
  try {
443
- makeValidPlan(tmpDir, 1);
505
+ makePlannablePhase(tmpDir, 1);
444
506
  runState(["transition", "--to", "planned"], tmpDir);
445
507
  runState(["transition", "--to", "built", "--tasks-done", "3", "--tasks-total", "5"], tmpDir);
446
508
  fs.writeFileSync(path.join(tmpDir, ".planning", "phase-1-verification.md"), "fail");
@@ -459,7 +521,7 @@ describe("State Machine", () => {
459
521
  it("planned -> verified fails (requires built)", () => {
460
522
  const tmpDir = makeProject();
461
523
  try {
462
- makeValidPlan(tmpDir, 1);
524
+ makePlannablePhase(tmpDir, 1);
463
525
  runState(["transition", "--to", "planned"], tmpDir);
464
526
  fs.writeFileSync(path.join(tmpDir, ".planning", "phase-1-verification.md"), "");
465
527
  const r = runState(["transition", "--to", "verified", "--verification", "pass"], tmpDir);
@@ -487,7 +549,7 @@ describe("State Machine", () => {
487
549
  it("built -> verified fails without verification file", () => {
488
550
  const tmpDir = makeProject();
489
551
  try {
490
- makeValidPlan(tmpDir, 1);
552
+ makePlannablePhase(tmpDir, 1);
491
553
  runState(["transition", "--to", "planned"], tmpDir);
492
554
  runState(["transition", "--to", "built", "--tasks-done", "1", "--tasks-total", "1"], tmpDir);
493
555
  const r = runState(["transition", "--to", "verified", "--verification", "pass"], tmpDir);
@@ -503,7 +565,7 @@ describe("State Machine", () => {
503
565
  it("built -> verified without --verification -> MISSING_ARG", () => {
504
566
  const tmpDir = makeProject();
505
567
  try {
506
- makeValidPlan(tmpDir, 1);
568
+ makePlannablePhase(tmpDir, 1);
507
569
  runState(["transition", "--to", "planned"], tmpDir);
508
570
  runState(["transition", "--to", "built", "--tasks-done", "1", "--tasks-total", "1"], tmpDir);
509
571
  fs.writeFileSync(path.join(tmpDir, ".planning", "phase-1-verification.md"), "");
@@ -650,7 +712,7 @@ waves: 1
650
712
  it("gap cycle circuit breaker blocks after limit", () => {
651
713
  const tmpDir = makeProject();
652
714
  try {
653
- makeValidPlan(tmpDir, 1);
715
+ makePlannablePhase(tmpDir, 1);
654
716
  fs.writeFileSync(path.join(tmpDir, ".planning", "phase-1-verification.md"), "");
655
717
 
656
718
  // Cycle 1: planned -> built -> verified(fail) -> planned
@@ -685,7 +747,7 @@ waves: 1
685
747
  it("verified(pass) resets gap_cycles to 0", () => {
686
748
  const tmpDir = makeProject();
687
749
  try {
688
- makeValidPlan(tmpDir, 1);
750
+ makePlannablePhase(tmpDir, 1);
689
751
  fs.writeFileSync(path.join(tmpDir, ".planning", "phase-1-verification.md"), "");
690
752
 
691
753
  // One fail cycle
@@ -710,7 +772,7 @@ waves: 1
710
772
  it("configurable gap_cycle_limit allows more cycles", () => {
711
773
  const tmpDir = makeProject();
712
774
  try {
713
- makeValidPlan(tmpDir, 1);
775
+ makePlannablePhase(tmpDir, 1);
714
776
  fs.writeFileSync(path.join(tmpDir, ".planning", "phase-1-verification.md"), "");
715
777
 
716
778
  // Set custom limit
@@ -969,7 +1031,7 @@ waves: 1
969
1031
  it("transition leaves no .tmp file on success (atomic write)", () => {
970
1032
  const tmpDir = makeProject();
971
1033
  try {
972
- makeValidPlan(tmpDir, 1);
1034
+ makePlannablePhase(tmpDir, 1);
973
1035
  const r = runState(["transition", "--to", "planned"], tmpDir);
974
1036
  assert.equal(r.status, 0);
975
1037
  const planning = path.join(tmpDir, ".planning");
@@ -1115,6 +1177,8 @@ waves: 1
1115
1177
  **Acceptance Criteria:**
1116
1178
  - ok
1117
1179
  `);
1180
+ // v7 kernel: planned needs a contract, verified(pass) needs evidence.
1181
+ makeValidContract(tmpDir, phase);
1118
1182
  const verFile = path.join(tmpDir, ".planning", `phase-${phase}-verification.md`);
1119
1183
  // Plan → built → verified
1120
1184
  let r = spawnSync(NODE, [path.join(BIN, "state.js"), "transition", "--to", "planned", "--phase", String(phase)],
@@ -1164,6 +1228,8 @@ waves: 1
1164
1228
  **Acceptance Criteria:**
1165
1229
  - ok
1166
1230
  `);
1231
+ // v7 kernel: planned requires a compiled machine contract.
1232
+ makeValidContract(tmpDir, 1);
1167
1233
  spawnSync(NODE, [path.join(BIN, "state.js"), "transition", "--to", "planned", "--phase", "1"],
1168
1234
  { encoding: "utf8", cwd: tmpDir, timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
1169
1235
  const r = spawnSync(NODE, [path.join(BIN, "state.js"), "transition", "--to", "built", "--phase", "1", "--tasks-done", "1", "--tasks-total", "1"],
@@ -1229,13 +1295,13 @@ waves: 1
1229
1295
  const tmpDir = makeProject();
1230
1296
  try {
1231
1297
  // Walk both phases through verified, then polished, then shipped.
1232
- makeValidPlan(tmpDir, 1);
1298
+ makePlannablePhase(tmpDir, 1);
1233
1299
  runState(["transition", "--to", "planned"], tmpDir);
1234
1300
  runState(["transition", "--to", "built", "--tasks-done", "1", "--tasks-total", "1"], tmpDir);
1235
1301
  fs.writeFileSync(path.join(tmpDir, ".planning", "phase-1-verification.md"), "# pass\n");
1236
1302
  runState(["transition", "--to", "verified", "--verification", "pass"], tmpDir);
1237
1303
 
1238
- makeValidPlan(tmpDir, 2);
1304
+ makePlannablePhase(tmpDir, 2);
1239
1305
  runState(["transition", "--to", "planned"], tmpDir);
1240
1306
  runState(["transition", "--to", "built", "--tasks-done", "1", "--tasks-total", "1"], tmpDir);
1241
1307
  fs.writeFileSync(path.join(tmpDir, ".planning", "phase-2-verification.md"), "# pass\n");
@@ -1837,7 +1903,10 @@ describe("Hooks", () => {
1837
1903
  }
1838
1904
  });
1839
1905
 
1840
- it("branch-guard: EMPLOYEE on main -> blocked", () => {
1906
+ // v6.10 policy: EMPLOYEE pushing to main is ALLOWED + recorded (accountability,
1907
+ // not a hard block). The hook exits 0, emits a notice, and writes a per-employee
1908
+ // tally to .claude/.main-push-events.json. (Matches tests/hooks.test.sh.)
1909
+ it("branch-guard: EMPLOYEE on main -> allowed + recorded + notice", () => {
1841
1910
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "qualia-bg-"));
1842
1911
  try {
1843
1912
  const projDir = path.join(tmpDir, "proj");
@@ -1851,8 +1920,10 @@ describe("Hooks", () => {
1851
1920
  env: { ...process.env, HOME: tmpDir, USERPROFILE: tmpDir },
1852
1921
  stdio: ["pipe", "pipe", "pipe"],
1853
1922
  });
1854
- assert.equal(r.status, 2);
1855
- assert.match(r.stdout, /BLOCKED/);
1923
+ assert.equal(r.status, 0);
1924
+ assert.match(r.stdout + r.stderr, /main-push/);
1925
+ const events = JSON.parse(fs.readFileSync(path.join(tmpDir, ".claude", ".main-push-events.json"), "utf8"));
1926
+ assert.equal(events.events[0].type, "employee_main_push");
1856
1927
  } finally {
1857
1928
  fs.rmSync(tmpDir, { recursive: true, force: true });
1858
1929
  }
@@ -1878,68 +1949,72 @@ describe("Hooks", () => {
1878
1949
  }
1879
1950
  });
1880
1951
 
1881
- it("branch-guard: missing config -> blocked (fails closed)", () => {
1952
+ // v6.10: the hook never blocks now — missing config means no known role, so
1953
+ // it allows and does not record. (Matches tests/hooks.test.sh.)
1954
+ it("branch-guard: missing config -> allowed (never blocks)", () => {
1882
1955
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "qualia-bg-"));
1883
1956
  try {
1884
1957
  const projDir = path.join(tmpDir, "proj");
1885
1958
  fs.mkdirSync(projDir, { recursive: true });
1886
1959
  spawnSync("git", ["init", "-q"], { cwd: projDir });
1887
- spawnSync("git", ["checkout", "-b", "feature/x", "-q"], { cwd: projDir, stdio: "pipe" });
1960
+ spawnSync("git", ["checkout", "-b", "main", "-q"], { cwd: projDir, stdio: "pipe" });
1888
1961
  // No .claude/.qualia-config.json
1889
1962
  const r = spawnSync(NODE, [path.join(HOOKS, "branch-guard.js")], {
1890
1963
  encoding: "utf8", cwd: projDir, timeout: 5000,
1891
1964
  env: { ...process.env, HOME: tmpDir, USERPROFILE: tmpDir },
1892
1965
  stdio: ["pipe", "pipe", "pipe"],
1893
1966
  });
1894
- assert.equal(r.status, 2);
1967
+ assert.equal(r.status, 0);
1895
1968
  } finally {
1896
1969
  fs.rmSync(tmpDir, { recursive: true, force: true });
1897
1970
  }
1898
1971
  });
1899
1972
 
1900
- it("branch-guard: malformed config JSON -> blocked", () => {
1973
+ it("branch-guard: malformed config JSON -> allowed", () => {
1901
1974
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "qualia-bg-"));
1902
1975
  try {
1903
1976
  const projDir = path.join(tmpDir, "proj");
1904
1977
  fs.mkdirSync(projDir, { recursive: true });
1905
1978
  fs.mkdirSync(path.join(tmpDir, ".claude"), { recursive: true });
1906
1979
  spawnSync("git", ["init", "-q"], { cwd: projDir });
1907
- spawnSync("git", ["checkout", "-b", "feature/x", "-q"], { cwd: projDir, stdio: "pipe" });
1980
+ spawnSync("git", ["checkout", "-b", "main", "-q"], { cwd: projDir, stdio: "pipe" });
1908
1981
  fs.writeFileSync(path.join(tmpDir, ".claude", ".qualia-config.json"), "not json{");
1909
1982
  const r = spawnSync(NODE, [path.join(HOOKS, "branch-guard.js")], {
1910
1983
  encoding: "utf8", cwd: projDir, timeout: 5000,
1911
1984
  env: { ...process.env, HOME: tmpDir, USERPROFILE: tmpDir },
1912
1985
  stdio: ["pipe", "pipe", "pipe"],
1913
1986
  });
1914
- assert.equal(r.status, 2);
1987
+ assert.equal(r.status, 0);
1915
1988
  } finally {
1916
1989
  fs.rmSync(tmpDir, { recursive: true, force: true });
1917
1990
  }
1918
1991
  });
1919
1992
 
1920
- it("branch-guard: empty role field -> blocked", () => {
1993
+ // Empty role allowed AND not recorded (not a known EMPLOYEE).
1994
+ it("branch-guard: empty role field -> allowed, not recorded", () => {
1921
1995
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "qualia-bg-"));
1922
1996
  try {
1923
1997
  const projDir = path.join(tmpDir, "proj");
1924
1998
  fs.mkdirSync(projDir, { recursive: true });
1925
1999
  fs.mkdirSync(path.join(tmpDir, ".claude"), { recursive: true });
1926
2000
  spawnSync("git", ["init", "-q"], { cwd: projDir });
1927
- spawnSync("git", ["checkout", "-b", "feature/x", "-q"], { cwd: projDir, stdio: "pipe" });
2001
+ spawnSync("git", ["checkout", "-b", "main", "-q"], { cwd: projDir, stdio: "pipe" });
1928
2002
  fs.writeFileSync(path.join(tmpDir, ".claude", ".qualia-config.json"), JSON.stringify({ role: "" }));
1929
2003
  const r = spawnSync(NODE, [path.join(HOOKS, "branch-guard.js")], {
1930
2004
  encoding: "utf8", cwd: projDir, timeout: 5000,
1931
2005
  env: { ...process.env, HOME: tmpDir, USERPROFILE: tmpDir },
1932
2006
  stdio: ["pipe", "pipe", "pipe"],
1933
2007
  });
1934
- assert.equal(r.status, 2);
2008
+ assert.equal(r.status, 0);
2009
+ assert.equal(fs.existsSync(path.join(tmpDir, ".claude", ".main-push-events.json")), false);
1935
2010
  } finally {
1936
2011
  fs.rmSync(tmpDir, { recursive: true, force: true });
1937
2012
  }
1938
2013
  });
1939
2014
 
1940
- // v3.5.0: refspec bypass EMPLOYEE on a feature branch trying to push
1941
- // `feature/x:main` MUST be blocked, even though current branch isn't main.
1942
- it("branch-guard: EMPLOYEE refspec push to main -> blocked", () => {
2015
+ // v6.10: a refspec push to :main from a feature branch is detected and
2016
+ // RECORDED (not blocked). EMPLOYEE exit 0 + tally written.
2017
+ it("branch-guard: EMPLOYEE refspec push to main -> allowed + recorded", () => {
1943
2018
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "qualia-bg-"));
1944
2019
  try {
1945
2020
  const projDir = path.join(tmpDir, "proj");
@@ -1958,13 +2033,15 @@ describe("Hooks", () => {
1958
2033
  input: payload,
1959
2034
  stdio: ["pipe", "pipe", "pipe"],
1960
2035
  });
1961
- assert.equal(r.status, 2, "refspec push to main must be blocked for EMPLOYEE");
2036
+ assert.equal(r.status, 0, "refspec push to main is recorded, not blocked");
2037
+ const events = JSON.parse(fs.readFileSync(path.join(tmpDir, ".claude", ".main-push-events.json"), "utf8"));
2038
+ assert.equal(events.events[0].type, "employee_main_push");
1962
2039
  } finally {
1963
2040
  fs.rmSync(tmpDir, { recursive: true, force: true });
1964
2041
  }
1965
2042
  });
1966
2043
 
1967
- it("branch-guard: EMPLOYEE refspec push to master -> blocked", () => {
2044
+ it("branch-guard: EMPLOYEE refspec push to master -> allowed + recorded", () => {
1968
2045
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "qualia-bg-"));
1969
2046
  try {
1970
2047
  const projDir = path.join(tmpDir, "proj");
@@ -1982,7 +2059,7 @@ describe("Hooks", () => {
1982
2059
  input: payload,
1983
2060
  stdio: ["pipe", "pipe", "pipe"],
1984
2061
  });
1985
- assert.equal(r.status, 2, "refspec push to master must be blocked for EMPLOYEE");
2062
+ assert.equal(r.status, 0, "refspec push to master is recorded, not blocked");
1986
2063
  } finally {
1987
2064
  fs.rmSync(tmpDir, { recursive: true, force: true });
1988
2065
  }
@@ -2822,3 +2899,256 @@ describe("install.js", () => {
2822
2899
  }
2823
2900
  });
2824
2901
  });
2902
+
2903
+ // ─── Memory MCP server ─────────────────────────────────────
2904
+ describe("memory-mcp server", () => {
2905
+ const SERVER = path.join(ROOT, "mcp", "memory-mcp", "server.js");
2906
+
2907
+ // Drive the server through line-delimited JSON-RPC frames synchronously.
2908
+ // Returns an array of parsed responses in order.
2909
+ function rpc(frames, env = {}) {
2910
+ const input = frames.map((f) => JSON.stringify(f)).join("\n") + "\n";
2911
+ const r = spawnSync(process.execPath, [SERVER], {
2912
+ encoding: "utf8",
2913
+ timeout: 8000,
2914
+ input,
2915
+ env: { ...process.env, ...env },
2916
+ });
2917
+ return (r.stdout || "")
2918
+ .split("\n")
2919
+ .filter(Boolean)
2920
+ .map((line) => JSON.parse(line));
2921
+ }
2922
+
2923
+ it("responds to initialize with protocol + serverInfo", () => {
2924
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "memory-mcp-"));
2925
+ try {
2926
+ fs.mkdirSync(path.join(tmpRoot, "wiki"), { recursive: true });
2927
+ const out = rpc(
2928
+ [{ jsonrpc: "2.0", id: 1, method: "initialize" }],
2929
+ { QUALIA_MEMORY_ROOT: tmpRoot },
2930
+ );
2931
+ assert.equal(out[0].id, 1);
2932
+ assert.equal(out[0].result.serverInfo.name, "qualia-memory");
2933
+ assert.ok(out[0].result.protocolVersion);
2934
+ assert.ok(out[0].result.capabilities.tools);
2935
+ } finally {
2936
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
2937
+ }
2938
+ });
2939
+
2940
+ it("tools/list advertises three read-only tools", () => {
2941
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "memory-mcp-"));
2942
+ try {
2943
+ fs.mkdirSync(path.join(tmpRoot, "wiki"), { recursive: true });
2944
+ const out = rpc(
2945
+ [{ jsonrpc: "2.0", id: 1, method: "tools/list" }],
2946
+ { QUALIA_MEMORY_ROOT: tmpRoot },
2947
+ );
2948
+ const names = out[0].result.tools.map((t) => t.name).sort();
2949
+ assert.deepEqual(names, ["memory.list", "memory.read", "memory.search"]);
2950
+ } finally {
2951
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
2952
+ }
2953
+ });
2954
+
2955
+ it("memory.search finds matches and returns file:line:snippet", () => {
2956
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "memory-mcp-"));
2957
+ try {
2958
+ const wiki = path.join(tmpRoot, "wiki");
2959
+ fs.mkdirSync(path.join(wiki, "concepts"), { recursive: true });
2960
+ fs.writeFileSync(
2961
+ path.join(wiki, "concepts", "alpha.md"),
2962
+ "# Alpha\nSakani Properties uses Mapbox.\nUnrelated line.\n",
2963
+ );
2964
+ const out = rpc(
2965
+ [
2966
+ {
2967
+ jsonrpc: "2.0",
2968
+ id: 1,
2969
+ method: "tools/call",
2970
+ params: { name: "memory.search", arguments: { query: "Mapbox" } },
2971
+ },
2972
+ ],
2973
+ { QUALIA_MEMORY_ROOT: tmpRoot },
2974
+ );
2975
+ const payload = JSON.parse(out[0].result.content[0].text);
2976
+ assert.equal(payload.total, 1);
2977
+ assert.equal(payload.hits[0].path, "concepts/alpha.md");
2978
+ assert.equal(payload.hits[0].line, 2);
2979
+ assert.match(payload.hits[0].snippet, /Mapbox/);
2980
+ } finally {
2981
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
2982
+ }
2983
+ });
2984
+
2985
+ it("memory.read returns file content under the wiki", () => {
2986
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "memory-mcp-"));
2987
+ try {
2988
+ const wiki = path.join(tmpRoot, "wiki");
2989
+ fs.mkdirSync(wiki, { recursive: true });
2990
+ fs.writeFileSync(path.join(wiki, "hot.md"), "recent context");
2991
+ const out = rpc(
2992
+ [
2993
+ {
2994
+ jsonrpc: "2.0",
2995
+ id: 1,
2996
+ method: "tools/call",
2997
+ params: { name: "memory.read", arguments: { path: "hot.md" } },
2998
+ },
2999
+ ],
3000
+ { QUALIA_MEMORY_ROOT: tmpRoot },
3001
+ );
3002
+ const payload = JSON.parse(out[0].result.content[0].text);
3003
+ assert.equal(payload.content, "recent context");
3004
+ assert.equal(payload.truncated, false);
3005
+ } finally {
3006
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
3007
+ }
3008
+ });
3009
+
3010
+ it("memory.read rejects path traversal outside wiki/", () => {
3011
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "memory-mcp-"));
3012
+ try {
3013
+ fs.mkdirSync(path.join(tmpRoot, "wiki"), { recursive: true });
3014
+ // Sibling secret outside wiki/ — must not be reachable via ..
3015
+ fs.writeFileSync(path.join(tmpRoot, "secret.txt"), "shhh");
3016
+ const out = rpc(
3017
+ [
3018
+ {
3019
+ jsonrpc: "2.0",
3020
+ id: 1,
3021
+ method: "tools/call",
3022
+ params: { name: "memory.read", arguments: { path: "../secret.txt" } },
3023
+ },
3024
+ ],
3025
+ { QUALIA_MEMORY_ROOT: tmpRoot },
3026
+ );
3027
+ assert.ok(out[0].error, "expected error response");
3028
+ assert.match(out[0].error.message, /escapes wiki root/);
3029
+ } finally {
3030
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
3031
+ }
3032
+ });
3033
+
3034
+ it("memory.list returns directories first, then files", () => {
3035
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "memory-mcp-"));
3036
+ try {
3037
+ const wiki = path.join(tmpRoot, "wiki");
3038
+ fs.mkdirSync(path.join(wiki, "concepts"), { recursive: true });
3039
+ fs.writeFileSync(path.join(wiki, "index.md"), "i");
3040
+ const out = rpc(
3041
+ [
3042
+ {
3043
+ jsonrpc: "2.0",
3044
+ id: 1,
3045
+ method: "tools/call",
3046
+ params: { name: "memory.list", arguments: {} },
3047
+ },
3048
+ ],
3049
+ { QUALIA_MEMORY_ROOT: tmpRoot },
3050
+ );
3051
+ const payload = JSON.parse(out[0].result.content[0].text);
3052
+ assert.equal(payload.entries[0].type, "dir");
3053
+ assert.equal(payload.entries[0].name, "concepts");
3054
+ assert.ok(payload.entries.some((e) => e.name === "index.md" && e.type === "file"));
3055
+ } finally {
3056
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
3057
+ }
3058
+ });
3059
+
3060
+ it("unknown tool returns JSON-RPC -32601", () => {
3061
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "memory-mcp-"));
3062
+ try {
3063
+ fs.mkdirSync(path.join(tmpRoot, "wiki"), { recursive: true });
3064
+ const out = rpc(
3065
+ [
3066
+ {
3067
+ jsonrpc: "2.0",
3068
+ id: 1,
3069
+ method: "tools/call",
3070
+ params: { name: "memory.delete", arguments: {} },
3071
+ },
3072
+ ],
3073
+ { QUALIA_MEMORY_ROOT: tmpRoot },
3074
+ );
3075
+ assert.equal(out[0].error.code, -32601);
3076
+ } finally {
3077
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
3078
+ }
3079
+ });
3080
+
3081
+ // ── Access control (honors wiki/_meta/access.md, shared with recall.js) ──
3082
+ // Seed a vault with an access manifest: one OWNER_ONLY page + one ALL_ROLES page.
3083
+ function seedGatedVault() {
3084
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "memory-mcp-acl-"));
3085
+ const wiki = path.join(tmpRoot, "wiki");
3086
+ fs.mkdirSync(path.join(wiki, "concepts"), { recursive: true });
3087
+ fs.mkdirSync(path.join(wiki, "_meta"), { recursive: true });
3088
+ fs.writeFileSync(path.join(wiki, "concepts", "pub.md"), "Public lesson: gizmo retries.\n");
3089
+ fs.writeFileSync(
3090
+ path.join(wiki, "_meta", "access.md"),
3091
+ "# Vault Access Manifest\n## OWNER_ONLY\n| `Clients/*.md` | x |\n| `wiki/_meta/access.md` | gizmo marker |\n## ALL_ROLES\n| `wiki/concepts/` | ok |\n",
3092
+ );
3093
+ return tmpRoot;
3094
+ }
3095
+
3096
+ it("memory.search filters OWNER_ONLY hits for non-OWNER", () => {
3097
+ const tmpRoot = seedGatedVault();
3098
+ try {
3099
+ const out = rpc(
3100
+ [{ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "memory.search", arguments: { query: "gizmo" } } }],
3101
+ { QUALIA_MEMORY_ROOT: tmpRoot, QUALIA_ROLE: "EMPLOYEE" },
3102
+ );
3103
+ const payload = JSON.parse(out[0].result.content[0].text);
3104
+ const paths = payload.hits.map((h) => h.path);
3105
+ assert.ok(paths.includes("concepts/pub.md"), "ALL_ROLES hit present");
3106
+ assert.ok(!paths.includes("_meta/access.md"), "OWNER_ONLY hit filtered");
3107
+ } finally {
3108
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
3109
+ }
3110
+ });
3111
+
3112
+ it("memory.search shows OWNER_ONLY hits for OWNER", () => {
3113
+ const tmpRoot = seedGatedVault();
3114
+ try {
3115
+ const out = rpc(
3116
+ [{ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "memory.search", arguments: { query: "gizmo" } } }],
3117
+ { QUALIA_MEMORY_ROOT: tmpRoot, QUALIA_ROLE: "OWNER" },
3118
+ );
3119
+ const payload = JSON.parse(out[0].result.content[0].text);
3120
+ const paths = payload.hits.map((h) => h.path);
3121
+ assert.ok(paths.includes("_meta/access.md"), "OWNER sees OWNER_ONLY hit");
3122
+ } finally {
3123
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
3124
+ }
3125
+ });
3126
+
3127
+ it("memory.read denies OWNER_ONLY path for non-OWNER", () => {
3128
+ const tmpRoot = seedGatedVault();
3129
+ try {
3130
+ const out = rpc(
3131
+ [{ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "memory.read", arguments: { path: "_meta/access.md" } } }],
3132
+ { QUALIA_MEMORY_ROOT: tmpRoot, QUALIA_ROLE: "EMPLOYEE" },
3133
+ );
3134
+ assert.ok(out[0].error, "expected error");
3135
+ assert.match(out[0].error.message, /OWNER-only/);
3136
+ } finally {
3137
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
3138
+ }
3139
+ });
3140
+
3141
+ it("memory.list hides OWNER_ONLY entries for non-OWNER", () => {
3142
+ const tmpRoot = seedGatedVault();
3143
+ try {
3144
+ const out = rpc(
3145
+ [{ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "memory.list", arguments: { folder: "_meta" } } }],
3146
+ { QUALIA_MEMORY_ROOT: tmpRoot, QUALIA_ROLE: "EMPLOYEE" },
3147
+ );
3148
+ const payload = JSON.parse(out[0].result.content[0].text);
3149
+ assert.ok(!payload.entries.some((e) => e.name === "access.md"), "access.md hidden from non-OWNER listing");
3150
+ } finally {
3151
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
3152
+ }
3153
+ });
3154
+ });