From 67447fcdc31fd3779b7a5b625b25ad75951da304 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:14:25 +0800 Subject: [PATCH 1/7] feat: enforce merge-commit policy and markdown wrap convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AGENTS.md: require merging PRs with a merge commit (gh pr merge --merge), never squash/rebase — the per-PR commit history (review-fix and regression-test commits) is intentional record. - Add scripts/verify-md-wrap.ts: a doc-sync gate that fails on hard-wrapped prose paragraphs (one physical line per paragraph), with smart exemptions for fenced code, tables, lists, blockquotes, headings, HTML comments, hrs, and reference defs. Scope covers README.md, docs/**/*.md, packages/*/README.md, plus AGENTS.md / packages/AGENTS.md (the files doc-sync did not previously cover). Folded into doc-sync so it rides the existing pre-push and CI gates. - Sync AGENTS.md and docs/development.md doc-sync descriptions and command lists to include verify-md-wrap. --- AGENTS.md | 9 +- docs/development.md | 5 +- package.json | 3 +- scripts/verify-md-wrap.ts | 172 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 scripts/verify-md-wrap.ts diff --git a/AGENTS.md b/AGENTS.md index c602ff4872..7bd3090280 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,7 +69,9 @@ pnpm run doc-typecheck # typecheck every ```ts block in README.md, docs/**/*.md # packages/*/README.md (doc/code drift gate) pnpm run verify-event-taxonomy # assert the event-taxonomy table in docs/architecture.md # matches the interface Events declarations in source -pnpm run doc-sync # doc-typecheck + verify-event-taxonomy (CI runs this) +pnpm run verify-md-wrap # assert no hard-wrapped prose paragraphs in README.md, + # docs/**/*.md, packages/*/README.md, AGENTS.md (one line per paragraph) +pnpm run doc-sync # doc-typecheck + verify-event-taxonomy + verify-md-wrap (CI runs this) pnpm run demo:echo # run examples/echo-agent (no API key; type "echo hi" to # see a tool call) — the mock skeleton pnpm run demo:coding # run examples/coding-agent — the real agent (needs @@ -104,6 +106,7 @@ Dev/test/demo run **unbuilt** via tsx + the `paths` map in the root `tsconfig.js - **Explicit > implicit at package seams**: interface/vocabulary types spell out every field a consumer must supply — no optional field that the implementation silently fills with a hidden `?? default`. Put defaulting in the owning implementation as an explicit step (a `resolve(request): Spec` method that turns the optional-field request into the required-field spec), not smuggled inside `run()`/`start()`. Example: `dsh-bash` splits `BashExecRequest` (optional `workdir`/`timeoutMs`, model-facing) from `BashExecSpec` (required, what `run`/`start` act on); the tool layer calls `ctx.bash.resolve()` between them. The reader of a `BashExecSpec` never has to wonder where the working directory came from. - **An empty `catch` must name what it swallows and why nothing else can hit it**: a bare `catch {}` hides bugs. When you deliberately ignore a throw, the comment must (a) name the single expected failure, (b) say why ignoring it is correct — usually because the useful state was already captured *before* the `try` — and (c) make clear nothing else of consequence can reach the catch (ideally the `try` wraps a single statement). Example: the error-body `response.json()` parse in `dsh-llm-deepseek`'s adapter sets `code` + HTTP `status` from the status line before the `try`, so a malformed provider body can only cost a richer message, never the real error. - **Symmetry is usually more correct**: when two related values play parallel roles (a test fixture and its expected output, a request shape and its response shape, a buggy input and the test that checks the fix), give them parallel form — both named consts, or both inline, not one each way. Asymmetry is a smell that usually points at a missed extraction. +- **Merging PRs**: always merge with a **merge commit** (`gh pr merge --merge`), never squash or rebase. The per-PR commit history is intentional — review-fix commits, regression-test commits, and the reasoning in each message are part of the record — and squashing flattens it away. - **Tests**: vitest, colocated under `packages//tests/*.spec.ts`. Every registry needs an HMR-safety test (dispose the contributing fiber, assert cleanup). **Excessive tests are welcome** — when in doubt, write the test; err on the side of covering edge cases, error paths, event ordering, and concurrency races even if they seem unlikely. Review findings get regression tests (see `packages/agent-loop/tests/review-fixes.spec.ts`). ## Defensive patterns (hard-won) @@ -125,9 +128,9 @@ This codebase aims to be **very type-safe and well documented** for maintainabil In the **core** packages (`packages/llm`, `packages/tools`, `packages/agent`, `packages/agent-loop`, `packages/session`, `packages/system-prompt`), **type gymnastics are acceptable when they improve the DX of plugin authors** for common plugin types. The `defineTool` typed schema DSL in `dsh-tools` is the canonical example: the `SchemaSpec` to `InferArgs` type-level mapping gives tool authors zero-cast typed `execute` args, and the cost of the conditional types stays inside the core package. -Verbose documentation is fine **as long as docs and code stay strictly in sync**. Out-of-sync docs are worse than no docs. **When you change code, update its docs in the SAME change** — grep the package README and the module/JSDoc comments for the old behavior (config keys, defaults, error codes, wire field names, event names) and fix every hit. CI runs `pnpm run doc-sync` (`doc-typecheck` + `verify-event-taxonomy`), which typechecks every fenced `ts` block in `README.md`, `docs/**/*.md`, and `packages/*/README.md` and verifies the event-taxonomy table against source — but that scope does NOT cover `AGENTS.md`, `packages/AGENTS.md`, or `packages/README.md`, nor does it catch prose drift (config keys, defaults, error codes), so keeping those in sync remains on the author. Every module has a module-level doc comment explaining its role. Every exported class, interface, type, function, and non-obvious method has a JSDoc that explains semantics (not just the name) — contracts (what events fire when), disposal behavior, error behavior, and extension intent. Internal helpers get docs only where non-obvious. Prefer one-liners when one line suffices. +Verbose documentation is fine **as long as docs and code stay strictly in sync**. Out-of-sync docs are worse than no docs. **When you change code, update its docs in the SAME change** — grep the package README and the module/JSDoc comments for the old behavior (config keys, defaults, error codes, wire field names, event names) and fix every hit. CI runs `pnpm run doc-sync` (`doc-typecheck` + `verify-event-taxonomy` + `verify-md-wrap`), which typechecks every fenced `ts` block in `README.md`, `docs/**/*.md`, and `packages/*/README.md`, verifies the event-taxonomy table against source, and asserts no hard-wrapped prose paragraphs across those files plus `AGENTS.md` / `packages/AGENTS.md` — but that scope does NOT catch prose drift in `AGENTS.md` / `packages/AGENTS.md` / `packages/README.md` (config keys, defaults, error codes), so keeping those in sync remains on the author. Every module has a module-level doc comment explaining its role. Every exported class, interface, type, function, and non-obvious method has a JSDoc that explains semantics (not just the name) — contracts (what events fire when), disposal behavior, error behavior, and extension intent. Internal helpers get docs only where non-obvious. Prefer one-liners when one line suffices. -**Markdown is not hard-wrapped**: write one line per paragraph and let the editor soft-wrap. Hard line breaks mid-paragraph make docs harder to edit and diff — a one-word change reflows and re-diffs the whole paragraph. This applies to prose only: leave fenced code blocks, tables, and list structure intact (a wrapped list item folds to one line per bullet). Code comments / JSDoc are exempt — they stay under the linter's column limit. +**Markdown is not hard-wrapped**: write one line per paragraph and let the editor soft-wrap. Hard line breaks mid-paragraph make docs harder to edit and diff — a one-word change reflows and re-diffs the whole paragraph. This applies to prose only: leave fenced code blocks, tables, and list structure intact (a wrapped list item folds to one line per bullet). Code comments / JSDoc are exempt — they stay under the linter's column limit. `pnpm run verify-md-wrap` (part of `doc-sync`) enforces this across `README.md`, `docs/**/*.md`, `packages/*/README.md`, and `AGENTS.md` / `packages/AGENTS.md`. **Editing these instructions**: `AGENTS.md` is the real file; `CLAUDE.md` is a symlink to it (at the repo root and in `packages/`). Always edit `AGENTS.md` — never write through the `CLAUDE.md` symlink or replace it with a regular file. diff --git a/docs/development.md b/docs/development.md index 0defe20cec..92d4d3ec99 100644 --- a/docs/development.md +++ b/docs/development.md @@ -93,14 +93,15 @@ pnpm run lint # eslint . pnpm run lint:fix # eslint . --fix pnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs pnpm run verify-event-taxonomy # compare docs/architecture.md event names with source -pnpm run doc-sync # doc-typecheck plus event taxonomy verification +pnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown +pnpm run doc-sync # doc-typecheck, event taxonomy, and markdown wrap verification pnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps pnpm run verify-module-graph # fail if docs/module-graph.md is stale pnpm run build # build declarations and JS bundles pnpm run hygiene # knip, publint, and workspace constraints ``` -When changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets and event-taxonomy drift, but broader prose/API sync still needs review. +When changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, event-taxonomy drift, and hard-wrapped markdown prose, but broader prose/API sync still needs review. ## Demos diff --git a/package.json b/package.json index b0a7e5ae4d..e447dadba2 100644 --- a/package.json +++ b/package.json @@ -23,10 +23,11 @@ "publint": "tsx scripts/publint-all.ts", "doc-typecheck": "tsx scripts/doc-typecheck.ts", "verify-event-taxonomy": "tsx scripts/verify-event-taxonomy.ts", + "verify-md-wrap": "tsx scripts/verify-md-wrap.ts", "gen-module-graph": "tsx scripts/gen-module-graph.ts", "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", "constraints": "tsx scripts/check-workspace-constraints.ts", - "doc-sync": "pnpm run doc-typecheck && pnpm run verify-event-taxonomy", + "doc-sync": "pnpm run doc-typecheck && pnpm run verify-event-taxonomy && pnpm run verify-md-wrap", "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints", "demo:echo": "node --expose-internals --import tsx examples/echo-agent/start.ts", "demo:coding": "node --expose-internals --import tsx examples/coding-agent/start.ts", diff --git a/scripts/verify-md-wrap.ts b/scripts/verify-md-wrap.ts new file mode 100644 index 0000000000..5a08611108 --- /dev/null +++ b/scripts/verify-md-wrap.ts @@ -0,0 +1,172 @@ +/** + * Doc-sync gate: enforce the repo's "Markdown is not hard-wrapped" convention + * (AGENTS.md § Type Safety and Documentation) — prose paragraphs are written as + * one physical line per paragraph and the editor soft-wraps. A hard-wrapped + * paragraph (a one-word edit reflows and re-diffs the whole block) is a defect + * this script catches before review. + * + * Scope mirrors doc-typecheck plus the two AGENTS.md files that doc-sync does + * NOT otherwise cover (the convention itself lives there): README.md, + * docs/** /*.md, packages/* /README.md, AGENTS.md, packages/AGENTS.md. (The + * root and packages/ CLAUDE.md are symlinks to the AGENTS.md files, so they are + * skipped to avoid double-reporting.) + * + * A violation is two consecutive *prose* lines — a paragraph that spans + * physical lines instead of soft-wrapping. Structure that legitimately occupies + * multiple lines is exempt: fenced code blocks, tables, list items (and their + * indented continuations), headings, blockquotes, HTML blocks/comments, + * horizontal rules, and reference-link / footnote definitions. + * + * Run: `tsx scripts/verify-md-wrap.ts`. + */ + +import { readFileSync, realpathSync } from 'node:fs' +import { relative, resolve } from 'node:path' +import { glob } from 'node:fs/promises' + +const root = resolve(import.meta.dirname, '..') + +/** Files to check: doc-typecheck's scope plus the AGENTS.md pair. */ +const PATTERNS = ['README.md', 'docs/**/*.md', 'packages/*/README.md', 'AGENTS.md', 'packages/AGENTS.md'] + +/** A located hard-wrap: the second line of a multi-line prose paragraph. */ +interface Violation { + file: string + /** 1-based line number of the offending continuation line. */ + line: number + text: string +} + +/** + * True when a line is *prose* — ordinary paragraph text, not markdown + * structure. Structural lines (headings, lists, tables, blockquotes, HTML, + * fences, hrs, reference defs) legitimately stand alone or stack, so they never + * count toward a hard-wrap pair. Caller handles fenced-code and list-body state. + */ +function isProse(line: string): boolean { + if (line.trim() === '') return false + // Up to 3 leading spaces is still a "top-level" block in CommonMark; deeper + // indentation is handled as list continuation by the caller. + const s = line.replace(/^ {0,3}/, '') + if (/^#{1,6}\s/.test(s)) return false // ATX heading + if (/^([-*+])\s/.test(s)) return false // bullet list + if (/^\d{1,9}[.)]\s/.test(s)) return false // ordered list + if (/^>/.test(s)) return false // blockquote + if (/^\|/.test(s)) return false // table row + if (/^\s*$/.test(s)) return false // HTML comment line + if (/^ HTML comment + let inListItem = false // inside a list item's body (its indented continuations) + let prevWasProse = false + + lines.forEach((raw, i) => { + const trimmed = raw.trim() + + // Fenced code blocks: everything between matching fences is exempt. + const fence = /^ {0,3}(```+|~~~+)/.exec(raw) + if (fence) { + const marker = (fence[1] ?? '').startsWith('`') ? '```' : '~~~' + if (!inFence) { + inFence = true + fenceMarker = marker + } else if (marker === fenceMarker) { + inFence = false + } + prevWasProse = false + return + } + if (inFence) { + prevWasProse = false + return + } + + // Multi-line HTML comments are exempt (e.g. generated-file headers). Track + // open/close across lines so the body of a 3+ line comment isn't read as + // hard-wrapped prose. + if (inComment) { + if (/-->/.test(raw)) inComment = false + prevWasProse = false + return + } + if (/^ {0,3}/.test(raw)) { + inComment = true + prevWasProse = false + return + } + + if (trimmed === '') { + inListItem = false + prevWasProse = false + return + } + + // Track list context so an item's wrapped continuation lines (indented or + // lazy) are treated as list structure, not a hard-wrapped prose paragraph. + const isListMarker = /^ {0,3}([-*+]|\d{1,9}[.)])\s/.test(raw) + if (isListMarker) { + inListItem = true + prevWasProse = false + return + } + if (inListItem) { + // Indented under the item, or lazy continuation — still the list item. + prevWasProse = false + return + } + + if (!isProse(raw)) { + prevWasProse = false + return + } + + // A prose line. If the line before it was also prose, the paragraph spans + // physical lines — a hard wrap. + if (prevWasProse) { + out.push({ file, line: i + 1, text: trimmed }) + } + prevWasProse = true + }) + + return out +} + +const seen = new Set() +const all: Violation[] = [] +let checked = 0 +for (const pattern of PATTERNS) { + for await (const match of glob(pattern, { cwd: root })) { + const abs = resolve(root, match) + // CLAUDE.md symlinks resolve onto AGENTS.md; dedupe by real path so a file + // matched twice (or via symlink) is checked once. + const real = realpathSync(abs) + if (seen.has(real)) continue + seen.add(real) + checked++ + all.push(...findViolations(abs)) + } +} + +if (all.length === 0) { + console.log(`verify-md-wrap: ${checked} file(s) checked, no hard-wrapped prose paragraphs.`) + process.exit(0) +} + +console.error('verify-md-wrap: hard-wrapped prose paragraphs found (write one physical line per paragraph):') +for (const v of all) { + console.error(` ${v.file}:${v.line} ${v.text.slice(0, 80)}${v.text.length > 80 ? '…' : ''}`) +} +process.exit(1) From 326b026ab3bee9374cdad0975cc276a65c1ed061 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:45:19 +0800 Subject: [PATCH 2/7] docs(development): document FIXME/TODO/XXX markers Define the three issue-urgency tags so contributors can flag a release blocker, a soon-to-fix item, and a someday-maybe consistently. --- docs/development.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/development.md b/docs/development.md index 0defe20cec..d19308faa0 100644 --- a/docs/development.md +++ b/docs/development.md @@ -116,6 +116,16 @@ The coding-agent demo uses the real DeepSeek adapter and needs `DEEPSEEK_API_KEY pnpm run demo:coding ``` +## TODO markers + +Use one of three comment tags to flag known issues in the code, ordered by urgency: + +- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway. +- `TODO` — an issue that should be fixed soon, once we have the resources. +- `XXX` — an issue that we may fix someday; lowest priority, no commitment. + +Pick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe. + ## Architecture context Read `docs/architecture.md` before changing anything under `packages/`. The codebase is built around Cordis plugins, event-sourced sessions, typed service seams, and explicit extension points. From 53b37b39c5ca57a506d8eb2e469f77e7d373e151 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:02:48 +0800 Subject: [PATCH 3/7] docs: point AGENTS.md to development.md for TODO marker semantics --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index c602ff4872..8213075ed7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,6 +104,7 @@ Dev/test/demo run **unbuilt** via tsx + the `paths` map in the root `tsconfig.js - **Explicit > implicit at package seams**: interface/vocabulary types spell out every field a consumer must supply — no optional field that the implementation silently fills with a hidden `?? default`. Put defaulting in the owning implementation as an explicit step (a `resolve(request): Spec` method that turns the optional-field request into the required-field spec), not smuggled inside `run()`/`start()`. Example: `dsh-bash` splits `BashExecRequest` (optional `workdir`/`timeoutMs`, model-facing) from `BashExecSpec` (required, what `run`/`start` act on); the tool layer calls `ctx.bash.resolve()` between them. The reader of a `BashExecSpec` never has to wonder where the working directory came from. - **An empty `catch` must name what it swallows and why nothing else can hit it**: a bare `catch {}` hides bugs. When you deliberately ignore a throw, the comment must (a) name the single expected failure, (b) say why ignoring it is correct — usually because the useful state was already captured *before* the `try` — and (c) make clear nothing else of consequence can reach the catch (ideally the `try` wraps a single statement). Example: the error-body `response.json()` parse in `dsh-llm-deepseek`'s adapter sets `code` + HTTP `status` from the status line before the `try`, so a malformed provider body can only cost a richer message, never the real error. - **Symmetry is usually more correct**: when two related values play parallel roles (a test fixture and its expected output, a request shape and its response shape, a buggy input and the test that checks the fix), give them parallel form — both named consts, or both inline, not one each way. Asymmetry is a smell that usually points at a missed extraction. +- **TODO markers**: use `FIXME`/`TODO`/`XXX` to flag known issues by urgency — see [docs/development.md](docs/development.md) for the semantics of each. - **Tests**: vitest, colocated under `packages//tests/*.spec.ts`. Every registry needs an HMR-safety test (dispose the contributing fiber, assert cleanup). **Excessive tests are welcome** — when in doubt, write the test; err on the side of covering edge cases, error paths, event ordering, and concurrency races even if they seem unlikely. Review findings get regression tests (see `packages/agent-loop/tests/review-fixes.spec.ts`). ## Defensive patterns (hard-won) From 63425a2b873779c96c089adb8c9520019628342f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:35:33 +0800 Subject: [PATCH 4/7] refactor: detect md hard-wraps via mdast AST, not regex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review feedback (use a real markdown parser with an AST linked to source positions), rewrite verify-md-wrap to parse each file with mdast-util-from-markdown (the CommonMark parser behind remark) + the GFM extension, then flag any `paragraph` node whose source span covers more than one line. Why a parser over the hand-rolled line scanner: - It is a checker, not a formatter — it reports and never rewrites, so zero cosmetic churn (no emphasis-marker or table-delimiter normalization, which is why Prettier was rejected for this). - The AST owns every structural exemption (fenced code of any fence length, tables, lists, blockquotes, HTML, headings, reference defs), fixing both bugs the regex version had: it now catches wrapped list-item / blockquote prose (a `paragraph` inside those nodes) and no longer false-positives on a longer ```` fence wrapping an inner ```. Also unwrap two pre-existing hard-wrapped blockquotes (architecture.md, adding-a-tool.md) that the stricter AST check correctly surfaced. --- docs/architecture.md | 12 +- docs/cookbook/adding-a-tool.md | 4 +- package.json | 4 + pnpm-lock.yaml | 528 +++++++++++++++++++++++++++++++++ scripts/verify-md-wrap.ts | 144 +++------ 5 files changed, 570 insertions(+), 122 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 6cab7201b5..8dec7a90d8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -61,17 +61,7 @@ Swappable capabilities are split into **three packages** so each part evolves in The LLM seam has the same topology folded differently: `dsh-llm` carries the interface (`LlmAdapter`) AND the consumer surface (`ctx.llm.stream()`), with adapters as implementation packages — there the consumer is the loop itself, not a swappable schema surface. Use the full three-package split when the consumer is independently replaceable; keep interface + consumer together when they are one concern. Don't split preemptively: a capability with one conceivable implementation and one consumer stays one package until proven otherwise. -> **"Capability" — two unrelated meanings.** (1) The *seam pattern* above -> ("one plugin provides a capability, another needs it") is realized by -> plain Cordis **services + `inject`**: a provider registers a service -> (`ctx.bash`, declared in `interface Context`); a consumer declares -> `inject: ['bash']` and its fiber stays pending until the service exists, -> tearing down via HMR if it later vanishes. No extra library is needed. -> (2) `@cordisjs/plugin-capability` is a different axis entirely — a -> **permission/capability-security** service (named permissions with -> inheritance/dependency, tested against a session via `ctx.capability.test`). -> It is a candidate for the deferred permissions/sandbox work (the -> `tools/execute` veto seam), NOT a mechanism for swapping implementations. +> **"Capability" — two unrelated meanings.** (1) The *seam pattern* above ("one plugin provides a capability, another needs it") is realized by plain Cordis **services + `inject`**: a provider registers a service (`ctx.bash`, declared in `interface Context`); a consumer declares `inject: ['bash']` and its fiber stays pending until the service exists, tearing down via HMR if it later vanishes. No extra library is needed. (2) `@cordisjs/plugin-capability` is a different axis entirely — a **permission/capability-security** service (named permissions with inheritance/dependency, tested against a session via `ctx.capability.test`). It is a candidate for the deferred permissions/sandbox work (the `tools/execute` veto seam), NOT a mechanism for swapping implementations. ## The vocabulary (dsh-llm) diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index 905fde960a..2dcc6c9093 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -42,9 +42,7 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool (w Follow tool-bash's background pattern: a `run_in_background` flag returns a task id immediately; companion tools poll incrementally and kill; completion notices arrive via `agent.inject()`. Bound buffers and spill full output to disk so nothing is silently lost. -> TODO: each tool reimplements this background pattern by hand today. At some -> point we need a generic long-running-tool layer that handles task ids, -> incremental polling, kill, and completion notices uniformly. +> TODO: each tool reimplements this background pattern by hand today. At some point we need a generic long-running-tool layer that handles task ids, incremental polling, kill, and completion notices uniformly. ## Permissions / sandboxing diff --git a/package.json b/package.json index e447dadba2..b764f26ce8 100644 --- a/package.json +++ b/package.json @@ -35,12 +35,16 @@ }, "devDependencies": { "@stylistic/eslint-plugin": "^5.10.0", + "@types/mdast": "^4.0.4", "@types/node": "^25.3.5", "@vitest/coverage-v8": "^4.1.8", "eslint": "^10.4.1", "fast-check": "^4.8.0", "knip": "^6.16.1", "lefthook": "^2.1.9", + "mdast-util-from-markdown": "^2.0.3", + "mdast-util-gfm": "^3.1.0", + "micromark-extension-gfm": "^3.0.0", "publint": "^0.3.21", "tsdown": "^0.22.2", "tsx": "^4.22.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90f30cacb1..1bbb1edf58 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@stylistic/eslint-plugin': specifier: ^5.10.0 version: 5.10.0(eslint@10.5.0(jiti@2.7.0)) + '@types/mdast': + specifier: ^4.0.4 + version: 4.0.4 '@types/node': specifier: ^25.3.5 version: 25.9.3 @@ -29,6 +32,15 @@ importers: lefthook: specifier: ^2.1.9 version: 2.1.9 + mdast-util-from-markdown: + specifier: ^2.0.3 + version: 2.0.3 + mdast-util-gfm: + specifier: ^3.1.0 + version: 3.1.0 + micromark-extension-gfm: + specifier: ^3.0.0 + version: 3.0.0 publint: specifier: ^0.3.21 version: 0.3.21 @@ -1305,6 +1317,9 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -1320,6 +1335,12 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@25.9.3': resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==} @@ -1329,6 +1350,9 @@ packages: '@types/retry@0.12.0': resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@typescript-eslint/eslint-plugin@8.61.0': resolution: {integrity: sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1491,10 +1515,16 @@ packages: resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} engines: {node: '>=20.19.0'} + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -1534,16 +1564,26 @@ packages: supports-color: optional: true + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dts-resolver@3.0.0: resolution: {integrity: sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==} engines: {node: ^22.18.0 || >=24.0.0} @@ -1572,6 +1612,10 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + eslint-scope@9.1.2: resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -1972,6 +2016,9 @@ packages: long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1982,6 +2029,126 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -2309,6 +2476,18 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -2456,6 +2635,9 @@ packages: zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + snapshots: '@anthropic-ai/sdk@0.91.1(zod@4.4.3)': @@ -3308,6 +3490,10 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} '@types/esrecurse@4.3.1': {} @@ -3318,6 +3504,12 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/ms@2.1.0': {} + '@types/node@25.9.3': dependencies: undici-types: 7.24.6 @@ -3326,6 +3518,8 @@ snapshots: '@types/retry@0.12.0': {} + '@types/unist@3.0.3': {} + '@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -3525,8 +3719,12 @@ snapshots: cac@7.0.0: {} + ccount@2.0.1: {} + chai@6.2.2: {} + character-entities@2.0.2: {} + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -3555,12 +3753,22 @@ snapshots: dependencies: ms: 2.1.3 + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + deep-is@0.1.4: {} defu@6.1.7: {} + dequal@2.0.3: {} + detect-libc@2.1.2: {} + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + dts-resolver@3.0.0(oxc-resolver@11.20.0): optionalDependencies: oxc-resolver: 11.20.0 @@ -3604,6 +3812,8 @@ snapshots: escape-string-regexp@4.0.0: {} + escape-string-regexp@5.0.0: {} + eslint-scope@9.1.2: dependencies: '@types/esrecurse': 4.3.1 @@ -4001,6 +4211,8 @@ snapshots: long@5.3.2: {} + longest-streak@3.1.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -4015,6 +4227,301 @@ snapshots: dependencies: semver: 7.8.4 + markdown-table@3.0.4: {} + + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + minimatch@10.2.5: dependencies: brace-expansion: 5.0.6 @@ -4353,6 +4860,25 @@ snapshots: undici-types@7.24.6: {} + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -4438,3 +4964,5 @@ snapshots: zod: 4.4.3 zod@4.4.3: {} + + zwitch@2.0.4: {} diff --git a/scripts/verify-md-wrap.ts b/scripts/verify-md-wrap.ts index 5a08611108..169b06b15b 100644 --- a/scripts/verify-md-wrap.ts +++ b/scripts/verify-md-wrap.ts @@ -5,17 +5,22 @@ * paragraph (a one-word edit reflows and re-diffs the whole block) is a defect * this script catches before review. * - * Scope mirrors doc-typecheck plus the two AGENTS.md files that doc-sync does - * NOT otherwise cover (the convention itself lives there): README.md, - * docs/** /*.md, packages/* /README.md, AGENTS.md, packages/AGENTS.md. (The - * root and packages/ CLAUDE.md are symlinks to the AGENTS.md files, so they are - * skipped to avoid double-reporting.) + * Detection is AST-based: we parse each file with mdast-util-from-markdown (the + * CommonMark parser behind remark) plus the GFM extension, then flag any + * `paragraph` node whose source span covers more than one line. The parser owns + * all the structure that legitimately occupies multiple lines — fenced code + * (any fence length), tables, list items, blockquotes, HTML blocks, headings, + * thematic breaks, link-reference definitions — so a hard wrap is simply "a + * paragraph node that starts and ends on different lines." This is checker, not + * formatter: it reports and never rewrites, so it introduces zero cosmetic + * churn (no emphasis-marker or table-delimiter normalization). * - * A violation is two consecutive *prose* lines — a paragraph that spans - * physical lines instead of soft-wrapping. Structure that legitimately occupies - * multiple lines is exempt: fenced code blocks, tables, list items (and their - * indented continuations), headings, blockquotes, HTML blocks/comments, - * horizontal rules, and reference-link / footnote definitions. + * A wrapped paragraph inside a list item or blockquote is still a `paragraph` + * node, so those are caught too. Scope mirrors doc-typecheck plus the two + * AGENTS.md files that doc-sync does NOT otherwise cover (the convention itself + * lives there): README.md, docs/** /*.md, packages/* /README.md, AGENTS.md, + * packages/AGENTS.md. The root and packages/ CLAUDE.md are symlinks to the + * AGENTS.md files, so they are deduped by real path. * * Run: `tsx scripts/verify-md-wrap.ts`. */ @@ -23,124 +28,47 @@ import { readFileSync, realpathSync } from 'node:fs' import { relative, resolve } from 'node:path' import { glob } from 'node:fs/promises' +import { fromMarkdown } from 'mdast-util-from-markdown' +import { gfmFromMarkdown } from 'mdast-util-gfm' +import { gfm } from 'micromark-extension-gfm' +import type { Nodes } from 'mdast' const root = resolve(import.meta.dirname, '..') /** Files to check: doc-typecheck's scope plus the AGENTS.md pair. */ const PATTERNS = ['README.md', 'docs/**/*.md', 'packages/*/README.md', 'AGENTS.md', 'packages/AGENTS.md'] -/** A located hard-wrap: the second line of a multi-line prose paragraph. */ +/** A located hard-wrap: a prose paragraph spanning more than one source line. */ interface Violation { file: string - /** 1-based line number of the offending continuation line. */ + /** 1-based line where the hard-wrapped paragraph starts. */ line: number text: string } -/** - * True when a line is *prose* — ordinary paragraph text, not markdown - * structure. Structural lines (headings, lists, tables, blockquotes, HTML, - * fences, hrs, reference defs) legitimately stand alone or stack, so they never - * count toward a hard-wrap pair. Caller handles fenced-code and list-body state. - */ -function isProse(line: string): boolean { - if (line.trim() === '') return false - // Up to 3 leading spaces is still a "top-level" block in CommonMark; deeper - // indentation is handled as list continuation by the caller. - const s = line.replace(/^ {0,3}/, '') - if (/^#{1,6}\s/.test(s)) return false // ATX heading - if (/^([-*+])\s/.test(s)) return false // bullet list - if (/^\d{1,9}[.)]\s/.test(s)) return false // ordered list - if (/^>/.test(s)) return false // blockquote - if (/^\|/.test(s)) return false // table row - if (/^\s*$/.test(s)) return false // HTML comment line - if (/^ HTML comment - let inListItem = false // inside a list item's body (its indented continuations) - let prevWasProse = false - - lines.forEach((raw, i) => { - const trimmed = raw.trim() - - // Fenced code blocks: everything between matching fences is exempt. - const fence = /^ {0,3}(```+|~~~+)/.exec(raw) - if (fence) { - const marker = (fence[1] ?? '').startsWith('`') ? '```' : '~~~' - if (!inFence) { - inFence = true - fenceMarker = marker - } else if (marker === fenceMarker) { - inFence = false + const visit = (node: Nodes): void => { + if (node.type === 'paragraph' && node.position) { + const { start, end } = node.position + if (end.line > start.line) { + const firstLine = source.split('\n')[start.line - 1] ?? '' + out.push({ file, line: start.line, text: firstLine.trim() }) } - prevWasProse = false + // A paragraph's children are inline (text/emphasis/…); no nested + // paragraphs to find, so don't descend. return } - if (inFence) { - prevWasProse = false - return + if ('children' in node) { + for (const child of node.children) visit(child) } - - // Multi-line HTML comments are exempt (e.g. generated-file headers). Track - // open/close across lines so the body of a 3+ line comment isn't read as - // hard-wrapped prose. - if (inComment) { - if (/-->/.test(raw)) inComment = false - prevWasProse = false - return - } - if (/^ {0,3}/.test(raw)) { - inComment = true - prevWasProse = false - return - } - - if (trimmed === '') { - inListItem = false - prevWasProse = false - return - } - - // Track list context so an item's wrapped continuation lines (indented or - // lazy) are treated as list structure, not a hard-wrapped prose paragraph. - const isListMarker = /^ {0,3}([-*+]|\d{1,9}[.)])\s/.test(raw) - if (isListMarker) { - inListItem = true - prevWasProse = false - return - } - if (inListItem) { - // Indented under the item, or lazy continuation — still the list item. - prevWasProse = false - return - } - - if (!isProse(raw)) { - prevWasProse = false - return - } - - // A prose line. If the line before it was also prose, the paragraph spans - // physical lines — a hard wrap. - if (prevWasProse) { - out.push({ file, line: i + 1, text: trimmed }) - } - prevWasProse = true - }) - + } + visit(tree) return out } From 2bae18f8118b08238952af3f0471a127a853f83b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:50:52 +0800 Subject: [PATCH 5/7] fix(session): synthesize tool results for interrupted tool calls on crash recovery (review #33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loop logs the assistant/message (carrying tool-call blocks) BEFORE running the tools, so a crash mid-tool leaves durable tool calls with no matching tool/result. interruptedTurnClosers only added step/end + turn/end, so a resumed session's deriveMessages() replayed a dangling assistant tool-call — which every provider rejects as an invalid transcript on the next request. interruptedTurnClosers now scans the interrupted turn for tool-call blocks without a matching tool/result and synthesizes an error tool/result for each (before the step/end), so the rehydrated history is a valid transcript. Adds a dedicated repair.spec.ts and a shared-contract case proving both backends pair every orphaned call with a result. Docs (ADR 0018, both persistence READMEs, load() JSDoc) updated. Also fixes the echo-agent README session-cleanup path: demo:echo runs from the repo root, so sessions land in /.sessions/_no-cwd/, not examples/echo-agent/.sessions/ (review #33). --- docs/adr/0018-session-persistence.md | 2 +- examples/echo-agent/README.md | 2 +- packages/session-persistence-jsonl/README.md | 2 +- packages/session-persistence/README.md | 4 +- packages/session-persistence/src/index.ts | 19 +-- .../session-persistence/tests/contract.ts | 41 ++++++ packages/session/src/repair.ts | 65 ++++++++- packages/session/tests/repair.spec.ts | 125 ++++++++++++++++++ 8 files changed, 244 insertions(+), 16 deletions(-) create mode 100644 packages/session/tests/repair.spec.ts diff --git a/docs/adr/0018-session-persistence.md b/docs/adr/0018-session-persistence.md index 2a90377d4f..5f211bef51 100644 --- a/docs/adr/0018-session-persistence.md +++ b/docs/adr/0018-session-persistence.md @@ -18,7 +18,7 @@ Persistence is an abstract **capability seam** ([ADR 0009](0009-capability-seams Key choices recorded here because they are durable, contested, and surprising: - **The canonical durable log persists every `SessionEvent` verbatim, including `assistant/chunk`.** `deriveMessages()` skips chunks, and a chunk-filtered rollout (Codex's `policy.rs`) is tempting — but `seq = log.length` and the load-validation `events[i].seq === i` require a *contiguous* log; filtering chunks out would leave holes and break both the contract and resume. A chunk-filtered projection is possible later as a derived view with its own renumbering, but it is NOT the canonical log. -- **Append-only; a crashed turn is closed, never truncated.** Committed events — those at or below a flushed `turn/end` — are never rewritten. The loop only flushes at `turn/end`, so a crash can leave a durable log whose final turn never closed: real, fully-written events sit after the last `turn/end`. **A single turn can be huge in a long-horizon task** (many steps, large tool output spanning a long autonomous run), so discarding the interrupted turn would silently destroy a large amount of real work — truncating a turn is wrong. Instead, on reload `load` PRESERVES those events and CLOSES the orphaned turn by durably appending the minimal synthetic boundary events: a `step/end` if a step was still open, then a `turn/end` carrying the merge-extensible `{ kind: 'interrupted' }` reason (a marker that records the turn was cut short by a crash, not completed by the model — no loop ever emits it). `load` returns the balanced log, so a resumed session is immediately usable. The ONLY thing discarded is a never-fully-written **torn tail fragment** — a final record whose bytes (JSONL) or row were never completely flushed; that fragment is not a valid event and is dropped before the synthetic closers are written. A parse error or `seq` gap in the COMMITTED region (at or before the last real `turn/end`) is genuine corruption and makes the session unloadable. +- **Append-only; a crashed turn is closed, never truncated.** Committed events — those at or below a flushed `turn/end` — are never rewritten. The loop only flushes at `turn/end`, so a crash can leave a durable log whose final turn never closed: real, fully-written events sit after the last `turn/end`. **A single turn can be huge in a long-horizon task** (many steps, large tool output spanning a long autonomous run), so discarding the interrupted turn would silently destroy a large amount of real work — truncating a turn is wrong. Instead, on reload `load` PRESERVES those events and CLOSES the orphaned turn by durably appending the minimal synthetic boundary events: an error `tool/result` for every `tool-call` the crash left unanswered, then a `step/end` if a step was still open, then a `turn/end` carrying the merge-extensible `{ kind: 'interrupted' }` reason (a marker that records the turn was cut short by a crash, not completed by the model — no loop ever emits it). The synthetic tool results matter for resume correctness: the loop logs the `assistant/message` (carrying the `tool-call` blocks) BEFORE running the tools, so a crash mid-tool leaves calls without results; `deriveMessages()` would then replay a dangling assistant tool-call, which every provider rejects as an invalid transcript on the next request. Answering each orphaned call with an error result keeps the rehydrated history valid. `load` returns the balanced log, so a resumed session is immediately usable. The ONLY thing discarded is a never-fully-written **torn tail fragment** — a final record whose bytes (JSONL) or row were never completely flushed; that fragment is not a valid event and is dropped before the synthetic closers are written. A parse error or `seq` gap in the COMMITTED region (at or before the last real `turn/end`) is genuine corruption and makes the session unloadable. - **File backend canonical, DB backend a drop-in.** `SessionEvent` maps 1:1 onto a row `(session_id, seq, type, time, data)` — `append` is INSERT (in a transaction asserting the contiguous-seq contract), `load` is SELECT … ORDER BY seq. A future `dsh-session-persistence-sqlite` is a `SessionPersistence` subclass with no interface change (opencode runs this exact shape on SQLite/WAL). - **Metadata is out-of-log.** Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a `SessionMeta` (`SessionHeader & SessionSummary`) owned by `dsh-session` and attached to a `Session` via a new readonly `session.header` — never in `SessionEventMap`, never reaching `deriveMessages()`. The alternative (a merge-extensible `session/meta` event as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header seam is the cleaner cost. - **`load` returns a resumable event log, not just bytes.** `load(sessionId)` yields the `SessionMeta` plus the committed `SessionEvent[]` (through the last complete `turn/end`), shaped so a caller can reconstruct a live session with the loaded events as seed (so `lastTurnNumber`/`deriveMessages` continue) on the SAME session id. The agent-facing create/resume factory that consumes this is a separate seam (a follow-up on `ctx.agents`); the persistence layer deliberately stops at the `load` primitive and does NOT reach into the loop. The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever), so any resume path built on this rejects with a clear error when the backend is absent. diff --git a/examples/echo-agent/README.md b/examples/echo-agent/README.md index f274d1ddbd..a5311e1fd5 100644 --- a/examples/echo-agent/README.md +++ b/examples/echo-agent/README.md @@ -31,4 +31,4 @@ node --expose-internals --import tsx examples/echo-agent/start.ts Type a message and press Enter. "echo " triggers a tool call round-trip (the mock model requests the `echo` tool, which echoes the text uppercased, and the next model step acknowledges it). -The session is persisted under `examples/echo-agent/.sessions/` (per-cwd subdirectory, one `.jsonl` log per session). Clean up with: `rm -rf examples/echo-agent/.sessions` +The session is persisted under `.sessions/` relative to the directory you launch the demo from. `pnpm run demo:echo` runs from the repo root, so the logs land in `/.sessions/` (a session with no cwd goes in the `_no-cwd/` bucket, one `.jsonl` log per session). Clean up with: `rm -rf .sessions` diff --git a/packages/session-persistence-jsonl/README.md b/packages/session-persistence-jsonl/README.md index 8d1d55d9a7..e3df89931c 100644 --- a/packages/session-persistence-jsonl/README.md +++ b/packages/session-persistence-jsonl/README.md @@ -24,7 +24,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence - **Lazy materialization.** `create(meta)` writes nothing; the `.jsonl` (header + first batch) is written atomically (temp-write + `fsync` + rename) on the first `append`. A created-but-never-appended session leaves nothing on disk and is absent from `has`/`list`. - **Append-only.** Committed events (at or below a flushed `turn/end`) are never rewritten. Subsequent appends are line appends at EOF + `fsync`. -- **Crash recovery — close, don't truncate.** A crash can leave a log whose final turn never closed (real events after the last `turn/end`). `load` PRESERVES those events (a turn can be huge — they are real work) and closes the orphaned turn by durably appending synthetic boundary events (a `step/end` if a step was open, then `turn/end {kind:'interrupted'}`), returning a balanced log. Only a never-fully-written **torn tail fragment** (a final line with no newline / unparseable) is `ftruncate`d away before the closers are written. See ADR 0018. +- **Crash recovery — close, don't truncate.** A crash can leave a log whose final turn never closed (real events after the last `turn/end`). `load` PRESERVES those events (a turn can be huge — they are real work) and closes the orphaned turn by durably appending synthetic boundary events: an error `tool/result` for every `tool-call` the crash left unanswered (the loop logs the assistant message before running the tools, so a mid-tool crash leaves dangling calls — and `deriveMessages()` would replay an assistant tool-call with no result, which providers reject), then a `step/end` if a step was open, then `turn/end {kind:'interrupted'}`, returning a balanced log. Only a never-fully-written **torn tail fragment** (a final line with no newline / unparseable) is `ftruncate`d away before the closers are written. See ADR 0018. - **Contiguous-seq.** `load` rejects a mid-log parse error or `seq` gap (unloadable); `append` rejects a batch whose first `seq` does not continue the stored log, and rejects non-JSON-serializable `event.data` naming the offending event type. - **Format version.** Only v1 is supported; `load` rejects an unknown version. A future format change requires a version bump + migration. diff --git a/packages/session-persistence/README.md b/packages/session-persistence/README.md index 8f534fbc36..686b27e506 100644 --- a/packages/session-persistence/README.md +++ b/packages/session-persistence/README.md @@ -10,14 +10,14 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l |---|---| | `create(meta): Promise` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). | | `append(id, events): Promise` | Durably persist a batch (from the `session/flush` drain). Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. | -| `load(id): Promise<{ meta; events }>` | Reload meta + log. Preserves an interrupted (unclosed) final turn and closes it with synthetic `step/end?`+`turn/end {interrupted}` (a turn can be huge — never truncated); only a torn tail fragment is dropped. Events contiguous (`events[i].seq === i`); rejects a committed-region gap/parse error or unknown `version`. | +| `load(id): Promise<{ meta; events }>` | Reload meta + log. Preserves an interrupted (unclosed) final turn and closes it with synthetic closers — an error `tool/result` per unanswered `tool-call`, then `step/end?`+`turn/end {interrupted}` (a turn can be huge — never truncated); only a torn tail fragment is dropped. Events contiguous (`events[i].seq === i`); rejects a committed-region gap/parse error or unknown `version`. | | `list(): Promise` | Lightweight listing from metadata, no full-log parse. | | `has(id)` / `delete(id)` | Existence / removal. A zero-event lazily-materialized session is absent from `has`/`list`. | | `update(id, summary): Promise` | Update mutable `SessionSummary` fields without touching the append-only log. | ## Invariants every backend must honor -- **Append-only; a crashed turn is closed, not truncated.** Committed events (at or below a flushed `turn/end`) are never rewritten. A crash can leave an unclosed final turn whose events are real and possibly large; `load` preserves them and durably appends synthetic closers (`step/end?`+`turn/end {interrupted}`) to balance the log. Only a never-fully-written torn tail fragment is discarded. +- **Append-only; a crashed turn is closed, not truncated.** Committed events (at or below a flushed `turn/end`) are never rewritten. A crash can leave an unclosed final turn whose events are real and possibly large; `load` preserves them and durably appends synthetic closers (an error `tool/result` per unanswered `tool-call`, then `step/end?`+`turn/end {interrupted}`) to balance the log and keep the rehydrated history a valid provider transcript. Only a never-fully-written torn tail fragment is discarded. - **Contiguous seq.** `load` rejects a `seq` gap/parse error in the MIDDLE of the log; `append`'s first `seq` must equal the stored next-seq. - **JSON-serializable data.** `append` rejects non-serializable `event.data`; backends snapshot each event when buffering (the live `session.events` object is mutable). - **Durability.** `append` returns only once the batch is durable. diff --git a/packages/session-persistence/src/index.ts b/packages/session-persistence/src/index.ts index e770b5b86c..1ebe31c8d0 100644 --- a/packages/session-persistence/src/index.ts +++ b/packages/session-persistence/src/index.ts @@ -90,14 +90,17 @@ export abstract class SessionPersistence extends Service { * `turn/end`. Those events are PRESERVED — a single turn can be huge in a * long-horizon task, so truncating it would destroy real work — and `load` * CLOSES the orphaned turn by durably appending the minimal synthetic boundary - * events (a `step/end` if a step was open, then a `turn/end` carrying the - * `{ kind: 'interrupted' }` reason). The returned `events` therefore end on a - * balanced `turn/end` and are immediately usable as a session seed. Only a - * never-fully-written TORN tail fragment (a half-written final record) is - * discarded. Returned events are contiguous (`events[i].seq === i`); a parse - * error or a `seq` gap in the COMMITTED region (at or before the last real - * `turn/end`) makes the session unloadable (reject). Rejects an unknown format - * `version`. See ADR 0018 for the crash-recovery contract. + * events: an error `tool/result` for every `tool-call` the crash left + * unanswered (so the rehydrated history is a valid provider transcript — a + * dangling assistant tool-call is otherwise rejected), then a `step/end` if a + * step was open, then a `turn/end` carrying the `{ kind: 'interrupted' }` + * reason. The returned `events` therefore end on a balanced `turn/end` and are + * immediately usable as a session seed. Only a never-fully-written TORN tail + * fragment (a half-written final record) is discarded. Returned events are + * contiguous (`events[i].seq === i`); a parse error or a `seq` gap in the + * COMMITTED region (at or before the last real `turn/end`) makes the session + * unloadable (reject). Rejects an unknown format `version`. See ADR 0018 for + * the crash-recovery contract. */ abstract load(id: SessionId): Promise<{ meta: SessionMeta; events: SessionEvent[] }> diff --git a/packages/session-persistence/tests/contract.ts b/packages/session-persistence/tests/contract.ts index 189a4e10c2..f8535a70f9 100644 --- a/packages/session-persistence/tests/contract.ts +++ b/packages/session-persistence/tests/contract.ts @@ -11,6 +11,7 @@ import { describe, expect, it } from 'vitest' import { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionMeta } from '@deepseek-ai/dsh-session' +import { CallId } from '@deepseek-ai/dsh-llm' import type { SessionPersistence } from '../src/index.ts' /** A backend under test plus its teardown. */ @@ -102,6 +103,46 @@ export function runPersistenceContract(name: string, make: () => Promise { + const { persistence, dispose } = await make() + try { + const m = meta('interrupted-toolcall') + await persistence.create(m) + await persistence.append(m.id, oneTurnLog()) // turn 1, committed (seqs 0..5) + // Turn 2 crashed AFTER the assistant message asked for a tool call but + // BEFORE the tool/result was written (the loop runs tools after logging + // the assistant message — a process killed mid-tool lands exactly here). + await persistence.append(m.id, [ + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } }, + { type: 'assistant/message', seq: 8, time: 9, data: { turn: 2, step: 1, content: [ + { type: 'tool-call', id: CallId('call-x'), name: 'bash', arguments: '{}' }, + ] } }, + ]) + + const loaded = await persistence.load(m.id) + // The orphaned call is answered by a synthetic error tool/result BEFORE + // step/end + turn/end {interrupted}, so the step (and turn) are balanced + // and a resumed session derives a valid transcript (no dangling call). + expect(loaded.events.map(e => e.type)).toEqual([ + 'turn/start', 'user/message', 'step/start', 'assistant/message', 'step/end', 'turn/end', // turn 1 + 'turn/start', 'step/start', 'assistant/message', 'tool/result', 'step/end', 'turn/end', // turn 2 + ]) + const synthetic = loaded.events.find(e => e.type === 'tool/result') + expect(synthetic?.type === 'tool/result' && synthetic.data).toMatchObject({ + callId: CallId('call-x'), isError: true, error: { code: 'interrupted' }, + }) + // The synthetic result carries the SAME callId as the orphaned tool-call, + // so deriveMessages() pairs them — no provider-invalid dangling call. + const call = loaded.events.findLast(e => e.type === 'assistant/message') + const callId = call?.type === 'assistant/message' + && call.data.content.find(b => b.type === 'tool-call') + expect(callId && callId.type === 'tool-call' && callId.id).toBe(CallId('call-x')) + } finally { + await dispose() + } + }) + it('has()/list() exclude a created-but-never-appended (zero-event) session', async () => { const { persistence, dispose } = await make() try { diff --git a/packages/session/src/repair.ts b/packages/session/src/repair.ts index c09d15eee6..6a3f60a681 100644 --- a/packages/session/src/repair.ts +++ b/packages/session/src/repair.ts @@ -7,11 +7,26 @@ * in a long-horizon task (many steps, large tool output), so those events MUST * be preserved — truncating the turn would silently destroy real work. Instead, * on reload the backend CLOSES the orphaned turn by appending the minimal - * synthetic boundary events (a `step/end` if a step was still open, then a - * `turn/end` carrying the merge-extensible `{ kind: 'interrupted' }` reason). + * synthetic boundary events: + * + * 1. an error `tool/result` for every `tool-call` in the interrupted turn that + * never got its matching `tool/result` (so the rehydrated history is a + * VALID provider transcript — see below), + * 2. a `step/end` if a step was still open, then + * 3. a `turn/end` carrying the merge-extensible `{ kind: 'interrupted' }` reason. + * * The marker records that the turn was cut short by a crash, not completed by * the model. See ADR 0018. * + * Why the synthetic tool results matter: `deriveMessages()` renders the + * `tool-call` blocks inside a durable `assistant/message` but only emits a + * matching tool-result when a `tool/result` EVENT exists. A crash between the + * assistant message and its tool results (the loop runs the tools AFTER logging + * the assistant message, so a process killed mid-tool leaves the calls without + * results) would otherwise reload a history with a dangling assistant tool-call + * — which every provider rejects as an invalid transcript on the next request. + * Synthesizing an error result per orphaned call keeps resume safe. + * * This module computes those synthetic closers from an event list; the backend * returns them inline from `load` (so the reconstructed session is balanced and * immediately usable) and persists them on the first post-load `append`. @@ -19,6 +34,7 @@ * @module @deepseek-ai/dsh-session/repair */ +import type { CallId } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from './types.ts' /** @@ -29,6 +45,11 @@ import type { SessionEvent } from './types.ts' * "future" time). Returns an empty array when the log is already balanced * (ends on a `turn/end`, or is empty) — the common, non-crash case. * + * The closers, in order: an error `tool/result` for each unmatched `tool-call` + * in the interrupted turn, then a `step/end` if a step is open, then the + * `turn/end {interrupted}`. The tool-results come first so a step that issued + * tool calls is balanced (every call has a result) before its `step/end`. + * * Only the LAST turn can be open: the invariants plugin guarantees a `turn/end` * before any later `turn/start`, so an interior open turn is impossible in a * valid committed log. Likewise at most one step is open within that turn. @@ -36,14 +57,22 @@ import type { SessionEvent } from './types.ts' export function interruptedTurnClosers(events: readonly SessionEvent[]): SessionEvent[] { let openTurn: number | null = null let openStep: number | null = null + // Track tool calls vs. their results WITHIN the currently-open turn only: a + // call is "pending" until its matching tool/result arrives. Reset at every + // turn boundary so a committed earlier turn (already balanced) never leaks a + // phantom pending call into the interrupted-turn repair. + const pendingCalls = new Map() for (const event of events) { switch (event.type) { case 'turn/start': openTurn = event.data.turn + openStep = null + pendingCalls.clear() break case 'turn/end': openTurn = null openStep = null + pendingCalls.clear() break case 'step/start': openStep = event.data.step @@ -51,6 +80,16 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session case 'step/end': openStep = null break + case 'assistant/message': + // The assistant message carries the tool-call blocks; each is pending + // until a tool/result event with the same callId is logged. + for (const block of event.data.content) { + if (block.type === 'tool-call') pendingCalls.set(block.id, { step: event.data.step }) + } + break + case 'tool/result': + pendingCalls.delete(event.data.callId) + break // Other event types do not move the turn/step boundary cursor. default: break @@ -69,7 +108,27 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session const time = last.time const closers: SessionEvent[] = [] - // Close an open step first — a turn/end while a step is open is an invariant + // Synthesize an error tool/result for each tool-call left unanswered by the + // crash, so deriveMessages() yields a valid provider transcript on resume (a + // dangling assistant tool-call is rejected by every provider). Insertion + // order follows the Map (insertion = log order of the assistant messages). + for (const [callId, { step }] of pendingCalls) { + closers.push({ + type: 'tool/result', + seq: seq++, + time, + data: { + turn: openTurn, + step, + callId, + content: [{ type: 'text', text: 'Tool call interrupted by a crash; no result was recorded.' }], + isError: true, + error: { name: 'InterruptedError', code: 'interrupted' }, + }, + }) + } + + // Close an open step next — a turn/end while a step is open is an invariant // violation, so the step's boundary must be synthesized before the turn's. if (openStep !== null) { closers.push({ type: 'step/end', seq: seq++, time, data: { turn: openTurn, step: openStep } }) diff --git a/packages/session/tests/repair.spec.ts b/packages/session/tests/repair.spec.ts new file mode 100644 index 0000000000..893015b218 --- /dev/null +++ b/packages/session/tests/repair.spec.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest' +import { CallId } from '@deepseek-ai/dsh-llm' +import { interruptedTurnClosers } from '../src/index.ts' +import type { SessionEvent } from '../src/index.ts' + +/** + * Unit coverage for the crash-recovery closer synthesis. The persistence + * contract exercises it end-to-end through both backends; these tests pin the + * pure function's branches directly — especially the synthetic error + * `tool/result` for a tool call the crash left unanswered (without it a + * resumed session replays a dangling assistant tool-call and the provider + * rejects the transcript). + */ + +const userTurnStart = (turn: number, seq: number): SessionEvent => + ({ type: 'turn/start', seq, time: seq, data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }) + +describe('interruptedTurnClosers', () => { + it('returns nothing for a balanced log (ends on turn/end)', () => { + const balanced: SessionEvent[] = [ + userTurnStart(1, 0), + { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } }, + ] + expect(interruptedTurnClosers(balanced)).toEqual([]) + }) + + it('returns nothing for an empty log', () => { + expect(interruptedTurnClosers([])).toEqual([]) + }) + + it('closes an open turn with no open step (turn/end {interrupted} only)', () => { + const events: SessionEvent[] = [userTurnStart(1, 0)] + const closers = interruptedTurnClosers(events) + expect(closers.map(e => e.type)).toEqual(['turn/end']) + const end = closers[0]! + expect(end.seq).toBe(1) + expect(end.type === 'turn/end' && end.data.reason).toEqual({ kind: 'interrupted' }) + }) + + it('closes an open step before the turn (step/end then turn/end)', () => { + const events: SessionEvent[] = [ + userTurnStart(1, 0), + { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } }, + ] + const closers = interruptedTurnClosers(events) + expect(closers.map(e => e.type)).toEqual(['step/end', 'turn/end']) + expect(closers.map(e => e.seq)).toEqual([2, 3]) + }) + + it('synthesizes an error tool/result for a tool-call the crash left unanswered', () => { + // A step issued one tool call (in the assistant message) but crashed before + // the tool/result was logged — the classic mid-tool crash. + const events: SessionEvent[] = [ + userTurnStart(2, 0), + { type: 'step/start', seq: 1, time: 1, data: { turn: 2, step: 1 } }, + { type: 'assistant/message', seq: 2, time: 2, data: { turn: 2, step: 1, content: [ + { type: 'text', text: 'calling a tool' }, + { type: 'tool-call', id: CallId('call-1'), name: 'bash', arguments: '{}' }, + ] } }, + ] + const closers = interruptedTurnClosers(events) + // tool/result (for the orphaned call) → step/end → turn/end, contiguous seqs. + expect(closers.map(e => e.type)).toEqual(['tool/result', 'step/end', 'turn/end']) + expect(closers.map(e => e.seq)).toEqual([3, 4, 5]) + const result = closers[0]! + expect(result.type === 'tool/result' && result.data).toMatchObject({ + turn: 2, step: 1, callId: CallId('call-1'), isError: true, error: { code: 'interrupted' }, + }) + }) + + it('does NOT synthesize a result for a tool-call that already has one', () => { + const events: SessionEvent[] = [ + userTurnStart(2, 0), + { type: 'step/start', seq: 1, time: 1, data: { turn: 2, step: 1 } }, + { type: 'assistant/message', seq: 2, time: 2, data: { turn: 2, step: 1, content: [ + { type: 'tool-call', id: CallId('call-1'), name: 'bash', arguments: '{}' }, + ] } }, + { type: 'tool/result', seq: 3, time: 3, data: { turn: 2, step: 1, callId: CallId('call-1'), content: [{ type: 'text', text: 'ok' }], isError: false } }, + ] + // The call is answered, so only the open step + turn need closing. + const closers = interruptedTurnClosers(events) + expect(closers.map(e => e.type)).toEqual(['step/end', 'turn/end']) + }) + + it('synthesizes results only for the still-open turn, not a committed earlier turn', () => { + // Turn 1 completed with its own tool call+result (balanced). Turn 2 crashed + // with an unanswered call. Only turn 2's call must get a synthetic result. + const events: SessionEvent[] = [ + userTurnStart(1, 0), + { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } }, + { type: 'assistant/message', seq: 2, time: 2, data: { turn: 1, step: 1, content: [ + { type: 'tool-call', id: CallId('old-call'), name: 'bash', arguments: '{}' }, + ] } }, + { type: 'tool/result', seq: 3, time: 3, data: { turn: 1, step: 1, callId: CallId('old-call'), content: [], isError: false } }, + { type: 'step/end', seq: 4, time: 4, data: { turn: 1, step: 1 } }, + { type: 'turn/end', seq: 5, time: 5, data: { turn: 1, reason: { kind: 'completed' } } }, + userTurnStart(2, 6), + { type: 'step/start', seq: 7, time: 7, data: { turn: 2, step: 1 } }, + { type: 'assistant/message', seq: 8, time: 8, data: { turn: 2, step: 1, content: [ + { type: 'tool-call', id: CallId('new-call'), name: 'bash', arguments: '{}' }, + ] } }, + ] + const closers = interruptedTurnClosers(events) + expect(closers.map(e => e.type)).toEqual(['tool/result', 'step/end', 'turn/end']) + const result = closers[0]! + expect(result.type === 'tool/result' && result.data.callId).toBe('new-call') + }) + + it('synthesizes a result for each of multiple unanswered calls, in log order', () => { + const events: SessionEvent[] = [ + userTurnStart(1, 0), + { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } }, + { type: 'assistant/message', seq: 2, time: 2, data: { turn: 1, step: 1, content: [ + { type: 'tool-call', id: CallId('call-a'), name: 'bash', arguments: '{}' }, + { type: 'tool-call', id: CallId('call-b'), name: 'bash', arguments: '{}' }, + ] } }, + // call-a got answered before the crash; call-b did not. + { type: 'tool/result', seq: 3, time: 3, data: { turn: 1, step: 1, callId: CallId('call-a'), content: [], isError: false } }, + ] + const closers = interruptedTurnClosers(events) + expect(closers.map(e => e.type)).toEqual(['tool/result', 'step/end', 'turn/end']) + const result = closers[0]! + expect(result.type === 'tool/result' && result.data.callId).toBe('call-b') + }) +}) From e31e19d99b1a4e5c460fde17fdf7576bb17ccff5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 17 Jun 2026 10:05:07 +0800 Subject: [PATCH 6/7] docs: remove duplicate AGENTS.md convention bullets The origin/master merge resolution accidentally duplicated the "Symmetry" and "Tests" Conventions bullets. Collapse each back to a single copy. --- AGENTS.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bbb3f418bb..1b31107b17 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,11 +106,9 @@ Dev/test/demo run **unbuilt** via tsx + the `paths` map in the root `tsconfig.js - **Explicit > implicit at package seams**: interface/vocabulary types spell out every field a consumer must supply — no optional field that the implementation silently fills with a hidden `?? default`. Put defaulting in the owning implementation as an explicit step (a `resolve(request): Spec` method that turns the optional-field request into the required-field spec), not smuggled inside `run()`/`start()`. Example: `dsh-bash` splits `BashExecRequest` (optional `workdir`/`timeoutMs`, model-facing) from `BashExecSpec` (required, what `run`/`start` act on); the tool layer calls `ctx.bash.resolve()` between them. The reader of a `BashExecSpec` never has to wonder where the working directory came from. - **An empty `catch` must name what it swallows and why nothing else can hit it**: a bare `catch {}` hides bugs. When you deliberately ignore a throw, the comment must (a) name the single expected failure, (b) say why ignoring it is correct — usually because the useful state was already captured *before* the `try` — and (c) make clear nothing else of consequence can reach the catch (ideally the `try` wraps a single statement). Example: the error-body `response.json()` parse in `dsh-llm-deepseek`'s adapter sets `code` + HTTP `status` from the status line before the `try`, so a malformed provider body can only cost a richer message, never the real error. - **Symmetry is usually more correct**: when two related values play parallel roles (a test fixture and its expected output, a request shape and its response shape, a buggy input and the test that checks the fix), give them parallel form — both named consts, or both inline, not one each way. Asymmetry is a smell that usually points at a missed extraction. -- **Symmetry is usually more correct**: when two related values play parallel roles (a test fixture and its expected output, a request shape and its response shape, a buggy input and the test that checks the fix), give them parallel form — both named consts, or both inline, not one each way. Asymmetry is a smell that usually points at a missed extraction. - **Merging PRs**: always merge with a **merge commit** (`gh pr merge --merge`), never squash or rebase. The per-PR commit history is intentional — review-fix commits, regression-test commits, and the reasoning in each message are part of the record — and squashing flattens it away. - **TODO markers**: use `FIXME`/`TODO`/`XXX` to flag known issues by urgency — see [docs/development.md](docs/development.md) for the semantics of each. - **Tests**: vitest, colocated under `packages//tests/*.spec.ts`. Every registry needs an HMR-safety test (dispose the contributing fiber, assert cleanup). **Excessive tests are welcome** — when in doubt, write the test; err on the side of covering edge cases, error paths, event ordering, and concurrency races even if they seem unlikely. Review findings get regression tests (see `packages/agent-loop/tests/review-fixes.spec.ts`). -- **Tests**: vitest, colocated under `packages//tests/*.spec.ts`. Every registry needs an HMR-safety test (dispose the contributing fiber, assert cleanup). **Excessive tests are welcome** — when in doubt, write the test; err on the side of covering edge cases, error paths, event ordering, and concurrency races even if they seem unlikely. Review findings get regression tests (see `packages/agent-loop/tests/review-fixes.spec.ts`). ## Defensive patterns (hard-won) From ffc107aa57b2c8979b7bf2d0d264dc52b3121c2b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 17 Jun 2026 10:50:58 +0800 Subject: [PATCH 7/7] docs: record verify-md-wrap in the doc-sync source-of-truth docs Adding verify-md-wrap to the shared doc-sync gate left its defining docs stale (Codex review): - ADR 0014 described doc-sync as two gates; add a dated amendment for the third (verify-md-wrap) and drop the "two checkable classes" wording. - CI step label/comment said "doc code blocks + event taxonomy"; include the markdown wrap check. --- .github/workflows/ci.yml | 6 +++--- docs/adr/0014-doc-sync-enforcement.md | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0293d1ddb..08c889e70c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,9 +46,9 @@ jobs: # Doc-sync gates (RFC 006). doc-typecheck compiles the fenced ts blocks in # the docs and resolves vendor packages via their built declarations, which # the typecheck step above emits — so it runs after typecheck. The event - # taxonomy check only reads source. Same `doc-sync` script the pre-push - # hook runs (ADR 0007: one source of truth). - - name: Doc-sync gates (doc code blocks + event taxonomy) + # taxonomy check and the markdown wrap check only read source. Same + # `doc-sync` script the pre-push hook runs (ADR 0007: one source of truth). + - name: Doc-sync gates (doc code blocks + event taxonomy + markdown wrap) run: pnpm run doc-sync # Module-graph freshness: regenerate docs/module-graph.md from the diff --git a/docs/adr/0014-doc-sync-enforcement.md b/docs/adr/0014-doc-sync-enforcement.md index 6c248b9431..2187249c43 100644 --- a/docs/adr/0014-doc-sync-enforcement.md +++ b/docs/adr/0014-doc-sync-enforcement.md @@ -15,9 +15,11 @@ Two gates, mirroring the existing `scripts/` style (tsx ESM, one job each): Both run via a shared `doc-sync` package.json script that the lefthook pre-push hook and CI both invoke (ADR 0007: hooks and CI call the same scripts, so the gate fires locally before a push — not only after it). They run after `pnpm run typecheck` (which emits the vendor `lib/` that doc-typecheck resolves against). API-extractor golden reports (RFC 006 part 3) were deliberately **deferred** — low value for an internal monorepo where reviewers already see the source diff, and a heavy, finicky dependency. +**Amendment (2026-06-17):** a third gate, **`verify-md-wrap`**, was later folded into `doc-sync`. It parses each in-scope Markdown file (`README.md`, `docs/**`, `packages/*/README.md`, plus `AGENTS.md` / `packages/AGENTS.md`) with `mdast-util-from-markdown` + GFM and fails on any `paragraph` node spanning more than one source line, enforcing the AGENTS.md "Markdown is not hard-wrapped" convention. Same verify-don't-generate principle: it reports hard-wraps and never rewrites, so it adds no formatting churn. `doc-sync` is now three gates. + ## Consequences -- Doc drift in the two checkable classes now fails the pre-push hook and CI instead of waiting for a reviewer to notice. This is an instance of ADR 0007's "mechanical gates over prose." +- Doc drift in the checkable classes now fails the pre-push hook and CI instead of waiting for a reviewer to notice. This is an instance of ADR 0007's "mechanical gates over prose." - Making doc snippets compile costs a few stub imports/`declare`s; the `ignore-check` ratio must stay low or the gate is theater (the ratio guard enforces this). - The taxonomy check is name-only — a wrong Mode or Purpose column still needs human review. Generating the table from source was considered and rejected as more machinery than the problem warrants. - API reports remain available to revisit if the packages are ever published externally.