chapterhouse 0.9.2 → 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.
Files changed (111) hide show
  1. package/README.md +1 -1
  2. package/dist/api/auth.js +11 -1
  3. package/dist/api/auth.test.js +29 -0
  4. package/dist/api/errors.js +23 -0
  5. package/dist/api/route-coverage.test.js +61 -21
  6. package/dist/api/routes/agents.js +472 -0
  7. package/dist/api/routes/memory.js +299 -0
  8. package/dist/api/routes/projects.js +170 -0
  9. package/dist/api/routes/sessions.js +347 -0
  10. package/dist/api/routes/system.js +82 -0
  11. package/dist/api/routes/wiki.js +455 -0
  12. package/dist/api/routes/wiki.test.js +49 -0
  13. package/dist/api/send-json.js +16 -0
  14. package/dist/api/send-json.test.js +18 -0
  15. package/dist/api/server-runtime.js +45 -3
  16. package/dist/api/server.js +34 -1764
  17. package/dist/api/server.test.js +239 -8
  18. package/dist/api/sse-hub.js +37 -0
  19. package/dist/cli.js +1 -1
  20. package/dist/config.js +151 -58
  21. package/dist/config.test.js +29 -0
  22. package/dist/copilot/okr-mapper.js +2 -11
  23. package/dist/copilot/orchestrator.js +358 -352
  24. package/dist/copilot/orchestrator.test.js +139 -4
  25. package/dist/copilot/prompt-date.js +2 -1
  26. package/dist/copilot/session-manager.js +25 -23
  27. package/dist/copilot/session-manager.test.js +35 -1
  28. package/dist/copilot/standup.js +2 -2
  29. package/dist/copilot/task-event-log.js +7 -1
  30. package/dist/copilot/task-event-log.test.js +13 -0
  31. package/dist/copilot/tools/agent.js +608 -0
  32. package/dist/copilot/tools/index.js +19 -0
  33. package/dist/copilot/tools/memory.js +678 -0
  34. package/dist/copilot/tools/models.js +2 -0
  35. package/dist/copilot/tools/okr.js +171 -0
  36. package/dist/copilot/tools/wiki.js +333 -0
  37. package/dist/copilot/tools-deps.js +4 -0
  38. package/dist/copilot/tools.agent.test.js +10 -8
  39. package/dist/copilot/tools.inventory.test.js +76 -0
  40. package/dist/copilot/tools.js +1 -1780
  41. package/dist/copilot/tools.okr.test.js +31 -0
  42. package/dist/copilot/tools.wiki.test.js +6 -3
  43. package/dist/copilot/turn-event-log.js +31 -4
  44. package/dist/copilot/turn-event-log.test.js +24 -2
  45. package/dist/copilot/workiq-installer.test.js +2 -2
  46. package/dist/daemon-install.js +3 -2
  47. package/dist/daemon.js +9 -17
  48. package/dist/integrations/ado-client.js +90 -9
  49. package/dist/integrations/ado-client.test.js +56 -0
  50. package/dist/integrations/team-push.js +1 -0
  51. package/dist/integrations/team-push.test.js +6 -0
  52. package/dist/integrations/teams-notify.js +1 -0
  53. package/dist/integrations/teams-notify.test.js +5 -0
  54. package/dist/memory/active-scope.test.js +0 -1
  55. package/dist/memory/checkpoint.js +89 -72
  56. package/dist/memory/checkpoint.test.js +23 -3
  57. package/dist/memory/eot.js +87 -85
  58. package/dist/memory/eot.test.js +71 -3
  59. package/dist/memory/hooks.js +2 -4
  60. package/dist/memory/housekeeping-scheduler.js +1 -1
  61. package/dist/memory/housekeeping-scheduler.test.js +1 -2
  62. package/dist/memory/housekeeping.js +100 -3
  63. package/dist/memory/housekeeping.test.js +33 -2
  64. package/dist/memory/reflect.test.js +2 -0
  65. package/dist/memory/scope-lock.js +26 -0
  66. package/dist/memory/scope-lock.test.js +118 -0
  67. package/dist/memory/scopes.test.js +0 -1
  68. package/dist/mode-context.js +58 -5
  69. package/dist/mode-context.test.js +68 -0
  70. package/dist/paths.js +1 -0
  71. package/dist/setup.js +3 -2
  72. package/dist/shared/api-schemas.js +48 -5
  73. package/dist/store/connection.js +96 -0
  74. package/dist/store/db.js +5 -1498
  75. package/dist/store/db.test.js +182 -1
  76. package/dist/store/migrations.js +460 -0
  77. package/dist/store/repositories/memory.js +281 -0
  78. package/dist/store/repositories/okr.js +3 -0
  79. package/dist/store/repositories/projects.js +5 -0
  80. package/dist/store/repositories/sessions.js +284 -0
  81. package/dist/store/repositories/wiki.js +60 -0
  82. package/dist/store/schema.js +501 -0
  83. package/dist/util/logger.js +3 -2
  84. package/dist/wiki/consolidation.js +50 -9
  85. package/dist/wiki/consolidation.test.js +45 -0
  86. package/dist/wiki/frontmatter.js +43 -13
  87. package/dist/wiki/frontmatter.test.js +24 -0
  88. package/dist/wiki/fs.js +16 -4
  89. package/dist/wiki/fs.test.js +84 -0
  90. package/dist/wiki/index-manager.js +30 -2
  91. package/dist/wiki/index-manager.test.js +43 -12
  92. package/dist/wiki/ingest.js +1 -1
  93. package/dist/wiki/lock.js +11 -1
  94. package/dist/wiki/log-manager.js +2 -7
  95. package/dist/wiki/migrate.js +44 -17
  96. package/dist/wiki/project-registry.js +10 -5
  97. package/dist/wiki/project-registry.test.js +14 -0
  98. package/dist/wiki/scheduler.js +1 -1
  99. package/dist/wiki/seed-team-wiki.js +2 -1
  100. package/dist/wiki/team-sync.js +31 -6
  101. package/dist/wiki/team-sync.test.js +81 -0
  102. package/package.json +1 -1
  103. package/web/dist/assets/WikiEdit-BZXAdarz.js +30 -0
  104. package/web/dist/assets/WikiEdit-BZXAdarz.js.map +1 -0
  105. package/web/dist/assets/WikiGraph-KrCYco4v.js +2 -0
  106. package/web/dist/assets/WikiGraph-KrCYco4v.js.map +1 -0
  107. package/web/dist/assets/index-CUm2Wbuh.js +250 -0
  108. package/web/dist/assets/index-CUm2Wbuh.js.map +1 -0
  109. package/web/dist/index.html +1 -1
  110. package/web/dist/assets/index-iQrv3lQN.js +0 -286
  111. package/web/dist/assets/index-iQrv3lQN.js.map +0 -1
@@ -5,6 +5,7 @@ import { recordDecision } from "./decisions.js";
5
5
  import { listEntities } from "./entities.js";
6
6
  import { recordObservation, listObservations } from "./observations.js";
7
7
  import { getScope } from "./scopes.js";
8
+ import { releaseScopeWriteLocks, tryAcquireScopeWriteLocks } from "./scope-lock.js";
8
9
  import { listDecisions } from "./decisions.js";
9
10
  import { buildCheckpointSystemPrompt, buildCheckpointUserPrompt, } from "./checkpoint-prompt.js";
10
11
  const log = childLogger("memory.checkpoint");
@@ -48,29 +49,35 @@ function calculateSimilarity(left, right) {
48
49
  return overlap / new Set([...leftTokens, ...rightTokens]).size;
49
50
  }
50
51
  function parseProposals(raw) {
51
- const parsed = JSON.parse(raw);
52
- if (!Array.isArray(parsed.proposals)) {
53
- return [];
54
- }
55
- return parsed.proposals.flatMap((proposal) => {
56
- if (!proposal || typeof proposal !== "object") {
57
- return [];
58
- }
59
- const candidate = proposal;
60
- if ((candidate.kind !== "observation" && candidate.kind !== "decision")
61
- || typeof candidate.content !== "string"
62
- || typeof candidate.confidence !== "number") {
52
+ try {
53
+ const parsed = JSON.parse(raw);
54
+ if (!Array.isArray(parsed.proposals)) {
63
55
  return [];
64
56
  }
65
- return [{
66
- kind: candidate.kind,
67
- title: typeof candidate.title === "string" ? candidate.title.trim() : undefined,
68
- content: candidate.content.trim(),
69
- scope: typeof candidate.scope === "string" ? candidate.scope.trim() : undefined,
70
- confidence: candidate.confidence,
71
- decided_at: typeof candidate.decided_at === "string" ? candidate.decided_at.trim() : undefined,
72
- }];
73
- });
57
+ return parsed.proposals.flatMap((proposal) => {
58
+ if (!proposal || typeof proposal !== "object") {
59
+ return [];
60
+ }
61
+ const candidate = proposal;
62
+ if ((candidate.kind !== "observation" && candidate.kind !== "decision")
63
+ || typeof candidate.content !== "string"
64
+ || typeof candidate.confidence !== "number") {
65
+ return [];
66
+ }
67
+ return [{
68
+ kind: candidate.kind,
69
+ title: typeof candidate.title === "string" ? candidate.title.trim() : undefined,
70
+ content: candidate.content.trim(),
71
+ scope: typeof candidate.scope === "string" ? candidate.scope.trim() : undefined,
72
+ confidence: candidate.confidence,
73
+ decided_at: typeof candidate.decided_at === "string" ? candidate.decided_at.trim() : undefined,
74
+ }];
75
+ });
76
+ }
77
+ catch (err) {
78
+ log.warn({ err }, "malformed checkpoint proposals response");
79
+ return [];
80
+ }
74
81
  }
75
82
  function resolveProposalScope(proposal, activeScope) {
76
83
  if (!proposal.scope || proposal.scope === activeScope.slug) {
@@ -81,14 +88,15 @@ function resolveProposalScope(proposal, activeScope) {
81
88
  function isDuplicateObservation(content, scopeId, batchContents) {
82
89
  const existing = listObservations({ scope_id: scopeId, limit: RECENT_MEMORY_LIMIT });
83
90
  const combined = [...existing.map((observation) => observation.content), ...batchContents];
84
- return combined.some((candidate) => calculateSimilarity(candidate, content) >= 0.85);
91
+ return combined.some((candidate) => calculateSimilarity(candidate, content) >= config.memoryCheckpointDuplicateThreshold);
85
92
  }
86
93
  function isDuplicateDecision(proposal, scopeId) {
87
94
  const existing = listDecisions({ scope_id: scopeId, limit: RECENT_MEMORY_LIMIT });
88
95
  return existing.some((decision) => {
89
96
  const titleSimilarity = proposal.title ? calculateSimilarity(decision.title, proposal.title) : 0;
90
97
  const rationaleSimilarity = calculateSimilarity(decision.rationale, proposal.content);
91
- return titleSimilarity >= 0.85 || rationaleSimilarity >= 0.85;
98
+ return titleSimilarity >= config.memoryCheckpointDuplicateThreshold
99
+ || rationaleSimilarity >= config.memoryCheckpointDuplicateThreshold;
92
100
  });
93
101
  }
94
102
  export class CheckpointTracker {
@@ -96,12 +104,8 @@ export class CheckpointTracker {
96
104
  cadence;
97
105
  enabled;
98
106
  constructor(options = {}) {
99
- const rawTurns = process.env.CHAPTERHOUSE_MEMORY_CHECKPOINT_TURNS?.trim();
100
- const envTurns = rawTurns && /^\d+$/.test(rawTurns) ? Number(rawTurns) : undefined;
101
- const rawEnabled = process.env.CHAPTERHOUSE_MEMORY_CHECKPOINT_ENABLED?.trim();
102
- const envEnabled = rawEnabled === "0" ? false : rawEnabled === "1" ? true : undefined;
103
- this.cadence = options.turns ?? envTurns ?? config.memoryCheckpointTurns;
104
- this.enabled = options.enabled ?? envEnabled ?? config.memoryCheckpointEnabled;
107
+ this.cadence = options.turns ?? config.memoryCheckpointTurns;
108
+ this.enabled = options.enabled ?? config.memoryCheckpointEnabled;
105
109
  }
106
110
  tickOrchestratorTurn() {
107
111
  this.turnCountSinceLastFire++;
@@ -180,65 +184,78 @@ export async function runCheckpointExtraction(input) {
180
184
  scope: input.activeScope.slug,
181
185
  sessionKey: input.sessionKey ?? "default",
182
186
  }, "memory.checkpoint.proposal_count");
187
+ const resolvedProposals = proposals.map((proposal) => ({
188
+ proposal,
189
+ scope: resolveProposalScope(proposal, input.activeScope),
190
+ }));
191
+ const lockedScopeIds = [...new Set(resolvedProposals.flatMap(({ scope }) => (scope ? [scope.id] : [])))];
192
+ if (lockedScopeIds.length > 0 && !tryAcquireScopeWriteLocks(lockedScopeIds)) {
193
+ log.info({ sessionKey: input.sessionKey ?? "default", scope: input.activeScope.slug }, "memory.checkpoint.scope_in_flight_skip");
194
+ return { written: 0, skipped: 0, errors };
195
+ }
183
196
  let written = 0;
184
197
  let skipped = 0;
185
198
  const pendingObservationContents = [];
186
- for (const proposal of proposals) {
187
- if (proposal.confidence < MIN_CONFIDENCE) {
188
- skipped++;
189
- log.info({ reason: "low_confidence", confidence: proposal.confidence, scope: input.activeScope.slug }, "memory.checkpoint.skip");
190
- continue;
191
- }
192
- if (written >= MAX_WRITES_PER_CHECKPOINT) {
193
- skipped++;
194
- log.info({ reason: "cap_exceeded", scope: input.activeScope.slug }, "memory.checkpoint.skip");
195
- continue;
196
- }
197
- const scope = resolveProposalScope(proposal, input.activeScope);
198
- if (!scope) {
199
- skipped++;
200
- log.info({ reason: "no_scope", scope: proposal.scope ?? input.activeScope.slug }, "memory.checkpoint.skip");
201
- continue;
202
- }
203
- if (proposal.kind === "observation" && isDuplicateObservation(proposal.content, scope.id, pendingObservationContents)) {
204
- skipped++;
205
- log.info({ reason: "duplicate", scope_id: scope.id }, "memory.checkpoint.skip");
206
- continue;
207
- }
208
- if (proposal.kind === "decision") {
209
- if (!proposal.title) {
199
+ try {
200
+ for (const { proposal, scope } of resolvedProposals) {
201
+ if (proposal.confidence < MIN_CONFIDENCE) {
210
202
  skipped++;
211
- log.info({ reason: "missing_title", scope_id: scope.id }, "memory.checkpoint.skip");
203
+ log.info({ reason: "low_confidence", confidence: proposal.confidence, scope: input.activeScope.slug }, "memory.checkpoint.skip");
212
204
  continue;
213
205
  }
214
- if (isDuplicateDecision(proposal, scope.id)) {
206
+ if (written >= MAX_WRITES_PER_CHECKPOINT) {
207
+ skipped++;
208
+ log.info({ reason: "cap_exceeded", scope: input.activeScope.slug }, "memory.checkpoint.skip");
209
+ continue;
210
+ }
211
+ if (!scope) {
212
+ skipped++;
213
+ log.info({ reason: "no_scope", scope: proposal.scope ?? input.activeScope.slug }, "memory.checkpoint.skip");
214
+ continue;
215
+ }
216
+ if (proposal.kind === "observation" && isDuplicateObservation(proposal.content, scope.id, pendingObservationContents)) {
215
217
  skipped++;
216
218
  log.info({ reason: "duplicate", scope_id: scope.id }, "memory.checkpoint.skip");
217
219
  continue;
218
220
  }
219
- const decision = recordDecision({
221
+ if (proposal.kind === "decision") {
222
+ if (!proposal.title) {
223
+ skipped++;
224
+ log.info({ reason: "missing_title", scope_id: scope.id }, "memory.checkpoint.skip");
225
+ continue;
226
+ }
227
+ if (isDuplicateDecision(proposal, scope.id)) {
228
+ skipped++;
229
+ log.info({ reason: "duplicate", scope_id: scope.id }, "memory.checkpoint.skip");
230
+ continue;
231
+ }
232
+ const decision = recordDecision({
233
+ scope_id: scope.id,
234
+ title: proposal.title,
235
+ rationale: proposal.content,
236
+ decided_at: proposal.decided_at || new Date().toISOString().slice(0, 10),
237
+ tier: "warm",
238
+ });
239
+ written++;
240
+ log.info({ kind: "decision", scope_id: scope.id, id: decision.id }, "memory.checkpoint.write");
241
+ continue;
242
+ }
243
+ const observation = recordObservation({
220
244
  scope_id: scope.id,
221
- title: proposal.title,
222
- rationale: proposal.content,
223
- decided_at: proposal.decided_at || new Date().toISOString().slice(0, 10),
245
+ content: proposal.content,
246
+ source: "checkpoint:orchestrator",
224
247
  tier: "warm",
248
+ confidence: proposal.confidence,
225
249
  });
250
+ pendingObservationContents.push(proposal.content);
226
251
  written++;
227
- log.info({ kind: "decision", scope_id: scope.id, id: decision.id }, "memory.checkpoint.write");
228
- continue;
252
+ log.info({ kind: "observation", scope_id: scope.id, id: observation.id }, "memory.checkpoint.write");
229
253
  }
230
- const observation = recordObservation({
231
- scope_id: scope.id,
232
- content: proposal.content,
233
- source: "checkpoint:orchestrator",
234
- tier: "warm",
235
- confidence: proposal.confidence,
236
- });
237
- pendingObservationContents.push(proposal.content);
238
- written++;
239
- log.info({ kind: "observation", scope_id: scope.id, id: observation.id }, "memory.checkpoint.write");
254
+ return { written, skipped, errors };
255
+ }
256
+ finally {
257
+ releaseScopeWriteLocks(lockedScopeIds);
240
258
  }
241
- return { written, skipped, errors };
242
259
  }
243
260
  catch (error) {
244
261
  const message = error instanceof Error ? error.message : String(error);
@@ -18,6 +18,24 @@ async function loadModules(cacheBust = `${Date.now()}-${Math.random()}`) {
18
18
  const checkpointPromptModule = await import(new URL(`./checkpoint-prompt.js?case=${cacheBust}`, import.meta.url).href);
19
19
  return { dbModule, memoryModule, checkpointModule, checkpointPromptModule };
20
20
  }
21
+ async function loadModulesWithWarnSpy(t, cacheBust) {
22
+ const warnings = [];
23
+ t.mock.module("../util/logger.js", {
24
+ namedExports: {
25
+ childLogger: () => ({
26
+ info: () => { },
27
+ warn: (...args) => {
28
+ warnings.push(args);
29
+ },
30
+ error: () => { },
31
+ }),
32
+ },
33
+ });
34
+ const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
35
+ const memoryModule = await import(new URL(`./index.js?case=${cacheBust}`, import.meta.url).href);
36
+ const checkpointModule = await import(new URL(`./checkpoint.js?case=${cacheBust}`, import.meta.url).href);
37
+ return { dbModule, memoryModule, checkpointModule, warnings };
38
+ }
21
39
  function getFunction(module, name) {
22
40
  const value = module[name];
23
41
  assert.equal(typeof value, "function", `expected ${name} to be exported`);
@@ -157,8 +175,8 @@ test("runCheckpointExtraction writes high-confidence memories, skips low-confide
157
175
  assert.equal(listDecisions({ scope_id: chapterhouse.id }).some((row) => row.title === "Run checkpoint extraction asynchronously"), true);
158
176
  assert.equal(listObservations({ scope_id: chapterhouse.id, limit: 20 }).filter((row) => row.content === "Base new memory implementation branches from origin/main after dependent PRs merge.").length, 1);
159
177
  });
160
- test("runCheckpointExtraction handles malformed JSON responses without crashing", async () => {
161
- const { dbModule, memoryModule, checkpointModule } = await loadModules();
178
+ test("runCheckpointExtraction ignores malformed JSON responses and warns", async (t) => {
179
+ const { dbModule, memoryModule, checkpointModule, warnings } = await loadModulesWithWarnSpy(t, "malformed-json");
162
180
  dbModule.getDb();
163
181
  const getScope = getFunction(memoryModule, "getScope");
164
182
  const runCheckpointExtraction = getFunction(checkpointModule, "runCheckpointExtraction");
@@ -171,7 +189,9 @@ test("runCheckpointExtraction handles malformed JSON responses without crashing"
171
189
  callLLM: async () => "{ definitely-not-json",
172
190
  });
173
191
  assert.equal(result.written, 0);
174
- assert.ok(result.errors.length >= 1);
192
+ assert.equal(result.skipped, 0);
193
+ assert.deepEqual(result.errors, []);
194
+ assert.equal(warnings.length, 1);
175
195
  });
176
196
  test("runCheckpointExtraction prevents overlapping executions with the in-flight guard", async () => {
177
197
  const { dbModule, memoryModule, checkpointModule } = await loadModules();
@@ -11,28 +11,13 @@ import { getActiveScope } from "./active-scope.js";
11
11
  import { getScope } from "./scopes.js";
12
12
  const log = childLogger("memory.eot");
13
13
  function isEndOfTaskHookEnabled() {
14
- const raw = process.env.CHAPTERHOUSE_MEMORY_EOT_HOOK_ENABLED?.trim();
15
- if (raw === "0")
16
- return false;
17
- if (raw === "1")
18
- return true;
19
- return true;
14
+ return config.memoryEndOfTaskHookEnabled;
20
15
  }
21
16
  function isMemoryAutoAcceptEnabled() {
22
- const raw = process.env.CHAPTERHOUSE_MEMORY_AUTO_ACCEPT?.trim();
23
- if (raw === "0")
24
- return false;
25
- if (raw === "1")
26
- return true;
27
- return true;
17
+ return config.memoryAutoAcceptEnabled;
28
18
  }
29
19
  function isFrictionHookEnabled() {
30
- const raw = process.env.CHAPTERHOUSE_MEMORY_EOT_FRICTION_ENABLED?.trim();
31
- if (raw === "0")
32
- return false;
33
- if (raw === "1")
34
- return true;
35
- return false;
20
+ return config.memoryEndOfTaskFrictionEnabled;
36
21
  }
37
22
  function buildReviewerSystemPrompt() {
38
23
  return [
@@ -71,77 +56,89 @@ function buildFrictionUserPrompt(finalResult) {
71
56
  return JSON.stringify({ final_result: finalResult }, null, 2);
72
57
  }
73
58
  function parseEnvelope(raw) {
74
- const parsed = JSON.parse(raw);
75
- if (!parsed || typeof parsed !== "object") {
76
- throw new Error("Invalid memory proposal payload.");
77
- }
78
- if (parsed.kind !== "observation"
79
- && parsed.kind !== "decision"
80
- && parsed.kind !== "entity"
81
- && parsed.kind !== "action_item") {
82
- throw new Error("Invalid proposal kind.");
59
+ try {
60
+ const parsed = JSON.parse(raw);
61
+ if (!parsed || typeof parsed !== "object") {
62
+ throw new Error("Invalid memory proposal payload.");
63
+ }
64
+ if (parsed.kind !== "observation"
65
+ && parsed.kind !== "decision"
66
+ && parsed.kind !== "entity"
67
+ && parsed.kind !== "action_item") {
68
+ throw new Error("Invalid proposal kind.");
69
+ }
70
+ if (!parsed.payload || typeof parsed.payload !== "object") {
71
+ throw new Error("Invalid proposal payload.");
72
+ }
73
+ return {
74
+ kind: parsed.kind,
75
+ scope_slug: typeof parsed.scope_slug === "string" ? parsed.scope_slug : undefined,
76
+ confidence: typeof parsed.confidence === "number" ? parsed.confidence : 0.5,
77
+ reason: typeof parsed.reason === "string" ? parsed.reason : undefined,
78
+ payload: parsed.payload,
79
+ };
83
80
  }
84
- if (!parsed.payload || typeof parsed.payload !== "object") {
85
- throw new Error("Invalid proposal payload.");
81
+ catch (err) {
82
+ log.warn({ err }, "malformed memory proposal payload");
83
+ return null;
86
84
  }
87
- return {
88
- kind: parsed.kind,
89
- scope_slug: typeof parsed.scope_slug === "string" ? parsed.scope_slug : undefined,
90
- confidence: typeof parsed.confidence === "number" ? parsed.confidence : 0.5,
91
- reason: typeof parsed.reason === "string" ? parsed.reason : undefined,
92
- payload: parsed.payload,
93
- };
94
85
  }
95
86
  function parseReviewerResponse(raw) {
96
- const parsed = JSON.parse(raw);
97
- return {
98
- decisions: Array.isArray(parsed.decisions)
99
- ? parsed.decisions.flatMap((entry) => {
100
- if (!entry || typeof entry !== "object") {
101
- return [];
102
- }
103
- const candidate = entry;
104
- if (typeof candidate.proposal_id !== "number") {
105
- return [];
106
- }
107
- if (candidate.decision !== "accept" && candidate.decision !== "reject") {
108
- return [];
109
- }
110
- if (typeof candidate.reason !== "string" || candidate.reason.trim().length === 0) {
111
- return [];
112
- }
113
- return [{
114
- proposal_id: candidate.proposal_id,
115
- decision: candidate.decision,
116
- reason: candidate.reason.trim(),
117
- }];
118
- })
119
- : [],
120
- implicit_memories: Array.isArray(parsed.implicit_memories)
121
- ? parsed.implicit_memories.flatMap((entry) => {
122
- if (!entry || typeof entry !== "object") {
123
- return [];
124
- }
125
- const candidate = entry;
126
- if (candidate.kind !== "observation"
127
- && candidate.kind !== "decision"
128
- && candidate.kind !== "entity"
129
- && candidate.kind !== "action_item") {
130
- return [];
131
- }
132
- if (typeof candidate.scope_slug !== "string" || !candidate.payload || typeof candidate.payload !== "object") {
133
- return [];
134
- }
135
- return [{
136
- kind: candidate.kind,
137
- scope_slug: candidate.scope_slug,
138
- payload: candidate.payload,
139
- confidence: typeof candidate.confidence === "number" ? candidate.confidence : undefined,
140
- reason: typeof candidate.reason === "string" ? candidate.reason : undefined,
141
- }];
142
- })
143
- : [],
144
- };
87
+ try {
88
+ const parsed = JSON.parse(raw);
89
+ return {
90
+ decisions: Array.isArray(parsed.decisions)
91
+ ? parsed.decisions.flatMap((entry) => {
92
+ if (!entry || typeof entry !== "object") {
93
+ return [];
94
+ }
95
+ const candidate = entry;
96
+ if (typeof candidate.proposal_id !== "number") {
97
+ return [];
98
+ }
99
+ if (candidate.decision !== "accept" && candidate.decision !== "reject") {
100
+ return [];
101
+ }
102
+ if (typeof candidate.reason !== "string" || candidate.reason.trim().length === 0) {
103
+ return [];
104
+ }
105
+ return [{
106
+ proposal_id: candidate.proposal_id,
107
+ decision: candidate.decision,
108
+ reason: candidate.reason.trim(),
109
+ }];
110
+ })
111
+ : [],
112
+ implicit_memories: Array.isArray(parsed.implicit_memories)
113
+ ? parsed.implicit_memories.flatMap((entry) => {
114
+ if (!entry || typeof entry !== "object") {
115
+ return [];
116
+ }
117
+ const candidate = entry;
118
+ if (candidate.kind !== "observation"
119
+ && candidate.kind !== "decision"
120
+ && candidate.kind !== "entity"
121
+ && candidate.kind !== "action_item") {
122
+ return [];
123
+ }
124
+ if (typeof candidate.scope_slug !== "string" || !candidate.payload || typeof candidate.payload !== "object") {
125
+ return [];
126
+ }
127
+ return [{
128
+ kind: candidate.kind,
129
+ scope_slug: candidate.scope_slug,
130
+ payload: candidate.payload,
131
+ confidence: typeof candidate.confidence === "number" ? candidate.confidence : undefined,
132
+ reason: typeof candidate.reason === "string" ? candidate.reason : undefined,
133
+ }];
134
+ })
135
+ : [],
136
+ };
137
+ }
138
+ catch (err) {
139
+ log.warn({ err }, "malformed reviewer response");
140
+ return { decisions: [], implicit_memories: [] };
141
+ }
145
142
  }
146
143
  function parseFrictionResponse(raw) {
147
144
  try {
@@ -368,6 +365,11 @@ export async function runEndOfTaskMemoryHook(input) {
368
365
  }
369
366
  else {
370
367
  const envelope = parseEnvelope(proposal.payload);
368
+ if (!envelope) {
369
+ resolveInboxItem(proposal.id, "rejected", "Malformed memory proposal payload.");
370
+ summary.rejected++;
371
+ continue;
372
+ }
371
373
  try {
372
374
  const accepted = rememberAcceptedMemory(envelope.kind, resolveAcceptedProposalScopeSlug(envelope, proposal), envelope.payload, `agent:${proposal.sourceAgent}`, envelope.confidence, proposal.sourceAgent);
373
375
  if (!accepted) {
@@ -218,7 +218,6 @@ test("runEndOfTaskMemoryHook rejects invalid action_item proposals with a clear
218
218
  const { dbModule, memoryModule, eotModule } = await loadModules("action-item-invalid");
219
219
  const db = dbModule.getDb();
220
220
  const getScope = getFunction(memoryModule, "getScope");
221
- const createScope = getFunction(memoryModule, "createScope");
222
221
  const listActionItems = getFunction(memoryModule, "listActionItems");
223
222
  const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
224
223
  const chapterhouse = getScope("chapterhouse");
@@ -352,7 +351,6 @@ test("runEndOfTaskMemoryHook rejects action_item proposals with ambiguous entity
352
351
  const { dbModule, memoryModule, eotModule } = await loadModules("action-item-ambiguous-entity");
353
352
  const db = dbModule.getDb();
354
353
  const getScope = getFunction(memoryModule, "getScope");
355
- const createScope = getFunction(memoryModule, "createScope");
356
354
  const listActionItems = getFunction(memoryModule, "listActionItems");
357
355
  const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
358
356
  const chapterhouse = getScope("chapterhouse");
@@ -593,7 +591,7 @@ test("runEndOfTaskMemoryHook rejects pending same-task proposals omitted by the
593
591
  assert.match(row.resolution_reason ?? "", /did not select/i);
594
592
  });
595
593
  test("runEndOfTaskMemoryHook can persist implicit extracted memories that were not explicitly proposed", async () => {
596
- const { dbModule, memoryModule, eotModule } = await loadModules("implicit");
594
+ const { memoryModule, eotModule } = await loadModules("implicit");
597
595
  const getScope = getFunction(memoryModule, "getScope");
598
596
  const listObservations = getFunction(memoryModule, "listObservations");
599
597
  const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
@@ -763,6 +761,76 @@ test("runEndOfTaskMemoryHook persists implicit observation memories with valid c
763
761
  assert.equal(summary.implicit_extracted, 1);
764
762
  assert.equal(warnings.length, 0);
765
763
  });
764
+ test("runEndOfTaskMemoryHook treats malformed reviewer JSON as an empty review and warns", async (t) => {
765
+ const { dbModule, memoryModule, eotModule, warnings } = await loadModulesWithWarnSpy(t, "reviewer-malformed-json");
766
+ const db = dbModule.getDb();
767
+ const getScope = getFunction(memoryModule, "getScope");
768
+ const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
769
+ const chapterhouse = getScope("chapterhouse");
770
+ assert.ok(chapterhouse, "chapterhouse scope should be seeded");
771
+ const inserted = db.prepare(`
772
+ INSERT INTO mem_inbox (scope_id, kind, payload, source_agent, source_task_id, status)
773
+ VALUES (?, 'memory_proposal', ?, 'coder', 'task-eot-reviewer-malformed-json', 'pending')
774
+ `).run(chapterhouse.id, JSON.stringify({
775
+ kind: "observation",
776
+ payload: { content: "Malformed reviewer JSON must not crash the hook." },
777
+ confidence: 0.9,
778
+ }));
779
+ const summary = await runEndOfTaskMemoryHook({
780
+ taskId: "task-eot-reviewer-malformed-json",
781
+ finalResult: "The reviewer returned malformed JSON, so the hook should fail closed and continue.",
782
+ copilotClient: {},
783
+ callLLM: async () => "{ definitely-not-json",
784
+ });
785
+ const inbox = db.prepare(`
786
+ SELECT status, resolution_reason
787
+ FROM mem_inbox
788
+ WHERE id = ?
789
+ `).get(Number(inserted.lastInsertRowid));
790
+ assert.equal(summary.accepted, 0);
791
+ assert.equal(summary.rejected, 1);
792
+ assert.equal(inbox.status, "rejected");
793
+ assert.match(inbox.resolution_reason ?? "", /reviewer did not select/i);
794
+ assert.equal(warnings.length, 1);
795
+ });
796
+ test("runEndOfTaskMemoryHook rejects malformed accepted proposal payload JSON and warns", async (t) => {
797
+ const { dbModule, memoryModule, eotModule, warnings } = await loadModulesWithWarnSpy(t, "proposal-malformed-json");
798
+ const db = dbModule.getDb();
799
+ const getScope = getFunction(memoryModule, "getScope");
800
+ const listObservations = getFunction(memoryModule, "listObservations");
801
+ const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
802
+ const chapterhouse = getScope("chapterhouse");
803
+ assert.ok(chapterhouse, "chapterhouse scope should be seeded");
804
+ const beforeCount = listObservations({ scope_id: chapterhouse.id }).length;
805
+ const inserted = db.prepare(`
806
+ INSERT INTO mem_inbox (scope_id, kind, payload, source_agent, source_task_id, status)
807
+ VALUES (?, 'memory_proposal', ?, 'coder', 'task-eot-proposal-malformed-json', 'pending')
808
+ `).run(chapterhouse.id, "{ definitely-not-json");
809
+ const summary = await runEndOfTaskMemoryHook({
810
+ taskId: "task-eot-proposal-malformed-json",
811
+ finalResult: "The accepted proposal payload is malformed JSON, so the hook should reject it safely.",
812
+ copilotClient: {},
813
+ callLLM: async () => JSON.stringify({
814
+ decisions: [{
815
+ proposal_id: Number(inserted.lastInsertRowid),
816
+ decision: "accept",
817
+ reason: "Looks durable.",
818
+ }],
819
+ implicit_memories: [],
820
+ }),
821
+ });
822
+ const inbox = db.prepare(`
823
+ SELECT status, resolution_reason
824
+ FROM mem_inbox
825
+ WHERE id = ?
826
+ `).get(Number(inserted.lastInsertRowid));
827
+ assert.equal(listObservations({ scope_id: chapterhouse.id }).length, beforeCount);
828
+ assert.equal(summary.accepted, 0);
829
+ assert.equal(summary.rejected, 1);
830
+ assert.equal(inbox.status, "rejected");
831
+ assert.match(inbox.resolution_reason ?? "", /malformed/i);
832
+ assert.equal(warnings.length, 1);
833
+ });
766
834
  test("runFrictionHook does nothing by default when CHAPTERHOUSE_MEMORY_EOT_FRICTION_ENABLED is unset", async () => {
767
835
  const { eotModule } = await loadModules("friction-disabled-default");
768
836
  const runFrictionHook = getFunction(eotModule, "runFrictionHook");
@@ -1,4 +1,5 @@
1
1
  import { childLogger } from "../util/logger.js";
2
+ import { config } from "../config.js";
2
3
  import { getActiveScope } from "./active-scope.js";
3
4
  import { recordObservation } from "./observations.js";
4
5
  import { getScope } from "./scopes.js";
@@ -7,10 +8,7 @@ const log = childLogger("memory.hooks");
7
8
  // Env knob
8
9
  // ---------------------------------------------------------------------------
9
10
  function isHooksEnabled() {
10
- const raw = process.env.CHAPTERHOUSE_MEMORY_HOOKS_ENABLED?.trim();
11
- if (raw === "false" || raw === "0")
12
- return false;
13
- return true;
11
+ return config.memoryHooksEnabled;
14
12
  }
15
13
  // ---------------------------------------------------------------------------
16
14
  // Dispatcher
@@ -44,7 +44,7 @@ export class MemoryHousekeepingScheduler {
44
44
  reflectIntervalDays;
45
45
  lastReflectAtMs;
46
46
  constructor(options = {}) {
47
- this.env = options.env ?? process.env;
47
+ this.env = options.env ?? {};
48
48
  this.runHousekeepingImpl = options.runHousekeeping ?? runHousekeeping;
49
49
  this.runReflectAllScopesImpl = options.runReflectAllScopes ?? reflectAllScopes;
50
50
  this.nowImpl = options.nowImpl ?? Date.now;
@@ -227,8 +227,7 @@ test("MemoryHousekeepingScheduler runs weekly reflection after housekeeping and
227
227
  assert.equal(reflectRuns.length, 0);
228
228
  now = 7 * DAY_MS;
229
229
  timers.intervals[0]?.callback();
230
- await Promise.resolve();
231
- await Promise.resolve();
230
+ await scheduler.stop();
232
231
  assert.equal(housekeepingRuns.length, 2);
233
232
  assert.equal(reflectRuns.length, 1);
234
233
  assert.equal(infos.some((entry) => entry.msg.includes("Memory reflect scheduled run complete")), true);