@treeseed/dsh-chapters 0.1.23 → 0.2.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 (104) hide show
  1. package/README.md +2 -1
  2. package/cordis.patch.yml +54 -1
  3. package/docs/architecture.md +1 -466
  4. package/docs/backlog.md +234 -4
  5. package/docs/contract.md +127 -1
  6. package/docs/development.md +248 -12
  7. package/docs/host-compaction-seam.md +108 -0
  8. package/docs/knowledge-repo.md +532 -16
  9. package/docs/project-state.md +323 -0
  10. package/docs/verify.md +217 -0
  11. package/lib/archive.d.ts +49 -1
  12. package/lib/archive.js +56 -2
  13. package/lib/archive.js.map +1 -1
  14. package/lib/arrival.js +24 -10
  15. package/lib/arrival.js.map +1 -1
  16. package/lib/attach.d.ts +46 -0
  17. package/lib/attach.js +120 -0
  18. package/lib/attach.js.map +1 -0
  19. package/lib/auto-save.d.ts +145 -0
  20. package/lib/auto-save.js +318 -0
  21. package/lib/auto-save.js.map +1 -0
  22. package/lib/client.js +413 -11
  23. package/lib/client.js.map +1 -1
  24. package/lib/commands.d.ts +11 -1
  25. package/lib/commands.js +138 -7
  26. package/lib/commands.js.map +1 -1
  27. package/lib/compose.js +25 -6
  28. package/lib/compose.js.map +1 -1
  29. package/lib/continue-core.d.ts +24 -1
  30. package/lib/continue-core.js +41 -9
  31. package/lib/continue-core.js.map +1 -1
  32. package/lib/diag.d.ts +47 -0
  33. package/lib/diag.js +141 -0
  34. package/lib/diag.js.map +1 -0
  35. package/lib/engine-core.d.ts +51 -1
  36. package/lib/engine-core.js +70 -7
  37. package/lib/engine-core.js.map +1 -1
  38. package/lib/engine.d.ts +93 -0
  39. package/lib/engine.js +477 -36
  40. package/lib/engine.js.map +1 -1
  41. package/lib/enrich-queue.js +28 -2
  42. package/lib/enrich-queue.js.map +1 -1
  43. package/lib/enrich-wire.d.ts +92 -2
  44. package/lib/enrich-wire.js +135 -17
  45. package/lib/enrich-wire.js.map +1 -1
  46. package/lib/enrich.d.ts +27 -2
  47. package/lib/enrich.js +114 -18
  48. package/lib/enrich.js.map +1 -1
  49. package/lib/index.d.ts +59 -0
  50. package/lib/index.js +215 -2
  51. package/lib/index.js.map +1 -1
  52. package/lib/indexing.d.ts +7 -0
  53. package/lib/indexing.js.map +1 -1
  54. package/lib/injections.d.ts +1 -0
  55. package/lib/injections.js +38 -0
  56. package/lib/injections.js.map +1 -1
  57. package/lib/last-resort.d.ts +24 -0
  58. package/lib/last-resort.js +27 -0
  59. package/lib/last-resort.js.map +1 -0
  60. package/lib/prompt.d.ts +130 -0
  61. package/lib/prompt.js +255 -0
  62. package/lib/prompt.js.map +1 -0
  63. package/lib/registry.d.ts +39 -1
  64. package/lib/registry.js +76 -9
  65. package/lib/registry.js.map +1 -1
  66. package/lib/render.d.ts +31 -3
  67. package/lib/render.js +82 -3
  68. package/lib/render.js.map +1 -1
  69. package/lib/repair-run.d.ts +49 -0
  70. package/lib/repair-run.js +423 -0
  71. package/lib/repair-run.js.map +1 -0
  72. package/lib/repair.d.ts +200 -0
  73. package/lib/repair.js +354 -0
  74. package/lib/repair.js.map +1 -0
  75. package/lib/rules-commands.d.ts +22 -0
  76. package/lib/rules-commands.js +177 -20
  77. package/lib/rules-commands.js.map +1 -1
  78. package/lib/rules.d.ts +184 -7
  79. package/lib/rules.js +312 -27
  80. package/lib/rules.js.map +1 -1
  81. package/lib/save-core.d.ts +72 -0
  82. package/lib/save-core.js +220 -0
  83. package/lib/save-core.js.map +1 -0
  84. package/lib/session-rules.d.ts +222 -0
  85. package/lib/session-rules.js +454 -0
  86. package/lib/session-rules.js.map +1 -0
  87. package/lib/store.d.ts +22 -1
  88. package/lib/store.js +94 -17
  89. package/lib/store.js.map +1 -1
  90. package/lib/sync.js +13 -1
  91. package/lib/sync.js.map +1 -1
  92. package/lib/tools.d.ts +14 -0
  93. package/lib/tools.js +341 -91
  94. package/lib/tools.js.map +1 -1
  95. package/lib/types.d.ts +31 -1
  96. package/lib/types.js +12 -0
  97. package/lib/types.js.map +1 -1
  98. package/lib/version.d.ts +9 -0
  99. package/lib/version.js +10 -0
  100. package/lib/version.js.map +1 -0
  101. package/lib/warnings.d.ts +10 -0
  102. package/lib/warnings.js +18 -0
  103. package/lib/warnings.js.map +1 -0
  104. package/package.json +9 -3
package/lib/tools.js CHANGED
@@ -7,21 +7,27 @@
7
7
  import { defineTool } from '@deepseek-ai/dsh-tools';
8
8
  import { randomUUID } from 'node:crypto';
9
9
  import * as fs from 'node:fs';
10
+ import { runRepair, restoreRepair } from "./repair-run.js";
11
+ import { formatWarnings } from "./warnings.js";
12
+ import { ensureDiagnosticsDefault } from "./diag.js";
13
+ import { REPAIR_CLASS_ALIASES, resolveRepairClasses } from "./repair.js";
14
+ import { readDurableSession } from "./auto-save.js";
10
15
  import * as path from 'node:path';
11
- import { estimateTokens, renderChapter, toolResultCandidates } from "./render.js";
16
+ import { candidateTotals, estimateTokens, toolResultCandidates } from "./render.js";
12
17
  import { detectPlans } from "./plans.js";
13
- import { buildRulesSection } from "./rules.js";
18
+ import { makeHeaderParentReader, queryTopics, renderRulesBlock } from "./session-rules.js";
14
19
  import { rulesCommand } from "./rules-commands.js";
20
+ import { diagnosticWarn } from "./diag.js";
21
+ import { attachCreatedChild } from "./attach.js";
15
22
  import { DEFAULT_CLONE_DIR } from "./sync.js";
23
+ import { lineageOf } from "./types.js";
16
24
  import { composeChapters } from "./compose.js";
17
25
  import { projectForCwd } from "./sync.js";
18
26
  import { deriveRanges, refusalResult, runContinue, runFork } from "./continue-core.js";
27
+ import { saveSession } from "./save-core.js";
19
28
  import { searchKnowledge, searchLocalStore } from "./search.js";
20
29
  import { resolveArtifactWithMirror, buildToc, searchArtifact, readLines } from "./artifact-query.js";
21
- import { makeArchiveFs, commitChapters, resolveStoreRoot } from "./store.js";
22
- import { reserve, reservePlan as reservePlanNumber } from "./registry.js";
23
- import { writeArchive, attemptKey } from "./archive.js";
24
- import { chapterTopics } from "./signature.js";
30
+ import { makeArchiveFs, resolveStoreRoot } from "./store.js";
25
31
  const jsonOutput = (render) => ({
26
32
  schema: { type: 'json' },
27
33
  render: (_args, value) => [{ type: 'text', text: render(value) }],
@@ -32,8 +38,21 @@ function callerOf(exec) {
32
38
  return agent ?? CALLER_MISSING;
33
39
  }
34
40
  export function buildChaptersTools(ctx, store, config) {
41
+ // #52: the delegation edge for rule ancestry, read from whatever this plane can
42
+ // reach (live handles, else the durable session query). undefined ⇒ the walk
43
+ // follows registry edges only, and a block that truncated says so.
44
+ const ruleParentOf = makeHeaderParentReader((name) => { try {
45
+ return ctx.get?.(name);
46
+ }
47
+ catch {
48
+ return undefined;
49
+ } });
35
50
  const portsFor = async (caller) => {
36
51
  const cwd = caller.session.header.cwd ?? '';
52
+ // #61: a door is touching this project's store, so THIS is where failures get written unless the
53
+ // operator said otherwise. First door wins; `off` in the env still means no file at all.
54
+ if (cwd !== '')
55
+ ensureDiagnosticsDefault(path.join(cwd, config.artifactStoreRoot, 'diagnostics.log'));
37
56
  // every real tool use carries the caller's routed model — record it for
38
57
  // the enrichment resolver (the only route source the host plane can have)
39
58
  if (caller.options.provider !== undefined && caller.options.model !== undefined) {
@@ -76,6 +95,7 @@ export function buildChaptersTools(ctx, store, config) {
76
95
  return { windowTokens, headerBoundTokens };
77
96
  },
78
97
  newId: () => randomUUID(),
98
+ diagnostic: (where, message, error) => { diagnosticWarn(ctx.logger, where, message, error); },
79
99
  createChild: async ({ sessionId, noticeEvent, modeEvents, presetId: mountPreset, title }) => {
80
100
  const callerOptions = caller.options;
81
101
  // The seed is contiguous from seq 0: the notice, then any STATE events this
@@ -96,17 +116,20 @@ export function buildChaptersTools(ctx, store, config) {
96
116
  await presets.mount(agentCtx, mountPreset);
97
117
  },
98
118
  });
99
- try {
100
- const workspaceRegistry = ctx.get?.('workspaceRegistry');
101
- if (cwd !== '' && workspaceRegistry?.createCanonical !== undefined) {
102
- const workspace = await workspaceRegistry.createCanonical(cwd);
103
- await workspace?.attachSession?.(sessionId);
104
- }
105
- }
106
- catch (error) {
107
- // An unattached child is durable but may not list — say so, keep going.
108
- ctx.logger?.warn?.(`dsh-chapters: child workspace attach failed (${String(error)})`);
109
- }
119
+ // #54: was an optional chain through a REQUIRED step, inside a catch that only
120
+ // warned the profile logger. On host 0.2.x that turned a reshaped workspace API
121
+ // into a silent no-op — durable child, missing from the sidebar, door reporting
122
+ // success. attachCreatedChild verifies the user-visible fact (is it LISTED) and
123
+ // returns words the door passes through to the user.
124
+ const attach = await attachCreatedChild({
125
+ workspaceRegistry: ctx.get?.('workspaceRegistry'),
126
+ sessionQuery: ctx.get?.('sessionQuery'),
127
+ // the surface the browser reads — a child missing here is invisible and unopenable
128
+ sessionController: ctx.get?.('sessionController'),
129
+ diagnostic: (where, message, error) => { diagnosticWarn(ctx.logger, where, message, error); },
130
+ childId: sessionId,
131
+ cwd,
132
+ });
110
133
  // Title while the child is still live: sessionController.rename resolves
111
134
  // the agent, routes through the official session-title normalizer, and
112
135
  // appends the durable `session/title` event (r6: commands-execute was the
@@ -128,6 +151,7 @@ export function buildChaptersTools(ctx, store, config) {
128
151
  // handle", measured r3). Dispose after the live-work, keep the session.
129
152
  await handle.dispose?.();
130
153
  }
154
+ return { warnings: attach.warnings };
131
155
  },
132
156
  now: () => Date.now(),
133
157
  };
@@ -185,8 +209,25 @@ export function buildChaptersTools(ctx, store, config) {
185
209
  : { planHint: 'a submitted plan is a topic boundary: the chapter that carries it ENDS there, and its title becomes the chapter title' }),
186
210
  // fresh literals, not the ToolResultCandidate interface: anonymous
187
211
  // object types carry the implicit index signature JsonValue needs.
188
- toolResults: toolResultCandidates(below, { title: '', summary: '', startSeq: 0, endSeq: ceiling })
189
- .map((c) => ({ seq: c.seq, toolName: c.toolName, bytes: c.bytes, estimatedTokens: c.estimatedTokens, excerpt: c.excerpt })),
212
+ // #61: the candidate list is CAPPED and says so. The field version returned 49,932 characters
213
+ // while also reporting `Omitted 275266 bytes` — ~81K est tokens into a 32K-class window, asking
214
+ // the model to keep/drop-judge a list it had not received. Ranking is by size descending, so the
215
+ // cap removes the SMALL entries and the big ones a real decision turns on stay visible.
216
+ ...(() => {
217
+ const range = { title: '', summary: '', startSeq: 0, endSeq: ceiling };
218
+ const cap = config.segmentCandidateCapTokens ?? 4000;
219
+ const kept = cap > 0
220
+ ? toolResultCandidates(below, range, cap)
221
+ : toolResultCandidates(below, range);
222
+ const totals = candidateTotals(below, range);
223
+ const withheld = Math.max(0, totals.count - kept.length);
224
+ const withheldBytes = Math.max(0, totals.bytes - kept.reduce((m, c) => m + c.bytes, 0));
225
+ return {
226
+ toolResults: kept.map((c) => ({ seq: c.seq, toolName: c.toolName, bytes: c.bytes, estimatedTokens: c.estimatedTokens, excerpt: c.excerpt })),
227
+ toolResultsReport: `showing ${kept.length} of ${totals.count} candidates by size, ${(withheldBytes / 1024).toFixed(0)} KB withheld`
228
+ + (cap > 0 ? ` (segmentCandidateCapTokens=${cap})` : ' (uncapped)'),
229
+ };
230
+ })(),
190
231
  existingChapters: callerState.chapters.map((c) => ({
191
232
  number: c.number, path: c.path, title: c.title, startSeq: c.startSeq, endSeq: c.endSeq,
192
233
  })),
@@ -245,7 +286,7 @@ export function buildChaptersTools(ctx, store, config) {
245
286
  await config.scheduler.pullFor(cwd).catch(() => undefined); // §5.1: pull → archive → push
246
287
  }
247
288
  const line = projectLineFor(caller);
248
- const rs = rulesSectionFor(caller);
289
+ const rs = await rulesSectionFor(caller, 'continue');
249
290
  if (rs.refusal !== undefined)
250
291
  return { ok: false, reason: rs.refusal };
251
292
  const result = await runContinue(ports, {
@@ -257,7 +298,7 @@ export function buildChaptersTools(ctx, store, config) {
257
298
  toolResultOverrides: args.toolResultOverrides ?? [],
258
299
  ...(line !== undefined ? { projectLine: line } : {}),
259
300
  ...(rs.section !== undefined ? { rulesSection: rs.section } : {}),
260
- }, config);
301
+ }, { ...config, workspaceCwd: cwd });
261
302
  if (result.ok && cwd !== '')
262
303
  config.scheduler?.schedule(cwd, 'archive:continue');
263
304
  // conditional spreads: absent optionals must not surface as `undefined`
@@ -307,7 +348,7 @@ export function buildChaptersTools(ctx, store, config) {
307
348
  await config.scheduler.pullFor(cwd).catch(() => undefined);
308
349
  }
309
350
  const line = projectLineFor(caller);
310
- const rs = rulesSectionFor(caller);
351
+ const rs = await rulesSectionFor(caller, 'branch');
311
352
  if (rs.refusal !== undefined)
312
353
  return { ok: false, reason: rs.refusal };
313
354
  const result = await runFork(ports, {
@@ -363,7 +404,7 @@ export function buildChaptersTools(ctx, store, config) {
363
404
  const parentTitle = [...events].reverse()
364
405
  .find((e) => e.type === 'session/title')?.data?.title;
365
406
  const title = trimmed !== '' ? trimmed : `${parentTitle ?? `Chapters ${door.noun}`} \u2014 ${door.noun}`;
366
- const rsCmd = rulesSectionFor(agent);
407
+ const rsCmd = await rulesSectionFor(agent, door.intent);
367
408
  if (rsCmd.refusal !== undefined)
368
409
  return { kind: 'error', text: `${door.name} refused: ${rsCmd.refusal}` };
369
410
  const callerArgs = {
@@ -406,17 +447,21 @@ export function buildChaptersTools(ctx, store, config) {
406
447
  : ['no stored turn signatures — legacy size-based segmentation'];
407
448
  const handoffNote = `Archived through the Chapters ${door.noun}. The chapters listed above carry every prior word verbatim — reload any with the read tool on its path. ${door.noDirective}`
408
449
  + (notes.length > 0 ? `\n(Segmentation notes: ${notes.join('; ')})` : '');
409
- const result = await runContinue(ports, { ...callerArgs, handoffNote, chapters }, config);
450
+ const result = await runContinue(ports, { ...callerArgs, handoffNote, chapters }, { ...config, workspaceCwd: cwd0 });
410
451
  if (result.ok && cwd0 !== '')
411
452
  config.scheduler?.schedule(cwd0, 'archive:fork');
412
453
  const budgetText = result.budget !== undefined ? `; TOC ~${result.budget.usedTokens} tokens, allowance ${result.budget.allowanceTokens}` : '';
413
- return { kind: 'success', text: `${door.verb}: ${chapters.length} new chapter(s) archived (watermark respected: seqs ${fromSeq}..${anchor}), ${door.noun} \u201C${title}\u201D is session ${result.childSessionId ?? '(created)'}${budgetText}. Switch to it from the sidebar — its first message is the table of contents.` };
454
+ // #61: the archive happened; the caveats were in `result.warnings` and were NEVER printed here.
455
+ const dWarn = formatWarnings(result.warnings ?? [], config.warningsDisplayCap ?? 6);
456
+ return { kind: 'success', text: `${door.verb}: ${chapters.length} new chapter(s) archived (watermark respected: seqs ${fromSeq}..${anchor}), ${door.noun} \u201C${title}\u201D is session ${result.childSessionId ?? '(created)'}${budgetText}. Switch to it from the sidebar — its first message is the table of contents.`
457
+ + (dWarn === '' ? '' : `\n warnings: ${dWarn}`) };
414
458
  }
415
459
  catch (error) {
416
460
  const refusal = refusalResult(error);
417
461
  if (refusal !== null) {
418
462
  const budget = refusal.budget !== undefined ? ` (notice ~${refusal.budget.usedTokens} > allowance ${refusal.budget.allowanceTokens})` : '';
419
- return { kind: 'error', text: `${door.name} refused: ${refusal.reason ?? 'refused'}${budget}` };
463
+ const rWarn = formatWarnings(refusal.warnings ?? [], config.warningsDisplayCap ?? 6);
464
+ return { kind: 'error', text: `${door.name} refused: ${refusal.reason ?? 'refused'}${budget}${rWarn === '' ? '' : `\n warnings: ${rWarn}`}` };
420
465
  }
421
466
  return { kind: 'error', text: `${door.name} failed: ${String(error?.message ?? error)}`.slice(0, 500) };
422
467
  }
@@ -441,10 +486,11 @@ export function buildChaptersTools(ctx, store, config) {
441
486
  });
442
487
  // ------------------------------------------------- /chapters-save (archive without child)
443
488
  /**
444
- * Archive-only door: same pipeline as fork/continue (composeChapters + writeArchive)
445
- * but stops before agents.create. Writes chapters to disk, commits records to the
446
- * session state (so search picks them up), and triggers enrichment. No child session,
447
- * no compaction, no disruption to the active conversation.
489
+ * Archive-only door: the SHARED save core (src/save-core.ts) plus this door's
490
+ * policies — human-readable refusal/success text, enrichment run-now (a human
491
+ * asked, so a human waits for nothing else), sync scheduled. The same core is
492
+ * what door 5 (subagent settlement) runs; a row pins that both doors archive
493
+ * byte-identically (tests/integration/subagent-auto-save.test.ts).
448
494
  */
449
495
  const saveCommand = {
450
496
  name: 'chapters-save',
@@ -458,59 +504,31 @@ export function buildChaptersTools(ctx, store, config) {
458
504
  const ports = await portsFor(agent);
459
505
  const cwd0 = agent.session.header?.cwd ?? '';
460
506
  const events = await ports.readCallerEvents();
461
- const anchor = [...events].reverse().find((e) => e.type === 'turn/end')?.seq;
462
- if (anchor === undefined)
463
- return { kind: 'error', text: 'chapters-save: nothing to save yet — the conversation has no completed turn' };
464
- // Watermark: only archive what is newer than the last saved chapter.
465
- const parentState = await ports.getState(agent.session.id);
466
- const lastArchived = parentState.chapters.reduce((m, c) => Math.max(m, c.endSeq), 0);
467
- const fromSeq = lastArchived > 0 ? lastArchived + 1 : 0;
468
- if (fromSeq > 0 && anchor <= lastArchived) {
469
- return { kind: 'success', text: `Nothing to save: all ${parentState.chapters.length} chapter(s) already archived through seq ${lastArchived}. Content is searchable via chapters_search.` };
470
- }
471
- // Topic-sequential composition — same pipeline as fork/continue.
472
- const composed = composeChapters(events, fromSeq, anchor, parentState.collections, {
473
- mergeThreshold: config.mergeThreshold,
474
- chapterLimit: config.chapterLimit,
475
- });
476
- const chapters = composed.chapters.length > 0
477
- ? composed.chapters
478
- : deriveRanges(events, anchor, config.chapterTokenTarget, fromSeq).chapters;
479
- // Render + write archive (same as runContinue steps 2-3, minus child creation).
480
- const below = events.filter((e) => e.seq <= anchor);
481
- const renderConfig = {
482
- toolResultDeferFloorTokens: config.toolResultDeferFloorTokens,
483
- chapterTokenTarget: config.chapterTokenTarget,
484
- ...(config.planInlineChars !== undefined ? { planInlineChars: config.planInlineChars } : {}),
485
- ...(config.callArgArtifactTokens !== undefined ? { callArgArtifactTokens: config.callArgArtifactTokens } : {}),
486
- };
487
- const rendered = chapters.map((range) => renderChapter(below, range, renderConfig, [], chapterTopics(below, range.startSeq, range.endSeq)));
488
- const fsPort = ports.fs();
489
- const wrote = await writeArchive({
490
- fs: fsPort,
491
- allocator: {
492
- async reserve(attemptId, count) {
493
- const fresh = await ports.getState(agent.session.id);
494
- const r = reserve(fresh, attemptId, count);
495
- await ports.putState(agent.session.id, r.state);
496
- return r.numbers;
497
- },
498
- async reservePlan(attemptId, count) {
499
- const fresh = await ports.getState(agent.session.id);
500
- const r = reservePlanNumber(fresh, attemptId, count);
501
- await ports.putState(agent.session.id, r.state);
502
- return r.numbers;
503
- },
507
+ // Lineage from the durable header (issue #47): a child running under the
508
+ // subagent seam saves through this same door and gets stamped identically.
509
+ const out = await saveSession({
510
+ events,
511
+ sessionId: agent.session.id,
512
+ cwd: cwd0,
513
+ store,
514
+ config: {
515
+ artifactStoreRoot: config.artifactStoreRoot,
516
+ chapterTokenTarget: config.chapterTokenTarget,
517
+ toolResultDeferFloorTokens: config.toolResultDeferFloorTokens,
518
+ mergeThreshold: config.mergeThreshold,
519
+ chapterLimit: config.chapterLimit,
520
+ chapterOversizeFactor: config.chapterOversizeFactor,
521
+ ...(config.planInlineChars !== undefined ? { planInlineChars: config.planInlineChars } : {}),
522
+ ...(config.callArgArtifactTokens !== undefined ? { callArgArtifactTokens: config.callArgArtifactTokens } : {}),
504
523
  },
505
- // raw value: makeArchiveFs roots RELATIVE paths at the session cwd and
506
- // passes absolute ones through — resolving here would double-root relative stores
507
- storeRoot: config.artifactStoreRoot,
508
- rootSessionId: parentState.rootSession,
509
- chapters: rendered,
510
- attemptId: attemptKey(agent.session.id, anchor),
524
+ subagentOf: lineageOf(agent.session.header),
511
525
  });
512
- // Commit chapter records to session state so search index picks them up.
513
- await commitChapters(store, agent.session.id, wrote.records);
526
+ if (out.skipped === 'no-completed-turn') {
527
+ return { kind: 'error', text: 'chapters-save: nothing to save yet — the conversation has no completed turn' };
528
+ }
529
+ if (out.skipped === 'nothing-new') {
530
+ return { kind: 'success', text: `Nothing to save: all ${out.priorChapters} chapter(s) already archived through seq ${out.lastArchived}. Content is searchable via chapters_search.` };
531
+ }
514
532
  // Schedule enrichment asynchronously — do NOT await. Enrichment calls
515
533
  // the LLM per chapter and can exceed the command timeout. The idle and
516
534
  // afterPush triggers will pick up any remaining work.
@@ -520,8 +538,12 @@ export function buildChaptersTools(ctx, store, config) {
520
538
  // Schedule sync so saved chapters travel to the pool.
521
539
  if (cwd0 !== '')
522
540
  config.scheduler?.schedule(cwd0, 'archive:save');
523
- const warnings = wrote.warnings.length > 0 ? ` (${wrote.warnings.length} warning(s))` : '';
524
- return { kind: 'success', text: `Saved: ${wrote.records.length} chapter(s) archived (seqs ${fromSeq}..${anchor})${warnings}. Enrichment queued. Content is searchable via chapters_search from any session in this workspace.` };
541
+ // #61: this used to be ` (86 warning(s))` — a COUNT that carried none of the 86 messages, six of
542
+ // which named the hollow chapter and an unarchived 0-4082 hole. A count is not a message.
543
+ const warnText = formatWarnings(out.warnings, config.warningsDisplayCap ?? 6);
544
+ return { kind: 'success', text: `Saved: ${out.records.length} chapter(s) archived (seqs ${out.fromSeq}..${out.anchor}).`
545
+ + (warnText === '' ? '' : `\n warnings: ${warnText}`)
546
+ + ` Enrichment queued. Content is searchable via chapters_search from any session in this workspace.` };
525
547
  }
526
548
  catch (error) {
527
549
  return { kind: 'error', text: `chapters-save failed: ${String(error?.message ?? error)}`.slice(0, 500) };
@@ -544,17 +566,62 @@ export function buildChaptersTools(ctx, store, config) {
544
566
  return undefined;
545
567
  }
546
568
  };
547
- /** P3 §7.3: this machine's rules block for a continuation notice. */
548
- const rulesSectionFor = (agent) => {
569
+ /**
570
+ * §7.3/§7.6: the rules block for a head this door is about to build, through
571
+ * the ONE renderer the compaction checkpoint also uses
572
+ * (`session-rules.renderRulesBlock`) — a session must never see two rule sets
573
+ * depending on which path rebuilt its head.
574
+ *
575
+ * `intent` is the whole semantic difference between the two doors, and it was
576
+ * already the axis that decides plan bytes (docs/plans-carriage.md §§ 9–13): a
577
+ * CONTINUATION extends the same stream of work, so it inherits the session
578
+ * tier; a BRANCH splits a new direction, so it gets the project tier and ZERO
579
+ * session bytes — a branch is not an order.
580
+ */
581
+ const rulesSectionFor = async (agent, intent) => {
549
582
  try {
550
583
  if (config.harnessId === undefined || config.coreRulesBudgetTokens === undefined)
551
584
  return {};
552
585
  const cwd = agent.session.header?.cwd ?? '';
553
- const best = projectForCwd(store.projects(), cwd);
554
- if (best === undefined)
586
+ if (cwd === '')
555
587
  return {};
556
- const built = buildRulesSection(path.join(cwd, DEFAULT_CLONE_DIR), {
557
- projectKey: best.projectKey, harnessId: config.harnessId, budgetTokens: config.coreRulesBudgetTokens,
588
+ const best = projectForCwd(store.projects(), cwd);
589
+ // §7.8: a session with NO project resolves normally renders zero rule bytes —
590
+ // and silence is exactly what this round exists to remove. But adding a
591
+ // "nothing is linked" line to EVERY unlinked workspace's head would spend real
592
+ // bytes on a warning most sessions do not need, so the line appears only when
593
+ // the session tier IS governing the head: then "0 project rules" is a fact the
594
+ // child must not misread as "the team approved nothing".
595
+ if (best === undefined) {
596
+ const orphan = await renderRulesBlock({
597
+ mirrorDir: path.join(cwd, DEFAULT_CLONE_DIR),
598
+ storeDir: resolveStoreRoot(cwd, config.artifactStoreRoot ?? '.dsh-chapters'),
599
+ projectKey: '',
600
+ harnessId: config.harnessId,
601
+ budgetTokens: config.coreRulesBudgetTokens,
602
+ relevance: config.rulesRelevance ?? true,
603
+ includeSession: intent === 'continue',
604
+ store,
605
+ sessionId: agent.session.id,
606
+ ...(ruleParentOf !== undefined ? { parentOf: ruleParentOf } : {}),
607
+ });
608
+ if (orphan.kind !== 'ok' || orphan.shown.session === 0)
609
+ return {};
610
+ return { section: `0 project rules — this checkout resolves no knowledge project (${cwd}); your session rules below are local only. Link one with /chapters-link to inherit the team's.\n\n${orphan.kind === 'ok' ? orphan.text : ''}` };
611
+ }
612
+ const built = await renderRulesBlock({
613
+ mirrorDir: path.join(cwd, DEFAULT_CLONE_DIR),
614
+ storeDir: resolveStoreRoot(cwd, config.artifactStoreRoot ?? '.dsh-chapters'),
615
+ projectKey: best.projectKey,
616
+ harnessId: config.harnessId,
617
+ budgetTokens: config.coreRulesBudgetTokens,
618
+ ...(config.projectRulesDisplayCap !== undefined ? { projectCap: config.projectRulesDisplayCap } : {}),
619
+ ...(config.sessionRulesDisplayCap !== undefined ? { sessionCap: config.sessionRulesDisplayCap } : {}),
620
+ relevance: config.rulesRelevance ?? true,
621
+ includeSession: intent === 'continue',
622
+ store,
623
+ sessionId: agent.session.id,
624
+ ...(ruleParentOf !== undefined ? { parentOf: ruleParentOf } : {}),
558
625
  });
559
626
  if (built.kind === 'none')
560
627
  return {};
@@ -739,7 +806,188 @@ export function buildChaptersTools(ctx, store, config) {
739
806
  }
740
807
  },
741
808
  });
742
- return { segment: chaptersSegment, chaptersContinue, chaptersFork, chaptersSearch, chaptersArtifact, chaptersRulePropose, forkCommand, continueCommand, saveCommand };
809
+ // ------------------------------------------------- #50 §7.9: the assembly door
810
+ //
811
+ // The ONE rule verb a session is told to reach for. Capture and carriage are
812
+ // invisible to the model (§7.9); this is the exception, because a tool an
813
+ // instruction never names does not exist to a small model — and because the
814
+ // head can only show a capped, relevance-selected subset, so "what actually
815
+ // applies to THIS task" needs a door that answers with the text.
816
+ const chaptersRules = defineTool({
817
+ name: 'chapters_rules',
818
+ description: 'Assemble the rules that govern a task: the approved project tier plus this session tree\u2019s '
819
+ + 'captured session rules, verbatim, within budget. Run it BEFORE starting a task. The answer '
820
+ + 'names what it withheld, so "you are seeing all of them" is a fact rather than a guess.',
821
+ parameters: {
822
+ query: { type: 'string', required: true, description: 'What the session is about to do, in a sentence or two \u2014 it selects relevance.' },
823
+ scope: { type: 'string', description: "'all' (default) or 'project' to see only the shared tier a branch would inherit." },
824
+ maxTokens: { type: 'number', description: 'Token ceiling for this answer (defaults to coreRulesBudgetTokens).' },
825
+ },
826
+ output: jsonOutput((value) => JSON.stringify(value, null, 1)),
827
+ async execute(args, exec) {
828
+ const caller = callerOf(exec);
829
+ if ('reason' in caller)
830
+ return { ...CALLER_MISSING };
831
+ if (config.harnessId === undefined || config.coreRulesBudgetTokens === undefined) {
832
+ return { ok: false, error: 'rules surface not configured (harnessId/budget absent)' };
833
+ }
834
+ const cwd = caller.session.header?.cwd ?? '';
835
+ const best = cwd === '' ? undefined : projectForCwd(store.projects(), cwd);
836
+ try {
837
+ const built = await renderRulesBlock({
838
+ mirrorDir: path.join(cwd, DEFAULT_CLONE_DIR),
839
+ storeDir: resolveStoreRoot(cwd, config.artifactStoreRoot ?? '.dsh-chapters'),
840
+ projectKey: best?.projectKey ?? '',
841
+ harnessId: config.harnessId,
842
+ budgetTokens: args.maxTokens ?? config.coreRulesBudgetTokens,
843
+ ...(config.projectRulesDisplayCap !== undefined ? { projectCap: config.projectRulesDisplayCap } : {}),
844
+ ...(config.sessionRulesDisplayCap !== undefined ? { sessionCap: config.sessionRulesDisplayCap } : {}),
845
+ relevance: config.rulesRelevance ?? true,
846
+ includeSession: args.scope !== 'project',
847
+ // the QUERY selects here (that is the door's whole job), not the session's
848
+ // own recent signatures — which is what a rebuilt head selects on
849
+ topicsOverride: queryTopics(args.query),
850
+ ...(ruleParentOf !== undefined ? { parentOf: ruleParentOf } : {}),
851
+ store,
852
+ sessionId: caller.session.id,
853
+ });
854
+ if (built.kind === 'refusal')
855
+ return { ok: false, error: built.reason };
856
+ if (built.kind === 'none') {
857
+ // §7.8: "nothing applies" and "nothing is reachable" are different answers
858
+ return {
859
+ ok: true, shown: { project: 0, session: 0 }, withheld: { project: 0, session: 0 }, text: '',
860
+ note: best === undefined
861
+ ? `no knowledge project resolves for ${cwd} \u2014 nothing is approved here AND no pool is linked (/chapters-link); session rules still apply once captured`
862
+ : 'no active rules: approved project rules need a human approve ON THIS MACHINE (/chapters-rule list --all), and this session tree has captured none',
863
+ };
864
+ }
865
+ const note = [
866
+ built.withheld.project + built.withheld.session > 0
867
+ ? `withheld ${built.withheld.project} project / ${built.withheld.session} session rule(s) over the display caps \u2014 /chapters-rule active lists all, pin one to keep it in every head`
868
+ : 'nothing withheld: this is the complete active set',
869
+ ...(best === undefined ? ['no linked project for this cwd \u2014 the project tier is empty by construction (/chapters-link)'] : []),
870
+ ];
871
+ return { ok: true, shown: built.shown, withheld: built.withheld, text: built.text, tokens: built.tokens, note: note.join('; ') };
872
+ }
873
+ catch (error) {
874
+ return { ok: false, error: `chapters_rules failed: ${String(error?.message ?? error).slice(0, 200)}` };
875
+ }
876
+ },
877
+ });
878
+ /**
879
+ * `/chapters-repair` — the operator door of the repair engine (#60). Read-only unless `apply` is
880
+ * named, and `apply` still cannot delete or rewrite anything: damage is SUPERSEDED under a new
881
+ * number, bad files are COPIED to `.repair-quarantine/`, and every run is journalled so `restore`
882
+ * can walk it backwards (docs/architecture.md § Repair).
883
+ *
884
+ * It repairs the sessions of THIS session's project — found by reading each registry row's durable
885
+ * header through `readDurableSession`, the same payload-bearing seam door 5 uses (#58). Rows whose
886
+ * log cannot be read are reported as unrecoverable rather than quietly skipped, because "nothing to
887
+ * repair" and "cannot repair" are different sentences.
888
+ */
889
+ const repairCommand = {
890
+ name: 'chapters-repair',
891
+ description: 'Audit the archive against the durable log; repair what the registry lost. Read-only by default; never deletes, never rewrites a chapter body.',
892
+ input: { hint: '[audit | dry-run | apply <all|hollow|gaps|citations|bookkeeping|files> | status | journal [id] | restore <journal-id>]' },
893
+ async handler(invocation) {
894
+ const agent = invocation.agent;
895
+ if (agent?.session === undefined)
896
+ return { kind: 'error', text: 'chapters-repair: no session context for this invocation' };
897
+ const cwd0 = agent.session.header?.cwd ?? '';
898
+ if (cwd0 === '')
899
+ return { kind: 'error', text: 'chapters-repair: this session has no workspace cwd — no store to inspect' };
900
+ const parts = (invocation.rawInput ?? '').trim().split(/\s+/).filter((x) => x.length > 0);
901
+ const verb = parts[0] ?? 'audit';
902
+ const get = (name) => { try {
903
+ return ctx.get?.(name);
904
+ }
905
+ catch {
906
+ return undefined;
907
+ } };
908
+ // targets: rows of this project, proven by their OWN durable header (never a path guess)
909
+ const ids = [];
910
+ for (const [id] of store.sessions()) {
911
+ const view = await readDurableSession(get, id);
912
+ if (view?.header.cwd !== undefined && path.resolve(view.header.cwd) === path.resolve(cwd0))
913
+ ids.push(id);
914
+ }
915
+ if (ids.length === 0)
916
+ return { kind: 'error', text: `chapters-repair: no session row under ${cwd0} has a readable durable log — nothing to audit` };
917
+ // roots this registry claims, so sibling session trees are not reported as litter
918
+ const roots = (await Promise.all(ids.map(async (id) => (await store.get(id)).rootSession)));
919
+ if (verb === 'status' || verb === 'journal') {
920
+ const lines = [];
921
+ for (const id of ids) {
922
+ const st = await store.get(id);
923
+ const dir = path.join(cwd0, config.artifactStoreRoot, st.rootSession, 'repairs');
924
+ if (!fs.existsSync(dir))
925
+ continue;
926
+ for (const f of fs.readdirSync(dir).filter((x) => x.endsWith('.json'))) {
927
+ const j = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
928
+ lines.push(`${j.id} ${id.slice(0, 14)}… ${j.completedAt === null ? 'UNFINISHED' : 'done'} ${j.findingsBefore}→${j.findingsAfter} findings ${j.entries.length} move(s)`);
929
+ }
930
+ }
931
+ if (verb === 'journal' && parts[1] !== undefined) {
932
+ const hit = lines.find((l) => l.startsWith(parts[1]));
933
+ return hit !== undefined ? { kind: 'success', text: hit } : { kind: 'error', text: `no journal ${parts[1]} in this project` };
934
+ }
935
+ return { kind: 'success', text: lines.length > 0 ? lines.join('\n') : 'no repair journals yet in this project' };
936
+ }
937
+ if (verb === 'restore') {
938
+ const jid = parts[1];
939
+ if (jid === undefined)
940
+ return { kind: 'error', text: 'restore needs a journal id (see `/chapters-repair status`)' };
941
+ const results = [];
942
+ const outcomes2 = [];
943
+ for (const id of ids) {
944
+ const r = await restoreRepair({ store, readLog: async (sid) => (await readDurableSession(get, sid))?.events ?? null, cwd: cwd0, config, log: () => { }, warn: (m) => results.push(`! ${m}`) }, jid, id);
945
+ outcomes2.push(r);
946
+ if (r.undone > 0 || r.problems.length > 0)
947
+ results.push(`${id.slice(0, 14)}…: ${r.undone} undone${r.problems.length > 0 ? `; ${r.problems.join('; ')}` : ''}`);
948
+ }
949
+ const anyUndone = outcomes2.some((o) => o.undone > 0);
950
+ return {
951
+ kind: anyUndone ? 'success' : 'error',
952
+ text: results.length > 0 ? results.join('\n') : `journal ${jid} matched no session row here — files were NOT touched`,
953
+ };
954
+ }
955
+ const dryRun = verb !== 'apply';
956
+ // ONE resolver, shared with the offline script (src/repair.ts). An unknown name is a REFUSAL:
957
+ // the first version fell through to `[which]`, matched nothing, and reported success while doing
958
+ // nothing — the silent no-op is the defect class this issue exists to end (#60).
959
+ const resolved = resolveRepairClasses((parts[1] ?? 'all').split(','));
960
+ if (resolved.unknown.length > 0) {
961
+ return { kind: 'error', text: `chapters-repair: unknown class(es) ${resolved.unknown.join(', ')}\n known: ${Object.keys(REPAIR_CLASS_ALIASES).join(', ')} — nothing was applied` };
962
+ }
963
+ const classes = resolved.classes;
964
+ const outcomes = [];
965
+ let actionable = 0;
966
+ for (const id of ids) {
967
+ const view = await readDurableSession(get, id);
968
+ if (view === undefined)
969
+ continue;
970
+ const out = await runRepair({ store, readLog: async (sid) => (await readDurableSession(get, sid))?.events ?? null, cwd: cwd0, config, knownRoots: roots, log: (m) => outcomes.push(`+ ${m}`), warn: (m) => outcomes.push(`! ${m}`) }, { sessionId: id, classes: dryRun ? 'all' : classes, dryRun });
971
+ actionable += out.findingsBefore.filter((f) => f.remedy !== 'report-only').length;
972
+ if (out.findingsBefore.length > 0 || out.applied.length > 0) {
973
+ const by = out.findingsBefore.reduce((m, f) => ({ ...m, [f.class]: (m[f.class] ?? 0) + 1 }), {});
974
+ outcomes.push(`${id.slice(0, 14)}…: ${out.findingsBefore.length} finding(s) ${JSON.stringify(by)}${out.blindSpots.length > 0 ? ` blind to ${out.blindSpots.join(',')}` : ''}`);
975
+ for (const r of out.refused)
976
+ outcomes.push(` refused: ${r}`);
977
+ if (!dryRun)
978
+ outcomes.push(` → ${out.applied.length} move(s) journalled ${out.journalId}; ${out.findingsAfter.length} finding(s) remain`);
979
+ }
980
+ }
981
+ const head = dryRun
982
+ ? `chapters-repair ${verb === 'audit' ? 'audit' : 'dry-run'} — ${ids.length} session(s): ${actionable} actionable finding(s). NOTHING WRITTEN.`
983
+ : `chapters-repair applied to ${ids.length} session(s) — see the per-session counts below.`;
984
+ const tail = dryRun
985
+ ? '\napply: /chapters-repair apply all (supersede under a new number; copy bad files to .repair-quarantine/; journal everything)'
986
+ : '';
987
+ return { kind: 'success', text: [head, ...outcomes, tail].join('\n') };
988
+ },
989
+ };
990
+ return { segment: chaptersSegment, chaptersContinue, chaptersFork, chaptersSearch, chaptersArtifact, chaptersRulePropose, chaptersRules, forkCommand, continueCommand, saveCommand, repairCommand };
743
991
  }
744
992
  export function registerChaptersTools(ctx, store, config) {
745
993
  const built = buildChaptersTools(ctx, store, config);
@@ -749,6 +997,7 @@ export function registerChaptersTools(ctx, store, config) {
749
997
  const disposeSearch = ctx.tools.register(built.chaptersSearch);
750
998
  const disposeArtifact = ctx.tools.register(built.chaptersArtifact);
751
999
  const disposeRulePropose = ctx.tools.register(built.chaptersRulePropose);
1000
+ const disposeRules = ctx.tools.register(built.chaptersRules);
752
1001
  let disposeCommand = null;
753
1002
  const commands = ctx.get?.('commands');
754
1003
  if (commands?.register !== undefined) {
@@ -757,7 +1006,7 @@ export function registerChaptersTools(ctx, store, config) {
757
1006
  // that overwrote `disposeCommand` would leak the first registration and unregister
758
1007
  // only whichever door was added last.
759
1008
  const commandDisposers = [];
760
- for (const def of [built.forkCommand, built.continueCommand, built.saveCommand]) {
1009
+ for (const def of [built.forkCommand, built.continueCommand, built.saveCommand, built.repairCommand]) {
761
1010
  const disposer = commands.register(def);
762
1011
  if (typeof disposer === 'function')
763
1012
  commandDisposers.push(disposer);
@@ -780,6 +1029,7 @@ export function registerChaptersTools(ctx, store, config) {
780
1029
  disposeSearch();
781
1030
  disposeArtifact();
782
1031
  disposeRulePropose();
1032
+ disposeRules();
783
1033
  disposeCommand?.();
784
1034
  };
785
1035
  }