chapterhouse 0.9.0 → 0.9.2

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.
@@ -11,6 +11,16 @@ async function readWikiArtifacts() {
11
11
  const indexManager = await import(new URL(`../wiki/index-manager.js?case=${nonce}`, import.meta.url).href);
12
12
  return { wikiFs, indexManager };
13
13
  }
14
+ async function loadWikiTool(name) {
15
+ const toolsModule = await loadToolsModule();
16
+ const tools = toolsModule.createTools({
17
+ client: { async listModels() { return []; } },
18
+ onAgentTaskComplete: () => { },
19
+ });
20
+ const tool = tools.find((entry) => entry.name === name);
21
+ assert.ok(tool, `Expected tool '${name}' to be registered`);
22
+ return tool;
23
+ }
14
24
  test.before(() => {
15
25
  mkdirSync(join(process.cwd(), ".test-work"), { recursive: true });
16
26
  });
@@ -70,9 +80,143 @@ Runtime notes.
70
80
  `,
71
81
  });
72
82
  assert.equal(typeof result, "object");
73
- assert.match(result.error, /invalid 'summary'/i);
74
- assert.match(result.error, /unknown tag 'made-up-tag'/i);
75
- assert.match(result.error, /pages\/_meta\/taxonomy\.md/);
83
+ const error = result.error;
84
+ assert.match(error, /invalid 'summary'/i);
85
+ assert.match(error, /unknown tag 'made-up-tag'|tag "made-up-tag" is not in the allowed tag list/i);
86
+ assert.match(error, /Valid tags:/i);
87
+ assert.match(error, /engineering/i);
88
+ assert.match(error, /release/i);
89
+ });
90
+ test("wiki_update accepts an empty tags list", async () => {
91
+ const tool = await loadWikiTool("wiki_update");
92
+ const result = await tool.handler({
93
+ path: "pages/shared/chapterhouse.md",
94
+ title: "Chapterhouse",
95
+ summary: "Runtime notes",
96
+ content: `---
97
+ title: Chapterhouse
98
+ summary: Runtime notes
99
+ tags: []
100
+ ---
101
+
102
+ # Chapterhouse
103
+
104
+ Runtime notes.
105
+ `,
106
+ });
107
+ assert.equal(result, "Wiki page updated: Chapterhouse (pages/shared/chapterhouse.md)");
108
+ });
109
+ test("wiki_update accepts mixed-case valid tags with surrounding whitespace", async () => {
110
+ const tool = await loadWikiTool("wiki_update");
111
+ const { wikiFs } = await readWikiArtifacts();
112
+ wikiFs.writePage("pages/_meta/taxonomy.md", "## Custom\n- workflow\n");
113
+ const result = await tool.handler({
114
+ path: "pages/shared/chapterhouse.md",
115
+ title: "Chapterhouse",
116
+ summary: "Runtime notes",
117
+ content: `---
118
+ title: Chapterhouse
119
+ summary: Runtime notes
120
+ tags: [ Engineering , workflow ]
121
+ ---
122
+
123
+ # Chapterhouse
124
+
125
+ Runtime notes.
126
+ `,
127
+ });
128
+ assert.equal(result, "Wiki page updated: Chapterhouse (pages/shared/chapterhouse.md)");
129
+ });
130
+ test("wiki_update accepts summaries that are exactly 160 characters long", async () => {
131
+ const tool = await loadWikiTool("wiki_update");
132
+ const summary = "x".repeat(160);
133
+ const result = await tool.handler({
134
+ path: "pages/shared/chapterhouse.md",
135
+ title: "Chapterhouse",
136
+ summary,
137
+ content: `---
138
+ title: Chapterhouse
139
+ summary: ${summary}
140
+ tags: [engineering]
141
+ ---
142
+
143
+ # Chapterhouse
144
+
145
+ ${summary}
146
+ `,
147
+ });
148
+ assert.equal(result, "Wiki page updated: Chapterhouse (pages/shared/chapterhouse.md)");
149
+ });
150
+ test("wiki_update rejects summaries longer than 160 characters", async () => {
151
+ const tool = await loadWikiTool("wiki_update");
152
+ const result = await tool.handler({
153
+ path: "pages/shared/chapterhouse.md",
154
+ title: "Chapterhouse",
155
+ summary: "x".repeat(161),
156
+ content: `---
157
+ title: Chapterhouse
158
+ summary: Runtime notes
159
+ tags: [engineering]
160
+ ---
161
+
162
+ # Chapterhouse
163
+
164
+ Runtime notes.
165
+ `,
166
+ });
167
+ assert.deepEqual(result, {
168
+ error: "Summary must be 160 characters or fewer",
169
+ });
170
+ const { wikiFs } = await readWikiArtifacts();
171
+ assert.equal(wikiFs.readPage("pages/shared/chapterhouse.md"), undefined);
172
+ });
173
+ test("wiki_update keeps empty-summary behavior unchanged", async () => {
174
+ const tool = await loadWikiTool("wiki_update");
175
+ const result = await tool.handler({
176
+ path: "pages/shared/chapterhouse.md",
177
+ title: "Chapterhouse",
178
+ summary: "",
179
+ content: `---
180
+ title: Chapterhouse
181
+ summary:
182
+ tags: [engineering]
183
+ ---
184
+
185
+ # Chapterhouse
186
+
187
+ Runtime notes.
188
+ `,
189
+ });
190
+ assert.equal(typeof result, "object");
191
+ assert.match(result.error, /missing 'summary'/i);
192
+ });
193
+ test("wiki_ingest_source rejects local file-style inputs with a clear error", async () => {
194
+ const tool = await loadWikiTool("wiki_ingest_source");
195
+ for (const source of [
196
+ "./notes.md",
197
+ "../notes.md",
198
+ "/var/data/notes.md",
199
+ "notes.md",
200
+ "C:\\Users\\brian\\notes.md",
201
+ "file:///home/brian/notes.md",
202
+ ]) {
203
+ const result = await tool.handler({ source });
204
+ assert.equal(typeof result, "object", `Expected '${source}' to be rejected`);
205
+ const error = result.error;
206
+ assert.match(error, /local|file/i, `Expected '${source}' error to mention local-file rejection`);
207
+ assert.match(error, /url|http|https/i, `Expected '${source}' error to mention the remote URL requirement`);
208
+ }
209
+ const { wikiFs } = await readWikiArtifacts();
210
+ assert.deepEqual(wikiFs.listSources(), []);
211
+ });
212
+ test("wiki_ingest_source still passes remote URLs through to ingestSource", async () => {
213
+ const tool = await loadWikiTool("wiki_ingest_source");
214
+ const result = await tool.handler({
215
+ source: "http://127.0.0.1/private",
216
+ });
217
+ assert.deepEqual(result, {
218
+ error: "Cannot fetch internal/private URLs.",
219
+ });
76
220
  });
77
221
  test("wiki_update accepts valid frontmatter and refreshes the index entry", async () => {
78
222
  const toolsModule = await loadToolsModule();
@@ -116,6 +260,211 @@ Runtime notes.
116
260
  },
117
261
  ]);
118
262
  });
263
+ test("wiki_batch_update creates multiple pages and refreshes the index", async () => {
264
+ const toolsModule = await loadToolsModule();
265
+ const tools = toolsModule.createTools({
266
+ client: { async listModels() { return []; } },
267
+ onAgentTaskComplete: () => { },
268
+ });
269
+ const tool = tools.find((entry) => entry.name === "wiki_batch_update");
270
+ assert.ok(tool);
271
+ const result = await tool.handler({
272
+ pages: [
273
+ {
274
+ path: "pages/projects/atlas/index.md",
275
+ title: "Atlas",
276
+ summary: "Project atlas overview",
277
+ content: `---
278
+ title: Atlas
279
+ summary: Project atlas overview
280
+ tags: [project]
281
+ ---
282
+
283
+ # Atlas
284
+
285
+ Project atlas overview.
286
+ `,
287
+ },
288
+ {
289
+ path: "pages/people/alice/index.md",
290
+ title: "Alice",
291
+ summary: "Team profile for Alice",
292
+ section: "People",
293
+ content: `---
294
+ title: Alice
295
+ summary: Team profile for Alice
296
+ tags: [people]
297
+ ---
298
+
299
+ # Alice
300
+
301
+ Team profile for Alice.
302
+ `,
303
+ },
304
+ ],
305
+ });
306
+ assert.equal(result, "Created 2 pages successfully.");
307
+ const { wikiFs, indexManager } = await readWikiArtifacts();
308
+ const index = indexManager.parseIndex();
309
+ assert.match(wikiFs.readPage("pages/projects/atlas/index.md") ?? "", /summary: Project atlas overview/);
310
+ assert.match(wikiFs.readPage("pages/people/alice/index.md") ?? "", /summary: Team profile for Alice/);
311
+ assert.ok(index.some((entry) => entry.path === "pages/projects/atlas/index.md" && entry.summary === "Project atlas overview"));
312
+ assert.ok(index.some((entry) => entry.path === "pages/people/alice/index.md" && entry.summary === "Team profile for Alice"));
313
+ });
314
+ test("wiki_batch_update reports per-page path errors and continues other writes", async () => {
315
+ const toolsModule = await loadToolsModule();
316
+ const tools = toolsModule.createTools({
317
+ client: { async listModels() { return []; } },
318
+ onAgentTaskComplete: () => { },
319
+ });
320
+ const tool = tools.find((entry) => entry.name === "wiki_batch_update");
321
+ assert.ok(tool);
322
+ const result = await tool.handler({
323
+ pages: [
324
+ {
325
+ path: "pages/projects/atlas/index.md",
326
+ title: "Atlas",
327
+ summary: "Project atlas overview",
328
+ content: `---
329
+ title: Atlas
330
+ summary: Project atlas overview
331
+ tags: [project]
332
+ ---
333
+
334
+ # Atlas
335
+
336
+ Project atlas overview.
337
+ `,
338
+ },
339
+ {
340
+ path: "../secrets.md",
341
+ title: "Secrets",
342
+ summary: "Should be rejected",
343
+ content: `---
344
+ title: Secrets
345
+ summary: Should be rejected
346
+ tags: [project]
347
+ ---
348
+
349
+ # Secrets
350
+ `,
351
+ },
352
+ ],
353
+ });
354
+ assert.equal(result, "Created 1 pages successfully.\nErrors (1):\n • ../secrets.md — Refused unsafe wiki path: ../secrets.md");
355
+ const { wikiFs } = await readWikiArtifacts();
356
+ assert.match(wikiFs.readPage("pages/projects/atlas/index.md") ?? "", /summary: Project atlas overview/);
357
+ assert.equal(wikiFs.readPage("../secrets.md"), undefined);
358
+ });
359
+ test("wiki_batch_update reports per-page tag errors and continues other writes", async () => {
360
+ const toolsModule = await loadToolsModule();
361
+ const tools = toolsModule.createTools({
362
+ client: { async listModels() { return []; } },
363
+ onAgentTaskComplete: () => { },
364
+ });
365
+ const tool = tools.find((entry) => entry.name === "wiki_batch_update");
366
+ assert.ok(tool);
367
+ const result = await tool.handler({
368
+ pages: [
369
+ {
370
+ path: "pages/projects/atlas/index.md",
371
+ title: "Atlas",
372
+ summary: "Project atlas overview",
373
+ content: `---
374
+ title: Atlas
375
+ summary: Project atlas overview
376
+ tags: [project]
377
+ ---
378
+
379
+ # Atlas
380
+
381
+ Project atlas overview.
382
+ `,
383
+ },
384
+ {
385
+ path: "pages/people/alice/index.md",
386
+ title: "Alice",
387
+ summary: "Team profile for Alice",
388
+ content: `---
389
+ title: Alice
390
+ summary: Team profile for Alice
391
+ tags: [personal]
392
+ ---
393
+
394
+ # Alice
395
+
396
+ Team profile for Alice.
397
+ `,
398
+ },
399
+ ],
400
+ });
401
+ assert.equal(typeof result, "string");
402
+ assert.match(result, /^Created 1 pages successfully\.\nErrors \(1\):/);
403
+ assert.match(result, /pages\/people\/alice\/index\.md — Wiki page frontmatter violates the required shape: unknown tag 'personal'/);
404
+ const { wikiFs } = await readWikiArtifacts();
405
+ assert.match(wikiFs.readPage("pages/projects/atlas/index.md") ?? "", /summary: Project atlas overview/);
406
+ assert.equal(wikiFs.readPage("pages/people/alice/index.md"), undefined);
407
+ });
408
+ test("wiki_batch_update fails fast on an empty pages array", async () => {
409
+ const toolsModule = await loadToolsModule();
410
+ const tools = toolsModule.createTools({
411
+ client: { async listModels() { return []; } },
412
+ onAgentTaskComplete: () => { },
413
+ });
414
+ const tool = tools.find((entry) => entry.name === "wiki_batch_update");
415
+ assert.ok(tool);
416
+ const result = await tool.handler({ pages: [] });
417
+ assert.deepEqual(result, { error: "Too small: expected array to have >=1 items" });
418
+ const { indexManager } = await readWikiArtifacts();
419
+ assert.deepEqual(indexManager.parseIndex(), []);
420
+ });
421
+ test("wiki_batch_update fails fast when more than 50 pages are requested", async () => {
422
+ const toolsModule = await loadToolsModule();
423
+ const tools = toolsModule.createTools({
424
+ client: { async listModels() { return []; } },
425
+ onAgentTaskComplete: () => { },
426
+ });
427
+ const tool = tools.find((entry) => entry.name === "wiki_batch_update");
428
+ assert.ok(tool);
429
+ const result = await tool.handler({
430
+ pages: Array.from({ length: 51 }, (_, index) => ({
431
+ path: `pages/projects/page-${index + 1}/index.md`,
432
+ title: `Page ${index + 1}`,
433
+ summary: `Summary ${index + 1}`,
434
+ content: `---\ntitle: Page ${index + 1}\nsummary: Summary ${index + 1}\ntags: [project]\n---\n\n# Page ${index + 1}\n`,
435
+ })),
436
+ });
437
+ assert.deepEqual(result, { error: "Too big: expected array to have <=50 items" });
438
+ const { indexManager } = await readWikiArtifacts();
439
+ assert.deepEqual(indexManager.parseIndex(), []);
440
+ });
441
+ test("wiki_batch_update inherits summary length validation from the per-page schema", async () => {
442
+ const toolsModule = await loadToolsModule();
443
+ const tools = toolsModule.createTools({
444
+ client: { async listModels() { return []; } },
445
+ onAgentTaskComplete: () => { },
446
+ });
447
+ const tool = tools.find((entry) => entry.name === "wiki_batch_update");
448
+ assert.ok(tool);
449
+ const result = await tool.handler({
450
+ pages: [{
451
+ path: "pages/projects/atlas/index.md",
452
+ title: "Atlas",
453
+ summary: "x".repeat(161),
454
+ content: `---
455
+ title: Atlas
456
+ summary: Project atlas overview
457
+ tags: [project]
458
+ ---
459
+
460
+ # Atlas
461
+ `,
462
+ }],
463
+ });
464
+ assert.deepEqual(result, { error: "Summary must be 160 characters or fewer" });
465
+ const { indexManager } = await readWikiArtifacts();
466
+ assert.deepEqual(indexManager.parseIndex(), []);
467
+ });
119
468
  test("retained wiki tools append audit entries to pages/_meta/log.md", async () => {
120
469
  const toolsModule = await loadToolsModule();
121
470
  const tools = toolsModule.createTools({
@@ -26,6 +26,14 @@ function isMemoryAutoAcceptEnabled() {
26
26
  return true;
27
27
  return true;
28
28
  }
29
+ 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;
36
+ }
29
37
  function buildReviewerSystemPrompt() {
30
38
  return [
31
39
  "You review subagent memory proposals at end-of-task.",
@@ -48,6 +56,20 @@ function buildReviewerUserPrompt(finalResult, proposals) {
48
56
  })),
49
57
  }, null, 2);
50
58
  }
59
+ function buildFrictionSystemPrompt() {
60
+ return [
61
+ "You review a completed agent task for tool friction.",
62
+ "Tool friction is: missing validation feedback, missing batch capability, silent failures,",
63
+ "overly strict input constraints, or tool gaps that caused the agent to work around limitations.",
64
+ "If you identify friction, return a JSON array of action items.",
65
+ "Each item must have: title (string), detail (string), source (always 'eot:friction').",
66
+ "If no friction was found, return an empty array [].",
67
+ "Return JSON only. No prose, no wrapping.",
68
+ ].join("\n");
69
+ }
70
+ function buildFrictionUserPrompt(finalResult) {
71
+ return JSON.stringify({ final_result: finalResult }, null, 2);
72
+ }
51
73
  function parseEnvelope(raw) {
52
74
  const parsed = JSON.parse(raw);
53
75
  if (!parsed || typeof parsed !== "object") {
@@ -121,6 +143,34 @@ function parseReviewerResponse(raw) {
121
143
  : [],
122
144
  };
123
145
  }
146
+ function parseFrictionResponse(raw) {
147
+ try {
148
+ const parsed = JSON.parse(raw);
149
+ if (!Array.isArray(parsed)) {
150
+ return [];
151
+ }
152
+ return parsed.flatMap((entry) => {
153
+ if (!entry || typeof entry !== "object") {
154
+ return [];
155
+ }
156
+ const candidate = entry;
157
+ if (!isNonEmptyString(candidate.title) || typeof candidate.detail !== "string") {
158
+ return [];
159
+ }
160
+ if (candidate.source !== "eot:friction") {
161
+ return [];
162
+ }
163
+ return [{
164
+ title: candidate.title.trim(),
165
+ detail: candidate.detail,
166
+ source: "eot:friction",
167
+ }];
168
+ }).slice(0, 3);
169
+ }
170
+ catch {
171
+ return [];
172
+ }
173
+ }
124
174
  function isNonEmptyString(value) {
125
175
  return typeof value === "string" && value.trim().length > 0;
126
176
  }
@@ -199,6 +249,25 @@ function resolveAcceptedProposalScopeSlug(envelope, proposal) {
199
249
  }
200
250
  throw new Error("No memory scope could be resolved for this proposal.");
201
251
  }
252
+ function resolveActiveScopeSlug() {
253
+ const activeScope = getActiveScope();
254
+ if (activeScope) {
255
+ return activeScope.slug;
256
+ }
257
+ return getScope("chapterhouse")?.slug;
258
+ }
259
+ function resolveCallLLM(input) {
260
+ return input.callLLM ?? (async ({ system, user, model }) => {
261
+ const result = await runOneShotPrompt({
262
+ client: input.copilotClient,
263
+ model,
264
+ system,
265
+ user,
266
+ expectJson: true,
267
+ });
268
+ return result.content;
269
+ });
270
+ }
202
271
  function rememberAcceptedMemory(kind, scopeSlug, payload, source, confidence, sourceAgent) {
203
272
  const scope = getScope(scopeSlug);
204
273
  if (!scope) {
@@ -279,16 +348,7 @@ export async function runEndOfTaskMemoryHook(input) {
279
348
  }
280
349
  const proposals = listPendingMemoryProposalsForTask(input.taskId);
281
350
  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
- });
351
+ const callLLM = resolveCallLLM(input);
292
352
  const review = parseReviewerResponse(await callLLM({
293
353
  system: buildReviewerSystemPrompt(),
294
354
  user: buildReviewerUserPrompt(input.finalResult, proposals),
@@ -347,8 +407,51 @@ export async function runEndOfTaskMemoryHook(input) {
347
407
  }
348
408
  }
349
409
  }
410
+ await runFrictionHook({
411
+ taskId: input.taskId,
412
+ finalResult: input.finalResult,
413
+ copilotClient: input.copilotClient,
414
+ callLLM,
415
+ model: input.model,
416
+ });
350
417
  log.info(summary, "memory.eot.processed");
351
418
  input.onProcessed?.(summary);
352
419
  return summary;
353
420
  }
421
+ export async function runFrictionHook(input) {
422
+ try {
423
+ if (!isFrictionHookEnabled()) {
424
+ return;
425
+ }
426
+ if (input.finalResult.trim().length <= 100) {
427
+ return;
428
+ }
429
+ const scopeSlug = resolveActiveScopeSlug();
430
+ if (!scopeSlug) {
431
+ return;
432
+ }
433
+ const callLLM = resolveCallLLM(input);
434
+ const raw = await callLLM({
435
+ system: buildFrictionSystemPrompt(),
436
+ user: buildFrictionUserPrompt(input.finalResult),
437
+ model: input.model ?? config.copilotModel,
438
+ });
439
+ const frictionItems = parseFrictionResponse(raw);
440
+ for (const item of frictionItems) {
441
+ try {
442
+ rememberAcceptedMemory("action_item", scopeSlug, {
443
+ title: item.title,
444
+ detail: item.detail,
445
+ source: item.source,
446
+ }, "eot:friction");
447
+ }
448
+ catch (err) {
449
+ log.warn({ err, taskId: input.taskId, title: item.title }, "friction hook: failed to record action item");
450
+ }
451
+ }
452
+ }
453
+ catch (err) {
454
+ log.warn({ err, taskId: input.taskId }, "friction hook failed");
455
+ }
456
+ }
354
457
  //# sourceMappingURL=eot.js.map
@@ -763,4 +763,119 @@ test("runEndOfTaskMemoryHook persists implicit observation memories with valid c
763
763
  assert.equal(summary.implicit_extracted, 1);
764
764
  assert.equal(warnings.length, 0);
765
765
  });
766
+ test("runFrictionHook does nothing by default when CHAPTERHOUSE_MEMORY_EOT_FRICTION_ENABLED is unset", async () => {
767
+ const { eotModule } = await loadModules("friction-disabled-default");
768
+ const runFrictionHook = getFunction(eotModule, "runFrictionHook");
769
+ let llmCalls = 0;
770
+ await runFrictionHook({
771
+ taskId: "task-friction-disabled-default",
772
+ finalResult: "A substantive final result that is definitely longer than one hundred characters to prove the friction hook still stays off by default.",
773
+ copilotClient: {},
774
+ callLLM: async () => {
775
+ llmCalls++;
776
+ return "[]";
777
+ },
778
+ });
779
+ assert.equal(llmCalls, 0);
780
+ });
781
+ test("runFrictionHook skips short final results even when enabled", async () => {
782
+ process.env.CHAPTERHOUSE_MEMORY_EOT_FRICTION_ENABLED = "1";
783
+ const { eotModule } = await loadModules("friction-short-result");
784
+ const runFrictionHook = getFunction(eotModule, "runFrictionHook");
785
+ let llmCalls = 0;
786
+ await runFrictionHook({
787
+ taskId: "task-friction-short-result",
788
+ finalResult: "too short",
789
+ copilotClient: {},
790
+ callLLM: async () => {
791
+ llmCalls++;
792
+ return "[]";
793
+ },
794
+ });
795
+ assert.equal(llmCalls, 0);
796
+ });
797
+ test("runFrictionHook records action items when enabled and the task result is substantive", async () => {
798
+ process.env.CHAPTERHOUSE_MEMORY_EOT_FRICTION_ENABLED = "1";
799
+ const { memoryModule, eotModule } = await loadModules("friction-records-action-items");
800
+ const getScope = getFunction(memoryModule, "getScope");
801
+ const setActiveScope = getFunction(memoryModule, "setActiveScope");
802
+ const listActionItems = getFunction(memoryModule, "listActionItems");
803
+ const runFrictionHook = getFunction(eotModule, "runFrictionHook");
804
+ const chapterhouse = getScope("chapterhouse");
805
+ assert.ok(chapterhouse, "chapterhouse scope should be seeded");
806
+ setActiveScope("chapterhouse");
807
+ await runFrictionHook({
808
+ taskId: "task-friction-records-action-items",
809
+ 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.",
810
+ copilotClient: {},
811
+ callLLM: async () => JSON.stringify([
812
+ {
813
+ title: "Improve validation feedback for memory tools",
814
+ detail: "Return the rejected field name and allowed values instead of a generic failure.",
815
+ source: "eot:friction",
816
+ },
817
+ ]),
818
+ });
819
+ const actionItems = listActionItems({ scope_id: chapterhouse.id });
820
+ assert.equal(actionItems.length, 1);
821
+ assert.equal(actionItems[0]?.title, "Improve validation feedback for memory tools");
822
+ assert.equal(actionItems[0]?.detail, "Return the rejected field name and allowed values instead of a generic failure.");
823
+ assert.equal(actionItems[0]?.source, "eot:friction");
824
+ assert.equal(actionItems[0]?.status, "open");
825
+ });
826
+ test("runFrictionHook caps parsed friction items at 3", async () => {
827
+ process.env.CHAPTERHOUSE_MEMORY_EOT_FRICTION_ENABLED = "1";
828
+ const { memoryModule, eotModule } = await loadModules("friction-cap");
829
+ const getScope = getFunction(memoryModule, "getScope");
830
+ const setActiveScope = getFunction(memoryModule, "setActiveScope");
831
+ const listActionItems = getFunction(memoryModule, "listActionItems");
832
+ const runFrictionHook = getFunction(eotModule, "runFrictionHook");
833
+ const chapterhouse = getScope("chapterhouse");
834
+ assert.ok(chapterhouse, "chapterhouse scope should be seeded");
835
+ setActiveScope("chapterhouse");
836
+ await runFrictionHook({
837
+ taskId: "task-friction-cap",
838
+ 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.",
839
+ copilotClient: {},
840
+ callLLM: async () => JSON.stringify([
841
+ { title: "Item 1", detail: "detail 1", source: "eot:friction" },
842
+ { title: "Item 2", detail: "detail 2", source: "eot:friction" },
843
+ { title: "Item 3", detail: "detail 3", source: "eot:friction" },
844
+ { title: "Item 4", detail: "detail 4", source: "eot:friction" },
845
+ ]),
846
+ });
847
+ assert.deepEqual(listActionItems({ scope_id: chapterhouse.id }).map((item) => item.title).sort(), ["Item 1", "Item 2", "Item 3"]);
848
+ });
849
+ test("runFrictionHook treats malformed JSON as no friction items", async () => {
850
+ process.env.CHAPTERHOUSE_MEMORY_EOT_FRICTION_ENABLED = "1";
851
+ const { memoryModule, eotModule } = await loadModules("friction-malformed-json");
852
+ const getScope = getFunction(memoryModule, "getScope");
853
+ const setActiveScope = getFunction(memoryModule, "setActiveScope");
854
+ const listActionItems = getFunction(memoryModule, "listActionItems");
855
+ const runFrictionHook = getFunction(eotModule, "runFrictionHook");
856
+ const chapterhouse = getScope("chapterhouse");
857
+ assert.ok(chapterhouse, "chapterhouse scope should be seeded");
858
+ setActiveScope("chapterhouse");
859
+ await runFrictionHook({
860
+ taskId: "task-friction-malformed-json",
861
+ 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.",
862
+ copilotClient: {},
863
+ callLLM: async () => "{not valid json",
864
+ });
865
+ assert.deepEqual(listActionItems({ scope_id: chapterhouse.id }), []);
866
+ });
867
+ test("runFrictionHook never propagates errors from the LLM call", async (t) => {
868
+ process.env.CHAPTERHOUSE_MEMORY_EOT_FRICTION_ENABLED = "1";
869
+ const { eotModule, warnings } = await loadModulesWithWarnSpy(t, "friction-no-throw");
870
+ const runFrictionHook = getFunction(eotModule, "runFrictionHook");
871
+ await assert.doesNotReject(() => runFrictionHook({
872
+ taskId: "task-friction-no-throw",
873
+ 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.",
874
+ copilotClient: {},
875
+ callLLM: async () => {
876
+ throw new Error("boom");
877
+ },
878
+ }));
879
+ assert.equal(warnings.length, 1);
880
+ });
766
881
  //# sourceMappingURL=eot.test.js.map
@@ -314,9 +314,6 @@ export const WikiPageUpdateResponseSchema = z.object({
314
314
  page: WikiPageDetailSchema.shape.page.optional(),
315
315
  pinned: z.boolean().optional(),
316
316
  }).passthrough();
317
- export const WikiKorgResponseSchema = z.object({
318
- reply: z.string(),
319
- }).passthrough();
320
317
  export const WikiReingestResponseSchema = z.object({
321
318
  ok: z.boolean(),
322
319
  }).passthrough();
package/dist/store/db.js CHANGED
@@ -551,6 +551,7 @@ export function getDb() {
551
551
  ensureChapterhouseHome();
552
552
  db = new Database(getDbPath());
553
553
  db.pragma("journal_mode = WAL");
554
+ db.pragma("busy_timeout = 5000");
554
555
  db.pragma("foreign_keys = ON");
555
556
  db.exec(`
556
557
  CREATE TABLE IF NOT EXISTS worker_sessions (
@@ -175,12 +175,13 @@ export function validateWikiFrontmatter(content, options = {}) {
175
175
  }
176
176
  if (options.allowedTags && parsed.tags) {
177
177
  const allowed = new Set(options.allowedTags.map((tag) => tag.toLowerCase()));
178
+ const validTags = options.allowedTags.join(", ");
178
179
  for (const tag of parsed.tags) {
179
180
  if (!allowed.has(tag.toLowerCase())) {
180
181
  errors.push({
181
182
  rule: "unknown-tag",
182
183
  field: "tags",
183
- message: formatFrontmatterMessage(`unknown tag '${tag}'. Add it to \`pages/_meta/taxonomy.md\` first.`),
184
+ message: formatFrontmatterMessage(`unknown tag '${tag}'. Tag "${tag}" is not in the allowed tag list. Valid tags: ${validTags}. Add it to \`pages/_meta/taxonomy.md\` first.`),
184
185
  });
185
186
  }
186
187
  }