chapterhouse 0.13.1 → 0.14.1

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 (132) hide show
  1. package/dist/api/route-coverage.test.js +1 -3
  2. package/dist/api/server.js +0 -2
  3. package/dist/api/server.test.js +0 -281
  4. package/dist/config.js +3 -85
  5. package/dist/config.test.js +5 -123
  6. package/dist/copilot/agents.js +13 -10
  7. package/dist/copilot/agents.test.js +10 -11
  8. package/dist/copilot/memory-coordinator.js +12 -227
  9. package/dist/copilot/memory-coordinator.test.js +31 -250
  10. package/dist/copilot/orchestrator.js +8 -66
  11. package/dist/copilot/orchestrator.test.js +9 -467
  12. package/dist/copilot/skills.js +15 -1
  13. package/dist/copilot/system-message.js +9 -15
  14. package/dist/copilot/system-message.test.js +9 -22
  15. package/dist/copilot/tools/index.js +3 -3
  16. package/dist/copilot/tools-deps.js +1 -1
  17. package/dist/copilot/tools.agent.test.js +6 -0
  18. package/dist/copilot/tools.inventory.test.js +1 -14
  19. package/dist/daemon.js +7 -9
  20. package/dist/memory/assets.js +33 -0
  21. package/dist/memory/domains.js +58 -0
  22. package/dist/memory/domains.test.js +47 -0
  23. package/dist/memory/git.js +66 -0
  24. package/dist/memory/git.test.js +32 -0
  25. package/dist/memory/history.js +19 -0
  26. package/dist/memory/hottier.js +32 -0
  27. package/dist/memory/hottier.test.js +33 -0
  28. package/dist/memory/index.js +5 -13
  29. package/dist/memory/instructions.js +17 -0
  30. package/dist/memory/manager.js +92 -0
  31. package/dist/memory/markdown.js +78 -0
  32. package/dist/memory/markdown.test.js +42 -0
  33. package/dist/memory/mutex.js +18 -0
  34. package/dist/memory/path-guard.js +26 -0
  35. package/dist/memory/path-guard.test.js +27 -0
  36. package/dist/memory/paths.js +12 -0
  37. package/dist/memory/reconcile.js +75 -0
  38. package/dist/memory/reconcile.test.js +50 -0
  39. package/dist/memory/scaffold.js +37 -0
  40. package/dist/memory/scaffold.test.js +52 -0
  41. package/dist/memory/tools/commit-wrapper.js +32 -0
  42. package/dist/memory/tools/domains.js +73 -0
  43. package/dist/memory/tools/domains.test.js +66 -0
  44. package/dist/memory/tools/git.js +52 -0
  45. package/dist/memory/tools/index.js +25 -0
  46. package/dist/memory/tools/read.js +101 -0
  47. package/dist/memory/tools/read.test.js +69 -0
  48. package/dist/memory/tools/search.js +103 -0
  49. package/dist/memory/tools/search.test.js +63 -0
  50. package/dist/memory/tools/sessions.js +45 -0
  51. package/dist/memory/tools/sessions.test.js +74 -0
  52. package/dist/memory/tools/shared.js +7 -0
  53. package/dist/memory/tools/write.js +116 -0
  54. package/dist/memory/tools/write.test.js +107 -0
  55. package/dist/memory/walk.js +39 -0
  56. package/dist/store/repositories/sessions.js +40 -0
  57. package/dist/wiki/consolidation.js +3 -31
  58. package/dist/wiki/consolidation.test.js +0 -19
  59. package/memory-assets/domain-skill.md +38 -0
  60. package/memory-assets/seed/cog-meta/improvements.md +8 -0
  61. package/memory-assets/seed/cog-meta/patterns.md +5 -0
  62. package/memory-assets/seed/cog-meta/reflect-cursor.md +4 -0
  63. package/memory-assets/seed/cog-meta/scenario-calibration.md +14 -0
  64. package/memory-assets/seed/cog-meta/self-observations.md +4 -0
  65. package/memory-assets/seed/domains.yml +19 -0
  66. package/memory-assets/seed/glacier/index.md +6 -0
  67. package/memory-assets/seed/hot-memory.md +5 -0
  68. package/memory-assets/seed/link-index.md +6 -0
  69. package/memory-assets/system-instructions.md +214 -0
  70. package/memory-assets/templates/action-items.md +8 -0
  71. package/memory-assets/templates/entities.md +4 -0
  72. package/memory-assets/templates/generic.md +2 -0
  73. package/memory-assets/templates/hot-memory.md +4 -0
  74. package/memory-assets/templates/observations.md +4 -0
  75. package/package.json +2 -1
  76. package/skills/system/evolve/SKILL.md +131 -0
  77. package/skills/system/foresight/SKILL.md +116 -0
  78. package/skills/system/history/SKILL.md +58 -0
  79. package/skills/system/housekeeping/SKILL.md +185 -0
  80. package/skills/system/reflect/SKILL.md +214 -0
  81. package/skills/system/scenario/SKILL.md +198 -0
  82. package/skills/system/setup/SKILL.md +113 -0
  83. package/web/dist/assets/{WikiEdit-CGRxNazp.js → WikiEdit-BTsiBfbC.js} +2 -2
  84. package/web/dist/assets/{WikiEdit-CGRxNazp.js.map → WikiEdit-BTsiBfbC.js.map} +1 -1
  85. package/web/dist/assets/{WikiGraph-eVWNhZS3.js → WikiGraph-COOZbUeH.js} +2 -2
  86. package/web/dist/assets/{WikiGraph-eVWNhZS3.js.map → WikiGraph-COOZbUeH.js.map} +1 -1
  87. package/web/dist/assets/{index-gAvLNEvJ.js → index-aCcfpaLM.js} +101 -101
  88. package/web/dist/assets/index-aCcfpaLM.js.map +1 -0
  89. package/web/dist/index.html +1 -1
  90. package/dist/api/routes/memory.js +0 -475
  91. package/dist/api/routes/memory.test.js +0 -108
  92. package/dist/copilot/tools/memory.js +0 -678
  93. package/dist/copilot/tools.memory.test.js +0 -590
  94. package/dist/memory/action-items.js +0 -100
  95. package/dist/memory/action-items.test.js +0 -83
  96. package/dist/memory/active-scope.js +0 -78
  97. package/dist/memory/active-scope.test.js +0 -80
  98. package/dist/memory/checkpoint-prompt.js +0 -71
  99. package/dist/memory/checkpoint.js +0 -274
  100. package/dist/memory/checkpoint.test.js +0 -275
  101. package/dist/memory/decisions.js +0 -54
  102. package/dist/memory/decisions.test.js +0 -92
  103. package/dist/memory/entities.js +0 -70
  104. package/dist/memory/entities.test.js +0 -65
  105. package/dist/memory/eot.js +0 -459
  106. package/dist/memory/eot.test.js +0 -949
  107. package/dist/memory/hooks.js +0 -149
  108. package/dist/memory/hooks.test.js +0 -325
  109. package/dist/memory/hot-tier.js +0 -283
  110. package/dist/memory/hot-tier.test.js +0 -275
  111. package/dist/memory/housekeeping-scheduler.js +0 -187
  112. package/dist/memory/housekeeping-scheduler.test.js +0 -236
  113. package/dist/memory/housekeeping.js +0 -497
  114. package/dist/memory/housekeeping.test.js +0 -410
  115. package/dist/memory/inbox.js +0 -83
  116. package/dist/memory/inbox.test.js +0 -178
  117. package/dist/memory/migration.js +0 -244
  118. package/dist/memory/migration.test.js +0 -108
  119. package/dist/memory/observations.js +0 -46
  120. package/dist/memory/observations.test.js +0 -86
  121. package/dist/memory/recall.js +0 -269
  122. package/dist/memory/recall.test.js +0 -265
  123. package/dist/memory/reflect.js +0 -273
  124. package/dist/memory/reflect.test.js +0 -256
  125. package/dist/memory/scope-lock.js +0 -26
  126. package/dist/memory/scope-lock.test.js +0 -118
  127. package/dist/memory/scopes.js +0 -89
  128. package/dist/memory/scopes.test.js +0 -176
  129. package/dist/memory/tiering.js +0 -223
  130. package/dist/memory/tiering.test.js +0 -323
  131. package/dist/memory/types.js +0 -2
  132. package/web/dist/assets/index-gAvLNEvJ.js.map +0 -1
@@ -1,149 +0,0 @@
1
- import { childLogger } from "../util/logger.js";
2
- import { config } from "../config.js";
3
- import { getActiveScope } from "./active-scope.js";
4
- import { recordObservation } from "./observations.js";
5
- import { getScope } from "./scopes.js";
6
- const log = childLogger("memory.hooks");
7
- // ---------------------------------------------------------------------------
8
- // Env knob
9
- // ---------------------------------------------------------------------------
10
- function isHooksEnabled() {
11
- return config.memoryHooksEnabled;
12
- }
13
- // ---------------------------------------------------------------------------
14
- // Dispatcher
15
- // ---------------------------------------------------------------------------
16
- export class MemoryHookDispatcher {
17
- handlers = new Map();
18
- register(event, handler) {
19
- const bucket = this.handlers.get(event) ?? [];
20
- bucket.push(handler);
21
- this.handlers.set(event, bucket);
22
- }
23
- async dispatch(event, payload) {
24
- if (!isHooksEnabled()) {
25
- log.debug({ event }, "memory.hooks.skip.disabled");
26
- return;
27
- }
28
- const bucket = this.handlers.get(event) ?? [];
29
- if (bucket.length === 0) {
30
- log.debug({ event }, "memory.hooks.no_handlers");
31
- return;
32
- }
33
- log.info({ event, handlerCount: bucket.length }, "memory.hooks.dispatch");
34
- for (const handler of bucket) {
35
- try {
36
- await handler(payload);
37
- }
38
- catch (err) {
39
- log.error({ err, event }, "memory.hooks.handler_error");
40
- }
41
- }
42
- }
43
- }
44
- // ---------------------------------------------------------------------------
45
- // Singleton dispatcher — shared across the process
46
- // ---------------------------------------------------------------------------
47
- export const hookDispatcher = new MemoryHookDispatcher();
48
- // ---------------------------------------------------------------------------
49
- // Built-in handler: git:commit → observation in active scope
50
- // ---------------------------------------------------------------------------
51
- function buildGitCommitContent(payload) {
52
- const lines = [`Git commit: ${payload.message}`];
53
- if (payload.stat?.trim()) {
54
- lines.push("", "Changed files:", payload.stat.trim());
55
- }
56
- return lines.join("\n");
57
- }
58
- function resolveGlobalOrActiveScope() {
59
- const active = getActiveScope();
60
- if (active)
61
- return active;
62
- return getScope("global") ?? null;
63
- }
64
- export async function handleGitCommitHook(payload) {
65
- if (!isHooksEnabled()) {
66
- log.debug("memory.hooks.git_commit.disabled");
67
- return { observation_id: "disabled" };
68
- }
69
- const scope = resolveGlobalOrActiveScope();
70
- if (!scope) {
71
- throw new Error("No active or global memory scope found. Create a scope before using memory hooks.");
72
- }
73
- const content = buildGitCommitContent(payload);
74
- const observation = recordObservation({
75
- scope_id: scope.id,
76
- content,
77
- source: "git:commit",
78
- tier: "warm",
79
- confidence: 1.0,
80
- });
81
- log.info({ scope: scope.slug, observation_id: observation.id }, "memory.hooks.git_commit.recorded");
82
- return { observation_id: String(observation.id) };
83
- }
84
- // ---------------------------------------------------------------------------
85
- // Built-in handler: pr:merge → observation
86
- // ---------------------------------------------------------------------------
87
- function buildPrMergeContent(payload) {
88
- const lines = [`PR #${payload.number} merged: ${payload.title}`];
89
- if (payload.body?.trim()) {
90
- lines.push("", payload.body.trim());
91
- }
92
- if (payload.files_changed && payload.files_changed.length > 0) {
93
- lines.push("", "Files changed:");
94
- for (const file of payload.files_changed) {
95
- lines.push(` ${file}`);
96
- }
97
- }
98
- return lines.join("\n");
99
- }
100
- export async function handlePrMergeHook(payload) {
101
- if (!isHooksEnabled()) {
102
- log.debug("memory.hooks.pr_merge.disabled");
103
- return { observation_id: "disabled" };
104
- }
105
- const scope = resolveGlobalOrActiveScope();
106
- if (!scope) {
107
- throw new Error("No active or global memory scope found. Create a scope before using memory hooks.");
108
- }
109
- const content = buildPrMergeContent(payload);
110
- const observation = recordObservation({
111
- scope_id: scope.id,
112
- content,
113
- source: "pr:merge",
114
- tier: "warm",
115
- confidence: 1.0,
116
- });
117
- log.info({ scope: scope.slug, observation_id: observation.id, pr: payload.number }, "memory.hooks.pr_merge.recorded");
118
- return { observation_id: String(observation.id) };
119
- }
120
- // ---------------------------------------------------------------------------
121
- // Built-in handler: scope:created → observation in global scope
122
- // ---------------------------------------------------------------------------
123
- hookDispatcher.register("scope:created", async (payload) => {
124
- const globalScope = getScope("global");
125
- if (!globalScope) {
126
- log.debug("memory.hooks.scope_created.no_global_scope");
127
- return;
128
- }
129
- const content = `Scope '${payload.slug}' created: ${payload.description || payload.title}`;
130
- const observation = recordObservation({
131
- scope_id: globalScope.id,
132
- content,
133
- source: "scope:created",
134
- tier: "warm",
135
- confidence: 1.0,
136
- });
137
- log.info({ scope: payload.slug, observation_id: observation.id }, "memory.hooks.scope_created.recorded");
138
- });
139
- // ---------------------------------------------------------------------------
140
- // Built-in handler: memory:decision → structured log event
141
- // ---------------------------------------------------------------------------
142
- hookDispatcher.register("memory:decision", async (payload) => {
143
- log.info({
144
- decision_id: payload.id,
145
- scope_id: payload.scope_id,
146
- title: payload.title,
147
- }, "memory.hooks.decision.recorded");
148
- });
149
- //# sourceMappingURL=hooks.js.map
@@ -1,325 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { spawn } from "node:child_process";
3
- import { mkdirSync, rmSync } from "node:fs";
4
- import { join } from "node:path";
5
- import test from "node:test";
6
- import { DEFAULT_API_SERVER_STARTUP_TIMEOUT_MS, getFreePort, stopChild, waitForApiServerReady, } from "../test/api-server.js";
7
- const repoRoot = process.cwd();
8
- const sandboxRoot = join(repoRoot, ".test-work", `memory-hooks-${process.pid}`);
9
- const chapterhouseHome = join(sandboxRoot, ".chapterhouse");
10
- process.env.CHAPTERHOUSE_HOME = sandboxRoot;
11
- function resetSandbox() {
12
- mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
13
- rmSync(sandboxRoot, { recursive: true, force: true });
14
- mkdirSync(chapterhouseHome, { recursive: true });
15
- }
16
- async function loadModules(cacheBust = `${Date.now()}-${Math.random()}`) {
17
- const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
18
- const memoryModule = await import(new URL(`./index.js?case=${cacheBust}`, import.meta.url).href);
19
- const hooksModule = await import(new URL(`./hooks.js?case=${cacheBust}`, import.meta.url).href);
20
- return { dbModule, memoryModule, hooksModule };
21
- }
22
- function getFunction(module, name) {
23
- const value = module[name];
24
- assert.equal(typeof value, "function", `expected ${name} to be exported`);
25
- return value;
26
- }
27
- test.beforeEach(async () => {
28
- process.env.CHAPTERHOUSE_HOME = sandboxRoot;
29
- delete process.env.CHAPTERHOUSE_MEMORY_HOOKS_ENABLED;
30
- const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
31
- dbModule.closeDb();
32
- resetSandbox();
33
- });
34
- test.after(async () => {
35
- delete process.env.CHAPTERHOUSE_MEMORY_HOOKS_ENABLED;
36
- const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
37
- dbModule.closeDb();
38
- rmSync(sandboxRoot, { recursive: true, force: true });
39
- });
40
- // ---------------------------------------------------------------------------
41
- // MemoryHookDispatcher — basic register/dispatch
42
- // ---------------------------------------------------------------------------
43
- test("MemoryHookDispatcher dispatches to registered handlers", async () => {
44
- const { hooksModule } = await loadModules("dispatch-basic");
45
- const MemoryHookDispatcher = hooksModule["MemoryHookDispatcher"];
46
- assert.ok(MemoryHookDispatcher, "MemoryHookDispatcher should be exported");
47
- const dispatcher = new MemoryHookDispatcher();
48
- const received = [];
49
- dispatcher.register("git:commit", async (payload) => { received.push(payload); });
50
- await dispatcher.dispatch("git:commit", { message: "initial commit", stat: "1 file changed" });
51
- assert.equal(received.length, 1);
52
- assert.deepEqual(received[0], { message: "initial commit", stat: "1 file changed" });
53
- });
54
- test("MemoryHookDispatcher dispatches to multiple handlers for same event", async () => {
55
- const { hooksModule } = await loadModules("dispatch-multi");
56
- const MemoryHookDispatcher = hooksModule["MemoryHookDispatcher"];
57
- const dispatcher = new MemoryHookDispatcher();
58
- let callCount = 0;
59
- dispatcher.register("pr:merge", async () => { callCount++; });
60
- dispatcher.register("pr:merge", async () => { callCount++; });
61
- await dispatcher.dispatch("pr:merge", { number: 42, title: "feat: something" });
62
- assert.equal(callCount, 2);
63
- });
64
- test("MemoryHookDispatcher is no-op when CHAPTERHOUSE_MEMORY_HOOKS_ENABLED=false", async () => {
65
- process.env.CHAPTERHOUSE_MEMORY_HOOKS_ENABLED = "false";
66
- const { hooksModule } = await loadModules("dispatch-disabled-false");
67
- const MemoryHookDispatcher = hooksModule["MemoryHookDispatcher"];
68
- const dispatcher = new MemoryHookDispatcher();
69
- let called = false;
70
- dispatcher.register("git:commit", async () => { called = true; });
71
- await dispatcher.dispatch("git:commit", { message: "disabled test" });
72
- assert.equal(called, false);
73
- });
74
- test("MemoryHookDispatcher is no-op when CHAPTERHOUSE_MEMORY_HOOKS_ENABLED=0", async () => {
75
- process.env.CHAPTERHOUSE_MEMORY_HOOKS_ENABLED = "0";
76
- const { hooksModule } = await loadModules("dispatch-disabled-zero");
77
- const MemoryHookDispatcher = hooksModule["MemoryHookDispatcher"];
78
- const dispatcher = new MemoryHookDispatcher();
79
- let called = false;
80
- dispatcher.register("pr:merge", async () => { called = true; });
81
- await dispatcher.dispatch("pr:merge", { number: 1, title: "test" });
82
- assert.equal(called, false);
83
- });
84
- test("MemoryHookDispatcher handler errors don't propagate to caller", async () => {
85
- const { hooksModule } = await loadModules("dispatch-error-swallow");
86
- const MemoryHookDispatcher = hooksModule["MemoryHookDispatcher"];
87
- const dispatcher = new MemoryHookDispatcher();
88
- dispatcher.register("git:commit", async () => { throw new Error("handler blew up"); });
89
- await assert.doesNotReject(dispatcher.dispatch("git:commit", { message: "safe?" }));
90
- });
91
- // ---------------------------------------------------------------------------
92
- // handleGitCommitHook — writes observation to DB
93
- // ---------------------------------------------------------------------------
94
- test("handleGitCommitHook records an observation in the active/global scope", async () => {
95
- const { dbModule, memoryModule, hooksModule } = await loadModules("git-commit-hook");
96
- dbModule.getDb();
97
- const getScope = getFunction(memoryModule, "getScope");
98
- const listObservations = getFunction(memoryModule, "listObservations");
99
- const handleGitCommitHook = getFunction(hooksModule, "handleGitCommitHook");
100
- const globalScope = getScope("global");
101
- assert.ok(globalScope, "global scope must exist after DB init");
102
- const result = await handleGitCommitHook({ message: "feat: add hooks", stat: "src/memory/hooks.ts | 5 ++" });
103
- assert.ok(result.observation_id);
104
- assert.notEqual(result.observation_id, "disabled");
105
- const observations = listObservations({ scope_id: globalScope.id, limit: 10 });
106
- const found = observations.find((obs) => obs.source === "git:commit" && obs.content.includes("feat: add hooks"));
107
- assert.ok(found, "observation should be recorded with source git:commit");
108
- assert.ok(found.content.includes("Changed files:"), "content should include changed files section");
109
- });
110
- test("handleGitCommitHook returns disabled when CHAPTERHOUSE_MEMORY_HOOKS_ENABLED=false", async () => {
111
- process.env.CHAPTERHOUSE_MEMORY_HOOKS_ENABLED = "false";
112
- const { dbModule, hooksModule } = await loadModules("git-commit-hook-disabled");
113
- dbModule.getDb();
114
- const handleGitCommitHook = getFunction(hooksModule, "handleGitCommitHook");
115
- const result = await handleGitCommitHook({ message: "should not be recorded" });
116
- assert.equal(result.observation_id, "disabled");
117
- });
118
- // ---------------------------------------------------------------------------
119
- // handlePrMergeHook
120
- // ---------------------------------------------------------------------------
121
- test("handlePrMergeHook records an observation with PR details", async () => {
122
- const { dbModule, memoryModule, hooksModule } = await loadModules("pr-merge-hook");
123
- dbModule.getDb();
124
- const getScope = getFunction(memoryModule, "getScope");
125
- const listObservations = getFunction(memoryModule, "listObservations");
126
- const handlePrMergeHook = getFunction(hooksModule, "handlePrMergeHook");
127
- const globalScope = getScope("global");
128
- assert.ok(globalScope, "global scope must exist");
129
- const result = await handlePrMergeHook({
130
- number: 42,
131
- title: "feat(memory): event hooks",
132
- body: "Implements Sprint 1 Track A",
133
- files_changed: ["src/memory/hooks.ts", "src/memory/index.ts"],
134
- });
135
- assert.ok(result.observation_id);
136
- assert.notEqual(result.observation_id, "disabled");
137
- const observations = listObservations({ scope_id: globalScope.id, limit: 10 });
138
- const found = observations.find((obs) => obs.source === "pr:merge" && obs.content.includes("PR #42"));
139
- assert.ok(found, "observation should record PR number");
140
- assert.ok(found.content.includes("feat(memory): event hooks"), "content should include PR title");
141
- assert.ok(found.content.includes("src/memory/hooks.ts"), "content should list changed files");
142
- });
143
- // ---------------------------------------------------------------------------
144
- // scope:created hook (fires from hookDispatcher after createScope)
145
- // ---------------------------------------------------------------------------
146
- test("scope:created handler writes observation to global scope", async () => {
147
- const { dbModule, memoryModule, hooksModule } = await loadModules("scope-created");
148
- dbModule.getDb();
149
- const getScope = getFunction(memoryModule, "getScope");
150
- const listObservations = getFunction(memoryModule, "listObservations");
151
- const hookDispatcher = hooksModule["hookDispatcher"];
152
- assert.ok(hookDispatcher, "hookDispatcher should be exported");
153
- const globalScope = getScope("global");
154
- assert.ok(globalScope, "global scope must exist");
155
- await hookDispatcher.dispatch("scope:created", {
156
- slug: "my-project",
157
- title: "My Project",
158
- description: "A test project scope",
159
- });
160
- const observations = listObservations({ scope_id: globalScope.id, limit: 10 });
161
- const found = observations.find((obs) => obs.source === "scope:created" && obs.content.includes("my-project"));
162
- assert.ok(found, "scope creation observation should be written to global scope");
163
- assert.ok(found.content.includes("A test project scope"), "content should include description");
164
- });
165
- test("scope:created hook is no-op when CHAPTERHOUSE_MEMORY_HOOKS_ENABLED=0", async () => {
166
- process.env.CHAPTERHOUSE_MEMORY_HOOKS_ENABLED = "0";
167
- const { dbModule, memoryModule, hooksModule } = await loadModules("scope-created-disabled");
168
- dbModule.getDb();
169
- const getScope = getFunction(memoryModule, "getScope");
170
- const listObservations = getFunction(memoryModule, "listObservations");
171
- const hookDispatcher = hooksModule["hookDispatcher"];
172
- const globalScope = getScope("global");
173
- assert.ok(globalScope, "global scope must exist");
174
- const before = listObservations({ scope_id: globalScope.id, limit: 100 });
175
- await hookDispatcher.dispatch("scope:created", { slug: "silent", title: "Silent", description: "should not persist" });
176
- const after = listObservations({ scope_id: globalScope.id, limit: 100 });
177
- assert.equal(after.length, before.length, "no new observations when hooks disabled");
178
- });
179
- // ---------------------------------------------------------------------------
180
- // API endpoints — integration tests using a spawned server
181
- // ---------------------------------------------------------------------------
182
- async function withStartedServer(run, extraEnv = {}, timeoutMs = DEFAULT_API_SERVER_STARTUP_TIMEOUT_MS) {
183
- const testRoot = join(repoRoot, ".test-work", `hooks-api-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`);
184
- mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
185
- rmSync(testRoot, { recursive: true, force: true });
186
- mkdirSync(testRoot, { recursive: true });
187
- const port = await getFreePort();
188
- const logs = [];
189
- const child = spawn(process.execPath, [
190
- "--input-type=module",
191
- "-e",
192
- "import { startApiServer } from './dist/api/server.js'; await startApiServer();",
193
- ], {
194
- cwd: repoRoot,
195
- env: {
196
- ...Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.startsWith("COPILOT_"))),
197
- CHAPTERHOUSE_DISABLE_DOTENV: "1",
198
- CHAPTERHOUSE_HOME: testRoot,
199
- API_HOST: "127.0.0.1",
200
- API_PORT: String(port),
201
- API_TOKEN: "hooks-test-token",
202
- ...extraEnv,
203
- },
204
- stdio: ["ignore", "pipe", "pipe"],
205
- });
206
- child.stdout?.on("data", (chunk) => logs.push(String(chunk)));
207
- child.stderr?.on("data", (chunk) => logs.push(String(chunk)));
208
- const baseUrl = `http://127.0.0.1:${port}`;
209
- try {
210
- await waitForApiServerReady({ child, baseUrl, logs, timeoutMs });
211
- await run({ baseUrl, authHeader: "Bearer hooks-test-token", testRoot });
212
- }
213
- finally {
214
- await stopChild(child);
215
- rmSync(testRoot, { recursive: true, force: true });
216
- }
217
- }
218
- test("POST /api/memory/hooks/git-commit returns ok with observation_id", async () => {
219
- await withStartedServer(async ({ baseUrl, authHeader }) => {
220
- const response = await fetch(`${baseUrl}/api/memory/hooks/git-commit`, {
221
- method: "POST",
222
- headers: { "Content-Type": "application/json", Authorization: authHeader },
223
- body: JSON.stringify({ message: "feat: test commit", stat: "1 file changed, 5 insertions" }),
224
- });
225
- assert.equal(response.status, 200, `Expected 200, got ${response.status}`);
226
- const body = await response.json();
227
- assert.equal(body.ok, true);
228
- assert.ok(typeof body.observation_id === "string" && body.observation_id.length > 0, "observation_id should be returned");
229
- });
230
- });
231
- test("POST /api/memory/hooks/git-commit validates required message field", async () => {
232
- await withStartedServer(async ({ baseUrl, authHeader }) => {
233
- const response = await fetch(`${baseUrl}/api/memory/hooks/git-commit`, {
234
- method: "POST",
235
- headers: { "Content-Type": "application/json", Authorization: authHeader },
236
- body: JSON.stringify({ stat: "no message field" }),
237
- });
238
- assert.equal(response.status, 400, `Expected 400, got ${response.status}`);
239
- });
240
- });
241
- test("POST /api/memory/hooks/pr-merge returns ok with observation_id", async () => {
242
- await withStartedServer(async ({ baseUrl, authHeader }) => {
243
- const response = await fetch(`${baseUrl}/api/memory/hooks/pr-merge`, {
244
- method: "POST",
245
- headers: { "Content-Type": "application/json", Authorization: authHeader },
246
- body: JSON.stringify({
247
- number: 99,
248
- title: "feat: test PR",
249
- body: "Adds some feature",
250
- files_changed: ["src/foo.ts", "src/bar.ts"],
251
- }),
252
- });
253
- assert.equal(response.status, 200, `Expected 200, got ${response.status}`);
254
- const body = await response.json();
255
- assert.equal(body.ok, true);
256
- assert.ok(typeof body.observation_id === "string" && body.observation_id.length > 0, "observation_id should be returned");
257
- });
258
- });
259
- test("POST /api/memory/hooks/pr-merge validates required fields", async () => {
260
- await withStartedServer(async ({ baseUrl, authHeader }) => {
261
- const response = await fetch(`${baseUrl}/api/memory/hooks/pr-merge`, {
262
- method: "POST",
263
- headers: { "Content-Type": "application/json", Authorization: authHeader },
264
- body: JSON.stringify({ title: "missing number" }),
265
- });
266
- assert.equal(response.status, 400, `Expected 400, got ${response.status}`);
267
- });
268
- });
269
- // ---------------------------------------------------------------------------
270
- // Auth enforcement on hook endpoints — Bug #334
271
- // ---------------------------------------------------------------------------
272
- test("POST /api/memory/hooks/git-commit returns 401 without auth token", async () => {
273
- await withStartedServer(async ({ baseUrl }) => {
274
- const response = await fetch(`${baseUrl}/api/memory/hooks/git-commit`, {
275
- method: "POST",
276
- headers: { "Content-Type": "application/json" },
277
- body: JSON.stringify({ message: "unauthorized commit" }),
278
- });
279
- assert.equal(response.status, 401, `Expected 401, got ${response.status}`);
280
- });
281
- });
282
- test("POST /api/memory/hooks/git-commit returns 401 with wrong auth token", async () => {
283
- await withStartedServer(async ({ baseUrl }) => {
284
- const response = await fetch(`${baseUrl}/api/memory/hooks/git-commit`, {
285
- method: "POST",
286
- headers: { "Content-Type": "application/json", Authorization: "Bearer wrong-token" },
287
- body: JSON.stringify({ message: "bad token commit" }),
288
- });
289
- assert.equal(response.status, 401, `Expected 401, got ${response.status}`);
290
- });
291
- });
292
- test("POST /api/memory/hooks/pr-merge returns 401 without auth token", async () => {
293
- await withStartedServer(async ({ baseUrl }) => {
294
- const response = await fetch(`${baseUrl}/api/memory/hooks/pr-merge`, {
295
- method: "POST",
296
- headers: { "Content-Type": "application/json" },
297
- body: JSON.stringify({ number: 1, title: "unauthorized PR" }),
298
- });
299
- assert.equal(response.status, 401, `Expected 401, got ${response.status}`);
300
- });
301
- });
302
- test("POST /api/memory/hooks/pr-merge returns 401 with wrong auth token", async () => {
303
- await withStartedServer(async ({ baseUrl }) => {
304
- const response = await fetch(`${baseUrl}/api/memory/hooks/pr-merge`, {
305
- method: "POST",
306
- headers: { "Content-Type": "application/json", Authorization: "Bearer wrong-token" },
307
- body: JSON.stringify({ number: 1, title: "bad token PR" }),
308
- });
309
- assert.equal(response.status, 401, `Expected 401, got ${response.status}`);
310
- });
311
- });
312
- test("POST /api/memory/hooks/git-commit is no-op when CHAPTERHOUSE_MEMORY_HOOKS_ENABLED=false", async () => {
313
- await withStartedServer(async ({ baseUrl, authHeader }) => {
314
- const response = await fetch(`${baseUrl}/api/memory/hooks/git-commit`, {
315
- method: "POST",
316
- headers: { "Content-Type": "application/json", Authorization: authHeader },
317
- body: JSON.stringify({ message: "should be disabled" }),
318
- });
319
- assert.equal(response.status, 200, `Expected 200, got ${response.status}`);
320
- const body = await response.json();
321
- assert.equal(body.ok, true);
322
- assert.equal(body.observation_id, "disabled", "observation_id should be 'disabled' when hooks are off");
323
- }, { CHAPTERHOUSE_MEMORY_HOOKS_ENABLED: "0" });
324
- });
325
- //# sourceMappingURL=hooks.test.js.map