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.
Files changed (112) hide show
  1. package/README.md +1 -1
  2. package/agents/korg.agent.md +20 -0
  3. package/dist/api/auth.js +11 -1
  4. package/dist/api/auth.test.js +29 -0
  5. package/dist/api/errors.js +23 -0
  6. package/dist/api/route-coverage.test.js +61 -21
  7. package/dist/api/routes/agents.js +472 -0
  8. package/dist/api/routes/memory.js +299 -0
  9. package/dist/api/routes/projects.js +170 -0
  10. package/dist/api/routes/sessions.js +347 -0
  11. package/dist/api/routes/system.js +82 -0
  12. package/dist/api/routes/wiki.js +455 -0
  13. package/dist/api/routes/wiki.test.js +49 -0
  14. package/dist/api/send-json.js +16 -0
  15. package/dist/api/send-json.test.js +18 -0
  16. package/dist/api/server-runtime.js +45 -3
  17. package/dist/api/server.js +34 -1764
  18. package/dist/api/server.test.js +239 -8
  19. package/dist/api/sse-hub.js +37 -0
  20. package/dist/cli.js +1 -1
  21. package/dist/config.js +151 -58
  22. package/dist/config.test.js +29 -0
  23. package/dist/copilot/okr-mapper.js +2 -11
  24. package/dist/copilot/orchestrator.js +358 -352
  25. package/dist/copilot/orchestrator.test.js +139 -4
  26. package/dist/copilot/prompt-date.js +2 -1
  27. package/dist/copilot/session-manager.js +25 -23
  28. package/dist/copilot/session-manager.test.js +35 -1
  29. package/dist/copilot/standup.js +2 -2
  30. package/dist/copilot/task-event-log.js +7 -1
  31. package/dist/copilot/task-event-log.test.js +13 -0
  32. package/dist/copilot/tools/agent.js +608 -0
  33. package/dist/copilot/tools/index.js +19 -0
  34. package/dist/copilot/tools/memory.js +678 -0
  35. package/dist/copilot/tools/models.js +2 -0
  36. package/dist/copilot/tools/okr.js +171 -0
  37. package/dist/copilot/tools/wiki.js +333 -0
  38. package/dist/copilot/tools-deps.js +4 -0
  39. package/dist/copilot/tools.agent.test.js +10 -8
  40. package/dist/copilot/tools.inventory.test.js +76 -0
  41. package/dist/copilot/tools.js +1 -1725
  42. package/dist/copilot/tools.okr.test.js +31 -0
  43. package/dist/copilot/tools.wiki.test.js +358 -6
  44. package/dist/copilot/turn-event-log.js +31 -4
  45. package/dist/copilot/turn-event-log.test.js +24 -2
  46. package/dist/copilot/workiq-installer.test.js +2 -2
  47. package/dist/daemon-install.js +3 -2
  48. package/dist/daemon.js +9 -17
  49. package/dist/integrations/ado-client.js +90 -9
  50. package/dist/integrations/ado-client.test.js +56 -0
  51. package/dist/integrations/team-push.js +1 -0
  52. package/dist/integrations/team-push.test.js +6 -0
  53. package/dist/integrations/teams-notify.js +1 -0
  54. package/dist/integrations/teams-notify.test.js +5 -0
  55. package/dist/memory/active-scope.test.js +0 -1
  56. package/dist/memory/checkpoint.js +89 -72
  57. package/dist/memory/checkpoint.test.js +23 -3
  58. package/dist/memory/eot.js +194 -89
  59. package/dist/memory/eot.test.js +186 -3
  60. package/dist/memory/hooks.js +2 -4
  61. package/dist/memory/housekeeping-scheduler.js +1 -1
  62. package/dist/memory/housekeeping-scheduler.test.js +1 -2
  63. package/dist/memory/housekeeping.js +100 -3
  64. package/dist/memory/housekeeping.test.js +33 -2
  65. package/dist/memory/reflect.test.js +2 -0
  66. package/dist/memory/scope-lock.js +26 -0
  67. package/dist/memory/scope-lock.test.js +118 -0
  68. package/dist/memory/scopes.test.js +0 -1
  69. package/dist/mode-context.js +58 -5
  70. package/dist/mode-context.test.js +68 -0
  71. package/dist/paths.js +1 -0
  72. package/dist/setup.js +3 -2
  73. package/dist/shared/api-schemas.js +48 -5
  74. package/dist/store/connection.js +96 -0
  75. package/dist/store/db.js +5 -1498
  76. package/dist/store/db.test.js +182 -1
  77. package/dist/store/migrations.js +460 -0
  78. package/dist/store/repositories/memory.js +281 -0
  79. package/dist/store/repositories/okr.js +3 -0
  80. package/dist/store/repositories/projects.js +5 -0
  81. package/dist/store/repositories/sessions.js +284 -0
  82. package/dist/store/repositories/wiki.js +60 -0
  83. package/dist/store/schema.js +501 -0
  84. package/dist/util/logger.js +3 -2
  85. package/dist/wiki/consolidation.js +50 -9
  86. package/dist/wiki/consolidation.test.js +45 -0
  87. package/dist/wiki/frontmatter.js +45 -14
  88. package/dist/wiki/frontmatter.test.js +26 -1
  89. package/dist/wiki/fs.js +16 -4
  90. package/dist/wiki/fs.test.js +84 -0
  91. package/dist/wiki/index-manager.js +30 -2
  92. package/dist/wiki/index-manager.test.js +43 -12
  93. package/dist/wiki/ingest.js +17 -1
  94. package/dist/wiki/lock.js +11 -1
  95. package/dist/wiki/log-manager.js +2 -7
  96. package/dist/wiki/migrate.js +44 -17
  97. package/dist/wiki/project-registry.js +10 -5
  98. package/dist/wiki/project-registry.test.js +14 -0
  99. package/dist/wiki/scheduler.js +1 -1
  100. package/dist/wiki/seed-team-wiki.js +2 -1
  101. package/dist/wiki/team-sync.js +31 -6
  102. package/dist/wiki/team-sync.test.js +81 -0
  103. package/package.json +1 -1
  104. package/web/dist/assets/WikiEdit-BZXAdarz.js +30 -0
  105. package/web/dist/assets/WikiEdit-BZXAdarz.js.map +1 -0
  106. package/web/dist/assets/WikiGraph-KrCYco4v.js +2 -0
  107. package/web/dist/assets/WikiGraph-KrCYco4v.js.map +1 -0
  108. package/web/dist/assets/index-CUm2Wbuh.js +250 -0
  109. package/web/dist/assets/index-CUm2Wbuh.js.map +1 -0
  110. package/web/dist/index.html +1 -1
  111. package/web/dist/assets/index-iQrv3lQN.js +0 -286
  112. package/web/dist/assets/index-iQrv3lQN.js.map +0 -1
@@ -11,20 +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;
18
+ }
19
+ function isFrictionHookEnabled() {
20
+ return config.memoryEndOfTaskFrictionEnabled;
28
21
  }
29
22
  function buildReviewerSystemPrompt() {
30
23
  return [
@@ -48,78 +41,132 @@ function buildReviewerUserPrompt(finalResult, proposals) {
48
41
  })),
49
42
  }, null, 2);
50
43
  }
44
+ function buildFrictionSystemPrompt() {
45
+ return [
46
+ "You review a completed agent task for tool friction.",
47
+ "Tool friction is: missing validation feedback, missing batch capability, silent failures,",
48
+ "overly strict input constraints, or tool gaps that caused the agent to work around limitations.",
49
+ "If you identify friction, return a JSON array of action items.",
50
+ "Each item must have: title (string), detail (string), source (always 'eot:friction').",
51
+ "If no friction was found, return an empty array [].",
52
+ "Return JSON only. No prose, no wrapping.",
53
+ ].join("\n");
54
+ }
55
+ function buildFrictionUserPrompt(finalResult) {
56
+ return JSON.stringify({ final_result: finalResult }, null, 2);
57
+ }
51
58
  function parseEnvelope(raw) {
52
- const parsed = JSON.parse(raw);
53
- if (!parsed || typeof parsed !== "object") {
54
- throw new Error("Invalid memory proposal payload.");
55
- }
56
- if (parsed.kind !== "observation"
57
- && parsed.kind !== "decision"
58
- && parsed.kind !== "entity"
59
- && parsed.kind !== "action_item") {
60
- 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
+ };
61
80
  }
62
- if (!parsed.payload || typeof parsed.payload !== "object") {
63
- throw new Error("Invalid proposal payload.");
81
+ catch (err) {
82
+ log.warn({ err }, "malformed memory proposal payload");
83
+ return null;
64
84
  }
65
- return {
66
- kind: parsed.kind,
67
- scope_slug: typeof parsed.scope_slug === "string" ? parsed.scope_slug : undefined,
68
- confidence: typeof parsed.confidence === "number" ? parsed.confidence : 0.5,
69
- reason: typeof parsed.reason === "string" ? parsed.reason : undefined,
70
- payload: parsed.payload,
71
- };
72
85
  }
73
86
  function parseReviewerResponse(raw) {
74
- const parsed = JSON.parse(raw);
75
- return {
76
- decisions: Array.isArray(parsed.decisions)
77
- ? parsed.decisions.flatMap((entry) => {
78
- if (!entry || typeof entry !== "object") {
79
- return [];
80
- }
81
- const candidate = entry;
82
- if (typeof candidate.proposal_id !== "number") {
83
- return [];
84
- }
85
- if (candidate.decision !== "accept" && candidate.decision !== "reject") {
86
- return [];
87
- }
88
- if (typeof candidate.reason !== "string" || candidate.reason.trim().length === 0) {
89
- return [];
90
- }
91
- return [{
92
- proposal_id: candidate.proposal_id,
93
- decision: candidate.decision,
94
- reason: candidate.reason.trim(),
95
- }];
96
- })
97
- : [],
98
- implicit_memories: Array.isArray(parsed.implicit_memories)
99
- ? parsed.implicit_memories.flatMap((entry) => {
100
- if (!entry || typeof entry !== "object") {
101
- return [];
102
- }
103
- const candidate = entry;
104
- if (candidate.kind !== "observation"
105
- && candidate.kind !== "decision"
106
- && candidate.kind !== "entity"
107
- && candidate.kind !== "action_item") {
108
- return [];
109
- }
110
- if (typeof candidate.scope_slug !== "string" || !candidate.payload || typeof candidate.payload !== "object") {
111
- return [];
112
- }
113
- return [{
114
- kind: candidate.kind,
115
- scope_slug: candidate.scope_slug,
116
- payload: candidate.payload,
117
- confidence: typeof candidate.confidence === "number" ? candidate.confidence : undefined,
118
- reason: typeof candidate.reason === "string" ? candidate.reason : undefined,
119
- }];
120
- })
121
- : [],
122
- };
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
+ }
142
+ }
143
+ function parseFrictionResponse(raw) {
144
+ try {
145
+ const parsed = JSON.parse(raw);
146
+ if (!Array.isArray(parsed)) {
147
+ return [];
148
+ }
149
+ return parsed.flatMap((entry) => {
150
+ if (!entry || typeof entry !== "object") {
151
+ return [];
152
+ }
153
+ const candidate = entry;
154
+ if (!isNonEmptyString(candidate.title) || typeof candidate.detail !== "string") {
155
+ return [];
156
+ }
157
+ if (candidate.source !== "eot:friction") {
158
+ return [];
159
+ }
160
+ return [{
161
+ title: candidate.title.trim(),
162
+ detail: candidate.detail,
163
+ source: "eot:friction",
164
+ }];
165
+ }).slice(0, 3);
166
+ }
167
+ catch {
168
+ return [];
169
+ }
123
170
  }
124
171
  function isNonEmptyString(value) {
125
172
  return typeof value === "string" && value.trim().length > 0;
@@ -199,6 +246,25 @@ function resolveAcceptedProposalScopeSlug(envelope, proposal) {
199
246
  }
200
247
  throw new Error("No memory scope could be resolved for this proposal.");
201
248
  }
249
+ function resolveActiveScopeSlug() {
250
+ const activeScope = getActiveScope();
251
+ if (activeScope) {
252
+ return activeScope.slug;
253
+ }
254
+ return getScope("chapterhouse")?.slug;
255
+ }
256
+ function resolveCallLLM(input) {
257
+ return input.callLLM ?? (async ({ system, user, model }) => {
258
+ const result = await runOneShotPrompt({
259
+ client: input.copilotClient,
260
+ model,
261
+ system,
262
+ user,
263
+ expectJson: true,
264
+ });
265
+ return result.content;
266
+ });
267
+ }
202
268
  function rememberAcceptedMemory(kind, scopeSlug, payload, source, confidence, sourceAgent) {
203
269
  const scope = getScope(scopeSlug);
204
270
  if (!scope) {
@@ -279,16 +345,7 @@ export async function runEndOfTaskMemoryHook(input) {
279
345
  }
280
346
  const proposals = listPendingMemoryProposalsForTask(input.taskId);
281
347
  summary.proposals_total = proposals.length;
282
- const callLLM = input.callLLM ?? (async ({ system, user, model }) => {
283
- const result = await runOneShotPrompt({
284
- client: input.copilotClient,
285
- model,
286
- system,
287
- user,
288
- expectJson: true,
289
- });
290
- return result.content;
291
- });
348
+ const callLLM = resolveCallLLM(input);
292
349
  const review = parseReviewerResponse(await callLLM({
293
350
  system: buildReviewerSystemPrompt(),
294
351
  user: buildReviewerUserPrompt(input.finalResult, proposals),
@@ -308,6 +365,11 @@ export async function runEndOfTaskMemoryHook(input) {
308
365
  }
309
366
  else {
310
367
  const envelope = parseEnvelope(proposal.payload);
368
+ if (!envelope) {
369
+ resolveInboxItem(proposal.id, "rejected", "Malformed memory proposal payload.");
370
+ summary.rejected++;
371
+ continue;
372
+ }
311
373
  try {
312
374
  const accepted = rememberAcceptedMemory(envelope.kind, resolveAcceptedProposalScopeSlug(envelope, proposal), envelope.payload, `agent:${proposal.sourceAgent}`, envelope.confidence, proposal.sourceAgent);
313
375
  if (!accepted) {
@@ -347,8 +409,51 @@ export async function runEndOfTaskMemoryHook(input) {
347
409
  }
348
410
  }
349
411
  }
412
+ await runFrictionHook({
413
+ taskId: input.taskId,
414
+ finalResult: input.finalResult,
415
+ copilotClient: input.copilotClient,
416
+ callLLM,
417
+ model: input.model,
418
+ });
350
419
  log.info(summary, "memory.eot.processed");
351
420
  input.onProcessed?.(summary);
352
421
  return summary;
353
422
  }
423
+ export async function runFrictionHook(input) {
424
+ try {
425
+ if (!isFrictionHookEnabled()) {
426
+ return;
427
+ }
428
+ if (input.finalResult.trim().length <= 100) {
429
+ return;
430
+ }
431
+ const scopeSlug = resolveActiveScopeSlug();
432
+ if (!scopeSlug) {
433
+ return;
434
+ }
435
+ const callLLM = resolveCallLLM(input);
436
+ const raw = await callLLM({
437
+ system: buildFrictionSystemPrompt(),
438
+ user: buildFrictionUserPrompt(input.finalResult),
439
+ model: input.model ?? config.copilotModel,
440
+ });
441
+ const frictionItems = parseFrictionResponse(raw);
442
+ for (const item of frictionItems) {
443
+ try {
444
+ rememberAcceptedMemory("action_item", scopeSlug, {
445
+ title: item.title,
446
+ detail: item.detail,
447
+ source: item.source,
448
+ }, "eot:friction");
449
+ }
450
+ catch (err) {
451
+ log.warn({ err, taskId: input.taskId, title: item.title }, "friction hook: failed to record action item");
452
+ }
453
+ }
454
+ }
455
+ catch (err) {
456
+ log.warn({ err, taskId: input.taskId }, "friction hook failed");
457
+ }
458
+ }
354
459
  //# sourceMappingURL=eot.js.map
@@ -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,4 +761,189 @@ 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
+ });
834
+ test("runFrictionHook does nothing by default when CHAPTERHOUSE_MEMORY_EOT_FRICTION_ENABLED is unset", async () => {
835
+ const { eotModule } = await loadModules("friction-disabled-default");
836
+ const runFrictionHook = getFunction(eotModule, "runFrictionHook");
837
+ let llmCalls = 0;
838
+ await runFrictionHook({
839
+ taskId: "task-friction-disabled-default",
840
+ finalResult: "A substantive final result that is definitely longer than one hundred characters to prove the friction hook still stays off by default.",
841
+ copilotClient: {},
842
+ callLLM: async () => {
843
+ llmCalls++;
844
+ return "[]";
845
+ },
846
+ });
847
+ assert.equal(llmCalls, 0);
848
+ });
849
+ test("runFrictionHook skips short final results even when enabled", async () => {
850
+ process.env.CHAPTERHOUSE_MEMORY_EOT_FRICTION_ENABLED = "1";
851
+ const { eotModule } = await loadModules("friction-short-result");
852
+ const runFrictionHook = getFunction(eotModule, "runFrictionHook");
853
+ let llmCalls = 0;
854
+ await runFrictionHook({
855
+ taskId: "task-friction-short-result",
856
+ finalResult: "too short",
857
+ copilotClient: {},
858
+ callLLM: async () => {
859
+ llmCalls++;
860
+ return "[]";
861
+ },
862
+ });
863
+ assert.equal(llmCalls, 0);
864
+ });
865
+ test("runFrictionHook records action items when enabled and the task result is substantive", async () => {
866
+ process.env.CHAPTERHOUSE_MEMORY_EOT_FRICTION_ENABLED = "1";
867
+ const { memoryModule, eotModule } = await loadModules("friction-records-action-items");
868
+ const getScope = getFunction(memoryModule, "getScope");
869
+ const setActiveScope = getFunction(memoryModule, "setActiveScope");
870
+ const listActionItems = getFunction(memoryModule, "listActionItems");
871
+ const runFrictionHook = getFunction(eotModule, "runFrictionHook");
872
+ const chapterhouse = getScope("chapterhouse");
873
+ assert.ok(chapterhouse, "chapterhouse scope should be seeded");
874
+ setActiveScope("chapterhouse");
875
+ await runFrictionHook({
876
+ taskId: "task-friction-records-action-items",
877
+ finalResult: "The agent had to retry the same command several times because the tool returned a generic error with no validation detail, which made the task materially slower and harder to finish cleanly.",
878
+ copilotClient: {},
879
+ callLLM: async () => JSON.stringify([
880
+ {
881
+ title: "Improve validation feedback for memory tools",
882
+ detail: "Return the rejected field name and allowed values instead of a generic failure.",
883
+ source: "eot:friction",
884
+ },
885
+ ]),
886
+ });
887
+ const actionItems = listActionItems({ scope_id: chapterhouse.id });
888
+ assert.equal(actionItems.length, 1);
889
+ assert.equal(actionItems[0]?.title, "Improve validation feedback for memory tools");
890
+ assert.equal(actionItems[0]?.detail, "Return the rejected field name and allowed values instead of a generic failure.");
891
+ assert.equal(actionItems[0]?.source, "eot:friction");
892
+ assert.equal(actionItems[0]?.status, "open");
893
+ });
894
+ test("runFrictionHook caps parsed friction items at 3", async () => {
895
+ process.env.CHAPTERHOUSE_MEMORY_EOT_FRICTION_ENABLED = "1";
896
+ const { memoryModule, eotModule } = await loadModules("friction-cap");
897
+ const getScope = getFunction(memoryModule, "getScope");
898
+ const setActiveScope = getFunction(memoryModule, "setActiveScope");
899
+ const listActionItems = getFunction(memoryModule, "listActionItems");
900
+ const runFrictionHook = getFunction(eotModule, "runFrictionHook");
901
+ const chapterhouse = getScope("chapterhouse");
902
+ assert.ok(chapterhouse, "chapterhouse scope should be seeded");
903
+ setActiveScope("chapterhouse");
904
+ await runFrictionHook({
905
+ taskId: "task-friction-cap",
906
+ finalResult: "The toolchain created several distinct sources of friction across the task, and the result is long enough that the friction hook should inspect it and write only the first three issues back into memory.",
907
+ copilotClient: {},
908
+ callLLM: async () => JSON.stringify([
909
+ { title: "Item 1", detail: "detail 1", source: "eot:friction" },
910
+ { title: "Item 2", detail: "detail 2", source: "eot:friction" },
911
+ { title: "Item 3", detail: "detail 3", source: "eot:friction" },
912
+ { title: "Item 4", detail: "detail 4", source: "eot:friction" },
913
+ ]),
914
+ });
915
+ assert.deepEqual(listActionItems({ scope_id: chapterhouse.id }).map((item) => item.title).sort(), ["Item 1", "Item 2", "Item 3"]);
916
+ });
917
+ test("runFrictionHook treats malformed JSON as no friction items", async () => {
918
+ process.env.CHAPTERHOUSE_MEMORY_EOT_FRICTION_ENABLED = "1";
919
+ const { memoryModule, eotModule } = await loadModules("friction-malformed-json");
920
+ const getScope = getFunction(memoryModule, "getScope");
921
+ const setActiveScope = getFunction(memoryModule, "setActiveScope");
922
+ const listActionItems = getFunction(memoryModule, "listActionItems");
923
+ const runFrictionHook = getFunction(eotModule, "runFrictionHook");
924
+ const chapterhouse = getScope("chapterhouse");
925
+ assert.ok(chapterhouse, "chapterhouse scope should be seeded");
926
+ setActiveScope("chapterhouse");
927
+ await runFrictionHook({
928
+ taskId: "task-friction-malformed-json",
929
+ finalResult: "The agent hit confusing tool friction repeatedly, but the friction reviewer returned malformed JSON and the hook should safely ignore it without writing any action items.",
930
+ copilotClient: {},
931
+ callLLM: async () => "{not valid json",
932
+ });
933
+ assert.deepEqual(listActionItems({ scope_id: chapterhouse.id }), []);
934
+ });
935
+ test("runFrictionHook never propagates errors from the LLM call", async (t) => {
936
+ process.env.CHAPTERHOUSE_MEMORY_EOT_FRICTION_ENABLED = "1";
937
+ const { eotModule, warnings } = await loadModulesWithWarnSpy(t, "friction-no-throw");
938
+ const runFrictionHook = getFunction(eotModule, "runFrictionHook");
939
+ await assert.doesNotReject(() => runFrictionHook({
940
+ taskId: "task-friction-no-throw",
941
+ finalResult: "The agent encountered enough friction to trigger the hook, but the LLM call itself crashed and the hook must still fail closed without breaking end-of-task processing.",
942
+ copilotClient: {},
943
+ callLLM: async () => {
944
+ throw new Error("boom");
945
+ },
946
+ }));
947
+ assert.equal(warnings.length, 1);
948
+ });
766
949
  //# sourceMappingURL=eot.test.js.map
@@ -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);