Jelajahi Sumber

Merge pull request #145 from deepseek-harness/simpl-0-rfc-index

feat(scripts): generate the RFC index tables from the tree
Tianyi Cui 2 bulan lalu
induk
melakukan
6c7bbe0e6a

+ 2 - 2
docs/development.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write
-development.md: 28babca2b59c844690750d767c242c08c37bf702
-development.zh.md: b34c52cebd3f333b6554ca2b5ebd66463e436572
+development.md: c57ccd4a393bdf54a13c5786b70b6b665904aedd
+development.zh.md: 198b7651e6937b3f5280a6104a5b43b35f40262b

+ 1 - 0
docs/development.md

@@ -98,6 +98,7 @@ pnpm run lint:fix       # eslint . --fix
 pnpm run doc-typecheck  # compile checked TypeScript snippets in Markdown docs
 pnpm run gen-cordis-catalog     # regenerate docs/cordis-catalog/events-and-services.md from source
 pnpm run verify-cordis-catalog  # fail if the cordis events/services catalog is stale
+pnpm run gen-rfc-index          # regenerate the docs/rfc/README.md index tables from the RFC tree
 pnpm run verify-md-wrap  # fail on hard-wrapped prose paragraphs in docs/README markdown
 pnpm run verify-type-equiv  # fail if a ```ts type-equiv doc block drifts from its source type
 pnpm run verify-doc-budgets  # fail if a budgeted standing doc exceeds its word ceiling

+ 1 - 0
docs/development.zh.md

@@ -98,6 +98,7 @@ pnpm run lint:fix       # eslint . --fix
 pnpm run doc-typecheck  # compile checked TypeScript snippets in Markdown docs
 pnpm run gen-cordis-catalog     # regenerate docs/cordis-catalog/events-and-services.md from source
 pnpm run verify-cordis-catalog  # fail if the cordis events/services catalog is stale
+pnpm run gen-rfc-index          # regenerate the docs/rfc/README.md index tables from the RFC tree
 pnpm run verify-md-wrap  # fail on hard-wrapped prose paragraphs in docs/README markdown
 pnpm run verify-type-equiv  # fail if a ```ts type-equiv doc block drifts from its source type
 pnpm run verify-doc-budgets  # fail if a budgeted standing doc exceeds its word ceiling

+ 37 - 31
docs/rfc/README.md

@@ -16,7 +16,7 @@ The date in the filename is when the topic was **first proposed** (per git histo
 
 ## Classification
 
-Each RFC is filed under exactly one **class** — the kind of decision it records. The class is encoded in the path (the folder *is* the label, so a file's location declares its class) and the set is **closed**: `scripts/verify-rfc-classification.ts` rejects any folder outside the set and asserts this index lists every RFC under the heading matching its path. Adding a new class means amending that gate and this section, not just dropping a new folder. See [the classification RFC](implemented/process/2026-06-20-rfc-classification.md) for why the taxonomy is path-encoded and gated.
+Each RFC is filed under exactly one **class** — the kind of decision it records. The class is encoded in the path (the folder *is* the label, so a file's location declares its class) and the set is **closed**: `scripts/rfc-index.ts` owns the canonical set, `scripts/verify-rfc-classification.ts` rejects any folder outside it, and the index tables below are **generated** from the tree (`pnpm run gen-rfc-index` rewrites the marker-delimited regions from each RFC's path, H1 title, and filename date; the gate fails when they are stale). Adding a new class means amending that `const` and this section, not just dropping a new folder. See [the classification RFC](implemented/process/2026-06-20-rfc-classification.md) for why the taxonomy is path-encoded and gated, and [the index-generation RFC](implemented/process/2026-07-04-generate-rfc-index-tables.md) for why the tables are generated while this prose stays curated.
 
 | Class | What it covers |
 |---|---|
@@ -37,11 +37,12 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
 
 ## Proposed
 
+<!-- gen-rfc-index:begin proposed -->
 ### Feature
 
 | Title | First proposed |
 |---|---|
-| [Agent Client Protocol (ACP) support for external editors](proposed/feature/2026-06-14-acp-agent-client-protocol.md) | 2026-06-14 |
+| [Agent Client Protocol (ACP) support — drive the coding agent from external editors](proposed/feature/2026-06-14-acp-agent-client-protocol.md) | 2026-06-14 |
 | [Multiplex concurrent ACP sessions over one connection](proposed/feature/2026-06-14-acp-multi-session.md) | 2026-06-14 |
 | [Optional Code Mode — model writes TypeScript against an SDK of all tools](proposed/feature/2026-06-15-optional-code-mode.md) | 2026-06-15 |
 | [Pre-tool input rewrite — a consistent design](proposed/feature/2026-06-30-pre-tool-input-rewrite.md) | 2026-06-30 |
@@ -51,17 +52,17 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
 | Title | First proposed |
 |---|---|
 | [Unify the agent id and the session id](proposed/simplification/2026-06-20-unify-agent-and-session-id.md) | 2026-06-20 |
-| [Prune producer-less vocabulary variants (block cache hints, the `agent` message source, the `continuation` turn trigger)](proposed/simplification/2026-07-04-prune-producerless-vocabulary-variants.md) | 2026-07-04 |
+| [Drop the `image` content block until a path can honor it](proposed/simplification/2026-07-04-drop-image-content-block.md) | 2026-07-04 |
 | [Drop `GenerateOptions.prefill` and `ToolSchema.strict` — request knobs with no working end-to-end path](proposed/simplification/2026-07-04-drop-inert-request-knobs.md) | 2026-07-04 |
 | [Drop the unconsumed web observation surface — the `providers-change` event and the status methods](proposed/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) | 2026-07-04 |
-| [Drop the `image` content block until a path can honor it](proposed/simplification/2026-07-04-drop-image-content-block.md) | 2026-07-04 |
-| [Prune write-only fields and a dead routing knob from the fs seam](proposed/simplification/2026-07-04-prune-write-only-fs-surface.md) | 2026-07-04 |
-| [Trim unreachable ACP bridge surface — the branding knobs and the kind-sniffing fallback](proposed/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md) | 2026-07-04 |
+| [Fold the stdio UI helper into the stdio app](proposed/simplification/2026-07-04-fold-stdio-ui-helper.md) | 2026-07-04 |
 | [Prune dead core-spine surface — `SurfaceManager.invalidate()`, the loop-internal exports, `ToolExecutionResult.callId`](proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md) | 2026-07-04 |
-| [Share the app bins' boot glue instead of maintaining twin copies](proposed/simplification/2026-07-04-share-app-bin-boot-glue.md) | 2026-07-04 |
+| [Prune producer-less vocabulary variants (block cache hints, the `agent` message source, the `continuation` turn trigger)](proposed/simplification/2026-07-04-prune-producerless-vocabulary-variants.md) | 2026-07-04 |
+| [Prune write-only fields and a dead routing knob from the fs seam](proposed/simplification/2026-07-04-prune-write-only-fs-surface.md) | 2026-07-04 |
 | [Remove the `agent/steering` mirror emit](proposed/simplification/2026-07-04-remove-agent-steering-mirror.md) | 2026-07-04 |
+| [Share the app bins' boot glue instead of maintaining twin copies](proposed/simplification/2026-07-04-share-app-bin-boot-glue.md) | 2026-07-04 |
 | [Tighten the hook-protocol contract — dialect, discarded fields, double defaults, and lib-owned `hook/result` semantics](proposed/simplification/2026-07-04-tighten-hook-protocol-contract.md) | 2026-07-04 |
-| [Fold the stdio UI helper into the stdio app](proposed/simplification/2026-07-04-fold-stdio-ui-helper.md) | 2026-07-04 |
+| [Trim unreachable ACP bridge surface — the branding knobs and the kind-sniffing fallback](proposed/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md) | 2026-07-04 |
 
 ### Architecture
 
@@ -74,47 +75,48 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
 
 | Title | First proposed |
 |---|---|
-| [Architectural conformance — dependency rules and the adapter kit](proposed/process/2026-06-11-architectural-conformance.md) | 2026-06-11 |
 | [API extractor reports](proposed/process/2026-06-11-api-extractor-reports.md) | 2026-06-11 |
+| [Architectural conformance — dependency rules and the adapter kit](proposed/process/2026-06-11-architectural-conformance.md) | 2026-06-11 |
 | [Supply chain checks and vendor drift verification](proposed/process/2026-06-11-supply-chain-and-vendor-drift.md) | 2026-06-11 |
 | [Discover package inventories instead of maintaining static lists](proposed/process/2026-06-20-discover-package-inventory.md) | 2026-06-20 |
-| [Generate the RFC index tables](proposed/process/2026-07-04-generate-rfc-index-tables.md) | 2026-07-04 |
 
 ### Testing
 
 | Title | First proposed |
 |---|---|
-| [Mutation testing as the coverage counterweight](proposed/testing/2026-06-11-mutation-testing.md) | 2026-06-11 |
 | [Deterministic tests, the replay invariant fixture, and race stress](proposed/testing/2026-06-11-deterministic-and-stress-testing.md) | 2026-06-11 |
+| [Mutation testing as the coverage counterweight](proposed/testing/2026-06-11-mutation-testing.md) | 2026-06-11 |
 | [Single-source the acp-agent replay config](proposed/testing/2026-07-04-single-source-acp-replay-config.md) | 2026-07-04 |
+<!-- gen-rfc-index:end proposed -->
 
 ## Implemented
 
+<!-- gen-rfc-index:begin implemented -->
 ### Feature
 
 | Title | First proposed |
 |---|---|
 | [Filesystem tool schemas — model-facing read/write/edit shapes](implemented/feature/2026-06-17-filesystem-tool-schemas.md) | 2026-06-17 |
-| [Rich ACP bash rendering — the terminal card (`_meta`) and command classification](implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) | 2026-06-18 |
+| [Rich ACP bash rendering — the terminal card via the `_meta` convention](implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) | 2026-06-18 |
 | [Compaction as a capability seam (abstract contract + basic backend)](implemented/feature/2026-06-18-compaction-capability-seam.md) | 2026-06-18 |
 | [Subagent capability seam](implemented/feature/2026-06-21-subagent-capability-seam.md) | 2026-06-21 |
 | [ACP subagent backend (out-of-process delegation)](implemented/feature/2026-06-22-acp-subagent-backend.md) | 2026-06-22 |
 | [The `todo_write` tool — model task list as event-sourced session state](implemented/feature/2026-06-29-todo-write-tool.md) | 2026-06-29 |
+| [dsh-hooks-claude + dsh-hooks-codex — the Claude Code / Codex hook bridges](implemented/feature/2026-06-30-hook-bridges.md) | 2026-06-30 |
+| [dsh-hook-protocol — the shared Claude Code / Codex hook wire-protocol core](implemented/feature/2026-06-30-hook-protocol-lib.md) | 2026-06-30 |
 | [Interception seams — the typed-Decision surface a hook programs against](implemented/feature/2026-06-30-interception-seams.md) | 2026-06-30 |
 | [Subagent lifecycle enrichment — lastAssistantMessage (observe-only)](implemented/feature/2026-06-30-subagent-observe-enrich.md) | 2026-06-30 |
-| [dsh-hook-protocol — the shared Claude Code / Codex hook wire-protocol core](implemented/feature/2026-06-30-hook-protocol-lib.md) | 2026-06-30 |
-| [dsh-hooks-claude + dsh-hooks-codex — the Claude Code / Codex hook bridges](implemented/feature/2026-06-30-hook-bridges.md) | 2026-06-30 |
 
 ### Simplification
 
 | Title | First proposed |
 |---|---|
 | [Drop the mutable session summary](implemented/simplification/2026-06-19-drop-mutable-session-summary.md) | 2026-06-19 |
-| [Drop unconsumed assembled LLM convenience surfaces](implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md) | 2026-06-20 |
+| [Fold trace-only session facts into load-bearing events](implemented/simplification/2026-06-20-collapse-trace-only-session-events.md) | 2026-06-20 |
 | [Drop the unconsumed `llm/adapter-change` event](implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md) | 2026-06-20 |
+| [Drop unconsumed assembled LLM convenience surfaces](implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md) | 2026-06-20 |
 | [Prune dead methods from the persistence seam](implemented/simplification/2026-06-20-prune-dead-seam-methods.md) | 2026-06-20 |
 | [Keep one public stop primitive](implemented/simplification/2026-06-20-public-agent-stop-surface.md) | 2026-06-20 |
-| [Fold trace-only session facts into load-bearing events](implemented/simplification/2026-06-20-collapse-trace-only-session-events.md) | 2026-06-20 |
 | [Stop mirroring durable boundaries as agent events](implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md) | 2026-06-20 |
 | [Split the filesystem seam — provider text mutations plus the `dsh-fs-policy` plugin](implemented/simplification/2026-06-26-fsspec-style-fs-seam.md) | 2026-06-26 |
 | [Stop mirroring the token stream as an agent event](implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md) | 2026-07-02 |
@@ -123,52 +125,53 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
 
 | Title | First proposed |
 |---|---|
-| [Microkernel: extension via Cordis event taxonomy, one concrete loop](implemented/architecture/2026-06-11-microkernel-event-taxonomy.md) | 2026-06-11 |
-| [Event-sourced sessions with derived message history](implemented/architecture/2026-06-11-event-sourced-sessions.md) | 2026-06-11 |
 | [Provider-neutral content-block vocabulary owned by dsh-llm](implemented/architecture/2026-06-11-content-block-vocabulary.md) | 2026-06-11 |
 | [Custom typed tool-schema DSL instead of schemastery](implemented/architecture/2026-06-11-custom-schema-dsl.md) | 2026-06-11 |
-| [Tool schemas are part of the system-prompt assembly](implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.md) | 2026-06-11 |
-| [Runtime arg validation at the model boundary](implemented/architecture/2026-06-11-runtime-arg-validation.md) | 2026-06-11 |
 | [Dev-mode invariants over compile-time deep-readonly](implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md) | 2026-06-11 |
+| [Event-sourced sessions with derived message history](implemented/architecture/2026-06-11-event-sourced-sessions.md) | 2026-06-11 |
+| [Microkernel — extension via Cordis event taxonomy, one concrete loop](implemented/architecture/2026-06-11-microkernel-event-taxonomy.md) | 2026-06-11 |
+| [Runtime arg validation at the model boundary](implemented/architecture/2026-06-11-runtime-arg-validation.md) | 2026-06-11 |
 | [Structured error taxonomy](implemented/architecture/2026-06-11-structured-error-taxonomy.md) | 2026-06-11 |
+| [Tool schemas are part of the system-prompt assembly](implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.md) | 2026-06-11 |
 | [Capability seams — interface / implementation / consumer split](implemented/architecture/2026-06-13-capability-seams.md) | 2026-06-13 |
 | [Two LLM adapters as a design-verification twin](implemented/architecture/2026-06-13-twin-llm-adapters.md) | 2026-06-13 |
-| [Session persistence as an abstract service over `SessionEvent`](implemented/architecture/2026-06-14-session-persistence.md) | 2026-06-14 |
+| [Session persistence as an abstract service over the existing `SessionEvent`](implemented/architecture/2026-06-14-session-persistence.md) | 2026-06-14 |
 | [Every session event is enclosed in a turn](implemented/architecture/2026-06-15-turn-enclosure-invariant.md) | 2026-06-15 |
 | [Filesystem capability seam — ctx.fs, local backend, and model-facing filesystem tools](implemented/architecture/2026-06-17-filesystem-capability-seam.md) | 2026-06-17 |
-| [Shared persistence write coordinator](implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) | 2026-06-18 |
 | [Agent lifecycle and ownership seams](implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md) | 2026-06-18 |
 | [Session surface — a linked list over the event log for LLM message derivation](implemented/architecture/2026-06-18-session-surface.md) | 2026-06-18 |
-| [Reorganize packages into a modular hierarchy](implemented/architecture/2026-06-20-package-hierarchy.md) | 2026-06-20 |
+| [Shared persistence write coordinator](implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) | 2026-06-18 |
 | [Branded IDs everywhere they belong](implemented/architecture/2026-06-20-branded-ids.md) | 2026-06-20 |
 | [Extract example apps into packages](implemented/architecture/2026-06-20-extract-example-app-packages.md) | 2026-06-20 |
-| [Web capability seam — provider registry and model-facing web tools](implemented/architecture/2026-06-24-web-capability-seam.md) | 2026-06-24 |
+| [Reorganize packages into a modular hierarchy](implemented/architecture/2026-06-20-package-hierarchy.md) | 2026-06-20 |
+| [Web capability seam - stable tools over multiple providers](implemented/architecture/2026-06-24-web-capability-seam.md) | 2026-06-24 |
 | [Make `dsh-fs-policy` an event-gate plugin, not a method interface](implemented/architecture/2026-06-26-file-context-as-event-gate.md) | 2026-06-26 |
-| [Event-domain semantics — session is the fact log, agent is the live surface](implemented/architecture/2026-06-30-event-domain-semantics.md) | 2026-06-30 |
 | [stdin + extra env on the bash seam](implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) | 2026-06-30 |
+| [Event-domain semantics — session is the fact log, agent is the live surface](implemented/architecture/2026-06-30-event-domain-semantics.md) | 2026-06-30 |
 | [Resolve filesystem paths against the caller's session cwd](implemented/architecture/2026-07-02-fs-per-session-cwd.md) | 2026-07-02 |
-| [Tagged render-intent union for tool-call presentation](implemented/architecture/2026-07-02-tool-render-intent-union.md) | 2026-07-02 |
 | [Result-time applied-hunk diffs for file mutations](implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md) | 2026-07-02 |
+| [Tagged render-intent union for tool-call presentation](implemented/architecture/2026-07-02-tool-render-intent-union.md) | 2026-07-02 |
 | [Add direct directory listing to the filesystem seam](implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md) | 2026-07-03 |
 
 ### Process
 
 | Title | First proposed |
 |---|---|
-| [Vendor Cordis as source, not npm dependencies](implemented/process/2026-06-11-vendor-cordis-as-source.md) | 2026-06-11 |
+| [Doc-sync enforcement](implemented/process/2026-06-11-doc-sync-enforcement.md) | 2026-06-11 |
 | [Mechanical quality gates over prose guidelines](implemented/process/2026-06-11-quality-gates.md) | 2026-06-11 |
 | [tsdown for JS bundling instead of dumble](implemented/process/2026-06-11-tsdown-over-dumble.md) | 2026-06-11 |
-| [Doc-sync enforcement](implemented/process/2026-06-11-doc-sync-enforcement.md) | 2026-06-11 |
+| [Vendor Cordis as source, not npm dependencies](implemented/process/2026-06-11-vendor-cordis-as-source.md) | 2026-06-11 |
 | [pnpm as the package manager instead of Yarn 4](implemented/process/2026-06-16-pnpm-over-yarn.md) | 2026-06-16 |
 | [TSC-first build and one tsconfig](implemented/process/2026-06-17-ts-build-config.md) | 2026-06-17 |
 | [Markdown cross-link validity linting](implemented/process/2026-06-18-markdown-cross-link-lint.md) | 2026-06-18 |
 | [Core-data-structures catalog and the `ts type-equiv` drift gate](implemented/process/2026-06-20-core-data-structures-catalog.md) | 2026-06-20 |
 | [Generated cordis events + services catalog](implemented/process/2026-06-20-generated-cordis-catalog.md) | 2026-06-20 |
 | [Classify RFCs by kind via path-encoded subdirectories](implemented/process/2026-06-20-rfc-classification.md) | 2026-06-20 |
-| [Generated tool-schema catalog (boot-and-harvest)](implemented/process/2026-07-02-tool-schema-catalog.md) | 2026-07-02 |
 | [Bilingual documentation via paired sibling files and a pairing gate](implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md) | 2026-07-02 |
-| [Documentation tiers, budgets, and the ceiling gate](implemented/process/2026-07-04-doc-tiers-and-budgets.md) | 2026-07-04 |
+| [Generated tool-schema catalog (boot-and-harvest)](implemented/process/2026-07-02-tool-schema-catalog.md) | 2026-07-02 |
 | [JSDoc completeness gate for the cordis surface](implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md) | 2026-07-04 |
+| [Documentation tiers, budgets, and the ceiling gate](implemented/process/2026-07-04-doc-tiers-and-budgets.md) | 2026-07-04 |
+| [Generate the RFC index tables](implemented/process/2026-07-04-generate-rfc-index-tables.md) | 2026-07-04 |
 
 ### Testing
 
@@ -178,13 +181,15 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
 | [ACP snapshot tests — record-once / replay-deterministic](implemented/testing/2026-06-19-acp-snapshot-tests.md) | 2026-06-19 |
 | [Real-API e2e in CI against the external DeepSeek API](implemented/testing/2026-06-19-real-api-e2e-ci.md) | 2026-06-19 |
 | [Use `session.jsonl` as the only snapshot session-log artifact](implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md) | 2026-06-20 |
-| [Per-session snapshot replay for nested agents](implemented/testing/2026-06-22-subagent-snapshot-replay.md) | 2026-06-22 |
 | [Persist the seed boundary so fork-child replay routes correctly](implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md) | 2026-06-22 |
 | [Record fork and mixed spawn+fork snapshot scenarios](implemented/testing/2026-06-22-fork-snapshot-scenarios.md) | 2026-06-22 |
+| [Per-session snapshot replay for nested agents](implemented/testing/2026-06-22-subagent-snapshot-replay.md) | 2026-06-22 |
 | [Hook snapshot matrix — end-to-end goldens for both bridges](implemented/testing/2026-07-04-hook-snapshot-matrix.md) | 2026-07-04 |
+<!-- gen-rfc-index:end implemented -->
 
 ## Rejected
 
+<!-- gen-rfc-index:begin rejected -->
 ### Simplification
 
 | Title | First proposed |
@@ -208,3 +213,4 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
 |---|---|
 | [Deep-readonly public surfaces](rejected/architecture/2026-06-11-immutable-public-surfaces.md) | 2026-06-11 |
 | [Make the shared example base providerless](rejected/architecture/2026-06-20-providerless-example-base.md) | 2026-06-20 |
+<!-- gen-rfc-index:end rejected -->

+ 3 - 3
docs/rfc/implemented/process/2026-06-20-rfc-classification.md

@@ -29,18 +29,18 @@ The `architecture` / `process` line: **architecture** is about the source we shi
 
 Both are `doc-sync` members, in the `verify-md-wrap` style (tsx ESM, verify-don't-generate, exit non-zero on the first violation):
 
-- **`scripts/verify-rfc-classification.ts`** — the closed set and index completeness. It asserts every file under a lifecycle folder lives in a class folder from the canonical set (a loose `.md` at a lifecycle root, or an unknown class folder, fails), and that `README.md` lists every RFC exactly once under the `###` heading matching its `{lifecycle}/{class}` path. The canonical class set lives as a `const` in this script — the machine source of truth — and [the index](../../README.md) documents it in prose; the two are kept in sync by hand (the README's completeness is gated, its class *descriptions* are not). This mirrors `verify-event-taxonomy`, which checks a doc table against source.
+- **`scripts/verify-rfc-classification.ts`** — the closed set and index freshness. It asserts every file under a lifecycle folder lives in a class folder from the canonical set (a loose `.md` at a lifecycle root, or an unknown class folder, fails), and that the README's marker-delimited index regions byte-match a fresh render from the tree (see [generate the RFC index tables](2026-07-04-generate-rfc-index-tables.md)). The canonical class set lives as a `const` in `scripts/rfc-index.ts` — the machine source of truth shared with the generator — and [the index](../../README.md) documents it in prose; the README's class *descriptions* stay hand-written, its tables are generated.
 - **`scripts/verify-doc-refs.ts`** — source comments that cite docs. RFC paths are referenced not only from Markdown but from TypeScript doc comments (root-relative prose like `docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md`). `verify-md-links` never saw those, so the reorg could have silently orphaned them. This gate scans repo-authored `.ts` under `packages/**` and `examples/**` (excluding built `lib/` and `vendor/`) for `docs/….md` tokens, resolves each root-relative, and asserts it exists. It requires the `.md` extension so extensionless prose (`docs/postmortem/0001`, `docs/architecture.md § plugin checklist`) is left alone.
 
 ### Rejected alternatives
 
 - **A `Classification:` prose line** in each file (next to `Status:`), parsed by the gate. Workable, but it duplicates into the file a fact the path can already carry, and a line can disagree with its folder. Path-encoding makes the label and its storage the same thing — there is nothing to keep in sync.
 - **A `refactor` class.** It overlaps `simplification` almost entirely; the only discriminator anyone reached for was "does observable behavior change?", which `simplification` already encodes (it does not). One class, not two.
-- **Auto-generating the README index** from the filesystem. Rejected to keep the index hand-written like every other doc here; the completeness gate gives the same drift-protection without generated Markdown in a curated file.
+- **Auto-generating the README index** from the filesystem. Rejected here to keep the index hand-written; superseded by [generate the RFC index tables](2026-07-04-generate-rfc-index-tables.md) once stacked proposal waves made the hand-written tables the repo's most conflict-prone docs region — the tables are now generated between markers while the surrounding prose stays curated.
 
 ## Consequences
 
 - Every RFC now sits under a class folder, and the index groups by class within each lifecycle. A reader scans one heading to see all simplifications, or all testing decisions.
 - Two more fast tsx scripts in the `doc-sync` chain; no new dependency (the mdast/GFM stack was already present for `verify-md-wrap`/`verify-md-links`).
-- Adding a class is a deliberate act: amend the `const` in `verify-rfc-classification.ts` and the [Classification section](../../README.md#classification), not just `mkdir` a folder. The gate rejects an unknown folder, so an ad-hoc class can't slip in.
+- Adding a class is a deliberate act: amend the `const` in `scripts/rfc-index.ts` and the [Classification section](../../README.md#classification), not just `mkdir` a folder. The gate rejects an unknown folder, so an ad-hoc class can't slip in.
 - Source-comment doc references are now gated too — a moved or renamed doc that a `.ts` comment cites fails the pre-push hook, closing a drift class `verify-md-links` structurally could not see.

+ 26 - 0
docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.md

@@ -0,0 +1,26 @@
+# RFC: Generate the RFC index tables
+
+Status: implemented
+
+## Problem
+
+`docs/rfc/README.md`'s per-lifecycle/per-class tables list facts that are fully derivable: an RFC's path encodes lifecycle and class, its filename encodes the first-proposed date, and its H1 carries the title. A hand-maintained copy of those facts is also the repo's highest-contention docs hotspot: every proposal wave appends rows to the same few lines, so concurrent RFC branches conflict precisely there while agreeing everywhere else, and each conflict is resolved by hand-merging rows whose content the filesystem already knows. [The classification RFC](2026-06-20-rfc-classification.md) originally kept the index hand-written for curation's sake — but the curated part of the README is the prose, and the prose never conflicts; only the mechanical tables do.
+
+## Decision
+
+Keep the curated prose; generate the tables. [`scripts/rfc-index.ts`](../../../../scripts/rfc-index.ts) is the shared source of truth — the tree walker (owning the closed lifecycle/class sets and the structure rules, including a parseable-H1 requirement) and the renderer (rows from H1 title with any `RFC: ` prefix stripped, plus the filename date, sorted by date then filename, grouped as `### {Class}` sections in canonical class order). Two thin consumers share it:
+
+- [`scripts/gen-rfc-index.ts`](../../../../scripts/gen-rfc-index.ts) (`pnpm run gen-rfc-index`) rewrites the three marker-delimited regions in the README (`<!-- gen-rfc-index:begin {lifecycle} -->` … `end`), one per `## {Lifecycle}` section, leaving everything outside the markers untouched.
+- [`scripts/verify-rfc-classification.ts`](../../../../scripts/verify-rfc-classification.ts) (a `doc-sync` member) checks structure and asserts the committed regions byte-match a fresh render — the `gen-cordis-catalog`/`verify-cordis-catalog` pattern. Freshness subsumes the index-completeness check: a generated-from-disk table is definitionally complete and correctly headed.
+
+Adding, moving, or deleting an RFC means editing only the RFC file and running the generator; the classification RFC's rejected-alternatives record carries the supersession cross-link.
+
+## Why not the verifier-only model?
+
+It catches mistakes but still makes every proposal edit a shared hotspot, and a failed verifier is strictly more annoying than a generator for a purely mechanical row: the author has already named and placed the file; the index copy adds no information. This is the same hand-list-versus-derivation judgment the [package-inventory proposal](../../proposed/process/2026-06-20-discover-package-inventory.md) applies to tsconfig references and knip stanzas — applied to the one list that demonstrably conflicts.
+
+## Consequences
+
+- The generated regions are explicit: marker comments make script ownership obvious to reviewers, and the generator refuses to run on a structurally invalid tree.
+- A malformed or missing H1 is a hard error in both the generator and the gate — the H1 is now load-bearing as the index title source.
+- Concurrent RFC branches resolve index conflicts by rerunning the generator, never by hand-merging rows.

+ 1 - 1
docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md

@@ -1,4 +1,4 @@
-# RFC: Pre-tool input rewrite — a consistent design (proposed)
+# RFC: Pre-tool input rewrite — a consistent design
 
 Status: proposed (2026-06-30)
 

+ 0 - 27
docs/rfc/proposed/process/2026-07-04-generate-rfc-index-tables.md

@@ -1,27 +0,0 @@
-# RFC: Generate the RFC index tables
-
-Status: proposed
-
-## Problem
-
-`docs/rfc/README.md`'s per-lifecycle/per-class tables are hand-maintained even though every fact in them is derivable: an RFC's path encodes lifecycle and class, its filename encodes the first-proposed date, and its H1 carries the title. `scripts/verify-rfc-classification.ts` already walks the tree and cross-checks the index — the expensive parsing exists; it reports instead of writing.
-
-The tables are also the repo's highest-contention docs hotspot: every proposal wave appends rows to the same few lines, so concurrent RFC branches conflict precisely there while agreeing everywhere else, and each conflict is resolved by hand-merging rows whose content the filesystem already knows. [The classification RFC](../../implemented/process/2026-06-20-rfc-classification.md) records rejecting auto-generation to keep the file curated — but the curated part of the README is the prose, and the prose never conflicts; only the mechanical tables do.
-
-## Proposal
-
-Keep the curated prose; generate the tables. Add a `gen-rfc-index` mode (a `--write` flag on `verify-rfc-classification.ts`, or a sibling script sharing its walker) that scans the RFC tree, reads each H1, derives the date from the filename, and rewrites the table rows under stable generated markers per `## {Lifecycle}` / `### {Class}` section; `verify-rfc-classification` asserts freshness — the `gen-cordis-catalog`/`verify-cordis-catalog` pattern. The class and lifecycle sets stay closed in the script. The implementing PR amends the classification RFC's rejected-alternatives record per [implemented/AGENTS.md](../../implemented/AGENTS.md), since this supersedes that recorded choice.
-
-## Why not keep the verifier-only model?
-
-It catches mistakes but still makes every proposal edit a shared hotspot, and a failed verifier is strictly more annoying than a generator for a purely mechanical row: the author has already named and placed the file; the index copy adds no information. This is the same hand-list-versus-derivation judgment the [package-inventory proposal](2026-06-20-discover-package-inventory.md) applies to tsconfig references and knip stanzas — applied to the one list that demonstrably conflicts.
-
-## Acceptance criteria
-
-- `pnpm run gen-rfc-index` (or the chosen spelling) rewrites only the generated table regions; `verify-rfc-classification` fails when they are stale and passes after regeneration.
-- Adding, moving, or deleting an RFC requires editing only the RFC file itself; the rows are produced from path + H1 + filename date.
-- The prose outside the generated markers is untouched by the generator; `pnpm run doc-sync` passes.
-
-## Risks
-
-Generated regions inside a curated file need explicit markers so ownership is obvious to reviewers. Reading H1s makes a malformed header a generator error — useful pressure, and it should fail clearly. This supersedes an implemented process decision; amending that RFC's record is part of the change, not optional.

+ 1 - 0
package.json

@@ -35,6 +35,7 @@
     "verify-doc-budgets": "tsx scripts/verify-doc-budgets.ts",
     "verify-node-next-types": "tsx scripts/verify-node-next-types.ts",
     "gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts",
+    "gen-rfc-index": "tsx scripts/gen-rfc-index.ts",
     "verify-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts --check",
     "gen-tool-catalog": "tsx scripts/gen-tool-catalog.ts",
     "verify-tool-catalog": "tsx scripts/gen-tool-catalog.ts --check",

+ 30 - 0
scripts/gen-rfc-index.ts

@@ -0,0 +1,30 @@
+/**
+ * Regenerate the RFC index tables in `docs/rfc/README.md` from the RFC tree
+ * (see [rfc-index.ts](./rfc-index.ts) for the layout contract and rendering
+ * rules). Rewrites ONLY the marker-delimited regions; the curated prose is
+ * untouched. Freshness is asserted by `verify-rfc-classification.ts` (a
+ * `doc-sync` member), so a stale committed index fails CI.
+ *
+ * Run: `pnpm run gen-rfc-index`.
+ */
+
+import { readFileSync, writeFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { rfcRoot, spliceReadme, walkRfcTree } from './rfc-index.ts'
+
+const { rfcs, errors } = walkRfcTree()
+if (errors.length > 0) {
+  console.error('gen-rfc-index: refusing to generate from a structurally invalid tree:')
+  for (const e of errors) console.error(`  ${e}`)
+  process.exit(1)
+}
+
+const readmePath = resolve(rfcRoot, 'README.md')
+const readme = readFileSync(readmePath, 'utf8')
+const next = spliceReadme(readme, rfcs)
+if (next === readme) {
+  console.log(`gen-rfc-index: docs/rfc/README.md is up to date (${rfcs.length} RFCs).`)
+} else {
+  writeFileSync(readmePath, next)
+  console.log(`gen-rfc-index: docs/rfc/README.md regenerated (${rfcs.length} RFCs).`)
+}

+ 173 - 0
scripts/rfc-index.ts

@@ -0,0 +1,173 @@
+/**
+ * Shared source of truth for the RFC index: the tree walker (structure rules)
+ * and the README table renderer. `gen-rfc-index.ts` writes the generated
+ * regions; `verify-rfc-classification.ts` checks structure and asserts the
+ * committed regions are fresh. Pure module — no side effects on import.
+ *
+ * The layout contract ([the classification RFC](../docs/rfc/implemented/process/2026-06-20-rfc-classification.md)):
+ * every RFC lives at `docs/rfc/{lifecycle}/{class}/yyyy-mm-dd-topic.md`, the
+ * folder IS the label, and both sets are CLOSED — extending either means
+ * amending this module AND the README's Classification prose.
+ *
+ * The README's per-lifecycle tables are GENERATED between marker comments
+ * (`<!-- gen-rfc-index:begin {lifecycle} -->` … `end`): section headings and
+ * rows are derived from each RFC's path (lifecycle/class), H1 (title, with an
+ * optional `RFC: ` prefix stripped), and filename date, sorted by date then
+ * filename. Prose outside the markers is curated by hand and never touched.
+ */
+
+import { readFileSync, readdirSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { globSync } from 'node:fs'
+
+export const rfcRoot = resolve(import.meta.dirname, '../docs/rfc')
+
+/** The closed set of RFC lifecycles (top-level folders under docs/rfc/). */
+const LIFECYCLES = ['proposed', 'implemented', 'rejected'] as const
+
+/**
+ * The closed set of RFC classes (nested folder under each lifecycle). Adding a
+ * class is a deliberate act: extend this list AND the README's Classification
+ * section. The gate rejects any folder not listed here.
+ */
+const CLASSES = ['feature', 'bug-fix', 'simplification', 'architecture', 'process', 'testing'] as const
+
+/** Non-RFC Markdown allowed to sit directly at a lifecycle root. */
+const ROOT_ALLOWLIST = new Set(['AGENTS.md', 'CLAUDE.md'])
+
+/** Title-case a class/lifecycle folder name for a README heading. */
+const heading = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1)
+
+/** One RFC file, as discovered by the walker. */
+export interface Rfc {
+  lifecycle: string
+  cls: string
+  base: string
+  /** Path relative to docs/rfc — the README link target. */
+  rel: string
+  /** H1 text with any `RFC: ` prefix stripped — the README row title. */
+  title: string
+  /** `yyyy-mm-dd` from the filename — the "First proposed" column. */
+  date: string
+}
+
+/**
+ * Walk the RFC tree, enforcing the structure rules. Returns every valid RFC
+ * plus one error string per violation (unknown lifecycle or class folder, bad
+ * depth, bad filename, missing/malformed H1). Callers treat a non-empty error
+ * list as fatal — the index is only generated from a structurally valid tree.
+ */
+export function walkRfcTree(): { rfcs: Rfc[]; errors: string[] } {
+  const rfcs: Rfc[] = []
+  const errors: string[] = []
+  // The lifecycle set is closed too: any directory under docs/rfc/ that is not
+  // a known lifecycle would otherwise hold RFCs invisible to the walk below.
+  for (const entry of readdirSync(rfcRoot, { withFileTypes: true })) {
+    if (entry.isDirectory() && !(LIFECYCLES as readonly string[]).includes(entry.name)) {
+      errors.push(`structure: ${entry.name}/ — unknown lifecycle folder (allowed: ${LIFECYCLES.join(', ')})`)
+    }
+  }
+  for (const lifecycle of LIFECYCLES) {
+    for (const match of globSync(`${lifecycle}/**/*.md`, { cwd: rfcRoot }).sort()) {
+      const segs = match.split('/')
+      // Allowlisted file directly at the lifecycle root (e.g. implemented/AGENTS.md).
+      if (segs.length === 2 && ROOT_ALLOWLIST.has(segs[1] ?? '')) continue
+      // A Chinese counterpart (foo.zh.md, docs/i18n/README.md) is the SAME RFC,
+      // indexed via its English filename; the pairing gate owns its consistency.
+      if (match.endsWith('.zh.md')) continue
+      const cls = segs[1]
+      const base = segs[2]
+      if (segs.length !== 3 || cls === undefined || base === undefined) {
+        errors.push(`structure: ${match} — expected {lifecycle}/{class}/file.md (got depth ${segs.length})`)
+        continue
+      }
+      if (!(CLASSES as readonly string[]).includes(cls)) {
+        errors.push(`structure: ${match} — unknown class folder "${cls}" (allowed: ${CLASSES.join(', ')})`)
+        continue
+      }
+      if (!/^\d{4}-\d{2}-\d{2}-.+\.md$/.test(base)) {
+        errors.push(`structure: ${match} — filename must be yyyy-mm-dd-topic.md`)
+        continue
+      }
+      const firstLine = readFileSync(resolve(rfcRoot, match), 'utf8').split('\n', 1)[0] ?? ''
+      const h1 = /^#\s+(?:RFC:\s+)?(.+?)\s*$/.exec(firstLine)
+      if (!h1?.[1]) {
+        errors.push(`title: ${match} — first line must be an H1 (\`# RFC: <title>\` or \`# <title>\`), got: ${JSON.stringify(firstLine)}`)
+        continue
+      }
+      rfcs.push({ lifecycle, cls, base, rel: match, title: h1[1], date: base.slice(0, 10) })
+    }
+  }
+  return { rfcs, errors }
+}
+
+/** The begin/end marker lines that delimit one lifecycle's generated region. */
+const markers = (lifecycle: string): { begin: string; end: string } => ({
+  begin: `<!-- gen-rfc-index:begin ${lifecycle} -->`,
+  end: `<!-- gen-rfc-index:end ${lifecycle} -->`,
+})
+
+/**
+ * Render one lifecycle's generated region body: a `### {Class}` heading plus a
+ * `| Title | First proposed |` table for every non-empty class, in CLASSES
+ * order, rows sorted by date then filename.
+ */
+function renderLifecycle(rfcs: Rfc[], lifecycle: string): string {
+  const sections: string[] = []
+  for (const cls of CLASSES) {
+    const rows = rfcs
+      .filter(r => r.lifecycle === lifecycle && r.cls === cls)
+      .sort((a, b) => a.date.localeCompare(b.date) || a.base.localeCompare(b.base))
+    if (rows.length === 0) continue
+    const table = rows.map(r => `| [${r.title}](${r.rel}) | ${r.date} |`).join('\n')
+    sections.push(`### ${heading(cls)}\n\n| Title | First proposed |\n|---|---|\n${table}`)
+  }
+  return sections.join('\n\n')
+}
+
+/**
+ * Splice freshly rendered regions into the README text. Throws when a marker
+ * pair is missing, duplicated, or out of order, when a region does not sit
+ * under its own `## {Lifecycle}` heading, or when an index-shaped table row
+ * (a `| [title](lifecycle/…)` line) appears OUTSIDE the generated regions —
+ * the markers are part of the curated prose, the heading above each region is
+ * the one its lifecycle names, and index rows live only inside the regions
+ * (prose links to RFCs remain fine anywhere).
+ */
+export function spliceReadme(readme: string, rfcs: Rfc[]): string {
+  let out = readme
+  const regions: Array<{ from: number; to: number }> = []
+  for (const lifecycle of LIFECYCLES) {
+    const { begin, end } = markers(lifecycle)
+    const beginAt = out.indexOf(begin)
+    const endAt = out.indexOf(end)
+    if (beginAt === -1 || endAt === -1 || endAt < beginAt) {
+      throw new Error(`README.md is missing the ${JSON.stringify(begin)} … ${JSON.stringify(end)} marker pair`)
+    }
+    if (out.indexOf(begin, beginAt + 1) !== -1 || out.indexOf(end, endAt + 1) !== -1) {
+      throw new Error(`README.md has a duplicated ${lifecycle} index marker`)
+    }
+    // The region must sit directly under its own lifecycle heading: the last
+    // H2 above the begin marker is `## {Heading(lifecycle)}`, or the heading
+    // itself has drifted while the generated table stayed put.
+    const before = out.slice(0, beginAt)
+    const lastH2 = [...before.matchAll(/^##\s+(.+?)\s*$/gm)].at(-1)?.[1]
+    if (lastH2 !== heading(lifecycle)) {
+      throw new Error(`README.md: the ${lifecycle} index region is not under a "## ${heading(lifecycle)}" heading (found "## ${lastH2 ?? '<none>'}")`)
+    }
+    out = `${out.slice(0, beginAt + begin.length)}\n${renderLifecycle(rfcs, lifecycle)}\n${out.slice(endAt)}`
+    regions.push({ from: out.indexOf(begin), to: out.indexOf(markers(lifecycle).end) + markers(lifecycle).end.length })
+  }
+  // Index rows are generated state: a table row linking into a lifecycle
+  // folder anywhere OUTSIDE the regions is a hand-added index entry the
+  // generator would never reconcile.
+  let offset = 0
+  for (const line of out.split('\n')) {
+    const inRegion = regions.some(r => offset >= r.from && offset < r.to)
+    if (!inRegion && /^\|\s*\[[^\]]+\]\((?:proposed|implemented|rejected)\//.test(line)) {
+      throw new Error(`README.md: index-shaped row outside the generated regions: ${JSON.stringify(line.slice(0, 80))}`)
+    }
+    offset += line.length + 1
+  }
+  return out
+}

+ 25 - 133
scripts/verify-rfc-classification.ts

@@ -1,158 +1,50 @@
 /**
  * Doc-sync gate: enforce the RFC classification scheme
- * ([the classification RFC](../docs/rfc/implemented/process/2026-06-20-rfc-classification.md)).
+ * ([the classification RFC](../docs/rfc/implemented/process/2026-06-20-rfc-classification.md))
+ * and the freshness of the generated index tables
+ * ([the index-generation RFC](../docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.md)).
  * Every RFC is filed at `docs/rfc/{lifecycle}/{class}/yyyy-mm-dd-topic.md`; the
  * folder IS the label. This gate is the machine source of truth for the closed
  * class set and keeps the README index honest.
  *
- * Two checks:
+ * Two checks (both against [rfc-index.ts](./rfc-index.ts), the shared walker
+ * and renderer):
  *
  * 1. STRUCTURE — every `.md` under a lifecycle folder lives in a class folder
- *    from CLASSES, named `yyyy-mm-dd-*.md`. A loose `.md` directly under a
- *    lifecycle root (other than the README/AGENTS allowlist) fails; an unknown
- *    class folder fails; a stray file at an unexpected depth fails. This is what
- *    makes the set CLOSED: a new class folder can't appear without amending
- *    CLASSES here (and the README's Classification section, per the RFC).
+ *    from CLASSES, is named `yyyy-mm-dd-*.md`, and opens with a parseable H1.
+ *    A loose `.md` directly under a lifecycle root (other than the
+ *    README/AGENTS allowlist) fails; an unknown class folder fails; a stray
+ *    file at an unexpected depth fails. This is what makes the set CLOSED: a
+ *    new class folder can't appear without amending CLASSES (and the README's
+ *    Classification section, per the RFC).
  *
- * 2. COMPLETENESS — `docs/rfc/README.md` lists every RFC exactly once, under the
- *    `### {Class}` heading inside the `## {Lifecycle}` section that matches the
- *    file's path. A missing entry, a duplicate, or an entry under the wrong
- *    heading fails. This mirrors `verify-event-taxonomy`: a curated doc table
- *    checked against the on-disk source of truth, so the index can't drift.
- *
- * The class DESCRIPTIONS in the README prose are not checked (they are
- * explanatory text); only the per-class index tables are. This is checker, not
- * fixer: it reports and never rewrites.
+ * 2. FRESHNESS — the marker-delimited index regions in `docs/rfc/README.md`
+ *    byte-match a fresh render from the tree, so every RFC is listed exactly
+ *    once, under the heading matching its path, with its H1 title and filename
+ *    date. The fix for a stale index is `pnpm run gen-rfc-index`, never a hand
+ *    edit. This is checker, not fixer: it reports and never rewrites.
  *
  * Run: `tsx scripts/verify-rfc-classification.ts`.
  */
 
 import { readFileSync } from 'node:fs'
-import { relative, resolve } from 'node:path'
-import { glob } from 'node:fs/promises'
-
-const root = resolve(import.meta.dirname, '..')
-const rfcRoot = resolve(root, 'docs/rfc')
-
-/** The closed set of RFC lifecycles (top-level folders under docs/rfc/). */
-const LIFECYCLES = ['proposed', 'implemented', 'rejected'] as const
-
-/**
- * The closed set of RFC classes (nested folder under each lifecycle). Adding a
- * class is a deliberate act: extend this list AND the README's Classification
- * section. The gate rejects any folder not listed here.
- */
-const CLASSES = ['feature', 'bug-fix', 'simplification', 'architecture', 'process', 'testing'] as const
-
-/** Non-RFC Markdown allowed to sit directly at a lifecycle root. */
-const ROOT_ALLOWLIST = new Set(['AGENTS.md', 'CLAUDE.md'])
+import { resolve } from 'node:path'
+import { rfcRoot, spliceReadme, walkRfcTree } from './rfc-index.ts'
 
-/** Title-case a class/lifecycle folder name for README heading comparison. */
-const heading = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1)
+const { rfcs, errors } = walkRfcTree()
 
-const errors: string[] = []
-
-// --- Check 1: structure -----------------------------------------------------
-// Every Markdown file anywhere under a lifecycle folder, at any depth.
-interface Rfc {
-  lifecycle: string
-  cls: string
-  base: string
-  /** Path relative to docs/rfc, for the README link check. */
-  rel: string
-}
-const rfcs: Rfc[] = []
-
-for (const lifecycle of LIFECYCLES) {
-  for await (const match of glob(`${lifecycle}/**/*.md`, { cwd: rfcRoot })) {
-    const segs = match.split('/')
-    // Allowlisted file directly at the lifecycle root (e.g. implemented/AGENTS.md).
-    if (segs.length === 2 && ROOT_ALLOWLIST.has(segs[1] ?? '')) continue
-    // A Chinese counterpart (foo.zh.md, docs/i18n/README.md) is the SAME RFC,
-    // indexed via its English filename; the pairing gate owns its consistency.
-    if (match.endsWith('.zh.md')) continue
-    const cls = segs[1]
-    const base = segs[2]
-    if (segs.length !== 3 || cls === undefined || base === undefined) {
-      errors.push(`structure: ${match} — expected {lifecycle}/{class}/file.md (got depth ${segs.length})`)
-      continue
-    }
-    if (!(CLASSES as readonly string[]).includes(cls)) {
-      errors.push(`structure: ${match} — unknown class folder "${cls}" (allowed: ${CLASSES.join(', ')})`)
-      continue
-    }
-    if (!/^\d{4}-\d{2}-\d{2}-.+\.md$/.test(base)) {
-      errors.push(`structure: ${match} — filename must be yyyy-mm-dd-topic.md`)
-      continue
-    }
-    rfcs.push({ lifecycle, cls, base, rel: match })
-  }
-}
-
-// --- Check 2: README completeness -------------------------------------------
-// Parse the index into (lifecycle, class) -> set of linked rel paths, by
-// tracking the current `## {Lifecycle}` and `### {Class}` headings and reading
-// every `](path)` link target underneath. A link target is normalized to its
-// path relative to docs/rfc.
 const readmePath = resolve(rfcRoot, 'README.md')
 const readme = readFileSync(readmePath, 'utf8')
-const lifecycleByHeading = new Map(LIFECYCLES.map((l): [string, string] => [heading(l), l]))
-const classByHeading = new Map(CLASSES.map((c): [string, string] => [heading(c), c]))
-
-/** README-listed RFC link targets, keyed `lifecycle/class` -> set of rel paths. */
-const listed = new Map<string, Set<string>>()
-let curLifecycle: string | null = null
-let curClass: string | null = null
-
-for (const line of readme.split('\n')) {
-  const h2 = /^##\s+(.+?)\s*$/.exec(line)
-  if (h2?.[1] !== undefined) {
-    curLifecycle = lifecycleByHeading.get(h2[1].trim()) ?? null
-    curClass = null
-    continue
-  }
-  const h3 = /^###\s+(.+?)\s*$/.exec(line)
-  if (h3?.[1] !== undefined) {
-    curClass = classByHeading.get(h3[1].trim()) ?? null
-    continue
-  }
-  if (!curLifecycle || !curClass) continue
-  // Collect every relative .md link target on this line.
-  for (const m of line.matchAll(/\]\(([^)]+\.md)[^)]*\)/g)) {
-    const target = m[1]
-    if (target === undefined) continue
-    // README links are relative to docs/rfc; normalize and key by location.
-    const rel = relative(rfcRoot, resolve(rfcRoot, target))
-    const key = `${curLifecycle}/${curClass}`
-    const set = listed.get(key) ?? new Set<string>()
-    set.add(rel)
-    listed.set(key, set)
-  }
-}
-
-// Every on-disk RFC must be listed under the heading matching its path.
-const seenOnDisk = new Set<string>()
-for (const rfc of rfcs) {
-  seenOnDisk.add(rfc.rel)
-  const key = `${rfc.lifecycle}/${rfc.cls}`
-  if (!listed.get(key)?.has(rfc.rel)) {
-    errors.push(
-      `index: ${rfc.rel} is not listed in README under "## ${heading(rfc.lifecycle)}" → "### ${heading(rfc.cls)}"`,
-    )
-  }
-}
-
-// Every README entry must point at a real RFC under that same heading (catches a
-// misfiled or stale row).
-for (const [key, targets] of listed) {
-  for (const rel of targets) {
-    if (!seenOnDisk.has(rel)) {
-      errors.push(`index: README lists "${rel}" under "${key}", but no such RFC exists`)
+if (errors.length === 0) {
+  try {
+    if (spliceReadme(readme, rfcs) !== readme) {
+      errors.push('index: docs/rfc/README.md is stale — run `pnpm run gen-rfc-index` and commit the result')
     }
+  } catch (error) {
+    errors.push(`index: ${error instanceof Error ? error.message : String(error)}`)
   }
 }
 
-// --- Report -----------------------------------------------------------------
 if (errors.length === 0) {
   console.log(`verify-rfc-classification: ${rfcs.length} RFC(s) checked, structure and index consistent.`)
   process.exit(0)