@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
@@ -0,0 +1,938 @@
1
+ # DSH Plugin Contract
2
+
3
+ Verified against `examples/deepseek-harness` and the reference plugins. Every claim here cites a file
4
+ so it can be re-checked after an upgrade. **Nothing in this file is inferred.**
5
+
6
+ ## Three Schema Systems
7
+
8
+ Conflating these is the top time-waster in this project.
9
+
10
+ | Declaring | Format | Import |
11
+ |---|---|---|
12
+ | Plugin `Config` | `Schema.object({...})` | `import Schema from '@deepseek-ai/schemastery'` — **default** |
13
+ | Tool `parameters` | plain declarative spec (`ParameterSchemaSpec`) | none; `defineTool` from `@deepseek-ai/dsh-tools` |
14
+ | Storage-domain records | zod | `import { z } from 'zod'` |
15
+
16
+ Plain objects are *required* for tool parameters and *rejected* for plugin config.
17
+
18
+ `import { z } from '@deepseek-ai/schemastery'` **does not exist** — 0 occurrences upstream versus 128
19
+ default imports. Copying the named form fails to build.
20
+
21
+ ## Config
22
+
23
+ Shape copied from `examples/dsh-compact/src/index.ts:15,26-37`.
24
+
25
+ ```ts
26
+ import Schema from '@deepseek-ai/schemastery'
27
+
28
+ export interface Config {
29
+ continuationBudgetRatio: number
30
+ chapterTokenTarget: number
31
+ toolResultDeferFloorTokens: number
32
+ artifactStoreRoot: string
33
+ tocBulletBudgetTokens: number
34
+ pressureSuggestionRatio: number
35
+ }
36
+
37
+ // Per-model, mirroring the host's `modelPolicies[]` — do NOT invent a flat project-wide ratio.
38
+ export const Config: Schema<Config> = Schema.object({
39
+ compactAtRatio: Schema.number().default(0.90), // host thresholdRatio, ours defaults higher
40
+ continuationBudgetRatio: Schema.number().default(0.25), // of the window REMAINING after the header
41
+ chapterTokenTarget: Schema.number().default(8000),
42
+ toolResultDeferFloorTokens: Schema.number().default(200), // a floor, not the policy — see below
43
+ artifactStoreRoot: Schema.string().default('.dsh-chapters'),
44
+ tocBulletBudgetTokens: Schema.number().default(2000),
45
+ pressureSuggestionRatio: Schema.number().default(0.75),
46
+ })
47
+ ```
48
+
49
+ These are **defaults overridable per model**, matching the host's own `modelPolicies: z.array(modelPolicy)`
50
+ (`compaction-basic/src/index.ts:113`) — some models have huge windows, some have 32K, and one flat number
51
+ serves neither. Resolve the window with `ctx.llm.resolveModelInfo()` and keep our own keys alongside the
52
+ host's rather than shadowing them. Three notes on the defaults, so nobody trusts them as settled:
53
+
54
+ - `continuationBudgetRatio` is a share of **(window − system prompt tokens)**, never of the whole window.
55
+ See `docs/architecture.md § Budgets`. Measure the header once per process generation and cache it.
56
+ - `toolResultDeferFloorTokens` is **a floor, not the policy.** The keep/defer decision belongs to the model
57
+ (`toolResultOverrides` below); the floor exists only so trivial results stay inline without being
58
+ itemized, keeping the chapter's narrative readable and the exception list sparse. The number is a guess
59
+ to calibrate in Phase 1 — too high and meaningful short outputs get deferred, too low and chapters bloat.
60
+ - **One exception since 2026-09-19:** `toolResultArtifactTokens` IS a policy — a hard band for results too
61
+ large to be reasoning context at all (see architecture.md's arrival-time amendment). It intercepts at
62
+ the tail node before the next request composes (the pruner's mid-history rewrite is NOT this); the
63
+ model still judges everything below the floor, at archive time, exactly as before. Queries go through
64
+ `chapters_artifact` (toc | search | read), which is read-only and store-contained by construction.
65
+ - There is deliberately **no maximum.** Deferral is a judgement with a size floor; over-sized output is
66
+ caught by the continuation budget and by the chapter split signal, both of which refuse or report rather
67
+ than silently clip.
68
+
69
+ Note `forkThresholdRatio` from earlier drafts is renamed to `pressureSuggestionRatio`: with the reordered
70
+ plan it gates a *suggestion*, not an action.
71
+
72
+ ## Entry Point
73
+
74
+ ```ts
75
+ export const name = 'dsh-chapters' // must match package.json and cordis.patch.yml
76
+ export const inject = [/* service names */]
77
+ export const Config: Schema<Config> = …
78
+ export function apply(ctx: Context, config: Config) { … }
79
+ ```
80
+
81
+ ## Services (`inject`)
82
+
83
+ `dsh-session-fork/src/index.ts:161` shows the full menu: `commands`, `storageDomain`, `sessions`,
84
+ `sessionPersistence`, `sessionQuery`, `agents`, `tokenMeter`, `llm`, `sessionController`, `connection`,
85
+ `tools`, `systemPrompt`.
86
+
87
+ Ours, minimal and honest:
88
+
89
+ ```ts
90
+ export const inject = [
91
+ 'sessions', 'sessionPersistence', 'sessionQuery', 'storageDomain',
92
+ 'agents', 'tokenMeter', 'llm', 'tools',
93
+ ]
94
+ ```
95
+
96
+ - `agents` — `ctx.agents.create`, the continuation primitive. **The load-bearing one.**
97
+ - `storageDomain` — chain registry.
98
+ - `sessionQuery` — `observeSession` for the calling agent's own log and preset projection, plus the
99
+ durable reads for **other** sessions (see § Durable reads — the verbs are not interchangeable).
100
+ - `tokenMeter` — pressure math and the continuation budget preflight.
101
+ - **`systemPrompt` is excluded on purpose** (invariant 3).
102
+ - Add `commands` at the UI/CLI trigger phase; `connection` / `sessionController` only if a create path
103
+ turns out to need the client RPC round-trip.
104
+
105
+ Declare only what you use — `dsh-compact` gets by on `inject = ["commands"]`.
106
+
107
+ ## Durable reads — `sessionQuery` verbs are NOT interchangeable
108
+
109
+ Measured 2026-10-22 against host `0.2.0-rc.2` while root-causing #58. The rule this section exists to
110
+ enforce: **measure a host verb's payload *shape*, not its presence.** `spikes/subagent-end/FINDINGS.md`
111
+ finding 5 recorded `{count, types}` from `listEvents`, saw 10 plausible event types, wrote "returned the
112
+ full durable log", and the whole of door 5 was built on that. The types were real; the payloads were not
113
+ there at all.
114
+
115
+ | verb | returns | safe as an archive input? |
116
+ |---|---|---|
117
+ | `listEvents(sessionId)` | `SessionEventRecord[]` = `{sessionId, seq, type, time, surface}` — **no `data`** | **No.** Types and seqs only: a metadata index. |
118
+ | `readSession(sessionId)` | `{session: structuredClone(header), inheritedEventCount, events: snapshotSessionEvent(e)[]}` | **Yes**, and it carries the header too — one call, so no `listSessions()` scan. |
119
+ | `readSurface(sessionId)` | cloned header + `currentSurfaceEvents` (folded surface, snapshot-detached) | Yes for surface work; it is *folded*, so it is not the whole log. |
120
+ | `filterEvents(sessionId, filters)` | semantic event documents | No — search documents, not events. |
121
+
122
+ File:line (host install, `/home/adrian/node_modules/@deepseek-ai/dsh-session-query/`):
123
+ `lib/index.js:1141` `async listEvents(sessionId) { return eventRecords(sessionId, (await this._corpus.load(sessionId)).events) }`
124
+ → `:845-847` `eventRecords` → `analyzeEventLog(sessionId, events).records`, built at `:966-973` as
125
+ `records: events.map((event) => ({ sessionId, seq: event.seq, type: event.type, time: event.time, surface: … }))`.
126
+ Published type: `lib/types/types.d.ts:43-54` (`interface SessionEventRecord` — the doc comment says
127
+ *"Lightweight metadata for one event"*, which is the sentence the spike skipped).
128
+ `lib/index.js:1079-1087` `readSession` → `events: loaded.events.map(snapshotSessionEvent)`;
129
+ `snapshotSessionEvent = adoptSessionEvent(structuredClone(event))` at
130
+ `@deepseek-ai/dsh-session/lib/index.js:1106-1108`, i.e. a **validated, payload-bearing** clone.
131
+
132
+ Read the header, never infer it: `readSession().session` is the kernel's own `SessionHeader` clone, so
133
+ `origin` / `parentSession` / `agentPreset` / `cwd` come from the same observation as the bytes being
134
+ archived — which is what door 5's gate needs (#58). The header is *recorded* on the child's own row
135
+ (`subagent`/`parentSession` frontmatter), not used to re-link the tree — see docs/architecture.md
136
+ § The archive moments for why `linkChild` was measured and rejected.
137
+
138
+ And the host's own teardown ordering, which decides **which** arm runs:
139
+ `@deepseek-ai/dsh-subagent/lib/index.js:1240-1250` (continuable) — `flushFinalState` →
140
+ `await activation.handle.dispose()` → `resident.delete(childId)` → `observer.settle(failure)`, and
141
+ `settle` is what emits `subagent/end` (`:317-324`). `observeRun` (one-shot, `:296-306`) emits earlier,
142
+ before disposal. So a continuable child's live handle is **always** gone at settlement: the durable arm
143
+ is the only arm for it, and the fast path is the only arm for one-shot children. Both must be tested;
144
+ one measurement of one mode is what #58 is.
145
+
146
+ `SessionEventLike.data` is therefore **required** in `src/types.ts` (was optional). Optionality is the
147
+ reason a `listEvents` record passed as `SessionEventLike[]` type-checked, and the renderer's
148
+ `ev.data ?? {}` then made a hollow chapter legal instead of failing to compile.
149
+
150
+ ## Tools
151
+
152
+ `defineTool` at `packages/core/tools/src/schema.ts:545`:
153
+ `defineTool<const S extends ParameterSchemaSpec, const O extends ValueSchemaSpec>(...)`.
154
+
155
+ **`output` is mandatory** on every `ToolDefinition` — the `O` — and needs `{ schema, render }`. Idiom
156
+ from `dsh-session-fork/src/tools.ts:110-121`:
157
+
158
+ ```ts
159
+ function jsonOutput<T>(schema: ValueSchemaSpec) {
160
+ return { schema, render: (_args: unknown, value: T) => [{ type: 'text', text: JSON.stringify(value) }] }
161
+ }
162
+ ```
163
+
164
+ Definitions (names pending the rename decision — see `docs/architecture.md § Naming`):
165
+
166
+ ```ts
167
+ const chaptersContinue = defineTool({
168
+ name: 'chapters_continue',
169
+ description:
170
+ 'Archive this conversation as Markdown chapters and open a new session that starts from their '
171
+ + 'table of contents. This session is left untouched. Supply event ranges, not text.',
172
+ parameters: {
173
+ title: { type: 'string', required: true, description: 'Name for the continued session.' },
174
+ handoffNote: { type: 'string', required: true, description: 'State of play: what was in progress, decisions held, next step. As long as it needs to be — it is budget-checked, not truncated.' },
175
+ chapters: {
176
+ type: 'array', required: true,
177
+ description: 'Ranges ascending by startSeq, non-overlapping, all <= archiveCeiling, covering every event below the ceiling.',
178
+ },
179
+ },
180
+ output: jsonOutput(CHAPTERS_CONTINUE_RESULT),
181
+ async execute(args, exec) {
182
+ // caller agent from `exec`; refuse cleanly when absent
183
+ },
184
+ })
185
+ ```
186
+
187
+ Register host-side: `ctx.tools.register(tool)`. Call-site precedent:
188
+ `dsh-session-fork/src/index.ts:786`.
189
+
190
+ ## LLM Calls
191
+
192
+ Segmentation and summaries go through **`ctx.llm.stream()`** — the concrete method, not `ctx.llm`
193
+ generically. Window sizes via `ctx.llm.resolveModelInfo(provider, model, signal)`.
194
+
195
+ The cache-friendly pattern — a stream call reusing the conversation's own system prompt as its prefix —
196
+ is at `packages/compaction/compaction-basic/src/index.ts:225,290`. Copy that for segmentation so the
197
+ pass is served from cache.
198
+
199
+ ## Session Creation
200
+
201
+ **Never** `ctx.sessions.fork(...)`. *(Corrected by Phase 0 measurement — an earlier draft of this file
202
+ claimed the method was client-only. It is not.)* `ctx.sessions` does expose `fork`, `_forkSeed` and
203
+ `_resolveForkSource` host-side; calling `fork` from a plugin is refused:
204
+
205
+ > `session fork is owned by session-controller; ambient plugin access is denied`
206
+
207
+ So the prohibition holds for a better reason than we stated: the copy-the-log path is capability-gated
208
+ away from plugins, and the kernel handler seeds with
209
+ `commands.ts:260` → `seed: source.events.slice(0, cut)` (invariant 2). Do not go looking for a way
210
+ through the gate — `agents.create` is the sanctioned seam.
211
+
212
+ Ours — `seed` is optional per `packages/core/agent/src/index.ts:95`
213
+ (`readonly seed?: readonly SessionEvent[]`); the multi-event and one-event seed contracts are measured
214
+ in [spikes/probe/FINDINGS.md](../spikes/probe/FINDINGS.md). **Phase 0b rewrote this call**: `agents.create`
215
+ composes NO preset — `meta.agentPreset` is a durable label only (r15: child first request 974 tokens,
216
+ `tools: 0`, a tool-less agent that cannot `read` its own chapters). Composition goes through the `setup`
217
+ callback exactly as session-controller's `composeAgent` does it
218
+ (`api/session-controller/src/agent.ts:377-390`; the service itself says so at
219
+ `preset/agent-presets/src/index.ts:221`):
220
+
221
+ ```ts
222
+ ctx.agents.create({
223
+ sessionId: newId,
224
+ seed: [tocNoticeEvent], // exactly one event at seq 0 (invariant 2)
225
+ inheritedEventCount: 0,
226
+ meta: { cwd: parentCwd, agentPreset: presetId },
227
+ agentOptions: ctx.get('agentDefaultModel').currentSelection(),
228
+ setup: async (agentCtx) => { await ctx.get('agentPresets').mount(agentCtx, presetId) },
229
+ })
230
+ // then attach to the parent's workspace (r6 lesson: unattached sessions list nowhere)
231
+ ```
232
+
233
+ Without `agentOptions` every turn dies pre-provider (`{{model}}` no value, r10); without `setup` the
234
+ child has no tools (r15); with both, r16 measured the real composed head: 13,315 tokens / 27 tools.
235
+ Carry over from `dsh-session-fork`: `src/vendor/fork.ts` (`forkWorkspace`, `composeAgent`,
236
+ `readSessionState`) already follows this shape. `branch.ts:10-14` warns that kernel `SessionStore.fork`
237
+ yields fiber-scoped sessions evicted from the sidebar — `agents.create` is the only durable, listed,
238
+ resumable route. *(Phase 0 confirmed: durable, listed, resumable, byte-identical across a kill.)*
239
+
240
+ Keep `anchoredBoundaryOf` (`vendor/fork.ts:213`) with its role changed: it yields the **archive ceiling**
241
+ (`boundarySeq`, `cut`) for *what to render*, never for seeding.
242
+
243
+ Notice event shape — built and vocabulary-pinned by `src/notice.ts` (+ `test/notice.test.ts`, + the
244
+ v4 admission rows in `tests/unit/host-seed-contract.test.ts`): one `user/message`, `surfaceOp: 'append'`,
245
+ at `seq = 0`, `source { kind: 'plugin:dsh-chapters', form: 'snapshot', sections: [{ name: 'chapters:toc', text }] }`
246
+ — **producer-owned kind, no `plugin` field**. Session format v4 (host 0.2.x, measured 2026-10-18 on
247
+ `0.2.0-rc.2`) retired the bare `kind: 'plugin'` wrapper: persistence-row admission refuses it with
248
+ `format v4 message requires a producer-owned source kind`
249
+ (`@deepseek-ai/dsh-session-format-v3-to-v4` `lib/index.js:126`; also
250
+ `dsh-session-persistence-jsonl/lib/worker.cjs:10902`). The v3→v4 migration's own
251
+ `rewritePluginSource` does exactly what we now emit: "kind is the producer's own kind and whose
252
+ `plugin` field is dropped". Continuity validation in `Session.create`
253
+ requires the seq to be the next contiguous seed position (rejection message measured in r5).
254
+
255
+ ## Compaction Engine Integration (Phase 0b)
256
+
257
+ The subclass seam, with everything measured by rounds 12-17 (sources: the installed
258
+ `@deepseek-ai/dsh-compaction-basic@0.1.5-rc.2`; same tree in `examples/deepseek-harness`):
259
+
260
+ - `class X extends BasicCompactionEngine` (`export default` it; the loader mounts class plugins directly)
261
+ and override `protected summarize(input, agent, signal?)` — the **sole** customization hook; `compactRegion`,
262
+ `compactIfNeeded`, `compactNow` stay inherited. Subclassing inherits `static inject
263
+ = ['llm','tokenMeter','sessions']` — extend it or `ctx.agents` throws *cannot get property "agents" without
264
+ inject* (r12 crash).
265
+ - Deterministic summaries use the unmarked `SummaryResult` branch: `{ summary, provider, model }` with
266
+ **no `llmStreamCall`** — the union explicitly admits "template, remote, or other summarizer"
267
+ (`compaction-basic/src/summarizer.ts:100-106`); the durable `compaction/summary` schema matches
268
+ (`compaction/src/types.ts`). Omit `usage` — its absence is the zero-token record (r12 verified).
269
+ - `summarize()` gets content, not seqs: `SummarizationInput = { tools?, messages }` where messages are the
270
+ system head (when present) + the region's derived messages (`region.ts:545`). Correlate chapter files
271
+ to ranges **post-commit** from `CompactionResult.shadowedSeqs` / the `compaction/summary` event — and
272
+ remember the manual path calls `compactSurfaceRegion` directly (`index.ts:382`), so overriding
273
+ `compactRegion` sees only the automatic paths.
274
+ - The kernel enforces the transaction: `compaction/start` lock, `SurfaceChangedError` → `changed`,
275
+ shrink floor `framed ≥ priced` → refuse (`code='summary'`, r12), replacement `user/message` carries
276
+ `source { kind: 'plugin', plugin: 'compact', compactionId }` — a NEW vocabulary member to pin against
277
+ (our notices use `plugin: 'dsh-chapters'`; the checkpoint's is the host's own).
278
+ - Config: `retainRatio ∈ (0, 1]` (rc.2 validates; boot dies), `retainTokens ≥ 0`, `retainRatio <
279
+ thresholdRatio`, `modelPolicies[]` per exact target, `auto` default true. Mounting shape per
280
+ `docs/host-compaction-seam.md`: OUR row goes inside a preset's `cordis:group` realm
281
+ (`isolate: { compaction: true }`), where `ctx.get('toolResultPruner')` also resolves in-realm —
282
+ the standard preset deliberately mounts basic + command-compact + pruner as one group.
283
+ - Cross-copy identity rules (we ship our own `0.1.5-rc.2` deps; the host runs its copies):
284
+ `ctx.get('compaction')` returns the host's receiver proxy — property reads work, `=== this` never
285
+ (r12); duplicate provision of `compaction` on one plane fails boot loudly (r14); a host-plane
286
+ `command-compact` does not classify OUR `ManualCompactionError` (different class objects) — put the
287
+ command in our own realm rows or ship our command (r12 §J).
288
+
289
+ ### Mounting shape, measured (stages 0-3)
290
+
291
+ - Realm rows resolve **subpath exports**: `name: dsh-chapters/engine` →
292
+ `exports["./engine"]` (r18). Host-plane mounting is the wrong shape (double-fire
293
+ with preset realms; the kernel's duplicate guard is same-plane only).
294
+ - The loader passes row config to constructors **unstripped**, and the base's
295
+ `resolveConfig` **rejects unknown keys at runtime** (`BasicCompactionConfig:
296
+ unknown key "artifactStoreRoot"`, r18) — peel engine-specific keys in the
297
+ subclass constructor before `super(ctx, baseConfig)`.
298
+ - Preset delivery: the plugin registers the `chapters` declaration row at activation
299
+ (`src/preset.ts` -> `ctx.get('agentPresets').register(...)`). The old copy of
300
+ `presets/chapters/` into `$DSH_HOME/.agent-presets/` is GONE with the 0.2.0 support
301
+ floor; the section "Presets are declaration rows" records why the row is not a shipped
302
+ patch layer (`!!js` evaluates `disabled` before services mount, and an array
303
+ `dsh.bundle.patch` throws on a pre-0.1.7 host). Related: profile `!!js` reaches
304
+ `dshHomePath`/`process` but NOT `require` (r20 boot-killer).
305
+ - `ctx.logger` output is invisible to headless `dsh web` (r19); engine failures
306
+ mirror to `$DSH_CHAPTERS_ENGINE_ERRORS` when set. Engine finalization runs on
307
+ BOTH the success and rejection paths of `compactIfNeeded`/`compactNow` — the
308
+ retry loop can commit then throw, and success-path-only finalization strands
309
+ committed records (measured: 3 plans / 1 finalized / no errors).
310
+ - `@deepseek-ai/dsh-home-paths` exports `dshHomePath(...segments)` — the sanctioned
311
+ way to reach `$DSH_HOME` from plugin code (host rc.2 line).
312
+
313
+ ## Lineage: Kernel Fields We Must Not Lean On
314
+
315
+ `SessionHeader` (`packages/core/session/src/types.ts:93-122`) carries `version`, `id`, `createdAt`,
316
+ `cwd`, `parentSession`, `isSeeded`, `origin`, `delegationDepth` — and **no system prompt or tool
317
+ schemas**.
318
+
319
+ - `isSeeded` means "contains a fork-inherited event prefix"; our one-event synthetic seed is not that.
320
+ - `parentSession` is documented as *seed lineage*. Setting it to fake a relationship would be a misuse.
321
+ - Therefore **our storage-domain registry is the sole authority on ancestry**, and the web UI's branch
322
+ graph will show our child as an unrelated root. A chain/manifest view is this plugin's job.
323
+ - The header being composed per request from the preset plus plugin contributions is *why* invariant 3
324
+ holds, and why cache validity is per-process-generation: a restart or config edit recomposes it for
325
+ everyone.
326
+
327
+ ## Storage Domain Registry
328
+
329
+ Precedent verbatim: `examples/dsh-session-fork/src/store.ts:36-43`.
330
+
331
+ ```ts
332
+ import { z } from 'zod'
333
+ import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'
334
+
335
+ const chapterRecord = z.object({
336
+ id: z.string(), // `${authorSession}/${n}`
337
+ authorSession: z.string(), // keying by creator, not by a linear chain
338
+ path: z.string(),
339
+ startSeq: z.number().int().nonnegative(),
340
+ endSeq: z.number().int().nonnegative(),
341
+ sha256: z.string(), // integrity: verified before citing
342
+ tokens: z.number().int().nonnegative(),
343
+ summary: z.string(),
344
+ createdAt: z.string(),
345
+ })
346
+
347
+ const sessionState = z.object({
348
+ parentSession: z.string().nullable(),
349
+ rootSession: z.string(),
350
+ nextChapterNumber: z.number().int().nonnegative(),
351
+ chapters: z.array(chapterRecord),
352
+ })
353
+
354
+ export const chapterDomainSpec = defineDomain({
355
+ name: 'dsh_chapters',
356
+ version: 0,
357
+ tables: { sessions: domainTable<string, SessionState>(sessionState) },
358
+ })
359
+ ```
360
+
361
+ Open with `ctx.storageDomain.open(spec)` → `table(name).get/put/entries/size`. `put` reaches backend
362
+ durability before resolving, so a resolved save survives restart. Backend is JSON under
363
+ `~/.dsh/storages`.
364
+
365
+ ## Chapter File Format: Markdown + Frontmatter, Not XML
366
+
367
+ Decision with reasoning in `docs/architecture.md § File Format`. Short form: `.md` with YAML frontmatter.
368
+
369
+ ```markdown
370
+ ---
371
+ chapter: "003-database-schema"
372
+ title: "Database Schema"
373
+ session: 01HTZ6Q8K3
374
+ root: 01HTAB12CD7
375
+ seqRange: [184, 231]
376
+ sha256: 6d59…
377
+ tokens: 7420
378
+ generatedAt: 2026-09-15T17:22:04Z
379
+ artifacts: 2
380
+ ---
381
+
382
+ # Database Schema
383
+
384
+ **User:** Let's design the user table…
385
+ ```
386
+
387
+ Frontmatter carries every machine-queriable fact a parser would want. The body stays token-dense prose.
388
+ The `read` tool returns both; the harness's own skill loader uses this same frontmatter-plus-body idiom,
389
+ so it is an established convention here. **Do not convert chapters to XML** — see
390
+ `docs/architecture.md § File Format` for the full rationale and the one case that would reopen it.
391
+
392
+ ## Packaging
393
+
394
+ `cordis.patch.yml` is required for the bundle to mount — a one-entry list that `insert`s the package by
395
+ name (compare `examples/dsh-session-fork/cordis.patch.yml`). Its `name` must match `package.json`. In
396
+ `package.json` the patch appears in **three** places:
397
+
398
+ ```jsonc
399
+ "main": "lib/index.js",
400
+ "exports": { ".": {…}, "./cordis.patch.yml": "./cordis.patch.yml", "./package.json": "./package.json" },
401
+ "files": ["lib", "cordis.patch.yml"],
402
+ "dsh": { "bundle": { "patch": "./cordis.patch.yml" } }
403
+ ```
404
+
405
+ Entry `apply(ctx, config)`. Build with `tsc -p tsconfig.json` and **build before installing**. A client
406
+ UI (later phase) additionally needs `dsh.client` and a `tsdown.client.config.ts` — see
407
+ `dsh-session-fork/package.json` for the shape.
408
+
409
+ ### An armed goal has no rest state — measured, with the lines that prove it
410
+
411
+ Verified while trying to make a goal-bearing tape leg deterministic (#138). The round driver gates on
412
+ `phase === 'active' && activation === 'armed'` plus agent idleness
413
+ (`@deepseek-ai/dsh-goal-round-driver` `src/index.ts:105-117`), and a round is itself a turn — so every round ends, and
414
+ the next is scheduled. There is no moment in which an armed goal is also at rest:
415
+
416
+ - **spending the budget ends the goal.** At `:182-188`, `roundsStarted >= maxGoalRounds` calls
417
+ `ctx.goals.block(agent, ref, { code: 'round-limit', message: 'Goal reached its configured limit of N rounds.' })`.
418
+ - **the strip then disappears.** `@deepseek-ai/dsh-client-ui-goal` `GoalBar.tsx:85-86`: *"Loading, absent, and complete
419
+ goals have no strip at all"*, and the guard excludes blocked/complete. So the control a test wants to observe
420
+ (`Pause goal` → `Resume goal`) is not rendered in the state the budget produces.
421
+ - **the per-goal budget is a plugin argument, not a user one.** `ctx.goals.create(agent, { objective, maxGoalRounds })`
422
+ (`@deepseek-ai/dsh-goal` `src/index.ts:303`, `types.ts:28-30`) — the human `/goal` command passes no budget
423
+ (`dsh-command-goal` usage is `goal <objective>, /goal clear`; `command-goal/src/index.ts:150` calls `create` without
424
+ one), so the default 256 applies.
425
+
426
+ Consequences for this repo, both measured: a goal-bearing tape leg agrees with a runner only while the runner's latency
427
+ happens to reproduce the capture's round count (staging's `prompt` leg failed twice on docs-only pushes:
428
+ `MISS tape=prompt entries=23 storedCandidates: []`, runs 40697174778 / 40706037817); and bounding the budget to 1 makes
429
+ the goal vanish before a leg can act on it (17-entry tape, `Round: 1/`, no second round, `the host goal bar appears once
430
+ a goal exists`). Upstream's own test fixture takes the third path — create then `ctx.goals.disarm(agent)`, noting
431
+ *"An armed goal makes the shipped goal-round-driver start a round at idle, racing the test driver's disposal"* — which
432
+ is the disarmed state, not the armed one a displacement claim needs.
433
+
434
+ What is needed to test this in a tape: a way to open an **armed** goal whose driver will not fire until released, or a
435
+ non-terminal exhausted state that still renders the control (#138). Until then the goal/suggestion interplay is
436
+ verified live under a wall-clock bound (`tests/e2e/prompt-goal-live.spec.ts`), not by the model-free acceptance job.
437
+
438
+ ### A profile patch may not re-declare a host plugin's row — measured on 0.2.0-rc.2
439
+
440
+ `profiles/web/cordis.patch.yml` layers by row `id`, and the rig relies on that for its **own** rows
441
+ (`preset-chapters`, `agent-preset-registry`, `llm-pi-ai`, `agent-default-model`, `llm-gate`,
442
+ `fetch-timeouts`). Copying that trick onto a row that belongs to the installed bundle is not neutral. Three variants,
443
+ each run against an unchanged spec and corpus so the only variable was the patch:
444
+
445
+ | profile patch | result |
446
+ |---|---|
447
+ | `- id: goal` + `config: { defaultMaxGoalRounds: 1 }` | the `goal` plugin stops working: `/goal` creates no goal, so `[data-goal-bar]` never renders |
448
+ | `- id: goal` with **no** config | every test passes, but the boot issues a request no tape has: `MISS tape=prompt entries=23 messages=1 closest=0000.json` — at the FIRST message, before any goal exists |
449
+ | no `goal` row in the profile patch | clean run (`5 passed`, rc 0) |
450
+
451
+ Read it as: duplicating a host row changes the mounted plugin set (extra registration → an extra request), and adding a
452
+ `config:` block to that duplicate replaces the base row rather than merging into it, taking the plugin's real config
453
+ surface with it. So the patch is a way to configure **rows you own**, not a way to reach into a host plugin's settings.
454
+ The concrete consequence: `@deepseek-ai/dsh-goal` does export
455
+ `config = z.object({ defaultMaxGoalRounds: z.number().default(256) })` and `resolveCreateGoal(request,
456
+ defaultMaxGoalRounds)` honors it, but no boot can pin it from the profile — which is why goal-round bounding is a host
457
+ ask (#138) and not a rig bug to fix.
458
+
459
+ ### Presets are declaration rows now (host ≥ 0.1.7-alpha.1) — measured, with the incident
460
+
461
+ A user preset used to be a directory `$DSH_HOME/.agent-presets/<id>/` holding `preset.yml` +
462
+ `agent.cordis.yml`. The host's own migration note is blunt: *"Nothing reads that directory any more."*
463
+ (`@deepseek-ai/dsh-agent-preset/skills/editing-cordis-compositions/SKILL.md`). A preset is now an
464
+ `@deepseek-ai/dsh-agent-preset` **declaration row** (`config: {id, name?, description?, order, plugins}`;
465
+ `dsh-agent-preset/lib/index.js:13-20`) carried by a patch layer, and `AgentPresetRegistry.register()` is
466
+ fed only by such rows (`dsh-agent-preset-registry/lib/index.js:470`, installed 0.2.0-rc.2). Nothing
467
+ crashes when a plugin misses this: the bundle boots, its tools register, Settings says "enabled and
468
+ running", the preset is simply absent — and a session's preset id is **durable state** (`resumeObserved()`
469
+ → `presets.resolve(id)`, `dsh-api-session-controller/lib/index.js:404`, `:364`; `resolve()` throws
470
+ `agent-preset/not-found`, `dsh-agent-preset-registry/lib/index.js:596`), so every chapters session refuses
471
+ to OPEN while its log sits there intact.
472
+
473
+ **This bundle DOES ship the row — in its one existing patch layer, with our own row module.**
474
+ `cordis.patch.yml` ends with `- insert: [{id: preset-chapters, name: '@fractalsynapse/dsh-chapters/preset',
475
+ config: <the preset definition>}]`, so the preset is an ordinary Loader entry: visible in the plugin
476
+ list and `dsh --dump-default-config`, overridable or disable-able from a profile patch, positioned by
477
+ `config.order`. The row module (`src/preset.ts`) mirrors `@deepseek-ai/dsh-agent-preset`'s plugin line
478
+ for line and adds the pruning described below. Why not the host package directly, measured on the
479
+ 0.1.5-rc.1 closure and on 0.2.0-rc.2:
480
+
481
+ - `dsh.bundle.patch` as an **array** (what a second layer requires) dies at profile load on a pre-0.1.7
482
+ host: `TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of type string. Received an
483
+ instance of Array` (`dsh-app-boot/lib/index.js:853`, `join(packageDir, declared)`). Array support and
484
+ declaration rows arrive in the SAME release, 0.1.7-alpha.1 — so one layer, still a string, is both
485
+ enough and the only boot-safe shape.
486
+ - a row naming `@deepseek-ai/dsh-agent-preset` in ANY layer a legacy host reads aborts its plugin tree:
487
+ `plugin tree failed to load: … failed to import loader entry … Cannot find package` (the package is
488
+ first published at 0.1.7-alpha.1). A row naming our own module resolves wherever our package runs.
489
+ - **`disabled: !!js` cannot capability-gate a root row**: the Loader evaluates `disabled` before services
490
+ are mounted (`cordis-plugin-loader/lib/index.js:348` → `with (ctx) { eval(expr) }`), so
491
+ `get('agentPresets')?.register === undefined` is true on the MODERN host too and the row never
492
+ registers — measured (`preset-v2 … failed to import`, roster without it, while the same row with
493
+ `disabled: !!js false` registered normally). Bare `get(...)` also throws there; only `ctx` is bound.
494
+ - and the row must be *ours* anyway for the reason below: it is the one place that can drop a child the
495
+ host cannot load before the mount is attempted.
496
+
497
+ Three consequences of reading the registry rather than the filesystem:
498
+
499
+ - **A row that does not resolve is dropped, never mounted.** `mountPreset` throws if ANY enabled row failed
500
+ (`dsh-agent-preset-registry/lib/index.js:272`), the registry stores it as `record.broken` (`:541`), and
501
+ `retain()` then refuses with `agent-preset/invalid` (`:651`) — so one stale row makes the whole preset
502
+ unusable, which is the same user-visible failure as a missing preset. Measured on 0.2.0-rc.2 with a row
503
+ for `@deepseek-ai/dsh-tool-schedule` (published only from 0.2.1-alpha.1):
504
+ `preset "chapters" is DECLARED BUT BROKEN — tool-schedule …: never started`. `pruneResolvableRows`
505
+ (`src/preset.ts`) drops such rows and NAMES them in the mount line; `resolvableFromThisPackage` is the
506
+ probe, asked with the row's full specifier so a lost subpath export is caught too.
507
+ - **A held disposer, not an effect.** `register()` returns an unregister disposer, and wiring it through
508
+ `ctx.effect` un-registers the preset the moment our activation finishes — measured: `register()`
509
+ succeeded while the roster came back WITHOUT `chapters`. The same apply-fiber trap bit the storage
510
+ domain in r35. The disposer is held in a module array instead.
511
+ - **The roster is the health check, and it must be asked after activation.** The registry re-audits a mount
512
+ by awaiting the settled loader tree (`:562`), and its doc says callers "must not run inside a Host row's
513
+ own activation, which the settlement would wait on". So `reportPresetRoster()` runs deferred, and
514
+ `compositionInventory()` rows say which mounted rows are actually `active` — `broken` alone hides a row
515
+ that resolved but never started.
516
+
517
+ Rows are renamed and dropped between host versions, so re-anchor `src/preset.ts` against the installed
518
+ host's own `@deepseek-ai/dsh-web-app/presets/standard.patch.yml` after an upgrade. Measured drift across
519
+ the five published versions (rc.1, rc.2, alpha.1, alpha.2, alpha.4): alpha.1 ADDS `dsh-time-context` +
520
+ `dsh-tool-schedule` + `toolFilter.deny[schedule_*]`; alpha.2 DROPS the persona `suffix` (with its
521
+ `{{cwd}}`), `backgroundMode`, and the codex/claude-code/ralph rows; alpha.4 rewrites the plan-mode
522
+ sentence, drops `toolFilter`, adds `dsh-plugin-market`. rc.1 and rc.2 are byte-identical.
523
+ `tests/unit/preset-declaration.test.ts` is what turns the next rename into a red row instead of a broken
524
+ user session — and it pins that `dsh.bundle.patch` is still a string.
525
+
526
+ - **Prompt variables are a closed set, and violating them throws per turn, not at boot.** `dsh-agent-loop`
527
+ registers `provider`/`model`/`cwd` through 0.2.0-rc.2 (`lib/index.js:1564-1566`) and only `provider` and
528
+ `model` from 0.2.1-alpha.2 (`:1591-1592`); `interpolate()` rejects any other reference with `prompt
529
+ variable "{{x}}" has no value for this assembly` (`dsh-system-prompt/lib/index.js:171`). There is no
530
+ `{{cwd}}` worth writing: `dsh-working-directory` (host plane) already prints `Current working directory:
531
+ "…"` into the runtime context. This is what broke every new chapters session immediately AFTER the
532
+ delivery fix above — the preset composed fine, the legacy `persona.suffix` interpolated `{{cwd}}`, and
533
+ assembly threw on every turn. Pinned by the `{{variable}}` row of the test.
534
+ - **Some host plugins reject unknown config keys**, so a preset row's config is not free-form:
535
+ `dsh-compaction-tool-result-pruner`, `dsh-compaction-basic` and `dsh-plan-mode` throw on any key they do
536
+ not know (`PlanModeConfig has unknown key(s) …`); `dsh-tool-subagent` and `dsh-tool-ralph` are plain zod
537
+ objects that strip them. Keep exactly `{thresholdChars, headChars, tailChars}` on the pruner and exactly
538
+ `{section}` on plan mode.
539
+ - **Persona wording is load-bearing for the engine.** `src/engine-core.ts`'s `PLOT_TEMPLATE_ECHO` suppresses
540
+ a "plot" that is really our own instruction quoted back (the 2026-09-25 audit: eight checkpoints of a
541
+ real run inherited boilerplate, and because the result was non-null the bounded elicited fallback never
542
+ ran). It matches phrases inside the suffix, so reword the suffix and the guard in the same change — the
543
+ gate asserts that coupling by behaviour, not by copying the regex.
544
+
545
+ ## Trigger Constraints
546
+
547
+ Two different actions, two different timings — Phase 0b measured the seam so this split is now factual,
548
+ not stylistic:
549
+
550
+ - **In-place compaction** (the engine's job) fires at the base's own timings, which we inherit:
551
+ `agent/pre-step` pressure and `agent/request-error` overflow recovery
552
+ (`compaction-basic/src/index.ts:144,176`). Pre-step is BETWEEN steps, never mid-request; the pressure
553
+ loop is retry-bounded and its failures are caught, warned, and the turn continues (r13 watched it).
554
+ An earlier draft called `agent/pre-step` "mid-turn, breaks the in-flight request" — the shipped design
555
+ contradicts that reading; the harness itself compacts there, replay-safely. One structural limit worth
556
+ remembering: `compactIfNeeded` needs a durable routed request, so the FIRST step of a fresh session can
557
+ never compact (index.ts:260, measured r13).
558
+ - **Session creation** (`chapters_continue`, `chapters_fork`) fires at `turn/end` boundaries only — an
559
+ archive ceiling is a completed `turn/end` by construction, and an in-flight exchange belongs to the
560
+ handoff note, never a mid-turn cut.
561
+ - Manual is MVP. The button path cannot supply model-authored titles or ranges, so segmentation must be
562
+ a plain function callable **without an agent turn** — build that seam in Phase 1, not Phase 5, or the
563
+ UI and automatic paths both force a rewrite.
564
+
565
+ > **SUPERSEDED as a surface by § "Suggested Next Step bar" below** (#113, chip removed in #114). The facts in
566
+ > this section stay authoritative and are the reason the replacement is a docked row: the overlay geometry,
567
+ > the non-writable placeholder, and `inputActions.setDraft`/`submit` were all measured here first. Reviving an
568
+ > overlay must answer all four defects #113 recorded, with measurements.
569
+
570
+ ## Composer Ghost Layer — measured host facts (#71, verified 2026-10-21 against `examples/dsh` @ 0.2.1-rc.2; runtime pin 0.2.0-rc.2, compile deps still 0.1.5-rc.2)
571
+
572
+ The prompt-recommendation feature renders a ghost layer in the composer. What the host does
573
+ and does not let a plugin do, with anchors:
574
+
575
+ - **The placeholder text is NOT plugin-writable.** `placeholderText` is computed inside the
576
+ host's `InputBar.tsx:363-372` (`packages/client/ui-conversation/src/client/skeleton/`): an
577
+ owner prop (passed only by the host's own `ConversationContent`) ?? offline/disabled/steer
578
+ states ?? `useProjection('plan')` swap ?? locale defaults. Every string is locale-owned
579
+ (`client/locales.ts`, enforced upstream by `verify-client-ui-i18n`); no config key, slot,
580
+ projection input, or service feeds a plugin value into that chain. Plan mode swaps it from
581
+ host code (`InputBar.tsx:67-68`) — mechanism precedent, not an extension point.
582
+ - **The composer overlay slot is the hole.** `'conversation.input.overlay'` — "floating
583
+ entries rendered inside the resident composer card", `{ kind: 'list'; scope: 'session' }`
584
+ (`contract/slots.ts:271-272`), rendered at `InputBar.tsx:404` inside
585
+ `[data-composer-overlay]`. The shipped slash-menu occupies it
586
+ (`ui-input-trigger/src/client/index.ts:65`) — registering there is a proven path, and
587
+ `slots.inject(name, …)` waits for the declaration, same as our assistant-actions door rows.
588
+ - **Standard session props give the composer machine.** Session-scope slot entries receive
589
+ `useConversation` / `useInput` / `inputActions` (`contract/slots.ts:329-336`); `InputActions`
590
+ carries `setDraft(text)` ("replace the whole draft with plain text"), `insertText`,
591
+ `persistDraft`, `submit` (`contract/input.ts:263-291`), and `InputState.draft` is the
592
+ published draft text (`contract/input.ts`, `InputState`). `useProjection` is on
593
+ `SessionStandardProps` (`ui-session/src/client/index.ts:166-173`) for the 0.2.x push upgrade.
594
+ - **ArrowRight is free.** The composer keymap registers `KEY_ARROW_UP/DOWN`, `KEY_TAB`,
595
+ `KEY_ESCAPE`, `KEY_SPACE`, `KEY_ENTER` at `COMMAND_PRIORITY_CRITICAL`
596
+ (`input/editor/keymap.ts:104-140`); `grep ArrowRight` over `ui-conversation/src/client` is
597
+ empty on both 0.1.5 and the 0.2.1 checkout. `ComposerKeyboard` itself is
598
+ "InputBar-exclusive … never across a plugin boundary" (`contract/draft-editor.ts:54-58`),
599
+ so the plugin's accept gesture attaches a capture-phase `keydown` listener at the DOM level
600
+ while its ghost is visible — fail-open: if the host ever claims the key or reshapes the DOM,
601
+ the click-to-accept path still works and nothing else breaks.
602
+ - **Host→client pull channel: `remote.commands.execute(sessionId, line, [])`**, the exact wire
603
+ the door buttons ride (`src/client/doors.ts`, Playwright-verified; result arm
604
+ `{value.result.kind/text}`). New **wire events are NOT available to plugins**: the forwarded
605
+ allowlist is app-owned ("the one home of this application's forwarded-Host-event allowlist",
606
+ `packages/api/remotes/src/remote-events.ts:24`).
607
+ **Every execution is recorded** (measured, `packages/interaction/commands/src/index.ts:365-385`):
608
+ `command/run` + `command/done` lifecycle events, log-only (never model surface) but rendered as
609
+ history nodes by the web UI; `recordInput: false` (definition flag, `index.ts:71-75`) suppresses
610
+ only the `args` payload, NOT the node pair. There is no quiet-invocation mode — a polling client
611
+ on this wire writes visible history every pull, which is why the bar's cadence is a tested
612
+ decision (#193) and the push swap (#194) is the structural fix.
613
+ - **Push transport is 0.2.1+ (measured 2026-10-30, #193 — this line said "0.2.x-only" and was
614
+ wrong):** `appendPluginRecord` has **zero occurrences** at tag `dsh-v0.2.0-rc.2`
615
+ (`packages/core/session/src/index.ts`) and is present at `dsh-v0.2.1-rc.2` (`:492`). The pinned
616
+ rig host (0.2.0-rc.2) therefore CANNOT serve the push swap; #194 gates on a host-pin bump plus
617
+ feature detection with pull fallback. `appendPluginRecord(session, 'plugin:<name>', data)`
618
+ (`packages/core/session/src/index.ts:492`, grammar/types at `types.ts:525-543`, reader
619
+ `pluginRecordOf` at `index.ts:508`): durable, append-only, "never enters the model-visible
620
+ surface", retained by fork/resume, skipped by unknown readers. The `verify-plugin-record-callers`
621
+ gate is a **source-tree policy inside the dsh repo** ("rejects any other production caller
622
+ *in this repository*") — it does not and cannot gate an external plugin, and the `plugin:`
623
+ grammar is precisely the external-plugin carrier. `PluginRecordMap` is declaration-merged
624
+ (`export interface PluginRecordMap {}`, `types.ts:533`); a `ProjectionDefinition` registered
625
+ on `ctx.sessionProjections` (the plan/goal pattern; our host plane ALREADY injects
626
+ `sessionProjections`) folds committed events to client-pushed values. Both APIs are absent
627
+ from our installed `@deepseek-ai/dsh-session@0.1.5-rc.2` (grep-verified on `lib/types`),
628
+ which is why #71 ships the PULL transport; the push swap is the backlog item, and it needs
629
+ the compile-time `@deepseek-ai/*` closure moved to 0.2.x alongside the runtime pin.
630
+
631
+ ## Client Sessions Service: which verbs exist (measured 0.2.0-rc.2, #77)
632
+
633
+ The client service injected as `sessions` (`dsh-api-session-controller/lib/types/client/sessions/
634
+ service.js`) is a **session store and lifecycle**, not a navigator. Its verbs are
635
+ `create / fork / using / retain / retainInfo / release / refresh / projectList / binding /
636
+ handleSessionAdded / handleSessionRemoved / handleSessionActivity / …`. There is **no
637
+ `sessions.open`** — a guard written as `if (typeof sessions.open !== 'function') return false`
638
+ fails silently on every call, which is exactly how the chapters door spent three evenings looking
639
+ like a client that refused to catalogue a plugin-created child.
640
+
641
+ The navigation verb lives on the workspace service (`dsh-client-ui-workspace/lib/client.js`):
642
+
643
+ ```js
644
+ openSession(target) { this.replaceMain(target, this.lifetime.signal, "reveal"); }
645
+ ```
646
+
647
+ and the host's own fork affordance uses it — `dsh-client-ui-chat/lib/client.js`, right after its
648
+ fork RPC resolves: `.then((childId) => { ctx.uiWorkspace.openSession(childId); })`. So a plugin
649
+ that must move the visible session injects **`uiWorkspace`** and calls `openSession(sessionId)`;
650
+ `sessions.*` cannot do it. `sessions.using/retain` materialize a scope for a session the client
651
+ addresses; they do not change what is displayed.
652
+
653
+ Two corollaries, both bought once:
654
+ - A client capability you assume is a client capability you must assert at the decision point —
655
+ `src/client/doors.ts` now prints which verbs were actually present when it decided
656
+ (`+c/+u/+o/+w`), because a note that blames the user's client for the plugin's own missing port
657
+ sends the next reader down the wrong path.
658
+ - An injected port that is optional and stops being wired fails as a symptom, not as an error:
659
+ `tests/unit/client-doors.test.ts` has a row that fails if `src/client/index.ts` drops
660
+ `uiWorkspace` from `inject` or stops passing it.
661
+
662
+ ## `ContinuePorts.mutateState` — the row write seam (#101)
663
+
664
+ `ContinuePorts` exposes **both** `getState`/`putState` and `mutateState(sessionId, fn)`, and the split is
665
+ load-bearing, not historical:
666
+
667
+ - `getState` is for **reads** (ancestor walks, root resolution) and for nothing else.
668
+ - `putState` survives only for **brand-new rows** — a child row that no other writer can hold yet
669
+ (`linkChild(freshSession(childId), …)` at `continue-core.ts:527`).
670
+ - **Every write to an EXISTING row goes through `mutateState`**, because `store.put` replaces the whole
671
+ value: a `getState`→`putState` pair loses any commit another door made in between. The chapter allocator
672
+ (`reserve`, `reservePlan`) runs *after* hundreds of fs writes, and the commit runs after `agents.create`
673
+ plus the artifact work — both are windows. AGENTS records the incident class: an enrichment put at
674
+ 04:53:52.841 rewound a compaction commit from 04:53:50.915, deleting its manifest, reservation,
675
+ `finalized` entry and chapter record while its 960 KB file survived on disk. Files present + records
676
+ gone is the signature, and `chapters-repair` then looks like a phantom-maker.
677
+
678
+ `fn` must apply its change to the row it is handed and may not return a captured snapshot — a
679
+ `mutate(id, () => snapshot)` is a stale `put` with better branding (fixed once already in `repair-run.ts`
680
+ for the hollow-payload supersede).
681
+
682
+ ## Suggested Next Step bar
683
+
684
+ > **Host fact, measured 2026-10-28 (the draft gesture's limit, and where it actually lives).** The composer surface a
685
+ > dock receives carries `captureInsertion, insertText, setDraft, persistDraft, addAttachments, removeAttachment,
686
+ > pruneAttachments, submit` — every verb is present and callable — and a programmatic write still throws
687
+ > `Cannot read properties of undefined (reading 'draftRev')` with the composer text left empty. Reproduced identically
688
+ > on host `0.2.0-rc.2` and `0.2.1-rc.4` (`var/e2e-prompt-draft-dump.json`, `LEG=prompt MODE=record`), which is the
689
+ > evidence behind #126 staying a host ask: **`draftRev` is not referenced anywhere in `ui-conversation/src`** in either
690
+ > tag — the identifier lives in `ui-commands/src/client/popup.ts` — so the write path's missing revision object is not
691
+ > the module whose `setDraft` implementation changed between those versions. Do not infer a fix from that module's diff;
692
+ > the source read looked convincing and the browser said otherwise. The shipped client therefore refuses visibly and
693
+ > keeps Run as the working gesture (`docs/verify.md`'s "delivered, not assumed").
694
+
695
+
696
+ **What a dock component is handed (measured from the host's own contract table,
697
+ `dsh-cordis-client-runner/lib/client.js`, entry `key: "conversation.input.dock"`).** It is a `list` region,
698
+ `scope: session`. Register options are `id` (required), `order` (optional, ascending, default 0), and `label`.
699
+ The component's props are **hooks**, not scalars: `useSessions`, `useSessionStatus`, `useSessionRetainInfo`,
700
+ `useWorkspaces`, `useChat`, `useConversation`, `useInput`, `inputActions`, `useSession`, `sessionId`,
701
+ `useProjection`, `useTrajectory`. Shipped occupants: `todo` (order 0), `goal` (10), `queue` (20).
702
+
703
+ Why this is written down: the bar's Autorun gate was written against a **scalar** `props.running` that no host
704
+ version supplies, so Autorun reported itself unavailable on a host that exposes the very signal through
705
+ `useSessionStatus`/`useChat`. Reading the contract table beats remembering a prop name. `inputActions` here is
706
+ `{ captureInsertion, insertText, setDraft, addAttachments, removeAttachment, pruneAttachments, submit }`, and
707
+ the composer-write limitation in #126 is measured against that same kit.
708
+
709
+ **The running signal has seats, and the scalar is only one of them (measured 2026-10-31, #194).**
710
+ `SessionSnapshot.running: boolean` (`packages/api/session-controller/src/client/contract/snapshot.ts:69-73`)
711
+ arrives through the dock's standard prop `useSession` — a `MaybeSnapshotSelectorHook<SessionSnapshot>`
712
+ (`useSession((s) => s.running)`), the same seat `ui-session` documents in `SessionStandardProps`.
713
+ Some builds ALSO hand a scalar `running` prop. The shipped dock declared both hook channels
714
+ (`useSessionStatus?`, `useSession?`) and consumed only the scalar — so on a hook-only host the #193
715
+ idle edge never fired and the cadence silently degraded to the conservative 10s-active fallback
716
+ (`props.running ?? true`). Resolution order, locked here: scalar `running` → `useSession((s) =>
717
+ s.running)` → unknown (conservative true, legacy cadence). Conditional invocation of an optional
718
+ hook prop is deliberate: the prop face is fixed per host build, so hook order is stable across
719
+ renders within any one deployment, which is the invariant React actually keeps.
720
+
721
+ **Push transport (interface lock, #194 — probe-verified on host 0.2.1-rc.5, `spikes/push-probe/FINDINGS.md`).**
722
+ Where the host has `appendPluginRecord` (0.2.1+; absent from 0.2.0-rc.2 — grep-verified on both tags),
723
+ the bar's state rides a plugin record and a projection, and the steady state costs ZERO command runs:
724
+
725
+ - Record type: `plugin:dsh-chapters/prompt`. Payload = the bar's durable facts, versioned:
726
+ `{ v: 1, status, text, turnSeq, error, intent, goalRevision, autorun, at }`.
727
+ - Append sites: every durable row write — the runner's `analyzing`/`ready`/`failed`/quiet-`off`
728
+ transitions (it holds the live session) and the bar-access mutations (`setAutorun`, `setOff`,
729
+ `setOn`, `runNow`'s consumption), which gain an optional live-session parameter for the purpose.
730
+ - The function comes ONLY from the host's own module instance — `createRequire(process.argv[1])
731
+ → resolve('@deepseek-ai/dsh-session') → await import(resolved)`. The plugin's OWN copy at the
732
+ IDENTICAL version throws `Receiver must be an instance of class Session` against a host session
733
+ (measured), and a bare import from a `link:` install resolves by realpath into the source tree —
734
+ on a dev machine, into a DIFFERENT host checkout than the running server. No plugin dependency is
735
+ declared for it; detection is `typeof appendPluginRecord === 'function'`, one try behind try/catch,
736
+ and any failure marks push unavailable for the process.
737
+ - Projection: key `chaptersPrompt`, registered ONLY when the writer resolved — registration is the
738
+ feature signal. `apply` folds records last-wins; `wire.view` passes the payload through.
739
+ - Client: the dock reads the standard prop `useProjection('chaptersPrompt')`. A live value renders
740
+ through the SAME reducer (one fold path); `undefined` ⇒ the #203/#208 pull schedule stands,
741
+ unchanged — the fallback is the floor. In push mode the only pulls are the bar's own actions and
742
+ ONE staleness-confirm per idle edge, scheduled at +20s and cancelled when a fresher record arrives
743
+ (the runner writes `analyzing` within moments of turn/end, so a healthy push cancels every confirm):
744
+ a silently dead push degrades to one pull per turn, never to a frozen bar.
745
+ - Records are `ignorable: true` with an unknown `plugin:` type — skipped by readers, rendered by
746
+ nothing; the projection value travels the host's own push channel. Verbs, state block, and the
747
+ pull schedule below are unchanged and remain the contract for pre-0.2.1 hosts.
748
+
749
+ **Verbs (interface lock, #113).** `/chapters-prompt` accepts `model …|clear`, `off`, `on`, `state`, `autorun on|off`,
750
+ and **`run`** — submit the offer currently on screen as a **human-authored user turn**, through the same follow-up
751
+ path autorun uses (`agent.followup(suggestPromptMessage(…))`). Run goes through the command plane rather than the
752
+ composer's `inputActions.setDraft` because a dock cannot rely on writing the host editor: measured, `setDraft` is
753
+ accepted and the composer still reads empty, and the goal bar's own `setDraft` is plain React state, not a
754
+ composer write. A plugin's only dependable wire to its own server plane is the command — which is why the bar is
755
+ push-shaped and pull-fed.
756
+
757
+ **The dock's status hook is a SELECTOR hook (measured, 2026-10-27).** `useSessionStatus` is declared
758
+ `SnapshotSelectorHook<SessionStatusSnapshot>` in `dsh-client-ui-session`'s client types, so it is called as
759
+ `useSessionStatus(selector)` — passing a session id hands the host a string where it wants a function and it
760
+ fails `TypeError: l is not a function`. Two consequences for anyone wiring the bar: the call must be hoisted
761
+ above the component's early `return null` (a hook below that guard changes the hook count and React aborts the
762
+ whole slot, `#310`), and the snapshot's field names are not ours to assume — the dock folds it through
763
+ `deriveRunning`, which maps `boolean`, status words, `{status|state|phase}`, and `{running}`, and returns
764
+ **`undefined` for anything unrecognized**, because `undefined` (unknown) and `false` (idle) are different
765
+ safety answers: unknown must never start a turn.
766
+
767
+ **State vocabulary (interface lock, #113).** `/chapters-prompt state` reports one of:
768
+ `off` — switched off by the human (a stored override), or no defining model is configured, so the bar renders
769
+ nothing; `idle` — **enabled and awaiting its first recommendation** (no completed turn yet); `analyzing` — a
770
+ generation is in flight for the current turn; `ready` — an offer is on screen with `text`; `failed` — the
771
+ generation could not run, with the reason. `idle` exists because collapsing "nothing yet" into `off` made a
772
+ working feature indistinguishable from a disabled one: measured in the rig, the bar replied
773
+ `suggested next step: off` while the profile row read `model: qwen-token-plan/qwen3.8-flash (config)`.
774
+ The bar stays hidden on `off` only, and re-reads while the state is `idle`/`analyzing`/`ready`.
775
+ — interface lock (#113, builds on the measured goal-bar facts above)
776
+
777
+ A composer dock row that, once per **completed turn**, generates a fresh prompt suggestion and offers
778
+ **Run · Autorun · Off**. Modelled on the goal bar because the goal bar is tested and already fits — and the
779
+ mechanical parallel is exact: *a goal re-injects the same directive every turn until the model stops; a
780
+ suggestion re-derives a new directive every turn until the human stops it.*
781
+
782
+ **Measured anchors for every claim below are in the section above** (dock slot, ordering, anatomy, hiding
783
+ rules, `hooks:` observables, `remote.commands.execute` as the only plugin-reachable call path).
784
+
785
+ ### State, and who owns it
786
+
787
+ | field | owner | values |
788
+ |---|---|---|
789
+ | `status` | server, persisted per session | `off` \| `analyzing` \| `ready` \| `failed` |
790
+ | `text` | server | the suggestion, verbatim, never truncated (`promptMaxChars` is a **refusal** cap) |
791
+ | `turnSeq` | server | the `turn/end` seq the suggestion was generated **for** — the identity of an offer |
792
+ | `autorun` | **server, process-local** (exactly like the goal's `activation`) | `armed` \| `disarmed` |
793
+ | `expanded` | client, per-mount | `false` collapsed one line · `true` full text shown |
794
+
795
+ The durable pair (`status`, `turnSeq`) is the server's one-generation-per-turn key; the process-local pair
796
+ (`autorun`, `expanded`) is never persisted — same split the goal bar uses between `goal.phase` and
797
+ `activation`.
798
+
799
+ **Correction, found while wiring the loop and kept visible because the first version of this table was
800
+ wrong.** `autorun` is *not* client-local: the whole point of the feature is that a suggestion drives the
801
+ next real turn, and a turn is started host-side (`agent.followup`), so the flag has to live where the loop
802
+ does. It is **server-side process-local state**, disarmed on mount and on `agent/created`, re-armed only by
803
+ an explicit human action through the command — the host goal driver's `WeakMap` semantics copied on purpose,
804
+ including the safety property: **a loop cannot resurrect itself after a restart.** `expanded` stays purely
805
+ client-local, because only a browser cares.
806
+
807
+ ### Dock placement
808
+ `conversation.input.dock`, `order: 30` — after todo `0`, goal `10`, queue `20` (ascending sort), so it sits
809
+ **under all other dock entries and directly above the composer card**, which is what "right above the chat
810
+ input" means in this host. It is a docked row, never an overlay, so it cannot cover the placeholder.
811
+
812
+ ### Label and visibility (two fields decide both, as with the goal)
813
+ - `analyzing` → **`Analyzing situation…`**
814
+ - `ready` → **`Recommended Next Step`**
815
+ - `failed` → **`Recommended Next Step`** *plus* an error span (`role="alert"`) naming the reason and code — a
816
+ detected failure must be a delivered failure (#61); nothing is silently blank.
817
+ - `off`, or a **quiet** refusal (nothing to recommend yet) → renders **nothing**. A refusal is not an error,
818
+ and shouting it was defect #2 of the removed ghost.
819
+ - Also renders nothing while the session zone is absent, and keeps a just-turned-off guard so it cannot
820
+ flicker back before the server state catches up (the goal's `clearedGoalId` trick).
821
+
822
+ ### Interactions
823
+ 1. **Click the prompt (collapsed)** → expand; full text shown in the same strip.
824
+ 2. **Click the prompt (expanded)** → `inputActions.setDraft(text)`: the draft is *replaced*, editable, and
825
+ nothing is sent. No `Edit`-button fallback unless a browser pass proves the click is captured by the host
826
+ editor — and if so the fallback is the goal's inline swap, which is already the house pattern.
827
+ 3. **Run** → `setDraft(text)` then `submit()`, in that order, so what goes out is visible in the composer
828
+ first and lands in the durable log as an ordinary **human** message.
829
+ 4. **Autorun / Autorun off** → toggles the process-local `armed`/`disarmed`. While armed, after each
830
+ completed turn the bar pulls, and on `ready` performs the Run path by itself.
831
+ 5. **Off** → server `status: 'off'`; generation stops, the row disappears, the configured model is kept so
832
+ turning it back on is one click.
833
+
834
+ ### Goal composition (per the maintainer's spec, which corrects my first plan)
835
+ Autorun **runs alongside** an active goal — it does not stand aside. The suggested prompt is always generated
836
+ *with the goal in view*: the server reads the newest `goal/change` from the session and feeds the live
837
+ objective into the recommendation, with the directive that the next step must advance an active goal.
838
+ **When the goal completes (or is cleared/paused out of `active`), autorun disarms itself** — the suggestion
839
+ loop yields the stage back to the human at the moment the purpose it was serving is finished. A human prompt
840
+ also disarms autorun, while generation continues on later turn boundaries; re-arming is a human act.
841
+
842
+ ### Enabling
843
+ `promptModel` remains the single switch (empty ⇒ no bar, no calls, no cost) — the model *is* the enable.
844
+ `promptAutorunDefault` (bool, **false**) decides only whether a fresh session starts armed; nothing autoruns
845
+ that a human did not opt into.
846
+
847
+ ### Where the goal's loop actually lives, and what that changes for us (measured 2026-10-25)
848
+
849
+ The second probe corrected two things I had assumed, and one thing I had written:
850
+
851
+ - **The goal loop has no timer.** `dsh-goal-round-driver` chains turns off `agent/status === "idle"` and
852
+ `goal/changed`, coalesced into one serialized task per agent, and starts the next turn with
853
+ `agent.followup(createUserMessage({…, source:{kind:'goal', goalId, revision, round}}))`. It stops **silently**
854
+ unless `phase === "active" && activation === "armed"`.
855
+ - **`activation` is process-local and starts disarmed.** On mount the driver disarms every agent, and
856
+ `agent/created` disarms new ones; only a human `ctx.goals.resume` re-arms. A loop cannot survive a restart
857
+ on its own.
858
+ - **A plugin may read the goal through the host, not just the log**: `ctx.get('goals').get(agent)` → `GoalView`
859
+ (adds `roundsStarted` + `activation`), or `ctx.sessionProjections.stateOf(session, 'goal')`. `readActiveGoal`
860
+ remains for log-only paths (repair, distill, tests).
861
+ - **Trap that governs our authorship**: two invariant companions re-fold the goal stream and
862
+ `deepEqual`-check every `source.kind:'goal'` message against the package-owned `<goal_round>` template. So
863
+ **we never author as `goal`** — impersonating that kind is a loud invariant failure by design.
864
+
865
+ **Decision this forces (and the one I'm making, since it is a semantics choice, not a style one).** Run and
866
+ Autorun are different acts and must not share a mechanism:
867
+
868
+ | control | mechanism | durable effect | survives a closed tab? |
869
+ |---|---|---|---|
870
+ | **Run** (a button the human pressed) | client `setDraft` + `submit` | an ordinary **human** `user/message` (`source.kind:'user'`) | no — and correctly so |
871
+ | **Autorun** (a loop the human opted into) | server-side `agent.followup(...)` with our **own** producer kind `plugin:dsh-chapters` | a plugin-authored message, screened like a notice, never counted as human input | the loop itself is process-local and **disarms on restart**, mirroring the goal |
872
+
873
+ That split is what makes it goal-shaped *and* honest: the suggestion reaches the model with the session's full
874
+ tool capability (it is a real turn, not a browser trick), while nothing we generate can masquerade as the
875
+ human typing it. Autorun defaulting off, and being off after any restart, is a safety property inherited from
876
+ the goal driver rather than a limitation to apologize for.
877
+
878
+ ### Goal ↔ suggestion authority and precedence — measured 2026-10-25, host 0.2.0-rc.2
879
+
880
+ Read this before touching either loop. The two features start turns, and the host reserves *some* turns for
881
+ humans. Facts, with anchors in `dsh-tool-goal/lib/index.js`:
882
+
883
+ - **`hasDirectHumanInput(ctx, execution)`** = the agent is a runtime root **and** the open turn contains
884
+ `user/message` with `data.source.kind === 'user'` (:49-52). **`requireDirectHuman`** (:62-65) gates goal
885
+ `create`/`edit`/`pause`/`resume` — a model cannot re-cut a goal without a human turn in progress.
886
+ - **`isMatchingGoalRound`** (:54-56) additionally grants *completion* authority to the exact admitted goal
887
+ round (`source.kind === 'goal'` with matching `goalId/revision/round`), so the worker may close the goal it
888
+ was handed — but not re-author it.
889
+
890
+ Consequences for this plugin, in order of how much they matter:
891
+
892
+ 1. **A `Run`n suggestion is a human turn, so it CAN re-cut the goal.** The composer's `setDraft`+`submit`
893
+ produces `source.kind: 'user'`. This is the director path the maintainer is building: the big model writes
894
+ *"edit the goal to X, because step 2 of the plan showed Y"*, the human presses Run, and the worker now has
895
+ authority to edit the goal. Nothing about our authorship needs to change for this to work.
896
+ 2. **An autorun suggestion is NOT a human turn, so it MUST NOT try to edit the goal.** Our loop authors under
897
+ `plugin:dsh-chapters` (see above: faking `kind:'user'` here would be a privilege escalation — it would hand
898
+ an automated loop the authority the host deliberately reserves). So the host's refusal is the correct
899
+ behaviour, and the feature must respect it rather than route around it.
900
+ 3. **Therefore an offer carries an intent, and intent decides whether autorun may act on it.** The suggestion
901
+ model prefixes its answer: `GOAL:` = this step changes the standing objective → **never auto-run**; the bar
902
+ holds it for the human's keystroke and says why. Anything else = a worker step → auto-run is allowed.
903
+ The prefix is stripped before the prompt reaches the composer or the followup, so the worker never sees
904
+ protocol scaffolding.
905
+ 4. **Exactly one thing may start a turn at a boundary.** The host goal driver queues a round on
906
+ `agent/status === 'idle'` when `phase === 'active' && activation === 'armed'`. If our loop also followed
907
+ up, the session would start two turns per idle edge — a race against the driver's own reservation fence.
908
+ So when the service reports the goal `active` **and** `armed`, the suggestion loop **yields**: it keeps
909
+ generating offers (that is the director's eye on the work) and runs none of them. It runs again when the
910
+ goal is `paused`, `blocked`, `complete`, disarmed, or absent.
911
+ Activation is process-local and reachable only through the service (`ctx.get('goals').get(agent)` →
912
+ `GoalView.activation`), never from the log — that read is the point, not a convenience.
913
+ 5. **A goal edit invalidates the offer made under the old revision.** `revision` bumps exactly +1 per
914
+ accepted mutation, so the loop compares the revision it generated against and refuses to run a stale one
915
+ instead of pushing last cycle's directive into a re-cut objective.
916
+
917
+ This is the shape of the pair: the **goal** is the thing the human owns and re-cuts, the **suggestion** is the
918
+ thing the director model re-derives every turn. Autorun drives the worker; the keystroke drives the objective.
919
+
920
+ ### Displacement, not yielding — the maintainer's rule for two autoruns in one session (2026-10-25)
921
+
922
+ My first pass had the suggestion loop **yield** whenever a goal was `active`+`armed`. That was wrong against
923
+ the spec: *"the autorun for the suggested prompt … can either introduce or **replace** the autorun by the goal
924
+ if it is running."* Yielding made the two features mutually exclusive, which is the opposite of built for each
925
+ other — with a goal armed, a director's autorun would have quietly done nothing at all.
926
+
927
+ The rule now: **arming prompt autorun displaces the goal's continuation, and the suggestion carries the goal.**
928
+ - Mechanism: `GoalService.disarm(agent)` (`dsh-goal/lib/index.js:622-627`) — *"without changing durable goal
929
+ phase or revision"*, a later human-authorized `resume` records the new activation edge. That is precisely a
930
+ cooperative brake: the objective stays authoritative in the log, the driver simply stops re-injecting it, so
931
+ **exactly one loop starts a turn** at each boundary, and it is whichever the human armed last.
932
+ - The goal is not lost by being disarmed: every suggestion is generated *from* `goal/change` (objective +
933
+ revision) and the directive still says the next step must advance it. The director's words replace the host's
934
+ repeated round; the objective is unchanged.
935
+ - If `disarm` is unavailable (no `goals` service on this host) arming still succeeds, and the loop yields the
936
+ turn — so a missing seam degrades to the safe behaviour rather than double-firing.
937
+ - Re-arming the goal (`/goal resume`, or the bar's next human Run of a `GOAL:` step) is a human act, matching
938
+ the host's own rule that a model may not resume a paused goal (`GOAL_TOOL_RESUME_PAUSED`).