* chore: rebuild ADR index as a generated artifact and enforce lifecycle invariants
The ADR index in docs/adr/README.md was hand-maintained with nothing checking
it, and had drifted to 40 of 65 ADRs. The absent rows included the entire
capability family (857/894/959/1016/1143/1213/1244) and ADR-1239 (EoS) itself,
so the decisions a reader most needed were the ones they could not find.
Make the index a derived artifact, matching the repo's existing generated-file
idiom (lint:generated-sync), and enforce the corpus' lifecycle invariants:
- scripts/gen-adr-index.cjs generates the index between markers and validates
the status vocabulary (Accepted/Proposed/Superseded/Legacy/Retired),
successor links, id/filename agreement, and supersession symmetry.
- Wire --check into lint:generated-sync so drift fails CI.
Correct the lifecycle metadata the gate surfaced, without flipping any status:
- ADR-1239 (EoS) declared it subsumed ADR-1016/58/3660/894; none recorded it.
Add reciprocal "Subsumed by" pointers + dated amendments. Subsumption keeps
the target Accepted -- these are live adapters, not dead decisions.
- ADR-857/894 carry dated status caveats: they read Proposed while the
capability system shipped and epic #857 is closed. Ratification is a
maintainer act and is deliberately left open.
- Link ADR-0005/0007/0012/3524 -> ADR-0174 and ADR-0010 -> ADR-0009; record
the reciprocal Supersedes on ADR-0009.
- ADR-218 declared itself "ADR-0175" -- an unfinished rename.
- The 0011 PRD moves from the non-canonical "Draft" to "Legacy".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test: capture stderr via spawnSync; record ADR-0010 draft supersession
Two fixes surfaced by the first gsd-test run and by regenerating the index:
- tests/adr-index-gate.test.cjs used execFileSync, which only surfaces stderr
through the thrown error on non-zero exit. The `--write` path exits 0 while
reporting outstanding violations on stderr, so the helper always saw ''.
spawnSync captures both streams on both outcomes.
- The hand-maintained index recorded 0010-skill-surface-budget-module.md as
"earlier draft superseded by ADR-0011" while the file itself still said
Proposed. Deriving the index from the files would have dropped that
assertion and resurrected a superseded draft as a live decision, so it is
recorded at its source, with the reciprocal Supersedes on ADR-0011.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix: drop the dead sdk/ model-catalog candidate retired by ADR-0174
src/model-catalog.cts resolved model-catalog.json through three candidates, the
second being sdk/shared/model-catalog.json three levels up. That was the legacy
source-repo fallback kept by the #3288 fix ("check the co-located path FIRST,
before the legacy source-repo path").
ADR-0174 then retired the @opengsd/gsd-sdk package boundary and deleted the sdk/
tree (11918dcc3), so the candidate can no longer resolve in any layout: a source
repo has no sdk/, and an install layout points it at ~/.claude/sdk/shared/, which
the installer never writes -- the original #3288 bug. It was dead weight implying
a package boundary this repo no longer has.
No test depends on it: the #3288 regression tests in tests/install.test.cjs write
their own synthetic old-path fixture and assert it throws.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs: ratify nine shipped ADRs; record why ten others stay Proposed
The corpus carried 19 Proposed ADRs, most describing architecture that had
already shipped. A Proposed label on live architecture tells contributors and
agents the decision is an unbuilt idea -- the capability system and EoS were
both being misread that way.
Audited all 19 against the shipped tree and GitHub. Each candidate flip then had
to survive two independent reviewers instructed to refute it.
Ratified Proposed -> Accepted, each with a dated Ratification section carrying
the verified evidence (file:line, symbols, tests, issue state):
857 capability system 894 declaration format 1244 capability ecosystem
1577 injection boundary 1610 size-budget ratchet 1990 existing-code onboarding
15 cross-AI convergence 22 plan-drift guard 0011 default reviewers
Held ten, each now carrying a "Why this is still Proposed" section naming the
blocker and its unblock condition, so the audit is not repeated:
2264 its own headline acceptance criterion is unmet in the tree
230 live branch protection contradicts the decided spec (1 approval, not 2)
660 the namesake release/<version> re-cut is manual, not automated
959 issue #2346 is approved and plans its graduation as its own ADR
1213 the shipped writer's return shape differs from the decided interface
443 the orchestrator override path has no live caller
1143 / 1606 each states its own bar for acceptance; neither is met
612 / 1671 legitimately open
Shipped code proved necessary but not sufficient: eight ADRs had every named
module, symbol, and test present with their epics closed, and still failed the
bar. That lesson is written into README.md's ratification procedure.
Also corrected ADR-857's "Supersedes (generalizes)" to "Subsumes": taken
literally it would have marked two live seams dead -- ADR-0011 (surface.cts:348)
and ADR-58 (runtime-artifact-install-plan.cts:82). Both keep Accepted status and
gain Subsumed-by pointers.
Index: Active 39->48, Proposed 19->10, Superseded/Legacy 7. 65 total.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix: harden gen-adr-index against hostile titles and non-ADR filenames (#2356)
Three findings from the pre-PR orthogonal security review, all confirmed:
- An ADR title containing the literal ADR-INDEX:END marker was emitted verbatim
into its table cell, relocating the splice boundary so the NEXT --write
spliced against the wrong marker and truncated README.md. Titles now render
through cellText(), which escapes pipes and angle brackets -- making an HTML
comment (and any other HTML) unformable from ADR-authored text.
- A docs/adr/*.md without a numeric prefix crashed on match(...)[1] of null.
Such a file is also invisible to the index -- the very failure this gate
exists to prevent -- so it is now reported as a naming-convention violation
naming the file and the fix.
- Tests leaked their mkdtemp dirs. They now use helpers.createTempDir/cleanup
via t.after(); helpers.cleanup carries the Windows-EBUSY retry budget that a
raw fs.rmSync lacks (caught by local/no-raw-rmsync-in-tests).
Adds five regression tests: marker hijack, HTML injection, pipe cell-break,
non-conforming filename, and splice stability across repeated writes.
Refs #2356
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix: close two gate false-passes; read ## Supersedes sections (#2356)
Second round of confirmed findings from the pre-PR orthogonal code review. Both
false-passes matter more than a false-fail: a gate that silently misses a
violation is worse than no gate, because it is trusted.
- A relation field mixing a link with a bare id silently dropped the bare claim:
the check tested `rel.links.length` (does this field have ANY link?) instead
of whether THAT id was linked. `Supersedes: [ADR-0001](...), ADR-0011` passed
clean -- accepting exactly the ambiguous bare reference the rule forbids. Now
each bare id is checked against the ids actually linked in the same field, so
a repeat in trailing prose stays quiet while an unlinked claim is flagged.
- The ratification guard (`statusToken !== 'Accepted'`) skipped BOTH relation
directions, which killed the IN check entirely: `supersedes.in` is only ever
populated on an ADR whose status IS `Superseded`, so a dangling `Superseded by
X` where X never claims it always passed. The guard now applies to OUT only --
a prospective claim must not obligate its target, but an ADR's statement about
ITSELF is always owed a reciprocal.
- Fixing that surfaced a parser gap: ADR-0174 declares its supersessions in a
`## Supersedes` table SECTION, not a header field, and headerBlock() stops at
the first `##`. The repo's best-documented supersession was invisible. Section
form is now parsed for both relations.
- Replaced a vacuous test: the em-dash negation case passed whether or not
NEGATED_RELATION_RE matched (a mutation to /$^/ survived). It now carries a
link that would create a failing asymmetric relation if negation did not fire.
Also removes docs/adr/9401-test-target.md -- a synthetic fixture a reviewer
created in the worktree while reproducing a finding, swept in by `git add -A`.
Adds regression tests for each: mixed link+bare, linked-and-repeated-in-prose,
dangling superseded-by from a non-Accepted ADR, and the ADR-0174 section shape.
Refs #2356
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix: escape backslashes before pipes in the ADR index cell renderer (#2356)
CodeQL js/incomplete-sanitization (high) on scripts/gen-adr-index.cjs: cellText()
escaped `|` -> `\|` without first escaping the backslash. Markdown's escape
character is the backslash, so the input `\|` became `\\|`, which renders as a
literal backslash followed by an UNESCAPED pipe -- re-opening the cell break the
pipe escape exists to prevent. Order is load-bearing: escape the escape
character first, then everything that emits one.
Same class as the index-marker hijack fixed earlier: ADR-authored text breaking
out of the cell it is rendered into.
Adds a regression test asserting a `\|`-bearing title leaves exactly the row's
own 5 unescaped delimiters and cannot forge a Status cell. Uses split(/\r?\n/)
per local/no-crlf-fragile-split -- a literal "\n" split is CRLF-fragile on the
Windows CI leg.
Refs #2356
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# SDK Architecture seam map for query/runtime surfaces
|
||||
|
||||
- **Status:** Superseded by ADR-0174 (2026-05-23); originally Accepted (2026-05-09)
|
||||
- **Status:** Superseded by [ADR-0174](0174-retire-gsd-sdk-package-boundary.md) (2026-05-23); originally Accepted (2026-05-09)
|
||||
- **Date:** 2026-05-09
|
||||
|
||||
We decided to keep SDK architecture explicitly module-seamed rather than allow feature logic to spread across query handlers, runtime adapters, and compatibility shims. This ADR is the top-level map for SDK seams and their ownership boundaries.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# SDK Package Seam Module owns SDK-to-get-shit-done-redux compatibility
|
||||
|
||||
- **Status:** Superseded by ADR-0174 (2026-05-23); originally Accepted (2026-05-07)
|
||||
- **Status:** Superseded by [ADR-0174](0174-retire-gsd-sdk-package-boundary.md) (2026-05-23); originally Accepted (2026-05-07)
|
||||
- **Date:** 2026-05-07
|
||||
|
||||
We decided to define one explicit SDK Package Seam Module for the `@opengsd/gsd-sdk` → `@opengsd/get-shit-done-redux` transition. During this transition, install-layout probing, legacy `gsd-tools.cjs` discovery, legacy `core.cjs` discovery, and compatibility-only missing-asset diagnostics must live behind one seam instead of leaking across SDK Modules. This keeps callers thin, raises leverage for standalone-SDK testing, and improves locality by making package-readiness bugs land in one place. First tracer-bullet slice: add one compatibility Adapter Module at this seam and migrate current legacy asset callers onto it before broader native replacement work.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Shell Command Projection Module owns runtime-aware OS command rendering
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Supersedes:** [ADR-0010](0010-file-operation-engine-module.md) (File Operation Engine Module) — absorbed into this seam's Phases 3–4 (`#3467`–`#3468`), 2026-05-13
|
||||
- **Date:** 2026-05-12
|
||||
|
||||
We propose introducing a Shell Command Projection Module that owns projection from typed command intent to concrete shell/runtime-specific command text. GSD currently hand-builds hook commands, PATH repair commands, shim scripts, and other serialized OS-facing command strings across installer call sites. That drift has repeatedly produced cross-shell regressions (`#2376`, `#2979`, `#3002`, `#3011`, `#3181`, `#3393`, `#3413`). The proposed seam concentrates quoting, path-style, and runtime-wrapper policy in one module while keeping real subprocess execution on array-arg/non-shell paths.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File Operation Engine Module owns safe runtime/config file mutations
|
||||
|
||||
- **Status:** Superseded by ADR-0009 (Shell Command Projection Module expansion, Phases 3–4, `#3467`–`#3468`)
|
||||
- **Status:** Superseded by [ADR-0009](0009-shell-command-projection-module.md) (Shell Command Projection Module expansion, Phases 3–4, `#3467`–`#3468`)
|
||||
- **Date:** 2026-05-12
|
||||
- **Superseded:** 2026-05-13
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# Skill Surface Budget Module owns install-time skill listing curation
|
||||
|
||||
- **Status:** Proposed
|
||||
- **Status:** Superseded by [ADR-0011](0011-skill-surface-budget-module.md) (Skill Surface Budget Module — install-time profile staging and runtime surface control); originally Proposed (2026-05-12)
|
||||
- **Date:** 2026-05-12
|
||||
|
||||
> **Provenance of this status (2026-07-16).** This file said `Proposed` while the hand-maintained index in `README.md` recorded it as *"Skill Surface Budget Module — earlier draft superseded by ADR-0011"*, status *"Superseded by 0011"*. The index was right and the file was stale. When the index became a generated artifact (derived from these files), that assertion would have been silently dropped and this superseded draft would have reappeared as a live `Proposed` decision — so it is recorded here, at its source, instead. This is the one status corrected from the old index rather than left for ratification, because leaving it would have *lost* a decision the maintainer had already made.
|
||||
|
||||
We propose extending the existing install profile seam (`gsd-core/bin/lib/install-profiles.cjs`) into a **Skill Surface Budget Module** that owns which subset of GSD's 66 skills is written to the runtime config dirs, and that owns the per-skill `requires:` dependency manifest used to keep that subset closed under cross-skill references. GSD currently ships a binary `--minimal` / full toggle; runtimes that enumerate skills (Claude Code, OpenCode, etc.) cap the `<available_skills>` system-prompt block at `skillListingBudgetFraction` of the context window (default 1% = ~2k tokens at 200k), and GSD alone consumes ~60% of that cap (#3408). Further description shrinkage is unavailable — `scripts/lint-descriptions.cjs` already enforces a hard 100-char ceiling and the mean is 72.5 chars. The remaining lever is surfacing fewer skills, which requires a typed profile model plus a dependency manifest, not more ad-hoc allowlists.
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# PRD — `review.default_reviewers` config key for `/gsd-review` reviewer selection
|
||||
|
||||
- **Status:** Draft
|
||||
- **Status:** Legacy — frozen historical record; not a pattern to follow (see the note below)
|
||||
- **Date:** 2026-05-13
|
||||
- **Issue:** `#3079`
|
||||
- **Related ADR:** `0011-review-default-reviewers.md`
|
||||
- **Related ADR:** [`0011-review-default-reviewers.md`](0011-review-default-reviewers.md)
|
||||
|
||||
> This PRD is filed alongside its ADR under `docs/adr/` for co-location. The repo does not yet have a `docs/prd/` directory; if maintainers prefer one, this file can move there with the `0011-` prefix preserved.
|
||||
> **Note (2026-07-16).** This PRD's original note said "the repo does not yet have a `docs/prd/` directory; if maintainers prefer one, this file can move there." That directory **now exists**, and [`docs/prd/README.md`](../prd/README.md) records this file's disposition: it *"predates this directory and is preserved as immutable historical record. It is not a pattern to follow. New PRDs live here."* It is therefore kept in place, and its status is `Legacy` — the decision is frozen for provenance, not superseded by a specific successor. New PRDs go in `docs/prd/`.
|
||||
|
||||
## TL;DR
|
||||
|
||||
|
||||
@@ -1,10 +1,28 @@
|
||||
# `review.default_reviewers` config key scopes the no-flag `/gsd-review` fan-out
|
||||
|
||||
- **Status:** Proposed
|
||||
- **Status:** Accepted — ratified 2026-07-17 (originally Proposed 2026-05-13); see "Ratification" below
|
||||
- **Date:** 2026-05-13
|
||||
|
||||
We propose adding a `review.default_reviewers` key to `.planning/config.json` that scopes the no-flag default of `/gsd-review` to a user-chosen subset of detected CLI reviewers. Today the no-flag branch of `workflows/review.md` (line 52) invokes **all available** CLIs, which for multi-CLI users plus local model servers (ollama, lm-studio, llama.cpp) means probing up to ~10 backends per review, paying timeout costs on servers that aren't running and burning tokens on reviewers the user doesn't want for routine work (`#3079`). The only workaround today is patching `workflows/review.md` in place; that patch is wiped on every `/gsd-update` and requires `/gsd-update --reapply` to restore, with no machine-readable record of intent. The proposed key sits inside the existing `review.*` namespace (alongside `review.models.<cli>` and `review.*_host`), follows GSD's **absent = enabled** config philosophy, and is implementable as a one-line config read plus an intersection on the detected reviewer set.
|
||||
|
||||
## Ratification (2026-07-17): Proposed → Accepted
|
||||
|
||||
Ratified by explicit maintainer directive; the Status field had sat stale at "Proposed" for roughly 65 days after the decision actually shipped.
|
||||
|
||||
**Evidence the decision shipped:**
|
||||
|
||||
- Landing commit `245d5f66a` ("feat: add review.default_reviewers config for /gsd-review defaults (#3464)", 2026-05-13) added the schema, resolution logic, workflow wiring, docs, and three test files in one change.
|
||||
- `src/review-reviewer-selection.cts` (329 lines) exports `KNOWN_REVIEWER_SLUGS` (line 51) and `normalizeConfiguredDefaultReviewers` (line 105), implementing the ADR's precedence order (explicit flags > `--all` > `review.default_reviewers` > all detected).
|
||||
- `src/config.cts:878` handles `kp === 'review.default_reviewers'` for `config-get`/`config-set`, running values through `normalizeConfiguredDefaultReviewers` and surfacing schema errors.
|
||||
- `gsd-core/workflows/review.md` (no-flag branch, ~lines 55-70) intersects detected reviewers with `review.default_reviewers` exactly as specified, including unknown-slug warnings and undetected-slug info notes.
|
||||
- `docs/CONFIGURATION.md:219-225` documents the key, type, default, and precedence; `docs/COMMANDS.md:1451-1461` documents usage with a `gsd config-set` example.
|
||||
- Four test files are present and current: `tests/review-default-reviewers-config.test.cjs`, `tests/review-default-reviewers-resolution.test.cjs`, `tests/review-default-reviewers-workflow.test.cjs`, `tests/review-reviewer-instances.test.cjs`.
|
||||
- `.changeset/archived/daring-badgers-munch.md` (type: Added, pr: 3464) is archived, confirming release tooling already processed it.
|
||||
|
||||
Governance state: the owning issue (`#3079`, referenced above) and its landing PR (`#3464`) both 404 against the current `open-gsd/gsd-core` tracker — their numbering belongs to a predecessor repo whose issue space predates this repo's 2026-05 range (which topped out near `#540`), consistent with known predecessor-repo numbering rather than a fabricated reference. No in-tracker close event is directly checkable; the shipped-code evidence above substitutes for it.
|
||||
|
||||
**Known gaps at ratification:** two of the ADR's own non-blocking open questions remain genuinely unresolved — Q-2 (`--no-default` flag) and Q-3 (`review.profiles.*` namespace) — exactly as the ADR itself scoped them as future/non-blocking, so this is expected rather than a regression.
|
||||
|
||||
## Decision
|
||||
|
||||
- Add **`review.default_reviewers`** to the `config.json` schema as `string[]`, validated against the existing CLI slug pattern `^[a-zA-Z0-9_-]+$` (the same pattern used for `review.models.<cli>` slugs).
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-05-12
|
||||
- **Decision date:** 2026-05-12
|
||||
- **Supersedes:** [ADR-0010](0010-skill-surface-budget-module.md) (Skill Surface Budget Module — earlier draft, install-time skill listing curation)
|
||||
- **Subsumed by:** [ADR-857](857-capability-system.md) (Capability system) — generalizes this module; this seam remains live at `src/surface.cts:348` (`applySurface`)
|
||||
- **Implementation:** feat/3408-skills-description-dropped-due-to-size, PR <TBD>
|
||||
|
||||
Every installed `gsd-*` skill costs eager system-prompt tokens: runtimes (Claude Code, opencode, and others) enumerate all skill descriptions in `<available_skills>` on every turn. With 66 skills and 33 agents, GSD alone consumes roughly 60% of the default 1%-of-context skill-listing budget, causing descriptions to drop when users stack multiple plugins (#3408).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# CommandRoutingHub as single dispatch seam for CJS command families
|
||||
|
||||
- **Status:** Superseded by ADR-0174 (2026-05-23); originally Accepted (2026-05-20)
|
||||
- **Status:** Superseded by [ADR-0174](0174-retire-gsd-sdk-package-boundary.md) (2026-05-23); originally Accepted (2026-05-20)
|
||||
- **Date:** 2026-05-20
|
||||
|
||||
## Context
|
||||
|
||||
@@ -7,6 +7,20 @@
|
||||
- **Realizes:** [ADR-857](857-capability-system.md) Branch 8 (host-CLI support as `role: runtime` Capabilities)
|
||||
- **Materializes:** [ADR-58](58-runtime-install-policy-module.md) (the typed `InstallPlan` projection)
|
||||
- **Builds on:** [ADR-3660](3660-runtime-artifact-layout-module.md) (artifact layout), [ADR-894](894-capability-declaration-format.md) (the `role: runtime` body, already validated)
|
||||
- **Subsumed by:** [ADR-1239](1239-gsd-embeddable-orchestration-engine.md) (GSD as an Embeddable Orchestration Engine) — read it first; see the amendment below
|
||||
|
||||
## Amendment (2026-07-16): subsumed by ADR-1239 (EoS) — this ADR is the *declarative adapter*, not the whole architecture
|
||||
|
||||
[ADR-1239](1239-gsd-embeddable-orchestration-engine.md) — **GSD as an Embeddable Orchestration Engine** (EoS), Accepted — subsumes this ADR **as the declarative adapter** in a larger frame, and inverts its direction of travel:
|
||||
|
||||
- This ADR answers *"how does GSD project its files onto a host CLI we already know?"* — GSD reaches into the host.
|
||||
- ADR-1239 inverts that: **GSD is the engine; the host loads it through a negotiated Host-Integration Interface**, and a third party writes the thin host-plugin. ADR-1239 calls this "flips *projection* to *embedding*, and **unifies** them."
|
||||
|
||||
**This ADR is not superseded and its status is unchanged.** The runtime descriptor is real, live, and load-bearing: it remains the *declarative* adapter within EoS. But it is a **component of** the current architecture, not the statement of it. A reader who takes this ADR as the top-level answer to "how does GSD meet a host?" will reach the wrong conclusion for any new host.
|
||||
|
||||
**Read [ADR-1239](1239-gsd-embeddable-orchestration-engine.md) first.**
|
||||
|
||||
Recorded because ADR-1239 declared this subsumption while this file recorded nothing, leaving the pointer one-way and EoS undiscoverable from here.
|
||||
|
||||
## Context
|
||||
|
||||
|
||||
@@ -7,6 +7,14 @@
|
||||
- **Blocked by:** [#857](https://github.com/open-gsd/gsd-core/issues/857) being **released** (Proposed → Accepted + capability infrastructure shipped). Not actionable until then.
|
||||
- **Relates to:** [#853](https://github.com/open-gsd/gsd-core/issues/853) (Claude Code backgrounded agents cannot nest subagents), existing BETA skill `gsd-ultraplan-phase`
|
||||
|
||||
## Why this is still `Proposed` (audited 2026-07-17)
|
||||
|
||||
Confirmed shipped, on-tree: the capability is real and registered, not vaporware. `capabilities/claude-orchestration/capability.json` exists with detection + emission (`detectWorkflowBackend` / `emitWorkflowScript`) in `src/claude-orchestration.cts` (compiled to `gsd-core/bin/lib/claude-orchestration.cjs`), federated config (`claude_orchestration.enabled` / `execution_backend` / `min_agent_sdk_version`), and 1,552 lines of tests across `tests/claude-orchestration.test.cjs`, `tests/claude-orchestration-command-router.test.cjs`, and `tests/fix-2285-claude-orchestration-wiring.test.cjs`. The previously-fatal wiring bug, #2285 ("claude-orchestration capability (#1143) registered as active but never wired into execute-phase orchestrator prompt"), is closed COMPLETED (2026-07-15) — one day before this audit — and the owning feature issue #1143 is also closed COMPLETED.
|
||||
|
||||
**The blocker.** The ADR sets its own bar for ratification in its own Amendment (above): "flipping to Accepted follows maintainer sign-off on the E2E behaviour once exercised on Claude Code with the Workflow tool present." No such exercise is recorded anywhere in issues, PRs, or tests. Every test in the three files above operates at the contract or CLI-subprocess layer — asserting the *shape* of an emitted script or the return value of `resolve-wave-dispatch` — none constructs or executes an actual Workflow-tool run (`grep -rn "Workflow(" tests/claude-orchestration*.test.cjs tests/fix-2285-*.test.cjs` returns no hits). Two further gaps sit inside the ADR's own Decision section: (1) Decision §1's claimed net effect — "wave parallelism, the plan-checker, and the verifier are restored" — is narrower than what shipped: `capability.json`'s own description says "the plan-checker and verifier remain inline until separately wired — this capability delivers the parallel-execution backend, not those gates"; (2) Decision §3's fold-in of the `gsd-ultraplan-phase` skill into the capability's `skills[]` has not happened — `capability.json` still shows `"skills": []`, and no follow-up issue for the migration the Amendment promises exists (searched via `gh issue list --search`, no result).
|
||||
|
||||
**Unblock condition.** Ratify once: (a) a real Claude Code session with the Workflow tool present and `claude_orchestration.enabled=true` drives an `execute-phase` wave through the Workflow backend, and the result is recorded (issue comment, PR, or a test that actually builds/executes a `Workflow` script rather than asserting emitted-script shape) — that is the maintainer sign-off the ADR itself asks for; and (b) the Decision section's "plan-checker and verifier restored" language is reconciled with the shipped scope (either corrected to match, or backed by a tracked issue for the deferred wiring `capability.json` already discloses). The `skills[]` migration (item 3) is lower priority since it is openly disclosed as deferred rather than silently dropped, but should carry a tracked issue number before ratification so it doesn't quietly vanish.
|
||||
|
||||
## Context
|
||||
|
||||
Claude Code ships two orchestration primitives GSD does not yet treat as first-class:
|
||||
|
||||
@@ -6,6 +6,16 @@
|
||||
- **Completes:** Capability system (ADR-857) — the write half of the phase-4 "Wire" step
|
||||
- **Builds on:** Capability declaration format (ADR-894), Capability command contribution (ADR-959), Skill Surface Budget Module (ADR-0011)
|
||||
|
||||
## Why this is still `Proposed` (audited 2026-07-17)
|
||||
|
||||
**What shipped.** The module this ADR decided is real and in production use: `setCapabilityState` / `cmdCapabilitySet` are implemented at `src/capability-writer.cts:140` and `:446`, wired into the CLI at `gsd-core/bin/gsd-tools.cjs:1941` and `:2314`, and `gsd-core/workflows/settings.md:448` routes gate writes through `capability set --gate`. `CONTEXT.md:249` carries the glossary entry, and the landing commit (`bf634b95c`, "feat(#1213): Capability State Writer — write-side inverse of the resolver (#1225)") is a confirmed ancestor of `origin/next`. The write-side invariant this ADR set out to build — off means off, enforced at write time — is in force.
|
||||
|
||||
**The blocker.** This ADR's own Decision section (lines 27–29, as written above) declares the writer's return shape as `{ capabilities: CapabilityStateEntry[]; warnings: string[] }`, and decision item 4 says post-write divergence "is returned as warnings, not silently swallowed" — a warnings-only channel, no separate hard-failure signal. The shipped code does not match that: `src/capability-writer.cts:113-117` defines `SetCapabilityStateResult` as `{ capabilities, warnings, errors }`, and `errors[]` is populated both by pre-write validation rejections (e.g. `"unknown capability"`, `"cannot enable ... not in the install profile"`, which abort with zero writes) and by post-write assert failures (e.g. `"failed to disable ... still surfaced after write"`), which the CLI (`cmdCapabilitySet`, lines 483–493) turns into a non-zero exit — a real hard-failure channel this ADR's Decision section does not describe. This is not drift or a bug: it is a later, deliberate redesign. ADR-1411 (Accepted; 2026-06-18 Amendment) states explicitly that `capability-writer`'s "`errors[]` (operation-not-applied) is load-bearing and cannot fold into `warnings[]` (advisory)" and records the mutation-verb shape as `{ capabilities, warnings, errors }` (ADR-1411 lines 81, 86) — superseding the two-field interface this ADR decided. ADR-1213's own text has never been updated to note the amendment or to revise the signature, so as written it misdescribes the interface actually shipped.
|
||||
|
||||
**Dropped claim.** A second refutation argument held that the parent ADR-857 carried an explicit governance caveat reserving any Proposed→Accepted flip in this ADR family for a maintainer, and that flipping ADR-1213 on shipped-code evidence alone would repeat a move ADR-857 itself refused to make unilaterally. That premise no longer holds: `docs/adr/857-capability-system.md` now reads "Status: Accepted — ratified 2026-07-17" with a "## Ratification (2026-07-17)" section, and the caveat text this argument quoted is no longer present anywhere in that file (confirmed by direct search). ADR-857 was ratified in the same 2026-07-17 audit pass that reviewed this ADR, so this argument is dropped rather than carried forward as a live blocker.
|
||||
|
||||
**Unblock condition.** Revise this ADR's Decision section — the return-shape signature and item 4's assert-and-report description — to match what shipped: `{ capabilities: CapabilityStateEntry[]; warnings: string[]; errors: string[] }`, with `errors` describing operation-not-applied hard failures (pre-write validation rejects, post-write assert failures) distinct from advisory `warnings`. Either fold in a one-line "Amended by ADR-1411" pointer or edit the signature directly. Once the Decision section states the interface actually in the tree, this ADR is ready to ratify — the underlying mechanism is already proven in production.
|
||||
|
||||
## Context
|
||||
|
||||
ADR-857 promised: *"one resolved capability state replaces three contradicting toggle systems; 'off' means off."* The **read** side delivers it. The **Capability State Resolver** (`src/capability-state.cts`) collapses three substrates into one resolved state:
|
||||
|
||||
@@ -1,12 +1,33 @@
|
||||
# ADR-1244 — Capability Ecosystem: third-party authoring, versioned manifests, and URL import/upgrade/remove
|
||||
|
||||
- **Status:** Proposed
|
||||
- **Status:** Accepted — ratified 2026-07-17 (originally Proposed 2026-06-14); see "Ratification" below
|
||||
- **Date:** 2026-06-14
|
||||
|
||||
> **Relationship to other ADRs.** This ADR **amends and extends ADR-857 Decisions 7 and 8** — it does not reverse them. ADR-857 D7 deferred third-party code-loading "to its own ADR"; D8 deferred third-party CLI support "to an external loader + trust/validation gate, no rework because runtimes are already descriptors." This *is* that ADR, and it *delivers* that gate. It builds on **ADR-894** (capability declaration format), **ADR-1016** (runtime capability descriptor), and **ADR-58** (InstallPlan seam). Tracked by [#1244](https://github.com/open-gsd/gsd-core/issues/1244). Target release: **1.6.0**.
|
||||
|
||||
---
|
||||
|
||||
## Ratification (2026-07-17): Proposed → Accepted
|
||||
|
||||
Ratified by explicit maintainer directive; the Status field sat at Proposed for 33 days after the owning issue and all six phase sub-issues had already closed as shipped.
|
||||
|
||||
**Evidence the decision shipped:**
|
||||
|
||||
- Issue #1244 and all six phase sub-issues (#1430–#1435, Phase 1 through Phase 6) are CLOSED / `stateReason: COMPLETED`.
|
||||
- **D1** (versioned manifest): `capabilities/*/capability.json` carry `version` + `engines` (confirmed in `ai-integration`, `antigravity`, `claude-orchestration`).
|
||||
- **D2** (runtime overlay): `src/capability-loader.cts:486` exports `loadRegistry({ includeInstalled })`.
|
||||
- **D3** (source resolver): `src/capability-source.cts:1080` exports `resolveCapabilitySource`, backed by the four adapters `resolveLocal` (861), `resolveGit` (892), `resolveNpm` (944), `resolveTarball` (1017).
|
||||
- **D4** (ledger): `src/capability-ledger.cts` (42.4K) exists with `tests/capability-ledger.test.cjs` (111.0K) covering it.
|
||||
- **D5** (trust model): `src/capability-trust.cts` and `src/capability-consent.cts` exist; `strictKnownRegistries` is threaded through `src/capability-lifecycle.cts` at lines 171, 881, 956, and 1079, each backed by `tests/capability-trust.test.cjs` and `tests/capability-consent.test.cjs`.
|
||||
- **D6** (upgrade/compat): `src/capability-lifecycle.cts:1078` implements `upgradeCapability` under the documented atomic stage-then-swap (comment header at line 1056); `compatVersions` downgrade handling is present at lines 126, 920, and 1111.
|
||||
- **D9** (capability matrix): `docs/reference/capability-matrix.md` (9.4K) exists and is generated from the registry.
|
||||
|
||||
Governance: owning issue #1244, `stateReason: COMPLETED`, closed 2026-07-07.
|
||||
|
||||
**Known gaps at ratification:** the D8 cross-reference promised back into ADR-857 ("D7 and D8... extended by ADR-1244") was never written — `docs/adr/857-capability-system.md` has no mention of ADR-1244. And epic #1900 (ADR-1244 edge hardening: MCP arg/cwd confinement, tarball/registry SSRF denylist, duplicated injection patterns) remains OPEN with all three of its filed children (#1901, #1902, #1903) closed `NOT_PLANNED` — the epic's own text scopes this as post-ship hardening on an already fail-closed pipeline, not a reversal of any D1–D9 decision, but the hardening itself is not yet scheduled.
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-857 turned the five-step loop into a **host** with **12 Loop Extension Points** and made every feature a **Capability** — a folder `capabilities/<id>/capability.json` declaring owned skills/agents, lifecycle hooks, a federated config slice, and loop-extension registrations (`step` / `contribution` / `gate`). 32 capabilities ship today (20 `role:feature`, 12 `role:runtime`). The architecture is in place; the **ecosystem is not**.
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
# Cross-AI Plan Convergence via Existing Orchestration Commands
|
||||
|
||||
- **Status:** Proposed
|
||||
- **Status:** Accepted — ratified 2026-07-17 (originally Proposed 2026-05-24); see "Ratification" below
|
||||
- **Date:** 2026-05-24
|
||||
- **Issue:** #15
|
||||
|
||||
Current orchestration commands (`/gsd-autonomous` and `/gsd-progress --next --auto`) route planning through `gsd-plan-phase` and only use local/Claude subagent review paths. The cross-AI convergence path already exists (`/gsd-plan-review-convergence`, `/gsd-review`, `review.default_reviewers`, `review.models.*`) but is not wired into these orchestrators. This creates a gap: users can configure cross-AI reviewers yet still get local-only planning in autonomous/auto-chain execution.
|
||||
|
||||
## Ratification (2026-07-17): Proposed → Accepted
|
||||
|
||||
Ratified by explicit maintainer directive after the shipped implementation was independently re-verified; the Status field had read "Proposed" for roughly 8 weeks after the underlying decision had already landed.
|
||||
|
||||
**Evidence the decision shipped:**
|
||||
|
||||
- Primary, parity, and alias surfaces are present verbatim: `commands/gsd/progress.md:4,28` (`--next --converge`, `--cross-ai` alias, reviewer flags, `--max-cycles N`) and `commands/gsd/autonomous.md:4,40-41` (`--converge`, `--cross-ai` alias).
|
||||
- The `plan_strategy=local|converge` seam is implemented in `gsd-core/workflows/next.md:260-313` (`PLAN_STRATEGY` parsing, `CONVERGENCE_ARGS` build, feature-gate check, Route-3 override) and mirrored in `gsd-core/workflows/autonomous.md:19-90,378-419`.
|
||||
- Fail-fast-on-disabled-gate behavior matches the ADR's Failure Policy exactly: `next.md:279-292` and the equivalent block in `autonomous.md` check `workflow.plan_review_convergence` via `config-get` and abort with the exact `gsd config-set workflow.plan_review_convergence true` instruction — no silent downgrade to `local`.
|
||||
- The config contract is shipped: `gsd-core/bin/shared/config-schema.manifest.json:36` (`workflow.plan_review_convergence`), `:54` (`review.default_reviewers`), `:123,141` (`review.models.*`); documented identically in `docs/CONFIGURATION.md:225,316` and `docs/COMMANDS.md:620-622,850-852`.
|
||||
- Dedicated regression tests exist: `tests/adr-15-progress-converge.test.cjs` (179 lines, describe block titled `'ADR-15: /gsd:progress --next --auto --converge (#1190)'`) and `tests/autonomous-converge.test.cjs` (225 lines, covering the parity surface under `'autonomous --converge flag (#711)'` — this file does not itself reference ADR-15 by name).
|
||||
- Landing commits: `092340d18` (`fix(#711): wire autonomous convergence flag`, 2026-06-10, parity surface) and `0b3a2e5f9` (`feat(#1190): wire --converge primary surface into /gsd:progress --next (ADR-15) (#1237)`, 2026-06-14) — the latter's commit body states "ADR-15 designates /gsd-progress --next --auto --converge as the PRIMARY plan-convergence surface" and confirms the wiring gap the ADR called out is closed.
|
||||
- No later ADR references or supersedes ADR-15: `grep -rl 'ADR-15' docs/adr/*.md` returns only `docs/adr/README.md`'s own index row (line 158), which still lists it as "Proposed" — the stale bookkeeping entry this ratification corrects.
|
||||
|
||||
**Governance state:** Issue #15 CLOSED — stateReason COMPLETED (closed 2026-05-25T03:12:26Z). Follow-up test-coverage issue #1190 ("test(coverage): fill Proposed-ADR test gaps") also CLOSED — stateReason COMPLETED (closed 2026-06-14T19:52:24Z).
|
||||
|
||||
## Decision
|
||||
|
||||
Do not add a new command. Add convergence as an orchestration policy in existing commands, with `/gsd-progress` as the primary operator surface.
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
# ADR-1577: Untrusted-input boundary + opt-in injection blocking
|
||||
|
||||
- **Status:** Proposed
|
||||
- **Status:** Accepted — ratified 2026-07-17 (originally Proposed 2026-06-25); see "Ratification" below
|
||||
- **Issue:** [#1577](https://github.com/open-gsd/gsd-core/issues/1577)
|
||||
- **Part of:** [#1573](https://github.com/open-gsd/gsd-core/issues/1573) (harden the agent layer against documented LLM failure modes)
|
||||
|
||||
## Ratification (2026-07-17): Proposed → Accepted
|
||||
|
||||
Ratified by explicit maintainer directive; the Proposed status had gone unconfirmed for 22 days since the ADR landed on 2026-06-25.
|
||||
|
||||
**Evidence the decision shipped:**
|
||||
|
||||
- Issue #1577 is closed (`state=CLOSED`, `stateReason=COMPLETED`, closed 2026-06-24T21:07:24Z) as split A of the umbrella #1573, scoped exactly to this ADR's decision.
|
||||
- `hooks/gsd-read-injection-scanner.js:118` extends the scanner to `SCANNED_TOOLS = new Set(['Read', 'WebFetch', 'WebSearch'])`, wired via `hooks/hooks.json:34`'s `"Read|WebFetch|WebSearch"` matcher — closing the WebFetch/WebSearch gap named in Context.
|
||||
- `hooks/gsd-read-injection-scanner.js:212` gates blocking on `cfg.security?.injection_blocking === true`, read directly via `fs.readFileSync`/`JSON.parse` (independent of `src/configuration.cts`'s key whitelist, so no drop risk).
|
||||
- `security.injection_blocking` is a registered config key end-to-end: `gsd-core/bin/shared/config-schema.manifest.json:109` lists it and `gsd-core/bin/shared/config-defaults.manifest.json:103` defaults it `false`; `src/configuration.cts:47` builds `VALID_CONFIG_KEYS` from that manifest and `src/config-schema.cts:61` (`isValidConfigKey`) consults it.
|
||||
- `gsd-core/references/untrusted-input-boundary.md` exists and is `@`-included by exactly the 10 ingest agents named in the Decision: `gsd-advisor-researcher`, `gsd-ai-researcher`, `gsd-assumptions-analyzer`, `gsd-doc-classifier`, `gsd-doc-synthesizer`, `gsd-domain-researcher`, `gsd-phase-researcher`, `gsd-project-researcher`, `gsd-research-synthesizer`, `gsd-ui-researcher`.
|
||||
- `docs/explanation/security-model.md:150-165` documents the PostToolUse pre-filter framing and names all 10 agents; `docs/CONFIGURATION.md:910` documents `security.injection_blocking` with a direct link to ADR-1577.
|
||||
- `tests/read-injection-scanner.security.test.cjs` runs `SCAN-WF-01`, `SCAN-WF-02`, `SCAN-WF-03`, and `SCAN-WS-01` against the real hook subprocess for WebFetch/WebSearch payloads and asserts real detections.
|
||||
- `tests/injection-blocking-config.test.cjs` asserts `isValidConfigKey('security.injection_blocking')` is true, `isValidConfigKey('security')` is false, and `CONFIG_DEFAULTS.security.injection_blocking === false`.
|
||||
|
||||
**Governance state:** owning issue #1577 — CLOSED, stateReason COMPLETED, closed 2026-06-24T21:07:24Z.
|
||||
|
||||
## Context
|
||||
|
||||
The research/doc-ingest agents concatenate text returned by WebFetch / WebSearch / Read into their context with no data/instruction separation, and the `gsd-read-injection-scanner` hook only scanned the `Read` tool — leaving WebFetch/WebSearch (the largest untrusted channel) unscanned. Prompt injection via fetched content is a documented LLM failure mode (arXiv [2506.05739](https://arxiv.org/abs/2506.05739), [2507.15219](https://arxiv.org/abs/2507.15219), [2504.20472](https://arxiv.org/abs/2504.20472)).
|
||||
|
||||
@@ -37,6 +37,41 @@ addenda. The boundary:
|
||||
this ADR, leaving 550 to own the contract and this ADR to own the mechanism. Until that is
|
||||
agreed, 550's addenda remain authoritative and this ADR is non-binding.
|
||||
|
||||
## Why this is still `Proposed` (audited 2026-07-17)
|
||||
|
||||
**What shipped.** The audit confirmed the enforcement mechanism this ADR describes is real
|
||||
and in place, not aspirational. All seven Decision points are present in
|
||||
`src/prohibition-enforcement.cts` and `src/probe-core.cts`: `runProhibitionEnforcement`
|
||||
(`src/prohibition-enforcement.cts:654-732`), `dispositionForProhibition`
|
||||
(`src/probe-core.cts:470-516`), the vacuity guards `isNonVacuousNodeTestRed`
|
||||
(`src/prohibition-enforcement.cts:351-355`) and `isNonVacuousNodeTestPass`, and
|
||||
`defaultProveFailFirst`'s node-test branch (`src/prohibition-enforcement.cts:591-627`) —
|
||||
which does implement the #1906 mandatory-`cleanFixture` causation control exactly as
|
||||
Decision 4 / the 2026-07-03 addendum describe, not merely as a documented intent. Test
|
||||
coverage is substantial (`tests/prohibition-enforcement.test.cjs`, 1336 lines), and all six
|
||||
contributing issues (#644, #1259, #1278, #1279, #1346, #1906) are closed as COMPLETED on
|
||||
GitHub.
|
||||
|
||||
**The blocker.** This ADR names its own precondition for becoming binding, in its own words:
|
||||
"on accepting this ADR, replace ADR-550's 2026-06-12 / #1259 / #1279 / #1346 / #1278
|
||||
enforcement addenda with a one-line pointer to this ADR ... Until that is agreed, 550's
|
||||
addenda remain authoritative and this ADR is non-binding." That dedup has not happened.
|
||||
Direct read of `docs/adr/550-spec-phase-probe-contract.md` confirms all four named addenda —
|
||||
"Addendum (2026-06-12; updated 2026-06-15)", "Addendum (2026-06-15, #1279)", "Addendum
|
||||
(2026-06-21, #1346)", and "Addendum (2026-06-15): optional `check` descriptor ... (#1278)" —
|
||||
remain in ADR-550 in full, verbatim; none has been collapsed to a pointer. A later, separate
|
||||
addendum in ADR-550 (2026-06-22, from #1607) does cross-reference ADR-1606 for the
|
||||
*recall/representation-side* "Alternatives considered," but that is additive scaffolding, not
|
||||
the enforcement-addenda dedup this ADR names as its own condition — the four target addenda
|
||||
are untouched by it. No commit, PR, or tracked issue was found executing the dedup.
|
||||
|
||||
**Unblock condition.** Edit `docs/adr/550-spec-phase-probe-contract.md` to collapse the four
|
||||
named addenda (2026-06-12/2026-06-15 update, #1279, #1346, #1278) into the one-line pointer
|
||||
this ADR calls for, then flip both ADR-550's cross-reference and this ADR's Status in the
|
||||
same PR. To check in minutes: grep `docs/adr/550-spec-phase-probe-contract.md` for
|
||||
`## Addendum (2026-06-12`, `#1279`, `#1346`, and `#1278` — if those headings still carry the
|
||||
full addendum text rather than a one-line pointer, the precondition remains unmet.
|
||||
|
||||
## Context
|
||||
|
||||
ADR-550 D4 originally specified the `test` tier as a "hard gate in both interactive and
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ADR 1610: workflow & agent size-budget ratchet (per-file byte baseline + tier hard caps) [Proposed]
|
||||
# ADR 1610: workflow & agent size-budget ratchet (per-file byte baseline + tier hard caps) [Accepted]
|
||||
|
||||
- **Status:** Proposed
|
||||
- **Status:** Accepted — ratified 2026-07-17 (originally Proposed 2026-06-22); see "Ratification" below
|
||||
- **Date:** 2026-06-22
|
||||
|
||||
> **Provenance.** Drafted 2026-06-22 to give an already-shipped architectural governance
|
||||
@@ -13,6 +13,22 @@
|
||||
> `scripts/lib/allowlist-ratchet.cjs` on `next`. The rationale here is lifted from those
|
||||
> tests' own doc comments (the decision was documented in-code but never as an ADR).
|
||||
|
||||
## Ratification (2026-07-17): Proposed → Accepted
|
||||
|
||||
Ratified by explicit maintainer directive after independent re-verification of the evidence below; the ADR had sat in `Proposed` for 25 days after the decision it documents had already shipped.
|
||||
|
||||
**Evidence the decision shipped:**
|
||||
|
||||
- Owning issue #1074 ("replace tier-max workflow size-budget ratchet with a per-file baseline + loose hard caps") is CLOSED, `stateReason: COMPLETED`, closed 2026-06-12T14:00:56Z.
|
||||
- All three landing PRs are MERGED: #1089 (`test(#1074): add additive per-file workflow size baseline guard`, 2026-06-12T03:59:57Z), #1096 (`test(#1074): swap workflow size enforcement to baseline + loose hard caps`, 2026-06-12T13:30:44Z), #1097 (`test(#1074): agent-size-budget per-file baseline + line→byte rebase`, 2026-06-12T13:58:44Z).
|
||||
- `scripts/workflow-size.cjs:32-35` — `lfByteCount()` implements the CRLF→LF-normalized byte count described in Decision point 2 (#683).
|
||||
- `scripts/workflow-size.cjs:64-72,80-82` — `measureMdFiles`/`measureWorkflows` is the single shared measurement path cited in Decision point 5, re-exported for both the guard and `scripts/update-size-baseline.cjs`.
|
||||
- `scripts/lib/allowlist-ratchet.cjs:180` exports `assertFileBaseline` — the per-file baseline assertion named in Decision point 3 and Cross-references.
|
||||
- `tests/workflow-size-budget.test.cjs:95-97,102` defines `XL_CAP = 98304` (96 KiB), `LARGE_CAP = 61440` (60 KiB), `DEFAULT_CAP = 40960` (40 KiB), `NEW_FILE_CAP = 32768` (32 KiB) — the exact numbers quoted in Decision point 3.
|
||||
|
||||
**Governance:** owning issue #1074, `stateReason: COMPLETED`, closed 2026-06-12T14:00:56Z.
|
||||
|
||||
|
||||
## Context
|
||||
|
||||
`gsd-core/workflows/*.md` and `agents/*.md` are loaded **verbatim into agent context** every
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
# Existing Code Onboarding Module owns deterministic repo-state detection and onboarding route selection
|
||||
|
||||
- **Status:** Proposed
|
||||
- **Status:** Accepted — ratified 2026-07-17 (originally Proposed 2026-07-06); see "Ratification" below
|
||||
- **Date:** 2026-07-06
|
||||
- **Issue:** #1990
|
||||
- **Implementation:** PR #1994
|
||||
|
||||
## Ratification (2026-07-17): Proposed → Accepted
|
||||
|
||||
Ratified by explicit maintainer directive after independent re-verification of the evidence below; the Status field sat at Proposed for 11 days after the decision shipped.
|
||||
|
||||
**Evidence the decision shipped:**
|
||||
|
||||
- Issue #1990 ("Add /gsd:onboard for existing-codebase setup") is CLOSED, stateReason COMPLETED, closed 2026-07-07T04:23:56Z; PR #1994 ("feat(#1990): add brownfield onboarding workflow") is MERGED into `next` at 2026-07-07T04:23:55Z with body `Closes #1990`.
|
||||
- `src/onboard-projection.cts` (15,248 bytes) and its compiled `gsd-core/bin/lib/onboard-projection.cjs` are both present on disk, implementing the projection this ADR describes.
|
||||
- `src/init.cts:56` imports the projection as `onboardProjection`, and `src/init-command-router.cts:75` wires the `onboard:` route that consumes it — confirming the Init Command Module integration (the ADR's literal handler name "initOnboard" is not itself a grep-matched symbol; the consumption is the router entry plus the destructured import).
|
||||
- `gsd-core/workflows/onboard.md`, `commands/gsd/onboard.md`, and `skills/gsd-onboard/SKILL.md` all exist on disk, matching the "What stays OUTSIDE this Module" boundary.
|
||||
- `tests/onboard-command.test.cjs` (25,129 bytes) contains named tests covering the load-bearing gate order — `routes planning artifacts without PROJECT.md to partial planning`, `fast mode routes incomplete planning to partial-planning before the complete-map gate (regression #1990: fast map gate misroute)` — vendor exclusion (`ignores generated and vendor directories when detecting existing code`), and package-manifest brownfield detection (`treats package manifests as brownfield even without source files`).
|
||||
- Six commits tagged `#1990` landed the ADR, the projection module, and doc/index updates: `3c7d722ed`, `e8fb05e96`, `d0b8eacd3`, `1171499f3`, `bc751a64e`, `192764f0c`.
|
||||
|
||||
**Governance state:** Owning issue #1990 — CLOSED, stateReason COMPLETED, closed 2026-07-07T04:23:56Z.
|
||||
|
||||
## Context
|
||||
|
||||
GSD already ships strong individual primitives for adopting an existing codebase: `/gsd:map-codebase` (parallel codebase analysis), `/gsd:ingest-docs` (classify and consolidate existing ADR/PRD/SPEC/RFC docs), and `/gsd:new-project` (planning initialization). What it lacked was a single guided entry point that inspects a brownfield repository and tells the user *which primitive runs first*.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# ADR-0175: Harden release-workflow version validation — reject leading zeros and pre-check npm
|
||||
# ADR-218: Harden release-workflow version validation — reject leading zeros and pre-check npm
|
||||
|
||||
- **Status:** Accepted (2026-05-24)
|
||||
- **Date:** 2026-05-24
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
# Plan-vs-codebase drift guard: defaults and symbol-resolver seam
|
||||
|
||||
- **Status:** Proposed
|
||||
- **Status:** Accepted — ratified 2026-07-17 (originally Proposed 2026-05-29); see "Ratification" below
|
||||
- **Date:** 2026-05-29
|
||||
- **Issue:** open-gsd/gsd-core#22
|
||||
|
||||
## Ratification (2026-07-17): Proposed → Accepted
|
||||
|
||||
Ratified by explicit maintainer directive; the Status field sat at Proposed for roughly 14 months against a decision that in fact shipped and closed within a day of the ADR being written (issue closed 2026-05-30, one day after the 2026-05-29 ADR date).
|
||||
|
||||
**Evidence the decision shipped**
|
||||
- `src/plan-drift-guard.cts` implements the ADR's authority ladder and severity table as a pure decision module: `AUTHORITY_RUNGS` (grep=0…scip=4), `getEffectiveAuthority()` (auto-upgrades `grep`→`intel` when `intel.enabled`), and `classifyDriftSeverity()` producing the exact table (VERIFIED→none, MISSING@rung<3→needs-acknowledgement, MISSING@rung>=3→HIGH/hardBlock, AMBIGUOUS→MEDIUM, UNCHECKABLE→INFO); compiled to `gsd-core/bin/lib/plan-drift-guard.cjs` (gitignored generated artifact, `.gitignore:101`).
|
||||
- `gsd-core/bin/shared/config-defaults.manifest.json:94-97` sets `plan_review.source_grounding` default `true` and `plan_review.source_grounding_authority` default `'grep'` — the default-on verification pass from Part 1 point 1.
|
||||
- `capabilities/intel/capability.json` keeps `intel.enabled` default `false` and wires its `plan:pre` step (`intel api-surface`) with `onError: "skip"` — `intel.enabled` stays opt-in and the injection never blocks, per Part 1 points 2-3.
|
||||
- `gsd-core/workflows/plan-review-convergence.md`'s "Source-grounding pass" section (~lines 184-208) implements the four-valued resolver contract (VERIFIED/MISSING/AMBIGUOUS/UNCHECKABLE), excludes plan-declared "Artifacts this phase produces," delegates severity to the `drift-guard` CLI seam rather than inline reviewer reasoning, and appends a "Verification coverage" block to REVIEWS.md.
|
||||
- `gsd-core/workflows/plan-phase.md` §7.9 ("Regenerate API-SURFACE.md (intel gate)") regenerates the surface only when the intel step hook is active and injects it into the planner prompt labeled "HINT ONLY... MAY BE INCOMPLETE... Never treat the surface as exhaustive" — matching Part 1 point 2 verbatim.
|
||||
- `gsd-core/workflows/settings.md` and `gsd-core/workflows/new-project.md` surface `plan_review.source_grounding` as a "Drift Guard" toggle/setup question; `docs/CONFIGURATION.md` documents both config keys, explicitly marking authority rungs 2-4 (treesitter/lsp/scip) as reserved with no effect in the current release.
|
||||
|
||||
Governance: owning issue open-gsd/gsd-core#22 — CLOSED, stateReason COMPLETED, closed 2026-05-30T21:08:13Z, labeled `enhancement` + `approved-feature`.
|
||||
|
||||
## Context
|
||||
|
||||
The planner regularly cites symbols that do not exist in the codebase — invented decorators, wrong dataclass fields, renamed CLI flags, mismatched signatures. The phenomenon is measured, not anecdotal: the *Practical Code Generation* hallucination taxonomy (arXiv:2409.20550) reports Dependency Conflicts (11.26%) and API Knowledge Conflicts (20.41%), which together describe exactly this failure. Today the drift is caught only at execution time by the executor (ImportError/AttributeError), at roughly 10–15 min/fix, a dozen per multi-wave phase.
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
- **Supersedes:** nothing; amends the ADR-1239 Phase-B safety-net harness
|
||||
- **Relationship to prior work:** Evolves `tests/golden-install-parity.test.cjs` (ADR-1239 Phase B). Related: #2086 (claude-local realpath normalization), #2095/#2100/#2117 (exclusion-set drift incidents), #1691 (scoped-CI drift guard).
|
||||
|
||||
## Why this is still `Proposed` (audited 2026-07-17)
|
||||
|
||||
Audited 2026-07-17 against the live tree and GitHub. Phase 1 shipped cleanly and is genuinely done: `buildParityManifest`, `buildInstallTree`, and the four exclusion constants (`VOLATILE_FILES`, `HOOK_CONFIG_FILES`, `HOOK_CONFIG_RELATIVE_PATHS`, `EXCLUDED_PREFIXES`) live as the single source of truth in `tests/helpers/install-shared.cjs` (lines 104-234); `scripts/gen-golden-install-parity-zcode.cjs` now imports them instead of re-declaring them; the anti-divergence guard (`tests/golden-parity-single-source.test.cjs`) enforces no second copy; `scripts/ci-test-scope.cjs` (lines 121, 145, 175-176) selects the golden suite on the wider path set the Amendment describes; `npm run gen:golden` (`package.json:93`) exists; and all five issues (#2264-#2268) are closed as completed.
|
||||
|
||||
**The blocker:** Acceptance criteria 3 and 4 name mechanisms that were never built. AC3 requires "A simulated converter bug ... caught by the converted-artifact golden" and AC4 requires "A simulated verbatim-copy corruption ... caught by the copy-parity property test" — neither `tests/fixtures/converted-artifacts/` nor any `*copy-parity*` test file exists anywhere in the tree (confirmed absent by direct filesystem check). The ADR's own same-day Amendment explains why (§3/§4 were found "unsound" in the Phase 2 spike) and states the old monolithic content-hash golden "is retained as-is" — but the Acceptance Criteria section itself was never edited to drop or reword AC1/AC3/AC4 to match the revised design. That leaves AC1 — the document's headline must-have, "editing the content of a verbatim/path-injected copied shipped file (e.g. a `workflows/*.md`) requires zero manual fixture regeneration" — literally unmet: `tests/golden-install-parity.test.cjs` still content-hashes every emitted file via the retained `buildParityManifest` (confirmed at `install-shared.cjs:218`, `crypto.createHash('sha256')`), and `EXCLUDED_PREFIXES` (`install-shared.cjs:154`) excludes only `gsd-core/bin/lib/` — `workflows/*.md` is still fully inside the hashed manifest, so editing one still requires `npm run gen:golden`. The functional invariants AC3/AC4 care about are still covered, but only by the legacy hash golden this ADR set out to partly replace, not by the mechanisms the criteria name.
|
||||
|
||||
**Unblock condition:** Edit the Acceptance Criteria section (items 1, 3, 4) to match the shipped, amended design — replace "zero manual fixture regeneration" and the named converted-artifact/copy-parity mechanisms with the criteria the Amendment actually delivers (file-set snapshot catches structural drift; the retained content-hash golden catches converter and copy corruption; `npm run gen:golden` is the one-command fix for legitimate content changes) — then flip Status. No further code work is required; this is a documentation edit against already-shipped, already-closed work.
|
||||
|
||||
## Context
|
||||
|
||||
`tests/golden-install-parity.test.cjs` snapshots the installer output for 18 runtime layouts. For each runtime it runs a real `runMinimalInstall`, walks every emitted file, normalizes volatile bits (temp root → `<HOME>`, package version → `<VERSION>`, macOS realpath `/private` → `<HOME>`), SHA-256s each file (16-char slice), and compares the entire path→hash map against a committed fixture under `tests/fixtures/golden-install-parity/*.json` (18 files, ~520 KB, ~7,500 hash lines).
|
||||
|
||||
@@ -8,6 +8,72 @@
|
||||
> open a `chore:` issue, replace `XXXX` with the assigned issue number, and
|
||||
> rename the file accordingly.
|
||||
|
||||
## Why this is still `Proposed` (audited 2026-07-17)
|
||||
|
||||
The architectural shift is real and operating: the live default branch is
|
||||
`next` (`gh api repos/open-gsd/gsd-core --jq .default_branch`), `.github/workflows/auto-backmerge.yml`
|
||||
runs unconditionally (`if: true`, not the Phase-1 `if: false` stub) and has
|
||||
produced real, merged `main → next` back-merge PRs across multiple releases
|
||||
(#671, #1337, #1673, and others), `release.yml` cherry-picks from
|
||||
`origin/next` with an `origin/main` fallback per the Phase-3 patch,
|
||||
`pr-target-validator.yml` enforces (`WARN_ONLY: 'false'`), and
|
||||
`auto-branch.yml` branches from `next` with a `main` fallback.
|
||||
|
||||
**The blocker.** The Decision section requires differentiated branch
|
||||
protection: `main` — "strict: 2 reviewer approvals, all CI green, ...
|
||||
restrict push to maintainers via PR only"; `next` — "loose: ... 'require
|
||||
branches up to date' OFF." Live settings invert this. `main`'s classic
|
||||
branch protection (verified via `gh api repos/open-gsd/gsd-core/branches/main/protection`
|
||||
and its `required_pull_request_reviews` / `required_status_checks`
|
||||
sub-resources) shows `required_approving_review_count: 1` (spec: 2),
|
||||
`required_status_checks` returns 404 "not enabled" (spec: all CI green
|
||||
required — there is no CI gate on `main` at all), and
|
||||
`allow_force_pushes.enabled: true` (spec: restrict push to maintainers via
|
||||
PR only). `next`'s protection, by contrast, has `required_status_checks.strict: true`
|
||||
across 7 contexts and `allow_force_pushes.enabled: false` — stricter than
|
||||
`main`, not looser. The two GitHub Rulesets that might have compensated
|
||||
(`main-protection` id 16752567, `release-branches` id 16752568) are both
|
||||
`enforcement: "evaluate"` (dry-run, non-blocking) and were never promoted
|
||||
to active; `main-protection`'s condition further targets `~DEFAULT_BRANCH`,
|
||||
a dynamic alias that now resolves to `next` (the current default branch),
|
||||
so even if activated it would apply to the wrong branch. Migration Phase 2
|
||||
step 3 ("Apply branch protection: `bash scripts/setup-branch-protection.sh`")
|
||||
was evidently run for `next` but never durably applied to `main`.
|
||||
|
||||
Issue #230's own closure (`state_reason: completed`) certifies only Phase 1
|
||||
(additive infrastructure) — its body scopes itself explicitly to Phase 1
|
||||
and defers branch-protection application, the default-branch flip, and
|
||||
workflow-enforcement flags to a "Phase 2 follow-up (separate PR)"; that
|
||||
follow-up evidently landed for `next` but not for `main`'s protection.
|
||||
Separately, `next`'s "require branches up to date OFF (this is the whole
|
||||
point)" was reversed five days later by ADR-415 (Accepted, 2026-05-28),
|
||||
which set `required_status_checks.strict = true` on `next` after a real
|
||||
stale-base regression (#406/#411/#412) — so the specific rebase-treadmill
|
||||
relief this ADR promises for `next` no longer holds exactly as written,
|
||||
though the broader architectural decision (integration branch, isolated
|
||||
`main`, automated back-merge) is unaffected. Migration Phase 4 cleanup
|
||||
(drop `develop` from `branch-naming.yml`'s `alwaysValid`; drop the `|| main`
|
||||
fallbacks in `release.yml`/`auto-branch.yml`) is also still open, gated on
|
||||
"2-3 successful releases" per the ADR's own text — cosmetic, not blocking.
|
||||
|
||||
**Unblock condition.** Ratify once `main`'s live branch protection matches
|
||||
this ADR's Decision section — `required_approving_review_count: 2`,
|
||||
`required_status_checks` enabled and required, `allow_force_pushes: false`
|
||||
— applied via `scripts/setup-branch-protection.sh` (or an equivalent `gh api`
|
||||
call), and the two `evaluate`-mode Rulesets are either activated with
|
||||
corrected `ref_name` conditions or removed as redundant with classic
|
||||
protection. Verify with:
|
||||
|
||||
```
|
||||
gh api repos/open-gsd/gsd-core/branches/main/protection/required_pull_request_reviews --jq .required_approving_review_count # expect 2
|
||||
gh api repos/open-gsd/gsd-core/branches/main/protection/required_status_checks # expect 200, not 404
|
||||
gh api repos/open-gsd/gsd-core/branches/main/protection --jq .allow_force_pushes.enabled # expect false
|
||||
```
|
||||
|
||||
Until then, either bring `main`'s protection into line with the Decision
|
||||
section, or amend this ADR (as ADR-415 did for one `next` parameter) to
|
||||
record the protection posture actually in force.
|
||||
|
||||
## Context
|
||||
|
||||
Today every contributor branch — `feat/`, `fix/`, `chore/`, `docs/`,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# CJS↔SDK hard seam — one source of truth per Shared Module
|
||||
|
||||
- **Status:** Superseded by ADR-0174 (2026-05-23); originally Proposed (2026-05-14)
|
||||
- **Status:** Superseded by [ADR-0174](0174-retire-gsd-sdk-package-boundary.md) (2026-05-23); originally Proposed (2026-05-14)
|
||||
- **Date:** 2026-05-14
|
||||
- **Tracking issue:** [#3524](https://github.com/open-gsd/get-shit-done-redux/issues/3524)
|
||||
- **Related PRD:** [`docs/prd/3524-cjs-sdk-hard-seam.md`](../prd/3524-cjs-sdk-hard-seam.md)
|
||||
|
||||
@@ -4,6 +4,19 @@
|
||||
- **Date:** 2026-05-17
|
||||
- **Issue:** #3660
|
||||
- **Implementation:** #3663 (Phase 1), feat/3663-runtime-artifact-layout-module-phase-1-m
|
||||
- **Subsumed by:** [ADR-1239](1239-gsd-embeddable-orchestration-engine.md) (GSD as an Embeddable Orchestration Engine) — read it first; see the amendment below
|
||||
|
||||
## Amendment (2026-07-16): subsumed by ADR-1239 (EoS)
|
||||
|
||||
[ADR-1239](1239-gsd-embeddable-orchestration-engine.md) — **GSD as an Embeddable Orchestration Engine** (EoS), Accepted — subsumes this ADR as an adapter: per-runtime artifact placement becomes one negotiated surface of the Host-Integration Interface rather than the outermost seam at which GSD meets a host.
|
||||
|
||||
**This ADR is not superseded and its status is unchanged.** The artifact-layout seam is live and load-bearing; it is now a *component* of the EoS frame.
|
||||
|
||||
**Read [ADR-1239](1239-gsd-embeddable-orchestration-engine.md) first.**
|
||||
|
||||
Recorded because ADR-1239 declared this subsumption while this file recorded nothing.
|
||||
|
||||
## Context
|
||||
|
||||
The **Runtime Surface Module** (`gsd-core/bin/lib/surface.cjs`, introduced by ADR-0011 Phase 2) re-materializes a resolved Skill Surface profile to disk via `applySurface`. It currently hardcodes two artifact kinds (`commands`, `agents`) and re-derives their source directories via `_findInstallSource` / `_findAgentsSource` walk-up heuristics. The install and uninstall pipelines in `bin/install.js` each encode the same per-runtime artifact layout independently across ~14 install sites and ~6 uninstall sites. Bug #3659 surfaced the resulting drift: `applySurface` omits the `skills` kind for runtimes whose canonical layout is `skills/gsd-<stem>/SKILL.md`, so `gsd-surface profile <name>` leaves ~67 skill directories on disk under the install-time profile's footprint when the resolved profile should have pruned them — roughly 2.7k tokens per session on a measured workstation.
|
||||
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
- **Date:** 2026-05-28
|
||||
- **Tracking issue:** [#443](https://github.com/open-gsd/get-shit-done-redux/issues/443)
|
||||
|
||||
## Why this is still `Proposed` (audited 2026-07-17)
|
||||
|
||||
The audit confirmed the cross-provider resolver/renderer/CLI machinery genuinely shipped: `resolveEffortInternal`, `resolveEffortForTier`, `renderEffortForRuntime`, `RUNTIMES_WITH_FAST_MODE`, and `cmdResolveExecution` (`src/model-resolver.cts:534,654`; `src/commands.cts`) implement the cascade and clamping exactly as Decision items 1–3, 5, and 6 describe, and static install-time propagation is real and end-to-end tested — `tests/install-runtime-artifacts.test.cjs`'s `describe('#443 Claude install: effort: injected into frontmatter')` runs the actual `install()` function and reads the resulting agent `.md` files off disk, confirming `gsd-planner` gets `effort: xhigh`, `gsd-codebase-mapper` gets `effort: low`, and `gsd-executor` gets `effort: high`. That test predates the QA audit below (landed 2026-05-29 in the original `#443` PR, commit `5ca646f01`), so the "resolver-only, nothing reaches the runtime" framing of the original flavor-text problem this ADR set out to fix is fixed for the static path.
|
||||
|
||||
**The blocker.** Decision item 1's cascade names an "(1) orchestrator invocation override" as the *highest*-precedence layer, and Decision item 6 adds a dynamic escalation path ("effort steps up the ladder on a failed attempt"). Both exist only as CLI-callable resolver code — `resolveEffortInternal`'s invocation-override step (`src/model-resolver.cts:535`) and `resolveEffortForTier`'s attempt-based escalation (`src/model-resolver.cts:654`) — exercised solely by unit/CLI tests. Nothing in the shipped orchestration actually calls them: a search across every file in `gsd-core/workflows/*.md` and `agents/*.md` for `resolve-execution` or `CLAUDE_CODE_EFFORT_LEVEL` returns zero hits; the only workflow-level mentions of "effort" are documentation of the config keys in `settings-advanced.md`'s confirmation table. The only propagation channel actually wired into a real GSD flow is the static one (config → `install()` → frontmatter, baked once at install time) — the ADR's own decided design promises more than that, and the more-than-static-baking part has no consumer. Separately, the repo's own dated QA test-architecture audit (`docs/issueevidence/1192-adr-test-audit-2026-06-13.md`, produced under issue #1192, closed COMPLETED) rated ADR-443 "partial ... **end-to-end effort propagation untested**" and named it in its action plan ("Strengthen ... ADR-443 end-to-end effort propagation," line 220); that action item was never converted into a tracked follow-up issue, and no commit since 2026-06-13 addresses it. That audit's blanket "untested" framing overstates the gap — the static path is tested — but the underlying signal (a decided mechanism with no live caller) is real and independently confirmed here.
|
||||
|
||||
**Unblock condition.** Either (a) wire the orchestrator-invocation-override and attempt-based-escalation paths into an actual GSD workflow or agent dispatch (so `resolveEffortForTier`'s escalation and `resolveEffortInternal`'s invocation-override step have a real caller outside `src/commands.cts`'s CLI surface and tests), and add a test exercising that live path the way `tests/install-runtime-artifacts.test.cjs` exercises the static one; or (b) if the ADR's intended scope is in fact limited to static install-time propagation, amend Decision items 1 and 6 to say so explicitly and close out audit issue #1192's action-plan item 18 with a note pointing at the shipped install-wiring tests. Either is a maintainer call this file records but does not make.
|
||||
|
||||
## Context
|
||||
|
||||
### Effort control and fast mode in Claude Opus 4.8
|
||||
|
||||
@@ -3,6 +3,24 @@
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-06-07
|
||||
- **Issue:** #58
|
||||
- **Subsumed by:** [ADR-1239](1239-gsd-embeddable-orchestration-engine.md) (GSD as an Embeddable Orchestration Engine) — read it first; see the amendment below
|
||||
- **Subsumed by:** [ADR-857](857-capability-system.md) (Capability system) — generalizes this module's install-plan projection; this seam remains live at `src/runtime-artifact-install-plan.cts:82`
|
||||
|
||||
## Amendment (2026-07-16): subsumed by ADR-1239 (EoS)
|
||||
|
||||
[ADR-1239](1239-gsd-embeddable-orchestration-engine.md) — **GSD as an Embeddable Orchestration Engine** (EoS), Accepted — subsumes this ADR as an adapter: the typed `InstallPlan` projection this module owns becomes one of the surfaces the host negotiates for, rather than the outermost seam at which GSD meets a host.
|
||||
|
||||
**This ADR is not superseded and its status is unchanged.** The `InstallPlan` seam is live and load-bearing. It is now a *component* of the EoS frame, not the top-level answer to "how does GSD meet a host?".
|
||||
|
||||
**Read [ADR-1239](1239-gsd-embeddable-orchestration-engine.md) first.**
|
||||
|
||||
Recorded because ADR-1239 declared this subsumption while this file recorded nothing.
|
||||
|
||||
## Amendment (2026-07-17): also subsumed by ADR-857 (Capability system)
|
||||
|
||||
[ADR-857](857-capability-system.md) was ratified `Proposed → Accepted` on 2026-07-17 and generalizes this module's install-plan projection into the unified Capability model (install composes *active Features × the chosen Runtime* at this ADR's `InstallPlan` seam).
|
||||
|
||||
**This ADR remains Accepted and live.** ADR-857's header originally read "Supersedes (generalizes)"; on ratification that was corrected to **Subsumes**, precisely because this seam is not dead — `InstallPlan` is live at `src/runtime-artifact-install-plan.cts:82`. This module is now a component of two broader frames: ADR-857 (what composes an install) and ADR-1239/EoS (how a host loads the engine at all).
|
||||
|
||||
## Context
|
||||
|
||||
|
||||
@@ -3,6 +3,38 @@
|
||||
- **Status:** Proposed
|
||||
- **Date:** 2026-06-03
|
||||
|
||||
## Why this is still `Proposed` (audited 2026-07-17)
|
||||
|
||||
Confirmed shipped: immutable per-release tags (`finalize` mints `v<version>` exactly once at
|
||||
line 629; `rc` auto-increments `v<version>-rc.N` at line 353 — no force-push or re-tag anywhere
|
||||
in the file), the `@next`/`@latest` dist-tag split (`npm publish --provenance --access public
|
||||
--tag next` in the `rc` job at line 437 vs. the default/`latest` publish in `finalize` at line
|
||||
636), and the Amendment (2026-06-12, #1104) "`next` rests at last published" behavior, wired
|
||||
through `scripts/sync-next-version.cjs` in both the `rc` job (`release.yml:479`) and the
|
||||
`main`→`next` back-merge (`auto-backmerge.yml:176-178`).
|
||||
|
||||
**The blocker.** Decision §1 — the mechanism this ADR is named for — is not implemented: "recreate
|
||||
(or hard-reset) an **ephemeral** `release/<version>` branch from `origin/next` HEAD at the *start*
|
||||
of each `rc`/`finalize` run." In the live `.github/workflows/release.yml`, the `create` job still
|
||||
creates `release/<version>` once and hard-errors if it already exists ("Branch $BRANCH already
|
||||
exists. Delete it first or use rc/finalize.", lines 126–133); the `rc` job's checkout (line 330)
|
||||
and the `finalize` job's checkout (line 522) both simply check out that same pre-existing ref —
|
||||
neither job fetches, resets, or recreates it from `origin/next`. This is exactly the "persistent
|
||||
branch you never backport into" antipattern the ADR's own Context section set out to kill, and
|
||||
precisely the alternative its own Alternatives section rejected ("Keep the persistent branch but
|
||||
cherry-pick RC fixes into it ... Rejected as primary"). `docs/adr/README.md:98` already names this
|
||||
ADR in the corpus audit as one whose "namesake mechanism is performed by hand." Issue #660 is
|
||||
closed `COMPLETED`, but its scope was landing the ADR/design decision, not the `release.yml`
|
||||
re-cut step — no commit since has added it; today, cutting an rc "on the head of `next`" still
|
||||
requires a manual `git push --force origin <next-head>:refs/heads/release/<version>` before
|
||||
dispatching the workflow.
|
||||
|
||||
**Unblock condition.** Add a step to both the `rc` and `finalize` jobs in
|
||||
`.github/workflows/release.yml` that hard-resets (or recreates) `release/<version>` from
|
||||
`origin/next` HEAD before the version bump, so the re-cut happens automatically on every
|
||||
dispatch instead of via a manual force-push. Once that step exists in the file and one real
|
||||
`rc`/`finalize` run has exercised it end to end, this ADR is ready for another ratification pass.
|
||||
|
||||
## Context
|
||||
|
||||
The release pipeline (`.github/workflows/release.yml`) is a three-mode `workflow_dispatch`
|
||||
|
||||
@@ -1,12 +1,42 @@
|
||||
# ADR-857: Capability system — five-step loop as core, features as plug-ins behind Loop Extension Points [Proposed]
|
||||
|
||||
- **Status:** Proposed
|
||||
- **Status:** Accepted — ratified 2026-07-17 (originally Proposed 2026-06-08); see "Ratification" below
|
||||
- **Date:** 2026-06-08
|
||||
- **Issue:** #857
|
||||
- **Supersedes (generalizes):** Skill Surface Budget Module (ADR-0011), Runtime Install Policy Module (ADR-0058)
|
||||
- **Subsumes (generalizes):** Skill Surface Budget Module ([ADR-0011](0011-skill-surface-budget-module.md)), Runtime Install Policy Module ([ADR-58](58-runtime-install-policy-module.md)) — both remain **Accepted and live**; this ADR generalizes them, it does not replace them. See "Relation to ADR-0011 and ADR-58" below.
|
||||
- **Builds on:** CommandRoutingHub (ADR-0012), Runtime Artifact Layout Module (ADR-3660), generated-cjs single source (ADR-457)
|
||||
- **Amended:** 2026-06-12 — phase-6 boundary settled before the Migrate phase freezes it: the **verifier↔predicate contract** is classified as core verification substrate (not an off-by-default Feature Capability). See *Verification substrate vs. plug-in tier (the predicate boundary)* below. Prompted by @davesienkowski's boundary analysis on #857; coordinates with ADR-550 (spec-phase probe contract).
|
||||
|
||||
## Ratification (2026-07-17): Proposed → Accepted
|
||||
|
||||
Ratified by maintainer directive. This ADR read `Proposed` for over a month while the capability system it decides **was the shipped architecture of 1.6.0/1.7.0** — a label that invited contributors and agents to treat the live plug-in architecture as an unbuilt idea.
|
||||
|
||||
**Evidence the decision shipped** (each item verified against the tree, then independently re-verified by two reviewers instructed to refute this ratification; neither could):
|
||||
|
||||
- **The lifecycle exists.** `src/capability-lifecycle.cts:880-1053` implements `installCapability`, plus `upgradeCapability` / `removeCapability` / `bindProjectConsent`. `src/capability-state.cts` resolves capability state; `gsd-core/bin/lib/capability-validator.cjs` validates descriptors; `scripts/gen-capability-registry.cjs` generates the registry into `gsd-core/bin/lib/capability-registry.cjs`.
|
||||
- **The plug-in split is real, not notional.** `capabilities/` holds 30+ descriptors spanning `role: feature` (research, ui, security, code-review, graphify, intel, audit, profile-pipeline, tdd, schema-gate, drift, gap-analysis, nyquist, pattern-mapper, ai-integration, mempalace, assumption-delta, external-job) and `role: runtime` (claude, codex, antigravity, cline, cursor, windsurf, kilo, qwen, hermes, pi, trae, augment, copilot, codebuddy, opencode, vscode).
|
||||
- **The god-module is gone.** `src/core.cts` — the 2271-line module named in this ADR's Context — no longer exists; it decomposed into `io.cts`, `config-loader.cts`, `phase-locator.cts`, `model-resolver.cts`, `roadmap-parser.cts`. Epic #1267 ("retire the core.cjs re-export spine") is closed. Corroborated independently at [`612-bracket-phase-id-convention.md`](612-bracket-phase-id-convention.md):165 ("core.cts no longer exists").
|
||||
- **The Loop Extension Points are wired.** All 12 named points (`discuss:pre/post`, `plan:pre/post`, `execute:pre`, `execute:wave:pre/post`, `execute:post`, `verify:pre/post`, `ship:pre/post`) have live render-hook call sites in the host loop.
|
||||
- **The workflow bodies shrank, as this ADR's Consequences promised.** `plan-phase.md` is 93,959 bytes against a frozen pre-phase-6 ceiling of 94,519; `execute-phase.md` is 93,363 against 93,600.
|
||||
- **Tests exercise it.** 16 dedicated `tests/capability-*.test.cjs` files (largest: `capability-registry.test.cjs` at 297K, `capability-lifecycle.test.cjs` at 184K), plus `tests/phase6-capstone-conformance.test.cjs`, whose three assertions are marked "RED BY DESIGN until phase 6 is actually complete" — added after #1139 was caught closing green on a false completion — and all three pass.
|
||||
- **Governance closed.** Epic [#857](https://github.com/open-gsd/gsd-core/issues/857) is CLOSED with `stateReason=COMPLETED` (2026-06-14). All six rollout-phase sub-issue clusters are CLOSED/COMPLETED: #870/#885 (phases 1–2), #894/#896/#903/#910/#918 (phase 3), #942/#945/#959/#961/#1136/#1138/#1213 (phase 4), #1016/#1035/#1056/#1077 (phase 5), #1120/#1135/#1137/#1139/#1820 (phase 6).
|
||||
- **The corpus already treats it as live.** 20+ later ADRs (894, 959, 1016, 1056, 1077, 1143, 1213, 1239, 1244, 1372, 1593, 1606, 1671, 1769, 1817, 1820, 550, 58, 612) build on this ADR's capability model as current architecture; none claims to replace it. [`1244-capability-ecosystem.md`](1244-capability-ecosystem.md):12, authored independently, states: "32 capabilities ship today (20 `role:feature`, 12 `role:runtime`). The architecture is in place."
|
||||
|
||||
### Relation to ADR-0011 and ADR-58 — generalized, not replaced
|
||||
|
||||
This ADR's header field originally read "**Supersedes** (generalizes)". On ratification that wording was corrected to **Subsumes**, because taking "supersedes" literally would have stamped two live decisions as dead:
|
||||
|
||||
- [ADR-0011](0011-skill-surface-budget-module.md)'s Skill Surface Budget Module is live — `applySurface` at `src/surface.cts:348`.
|
||||
- [ADR-58](58-runtime-install-policy-module.md)'s typed `InstallPlan` seam is live — `src/runtime-artifact-install-plan.cts:82`.
|
||||
|
||||
Both keep `Accepted` status and now carry a `Subsumed by` pointer here. The parenthetical "(generalizes)" was always the accurate word; only the field name was wrong.
|
||||
|
||||
### What this ratification does not settle
|
||||
|
||||
[ADR-959](959-capability-command-contribution.md) (command contribution) stays `Proposed`: issue [#2346](https://github.com/open-gsd/gsd-core/issues/2346) — "ADR: Command Dispatch Completion" — is OPEN and maintainer-approved, and explicitly plans 959's graduation as its own capstone ADR. Ratifying it here would preempt that.
|
||||
|
||||
See also [ADR-1239](1239-gsd-embeddable-orchestration-engine.md) (EoS, Accepted), which realizes and **inverts** this ADR's Decision 8 — flipping *projection* to *embedding*. For how GSD meets a host, EoS is the current frame.
|
||||
|
||||
## Context
|
||||
|
||||
GSD has no real line between **the loop** and **a feature**. The five-step loop — Discuss → Plan → Execute → Verify → Ship — is the product, but its workflow bodies have absorbed every optional feature as inline `if config.X` branches:
|
||||
|
||||
@@ -1,10 +1,35 @@
|
||||
# ADR-894: Capability declaration format + registry generation [Proposed]
|
||||
# ADR-894: Capability declaration format + registry generation [Accepted]
|
||||
|
||||
- **Status:** Proposed
|
||||
- **Status:** Accepted — ratified 2026-07-17 (originally Proposed 2026-06-08); see "Ratification" below
|
||||
- **Date:** 2026-06-08 (amended same day across two design grillings — see "Grilling amendments")
|
||||
- **Issue:** #894
|
||||
- **Parent:** ADR-857 (Capability system) — resolves its Open question #1
|
||||
- **Parent:** [ADR-857](857-capability-system.md) (Capability system) — resolves its Open question #1
|
||||
- **Phase:** ADR-857 rollout phase 3a (design-only)
|
||||
- **Subsumed by:** [ADR-1239](1239-gsd-embeddable-orchestration-engine.md) (GSD as an Embeddable Orchestration Engine) — read it first; see the amendment below
|
||||
|
||||
## Amendment (2026-07-16): subsumed by ADR-1239 (EoS); status is stale
|
||||
|
||||
[ADR-1239](1239-gsd-embeddable-orchestration-engine.md) — **GSD as an Embeddable Orchestration Engine** (EoS), Accepted — subsumes this ADR as an adapter. The capability declaration format remains the vocabulary a descriptor is written in; EoS is the frame that decides how a host loads the engine at all.
|
||||
|
||||
**Read [ADR-1239](1239-gsd-embeddable-orchestration-engine.md) first.**
|
||||
|
||||
Recorded because ADR-1239 declared this subsumption while this file recorded nothing.
|
||||
|
||||
## Ratification (2026-07-17): Proposed → Accepted
|
||||
|
||||
Ratified by explicit maintainer directive after independent re-verification of the evidence below; the `Proposed` label had been stale for roughly 39 days (2026-06-08 → 2026-07-17) after the format it specifies had already shipped.
|
||||
|
||||
**Evidence the decision shipped:**
|
||||
|
||||
- Owning issue [#894](https://github.com/open-gsd/gsd-core/issues/894) and parent epic [#857](https://github.com/open-gsd/gsd-core/issues/857) are both CLOSED / COMPLETED.
|
||||
- `scripts/gen-capability-registry.cjs` (886 lines) implements the §4 generator: reads every `capabilities/<id>/capability.json`, validates each via `capability-validator.cjs`, and enforces the one-owner / acyclic / tier-monotone / config-exclusivity / unique-producer invariants.
|
||||
- `gsd-core/bin/lib/capability-validator.cjs` (2,346 lines) validates the §2 schema, including the gate `check` discriminator — exactly one of `query` / `predicate` / `agentVerdict` (around lines 1566–1573).
|
||||
- 37 real `capabilities/<id>/capability.json` files exist on disk; `capabilities/ui/capability.json` matches the ADR's worked UI example (`tier`/`requires`/`skills`/`agents`/`steps`/`gates`) near-verbatim, plus additive fields (`version`, `engines`, `runtimeCompat`) not in the original text.
|
||||
- `capabilities/codex/capability.json` has concrete `role: "runtime"` enums filled in — `commandStyle: "shell-var"`, `hooksSurface: "codex-hooks-json"`, `sandboxTier: "codex-agent-sandbox"` — resolving the ADR's own "deferred to phase 5" open question.
|
||||
- `scripts/gen-loop-host-contract.cjs` (18,992 bytes) parses the `<!-- gsd:loop-host … -->` comment markers out of the five step workflows (confirmed at `gsd-core/workflows/plan-phase.md:1`) into the generated host contract.
|
||||
- `gsd-core/bin/lib/capability-registry.cjs` (235,826 bytes, generated) contains `byLoopPoint` (line 3033), `configKeys` (line 3578), and `requiresClosure()` (line 5779) — the role-partitioned §5 shape.
|
||||
|
||||
**Governance state:** owning issue #894 CLOSED/COMPLETED (closed 2026-06-08); parent epic #857 CLOSED/COMPLETED (closed 2026-06-14).
|
||||
|
||||
## Context
|
||||
|
||||
|
||||
@@ -4,6 +4,15 @@ This directory contains Architecture Decision Records (ADRs) for GSD.
|
||||
|
||||
Each ADR documents one architectural decision: what was decided, why, and what consequences follow. ADRs are append-only. Amendments extend existing ADRs with a dated section rather than replacing them.
|
||||
|
||||
## Reading this corpus
|
||||
|
||||
**Start with the [index](#index) below, and respect the status.** The index is grouped so that the first table — *Active decisions* — is the set that governs the system as it stands. An ADR in *Superseded, Retired, and Legacy* is historical: it records what was once decided and names what replaced it. Do not cite it as current architecture.
|
||||
|
||||
Two things the index makes explicit, because getting them wrong has actually misled readers here:
|
||||
|
||||
- **"Read first"** on an active ADR points at a *broader* ADR that now frames it. A decision can be entirely correct and still not be the whole picture. The runtime capability descriptor ([ADR-1016](1016-runtime-capability-descriptor.md)) is live and load-bearing, but [ADR-1239](1239-gsd-embeddable-orchestration-engine.md) (**EoS** — GSD as an Embeddable Orchestration Engine) subsumes it as the *declarative adapter* and inverts its direction: GSD is the engine a host embeds, not an installer that projects onto a host. For **how GSD meets a host, EoS is the current frame.**
|
||||
- **`Proposed` means not ratified — and it is kept honest.** On 2026-07-17 the corpus was audited against the shipped tree and nine ADRs whose decisions had demonstrably shipped were ratified to `Accepted`, each carrying a dated **Ratification** section with the evidence (see [ADR-857](857-capability-system.md) for the fullest example). The ADRs that remain `Proposed` are `Proposed` **for a reason recorded in the file** — an unmet acceptance criterion, an outstanding phase, or a successor ADR already planned — not through neglect. Trust the label; if you think it is wrong, prove it in a dated section and see [Ratifying a stale `Proposed`](#ratifying-a-stale-proposed).
|
||||
|
||||
## Naming Convention
|
||||
|
||||
New ADRs use **issue#-prefix slug** naming:
|
||||
@@ -12,90 +21,206 @@ New ADRs use **issue#-prefix slug** naming:
|
||||
docs/adr/<issue#>-<kebab-slug>.md
|
||||
```
|
||||
|
||||
Examples: `3485-adr-prd-naming-convention.md`, `3464-review-default-reviewers.md`.
|
||||
Examples: `2264-golden-parity-redesign.md`, `1239-gsd-embeddable-orchestration-engine.md`.
|
||||
|
||||
### Why
|
||||
|
||||
Two developers computing "next ADR number" locally against `main` will independently pick the same integer and both ship. The collision is already on disk — `0010-*` exists twice and `0011-*` exists three times. GitHub issue numbers are server-assigned and atomic: the moment you open an issue, that number is reserved globally. Two PRs that both edit the `### Fixed` block of `CHANGELOG.md` always conflict on merge — two PRs that each use a distinct issue# as their ADR prefix never collide. Same shape, same solution.
|
||||
|
||||
### Legacy ADRs
|
||||
### Legacy *naming* is not `Legacy` *status*
|
||||
|
||||
Files `0001-*` through `0011-*` are preserved as immutable historical record. The duplicate `0010-*` and the three-way `0011-*` are documented residue of the old local-compute convention — not patterns to imitate. Do not renumber them.
|
||||
Files `0001-*` through `0012-*` (and `0174-*`) are preserved as immutable historical record of the old local-compute numbering. The duplicate `0010-*` and the three-way `0011-*` are documented residue of that convention — not patterns to imitate. **Do not renumber them.**
|
||||
|
||||
This is a statement about **filenames only**. Many of those ADRs are `Accepted` and load-bearing today ([ADR-0002](0002-command-contract-validation-module.md), [ADR-0004](0004-worktree-workstream-seam-module.md), [ADR-0008](0008-installer-migration-module.md), [ADR-0009](0009-shell-command-projection-module.md)). An old filename says nothing about whether a decision still holds. The `Legacy` **status** in the table below is a separate claim — see the vocabulary.
|
||||
|
||||
Because `0010-*` and `0011-*` each resolve to more than one file, a bare cross-reference like "ADR-0011" is genuinely ambiguous. Link the file (see [Lifecycle rules](#lifecycle-rules)).
|
||||
|
||||
### Full process
|
||||
|
||||
See **[CONTRIBUTING.md — "Proposing an ADR or PRD"](../../CONTRIBUTING.md#proposing-an-adr-or-prd)** for the end-to-end workflow: opening the issue, waiting for approval, naming the file, and submitting the PR.
|
||||
|
||||
PRDs live in [`docs/prd/`](../prd/), not here. ([`0011-review-default-reviewers-prd.md`](0011-review-default-reviewers-prd.md) predates that directory and is kept in place as frozen historical record.)
|
||||
|
||||
## Lifecycle rules
|
||||
|
||||
These are enforced by `scripts/gen-adr-index.cjs`, which runs in CI via `npm run lint:generated-sync`. A violation fails the build with the exact file and fix.
|
||||
|
||||
### 1. Every ADR declares one status from the canonical vocabulary
|
||||
|
||||
The first word of the `Status` field must be one of:
|
||||
|
||||
| Status | Means | Obligation |
|
||||
|--------|-------|------------|
|
||||
| `Accepted` | Decided and in force. Cite it. | — |
|
||||
| `Proposed` | Decided in principle, not ratified. Do not cite as settled. | If the work has demonstrably shipped, ratify it (below) — do not leave the label lying. |
|
||||
| `Superseded` | A specific newer ADR replaced this decision. | **Must name the successor as a file link.** |
|
||||
| `Retired` | What this ADR decided no longer exists at all, and no single ADR replaced it. | Say what was removed and when. |
|
||||
| `Legacy` | Frozen historical record, kept for provenance; not a pattern to follow. | Say why it is frozen. |
|
||||
|
||||
Prose may follow the token (`Superseded by [ADR-0174](0174-retire-gsd-sdk-package-boundary.md) (2026-05-23); originally Accepted (2026-05-09)`). Both the bullet form (`- **Status:** Accepted`) and the table form (`| **Status** | Accepted |`) are accepted.
|
||||
|
||||
### 2. Cross-references to other ADRs are file links, never bare ids
|
||||
|
||||
Write `[ADR-0011](0011-skill-surface-budget-module.md)`, not `ADR-0011`. Bare ids are ambiguous for `0010`/`0011`, and unlinked references cannot be checked.
|
||||
|
||||
If you mean an **issue**, write `#857` — not `ADR-857`. (An ADR and its owning issue often share a number; that is intentional and not a conflict.)
|
||||
|
||||
### 3. Supersession and subsumption are symmetric
|
||||
|
||||
These are different relations. Do not conflate them:
|
||||
|
||||
- **`Supersedes` / `Superseded by`** — the target is *replaced*. Its status becomes `Superseded`.
|
||||
- **`Subsumes` / `Subsumed by`** — the target *still holds*, but a broader ADR now frames it. Its status is **unchanged**; it becomes a component of the larger decision.
|
||||
|
||||
If A declares either relation toward B, **B must record the reciprocal.** A one-way pointer is the failure this corpus actually suffered: [ADR-1239](1239-gsd-embeddable-orchestration-engine.md) declared it subsumed four ADRs, none of which said so, and none of which pointed back — so a reader landing on any of them concluded the superseded frame was the way forward.
|
||||
|
||||
Only an `Accepted` ADR is owed the back-link. A `Proposed` ADR's claim is **prospective**: it has not taken effect, so its target is not marked. On ratification, the check begins demanding the back-links.
|
||||
|
||||
### 4. The declared id matches the filename
|
||||
|
||||
An H1 of `# ADR-0175: …` in a file named `218-*.md` is a rename that never finished. The id in the title must match the filename's prefix.
|
||||
|
||||
### Ratifying a stale `Proposed`
|
||||
|
||||
A stale `Proposed` is not cosmetic: it tells contributors and agents that live architecture is an unbuilt idea. Fix it — but on evidence, not vibes.
|
||||
|
||||
**The bar.** All four must hold before flipping to `Accepted`:
|
||||
|
||||
1. The decided mechanism demonstrably **exists** in the tree — name the files, symbols, and tests.
|
||||
2. The owning issue is closed **as completed**. A closed issue is not proof: `stateReason` of *not planned* / duplicate means the decision was **dropped** (that is `Legacy` or `Retired`, not `Accepted`).
|
||||
3. **No material part is unshipped.** If the ADR defines phases and one is outstanding, or states its own bar for acceptance and that bar is unmet, it stays `Proposed`.
|
||||
4. No later ADR supersedes it, and no approved issue already plans its graduation as separate work.
|
||||
|
||||
**The procedure.** Set the status to `Accepted — ratified <date> (originally Proposed <date>)`, add a dated `## Ratification` section holding the evidence, then run `node scripts/gen-adr-index.cjs --write`. If the ADR claims to supersede or subsume others, the gate will now demand their back-links — that is the point. Ratify deliberately.
|
||||
|
||||
**Two traps worth knowing**, both hit during the 2026-07-17 audit:
|
||||
|
||||
- **Shipped code is necessary, not sufficient.** Eight ADRs had every named module, symbol, and test present and their epics closed — and still failed the bar: [ADR-2264](2264-golden-parity-redesign.md)'s own headline acceptance criterion is unmet in the tree, [ADR-230](230-introduce-next-integration-branch.md)'s decided branch protection does not match the live API, [ADR-660](660-release-from-next-head.md)'s namesake mechanism is performed by hand, and [ADR-959](959-capability-command-contribution.md) has an approved issue planning its graduation as its own ADR. Verify the *decision*, not just the code.
|
||||
- **"Supersedes" is often "subsumes".** Read what the ADR means before the gate makes you act on what it says. [ADR-857](857-capability-system.md) said "Supersedes (generalizes)"; taken literally, ratifying it would have stamped two live seams ([ADR-0011](0011-skill-surface-budget-module.md), [ADR-58](58-runtime-install-policy-module.md)) as dead. The parenthetical was the truth; the field name was wrong.
|
||||
|
||||
## Maintaining the index
|
||||
|
||||
**The index is generated. Do not hand-edit it.** Everything between the `ADR-INDEX:START` / `ADR-INDEX:END` markers is derived from the ADR files themselves:
|
||||
|
||||
```bash
|
||||
node scripts/gen-adr-index.cjs # print the index
|
||||
node scripts/gen-adr-index.cjs --write # regenerate it into this file
|
||||
node scripts/gen-adr-index.cjs --check # CI: fail if stale or invalid
|
||||
```
|
||||
|
||||
After adding an ADR, or changing any ADR's status or relations, run `--write` and commit the result. `npm run lint:generated-sync` runs `--check` in CI, so a missing or stale row fails the build rather than rotting silently.
|
||||
|
||||
This replaces a hand-maintained table that had drifted to **40 of 65 ADRs** — the entire capability family and EoS itself were missing from it, which is precisely why the ADRs a reader most needed were the ones they could not find.
|
||||
|
||||
## Index
|
||||
|
||||
| ADR | Title | Status |
|
||||
|-----|-------|--------|
|
||||
| [0001-dispatch-policy-module.md](0001-dispatch-policy-module.md) | Dispatch policy module as single seam for query execution outcomes | Accepted |
|
||||
| [0002-command-contract-validation-module.md](0002-command-contract-validation-module.md) | Command Contract Validation Module | Accepted |
|
||||
| [0003-model-catalog-module.md](0003-model-catalog-module.md) | Model Catalog Module as single source of truth for agent profiles and runtime tier defaults | Accepted |
|
||||
| [0004-worktree-workstream-seam-module.md](0004-worktree-workstream-seam-module.md) | Planning Workspace Module as single seam for worktree and workstream state | Accepted |
|
||||
| [0005-sdk-architecture-seam-map.md](0005-sdk-architecture-seam-map.md) | SDK Architecture seam map for query/runtime surfaces | Superseded by ADR-0174 |
|
||||
| [0006-planning-path-projection-module.md](0006-planning-path-projection-module.md) | Planning Path Projection Module for SDK query handlers | Accepted |
|
||||
| [0007-sdk-package-seam-module.md](0007-sdk-package-seam-module.md) | SDK Package Seam Module owns SDK-to-get-shit-done-redux compatibility | Superseded by ADR-0174 |
|
||||
| [0008-installer-migration-module.md](0008-installer-migration-module.md) | Installer Migration Module owns install-time upgrade safety | Accepted |
|
||||
| [0009-shell-command-projection-module.md](0009-shell-command-projection-module.md) | Shell Command Projection Module owns runtime-aware OS command rendering | Accepted |
|
||||
| [0010-file-operation-engine-module.md](0010-file-operation-engine-module.md) | File Operation Engine Module owns safe runtime/config file mutations | Proposed |
|
||||
| [0010-skill-surface-budget-module.md](0010-skill-surface-budget-module.md) | Skill Surface Budget Module — earlier draft superseded by ADR-0011 | Superseded by 0011 |
|
||||
| [0011-skill-surface-budget-module.md](0011-skill-surface-budget-module.md) | Skill Surface Budget Module owns install-time profile staging and runtime surface control | Accepted |
|
||||
| [0011-review-default-reviewers.md](0011-review-default-reviewers.md) | Review default-reviewers selection policy for /gsd:review | Accepted |
|
||||
| [0011-review-default-reviewers-prd.md](0011-review-default-reviewers-prd.md) | PRD for review.default_reviewers feature (#3464) | Reference |
|
||||
| [0012-command-routing-hub.md](0012-command-routing-hub.md) | CommandRoutingHub as single dispatch seam for CJS command families | Superseded by ADR-0174 |
|
||||
| [15-autonomous-cross-ai-convergence.md](15-autonomous-cross-ai-convergence.md) | Cross-AI plan convergence via existing orchestration commands | Proposed |
|
||||
| [22-plan-drift-guard.md](22-plan-drift-guard.md) | Plan-vs-codebase drift guard: defaults and symbol-resolver seam | Proposed |
|
||||
| [3524-cjs-sdk-hard-seam.md](3524-cjs-sdk-hard-seam.md) | CJS↔SDK hard seam — single canonical owner per responsibility (#3524) | Superseded by ADR-0174 |
|
||||
| [3660-runtime-artifact-layout-module.md](3660-runtime-artifact-layout-module.md) | Runtime Artifact Layout Module owns per-runtime artifact placement | Accepted |
|
||||
| [0174-retire-gsd-sdk-package-boundary.md](0174-retire-gsd-sdk-package-boundary.md) | Retire @opengsd/gsd-sdk package boundary — single-runtime collapse | Accepted |
|
||||
| [452-eslint-lint-harness.md](452-eslint-lint-harness.md) | Adopt standard ESLint flat-config lint harness; retire homegrown regex scanners | Accepted |
|
||||
| [456-test-rigor-architecture.md](456-test-rigor-architecture.md) | Test-rigor architecture — deterministic scheduling, antagonistic tier, typed-surface mandate, delete-bad-tests policy | Accepted |
|
||||
| [457-generated-cjs-single-source.md](457-generated-cjs-single-source.md) | Collapse hand-written CJS to generated single-source | Proposed |
|
||||
| [660-release-from-next-head.md](660-release-from-next-head.md) | Release from the head of next; immutable release tags; @next dist-tag as the RC surface | Proposed |
|
||||
| [58-runtime-install-policy-module.md](58-runtime-install-policy-module.md) | Runtime Install Policy Module owns the typed install-plan projection | Accepted |
|
||||
| [766-claude-code-plugin-manifest-module.md](766-claude-code-plugin-manifest-module.md) | Claude Code Plugin Manifest Module owns the projection of gsd-core surfaces onto the Claude Code plugin contract | Accepted |
|
||||
| [1016-runtime-capability-descriptor.md](1016-runtime-capability-descriptor.md) | Runtime Capability Descriptor | Accepted |
|
||||
| [1235-descriptor-driven-agent-conversion-migration.md](1235-descriptor-driven-agent-conversion-migration.md) | Migrate agent conversion to the descriptor-driven install path (parity + per-runtime cutover) | Proposed |
|
||||
| [1411-resolution-provenance.md](1411-resolution-provenance.md) | Resolution must report provenance, not fall open silently | Accepted |
|
||||
| [1508-runtime-artifact-conversion-module.md](1508-runtime-artifact-conversion-module.md) | Runtime Artifact Conversion Module owns per-runtime content rewriting | Accepted |
|
||||
| [1593-skill-mapping-converter-methodology.md](1593-skill-mapping-converter-methodology.md) | Skill mapping & converter methodology across runtimes | Accepted |
|
||||
| [1769-state-md-transition-module.md](1769-state-md-transition-module.md) | STATE.md Transition Module — intent-based transitions over scattered RMW callbacks | Proposed |
|
||||
| [1817-state-md-rebuild-derivability-contract.md](1817-state-md-rebuild-derivability-contract.md) | STATE.md rebuild — derivability contract (capstone 11th transition) | Accepted |
|
||||
| [2008-command-exit-zero-gate.md](2008-command-exit-zero-gate.md) | Generic gate-predicate evaluator with a `command-exit-zero` kind (#2008) | Accepted |
|
||||
| [1990-existing-code-onboarding.md](1990-existing-code-onboarding.md) | Existing Code Onboarding Module owns deterministic repo-state detection and onboarding route selection | Proposed |
|
||||
| [2121-phase-identifier-parsing-consolidation.md](2121-phase-identifier-parsing-consolidation.md) | Phase-identifier parsing consolidation — single canonical owner (phase-id.cts) + anti-divergence guard | Accepted |
|
||||
| [2143-markdown-table-and-mutation-consolidation.md](2143-markdown-table-and-mutation-consolidation.md) | Markdown table model, bounded mutation, and fail-loud consolidation (#1372 part 2) | Accepted |
|
||||
| [2164-statusline-scope-boundary.md](2164-statusline-scope-boundary.md) | Statusline draws its data boundary at local, read-only sources (no external/credentialed data) | Accepted |
|
||||
| [612-bracket-phase-id-convention.md](612-bracket-phase-id-convention.md) | Bracket phase-ID convention — lift the milestone into a `[PROJECT.MM]` prefix; terminal deprecation of M-NN | Proposed |
|
||||
| [2264-golden-parity-redesign.md](2264-golden-parity-redesign.md) | Redesign golden-install-parity: single-source manifest builder + split invariant | Proposed |
|
||||
| [959-capability-command-contribution.md](959-capability-command-contribution.md) | Capability Command Contribution — `commands` field; family routers discovered via registry in `runCommand` default case | Accepted (graduated by ADR-2346) |
|
||||
| [2346-command-dispatch-completion.md](2346-command-dispatch-completion.md) | Command Dispatch Completion — dissolve the 73-case `runCommand` switch into a two-layer (registry + leaf-table) dispatch | Accepted |
|
||||
<!-- ADR-INDEX:START — generated by scripts/gen-adr-index.cjs; do not edit by hand -->
|
||||
|
||||
### Active decisions (50)
|
||||
|
||||
These govern the system as it stands. Cite these.
|
||||
|
||||
| ADR | Title | Status | Read first |
|
||||
|-----|-------|--------|------------|
|
||||
| [ADR-0001](0001-dispatch-policy-module.md) | Dispatch policy module as single seam for query execution outcomes | Accepted | — |
|
||||
| [ADR-0002](0002-command-contract-validation-module.md) | Command Contract Validation Module | Accepted | — |
|
||||
| [ADR-0003](0003-model-catalog-module.md) | Model Catalog Module as single source of truth for agent profiles and runtime tier defaults | Accepted | — |
|
||||
| [ADR-0004](0004-worktree-workstream-seam-module.md) | Planning Workspace Module as single seam for worktree and workstream state | Accepted | — |
|
||||
| [ADR-0006](0006-planning-path-projection-module.md) | Planning Path Projection Module for SDK query handlers | Accepted | — |
|
||||
| [ADR-0008](0008-installer-migration-module.md) | Installer Migration Module owns install-time upgrade safety | Accepted | — |
|
||||
| [ADR-0009](0009-shell-command-projection-module.md) | Shell Command Projection Module owns runtime-aware OS command rendering | Accepted | — |
|
||||
| [ADR-0011](0011-review-default-reviewers.md) | `review.default_reviewers` config key scopes the no-flag `/gsd-review` fan-out | Accepted | — |
|
||||
| [ADR-0011](0011-skill-surface-budget-module.md) | Skill Surface Budget Module owns install-time profile staging and runtime surface control | Accepted | [ADR-857](857-capability-system.md) |
|
||||
| [ADR-15](15-autonomous-cross-ai-convergence.md) | Cross-AI Plan Convergence via Existing Orchestration Commands | Accepted | — |
|
||||
| [ADR-22](22-plan-drift-guard.md) | Plan-vs-codebase drift guard: defaults and symbol-resolver seam | Accepted | — |
|
||||
| [ADR-58](58-runtime-install-policy-module.md) | Runtime Install Policy Module owns the typed install-plan projection | Accepted | [ADR-1239](1239-gsd-embeddable-orchestration-engine.md), [ADR-857](857-capability-system.md) |
|
||||
| [ADR-0174](0174-retire-gsd-sdk-package-boundary.md) | Retire @opengsd/gsd-sdk package boundary — single-runtime collapse | Accepted | — |
|
||||
| [ADR-218](218-release-version-validation.md) | Harden release-workflow version validation — reject leading zeros and pre-check npm | Accepted | — |
|
||||
| [ADR-227](227-input-validation-shape-not-just-type.md) | Input validation must check semantic shape, not just type | Accepted | — |
|
||||
| [ADR-415](415-prevent-stale-base-token-reintroduction.md) | Prevent stale-base reintroduction of retired runtime tokens | Accepted | — |
|
||||
| [ADR-452](452-eslint-lint-harness.md) | Adopt standard ESLint flat-config lint harness | Accepted | — |
|
||||
| [ADR-456](456-test-rigor-architecture.md) | Test-rigor architecture — deterministic scheduling, antagonistic tier, typed-surface mandate, and delete-bad-tests policy | Accepted | — |
|
||||
| [ADR-457](457-generated-cjs-single-source.md) | Generation model for `bin/lib/*.cjs` type safety | Accepted | — |
|
||||
| [ADR-550](550-spec-phase-probe-contract.md) | spec-phase probe pattern and prohibition contract | Accepted | — |
|
||||
| [ADR-0656](0656-research-module-seam.md) | Research Module — L2-hybrid seam for cached, curated-first research | Accepted | — |
|
||||
| [ADR-766](766-claude-code-plugin-manifest-module.md) | Claude Code Plugin Manifest Module owns the projection of gsd-core surfaces onto the Claude Code plugin contract | Accepted | — |
|
||||
| [ADR-857](857-capability-system.md) | Capability system — five-step loop as core, features as plug-ins behind Loop Extension Points | Accepted | — |
|
||||
| [ADR-894](894-capability-declaration-format.md) | Capability declaration format + registry generation | Accepted | [ADR-1239](1239-gsd-embeddable-orchestration-engine.md) |
|
||||
| [ADR-959](959-capability-command-contribution.md) | Capability Command Contribution | Accepted | — |
|
||||
| [ADR-1016](1016-runtime-capability-descriptor.md) | Runtime Capability Descriptor | Accepted | [ADR-1239](1239-gsd-embeddable-orchestration-engine.md) |
|
||||
| [ADR-1235](1235-descriptor-driven-agent-conversion-migration.md) | Migrate agent conversion to the descriptor-driven install path | Accepted | — |
|
||||
| [ADR-1239](1239-gsd-embeddable-orchestration-engine.md) | GSD as an Embeddable Orchestration Engine | Accepted | — |
|
||||
| [ADR-1244](1244-capability-ecosystem.md) | Capability Ecosystem: third-party authoring, versioned manifests, and URL import/upgrade/remove | Accepted | — |
|
||||
| [ADR-1372](1372-markdown-sectionizer-seam.md) | Canonical markdown-structure parsing — the `markdown-sectionizer` seam | Accepted | — |
|
||||
| [ADR-1411](1411-resolution-provenance.md) | Resolution must report provenance, not fall open silently | Accepted | — |
|
||||
| [ADR-1508](1508-runtime-artifact-conversion-module.md) | Runtime Artifact Conversion Module owns per-runtime content rewriting | Accepted | — |
|
||||
| [ADR-1517](1517-reviewer-instances-config-surface.md) | Reviewer instances — bounded config surface for same-adapter multi-model review | Accepted | — |
|
||||
| [ADR-1577](1577-untrusted-input-boundary-and-injection-blocking.md) | Untrusted-input boundary + opt-in injection blocking | Accepted | — |
|
||||
| [ADR-1593](1593-skill-mapping-converter-methodology.md) | Skill mapping & converter methodology across runtimes | Accepted | — |
|
||||
| [ADR-1610](1610-workflow-agent-size-budget-ratchet.md) | workflow & agent size-budget ratchet (per-file byte baseline + tier hard caps) | Accepted | — |
|
||||
| [ADR-1703](1703-portability-enforcement-architecture.md) | Cross-platform portability enforcement as AST ESLint rules | Accepted | — |
|
||||
| [ADR-1769](1769-state-md-transition-module.md) | STATE.md Transition Module — intent-based transitions over scattered RMW callbacks | Accepted | — |
|
||||
| [ADR-1787](1787-gsd-next-smart-entry.md) | `/gsd:next` smart-entry front door delegates advancement to `/gsd:progress --next` | Accepted | — |
|
||||
| [ADR-1817](1817-state-md-rebuild-derivability-contract.md) | STATE.md rebuild — derivability contract (capstone transition) | Accepted | — |
|
||||
| [ADR-1820](1820-spec-optional-predicate-rail.md) | Spec-Optional Predicate Rail — the Spec-Section Detection Module, the fallback toggle, and the SPEC↔probe precedence contract | Accepted | — |
|
||||
| [ADR-1866](1866-agent-skills-dual-injection-contract.md) | agent_skills dual injection — orchestrator-side + agent-side self-load | Accepted | — |
|
||||
| [ADR-1990](1990-existing-code-onboarding.md) | Existing Code Onboarding Module owns deterministic repo-state detection and onboarding route selection | Accepted | — |
|
||||
| [ADR-2008](2008-command-exit-zero-gate.md) | Generic gate-predicate evaluator (`command-exit-zero`) | Accepted | — |
|
||||
| [ADR-2121](2121-phase-identifier-parsing-consolidation.md) | Phase-Identifier Parsing Consolidation | Accepted | — |
|
||||
| [ADR-2143](2143-markdown-table-and-mutation-consolidation.md) | Markdown Table Model, Bounded Mutation, and Fail-Loud Consolidation (#1372 part 2) | Accepted | — |
|
||||
| [ADR-2164](2164-statusline-scope-boundary.md) | Statusline draws its data boundary at local, read-only sources | Accepted | — |
|
||||
| [ADR-2207](2207-status-field-lifecycle-ownership.md) | STATE.md `Status` lifecycle — phase-completion writes an intermediate state; milestone-close owns termination | Accepted | — |
|
||||
| [ADR-2346](2346-command-dispatch-completion.md) | Command Dispatch Completion | Accepted | — |
|
||||
| [ADR-3660](3660-runtime-artifact-layout-module.md) | Runtime Artifact Layout Module owns per-runtime artifact placement | Accepted | [ADR-1239](1239-gsd-embeddable-orchestration-engine.md) |
|
||||
|
||||
### Proposed (9)
|
||||
|
||||
Decided in principle, not yet ratified. Do not cite as settled architecture.
|
||||
|
||||
| ADR | Title | Status | Read first |
|
||||
|-----|-------|--------|------------|
|
||||
| [ADR-230](230-introduce-next-integration-branch.md) | Introduce `next` as a long-lived integration branch | Proposed | — |
|
||||
| [ADR-443](443-opus48-unified-effort-and-fast-mode-routing.md) | Unified cross-provider effort controls and fast-mode-aware routing | Proposed | — |
|
||||
| [ADR-612](612-bracket-phase-id-convention.md) | Bracket Phase-ID Convention | Proposed | — |
|
||||
| [ADR-660](660-release-from-next-head.md) | Release from the head of `next`; immutable release tags; `@next` dist-tag as the RC surface | Proposed | — |
|
||||
| [ADR-1143](1143-claude-orchestration-capability.md) | Claude orchestration capability — Workflow tool (ultracode) as a runtime-gated loop execution backend | Proposed | — |
|
||||
| [ADR-1213](1213-capability-state-writer.md) | Capability write side — the Capability State Writer | Proposed | — |
|
||||
| [ADR-1606](1606-prohibition-enforcement-verify-seam.md) | prohibition-enforcement verify-time seam | Proposed | — |
|
||||
| [ADR-1671](1671-dynamic-context-management-platform.md) | Dynamic context management platform | Proposed | — |
|
||||
| [ADR-2264](2264-golden-parity-redesign.md) | Redesign golden-install-parity — single-source manifest builder + split invariant | Proposed | — |
|
||||
|
||||
### Superseded, Retired, and Legacy (7)
|
||||
|
||||
Historical record. **Do not follow these** — each names what replaced it, or why it was retired.
|
||||
|
||||
| ADR | Title | Status | Replaced by |
|
||||
|-----|-------|--------|-------------|
|
||||
| [ADR-0005](0005-sdk-architecture-seam-map.md) | SDK Architecture seam map for query/runtime surfaces | Superseded | [ADR-0174](0174-retire-gsd-sdk-package-boundary.md) |
|
||||
| [ADR-0007](0007-sdk-package-seam-module.md) | SDK Package Seam Module owns SDK-to-get-shit-done-redux compatibility | Superseded | [ADR-0174](0174-retire-gsd-sdk-package-boundary.md) |
|
||||
| [ADR-0010](0010-file-operation-engine-module.md) | File Operation Engine Module owns safe runtime/config file mutations | Superseded | [ADR-0009](0009-shell-command-projection-module.md) |
|
||||
| [ADR-0010](0010-skill-surface-budget-module.md) | Skill Surface Budget Module owns install-time skill listing curation | Superseded | [ADR-0011](0011-skill-surface-budget-module.md) |
|
||||
| [ADR-0011](0011-review-default-reviewers-prd.md) | PRD — `review.default_reviewers` config key for `/gsd-review` reviewer selection | Legacy | — |
|
||||
| [ADR-0012](0012-command-routing-hub.md) | CommandRoutingHub as single dispatch seam for CJS command families | Superseded | [ADR-0174](0174-retire-gsd-sdk-package-boundary.md) |
|
||||
| [ADR-3524](3524-cjs-sdk-hard-seam.md) | CJS↔SDK hard seam — one source of truth per Shared Module | Superseded | [ADR-0174](0174-retire-gsd-sdk-package-boundary.md) |
|
||||
|
||||
_66 ADRs. Generated by `scripts/gen-adr-index.cjs` — run `--write` after adding or restatusing an ADR._
|
||||
|
||||
<!-- ADR-INDEX:END -->
|
||||
|
||||
## Seam map
|
||||
|
||||
ADR 0005 is the top-level SDK seam index. It references per-seam ADRs and states the narrow-waist principle each seam follows. Use it as the entry point for understanding SDK module ownership.
|
||||
Orientation for the module-ownership ADRs. This section is prose and hand-maintained; the index above is the authority on status.
|
||||
|
||||
ADR 0006 documents how SDK query handlers project planning paths (`cwd → effectiveRoot → .planning/<project>/...`). Cross-reference with the Planning Workspace Module (ADR 0004) for workstream pointer policy.
|
||||
**How GSD meets a host — start at [ADR-1239](1239-gsd-embeddable-orchestration-engine.md) (EoS).** It is the current frame and subsumes the descriptor/projection ADRs ([ADR-1016](1016-runtime-capability-descriptor.md), [ADR-58](58-runtime-install-policy-module.md), [ADR-3660](3660-runtime-artifact-layout-module.md), [ADR-894](894-capability-declaration-format.md)) as adapters beneath it.
|
||||
|
||||
ADR 0008 documents the Installer Migration Module for safe install-time moves, removals, config rewrites, and user-data preservation.
|
||||
**The SDK seam map is gone.** [ADR-0005](0005-sdk-architecture-seam-map.md) was once the entry point for SDK module ownership; it is **superseded by [ADR-0174](0174-retire-gsd-sdk-package-boundary.md)**, which retired the `@opengsd/gsd-sdk` package boundary entirely. There is no `sdk/` tree. Read ADR-0174 for the single-runtime collapse; the seam-Module vocabulary survives under one `src/`.
|
||||
|
||||
ADR 0009 documents the Shell Command Projection Module seam for runtime-aware
|
||||
projection of installer-owned command text and projection IR.
|
||||
[ADR-0006](0006-planning-path-projection-module.md) documents how query handlers project planning paths (`cwd → effectiveRoot → .planning/<project>/...`). Cross-reference the Planning Workspace Module ([ADR-0004](0004-worktree-workstream-seam-module.md)) for workstream pointer policy.
|
||||
|
||||
ADR 0010 documents the File Operation Engine Module seam for converging
|
||||
installer/migration/planning file mutation safety policy, and its relationship
|
||||
to ADR 0009 hook-command ownership policy.
|
||||
[ADR-0008](0008-installer-migration-module.md) documents the Installer Migration Module for safe install-time moves, removals, config rewrites, and user-data preservation.
|
||||
|
||||
ADR 0011 documents the Skill Surface Budget Module for install-time skill/agent
|
||||
profile staging (`--profile=<name>`, `.gsd-profile` marker, `requires:` closure)
|
||||
and the Phase 2 runtime `/gsd:surface` command for cluster-level enable/disable
|
||||
without reinstall.
|
||||
[ADR-0009](0009-shell-command-projection-module.md) documents the Shell Command Projection Module seam for runtime-aware projection of installer-owned command text and projection IR. Its Phases 3–4 absorbed the File Operation Engine Module ([ADR-0010](0010-file-operation-engine-module.md)).
|
||||
|
||||
ADR 1411 establishes the Resolution Provenance principle: context resolution
|
||||
(config loading, project-root anchoring, workstream resolution) must report its
|
||||
provenance rather than fall open silently to defaults. It is the resolution-side
|
||||
analog of ADR 227 (input-validation shape), binds the Config Loader Module,
|
||||
Project-Root Resolution Module, and I/O Module, and is the decision record for
|
||||
epic #1411 (phases P1–P4).
|
||||
[ADR-0011](0011-skill-surface-budget-module.md) documents the Skill Surface Budget Module for install-time skill/agent profile staging (`--profile=<name>`, `.gsd-profile` marker, `requires:` closure) and the Phase 2 runtime `/gsd:surface` command.
|
||||
|
||||
[ADR-1411](1411-resolution-provenance.md) establishes the Resolution Provenance principle: context resolution (config loading, project-root anchoring, workstream resolution) must report its provenance rather than fall open silently to defaults. It is the resolution-side analog of [ADR-227](227-input-validation-shape-not-just-type.md) (input-validation shape).
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
"lint:test-file-count": "node scripts/lint-test-file-count.cjs",
|
||||
"lint:pr-checks": "node scripts/lint-pr-check-project-dir.cjs",
|
||||
"lint:changeset": "node scripts/changeset/lint.cjs",
|
||||
"lint:generated-sync": "node scripts/gen-capability-registry.cjs --check && node scripts/gen-loop-host-contract.cjs --check && node scripts/gen-capability-matrix.cjs --check && node scripts/sync-manifest-versions.cjs --check && node scripts/gen-inventory-manifest.cjs --check && node scripts/generate-package-identity.cjs --check && node scripts/gen-plugin-skills.cjs --check && node scripts/gen-registry.cjs --check",
|
||||
"lint:generated-sync": "node scripts/gen-capability-registry.cjs --check && node scripts/gen-loop-host-contract.cjs --check && node scripts/gen-capability-matrix.cjs --check && node scripts/sync-manifest-versions.cjs --check && node scripts/gen-inventory-manifest.cjs --check && node scripts/generate-package-identity.cjs --check && node scripts/gen-plugin-skills.cjs --check && node scripts/gen-registry.cjs --check && node scripts/gen-adr-index.cjs --check",
|
||||
"lint:docs": "node scripts/lint-docs-required.cjs",
|
||||
"lint:legacy-name": "node scripts/lint-legacy-dir-name.cjs",
|
||||
"ci:test-scope": "node scripts/ci-test-scope.cjs",
|
||||
|
||||
526
scripts/gen-adr-index.cjs
Normal file
526
scripts/gen-adr-index.cjs
Normal file
@@ -0,0 +1,526 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Generates the ADR index table in docs/adr/README.md from the ADR files
|
||||
* themselves, and validates the corpus' lifecycle invariants.
|
||||
*
|
||||
* The index is a DERIVED artifact: it is regenerated from every
|
||||
* `docs/adr/<id>-<slug>.md` on disk, so it cannot silently drift out of date
|
||||
* the way a hand-maintained table does. CI re-runs this with `--check` and
|
||||
* fails on any diff or invariant violation.
|
||||
*
|
||||
* Invariants enforced (see docs/adr/README.md "Lifecycle rules"):
|
||||
* 1. Every ADR declares `- **Status:** <Token>` with Token in STATUSES.
|
||||
* 2. A Superseded/Retired ADR names its successor as a markdown link to the
|
||||
* target file — never a bare "ADR-N", which is ambiguous (ADR-0010 and
|
||||
* ADR-0011 each resolve to more than one file).
|
||||
* 3. Supersession is symmetric: if A supersedes B, B records superseded-by A.
|
||||
* 4. An ADR whose H1 declares an id must match its filename's id.
|
||||
* 5. The committed index equals the generated index.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/gen-adr-index.cjs # print the index to stdout
|
||||
* node scripts/gen-adr-index.cjs --write # rewrite the index in README.md
|
||||
* node scripts/gen-adr-index.cjs --check # exit 1 if stale or invalid
|
||||
*/
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const ADR_DIR = path.join(ROOT, 'docs', 'adr');
|
||||
const README_PATH = path.join(ADR_DIR, 'README.md');
|
||||
|
||||
const START_MARKER = '<!-- ADR-INDEX:START — generated by scripts/gen-adr-index.cjs; do not edit by hand -->';
|
||||
const END_MARKER = '<!-- ADR-INDEX:END -->';
|
||||
|
||||
/**
|
||||
* The canonical status vocabulary.
|
||||
*
|
||||
* `Legacy` and `Retired` are deliberately distinct from `Superseded`:
|
||||
* - Superseded — a specific newer ADR replaced this decision. Names it.
|
||||
* - Retired — the thing this ADR decided no longer exists at all, and no
|
||||
* single ADR replaced it (e.g. a deleted package boundary).
|
||||
* - Legacy — frozen historical record, kept for provenance, not a
|
||||
* pattern to imitate.
|
||||
*
|
||||
* NOTE: `Legacy` describes a DECISION's standing, not a filename. The
|
||||
* `0001-`..`0012-` sequential *naming* era is legacy, but many of those ADRs
|
||||
* (e.g. 0002, 0004, 0008, 0009) are Accepted and load-bearing today. Do not
|
||||
* conflate the two: grep the naming rule in README.md, not this enum.
|
||||
*/
|
||||
const STATUSES = ['Accepted', 'Proposed', 'Superseded', 'Legacy', 'Retired'];
|
||||
|
||||
/**
|
||||
* Header fields that assert a lifecycle relation.
|
||||
*
|
||||
* Two DISTINCT relations, deliberately not conflated:
|
||||
*
|
||||
* supersedes — the target decision is REPLACED. The target's status becomes
|
||||
* Superseded and it must name this ADR. (ADR-0174 → ADR-0005.)
|
||||
*
|
||||
* subsumes — the target decision still HOLDS, but a broader ADR now frames
|
||||
* it; the target keeps its Accepted status and becomes a component of the
|
||||
* larger decision. (ADR-1239/EoS subsumes ADR-1016 "as the declarative
|
||||
* adapter" — the descriptor is still real and still correct.)
|
||||
*
|
||||
* Both directions are symmetry-checked, but only `supersedes` implies a status
|
||||
* change on the target. Collapsing subsumption into supersession would mark
|
||||
* four live, load-bearing ADRs as dead — the opposite of the truth.
|
||||
*/
|
||||
const RELATION_FIELDS = new Map([
|
||||
['supersedes', { kind: 'supersedes', dir: 'out' }],
|
||||
['superseded by', { kind: 'supersedes', dir: 'in' }],
|
||||
['subsumes', { kind: 'subsumes', dir: 'out' }],
|
||||
['subsumed by', { kind: 'subsumes', dir: 'in' }],
|
||||
]);
|
||||
|
||||
/** Relation kinds and the header field a reader should add to fix each gap. */
|
||||
const RELATION_SPEC = {
|
||||
supersedes: { out: 'Supersedes', in: 'Superseded by' },
|
||||
subsumes: { out: 'Subsumes', in: 'Subsumed by' },
|
||||
};
|
||||
|
||||
/**
|
||||
* A relation field whose value opens with "nothing"/"none"/"n/a" asserts the
|
||||
* absence of the relation, whatever prose follows it.
|
||||
*/
|
||||
const NEGATED_RELATION_RE = /^\s*(?:nothing|none|n\/a|[—–-])\s*(?:$|[;,.]|\s)/i;
|
||||
|
||||
/**
|
||||
* Header fields appear in two shapes across the corpus, both legitimate:
|
||||
* bullet — `- **Status:** Accepted`
|
||||
* table — `| **Status** | Accepted |`
|
||||
* Yield [field, value] for either.
|
||||
*/
|
||||
function* headerFields(header) {
|
||||
const bullet = /^\s*[-*]\s*\*\*([^*:]+?)(?::)?\*\*\s*(.*)$/gm;
|
||||
let m;
|
||||
while ((m = bullet.exec(header)) !== null) yield [m[1].trim(), m[2].trim()];
|
||||
|
||||
const row = /^\s*\|\s*\*\*([^*|]+?)(?::)?\*\*\s*\|\s*(.*?)\s*\|\s*$/gm;
|
||||
while ((m = row.exec(header)) !== null) yield [m[1].trim(), m[2].trim()];
|
||||
}
|
||||
|
||||
/** Numeric identity of an ADR: "0011" and "11" are the same id. */
|
||||
function canonicalId(raw) {
|
||||
return String(raw).replace(/^0+(?=\d)/, '');
|
||||
}
|
||||
|
||||
/** The documented filename shape: `<issue#>-<kebab-slug>.md`. */
|
||||
const ADR_FILENAME_RE = /^[0-9]+-[a-z0-9-]+\.md$/;
|
||||
|
||||
function adrFiles() {
|
||||
return fs
|
||||
.readdirSync(ADR_DIR)
|
||||
.filter((f) => f.endsWith('.md') && f !== 'README.md')
|
||||
.filter((f) => fs.statSync(path.join(ADR_DIR, f)).isFile())
|
||||
.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the directory into files this tool can parse and files it cannot.
|
||||
*
|
||||
* A file without a numeric prefix is not merely unparseable — it is invisible
|
||||
* to the index, which is the failure this gate exists to prevent. Report it as
|
||||
* a violation naming the convention, rather than crashing on `match(...)[1]`
|
||||
* or silently skipping it.
|
||||
*/
|
||||
function partitionAdrFiles() {
|
||||
const conforming = [];
|
||||
const nonConforming = [];
|
||||
for (const f of adrFiles()) (ADR_FILENAME_RE.test(f) ? conforming : nonConforming).push(f);
|
||||
return { conforming, nonConforming };
|
||||
}
|
||||
|
||||
/** Extract the leading bullet-field header block (everything before the first `##`). */
|
||||
function headerBlock(text) {
|
||||
const body = text.split(/\r?\n/);
|
||||
const stop = body.findIndex((l) => /^##\s/.test(l));
|
||||
return (stop === -1 ? body : body.slice(0, stop)).join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* A relation may also be declared as a whole SECTION rather than a header field.
|
||||
* ADR-0174 is the exemplar: a `## Supersedes` heading over a table whose first
|
||||
* column links each superseded ADR and whose remaining columns explain why.
|
||||
* That is the richest form in the corpus and must count — reading only the
|
||||
* header block would report the repo's best-documented supersession as missing.
|
||||
*
|
||||
* Returns { supersedes: [file…], subsumes: [file…] } from matching sections.
|
||||
*/
|
||||
const RELATION_SECTION_RE = /^##\s+(Supersedes|Subsumes)\b[^\n]*$/i;
|
||||
|
||||
function relationSections(text) {
|
||||
const lines = text.split(/\r?\n/);
|
||||
const out = { supersedes: [], subsumes: [] };
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const m = lines[i].match(RELATION_SECTION_RE);
|
||||
if (!m) continue;
|
||||
const kind = m[1].toLowerCase() === 'supersedes' ? 'supersedes' : 'subsumes';
|
||||
// Collect until the next heading of any level.
|
||||
let j = i + 1;
|
||||
const body = [];
|
||||
for (; j < lines.length && !/^#{1,6}\s/.test(lines[j]); j++) body.push(lines[j]);
|
||||
const chunk = body.join('\n');
|
||||
if (NEGATED_RELATION_RE.test(chunk.trim())) continue;
|
||||
out[kind].push(...linkedAdrFiles(chunk));
|
||||
i = j - 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** All markdown links to sibling ADR files inside a chunk of text. */
|
||||
function linkedAdrFiles(text) {
|
||||
const out = [];
|
||||
const re = /\]\(\s*(?:\.\/)?([0-9]+-[a-z0-9-]+\.md)\s*\)/gi;
|
||||
let m;
|
||||
while ((m = re.exec(text)) !== null) out.push(m[1]);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Bare `ADR-123` / `ADR 123` mentions that are NOT part of a markdown link. */
|
||||
function bareAdrRefs(text) {
|
||||
const withoutLinks = text.replace(/\[[^\]]*\]\([^)]*\)/g, '');
|
||||
const out = [];
|
||||
const re = /\bADR[-\s]0*(\d+)\b/gi;
|
||||
let m;
|
||||
while ((m = re.exec(withoutLinks)) !== null) out.push(canonicalId(m[1]));
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseAdr(file) {
|
||||
const full = path.join(ADR_DIR, file);
|
||||
const text = fs.readFileSync(full, 'utf8');
|
||||
const lines = text.split(/\r?\n/);
|
||||
|
||||
// `fileId` is the numeric identity used for comparison ("0011" === "11");
|
||||
// `displayId` preserves the filename's prefix exactly as written, because the
|
||||
// corpus and its cross-references say "ADR-0001" and "ADR-58", not "ADR-1".
|
||||
const rawId = file.match(/^([0-9]+)-/)[1];
|
||||
const fileId = canonicalId(rawId);
|
||||
const displayId = rawId;
|
||||
|
||||
const h1 = (lines.find((l) => /^#\s/.test(l)) || '').replace(/^#\s+/, '').trim();
|
||||
// Title as displayed: drop a leading "ADR-123 — " / "ADR-123: " prefix and a
|
||||
// trailing "[Proposed]"-style status bracket, both of which the index renders
|
||||
// from structured fields instead.
|
||||
const title = h1
|
||||
.replace(/^ADR[-\s]?0*\d+\s*(?:[—:-]\s*)?/i, '')
|
||||
.replace(/\s*\[(?:Proposed|Accepted|Superseded|Legacy|Retired)\]\s*$/i, '')
|
||||
.trim();
|
||||
|
||||
const declaredIdMatch = h1.match(/^ADR[-\s]?0*(\d+)\b/i);
|
||||
const declaredId = declaredIdMatch ? canonicalId(declaredIdMatch[1]) : null;
|
||||
|
||||
const header = headerBlock(text);
|
||||
|
||||
let statusRaw = null;
|
||||
// relations[kind][dir] = [{field, value, links, bare}]
|
||||
const relations = { supersedes: { out: [], in: [] }, subsumes: { out: [], in: [] } };
|
||||
|
||||
for (const [field, value] of headerFields(header)) {
|
||||
if (field.toLowerCase() === 'status') {
|
||||
if (statusRaw === null) statusRaw = value;
|
||||
continue;
|
||||
}
|
||||
// "Supersedes (generalizes)" / "Subsumes as adapters" → "supersedes" / "subsumes"
|
||||
const key = field.toLowerCase().replace(/\s*\([^)]*\)\s*/g, ' ').replace(/\s+as\s+.*$/, '').trim();
|
||||
const spec = RELATION_FIELDS.get(key);
|
||||
if (!spec) continue;
|
||||
// "Supersedes: nothing; amends the ADR-1239 harness" asserts NO relation. Such a
|
||||
// field routinely name-drops other ADRs in its prose ("related", "amends", "builds
|
||||
// on"); reading those as supersession claims invents links that were never made.
|
||||
if (NEGATED_RELATION_RE.test(value)) continue;
|
||||
relations[spec.kind][spec.dir].push({ field, value, links: linkedAdrFiles(value), bare: bareAdrRefs(value) });
|
||||
}
|
||||
|
||||
const statusToken = statusRaw ? (statusRaw.match(/^([A-Za-z]+)/) || [])[1] : null;
|
||||
|
||||
// A "Superseded by X" written into the Status line itself is the relation.
|
||||
if (statusRaw && /^Superseded\b/i.test(statusRaw)) {
|
||||
relations.supersedes.in.push({ field: 'Status', value: statusRaw, links: linkedAdrFiles(statusRaw), bare: bareAdrRefs(statusRaw) });
|
||||
}
|
||||
|
||||
// `## Supersedes` / `## Subsumes` sections count as OUT claims (ADR-0174's table).
|
||||
const sections = relationSections(text);
|
||||
for (const kind of ['supersedes', 'subsumes']) {
|
||||
if (sections[kind].length === 0) continue;
|
||||
relations[kind].out.push({ field: `## ${kind === 'supersedes' ? 'Supersedes' : 'Subsumes'} section`, value: '', links: sections[kind], bare: [] });
|
||||
}
|
||||
|
||||
return { file, fileId, displayId, title, declaredId, statusRaw, statusToken, relations, text };
|
||||
}
|
||||
|
||||
function buildCorpus() {
|
||||
const { conforming, nonConforming } = partitionAdrFiles();
|
||||
const adrs = conforming.map(parseAdr);
|
||||
const byFile = new Map(adrs.map((a) => [a.file, a]));
|
||||
const byId = new Map();
|
||||
for (const a of adrs) {
|
||||
if (!byId.has(a.fileId)) byId.set(a.fileId, []);
|
||||
byId.get(a.fileId).push(a);
|
||||
}
|
||||
return { adrs, byFile, byId, nonConforming };
|
||||
}
|
||||
|
||||
function validate({ adrs, byFile, byId, nonConforming }) {
|
||||
const errors = [];
|
||||
const add = (file, msg) => errors.push(`${file}: ${msg}`);
|
||||
|
||||
for (const f of nonConforming) {
|
||||
add(
|
||||
f,
|
||||
'filename does not match the `<issue#>-<kebab-slug>.md` convention, so it cannot appear in the index. ' +
|
||||
'Rename it (see docs/adr/README.md "Naming Convention"), or move it out of docs/adr/ if it is not an ADR.',
|
||||
);
|
||||
}
|
||||
|
||||
for (const a of adrs) {
|
||||
if (!a.statusToken) {
|
||||
add(a.file, 'no `- **Status:** <Token>` field found in the header block.');
|
||||
continue;
|
||||
}
|
||||
if (!STATUSES.includes(a.statusToken)) {
|
||||
add(a.file, `status "${a.statusToken}" is not one of ${STATUSES.join(' | ')} (full line: "${a.statusRaw}").`);
|
||||
}
|
||||
if (a.declaredId && a.declaredId !== a.fileId) {
|
||||
add(a.file, `H1 declares ADR-${a.declaredId} but the filename says ${a.fileId}. The id must match the filename.`);
|
||||
}
|
||||
|
||||
// A Superseded ADR must point at its successor by FILE LINK.
|
||||
if (a.statusToken === 'Superseded') {
|
||||
const links = a.relations.supersedes.in.flatMap((r) => r.links);
|
||||
if (links.length === 0) {
|
||||
const bare = a.relations.supersedes.in.flatMap((r) => r.bare);
|
||||
add(
|
||||
a.file,
|
||||
bare.length
|
||||
? `status is Superseded and mentions ADR-${bare.join('/')} but not as a markdown link to the file. ` +
|
||||
'Bare ids are ambiguous (ADR-0010 and ADR-0011 each resolve to multiple files) — link the target file.'
|
||||
: 'status is Superseded but names no successor. Write `Superseded by [ADR-N](N-slug.md)`.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Every relation link must resolve; every bare id must be linked (and exist).
|
||||
for (const kind of Object.keys(RELATION_SPEC)) {
|
||||
for (const dir of ['out', 'in']) {
|
||||
for (const rel of a.relations[kind][dir]) {
|
||||
for (const l of rel.links) {
|
||||
if (!byFile.has(l)) add(a.file, `"${rel.field}" links "${l}", which does not exist in docs/adr/.`);
|
||||
}
|
||||
// The synthetic relation lifted out of the Status line is already covered by
|
||||
// the dedicated Superseded check above; reporting it again just duplicates.
|
||||
if (rel.field === 'Status') continue;
|
||||
// Ids already linked ANYWHERE in this field. A field legitimately
|
||||
// reads "…([ADR-58](58-x.md)) — see 'Relation to ADR-58' below": the
|
||||
// trailing prose repeats an id that is linked earlier, and flagging
|
||||
// that would be noise. But an id that appears ONLY bare is an
|
||||
// unchecked claim — and testing `rel.links.length` instead of the
|
||||
// specific id silently dropped every bare claim in a field that
|
||||
// happened to carry one link.
|
||||
const linkedIds = new Set(rel.links.map((l) => (byFile.get(l) || {}).fileId).filter(Boolean));
|
||||
for (const b of rel.bare) {
|
||||
if (linkedIds.has(b)) continue;
|
||||
const candidates = byId.get(b) || [];
|
||||
if (candidates.length === 0) {
|
||||
add(a.file, `"${rel.field}" names ADR-${b}, which does not exist in docs/adr/. If it is an ISSUE number, write "#${b}" — not "ADR-${b}".`);
|
||||
} else {
|
||||
add(
|
||||
a.file,
|
||||
`"${rel.field}" names ADR-${b} without a file link` +
|
||||
(candidates.length > 1 ? ` (ambiguous — resolves to ${candidates.length} files: ${candidates.map((c) => c.file).join(', ')})` : '') +
|
||||
'. Link the target file so the relation is checkable.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Symmetry, per relation kind: A -out-> B <=> B -in-> A.
|
||||
//
|
||||
// Only a RATIFIED (Accepted) claimant is owed the back-link. A Proposed ADR's
|
||||
// supersession claim is prospective — it has not taken effect, so stamping its
|
||||
// target as superseded would assert something untrue (ADR-857 is Proposed and
|
||||
// claims to generalize ADR-0011/ADR-58, both of which are Accepted and live).
|
||||
// When such an ADR is ratified to Accepted, this check starts demanding the
|
||||
// back-links at exactly the right moment.
|
||||
const OPPOSITE = { out: 'in', in: 'out' };
|
||||
for (const a of adrs) {
|
||||
for (const kind of Object.keys(RELATION_SPEC)) {
|
||||
for (const dir of ['out', 'in']) {
|
||||
// The ratification guard applies to the OUT direction only: an unratified
|
||||
// ADR's claim over someone else is prospective and must not obligate the
|
||||
// target. The IN direction is this ADR's statement about ITSELF ("I am
|
||||
// superseded by X") and is always owed a reciprocal — guarding it too
|
||||
// would skip every Superseded ADR (statusToken !== 'Accepted') and leave
|
||||
// dangling one-way claims unchecked, which is the bug this gate exists
|
||||
// to catch.
|
||||
if (dir === 'out' && a.statusToken !== 'Accepted') continue;
|
||||
for (const target of new Set(a.relations[kind][dir].flatMap((r) => r.links))) {
|
||||
const b = byFile.get(target);
|
||||
if (!b) continue;
|
||||
const back = new Set(b.relations[kind][OPPOSITE[dir]].flatMap((r) => r.links));
|
||||
if (back.has(a.file)) continue;
|
||||
const needed = RELATION_SPEC[kind][OPPOSITE[dir]];
|
||||
const claim = dir === 'out' ? `it ${kind} this ADR` : `it is ${kind === 'supersedes' ? 'superseded' : 'subsumed'} by this ADR`;
|
||||
add(
|
||||
target,
|
||||
`${a.file} declares ${claim}, but this ADR does not record it. ` +
|
||||
`Add \`- **${needed}:** [ADR-${a.displayId}](${a.file})\` so a reader of THIS file learns the decision moved on.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
const GROUPS = [
|
||||
{
|
||||
heading: 'Active decisions',
|
||||
blurb: 'These govern the system as it stands. Cite these.',
|
||||
match: (a) => a.statusToken === 'Accepted',
|
||||
},
|
||||
{
|
||||
heading: 'Proposed',
|
||||
blurb: 'Decided in principle, not yet ratified. Do not cite as settled architecture.',
|
||||
match: (a) => a.statusToken === 'Proposed',
|
||||
},
|
||||
{
|
||||
heading: 'Superseded, Retired, and Legacy',
|
||||
blurb: 'Historical record. **Do not follow these** — each names what replaced it, or why it was retired.',
|
||||
match: (a) => ['Superseded', 'Retired', 'Legacy'].includes(a.statusToken),
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Render ADR-authored text (a title) into a markdown table cell.
|
||||
*
|
||||
* Three hazards, all from text this script does not control:
|
||||
* - `|` would split the cell and corrupt the row.
|
||||
* - An HTML comment would be emitted verbatim into README.md. A title
|
||||
* containing the END marker relocates it, so the NEXT `--write` splices
|
||||
* against the wrong boundary and silently eats the rest of the file.
|
||||
* Escaping `<`/`>` makes a comment sequence unformable, which also blocks
|
||||
* any other HTML injected through a title.
|
||||
* - A backslash is markdown's own escape character, so it MUST be escaped
|
||||
* first. Escaping `|` → `\|` without it turns the input `\|` into `\\|`,
|
||||
* which markdown reads as a literal backslash followed by an UNESCAPED
|
||||
* pipe — re-opening the cell break the pipe escape exists to prevent.
|
||||
* Order is load-bearing: backslash first, then everything that emits one.
|
||||
*/
|
||||
function cellText(text) {
|
||||
return String(text)
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/\|/g, '\\|')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\r?\n/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function linkCell(files, byFile) {
|
||||
if (files.length === 0) return '—';
|
||||
return files.map((l) => `[ADR-${(byFile.get(l) || {}).displayId || '?'}](${l})`).join(', ');
|
||||
}
|
||||
|
||||
function renderIndex(corpus) {
|
||||
const { byFile } = corpus;
|
||||
const out = [START_MARKER, ''];
|
||||
|
||||
for (const g of GROUPS) {
|
||||
const rows = corpus.adrs.filter(g.match).sort((x, y) => Number(x.fileId) - Number(y.fileId));
|
||||
if (rows.length === 0) continue;
|
||||
|
||||
out.push(`### ${g.heading} (${rows.length})`, '', g.blurb, '');
|
||||
const isHistorical = g.heading.startsWith('Superseded');
|
||||
// "Read first" points at the broader ADR that now frames this one. It is how a
|
||||
// reader of a still-Accepted component decision (e.g. the runtime descriptor)
|
||||
// discovers the wider decision that reframed it (e.g. EoS) instead of assuming
|
||||
// the component IS the architecture.
|
||||
out.push(
|
||||
isHistorical ? '| ADR | Title | Status | Replaced by |' : '| ADR | Title | Status | Read first |',
|
||||
isHistorical ? '|-----|-------|--------|-------------|' : '|-----|-------|--------|------------|',
|
||||
);
|
||||
for (const a of rows) {
|
||||
const cells = [`[ADR-${a.displayId}](${a.file})`, cellText(a.title), a.statusToken];
|
||||
cells.push(
|
||||
isHistorical
|
||||
? linkCell([...new Set(a.relations.supersedes.in.flatMap((r) => r.links))], byFile)
|
||||
: linkCell([...new Set(a.relations.subsumes.in.flatMap((r) => r.links))], byFile),
|
||||
);
|
||||
out.push(`| ${cells.join(' | ')} |`);
|
||||
}
|
||||
out.push('');
|
||||
}
|
||||
|
||||
out.push(
|
||||
`_${corpus.adrs.length} ADRs. Generated by \`scripts/gen-adr-index.cjs\` — run \`--write\` after adding or restatusing an ADR._`,
|
||||
'',
|
||||
END_MARKER,
|
||||
);
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
function spliceIntoReadme(readme, index) {
|
||||
const start = readme.indexOf(START_MARKER);
|
||||
const end = readme.indexOf(END_MARKER);
|
||||
if (start === -1 || end === -1) {
|
||||
throw new ExitError(
|
||||
1,
|
||||
`docs/adr/README.md is missing the index markers.\nExpected:\n ${START_MARKER}\n ${END_MARKER}\n`,
|
||||
);
|
||||
}
|
||||
return readme.slice(0, start) + index + readme.slice(end + END_MARKER.length);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const [, , flag] = process.argv;
|
||||
|
||||
const corpus = buildCorpus();
|
||||
const errors = validate(corpus);
|
||||
|
||||
if (errors.length > 0 && flag !== '--write') {
|
||||
process.stderr.write(
|
||||
`docs/adr/ has ${errors.length} lifecycle violation(s).\n` +
|
||||
'See docs/adr/README.md "Lifecycle rules" for the contract.\n\n',
|
||||
);
|
||||
for (const e of errors) process.stderr.write(` ✗ ${e}\n`);
|
||||
process.stderr.write('\n');
|
||||
throw new ExitError(1);
|
||||
}
|
||||
|
||||
const index = renderIndex(corpus);
|
||||
|
||||
if (flag === '--check') {
|
||||
const readme = fs.readFileSync(README_PATH, 'utf8');
|
||||
const expected = spliceIntoReadme(readme, index);
|
||||
if (expected !== readme) {
|
||||
process.stderr.write(
|
||||
'docs/adr/README.md index is stale. Run:\n node scripts/gen-adr-index.cjs --write\n\n',
|
||||
);
|
||||
throw new ExitError(1);
|
||||
}
|
||||
process.stdout.write(`docs/adr/README.md index is up to date (${corpus.adrs.length} ADRs).\n`);
|
||||
} else if (flag === '--write') {
|
||||
const readme = fs.readFileSync(README_PATH, 'utf8');
|
||||
fs.writeFileSync(README_PATH, spliceIntoReadme(readme, index));
|
||||
process.stdout.write(`Wrote ADR index into ${README_PATH} (${corpus.adrs.length} ADRs).\n`);
|
||||
if (errors.length > 0) {
|
||||
process.stderr.write(`\n${errors.length} lifecycle violation(s) remain — --check will fail:\n\n`);
|
||||
for (const e of errors) process.stderr.write(` ✗ ${e}\n`);
|
||||
}
|
||||
} else {
|
||||
process.stdout.write(index + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
runMain(main);
|
||||
@@ -16,11 +16,19 @@ const _require: NodeRequire = require;
|
||||
// works in every layout:
|
||||
//
|
||||
// 1. Co-located install path — gsd-core/bin/shared/model-catalog.json
|
||||
// 2. Source-repo dev path — sdk/shared/model-catalog.json
|
||||
// 3. GSD_MODEL_CATALOG env override
|
||||
// 2. GSD_MODEL_CATALOG env override
|
||||
//
|
||||
// A third candidate — `sdk/shared/model-catalog.json`, three levels up — used to
|
||||
// sit between them. It was the legacy source-repo path kept as a fallback by the
|
||||
// #3288 fix, whose contract was "check the co-located path FIRST, before the
|
||||
// legacy source-repo path". ADR-0174 then retired the `@opengsd/gsd-sdk` package
|
||||
// boundary and deleted the `sdk/` tree outright, so that candidate can no longer
|
||||
// resolve in any layout: in a source repo there is no `sdk/`, and in an install
|
||||
// layout it points at `~/.claude/sdk/shared/`, which the installer never writes
|
||||
// (the original #3288 bug). It is removed rather than left as dead weight that
|
||||
// implies a package boundary this repo no longer has.
|
||||
const _catalogCandidates: string[] = [
|
||||
path.resolve(__dirname, '..', 'shared', 'model-catalog.json'),
|
||||
path.resolve(__dirname, '..', '..', '..', 'sdk', 'shared', 'model-catalog.json'),
|
||||
...(process.env['GSD_MODEL_CATALOG'] ? [path.resolve(process.env['GSD_MODEL_CATALOG'])] : []),
|
||||
];
|
||||
|
||||
|
||||
438
tests/adr-index-gate.test.cjs
Normal file
438
tests/adr-index-gate.test.cjs
Normal file
@@ -0,0 +1,438 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Behavioral tests for scripts/gen-adr-index.cjs — the ADR index generator and
|
||||
* lifecycle gate (#2340).
|
||||
*
|
||||
* These drive the real CLI as a subprocess against synthetic ADR corpora in a
|
||||
* temp dir, asserting on exit code and emitted text. No source-grepping: the
|
||||
* runtime behavior is the contract.
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const SCRIPT_REL = path.join('scripts', 'gen-adr-index.cjs');
|
||||
|
||||
const START = '<!-- ADR-INDEX:START — generated by scripts/gen-adr-index.cjs; do not edit by hand -->';
|
||||
const END = '<!-- ADR-INDEX:END -->';
|
||||
|
||||
/**
|
||||
* Build a throwaway repo whose docs/adr/ contains exactly `files`, and whose
|
||||
* scripts/ holds a copy of the generator + its cli-exit dependency. A unique
|
||||
* mkdtemp per call keeps parallel tests from colliding, and the dir is removed
|
||||
* via `t.after()` so a failing assertion cannot leak it.
|
||||
*/
|
||||
function makeRepo(t, files) {
|
||||
// helpers.cleanup (not raw fs.rmSync) carries the Windows-EBUSY retry budget.
|
||||
const root = createTempDir('gsd-adr-index-');
|
||||
t.after(() => cleanup(root));
|
||||
fs.mkdirSync(path.join(root, 'docs', 'adr'), { recursive: true });
|
||||
fs.mkdirSync(path.join(root, 'scripts', 'lib'), { recursive: true });
|
||||
|
||||
fs.copyFileSync(path.join(REPO_ROOT, SCRIPT_REL), path.join(root, SCRIPT_REL));
|
||||
fs.copyFileSync(
|
||||
path.join(REPO_ROOT, 'scripts', 'lib', 'cli-exit.cjs'),
|
||||
path.join(root, 'scripts', 'lib', 'cli-exit.cjs'),
|
||||
);
|
||||
|
||||
for (const [name, body] of Object.entries(files)) {
|
||||
fs.writeFileSync(path.join(root, 'docs', 'adr', name), body);
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'docs', 'adr', 'README.md'),
|
||||
`# ADRs\n\n## Index\n\n${START}\n${END}\n`,
|
||||
);
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the generator in `root`; never throws — returns {status, stdout, stderr}.
|
||||
*
|
||||
* spawnSync (not execFileSync) because BOTH streams matter on BOTH outcomes:
|
||||
* `--write` exits 0 while reporting outstanding violations on stderr, and
|
||||
* execFileSync only surfaces stderr via the thrown error on non-zero exit.
|
||||
*/
|
||||
function run(root, args = []) {
|
||||
const res = spawnSync(process.execPath, [path.join(root, SCRIPT_REL), ...args], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
if (res.error) throw res.error;
|
||||
return { status: res.status, stdout: res.stdout || '', stderr: res.stderr || '' };
|
||||
}
|
||||
|
||||
const adr = (title, fields) => `# ${title}\n\n${fields.map((f) => `- ${f}`).join('\n')}\n\n## Context\n\nBody.\n`;
|
||||
|
||||
test('a clean corpus generates an index and --check passes', (t) => {
|
||||
const root = makeRepo(t, {
|
||||
'0001-alpha.md': adr('Alpha module', ['**Status:** Accepted', '**Date:** 2026-01-01']),
|
||||
'900-beta.md': adr('ADR-900: Beta module', ['**Status:** Proposed', '**Date:** 2026-01-02']),
|
||||
});
|
||||
|
||||
const write = run(root, ['--write']);
|
||||
assert.equal(write.status, 0, `--write failed: ${write.stderr}`);
|
||||
|
||||
const check = run(root, ['--check']);
|
||||
assert.equal(check.status, 0, `--check failed: ${check.stderr}`);
|
||||
|
||||
const readme = fs.readFileSync(path.join(root, 'docs', 'adr', 'README.md'), 'utf8');
|
||||
assert.match(readme, /\[ADR-0001\]\(0001-alpha\.md\)/, 'zero-padded id must render as written, not ADR-1');
|
||||
assert.match(readme, /\[ADR-900\]\(900-beta\.md\)/);
|
||||
assert.match(readme, /Active decisions \(1\)/);
|
||||
assert.match(readme, /Proposed \(1\)/);
|
||||
});
|
||||
|
||||
test('--check fails when an ADR is added but the index is not regenerated', (t) => {
|
||||
const root = makeRepo(t, { '0001-alpha.md': adr('Alpha', ['**Status:** Accepted']) });
|
||||
assert.equal(run(root, ['--write']).status, 0);
|
||||
|
||||
// A new ADR lands without re-running --write. This is the exact drift that let
|
||||
// the hand-maintained index reach 40/65.
|
||||
fs.writeFileSync(path.join(root, 'docs', 'adr', '901-gamma.md'), adr('Gamma', ['**Status:** Accepted']));
|
||||
|
||||
const check = run(root, ['--check']);
|
||||
assert.equal(check.status, 1, 'a missing index row must fail CI');
|
||||
assert.match(check.stderr, /stale/i);
|
||||
});
|
||||
|
||||
test('a status outside the vocabulary is rejected and names the offender', (t) => {
|
||||
const root = makeRepo(t, { '0001-alpha.md': adr('Alpha', ['**Status:** Draft']) });
|
||||
const res = run(root, ['--check']);
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stderr, /0001-alpha\.md/);
|
||||
assert.match(res.stderr, /"Draft" is not one of/);
|
||||
});
|
||||
|
||||
test('an ADR with no status field at all is rejected', (t) => {
|
||||
const root = makeRepo(t, { '0001-alpha.md': '# Alpha\n\nNo header fields.\n\n## Context\n\nBody.\n' });
|
||||
const res = run(root, ['--check']);
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stderr, /no `- \*\*Status:\*\* <Token>` field/);
|
||||
});
|
||||
|
||||
test('the table header form is parsed as legitimately as the bullet form', (t) => {
|
||||
// ADR-2008 uses a markdown table for its header. Treating that as "missing a
|
||||
// status" would flag a correct ADR.
|
||||
const root = makeRepo(t, {
|
||||
'0001-alpha.md': '# Alpha\n\n| | |\n|---|---|\n| **Status** | Accepted |\n| **Date** | 2026-01-01 |\n\n## Context\n\nBody.\n',
|
||||
});
|
||||
const res = run(root, ['--write']);
|
||||
assert.equal(res.status, 0, `table-form header must parse: ${res.stderr}`);
|
||||
assert.match(fs.readFileSync(path.join(root, 'docs', 'adr', 'README.md'), 'utf8'), /Active decisions \(1\)/);
|
||||
});
|
||||
|
||||
test('Superseded must name its successor as a file link, not a bare id', (t) => {
|
||||
const root = makeRepo(t, {
|
||||
'0001-alpha.md': adr('Alpha', ['**Status:** Superseded by ADR-900 (2026-02-01)']),
|
||||
'900-beta.md': adr('Beta', ['**Status:** Accepted', '**Supersedes:** [ADR-0001](0001-alpha.md)']),
|
||||
});
|
||||
const res = run(root, ['--check']);
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stderr, /not as a markdown link/);
|
||||
});
|
||||
|
||||
test('Superseded with no successor at all is rejected', (t) => {
|
||||
const root = makeRepo(t, { '0001-alpha.md': adr('Alpha', ['**Status:** Superseded']) });
|
||||
const res = run(root, ['--check']);
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stderr, /names no successor/);
|
||||
});
|
||||
|
||||
test('a one-way supersession is rejected and the message names the fix', (t) => {
|
||||
const root = makeRepo(t, {
|
||||
// Beta claims Alpha; Alpha says nothing back.
|
||||
'900-beta.md': adr('Beta', ['**Status:** Accepted', '**Supersedes:** [ADR-0001](0001-alpha.md)']),
|
||||
'0001-alpha.md': adr('Alpha', ['**Status:** Accepted']),
|
||||
});
|
||||
const res = run(root, ['--check']);
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stderr, /0001-alpha\.md/);
|
||||
assert.match(res.stderr, /does not record it/);
|
||||
assert.match(res.stderr, /\*\*Superseded by:\*\* \[ADR-900\]\(900-beta\.md\)/);
|
||||
});
|
||||
|
||||
test('a symmetric supersession pair passes', (t) => {
|
||||
const root = makeRepo(t, {
|
||||
'900-beta.md': adr('Beta', ['**Status:** Accepted', '**Supersedes:** [ADR-0001](0001-alpha.md)']),
|
||||
'0001-alpha.md': adr('Alpha', ['**Status:** Superseded by [ADR-900](900-beta.md) (2026-02-01)']),
|
||||
});
|
||||
assert.equal(run(root, ['--check']).status, 1, 'index not yet written');
|
||||
assert.equal(run(root, ['--write']).status, 0);
|
||||
assert.equal(run(root, ['--check']).status, 0, 'a symmetric pair must pass');
|
||||
});
|
||||
|
||||
test('subsumption is symmetry-checked but does NOT mark the target superseded', (t) => {
|
||||
// The EoS case: ADR-1239 subsumes ADR-1016 as an adapter. ADR-1016 stays
|
||||
// Accepted — collapsing this into supersession would kill a live decision.
|
||||
const root = makeRepo(t, {
|
||||
'900-eos.md': adr('EoS', ['**Status:** Accepted', '**Subsumes as adapters:** [ADR-0001](0001-alpha.md)']),
|
||||
'0001-alpha.md': adr('Alpha', ['**Status:** Accepted', '**Subsumed by:** [ADR-900](900-eos.md)']),
|
||||
});
|
||||
assert.equal(run(root, ['--write']).status, 0);
|
||||
const readme = fs.readFileSync(path.join(root, 'docs', 'adr', 'README.md'), 'utf8');
|
||||
assert.match(readme, /Active decisions \(2\)/, 'a subsumed ADR stays Active');
|
||||
// The subsumer is surfaced in the "Read first" column so EoS is discoverable
|
||||
// from the component ADR.
|
||||
assert.match(readme, /\| \[ADR-0001\]\(0001-alpha\.md\) \|[^|]*\| Accepted \| \[ADR-900\]\(900-eos\.md\) \|/);
|
||||
});
|
||||
|
||||
test('a missing subsumption back-link is rejected', (t) => {
|
||||
const root = makeRepo(t, {
|
||||
'900-eos.md': adr('EoS', ['**Status:** Accepted', '**Subsumes:** [ADR-0001](0001-alpha.md)']),
|
||||
'0001-alpha.md': adr('Alpha', ['**Status:** Accepted']),
|
||||
});
|
||||
const res = run(root, ['--check']);
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stderr, /\*\*Subsumed by:\*\* \[ADR-900\]\(900-eos\.md\)/);
|
||||
});
|
||||
|
||||
test("a Proposed ADR's supersession claim is prospective — no back-link demanded", (t) => {
|
||||
// ADR-857 is Proposed and claims to generalize live ADRs. Demanding the
|
||||
// back-link would stamp an Accepted decision as superseded by an unratified one.
|
||||
const root = makeRepo(t, {
|
||||
'900-beta.md': adr('Beta', ['**Status:** Proposed', '**Supersedes:** [ADR-0001](0001-alpha.md)']),
|
||||
'0001-alpha.md': adr('Alpha', ['**Status:** Accepted']),
|
||||
});
|
||||
assert.equal(run(root, ['--write']).status, 0);
|
||||
const check = run(root, ['--check']);
|
||||
assert.equal(check.status, 0, `a Proposed claimant must not force a back-link: ${check.stderr}`);
|
||||
});
|
||||
|
||||
test('ratifying that Proposed ADR to Accepted then demands the back-link', (t) => {
|
||||
const root = makeRepo(t, {
|
||||
'900-beta.md': adr('Beta', ['**Status:** Accepted', '**Supersedes:** [ADR-0001](0001-alpha.md)']),
|
||||
'0001-alpha.md': adr('Alpha', ['**Status:** Accepted']),
|
||||
});
|
||||
const res = run(root, ['--check']);
|
||||
assert.equal(res.status, 1, 'on ratification the back-link becomes required');
|
||||
assert.match(res.stderr, /does not record it/);
|
||||
});
|
||||
|
||||
test('"Supersedes: nothing" asserts no relation even when it name-drops an ADR', (t) => {
|
||||
// ADR-2264 says "Supersedes: nothing; amends the ADR-1239 harness". Reading that
|
||||
// as a supersession claim invents a link the author never made.
|
||||
const root = makeRepo(t, {
|
||||
'900-beta.md': adr('Beta', ['**Status:** Accepted', '**Supersedes:** nothing; amends the [ADR-0001](0001-alpha.md) harness']),
|
||||
'0001-alpha.md': adr('Alpha', ['**Status:** Accepted']),
|
||||
});
|
||||
assert.equal(run(root, ['--write']).status, 0);
|
||||
const check = run(root, ['--check']);
|
||||
assert.equal(check.status, 0, `a negated relation field must assert nothing: ${check.stderr}`);
|
||||
});
|
||||
|
||||
test('an em-dash relation value asserts no relation', (t) => {
|
||||
// Vacuous unless the negated field also carries a LINK: with a bare em-dash
|
||||
// there is nothing to mis-parse, so the test passes whether or not negation
|
||||
// works. Linking an ADR after the em-dash makes it discriminating — if the
|
||||
// field were read as a real claim, symmetry would demand 0001 record it.
|
||||
const root = makeRepo(t, {
|
||||
'900-beta.md': '# Beta\n\n| | |\n|---|---|\n| **Status** | Accepted |\n| **Supersedes** | — see [ADR-0001](0001-alpha.md) for context |\n\n## Context\n\nBody.\n',
|
||||
'0001-alpha.md': adr('Alpha', ['**Status:** Accepted']),
|
||||
});
|
||||
assert.equal(run(root, ['--write']).status, 0);
|
||||
const check = run(root, ['--check']);
|
||||
assert.equal(check.status, 0, `an em-dash field must assert nothing: ${check.stderr}`);
|
||||
assert.doesNotMatch(check.stderr, /does not record it/);
|
||||
});
|
||||
|
||||
test('a mixed field with one link and one bare id still flags the bare id', (t) => {
|
||||
// Regression: testing `rel.links.length` instead of the specific id meant a
|
||||
// field carrying ANY link silently dropped every bare claim beside it.
|
||||
const root = makeRepo(t, {
|
||||
'900-beta.md': adr('Beta', ['**Status:** Accepted', '**Supersedes:** [ADR-0001](0001-alpha.md), ADR-0002']),
|
||||
'0001-alpha.md': adr('Alpha', ['**Status:** Superseded by [ADR-900](900-beta.md)']),
|
||||
'0002-gamma.md': adr('Gamma', ['**Status:** Accepted']),
|
||||
});
|
||||
const res = run(root, ['--check']);
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stderr, /names ADR-2 without a file link/);
|
||||
});
|
||||
|
||||
test('a bare id repeated in prose beside its own link is not flagged', (t) => {
|
||||
// The corpus legitimately writes "…([ADR-0001](0001-alpha.md)) — see ADR-0001
|
||||
// below". That repeat must not be noise.
|
||||
const root = makeRepo(t, {
|
||||
'900-beta.md': adr('Beta', ['**Status:** Accepted', '**Supersedes:** Alpha ([ADR-0001](0001-alpha.md)) — see the ADR-0001 note below']),
|
||||
'0001-alpha.md': adr('Alpha', ['**Status:** Superseded by [ADR-900](900-beta.md)']),
|
||||
});
|
||||
assert.equal(run(root, ['--write']).status, 0);
|
||||
const check = run(root, ['--check']);
|
||||
assert.equal(check.status, 0, `a linked-and-repeated id must not be flagged: ${check.stderr}`);
|
||||
});
|
||||
|
||||
test('a dangling "Superseded by" is caught even though the ADR is not Accepted', (t) => {
|
||||
// Regression: the ratification guard skipped every non-Accepted ADR, which
|
||||
// killed the IN direction entirely — a Superseded ADR pointing at a successor
|
||||
// that never claims it went unchecked.
|
||||
const root = makeRepo(t, {
|
||||
'0001-alpha.md': adr('Alpha', ['**Status:** Superseded by [ADR-900](900-beta.md)']),
|
||||
'900-beta.md': adr('Beta', ['**Status:** Accepted']),
|
||||
});
|
||||
const res = run(root, ['--check']);
|
||||
assert.equal(res.status, 1, 'a one-way superseded-by must fail');
|
||||
assert.match(res.stderr, /does not claim it|does not record it/);
|
||||
});
|
||||
|
||||
test('a `## Supersedes` table section counts as the claim (ADR-0174 shape)', (t) => {
|
||||
// The richest form in the corpus declares supersession as a section+table, not
|
||||
// a header field. Reading only the header block reported the repo's
|
||||
// best-documented supersession as missing.
|
||||
const root = makeRepo(t, {
|
||||
'900-beta.md': '# Beta\n\n- **Status:** Accepted\n\n## Supersedes\n\n| ADR | What it said | Why superseded |\n|---|---|---|\n| [ADR-0001](0001-alpha.md) | a thing | a reason |\n\n## Context\n\nBody.\n',
|
||||
'0001-alpha.md': adr('Alpha', ['**Status:** Superseded by [ADR-900](900-beta.md)']),
|
||||
});
|
||||
assert.equal(run(root, ['--write']).status, 0);
|
||||
const check = run(root, ['--check']);
|
||||
assert.equal(check.status, 0, `a ## Supersedes table must satisfy symmetry: ${check.stderr}`);
|
||||
});
|
||||
|
||||
test('a title id that disagrees with the filename is rejected', (t) => {
|
||||
// The real ADR-218 case: renamed to the issue# convention, title left behind.
|
||||
const root = makeRepo(t, { '218-release.md': adr('ADR-0175: Harden release validation', ['**Status:** Accepted']) });
|
||||
const res = run(root, ['--check']);
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stderr, /H1 declares ADR-175 but the filename says 218/);
|
||||
});
|
||||
|
||||
test('a relation link to a nonexistent ADR is rejected', (t) => {
|
||||
const root = makeRepo(t, {
|
||||
'0001-alpha.md': adr('Alpha', ['**Status:** Accepted', '**Supersedes:** [ADR-404](404-ghost.md)']),
|
||||
});
|
||||
const res = run(root, ['--check']);
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stderr, /does not exist in docs\/adr\//);
|
||||
});
|
||||
|
||||
test('a bare id naming a nonexistent ADR is steered toward issue syntax', (t) => {
|
||||
// ADR-1610 says "superseding the #597 tier-max ratchet" — #597 is an ISSUE.
|
||||
// Written as "ADR-597" it would be an unresolvable reference.
|
||||
const root = makeRepo(t, {
|
||||
'0001-alpha.md': adr('Alpha', ['**Status:** Accepted', '**Supersedes:** ADR-597 tier-max ratchet']),
|
||||
});
|
||||
const res = run(root, ['--check']);
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stderr, /If it is an ISSUE number, write "#597"/);
|
||||
});
|
||||
|
||||
test('an ambiguous bare id reports every file it could mean', (t) => {
|
||||
const root = makeRepo(t, {
|
||||
'0011-one.md': adr('One', ['**Status:** Accepted']),
|
||||
'0011-two.md': adr('Two', ['**Status:** Accepted']),
|
||||
'900-beta.md': adr('Beta', ['**Status:** Accepted', '**Supersedes:** ADR-0011']),
|
||||
});
|
||||
const res = run(root, ['--check']);
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stderr, /ambiguous — resolves to 2 files/);
|
||||
assert.match(res.stderr, /0011-one\.md/);
|
||||
assert.match(res.stderr, /0011-two\.md/);
|
||||
});
|
||||
|
||||
test('--check fails loudly when the README markers are missing', (t) => {
|
||||
const root = makeRepo(t, { '0001-alpha.md': adr('Alpha', ['**Status:** Accepted']) });
|
||||
fs.writeFileSync(path.join(root, 'docs', 'adr', 'README.md'), '# ADRs\n\nNo markers here.\n');
|
||||
const res = run(root, ['--check']);
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stderr, /missing the index markers/);
|
||||
});
|
||||
|
||||
test('--write still emits the index while reporting outstanding violations', (t) => {
|
||||
// --write must remain usable as a repair tool on a corpus that is not yet clean,
|
||||
// but must not pretend the corpus is healthy.
|
||||
const root = makeRepo(t, {
|
||||
'900-beta.md': adr('Beta', ['**Status:** Accepted', '**Supersedes:** [ADR-0001](0001-alpha.md)']),
|
||||
'0001-alpha.md': adr('Alpha', ['**Status:** Accepted']),
|
||||
});
|
||||
const res = run(root, ['--write']);
|
||||
assert.equal(res.status, 0, '--write proceeds');
|
||||
assert.match(res.stderr, /lifecycle violation\(s\) remain/);
|
||||
assert.match(fs.readFileSync(path.join(root, 'docs', 'adr', 'README.md'), 'utf8'), /Active decisions \(2\)/);
|
||||
});
|
||||
|
||||
test('an ADR title cannot hijack the README splice with an index marker', (t) => {
|
||||
// Review finding: a title carrying the literal END marker was emitted verbatim
|
||||
// into the table cell, relocating the boundary so the NEXT --write spliced
|
||||
// against the wrong marker and ate the rest of README.md.
|
||||
const root = makeRepo(t, {
|
||||
'0001-alpha.md': adr(`Evil ${END} title`, ['**Status:** Accepted']),
|
||||
});
|
||||
assert.equal(run(root, ['--write']).status, 0);
|
||||
|
||||
const readme = fs.readFileSync(path.join(root, 'docs', 'adr', 'README.md'), 'utf8');
|
||||
const endCount = readme.split(END).length - 1;
|
||||
assert.equal(endCount, 1, 'exactly one END marker must survive — the title must not forge another');
|
||||
|
||||
// The splice must remain stable across repeated writes.
|
||||
assert.equal(run(root, ['--write']).status, 0);
|
||||
const again = fs.readFileSync(path.join(root, 'docs', 'adr', 'README.md'), 'utf8');
|
||||
assert.equal(again.split(END).length - 1, 1);
|
||||
assert.equal(run(root, ['--check']).status, 0, 'a hostile title must not leave the index permanently stale');
|
||||
});
|
||||
|
||||
test('a title cannot inject raw HTML into the generated index', (t) => {
|
||||
const root = makeRepo(t, {
|
||||
'0001-alpha.md': adr('Alpha <script>x</script> module', ['**Status:** Accepted']),
|
||||
});
|
||||
assert.equal(run(root, ['--write']).status, 0);
|
||||
const readme = fs.readFileSync(path.join(root, 'docs', 'adr', 'README.md'), 'utf8');
|
||||
assert.ok(!readme.includes('<script>'), 'angle brackets must be escaped, not emitted raw');
|
||||
assert.match(readme, /<script>/);
|
||||
});
|
||||
|
||||
test('a backslash-pipe in a title cannot break out of its table cell', (t) => {
|
||||
// CodeQL js/incomplete-sanitization: escaping `|` -> `\|` without escaping the
|
||||
// backslash FIRST turns the input `\|` into `\\|`, which markdown reads as a
|
||||
// literal backslash plus an UNESCAPED pipe — re-opening the very cell break the
|
||||
// pipe escape exists to prevent.
|
||||
const root = makeRepo(t, {
|
||||
'0001-alpha.md': adr('Alpha \\| Accepted \\| forged', ['**Status:** Proposed']),
|
||||
});
|
||||
assert.equal(run(root, ['--write']).status, 0);
|
||||
const readme = fs.readFileSync(path.join(root, 'docs', 'adr', 'README.md'), 'utf8');
|
||||
|
||||
const row = readme.split(/\r?\n/).find((l) => l.includes('0001-alpha.md'));
|
||||
assert.ok(row, 'the ADR must still have a row');
|
||||
// 4 pipes = the row's own delimiters (| id | title | status | read-first |) = 5.
|
||||
// Any unescaped pipe from the title would add a 6th boundary and shift the cells.
|
||||
const unescaped = [...row.matchAll(/(?<!\\)\|/g)].length;
|
||||
assert.equal(unescaped, 5, `title pipes must stay escaped; row was: ${row}`);
|
||||
assert.match(readme, /Proposed \(1\)/, 'the forged cell must not land the ADR in Active');
|
||||
});
|
||||
|
||||
test('a pipe in a title cannot break out of its table cell', (t) => {
|
||||
const root = makeRepo(t, {
|
||||
'0001-alpha.md': adr('Alpha | Accepted | fake', ['**Status:** Proposed']),
|
||||
});
|
||||
assert.equal(run(root, ['--write']).status, 0);
|
||||
const readme = fs.readFileSync(path.join(root, 'docs', 'adr', 'README.md'), 'utf8');
|
||||
assert.match(readme, /Alpha \\\| Accepted \\\| fake/, 'pipes must be escaped');
|
||||
assert.match(readme, /Proposed \(1\)/, 'the forged cell must not land the ADR in Active');
|
||||
});
|
||||
|
||||
test('a file that does not match the naming convention is reported, not crashed on', (t) => {
|
||||
// Review finding: `notes.md` hit `file.match(/^([0-9]+)-/)[1]` → TypeError on
|
||||
// null. An unparseable name is also invisible to the index — the exact failure
|
||||
// this gate exists to prevent — so it must surface as a violation.
|
||||
const root = makeRepo(t, {
|
||||
'0001-alpha.md': adr('Alpha', ['**Status:** Accepted']),
|
||||
'notes.md': '# Scratch notes\n\nNot an ADR.\n',
|
||||
});
|
||||
const res = run(root, ['--check']);
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stderr, /notes\.md/);
|
||||
assert.match(res.stderr, /does not match the .*convention/);
|
||||
assert.doesNotMatch(res.stderr, /TypeError|Cannot read propert/, 'must be a gate violation, not a crash');
|
||||
});
|
||||
|
||||
test('the real repo corpus is clean and its index is current', () => {
|
||||
// The gate must hold against docs/adr/ as committed, not only fixtures.
|
||||
const res = run(REPO_ROOT, ['--check']);
|
||||
assert.equal(res.status, 0, `docs/adr/ must satisfy its own gate:\n${res.stderr}`);
|
||||
});
|
||||
Reference in New Issue
Block a user