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.
- package/AGENTS.md +8 -5
- package/CHANGELOG.md +316 -0
- package/CLAUDE.md +3 -1
- package/agents/roadmapper.md +16 -14
- package/bin/agent-status.js +24 -11
- package/bin/batch-plan.js +111 -0
- package/bin/branch-hygiene.js +135 -0
- package/bin/command-surface.js +2 -0
- package/bin/compile-instructions.js +82 -0
- package/bin/design-tokens.js +131 -0
- package/bin/erp-event.js +177 -0
- package/bin/erp-retry.js +12 -1
- package/bin/eval-runner.js +218 -0
- package/bin/host-adapters.js +84 -12
- package/bin/install.js +44 -13
- package/bin/knowledge-flush.js +6 -3
- package/bin/last-report.js +207 -0
- package/bin/project-sync.js +315 -0
- package/bin/recall.js +172 -0
- package/bin/repo-map.js +188 -0
- package/bin/runtime-manifest.js +12 -0
- package/bin/state.js +112 -1
- package/bin/vault-access.js +82 -0
- package/bin/verify-panel.js +294 -0
- package/bin/wave-plan.js +211 -0
- package/docs/erp-contract.md +180 -0
- package/mcp/memory-mcp/server.js +257 -0
- package/package.json +6 -3
- package/qualia-design/design-dials.md +72 -0
- package/qualia-design/design-reference.md +24 -0
- package/rules/access.md +42 -0
- package/rules/codex-goal.md +28 -26
- package/rules/infrastructure.md +1 -1
- package/skills/qualia/SKILL.md +6 -0
- package/skills/qualia-build/SKILL.md +43 -9
- package/skills/qualia-eval/SKILL.md +83 -0
- package/skills/qualia-feature/SKILL.md +20 -4
- package/skills/qualia-fix/SKILL.md +13 -1
- package/skills/qualia-map/SKILL.md +15 -0
- package/skills/qualia-milestone/SKILL.md +12 -6
- package/skills/qualia-new/REFERENCE.md +6 -4
- package/skills/qualia-new/SKILL.md +41 -15
- package/skills/qualia-plan/SKILL.md +2 -2
- package/skills/qualia-polish/SKILL.md +3 -2
- package/skills/qualia-recall/SKILL.md +76 -0
- package/skills/qualia-report/SKILL.md +10 -0
- package/skills/qualia-scope/SKILL.md +3 -3
- package/skills/qualia-ship/SKILL.md +34 -4
- package/skills/qualia-update/SKILL.md +4 -0
- package/skills/qualia-verify/SKILL.md +53 -24
- package/templates/DESIGN.md +15 -0
- package/templates/instructions.md +32 -0
- package/templates/journey.md +1 -1
- package/templates/project-discovery.md +30 -23
- package/templates/requirements.md +7 -7
- package/tests/agent-status.test.sh +15 -0
- package/tests/batch-plan.test.sh +56 -0
- package/tests/branch-hygiene.test.sh +93 -0
- package/tests/design-tokens.test.sh +53 -0
- package/tests/erp-event.test.sh +78 -0
- package/tests/eval-runner.test.sh +147 -0
- package/tests/instructions.test.sh +109 -0
- package/tests/last-report.test.sh +156 -0
- package/tests/lib.test.sh +29 -4
- package/tests/project-sync.test.sh +175 -0
- package/tests/recall.test.sh +91 -0
- package/tests/repo-map.test.sh +70 -0
- package/tests/run-all.sh +12 -0
- package/tests/runner.js +363 -33
- package/tests/state.test.sh +92 -0
- package/tests/verify-panel.test.sh +162 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
1855
|
-
assert.match(r.stdout, /
|
|
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
|
-
|
|
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", "
|
|
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,
|
|
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 ->
|
|
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", "
|
|
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,
|
|
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
|
-
|
|
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", "
|
|
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,
|
|
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
|
-
//
|
|
1941
|
-
//
|
|
1942
|
-
it("branch-guard: EMPLOYEE refspec push to main ->
|
|
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,
|
|
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 ->
|
|
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,
|
|
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
|
+
});
|