Skip to content

Latest commit

 

History

History
1340 lines (1252 loc) · 86.1 KB

AGENTS.md

File metadata and controls

1340 lines (1252 loc) · 86.1 KB

adrkit

Decision memory for human- and agent-authored plans — machine-readable ADRs that are enforceable in CI and legible to agents, without leaving git. Status: early — phases 0–6 landed and v0.20.0 is public. @adrkit/core, @adrkit/evaluator, @adrkit/cli (lint, new, graph, explain, check, queue, accept, approve, object, resolve, migrate --from madr, evaluate) are published on npm, as is the independently versioned @adrkit/spec-kit Spec Kit extension (0.1.5); the repository-backed CI Action is available at mbeacom/adrkit/packages/ci@v0, and the build-only decision-portal Action at mbeacom/adrkit/packages/ci/portal@v0.20.0 (pin an immutable tag, never @v0). The governing-decisions Action also has a root action.yml alias for GitHub Marketplace beginning with v0.13.0. Root Marketplace guidance uses immutable release tags; never advertise mbeacom/adrkit@v0, because recovery may move that shared tag to a pre-alias release. The queue Action remains nested because GitHub lists only root Action metadata. The published @adrkit/mcp server has exactly four local stdio tools and serves both MCP protocol eras over one stdio connection — 2026-07-28 (stateless; opened by server/discover or a 2026 _meta envelope) and the 2025 era (opened by initialize) — via the SDK v2 serveStdio entry (ADR-0018). It passed real-session dogfood against the published artifact on both eras, driven through the official MCP Inspector. The Inspector defaults to the 2025 era; select the modern one with "protocolEra": "modern" (or "auto") in the server's entry in the Inspector's mcp.json; Inspector 2.8.0 (verified 2026-09-27) also accepts --protocol-era legacy|auto|modern in CLI mode.

Visual adr graph

adr graph preserves agent/script compatibility while giving interactive users a useful view under ADR-0033. Its default is auto: stdout attached to a TTY receives the terminal status/relationship instrument; piped, redirected, and captured stdout still receives deterministic DOT. Explicit --format terminal|dot|json|mermaid always wins. --focus <id> keeps one ADR and its direct neighborhood; repeatable --kind supersedes|relatesTo|conflictsWith filters every format.

Three boundaries are load-bearing:

  • TTY detection belongs only at the CLI boundary. buildAdrGraph, filterAdrGraph, and every core renderer stay pure; JSON retains its existing { nodes, edges } contract.
  • Full dense corpora are summarized in the terminal instead of being rendered as an unreadable network. Use --focus or --kind to expand a useful subgraph. Native SVG/HTML is deliberately deferred; polished DOT remains the dependency-free path to Graphviz SVG.
  • Terminal views have node and relationship budgets, and title truncation uses grapheme-safe display width rather than UTF-16 length. Valid records still produce complete DOT/JSON/Mermaid output when another corpus record is invalid, but graph writes those error findings to stderr and exits 1.

Inbound @adr markers (v0.5.0: explain, check, and CI)

A file can declare the decision it lives under by putting @adr 0012 on a dedicated comment line inside its first 8192 bytes. v0.4.0 shipped that inbound edge for adr explain <path> under ADR-0021 and #97. v0.5.0 extended the same resolution to adr check and the governing-decisions Action under ADR-0022, which supersedes ADR-0021 — read 0022, not 0021, for the current scope. No schema change: AdrFrontmatter, AffectsType, and schema/adr.schema.json are untouched.

Two properties are load-bearing and easy to break:

  • Marker I/O stays outside the pure resolvers. adr explain, adr check, and the CI Action scan at their filesystem boundaries; checkChanges accepts pre-scanned markerScans and remains pure. declaredBy lives on the shared GoverningDecision, so both check --json and the Action can report marker provenance. Markers add governance context and findings but never gain exit-code authority. packages/adapters/spec-kit/scripts/context.sh delegates path-aware context to adr check, so it inherits that marker scan without implementing a separate reader.
  • PR-authored declarations are capped before resolution. A file retains the first 64 parsed declarations in physical/source order; a batch retains the first 10,000 in code-unit path then source order after concurrent reads finish. Exact overflow is collapsed into one advisory report/finding, never one object per dropped declaration, and never gains exit-code authority.
  • A marker naming history stays historical and gets an advisory warning. superseded, rejected, and deprecated declarations emit stale-marker; a resolvable supersession chain names its terminal live successor, but the resolver never silently substitutes that record. The original declaration remains under history, and the warning never changes an exit code.
  • A marker must not be able to lie. The scanner requires the comment introducer to begin the physical line with @adr as the comment's first content, so prose discussing a decision, a string literal containing one, and a trailing } // @adr 0012 are all rejected. Two further rules narrow that to lines the file's own format hides (ADR-0023, #101): a line inside a ``` or ~~~ fence is an example rather than a declaration, and in .md/.mdx/.markdown the only introducers are <!-- and {/*, because # and * are markdown's heading and bullet rather than comments. Both are line-lead rules, and both only ever remove a declaration — the one addition is {/*, which previously could not declare anywhere. path consequently selects the introducer set, so the pure scanner's result is no longer a function of the text alone. Truncation uses the byte count read.ts observed rather than re-deriving it from decoded text, because TextDecoder drops a BOM and expands invalid bytes, and a re-derived window can sever a reference mid-token and report a record the file never named.

Unlike the surfaces below, this is at rung 1 of ADR-0014 only — unit, contract, and purity coverage plus maintainer verification. No reference-repository run.

Time travel (adr explain --as-of)

adr explain <path> --as-of <date|ref> answers which decisions governed a path on a past date, under ADR-0039 (accepted, part B of #116; part A shipped in #187). A valid-time window opens at a record's own date and closes at its immediate successor's — not the terminal one, which would report a record in force for its successor's whole tenure. No schema change.

Five things are load-bearing and easy to break:

  • The as-of view has its own bucketing. decisionBucketFor sends superseded to history unconditionally, which is right in the present tense and exactly wrong here. Routing --as-of through bucketDecisions produces a command that runs, passes its tests, and decorates rather than answers. Standings are governing, activeProposals, history, notYetRecorded, and undetermined, and windows are half-open so the successor owns its own start day.
  • deprecated is undetermined, never guessed. adr.schema.ts allows supersededBy only on superseded, so a deprecated record carries no close date anywhere in frontmatter. Treating it as "open from date" would assert it governed in a year it may not have. rejected is history on every date, because it was in force on none. Both emit advisory findings with no exit-code authority.
  • --as-of re-dates the corpus, never the working tree. affects patterns come from today's records and @adr markers from today's files. Reading file contents at a past ref needs rename tracking and a blob read, and is deliberately out of scope — it is the boundary #116's author named when deferring part B. The human view prints a note saying so, because the evidence lines are the one thing that is not re-dated; do not delete it as noise.
  • A marker accurate on that date is not stale. resolveSourceMarkers takes an optional asOf, and suppresses stale-marker for a record that was in force then. Without it, the same output tells you to fix a marker while reporting its record as governing. Absent the option, behavior is unchanged — adr check and the CI Action never pass it.
  • The kernel is pure; git lives at the CLI boundary. packages/core/src/temporal/** has no clock, no filesystem, and no subprocess — the date is always an argument. packages/cli/src/as-of.ts holds the only subprocess in @adrkit/cli, tries the date grammar before a git ref (so a tag named 2026-03-01 reads as a date), peels with rev-parse --verify <ref>^{commit}, and dates with the committer date (%cI, a stated choice). Inherited git config is a hazard on both sides: tests that shell out to git must set GIT_CONFIG_GLOBAL=/dev/null, because a developer with tag.gpgSign = true globally otherwise hangs the suite on a passphrase prompt; and git show must keep --no-show-signature, because a user with log.showSignature = true and a signed commit otherwise gets Good "git" signature for … prepended to stdout and --as-of HEAD fails with a misleading "could not resolve". Both were found by running the code.
  • Shipped source must be Node-compatible. packages/cli/src, packages/core/src, and packages/evaluator/src build with --target=node, and bun build does not shim the Bun global — it emits the reference verbatim. A Bun.spawn there is a ReferenceError in every published install while the whole Bun-run suite stays green. as-of.ts shipped exactly that once, behind a catch that reported it as "git is not installed", so the subprocess wrapper uses node:child_process and catches only ENOENT. packages/cli/test/node-compatibility.test.ts enforces the rule; adapters under packages/adapters/* are Bun-only and exempt, which is why copying runGit from one of them was wrong.
  • An inverted window is not an interval. isInvertedWindow lives in @adrkit/core and is used by both the kernel and the CLI renderer, so a window the kernel refuses to call governing is never printed as in force <opens> → <closes>.
  • temporal-window-open is library-only. A superseded record whose successor the corpus lacks is a dangling-supersededBy error, which gates adr explain before any temporal code runs. The finding is reachable through resolveDecisionsAsOf as a library call and not through the CLI.

Additive: without the flag, stdout and --json are unchanged, and the present-tense governing/activeProposals/history keys keep their meaning beside the new asOf block. Rung 1–2 of ADR-0014: rung 1 is unit, contract, purity and mutation coverage plus a Node-runtime smoke; rung 2 is maintainer-owned isolated reference-repository validation in adrkit-t018-dogfood (fixtures exercising the window closing along a real supersession chain, a deprecated and a rejected record, and a deliberately inverted-date pair; self-verifying and fail-closed CI; see mbeacom/adrkit-t018-dogfood#24 and its tracked evidence index). Not rung 3 — no external party has run this.

Phase 6 ARB queue is implemented under specs/007-arb-queue/ (see plan.md): the pure buildQueueReport kernel and canonical JSON/Markdown formatters live in @adrkit/core, the adr queue CLI subcommand ships in @adrkit/cli, and a managed-issue queue Action lives in the private @adrkit/ci (packages/ci/queue/action.yml, bundled to packages/ci/dist/queue-action.js). Phase 6 is landed / reference-verified on rungs 1–2 of the ADR-0014 evidence ladder — unit/contract/conformance plus maintainer-owned isolated reference-repository validation (adrkit-t018-dogfood, queue Action pinned at efef89b). It is not yet externally validated; the rung-3 external/community signal is tracked honestly as open.

The Spec Kit extension (packages/adapters/spec-kit)

@adrkit/spec-kit is the first package under packages/adapters/* and the second distribution surface (ADR-0003). It adds three namespaced commands to a Spec Kit project — /speckit.adrkit.context, /speckit.adrkit.check, /speckit.adrkit.draft — plus one optional after_plan hook that offers to run the check. Pinned to Spec Kit >=0.13.0,<1.2.0, verified against 0.13.0, 0.14.4, 0.15.1, 0.16.5, 1.0.0, 1.0.4–1.0.6, 1.0.13, and 1.1.0–1.1.3. Authorized by ADR-0019. Landed / reference-verified on ADR-0014 rungs 1–2; rung 3 open.

Things that are load-bearing and easy to break:

  • It is versioned independently of the lockstep surface, per ADR-0007 — its semver contract is with Spec Kit, not with @adrkit/core. It releases on its own spec-kit-v<semver> tag (see docs/RELEASING.md), currently 0.1.5, and does not move with the repository version.
  • Two version fields must agree: package.json (npm) and extension.yml (Spec Kit). A test asserts they match — 0.1.1 shipped with them diverged and told every user the wrong version.
  • It ships no dist and declares no dependencies, not even dev ones. specify extension add --dev copies the directory verbatim, and a single declared dependency is enough for Bun's isolated linker to create a node_modules/ here that then lands in someone else's repo or aborts their install. LICENSE and NOTICE are committed rather than generated for the same reason. Enforced by test/packaging.test.ts.
  • The community catalog is discovery-only, so docs install from the archive. specify extension add adrkit exits 1 on every Spec Kit version measured (0.13.0 to 1.1.3). The docs give --from .../releases/download/spec-kit-v<version>/adrkit.zip, which prompts Continue with installation? [y/N] (no --yes; --force does not skip it). releases/latest/download follows the lockstep release and 404s, so the URL carries the version, and test/manifest.test.ts fails if it disagrees with extension.yml. Bump the README and site URLs with the version.
  • The dotted ids are what the manifest declares, not always what users type. Under Claude Code, and under Copilot from Spec Kit 0.16.5, Spec Kit registers the commands as skills (/speckit-adrkit-check). The after_plan hook keeps speckit.adrkit.check; the generated plan skill tells the agent to swap dots for hyphens. Do not rename the commands in extension.yml to match.
  • Hooks can only reach commands that do not write. draft is the only writing command and is unreachable from any hook, by test.
  • packages/core, packages/cli, and schema/ import nothing from packages/adapters/*; CI enforces this, and the rule has been observed failing against a deliberately introduced violation (ADR-0016).

adr queue

Emit the ARB operations queue — a read-only, deterministic projection of the local ADR corpus — to stdout:

adr queue [--dir docs/adr] [--as-of YYYY-MM-DD] [--format auto|terminal|markdown|json]
  • --dir (default docs/adr): ADR corpus directory.
  • --as-of (default: today, UTC): UTC calendar date used for SLA state computation. Accepts a bare YYYY-MM-DD or an ISO datetime with an explicit timezone (e.g. 2026-01-08T00:00:00Z); timezone-less datetimes are rejected.
  • --format (default auto): terminal on a TTY, otherwise markdown; or an explicit terminal, markdown, or json (QueueReport v1). Only a TTY ever gets the terminal view — under ADR-0044 (accepted), following ADR-0033 — so piped output is unchanged.

Exit codes: 0 = report with no corpus error findings; 1 = report emitted (complete, to stdout) with one or more error-severity corpus findings; 2 = usage error (invalid flag/value or unreachable corpus directory). Identical inputs produce byte-for-byte identical output (SC-001).

Records corpus discovery cannot see — misnamed, or nested below the corpus root — are reported as corpus.file-skipped corpus findings at warn severity, so a proposed record never disappears from the queue silently. Being warn, they do not change the exit code and do not fail the managed-issue Action.

adr accept

adr accept <id> --by <identity> ratifies a proposed record, under ADR-0044 (accepted). It is the third writing command, after new and migrate.

  • It splices three fields and nothing else: status, provenance.ratifiedBy, and review.decidedAt. A yaml round trip would reformat 39 of this corpus's 44 records, so acceptAdrSource in packages/core/src/transition/ edits lines. It then re-parses the result and refuses unless every other field is semantically unchanged. That re-check caught a real ordering bug during development, so do not replace it with a serializer.
  • --by is mandatory and never inferred. The clock is read in packages/cli/src/accept.ts; the core transition is pure.
  • It refuses rather than overrides review state: a record that is not proposed, has an unresolved objection, has fewer approvals than its review.quorum, or would fail validation is left untouched, with exit 1.
  • No agent surface runs it. The agent plugin's wiring test fails if any command, skill, or agent mentions adr accept. The queue's terminal view may print the command for a human. It does so only when a dry run of the same acceptAdrSource transition, made in queue.ts, would succeed; otherwise it prints that refusal. Review state alone misses refusals such as an empty deciders, so do not replace the dry run with a field check.

adr approve / adr object / adr resolve

adr approve <id> --by <identity>, adr object <id> --by <identity> --summary <text>, and adr resolve <id> --objection <n> --by <identity> record review state on a proposed record, under ADR-0051 (proposed). They follow adr accept's design, and adr queue's approval and objection counts change with them.

  • They share accept's splice-and-verify kernel; do not fork it. packages/core/src/transition/splice.ts holds the line-splice helpers and finishSplice, the re-parse guard that refuses unless the new frontmatter equals exactly the intended data and still validates. review.ts and accept.ts both end in it. A copy of the guard in a fourth transition is how one of them would quietly lose it.
  • A flow list stays flow and a block list stays block. approvals: [] becomes approvals: ["@bob"]; objections: [] grows a flow mapping. Turning either into a block would be a second, unowned layout change. A layout that cannot be spliced, such as review: {…}, is refused with exit 1.
  • Identities compare by ASCII casefold of the whole identity (packages/core/src/schema/identity.ts): @bob and @Bob are one person. That governs the approve and object no-ops and the objector check, and it is why acceptAdrSource's quorum check and the queue kernel's approvalCount count distinct identities, a fix to ADR-0044's entry count. Before it, one reviewer approving under two spellings met a quorum of two. Do not go back to approvals.length or includes anywhere approvals are counted or matched.
  • Only the objector may resolve. --by must be the objection's by under that fold. There is no resolvedBy field and no chair override; both need a schema change and are future work in ADR-0051. Do not add either here.
  • Repeats are no-ops, exit 0 with no write: an identity that already approved, the same open objection (same by and summary), an objection that is already resolved. The core result says changed: false and the CLI skips writeFile.
  • A summary is one line, written double-quoted. Trimmed, non-empty, at most 500 code points after trimming, no control character (\p{Cc}), no invisible format character (\p{Cf}: bidi overrides, zero-width characters, the BOM), no U+2028 or U+2029. --by gets the same control and format check in all four ratifying and review commands, because the schema's email branch admits both, except ZWNJ and ZWJ inside an email. The CLI checks these as usage errors (exit 2) and core refuses them independently; a repeated --by is a usage error too, in adr accept as well.
  • An insertion never backs up into the value above it. backUpOverTrivia takes a floor at the end of that value, so a literal block's # … line and a keep-chomped scalar's trailing blank lines stay inside their scalar, and resolve scans back to an item's - when it stands alone on its line.
  • The record must be proposed, for all three. --by is mandatory and never inferred. A BOM-prefixed record is already invalid to adr lint (parseFrontmatter requires a leading ---), so these refuse it, as adr accept does.
  • adr accept and the three review commands find their record through packages/cli/src/record-target.ts, which accept's lookup moved into with its messages unchanged.
  • No agent surface runs them, and the guard checks code, not only prose. The agent plugin's wiring test fails if a command, skill, agent, or any extension module, the canvas included, names accept, approve, object, or resolve as a CLI call: as any quoted array element or call argument ([cli, 'approve', id], args.push('approve')), as a quoted value assigned to a name, as a tool name (adr_approve), or after adr, $ADRKIT_CLI, @adrkit/cli, or a ${…} placeholder, in any case and across invisible characters. JSON-schema type lists such as ['object', 'null'] are dropped first, so they never match. A prose-only pattern was green while an extension spawning ['approve', …] passed it. The Spec Kit extension (plus its adrkit_cli wrapper) and the MCP server (plus any import of a writing transition) have the same guard. The canvas's actions are model-callable, so they stay guarded. The one exception is the decision board's page-POST module, board-review-write.mjs (ADR-0052, proposed), which takes the identity from ADRKIT_REVIEWER and writes only after the host's confirmation, because the model can reach its routes over HTTP; see "Recording review from the board" below. The extension tools' redactWritingCommands scrubs these verbs from tool results too, because a record title can carry adr approve … to the model.
  • The read, transition, write window is not locked. Two of these commands, or one and adr accept, run at the same instant on the same file can lose an update. That is accepted in ADR-0051: they are human-run edits to a working tree, and the pull request diff is where the result is checked.

Moving Action tag recovery

Normal lockstep releases publish npm and create a draft GitHub release. A human publishes that draft with the Marketplace selection; only the resulting release: published finalization moves the lightweight major Action tag (v0) and starts container publication. .github/workflows/action-tag-recovery.yml is the explicit backward path. Run it from main with an existing stable vX.Y.Z release tag. It requires an annotated tag that peels to a commit on main, an exact successful Release run, matching root version, and both committed nested Action bundles. It deliberately permits pre-Marketplace releases so the first compatible release does not remove the last known-good target for nested @v0 consumers. For the same reason the third bundle, dist/portal-action.js, is required only where packages/ci/portal/action.yml exists at the target, so pre-portal releases stay eligible; never make that check unconditional. It shares the release concurrency group, holds only actions: read and contents: write, and pushes with a lease against the observed remote tag object. Recovery also records a durable action-recovery-block/<commit> tag for the commit removed from v0; the normal release workflow rejects a rerun of that commit before npm publication. A context-validation job fails dispatches from another repository or ref instead of leaving a skipped workflow green.

Moving v0 stops future jobs from resolving a bad release; it does not undo an already-edited PR comment or change a job that already resolved the old SHA. Restore comment content from GitHub's edit history or rerun the known-good Action. The full preferred and manual fallback runbook is in docs/RELEASING.md. It also does not contain an immutable root ref copied from Marketplace: unpublish the bad listing, warn pinned consumers, and publish a higher hotfix.

The agent plugin (packages/adapters/agent-plugin)

The adrkit plugin is the fourth distribution surface and the one that reaches GitHub Copilot CLI, Claude Code, opencode, and Agent Package Manager. It ships two skills (decision-memory, decision-backfill), one read-only subagent (decision-checker), and five commands (/adr-context, /adr-check, /adr-draft, /adr-queue, /adr-backfill), all of which drive or reconcile through the adr CLI. Since 0.4.0 it also ships one GitHub Copilot CLI dynamic workflow, adr-review (extensions/adrkit/), authorized by ADR-0045 (accepted). claude plugin validate passes with it present; APM 0.33.0 installs it into apm_modules and deploys it to no target (its one warning, Unrecognized plugin manifest $schema, predates the workflow); a native opencode load is unmeasured. Independently versioned per ADR-0007, not published to npm, and catalogued from the repository root's .claude-plugin/marketplace.json. Authorized by ADR-0028 and its accepted backfill amendment, ADR-0034. Rung 1 only — unit and contract coverage plus maintainer verification against the installed hosts, including a functional exercise in an ephemeral consumer repository. The v0.4.0 adr-review workflow adds unit and contract tests and a maintainer live smoke on Copilot CLI 1.0.92 (two subagents, both planted conflicts found, worktree clean). It also has a repeat through the published GitHub install on 1.0.93, plus a deletion-only run. Since 0.5.0 it also ships one read-only Copilot app canvas, decision-review, in the same extension (a second, decision-board, is described under its own heading below), authorized by ADR-0046 (accepted). The canvas is rung 1: unit and contract tests plus a headless Copilot CLI 1.0.93 SDK-host smoke (open, state, refresh, and one run_review at about 0.16 AI credits), plus maintainer sessions in Copilot app 1.1.27 where the panel rendered in the app's theme and a review started from it came back as findings. Earlier Copilot CLI versions and a native opencode load are unverified. No persistent reference-repository run, no external validation. Scope and limitations: docs/reference-verification-agent-plugin.md. That functional evidence covers the v0.1.0 context/check/draft/queue baseline. The v0.2.0 backfill skill and command are contract- and static-host-validated. A fresh Copilot synthetic-consumer run produced the expected covered/history/new classification and a complete handoff without changing the worktree. For the v0.3.1 bootstrap-record offer, detection is measured against synthetic corpora (missing, empty, source-only, process-record-present, unmigrated MADR, and — added after 0.3.0 review — proposed, rejected, and mixed-validity); host surfacing behavior and the offer's write path end to end are both unverified. No persistent reference-repository or external run exists.

Things that are load-bearing and easy to break — each measured against the real hosts rather than read off their docs, so a change that "looks more correct" will usually be a regression:

  • The manifest declares no component paths. agents, skills, and commands are documented Copilot CLI fields, but claude plugin validate rejects the string form outright (commands: Invalid input). Both hosts discover the conventional directories without them. category belongs to the marketplace entry, not the plugin manifest.
  • The manifest declares no extensions key either. Measured on Copilot CLI 1.0.93: a non-string extensions value (three objects and an array were tried, not null, booleans or numbers; the string form loads) in .claude-plugin/plugin.json stops the plugin's extension from loading, so the workflow and canvas vanish while plugins.list still shows the plugin enabled. claude plugin validate only warns and APM accepts it, so both validators pass a change that removes the shipped surface. That rules out the Agent Plugins 1.0 extensions["com.github.copilot"].logo listing key; see docs/reference-verification-agent-plugin.md.
  • No component declares a tools list. Claude Code takes a comma-separated string of capitalized names, Copilot CLI an array of lowercase ones, and opencode requires a name-to-boolean mapping and rejects the agent at load time when handed a list. The read-only contract lives in the agent body instead.
  • The plugin deliberately ships no .mcp.json. Copilot CLI spawns a plugin's MCP servers with a working directory that is neither the workspace nor any Git repository, and exports nothing naming the repository. The adrkit server requires a Git worktree root, so it exits during initialize and logs Failed to start MCP client for adrkit every session. MCP is wired per project, where the working directory is correct. Do not "fix" this by adding the file back.
  • The subagent must resolve the CLI properly. @adrkit/cli is normally a dev dependency, so a bare adr is not on PATH. An agent that tries only that concludes "no CLI available" and falls back to reading ADR frontmatter by hand — which cannot expand glob matchers, cannot read inbound @adr markers, and has no exit code, so it produces an answer that looks complete and is not. Measured, then fixed: both the skill and the agent now state $ADRKIT_CLI → ./node_modules/.bin/adr → PATH, and a test enforces it.
  • Every version-bearing surface must agree: .claude-plugin/plugin.json, apm.yml, package.json, bun.lock, marketplace metadata and entry, and every skill's metadata. Claude Code keys its plugin cache on version.
  • Backfill discovery is read-only evidence triage. Code proves current state, not intent or ratification. /adr-backfill produces a coverage ledger and candidates; only /adr-draft may create one proposed record after a human selects it. Statusless evidence never becomes accepted automatically.
  • Repository content and local executables are trust boundaries. Backfill treats source text as untrusted data, stays inside the worktree, enforces explicit scan caps, and requires confirmation before running a CLI resolved inside an inherited repository.
  • The bootstrap record is an offer, not a candidate. A repository with no corpus is missing the process decision (keep decisions in git) and the tooling decision (enforce them with adrkit). Nothing proves a human ratified either, so it stays out of the candidates table and out of every backfillHandoff — it governs the corpus directory, a glob, and could never supply concrete candidatePaths — and routes to plain /adr-draft. Adopting adrkit is never a supersession of the decision to record decisions; supersedes is reserved for a prior tooling record, and a MADR corpus is migrated, not superseded. Detection reads the exit code before the buckets, and reads all three buckets — governing holds accepted alone, so activeProposals (draft/proposed) and history (rejected/superseded/deprecated) are where a record already settling the question actually sits. Reading governing by itself makes the offer re-propose its own output, because /adr-draft writes proposed. The corpus-wide gate is adr lint, not adr check, whose exit code is path-scoped: a malformed record elsewhere leaves adr check at exit 0 with an empty result. Both the skill and commands/adr-backfill.md carry this, because /adr-backfill loads the command (ADR-0038).
  • The offered record must bind the corpus directory. adr new scaffolds affects: [], and a record that binds nothing is invisible to the detection above however many buckets are read — so the offer states the matcher. Found by the first functional run of the write path, not by review; it is the failure ADR-0038 already listed under "how we would know this was wrong".
  • /adr-draft can write into a repository with no corpus. Its adr lint gate stops on exit 2 except when the corpus directory does not exist yet: adr new creates it and allocates 0001, so the bootstrap offer's own headline case is writable. A corpus that exists and does not parse is still a hard stop.
  • The adr-review workflow lives at the plugin root extensions/. For a .claude-plugin/plugin.json plugin Copilot reads <plugin-root>/extensions/<dir>/extension.mjs; com.github.copilot/extensions/ is for Agent Plugins 1.0 manifests and was not found. Exactly one copy.
  • The workflow's exit code is always 0, so result.status is the gate. copilot workflow run exits 0 on success, a thrown error, invalid arguments, and an unknown name. The workflow is advisory, and the single gating rule is: the run's status is completed and result.status is "ok". status is ok | findings | incomplete | usage-error, with precedence usage-error > findings > incomplete > ok; checkExitCode, lintExitCode, verdicts, and unverified are detail, not the gate. A missing judgment makes the run incomplete, and an unresolvable explicit base, or an unresolved origin/main over a clean working tree, is a usage-error — never ok. A non-zero adr exit is data, never a throw, or the run settles with no result and still exits 0.
  • The agent name is namespaced. ctx.agent must use adrkit:decision-checker; the bare name resolves to null without throwing, so every null is reported under unverified, never dropped.
  • The CLI is chosen by the environment only, and a repo-local one is gated. $ADRKIT_CLI, then ./node_modules/.bin/adr only when ADRKIT_ALLOW_REPO_CLI=1, then PATH. cli and allowRepoCli are deliberately not workflow arguments: a model that read untrusted repository content can choose arguments, and extension code runs outside Copilot's permission prompts. Unknown arguments are a usage-error. Do not add them back.
  • One extension registers both the workflow and the canvas, and each registration is guarded. register.mjs builds each in its own try and logs a failure through session.log. Measured: one invalid workflow definition (no meta.phases) throws at import and took the whole extension down, canvas included. A throwing canvas must likewise not stop the workflow.
  • The canvas server starts lazily inside open(). The app launched 181 extension loads within minutes of starting, one process per restored session; a server bound at load would bind a port in every one. A test imports the modules under Node and asserts no socket opens.
  • The panel is hardened because ADR titles are untrusted. Every route checks a per-panel token before routing (403 otherwise), a POST needs the token as a header and a same-origin Origin, the CSP is default-src 'none' with 'self' scripts and styles, and the page builds its DOM with textContent only. Do not add innerHTML, an inline script or style, or a looser CSP; tests fail on each.
  • refresh costs nothing and run_review spends credits. refresh runs git diff, adr check, and adr lint; run_review starts adr-review, one decision-checker call per governing decision. Invalid run_review arguments throw invalid_input before anything is spent. The explain affordance is an HTTP route the page uses, not an agent action.
  • The canvas takes its directory from ctx.session.workingDirectory. That is the documented source. The app's runtime process runs from /, but each extension process it forks starts in its session's directory: a probe in an app session measured process.cwd() equal to sessionWorkingDirectory, both the app's session worktree. The workflow starts from that process.cwd() and then follows session.context_changed (session-dir.mjs, below), which is why run_review reviews the right repository in the app. If a later runtime breaks that equality at fork, the workflow must take the session directory too.
  • The default change is a union, not the committed range. Through 0.9.0 it was git diff <base>...HEAD alone whenever that resolved, so an app session (a fresh worktree off the default branch) or any checkout on main showed 0 changed file(s) and status ok until its edits were committed: measured on a copy of the dogfood repository, where adr check on the same three edited paths found 3 governing records. Since 0.9.1 collectChangedFiles unions the branch range, git diff HEAD (staged, unstaged, deletions), and git ls-files --others --exclude-standard --full-name -- :/ (untracked, not ignored). Keep --full-name -- :/: plain ls-files is cwd-relative and cwd-scoped while git diff --name-only is neither, so a subdirectory session would mix two path conventions. The workflow, both review surfaces, the adr_check tool, and the session-start hook all go through it.
  • Read source through review.mjs's helpers, never by string. Labels are args, git:<base>...HEAD+worktree, and git:worktree (the fallback when origin/main does not resolve, and the only partial one). isPartialSource, listCommandsFor, and describeSource keep the workflow, the canvas, the hook summary, and the Judge prompt in agreement; a source.replace(...) in the hook printed git diff origin/main...HEAD+worktree as prose before the helper existed. The Judge is told to read an uncommitted edit with git diff HEAD -- <path> and an untracked file whole, because both have an empty range diff.
  • git rev-parse --is-inside-work-tree runs before any listing. A directory git does not treat as a work tree makes git diff print its --no-index usage (the Windows report), and so does git's ownership check (measured with GIT_TEST_ASSUME_DIFFERENT_OWNER=1). Exit non-zero or output other than true (.git prints false, exit 0) is not-work-tree; "dubious ownership" in stderr is git-unsafe-directory. stderr is compared, never shown. A queue that exits 2 where the corpus directory does not exist says so instead of "exited 2". The Windows cause is unconfirmed.
  • show_review never replaces a run the panel started. In the app, a finished panel run is surfaced to the agent, which then handed the same result back and relabelled it "supplied by the agent". Measured, then fixed; do not loosen the guard. It also refuses a result whose status is cleaner than its own payload, or whose files or governing records are not the panel's, and a refresh drops a shown review once any changed file's size or modification time moves. File names alone miss an edit inside the set.
  • Canvases render only in the Copilot app. A CLI terminal session has no canvas renderer, so the agent gets no canvas tools there. Do not describe the canvas as available in the CLI.
  • Canvas provenance shows only what adr check reports (ADR-0047, proposed). Measured on a fixture: declaredBy names the changed file and line of an inbound marker, but an affects match carries only { type, pattern }, with no file. The page says so instead of matching globs itself; the extension cannot import core, and a homemade matcher could disagree with the CLI and present that as provenance. If core gains per-file attribution, read it.
  • judgeCalls follows the Judge, which is ctx.pipeline(governing, … ctx.agent(…)) in review.mjs: at most one decision-checker call per governing decision. It is the governing count when adr check and adr lint exit 0 or 1, and 0 otherwise, because the workflow then skips the Judge. Change the workflow's Judge shape and the button label and run_review description go stale. The button is disabled whenever judgeCalls is 0, and says why.
  • The canvas queue is an allowlist, and that is what keeps a ratifying field out. adr queue's terminal view prints that command for a human; the canvas reads only the JSON and keeps nine named item fields, so a field added later is dropped unnamed. That selects fields, not text: a kept string such as title is untrusted repository text shown as data, and is not sanitized for command-like wording. A test plants such fields and checks the snapshot, /api/state, the rendered page, and the shipped page strings. Queue rows have no control or explain, and their ids are not explainable. A queue failure is a fixed note, never CLI stderr, and never changes the panel's status, governing list, or notes. The queue starts beside the check with its own timeout (30 s, then its process is signalled). The check commits and broadcasts when it settles and the queue follows in a second update, so a hung adr queue cannot hold the governing view; opening a panel and the page's routes (GET and POST) do not wait for it, nor does run_review's pre-run refresh; only agent get_state/refresh do. Its strings, and declaredBy paths, are clipped like CLI messages, and the rows share a 256 KiB (UTF-8 bytes) serialized budget, before every broadcast.
  • The extension also registers two advisory session hooks (hooks.mjs, ADR-0049, proposed): onSessionStart adds a governing-decisions summary; onPostToolUse, after an edit, names the accepted decision(s) governing the file just edited and refreshes open decision-review panels. Their registration is guarded like the other two, and ADRKIT_HOOKS=0 makes the factory return undefined, so no hooks key is joined at all.
  • There is no onPreToolUse, and do not add one. Measured on Copilot CLI 1.0.93 through session.rpc.tools.execute: a pre-tool hook that never answers holds the tool call unexecuted (still pending at 90 s), while a post-tool hook that hangs lets the edit land and only holds the result. The CLI changelog also records versions where a pre-tool hook error denies the call. A pre-tool hook makes extension liveness a gate, which ADR-0022 denies to an advisory. A crashed extension failed open in both hooks there.
  • A hook returns additionalContext and nothing else. No permissionDecision (the SDK's own example returns "allow", which would override a person's ask), no modifiedArgs, modifiedResult, or suppressOutput. A test asserts the key set and was observed failing against an "allow" mutation.
  • Hook context carries ids, never text. Record ids are checked against a record's own id grammar (adr.schema.ts id: 4+ digits or a ULID, no namespace, because a namespace segment is free text) and statuses against a fixed set; anything else is skipped silently. Titles, paths, and error messages never reach it, because the model reads hook context as instructions.
  • session.log is fire-and-forget in hooks; never await fail(...). It is an RPC with no deadline: awaiting it let a never-answering log hold onSessionStart past its 5 s deadline indefinitely (found in review), pin a failed cached check, and wedge the single-flight refresh. fail guards both a synchronous throw and a rejected log; removing either guard fails a test (one of them a Node child-process test for an unhandled rejection that would kill the extension).
  • onSessionStart fires with the first prompt, not at load. Measured on 1.0.93 (SDK host): a plugin extension joins after session.start; with no prompt no hook fires, even on resume. With a prompt it fires after onUserPromptSubmitted with source: "new". It races a 5 s deadline, because its sequential calls (four git calls since 0.9.1, then one adr check per batch of a wide diff) could otherwise hold the first prompt for 15 s or more. When the deadline wins, the hook aborts the call in flight and starts no further batch: nobody reads that summary, and a 40,000-path diff would otherwise keep one hook slot busy for minutes (found in review; a counting test pins it).
  • The edit tools are the ones the runtime classifies as edits: edit and create ({ path }, absolute in session logs), str_replace (edit's shape), str_replace_editor only when command is create, str_replace, or insert (its view reads; both from the bundle, unobserved in logs), and apply_patch, whose toolArgs is the raw patch string (measured with gpt-6-luna), parsed for *** Add/Update/Delete File: and *** Move to: and cut at 20 paths while parsing. A renamed tool turns the note off silently. Paths are made relative to the hook input's workingDirectory, never process.cwd().
  • Hook cost is capped, and the caps are tested.
    • Non-edit tools return before any I/O.
    • At most two hook-spawned processes run at once, each with AbortSignal.timeout(5000).
    • There is one adr check per distinct path per process, shared by concurrent edits and cached even when it fails, except when a signal, a timeout, or an abort ended it: that says nothing about the file, so the next edit checks again (a CLI that cannot start stays cached, so it is not retried on every edit). The 500-check budget is a monotonic count, not the cache size, because a corpus edit clears the cache and must not re-arm the budget.
    • The post-edit note gives up after 2 s and the check keeps filling the cache.
    • The hook-triggered canvas refresh is single-flight: one in flight, at most one queued, under one 15 s abort signal passed through refreshOpen into the canvas's refresh. That signal bounds the adr queue read too, combined with the queue's own timeout rather than replacing it, and the hook's refresh waits for the queue so single-flight stays true. When the signal fires, the panel keeps its previous result and queue under fixed timeout notes; an aborted refresh never commits the abort's exception text as a usage error.
    • The debounce timer is unref'd.
    • A timeout or abort ends the whole process tree on POSIX. The shared runCommand in review.mjs (the one runner the workflow, canvas, tools, and hooks all use; there are no copies) spawns with stdin ignored and detached, then signals the group once: SIGTERM, then SIGKILL after an unref'd 1 s grace, skipped if a signal-0 probe already shows the group gone. Signalling only the child left a grandchild behind a version-manager shim running (shown against the 0.8.0 runner with a shell wrapper and with a node shim; Node-run tests now assert the grandchild is gone). A descendant that calls setsid itself leaves the group and is out of reach.
    • Detached groups must not outlive the extension, and detached alone made them do so: measured on 1.0.93 in a headless SDK host, both a SIGTERM to the extension process and a plain disconnect + client.stop() left the child and its grandchild running before the fix, and ended both after it. So a group stays tracked until a probe says it is gone (not merely until its leader closes; at most 10 probes, 1 s apart, then it is dropped so a lingering member or an EPERM probe cannot keep the listeners forever), an exit listener SIGKILLs tracked groups, and while any group is tracked, listeners for SIGTERM, SIGINT, and SIGHUP do the same, remove themselves, and re-raise the signal so its default action still ends the process. With no group tracked no listener exists, so the extension's own signal behavior is unchanged; if the host added its own listener for that signal, the signal is not re-raised.
    • Every command has a 120 s ceiling (COMMAND_CEILING_MS). The callers' own limits are shorter, so it only bounds calls that had none: the workflow, the page's own refresh, and the tools. It ends the group and rejects with a TimeoutError.
    • On Windows the signal goes to spawn, which ends the direct child only: a stated limit, not fixed.
  • One join retry ladder serves the hooks and the tools: without hooks, without tools, without both, without canvases alone, then the workflow alone (at most six joins; onEvent is never dropped). It rethrows the original error if every rung fails, and logs exactly the fields the successful join dropped. A runtime that refuses hooks must not cost the workflow, the canvas, or the tools, and one that refuses the tools must not cost the hooks. Because hooks goes first, a one-off unrelated join error turns the hooks off for the session; that is accepted, and the log line says so instead of blaming them.
  • ADRKIT_* variables reach the extension without requestedEnvironmentVariables. Measured on 1.0.93 through the SDK host, including a variable whose name ends in _SECRET_TOKEN. Its arrival in the app, and hook firing in the app, an interactive CLI session, and subagent child sessions, are unmeasured.
  • copilot plugin install prints only a skill count. Version 0.11.0 should report two skills; that does not inventory the agent or commands — verify them in a fresh session.

Extension tools (adr_check, adr_explain, adr_lint)

The same extension registers three read-only tools through joinSession({ tools }), proposed in ADR-0048 (proposed). They amend ADR-0028's inventory without reversing it: the plugin still ships no .mcp.json. Rung 1: unit and contract tests plus headless Copilot CLI 1.0.93 measurements with no model calls; unmeasured in the Copilot app. Measured, and easy to break:

  • A tool invocation carries no directory, and process.cwd() goes stale. After metadata.setWorkingDirectory (what /cd uses) the extension is not restarted and its process.cwd() does not move, but it receives session.context_changed with the new cwd. The tools track that event, through joinSession's onEvent so a change during the join is kept; do not "simplify" them back to process.cwd(). The workflow reads the same tracker (session-dir.mjs) when each run starts, through createReviewWorkflow, so a review after /cd reviews the new directory; a tracker value captured at load would be stale too.
  • A bad tool definition refuses the whole join. A name outside /^[a-zA-Z0-9_-]+$/ made the runtime reject joinSession, workflow and canvas included, so register.mjs retries without the tools on the shared ladder described under the hooks above, keeping the workflow always. A name that collides with a built-in joins but breaks tools.initializeAndValidate() for the whole session, so never name a tool after a built-in.
  • The executable is chosen by the environment only, through the workflow's resolveCli. No tool argument selects it, and unknown keys are refused.
  • Arguments are validated in the extension, because the host does not enforce the schema: relative paths only, no .., no leading -, no control characters, at most 200 paths of 1024 characters, and a conservative base (isSafeBaseRef: ^[A-Za-z0-9._/@{}~^-]+$, no leading -, .. only in a ... range), shared with the workflow's validateArgs because base is repeated in the Judge prompt's commands. The corpus directory taken from the argument or the default docs/adr is realpath-checked against the session root before spawning, so a committed symlink out of the worktree is refused (symlink-escape); a user-set ADRKIT_DIR is trusted, and the checked value is the value passed as --dir.
  • Every adr check is batched, and every echoed file list is capped. The CLI takes paths as arguments only (no stdin or file list), so a wide diff can exceed Windows' ~32 KiB command line. checkInBatches splits paths at about 24 KiB of argv and merges the reports (decisions by record id, sorted; findings deduplicated and sorted with core's sortFindings key, restated and pinned against core by a test; highest exit; markerScan dropped, because per-call scan counts do not add up; batches: N added). One batch returns the CLI's result untouched, so batches marks the merged shape. ADR-0022's declaration caps apply per call. Echoed lists stop at 200 paths with a filesOmitted count and, in the workflow result, a filesDigest (SHA-256 of the full sorted list). The canvas keeps the full list in memory and compares a capped result with sameFileSet, by digest; sanitizeReviewResult refuses a capped result with no digest or with other than exactly 200 paths, and sameFileSet requires the canonical capped prefix even when the digest matches; comparing a capped result with the full list would drop every wide-change review as stale (pinned in test/batching.test.ts).
  • A capped Judge prompt must say how to see the rest. A bare git diff --name-only prints nothing for committed work, so a Judge shown 200 unrelated paths answered from what it could see (found in review). The prompt lists the paths whose markers declared the decision first, then names the range the run collected (git diff --name-only <base>...HEAD, or HEAD in the fallback); for explicit files it says the caller supplied the list, with its count, and the run is incomplete at best, because no git command reproduces an explicit list and a clean verdict would cover files nobody inspected (found by Copilot review on #272). One test per mode pins the hint.
  • Results never carry exception text or a writing command. Rejections and spawn failures return fixed messages chosen by code (CodeQL js/stack-trace-exposure). CLI stderr is returned only on exit 2, capped and without stack-frame lines, because a crash's stderr is a stack. The workflow result, the canvas's notes, /api/state, and its agent results follow the same rule since 0.8.1: every error is a ReviewError or is mapped by publicMessage from its code and signal alone, validateArgs no longer echoes the value it refused, and a run's own error/reason is never shown. test/error-text.test.ts plants a sentinel in stderr and in thrown errors and asserts it reaches none of those outputs. Every string is scrubbed before serialization, matching any whitespace or format character between adr and the subcommand: scrubbing the JSON text missed adr\naccept (found in review). A non-zero adr exit with a report is success with its exitCode.
  • Commands and skills do not mention the tools. Claude Code and opencode never load extensions; the CLI path stays the portable one.

The decision-board canvas

A second canvas in the same extension maps the whole corpus from adr graph --format json and adr queue --format json, proposed in ADR-0050 (proposed; amends 0046 and 0047). Code in board.mjs, board-page.mjs, and board-layout.mjs; the HTTP hardening both canvases share is in panel-http.mjs. Rung 1: unit and contract tests plus a headless Copilot CLI 1.0.93 SDK-host smoke with no model calls; unmeasured in the Copilot app. Load-bearing:

  • Its actions are read-only; writes need the host's confirmation. No action writes, starts a workflow, sends a prompt, or spends anything. The factory uses the session only for the review controls below (the host confirmation, the agent mode, and the log), and a test fails on any rpc.workflow or .send(. The review routes are reachable by anything holding the panel token, the model included, so every write waits for the host's confirmation (ADR-0052). There is no ratify control.
  • It shows no readiness verdict. A queue row shows approvals against quorum, objection counts, SLA state, deadline, routing, and a finding count, and nothing derived from them. Review state misses refusals the acceptAdrSource dry run catches (an empty deciders), so a "ready" label would be a claim the board cannot back. A test fails on ready in the page.
  • Filters are the CLI's. A focus or kind filter re-runs adr graph --focus <id> --kind <kind>; do not filter or walk the graph in JS, or the board can disagree with adr graph. The id is checked against a record's own grammar (and refused past 64 characters, never clipped) and each kind against the three, before any spawn. The action schemas carry the same id pattern and the kinds enum, and the runtime was measured enforcing both before the handler runs; the extension's check stays the authority.
  • State is per panel; the queue is shared. Filter, corpus directory, and graph are keyed by panel id, so a focus in one board never moves another (review M2, measured both ways). The queue is keyed by working directory and corpus directory. Do not move the filter back to the working directory. A new dir is committed with its graph (graphDir), and the queue shown is the graph's directory's, so a faster queue never pairs with the old graph.
  • A snapshot reports the filter its graph was read with. computeGraph stamps graph.filter, and snapshotOf reads it, never the pending request (review M1). Only the newest graph read is applied; an overtaken focus returns superseded: true with the current, consistent state. Tests with a gated fake CLI fail if either sequence guard (graph or queue) is removed.
  • A model-chosen dir is confined. It must resolve inside the panel's own repository (on a re-open, the panel's, not the session's current directory) lexically and through staysInside (tools.mjs), or the action is refused with a fixed message before any spawn. A remembered dir is re-checked right before every graph and queue spawn; one that now escapes gets a fixed note and nothing runs. ADRKIT_DIR is trusted. decision-review's dir is not confined; that predates the board.
  • Allowlists and budgets, as in ADR-0047. Node id/title/status, edge from/to/kind, and the queue row's nine fields plus resolvedObjectionCount and itemFindingCount; everything else is dropped unnamed. Titles clip at 200 characters. At most 300 records (past that, counts by status), 1000 relationships, 200 queue rows, and 512 KiB per snapshot by Buffer.byteLength (graph to summary first, then queue rows), on every route, action result, and event-stream frame (a test reads the frames). A drawing past 20,000 px a side also becomes the summary. Each CLI call has a 30 s timeout. A failure is a fixed note from BOARD_NOTES, never stderr or exception text.
  • The layout runs on the server, in a pure module. board-layout.mjs has no clock, file, or process; columns come from supersedes alone (from = successor, to = replaced, as buildAdrGraph emits them), ties break by id, and a cycle terminates. The page only draws coordinates.
  • SVG classes go through setAttribute('class', …). An SVG element's className is an SVGAnimatedString; assigning a string does nothing in a browser while a fake DOM accepts it, so a test checks the fake's className stays empty. No inline style either: status and edge styles are CSS classes.
  • It is a second entry in canvases, in its own guarded try. A throwing board, or a createCanvas that refuses it, costs neither decision-review, the workflow, the tools, nor the hooks. LABELS.canvases stays "decision-review canvas" when the board is absent, because existing tests assert that text; with both present, the failure log names both. Known limit: the join ladder has no board-only rung, so a join the runtime refuses because of the board drops decision-review too (ADR-0050).
  • The post-edit hook does not refresh boards. Adding a graph and queue read under the hooks' 15 s single-flight signal was left out on purpose.

Recording review from the board

Each open-proposal row has approve, object, and resolve controls, proposed in ADR-0052 (proposed; amends ADR-0050, implements the plugin half of ADR-0051). They run adr approve, adr object, and adr resolve and need @adrkit/cli 0.18.0 or later. Rung 1: unit, contract, and mutation tests, an end-to-end test against the built CLI, and headless SDK-host measurements with no model calls. In the Copilot app the maintainer ran Approve once (runtime 1.0.94-3): the dialog appeared, a decline wrote nothing, an accept wrote, and a click within 10 s was rate-limited. Object, Resolve, and the app's autonomous modes are unmeasured there. Load-bearing:

  • The model holds the board's URL and token. The runtime's open_canvas tool returns the instance URL, token included, to the model, and the model can open its own instance. With a shell it can call every route the page calls, with any headers (measured by review: an approval with no click). So nothing on the page or in the HTTP layer is the boundary.
  • The boundary is the host's confirmation. The write route calls session.ui.confirm(...) before spawning and writes only on a literal true within two minutes; a throw, a timeout, or any other answer writes nothing. Without capabilities.ui.elicitation === true the controls are off with a fixed note and both routes refuse. In agent mode autopilot, or when rpc.mode.get throws or does not answer within 5 s, the route refuses before asking (a never-answering RPC must not hold the single-flight flag); a session with no mode method is still asked. The shown corpus directory is confined again after the confirmation, right before the spawn, because the dialog can take two minutes. Measured: the runtime routes an extension's confirm to the host's handler in every permission and agent mode, and answers nothing on its own; the CLI's terminal UI declines elicitations in autopilot (read from its bundle). The Copilot app offered elicitation and showed the dialog for an approval (measured once). Whether it ever answers without a person is unmeasured; ADR-0052 names that as a residual risk the maintainer must accept or measure. Do not remove the confirmation, accept a truthy non-true answer, or put the summary or a title in the dialog: the dialog text is fixed text, the kind, the validated id, the identity, and numbers only. The confirmation guards the board's write path only: a model with a shell can still run adr approve --by=… itself, edit frontmatter, or edit the extension, and the pull request diff is the backstop.
  • A staged dialog must not pass for the person's own (re-review R1-M1). The dialog's first line is the record and the action (ADR-0042 · approve as @x). A write that arrives while one is pending gets a 409 naming the pending kind and record and saying to decline it unless the person started it, shown on the page as a warning. The board may ask at most once per 10 s and five times per sliding 10 minutes, counted across every board panel in the extension process; over that it is a 429 with nothing asked or spawned, and the refusal is logged. Do not make the budget per panel again: the model can open panels, and each would get a fresh budget (a test opens a second panel and reopens one).
  • The identity is ADRKIT_REVIEWER, read on every request, never taken from the page, the model, or an argument, and checked by a mirror of core's isWritableIdentity that a test compares with core. A body with any extra key (by among them) is a 400. It goes to the CLI as one --by=<identity> element, because an email identity may start with -. Action results carry review: { enabled, note } only; the identity appears only in the page's own HTTP state.
  • The nonce and the two clicks are defence in depth, not the boundary. The first click asks POST /api/review/nonce for a nonce bound to the kind and record; the second, a Confirm button placed after Cancel so a double click cannot hit it, spends it. Single use, two minutes, dropped on close, never in an action result. A token holder (the model included) can mint and spend one as the page does, and can also spend the panel's live nonce to cancel a person's armed Confirm (denial of service only; the person re-arms).
  • The review routes need an exact same-origin Origin (none is refused) and Sec-Fetch-Site: same-origin when sent, through sameOriginPost in panel-http.mjs. That stops naive scripts and other pages only; curl can send both.
  • Exactly one module is exempt from the verb guard. board-review-write.mjs may name the three review verbs, and never accept. wiring.test.ts pins the exemption list to that one path. Only board.mjs names the module, once, as a static import it does not re-export; a test fails on any other mention (static, dynamic import(), require, a path variant) and on a re-export, each planted. The actions never reference the writer. That keeps the code structure honest; the HTTP routes are still the model's path, which is why the confirmation exists.
  • Exit codes map to fixed messages, and stderr is never read. Measured on 0.18.0: a refusal (exit 1) prints nothing on stdout even with --json, so exit 1 is one fixed message. Measured on 0.17.0: an unknown subcommand also exits 2, so an exit 2 asks adr --version afresh, and the nonce route asks first. A refusal's reply text is looked up by code, never read from an error.
  • A write re-reads the board and the queue, never adds a readiness verdict, and neither the page nor the write module names the ratifying command.
  • Each attempt that reaches the confirmation is logged fire-and-forget through session.log with the id, kind, identity, and outcome (not-confirmed included), never the summary.

Canvas look and feel (0.11.0)

Both canvases share one stylesheet and the board pans and zooms. Rung 1: unit tests, mutation plants, and headless SDK-host renders in Chrome; unmeasured in the Copilot app. Load-bearing:

  • One shared stylesheet, generated from its tokens. canvas-theme.mjs exports THEME_CSS, and each page's /app.css is that block followed by the page's own rules (no new route). Every colour is an alias that reads the app's injected token first, var(--background-color-default, var(--ak-bg)), so theming stays the app's. The --ak-* fallbacks come from THEME_TOKENS and follow prefers-color-scheme; before 0.11.0 a dark render outside the app kept a white page and dark text, and only native form controls followed the system theme. The contrast test reads THEME_TOKENS, so a hue changed there is checked at 4.5:1 on the background, the surface, and its own tint, in both themes.
  • No inline style on either page, tested. test/visual-pass.test.ts fails on a style attribute or element in the HTML and on .style, 'style', cssText, setProperty(, insertRule(, or CSSStyleSheet in either script. Sizes are class steps (the graph frame's h-s to h-xl), never computed styles.
  • Pan and zoom are pure helpers shipped twice. board-view.mjs exports them for the tests, and board-page.mjs embeds the same functions with Function.prototype.toString. Each must stay self-contained ES2017 (no imports, no module constants, no closures); a test runs the embedded copies. The decision portal also carries a generated copy of board-view.mjs, board-layout.mjs, and canvas-theme.mjs (see "The decision portal" below), so an edit to any of the three needs bun run emit:portal-vendor. A view is a viewBox, so zooming sets one attribute and rebuilds nothing. Fit means the whole graph, so fitView may go below the 0.2 zoom minimum and zooming out stops at that fit, not at 0.2 (review on #282).
  • Dim the box, never the label. A dimmed record keeps its text and focus ring at full opacity (0.28 on the whole group puts a muted label at about 1.5:1 on the light surface, computed from the fallback tokens), and a clipped title is cut by grapheme, never mid-character.
  • A drag must not select. A pointer drag past 4 px sets a flag the record's click handler consumes, and the graph's + - 0 and arrow keys ignore events from an input or button.
  • The confirm step is an attribute, not a class. data-step="confirm" styles step 2 of the review controls, because the review tests find the box by className === 'review-controls'. With recording off, the fixed note is shown once above the queue and each disabled control carries it as its description.
  • Objector names are not shown, because adr queue does not report them. QueueReport v1 carries only the two objection counts; showing who raised each needs a CLI field, not a page change.

The decision portal (packages/portal)

A static, read-only decision portal for GitHub Pages, under ADR-0054 (accepted). @adrkit/portal is private and depends on @adrkit/sdk alone; it is the SDK's first consumer under ADR-0053. The nested build-only Action (packages/ci/portal, dist/portal-action.js) is described below. Rung 1: unit, contract, purity, and mutation tests, a Node run of the bundled generator, and a maintainer browser check on this repository's corpus. Since #310, site.yml also builds this repository's corpus with the shipped portal Action and publishes it at https://adrkit.dev/decisions/. That is supplemental dogfood, not rung 2: ADR-0014's rung 2 needs a run in a separate, isolated reference repository, and an in-repository consumer stays at rung 1 (ADR-0053). Load-bearing:

  • It lives outside the CLI. ADR-0033 decision 11 keeps HTML out of @adrkit/cli; do not add an adr site or --format html.
  • The builder is pure, and the file is deterministic. buildPortalData takes the handle, commit, asOf, and a resolved path table, and reads no clock, file, or environment, because ADR-0055's server will call it. All I/O is in generate.ts. portal-data.json has no generatedAt; identical inputs give identical bytes. A field is added additively; changing one is version: "2".
  • Governance by path is a precomputed table, never a matcher. States are available (possibly capped), unavailable (the corpus has an error, so the SDK withheld resolution), and absent (a host with no source tree). The page never says "ungoverned"; a miss in a capped table says "not in this table".
  • Untrusted text stays text. Titles, bodies, tags, and paths come from the consumer's repository. The Markdown subset parser turns raw HTML and any link that is not http(s), a fragment, or another record into text; the page rebuilds links from checked parts and builds its DOM with textContent only, under a default-src 'none' meta CSP. Tests fail on innerHTML, inline style, and a looser policy.
  • No verdict, no verbs. The queue view shows the raw review facts only, and the portal's own strings never name a review or ratifying command or a readiness word; a test enforces both.
  • The graph opens on supersession alone. The data file holds all three edge kinds; the other two are opt-in checkboxes. Edges are drawn beneath opaque record boxes.
  • Three modules are generated copies. src/vendor/ holds board-layout.mjs, board-view.mjs, and canvas-theme.mjs, copied from the agent plugin by bun run emit:portal-vendor, and clean-clone-builds fails on any diff. The plugin owns them; never edit the copies, and never copy board-page.mjs, board.mjs, panel-http.mjs, or board-review-write.mjs.
  • Page assets are text imports. src/page/assets.ts imports the page files (the icon among them) and two vendor modules with { type: 'text' }, so bun build --target=node embeds them. canvas-theme.mjs is imported as a module (for THEME_CSS) and cannot also be a text import in the same bundle, which is why it is not written into the output.

The portal Action (packages/ci/portal)

The third nested Action in @adrkit/ci (ADR-0054 clauses 2, 9, 10), running ../dist/portal-action.js on node24. It builds the bundle into out and nothing else: no token, no API call, contents: read. The consumer workflow deploys with upload-pages-artifact and deploy-pages in a separate job; the documented workflow is in site/src/content/docs/ci.mdx. Rung 1: unit, contract, and mutation tests, and a Node run of the committed bundle against a git fixture. site.yml builds this repository's corpus with it into /decisions/ on adrkit.dev and checks the bundle (scripts/check-portal-bundle.ts); that deploy is supplemental dogfood; rung 2 needs a run in an isolated reference repository (ADR-0014). Load-bearing:

  • Logic is src/portal-action.ts, wiring is the entrypoint. Only portal-action-entrypoint.ts imports @actions/*, as for the queue Action, so the exit mapping and the visibility gate are tested with stubs.
  • A private repository fails closed, and so does an unknown one. The Action publishes only when the event payload says the repository is public (private: false and no visibility or public), or publish-private is exactly 'true'. Private, internal, and a payload with no repository (or neither field) are refused with fixed messages, before the generator runs.
  • Exit 1 publishes. 0 succeeds, 1 succeeds with a warning annotation, 2 fails with the generator's own fixed message and nothing written. An absent optional input is passed as undefined, never '': the generator treats asOf: '' as a usage error. A throw fails with a fixed message, never the error's text.
  • Nested only, immutable pins. No root alias, never mbeacom/adrkit@v0. Docs pin packages/ci/portal@vX.Y.Z, because recovering v0 to a pre-portal release removes the path from @v0.
  • The container build stage copies packages/portal/package.json. Bun 1.4.2's frozen install refuses a pruned workspace set in which a copied workspace (@adrkit/ci) depends on one not on disk; measured with podman build --target build. scripts/container-contract.test.ts checks every copied manifest's workspace dependencies are copied too.

The OCI container (ghcr.io/mbeacom/adrkit)

The OCI image is the fifth distribution surface, authorized by ADR-0032. It is versioned with the lockstep release, not independently. A successful Release workflow creates the lockstep draft only after npm succeeds. Publishing that stable draft triggers .github/workflows/container-release.yml, whose narrow write-capable job moves the Action's v0 tag before a separate lower-privilege job publishes one multi-architecture all-in-one image under immutable vX.Y.Z, moving vX, and latest tags with a registry provenance attestation. A failed or adapter-only Release run publishes no container. Manual recovery runs only from main and accepts only an existing successful stable GitHub release.

Things that are load-bearing:

  • Only the all-in-one adrkit target is published. The cli, mcp, server, ci, and queue-action targets exist for local isolation, SBOM, and policy checks; each final stage contains only its own executable. Publishing five packages would multiply visibility, retention, and rollback operations without adding behavior.
  • Bun builds; Node runs. The build stage uses the repository-pinned Bun version and bundles CLI/MCP source for Node. Final stages use Node 24, matching the Action runtime, and both base image indexes are pinned by digest.
  • MCP stays read-only and networkless. Run it with --read-only, --network none, and a :ro repository mount. The CI smoke speaks both MCP protocol eras through the dedicated image rather than treating clean EOF as a protocol test.
  • A container publish follows the coordinated release. It does not race the npm publication workflow and does not run for the independently versioned Spec Kit adapter. Container failure is recoverable by manually dispatching the workflow for the already-created release tag. All promotions are serialized; recovery may restore an immutable historical tag but moves vX or latest only when that release is still newest, and never changes an immutable tag to a different digest.

This surface is at rung 1 of ADR-0014: contract tests, local Docker/Podman build and runtime smoke, and CI construction. No reference-repository or external/community validation has been recorded.

The read-only REST server (packages/server)

@adrkit/server (private) is the image's server selector, under ADR-0055 (accepted). It authenticates no one. node:http over @adrkit/sdk and @adrkit/portal's pure builder, and nothing else (check-deps admits exactly those two). Implemented, rung 1 (#314): unit, contract, and side-effect-denial tests, a local Podman smoke, and a CI container smoke of the server selector under --read-only with a :ro corpus mount; the APIM path is unmeasured. Load-bearing:

  • The route table is the server. src/routes.ts is data; the dispatcher matches it and /openapi.json is generated from it. Every row is GET; any other method is 405 before routing and no body is read. operationIds are APIM tool names, so renaming one breaks remote agents.
  • The commit comes from the sidecar, never from git. .adrkit-corpus.json is the ADR-0055/0056 contract. Missing or invalid with the directory present is 503; a directory briefly absent (the two-move swap) or a failed or moved load keeps the last handle. A load is kept only if the sidecar names the same commit after it.
  • governing passes readMarkers: false, always. Removing it turns a query parameter into a file read; a test plants a marker file to catch it.
  • Errors come from messages.ts only. The catch-all answers internal with fixed text; a sentinel test covers thrown errors, sidecars, and directory names. No access log is written, because a request line carries caller-supplied paths.
  • The portal is per corpus at /portal/{corpus}/. / redirects relatively when one corpus is configured and lists them otherwise; links stay relative so a gateway base path survives. Portal files carry the page's CSP plus frame-ancestors 'none'. Its portal-data.json sets pathLookup to this server's own governing route (#315); the page prints it as text and never requests it, so do not add a fetch or loosen connect-src for it.
  • Configuration is ADRKIT_SERVER_CORPORA (key[=dir],... or JSON), confined under ADRKIT_SERVER_ROOT by realpath, at start and before every load. As PID 1 the binary handles SIGTERM itself.

Derived surfaces stay in lockstep three different ways

ADR-0040 (accepted) splits documentation drift into three classes and refuses to pretend one mechanism covers them. Conflating them is what produces a gate that reports green while checking nothing.

  • Derived inventory — MANIFEST.md's record table is a pure function of docs/adr/. bun run emit:manifest generates the block between its markers; clean-clone-builds asserts no diff. Never hand-edit it.
  • Referential integrity — bun run check:stale-refs fails when a document speaking in the present tense cites a superseded, rejected, or deprecated record without saying so. Acknowledgement is per paragraph or list item, and for a superseded record it must be the successor's id, not the word "superseded" — the word does not tell the reader where to go next. The @adr marker half of the same class is the separate, already-shipped stale-marker finding; the two share one definition of successor and nothing else, and prose is never treated as an inbound governance declaration.
  • Implementation claims — ADR action-item checkboxes are neither derivable nor checkable. Verify each claim against the tree before ticking, and leave an item unchecked when the evidence does not support it. This is process, and the record says so rather than faking automation.

Two boundaries are load-bearing:

  • Scan scope is the whole decision, and it is hand-maintained on purpose. docs/adr/, CHANGELOG.md, specs/, plan.md, and source are excluded because they narrate history correctly — rewriting them would falsify the past. That exclusion is the difference between one finding and 149 mentions. A new top-level prose document is unguarded until someone adds it to SCANNED in scripts/check-stale-adr-references.ts.
  • Neither rule gains exit-code authority over a consumer's corpus. The prose guard fails this repository's own CI step. It does not change adr lint, adr check, or the governing-decisions Action, and ADR-0022's deliberate denial of exit-code authority to marker findings is untouched.

Both guards are repo-local scripts, not CLI surface: adr graph --format json already emits every node's status and every supersedes edge, and the public CLI is a semver commitment (ADR-0031) whose write surface is deliberately small: new, migrate, accept (ADR-0044, accepted), and the review commands approve, object, and resolve (ADR-0051, proposed). A public Markdown inventory formatter waits for adopter demand.

Toolchain

This project uses Bun as its runtime, package manager, test runner, and bundler. Default to Bun instead of Node.js, npm, pnpm, or Vite:

  • bun install / bun add, not npm/yarn/pnpm
  • bun run <script>, bunx <pkg>
  • bun test, not jest/vitest
  • bun build, not webpack/esbuild

See ADR-0010 for the rationale (Bun for development; Node-targeted published artifacts).

Full, editor-scoped Bun conventions live in the subcontext rule files — keep them in sync when the tooling guidance changes:

Host-specific entry points

This file is the canonical, host-neutral project memory. The per-host files are thin pointers to it and carry only what is genuinely specific to their host — duplicating content into them costs every agent context on every session, and the copies drift:

Agent working directories

Do not write to the maintainer's main checkout. Agent runs work in a git worktree on their own branch, never in place on the primary clone.

Before the first write in a session, check where you are:

git rev-parse --show-toplevel   # is this the main checkout?
git worktree list               # the first entry is the main checkout
git status --porcelain          # is someone else's work already here?

If the toplevel is the main checkout, stop and create a worktree instead:

git worktree add -b <branch> ../copilot-worktrees/adrkit/<name> origin/main

Two rules follow from this, and both have been violated in practice:

  • A dirty main checkout is a hard stop, not a state to work around. Staged or modified files there are someone else's uncommitted work. Do not commit, stash, reset, checkout, or "clean up" around them — the owning session may still be running, and its work is invisible to git worktree list.
  • Never git worktree remove or git branch -D on the strength of commit ancestry alone. Squash merges rewrite history, so a fully-merged branch is never an ancestor of main and git log main..<branch> is not empty. Compare content instead — git diff <branch> origin/main -- <paths> — and gate the destructive command on that check actually passing, not merely on having printed a warning.