@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
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
# Architecture and Design Decisions
|
|
2
|
+
|
|
3
|
+
Decisions with their reasoning, so they can be revisited knowingly rather than rediscovered.
|
|
4
|
+
API mechanics and file:line citations live in `docs/contract.md`; this file is the *why*.
|
|
5
|
+
|
|
6
|
+
## Naming
|
|
7
|
+
|
|
8
|
+
**Decided: `dsh-chapters`** — package `dsh-chapters`, tools `chapters_segment` and `chapters_continue`,
|
|
9
|
+
storage domain `dsh_chapters`, store root `.dsh-chapters/`. Recorded with its reasoning because the
|
|
10
|
+
reasoning constrains future renames, not because the question is open.
|
|
11
|
+
|
|
12
|
+
Why the two most available-sounding words were rejected: DSH's "fork" means *copy the parent's history
|
|
13
|
+
into the child*, which is the one thing this plugin must not do. And "compact"/"summarize" implies the
|
|
14
|
+
lossy in-place editing this replaces.
|
|
15
|
+
|
|
16
|
+
| Candidate | Tools | Why taken or left |
|
|
17
|
+
|---|---|---|
|
|
18
|
+
| **`dsh-chapters`** ✓ | `chapters_segment`, `chapters_continue` | Names the durable artifact, makes no mechanism claim, greppable, and `continue` is exactly what happens: the conversation continues in a new session. Short enough for a plugin list. |
|
|
19
|
+
| `dsh-chapter-continue` | `segment_chapters`, `continue_with_chapters` | Verb-forward and self-describing to a model scanning a tool list; clunkier as a package. |
|
|
20
|
+
| `dsh-tome` | `tome_index`, `tome_continue` | Memorable and literally apt — a tome is a volume of chapters with a table of contents, which *is* the mechanic. But opaque to anyone who hasn't read the README. |
|
|
21
|
+
|
|
22
|
+
Also rejected: `dsh-archive` (sounds read-only, and the plugin's job is to keep working).
|
|
23
|
+
|
|
24
|
+
**One leftover: the repository directory.** The checkout is still `~/Projects/dsh-chapter-fork`. The
|
|
25
|
+
folder name is cosmetic — installs use `link:$(pwd)`, and the bundle id comes from `package.json` and
|
|
26
|
+
`cordis.patch.yml`, both of which say `dsh-chapters`. Rename it out-of-band with `mv` rather than from
|
|
27
|
+
inside a session whose working directory *is* that folder, which would strand the tree. Keeping the
|
|
28
|
+
identifiers in sync is what actually matters, and they are in sync.
|
|
29
|
+
|
|
30
|
+
## File Format: Markdown with Frontmatter, Not XML
|
|
31
|
+
|
|
32
|
+
Asked and answered: chapters stay **Markdown**, with structure in YAML frontmatter.
|
|
33
|
+
|
|
34
|
+
The reasoning against XML, strongest first:
|
|
35
|
+
|
|
36
|
+
1. **The consumer is a model reading text, not a parser.** Every markup token is a context token, in a
|
|
37
|
+
plugin whose entire purpose is bounding context. `<turn role="assistant" seq="184">` costs several
|
|
38
|
+
times what `**Assistant:**` does, repeated across a whole chapter.
|
|
39
|
+
2. **Structure already has two better homes.** The session log *is* the lossless machine-readable
|
|
40
|
+
record, keyed by seq, and the registry stores ranges and hashes. Duplicating turn structure into the
|
|
41
|
+
readable artifact creates a third source of truth that can silently drift from the other two.
|
|
42
|
+
3. **`.md` gets rendered; `.xml` gets downloaded.** The harness's own markdown component renders `.md`
|
|
43
|
+
inline in the web UI, and the `read` tool, editors, and git diffs are all Markdown-shaped. XML files
|
|
44
|
+
become write-only artifacts nobody looks at.
|
|
45
|
+
4. **Markdown is the established convention for exactly this shape.** DSH's skill loader parses
|
|
46
|
+
YAML-frontmatter-plus-Markdown-body files — the same idiom, already understood by the tooling.
|
|
47
|
+
|
|
48
|
+
What we *do* take from the XML instinct, because it was a real concern:
|
|
49
|
+
|
|
50
|
+
- **Frontmatter carries the parseable structure** — `chapter`, `session`, `root`, `seqRange`, `sha256`,
|
|
51
|
+
`tokens`, `artifacts`. Queryable with a YAML parser, invisible to the reading experience.
|
|
52
|
+
Chapters authored by a **subagent** carry two more lines — `subagent: true` and
|
|
53
|
+
`parentSession: <id>` — copied from the child's durable session header, which the host itself
|
|
54
|
+
stamps (`childSessionMeta`: `parentSession`, `origin: subagent`, `delegationDepth`, `agentPreset`;
|
|
55
|
+
measured, `spikes/subagent-end/FINDINGS.md`). Emitted ONLY when the header has `parentSession`,
|
|
56
|
+
so existing chapter bytes never change — which is also why no tape needs re-recording.
|
|
57
|
+
- **There is one index, not three.** The TOC lists the chapters for reading; the storage domain already
|
|
58
|
+
holds the machine-readable enumeration. A separate `MANIFEST.md` was proposed and **rejected as
|
|
59
|
+
redundant** — a regenerated duplicate that can drift, whose growth becomes a context cost when read
|
|
60
|
+
whole, and the sole exception to append-only. Dropping it removes all three problems at once.
|
|
61
|
+
- **Fence collision is a renderer bug, not a format problem.** Conversation text containing ```` ``` ````
|
|
62
|
+
must be escaped by choosing a longer fence per block. This is the actual failure mode an XML format
|
|
63
|
+
would have prevented, and it is solved in ~10 lines of the renderer. Get it wrong and chapters
|
|
64
|
+
silently truncate at an embedded fence.
|
|
65
|
+
|
|
66
|
+
**The one case that would reopen this:** if a future feature needs to *rewrite* chapter structure
|
|
67
|
+
programmatically — re-segmenting an existing chapter by turn boundaries without a session log — then a
|
|
68
|
+
losslessly round-trippable format earns its tokens. Rendering is currently one-way from log to file, so
|
|
69
|
+
it doesn't. Revisit if chapter *editing* ever enters scope.
|
|
70
|
+
|
|
71
|
+
## Chapter Rendering and the Artifact Store
|
|
72
|
+
|
|
73
|
+
The model supplies ranges; the plugin renders. Within a range:
|
|
74
|
+
|
|
75
|
+
| Event content | Handling |
|
|
76
|
+
|---|---|
|
|
77
|
+
| user / assistant text | inline, verbatim, attributed |
|
|
78
|
+
| tool **invocation** (name + arguments) | inline as a bounded head (400 chars) — the "what was attempted" half of the record; above `callArgArtifactTokens` the full arguments are **artifact-backed** like any deferred blob (0 = off by default) |
|
|
79
|
+
| session **plan** (`exit_plan_mode` call + its verdict result) | full text always artifact-backed + a labeled `## Plans in this chapter` block after the transcript (docs/plans-carriage.md) |
|
|
80
|
+
| tool **result** judged **not worth keeping** | deferred to `artifacts/`, replaced by a reference line |
|
|
81
|
+
| tool **result** judged worth keeping, ≤ floor | inline, verbatim, no model input needed |
|
|
82
|
+
| tool **result** judged worth keeping, > floor | inline by model override **and** still written to `artifacts/` |
|
|
83
|
+
| images / attachments | deferred to `artifacts/` by path (Markdown cannot carry them) |
|
|
84
|
+
| surface ops, projections, lifecycle events | omitted from the body; their seq ranges are recorded in frontmatter |
|
|
85
|
+
|
|
86
|
+
### Who decides: the model, not a threshold
|
|
87
|
+
|
|
88
|
+
The keep/defer call is **semantic**, so it belongs to the agent that lived the conversation. "This diff
|
|
89
|
+
matters; this npm install log does not" is not inferable from size — a 4-line compiler error is load-bearing
|
|
90
|
+
and a 400-line `read` of a config file may be pure noise. A size threshold gets this backwards exactly often
|
|
91
|
+
enough to matter. *(Amended 2026-09-19 below: one band — results too large to ever be reasoning context —
|
|
92
|
+
is artifacted at arrival without judgment; this section's rule governs everything that can genuinely fit.)*
|
|
93
|
+
|
|
94
|
+
Division of labour:
|
|
95
|
+
|
|
96
|
+
- **Plugin enumerates.** It has the log, so `chapters_segment` returns, per chapter, the candidate tool
|
|
97
|
+
results with `seq`, tool name, and size. The model never guesses at byte counts.
|
|
98
|
+
- **Default is defer** for anything above `toolResultDeferFloorTokens`; trivial results stay inline without
|
|
99
|
+
being itemized, so the narrative reads naturally and the exception list stays short.
|
|
100
|
+
- **A zero-length tool result defers nothing** (#59). `arrival.ts` has always skipped the empty string;
|
|
101
|
+
the renderer did not, so every hollow chapter wrote references to `sha256("")` and reported
|
|
102
|
+
`artifacts: 102` while holding one 0-byte file. An empty result renders `result (seq N, empty)` with
|
|
103
|
+
no `ArtifactRef` at all. `artifacts:` now means **distinct blobs** and `artifactRefs:` the total
|
|
104
|
+
references — the two were conflated, which made both the field diagnostic and the registry bloat
|
|
105
|
+
(90.6 % of chapter-record bytes here were duplicate references) unreadable.
|
|
106
|
+
- **Over-target is a warning; far over-target is a refusal** (#59). `chapterTokenTarget` stays the soft
|
|
107
|
+
target (split at the next boundary, never clip). Past `chapterTokenTarget × chapterOversizeFactor`
|
|
108
|
+
a range is refused with its own number, on every door including compaction: the field produced an
|
|
109
|
+
89,982-token chapter, an unrepaired 237,870-token orphan, and eleven chapters over 32K — against a
|
|
110
|
+
8K target and 32K-class hardware. A chapter that cannot be re-read is not an archive.
|
|
111
|
+
- **A title is chosen by the same predicate that screens bodies** (#59). Bodies use `injections.ts`
|
|
112
|
+
(`source.kind`); title derivation sees provider messages with no source, so it carried its own
|
|
113
|
+
regex — which drifted behind the host and let an injected `time-context` snapshot become a chapter
|
|
114
|
+
**title, slug and filename** (`020-time-sampled-while-preparing-turn-11-step-24-202.md`, cited in a
|
|
115
|
+
live checkpoint). `isInjectedProse` is now the one predicate, fed by the same list of injected
|
|
116
|
+
shapes; a row pins that both callers use it and that the regex file is gone.
|
|
117
|
+
|
|
118
|
+
- **Model submits exceptions only**: `toolResultOverrides: [{seq, inline}]`. Usually empty or one entry, so
|
|
119
|
+
the judgment costs almost no output tokens — which is why this is affordable where "the model writes the
|
|
120
|
+
chapter bodies" was not.
|
|
121
|
+
- **Invocations stay inline as a bounded head.** Plugin rule, not a choice; it is the "what was
|
|
122
|
+
attempted" half of the record and the thing a successor most needs. The head is 400 characters, so
|
|
123
|
+
a large argument (`write` carrying a whole file, a plan carrying a whole design) is *also* written to
|
|
124
|
+
`artifacts/` whenever `callArgArtifactTokens` is above zero — the inline text is then a head with a
|
|
125
|
+
resolution, never the only copy. Session plans get this treatment unconditionally and by name: the
|
|
126
|
+
plan's own bytes are always an artifact, cited with its outline and verdict
|
|
127
|
+
(docs/plans-carriage.md § 2/§ 3).
|
|
128
|
+
- **The artifact is written either way.** Inlining is a convenience copy *alongside* its reference. So a
|
|
129
|
+
wrong call is a context-economy miss, never data loss — that asymmetry is what makes delegating this
|
|
130
|
+
judgment to the model safe.
|
|
131
|
+
|
|
132
|
+
The floor is a formatting device, not the policy, and its value is a Phase 1 calibration guess. Oversized
|
|
133
|
+
output is caught by the continuation budget and the chapter split signal, both of which **report or refuse
|
|
134
|
+
rather than clip**.
|
|
135
|
+
|
|
136
|
+
Reference line format — stable, greppable, and `read`-able:
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
↳ tool: bash `npm run migrate -- --dry-run`
|
|
140
|
+
result: 41.2 KB, sha256 8f3a1c… → .dsh-chapters/<root>/artifacts/8f/8f3a1c9d….txt
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Artifacts are **content-addressed** at `artifacts/<first-2-hex>/<sha256>.txt`, which means identical
|
|
144
|
+
results across an entire subtree — the same 200-line file read ten times — are stored once. That
|
|
145
|
+
deduplication is a large part of why this design stays cheap over long chains.
|
|
146
|
+
|
|
147
|
+
### This changes the losslessness claim, and the claim must change with it
|
|
148
|
+
|
|
149
|
+
Chapters are no longer self-contained. The honest formulation is:
|
|
150
|
+
|
|
151
|
+
> **Every byte of the conversation remains retrievable** — inline in its chapter, or by an exact path
|
|
152
|
+
> from it. Nothing is paraphrased, summarized, or dropped.
|
|
153
|
+
|
|
154
|
+
Do **not** write "the chapter contains the full history" in a TOC preamble or a README. The distinction
|
|
155
|
+
matters operationally too: following a reference costs the model a turn and real tokens, so the default
|
|
156
|
+
rendering must keep the 99% case (who said what, and what was attempted) inline. That is precisely the
|
|
157
|
+
trade the user's judgment called for: long dumps are the least valuable thing to re-read, and the most
|
|
158
|
+
expensive to carry.
|
|
159
|
+
|
|
160
|
+
If a chapter still exceeds `chapterTokenTarget` after deferral, that is the *split* signal — not a
|
|
161
|
+
license to truncate. Truncation is refused by invariant 4 and by "never truncate to fit."
|
|
162
|
+
|
|
163
|
+
### Amendment (user-directed, 2026-09-19, APPROVED, **landed**): arrival-time artifacting for the impossible band
|
|
164
|
+
|
|
165
|
+
> Shipped with phases 2-4 of the artifacting plan: `src/arrival.ts` writes the stub, `chapters_artifact`
|
|
166
|
+
> is in `src/tools.ts`. Verified by unit rows plus browser journey **E7** in
|
|
167
|
+
> [verify.md](verify.md) — one `read` of a 373 KB book yields exactly one arrival stub at the tail, zero
|
|
168
|
+
> compaction events at the production 0.9 threshold, the blob fingerprint absent from every `request/*`
|
|
169
|
+
> event, and `cacheReadTokens` monotonic across the turn (the prefix was never rewritten).
|
|
170
|
+
|
|
171
|
+
The section above lets the model decide what to defer **at archive time** — a judgment about material
|
|
172
|
+
that fit in the window to begin with. This amendment carves off the band where there is no judgment to
|
|
173
|
+
make: a tool result above a hard threshold (config key `toolResultArtifactTokens`, default 8000,
|
|
174
|
+
floored at 256) **never enters the surface at all**. A 300-page book does not fit a 32K desk; loading it
|
|
175
|
+
to "see what happens" is not deference to the model's semantics, it is an emergency waiting to happen.
|
|
176
|
+
|
|
177
|
+
Mechanics:
|
|
178
|
+
|
|
179
|
+
- The result is written to the artifact store (content-addressed, same path scheme as archival
|
|
180
|
+
deferral — dedup applies) and the surface receives a **reference stub** instead: tool, size, sha,
|
|
181
|
+
path, plus the query instruction below. A few hundred tokens, appended at the tail — prefix cache
|
|
182
|
+
untouched. (This is distinct from the pruner's mid-history rewrite, whose re-prefill cliff on
|
|
183
|
+
llama.cpp we measured r28; arrival-time interception never rewrites the past.)
|
|
184
|
+
- A new read-only tool, **`chapters_artifact`**, queries artifacts *inside* their file: `search`
|
|
185
|
+
(terms/regex → matching blocks with line numbers, bounded by a maxTokens pack), `read`
|
|
186
|
+
(offset/limit — the model can still pull an exact section when its judgment says so), and `toc`
|
|
187
|
+
(for structured documents: markdown headings parsed to a line-numbered contents list — a 300-page
|
|
188
|
+
paper arrives with its own table of contents in hand).
|
|
189
|
+
- Consequence on the failure inventory: the **retention paradox** (a newest node too large to file,
|
|
190
|
+
too new to keep ⇒ bounded refusal) becomes structurally unreachable for tool results — the monster
|
|
191
|
+
is never on the desk to be caught in retention. This is the "handle all abortions" directive
|
|
192
|
+
realized at the gate rather than at the rescue.
|
|
193
|
+
- External validation: this is the Recursive Language Model result (Zhang, Kraska & Khattab,
|
|
194
|
+
arXiv:2512.24601) — arbitrarily long material as *external environment* the agent inspects
|
|
195
|
+
programmatically. Their mechanism is a Python REPL over the prompt; ours is a file + query tool
|
|
196
|
+
+ parsed TOC, which delivers the same decomposition without code execution or a sandbox. Their
|
|
197
|
+
reported compaction-baseline gap (median 26%) is the quality argument; the SRLM follow-up
|
|
198
|
+
(arXiv:2603.15653) supplies the policy guard: recursion that splits what already fits **hurts** —
|
|
199
|
+
hence the hard band boundary, not a general split-everything stance, and bounded pulls remain the
|
|
200
|
+
model's choice.
|
|
201
|
+
|
|
202
|
+
The archive-time model-judgment machinery above is unchanged for results below the floor.
|
|
203
|
+
|
|
204
|
+
**Verification (2026-09-19):** `tests/e2e/artifact-arrival.spec.ts` (green 9.3m and
|
|
205
|
+
4.1m runs) — a 373,861-byte book fetched in one read produced exactly the arrival
|
|
206
|
+
prune+stub pair, ZERO blob occurrences in any `request/*` event (never prefilled),
|
|
207
|
+
5–15 `chapters_artifact` calls recovering facts from inside it, and a cache table
|
|
208
|
+
(FINDINGS) showing `cacheReadTokens` monotone across the stub boundary — the tail
|
|
209
|
+
rewrite costs the prefix nothing, unlike the pruner's mid-history cliff. Row config
|
|
210
|
+
`toolResultArtifactTokens` is pinned per e2e project (500 for arrival, default
|
|
211
|
+
8000 elsewhere).
|
|
212
|
+
|
|
213
|
+
### Amendment (user-directed, 2026-09-19, APPROVED, **landed**): the plot note across in-place compaction
|
|
214
|
+
|
|
215
|
+
> Shipped: the forward path is the preset persona suffix (the agent is told to end substantive turns
|
|
216
|
+
> with a `PLOT:` line) and the engine copies the latest such note verbatim into the replacement above
|
|
217
|
+
> the index cards; the elicited fallback is one bounded call gated by `elicitedPlot` (default `true`) —
|
|
218
|
+
> tail-heavy excerpt capped at ~8K chars in, `maxTokens: 220`, note clamped to 900 chars, any failure
|
|
219
|
+
> degrading to `null` (no note, never a crash). Both halves pinned in unit; the forward path proven live
|
|
220
|
+
> in verify **E8** (a suite-B run carried `PLOT:` blocks through seven compactions). The 60-word figure
|
|
221
|
+
> below supersedes the ~120 originally proposed.
|
|
222
|
+
|
|
223
|
+
Heavy mid-turn tidying measurably erodes *plan* continuity (FINDINGS, oversized-turn runs: model
|
|
224
|
+
finished the reading but abandoned the wrap-up). The TOC preserves what was **done**; this adds what
|
|
225
|
+
is **going on**, authored while the model still holds the thread:
|
|
226
|
+
|
|
227
|
+
- **Forward-maintained (preferred):** preset discipline — at each turn's end the agent leaves a
|
|
228
|
+
compact *plot note* (objective, current hypothesis, next step; ~40–80 words, no tools). Compaction
|
|
229
|
+
copies the latest note verbatim into the replacement, above the index cards. Cost: a little output
|
|
230
|
+
per turn; zero extra prefill at compaction, which is when cost bites.
|
|
231
|
+
- **Elicited (fallback, bounded):** if no plot note exists at compaction time, one auxiliary
|
|
232
|
+
completion over the **TOC + retained tail only** (tail-heavy excerpt, ~8K chars in, ≤60 words out,
|
|
233
|
+
`maxTokens: 220`; key `elicitedPlot`, default `true`) asks the model to state the plan. This is
|
|
234
|
+
deliberately not a re-read of the archived span — it costs a fraction of what a naive summary call
|
|
235
|
+
would, and only when the free path failed.
|
|
236
|
+
- Invariant check: the model authors *state*, never archive text (invariant 4 intact); the note rides
|
|
237
|
+
the replacement event append-only; every byte of the conversation still lives in chapters.
|
|
238
|
+
|
|
239
|
+
## The archive moments
|
|
240
|
+
|
|
241
|
+
Chapters are written by exactly five doors, and knowledge that trips none of them is knowledge
|
|
242
|
+
that never enters the index. Four are session-facing, all idempotent over a per-session watermark
|
|
243
|
+
(`fromSeq = max(chapter.endSeq) + 1`) and anchored on the last **completed** turn (`turn/end`):
|
|
244
|
+
|
|
245
|
+
1. `chapters_continue` (tool) and `/chapters-continue` (deterministic door) — archive + child session.
|
|
246
|
+
2. `chapters_fork` (tool) and `/chapters-fork` (door) — archive + branch session (no new bytes).
|
|
247
|
+
3. `/chapters-save` — archive only, parent untouched.
|
|
248
|
+
4. Compaction finalize — the engine's `summarize()` commits a plan pre-durable, and the post-commit
|
|
249
|
+
finalizer writes the chapter (issue #47 note: the fanout field study shows this is the ONLY one
|
|
250
|
+
of these that subagents historically reached — 20 child compactions, or nothing).
|
|
251
|
+
5. **Subagent settlement (issue #47; ordering corrected by #58)** — a host-plane listener on the
|
|
252
|
+
published `subagent/end` lifecycle event runs the SAME save pipeline (one implementation, shared
|
|
253
|
+
with door 3 by extraction — never a second renderer) for children whose header carries
|
|
254
|
+
`origin: subagent` and `agentPreset === chapters`. **The ordering is mode-dependent, and that is
|
|
255
|
+
the whole defect** (#58, measured): a **one-shot** run emits `end` before its dispose
|
|
256
|
+
continuation, so the synchronous fast path — `agents.get(info.id).session.snapshotEvents()` —
|
|
257
|
+
sees the live log; a **continuable** run is disposed *first* (`flushFinalState` →
|
|
258
|
+
`handle.dispose()` → `resident.delete` → `observer.settle` → emit), so the fast path is **always**
|
|
259
|
+
empty for continuable children — the kind a real fanout produces. The durable arm is therefore
|
|
260
|
+
not an edge case; for `mode: "continuable"` it is the only arm, and it must return
|
|
261
|
+
**payload-bearing** events: `sessionQuery.readSession` (cloned header + `snapshotSessionEvent`
|
|
262
|
+
events, one call, so the header scan goes away too). It must **not** be `sessionQuery.listEvents`,
|
|
263
|
+
whose records carry `seq`/`type`/`time`/`surface` and **no `data`**: fed to the renderer they yield
|
|
264
|
+
a structurally valid, semantically empty chapter — every tool name `unknown`, every argument `{}`,
|
|
265
|
+
every result the `sha256("")` blob at 0 B, every message event pushed to `unrenderedSeqs` — which
|
|
266
|
+
is what 12 archives on this machine turned out to be. The file:line facts are in
|
|
267
|
+
`docs/contract.md` § Durable reads. When neither arm yields a payload-bearing read, the child is
|
|
268
|
+
skipped with a loud diagnostic, never guessed.
|
|
269
|
+
**No door commits an archive that rendered nothing** (#58): the shared save core refuses a chapter
|
|
270
|
+
with `stats.messages === 0` over `stats.events > 0` — a tool call always follows an assistant
|
|
271
|
+
message, so that pair is unreachable in real traffic and means "payload read", not "short
|
|
272
|
+
conversation". Refusal is a `payloadless` skip **with numbers**, before any number is reserved or
|
|
273
|
+
any file is written: a committed husk would poison the watermark, which is `max(endSeq)`, and the
|
|
274
|
+
child could then never be re-archived without a repair (#60). Door 5 also stops discarding
|
|
275
|
+
`writeArchive`'s warnings — it already says "N event(s) rendered no output", and dropping that
|
|
276
|
+
string is what let the defect run for days. It does **not** `linkChild` the parent (#58, measured
|
|
277
|
+
after the plan assumed otherwise): `rootSession` *is* the store directory name, so linking re-homes
|
|
278
|
+
every file the child already wrote, while `buildIndex` walks ancestors only — the link buys zero
|
|
279
|
+
reach and costs the tree. A child keeps its OWN tree and is reached through `chapters_search` and
|
|
280
|
+
`linkChild`-free ancestry of the PARENT's own rows. Save-only, so the
|
|
281
|
+
"no continuation-creating actions mid-step" rule is not engaged: no session is created, no surface
|
|
282
|
+
is compacted. Enrichment is **queued** per settlement — a 10-child fanout must not `runNow` ten
|
|
283
|
+
annotator passes — and an empty or refused chapter is never enqueued (#61). A continuable child
|
|
284
|
+
settles repeatedly; the watermark makes every repeat after the first a save of only-new-turns.
|
|
285
|
+
Config: `subagentAutoSave` (default on).
|
|
286
|
+
|
|
287
|
+
**Amendment 2026-10-22 (#61, item 5) — the seventh moment: a rooted child that has never archived.**
|
|
288
|
+
A `ch-*` continuation child is a real session with a real `parentSession`, and door 5 does NOT cover it
|
|
289
|
+
because its header says `origin: user`/`agentPreset: chapters`, not `origin: subagent`. The field case was
|
|
290
|
+
`ch-dc3be3ab`: **2,363 events, 414 tool calls, 2 completed turns, `chapters: []`, `reservations: {}`** —
|
|
291
|
+
not a failed archive, an archive that was **never even attempted**, because no moment was watching it. Its
|
|
292
|
+
work is in the log and invisible to every search on the machine.
|
|
293
|
+
|
|
294
|
+
So a `turn/end` listener archives through the SAME save core when all of:
|
|
295
|
+
1. the session is a `chapters`-preset session AND rooted — lineage read from whichever of its three
|
|
296
|
+
homes has it: the registry row (`chapters_continue` writes it with `linkChild`, continue-core.ts:520),
|
|
297
|
+
the header, or the `ch-` id prefix our own continue door mints (continue-core.ts:501). **Measured
|
|
298
|
+
2026-10-22: asking the header alone missed the field case** — `ch-dc3be3ab` has
|
|
299
|
+
`parentSession: NONE, origin: undefined`. The user's own root sessions stay behind doors 1–3;
|
|
300
|
+
2. `origin !== 'subagent'` (door 5 already owns those, and two doors on one child double-writes);
|
|
301
|
+
3. either its row has **never** archived (`chapters.length === 0`) and it has ≥1 completed turn, or the
|
|
302
|
+
unarchived completed turns have passed `childAutoArchiveMinTurns`.
|
|
303
|
+
|
|
304
|
+
It is **save-only**: it creates no session and compacts no surface, so the "no continuation-creating
|
|
305
|
+
actions mid-step" rule is not engaged — the same reasoning that let door 5 exist. Thresholds are config
|
|
306
|
+
(`childAutoArchive`, `childAutoArchiveMinTurns`), never literals. Knowledge that trips none of the seven
|
|
307
|
+
never enters the index: when adding a door, **extend this list, never copy the pipeline**.
|
|
308
|
+
|
|
309
|
+
## Budgets and the Reserve Margin
|
|
310
|
+
|
|
311
|
+
Two distinct ceilings. Conflating them caused the original design's silent-failure mode.
|
|
312
|
+
|
|
313
|
+
**Continuation budget** — the *newly added* context of a child must be small and predictable, as a share
|
|
314
|
+
of the space left after the fixed header:
|
|
315
|
+
|
|
316
|
+
```
|
|
317
|
+
tocTokens + handoffNoteTokens ≤ continuationBudgetRatio × (window − systemPromptTokens)
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
**Chapter target** — per-file size, governing rendering and splitting. Not a hard error.
|
|
321
|
+
|
|
322
|
+
Deriving the budget from the **remaining** window rather than the whole window is not a nitpick. On a 32K
|
|
323
|
+
model with a ~9K system prompt and tool schemas, a 25%-of-window rule gives an 8K ceiling that the TOC and
|
|
324
|
+
note may blow through before any work happens, and the plugin would refuse every continuation on exactly
|
|
325
|
+
the small-context hardware it targets. The header is a fixed cost to subtract, not a competitor for the
|
|
326
|
+
index. Measure it once per generation and cache it.
|
|
327
|
+
|
|
328
|
+
The budget is enforced as a **preflight**: measure, then refuse with concrete numbers
|
|
329
|
+
(`TOC 4,120 + note 2,800 = 6,920 > budget 5,750 (0.25 × (32,000 − 9,000))`) *before* creating any
|
|
330
|
+
session. Never truncate to fit — a clipped handoff note is the lossy behaviour this plugin exists to
|
|
331
|
+
avoid, and a silent clip is exactly the bug that takes a month to notice.
|
|
332
|
+
|
|
333
|
+
There is deliberately **no cap on the handoff note.** The budget check subsumes it: a model that dumps the
|
|
334
|
+
whole conversation into the note fails preflight and is told by how much. The note should be state of play
|
|
335
|
+
— decisions held, the next step, anything in flight — and the tool description should say so rather than a
|
|
336
|
+
numeric limit doing the talking.
|
|
337
|
+
|
|
338
|
+
This margin is also what automatic triggering needs, which is why the phases were reordered: pressure
|
|
339
|
+
measurement, the preflight, and a *suggestion* at `turn/end` all land in Phase 2; only autonomous
|
|
340
|
+
action stays late.
|
|
341
|
+
|
|
342
|
+
### The native branch button: cosmetic hide (user-approved 2026-09-17)
|
|
343
|
+
|
|
344
|
+
The message-row branch glyph is host chrome, not a slot entry. The chosen lever is
|
|
345
|
+
**display:none on its accessible label, scoped to action-row buttons** (en+zh spellings);
|
|
346
|
+
the sidebar fork entry stays. If a harness update rewords the label, the native button
|
|
347
|
+
reappears beside ours — accepted deliberately: a visible, temporary duplicate beats
|
|
348
|
+
behavioral monkey-patching of the client's fork call (the dsh-session-fork route, rejected
|
|
349
|
+
for its version-drift surface). Do not "upgrade" the hide into an intercept without asking.
|
|
350
|
+
|
|
351
|
+
## Ancestry: A DAG We Flatten For The Model
|
|
352
|
+
|
|
353
|
+
History becomes a tree the moment someone continues an older ancestor twice. The worry that agents
|
|
354
|
+
won't traverse a DAG is correct, and the answer is that **they never should have to.**
|
|
355
|
+
|
|
356
|
+
Three rules:
|
|
357
|
+
|
|
358
|
+
1. **The plugin walks; the model reads a list.** At continuation time the plugin walks the ancestor path
|
|
359
|
+
in the registry and emits a flat, chronologically ordered, numbered TOC. Reading order *is* causal
|
|
360
|
+
order. Depth 10 or mid-branch, the model sees a numbered index and a `read` path — no graph, no
|
|
361
|
+
inference, no traversal.
|
|
362
|
+
2. **Back-links are printed, not implied.** The TOC footer names the root session and the parent session,
|
|
363
|
+
so the trace is visible without being reconstructed.
|
|
364
|
+
3. **The TOC is the complete index for the current line of work.** Every chapter on the ancestor path is
|
|
365
|
+
listed with its summary, so ordinary follow-up needs no traversal at all.
|
|
366
|
+
|
|
367
|
+
Cross-**branch** recall — "what did the other attempt try?" — is the only case the linear TOC cannot serve,
|
|
368
|
+
and it is rare. It is answered by the registry (which holds the whole tree) behind a future read-only
|
|
369
|
+
command, never by a per-fork index file. Carrying it in every session's prefix to serve an occasional
|
|
370
|
+
question would tax all work for one.
|
|
371
|
+
|
|
372
|
+
Two structural consequences:
|
|
373
|
+
|
|
374
|
+
- **Chapters are keyed by creating session**, not by a linear chain id. Siblings branching from one
|
|
375
|
+
ancestor get different, correct TOCs and cannot collide on numbering.
|
|
376
|
+
- **Append-only has no exceptions.** Nothing derived is written back into the store; the registry is the
|
|
377
|
+
only mutable state, and it is a domain table designed for it.
|
|
378
|
+
|
|
379
|
+
## Storage
|
|
380
|
+
|
|
381
|
+
Two stores because they have opposite requirements.
|
|
382
|
+
|
|
383
|
+
**Chapters and artifacts: workspace files.** The `read` tool must reach them, and it runs under the
|
|
384
|
+
session's file sandbox (`workspace-write` permits only the workspace tree). The original
|
|
385
|
+
`~/.dsh/chapters` design fails asymmetrically: writes to `$HOME` may succeed while `read` cannot reach
|
|
386
|
+
the path, so every TOC bullet is silently dead and the plugin looks like it worked. `ctx.fs` is also
|
|
387
|
+
**optional**, so its absence must be a clean refusal.
|
|
388
|
+
|
|
389
|
+
Layout:
|
|
390
|
+
|
|
391
|
+
```
|
|
392
|
+
<store-root>/ # default .dsh-chapters/
|
|
393
|
+
<rootSession>/
|
|
394
|
+
chapters/001-project-setup.md # append-only, never renumbered
|
|
395
|
+
artifacts/8f/8f3a1c9d….txt # content-addressed, deduplicated
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
Add `<store-root>/` to `.gitignore` on first write and say so — polluting a user's tree silently is its
|
|
399
|
+
own small bug.
|
|
400
|
+
|
|
401
|
+
**Registry: a DSH storage domain.** Ancestry, numbering, hashes, ranges, bullet lists. Sanctioned
|
|
402
|
+
durability, survives restart, and gives the append-only counter a real allocation home instead of a
|
|
403
|
+
directory scan racing a restart.
|
|
404
|
+
|
|
405
|
+
Trade-off to keep honest: a workspace-relative root makes a chain **cwd-scoped**. Crossing workspaces
|
|
406
|
+
means an absolute configured root, at which point `read` reachability must be re-verified — do not assume
|
|
407
|
+
an absolute path inside one workspace stays reachable in another.
|
|
408
|
+
|
|
409
|
+
## Atomicity
|
|
410
|
+
|
|
411
|
+
Writing files, allocating numbers, creating a session, and committing registry state is not
|
|
412
|
+
transactional. Order them so the worst failure is an orphan, never a lie:
|
|
413
|
+
|
|
414
|
+
1. **Reserve** numbering, chapter ids, and slug assignments in the registry *before* writing files.
|
|
415
|
+
2. Render and write chapter files and artifacts; verify each path resolves.
|
|
416
|
+
3. Create the child session with the TOC notice.
|
|
417
|
+
5. **Only then** commit the registry entry linking parent → child — this is the commit point that makes
|
|
418
|
+
the continuation visible.
|
|
419
|
+
|
|
420
|
+
Retry must be **idempotent**: key the attempt on `sourceSession + archiveCeiling`, so a second call reuses
|
|
421
|
+
reserved numbers and existing files rather than duplicating chapters. Failure before step 5 leaves
|
|
422
|
+
unreferenced files, which is recoverable garbage; failure after step 4 without step 5 leaves a child
|
|
423
|
+
citing chapters that don't exist, which is not. Hence the ordering.
|
|
424
|
+
|
|
425
|
+
Concurrency is real, not theoretical: parallel branch work is a first-class pattern in this ecosystem, so
|
|
426
|
+
two agents can continue from one ancestor at the same time. A directory-scan counter races; registry
|
|
427
|
+
allocation does not.
|
|
428
|
+
|
|
429
|
+
### The row is shared, and a lost update is not an orphan — it is an erasure (#59)
|
|
430
|
+
|
|
431
|
+
Steps 1-5 order the work *inside* one door. They say nothing about two doors writing the **same session
|
|
432
|
+
row**, and the registry row is one whole value: `get → mutate → put`, last writer wins. Five doors plus the
|
|
433
|
+
turn/end signature listener write it, and the wide ones hold a stale snapshot across an await — a model
|
|
434
|
+
call, or hundreds of filesystem writes:
|
|
435
|
+
|
|
436
|
+
- `enrich-wire.ts` reads the row, annotates per chapter, then puts the **whole row** back
|
|
437
|
+
(`get:185 → … → put:297`);
|
|
438
|
+
- `engine.ts` summarize reads, elicits a plot (a model call), then puts (`:567 → :641`);
|
|
439
|
+
- `engine.ts` finalize reads, runs `writeArchive` (render + every artifact), then puts the state read
|
|
440
|
+
**before** those writes (`:857 → :891`);
|
|
441
|
+
- `store.ts` allocator and `commitChapters` each read-modify-write (`:240-266`).
|
|
442
|
+
|
|
443
|
+
Field instance, second-level evidence (`session-aac0254e`): a compaction committed its manifest and wrote
|
|
444
|
+
its chapter at **04:53:50.915**; an enrichment put built from a pre-compaction read landed at
|
|
445
|
+
**04:53:52.841** and rewound the row. The manifest, the reservation, the `finalized` entry and the chapter
|
|
446
|
+
**record** all disappeared — while the 960 KB chapter file stayed on disk, unregistered and uncitable.
|
|
447
|
+
`nextChapterNumber` rewound to 15, so a later door spent 15 again: two different `015-*.md` files in one
|
|
448
|
+
tree. `appendChapters` deduped the reallocated record by **number alone** (`registry.ts:159-161`), so the
|
|
449
|
+
collision presented as a harmless retry.
|
|
450
|
+
|
|
451
|
+
So an orphan file is the tolerated failure; an **erased commit** is not, and it is invisible — the files look
|
|
452
|
+
right and only the registry knows. The rule, therefore:
|
|
453
|
+
|
|
454
|
+
**Every registry mutation for a session goes through `RegistryStore.mutate(sessionId, fn)`** — a
|
|
455
|
+
per-session serialized section that re-reads the row before writing and refuses on a changed `rev`. A door
|
|
456
|
+
that holds work across an await passes a **function**, never a snapshot. Refuse with numbers, never
|
|
457
|
+
silently clobber: a refused write is visible and retryable, a lost update is neither.
|
|
458
|
+
|
|
459
|
+
Three invariants in the same family: an identity is `(number, sha256)`, never `number` alone; a reservation
|
|
460
|
+
whose files never landed is **released** (or expires) instead of burning a number forever; and one
|
|
461
|
+
unresolvable item in the finalize queue may not poison the items behind it.
|
|
462
|
+
|
|
463
|
+
## Observability — a detected failure is a delivered failure (#61)
|
|
464
|
+
|
|
465
|
+
Every P0 in #58/#59/#60 was **already detected by the code** before a human ever looked: `writeArchive`
|
|
466
|
+
warned "N event(s) rendered no output", `planSummarize` counted the oversize chapters, the finalize
|
|
467
|
+
catch built a full diagnostic — and then the value was discarded. So the rule is not "add checks", it is
|
|
468
|
+
**deliver what the checks already say**:
|
|
469
|
+
|
|
470
|
+
- **The diagnostics sink defaults ON.** `DSH_CHAPTERS_ENGINE_ERRORS` unset now means
|
|
471
|
+
`<store-root>/diagnostics.log`, not "nowhere". All three SAIP compaction finalization failures left
|
|
472
|
+
zero trace on disk, and door 5 logged `saved` to a logger nobody reads for three days. The env var
|
|
473
|
+
still wins; the literal `off` is honoured, because an operator must be able to silence it — but
|
|
474
|
+
silence is now a choice, never a default. `diagnosticsLog` in the profile config sets the path
|
|
475
|
+
declaratively; resolution order is env → config → `<store-root>/diagnostics.log`.
|
|
476
|
+
- **Warnings reach humans, with a limit and a withheld count.** `(86 warning(s))` told a user nothing
|
|
477
|
+
about 86 real findings; the ones that mattered named the husk and the unarchived 0–4082 hole. Doors
|
|
478
|
+
print the first `warningsDisplayCap` texts and `…and M more`, through ONE formatter — a per-door copy
|
|
479
|
+
is how one door would show text and another show a count.
|
|
480
|
+
- **`/chapters-status` never early-returns.** It prints Identity · Provider · Enrichment · Diagnostics ·
|
|
481
|
+
Auto-save · **Findings** (read-only, from the #60 auditor) in every state, including unlinked. An
|
|
482
|
+
unlinked user used to see one sentence and no health at all. A session with events and **no** chapters
|
|
483
|
+
says so: *"N events, M completed turns, 0 chapters — nothing it does is retrievable."*
|
|
484
|
+
- **A tool's own output is bounded and self-reporting.** `chapters_segment` returned 49,932 characters
|
|
485
|
+
while also reporting `(Omitted 275266 bytes …)` — ≈81K est tokens into a 32K-class window, asking a
|
|
486
|
+
small model to judge a candidate list it never received. Candidates are ranked by size, capped by
|
|
487
|
+
`segmentCandidateCapTokens`, and the result always says `showing X of Y by size, Z KB withheld`.
|
|
488
|
+
- **Attribution is durable.** `generated:` carries `pluginVersion`, because "which build wrote this row"
|
|
489
|
+
must not be inferred from mtimes after the fact — this audit had to.
|
|
490
|
+
|
|
491
|
+
## Repair — re-deriving the archive from the log (#60)
|
|
492
|
+
|
|
493
|
+
Invariant 1 (the durable log is append-only and complete) is what makes every defect in #58/#59
|
|
494
|
+
recoverable. Repair is the door that spends that guarantee. It is **not** a compaction, not a rewrite,
|
|
495
|
+
and not a second archive implementation: it calls the same save core every door calls
|
|
496
|
+
(`composeChapters`/`deriveRanges` → `renderChapter` → `writeArchive` → `commitChapters`), so a repaired
|
|
497
|
+
chapter is byte-for-byte a normal chapter.
|
|
498
|
+
|
|
499
|
+
The three-way disagreement it looks for, and the one rule that keeps it honest:
|
|
500
|
+
|
|
501
|
+
- **Log vs store vs registry.** `audit()` compares the reserved numbers, the records, and the files on
|
|
502
|
+
disk, and classifies each mismatch. A store can hold a file no record cites (a lost update erased the
|
|
503
|
+
commit), and a registry can hold a record whose file was never written (a poisoned finalize queue).
|
|
504
|
+
- **Supersede, never mutate.** Damage is never repaired in place, and the body-hash guard is the only
|
|
505
|
+
sanctioned chapter-file mutation — the one deletion class is operator *Removal of bad data*, below, and
|
|
506
|
+
repair itself never deletes — so a repair writes a NEW number carrying `repairs: <n>`, marks the
|
|
507
|
+
old record `status: 'superseded'`, and **copies** the bad file into `<tree>/.repair-quarantine/`.
|
|
508
|
+
Nothing is deleted. Every superseded record is excluded from the TOC, the index and enrichment — a
|
|
509
|
+
husk left in the corpus is a lie a later reader cannot see past.
|
|
510
|
+
- **A dead citation is satisfied, not edited.** A compaction manifest already handed the host TOC text
|
|
511
|
+
citing `…/020-<title>.md`. Repair renders the shadowed span and writes it to **that path, with that
|
|
512
|
+
reserved number**. The session's committed text becomes true; no log byte moves.
|
|
513
|
+
- **Numbers stay spent.** A released reservation frees its retry key, never its numbers, and a counter
|
|
514
|
+
is only ever raised to `max(spent)+1`. Re-using a number is how two files end up named `015-` in one
|
|
515
|
+
tree (#59), so allocation additionally skips any number the row already carries.
|
|
516
|
+
- **Journalled and reversible.** An applied run writes `<tree>/repairs/<utc-stamp>.json` first: one
|
|
517
|
+
entry per move with before/after hashes. `restore <journal-id>` walks it backwards. If the journal
|
|
518
|
+
cannot be written, the run refuses to start.
|
|
519
|
+
|
|
520
|
+
Repair runs as one serialized section per session row (#59) — a repair that clobbered a live door's
|
|
521
|
+
commit would be the same bug with better intentions.
|
|
522
|
+
|
|
523
|
+
## Removal — bad bytes leave the archive (#255)
|
|
524
|
+
|
|
525
|
+
The field case: a prompt meant for another project, typed into a project session. The contaminated turns
|
|
526
|
+
archive, and from that boundary on every derived surface — TOC notices, index shards, search, enrichment —
|
|
527
|
+
carries the other project's context. Supersede is the wrong remedy for *contamination*; it is for damage.
|
|
528
|
+
The operator wants the bytes gone from memory, not merely uncited. Maintainer decision, 2026-11-02:
|
|
529
|
+
**bad bytes leave the store; the log is what lets you admit you were wrong.**
|
|
530
|
+
|
|
531
|
+
What removal deletes, and what it keeps:
|
|
532
|
+
|
|
533
|
+
- **Deletes**: the chapter file, its orphaned artifacts and plan documents — a content-addressed blob dies
|
|
534
|
+
only when no *active* record in any session tree under the project's store root still names it (shared
|
|
535
|
+
blobs survive; identity through `sameStoredPath`, never `===`) — and the same paths in the pool mirror,
|
|
536
|
+
so `stageAllAndCommit` records the deletion and `buildIndexInClone` regenerates shards from what remains.
|
|
537
|
+
`publishIntoClone` stays copy-only: a deletion is done by the removal door, never by the publisher.
|
|
538
|
+
- **Keeps**: a `status: 'removed'` stub record — number, `startSeq`/`endSeq`, `path`, `sha256`, the
|
|
539
|
+
artifact manifest, title, `removedAt`. The watermark is `max(endSeq)` over *all* records: an erased
|
|
540
|
+
record rewinds it, and the next auto-archive moment re-captures the poisoned span — the exact failure
|
|
541
|
+
this door exists to prevent. Numbers stay spent; `nextChapterNumber` never rewinds.
|
|
542
|
+
- **Never touches**: the durable log (Invariant 1), an already-seeded child's TOC (a seeded notice is a
|
|
543
|
+
recorded event; its dead citation is *reported*, not rewritten), another project's store, or git history.
|
|
544
|
+
|
|
545
|
+
Discipline shared with #60: journal-before-unlink (`<tree>/removals/<journalId>.json` — a journal write
|
|
546
|
+
that fails stops the run before the first deletion; a partial apply journals exactly what went and says so
|
|
547
|
+
with numbers), registry mutation only through `store.mutate`, and undo by **re-derivation**: `restore`
|
|
548
|
+
re-renders the journaled span through the same save core repair uses, under a NEW number, and refuses
|
|
549
|
+
(never fakes) a span whose render yields zero messages.
|
|
550
|
+
|
|
551
|
+
The derived layers never each learn the new status: `isActiveChapter` is the ONE predicate, and both
|
|
552
|
+
`superseded` and `removed` fail it — TOC (`collectEntries`), index shards, enrichment, plan rows.
|
|
553
|
+
`rehydrateStoreFromMirror` will not restore a name owned by a `removed` stub: the copy-back path is
|
|
554
|
+
precisely how a deletion resurrects itself between unlink and push. The operator gets a dangling-citation
|
|
555
|
+
report at removal time, and dead-citation misses afterward land in `<tree>/removals/missing-access.log`
|
|
556
|
+
(#61 — a detected failure is a delivered failure).
|
|
557
|
+
|
|
558
|
+
Scope, stated rather than hidden: **v1 is single-machine correct**. A second machine holding the same pool
|
|
559
|
+
with the file still in its local store can re-publish it; cross-machine tombstones ride the per-session
|
|
560
|
+
collections JSONL (already synced, append-only) as a follow-up issue.
|
|
561
|
+
|
|
562
|
+
## Integrity
|
|
563
|
+
|
|
564
|
+
Chapter files are plain Markdown in a writable tree whose content is replayed into a child as an
|
|
565
|
+
authoritative `user/message` — the model's own claimed memory. Anything that can write those files,
|
|
566
|
+
including a tool executed inside the workspace, injects text into every session downstream. That is a
|
|
567
|
+
durable prompt-injection surface, and worse than the ordinary kind because it arrives wearing the
|
|
568
|
+
guise of the agent's own past.
|
|
569
|
+
|
|
570
|
+
Mitigation, in the order that pays:
|
|
571
|
+
|
|
572
|
+
1. **Hash every chapter body and artifact at write time** (`sha256` in frontmatter *and* registry).
|
|
573
|
+
2. **Verify before citing** at continuation time; a mismatch gets an explicit `⚠ modified since
|
|
574
|
+
archived` marker on its TOC bullet rather than silent trust.
|
|
575
|
+
3. **Record the authoring session** per chapter, so tampering can be correlated with a turn.
|
|
576
|
+
|
|
577
|
+
Not solved: a determined in-workspace writer can update the registry hash too. The honest claim is
|
|
578
|
+
*accidental drift and single-shot injection are detected*, not *the archive is tamper-proof*. Say it
|
|
579
|
+
that way.
|
|
580
|
+
|
|
581
|
+
## Bounds — What Still Grows
|
|
582
|
+
|
|
583
|
+
The head is bounded; the index and the disk are not, and neither doc should imply otherwise.
|
|
584
|
+
|
|
585
|
+
- **The flattened TOC must stay an ancestor-path list, not a whole-tree dump.** Listing every chapter of
|
|
586
|
+
every branch would make "flatten the DAG" a new way to exhaust context — the § Ancestry rule (walk the
|
|
587
|
+
ancestor path only; siblings stay behind a future read-only command) is what bounds the index; folding
|
|
588
|
+
below handles depth itself.
|
|
589
|
+
*(This bullet reconstructs a line corrupted in the file's first commit — the fragment `DAG" becomes a
|
|
590
|
+
new way to exhaust context` survived without its opening. The intent was restated, not recalled; see
|
|
591
|
+
spikes/probe/FINDINGS.md § Doc corrections. Delete this parenthetical once re-reviewed.)*
|
|
592
|
+
- **TOC length grows with depth.** At ~30 tokens per bullet and 3 chapters per continuation, a depth-10
|
|
593
|
+
chain costs ~900 tokens — fine. Depth 100 costs ~9K, which is not. **Bullet folding** belongs in
|
|
594
|
+
Phase 4: when ancestor bullets exceed `tocBulletBudgetTokens`, fold the oldest group into one summary
|
|
595
|
+
bullet citing a fold chapter. Folding happens only at continuation boundaries.
|
|
596
|
+
- **Reload is a feedback loop.** A model that `read`s three 8K chapters immediately re-triggers pressure
|
|
597
|
+
and may continue again. State the remaining reload headroom in the TOC preamble so the model spends it
|
|
598
|
+
deliberately, and have the pressure detector count reloaded content like anything else.
|
|
599
|
+
- **Retention.** Append-only with no GC is unbounded disk growth across abandoned chains. A retention
|
|
600
|
+
policy, or at minimum a documented manual prune command, is required before anyone runs this for
|
|
601
|
+
months. Content-addressed artifacts make pruning safe to reason about — refcount them.
|
|
602
|
+
|
|
603
|
+
## Open Questions
|
|
604
|
+
|
|
605
|
+
Carried into Phase 0 deliberately, not by omission:
|
|
606
|
+
|
|
607
|
+
1. **Is an unseeded `agents.create` durable, sidebar-listed, and resumable?** The premise of everything.
|
|
608
|
+
If not, the design needs a different carrier and should not be funded as written.
|
|
609
|
+
2. **Does creating a session schedule a turn?** If so, the child burns its first turn acknowledging its
|
|
610
|
+
own TOC. Fix by instructing the notice to stay silent until addressed, or by creating without
|
|
611
|
+
running.
|
|
612
|
+
3. **Coexistence with `dsh-session-fork`.** It already maintains session lineage and a branch registry,
|
|
613
|
+
and patches client `sessions.fork` globally. Depend on it as a peer, vendor against the same pinned
|
|
614
|
+
version, or declare mutual exclusion — all defensible; picking silently is not.
|
|
615
|
+
4. **How do tool results that reference deleted files render?** A `read` of a file later removed is
|
|
616
|
+
still verbatim text, which is correct — but a `bash` output naming a path that no longer exists is
|
|
617
|
+
misleading without context. Probably a renderer annotation, not a format change.
|
|
618
|
+
5. **Test strategy.** The verification checklist is manual, but "the child is smaller than the parent"
|
|
619
|
+
and "ranges validate" are exactly what an automated regression suite should own. Both reference
|
|
620
|
+
plugins have `tests/`; we should decide the harness (vitest, like `dsh-compact`) before Phase 1.
|
|
621
|
+
|
|
622
|
+
## References
|
|
623
|
+
|
|
624
|
+
`examples/` is gitignored; read the **one** file that answers the question. Do not read all four.
|
|
625
|
+
|
|
626
|
+
| Concern | File |
|
|
627
|
+
|---|---|
|
|
628
|
+
| Workspace attach, preset compose, resume kernel | `dsh-session-fork/src/vendor/fork.ts` — header lists upstream sources + pinned commit |
|
|
629
|
+
| Why `agents.create` is the only durable route; notice event | `dsh-session-fork/src/branch.ts:8-14,154` |
|
|
630
|
+
| Storage-domain registry | `dsh-session-fork/src/store.ts:36-43` |
|
|
631
|
+
| `defineTool`, `jsonOutput`, `exec` caller | `dsh-session-fork/src/tools.ts:110-121,200` |
|
|
632
|
+
| `inject`, registration | `dsh-session-fork/src/index.ts:161,786` |
|
|
633
|
+
| Real fork handler — confirm what we avoid | `deepseek-harness/packages/api/session-controller/src/commands.ts:185-282` (`seed` at `:260`) |
|
|
634
|
+
| `CreateAgentOptions`, optional `seed` | `deepseek-harness/packages/core/agent/src/index.ts:95,388` |
|
|
635
|
+
| `SessionHeader` | `deepseek-harness/packages/core/session/src/types.ts:93-122` |
|
|
636
|
+
| `defineTool` signature | `deepseek-harness/packages/core/tools/src/schema.ts:545` |
|
|
637
|
+
| `ctx.llm.stream()` prefix reuse | `deepseek-harness/packages/compaction/compaction-basic/src/index.ts:225,290` |
|
|
638
|
+
| `ctx.fs` is optional | `deepseek-harness/packages/context/agent-instructions/src/index.ts:6` |
|
|
639
|
+
| `Schema<Config>` idiom | `dsh-compact/src/index.ts:15,26-37` |
|
|
640
|
+
| Packaging (`dsh.bundle.patch`) | `dsh-session-fork/package.json`, `cordis.patch.yml` |
|
|
641
|
+
|
|
642
|
+
**Do not port from:** `dsh-session-fork/src/vendor/compact.ts`, `squash*.ts`, `merge-region.ts` (span
|
|
643
|
+
rewriting — forbidden); `dsh-session-fork/src/prompt.ts` (system-prompt contribution — invariant 3);
|
|
644
|
+
`dsh-compact`'s mutation strategy (in-place by design). `dsh-plugin-dev-kb` is a minimal plugin shell
|
|
645
|
+
only; skip it otherwise.
|
|
646
|
+
|
|
647
|
+
## Phases
|
|
648
|
+
|
|
649
|
+
> **Superseded in part.** `docs/host-compaction-seam.md` found that the harness already ships automatic
|
|
650
|
+
> pressure compaction (`thresholdRatio`, default 0.8), provider-confirmed overflow recovery, per-model
|
|
651
|
+
> policies, and a single sanctioned `summarize()` subclass hook on `BasicCompactionEngine`. Phases 1-3 below
|
|
652
|
+
> predate that discovery: `pressure.ts`, `compact.ts`, custom surface mutation, and our own compact command
|
|
653
|
+
> are all **dropped**. The MVP becomes a subclassed engine that returns a deterministic TOC over archived
|
|
654
|
+
> chapters, plus the archive, registry, and fork tool. **Phase 0b (rounds 12-17) verified that revised MVP
|
|
655
|
+
> end-to-end** and settled the mount: our engine rides a preset realm (`isolate: { compaction: true }`
|
|
656
|
+
> group, our row replacing `compaction-basic`), continuations compose via
|
|
657
|
+
> `setup: agentCtx => agentPresets.mount(agentCtx, presetId)`, and `chapters_continue` remains the
|
|
658
|
+
> *branching* mechanism — automatic relief is the engine's. Read
|
|
659
|
+
> [docs/host-compaction-seam.md](host-compaction-seam.md) and FINDINGS § Phase 0b before implementing.
|
|
660
|
+
|
|
661
|
+
Reordered so the reserve-margin and pressure machinery — which automatic triggering needs — is built
|
|
662
|
+
early rather than last.
|
|
663
|
+
|
|
664
|
+
**Phase 0 — Prove the premise.** Skeleton, one trivial tool with an `output` schema, then: unseeded
|
|
665
|
+
create → durable? listed? resumable? header-matching? Single-event seed at `seq = 0` accepted by
|
|
666
|
+
continuity validation? Child first-request tokens materially below the parent's? Settle naming, the test
|
|
667
|
+
harness, and the `dsh-session-fork` coexistence question here.
|
|
668
|
+
*Gate: if the child is not durable and listed, stop and redesign.*
|
|
669
|
+
|
|
670
|
+
**Phase 1 — Render, cite, and bound.** `chapters_continue` with validated ranges; bodies rendered from
|
|
671
|
+
the log; artifact deferral and content-addressed dedup; registry with ancestry, numbering, hashes; flat
|
|
672
|
+
ancestor-path TOC with handoff note and back-links; the continuation **preflight** with
|
|
673
|
+
concrete refusal numbers; atomic ordering and idempotent retry. Manual trigger, agent supplies ranges.
|
|
674
|
+
|
|
675
|
+
**Phase 2 — Segmentation as a function, plus pressure detection.** `chapters_segment` returning validated
|
|
676
|
+
ranges, implemented as a callable usable **with or without an agent turn**; `turn/end` pressure check
|
|
677
|
+
that **surfaces a suggestion** (no autonomous action). Both the UI button and Phase 3 depend on this
|
|
678
|
+
seam, which is why it is here rather than near the end.
|
|
679
|
+
|
|
680
|
+
**Phase 3 — Automatic continuation.** Fires on the Phase 2 detector at `turn/end` only, through the same
|
|
681
|
+
Phase 1 preflight. Never mid-step, never `agent/pre-step`.
|
|
682
|
+
|
|
683
|
+
**Phase 4 — Bound the index.** Chapter splitting at continuation boundaries; bullet folding past
|
|
684
|
+
`tocBulletBudgetTokens`; reload budget in the TOC preamble; artifact refcounting and a retention/prune
|
|
685
|
+
command.
|
|
686
|
+
|
|
687
|
+
**Phase 5 — UI trigger.** Title-bar button sharing Phase 2's segmentation. Deliberately last: it needs a
|
|
688
|
+
client bundle, `tsdown.client.config.ts`, and the `slots`/`locale`/`connection` client injects — the
|
|
689
|
+
heaviest packaging in the project, for ergonomics rather than correctness.
|
|
690
|
+
|
|
691
|
+
Permanently out of scope: in-place span compaction, prefix rewriting, seeding from parent events,
|
|
692
|
+
system-prompt contributions, and truncating anything to fit.
|