diff --git a/AGENTS.md b/AGENTS.md index 52b2f1bc..8caca2f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,17 +26,11 @@ tracks, applied to the instructions. One file, one answer. ## Writing a design-state record -This repository has adopted **half** of the kit's `design/state/` system, and the half it did -not adopt is the half this heading is about. What exists: `/agentkit:track`'s work-mirror -(`design/state/work/`, one `WorkRef` per issue — `design/90-decisions.md`, 2026-08-24) and -`design/state-index.md`, the projection over it (2026-09-05). What does not: `design/state/units/`, -and every other record kind. There are no `Unit`, `Invariant`, `Contract`, `Decision` or -`Question` records here, which is why five of the index's six regions render an empty-set -placeholder and only `outstanding` carries rows. - -So the full record-writing sequence the kit's own `AGENTS.md` describes under this heading — -appending to `90-decisions.md`, writing a decision record, updating unit records, regenerating -projections — still does not apply. **Write the decision-log entry alone**, in -`design/90-decisions.md`'s own register, in its existing format. Do not hand-write -`design/state-index.md`: it is generated, `/agentkit:track` regenerates it, and an edit between two of -its markers is discarded on the next run. +The kit no longer ships `/agentkit:track`, `Test-DesignState.ps1` or `Update-DesignProjection.ps1`, +so nothing regenerates this repository's `design/state/` records. What remains from the earlier +half-adoption is the work-mirror (`design/state/work/`, one `WorkRef` per issue — `design/90-decisions.md`, +2026-08-24) and `design/state-index.md`, the projection over it (2026-09-05); both are frozen as +they stand until a separate decision retires them (`design/90-decisions.md`, 2026-10-07). + +**Write the decision-log entry alone**, in `design/90-decisions.md`'s own register, in its existing +format. Do not hand-edit `design/state-index.md` or `design/state/`. diff --git a/CLAUDE.md b/CLAUDE.md index 18980d91..f7d0173b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -154,7 +154,7 @@ build context: matching the stated reading order. **A new page is invisible until it is listed there**; an id that does not resolve fails the build. - `build/ConvertTo-HumanDocumentation.ps1` — the design → human-doc generator. Run it after - editing marked blocks in `design/`; run `/agentkit:docs` for `docs/docs/guide.md`, then + editing marked blocks in `design/`; regenerate `docs/docs/guide.md` by hand, then stamp the guide with `./build/ConvertTo-HumanDocumentation.ps1 -StampGuide`. Never edit a generated engine page directly. `./build/Test-Documentation.ps1` runs the corresponding `-Check` automatically. @@ -239,7 +239,7 @@ bulk. When a suggestion is declined, record it in the affected document (or `OPEN-QUESTIONS.md`) as a known-and-retained issue rather than dropping it silently. **A reconciliation ends in a decision, not a report.** Any time you compare two things and -find they disagree — `/agentkit:align`, `/agentkit:install`, `/agentkit:track` drift, a spec checked against the +find they disagree — `/agentkit:align`, `/agentkit:install`, a spec checked against the code, or any time the user says "reconcile" — the work is not finished at the findings. Close by asking, one divergence at a time, each with a recommendation and what the alternatives cost. **A report the user has to turn into questions is half the job.** @@ -310,7 +310,7 @@ decides whether a model should be doing it at all. A red item is a defect in the tooling, not in the run. Noticing one is worth a line; performing it repeatedly and never saying so is the failure. When a red item recurs, add it to the open register -in `design/90-decisions.md` so `/agentkit:track` can turn it into an issue — that is the existing path, and +in `design/90-decisions.md` so it is picked up as an issue — that is the existing path, and there is no separate mechanism for this. This repository has already taken that route once: `build/ConvertTo-HumanDocumentation.ps1` exists because regenerating human pages by hand was red. @@ -321,7 +321,7 @@ Two distinctions that are easy to get wrong: merge convention governs is not. Do not classify a whole command by its cheapest step. - **Do not report a cost you did not measure.** A model is not given its own token counts or elapsed time, so any figure it states about its own run is an estimate presented as a - measurement. `tools/Measure-Session.ps1` reads the real per-call usage from the session + measurement. the kit's `measure-session.ts` reads the real per-call usage from the session transcript, and runs as a `SessionEnd` hook. It measures **Claude Code sessions only** — Codex writes a different schema this has no reader for, and Copilot records no token usage at all. Use it, or say nothing — under either of those, *say nothing* is the whole @@ -345,35 +345,20 @@ Two distinctions that are easy to get wrong: ### The Design Freeze -The pipeline's normal loop keeps `design/` live: a unit lands, `/agentkit:align` writes reality -back, `/agentkit:track` resyncs the tracker. That is right while the design is still being settled -and **wrong once implementation is the bottleneck**, because each pass is generative rather -than merely checking — landing W rewrites W's specification, which desyncs the -tracker, which needs `/agentkit:track`, which finds drift, which needs `/agentkit:align`. The loop has no -fixed point. Freezing is how it is escaped. - -**`design/FROZEN.md` is the marker, and its existence is the whole mechanism.** It is -tracked, not ignored — a freeze is a statement to everyone working in the repository, not -local state. While it exists: - -- **`/agentkit:align` and `/agentkit:track` do not run.** The tracker is deliberately allowed to go stale. -- **`/agentkit:design`, `/agentkit:spec` and `/agentkit:plan` refuse.** Authoring is gated too, so the docs - cannot drift forward while the implementation is being checked against them. -- **Units implement against `20-contract.md` as a fixed artifact**, at the SHA the marker - names. -- **A contradiction found while implementing is stated in that unit's pull request and left - in the document.** Do not fix it in `design/`. The staleness is the point; recording it in - the PR is what makes the eventual reconciliation cheap. - -**`/agentkit:hold` writes the marker; `/agentkit:resume` lifts it** — deletes the file, then runs one -reconciliation pass, `/agentkit:align` then `/agentkit:track`, in the same session. `/agentkit:resume` runs -unattended, without a confirmation prompt; the freeze itself is still the user's decision, -made when `/agentkit:hold` is invoked, and lifting it early is one command call away rather than -gated a second time. A unit that turns out to need a contract amendment still stops and -says so; that escalation is the user's to answer, and answering it may well be "thaw, -amend, re-freeze." - -The marker's format, which the five gated commands read and must not restate: +The kit no longer ships `/agentkit:hold`, `/agentkit:resume`, `/agentkit:track` or the gated +authoring commands, so a freeze is now a **manual convention** with no command enforcing it. +The reasoning is unchanged: keeping `design/` live while implementation is the bottleneck has +no fixed point, because each reconciliation pass rewrites the next unit's specification. + +**`design/FROZEN.md` is still the marker, and its existence is the whole mechanism.** It is +tracked, not ignored — a freeze is a statement to everyone working in the repository. While it +exists, nobody runs `/agentkit:align`, `/agentkit:design` or `/agentkit:plan`, and units +implement against `20-contract.md` as a fixed artifact at the SHA the marker names. A +contradiction found while implementing is stated in that unit's pull request and left in the +document; the staleness is what makes the eventual reconciliation cheap. + +The marker is written and lifted **by hand**. Lifting is deleting the file, then running +`/agentkit:align` in the same session. Its format: ```markdown # design/ is frozen @@ -382,23 +367,26 @@ Frozen at: , Frozen because: Lifts when: -To lift: run `/agentkit:resume`, or delete this file by hand and run `/agentkit:align`, then `/agentkit:track`. +To lift: delete this file and run `/agentkit:align`. ``` -A command that refuses reports `Frozen because` and `Lifts when` **verbatim** rather than -paraphrasing them — the point of a stated condition is that it can be checked against, and a -paraphrase is where it stops being checkable. +Anything that refuses on the marker reports `Frozen because` and `Lifts when` **verbatim**. +Recorded in `design/90-decisions.md`, 2026-10-07. ### The Agent Kit — Canonical Workflow -The [agent kit](https://github.com/The-Running-Dev/SubZeroDev.AgentKit)'s pipeline commands -load machine-wide as the `agentkit` plugin — `/agentkit:`, each read live from the kit -checkout — so this repository holds no copies of them, only its companions, -`skills//SKILL-local.md`. Bare `/plan`, `/help` and `/design` are Claude Code's own; the -namespace is not optional. -`design/00-brief.md`, `10-design.md`, `20-contract.md`, `30-slices.md`, and -`90-decisions.md` are now their real canonical inputs. This repository extends the generic kit -in two deliberate ways: +The [agent kit](https://github.com/The-Running-Dev/SubZeroDev.AgentKit)'s commands load +machine-wide as the `agentkit` plugin — `/agentkit:`, each read live from the kit +checkout — so this repository holds no copies of them and no per-repository companions. Bare +`/plan` and `/design` are Claude Code's own; the namespace is not optional. `/agentkit:sync` +advances the kit; `/agentkit:install` reconciles this repository's own files. + +The kit's pipeline is `interview` → `design` → `plan` → `next`, with `fix`, `align` and +`redteam` alongside, and `install`, `install-all`, `install-review` and `sync` for setup. +`/agentkit:next` builds the plan slice by slice through merge, in one session. There is no +separate `slice`, `check`, `pr`, `resolve`, `track`, `docs`, `hold` or `resume`. +`design/00-brief.md`, `10-design.md`, `20-contract.md`, `30-slices.md`, and `90-decisions.md` +are the canonical inputs. This repository extends the generic kit in two deliberate ways: - Existing W identifiers remain the slice ids. Renumbering them to S would break plans, tests, issues, changelog entries, and merged history without changing the work. @@ -411,86 +399,45 @@ executed, but it never overrides the five canonical design files. **Generation workflow.** After a canonical edit: 1. Run `./build/ConvertTo-HumanDocumentation.ps1` to regenerate detailed engine pages. -2. Run `/agentkit:docs` to regenerate `docs/docs/guide.md` when the brief, design, or contract - changed semantically. +2. Regenerate `docs/docs/guide.md` by hand when the brief, design, or contract changed + semantically — `/agentkit:docs` no longer exists. 3. Run `./build/ConvertTo-HumanDocumentation.ps1 -StampGuide` after regenerating the guide. 4. Run `./build/Test-Documentation.ps1`; it checks both exact generated pages and guide digest. -Routing, when a command is run: +**Routing, when a command is run:** | Command | Tier | |---|---| -| `/agentkit:brief`, `/agentkit:design`, `/agentkit:spec`, `/agentkit:plan` | Opus, high | +| `/agentkit:brief`, `/agentkit:design`, `/agentkit:plan`, `/agentkit:interview` | Opus, high | | `/agentkit:redteam` | strongest model, **different vendor from the design author**; if it must be Claude, a fresh Opus session | -| `/agentkit:slice` | Sonnet, medium — high for a large or difficult work unit | +| `/agentkit:next` | Sonnet, medium — high for a large or difficult work unit | +| `/agentkit:fix` | Sonnet, medium — escalate to deep reasoning only to diagnose | | `/agentkit:align` | Opus, high to decide which side of a drift is correct; Sonnet, medium to apply the edits | -| `/agentkit:install` | Sonnet, medium | -| `/agentkit:install-all` | Sonnet, medium — escalate only to judge whether a per-repo hard stop is actually safe to resolve; never to resolve it unattended | -| `/agentkit:docs` | Sonnet, medium — generate the developer guide from canonical `design/`, then stamp its digest | -| `/agentkit:track` | Sonnet, medium — escalate only to judge whether a drifted work unit is a design change | -| `/agentkit:check` | Sonnet, medium — escalate to deep reasoning only to diagnose a failure, never to run the gates | -| `/agentkit:pr` | Sonnet, medium | -| `/agentkit:resolve` | Sonnet, medium — escalate to judge a contested finding, not to triage the obvious ones | -| `/agentkit:tune` | Sonnet, medium — never escalates; an architectural ask is routed to the command that owns it, not refined | -| `/agentkit:help` | Haiku, low — orientation from file existence and a tracker listing; escalate only where the repository's state matches no stage | -| `/agentkit:clean` | Haiku, low — mechanical git housekeeping; escalate only to judge whether an unmerged-looking branch is actually safe to delete | -| `/agentkit:hold` | Sonnet, medium — `Frozen because`/`Lifts when` come from the user, never invented | -| `/agentkit:resume` | Sonnet, medium for the sequencing; runs `/agentkit:align` (Opus, high) and `/agentkit:track` (Sonnet, medium) as its own phases | - -**`/agentkit:track` reads `design/30-slices.md`.** It opens one issue per unvisited W-numbered work unit; -W is this repository's retained slice prefix. New units carry stable per-criterion ids -(`W42.1`, `W42.2`, never reused or renumbered — the same discipline as the existing positional -`W3a` unit numbering, one level down), and issues open in the shape the kit's `skills/track/SKILL.md` -defines: a human-first narrative, `### Done when` checkboxes, and a fenced `` -block that `/agentkit:track` regenerates but never overwrites outside the fence. Existing checked units -predate this and are not retrofitted or reopened. - -**Tracking work.** Opening, labelling, closing, commenting on, and editing an issue — including -one opened by someone else — needs no per-instance approval in a repository the user owns; -issues are cheap and reversible. Creating a milestone or a project is carved out the same way; -deleting either is not, since that direction is not cheaply reversible. Writing to a repository -the user does not own is never carved out. **Resolving or replying to a review thread is not -covered by this carve-out** — a pull request's review threads are a different object and stay -authorized regardless, same as any other external write in **Git and Pull Requests** below. -`/agentkit:track` owns every GitHub write it can make idempotent; closing an issue and ticking a -checkbox are the exceptions — the command that observes the work done (`/agentkit:track`, `/agentkit:slice`) -does those directly, in the same run, rather than waiting for a sync pass. - -**Session boundaries.** Routing above says which model runs a command. This says **when a session -must end.** A boundary exists wherever carrying context would corrupt the next step's judgement, or -wherever the next step must read the tree rather than remember it. The artifact is the handoff, not -the conversation — a stage that writes one has already handed over everything the next stage is -entitled to. +| `/agentkit:install`, `/agentkit:install-all`, `/agentkit:install-review`, `/agentkit:sync` | Sonnet, medium — escalate only to judge whether a hard stop is safe to resolve | + +**Tracking work.** The kit no longer opens issues from `design/30-slices.md`; `design/state/work/` +and `design/state-index.md` are an adopted-but-orphaned mirror, kept in place until the user +decides whether to retire them. Opening, labelling, closing, commenting on, and editing an +issue — including one opened by someone else — needs no per-instance approval in a repository +the user owns; issues are cheap and reversible. Creating a milestone or a project is carved +out the same way; deleting either is not. Writing to a repository the user does not own is +never carved out. **Resolving or replying to a review thread is not covered by this +carve-out** — review threads stay authorized regardless, same as any other external write in +**Git and Pull Requests** below. + +**Session boundaries.** A boundary exists wherever carrying context would corrupt the next +step's judgement. The artifact is the handoff, not the conversation. | Boundary | Rule | Why | |---|---|---| -| `/agentkit:design` → `/agentkit:redteam` | **Fresh session, and a different vendor.** | A model recognises its own output distribution and defends it. Fresh context on the same model is already the weak form; the same session is not a review at all. | -| Any stage that writes a canonical `design/` file → the next | Fresh. | The next stage's input is the committed file. A session that also remembers the arguments behind it will design against the arguments. | -| `/agentkit:plan` → `/agentkit:slice` | Fresh, and **one work unit per session**. | A unit that does not fit one session without compaction is too large — that is a `/agentkit:plan` defect, so say so rather than pressing on. | -| `/agentkit:slice` → `/agentkit:check` → `/agentkit:pr` → `/agentkit:resolve` | **Same session.** | These act on the branch and worktree the unit just produced, and `/agentkit:pr` must carry `/agentkit:check`'s did-not-run list into the description **verbatim**. A fresh session would restate it from a summary, which is the fabricated gate result **Validation** below exists to prevent. | -| merge → `/agentkit:track` | Fresh. | `/agentkit:track` reads the tracker and `design/` as they now stand. The session that just implemented the unit holds an opinion about whether it is done, and doneness is the user's mark, not an agent's. | -| implementation → `/agentkit:align` | Fresh. | It compares the tree against the canonical documents. The session that wrote the code carries what it *intended* to write, which is the one thing the comparison must not be given. | - -**Compaction is a boundary you did not choose.** If a session compacts mid-unit, report it — the -unit was mis-sized, and the work after the compaction was done against a summary of the contract -rather than the contract itself. - -**End a response that lands on a fresh-session boundary with a banner, not a footnote.** A -boundary buried in the last sentence of a report gets carried into the next reply of the same -session out of habit, which is the exact failure the boundary exists to prevent. Set it off -visibly — a horizontal rule and a bold line is enough — naming: the boundary just crossed, the -next command, and its tier from the routing table above. For example: +| `/agentkit:design` → `/agentkit:redteam` | **Fresh session, and a different vendor.** | A model recognises its own output distribution and defends it. | +| Any stage that writes a canonical `design/` file → the next | Fresh. | The next stage's input is the committed file, not the arguments behind it. | +| `/agentkit:plan` → `/agentkit:next` | Fresh. | `/agentkit:next` reads the plan from `origin/`, not from memory. | +| `/agentkit:next` (within one run) | **Same session.** | It carries a slice from code to merged PR, and the PR's *Verified* section must state what did not run. | +| implementation → `/agentkit:align` | Fresh. | It compares the tree against the canonical documents; the session that wrote the code carries what it *intended*. | -``` ---- -**Session boundary.** This context should not carry into `/agentkit:track`. -Next: `/agentkit:track`, fresh session, Sonnet, medium. ---- -``` - -Do not run the next command yourself. Ending a session may be the next step, and a command that -starts work cannot also tell the user to start a new one for it — that restriction is unchanged, -only how visibly the handoff is stated. +**Compaction is a boundary you did not choose.** If a session compacts mid-slice, report it — +the slice was mis-sized, and the work after it was done against a summary of the contract. ### Single Ownership @@ -534,19 +481,14 @@ in CI, with nothing saying why. feature branches do not, so it is discipline rather than enforcement. If a pushed commit needs changing, add a follow-up commit. -**Deleting a local branch `/agentkit:clean` independently confirms via `git branch --merged` is -delegated in this repository.** `/agentkit:clean` (the kit's `skills/clean/SKILL.md`) runs proactively — as -soon as a merge is on the table, not only when asked — and deletes every branch on that -confirmed list without a chat confirmation first; the `--merged` check is the authorization. -It also may stash (never discard) a dirty tree to unblock its own branch switch, and always -reports the stash back rather than popping it silently. This delegation stops exactly where -`--merged` stops: a branch it did not confirm, or a `-d` refusal on one it did, still needs a -separate ask before anything stronger (`-D`) is even considered. - -**`/agentkit:slice` may push the branch it creates and open its PR as a draft, without asking** — -carved out of the authorization rule the same as an issue: a draft blocks no one and requests -no review, so opening one carries the same reversibility argument. Marking that PR ready for -review, and merging it, are not carved out and stay `/agentkit:pr`'s and the user's respectively. +**Housekeeping is delegated.** `/agentkit:next` runs the kit's `invoke-housekeeping.ts`, which deletes a +local branch only after confirming it merged (`git branch --merged`, or a squash-merged PR whose +head matches the local tip), never stashes, and escalates anything else. That check is the +authorization; a branch it did not confirm needs a separate ask before `-D` is considered. + +**`/agentkit:next` opens its pull requests non-draft and merges them** through the kit's merge script, +which refuses on unconfirmed checks. The earlier draft-PR carve-out belonged to `/agentkit:slice`, +which no longer exists. **Do not enable auto-merge.** Open the PR, report the check outcomes, and leave the merge to the user. (Auto-merge is enabled at the repository level and `required_approving_review_count` diff --git a/design/90-decisions.md b/design/90-decisions.md index b0f9c8c4..88911103 100644 --- a/design/90-decisions.md +++ b/design/90-decisions.md @@ -2211,3 +2211,9 @@ Reversibility: cheap. Restoring the copies is one checkout of `6a91aec`'s `.clau Context: `.claude/settings.json` ran `pwsh … tools/Measure-Session.ps1` on `SessionEnd`, but that script no longer exists — it left this repository when the kit moved to a single home install, and the kit later ported it to Node as `measure-session.ts` — so the hook failed at the end of every session. The kit's setup installs one global `SessionEnd` hook in `~/.claude/settings.json` that logs every project. Chosen: remove this repository's `hooks.SessionEnd` entry, in the AgentKit sync to `v2026.10.06.1`. Nothing else in `settings.json` changes. Rejected: point it at `measure-session.ts` — the global hook already runs that script, so every session would be logged twice; leave it — it keeps failing at every session end. + +### 2026-10-07 — Kit sync to `v2026.10.07`: the track/slice pipeline is retired, and the docs stop naming it +Context: The kit's `v2026.10.07` ships thirteen commands (`interview`, `design`, `plan`, `next`, `fix`, `align`, `redteam`, `install`, `install-all`, `install-review`, `sync`, `brief`, `agentkit`) and no longer has `slice`, `check`, `pr`, `resolve`, `track`, `docs`, `hold`, `resume`, `help`, `tune`, `spec` or `clean`, nor `Test-DesignState.ps1` or `Update-DesignProjection.ps1`. `CLAUDE.md` and `AGENTS.md` still documented that pipeline, the `skills//SKILL-local.md` companions and the `design/state/` projection as live. +Chosen: Delete the 14 `SKILL-local.md` files and the three kit-owned `tools/*.ps1` copies (`Test-DesignDrift.ps1` and its test, `Test-CIWorkflow.Tests.ps1`). Rewrite `CLAUDE.md`'s Design Freeze and Agent Kit sections and `AGENTS.md`'s design-state section to the current command set. The freeze stays, as a manual convention: `design/FROZEN.md` is written and lifted by hand. `design/state/` and `design/state-index.md` stay in place, orphaned, until a separate decision retires them. +Rejected: **Leave the docs as written** — they point readers at commands that do not exist. **Retire the freeze with the commands that enforced it** — the reasoning for it (no fixed point while implementation is the bottleneck) still holds and a marker costs nothing. **Delete `design/state/` now** — that is a decision about the tracker mirror, not a transcription, and `design/20-contract.md`'s Design-state mechanics section names it. **Edit `design/20-contract.md` here** — it carries the same stale references (`Test-DesignState.ps1`, `Update-DesignProjection.ps1`, `/track`) and belongs to a `/agentkit:align` pass, not an install. +Reversibility: cheap. The deletions and the prose are one revert of this change. diff --git a/skills/align/SKILL-local.md b/skills/align/SKILL-local.md deleted file mode 100644 index f9724983..00000000 --- a/skills/align/SKILL-local.md +++ /dev/null @@ -1,2 +0,0 @@ -## vocabulary -`AGENTS.md` is named `CLAUDE.md` here. diff --git a/skills/check/SKILL-local.md b/skills/check/SKILL-local.md deleted file mode 100644 index 7ba68568..00000000 --- a/skills/check/SKILL-local.md +++ /dev/null @@ -1,30 +0,0 @@ -## gate-commands -| Flagged step | Workflow | Run locally | -|---|---|---| -| `Typecheck` | `ci.yml` | `npm --prefix src/engine run typecheck` | -| `Lint` | `ci.yml` | `npm --prefix src/engine run lint` | -| `Test` | `ci.yml` | `npm --prefix src/engine test` | -| `Pack package` | `ci.yml` | `cd src/engine; npm pack --silent` | -| `Inspect tarball` | `ci.yml` | `tar -tzf` the packed tarball; assert no `src/`, no `tsconfig*.json`, no `.test.*` artifacts, `package/dist/` present | -| `Consumer smoke` | `ci.yml` | Clear `consumer-smoke/{node_modules,package-lock.json,dist}`, then `npm run install:engine && npm run build && npm run smoke` | -| `Parse-check PowerShell scripts` | `verify.yml` | Parse every `*.ps1` with `[System.Management.Automation.Language.Parser]::ParseFile`, as the step does | -| `Run Pester tests` | `verify.yml` | `Invoke-Pester -Path tools -Output Detailed -PassThru` | -| `Validate Markdown links, terminology, and generated files` | `docs-ci.yml` | `./build/Test-Documentation.ps1` | -| `Build documentation` | `docs-ci.yml` | `./docs.ps1 -BuildOnly` — needs Docker **and** an installed `docs.ps1` | -| `Build and verify landing page` | `docs-ci.yml` | `npm --prefix src/engine run build; npm --prefix site run check` | -| `Merge landing page into documentation build` | `docs-ci.yml` | `npm --prefix site run merge` — needs a completed docs build in `artifacts/docs` | -| `Test the host` | `host-image.yml` | `dotnet test src/host/SubZeroDev.GameEngine.Host.Tests/…csproj` — needs `NUGET_GITHUB_TOKEN` for the sibling-repo feed | -| `Positive route and probe smoke` | `host-image.yml` | Build and run the host image, then `curl` `/`, `/roadmap/`, `/docs/`, `/health/live`, `/health/ready` → 200 and an unknown route → 404 | -| `Negative fixture -- corrupted artifact must fail to start` | `host-image.yml` | `docker build -f tools/host-smoke/Dockerfile.negative-fixture …`; the run must exit non-zero | - -Discover by reading the workflow files rather than trusting a memorized list; the fifteen rows -above describe this repository's gates as of the last sync (2026-08-21), not a substitute for -reading `.github/workflows/*.yml` directly. `Test-Companion.ps1` and `Test-DesignState.ps1` are -no longer `# verification: true` flagged in `verify.yml` — worth running, but not required gates. - -Local discovery, beyond the workflow files: -```powershell -if (Test-Path package.json) { Get-Content package.json | Select-String '"scripts"' -Context 0,20 } -Get-ChildItem . -Include *.sln, *.csproj -Recurse -Depth 2 -if (Test-Path build) { Get-ChildItem build -Filter *.ps1 } -``` diff --git a/skills/design/SKILL-local.md b/skills/design/SKILL-local.md deleted file mode 100644 index f9724983..00000000 --- a/skills/design/SKILL-local.md +++ /dev/null @@ -1,2 +0,0 @@ -## vocabulary -`AGENTS.md` is named `CLAUDE.md` here. diff --git a/skills/help/SKILL-local.md b/skills/help/SKILL-local.md deleted file mode 100644 index 1c12041f..00000000 --- a/skills/help/SKILL-local.md +++ /dev/null @@ -1,8 +0,0 @@ -## vocabulary -`AGENTS.md` is named `CLAUDE.md` here. Slice ids use the retained `W` scheme, not `S` — -read `/slice S3` below as `/slice W`. - -## document-map -`design/` here is a compound canonical set with generated `human-doc` blocks; file existence -alone is a weaker stage signal than in a bare repository. Read the marked delivery ledger in -`design/30-slices.md` rather than inferring status from the generated `docs/docs/engine/TODO.md`. diff --git a/skills/hold/SKILL-local.md b/skills/hold/SKILL-local.md deleted file mode 100644 index 909061a6..00000000 --- a/skills/hold/SKILL-local.md +++ /dev/null @@ -1,2 +0,0 @@ -## vocabulary -`AGENTS.md` is named `CLAUDE.md` here; the section is *The Design Freeze*. diff --git a/skills/plan/SKILL-local.md b/skills/plan/SKILL-local.md deleted file mode 100644 index d4976240..00000000 --- a/skills/plan/SKILL-local.md +++ /dev/null @@ -1,5 +0,0 @@ -## vocabulary -`AGENTS.md` is named `CLAUDE.md` here. This repository retains its established `W` prefix — do -not renumber existing units to `S`; every `S` heading and id below (`S.1`, `S3.1`, …) reads -as `W` here. Existing per-unit numbering already goes one level deeper than a bare `.1`/`.2` -(e.g. `W3a`) — extend that scheme rather than introducing a second one. diff --git a/skills/pr/SKILL-local.md b/skills/pr/SKILL-local.md deleted file mode 100644 index 0384e1b6..00000000 --- a/skills/pr/SKILL-local.md +++ /dev/null @@ -1,10 +0,0 @@ -## vocabulary -`AGENTS.md` is named `CLAUDE.md` here. Slice ids use the retained `W` scheme, not `S` — read -the `Slice: S3` example below as `Unit: W`. - -## tightened-authorization -`CLAUDE.md`, *Git and Pull Requests* carves `/slice` out to open its PR as a draft, without asking -— narrower than this core's "never as a draft." Where `gh pr view` finds a PR already open on this -branch, **ask before marking it ready for review** rather than treating the existing-PR case as -already actionable; do not open a second PR either way. This tightens what the core does on its -own — it does not remove the core's authorization to open a PR at all when none exists. diff --git a/skills/redteam/SKILL-local.md b/skills/redteam/SKILL-local.md deleted file mode 100644 index f9724983..00000000 --- a/skills/redteam/SKILL-local.md +++ /dev/null @@ -1,2 +0,0 @@ -## vocabulary -`AGENTS.md` is named `CLAUDE.md` here. diff --git a/skills/resolve/SKILL-local.md b/skills/resolve/SKILL-local.md deleted file mode 100644 index 00ee07da..00000000 --- a/skills/resolve/SKILL-local.md +++ /dev/null @@ -1,3 +0,0 @@ -## vocabulary -`AGENTS.md` is named `CLAUDE.md` here; the section this file cites as *Git and delivery* is -*Git and Pull Requests*, and *Working with me* is *Working Conventions*. diff --git a/skills/resume/SKILL-local.md b/skills/resume/SKILL-local.md deleted file mode 100644 index f9724983..00000000 --- a/skills/resume/SKILL-local.md +++ /dev/null @@ -1,2 +0,0 @@ -## vocabulary -`AGENTS.md` is named `CLAUDE.md` here. diff --git a/skills/slice/SKILL-local.md b/skills/slice/SKILL-local.md deleted file mode 100644 index 771c281e..00000000 --- a/skills/slice/SKILL-local.md +++ /dev/null @@ -1,8 +0,0 @@ -## vocabulary -`AGENTS.md` is named `CLAUDE.md` here. Slice ids use the retained `W` scheme, not `S` — -`slice/S`, `S3.1`, and the issue-title match below all read with `W` in place of `S`. - -## document-map -`design/` here is a compound canonical set with generated `human-doc` blocks; read the marked -delivery ledger in `design/30-slices.md` in full, not the generated `docs/docs/engine/TODO.md` -copy. diff --git a/skills/spec/SKILL-local.md b/skills/spec/SKILL-local.md deleted file mode 100644 index f9724983..00000000 --- a/skills/spec/SKILL-local.md +++ /dev/null @@ -1,2 +0,0 @@ -## vocabulary -`AGENTS.md` is named `CLAUDE.md` here. diff --git a/skills/track/SKILL-local.md b/skills/track/SKILL-local.md deleted file mode 100644 index 35a0bd30..00000000 --- a/skills/track/SKILL-local.md +++ /dev/null @@ -1,24 +0,0 @@ -## vocabulary -`AGENTS.md` is named `CLAUDE.md` here. This repository retains its established `W` prefix — every -`S` heading, id (`S3.1`), and issue-title pattern (`S —`) below reads as `W` here, matching -the existing positional `W3a` unit numbering one level down. - -## document-map -`design/30-slices.md` has no `## Outstanding` / `## Landed` split, and is not going to acquire -one. A unit's state is carried on its own heading — `### [x] W102 — …` for landed, `### [ ] W103 -— …` for outstanding, `### [~] W68 — … — cancelled` for abandoned — inside the topical section -that explains why the unit exists. `Test-DesignDrift.ps1` reads that convention correctly; it is -the *retirement* step that does not, because there is no second section for a body to move into. - -Read this document by heading marker, therefore, and treat *Landed slices → retired* as not -applying here: `tools/Update-SlicesDocument.ps1` exits 2 with `NoLandedSection` on every run, and -that is the expected outcome rather than a finding to report or a gap to repair. Nothing else -about the step changes — a landed unit is still one whose issue is closed, and the reported-not- -claimed rule for a step that did not run is unaffected. - -**Why the document is shaped this way.** The `[x]` heading and the surrounding narrative are the -same artifact: `### Correctness Debt — The Tick Pipeline Runs Twenty Systems and Implements -Fifteen` is only legible with the five units under it, landed or not, in delivery order. -Retiring a body to a table elsewhere would leave the section that argued for it empty, and -restructuring the ~4,600 lines would rewrite every `{#w}` anchor and every cross-link into -them across `design/` and the generated pages. Recorded in `design/90-decisions.md`, 2026-09-05. diff --git a/skills/tune/SKILL-local.md b/skills/tune/SKILL-local.md deleted file mode 100644 index e56185ca..00000000 --- a/skills/tune/SKILL-local.md +++ /dev/null @@ -1,6 +0,0 @@ -## vocabulary -`AGENTS.md` is named `CLAUDE.md` here — every `AGENTS.md §` citation this command emits should -resolve through that file's real section names (e.g. the routing table lives under *The Agent -Kit — Canonical Workflow*, not a section literally named "Command routing"); never emit a section -name you have not seen in `CLAUDE.md` itself. Slice ids use the retained `W` scheme, so -`/slice S` in the routing table reads `/slice W` here. diff --git a/tools/Test-CIWorkflow.Tests.ps1 b/tools/Test-CIWorkflow.Tests.ps1 deleted file mode 100644 index 0f09e716..00000000 --- a/tools/Test-CIWorkflow.Tests.ps1 +++ /dev/null @@ -1,54 +0,0 @@ -#Requires -Version 7.0 -#Requires -Modules Pester - -<# - Regression coverage for #79: the "Run Pester tests" CI step calls the real design-state - check (S12.5, tools/Test-DesignState.Tests.ps1) against this repository, which needs an - authenticated gh exactly as the later "Check the design state against the tree" step - already does - so it needs the same GH_TOKEN. Without it, an unauthenticated gh turns - S12.5 into a could-not-evaluate (TrackerUnavailable) rather than a check of anything this - step is meant to gate. - - This regression only applies once the kit's design-state tracking is adopted here and - verify.yml has grown the "Check the design state against the tree" step this test compares - against - the kit's own compatibility promise (design/90-decisions.md, 2026-08-19) does not - migrate that step to installed targets, so its absence here is not a divergence to report. -#> - -Describe 'CI workflow: the Run Pester tests step is authenticated (#79)' { - BeforeAll { - # The assertion itself is intentionally skipped until this repository - # adopts the design-state workflow step. Pester still evaluates - # BeforeAll for skipped examples, so avoid reading a workflow that is - # deliberately absent in that compatibility state. - $workflowPath = Join-Path (Split-Path $PSScriptRoot -Parent) '.github/workflows/verify.yml' - $script:RunGhTokenTest = (Test-Path $workflowPath) -and - (Select-String -LiteralPath $workflowPath -Pattern '- name: Check the design state against the tree' -Quiet) - if (-not $script:RunGhTokenTest) { - $script:Lines = @() - return - } - - $script:WorkflowPath = $workflowPath - $script:Lines = Get-Content -LiteralPath $script:WorkflowPath - } - - It 'the "Run Pester tests" step carries a GH_TOKEN env, the same as "Check the design state against the tree"' { - if (-not $script:RunGhTokenTest) { - Set-ItResult -Skipped -Because 'This repository has not adopted the design-state workflow step.' - return - } - $stepIndex = ($script:Lines | Select-String -Pattern '- name: Run Pester tests').LineNumber - $stepIndex | Should -Not -BeNullOrEmpty - - # The step body runs from its `- name:` line to the line before the next `- name:` - # (or end of file), so this only inspects this one step's own env block. - $nextStepIndex = ($script:Lines | Select-String -Pattern '^\s*- name:' | - Where-Object { $_.LineNumber -gt $stepIndex } | - Select-Object -First 1).LineNumber - $endIndex = if ($nextStepIndex) { $nextStepIndex - 1 } else { $script:Lines.Count } - $stepBody = $script:Lines[($stepIndex - 1)..($endIndex - 1)] -join "`n" - - $stepBody | Should -Match 'GH_TOKEN:\s*\$\{\{\s*secrets\.GITHUB_TOKEN\s*\}\}' - } -} diff --git a/tools/Test-DesignDrift.Tests.ps1 b/tools/Test-DesignDrift.Tests.ps1 deleted file mode 100644 index 804af536..00000000 --- a/tools/Test-DesignDrift.Tests.ps1 +++ /dev/null @@ -1,347 +0,0 @@ -#Requires -Version 7.0 -#Requires -Modules Pester - -<# - Test-DesignDrift.ps1 exits the process on every path (0/1/2), which would kill the Pester - runner if invoked with `&`. Same structure as Wait-PullRequestCheck.Tests.ps1: the script - guards its exit-calling wrapper with `$MyInvocation.InvocationName -ne '.'`, so dot-sourcing - defines its functions here and skips the wrapper. `Invoke-DriftCheck` is called directly and - asserted on its returned result; `Get-DriftExitCode` is a pure state->code map tested alone. - - The two boundaries - the tracker and git - are mocked at their own functions rather than at - `gh` and `git`, because both read a native *exit code* as their answer and a mock cannot set - $LASTEXITCODE. Mocking the seam above them tests the comparison, which is what this script is. - - Every slices document is written into $TestDrive; none of these tests read the real one, so - they do not start failing when a slice lands. -#> - -BeforeAll { - $script:ScriptPath = Join-Path $PSScriptRoot 'Test-DesignDrift.ps1' - # The dot-source runs the script's own Set-StrictMode/$ErrorActionPreference in this scope. - # Captured here and restored in the matching AfterAll so they do not leak into whichever - # test file Pester runs next in the same invocation. - $script:PreDotSourceErrorActionPreference = $ErrorActionPreference - . $script:ScriptPath - - function New-SlicesDoc { - param([Parameter(Mandatory)][string] $Content, [string] $Name = 'slices.md') - $path = Join-Path $TestDrive $Name - Set-Content -LiteralPath $path -Value $Content -Encoding utf8 - $path - } - - function New-Issue { - param([int] $Number, [string] $Title, [string] $Body) - [pscustomobject]@{ number = $Number; title = $Title; state = 'OPEN'; body = $Body } - } - - function New-Tracker { - param([object[]] $Issues = @()) - [pscustomobject]@{ Issues = @($Issues); Failure = $null } - } - - # One slice, two criteria - the shape every positive case starts from. This repository's - # own vocabulary (W, not S; a `### [ ]` heading, not `##`) per track-local.md. - $script:TwoCriterionDoc = @' -# Slices - -## Core - -### [ ] W1 — A slice - -Delivers: something a reader can follow. - -- **Done when:** - - W1.1 The first criterion holds. - - W1.2 The second criterion holds. -'@ -} - -AfterAll { - $ErrorActionPreference = $script:PreDotSourceErrorActionPreference - Set-StrictMode -Off -} - -Describe 'Test-DesignDrift' { - - Context 'criterion ids' { - - It 'matching ids on both sides is Clean, exit 0' { - $path = New-SlicesDoc -Content $script:TwoCriterionDoc - Mock Get-TrackerIssue { New-Tracker -Issues @( - New-Issue -Number 9 -Title 'W1 — A slice' -Body "### Done when`n- [ ] **W1.1** first`n- [x] **W1.2** second" - ) } - - $r = Invoke-DriftCheck -SlicesPath $path - - $r.State | Should -Be 'Clean' - $r.Findings.Count | Should -Be 0 - $r.SlicesCompared | Should -Be 1 - Get-DriftExitCode -State $r.State | Should -Be 0 - } - - It 'reworded criteria with the same ids are not drift' { - $path = New-SlicesDoc -Content $script:TwoCriterionDoc - Mock Get-TrackerIssue { New-Tracker -Issues @( - New-Issue -Number 9 -Title 'W1 — A slice' -Body "- [ ] **W1.1** completely different wording here`n- [ ] **W1.2** and here too" - ) } - - (Invoke-DriftCheck -SlicesPath $path).State | Should -Be 'Clean' - } - - It 'a criterion the doc has already ticked is still a criterion, not a removal' { - # This repository ticks criteria in place, so the doc-side id sits behind a - # `- [x] ` the issue-side regex has always tolerated and this one had not. Left - # unhandled it reads as InIssueNotDoc - a renumber - which is the one finding - # that can invalidate a tick. - $path = New-SlicesDoc -Content @' -# Slices - -## Core - -### [ ] W1 — A slice - -Delivers: something a reader can follow. - -- **Done when:** - - [x] W1.1 The first criterion holds, and has landed. - - W1.2 The second criterion holds. -'@ - Mock Get-TrackerIssue { New-Tracker -Issues @( - New-Issue -Number 9 -Title 'W1 — A slice' -Body "- [x] **W1.1** first`n- [ ] **W1.2** second" - ) } - - (Invoke-DriftCheck -SlicesPath $path).State | Should -Be 'Clean' - } - - It 'an id in the doc but not the issue is reported as InDocNotIssue, exit 1' { - $path = New-SlicesDoc -Content $script:TwoCriterionDoc - Mock Get-TrackerIssue { New-Tracker -Issues @( - New-Issue -Number 9 -Title 'W1 — A slice' -Body '- [ ] **W1.1** first' - ) } - - $r = Invoke-DriftCheck -SlicesPath $path - - $r.State | Should -Be 'Drifted' - $r.Findings.Kind | Should -Contain 'InDocNotIssue' - ($r.Findings | Where-Object Kind -eq 'InDocNotIssue').Detail | Should -Be 'W1.2' - Get-DriftExitCode -State $r.State | Should -Be 1 - } - - It 'an id in the issue but not the doc - a renumber - is reported as InIssueNotDoc' { - $path = New-SlicesDoc -Content $script:TwoCriterionDoc - Mock Get-TrackerIssue { New-Tracker -Issues @( - New-Issue -Number 9 -Title 'W1 — A slice' -Body "- [ ] **W1.1** first`n- [x] **W1.7** a tick now pointing at something else" - ) } - - $r = Invoke-DriftCheck -SlicesPath $path - - $r.State | Should -Be 'Drifted' - ($r.Findings | Where-Object Kind -eq 'InIssueNotDoc').Detail | Should -Be 'W1.7' - } - - It 'an id cited in prose outside a slice section is not counted as a criterion' { - # The real document does exactly this in its Contract questions section, so a - # whole-file regex would invent criteria that were never cut. - $path = New-SlicesDoc -Content @' -# Slices - -## Contract questions - -None outstanding - zero checks yields NotEvaluated, exercised by W1.9, and the batch by W1.8. - -### [ ] W1 — A slice - -- **Done when:** - - W1.1 The only real criterion. -'@ - Mock Get-TrackerIssue { New-Tracker -Issues @( - New-Issue -Number 9 -Title 'W1 — A slice' -Body '- [ ] **W1.1** the only real criterion' - ) } - - (Invoke-DriftCheck -SlicesPath $path).State | Should -Be 'Clean' - } - - It 'a landed slice with no body is not reported as a removal' { - # The kit-generic `## Landed` table layout. This repository does not retire - # slice bodies this way (every `### [x] W` keeps its full "Done when" list), - # but the table-row path stays supported and is worth covering. - $path = New-SlicesDoc -Content @' -# Slices - -## Outstanding - -None. - -## Landed - -| Slice | Name | Issue | Criteria | Body complete at | -|---|---|---|---|---| -| **W1** | A slice that landed | #9, closed | W1.1-W1.2 | `af610a6` | -'@ - Mock Get-TrackerIssue { New-Tracker -Issues @( - New-Issue -Number 9 -Title 'W1 — A slice that landed' -Body "- [x] **W1.1** first`n- [x] **W1.2** second" - ) } - - $r = Invoke-DriftCheck -SlicesPath $path - - $r.State | Should -Be 'Clean' - $r.Findings.Count | Should -Be 0 - $r.SlicesCompared | Should -Be 0 - } - - It 'a slice with no issue at all is reported rather than skipped' { - $path = New-SlicesDoc -Content $script:TwoCriterionDoc - Mock Get-TrackerIssue { New-Tracker } - - $r = Invoke-DriftCheck -SlicesPath $path - - $r.State | Should -Be 'Drifted' - $r.Findings.Kind | Should -Contain 'NoIssue' - } - - It 'a ticked slice with no issue is not reported, while an unticked one still is' { - # This repository marks a slice landed with `### [x]` and keeps its body, rather - # than retiring it into the `## Landed` table. track.md does not sync a landed - # slice, so there is no issue to open for one that predates the tracker - but the - # suppression must not swallow an outstanding slice that genuinely lacks one. - $path = New-SlicesDoc -Content @' -# Slices - -## Core - -### [x] W1 — A slice that landed before the tracker - -- **Done when:** - - [x] W1.1 The first criterion holds. - -### [ ] W2 — A slice still outstanding - -- **Done when:** - - W2.1 The first criterion holds. -'@ - Mock Get-TrackerIssue { New-Tracker } - - $r = Invoke-DriftCheck -SlicesPath $path - - @($r.Findings | Where-Object { $_.Kind -eq 'NoIssue' }).Slice | Should -Be @('W2') - } - } - - Context 'pin ancestry' { - - BeforeEach { - $script:PinnedDoc = New-SlicesDoc -Content $script:TwoCriterionDoc -Name 'pinned.md' - Mock Get-TrackerIssue { New-Tracker -Issues @( - New-Issue -Number 9 -Title 'W1 — A slice' ` - -Body "- [ ] **W1.1** first`n- [ ] **W1.2** second`n`nScope and criteria: ``design/30-slices.md`` § W1 @ ``deadbee```n" - ) } - } - - It 'a pin that is an ancestor of HEAD is Clean' { - Mock Test-CommitIsAncestor { 'Ancestor' } - - $r = Invoke-DriftCheck -SlicesPath $script:PinnedDoc - - $r.State | Should -Be 'Clean' - Should -Invoke Test-CommitIsAncestor -Exactly -Times 1 - } - - It 'a pin that is not an ancestor is drift, exit 1' { - Mock Test-CommitIsAncestor { 'NotAncestor' } - - $r = Invoke-DriftCheck -SlicesPath $script:PinnedDoc - - $r.State | Should -Be 'Drifted' - ($r.Findings | Where-Object Kind -eq 'PinNotAncestor').Detail | Should -Be 'deadbee' - Get-DriftExitCode -State $r.State | Should -Be 1 - } - - It 'a pin this clone cannot resolve is NOT clean and NOT drift - it is exit 2' { - # The distinction I12 exists for: absence of an answer never becomes an answer. - Mock Test-CommitIsAncestor { 'Unresolvable' } - - $r = Invoke-DriftCheck -SlicesPath $script:PinnedDoc - - $r.State | Should -Be 'NotEvaluated' - $r.Findings.Count | Should -Be 0 - $r.Failures.Reason | Should -Contain 'PinUnresolvable' - Get-DriftExitCode -State $r.State | Should -Be 2 - } - } - - Context 'incomplete runs never report clean' { - - It 'an unreadable tracker is NotEvaluated, exit 2' { - $path = New-SlicesDoc -Content $script:TwoCriterionDoc - Mock Get-TrackerIssue { - [pscustomobject]@{ Issues = @(); Failure = (New-Failure -Reason 'GhUnavailable' -Detail 'gh exited 4') } - } - - $r = Invoke-DriftCheck -SlicesPath $path - - $r.State | Should -Be 'NotEvaluated' - $r.Failures.Reason | Should -Contain 'GhUnavailable' - Get-DriftExitCode -State $r.State | Should -Be 2 - } - - It 'a missing slices document is NotEvaluated, and the tracker is never read' { - Mock Get-TrackerIssue { New-Tracker } - - $r = Invoke-DriftCheck -SlicesPath (Join-Path $TestDrive 'nothing-here.md') - - $r.State | Should -Be 'NotEvaluated' - $r.Failures.Reason | Should -Contain 'SlicesDocMissing' - Should -Invoke Get-TrackerIssue -Exactly -Times 0 - } - - It 'a criterion numbered for another slice is unparseable, not silently filed' { - $path = New-SlicesDoc -Content @' -# Slices - -### [ ] W1 — A slice - -- **Done when:** - - W1.1 The first criterion. - - W2.4 Numbered for a slice this is not. -'@ -Name 'stray.md' - Mock Get-TrackerIssue { New-Tracker -Issues @( - New-Issue -Number 9 -Title 'W1 — A slice' -Body '- [ ] **W1.1** first' - ) } - - $r = Invoke-DriftCheck -SlicesPath $path - - $r.State | Should -Be 'NotEvaluated' - $r.Failures.Reason | Should -Contain 'UnparseableCriterion' - } - - It 'drift AND a failed comparison resolves to exit 2, never 1' { - # 2 takes precedence: a run that found drift and also failed to finish is an - # incomplete run, and reporting it as a finished one is the failure I12 forbids. - $path = New-SlicesDoc -Content $script:TwoCriterionDoc -Name 'both.md' - Mock Get-TrackerIssue { New-Tracker -Issues @( - New-Issue -Number 9 -Title 'W1 — A slice' ` - -Body "- [ ] **W1.1** first`n``design/30-slices.md`` § W1 @ ``deadbee``" - ) } - Mock Test-CommitIsAncestor { 'Unresolvable' } - - $r = Invoke-DriftCheck -SlicesPath $path - - $r.Findings.Kind | Should -Contain 'InDocNotIssue' - $r.Failures.Reason | Should -Contain 'PinUnresolvable' - $r.State | Should -Be 'NotEvaluated' - Get-DriftExitCode -State $r.State | Should -Be 2 - } - } - - Context 'exit code map' { - - It 'maps each state, and refuses an unknown one rather than defaulting to 0' { - Get-DriftExitCode -State 'Clean' | Should -Be 0 - Get-DriftExitCode -State 'Drifted' | Should -Be 1 - Get-DriftExitCode -State 'NotEvaluated' | Should -Be 2 - { Get-DriftExitCode -State 'Something' } | Should -Throw - } - } -} diff --git a/tools/Test-DesignDrift.ps1 b/tools/Test-DesignDrift.ps1 deleted file mode 100644 index aaf5817c..00000000 --- a/tools/Test-DesignDrift.ps1 +++ /dev/null @@ -1,359 +0,0 @@ -#Requires -Version 7.0 -<# -.SYNOPSIS - Reports drift between design/30-slices.md and the GitHub tracker, and changes neither. - -.DESCRIPTION - Two comparisons that a model should never do by eye, because both are set arithmetic over - files and both fail silently when done from memory (AGENTS.md, "What should stop being - model work" - the red row): - - 1. Criterion ids. Every `W.` under a slice's `Done when:` lines, against every - `W.` checkbox in that slice's issue. Reworded criteria are not drift; an added, - removed, or renumbered id is. A renumber is the finding that matters, because an - existing ticked checkbox then refers to something else. - - 2. Pin ancestry. Every `design/30-slices.md § W @ ` pin in an issue's agent - block, against `git merge-base --is-ancestor HEAD`. This is the check - design/90-decisions.md (2026-08-10) records as having been claimed done and not - been: fourteen of seventeen issues cited a commit that was not an ancestor of main, - and nothing in the repository could tell anyone that. - - Read-only on both sides, by contract (I13). Which side of a drift is wrong is the user's - call, so this establishes only that the two disagree. - - Exit codes: 0 no drift, 1 drift found, 2 could not evaluate. **2 takes precedence over 1** - - a run that found drift *and* failed to complete a comparison is an incomplete run, and - reporting it as a finished one is exactly the failure I12 forbids. Never prompts. - -.PARAMETER SlicesPath - Path to the slices document. Defaults to design/30-slices.md beside this script's repo root. - -.PARAMETER Repository - owner/repo. Defaults to the current git remote, via gh's own resolution. - -.PARAMETER Quiet - Suppresses the human-readable report only. The result object is always emitted. - -.EXAMPLE - pwsh ./tools/Test-DesignDrift.ps1 -#> -[CmdletBinding()] -param( - [string] $SlicesPath, - [string] $Repository, - [switch] $Quiet -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -# Both comparisons below read a native command's *exit code* as their answer: -# `git merge-base --is-ancestor` returns 1 to mean "not an ancestor", which is a finding, not -# an error. On PowerShell 7.3+ this preference defaults to $true, which would turn that 1 into -# a terminating error under $ErrorActionPreference = 'Stop' and report every stale pin as -# unresolvable instead. Assigning it is inert on versions that predate it. -$PSNativeCommandUseErrorActionPreference = $false - -function New-DriftResult { - param( - [string] $State, - [object[]] $Findings = @(), - [object[]] $Failures = @(), - [int] $SlicesCompared = 0 - ) - [pscustomobject]@{ - State = $State - Findings = @($Findings) - Failures = @($Failures) - SlicesCompared = $SlicesCompared - } -} - -function New-Finding { - param([string]$Kind, [string]$Slice, [string]$Detail, [int]$Issue) - [pscustomobject]@{ Kind = $Kind; Slice = $Slice; Detail = $Detail; Issue = $Issue } -} - -function New-Failure { - param([string]$Reason, [string]$Detail) - [pscustomobject]@{ Reason = $Reason; Detail = $Detail } -} - -<# - Criterion ids are read only from lines under a `### [ ] W` heading, never from the - whole file. Prose cites ids too - "exercised by W1.10" would appear in this document's - own cross-references - and a whole-file regex would count those as criteria that exist. - This repository retains its established `W` slice prefix in place of the kit's generic - `S` (design/30-slices.md, "How this document is kept"; skills/track/SKILL-local.md). -#> -function Get-SliceCriteria { - param([Parameter(Mandatory)][string] $Path) - - if (-not (Test-Path -LiteralPath $Path)) { - return [pscustomobject]@{ - Slices = @{} - Landed = @() - Failure = (New-Failure -Reason 'SlicesDocMissing' -Detail $Path) - } - } - - $slices = @{} - $landed = [System.Collections.Generic.List[int]]::new() - $current = $null - - foreach ($line in (Get-Content -LiteralPath $Path)) { - if ($line -match '^##\s' -and $line -notmatch '^###') { - # A thematic `##` section boundary (this repository groups slices under - # `## Core`, `## Post-MVP — Depth`, etc., not a flat `## Outstanding`) always - # ends the previous slice's body, the same as a new slice heading does below. - $current = $null - continue - } - - if ($line -match '^###\s+\[') { - # A new third-level `### [ ] W` heading always ends the previous slice's body, - # so a Done-when line can never be attributed across a section boundary. - $current = if ($line -match '^###\s+\[(?.)\]\s+W(?\d+)\b') { [int]$Matches['n'] } else { $null } - if ($null -ne $current) { - # A ticked heading is how this repository marks a slice landed - it keeps the - # body rather than retiring it into the kit's `## Landed` table below, so the - # table path alone left $landed permanently empty here and every finished - # slice was compared as though it were still outstanding. - if ($Matches['mark'] -in @('x', 'X')) { $landed.Add($current) } - if (-not $slices.ContainsKey($current)) { - $slices[$current] = [System.Collections.Generic.List[string]]::new() - } - } - continue - } - - if ($line -match '^\|\s*\*\*W(?\d+)\*\*\s*\|') { - $landed.Add([int]$Matches['n']) - continue - } - - # The optional `[ ]`/`[x]` is not decoration either: this repository ticks individual - # criteria in place (`- [x] W103.2 ...`), so a regex requiring the id straight after - # the dash reads a completed criterion as missing from the doc and reports it as - # InIssueNotDoc - three false findings on W103 before this allowance existed. - if ($null -ne $current -and $line -match '^\s*-\s+(?:\[[ xX]\]\s+)?W(?\d+)\.(?\d+)\b') { - if ([int]$Matches['n'] -ne $current) { - # An id numbered for a different slice than the section it sits in. Reported - # rather than silently filed under either, because it is a defect in the doc. - if (-not $slices.ContainsKey(-1)) { - $slices[-1] = [System.Collections.Generic.List[string]]::new() - } - $slices[-1].Add("W$($Matches['n']).$($Matches['m']) (found under W$current)") - continue - } - $slices[$current].Add("W$($Matches['n']).$($Matches['m'])") - } - } - - [pscustomobject]@{ - Slices = $slices - Landed = @($landed) - Failure = $null - } -} - -function Get-IssueCriteria { - param([string] $Body) - if ([string]::IsNullOrWhiteSpace($Body)) { return @() } - - $ids = [System.Collections.Generic.List[string]]::new() - foreach ($line in ($Body -split "`r?`n")) { - if ($line -match '^\s*-\s*\[[ xX]\]\s*\*{0,2}W(?\d+)\.(?\d+)\*{0,2}') { - $ids.Add("W$($Matches['n']).$($Matches['m'])") - } - } - @($ids) -} - -function Get-IssuePin { - param([string] $Body) - if ([string]::IsNullOrWhiteSpace($Body)) { return $null } - # The backtick after `.md` is not optional decoration: track.md's pin format is - # `design/30-slices.md` § W3 @ `a1b2c3d`, so the path is code-fenced and the closing - # fence sits between `.md` and the section mark. Omitting it here matched no real issue - # at all - caught by the first CI run of this file's tests, not by reading it. - if ($Body -match '30-slices\.md`?\s*§\s*W(?\d+)\s*@\s*`?(?[0-9a-fA-F]{7,40})`?') { - return [pscustomobject]@{ Slice = [int]$Matches['n']; Sha = $Matches['sha'] } - } - $null -} - -function Get-TrackerIssue { - param([string] $Repository) - - $ghArgs = @('issue', 'list', '--state', 'all', '--limit', '200', '--json', 'number,title,state,body') - if ($Repository) { $ghArgs += @('-R', $Repository) } - - try { - $json = & gh @ghArgs 2>$null - if ($LASTEXITCODE -ne 0) { - return [pscustomobject]@{ Issues = @(); Failure = (New-Failure -Reason 'GhUnavailable' -Detail "gh exited $LASTEXITCODE") } - } - } catch { - return [pscustomobject]@{ Issues = @(); Failure = (New-Failure -Reason 'GhUnavailable' -Detail $_.Exception.Message) } - } - - if ([string]::IsNullOrWhiteSpace(($json -join ''))) { - return [pscustomobject]@{ Issues = @(); Failure = (New-Failure -Reason 'GhUnavailable' -Detail 'gh returned no output') } - } - - try { - $parsed = ($json -join "`n") | ConvertFrom-Json - } catch { - return [pscustomobject]@{ Issues = @(); Failure = (New-Failure -Reason 'TrackerUnreadable' -Detail $_.Exception.Message) } - } - - [pscustomobject]@{ Issues = @($parsed); Failure = $null } -} - -<# - Three outcomes, not two. An unknown object - a shallow clone, or a commit that was never - fetched - is not evidence the pin is stale, and folding it into NotAncestor would invent a - finding. It is the absence of an answer, which I12 requires be reported as such. -#> -function Test-CommitIsAncestor { - param([Parameter(Mandatory)][string] $Sha) - - try { - & git merge-base --is-ancestor $Sha HEAD 2>$null | Out-Null - switch ($LASTEXITCODE) { - 0 { 'Ancestor' } - 1 { 'NotAncestor' } - default { 'Unresolvable' } - } - } catch { - 'Unresolvable' - } -} - -function Invoke-DriftCheck { - param([string] $SlicesPath, [string] $Repository) - - $findings = [System.Collections.Generic.List[object]]::new() - $failures = [System.Collections.Generic.List[object]]::new() - - $doc = Get-SliceCriteria -Path $SlicesPath - if ($doc.Failure) { - $failures.Add($doc.Failure) - return New-DriftResult -State 'NotEvaluated' -Failures $failures - } - - if ($doc.Slices.ContainsKey(-1)) { - foreach ($stray in $doc.Slices[-1]) { - $failures.Add((New-Failure -Reason 'UnparseableCriterion' -Detail $stray)) - } - $doc.Slices.Remove(-1) - } - - $tracker = Get-TrackerIssue -Repository $Repository - if ($tracker.Failure) { - $failures.Add($tracker.Failure) - return New-DriftResult -State 'NotEvaluated' -Failures $failures - } - - $landed = @($doc.Landed) - $unissuedLanded = [System.Collections.Generic.List[int]]::new() - $compared = 0 - - foreach ($number in ($doc.Slices.Keys | Sort-Object)) { - $docIds = @($doc.Slices[$number] | Sort-Object -Unique) - $issue = $tracker.Issues | Where-Object { $_.title -match "^W$number\b" } | Select-Object -First 1 - - if (-not $issue) { - # A landed slice is not synced at all (the kit's skills/track/SKILL.md, "What syncs"), - # so there is no issue for /agentkit:track to open and this is not a finding. The ones - # here predate the tracker; reporting them made the gate permanently red, which - # is how three real findings sat unread. - if ($number -in $landed) { - $unissuedLanded.Add($number) - continue - } - $findings.Add((New-Finding -Kind 'NoIssue' -Slice "W$number" -Detail 'slice has no issue; /agentkit:track opens one' -Issue 0)) - continue - } - - $compared++ - $issueIds = @(Get-IssueCriteria -Body $issue.body | Sort-Object -Unique) - - foreach ($id in ($docIds | Where-Object { $_ -notin $issueIds })) { - $findings.Add((New-Finding -Kind 'InDocNotIssue' -Slice "W$number" -Detail $id -Issue $issue.number)) - } - foreach ($id in ($issueIds | Where-Object { $_ -notin $docIds })) { - $findings.Add((New-Finding -Kind 'InIssueNotDoc' -Slice "W$number" -Detail $id -Issue $issue.number)) - } - } - - # In the kit's generic layout, a landed slice's body is retired once its issue closes, - # so only its pin can be checked. This repository does not retire slice bodies (every - # `### [x] W` keeps its full "Done when" list - design/30-slices.md, "How this - # document is kept"), so a landed slice that still has an issue IS compared on ids above, - # and being landed only suppresses the NoIssue report for one that never had one. - foreach ($issue in $tracker.Issues) { - $pin = Get-IssuePin -Body $issue.body - if (-not $pin) { continue } - - switch (Test-CommitIsAncestor -Sha $pin.Sha) { - 'NotAncestor' { $findings.Add((New-Finding -Kind 'PinNotAncestor' -Slice "W$($pin.Slice)" -Detail $pin.Sha -Issue $issue.number)) } - 'Unresolvable' { $failures.Add((New-Failure -Reason 'PinUnresolvable' -Detail "#$($issue.number) pins $($pin.Sha), which this clone cannot resolve")) } - } - } - - if ($unissuedLanded.Count -gt 0) { - Write-Verbose "Landed slices with no issue, not reported: $($unissuedLanded -join ', ')" - } - - $state = if ($failures.Count -gt 0) { 'NotEvaluated' } - elseif ($findings.Count -gt 0) { 'Drifted' } - else { 'Clean' } - - New-DriftResult -State $state -Findings $findings -Failures $failures -SlicesCompared $compared -} - -function Get-DriftExitCode { - param([string] $State) - switch ($State) { - 'Clean' { 0 } - 'Drifted' { 1 } - 'NotEvaluated' { 2 } - default { throw "Unknown drift state: $State" } - } -} - -function Write-DriftReport { - param([Parameter(Mandatory)][object] $Result) - - Write-Host "Slices compared: $($Result.SlicesCompared)" - Write-Host "Findings: $($Result.Findings.Count) Could not evaluate: $($Result.Failures.Count)" - - foreach ($f in $Result.Findings) { - Write-Host " [$($f.Kind)] $($f.Slice) $($f.Detail)$(if ($f.Issue) { " (#$($f.Issue))" })" - } - foreach ($f in $Result.Failures) { - Write-Host " [$($f.Reason)] $($f.Detail)" - } - - switch ($Result.State) { - 'Clean' { Write-Host 'No drift.' } - 'Drifted' { Write-Host 'Drift found. Which side is wrong is not this script''s call.' } - 'NotEvaluated' { Write-Host 'Incomplete: at least one comparison did not run. This is NOT a clean result.' } - } -} - -# Guards the exit-calling wrapper so this script's tests can dot-source it instead - that -# defines every function above in the caller's scope, lets Mock intercept `gh` and `git`, and -# skips straight past this block rather than exiting the test runner's own process. -if ($MyInvocation.InvocationName -ne '.') { - if (-not $SlicesPath) { - $SlicesPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'design/30-slices.md' - } - $result = Invoke-DriftCheck -SlicesPath $SlicesPath -Repository $Repository - if (-not $Quiet) { Write-DriftReport -Result $result } - $result - exit (Get-DriftExitCode -State $result.State) -}