chapterhouse 0.9.1 → 0.10.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/README.md +1 -1
- package/agents/korg.agent.md +20 -0
- package/dist/api/auth.js +11 -1
- package/dist/api/auth.test.js +29 -0
- package/dist/api/errors.js +23 -0
- package/dist/api/route-coverage.test.js +61 -21
- package/dist/api/routes/agents.js +472 -0
- package/dist/api/routes/memory.js +299 -0
- package/dist/api/routes/projects.js +170 -0
- package/dist/api/routes/sessions.js +347 -0
- package/dist/api/routes/system.js +82 -0
- package/dist/api/routes/wiki.js +455 -0
- package/dist/api/routes/wiki.test.js +49 -0
- package/dist/api/send-json.js +16 -0
- package/dist/api/send-json.test.js +18 -0
- package/dist/api/server-runtime.js +45 -3
- package/dist/api/server.js +34 -1764
- package/dist/api/server.test.js +239 -8
- package/dist/api/sse-hub.js +37 -0
- package/dist/cli.js +1 -1
- package/dist/config.js +151 -58
- package/dist/config.test.js +29 -0
- package/dist/copilot/okr-mapper.js +2 -11
- package/dist/copilot/orchestrator.js +358 -352
- package/dist/copilot/orchestrator.test.js +139 -4
- package/dist/copilot/prompt-date.js +2 -1
- package/dist/copilot/session-manager.js +25 -23
- package/dist/copilot/session-manager.test.js +35 -1
- package/dist/copilot/standup.js +2 -2
- package/dist/copilot/task-event-log.js +7 -1
- package/dist/copilot/task-event-log.test.js +13 -0
- package/dist/copilot/tools/agent.js +608 -0
- package/dist/copilot/tools/index.js +19 -0
- package/dist/copilot/tools/memory.js +678 -0
- package/dist/copilot/tools/models.js +2 -0
- package/dist/copilot/tools/okr.js +171 -0
- package/dist/copilot/tools/wiki.js +333 -0
- package/dist/copilot/tools-deps.js +4 -0
- package/dist/copilot/tools.agent.test.js +10 -8
- package/dist/copilot/tools.inventory.test.js +76 -0
- package/dist/copilot/tools.js +1 -1725
- package/dist/copilot/tools.okr.test.js +31 -0
- package/dist/copilot/tools.wiki.test.js +358 -6
- package/dist/copilot/turn-event-log.js +31 -4
- package/dist/copilot/turn-event-log.test.js +24 -2
- package/dist/copilot/workiq-installer.test.js +2 -2
- package/dist/daemon-install.js +3 -2
- package/dist/daemon.js +9 -17
- package/dist/integrations/ado-client.js +90 -9
- package/dist/integrations/ado-client.test.js +56 -0
- package/dist/integrations/team-push.js +1 -0
- package/dist/integrations/team-push.test.js +6 -0
- package/dist/integrations/teams-notify.js +1 -0
- package/dist/integrations/teams-notify.test.js +5 -0
- package/dist/memory/active-scope.test.js +0 -1
- package/dist/memory/checkpoint.js +89 -72
- package/dist/memory/checkpoint.test.js +23 -3
- package/dist/memory/eot.js +194 -89
- package/dist/memory/eot.test.js +186 -3
- package/dist/memory/hooks.js +2 -4
- package/dist/memory/housekeeping-scheduler.js +1 -1
- package/dist/memory/housekeeping-scheduler.test.js +1 -2
- package/dist/memory/housekeeping.js +100 -3
- package/dist/memory/housekeeping.test.js +33 -2
- package/dist/memory/reflect.test.js +2 -0
- package/dist/memory/scope-lock.js +26 -0
- package/dist/memory/scope-lock.test.js +118 -0
- package/dist/memory/scopes.test.js +0 -1
- package/dist/mode-context.js +58 -5
- package/dist/mode-context.test.js +68 -0
- package/dist/paths.js +1 -0
- package/dist/setup.js +3 -2
- package/dist/shared/api-schemas.js +48 -5
- package/dist/store/connection.js +96 -0
- package/dist/store/db.js +5 -1498
- package/dist/store/db.test.js +182 -1
- package/dist/store/migrations.js +460 -0
- package/dist/store/repositories/memory.js +281 -0
- package/dist/store/repositories/okr.js +3 -0
- package/dist/store/repositories/projects.js +5 -0
- package/dist/store/repositories/sessions.js +284 -0
- package/dist/store/repositories/wiki.js +60 -0
- package/dist/store/schema.js +501 -0
- package/dist/util/logger.js +3 -2
- package/dist/wiki/consolidation.js +50 -9
- package/dist/wiki/consolidation.test.js +45 -0
- package/dist/wiki/frontmatter.js +45 -14
- package/dist/wiki/frontmatter.test.js +26 -1
- package/dist/wiki/fs.js +16 -4
- package/dist/wiki/fs.test.js +84 -0
- package/dist/wiki/index-manager.js +30 -2
- package/dist/wiki/index-manager.test.js +43 -12
- package/dist/wiki/ingest.js +17 -1
- package/dist/wiki/lock.js +11 -1
- package/dist/wiki/log-manager.js +2 -7
- package/dist/wiki/migrate.js +44 -17
- package/dist/wiki/project-registry.js +10 -5
- package/dist/wiki/project-registry.test.js +14 -0
- package/dist/wiki/scheduler.js +1 -1
- package/dist/wiki/seed-team-wiki.js +2 -1
- package/dist/wiki/team-sync.js +31 -6
- package/dist/wiki/team-sync.test.js +81 -0
- package/package.json +1 -1
- package/web/dist/assets/WikiEdit-BZXAdarz.js +30 -0
- package/web/dist/assets/WikiEdit-BZXAdarz.js.map +1 -0
- package/web/dist/assets/WikiGraph-KrCYco4v.js +2 -0
- package/web/dist/assets/WikiGraph-KrCYco4v.js.map +1 -0
- package/web/dist/assets/index-CUm2Wbuh.js +250 -0
- package/web/dist/assets/index-CUm2Wbuh.js.map +1 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-iQrv3lQN.js +0 -286
- package/web/dist/assets/index-iQrv3lQN.js.map +0 -1
package/dist/api/server.test.js
CHANGED
|
@@ -14,6 +14,7 @@ test("supports API_TOKEN env var and personal health route helpers", async () =>
|
|
|
14
14
|
assert.equal(typeof runtime.createPublicConfigPayload, "function", "createPublicConfigPayload should be exported");
|
|
15
15
|
assert.equal(typeof runtime.getDisplayHost, "function", "getDisplayHost should be exported");
|
|
16
16
|
assert.equal(typeof runtime.assertAuthenticationConfigured, "function", "assertAuthenticationConfigured should be exported");
|
|
17
|
+
assert.equal(typeof runtime.setupSseCleanup, "function", "setupSseCleanup should be exported");
|
|
17
18
|
const token = runtime.resolveApiToken({
|
|
18
19
|
envToken: "personal-token",
|
|
19
20
|
tokenPath: "/tmp/chapterhouse-api-token",
|
|
@@ -25,6 +26,10 @@ test("supports API_TOKEN env var and personal health route helpers", async () =>
|
|
|
25
26
|
status: "ok",
|
|
26
27
|
timestamp: "2026-05-06T00:00:00.000Z",
|
|
27
28
|
});
|
|
29
|
+
assert.deepEqual(runtime.createHealthPayload(new Date("2026-05-06T00:00:00.000Z"), { fts5Ready: false }), {
|
|
30
|
+
status: "initializing",
|
|
31
|
+
timestamp: "2026-05-06T00:00:00.000Z",
|
|
32
|
+
});
|
|
28
33
|
assert.deepEqual(runtime.createPublicConfigPayload({
|
|
29
34
|
entraAuthEnabled: true,
|
|
30
35
|
standaloneMode: false,
|
|
@@ -50,19 +55,51 @@ test("supports API_TOKEN env var and personal health route helpers", async () =>
|
|
|
50
55
|
standalone: false,
|
|
51
56
|
chatSseEnabled: true,
|
|
52
57
|
});
|
|
53
|
-
|
|
58
|
+
const assertAuthenticationConfigured = runtime.assertAuthenticationConfigured;
|
|
59
|
+
assert.doesNotThrow(() => assertAuthenticationConfigured({
|
|
54
60
|
entraAuthEnabled: false,
|
|
55
61
|
apiToken: null,
|
|
62
|
+
apiHost: "127.0.0.1",
|
|
56
63
|
}));
|
|
57
|
-
assert.
|
|
64
|
+
assert.throws(() => assertAuthenticationConfigured({
|
|
65
|
+
entraAuthEnabled: false,
|
|
66
|
+
apiToken: null,
|
|
67
|
+
apiHost: "0.0.0.0",
|
|
68
|
+
}), /Refusing to start without authentication on non-loopback bind 0\.0\.0\.0.*API_TOKEN.*Entra auth/s);
|
|
69
|
+
assert.doesNotThrow(() => assertAuthenticationConfigured({
|
|
58
70
|
entraAuthEnabled: false,
|
|
59
71
|
apiToken: "personal-token",
|
|
72
|
+
apiHost: "0.0.0.0",
|
|
60
73
|
}));
|
|
61
|
-
assert.doesNotThrow(() =>
|
|
74
|
+
assert.doesNotThrow(() => assertAuthenticationConfigured({
|
|
62
75
|
entraAuthEnabled: true,
|
|
63
76
|
apiToken: null,
|
|
77
|
+
apiHost: "0.0.0.0",
|
|
64
78
|
}));
|
|
65
79
|
});
|
|
80
|
+
test("setupSseCleanup cleans already-registered callbacks when setup throws", async () => {
|
|
81
|
+
const runtime = await import("./server-runtime.js");
|
|
82
|
+
const cleanupCalls = [];
|
|
83
|
+
const setupSseCleanup = runtime.setupSseCleanup;
|
|
84
|
+
assert.throws(() => setupSseCleanup((registerCleanup) => {
|
|
85
|
+
registerCleanup(() => cleanupCalls.push("heartbeat"));
|
|
86
|
+
registerCleanup(() => cleanupCalls.push("task-log"));
|
|
87
|
+
throw new Error("boom");
|
|
88
|
+
}), /boom/);
|
|
89
|
+
assert.deepEqual(cleanupCalls, ["task-log", "heartbeat"]);
|
|
90
|
+
});
|
|
91
|
+
test("setupSseCleanup only runs cleanup once after successful setup", async () => {
|
|
92
|
+
const runtime = await import("./server-runtime.js");
|
|
93
|
+
const cleanupCalls = [];
|
|
94
|
+
const setupSseCleanup = runtime.setupSseCleanup;
|
|
95
|
+
const cleanup = setupSseCleanup((registerCleanup) => {
|
|
96
|
+
registerCleanup(() => cleanupCalls.push("heartbeat"));
|
|
97
|
+
registerCleanup(() => cleanupCalls.push("task-log"));
|
|
98
|
+
});
|
|
99
|
+
cleanup();
|
|
100
|
+
cleanup();
|
|
101
|
+
assert.deepEqual(cleanupCalls, ["task-log", "heartbeat"]);
|
|
102
|
+
});
|
|
66
103
|
test("createPublicConfigPayload defaults chat SSE on when not explicitly provided", async () => {
|
|
67
104
|
const runtime = await import("./server-runtime.js");
|
|
68
105
|
assert.equal(typeof runtime.createPublicConfigPayload, "function", "createPublicConfigPayload should be exported");
|
|
@@ -219,7 +256,7 @@ async function withStartedServer(run, extraEnv = {}, timeoutMs = DEFAULT_API_SER
|
|
|
219
256
|
const baseUrl = `http://127.0.0.1:${port}`;
|
|
220
257
|
try {
|
|
221
258
|
await waitForApiServerReady({ child, baseUrl, logs, timeoutMs });
|
|
222
|
-
await run({ baseUrl, authHeader: "Bearer route-token", testRoot });
|
|
259
|
+
await run({ baseUrl, authHeader: "Bearer route-token", testRoot, logs });
|
|
223
260
|
}
|
|
224
261
|
finally {
|
|
225
262
|
await stopChild(child);
|
|
@@ -564,6 +601,44 @@ test("server returns 400 when PATCH /api/agents/:slug cannot parse malformed age
|
|
|
564
601
|
assert.match((await response.json()).error, /^Invalid content:/);
|
|
565
602
|
});
|
|
566
603
|
});
|
|
604
|
+
test("server rejects unknown fields in PATCH /api/agents/:slug", async () => {
|
|
605
|
+
await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
|
|
606
|
+
writeAgentFile(testRoot, "scribe", [
|
|
607
|
+
"---",
|
|
608
|
+
"name: Scribe",
|
|
609
|
+
"description: Drafts release notes",
|
|
610
|
+
"model: claude-sonnet-4.6",
|
|
611
|
+
"---",
|
|
612
|
+
"",
|
|
613
|
+
"Original system prompt.",
|
|
614
|
+
].join("\n"));
|
|
615
|
+
const response = await fetch(`${baseUrl}/api/agents/scribe`, {
|
|
616
|
+
method: "PATCH",
|
|
617
|
+
headers: {
|
|
618
|
+
authorization: authHeader,
|
|
619
|
+
"content-type": "application/json",
|
|
620
|
+
},
|
|
621
|
+
body: JSON.stringify({ description: "Updated", slug: "scribe" }),
|
|
622
|
+
});
|
|
623
|
+
assert.equal(response.status, 400);
|
|
624
|
+
assert.match((await response.json()).error, /slug|unrecognized/i);
|
|
625
|
+
});
|
|
626
|
+
});
|
|
627
|
+
test("server honors CHAPTERHOUSE_JSON_LIMIT for request bodies", async () => {
|
|
628
|
+
await withStartedServer(async ({ baseUrl, authHeader }) => {
|
|
629
|
+
const response = await fetch(`${baseUrl}/api/model`, {
|
|
630
|
+
method: "POST",
|
|
631
|
+
headers: {
|
|
632
|
+
authorization: authHeader,
|
|
633
|
+
"content-type": "application/json",
|
|
634
|
+
},
|
|
635
|
+
body: JSON.stringify({ model: "x".repeat(2_000) }),
|
|
636
|
+
});
|
|
637
|
+
assert.equal(response.status, 413);
|
|
638
|
+
}, {
|
|
639
|
+
CHAPTERHOUSE_JSON_LIMIT: "1kb",
|
|
640
|
+
});
|
|
641
|
+
});
|
|
567
642
|
test("server returns 404 when confirming reload for an unknown agent", async () => {
|
|
568
643
|
await withStartedServer(async ({ baseUrl, authHeader }) => {
|
|
569
644
|
const response = await fetch(`${baseUrl}/api/agents/missing/reload-confirm`, {
|
|
@@ -574,8 +649,8 @@ test("server returns 404 when confirming reload for an unknown agent", async ()
|
|
|
574
649
|
assert.match(await response.text(), /Agent not found/i);
|
|
575
650
|
});
|
|
576
651
|
});
|
|
577
|
-
test("server runs in standalone mode without auth", async () => {
|
|
578
|
-
await withStartedServer(async ({ baseUrl }) => {
|
|
652
|
+
test("server runs in standalone mode without auth on loopback and logs a warning", async () => {
|
|
653
|
+
await withStartedServer(async ({ baseUrl, logs }) => {
|
|
579
654
|
const bootstrap = await fetch(`${baseUrl}/api/bootstrap`);
|
|
580
655
|
assert.equal(bootstrap.status, 200);
|
|
581
656
|
assert.deepEqual(await bootstrap.json(), { authMode: "standalone" });
|
|
@@ -590,10 +665,46 @@ test("server runs in standalone mode without auth", async () => {
|
|
|
590
665
|
const model = await fetch(`${baseUrl}/api/model`);
|
|
591
666
|
assert.equal(model.status, 200);
|
|
592
667
|
assert.deepEqual(await model.json(), { model: "claude-sonnet-4.6" });
|
|
668
|
+
assert.match(logs.join(""), /Running without authentication on loopback/);
|
|
593
669
|
}, {
|
|
594
670
|
API_TOKEN: "",
|
|
671
|
+
LOG_LEVEL: "warn",
|
|
595
672
|
}, STANDALONE_API_SERVER_STARTUP_TIMEOUT_MS);
|
|
596
673
|
});
|
|
674
|
+
test("server refuses standalone mode on non-loopback binds", async () => {
|
|
675
|
+
const testRoot = join(repoRoot, ".test-work", `server-routes-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
|
676
|
+
mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
|
|
677
|
+
rmSync(testRoot, { recursive: true, force: true });
|
|
678
|
+
mkdirSync(testRoot, { recursive: true });
|
|
679
|
+
const port = await getFreePort();
|
|
680
|
+
const logs = [];
|
|
681
|
+
const child = spawn(process.execPath, [
|
|
682
|
+
"--input-type=module",
|
|
683
|
+
"-e",
|
|
684
|
+
"import { startApiServer } from './dist/api/server.js'; await startApiServer();",
|
|
685
|
+
], {
|
|
686
|
+
cwd: repoRoot,
|
|
687
|
+
env: {
|
|
688
|
+
...Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.startsWith("COPILOT_"))),
|
|
689
|
+
CHAPTERHOUSE_DISABLE_DOTENV: "1",
|
|
690
|
+
CHAPTERHOUSE_HOME: testRoot,
|
|
691
|
+
API_HOST: "0.0.0.0",
|
|
692
|
+
API_PORT: String(port),
|
|
693
|
+
API_TOKEN: "",
|
|
694
|
+
LOG_LEVEL: "warn",
|
|
695
|
+
},
|
|
696
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
697
|
+
});
|
|
698
|
+
child.stdout?.on("data", (chunk) => logs.push(String(chunk)));
|
|
699
|
+
child.stderr?.on("data", (chunk) => logs.push(String(chunk)));
|
|
700
|
+
const exitCode = await new Promise((resolve) => {
|
|
701
|
+
child.once("exit", (code) => resolve(code));
|
|
702
|
+
});
|
|
703
|
+
assert.equal(exitCode, 1);
|
|
704
|
+
assert.match(logs.join(""), /Refusing to start without authentication on non-loopback bind 0\.0\.0\.0/);
|
|
705
|
+
assert.match(logs.join(""), /Set API_TOKEN or configure Entra auth/);
|
|
706
|
+
rmSync(testRoot, { recursive: true, force: true });
|
|
707
|
+
});
|
|
597
708
|
test("server exposes the active memory scope API and requires auth", async () => {
|
|
598
709
|
await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
|
|
599
710
|
const unauthorized = await fetch(`${baseUrl}/api/memory/active-scope`);
|
|
@@ -774,6 +885,30 @@ test("POST /api/wiki/page/pin rejects path traversal slugs", async () => {
|
|
|
774
885
|
assert.match(body.error ?? "", /unsafe wiki path/i);
|
|
775
886
|
});
|
|
776
887
|
});
|
|
888
|
+
test("wiki explicit auth routes reject requests without a valid token", async () => {
|
|
889
|
+
await withStartedServer(async ({ baseUrl }) => {
|
|
890
|
+
const requests = [
|
|
891
|
+
fetch(`${baseUrl}/api/wiki/update`, {
|
|
892
|
+
method: "POST",
|
|
893
|
+
headers: { "content-type": "application/json" },
|
|
894
|
+
body: JSON.stringify({ slug: "topics/auth-required", compiled_truth: "# Auth required" }),
|
|
895
|
+
}),
|
|
896
|
+
fetch(`${baseUrl}/api/wiki/ingest`, {
|
|
897
|
+
method: "POST",
|
|
898
|
+
headers: { "content-type": "application/json" },
|
|
899
|
+
body: JSON.stringify({ path: "raw source" }),
|
|
900
|
+
}),
|
|
901
|
+
fetch(`${baseUrl}/api/wiki/search?q=chapterhouse`),
|
|
902
|
+
fetch(`${baseUrl}/api/wiki/page/pin`, {
|
|
903
|
+
method: "POST",
|
|
904
|
+
headers: { "content-type": "application/json" },
|
|
905
|
+
body: JSON.stringify({ slug: "topics/auth-required", pinned: true }),
|
|
906
|
+
}),
|
|
907
|
+
];
|
|
908
|
+
const responses = await Promise.all(requests);
|
|
909
|
+
assert.deepEqual(responses.map((response) => response.status), [401, 401, 401, 401]);
|
|
910
|
+
});
|
|
911
|
+
});
|
|
777
912
|
test("server worker detail returns the stored dispatched prompt", async () => {
|
|
778
913
|
await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
|
|
779
914
|
const db = new Database(join(testRoot, ".chapterhouse", "chapterhouse.db"));
|
|
@@ -804,7 +939,7 @@ test("server worker detail returns the stored dispatched prompt", async () => {
|
|
|
804
939
|
assert.equal(body.completedAt, null);
|
|
805
940
|
});
|
|
806
941
|
});
|
|
807
|
-
test("server session message hydration returns
|
|
942
|
+
test("server session message hydration returns full history by default and include=current scopes to this run", async () => {
|
|
808
943
|
await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
|
|
809
944
|
const db = new Database(join(testRoot, ".chapterhouse", "chapterhouse.db"));
|
|
810
945
|
try {
|
|
@@ -817,7 +952,15 @@ test("server session message hydration returns current run by default and includ
|
|
|
817
952
|
finally {
|
|
818
953
|
db.close();
|
|
819
954
|
}
|
|
820
|
-
const
|
|
955
|
+
const defaultHistory = await fetch(`${baseUrl}/api/session/hydration-session/messages`, {
|
|
956
|
+
headers: { authorization: authHeader },
|
|
957
|
+
});
|
|
958
|
+
assert.equal(defaultHistory.status, 200);
|
|
959
|
+
assert.deepEqual((await defaultHistory.json()).messages.map((message) => message.content), [
|
|
960
|
+
"previous run",
|
|
961
|
+
"current run",
|
|
962
|
+
]);
|
|
963
|
+
const currentOnly = await fetch(`${baseUrl}/api/session/hydration-session/messages?include=current`, {
|
|
821
964
|
headers: { authorization: authHeader },
|
|
822
965
|
});
|
|
823
966
|
assert.equal(currentOnly.status, 200);
|
|
@@ -1152,6 +1295,59 @@ test("server projects create route rejects a duplicate slug", async () => {
|
|
|
1152
1295
|
]);
|
|
1153
1296
|
}, {}, 60_000);
|
|
1154
1297
|
});
|
|
1298
|
+
test("server projects create route rejects paths that do not exist or are not directories", async () => {
|
|
1299
|
+
await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
|
|
1300
|
+
const missingResponse = await fetch(`${baseUrl}/api/projects`, {
|
|
1301
|
+
method: "POST",
|
|
1302
|
+
headers: {
|
|
1303
|
+
authorization: authHeader,
|
|
1304
|
+
"content-type": "application/json",
|
|
1305
|
+
},
|
|
1306
|
+
body: JSON.stringify({
|
|
1307
|
+
slug: "missing",
|
|
1308
|
+
cwd: join(testRoot, "missing-project"),
|
|
1309
|
+
}),
|
|
1310
|
+
});
|
|
1311
|
+
assert.equal(missingResponse.status, 400);
|
|
1312
|
+
assert.deepEqual(await missingResponse.json(), { error: "Project cwd must exist and be a directory" });
|
|
1313
|
+
const filePath = join(testRoot, "not-a-directory");
|
|
1314
|
+
writeFileSync(filePath, "not a project", "utf-8");
|
|
1315
|
+
const fileResponse = await fetch(`${baseUrl}/api/projects`, {
|
|
1316
|
+
method: "POST",
|
|
1317
|
+
headers: {
|
|
1318
|
+
authorization: authHeader,
|
|
1319
|
+
"content-type": "application/json",
|
|
1320
|
+
},
|
|
1321
|
+
body: JSON.stringify({
|
|
1322
|
+
slug: "file-path",
|
|
1323
|
+
cwd: filePath,
|
|
1324
|
+
}),
|
|
1325
|
+
});
|
|
1326
|
+
assert.equal(fileResponse.status, 400);
|
|
1327
|
+
assert.deepEqual(await fileResponse.json(), { error: "Project cwd must exist and be a directory" });
|
|
1328
|
+
assert.deepEqual(readProjectRegistryRows(testRoot), []);
|
|
1329
|
+
}, {}, 60_000);
|
|
1330
|
+
});
|
|
1331
|
+
test("server projects create route requires a team lead in team mode", async () => {
|
|
1332
|
+
await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
|
|
1333
|
+
const projectDir = join(testRoot, "team-project");
|
|
1334
|
+
mkdirSync(projectDir, { recursive: true });
|
|
1335
|
+
const response = await fetch(`${baseUrl}/api/projects`, {
|
|
1336
|
+
method: "POST",
|
|
1337
|
+
headers: {
|
|
1338
|
+
authorization: authHeader,
|
|
1339
|
+
"content-type": "application/json",
|
|
1340
|
+
},
|
|
1341
|
+
body: JSON.stringify({
|
|
1342
|
+
slug: "team-project",
|
|
1343
|
+
cwd: projectDir,
|
|
1344
|
+
}),
|
|
1345
|
+
});
|
|
1346
|
+
assert.equal(response.status, 403);
|
|
1347
|
+
assert.deepEqual(await response.json(), { error: "Forbidden" });
|
|
1348
|
+
assert.deepEqual(readProjectRegistryRows(testRoot), []);
|
|
1349
|
+
}, { CHAPTERHOUSE_MODE: "team" }, 60_000);
|
|
1350
|
+
});
|
|
1155
1351
|
test("server projects delete route removes the registry entry and rules page", async () => {
|
|
1156
1352
|
await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
|
|
1157
1353
|
seedProjectRegistry(testRoot, {
|
|
@@ -1515,6 +1711,41 @@ test("GET /api/memory/:scope returns entries for a scope and 404 for unknown", a
|
|
|
1515
1711
|
assert.deepEqual(await notFound.json(), { error: "Memory scope 'no-such-scope' not found" });
|
|
1516
1712
|
});
|
|
1517
1713
|
});
|
|
1714
|
+
test("GET /api/memory/:scope returns a cursor for additional pages", async () => {
|
|
1715
|
+
await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
|
|
1716
|
+
const db = new Database(getProjectDbPath(testRoot));
|
|
1717
|
+
try {
|
|
1718
|
+
const scope = db.prepare(`SELECT id FROM mem_scopes WHERE slug = ?`).get("chapterhouse");
|
|
1719
|
+
const insert = db.prepare(`
|
|
1720
|
+
INSERT INTO mem_observations (scope_id, content, source, tier, created_at)
|
|
1721
|
+
VALUES (?, ?, 'test', 'hot', ?)
|
|
1722
|
+
`);
|
|
1723
|
+
for (let i = 0; i < 105; i++) {
|
|
1724
|
+
insert.run(scope.id, `Paginated observation ${i}`, `2026-05-15T00:${String(i).padStart(2, "0")}:00.000Z`);
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
finally {
|
|
1728
|
+
db.close();
|
|
1729
|
+
}
|
|
1730
|
+
const first = await fetch(`${baseUrl}/api/memory/chapterhouse?store=observations&tier=hot`, {
|
|
1731
|
+
headers: { authorization: authHeader },
|
|
1732
|
+
});
|
|
1733
|
+
assert.equal(first.status, 200);
|
|
1734
|
+
const firstBody = await first.json();
|
|
1735
|
+
assert.equal(firstBody.entries.length, 100);
|
|
1736
|
+
assert.ok(firstBody.total >= 105);
|
|
1737
|
+
assert.ok(firstBody.nextCursor);
|
|
1738
|
+
const second = await fetch(`${baseUrl}/api/memory/chapterhouse?store=observations&tier=hot&cursor=${encodeURIComponent(firstBody.nextCursor)}`, {
|
|
1739
|
+
headers: { authorization: authHeader },
|
|
1740
|
+
});
|
|
1741
|
+
assert.equal(second.status, 200);
|
|
1742
|
+
const secondBody = await second.json();
|
|
1743
|
+
assert.ok(secondBody.entries.length >= 5);
|
|
1744
|
+
assert.equal(secondBody.total, firstBody.total);
|
|
1745
|
+
assert.equal(secondBody.nextCursor, undefined);
|
|
1746
|
+
assert.ok(Math.max(...secondBody.entries.map((entry) => entry.id)) < Math.min(...firstBody.entries.map((entry) => entry.id)));
|
|
1747
|
+
});
|
|
1748
|
+
});
|
|
1518
1749
|
test("POST /api/memory/:scope/remember writes an observation or decision", async () => {
|
|
1519
1750
|
await withStartedServer(async ({ baseUrl, authHeader }) => {
|
|
1520
1751
|
const unauthorized = await fetch(`${baseUrl}/api/memory/chapterhouse/remember`, {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { formatSseData } from "./sse.js";
|
|
2
|
+
export const sseClients = new Map();
|
|
3
|
+
export const pendingSseMessages = [];
|
|
4
|
+
let connectionCounter = 0;
|
|
5
|
+
export function nextSseConnectionId() {
|
|
6
|
+
connectionCounter += 1;
|
|
7
|
+
return `web-${connectionCounter}`;
|
|
8
|
+
}
|
|
9
|
+
export function broadcastSsePayload(payload) {
|
|
10
|
+
for (const [, client] of sseClients) {
|
|
11
|
+
client.res.write(formatSseData(payload));
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function associateSseClient(connectionId, sessionKey) {
|
|
15
|
+
const client = sseClients.get(connectionId);
|
|
16
|
+
if (client) {
|
|
17
|
+
client.sessionKey = sessionKey;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function broadcastSsePayloadToSession(sessionKey, payload) {
|
|
21
|
+
for (const [, client] of sseClients) {
|
|
22
|
+
if (client.sessionKey === sessionKey) {
|
|
23
|
+
client.res.write(formatSseData(payload));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Broadcast a proactive message to all connected SSE clients (for background task completions). */
|
|
28
|
+
export function broadcastToSSE(text) {
|
|
29
|
+
if (sseClients.size === 0) {
|
|
30
|
+
pendingSseMessages.push(text);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
for (const [, client] of sseClients) {
|
|
34
|
+
client.res.write(formatSseData({ type: "message", content: text }));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=sse-hub.js.map
|
package/dist/cli.js
CHANGED
|
@@ -72,7 +72,7 @@ switch (command) {
|
|
|
72
72
|
const force = updateFlags.includes("--force");
|
|
73
73
|
const refIdx = updateFlags.indexOf("--ref");
|
|
74
74
|
const ref = refIdx !== -1 ? (updateFlags[refIdx + 1] ?? null) : null;
|
|
75
|
-
const { checkForUpdate, performUpdate, detectInstallSource, checkPreconditions,
|
|
75
|
+
const { checkForUpdate, performUpdate, detectInstallSource, checkPreconditions, } = await import("./update.js");
|
|
76
76
|
const source = detectInstallSource();
|
|
77
77
|
// Precondition check (bypass with --force)
|
|
78
78
|
if (!force) {
|