@fractalsynapse/dsh-chapters 0.0.0-stage → 0.2.20

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 -4
package/lib/index.js ADDED
@@ -0,0 +1,1162 @@
1
+ /**
2
+ * dsh-chapters — cordis entry point (host plane). The ONLY host-facing function
3
+ * plugin; engine logic lives in engine.ts (mounted per preset realm), every
4
+ * decision in the pure modules (docs/development.md § L0).
5
+ *
6
+ * Responsibilities:
7
+ * 1. Make the `chapters` preset EXIST in the host's preset roster, and say loudly
8
+ * when it does not. Hosts before 0.2.0 are NOT supported: a preset is an
9
+ * `@deepseek-ai/dsh-agent-preset` declaration row, and this plugin registers
10
+ * that row at activation through the host's own `agentPresets.register()`
11
+ * (`src/preset.ts`). The old `$DSH_HOME/.agent-presets/` directory copy is
12
+ * gone — no 0.2.x host reads it, and shipping the row as a patch layer instead
13
+ * breaks the boot of every pre-0.1.7 host (measured; docs/contract.md).
14
+ * 2. Open the dsh_chapters storage domain and register chapters_segment /
15
+ * chapters_continue (src/tools.ts adapts them to the pure core).
16
+ * 3. Witness/dev diagnostics.
17
+ *
18
+ * Protocol notes (each measured, docs/contract.md):
19
+ * - function plugin: named exports `name` / `inject` / `Config` / `apply`;
20
+ * NO default export (mixing forms makes the Loader discard the namespace).
21
+ * - Config uses schemastery (default import) — plain specs are for tool
22
+ * parameters, zod for domain records; never mix.
23
+ */
24
+ import { writeFileSync, readFileSync } from 'node:fs';
25
+ import { dirname, join, isAbsolute } from 'node:path';
26
+ import { hostname } from 'node:os';
27
+ import { fileURLToPath } from 'node:url';
28
+ import Schema from '@deepseek-ai/schemastery';
29
+ import { appendChapters, reserve } from "./registry.js";
30
+ import { acquireChapterStore, makeDomainStore, resolveStoreRoot } from "./store.js";
31
+ import { registerChaptersTools } from "./tools.js";
32
+ import { CHAPTERS_PRESET_ID, inventoryEntry, unusableRows } from "./preset.js";
33
+ import { registerHostCommands } from "./commands.js";
34
+ import { wireSubagentAutoSave } from "./auto-save.js";
35
+ import { createEnrichWiring } from "./enrich-wire.js";
36
+ import { ENRICH_MAX_TOKENS_DEFAULT } from "./enrich.js";
37
+ import { carryActivePlan } from "./plans.js";
38
+ import { buildRulesSection } from "./rules.js";
39
+ import { createPromptSurface, promptChaptersForLineage, PROMPT_SETTING_KEY } from "./prompt.js";
40
+ import { makePromptLoop, suggestPromptMessage } from "./prompt-loop.js";
41
+ import { makePromptRoundRunner } from "./prompt-runner.js";
42
+ import { resolvePushWriter, promptProjectionDefinition, promptRecordPayload } from "./prompt-push.js";
43
+ import { classifyOffer, goalDirectiveOf, noSuggestion, readActiveGoal } from "./prompt-state.js";
44
+ import { rulesCommand } from "./rules-commands.js";
45
+ import { ancestorSessionIds, captureMinedRules, makeHeaderParentReader, ruleAncestorSet } from "./session-rules.js";
46
+ import { configureDiagnostics, configureDiagnosticsCap, diagnosticStatusLine } from "./diag.js";
47
+ import { BlockAssembler } from '@deepseek-ai/dsh-llm';
48
+ import { auxPromptMessage } from "./aux-message.js";
49
+ import { createSyncScheduler, makeCollectionsReader, projectForCwd, readToken, DEFAULT_CLONE_DIR } from "./sync.js";
50
+ import { removedGuard } from "./removal.js";
51
+ import { parseKnowledgeRemote, projectKeyForTarget, resolveProject } from "./repo.js";
52
+ import { providerKindFor, registerProvider } from "./provider.js";
53
+ import { createTreedxProvider } from "./treedx/provider.js";
54
+ export const Config = Schema.object({
55
+ // The preset is how the engine reaches real sessions (preset realm mount);
56
+ // turning this off means opting into manual preset authoring instead.
57
+ // Accepted-then-ignored, deliberately: removing a key would make an old profile patch
58
+ // fail to PARSE, and a bundle that will not mount is worse than a key that does nothing.
59
+ installChaptersPreset: Schema.boolean().default(true),
60
+ // The engine row of the registered preset (docs/host-compaction-seam.md). This is the
61
+ // one row config that used to live in a preset YAML a human edited in the home; with
62
+ // the row registered from code it belongs to THIS config, so a profile patch retunes it.
63
+ compactionThresholdRatio: Schema.number().default(0.9),
64
+ artifactStoreRoot: Schema.string().default('.dsh-chapters'),
65
+ // #85: pool-mirror root for rules/knowledge reads that produce model-visible text.
66
+ knowledgeMirrorDir: Schema.string().default('.dsh-knowledge'),
67
+ // Lines of the chapter index a continuation notice lists. 0 = list every chapter (the historical behavior).
68
+ // A small cap keeps the notice's SIZE settled, which is what makes a leg parked on the compaction boundary
69
+ // reproducible as a tape; the omission is stated in the notice itself, never silent (#56).
70
+ noticeIndexCap: Schema.number().step(1).min(0).default(6),
71
+ // #87: the newest human turn carried verbatim into a relief summary, capped.
72
+ openRequestCarryMaxChars: Schema.number().step(1).min(200).default(4000),
73
+ // #78 A: survivability switch, default on (docs/host-compaction-seam.md).
74
+ lastResortArtifact: Schema.boolean().default(true),
75
+ chapterTokenTarget: Schema.number().default(8000),
76
+ toolResultDeferFloorTokens: Schema.number().default(200),
77
+ // Share of the window REMAINING after the header bound — never of the
78
+ // whole window (docs/architecture.md § Budgets).
79
+ continuationBudgetRatio: Schema.number().default(0.25),
80
+ fallbackPreset: Schema.string().default('chapters'),
81
+ // Topic-sequential composition (record §4.2) for the model-free fork path.
82
+ mergeThreshold: Schema.number().default(0.3),
83
+ subagentAutoSave: Schema.boolean().default(true),
84
+ chapterLimit: Schema.number().default(8000),
85
+ chapterOversizeFactor: Schema.number().default(2),
86
+ warningsDisplayCap: Schema.number().default(6),
87
+ segmentCandidateCapTokens: Schema.number().default(4000),
88
+ diagnosticsLog: Schema.string().default(''),
89
+ diagnosticsMaxBytes: Schema.number().step(1).min(0).default(8388608),
90
+ // Plans (docs/plans-carriage.md § 7). A plan's full text is ALWAYS
91
+ // artifact-backed, so planInlineChars is display economy and never a data
92
+ // decision. callArgArtifactTokens defaults to 0 = OFF: turning it on changes
93
+ // chapter bytes for sessions with large tool-call arguments (a `write` call
94
+ // carries a whole file), which means model tapes must be re-recorded — so a
95
+ // deployment opts in deliberately rather than by accident.
96
+ planInlineChars: Schema.number().default(900),
97
+ callArgArtifactTokens: Schema.number().default(0),
98
+ // Renders zero bytes when the path holds no plans, so the default-on cost for
99
+ // sessions that never enter plan mode is exactly nothing.
100
+ plansInNotice: Schema.boolean().default(true),
101
+ retrievalHints: Schema.boolean().default(true),
102
+ // Seeding is measured-safe (kernel accepts the two-event seed and the host's own
103
+ // plan projection folds it to active) and it is what keeps the agent anchored to
104
+ // the task across a continuation — so it ships ON, for continuations only.
105
+ planSeedMode: Schema.boolean().default(true),
106
+ plansInCheckpoint: Schema.boolean().default(true),
107
+ planCheckpointChars: Schema.number().default(1600),
108
+ // Per-machine identity for the knowledge repo (commit author + per-harness
109
+ // token scoping, record §2.1). The hostname is the honest default; override
110
+ // per machine when two sessions on one box must be told apart.
111
+ harnessId: Schema.string().default(hostname()),
112
+ // Knowledge repo (record §12): '' leaves linking to /chapters-link; a URL
113
+ // auto-links every workspace on its first archive event.
114
+ knowledgeRemote: Schema.string().default(''),
115
+ projectKeyOverride: Schema.string().default(''),
116
+ syncDebounceMs: Schema.number().default(30000),
117
+ searchDefaultMaxTokens: Schema.number().default(400),
118
+ enrichmentEnabled: Schema.boolean().default(true),
119
+ enrichmentModel: Schema.string().default(''),
120
+ enrichmentIdleMs: Schema.number().default(60000),
121
+ enrichmentMaxTokens: Schema.number().default(ENRICH_MAX_TOKENS_DEFAULT),
122
+ enrichmentBatchCap: Schema.number().default(5),
123
+ vocabApply: Schema.boolean().default(false),
124
+ vocabCoMin: Schema.number().default(3),
125
+ vocabOverlapMin: Schema.number().default(0.5),
126
+ coreRulesBudgetTokens: Schema.number().default(1200),
127
+ rulesCoreBonus: Schema.number().default(0.15),
128
+ // #50 §7.3–§7.5: the session tier + per-tier display caps. The three the
129
+ // checkpoint also needs are mirrored into the engine row (docs/configuration).
130
+ sessionRulesEnabled: Schema.boolean().default(true),
131
+ rulesMarkerPrefix: Schema.string().default('ADD RULE:'),
132
+ projectRulesDisplayCap: Schema.number().default(12),
133
+ sessionRulesDisplayCap: Schema.number().default(12),
134
+ rulesRelevance: Schema.boolean().default(true),
135
+ rulesEnrichment: Schema.boolean().default(true),
136
+ rulesMinedPerChapter: Schema.number().default(3),
137
+ rulesAutoActivate: Schema.boolean().default(true),
138
+ rulesModel: Schema.string().default(''),
139
+ // #71 prompt recommendations: the model IS the switch (empty ⇒ off); the two
140
+ // caps bound the auxiliary call's input slice and its answer. The completion
141
+ // rides the existing enrichmentMaxTokens budget — one auxiliary budget, now
142
+ // three consumers (annotation, mined rules, recommendations).
143
+ promptModel: Schema.string().default(''),
144
+ // #188: the caps belong to the model. contextChars is now the NO-WINDOW FALLBACK — when the prompt
145
+ // model is set through /chapters-prompt model, the budget derives from its real context window
146
+ // (window × promptContextRatio at 4 chars/token); 32000 keeps a 32K-class model whole when no
147
+ // window resolves. maxChars rises 240 → 4096: a cap that cannot carry a paragraph cannot carry an
148
+ // instruction (the maintainer's number, 2026-10-30).
149
+ promptContextChars: Schema.number().default(32000),
150
+ promptMaxChars: Schema.number().default(4096),
151
+ promptContextRatio: Schema.number().default(0.5),
152
+ // #229: the ambient heartbeat is OFF by default — "never repeated chapters-prompt executions" is
153
+ // the user's absolute rule, and every pull is two visible history nodes on every host so far.
154
+ promptQuietHeartbeatSec: Schema.number().default(0),
155
+ promptMaxRounds: Schema.number().default(24),
156
+ /** #117: how many lines a `/chapters-repair` report may print. The finding COUNT always leads; */
157
+ repairReportMaxLines: Schema.number().default(60),
158
+ // enum-style string (same register as `fallbackPreset`): 'auto' | 'git' |
159
+ // 'treedx', validated at use — schemastery has no literal schema.
160
+ knowledgeProvider: Schema.string().default('auto'),
161
+ treedxFetchTimeoutMs: Schema.number().default(15000),
162
+ treedxWorkspaceTtlSeconds: Schema.number().default(900),
163
+ treedxLeaseRetries: Schema.number().default(3),
164
+ treedxLeaseRetryDelayMs: Schema.number().default(1000),
165
+ // CodeGraph integration: auto-detect by default. The CLI is probed lazily
166
+ // at first use; if absent, every integration point is a silent no-op.
167
+ codegraphEnabled: Schema.boolean().default(true),
168
+ codegraphEnrich: Schema.boolean().default(true),
169
+ codegraphSearchBonus: Schema.number().default(0.2),
170
+ });
171
+ export const name = '@fractalsynapse/dsh-chapters';
172
+ export const inject = ['storageDomain', 'tools', 'agents', 'llm', 'sessionProjections'];
173
+ const HERE = dirname(fileURLToPath(import.meta.url));
174
+ const PKG_ROOT = join(HERE, '..');
175
+ /** Registrations this module owns for the life of the process. The registry hands back an
176
+ * unregister disposer, and holding it — never calling it — is the point: a disposer wired
177
+ * through `ctx.effect` runs when the apply fiber's activation completes, which un-registered
178
+ * the preset the instant it had been registered (measured: `register()` reported success
179
+ * while the roster came back without `chapters`). The same apply-fiber trap bit the storage
180
+ * domain in r35. */
181
+ const heldDisposers = [];
182
+ export async function apply(ctx, config) {
183
+ // #61: the diagnostics sink defaults ON. An operator who set `diagnosticsLog` in the profile gets
184
+ // that path; `DSH_CHAPTERS_ENGINE_ERRORS` beats it; the literal `off` is the only silence. With
185
+ // neither set, the FIRST door that touches a project resolves `<store-root>/diagnostics.log`
186
+ // (ensureDiagnosticsDefault in tools/engine), which is why nothing is configured here yet — the
187
+ // three SAIP compaction finalization failures left no trace precisely because the sink had to be
188
+ // turned on before it could help.
189
+ if (config.diagnosticsLog !== '')
190
+ configureDiagnostics(config.diagnosticsLog);
191
+ configureDiagnosticsCap(config.diagnosticsMaxBytes);
192
+ // § Preset delivery lives in `src/preset.ts` — the `preset-chapters` declaration row in
193
+ // this bundle's patch, NOT here. A row is visible (`dsh --dump-default-config`, the config
194
+ // editor, `list_plugins`), overridable and disable-able from a profile patch, and its
195
+ // registration's lifetime belongs to Cordis rather than to our `apply()` fiber. What stays
196
+ // here is only the VERIFICATION: whether the roster ends up holding the preset at all
197
+ // (below), because declaring it and having it usable are different facts.
198
+ let domain;
199
+ try {
200
+ const storageDomain = ctx.get?.('storageDomain');
201
+ if (storageDomain === undefined)
202
+ throw new Error('storageDomain absent');
203
+ const handle = await acquireChapterStore(storageDomain);
204
+ domain = handle.domain;
205
+ const store = handle.store;
206
+ // No close effect here: the facility's own disposer closes domains still
207
+ // open at facility unmount (its documented teardown), and a close effect
208
+ // on the apply fiber fires when THAT fiber is disposed — mid-run, which
209
+ // closed the domain under later commands (r35 caught it). One opener, and
210
+ // the closer is the facility's unmount, not our fiber.
211
+ void handle.owner;
212
+ // §15 amendment: the TreeDX transport, configured and registered in THIS
213
+ // module instance (host plane). The realm engine registers its own copy
214
+ // (separate module instances under isolation) — same code, per-plane.
215
+ registerProvider(createTreedxProvider({
216
+ fetchTimeoutMs: config.treedxFetchTimeoutMs,
217
+ workspaceTtlSeconds: config.treedxWorkspaceTtlSeconds,
218
+ leaseRetries: config.treedxLeaseRetries,
219
+ leaseRetryDelayMs: config.treedxLeaseRetryDelayMs,
220
+ }));
221
+ // P2: host-plane enrichment wiring — command surface + status only
222
+ // (trigger forced manual; the realm engine owns auto drains).
223
+ // The routed model of the last real tool-carrying session on this plane:
224
+ // the host has no live agent, so without this a fork-only flow (never any
225
+ // in-place compaction => realm never captures lastRoute) leaves
226
+ // /chapters-enrich run with no route to call. Persisted immediately so
227
+ // the realm's resolver sees it via the shared settings table too.
228
+ let hostRoute = null;
229
+ const enrich = createEnrichWiring({
230
+ store,
231
+ cwd: () => undefined,
232
+ config: {
233
+ enabled: config.enrichmentEnabled,
234
+ model: config.enrichmentModel,
235
+ trigger: 'manual',
236
+ idleMs: config.enrichmentIdleMs,
237
+ maxTokens: config.enrichmentMaxTokens,
238
+ batchCap: config.enrichmentBatchCap,
239
+ mineRules: config.rulesEnrichment,
240
+ mineCap: config.rulesMinedPerChapter,
241
+ rulesAutoActivate: config.rulesAutoActivate,
242
+ rulesModel: config.rulesModel,
243
+ },
244
+ // §7.5 host-plane sink: `/chapters-enrich run` drains here, so a manual run
245
+ // mines exactly like the realm's auto pass. Local files only — nothing is
246
+ // armed on the scheduler, because session rules never travel (§7.5).
247
+ onMinedRules: async ({ cwd, sessionId, chapterNumber, humanText, rules, max, autoActivate }) => {
248
+ const res = captureMinedRules({
249
+ storeDir: resolveStoreRoot(cwd, config.artifactStoreRoot),
250
+ harnessId: config.harnessId,
251
+ sessionId,
252
+ records: store.rules(),
253
+ humanText,
254
+ rules,
255
+ maxPerChapter: max,
256
+ chapterNumber,
257
+ autoActivate,
258
+ });
259
+ return { written: res.ids.length, stated: res.stated, inferred: res.inferred, pending: res.pending };
260
+ },
261
+ fetch: async (prompt, route) => {
262
+ if (route === null)
263
+ throw new Error('no enrichment route resolvable (set /chapters-enrich model … or run a turn first)');
264
+ const llm = ctx.llm;
265
+ if (llm?.stream === undefined)
266
+ throw new Error('llm.stream absent');
267
+ const assembler = new BlockAssembler();
268
+ for await (const chunk of llm.stream({
269
+ provider: route.provider, model: route.model,
270
+ messages: [auxPromptMessage(prompt)],
271
+ maxTokens: config.enrichmentMaxTokens,
272
+ purpose: 'compaction',
273
+ }))
274
+ assembler.push(chunk);
275
+ return assembler.blocks().filter((b) => b.type === 'text').map((b) => b.text ?? '').join('');
276
+ },
277
+ conversationRoute: () => hostRoute,
278
+ log: (m) => ctx.logger?.info?.(`dsh-chapters: ${m}`),
279
+ warn: (m) => ctx.logger?.warn?.(`dsh-chapters: ${m}`),
280
+ });
281
+ // #71: prompt recommendations, host plane only (the web composer pulls;
282
+ // headless sessions have no ghost and never pay the call). The route for a
283
+ // bare model name rides the same last-seen route enrichment uses, with the
284
+ // persisted route as fallback — a fork-only flow that never compacted in
285
+ // place still has a provider to route through after one tool call.
286
+ /** Pull the host's own failure text out of a `finish` chunk whose reason kind is `error`. */
287
+ function hostStreamFailure(chunks) {
288
+ for (const raw of chunks) {
289
+ try {
290
+ const j = JSON.parse(raw);
291
+ if (j.type === 'finish' && j.reason?.kind === 'error') {
292
+ const msg = j.reason.failure?.message ?? 'the host reported an error without a message';
293
+ return `host llm.stream failed: ${msg.replace(/\s+/g, ' ').slice(0, 300)}`;
294
+ }
295
+ }
296
+ catch { /* truncated probe slice, not an error signal */ }
297
+ }
298
+ return null;
299
+ }
300
+ const prompt = createPromptSurface({
301
+ getSetting: (k) => store.getSetting(k),
302
+ putSetting: (k, v) => store.putSetting(k, v),
303
+ deleteSetting: (k) => store.deleteSetting(k),
304
+ // #188: the ANCESTOR PATH's chapters with plots, not the live row alone — a continuation
305
+ // mid-goal inherits its lineage's trajectory. Bodies are read from the archive itself
306
+ // (deterministic, zero model tokens); an unreadable body degrades to no plots, never to an
307
+ // assembly failure.
308
+ chaptersOf: async (session) => {
309
+ const rows = [];
310
+ const seen = new Set();
311
+ let cur = session.id;
312
+ while (cur !== null && !seen.has(cur)) {
313
+ seen.add(cur);
314
+ const row = await store.get(cur);
315
+ if (row === undefined)
316
+ break;
317
+ rows.unshift(row);
318
+ cur = row.parentSession;
319
+ }
320
+ const cwd = session.cwd ?? '';
321
+ return promptChaptersForLineage({
322
+ rows,
323
+ readBody: (rel) => {
324
+ try {
325
+ return readFileSync(isAbsolute(rel) ? rel : join(cwd, rel), 'utf8');
326
+ }
327
+ catch {
328
+ return null;
329
+ }
330
+ },
331
+ });
332
+ },
333
+ // #188: the caps derive from the selected model's REAL window; null falls back to config loudly.
334
+ resolveWindow: async (route) => {
335
+ const info = await ctx.llm?.resolveModelInfo(route.provider, route.model).catch(() => undefined);
336
+ return info?.context?.contextWindow ?? null;
337
+ },
338
+ // #188: the standing orders a rebuilt head would render — the SAME builder and budget, so the
339
+ // director and the worker can never operate under different rule sets. A degraded rules read
340
+ // degrades the suggestion (empty section); it never fails the assembly.
341
+ rulesOf: async (session) => {
342
+ try {
343
+ const cwd = session.cwd ?? '';
344
+ if (cwd === '')
345
+ return '';
346
+ const project = projectForCwd(store.projects(), cwd);
347
+ if (project === undefined)
348
+ return '';
349
+ const walk = await ruleAncestorSet({
350
+ seeds: [session.id],
351
+ registryAncestors: async (sid) => await ancestorSessionIds(async (x) => await store.get(x), sid),
352
+ });
353
+ const built = buildRulesSection(join(cwd, DEFAULT_CLONE_DIR), {
354
+ projectKey: project.projectKey,
355
+ harnessId: config.harnessId,
356
+ budgetTokens: config.coreRulesBudgetTokens,
357
+ storeDir: resolveStoreRoot(cwd, config.artifactStoreRoot),
358
+ ancestorSessions: walk.ancestors,
359
+ });
360
+ return 'text' in built ? built.text : '';
361
+ }
362
+ catch {
363
+ return '';
364
+ }
365
+ },
366
+ // #188: the operative plan via the SAME carry mechanism continuations seed with, budgeted at the
367
+ // context cap — its own announced clip (which names the archive chapter citing the full text)
368
+ // fires only if the plan alone would eat the entire budget.
369
+ planOf: async (session) => {
370
+ try {
371
+ const row = await store.get(session.id);
372
+ const docs = (row?.planDocs ?? []);
373
+ return carryActivePlan(docs, { maxChars: config.promptContextChars }) ?? '';
374
+ }
375
+ catch {
376
+ return '';
377
+ }
378
+ },
379
+ config: {
380
+ model: config.promptModel,
381
+ contextChars: config.promptContextChars,
382
+ maxChars: config.promptMaxChars,
383
+ contextRatio: config.promptContextRatio,
384
+ },
385
+ conversationRoute: () => {
386
+ if (hostRoute !== null)
387
+ return hostRoute;
388
+ const persisted = store.getSetting('enrichment.route') ?? '';
389
+ const i = persisted.indexOf('/');
390
+ return i > 0 ? { provider: persisted.slice(0, i), model: persisted.slice(i + 1) } : null;
391
+ },
392
+ fetch: async (input, route) => {
393
+ const llm = ctx.llm;
394
+ if (llm?.stream === undefined)
395
+ throw new Error('llm.stream absent');
396
+ const assembler = new BlockAssembler();
397
+ const seen = [];
398
+ const lastChunk = [];
399
+ for await (const chunk of llm.stream({
400
+ provider: route.provider, model: route.model,
401
+ messages: [auxPromptMessage(input)],
402
+ maxTokens: config.enrichmentMaxTokens,
403
+ purpose: 'compaction',
404
+ })) {
405
+ // Measured, not assumed: the fold below keeps only `type === 'text'` blocks, and the bar said
406
+ // "the model returned no text" on a route that returns text when called directly — so the shapes the
407
+ // host stream hands us are exactly what had to be seen.
408
+ lastChunk.push(JSON.stringify(chunk).slice(0, 120));
409
+ const c = chunk;
410
+ seen.push(`${String(c.type ?? '?')}:${String(c.content?.type ?? c.text ?? c.delta?.text ?? '').slice(0, 10)}`);
411
+ assembler.push(chunk);
412
+ }
413
+ // #61: the host already told us what went wrong, inside the stream. Before this, a provider with no
414
+ // adapter, a refused request, and an empty completion all arrived here as "the model returned no text"
415
+ // — the detected failure was thrown away and a stranger diagnosis took its place. Now the FIRST real
416
+ // failure is what the surface reports.
417
+ const failure = hostStreamFailure(lastChunk);
418
+ if (failure !== null)
419
+ throw new Error(failure);
420
+ return assembler.blocks().filter((b) => b.type === 'text').map((b) => b.text ?? '').join('');
421
+ },
422
+ warn: (m) => ctx.logger?.warn?.(`dsh-chapters: ${m}`),
423
+ });
424
+ // #113: the Suggested Next Step bar's server-side state. ONE instance, built here, because it is the
425
+ // only place that has the store, the model switch, and the live agents at once — and a loop that starts
426
+ // real turns must not have two copies of "is it armed" to disagree about. `autorun` is process-local on
427
+ // purpose: like the host goal driver's activation, it starts disarmed and cannot resurrect itself after a
428
+ // restart, so an unattended loop is impossible by construction rather than by promise.
429
+ // ONE path from "run this suggestion" to a human-authored turn. Autorun calls it from the idle edge; the
430
+ // bar's Run calls it from the command plane. Two implementations of "run it" would be two behaviors, and
431
+ // only one of them is the one the human clicks.
432
+ const submitOffer = (sessionId, text) => {
433
+ const agents = ctx.agents;
434
+ const agent = agents?.get?.(sessionId);
435
+ if (agent?.followup === undefined)
436
+ throw new Error(`no live agent for ${String(sessionId)}`);
437
+ agent.followup(suggestPromptMessage(text, sessionId));
438
+ };
439
+ const promptLoop = makePromptLoop({
440
+ followup: submitOffer,
441
+ maxRounds: () => config.promptMaxRounds,
442
+ // The host's cooperative brake: `GoalService.disarm` stops the driver re-injecting rounds and changes
443
+ // NOTHING durable ("without changing durable goal phase or revision"), so the objective stays
444
+ // authoritative while exactly one loop — the one the human armed last — starts the next turn.
445
+ displaceGoal: (sessionId) => {
446
+ try {
447
+ const goals = (typeof ctx.get === 'function' ? ctx.get('goals') : undefined);
448
+ const agents = ctx.agents;
449
+ const agent = agents?.get?.(sessionId);
450
+ if (goals?.disarm === undefined || agent === undefined)
451
+ return false;
452
+ goals.disarm(agent);
453
+ return true;
454
+ }
455
+ catch {
456
+ return false;
457
+ }
458
+ },
459
+ // The goal signal comes from the SERVICE, never folded from the log: `activation` is process-local by
460
+ // design (dsh-goal keeps it in a WeakMap), so the only honest read is the live GoalView. This is what
461
+ // lets the loop know when the driver owns the next turn.
462
+ goalSignal: (sessionId) => {
463
+ try {
464
+ const goals = (typeof ctx.get === 'function' ? ctx.get('goals') : undefined);
465
+ const agents = ctx.agents;
466
+ const agent = agents?.get?.(sessionId);
467
+ if (goals === undefined || agent === undefined)
468
+ return null;
469
+ const view = goals.get?.(agent);
470
+ if (view === undefined || typeof view.revision !== 'number')
471
+ return null;
472
+ const phase = view.phase === 'paused' ? 'paused' : view.phase === 'blocked' ? 'blocked' : view.phase === 'complete' ? 'complete' : 'active';
473
+ return { phase, activation: view.activation === 'armed' ? 'armed' : 'disarmed', revision: view.revision };
474
+ }
475
+ catch {
476
+ return null;
477
+ }
478
+ },
479
+ log: (m) => ctx.logger?.info?.(`dsh-chapters: ${m}`),
480
+ warn: (m) => ctx.logger?.warn?.(`dsh-chapters: ${m}`),
481
+ });
482
+ // A narrow, explicit view of the registry row for the bar. The surrounding `store` handle is loosely
483
+ // typed here, and widening a host-facing type to make a call compile is the exact habit this repo has
484
+ // been bitten by (#58) — so the two verbs the bar needs are named, not assumed.
485
+ const promptRow = store;
486
+ // #194: the push transport. The writer resolves against the HOST's own module instance (the
487
+ // probe-proven recipe — a plugin-side copy throws `Receiver must be an instance of class Session`
488
+ // even at the identical version), and the projection is registered ONLY when the writer exists:
489
+ // registration is the feature signal the client reads through its standard `useProjection` seat.
490
+ // No writer ⇒ no projection ⇒ every client keeps the #203/#208 pull schedule, unchanged.
491
+ let pushWriter = null;
492
+ try {
493
+ pushWriter = await resolvePushWriter({ log: (m) => { console.error(`[chapters-push] ${m}`); ctx.logger?.warn?.(`dsh-chapters: ${m}`); } });
494
+ if (pushWriter !== null) {
495
+ const projections = ctx.sessionProjections;
496
+ if (typeof projections?.register === 'function') {
497
+ projections.register(promptProjectionDefinition());
498
+ // stderr witness, same discipline as the turn/end door's armed line: ctx.logger.info never
499
+ // reaches a boot log a probe can read, and "is push live?" is exactly the question a
500
+ // failed proof must answer first (#194).
501
+ console.error('[chapters-push] transport LIVE — the bar rides plugin records; zero command runs in steady state');
502
+ ctx.logger?.info?.('dsh-chapters: prompt push transport LIVE — the bar rides plugin records; zero command runs in steady state');
503
+ }
504
+ else {
505
+ pushWriter = null; // no seat to fold records into; the pull floor stands
506
+ }
507
+ }
508
+ }
509
+ catch (error) {
510
+ pushWriter = null;
511
+ ctx.logger?.warn?.(`dsh-chapters: prompt push wiring failed (${String(error?.message ?? error).slice(0, 140)}) — pull transport stands`);
512
+ }
513
+ // One serialized push per session: the fold is last-wins, so records must land in mutation
514
+ // order — a ready that overtakes its own analyzing would show a stale offer as fresh.
515
+ const pushChains = new Map();
516
+ // #218: sessions that already carry a birth record this process; a Set, not persistence —
517
+ // the record itself is durable, and a restarted process re-birthing would append a duplicate
518
+ // (harmless: the fold is last-wins, but a duplicate is a log write for nothing).
519
+ const seenBirthSessions = new Set();
520
+ // #225: sessions whose first-boundary verdict has been printed (per process), and the total
521
+ // boundaries sighted — the status command prints both. "No suggestion ever" must not be a
522
+ // silence to interpret; it is a printed verdict per session at its first boundary.
523
+ const promptVerdictSighted = new Set();
524
+ let promptBoundariesSighted = 0;
525
+ const pushBar = (session, sessionId) => {
526
+ if (pushWriter === null || session === undefined || session === null)
527
+ return;
528
+ const writer = pushWriter;
529
+ const prev = pushChains.get(sessionId) ?? Promise.resolve();
530
+ const next = prev.then(async () => {
531
+ const row = (await promptRow.get(sessionId)).promptSuggestion ?? noSuggestion;
532
+ const live = promptLoop.statusOf(sessionId);
533
+ writer.append(session, promptRecordPayload({
534
+ status: row.status, text: row.text, turnSeq: row.turnSeq, error: row.error,
535
+ intent: row.intent, goalRevision: row.goalRevision, autorun: live.autorun, at: row.at,
536
+ }));
537
+ }, () => undefined);
538
+ pushChains.set(sessionId, next.then(() => undefined, () => undefined));
539
+ };
540
+ const promptBar = {
541
+ // #229: the ambient heartbeat is config-gated and defaults to ZERO — the maintainer's rule is
542
+ // absolute ("never want to see repeated chapters-prompt executions in a session again"), and
543
+ // the measurement that proved it: exact 60.0s pull gaps on their live log even with 0.2.11
544
+ // installed correctly. The value rides EVERY state reply (quietHeartbeatSec) so the client's
545
+ // schedule is the server's setting, not a compile-time guess; the same number lands in the
546
+ // /chapters-prompt state text for humans.
547
+ quietHeartbeatSec: config.promptQuietHeartbeatSec,
548
+ read: async (sessionId) => {
549
+ const status = (await promptRow.get(sessionId)).promptSuggestion ?? noSuggestion;
550
+ const live = promptLoop.statusOf(sessionId);
551
+ return { ...status, ...live };
552
+ },
553
+ // #231: the row without the loop — cheap, pure, and the answer to almost every pull. The
554
+ // maintainer's host pulled once a minute for hours; each of those hit the full read + mirror.
555
+ peekRow: async (sessionId) => {
556
+ const ps = (await promptRow.get(sessionId)).promptSuggestion;
557
+ if (ps === undefined || ps.status === 'off')
558
+ return null;
559
+ const live = promptLoop.statusOf(sessionId);
560
+ return { ...ps, ...live };
561
+ },
562
+ // #218: the state verb calls this after serving a pull — the answer becomes the record the
563
+ // next mount reads from the projection. Pull-only surfaces never see it called (the caller
564
+ // feature-checks), so it lives on the same object that owns the push queue.
565
+ mirror: (sessionId, session) => { pushBar(session, sessionId); },
566
+ // #113: Run is a command, not a keystroke. The offer is read from the row the bar is displaying and
567
+ // injected as a human-authored follow-up — the same path autorun uses, so what the human clicks and what
568
+ // the loop arms cannot be two different behaviors. `no live agent` is the honest failure and reaches the
569
+ // bar as its reason.
570
+ runNow: async (sessionId) => {
571
+ const st = await promptBar.read(sessionId);
572
+ if (st.text === '' || st.status !== 'ready')
573
+ return { ok: false, reason: 'there is no offer on screen to run' };
574
+ submitOffer(sessionId, st.text);
575
+ return { ok: true, text: st.text };
576
+ },
577
+ setAutorun: (sessionId, on, session) => {
578
+ const r = promptLoop.setArmed(sessionId, on);
579
+ pushBar(session, sessionId);
580
+ return r;
581
+ },
582
+ disarmHuman: (sessionId, session) => { promptLoop.disarmForHuman(sessionId); pushBar(session, sessionId); },
583
+ // `off` stops generation for the session without touching the model, so back-on is one action; `on`
584
+ // clears it and the NEXT completed turn offers again. Both write through mutate, never a snapshot.
585
+ setOff: async (sessionId, session) => {
586
+ promptLoop.setArmed(sessionId, false);
587
+ await promptRow.mutate(sessionId, ((row) => ({ ...row, promptSuggestion: noSuggestion })));
588
+ pushBar(session, sessionId);
589
+ },
590
+ // #225: the suggest verb's answer becomes the durable offer — same row shape the runner writes
591
+ // (status ready, this turn's seq unknown for manual suggests ⇒ turnSeq null-safe via Date.now
592
+ // identity), then the mirror makes it visible without any pull.
593
+ setSuggested: async (sessionId, text, session) => {
594
+ promptLoop.setArmed(sessionId, false);
595
+ await promptRow.mutate(sessionId, ((row) => ({ ...row, promptSuggestion: {
596
+ // A manual suggest has no turn boundary of its own; `turnSeq: null` marks it as such — the
597
+ // bar renders ready offers regardless, and autorun never fires on a null-turnSeq row (its
598
+ // own gate refuses), so a human-requested suggestion can never start a turn by itself.
599
+ status: 'ready', text: classifyOffer(text).text, turnSeq: null,
600
+ error: '', at: new Date().toISOString(), intent: classifyOffer(text).intent, goalRevision: null,
601
+ } })));
602
+ if (session !== undefined)
603
+ pushBar(session, sessionId);
604
+ },
605
+ setOn: async (sessionId, session) => {
606
+ await promptRow.mutate(sessionId, ((row) => ({ ...row, promptSuggestion: noSuggestion })));
607
+ pushBar(session, sessionId);
608
+ },
609
+ };
610
+ const scheduler = createScheduler(store, domain, config, undefined, (cwd, ok) => {
611
+ enrich.rememberCwd(cwd);
612
+ if (ok)
613
+ enrich.queue.onSyncDone('afterPush');
614
+ }, (m) => ctx.logger?.warn?.(m));
615
+ // ONE assembly feeds both planes (#90 drift trap): the doors' ToolsConfig doubles as the
616
+ // commands' SaveSessionConfig for /chapters-remove restore — a second literal would be a
617
+ // second truth about thresholds.
618
+ const toolsConfig = {
619
+ artifactStoreRoot: config.artifactStoreRoot,
620
+ chapterTokenTarget: config.chapterTokenTarget,
621
+ repairReportMaxLines: config.repairReportMaxLines,
622
+ toolResultDeferFloorTokens: config.toolResultDeferFloorTokens,
623
+ continuationBudgetRatio: config.continuationBudgetRatio,
624
+ fallbackPreset: config.fallbackPreset,
625
+ mergeThreshold: config.mergeThreshold,
626
+ chapterLimit: config.chapterLimit,
627
+ chapterOversizeFactor: config.chapterOversizeFactor,
628
+ warningsDisplayCap: config.warningsDisplayCap,
629
+ segmentCandidateCapTokens: config.segmentCandidateCapTokens,
630
+ planInlineChars: config.planInlineChars,
631
+ callArgArtifactTokens: config.callArgArtifactTokens,
632
+ plansInNotice: config.plansInNotice,
633
+ retrievalHints: config.retrievalHints,
634
+ planSeedMode: config.planSeedMode,
635
+ searchMaxTokens: config.searchDefaultMaxTokens,
636
+ harnessId: config.harnessId,
637
+ coreRulesBudgetTokens: config.coreRulesBudgetTokens,
638
+ rulesCoreBonus: config.rulesCoreBonus,
639
+ // #50 §7.4 — the doors read the HOST plane; the compaction checkpoint reads
640
+ // the realm row (same names, same defaults). There is no cross-plane channel
641
+ // to inject host config into a preset row, so a profile that overrides a cap
642
+ // sets it in both places; leaving one unset keeps the documented default.
643
+ projectRulesDisplayCap: config.projectRulesDisplayCap,
644
+ sessionRulesDisplayCap: config.sessionRulesDisplayCap,
645
+ rulesRelevance: config.rulesRelevance,
646
+ scheduler,
647
+ noteRoute: (r) => {
648
+ hostRoute = r;
649
+ void store.putSetting('enrichment.route', `${r.provider}/${r.model}`).catch(() => undefined);
650
+ },
651
+ enrich,
652
+ };
653
+ registerChaptersTools(ctx, store, toolsConfig);
654
+ registerHostCommands(ctx, domain, {
655
+ artifactStoreRoot: config.artifactStoreRoot,
656
+ subagentAutoSave: config.subagentAutoSave,
657
+ harnessId: config.harnessId,
658
+ projectKeyOverride: config.projectKeyOverride,
659
+ // #255: /chapters-remove mutates through the plane's OWN store handle (one #59 section),
660
+ // and its print arms share the repair report cap — one knob, not two.
661
+ store,
662
+ reportMaxLines: config.repairReportMaxLines,
663
+ save: toolsConfig,
664
+ scheduler,
665
+ enrich,
666
+ // #173: discovery surface for the three model settings. The factory re-reads per
667
+ // invocation — settings mutate underneath (that is what the setters are for) and the
668
+ // catalog is a host verb, not a snapshot. `llm` is passed only when it really carries
669
+ // all three catalog verbs: a partial host surface must render as the clean refusal the
670
+ // pure core gives, never a TypeError mid-render.
671
+ models: () => {
672
+ const llm = ctx.llm;
673
+ const catalog = llm !== undefined
674
+ && typeof llm.listProviders === 'function'
675
+ && typeof llm.listModels === 'function'
676
+ && typeof llm.resolveModelInfo === 'function'
677
+ ? llm
678
+ : undefined;
679
+ const setting = (key) => { try {
680
+ return store.getSetting(key) ?? '';
681
+ }
682
+ catch {
683
+ return '';
684
+ } };
685
+ const enrichVal = setting('enrichment.model');
686
+ const rulesVal = setting('rules.model');
687
+ const promptVal = setting(PROMPT_SETTING_KEY);
688
+ return {
689
+ llm: catalog,
690
+ current: {
691
+ enrichment: enrichVal !== '' ? { value: enrichVal, source: 'settings' }
692
+ : config.enrichmentModel !== '' ? { value: config.enrichmentModel, source: 'config' }
693
+ : { value: setting('enrichment.route'), source: 'conversation-route' },
694
+ rules: rulesVal !== '' ? { value: rulesVal, source: 'settings' }
695
+ : config.rulesModel !== '' ? { value: config.rulesModel, source: 'config' }
696
+ : { value: '', source: 'rides-enrichment' },
697
+ prompt: promptVal !== '' ? { value: promptVal, source: 'settings' }
698
+ : config.promptModel !== '' ? { value: config.promptModel, source: 'config' }
699
+ : { value: '', source: 'off' },
700
+ },
701
+ };
702
+ },
703
+ prompt: Object.assign({ ...prompt, bar: promptBar }, {
704
+ // #225: `/chapters-status` answers "why is there no suggestion?" without reading files.
705
+ planeStatus: () => {
706
+ const lines = [];
707
+ const { spec, source } = prompt.effective();
708
+ lines.push(`Prompt plane: model ${spec !== '' ? `${spec} (${source})` : 'UNSET — the bar is absent (§28)'}`);
709
+ lines.push(` transport: ${pushWriter === null ? 'pull (this host has no appendPluginRecord — state reads are commands)' : 'push LIVE (plugin records; the bar rides the projection)'}`);
710
+ lines.push(` turn/end door: ${String(promptBoundariesSighted)} boundary event(s) sighted this process across ${String(promptVerdictSighted.size)} session(s)`);
711
+ return lines;
712
+ },
713
+ sessionRow: async (sessionId) => {
714
+ try {
715
+ const row = await store.get(sessionId);
716
+ const ps = row.promptSuggestion;
717
+ if (ps === undefined)
718
+ return ' this session: NO ROW YET — the runner never persisted a verdict (if the door sighted boundaries above, check server stderr for [chapters-prompt] lines)';
719
+ return ` this session: status=${ps.status}, turn=${String(ps.turnSeq ?? '-')}${ps.error !== '' ? `, error=${ps.error.slice(0, 90)}` : ''}, at=${ps.at}`;
720
+ }
721
+ catch (error) {
722
+ return ` this session: row unreadable (${String(error?.message ?? error).slice(0, 80)})`;
723
+ }
724
+ },
725
+ }),
726
+ rules: (cwd, args, sessionId) => rulesCommand({
727
+ cwd: () => cwd,
728
+ storeRoot: config.artifactStoreRoot,
729
+ harnessId: config.harnessId,
730
+ store,
731
+ projectFor: (c) => projectForCwd(store.projects(), c),
732
+ mirrorDir: (c) => join(c, DEFAULT_CLONE_DIR),
733
+ syncNow: async (c) => { try {
734
+ await scheduler.run(c, 'rules');
735
+ }
736
+ catch { /* status file records it */ } },
737
+ now: () => new Date(),
738
+ // #50 §7.5: the session tier activates by ancestry, so the surface needs
739
+ // the tree the caller is sitting on. No sessionId ⇒ `active` reports the
740
+ // session tier UNRESOLVED rather than guessing an empty path.
741
+ // #52: the SAME walk every door renders with — registry links plus the host
742
+ // header's delegation edge — so `active` can never describe a view the head
743
+ // will not show. One walk, memoized: two answers about one ancestry is how
744
+ // a surface starts disagreeing with itself.
745
+ ...(sessionId !== undefined ? (() => {
746
+ const parentOf = makeHeaderParentReader((name) => { try {
747
+ return ctx.get?.(name);
748
+ }
749
+ catch {
750
+ return undefined;
751
+ } });
752
+ let once = null;
753
+ const walk = async () => {
754
+ if (once === null) {
755
+ once = ruleAncestorSet({
756
+ seeds: [sessionId],
757
+ registryAncestors: async (id) => await ancestorSessionIds(async (s) => await store.get(s), id),
758
+ ...(parentOf !== undefined ? { parentOf } : {}),
759
+ });
760
+ }
761
+ return await once;
762
+ };
763
+ return {
764
+ ancestors: async () => (await walk()).ancestors,
765
+ ancestryTruncated: async () => (await walk()).truncated,
766
+ };
767
+ })() : {}),
768
+ // §7.3/§7.4: the SAME numbers the doors render with, so `active` can never
769
+ // describe a view the head will not show.
770
+ budgetTokens: config.coreRulesBudgetTokens,
771
+ projectCap: config.projectRulesDisplayCap,
772
+ sessionCap: config.sessionRulesDisplayCap,
773
+ // §7.5: the review surface reads the same activation posture the ladder uses
774
+ // (so `active` can say whether mined rules govern on arrival), and `model`
775
+ // sets a dedicated mining model through the shared settings table.
776
+ autoActivate: config.rulesAutoActivate,
777
+ settings: {
778
+ get: (k) => store.getSetting(k),
779
+ put: async (k, v) => { await store.putSetting(k, v); },
780
+ },
781
+ // §7.7: ONE bounded call, report-only, reusing the conversation route (and
782
+ // therefore the conversation's own cached prefix). An explicit exception to
783
+ // zero-inference in the elicitedPlot family — never a mutation.
784
+ ask: async (prompt) => {
785
+ const route = hostRoute;
786
+ if (route === null)
787
+ throw new Error('no conversation route yet — run a turn first');
788
+ const llm = ctx.llm;
789
+ if (llm?.stream === undefined)
790
+ throw new Error('llm.stream absent');
791
+ const assembler = new BlockAssembler();
792
+ for await (const chunk of llm.stream({
793
+ provider: route.provider, model: route.model,
794
+ messages: [auxPromptMessage(prompt)],
795
+ maxTokens: config.enrichmentMaxTokens,
796
+ purpose: 'compaction',
797
+ }))
798
+ assembler.push(chunk);
799
+ return assembler.blocks().filter((b) => b.type === 'text').map((b) => b.text ?? '').join('');
800
+ },
801
+ }, args),
802
+ });
803
+ // Door 5 (issue #47): the fifth archive moment — subagent settlement. The
804
+ // host publishes `subagent/end` for every settled child run (one-shot AND
805
+ // each residency epoch of a continuable child); the wiring reads the live
806
+ // child synchronously (end strictly precedes disposal — measured, spike #47)
807
+ // with a durable sessionQuery fallback, gates on the header the HOST stamps
808
+ // (`origin: subagent`, `agentPreset`), and archives through the SAME
809
+ // saveSession core as /chapters-save. Enrichment is never run per child.
810
+ // Save-only: creates no session, so the "no continuation-creating actions
811
+ // mid-step" rule is not engaged; a settled child's turns are all completed
812
+ // by construction, and the anchor is its last `turn/end`.
813
+ if (config.subagentAutoSave) {
814
+ // #113: one suggestion per completed turn, and a human prompt that suspends the loop. Registered
815
+ // beside the other turn/end doors rather than as a new timing of our own (AGENTS: no second automatic
816
+ // engine), and inert whenever the model is unset — the model IS the switch.
817
+ const promptRunner = makePromptRoundRunner({
818
+ enabled: () => prompt.effective().spec !== '',
819
+ eligible: (session) => session.header?.origin !== 'subagent',
820
+ store: store,
821
+ // #194: every durable row write mirrors to the push transport (order serialized per session).
822
+ pushRow: (session, sessionId) => { pushBar(session, sessionId); },
823
+ generate: async (session, turnSeq) => {
824
+ // The live session object carries the events; this is the same read the archive door uses, and it
825
+ // is payload-bearing. The GOAL is folded from those events so a suggestion cannot ignore the
826
+ // purpose it is supposed to advance — the maintainer's rule that the two compose.
827
+ void turnSeq;
828
+ const events = [...(session.snapshotEvents?.() ?? [])];
829
+ const goal = readActiveGoal(events);
830
+ const r = await prompt.suggest({
831
+ id: session.id,
832
+ // #188: the workspace the chapter paths resolve against — without it the lineage walk
833
+ // cannot read chapter bodies, and the plots (the trajectory) would silently vanish.
834
+ cwd: session.header?.cwd ?? '',
835
+ events,
836
+ goalDirective: goalDirectiveOf(goal),
837
+ });
838
+ // Built as an object with ONLY the keys that hold a value: `exactOptionalPropertyTypes` is on, so
839
+ // `quiet: undefined` is not the same as omitting `quiet`, and a spread that always writes the key
840
+ // would not compile — which is the right outcome, because the runner branches on presence.
841
+ if (r.ok)
842
+ return { ok: true, text: r.text };
843
+ return { ok: false, reason: r.reason ?? r.text, ...(r.quiet === undefined ? {} : { quiet: r.quiet }) };
844
+ },
845
+ onReady: (sessionId, state) => {
846
+ // Fire only through the loop's own gate, against the LIVE goal signal: armed/idle/budget/one-per-offer
847
+ // plus intent, driver precedence and revision staleness, all re-checked at the moment of firing.
848
+ promptLoop.maybeFire(sessionId, state, promptLoop.goalSignal(sessionId));
849
+ },
850
+ now: () => new Date().toISOString(),
851
+ // The rig's legs run in a throwaway home whose `ctx.logger` output is not retained, so the prompt
852
+ // plane's own narration is copied to stderr — where the boot's server log keeps it. Every "why did the
853
+ // bar not move" answer tonight depended on lines that were being thrown away (#61).
854
+ log: (m) => { console.error(`[chapters-prompt] ${m}`); ctx.logger?.info?.(`dsh-chapters: ${m}`); },
855
+ warn: (m) => { console.error(`[chapters-prompt] WARN ${m}`); ctx.logger?.warn?.(`dsh-chapters: ${m}`); },
856
+ });
857
+ try {
858
+ ctx.on?.('session/event', ((session, event) => {
859
+ if (session?.id === undefined)
860
+ return;
861
+ if (event?.type === 'user/message' && event.data?.source?.kind === 'user')
862
+ promptBar.disarmHuman(session.id, session);
863
+ // Sighted, not assumed. Whether this listener is invoked at all is the question that has been
864
+ // unanswered for a day, and `ctx.logger` answers it only inside a home the rig deletes. stderr is
865
+ // captured by the boot's server log, so the answer survives the leg (#61).
866
+ if (event?.type === 'turn/end')
867
+ console.error(`[chapters-prompt] turn/end sighted session=${String(session.id)} seq=${String(event.seq ?? '?')}`);
868
+ if (event?.type === 'turn/end')
869
+ promptBoundariesSighted += 1;
870
+ // #225: the re-enable path needs a witness too. The maintainer ran `/chapters-prompt model
871
+ // …` at 18:16 and NO first-boundary line ever followed — meaning either no turn ended after
872
+ // it in this process, or the setting did not reach the plane. One line per enable tells
873
+ // which: `enable seen` with the effective spec immediately after the write.
874
+ if (event?.type === 'user/message' && event.data?.source?.kind === 'command') {
875
+ const argsV = String(event.data.args ?? '');
876
+ if (/^\s*model\s+\S/.test(argsV) || /^\s*suggest\b/.test(argsV)) {
877
+ const specN = prompt.effective().spec;
878
+ console.error(`[chapters-prompt] enable/suggest command seen for ${String(session.id)}: model now ${specN !== '' ? specN : 'STILL UNSET(→ writes landed elsewhere)'}`);
879
+ }
880
+ }
881
+ if (event?.type === 'turn/end' && !promptVerdictSighted.has(String(session.id))) {
882
+ promptVerdictSighted.add(String(session.id));
883
+ const specV = prompt.effective().spec;
884
+ const originV = session.header?.origin;
885
+ console.error(`[chapters-prompt] first boundary for ${String(session.id)}: model=${specV !== '' ? specV : 'UNSET(→ skipped)'}, origin=${String(originV ?? '-')} (${originV === 'subagent' ? 'NOT eligible' : 'eligible'}), transport=${pushWriter === null ? 'pull' : 'push-live'}`);
886
+ }
887
+ // #218 BIRTH RECORD: the first event of a session gets an `idle` record, so the bar's
888
+ // first mount reads a PUSHED value and the discovery pull cannot happen — the maintainer
889
+ // watched a chapters-prompt node land BEFORE their first message, and that node was the
890
+ // discovery. Rows only change at turn/end, so a session born before its first turn has
891
+ // nothing to say — `idle` is the whole truth — and saying it through the projection costs
892
+ // zero commands forever.
893
+ if (pushWriter !== null && !seenBirthSessions.has(session.id)) {
894
+ seenBirthSessions.add(session.id);
895
+ if (prompt.effective().spec !== '')
896
+ pushBar(session, session.id);
897
+ }
898
+ promptRunner(session, event);
899
+ }));
900
+ console.error('[chapters-prompt] turn/end door armed');
901
+ ctx.logger?.info?.('dsh-chapters: suggested-next-step door armed — one offer per completed turn when a prompt model is set (state: /chapters-prompt state)');
902
+ }
903
+ catch (error) {
904
+ console.error(`[chapters-prompt] turn/end door REFUSED: ${String(error?.message ?? error)}`);
905
+ ctx.logger?.warn?.(`dsh-chapters: suggested-next-step door registration REFUSED — no turn will offer a suggestion (${String(error?.message ?? error).slice(0, 160)})`);
906
+ }
907
+ const autoSaveListener = wireSubagentAutoSave(ctx, store, {
908
+ presetId: CHAPTERS_PRESET_ID,
909
+ artifactStoreRoot: config.artifactStoreRoot,
910
+ chapterTokenTarget: config.chapterTokenTarget,
911
+ toolResultDeferFloorTokens: config.toolResultDeferFloorTokens,
912
+ mergeThreshold: config.mergeThreshold,
913
+ chapterLimit: config.chapterLimit,
914
+ chapterOversizeFactor: config.chapterOversizeFactor,
915
+ planInlineChars: config.planInlineChars,
916
+ callArgArtifactTokens: config.callArgArtifactTokens,
917
+ scheduler,
918
+ });
919
+ try {
920
+ ctx.on?.('subagent/end', autoSaveListener);
921
+ ctx.logger?.info?.('dsh-chapters: subagent auto-save armed — chapters-preset children archive at settlement (reported by /chapters-status)');
922
+ }
923
+ catch (error) {
924
+ const message = `dsh-chapters: subagent auto-save registration REFUSED — settling subagents will NOT archive (${String(error?.message ?? error)})`;
925
+ ctx.logger?.warn?.(message);
926
+ console.error(message);
927
+ }
928
+ }
929
+ }
930
+ catch (error) {
931
+ // Tools are the whole user-facing surface short of the engine: a failure
932
+ // here is loud, never a silently missing tool. Loud means the operator
933
+ // actually sees it — the realm logger routes to boot stdout (r19/r35 twice
934
+ // burned by a warn nobody could read; the DSH_CHAPTERS_ERRORS sink env is
935
+ // the engine's precedent).
936
+ const message = `dsh-chapters: tool/command registration FAILED (${String(error)})`;
937
+ ctx.logger?.warn?.(message);
938
+ console.error(message);
939
+ }
940
+ // Honest status, because the settings surface reads "enabled and running" either way, and
941
+ // "mounted" never meant "the preset exists". The row logs its own delivery line; this one
942
+ // says what THIS plugin did, and the deferred roster check below says what the host has.
943
+ const mountMsg = 'dsh-chapters: mounted (tools + commands registered; preset is the `preset-chapters` row)';
944
+ ctx.logger?.info?.(mountMsg);
945
+ // Optional CodeGraph integration: lazy probe at activation. The subprocess
946
+ // service may not be mounted yet; resolveCodegraph caches the result so
947
+ // subsequent calls are free. Failure → silent no-op at every integration point.
948
+ if (config.codegraphEnabled !== false) {
949
+ const subprocess = ctx.get?.('subprocess');
950
+ void import("./codegraph.js").then(async (cg) => {
951
+ const exe = await cg.resolveCodegraph(subprocess);
952
+ ctx.logger?.info?.(`dsh-chapters: codegraph ${exe !== null ? 'found on PATH' : 'not found (integration inactive until installed)'}`);
953
+ }).catch(() => { });
954
+ }
955
+ // File presence is NOT preset existence (§ Preset delivery): the roster is the only
956
+ // evidence. It must be asked AFTER activation — the registry re-audits a mount by
957
+ // awaiting the settled loader tree, and a caller "must not run inside a Host row's
958
+ // own activation, which the settlement would wait on" — hence this deferred check,
959
+ // which also catches a registration that mounted but could not activate. One check
960
+ // per boot, reused by the dev witness.
961
+ const marker = process.env.DSH_CHAPTERS_WITNESS;
962
+ let roster;
963
+ const rosterCheck = () => roster ??= reportPresetRoster(ctx, config.fallbackPreset);
964
+ setTimeout(() => {
965
+ void rosterCheck().then((verdict) => {
966
+ if (marker !== undefined)
967
+ void witness(ctx, marker, domain, verdict);
968
+ });
969
+ }, 500).unref?.();
970
+ }
971
+ /**
972
+ * The §5 sync loop for the host plane. Auto-link (when `knowledgeRemote` is
973
+ * configured) happens lazily inside resolveProject: a workspace's first
974
+ * archive event finds a project record, so nothing network-shaped runs
975
+ * before anything archives.
976
+ */
977
+ export function createScheduler(store, domain, config, debounceOverride, onSyncDone, warn) {
978
+ return createSyncScheduler({
979
+ storeRoot: config.artifactStoreRoot,
980
+ cloneDir: DEFAULT_CLONE_DIR,
981
+ debounceMs: debounceOverride ?? config.syncDebounceMs,
982
+ vocab: { apply: config.vocabApply, coMin: config.vocabCoMin, overlapMin: config.vocabOverlapMin },
983
+ ...(onSyncDone !== undefined ? { onSyncDone } : {}),
984
+ resolveProject: (cwd) => {
985
+ const found = projectForCwd(store.projects(), cwd);
986
+ if (found !== undefined)
987
+ return found;
988
+ if (config.knowledgeRemote === '')
989
+ return undefined;
990
+ try {
991
+ const target = parseKnowledgeRemote(config.knowledgeRemote);
992
+ const kind = providerKindFor(target.kind, config.knowledgeProvider);
993
+ const resolved = resolveProject(cwd, config.projectKeyOverride !== '' ? config.projectKeyOverride : undefined, config.knowledgeRemote);
994
+ const rec = {
995
+ // Identity via projectKeyForTarget: §2.3 canonicalization, plus the
996
+ // treedx/ namespace for TreeDX targets (keyed per-machine-stable by
997
+ // construction — that is the whole point of §2.3). The override wins
998
+ // exactly as before.
999
+ projectKey: config.projectKeyOverride !== '' ? resolved.projectKey : projectKeyForTarget(target),
1000
+ slug: target.treedx?.repoName ?? (resolved.remote ?? config.knowledgeRemote).split('/').pop().replace(/\.git$/, ''),
1001
+ remote: config.knowledgeRemote,
1002
+ kind,
1003
+ harnessId: config.harnessId,
1004
+ linkedAt: new Date().toISOString(),
1005
+ cwd,
1006
+ };
1007
+ domain.table('projects').put(rec.projectKey, rec);
1008
+ return rec;
1009
+ }
1010
+ catch (error) {
1011
+ // A policy contradiction (knowledgeProvider vs the remote scheme) is
1012
+ // LOUD in the log but never throws into the archive path (§5.3 shape).
1013
+ warn?.(`dsh-chapters: knowledgeRemote auto-link refused: ${String(error?.message ?? error)}`);
1014
+ return undefined;
1015
+ }
1016
+ },
1017
+ tokenFor: (cwd, projectKey) => readToken(cwd, config.artifactStoreRoot, projectKey), // joins via resolveStoreRoot internally (#33)
1018
+ collectionsFor: makeCollectionsReader(store.sessions.bind(store), config.artifactStoreRoot),
1019
+ // #255: rehydration must not resurrect removed chapters; the guard reads the stub rows.
1020
+ removedFor: () => removedGuard(store.sessions()),
1021
+ });
1022
+ }
1023
+ /** Ask the host which presets exist. Tolerates both shapes the service has
1024
+ * offered: the bare row array (`list()`) and the remote envelope (`{presets}`). */
1025
+ export async function readRosterVerdict(presets, presetId) {
1026
+ if (presets?.list === undefined)
1027
+ return { state: 'unknown', error: 'agentPresets service absent' };
1028
+ try {
1029
+ const raw = await presets.list();
1030
+ const rows = ((Array.isArray(raw) ? raw : raw?.presets) ?? []);
1031
+ const mine = rows.find((r) => r?.id === presetId);
1032
+ if (mine === undefined)
1033
+ return { state: 'absent', rows: rows.map((r) => String(r?.id ?? '?')).sort() };
1034
+ const broken = mine.broken;
1035
+ return typeof broken === 'string' && broken !== '' ? { state: 'present', broken } : { state: 'present' };
1036
+ }
1037
+ catch (error) {
1038
+ return { state: 'unknown', error: String(error?.message ?? error) };
1039
+ }
1040
+ }
1041
+ /**
1042
+ * The remedy for an absent preset. It is a configuration fact about someone else's tree —
1043
+ * a patch layer can be overridden or disabled by a profile we do not own — so the job is to
1044
+ * name where to look, never to retry behind the operator's back.
1045
+ */
1046
+ export function rosterRemediation(presetId) {
1047
+ return `the plugin ships "${presetId}" as the "preset-chapters" declaration row in its bundle patch `
1048
+ + '(cordis.patch.yml), which is a visible Loader entry: check that row in the plugin list, whether a '
1049
+ + 'profile patch disables or overrides it, and whether @fractalsynapse/dsh-chapters is in this profile\u2019s '
1050
+ + 'dsh.profile.bundles at all. If the row is present but this line says absent, the host is below the '
1051
+ + '0.2.0 support floor and cannot register preset rows.';
1052
+ }
1053
+ /** Deferred boot check: one truthful line about the preset, plus the verdict for the witness.
1054
+ * Exported so the four arms of that line are tested without booting a host — the line IS the
1055
+ * health signal a user reads, and an untested health signal is how the silent case survived. */
1056
+ export async function reportPresetRoster(ctx, presetId) {
1057
+ const presets = ctx.get?.('agentPresets');
1058
+ const verdict = await readRosterVerdict(presets, presetId);
1059
+ // The roster says a preset is DECLARED; the inventory says whether its rows are ACTIVE.
1060
+ // A row can resolve and still never start, and that is invisible on the roster — so the
1061
+ // one line the operator reads carries both facts.
1062
+ const mounted = verdict.state === 'present' ? ' — ' + await describeMountedRows(presets, presetId) : '';
1063
+ const line = (message, warn) => {
1064
+ if (warn) {
1065
+ ctx.logger?.warn?.(message);
1066
+ console.error(message);
1067
+ }
1068
+ else
1069
+ ctx.logger?.info?.(message);
1070
+ };
1071
+ if (verdict.state === 'present' && verdict.broken === undefined) {
1072
+ line(`dsh-chapters: preset "${presetId}" is in the host roster${mounted} — chapter-form compaction and continuations will compose`, false);
1073
+ }
1074
+ else if (verdict.state === 'present') {
1075
+ line(`dsh-chapters: preset "${presetId}" is DECLARED BUT BROKEN — ${verdict.broken} — sessions choosing it will not compose. `
1076
+ + 'A preset row whose child plugin no longer resolves fails here; the plugin drops rows this host cannot import, '
1077
+ + `so this line means a package that resolves but will not activate — re-anchor the preset-chapters row against this host. Details: ${verdict.broken}`, true);
1078
+ }
1079
+ else if (verdict.state === 'absent') {
1080
+ line(`dsh-chapters: UNHEALTHY — preset "${presetId}" is NOT in the host roster (roster: ${verdict.rows.join(', ') || 'empty'}). `
1081
+ + `Every session whose log records it will refuse to open with agent-preset/not-found — the logs are intact; the composition is missing. `
1082
+ + `Remedy: ${rosterRemediation(presetId)}`, true);
1083
+ }
1084
+ else {
1085
+ line(`dsh-chapters: could not verify the preset roster (${verdict.error}) — treat chapter-form compaction as unconfirmed`, true);
1086
+ }
1087
+ return verdict;
1088
+ }
1089
+ /** One phrase describing what the host's inventory says about the preset's own rows.
1090
+ * Never throws: an unavailable inventory must degrade the line, not the boot check. */
1091
+ export async function describeMountedRows(presets, presetId) {
1092
+ try {
1093
+ const raw = await presets?.compositionInventory?.();
1094
+ const entry = inventoryEntry(raw, presetId);
1095
+ if (entry === undefined)
1096
+ return 'rows not reported by this host';
1097
+ const bad = unusableRows(entry);
1098
+ const total = (entry.rows ?? []).length;
1099
+ return bad.length === 0
1100
+ ? `${total} mounted rows all active`
1101
+ : `${bad.length} of ${total} rows NOT active (${bad.join('; ')})`;
1102
+ }
1103
+ catch (error) {
1104
+ return `rows unreadable (${String(error?.message ?? error)})`;
1105
+ }
1106
+ }
1107
+ // ---------------------------------------------------------------- dev witness
1108
+ async function witness(ctx, marker, preopened, roster) {
1109
+ const WITNESS_SESSION = 'dsh-chapters-witness';
1110
+ const out = { at: new Date().toISOString() };
1111
+ try {
1112
+ out.storageDomainPresent = ctx.get?.('storageDomain') !== undefined;
1113
+ out.diagnostics = diagnosticStatusLine();
1114
+ // What this host's preset mechanism IS, and what the roster says about the
1115
+ // chapters preset. These two lines are the difference between "the preset files
1116
+ // are installed" (true, and meaningless on a declaration-row host) and "the
1117
+ // preset exists" — the only thing a session cares about.
1118
+ out.registryRegisters = typeof ctx.get?.('agentPresets')?.register === 'function';
1119
+ out.presetRoster = roster?.state ?? 'not-checked';
1120
+ out.presetRosterLine = await describeMountedRows(ctx.get?.('agentPresets'), CHAPTERS_PRESET_ID);
1121
+ // The roster says a preset is declared; the inventory says whether its rows are
1122
+ // actually active. A row that resolves but will not activate leaves the preset on
1123
+ // the menu and unusable, and only this distinction names it.
1124
+ const invRaw = await ctx.get?.('agentPresets')?.compositionInventory?.()?.catch?.(() => undefined);
1125
+ const inv = inventoryEntry(invRaw, CHAPTERS_PRESET_ID);
1126
+ if (inv !== undefined) {
1127
+ out.presetInventoryRows = (inv.rows ?? []).length;
1128
+ out.presetUnusableRows = unusableRows(inv);
1129
+ if (inv.broken !== undefined)
1130
+ out.presetInventoryBroken = inv.broken;
1131
+ }
1132
+ if (roster?.state === 'absent')
1133
+ out.presetRosterRows = roster.rows;
1134
+ if (roster?.state === 'present' && roster.broken !== undefined)
1135
+ out.presetRosterBroken = roster.broken;
1136
+ if (roster?.state === 'unknown')
1137
+ out.presetRosterError = roster.error;
1138
+ const storageDomain = ctx.get?.('storageDomain');
1139
+ const store = preopened !== undefined ? makeDomainStore(preopened) : (await acquireChapterStore(storageDomain)).store;
1140
+ const prior = await store.get(WITNESS_SESSION);
1141
+ out.priorChapters = prior.chapters.length;
1142
+ if (prior.chapters.length === 0) {
1143
+ const reserved = reserve(prior, 'witness@1', 1);
1144
+ const record = {
1145
+ number: reserved.numbers[0] ?? 1,
1146
+ path: `.dsh-chapters/${WITNESS_SESSION}/chapters/001-witness.md`,
1147
+ title: 'witness', summary: `first boot ${new Date().toISOString()}`,
1148
+ startSeq: 0, endSeq: 0, topics: [], messages: 0, sha256: '0'.repeat(64), estimatedTokens: 0, artifacts: [],
1149
+ };
1150
+ await store.put(WITNESS_SESSION, appendChapters(reserved.state, [record]));
1151
+ }
1152
+ else {
1153
+ await store.put(WITNESS_SESSION, prior);
1154
+ }
1155
+ out.storedChapters = (await store.get(WITNESS_SESSION)).chapters.length;
1156
+ }
1157
+ catch (error) {
1158
+ out.error = String(error);
1159
+ }
1160
+ writeFileSync(marker, JSON.stringify(out, null, 2));
1161
+ }
1162
+ //# sourceMappingURL=index.js.map