@fractalsynapse/dsh-chapters 0.0.0-stage → 0.2.21

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 (195) hide show
  1. package/LICENSE +14 -0
  2. package/README.md +76 -2
  3. package/cordis.patch.yml +363 -0
  4. package/docs/architecture.md +692 -0
  5. package/docs/autorun-guide.md +248 -0
  6. package/docs/backlog.md +2022 -0
  7. package/docs/contract.md +938 -0
  8. package/docs/development.md +990 -0
  9. package/docs/host-compaction-seam.md +225 -0
  10. package/docs/host-protected-head-ask.md +80 -0
  11. package/docs/knowledge-repo.md +1332 -0
  12. package/docs/plans-carriage.md +571 -0
  13. package/docs/project-state.md +642 -0
  14. package/docs/provider.md +98 -0
  15. package/docs/superpowers/plans/2026-10-17-chapters-save.md +198 -0
  16. package/docs/superpowers/plans/2026-11-02-chapter-removal.md +151 -0
  17. package/docs/superpowers/specs/2026-11-02-chapter-removal-design.md +115 -0
  18. package/docs/verify.md +561 -0
  19. package/lib/archive.d.ts +161 -0
  20. package/lib/archive.js +229 -0
  21. package/lib/archive.js.map +1 -0
  22. package/lib/arrival.d.ts +23 -0
  23. package/lib/arrival.js +125 -0
  24. package/lib/arrival.js.map +1 -0
  25. package/lib/artifact-query.d.ts +62 -0
  26. package/lib/artifact-query.js +187 -0
  27. package/lib/artifact-query.js.map +1 -0
  28. package/lib/attach.d.ts +46 -0
  29. package/lib/attach.js +120 -0
  30. package/lib/attach.js.map +1 -0
  31. package/lib/auto-save.d.ts +145 -0
  32. package/lib/auto-save.js +334 -0
  33. package/lib/auto-save.js.map +1 -0
  34. package/lib/aux-message.d.ts +17 -0
  35. package/lib/aux-message.js +20 -0
  36. package/lib/aux-message.js.map +1 -0
  37. package/lib/client.js +1419 -0
  38. package/lib/client.js.map +1 -0
  39. package/lib/codegraph.d.ts +76 -0
  40. package/lib/codegraph.js +190 -0
  41. package/lib/codegraph.js.map +1 -0
  42. package/lib/commands.d.ts +52 -0
  43. package/lib/commands.js +946 -0
  44. package/lib/commands.js.map +1 -0
  45. package/lib/compose.d.ts +30 -0
  46. package/lib/compose.js +185 -0
  47. package/lib/compose.js.map +1 -0
  48. package/lib/continue-core.d.ts +245 -0
  49. package/lib/continue-core.js +405 -0
  50. package/lib/continue-core.js.map +1 -0
  51. package/lib/diag.d.ts +55 -0
  52. package/lib/diag.js +186 -0
  53. package/lib/diag.js.map +1 -0
  54. package/lib/engine-core.d.ts +301 -0
  55. package/lib/engine-core.js +531 -0
  56. package/lib/engine-core.js.map +1 -0
  57. package/lib/engine-note.d.ts +35 -0
  58. package/lib/engine-note.js +32 -0
  59. package/lib/engine-note.js.map +1 -0
  60. package/lib/engine.d.ts +329 -0
  61. package/lib/engine.js +1179 -0
  62. package/lib/engine.js.map +1 -0
  63. package/lib/enrich-queue.d.ts +66 -0
  64. package/lib/enrich-queue.js +126 -0
  65. package/lib/enrich-queue.js.map +1 -0
  66. package/lib/enrich-store.d.ts +31 -0
  67. package/lib/enrich-store.js +103 -0
  68. package/lib/enrich-store.js.map +1 -0
  69. package/lib/enrich-wire.d.ts +163 -0
  70. package/lib/enrich-wire.js +353 -0
  71. package/lib/enrich-wire.js.map +1 -0
  72. package/lib/enrich.d.ts +102 -0
  73. package/lib/enrich.js +267 -0
  74. package/lib/enrich.js.map +1 -0
  75. package/lib/gitops.d.ts +107 -0
  76. package/lib/gitops.js +539 -0
  77. package/lib/gitops.js.map +1 -0
  78. package/lib/index.d.ts +241 -0
  79. package/lib/index.js +1162 -0
  80. package/lib/index.js.map +1 -0
  81. package/lib/indexing.d.ts +98 -0
  82. package/lib/indexing.js +223 -0
  83. package/lib/indexing.js.map +1 -0
  84. package/lib/injections.d.ts +90 -0
  85. package/lib/injections.js +165 -0
  86. package/lib/injections.js.map +1 -0
  87. package/lib/last-resort.d.ts +24 -0
  88. package/lib/last-resort.js +27 -0
  89. package/lib/last-resort.js.map +1 -0
  90. package/lib/log-shape.d.ts +38 -0
  91. package/lib/log-shape.js +93 -0
  92. package/lib/log-shape.js.map +1 -0
  93. package/lib/models-command.d.ts +74 -0
  94. package/lib/models-command.js +100 -0
  95. package/lib/models-command.js.map +1 -0
  96. package/lib/notice.d.ts +74 -0
  97. package/lib/notice.js +92 -0
  98. package/lib/notice.js.map +1 -0
  99. package/lib/plans.d.ts +154 -0
  100. package/lib/plans.js +315 -0
  101. package/lib/plans.js.map +1 -0
  102. package/lib/preset.d.ts +186 -0
  103. package/lib/preset.js +212 -0
  104. package/lib/preset.js.map +1 -0
  105. package/lib/prompt-keys.d.ts +7 -0
  106. package/lib/prompt-keys.js +8 -0
  107. package/lib/prompt-keys.js.map +1 -0
  108. package/lib/prompt-loop.d.ts +73 -0
  109. package/lib/prompt-loop.js +147 -0
  110. package/lib/prompt-loop.js.map +1 -0
  111. package/lib/prompt-push.d.ts +47 -0
  112. package/lib/prompt-push.js +115 -0
  113. package/lib/prompt-push.js.map +1 -0
  114. package/lib/prompt-runner.d.ts +48 -0
  115. package/lib/prompt-runner.js +90 -0
  116. package/lib/prompt-runner.js.map +1 -0
  117. package/lib/prompt-state.d.ts +142 -0
  118. package/lib/prompt-state.js +101 -0
  119. package/lib/prompt-state.js.map +1 -0
  120. package/lib/prompt.d.ts +270 -0
  121. package/lib/prompt.js +408 -0
  122. package/lib/prompt.js.map +1 -0
  123. package/lib/provider.d.ts +44 -0
  124. package/lib/provider.js +66 -0
  125. package/lib/provider.js.map +1 -0
  126. package/lib/redact.d.ts +24 -0
  127. package/lib/redact.js +85 -0
  128. package/lib/redact.js.map +1 -0
  129. package/lib/registry.d.ts +187 -0
  130. package/lib/registry.js +235 -0
  131. package/lib/registry.js.map +1 -0
  132. package/lib/removal.d.ts +159 -0
  133. package/lib/removal.js +556 -0
  134. package/lib/removal.js.map +1 -0
  135. package/lib/render.d.ts +97 -0
  136. package/lib/render.js +530 -0
  137. package/lib/render.js.map +1 -0
  138. package/lib/repair-run.d.ts +76 -0
  139. package/lib/repair-run.js +506 -0
  140. package/lib/repair-run.js.map +1 -0
  141. package/lib/repair.d.ts +208 -0
  142. package/lib/repair.js +357 -0
  143. package/lib/repair.js.map +1 -0
  144. package/lib/repo.d.ts +78 -0
  145. package/lib/repo.js +184 -0
  146. package/lib/repo.js.map +1 -0
  147. package/lib/rules-commands.d.ts +46 -0
  148. package/lib/rules-commands.js +379 -0
  149. package/lib/rules-commands.js.map +1 -0
  150. package/lib/rules.d.ts +267 -0
  151. package/lib/rules.js +478 -0
  152. package/lib/rules.js.map +1 -0
  153. package/lib/save-core.d.ts +72 -0
  154. package/lib/save-core.js +220 -0
  155. package/lib/save-core.js.map +1 -0
  156. package/lib/search.d.ts +112 -0
  157. package/lib/search.js +282 -0
  158. package/lib/search.js.map +1 -0
  159. package/lib/session-rules.d.ts +222 -0
  160. package/lib/session-rules.js +454 -0
  161. package/lib/session-rules.js.map +1 -0
  162. package/lib/signature.d.ts +45 -0
  163. package/lib/signature.js +199 -0
  164. package/lib/signature.js.map +1 -0
  165. package/lib/store.d.ts +156 -0
  166. package/lib/store.js +368 -0
  167. package/lib/store.js.map +1 -0
  168. package/lib/sync.d.ts +220 -0
  169. package/lib/sync.js +810 -0
  170. package/lib/sync.js.map +1 -0
  171. package/lib/tools.d.ts +113 -0
  172. package/lib/tools.js +1090 -0
  173. package/lib/tools.js.map +1 -0
  174. package/lib/treedx/client.d.ts +57 -0
  175. package/lib/treedx/client.js +82 -0
  176. package/lib/treedx/client.js.map +1 -0
  177. package/lib/treedx/provider.d.ts +39 -0
  178. package/lib/treedx/provider.js +479 -0
  179. package/lib/treedx/provider.js.map +1 -0
  180. package/lib/treedx/state.d.ts +27 -0
  181. package/lib/treedx/state.js +65 -0
  182. package/lib/treedx/state.js.map +1 -0
  183. package/lib/types.d.ts +210 -0
  184. package/lib/types.js +31 -0
  185. package/lib/types.js.map +1 -0
  186. package/lib/version.d.ts +9 -0
  187. package/lib/version.js +10 -0
  188. package/lib/version.js.map +1 -0
  189. package/lib/vocabulary.d.ts +41 -0
  190. package/lib/vocabulary.js +118 -0
  191. package/lib/vocabulary.js.map +1 -0
  192. package/lib/warnings.d.ts +10 -0
  193. package/lib/warnings.js +18 -0
  194. package/lib/warnings.js.map +1 -0
  195. package/package.json +151 -3
package/lib/engine.js ADDED
@@ -0,0 +1,1179 @@
1
+ /**
2
+ * ChaptersCompactionEngine — the cordis-facing adapter over the pure
3
+ * engine-core logic. Mounted INSIDE a preset realm's compaction group (never
4
+ * the host plane beside `basic`: two engines double-fire across planes,
5
+ * FINDINGS § Phase 0b), it overrides exactly the sanctioned hook plus the two
6
+ * entry points whose results the finalizer needs.
7
+ *
8
+ * Everything decision-shaped lives in engine-core.ts, where it is
9
+ * harness-free testable. This file is glue: service resolution, durable
10
+ * reads/writes, and the never-break-the-caller containment the base's own
11
+ * listeners already model.
12
+ */
13
+ import { BasicCompactionEngine } from '@deepseek-ai/dsh-compaction-basic';
14
+ import { extractSignature, enrichSignatureWithCodegraph, turnSpanOf } from "./signature.js";
15
+ import { appendCollection } from "./registry.js";
16
+ import { compactionNote } from "./engine-note.js";
17
+ import { configureDiagnosticsCap, diagnosticNote, diagnosticWarn, ensureDiagnosticsDefault } from "./diag.js";
18
+ import { makeChildArchiveListener } from "./auto-save.js";
19
+ import z from '@deepseek-ai/schemastery';
20
+ import { assertReservationMatchesPlan, CHAPTERS_PROVIDER, DETERMINISTIC_MODEL, buildFinalizedChapters, findOpenCompactionId, planSummarize, reconstructShadowedSeqs, scanChaptersSummaries, scanAndFinalize, } from "./engine-core.js";
21
+ import { composeChapters } from "./compose.js";
22
+ import { carryActivePlan, detectPlans } from "./plans.js";
23
+ import { isInjectedProse, stripReminderSpans } from "./injections.js";
24
+ import { BlockAssembler } from '@deepseek-ai/dsh-llm';
25
+ import { auxPromptMessage } from "./aux-message.js";
26
+ import { applyArrivalStubs } from "./arrival.js";
27
+ import { lastResortCeilingTokens } from "./last-resort.js";
28
+ import { extractPlot } from "./engine-core.js";
29
+ import { appendChapters, isFinalized, markFinalized, rememberPlan, reserve } from "./registry.js";
30
+ import { acquireChapterStore, makeAllocator, makeArchiveFs, resolveStoreRoot } from "./store.js";
31
+ import { createSyncScheduler, makeCollectionsReader, projectForCwd, readToken, DEFAULT_CLONE_DIR } from "./sync.js";
32
+ import { removedGuard } from "./removal.js";
33
+ import { registerProvider } from "./provider.js";
34
+ import { createTreedxProvider } from "./treedx/provider.js";
35
+ import { createEnrichWiring } from "./enrich-wire.js";
36
+ import { writeArchive } from "./archive.js";
37
+ import { ENRICH_MAX_TOKENS_DEFAULT } from "./enrich.js";
38
+ import { hostname } from 'node:os';
39
+ import path from 'node:path';
40
+ import { captureMinedRules, makeHeaderParentReader, makeRuleCaptureListener, renderRulesBlock } from "./session-rules.js";
41
+ /**
42
+ * Per-turn deterministic signature listener (knowledge-repo.md §4.1), as a
43
+ * plain factory so the LOGIC is unit-testable against the real host Session
44
+ * shape without a full cordis Service context. Never-break containment: the
45
+ * handler reports through `warn`, it never throws into the session.
46
+ *
47
+ * r28 found the load-bearing fact: the host Session is a CLASS exposing
48
+ * snapshotEvents() — a `.events` property does not exist, and the first cut
49
+ * read `.events`, no-op'd silently in every real boot, and passed its L0
50
+ * test because the test stubbed the shape that does not exist.
51
+ */
52
+ export function makeSignatureListener(deps) {
53
+ // Per-session promise chain: the store write is a read-modify-append, so
54
+ // two turn-ends landing the same tick must serialize or the second put
55
+ // clobbers the first's collection (real sessions are minutes apart, but
56
+ // burst steer/resume exists; the queue makes the listener honest anyway).
57
+ const chains = new Map();
58
+ return (session, event) => {
59
+ try {
60
+ if (event?.type !== 'turn/end')
61
+ return;
62
+ const id = session?.id;
63
+ const events = session?.snapshotEvents?.();
64
+ if (id === undefined || events === undefined)
65
+ return;
66
+ const endSeq = event.seq;
67
+ if (endSeq === undefined)
68
+ return;
69
+ const span = turnSpanOf(events, endSeq);
70
+ if (span.length === 0)
71
+ return;
72
+ const sig = extractSignature(span);
73
+ deps.onCollected?.(id, sig);
74
+ // CodeGraph symbol verification is async and fire-and-forget. The cwd
75
+ // comes from the session header; missing cwd or missing CLI → silent no-op.
76
+ const cwd = session?.header?.cwd;
77
+ // #61: the compaction realm is where the SAIP finalization failures happened, and all three left
78
+ // zero trace because the sink had to be configured before it existed. Resolve it from the session
79
+ // that is compacting, first one wins.
80
+ if (cwd !== undefined && deps.storeRoot !== undefined) {
81
+ // Absolute stays absolute. `path.join(cwd, artifactStoreRoot)` silently nested an ABSOLUTE store root
82
+ // under the workspace (`/wsp/var/e2e-home-41739/chapters/…`), so on the rig — where #61 pins
83
+ // `artifactStoreRoot` to an absolute per-port path to keep test chapters out of the repo's store — the
84
+ // diagnostics file was written to a path nobody looked at. The failure this repo exists to end: a
85
+ // mechanism that detects and then loses the evidence. Same dialect rule as `toStoredPath` (#69).
86
+ ensureDiagnosticsDefault(path.join(path.isAbsolute(deps.storeRoot) ? deps.storeRoot : path.join(cwd, deps.storeRoot), 'diagnostics.log'));
87
+ }
88
+ const enrichPromise = cwd !== undefined
89
+ ? enrichSignatureWithCodegraph(sig, span, deps.subprocess, cwd).catch(() => { })
90
+ : Promise.resolve();
91
+ // One serialized section (#59): the signature listener used to get→append→put with the whole
92
+ // row snapshot, so a turn that landed during another door's await could erase that door's
93
+ // committed state. The lost signature is what dropped a COMPLETED turn out of the archive.
94
+ const job = () => enrichPromise.then(() => deps.store()).then(({ store }) => store.mutate(id, (st) => appendCollection(st, sig))).then(() => undefined).catch(deps.warn);
95
+ const prev = chains.get(id);
96
+ const next = prev === undefined ? job() : prev.then(job);
97
+ chains.set(id, next);
98
+ void next.finally(() => { if (chains.get(id) === next)
99
+ chains.delete(id); });
100
+ }
101
+ catch (error) {
102
+ deps.warn(error);
103
+ }
104
+ };
105
+ }
106
+ /**
107
+ * Pull-on-first-turn (§5.1) as a plain factory, same testability discipline
108
+ * as the signature listener. Process-scoped "first turn seen" — a resumed
109
+ * old session pulling once more is harmless and bounded.
110
+ */
111
+ export function makeFirstTurnPullListener(deps) {
112
+ const pulled = new Set();
113
+ return (session, event) => {
114
+ try {
115
+ if (event?.type !== 'turn/start')
116
+ return;
117
+ const id = session?.id;
118
+ const cwd = session?.header?.cwd;
119
+ if (id === undefined || cwd === undefined || pulled.has(id))
120
+ return;
121
+ pulled.add(id);
122
+ void deps.pull(cwd).catch(() => undefined);
123
+ }
124
+ catch {
125
+ // never break the session path (§5.3)
126
+ }
127
+ };
128
+ }
129
+ export function carryOpenRequest(parts, messages, capChars) {
130
+ // ONE predicate for "is this injected machine prose" — `src/injections.ts`, the same screen the
131
+ // archive body and the title chooser use. A second list here is the #59 bug re-made: it would lag
132
+ // the first, and a drift row exists precisely to catch that (it caught this, minutes after I wrote it).
133
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
134
+ const m = messages[i];
135
+ if (m?.role !== 'user')
136
+ continue;
137
+ const c = m.content;
138
+ const text = typeof c === 'string' ? c
139
+ : Array.isArray(c) ? c.filter((b) => b?.type === 'text').map((b) => b.text ?? '').join('\n') : '';
140
+ const t = text.trim();
141
+ if (t === '' || isInjectedProse(t))
142
+ continue;
143
+ const clipped = t.length > capChars;
144
+ const body = clipped
145
+ ? `${t.slice(0, capChars)}\n[carried in part: ${String(t.length)} characters total, ${String(capChars)} included — the whole turn stays in the durable log]`
146
+ : t;
147
+ return [...parts, { type: 'text', text: `OPEN REQUEST (verbatim; this relief shadows the turn that asked it):\n"""\n${body}\n"""` }];
148
+ }
149
+ return [...parts];
150
+ }
151
+ /**
152
+ * Is this the base engine refusing to run for a CONFIG reason rather than a provider failing? (#140)
153
+ *
154
+ * `dsh-compaction-basic@0.2.0-rc.2` validates per routed target:
155
+ * `pressureBudgetTokens = contextWindow − reservedCompletionTokens − policy.headroomTokens`, and throws its
156
+ * `TargetPressureConfigError` when that is ≤ 0. Its default `headroomTokens` is 65,536 — a reservation for a routed
157
+ * summarize call, which THIS engine never makes: `summarize()` is overridden and deterministic (src/engine.ts:664).
158
+ * On any model whose window is under that default (32K is this plugin's target hardware) the exception otherwise
159
+ * escapes `compactIfNeeded` forever, so relief is indistinguishable from a crash.
160
+ *
161
+ * The class is not re-exported by the bundle's types, so the identity is structural: the error carries the target key
162
+ * it validated and a message prefixed with the package's own labels. Provider failures match neither and still
163
+ * propagate, which is the asymmetry the row pins.
164
+ */
165
+ function isPressureConfigRejection(error) {
166
+ const e = error;
167
+ if (e === null || e === undefined)
168
+ return false;
169
+ const named = typeof e.name === 'string' && e.name.includes('TargetPressureConfigError');
170
+ const keyed = typeof e.targetKey === 'string' && e.targetKey.length > 0;
171
+ const labelled = typeof e.message === 'string' && /compaction-basic:|BasicCompactionConfig/.test(e.message);
172
+ return (keyed && labelled) || (named && labelled);
173
+ }
174
+ export class ChaptersCompactionEngine extends BasicCompactionEngine {
175
+ // Base inject is ['llm','tokenMeter','sessions']; subclassing INHERITS statics,
176
+ // so re-declare with what the chapter flow adds (r12's crash, FINDINGS).
177
+ static inject = ['llm', 'tokenMeter', 'sessions', 'storageDomain'];
178
+ static Config = z.object({
179
+ thresholdRatio: z.number(),
180
+ retainRatio: z.number(),
181
+ retainTokens: z.number().step(1).min(0),
182
+ // #140: the base's own default is 65,536 (`headroomTokens = config.headroomTokens ?? 65536`), which is a
183
+ // reservation for a routed summarize call. This engine overrides summarize() and routes nothing, so that
184
+ // default makes the pressure budget negative on any window under it — every 32K model, no relief at all.
185
+ // Declared here so the key survives the Loader and reaches super's resolveConfig.
186
+ headroomTokens: z.number().step(1).min(0),
187
+ summarizationProvider: z.string(),
188
+ summarizationModel: z.string(),
189
+ maxTokens: z.number().step(1).min(1),
190
+ compactionRetries: z.number().step(1).min(0),
191
+ maxOverflowRetries: z.number().step(1).min(0),
192
+ // Mirrors the base's modelPolicy shape (compaction-basic/src/index.ts:79-90)
193
+ // exactly: a narrower schema here could strip per-model ratios before
194
+ // super's resolveConfig sees them. Stage 3 probe asserts survival.
195
+ modelPolicies: z.array(z.object({
196
+ provider: z.string().required(),
197
+ model: z.string().required(),
198
+ thresholdRatio: z.number(),
199
+ retainRatio: z.number(),
200
+ retainTokens: z.number().step(1).min(0),
201
+ headroomTokens: z.number().step(1).min(0), // #140: mirror the base's modelPolicy exactly (see above)
202
+ summarizationProvider: z.string(),
203
+ summarizationModel: z.string(),
204
+ maxTokens: z.number().step(1).min(1),
205
+ compactionRetries: z.number().step(1).min(0),
206
+ maxOverflowRetries: z.number().step(1).min(0),
207
+ })),
208
+ auto: z.boolean(),
209
+ artifactStoreRoot: z.string(),
210
+ knowledgeMirrorDir: z.string().default('.dsh-knowledge'),
211
+ openRequestCarryMaxChars: z.number().step(1).min(200).default(4000),
212
+ /** #78 A: the last-resort deferral switch. Operator-visible, default on. */
213
+ lastResortArtifact: z.boolean().default(true),
214
+ /** #95 follow-up: diagnostics sink cap, bytes. 0 = uncapped. */
215
+ diagnosticsMaxBytes: z.number().step(1).min(0).default(8388608),
216
+ chapterTokenTarget: z.number().step(1).min(1),
217
+ toolResultDeferFloorTokens: z.number().step(1).min(0),
218
+ // The composition knobs ride the same row so the engine path and the fork
219
+ // path honor one configuration (found undeclared 2026-09-18: destructured
220
+ // by the constructor, stripped by the schema).
221
+ mergeThreshold: z.number().default(0.3),
222
+ chapterLimit: z.number().step(1).min(1).default(8000),
223
+ syncDebounceMs: z.number().step(1).min(0).default(30000),
224
+ toolResultArtifactTokens: z.number().step(1).min(256).default(8000),
225
+ // #61 Amendment A — the seventh archive moment. A `ch-*` continuation child has origin `user`, so
226
+ // door 5 never sees it: the field case archived NOTHING across 2,363 events because no moment was
227
+ // watching. Save-only, fires at turn/end, thresholds are config and never literals.
228
+ childAutoArchive: z.boolean().default(true),
229
+ childAutoArchiveMinTurns: z.number().step(1).min(1).default(4),
230
+ elicitedPlot: z.boolean().default(true),
231
+ harnessId: z.string().default(''),
232
+ sessionRulesEnabled: z.boolean().default(true),
233
+ rulesMarkerPrefix: z.string().default('ADD RULE:'),
234
+ coreRulesBudgetTokens: z.number().step(1).min(1).default(1200),
235
+ projectRulesDisplayCap: z.number().step(1).min(0).default(12),
236
+ sessionRulesDisplayCap: z.number().step(1).min(0).default(12),
237
+ rulesRelevance: z.boolean().default(true),
238
+ rulesEnrichment: z.boolean().default(true),
239
+ rulesMinedPerChapter: z.number().step(1).min(0).default(3),
240
+ rulesAutoActivate: z.boolean().default(true),
241
+ rulesModel: z.string().default(''),
242
+ enrichmentEnabled: z.boolean().default(true),
243
+ enrichmentModel: z.string().default(''),
244
+ enrichmentTrigger: z.string().default('both'),
245
+ enrichmentIdleMs: z.number().step(1).min(0).default(60000),
246
+ enrichmentBatchCap: z.number().step(1).min(1).default(5),
247
+ enrichmentMaxTokens: z.number().step(1).min(400).default(ENRICH_MAX_TOKENS_DEFAULT),
248
+ vocabApply: z.boolean().default(false),
249
+ vocabCoMin: z.number().step(1).min(2).default(3),
250
+ vocabOverlapMin: z.number().default(0.5),
251
+ // §15 amendment: TreeDX transport tunables ride the same row so both
252
+ // planes sync through identically-configured providers.
253
+ treedxFetchTimeoutMs: z.number().step(1).min(1000).default(15000),
254
+ treedxWorkspaceTtlSeconds: z.number().step(1).min(30).default(900),
255
+ treedxLeaseRetries: z.number().step(1).min(0).default(3),
256
+ treedxLeaseRetryDelayMs: z.number().step(1).min(0).default(1000),
257
+ });
258
+ chaptersConfig;
259
+ /** #50 §7.5/§7.3: capture + rendering numbers, host plane mirrored in. */
260
+ ruleCfg = { harnessId: '', captureEnabled: false, markerPrefix: 'ADD RULE:', budgetTokens: 1200, projectCap: 12, sessionCap: 12, relevance: true, mine: true, mineCap: 3, autoActivate: true, model: '' };
261
+ storePromise = null;
262
+ /** #52: the delegation-edge reader, built lazily because a field initializer
263
+ * cannot see the constructor parameter and the realm may hold neither service.
264
+ * null = not built yet; undefined = built and unavailable (registry edges only). */
265
+ parentOfReader = null;
266
+ /** The reader the rule-ancestry walk uses. Absent ⇒ delegation contributes no edge. */
267
+ ruleParentOf() {
268
+ if (this.parentOfReader === null) {
269
+ this.parentOfReader = makeHeaderParentReader((name) => {
270
+ try {
271
+ return this.ctx.get?.(name);
272
+ }
273
+ catch {
274
+ return undefined;
275
+ }
276
+ });
277
+ }
278
+ return this.parentOfReader ?? undefined;
279
+ }
280
+ constructor(ctx, config = {}) {
281
+ // The base's resolveConfig rejects unknown keys at runtime (measured r18:
282
+ // `BasicCompactionConfig: unknown key "artifactStoreRoot"`) — the archive's
283
+ // own keys are peeled off and held here; the base receives exactly its
284
+ // documented shape. (The loader does not strip them for us either.)
285
+ const { artifactStoreRoot, knowledgeMirrorDir, noticeIndexCap, openRequestCarryMaxChars, lastResortArtifact, diagnosticsMaxBytes, chapterTokenTarget, toolResultDeferFloorTokens, mergeThreshold, chapterLimit, syncDebounceMs, toolResultArtifactTokens, elicitedPlot, childAutoArchive, childAutoArchiveMinTurns, planInlineChars, callArgArtifactTokens, plansInCheckpoint, planCheckpointChars, harnessId, sessionRulesEnabled, rulesMarkerPrefix, coreRulesBudgetTokens, projectRulesDisplayCap, sessionRulesDisplayCap, rulesRelevance, rulesEnrichment, rulesMinedPerChapter, rulesAutoActivate, rulesModel, enrichmentEnabled, enrichmentModel, enrichmentTrigger, enrichmentIdleMs, enrichmentBatchCap, vocabApply, vocabCoMin, vocabOverlapMin, enrichmentMaxTokens, treedxFetchTimeoutMs, treedxWorkspaceTtlSeconds, treedxLeaseRetries, treedxLeaseRetryDelayMs, ...baseConfig } = config;
286
+ super(ctx, baseConfig);
287
+ // The realm's module copy owns its own provider registry (cordis isolation
288
+ // between planes): register the configured TreeDX transport HERE, exactly
289
+ // as the host plane does in its apply(). Row defaults keep this correct
290
+ // even when a preset omits every treedx key.
291
+ registerProvider(createTreedxProvider({
292
+ fetchTimeoutMs: treedxFetchTimeoutMs ?? 15000,
293
+ workspaceTtlSeconds: treedxWorkspaceTtlSeconds ?? 900,
294
+ leaseRetries: treedxLeaseRetries ?? 3,
295
+ leaseRetryDelayMs: treedxLeaseRetryDelayMs ?? 1000,
296
+ }));
297
+ this.chaptersConfig = {
298
+ artifactStoreRoot: artifactStoreRoot ?? '.dsh-chapters',
299
+ knowledgeMirrorDir: knowledgeMirrorDir ?? DEFAULT_CLONE_DIR,
300
+ // Capped notice index (#56): a fixed-size notice is what makes a boundary-sitting leg capturable at all.
301
+ noticeIndexCap: noticeIndexCap ?? 6,
302
+ openRequestCarryMaxChars: openRequestCarryMaxChars ?? 4000,
303
+ lastResortArtifact: lastResortArtifact ?? true,
304
+ diagnosticsMaxBytes: diagnosticsMaxBytes ?? 8388608,
305
+ chapterTokenTarget: chapterTokenTarget ?? 8000,
306
+ toolResultDeferFloorTokens: toolResultDeferFloorTokens ?? 200,
307
+ mergeThreshold: mergeThreshold ?? 0.3,
308
+ chapterLimit: chapterLimit ?? 8000,
309
+ toolResultArtifactTokens: toolResultArtifactTokens ?? 8000,
310
+ childAutoArchive: childAutoArchive ?? true,
311
+ childAutoArchiveMinTurns: childAutoArchiveMinTurns ?? 4,
312
+ planInlineChars: planInlineChars ?? 900,
313
+ callArgArtifactTokens: callArgArtifactTokens ?? 0,
314
+ plansInCheckpoint: plansInCheckpoint ?? true,
315
+ planCheckpointChars: planCheckpointChars ?? 1600,
316
+ elicitedPlot: elicitedPlot ?? true,
317
+ };
318
+ // The realm row is a second place the cap can legitimately come from (a plugin plane may boot before
319
+ // any session exists). Applying it here means the first door writes with the row's value.
320
+ configureDiagnosticsCap(this.chaptersConfig.diagnosticsMaxBytes);
321
+ this.syncDebounceMs = syncDebounceMs ?? 30000;
322
+ this.vocabCfg = { apply: vocabApply ?? false, coMin: vocabCoMin ?? 3, overlapMin: vocabOverlapMin ?? 0.5 };
323
+ this.enrichCfg = {
324
+ enabled: enrichmentEnabled ?? true,
325
+ model: enrichmentModel ?? '',
326
+ mineRules: rulesEnrichment ?? true,
327
+ mineCap: rulesMinedPerChapter ?? 3,
328
+ rulesAutoActivate: rulesAutoActivate ?? true,
329
+ rulesModel: rulesModel ?? '',
330
+ maxTokens: enrichmentMaxTokens ?? ENRICH_MAX_TOKENS_DEFAULT,
331
+ trigger: (enrichmentTrigger ?? 'both'),
332
+ idleMs: enrichmentIdleMs ?? 60000,
333
+ batchCap: enrichmentBatchCap ?? 5,
334
+ };
335
+ this.ruleCfg = {
336
+ harnessId: harnessId ?? '',
337
+ captureEnabled: sessionRulesEnabled ?? true,
338
+ markerPrefix: rulesMarkerPrefix ?? 'ADD RULE:',
339
+ budgetTokens: coreRulesBudgetTokens ?? 1200,
340
+ projectCap: projectRulesDisplayCap ?? 12,
341
+ sessionCap: sessionRulesDisplayCap ?? 12,
342
+ relevance: rulesRelevance ?? true,
343
+ mine: rulesEnrichment ?? true,
344
+ mineCap: rulesMinedPerChapter ?? 3,
345
+ autoActivate: rulesAutoActivate ?? true,
346
+ model: rulesModel ?? '',
347
+ };
348
+ this.#listenForSignatures();
349
+ this.#listenForRuleCapture();
350
+ this.#listenForFirstTurnPull();
351
+ ctx.logger?.info?.('dsh-chapters: engine constructed for a mount (signature listener live)');
352
+ }
353
+ /**
354
+ * Per-turn deterministic signatures at turn/end (knowledge-repo §4.1):
355
+ * synchronous extraction, async store append, and never-break containment —
356
+ * a signature failure must never touch the compaction path (AGENTS hard
357
+ * rule: the listener reports, it never throws into the session). The base
358
+ * engine's own listeners model the same shape.
359
+ */
360
+ /** The realm's own scheduler (§5): same code as the host plane's; the file
361
+ * lock and debounce keep the two honest against each other. */
362
+ syncDebounceMs = 30000;
363
+ vocabCfg = { apply: false, coMin: 3, overlapMin: 0.5 };
364
+ enrichCfg = { enabled: false, model: '', trigger: 'both', idleMs: 60000, batchCap: 5, mineRules: true, mineCap: 3 };
365
+ enrichPromise = null;
366
+ lastCwd = null;
367
+ lastRoute = null;
368
+ // The window the ADAPTER last reported for this route (#143). Cached rather than re-resolved so a note can carry a
369
+ // measured number; `null` means no model info has been resolved yet, and the note prints `?` — an honest abstention
370
+ // beats a config key that does not exist, which is what this field replaced.
371
+ lastWindowTokens = null;
372
+ schedulerPromise = null;
373
+ #scheduler() {
374
+ this.schedulerPromise ??= this.store().then(({ store }) => createSyncScheduler({
375
+ storeRoot: this.chaptersConfig.artifactStoreRoot,
376
+ cloneDir: DEFAULT_CLONE_DIR,
377
+ debounceMs: this.syncDebounceMs,
378
+ // The realm never AUTO-links (linking is the host command's job); it
379
+ // reads what the project table knows.
380
+ resolveProject: (cwd) => projectForCwd(store.projects(), cwd),
381
+ tokenFor: (cwd, projectKey) => readToken(cwd, this.chaptersConfig.artifactStoreRoot, projectKey),
382
+ collectionsFor: makeCollectionsReader(store.sessions.bind(store), this.chaptersConfig.artifactStoreRoot),
383
+ // #255: rehydration must not resurrect removed chapters; the guard reads the stub rows.
384
+ removedFor: () => removedGuard(store.sessions()),
385
+ vocab: this.vocabCfg,
386
+ // P2: a completed sync pass is the enrichment afterPush idle point.
387
+ onSyncDone: (cwd, ok) => {
388
+ void this.#enrich().then((w) => { w.rememberCwd(cwd); if (ok)
389
+ w.queue.onSyncDone('afterPush'); }).catch(() => undefined);
390
+ },
391
+ }));
392
+ return this.schedulerPromise;
393
+ }
394
+ /** P2 enrichment wiring (realm plane owns auto triggers; route + heartbeat
395
+ * persist through the shared settings table, so the host command surface
396
+ * and /chapters-status read one truth). Lazy; failures degrade silently —
397
+ * the corpus simply stays signatures-only (kill-switch parity). */
398
+ #enrich() {
399
+ this.enrichPromise ??= this.store().then(({ store }) => createEnrichWiring({
400
+ store,
401
+ cwd: () => this.lastCwd ?? undefined,
402
+ config: this.enrichCfg,
403
+ fetch: async (prompt, route) => {
404
+ const r = route ?? this.lastRoute;
405
+ if (r === null || r.provider === '' || r.model === '')
406
+ throw new Error('no auxiliary route yet');
407
+ return await this.#auxComplete(prompt, r, this.enrichCfg.maxTokens ?? ENRICH_MAX_TOKENS_DEFAULT);
408
+ },
409
+ conversationRoute: () => this.lastRoute,
410
+ // §7.5 the sink: the realm plane owns disk, identity and the registry, so it
411
+ // writes what the pass mined. Session rules are LOCAL — nothing is armed on
412
+ // the scheduler here, because the pool never carries them.
413
+ onMinedRules: async ({ sessionId, chapterNumber, humanText, rules, max, autoActivate }) => {
414
+ const cwd = this.lastCwd ?? undefined;
415
+ if (cwd === undefined)
416
+ return { written: 0, stated: 0, inferred: 0, pending: 0 };
417
+ const { store } = await this.store();
418
+ const res = captureMinedRules({
419
+ storeDir: resolveStoreRoot(cwd, this.chaptersConfig.artifactStoreRoot),
420
+ harnessId: (this.ruleCfg.harnessId !== '' ? this.ruleCfg.harnessId : hostname()),
421
+ sessionId,
422
+ records: store.rules(),
423
+ humanText,
424
+ rules,
425
+ maxPerChapter: max,
426
+ chapterNumber,
427
+ autoActivate,
428
+ });
429
+ return { written: res.ids.length, stated: res.stated, inferred: res.inferred, pending: res.pending };
430
+ },
431
+ scheduler: { schedule: (cwd, why) => { void this.#scheduler().then((s) => s.schedule(cwd, why)).catch(() => undefined); } },
432
+ log: (m) => this.ctx.logger?.info?.(`dsh-chapters: ${m}`),
433
+ warn: (m) => this.ctx.logger?.warn?.(`dsh-chapters: ${m}`),
434
+ }));
435
+ return this.enrichPromise;
436
+ }
437
+ /** one tools-free completion (shared plumbing: enrichment + plot). */
438
+ async #auxComplete(prompt, route, maxTokens, signal, sessionId) {
439
+ const llm = this.ctx.llm;
440
+ if (llm === undefined)
441
+ throw new Error('llm absent');
442
+ const assembler = new BlockAssembler();
443
+ for await (const chunk of llm.stream({
444
+ provider: route.provider, model: route.model,
445
+ messages: [auxPromptMessage(prompt)],
446
+ maxTokens,
447
+ ...(sessionId !== undefined ? { sessionId } : {}),
448
+ purpose: 'compaction',
449
+ ...(signal !== undefined ? { signal } : {}),
450
+ }))
451
+ assembler.push(chunk);
452
+ return assembler.blocks().filter((b) => b.type === 'text').map((b) => b.text ?? '').join('');
453
+ }
454
+ /** Capture workspace cwd + routed model at every compaction entry; arm the
455
+ * idle timer (turn activity). Advisory only — never disturbs compaction. */
456
+ #observeRoute(agent) {
457
+ try {
458
+ const session = agent.session;
459
+ if (session === undefined)
460
+ return;
461
+ const cwd = session.header?.cwd;
462
+ if (cwd !== undefined)
463
+ this.lastCwd = cwd;
464
+ const cfg = session.requestHeader?.()?.config;
465
+ if (cfg?.provider !== undefined && cfg?.model !== undefined)
466
+ this.lastRoute = { provider: cfg.provider, model: cfg.model };
467
+ void this.#enrich().then((w) => w.queue.noteActivity()).catch(() => undefined);
468
+ }
469
+ catch { /* observation is advisory */ }
470
+ }
471
+ /** §5.1: a new session pulls before its first turn — the corpus is fresh. */
472
+ #listenForFirstTurnPull() {
473
+ try {
474
+ this.ctx.on('session/event', makeFirstTurnPullListener({
475
+ pull: (cwd) => this.#scheduler().then((s) => s.pullFor(cwd)),
476
+ }));
477
+ }
478
+ catch {
479
+ // L0 ctx without an event bus: same containment rule as signatures.
480
+ }
481
+ }
482
+ #listenForSignatures() {
483
+ try {
484
+ // Lazy subprocess resolution for CodeGraph integration — same pattern as
485
+ // dsh-codegraph. The service may mount after this engine; ctx.get returns
486
+ // undefined when absent, which enrichSignatureWithCodegraph handles as a no-op.
487
+ const subprocess = this.ctx.get?.('subprocess');
488
+ this.ctx.on('session/event', makeSignatureListener({
489
+ store: () => this.store(),
490
+ warn: (error) => { this.ctx.logger?.warn?.(`dsh-chapters: signature capture failed (${String(error)})`); },
491
+ subprocess,
492
+ storeRoot: this.chaptersConfig.artifactStoreRoot,
493
+ onCollected: (id, sig) => {
494
+ this.ctx.logger?.info?.(`dsh-chapters: signature collected for ${id} (${sig.seqs.length} events, ${sig.paths.length} paths, ${sig.size} est tokens)`);
495
+ },
496
+ }));
497
+ // #61: the seventh moment, registered where the other turn/end doors already live. Its own
498
+ // watermark makes a re-run a no-op, and it creates NO session — the mid-step rule stays intact.
499
+ this.ctx.on('session/event', makeChildArchiveListener({
500
+ ctx: { logger: this.ctx.logger },
501
+ // the store opens lazily and once — same accessor the signature listener uses; a row that
502
+ // resolved it per turn would reopen the domain on every turn/end.
503
+ store: () => this.store().then(({ store }) => store),
504
+ wiring: {
505
+ presetId: 'chapters',
506
+ artifactStoreRoot: this.chaptersConfig.artifactStoreRoot,
507
+ chapterTokenTarget: this.chaptersConfig.chapterTokenTarget,
508
+ toolResultDeferFloorTokens: this.chaptersConfig.toolResultDeferFloorTokens,
509
+ mergeThreshold: this.chaptersConfig.mergeThreshold,
510
+ chapterLimit: this.chaptersConfig.chapterLimit,
511
+ scheduler: { schedule: (cwd, reason) => { void this.#scheduler().then((sc) => sc.schedule(cwd, reason)); } },
512
+ },
513
+ enabled: this.chaptersConfig.childAutoArchive ?? true,
514
+ minTurns: this.chaptersConfig.childAutoArchiveMinTurns ?? 4,
515
+ }));
516
+ }
517
+ catch {
518
+ // A non-cordis ctx (L0 construction) has no event bus — signatures are
519
+ // optional there by design; the compaction path is unaffected.
520
+ }
521
+ }
522
+ /**
523
+ * #50 §7.5: capture `ADD RULE:` markers at the SAME `turn/end` boundary the
524
+ * topic signatures use — this extends an existing door, it is not a sixth
525
+ * archive moment. Deterministic and model-free (invariant 1): no marker in the
526
+ * turn costs one string scan and nothing else. Gated off when `harnessId` is
527
+ * absent, because a rule with no author partition is a rule nothing can
528
+ * revoke.
529
+ */
530
+ #listenForRuleCapture() {
531
+ try {
532
+ this.ctx.on('session/event', makeRuleCaptureListener({
533
+ enabled: () => this.ruleCfg.captureEnabled,
534
+ prefix: () => this.ruleCfg.markerPrefix,
535
+ // The realm row's harnessId, or the same default the host plane's config
536
+ // uses (`hostname()`). A custom host-plane override does NOT strand the
537
+ // rule: the directory is only an author partition, and rendering walks
538
+ // every dir under `rules/` and activates by scope+ancestry (§7.5) — the
539
+ // id prefix differs, nothing else.
540
+ harnessId: () => (this.ruleCfg.harnessId !== '' ? this.ruleCfg.harnessId : hostname()),
541
+ storeRoot: (cwd) => resolveStoreRoot(cwd, this.chaptersConfig.artifactStoreRoot),
542
+ store: async () => ({ store: (await this.store()).store }),
543
+ warn: (error) => { this.ctx.logger?.warn?.(`dsh-chapters: session-rule capture failed (${String(error)})`); },
544
+ onCaptured: (sessionId, ids) => {
545
+ this.ctx.logger?.info?.(`dsh-chapters: ${ids.length} session rule(s) captured for ${sessionId} (${ids.join(', ')})`);
546
+ },
547
+ }));
548
+ }
549
+ catch {
550
+ // same containment as signatures: a non-cordis ctx has no event bus
551
+ }
552
+ }
553
+ /** Lazy, once. `storageDomain` is a host-plane singleton the realm resolves;
554
+ * acquire (never bare-open): a mounted tools-plugin may already own the reservation. */
555
+ store() {
556
+ this.storePromise ??= (async () => {
557
+ const sd = this.ctx.get?.('storageDomain');
558
+ if (sd === undefined)
559
+ throw new Error('chapters: storageDomain service absent — cannot archive');
560
+ return acquireChapterStore(sd);
561
+ })();
562
+ return this.storePromise;
563
+ }
564
+ // ------------------------------------------------------------ the sanctioned hook
565
+ async summarize(input, agent, _signal) {
566
+ const session = agent.session;
567
+ const cwd = session.header?.cwd ?? null;
568
+ const cid = findOpenCompactionId(session);
569
+ if (cid === null || cwd === null || input.messages.length === 0) {
570
+ // Degraded but honest: compaction proceeds with a plain deterministic
571
+ // digest; NO chapter path is cited, so nothing can dangle. A distinct
572
+ // provider tag keeps the finalizer's scan from claiming this record.
573
+ return {
574
+ summary: carryOpenRequest([{ type: 'text', text: `Compacted ${input.messages.length} messages (deterministic digest; no chapter: ${cwd === null ? 'no workspace cwd' : cid === null ? 'no open transaction' : 'empty region'}).` }], input.messages, this.chaptersConfig.openRequestCarryMaxChars),
575
+ provider: `${CHAPTERS_PROVIDER}-degraded`,
576
+ model: DETERMINISTIC_MODEL,
577
+ };
578
+ }
579
+ const { store } = await this.store();
580
+ const state = await store.get(session.id);
581
+ // Topic-sequential composition inside AUTO compaction (r29 — the record's
582
+ // §4.2 applied where sessions actually meet pressure, not only at the
583
+ // fork). Reconstruction maps the prepared region back to log seqs; any
584
+ // uncertainty (no Session mapping API, no collections, ambiguous match,
585
+ // thrown anywhere) falls to the legacy single-chapter plan, which is the
586
+ // behavior that has always been correct.
587
+ let composition = null;
588
+ try {
589
+ const seqs = reconstructShadowedSeqs(session, input);
590
+ if (seqs !== null && state.collections.length > 0) {
591
+ const spanStart = seqs[0];
592
+ const spanEnd = seqs[seqs.length - 1];
593
+ const spanEvents = seqs
594
+ .map((seq) => session.eventAt(seq))
595
+ .filter((e) => e !== undefined);
596
+ const composed = composeChapters(spanEvents, spanStart, spanEnd, state.collections, {
597
+ mergeThreshold: this.chaptersConfig.mergeThreshold,
598
+ chapterLimit: this.chaptersConfig.chapterLimit,
599
+ });
600
+ if (composed.chapters.length > 0) {
601
+ const chs = [...composed.chapters];
602
+ // Compaction SHADOWS the whole selected span — unlike the fork, its
603
+ // span need not end on a turn boundary. Absorb head/tail strays so
604
+ // nothing is ever shadowed unarchived.
605
+ if (chs[0].startSeq > spanStart)
606
+ chs[0] = { ...chs[0], startSeq: spanStart };
607
+ const lastIdx = chs.length - 1;
608
+ if (chs[lastIdx].endSeq < spanEnd)
609
+ chs[lastIdx] = { ...chs[lastIdx], endSeq: spanEnd };
610
+ composition = chs;
611
+ }
612
+ }
613
+ }
614
+ catch (error) {
615
+ this.ctx.logger?.warn?.(`dsh-chapters: compaction composition fell back to legacy (${String(error)})`);
616
+ }
617
+ // Plot carriage (architecture.md amendment): the model's own forward-
618
+ // maintained note survives the shadowing; when the agent never wrote one,
619
+ // one BOUNDED elicited call over the region being condensed (tail-heavy,
620
+ // ~8K chars in, ≤150 words out) — approved exception to zero-inference,
621
+ // gated by config. Any failure degrades to no plot; it never breaks the
622
+ // compaction path.
623
+ let plot = extractPlot(input.messages);
624
+ if (plot === null && this.chaptersConfig.elicitedPlot)
625
+ plot = await this.#elicitPlot(agent, input, _signal);
626
+ // The operative plan travels INTO the checkpoint, session-wide and newest-first:
627
+ // "what am I building" is not a property of the region being shadowed, so the
628
+ // carry reads the whole log rather than the span (docs/plans-carriage.md § 9).
629
+ let planCarry = null;
630
+ if (this.chaptersConfig.plansInCheckpoint) {
631
+ try {
632
+ const all = session?.snapshotEvents?.() ?? [];
633
+ planCarry = carryActivePlan(detectPlans(all).plans, { maxChars: this.chaptersConfig.planCheckpointChars });
634
+ }
635
+ catch (error) {
636
+ this.ctx.logger?.warn?.(`dsh-chapters: plan carriage skipped (${String(error)}) — the archive still holds the plan`);
637
+ }
638
+ }
639
+ // #50 §7.6: the rules ride the checkpoint the way the plot and the plan do.
640
+ // Anything that goes wrong here DEGRADES TO NOTHING and is reported on the
641
+ // operator channel — the compaction critical path stays zero-model-token and
642
+ // never fails because of a rules read (§1 invariant 1, §7.3's non-blocking rule).
643
+ let rulesCarry = null;
644
+ try {
645
+ rulesCarry = await this.#rulesForCheckpoint(session);
646
+ }
647
+ catch (error) {
648
+ this.#diagnostic(`dsh-chapters: rule carriage skipped (${String(error)}) — the archive still holds every rule`);
649
+ }
650
+ // #59: everything ABOVE this line (composition, plot elicitation, rules, plan carry) is
651
+ // expensive and may cross a model call; the WRITE re-runs on a fresh row inside one section, so
652
+ // the reservation and manifest can no longer be rewound by a stale whole-row put — and the TOC
653
+ // text we are about to hand the host cites numbers that are durable at this point.
654
+ let tocText = '';
655
+ await store.mutate(session.id, (fresh) => {
656
+ // #69: the realm resolves an ABSOLUTE store root; hand planSummarize the session cwd so the record it
657
+ // mints is workspace-relative like every door's.
658
+ const wcwd = session.header?.cwd;
659
+ const { plan, state: next } = planSummarize(session, fresh, input, { ...this.chaptersConfig, workspaceCwd: wcwd }, reserve, cid, composition, plot, planCarry, rulesCarry);
660
+ const stored = rememberPlan(next, cid, {
661
+ number: plan.numbers[0], path: plan.chapter.path, title: plan.chapter.title, summary: plan.chapter.summary,
662
+ ...(plan.chapters !== undefined ? { chapters: plan.chapters } : {}),
663
+ });
664
+ // The drift check that has existed with zero callers: a manifest whose reservation is not
665
+ // durable (or not the same numbers) means the TOC we are about to emit cites the unreachable.
666
+ assertReservationMatchesPlan(stored, cid, plan);
667
+ tocText = plan.tocText;
668
+ return stored;
669
+ });
670
+ return { summary: carryOpenRequest([{ type: 'text', text: tocText }], input.messages, this.chaptersConfig.openRequestCarryMaxChars), provider: CHAPTERS_PROVIDER, model: DETERMINISTIC_MODEL };
671
+ }
672
+ // ------------------------------------------------------------ entry points (finalize after commit)
673
+ /**
674
+ * Arrival-time artifacting (architecture.md amendment): heads EVERY
675
+ * compaction entry — pre-step pressure, overflow retries, manual compact —
676
+ * so oversized tool results are stubbed at the tail node before the next
677
+ * request composes (the base listener awaits this call before next(),
678
+ * compaction-basic/lib/index.js:799-812). Never throws; degradation is
679
+ * logged and the blob simply stays inline (today's behavior).
680
+ */
681
+ async #elicitPlot(agent, input, signal) {
682
+ try {
683
+ const session = agent.session;
684
+ // tail-heavy flattening: the plan lives in what the model said RECENTLY.
685
+ // Host-injected spans are screened OUT of the excerpt (stripReminderSpans
686
+ // — the same 'project state, not conversation' screen the chapter
687
+ // renderer uses). The excerpt is the only model-visible text on this
688
+ // path, so injections left in would make the elicited-plot request
689
+ // machine-dependent: measured on hosted CI 2026-09-26, where the
690
+ // runner's live excerpt carried a workspace-instructions span my
691
+ // recording region had not, the recorded exchanges 503'd, and heavy
692
+ // died mid-chain.
693
+ let flat = '';
694
+ for (let i = input.messages.length - 1; i >= 0 && flat.length < 8000; i--) {
695
+ const m = input.messages[i];
696
+ const raw = (m.content ?? []).filter((b) => b.type === 'text').map((b) => b.text ?? '').join(' ');
697
+ const text = stripReminderSpans(raw).text.slice(0, 1200);
698
+ if (text.length > 0)
699
+ flat = `${m.role ?? '?'}: ${text}\n${flat}`;
700
+ }
701
+ const instruction = 'You maintain a plot note for a conversation about to be compacted. From the excerpt below, state in at most 60 words, on one line beginning exactly with "PLOT:", what the agent is mid-way through: objective, current hypothesis, immediate next step. No tools, no prose around the line.\n\nEXCERPT:\n' + flat.slice(-8000);
702
+ // Unified auxiliary route (P2 decision): the enrichment resolver (override
703
+ // > config > persisted heartbeat > live conversation route) serves plot
704
+ // too; the summarization row fields remain a legacy prefix override.
705
+ const w = await this.#enrich().catch(() => null);
706
+ const shared = w?.auxRoute() ?? null;
707
+ const cfg = this.config ?? {};
708
+ const legacy = (typeof cfg.summarizationProvider === 'string' && cfg.summarizationProvider !== ''
709
+ && typeof cfg.summarizationModel === 'string' && cfg.summarizationModel !== '')
710
+ ? { provider: cfg.summarizationProvider, model: cfg.summarizationModel } : null;
711
+ const route = legacy ?? (shared !== null && shared.provider !== '' && shared.model !== '' ? shared : this.lastRoute);
712
+ if (route === null) {
713
+ // Loud, not silent: an empty plot section is a legitimate outcome, but
714
+ // SILENTLY skipping is how the treeseed run's early child checkpoints
715
+ // lost their plots without a trace (2026-09-27 audit).
716
+ this.#diagnostic('dsh-chapters: plot elicitation skipped — no route resolved (lastRoute unset, no aux route)');
717
+ return null;
718
+ }
719
+ // 600, not 220: reasoning tokens count against the budget on reasoning
720
+ // models (measured: the elicit prompt alone used 155 of 220 with a SHORT
721
+ // excerpt — real 8K-char excerpts invite more thinking and starved the
722
+ // content to nothing). Malformed replies are now logged with a snippet.
723
+ const text = await this.#auxComplete(instruction, route, 600, signal, session.id);
724
+ const at = text.indexOf('PLOT:');
725
+ if (at === -1) {
726
+ this.#diagnostic(`dsh-chapters: plot elicitation reply carried no PLOT line (${text.length} chars): ${JSON.stringify(text.slice(0, 80))}`);
727
+ return null;
728
+ }
729
+ const para = text.slice(at + 5).split(/\n\s*\n/)[0].trim();
730
+ if (para.length === 0) {
731
+ this.#diagnostic('dsh-chapters: plot elicitation reply had an empty PLOT line');
732
+ return null;
733
+ }
734
+ return para.slice(0, 900);
735
+ }
736
+ catch (error) {
737
+ this.#diagnostic(`dsh-chapters: plot elicitation failed (${String(error?.message ?? error).slice(0, 120)}) — continuing without a plot`);
738
+ return null;
739
+ }
740
+ }
741
+ async #arrive(agent) {
742
+ // #129: this path decides whether an oversized tool result becomes an artifact, and three separate
743
+ // investigations today (arrival's stub-pair failure pinned and unpinned, fanout's lost synthesis, the CI
744
+ // misses at the first assistant turn) all needed the same fact and none of them could get it: whether
745
+ // #arrive RAN AT ALL for a given turn. It logged only its two skip branches, so silence was ambiguous
746
+ // between "not called" and "called, deferred nothing" — the #61 rule applies: entry is a fact worth
747
+ // delivering, with the numbers that decide the outcome.
748
+ {
749
+ const session = agent.session;
750
+ diagnosticNote(this.ctx.logger, 'realm:engine:arrive', `entered: nodes=${String(session.surface?.nodes?.length ?? -1)}`
751
+ + ` cwd=${session.header?.cwd === undefined || session.header.cwd === '' ? 'NONE' : 'set'}`
752
+ + ` floor=${String(this.chaptersConfig.toolResultArtifactTokens)}`
753
+ + ` lastResort=${String(this.chaptersConfig.lastResortArtifact === true)}`);
754
+ }
755
+ const session = agent.session;
756
+ const cwd = session.header?.cwd;
757
+ if (cwd === undefined || cwd === '') {
758
+ // #78: two bare returns made arrival artifacting vanish COMPLETELY, silently — the very
759
+ // silence that lets a leg fail while nobody can say whether the code path ran. And the
760
+ // reason is printed as the SHAPE it found, because `session.header?.cwd` is precisely the
761
+ // assumption a host format bump retires (the v3→v4 log lesson).
762
+ diagnosticNote(this.ctx.logger, 'realm:engine:arrive', `arrival stubbing skipped: no session cwd (session keys=${Object.keys(session ?? {}).slice(0, 10).join(',')})`);
763
+ return;
764
+ }
765
+ const meter = this.ctx.tokenMeter;
766
+ if (meter === undefined) {
767
+ diagnosticNote(this.ctx.logger, 'realm:engine:arrive', 'arrival stubbing skipped: no tokenMeter on the realm context');
768
+ return;
769
+ }
770
+ const r = await applyArrivalStubs(session, {
771
+ cwd,
772
+ storeRoot: this.chaptersConfig.artifactStoreRoot,
773
+ floorTokens: this.chaptersConfig.toolResultArtifactTokens,
774
+ estimate: (m) => meter.estimateMessage(m),
775
+ });
776
+ if (r.detail !== undefined)
777
+ this.ctx.logger?.warn?.(`dsh-chapters: arrival stubbing degraded (${r.detail})`);
778
+ }
779
+ /**
780
+ * One line per compaction decision — the second half of "is relief actually running?".
781
+ * `realm:engine:*` so the site is attributable, note-level so a routine decision never reads as
782
+ * a failure, and it names which entry point (`compactIfNeeded` on pressure/auto vs `compactNow`
783
+ * on `/compact`) because those two disagreeing is exactly the kind of drift a field report needs
784
+ * to be able to tell apart. (#78)
785
+ */
786
+ /**
787
+ * The last-resort deferral (#78 option A). Returns how many nodes it stubbed, and 0 — never a
788
+ * guess — unless every input to the ceiling is real: `resolveStoreRoot`-style derivation lives in
789
+ * `lastResortCeilingTokens`, so this method only supplies measured values. Runs only from the
790
+ * declined branch; the operator's `toolResultArtifactTokens` still governs every ordinary case.
791
+ */
792
+ async #lastResortRelief(agent, signal) {
793
+ try {
794
+ if (this.chaptersConfig.lastResortArtifact !== true)
795
+ return 0;
796
+ const route = this.lastRoute;
797
+ const meter = this.ctx.tokenMeter;
798
+ const llm = this.ctx.llm;
799
+ if (route === null || meter === undefined || typeof llm?.resolveModelInfo !== 'function')
800
+ return 0;
801
+ const info = await llm.resolveModelInfo(route.provider, route.model, signal);
802
+ const base = this;
803
+ // Quiet unless there is a problem to solve. Relief declines on EVERY ordinary pre-step because
804
+ // the surface is below the trigger, and the first two notes this feature ever emitted were that
805
+ // non-event — noise that also hid the case worth naming. Every value here is measured at this
806
+ // point in the method: no invented field and no assumed window (my first attempt reached for a
807
+ // `lastWindowTokens` that does not exist, and `tsc` was the only thing that caught it).
808
+ const surfaceNow = Number(meter.measure?.(agent.session)?.totalTokens ?? NaN);
809
+ const ratioNow = this.config?.thresholdRatio;
810
+ const windowNow = Number(info?.context?.contextWindow ?? NaN);
811
+ if (Number.isFinite(windowNow))
812
+ this.lastWindowTokens = windowNow;
813
+ const overTrigger = Number.isFinite(surfaceNow) && Number.isFinite(windowNow)
814
+ && typeof ratioNow === 'number' && surfaceNow > windowNow * ratioNow;
815
+ const retainTokens = base.config?.retainTokens;
816
+ // The host builds model-info as `{ context: { contextWindow }, ...defaultMaxTokens ? { defaultMaxTokens } : {} }`
817
+ // — read out of the pinned bundle today. `defaultMaxTokens` is a SIBLING of `context`, so the previous line
818
+ // read a field that never exists, fell through to the base config's `maxTokens` (65,536: a routing
819
+ // headroom, `resolveConfig` defaults it from `headroomTokens`), and made the ceiling negative forever. The
820
+ // model's real output budget is 15,000 here, which is what makes `window − retain − reserved` derivable.
821
+ const reserved = Number(info?.defaultMaxTokens ?? info?.context?.maxTokens ?? base.config?.maxTokens ?? NaN);
822
+ const ceiling = lastResortCeilingTokens({
823
+ windowTokens: Number(info?.context?.contextWindow ?? NaN),
824
+ retainRatio: Number(base.config?.retainRatio ?? NaN),
825
+ // optional, not `| undefined`: `exactOptionalPropertyTypes` is on, and a property that can
826
+ // hold undefined is a different promise from one that may be absent.
827
+ ...(retainTokens !== undefined ? { retainTokens } : {}),
828
+ // The completion budget comes from the MODEL, measured: `resolveConfig` defaults
829
+ // `maxTokens` to `headroomTokens` (65,536 — a routing headroom, not a per-request output
830
+ // budget), so `32,000 − 5,120 − 65,536` is negative and the ceiling is permanently
831
+ // underivable. `info.context.maxTokens` (15,000 here) sits in the same object as
832
+ // `contextWindow`; the base config stays only as a fallback so the abstention path still
833
+ // fires and says so when neither is present.
834
+ reservedCompletionTokens: reserved,
835
+ });
836
+ if (ceiling === null) {
837
+ // Measured tonight: relief declined twice with `surface=39983 trigger=16000` and the last
838
+ // resort NEVER fired — and said nothing about why. A feature that abstains silently is
839
+ // indistinguishable from a feature that isn't installed, which is the exact complaint this
840
+ // branch has filed against everything else (#61a). So: name the missing input.
841
+ diagnosticNote(this.ctx.logger, 'realm:engine:last-resort', `not applied: coexistence ceiling underivable (window=${String(info?.context?.contextWindow)}, retainRatio=${String(base.config?.retainRatio)}, retainTokens=${String(base.config?.retainTokens)}, maxTokens=${String(base.config?.maxTokens)}) — check the realm row's compaction keys, not this feature`);
842
+ return 0;
843
+ }
844
+ const session = agent.session;
845
+ const cwd = session.header?.cwd ?? '';
846
+ if (cwd === '')
847
+ return 0; // no workspace identity, no deferral — same rule as `#arrive`
848
+ const before = [...session.surface.nodes].length;
849
+ const r = await applyArrivalStubs(session, {
850
+ cwd,
851
+ storeRoot: resolveStoreRoot(cwd, this.chaptersConfig.artifactStoreRoot),
852
+ floorTokens: ceiling,
853
+ estimate: (m) => meter.estimateMessage?.(m) ?? 0,
854
+ });
855
+ if (r.stubbed > 0) {
856
+ diagnosticWarn(this.ctx.logger, 'realm:engine:last-resort', `relief declined, so ${String(r.stubbed)} tool/result node(s) larger than the coexistence ceiling (${String(ceiling)} tok) were artifact-deferred regardless of toolResultArtifactTokens — survivability over economy (#78 A); the archive keeps every byte and chapters_artifact answers for them (surface nodes ${String(before)} → ${String(session.surface.nodes.length)})`);
857
+ }
858
+ return r.stubbed;
859
+ }
860
+ catch { /* a last resort must never become the reason relief failed */
861
+ return 0;
862
+ }
863
+ }
864
+ /** See the call site: a decline while OVER the trigger is a statement, not a shrug. Never throws. */
865
+ async #reportDecline(agent, signal) {
866
+ try {
867
+ const route = this.lastRoute;
868
+ const meter = this.ctx.tokenMeter;
869
+ if (route === null || meter === undefined)
870
+ return;
871
+ const surface = meter.measure?.(agent.session)?.totalTokens;
872
+ const llm = this.ctx.llm;
873
+ if (typeof surface !== 'number' || typeof llm?.resolveModelInfo !== 'function')
874
+ return;
875
+ const info = await llm.resolveModelInfo(route.provider, route.model, signal);
876
+ const window = info?.context?.contextWindow;
877
+ if (typeof window === 'number')
878
+ this.lastWindowTokens = window;
879
+ const ratio = this.config?.thresholdRatio;
880
+ if (typeof window !== 'number' || typeof ratio !== 'number')
881
+ return;
882
+ const triggerTokens = Math.floor(window * ratio);
883
+ if (surface <= triggerTokens)
884
+ return;
885
+ const session = agent.session;
886
+ let oversized = 0;
887
+ let biggest = 0;
888
+ for (const seq of session.surface.nodes) {
889
+ const ev = session.eventAt(seq);
890
+ if (ev?.type !== 'tool/result')
891
+ continue;
892
+ const est = meter.estimateMessage?.(ev.data?.message) ?? 0;
893
+ if (est > biggest)
894
+ biggest = est;
895
+ if (est > triggerTokens)
896
+ oversized += 1;
897
+ }
898
+ diagnosticWarn(this.ctx.logger, 'realm:engine:decline', `relief declined while the surface is over the trigger (surface=${String(surface)} trigger=${String(triggerTokens)} window=${String(window)} ratio=${String(ratio)}): no safe range to compact, and ${String(oversized)} inline tool/result node(s) individually exceed the trigger (largest ${String(biggest)} tok). Context relief needs the arrival floor below that node size — toolResultArtifactTokens is the operator switch, and the archive keeps every byte either way.`);
899
+ }
900
+ catch { /* a report must never become the reason compaction failed */ }
901
+ }
902
+ #noteDecision(where, trigger, result, agent) {
903
+ // The INPUTS, not just the verdict: `decided: none` is one word covering two opposite bugs —
904
+ // no routed target (the base returns null on line one) and under-threshold (a real decision).
905
+ // Heavy's leg sat exactly there, so the note now carries route, surface tokens and ratio.
906
+ // All of it wrapped: a diagnostic that can throw is a diagnostic that will eat the step.
907
+ let route = 'unknown';
908
+ let surface = '? tok';
909
+ let surfaceTok = null;
910
+ try {
911
+ route = this.lastRoute === null ? 'none' : `${this.lastRoute.provider}/${this.lastRoute.model}`;
912
+ const meter = this.ctx.tokenMeter;
913
+ const measured = meter?.measure?.(agent.session);
914
+ if (typeof measured?.totalTokens === 'number') {
915
+ surfaceTok = measured.totalTokens;
916
+ surface = `${String(measured.totalTokens)} tok`;
917
+ }
918
+ }
919
+ catch { /* never let instrumentation break compaction */ }
920
+ // #143: the window, the span shadowed and the nodes left on the surface join the note. The string is built by
921
+ // `compactionNote()` rather than inline, so one formatter owns both branches and a future field cannot be added at
922
+ // one call site and missed at another (the same one-predicate rule that governs the save core and the title chooser).
923
+ const windowForNote = this.lastWindowTokens;
924
+ diagnosticNote(this.ctx.logger, `realm:engine:${where}`, compactionNote({
925
+ where,
926
+ trigger,
927
+ sessionId: agent?.session?.id ?? null,
928
+ route: this.lastRoute === null ? null : `${this.lastRoute.provider}/${this.lastRoute.model}`,
929
+ surfaceTokens: surfaceTok,
930
+ window: windowForNote,
931
+ ratio: this.config?.thresholdRatio ?? null,
932
+ surfaceNodes: (() => {
933
+ const nodesOf = agent?.session?.surface?.nodes;
934
+ return Array.isArray(nodesOf) ? nodesOf.length : null;
935
+ })(),
936
+ result: result === null ? null : result,
937
+ }));
938
+ }
939
+ async compactIfNeeded(agent, trigger, signal) {
940
+ // #78: the entry line that proves the HOST called this. Every row in the lifecycle suite
941
+ // CALLS `compactIfNeeded` directly — the test has always been the caller, which is exactly
942
+ // how a suite stays green while the product relieves nothing. Field evidence on 0.2.0-rc.2:
943
+ // 35,428 tokens against a 16,000 trigger, ZERO `compaction/*` events, and no line anywhere
944
+ // saying whether relief ran. One info line per entry, carrying the trigger the host passed,
945
+ // makes "is context relief wired at all?" answerable from a session log instead of inferred.
946
+ diagnosticNote(this.ctx.logger, 'realm:engine:compact', `compactIfNeeded entered (trigger=${JSON.stringify(trigger)}, session=${String(agent?.session?.id ?? '?')})`);
947
+ this.#observeRoute(agent);
948
+ await this.#arrive(agent);
949
+ // Finalization runs whether super resolves OR throws: the pressure retry
950
+ // loop can commit one compaction and then reject on a later attempt (shrink
951
+ // floor — measured r19: 3 reserved plans, 1 finalized, zero errors, because
952
+ // success-path-only finalization skipped the committed record). The scan is
953
+ // session-based and idempotent; the original error is rethrown untouched.
954
+ let result;
955
+ try {
956
+ result = await super.compactIfNeeded(agent, trigger, signal);
957
+ }
958
+ catch (error) {
959
+ await this.#finalizeGuarded(agent);
960
+ // #140: a configuration the user can fix is a NOTE, not an exception out of the relief path. Finalization has
961
+ // already run above, so a durable plan is never left stranded; the base engine's own message carries the window
962
+ // and the headroom that made the budget negative, which is exactly what an operator needs to change.
963
+ if (isPressureConfigRejection(error)) {
964
+ diagnosticWarn(this.ctx.logger, 'realm:engine:pressure-config', `relief unavailable for this target (headroom vs context window; the realm's headroomTokens is the switch) — ${String(error?.message ?? error)}`);
965
+ // Both facts are delivered, not one: a config-impossible realm is still a relief that DECLINED while the
966
+ // surface sat over the trigger, and the over-trigger report is the row (#78) and the operator's numbers.
967
+ // Suppressing it here would trade one silence for another, which is the failure this file keeps refusing.
968
+ await this.#reportDecline(agent, signal);
969
+ this.#noteDecision('compactIfNeeded', trigger, null, agent);
970
+ return null;
971
+ }
972
+ // A throw while the surface is OVER the trigger is the same field failure as a null —
973
+ // arguably worse, because the user sees the endpoint's 400 rather than our explanation.
974
+ // Report first, then rethrow untouched; the report itself can never mask the error.
975
+ await this.#reportDecline(agent, signal);
976
+ // no result exists on this path — the base engine threw — so the line says `none` and the
977
+ // reason is the rethrown error, not a value invented for the log
978
+ this.#noteDecision('compactIfNeeded', trigger, null, agent);
979
+ throw error;
980
+ }
981
+ if (result !== null)
982
+ await this.#finalizeGuarded(agent);
983
+ // #78: entry alone is half the question. Three `trigger="pressure"` entries on 0.2.0-rc.2
984
+ // produced no `compaction/*` event at all, and nothing could say whether the base engine
985
+ // declined (a meter/threshold disagreement) or acted and our finalization lost the evidence.
986
+ // The decision now sits beside the entry, in the same artifact.
987
+ // #78: the quiet failure was the worst part. Six compactions, then a raw `400 … exceeds the
988
+ // context window` from the endpoint, and nothing the product said about WHY relief stopped.
989
+ // This reports the numbers it actually had — surface, trigger, window, ratio, and how many
990
+ // inline tool/results are individually larger than the trigger — and changes no behavior:
991
+ // the arrival floor is the operator's switch (AGENTS: a behavior switch, not a knob), and the
992
+ // engine does not get to reinterpret it silently.
993
+ if (result === null) {
994
+ // #78 option A: relief declined, so a node that cannot coexist with the mandatory tail is no
995
+ // longer an economy question — the session is stuck. Defer THAT node as a last resort and give
996
+ // relief one more chance. Ordering is the whole design: this sits inside the declined branch,
997
+ // never in `#arrive`, so `heavy` still proves pressure compaction first. An eager version would
998
+ // arrival-stub the surface and make the leg's claim untestable (AGENTS' named trap), which is
999
+ // what the anti-vacuity rows below guard against.
1000
+ const deferred = await this.#lastResortRelief(agent, signal);
1001
+ if (deferred > 0) {
1002
+ try {
1003
+ const retry = await super.compactIfNeeded(agent, trigger, signal);
1004
+ if (retry !== null) {
1005
+ await this.#finalizeGuarded(agent);
1006
+ this.#noteDecision('compactIfNeeded', trigger, retry, agent);
1007
+ return retry;
1008
+ }
1009
+ }
1010
+ catch { /* still stuck: fall through to the decline report, then rethrow nothing */ }
1011
+ }
1012
+ await this.#reportDecline(agent, signal);
1013
+ }
1014
+ this.#noteDecision('compactIfNeeded', trigger, result, agent);
1015
+ return result;
1016
+ }
1017
+ async compactNow(agent, signal, sourceCommandId) {
1018
+ this.#observeRoute(agent);
1019
+ await this.#arrive(agent);
1020
+ let result;
1021
+ try {
1022
+ result = await super.compactNow(agent, signal, sourceCommandId);
1023
+ }
1024
+ catch (error) {
1025
+ await this.#finalizeGuarded(agent);
1026
+ this.#noteDecision('compactNow', 'manual', null, agent);
1027
+ throw error;
1028
+ }
1029
+ if (result !== null)
1030
+ await this.#finalizeGuarded(agent);
1031
+ // #78: the same decision line on the manual path. Reporting only the pressure route would
1032
+ // leave `/compact` — the door a human actually pulls — as silent as the bug this instrument
1033
+ // exists to catch.
1034
+ this.#noteDecision('compactNow', 'manual', result, agent);
1035
+ return result;
1036
+ }
1037
+ /**
1038
+ * Finalization failure must never break a committed compaction (the surface
1039
+ * is already correct and the log holds everything needed to retry) — it
1040
+ * warns, and the per-session reconciliation scan retries it on next touch.
1041
+ */
1042
+ async #finalizeGuarded(agent) {
1043
+ const session = agent.session;
1044
+ try {
1045
+ await this.#finalizeAllOutstanding(session);
1046
+ }
1047
+ catch (error) {
1048
+ this.#diagnostic(`dsh-chapters: chapter finalization deferred (session ${session.id}): ${String(error?.stack ?? error)}`);
1049
+ }
1050
+ }
1051
+ /**
1052
+ * §7.3/§7.6: the block this session's rebuilt head should carry, from the ONE
1053
+ * builder that renders it for the notice too — a second rule-ranking or
1054
+ * rule-selecting implementation is forbidden (record §8: two doors, one
1055
+ * scorer). Zero bytes when nothing is active.
1056
+ *
1057
+ * Relevance topics come from the session's OWN stored turn signatures
1058
+ * (deterministic, already on disk, no model call), newest last. With
1059
+ * `rulesRelevance` off the selection falls back to category→id order, which is
1060
+ * the order the tapes were distilled against.
1061
+ */
1062
+ async #rulesForCheckpoint(session) {
1063
+ const cwd = session.header?.cwd;
1064
+ if (cwd === undefined || cwd === '')
1065
+ return null;
1066
+ const { store } = await this.store();
1067
+ const harness = this.ruleCfg.harnessId !== '' ? this.ruleCfg.harnessId : hostname();
1068
+ const project = projectForCwd(store.projects(), cwd);
1069
+ // the SAME renderer the doors use — one builder for every rebuilt head (§7.3)
1070
+ const built = await renderRulesBlock({
1071
+ // #85: the configured root, not the constant — an e2e boot pins this per port so a notice
1072
+ // cannot pick up rules from whatever the developer's repo happens to contain.
1073
+ mirrorDir: resolveStoreRoot(cwd, this.chaptersConfig.knowledgeMirrorDir ?? DEFAULT_CLONE_DIR),
1074
+ storeDir: resolveStoreRoot(cwd, this.chaptersConfig.artifactStoreRoot),
1075
+ projectKey: project?.projectKey ?? '',
1076
+ harnessId: harness,
1077
+ budgetTokens: this.ruleCfg.budgetTokens,
1078
+ projectCap: this.ruleCfg.projectCap,
1079
+ sessionCap: this.ruleCfg.sessionCap,
1080
+ relevance: this.ruleCfg.relevance,
1081
+ includeSession: true,
1082
+ store,
1083
+ sessionId: session.id,
1084
+ // #52: the delegation edge. A subagent that compacts in place must see the
1085
+ // rules of the session that delegated it, or the whole point of the tier is
1086
+ // lost exactly where the work actually happens.
1087
+ ...(() => { const p = this.ruleParentOf(); return p !== undefined ? { parentOf: p } : {}; })(),
1088
+ });
1089
+ if (built.kind === 'none')
1090
+ return null;
1091
+ if (built.kind === 'refusal') {
1092
+ // announced, never clipped: the head says the rules did not fit rather
1093
+ // than silently carrying none of them
1094
+ this.#diagnostic(`dsh-chapters: rules over budget at this checkpoint — ${built.reason}`);
1095
+ return `Rules NOT carried into this checkpoint (their token budget was exceeded, so nothing was clipped): ${built.reason}`;
1096
+ }
1097
+ return built.text;
1098
+ }
1099
+ /**
1100
+ * The operator-visible warn channel (r19: headless profiles route ctx.logger
1101
+ * where nobody can see it; measured again 2026-09-27: even the in-process
1102
+ * logger surface is unassertable on this node build). Warn + mirror to the
1103
+ * DSH_CHAPTERS_ENGINE_ERRORS append file when opted in — diagnostics seam,
1104
+ * not a tunable. Any degradation that leaves the user wondering "did the
1105
+ * feature run?" belongs here.
1106
+ */
1107
+ #diagnostic(message, error) {
1108
+ // #54: ONE writer, in src/diag.ts. This held a private three-line copy of the sink while
1109
+ // the two places a user's door can actually fail — the host-plane tool and the shared
1110
+ // continuation core — wrote nothing anywhere. Which is how a kernel refusal that ended the
1111
+ // whole acceptance chain left no trace readable without a browser.
1112
+ diagnosticWarn(this.ctx.logger, 'realm:engine', message, error);
1113
+ }
1114
+ /** Every provider-tagged summary without a finalized manifest, oldest first. */
1115
+ /**
1116
+ * Per-item containment (#59). The scan used to await `#finalizeOne` in a bare loop, and
1117
+ * `#finalizeOne` THROWS when a manifest is missing — so one erased manifest (exactly what the
1118
+ * SAIP lost-update produced) aborted the scan on its first iteration forever: every later
1119
+ * compaction in that session could never write its chapters, and their numbers stayed burned.
1120
+ * One bad item may never poison the items behind it: report it, name it, continue.
1121
+ */
1122
+ async #finalizeAllOutstanding(session) {
1123
+ const { store } = await this.store();
1124
+ const out = await scanAndFinalize(await store.get(session.id), scanChaptersSummaries(session), async (found) => { await this.#finalizeOne(session, store, found.compactionId, found.shadowedSeqs); }, (reason, error) => this.#diagnostic(`dsh-chapters: chapter finalization deferred for compaction ${reason.compactionId} (${reason.reason}: `
1125
+ + `${String(error?.message ?? error)}) — its chapter was never written and the `
1126
+ + 'rest of the queue still proceeds. Run /chapters-repair (issue #60) to materialize it.'));
1127
+ if (out.done.length > 0) {
1128
+ const cwd = session.header?.cwd;
1129
+ if (cwd !== undefined)
1130
+ void this.#scheduler().then((s2) => s2.schedule(cwd, 'archive:compaction')).catch(() => undefined);
1131
+ }
1132
+ }
1133
+ async #finalizeOne(session, store, compactionId, shadowedSeqs) {
1134
+ const state = await store.get(session.id);
1135
+ if (isFinalized(state, compactionId))
1136
+ return;
1137
+ const plan = state.plans[compactionId];
1138
+ const numbers = state.reservations[`compaction:${compactionId}`];
1139
+ if (plan === undefined || numbers === undefined) {
1140
+ throw new Error(`chapters: no durable plan/reservation for ${compactionId} (summarize never committed its manifest)`);
1141
+ }
1142
+ const cwd = session.header?.cwd;
1143
+ if (cwd === undefined)
1144
+ throw new Error('chapters: session has no cwd — nowhere reachable to write');
1145
+ const renderedList = buildFinalizedChapters(session, shadowedSeqs, {
1146
+ tocText: '', numbers, chapter: plan, ...(plan.chapters !== undefined ? { chapters: plan.chapters } : {}),
1147
+ }, this.chaptersConfig);
1148
+ const fs = makeArchiveFs(cwd);
1149
+ const wrote = await writeArchive({
1150
+ fs,
1151
+ allocator: makeAllocator(store, session.id),
1152
+ storeRoot: resolveStoreRoot(cwd, this.chaptersConfig.artifactStoreRoot),
1153
+ rootSessionId: state.rootSession,
1154
+ chapters: renderedList,
1155
+ attemptId: `compaction:${compactionId}`,
1156
+ });
1157
+ if (wrote.records.length !== renderedList.length) {
1158
+ // read-back failed or went missing — leaving this UNFINALIZED is the
1159
+ // point: the reconciliation scan will retry; nothing lies in the TOC.
1160
+ throw new Error(`chapters: write did not verify for ${compactionId}: ${wrote.warnings.join('; ')}`);
1161
+ }
1162
+ // Every record of this compaction carries the full shadowed span: they
1163
+ // jointly account for what the surface replacement hid (legacy single-
1164
+ // chapter behavior generalized).
1165
+ const records = wrote.records.map((r) => ({ ...r, shadowedSeqs: [...shadowedSeqs] }));
1166
+ // #59: `state` was read BEFORE writeArchive (render + every artifact write). Committing from it
1167
+ // would erase anything another door committed meanwhile — which is how the SAIP compaction lost
1168
+ // its record while its 960 KB file stayed on disk. The commit re-reads inside one section.
1169
+ await store.mutate(session.id, (fresh) => markFinalized(appendChapters(fresh, records), compactionId, numbers));
1170
+ // §5.1: compaction finalization is a push point (debounced).
1171
+ const cwdNow = session.header?.cwd;
1172
+ if (cwdNow !== undefined)
1173
+ void this.#scheduler().then((s) => s.schedule(cwdNow, 'archive:compaction')).catch(() => undefined);
1174
+ for (const w of wrote.warnings)
1175
+ this.ctx.logger?.warn?.(`dsh-chapters: ${w}`);
1176
+ }
1177
+ }
1178
+ export default ChaptersCompactionEngine;
1179
+ //# sourceMappingURL=engine.js.map