@fractalsynapse/dsh-chapters 0.0.0-stage → 0.2.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +14 -0
- package/README.md +76 -2
- package/cordis.patch.yml +363 -0
- package/docs/architecture.md +692 -0
- package/docs/autorun-guide.md +248 -0
- package/docs/backlog.md +2022 -0
- package/docs/contract.md +938 -0
- package/docs/development.md +990 -0
- package/docs/host-compaction-seam.md +225 -0
- package/docs/host-protected-head-ask.md +80 -0
- package/docs/knowledge-repo.md +1332 -0
- package/docs/plans-carriage.md +571 -0
- package/docs/project-state.md +642 -0
- package/docs/provider.md +98 -0
- package/docs/superpowers/plans/2026-10-17-chapters-save.md +198 -0
- package/docs/superpowers/plans/2026-11-02-chapter-removal.md +151 -0
- package/docs/superpowers/specs/2026-11-02-chapter-removal-design.md +115 -0
- package/docs/verify.md +561 -0
- package/lib/archive.d.ts +161 -0
- package/lib/archive.js +229 -0
- package/lib/archive.js.map +1 -0
- package/lib/arrival.d.ts +23 -0
- package/lib/arrival.js +125 -0
- package/lib/arrival.js.map +1 -0
- package/lib/artifact-query.d.ts +62 -0
- package/lib/artifact-query.js +187 -0
- package/lib/artifact-query.js.map +1 -0
- package/lib/attach.d.ts +46 -0
- package/lib/attach.js +120 -0
- package/lib/attach.js.map +1 -0
- package/lib/auto-save.d.ts +145 -0
- package/lib/auto-save.js +334 -0
- package/lib/auto-save.js.map +1 -0
- package/lib/aux-message.d.ts +17 -0
- package/lib/aux-message.js +20 -0
- package/lib/aux-message.js.map +1 -0
- package/lib/client.js +1419 -0
- package/lib/client.js.map +1 -0
- package/lib/codegraph.d.ts +76 -0
- package/lib/codegraph.js +190 -0
- package/lib/codegraph.js.map +1 -0
- package/lib/commands.d.ts +52 -0
- package/lib/commands.js +946 -0
- package/lib/commands.js.map +1 -0
- package/lib/compose.d.ts +30 -0
- package/lib/compose.js +185 -0
- package/lib/compose.js.map +1 -0
- package/lib/continue-core.d.ts +245 -0
- package/lib/continue-core.js +405 -0
- package/lib/continue-core.js.map +1 -0
- package/lib/diag.d.ts +55 -0
- package/lib/diag.js +186 -0
- package/lib/diag.js.map +1 -0
- package/lib/engine-core.d.ts +301 -0
- package/lib/engine-core.js +531 -0
- package/lib/engine-core.js.map +1 -0
- package/lib/engine-note.d.ts +35 -0
- package/lib/engine-note.js +32 -0
- package/lib/engine-note.js.map +1 -0
- package/lib/engine.d.ts +329 -0
- package/lib/engine.js +1179 -0
- package/lib/engine.js.map +1 -0
- package/lib/enrich-queue.d.ts +66 -0
- package/lib/enrich-queue.js +126 -0
- package/lib/enrich-queue.js.map +1 -0
- package/lib/enrich-store.d.ts +31 -0
- package/lib/enrich-store.js +103 -0
- package/lib/enrich-store.js.map +1 -0
- package/lib/enrich-wire.d.ts +163 -0
- package/lib/enrich-wire.js +353 -0
- package/lib/enrich-wire.js.map +1 -0
- package/lib/enrich.d.ts +102 -0
- package/lib/enrich.js +267 -0
- package/lib/enrich.js.map +1 -0
- package/lib/gitops.d.ts +107 -0
- package/lib/gitops.js +539 -0
- package/lib/gitops.js.map +1 -0
- package/lib/index.d.ts +241 -0
- package/lib/index.js +1162 -0
- package/lib/index.js.map +1 -0
- package/lib/indexing.d.ts +98 -0
- package/lib/indexing.js +223 -0
- package/lib/indexing.js.map +1 -0
- package/lib/injections.d.ts +90 -0
- package/lib/injections.js +165 -0
- package/lib/injections.js.map +1 -0
- package/lib/last-resort.d.ts +24 -0
- package/lib/last-resort.js +27 -0
- package/lib/last-resort.js.map +1 -0
- package/lib/log-shape.d.ts +38 -0
- package/lib/log-shape.js +93 -0
- package/lib/log-shape.js.map +1 -0
- package/lib/models-command.d.ts +74 -0
- package/lib/models-command.js +100 -0
- package/lib/models-command.js.map +1 -0
- package/lib/notice.d.ts +74 -0
- package/lib/notice.js +92 -0
- package/lib/notice.js.map +1 -0
- package/lib/plans.d.ts +154 -0
- package/lib/plans.js +315 -0
- package/lib/plans.js.map +1 -0
- package/lib/preset.d.ts +186 -0
- package/lib/preset.js +212 -0
- package/lib/preset.js.map +1 -0
- package/lib/prompt-keys.d.ts +7 -0
- package/lib/prompt-keys.js +8 -0
- package/lib/prompt-keys.js.map +1 -0
- package/lib/prompt-loop.d.ts +73 -0
- package/lib/prompt-loop.js +147 -0
- package/lib/prompt-loop.js.map +1 -0
- package/lib/prompt-push.d.ts +47 -0
- package/lib/prompt-push.js +115 -0
- package/lib/prompt-push.js.map +1 -0
- package/lib/prompt-runner.d.ts +48 -0
- package/lib/prompt-runner.js +90 -0
- package/lib/prompt-runner.js.map +1 -0
- package/lib/prompt-state.d.ts +142 -0
- package/lib/prompt-state.js +101 -0
- package/lib/prompt-state.js.map +1 -0
- package/lib/prompt.d.ts +270 -0
- package/lib/prompt.js +408 -0
- package/lib/prompt.js.map +1 -0
- package/lib/provider.d.ts +44 -0
- package/lib/provider.js +66 -0
- package/lib/provider.js.map +1 -0
- package/lib/redact.d.ts +24 -0
- package/lib/redact.js +85 -0
- package/lib/redact.js.map +1 -0
- package/lib/registry.d.ts +187 -0
- package/lib/registry.js +235 -0
- package/lib/registry.js.map +1 -0
- package/lib/removal.d.ts +159 -0
- package/lib/removal.js +556 -0
- package/lib/removal.js.map +1 -0
- package/lib/render.d.ts +97 -0
- package/lib/render.js +530 -0
- package/lib/render.js.map +1 -0
- package/lib/repair-run.d.ts +76 -0
- package/lib/repair-run.js +506 -0
- package/lib/repair-run.js.map +1 -0
- package/lib/repair.d.ts +208 -0
- package/lib/repair.js +357 -0
- package/lib/repair.js.map +1 -0
- package/lib/repo.d.ts +78 -0
- package/lib/repo.js +184 -0
- package/lib/repo.js.map +1 -0
- package/lib/rules-commands.d.ts +46 -0
- package/lib/rules-commands.js +379 -0
- package/lib/rules-commands.js.map +1 -0
- package/lib/rules.d.ts +267 -0
- package/lib/rules.js +478 -0
- package/lib/rules.js.map +1 -0
- package/lib/save-core.d.ts +72 -0
- package/lib/save-core.js +220 -0
- package/lib/save-core.js.map +1 -0
- package/lib/search.d.ts +112 -0
- package/lib/search.js +282 -0
- package/lib/search.js.map +1 -0
- package/lib/session-rules.d.ts +222 -0
- package/lib/session-rules.js +454 -0
- package/lib/session-rules.js.map +1 -0
- package/lib/signature.d.ts +45 -0
- package/lib/signature.js +199 -0
- package/lib/signature.js.map +1 -0
- package/lib/store.d.ts +156 -0
- package/lib/store.js +368 -0
- package/lib/store.js.map +1 -0
- package/lib/sync.d.ts +220 -0
- package/lib/sync.js +810 -0
- package/lib/sync.js.map +1 -0
- package/lib/tools.d.ts +113 -0
- package/lib/tools.js +1090 -0
- package/lib/tools.js.map +1 -0
- package/lib/treedx/client.d.ts +57 -0
- package/lib/treedx/client.js +82 -0
- package/lib/treedx/client.js.map +1 -0
- package/lib/treedx/provider.d.ts +39 -0
- package/lib/treedx/provider.js +479 -0
- package/lib/treedx/provider.js.map +1 -0
- package/lib/treedx/state.d.ts +27 -0
- package/lib/treedx/state.js +65 -0
- package/lib/treedx/state.js.map +1 -0
- package/lib/types.d.ts +210 -0
- package/lib/types.js +31 -0
- package/lib/types.js.map +1 -0
- package/lib/version.d.ts +9 -0
- package/lib/version.js +10 -0
- package/lib/version.js.map +1 -0
- package/lib/vocabulary.d.ts +41 -0
- package/lib/vocabulary.js +118 -0
- package/lib/vocabulary.js.map +1 -0
- package/lib/warnings.d.ts +10 -0
- package/lib/warnings.js +18 -0
- package/lib/warnings.js.map +1 -0
- package/package.json +151 -3
package/docs/contract.md
ADDED
|
@@ -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`).
|