Collapse docs/adr/ and docs/rfc/ into a single docs/rfc/ with proposed/, implemented/, and rejected/ subfolders. Every file is renamed to yyyy-mm-dd-topic-title.md, where the date is when the topic was first proposed (from git history). ADRs and RFCs that covered exactly the same topic are merged (property-based testing, session persistence); the umbrella RFC 005 stays split across its three implemented decisions, and RFC 006's deferred part-3 (API extractor reports) splits into its own proposed RFC. All cross-references become machine-checkable relative links instead of bare "ADR NNNN" / "RFC NNN" prose. Add a verify-md-links doc-sync gate (scripts/verify-md-links.ts) that checks every relative Markdown cross-link resolves, wired into doc-sync alongside verify-md-wrap. This makes the reorganization self-verifying: the same change that rewrote ~forty inter-doc links adds the check that proves none dangle. Document the cross-link convention in a new docs/AGENTS.md and record the gate as an implemented RFC. doc-sync, typecheck, lint, and the full test suite (667) all pass.
2.6 KiB
RFC: Structured error taxonomy
Status: implemented (accepted 2026-06-14)
Context
Failures crossed seams as bare strings. A tool error flattened to a text block — name, code, and stack lost — so a future sandbox/retry plugin couldn't tell ENOENT from EACCES, and the model got less actionable feedback than it could. A non-Error throw degraded further: the loop wrapped it in new Error(String(x)), dropping any code. And LlmError was the only typed error in the system, with no shared base, so there was nothing for a consumer to instanceof against generically.
This is the last of the runtime-validation / error-taxonomy pieces and the one the user was most skeptical of, so it was deliberately built last and in isolation: the earlier PRs (arg validation, dev invariants) threw plain Errors with a code field, decoupled from any shared base, so this change is a pure upgrade and is independently revertible without unpicking them.
Decision
A single HarnessError extends Error base in dsh-llm (the leaf package every other imports — no new dependency edge): a stable code distinct from message, cause chaining via ErrorOptions, and name defaulting to the subclass. isHarnessError narrows at seams.
LlmError,ToolArgsError(dsh-tools), andInvariantError(dsh-invariants) now extend it, keeping their existing codes.ToolExecutionResultgains optionalerror: { name, code }, populated in the registry's catch when the thrown value is aHarnessError. The agent loop forwards it onto thetool/resultsession event (which gained the same optional field), so the structured failure survives into the log for retry/sandbox plugins and replay. The model-facing text block is unchanged.- The loop's
toErrorwraps a non-Error throw in aHarnessError(code: 'UNKNOWN', original chained ascause) instead of a bareError, so even a bad throw carries a routable code into the sessionerrorevent (which already surfacedcode).
Consequences
- Errors are machine-routable end-to-end: a plugin can branch on
error.coderather than substring-matching a message. - One base class is imported widely, but it lives in the package everyone already depends on, so the cost is a single import, not a new edge.
deriveMessagesdoes not surfaceerrorinto model history — the model still sees the text block; the structured field is for code and replay.- Reverting this PR returns the earlier errors to plain
Error+codeform; nothing else in the stack depends on the shared base.