From c97f8ec94ab2108074136837dc578703cf76c4e0 Mon Sep 17 00:00:00 2001 From: prode Date: Sat, 19 Sep 2026 19:29:40 -0300 Subject: [PATCH 1/3] docs(workspace): return CLAUDE.md to the entry file the binary ships MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md was 96.8KB and preloaded into every request of every session — a third copy of what .claude/rules/ and docs/ already own, and in one place a contradiction of them: ## Commands told a session to run `make check` while rules/project.md says make is absent on Windows. The binary's own entry template says the methodology lives in rules/ and is never inlined, and TestScaffoldedEntryFileStaysShort holds every workspace scc scaffolds to 60 lines; this one broke the contract its own product enforces. The entry file is now that template as rendered for Claude Code, plus the two spliced usage blocks: 95 lines, 4.9KB. Each concern went to the one place that owns it. Extended, rather than restated: delivery-gate (the grouped check object, why flat put build on line 2 and its siblings on line 170, the distinct findings), integration-boundary (the Windows dev container and why it is the weaker boundary, one graph per scoped tree), managed-files (CarryOver, and the silent deletion it prevents), artifact-addressing (the reading surface, BM25 over regions rather than a search engine, the seal), spec-or-plan (the spec's delivery record, and why merged is not "is an ancestor of the base"). New: wiki pages note-log and release, docs/codewiki/packages.md with the package map and the two flag-parsing bugs the handlers must not each re-discover, and the first two ADRs — stdlib-only dependencies, and the narrowing from csdd with the dev container recorded as the one item since reversed. docs/stack.md and README.md pointed at CLAUDE.md for reasoning it no longer carries; both now point at the record that does. Verified: scc validate --checks — 11 validators, 0 findings, 1002 tests, 86.2% coverage. No Go code changed. --- CLAUDE.md | 396 +++------------------- README.md | 3 +- docs/adr/0001-stdlib-only-dependencies.md | 44 +++ docs/adr/0002-narrowed-from-csdd.md | 41 +++ docs/codewiki/packages.md | 170 ++++++++++ docs/stack.md | 2 +- docs/wiki/changelog.md | 7 + docs/wiki/index.md | 7 + docs/wiki/pages/artifact-addressing.md | 64 ++++ docs/wiki/pages/delivery-gate.md | 18 + docs/wiki/pages/integration-boundary.md | 66 ++++ docs/wiki/pages/managed-files.md | 7 + docs/wiki/pages/note-log.md | 59 ++++ docs/wiki/pages/release.md | 61 ++++ docs/wiki/pages/spec-or-plan.md | 33 ++ plans/claude-md-dedup.md | 59 ++++ 16 files changed, 681 insertions(+), 356 deletions(-) create mode 100644 docs/adr/0001-stdlib-only-dependencies.md create mode 100644 docs/adr/0002-narrowed-from-csdd.md create mode 100644 docs/codewiki/packages.md create mode 100644 docs/wiki/pages/note-log.md create mode 100644 docs/wiki/pages/release.md create mode 100644 plans/claude-md-dedup.md diff --git a/CLAUDE.md b/CLAUDE.md index b254831..46997a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,375 +1,63 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +Spec-driven development, scaffolded and checked by `scc`. The methodology lives in `.claude/rules/` — never inline it here. -## What this is +## Rules — `.claude/rules/.md` -`scc` is a single Go binary (`github.com/protonspy/spec-claude-code`) that enforces Spec-Driven Development inside an agent-driven repo — Claude Code, Codex, or opencode. One surface: a headless CLI, so an AI agent or a CI job drives it exactly as well as a human does. +Claude Code loads `.claude/rules/` at session start — nothing to open, bar `specs.md`, +`tasks.md` and `knowledge-base.md`, read when you touch their tree. The failure is not a +rule you never read, it is one you had and misapplied — so the triggers say *when* each governs. -The artifacts it governs (the harness's own directory, `specs/`, `plans/`, `docs/`) are plain Markdown/JSON. There is no server and no database — the files *are* the API. scc has no directory of its own: its state lives in one file under the harness's directory, alongside what that tool already keeps there. +`caveman.md` is always on: the register you answer in. The rest, by where you are: -This file covers working *on* scc. The product's own rules and methodology are not documented here: they live in `design/` while being designed, and ship as templates the binary scaffolds into `/rules/`. +- `autonomy.md` — at kickoff, before writing anything +- `prior-art.md` — then read what `docs/` already decides, before the first artifact +- `routing.md` — work arrives and needs a vehicle: a spec, or a plan +- `methodology.md` — starting a task: which cycle, what to run first +- `ladder.md` — about to write the code: how much of it the task gets +- `verification.md` — code is written and you think it is done +- `delivery.md` — last task done: branch, review, PR -Note: this repo is not itself an scc workspace (no harness directory, `specs/`, `plans/`, or `docs/` are committed) — those trees only exist in workspaces the binary scaffolds, and in test temp dirs. +Triggered by what you are about to touch: -**Status: v0.4.0-shaped.** Everything through `design/plan.md` phase 10 is built and green: scaffolding (`init`), artifact creation (`spec`, `plan`), and all ten validators behind `scc validate`. `init` also scaffolds the eight skills named in `design/orchestration.md` §6 — the six knowledge-base authors (one per `docs/` artifact a validator checks, plus `prd`) and the two workflow skills, `scc-plan-run` and `scc-init`. All eight carry the `scc-` prefix on the skill itself (`assets.Skills()`, which is `KnowledgeSkills` + `WorkflowSkills`), so the name is what the user types wherever the harness turns a skill into a slash command, and a command file is written only where it does not. +- `project.md` — **before any build, test, lint, or format command.** This project's + commands exist nowhere else; a guessed one that exits 0 looks like a passing suite. +- `code-search.md` — before going looking for code you have not read +- `artifacts.md` — before opening a plan or a spec +- `specs.md` — writing requirements, design, or tasks for a spec +- `tasks.md` — working through a spec's task list +- `knowledge-base.md` — something was learned, or a decision was made +- `notes.md` — **before typing a comment that is not a docstring**; `scc notes find --path ` -These landed after phase 10, and all are documented in `design/orchestration.md` §6 and §12: +## Ask the index before you read the file -- **Three harnesses, one template set.** `scc init --claude|--codex|--opencode` (Claude Code is the default, and a terminal with no flag gets a picker). One prose source: paths come from a `paths.Harness` profile and the header each loader parses is synthesized at render time — YAML frontmatter for Claude Code and opencode, a TOML agent role file for Codex. +**Code** — `scc graph query|explore `; read the source to change it, not to find it. - The profile also carries `PreloadsRules`, because where the rules go is scc's choice but what the harness then does with them is not. Claude Code loads `.claude/rules/*.md` at launch with the same priority as `CLAUDE.md`; Codex and opencode load nothing from `rules/`, which is scc's own directory there. The entry file branches on it: told to "read the rule when the concern is live", an agent that already has the set in context re-reads it, putting the same bytes in twice — and learns that the one document it is meant to trust is wrong about its own environment. Confirm per harness with `/context`. +**Plans and specs** — a plan is a header and a checklist: `map brief ` once, then +`map tasks --next` per task; **never open the plan**. Also `scc map` and `map +show
`. An address is a name, never a line number: `1.2` `#risks`. - **`ScopedRules` is the second half of that, and it is the only lever there is on what the methodology costs.** Measured on a freshly scaffolded Claude Code workspace, the entry file plus 15 rules came to 47KB in every request of the session — this file said nine rules and ~26KB, and had said so since the set was that size. Three rules now carry a `paths:` header, so the harness loads them when the agent touches the tree they govern: `specs.md` (`specs/**`), `tasks.md` (`specs/**`, `plans/**`), `knowledge-base.md` (`docs/**`). That is 10.4KB, about a quarter, moved off the standing cost without moving a word of the methodology. +**Changing one** — `scc patch check 1.2`, plus `task` `add` `append` `fm`. Not +an editor: it resolves the address, re-validates, and rolls back an edit that adds a +finding — so you need not read a plan to change one line of it. - **Which three is mechanical, not taste: a rule may be scoped only where a validator reports what it prevents.** A scope trades "in context before the decision" for "in context when the file is open", and that trade is survivable exactly when something else catches the result — a malformed EARS line, a task with no methodology annotation, a broken wikilink are all `scc validate` exit 2 before the commit. `TestOnlyRulesAValidatorBacksAreScoped` holds the bar. The rules that look most temptingly scopable are the clearest refusals: `artifacts.md` would scope to `plans/**` and its instruction is *never open the plan*, so it would only ever load to say the thing it prevents has happened; `prior-art.md` and `routing.md` fire before any file exists; `caveman.md`, `ladder.md` and `delivery.md` govern how the agent works rather than what it edits, and nothing mechanical reports a failure to follow them. -- **`scc update` (phase 11), as replace-or-keep rather than the planned three-way merge.** It hashes every managed file against this build and against the manifest, prints the plan grouped by outcome, asks, and then replaces what is safe to replace. An edited file is kept and named; `--force` is the separate decision. `internal/merge` is still unbuilt. -- **`scc rtk`, and the block `scc init` writes.** Wires in [RTK](https://github.com/rtk-ai/rtk), the CLI proxy that filters command output: `cargo install` when the binary is missing, plus a splice of RTK's marker-delimited usage block into the entry file. **scc's block wins by default**, replacing whatever sits between the markers; `--keep` is the separate decision. The reason is size, not authorship: `rtk init` (measured on 0.42.4) writes 139 lines / 5140 bytes, scc ships 18 lines / ~900, both stamped `v2`, both saying the same thing — and the entry file is preloaded into every request of the session, so the difference is paid continuously rather than once. Between two blocks of the same version the condensed one is simply better, and leaving the larger one because it got there first is a standing cost. Where that costs something is a block claiming a *newer* version, which is a real downgrade: the run names the version it displaced and points at `--keep`. `--check` reports without writing and exits 2 when the block is missing. - - **`scc init` writes the block, and the split is between the block and the install.** The block was opt-in in both places once, on the grounds that it names a binary the machine may not have — but that reasoning only ever covered the *binary*, and applying it to the block left the thing that makes RTK work at all behind a flag nobody passes. So the two halves are settled separately and in that order: the block goes in whenever `rtk` is on PATH, and the cargo build — a Rust toolchain and minutes — is asked about (`--rtk` is that consent in advance, `--no-rtk` declines the step, and the two contradict rather than one outranking the other). A run that cannot install writes nothing and says so, which is the same rule the CodeGraph block follows for the same reason. `init` and `launch` both splice with `keep`; `scc rtk` is the only one of the three that replaces, because that is the trade-off worth making deliberately rather than as a side effect of scaffolding. One consequence to keep in view: an entry file carrying the block is *edited* as far as the manifest is concerned, so `scc update` keeps it rather than delivering a newer entry template — already true of every workspace that had ever run `scc launch`, and now true from the first command. - - `rtkInstallOK` in `internal/cli/rtk.go` is that decision, in one place, for `init` and `launch` both. Two copies would be two answers to "is anybody here to ask", and the untested one would be the one that ran. - - **scc uses RTK's own markers rather than namespacing its own**, and that is load-bearing: `rtk init` writes `` … `` into the project entry file (verified against rtk 0.42.4), so addressing the block by that pair is what makes `rtk init` and `scc rtk` converge on one copy. A namespaced `scc:rtk-instructions` would make each tool blind to the other's block and leave the file carrying both — which is the bug, not the fix. Headroom is the counter-example: it *does* namespace (`headroom:rtk-instructions`), which is why a workspace wired by both can end up with two blocks. scc detects that one and names it in the report (`rtkFile.Foreign`), with `headroom unwrap ` as the fix — reported, never touched, because that block belongs to Headroom. -- **`scc launch `.** Starts the harness this workspace was scaffolded for, from the workspace root, with the workspace's symbol graph brought up to date and its entry file wired for RTK first. What a bare `scc launch` does, and does not, is settled by one question: **does this leave anything behind after the session ends?** The graph and the RTK block do, and both are what the agent needs. [Headroom](https://github.com/headroomlabs-ai/headroom)'s `wrap` does too — MCP registrations in the agent's own config — but what it buys is one session of compression, so it is **opt-in behind `--headroom`**. This is the one command that does not obey the 0/1/2 exit-code contract; see the convention below. - - Everything it does degrades instead of failing: a missing binary, a declined install, an unattended run, or a harness Headroom does not wrap all end in the agent starting anyway with a warning saying why. A missing Headroom prompts for `uv tool install` (`--yes`/`--no-install` are the unattended answers). - - **RTK preflight is on by default, bounded by the block.** `scc launch` makes sure the entry file the agent is about to load actually mentions RTK — the same setup `scc rtk` does standalone — and the *trigger is the block, not the binary*: it runs when an entry file carries none and does nothing at all when they all do. That bound is what lets it be the default. It fires once per workspace rather than once per session, so a wired workspace pays no cargo prompt and no edit at the top of every session. The write is the point: an agent that never read the block never types the prefix, so installing the binary and leaving the file alone would buy nothing. It splices with `keep`, so a block that is already there is left exactly as it is even when scc ships a different one — replacing somebody's block is a real trade-off, and `scc rtk` is where it is made deliberately. `--no-rtk` opts out, and a plan-only run (`--json`/`--dry-run`) reports the splice without performing it. - - Two things about `wrap` are load-bearing and were originally documented wrong here: - - **It does write to disk.** `headroom wrap` registers MCP servers into the agent's own config (`~/.claude.json` and the Codex/opencode equivalents), and those registrations outlive the session that made them — which is why Headroom ships `unwrap` at all. That is the reason `--headroom` is opt-in at all, and why even then `scc launch` defaults to **`--headroom-mcp none`**: the only thing it wants from Headroom is the compression proxy, so no MCP server is registered on the agent's behalf, not even Headroom's own retrieve tool. The cost of that is real — the proxy's compression markers go unactionable without retrieve — and `--headroom-mcp retrieve` hands that back for anyone who wants the markers expanded; `all` keeps Headroom's defaults, `none` (the default) drops the retrieve tool too. Any `--headroom-*` flag implies `--headroom`, because accepting a flag and then ignoring it is how somebody spends a session believing they configured something. - - **It also wants the entry file, and scc says no.** `headroom wrap`'s context-tool setup appends RTK guidance to `$PWD/CLAUDE.md` or `$PWD/AGENTS.md` — the same file `scc rtk` splices — behind its own marker pair, ``. Neither marker is a substring of the other, so both tools' idempotency checks pass and both append: an entry file carrying the same RTK instructions twice, in every request of the session. So `scc launch` passes `--no-context-tool` by default. Headroom already gates that injection behind `HEADROOM_RTK`, which makes this belt-and-braces — but only until an environment exports that variable for its own reasons, and `wrap claude` resolves `setup_context_tool = (context_tool or _rtk_opt_in()) and not no_rtk`, so the flag wins over both the env var and `--context-tool`. `--headroom-context-tool` hands it back. - - **The opt-out flags are discovered, not hardcoded.** `internal/headroom` reads `headroom wrap --help` and picks the spelling that build advertises. This is not defensiveness for its own sake: Headroom renamed this exact control once already (`--no-serena` → `--code-memory none`, and `--no-tokensave` in between), and the harness profiles disagree today — `wrap opencode` still takes `--no-serena` while `wrap claude` and `wrap codex` take `--code-memory`. A flag name compiled into scc turns that kind of release into a launch that dies on `no such option`, which is strictly worse than one unwanted MCP server. A build advertising no opt-out is reported, not overridden. - - **`--` reaches `wrap`, not only the agent.** `headroom wrap` parses every flag it recognizes out of the tail and forwards only the rest, so a pass-through argument that collides with one of Headroom's — `--verbose`, which both Claude Code and `wrap` define — is silently eaten. `WrapArgs` therefore takes scc's options and the pass-through as separate parameters and puts scc's first, so a colliding argument the user typed lands last and wins. To force something past `wrap` to the agent, use a second terminator: `scc launch claude -- -- -p`. - -- **`scc graph`, the launch-time index, and its usage block.** A wrap over [CodeGraph](https://github.com/colbymchenry/codegraph) — `build | sync | status | query | explore` — plus the same index run automatically by `scc launch`: `codegraph init` when `.codegraph/` is absent, `sync` when it is there. **`scc launch` also splices a CodeGraph usage block into the entry file**, the way it does RTK's, because an index nobody knows how to query is an index nobody queries. - - Three things about that block, and each is the reason it is not written at `init` time instead. It goes in **only once the binary is present** — guidance naming a command the machine cannot run is worse than none, since an agent that tries `scc graph explore` and watches it fail discounts the whole file — and CodeGraph is usually not installed when `init` runs. It is **written before the index**, so a failed index still leaves the agent knowing the command that rebuilds one. And a **plan-only run writes nothing**: `--dry-run` that edited a file the user owns would be the one flag nobody expects to change anything doing exactly that. - - **Its markers are scc's own** — `` — which is deliberately the opposite of the RTK decision above and for the opposite reason. Sharing RTK's markers is what makes `rtk init` and `scc rtk` converge on one copy of a block they *both* write. CodeGraph writes nothing into the entry file, and this block is scc's account of `scc graph` rather than CodeGraph's account of itself, so namespacing it leaves a future CodeGraph release free to add its own without either tool clobbering the other. The splice itself is `internal/mdblock`, shared by both: choosing the markers is the integration's decision, and everything after that choice — find, compare, replace, append, preserve the file's line endings — is one implementation rather than two sets of edge cases. Launch is the one moment where indexing is free, and it degrades exactly the way Headroom does, for the same reason: a graph is an enhancement, so a missing binary or a failed index still starts the agent. `--no-graph` opts out and a plan-only run (`--json`/`--dry-run`) reports without indexing. - - **How to actually use it.** The graph answers relationship questions — who calls this, what breaks if I change it, where does this concept live — in one command where reading files costs a dozen: - - ```bash - scc graph explore "how does a plan get validated" # start here: relevant symbols' source plus the call paths between them - scc graph query renderTask --kind function # find one symbol by name (--limit N) - scc graph status # what the graph holds (--check exits 2 when there is none) - scc graph sync # re-index after writing code you then need to search - scc graph build --force # full rebuild, for a graph that has gone wrong rather than stale - scc graph scope set backend/src frontend/src # index only these trees (one graph each) - ``` - - Reach for `explore` before `Read` when the question is about relationships, and for `Read` when you already know the file. `explore` takes a sentence and needs no quoting discipline — the positionals are joined, so an unquoted question still arrives as the sentence it was typed as. It emits no `--json` and that is CodeGraph's design, not an omission: it is the CLI face of the `codegraph_explore` MCP tool and returns the same agent-shaped text. - -**`scc graph scope` narrows what gets indexed, and it is one graph per directory rather than one graph filtered.** That distinction is the whole feature and it is not a design choice — it is measured against CodeGraph 1.5.0: `init` takes a single optional `[path]` and refuses two (`too many arguments`), no command has an include or exclude flag, and the only exclusion it honors is `.gitignore`, which is git's file about git rather than an index scope. So `{"codegraph": ["backend/src", "frontend/src"]}` in the manifest means two graphs, one `.codegraph/` inside each of those directories, and `build`, `sync`, `status`, `query` and `explore` all answering once per root. Said out loud wherever a user meets it, because "scoped to two trees" and "there are two graphs" are the same fact and only the second one explains why `status` answers twice. - - Empty — the default — stays exactly what it always was: one graph, at the root. The scope earns its cost on a monorepo whose vendored trees and build output are re-walked on every sync, and costs more than it buys on a repository that is one tree. - - **The patterns are normalized rather than taken literally**, because what people write is `backend/src/*` and what that means is the directory. A trailing `/*` or `/**` is stripped; an interior glob (`packages/*/src`, the monorepo shape) is expanded to the directories it matches, files dropped; anything escaping the workspace is refused, since a manifest arrives with a clone and `..` there would mean indexing — and writing a `.codegraph/` into — a directory outside the repository. A pattern that matches nothing is named and skipped; *every* pattern matching nothing stops the run instead, because falling back to the whole workspace is the opposite of what the scope asked for and an expensive way to learn about a typo. - - **Every command discovers its project from the working directory**, which is what let this land without touching a single argument vector: the fan-out sets `cmd.Dir` per root and `internal/codegraph`'s builders never learn that scoping exists. The one place it shows is `--json` over several roots, where N documents concatenated are not a document: `graphFanJSON` emits an array of `{path, output}` and passes each root's output through as `json.RawMessage` without parsing it — nesting a document is not reading one, and a root whose output is not JSON after all degrades to a string rather than breaking the array. - - - The index goes stale as you edit. `scc launch` syncs before the session starts, so it is current at turn one; after that, `sync` before searching for something you just wrote. - - What scc adds over typing `codegraph` directly is the two things it already knows: the workspace root, so `scc graph build` from `specs/` indexes the repo rather than a subtree, and whether the binary is there at all. The graph itself is *not* an scc artifact — not in the manifest, never touched by `scc update`, and `.codegraph/` stays CodeGraph's directory on CodeGraph's schedule. Unlike the launch path, a missing binary in `scc graph` is a hard error: the whole command is the binary. - - npm is the only installer scc will run. CodeGraph's headline install pipes a remote script into a shell (`curl … | sh`, `irm … | iex`), which is a fine thing for a person to type and not a thing scc executes on their behalf — `InstallHint` names it and leaves the decision where it belongs. -- **The plan as a contract (`design/plan-format-v2.md`).** A plan stopped being a document and became a header plus a checklist. Six sections and no others — `Why`, `Paths`, `References`, `Out of scope`, `Tasks`, `Done when` — because a closed set is the *only* thing that ever capped a plan's size: the 56KB plan measured below got there through `## Notes`, which nothing forbade, growing to half the file. There is no line limit anywhere in the contract except on the description, since a limit that fires on a legitimate plan is worse than the growth it prevents; what there is instead is nowhere for prose to go. - - **`## Decomposition` became `## References`, and that cost almost nothing** — `parseLeaves` recognizes a leaf by the `specs//` citation *anywhere in the file*, not by the heading above it, so `Leaf`, the `specs/foo/` address and `map trace` all kept working untouched. What closes the door on leaves appearing elsewhere is `plan.unknown-section` itself. - - **A task gains four flags and no more** — `_Depends_`, `_Priority_`, `_Status removed_`, `_Reason_` — and the vocabulary is closed because an italic one-liner is a shape prose also uses: a parser that absorbed any of them would silently eat a sentence and hand the task a region that is not the task. An unknown one is `task.unknown-flag`, reported and left where it sits — since the box is the state, a flag that could restate it is the `item-has-two-records` defect arriving by another door, and there is no `_Blocked_` because that is derived from `_Depends_`. `(Unit)`/`(TDD)` is reused as the test strategy rather than a new `_Test_` flag — zero migration, and the concept already had a name. `_Status_` never takes `open` or `completed`. - - Three consequences had to land together or the result is worse than before: `Task.End` covers the flags (so `map show` returns them and `patch rm` removes them), `Detail` excludes them (so the searcher does not index `_Priority 2_` as prose), and **`renderTask` re-emits them plus the continuation** — without that, `patch task --method TDD` was a data-loss command that deleted a sixty-line description and every dependency the task declared. - - **The reading surface is what gives "never read the plan" its authority.** `map brief` is the header, `map tasks` is the checklist, and no command returns both — so a session pays `brief` once and `--next` per task instead of ~14k tokens per reread. Forbidding the read without offering the equivalent query produces an agent that disobeys the rule, correctly — so the surface shipped in the phase before the rule did. `--next` is now determined (eligible → priority ascending, absent last → number compared *numerically*, which is also the fix for `1.10` sorting before `1.9`), and `--ready`/`--blocked`/`--deps` share that one implementation, because two notions of eligibility would be two answers to "what do I work on". - - **`--next` prints the task whole; the listings clip.** Every task in a real plan runs past one line and the line below the checkbox is usually where the decision sits, so a `--next` that stopped at the line break sent the reader to the file — the exact cost this surface exists to remove. It therefore ignores `--width` and prints the continuation under the checkbox, as it sits in the file. The listings still clip to one line, because a list of sixty-one-line tasks is not a list. `Task.Continuation()` is the raw lines with the flags removed; `Task.Detail` is the same text collapsed to one line, which is right for a row in a table and wrong for anything meant to be read. -- **`scc plan approve|reseal|migrate`, and the seal.** `approve` validates, then writes `status: approved` and a `checksum:` over the file minus its own checksum line, LF-normalized. It is **tamper-evidence, not prevention** — `reseal --force` is one command away and sha256 is public — and it is recorded that way here so nobody builds a guarantee on it later. The check runs before an edit is applied, which is the whole value: a harness that edited by hand and then ran `patch check` would otherwise have its edit resealed by the command that should have reported it. A plan with no `status:` is never checked, which is what makes every pre-existing plan keep working. - - After approval the work is fixed and only discovery moves: `add` allocates the number (high-water mark including removed tasks, so nothing is stored anywhere) and demands `--reason`; `rm` strikes the task out where it stands rather than deleting it; rewriting a task or the prose is refused. What discovery can never touch is guaranteed structurally rather than by instruction — `Why`, `Out of scope`, `Done when` and the title are reachable only through `append`/`prepend`/`replace`, and those are exactly the three refused. - - `migrate` moves a v1 plan across: it renames `Decomposition`, moves every other heading to `plans/archive/-notes.md` (safe because the plan scanner uses `ReadDir` and skips directories), creates the missing required sections **empty and lets the findings appear** — a placeholder that satisfied the validator would be a plan that lies — and writes `status: draft`, never `approved`. -- **`scc map` and `scc patch`.** The artifacts are structured documents that happen to be Markdown, and the cost of treating them as prose is paid on every request rather than once: measured on a real workspace, one plan is 56KB and its 31 specs bring the corpus to ~90k tokens, so an agent answering "what is the next open task?" by reading the file carries the whole plan for the rest of the session. `map` turns a file into addressable pieces — `index | outline | brief | tasks | show | blocks | find | trace` — and `patch` changes one of them — `check | uncheck | task | add | rm | append | prepend | replace | fm`. - - **The addresses are the design.** A task is `1.2`, a requirement `R1.2`, a section `#notes`, a leaf `specs//`, a paragraph `notes:7`; `L120-160` is the escape hatch and the only form that is a line number. That is what lets `patch` write into a file nobody read: a line number stops being true the moment anything above it moves, so an editor addressing by line has to read first — which is the cost the package exists to remove. The guard that reading-first was providing is replaced by three that are stronger for a structured file: an address that does not resolve is an error and never an insert at a guess, the file is re-validated afterwards and **rolled back if the edit introduced a finding** (exit 2), and the displaced and written lines are printed back — elided past a few lines, because a confirmation that echoed 400 lines would put the file in context by the back door. - - Two things measurement decided rather than taste. **`blocks` exists because section addressing bottoms out**: that plan's `## Notes` is 411 lines — half the file — with no headings inside it, but every paragraph opens with a bolded thesis, so the leads alone are an index a twentieth of the size. And **a requirement id is scoped to its spec**: `R2.5` is defined in nine of those 31 specs, so `map trace R2.5` unscoped answers with the list of specs and stops rather than concatenating nine traces. - - **`internal/artifact` owns the grammars, and `internal/validate` consumes them.** The task grammar used to live in the validator; a reader that disagreed with the validator about what a task is would be worse than no reader. The parser now states facts about a line (`Methodologies`, `Loose`, `HasCitation`) and turning a fact into a finding stays in `validate` — which is also what lets `map` read a malformed artifact instead of refusing exactly the file a user most needs to inspect. - - **No search engine.** The obvious reach for `find` is an inverted index; at 352KB and 94 artifacts a linear pass ranks the whole workspace in 55ms, and Tantivy or its kin would cost a CGO surface or a second binary against a stdlib-only `go.mod` and a six-platform cross-compile. What precision needed here was not a better index but a better *unit*: BM25 over addressable regions rather than lines, so a hit comes back as something `show` accepts. The seam is `artifact.Search` — it takes artifacts and returns hits, and nothing outside that file knows how it found them. - - **`map find` is now undocumented rather than removed.** Its stated reason was the whole corpus (94 artifacts, 352KB), not the plan — and with the plan small, searching *inside* one stopped making sense, while searching `design.md` and the knowledge base is still the only alternative to reading a file. So `runMapFind` and `search.go` stay and the line comes out of `rules/artifacts.md`, `entry.md` and `mapUsage()`: deleting the code saves nothing, and deleting the line saves tokens in every request of every session. -- **`caveman.md`, the register the agent answers in.** The output budget belongs to the code: prose about the work is written once and then carried in every later request of the session, so narration is the part of a long run that can be cut without losing a fact. It ships as a *rule* rather than a skill because it is on by default, and a default the model has to decide to load is not one — the cost is what every rule costs, since the harnesses that read `rules/` preload it. One level (ultra) rather than a dial, because three descriptions of the register are three things to keep true instead of one, and nobody turns the dial. - - What it must never compress is the line that keeps it honest: artifacts under `specs/`, `plans/`, `docs/`, anything a validator parses or a shell runs, quoted output, commit and PR bodies, and questions asked of the user. A denser EARS line is a finding, not a saving. - - **The language is the third kickoff answer.** `autonomy.md` asks it with the other two and it lands in the artifact's frontmatter as `lang: en|wenyan`, graded by `checkKickoffAs` on exactly the terms `autonomy` and `ci` are — checked when present, absent meaning the run predates the question. There is no `--lang` flag on `spec new` or `plan new`: it is the one answer that can arrive after the file exists, so `scc patch fm lang=wenyan` is the whole path to it, and a value neither the rule nor the validator knows is rolled back like any other bad edit. `TestTheRuleOffersEveryKickoffAnswerThisAccepts` is what stops the rule and the validator from naming different values. -- **`prior-art.md`, the read side of the knowledge base.** `docs/` is consulted *before the first artifact of a piece of work exists* — before `scc spec new`, before `scc plan new`, before code — because it is the constraint set and not reference material for when somebody is stuck: an ADR binds the design about to be written, `stack.md` says what may be built on, `glossary.md` says what to call it, and a spec already covering the area makes the work a delta rather than a second statement of the same feature. It prevents a failure that is invisible on review — a spec that re-decides a settled decision reads as new work, not as a contradiction — and under autonomy nobody sees the phase where noticing was still possible. - - **It is a rule of its own rather than a paragraph in `knowledge-base.md`**, because the two halves fire at opposite moments: that one is triggered by having learned something, this one by being about to write, and a read-side instruction filed under the write-side rule is read after the spec exists. It also has to state the thing no index states — `scc map` covers `plans/` and `specs/`, the symbol graph covers code, and `scc notes find` covers the note log, so **the rest of `docs/` is reached by opening a file**. That stays cheap only because the seeded anchors are built for it: `glossary.md` and `stack.md` are lists, `wiki/index.md` and the ADR filenames are tables of contents, so a page is opened when its title bears on the work and never to survey the base. What it finds is stated up front and then cited into the artifact (`adr:0007-…`, a delta, a `[[wikilink]]`), which is the only trace an `auto` run leaves that the pass happened at all. -- **The `init` skill and `/scc-init`, which fill what `scc init` can only lay down empty.** The CLI command scaffolds five anchors holding the format their validators check; what turns them into this project's knowledge base is a survey of the code, and a survey is judgment rather than scaffolding. So the skill holds the three things no rule can: the survey that precedes every artifact, the order across the six knowledge authors — `stack.md` first because `stack.undocumented-dependency` gives it a finish line, `docs/adr/` last because it is where invention is most likely, `scc validate` between stages, then the in-code `TODO`/`HACK` comments harvested into `docs/notes.md` last — and one bar that applies only here. - - **That bar is the design.** Everything written on this run is *reconstructed rather than remembered*, so nothing goes in that cannot be pointed at (a manifest, a CI file, a commit, a migration), and what nobody can justify is reported by name instead of filled in with something plausible: a gap is visible, an invention is believed, and the base's whole value is being trustworthy without checking. Each reconstructed ADR says so in its own `## Context`. It is a second `WorkflowSkill` for the same reason `plan-run` was the first — the knowledge skills each fire when *their* concern goes live, and none of them fires when the base is empty. What it deliberately never writes is `specs/`: restating a working system as requirements is the failure mode of a documentation pass, and `specs.md` already says a spec meets existing code as a delta, so the first spec is written by the next change. -- **`docs/notes.md` and `scc notes`, and the code stops carrying notes.** A comment says what a thing *is* and how to use it; everything else that used to be written beside code — the gotcha, the why-not, the "careful, this looks wrong and is not" — reaches exactly one reader, the one already looking at that line. Nobody asking *what do we know about this area* ever finds it, no command lists it, and it dies with the file. So `rules/notes.md` moves it into one log, and the log is queryable. - - **One note is one line**, index fields first — `- n-0042 2026-02-09 #gotcha @internal/cli/launch.go — wrap writes MCP config to the agent's own file` — and the single line is the entire design. A match is a *whole note*, so `grep ' #gotcha ' docs/notes.md` and `scc notes find --tag gotcha` answer the same question without either one reasoning about where a record ends; that is what lets the file that centralizes every note never be a file anybody reads. It also closes the door the v1 plan format left open, where `## Notes` grew to half the file because nothing forbade it: here there is nowhere for prose to go, and a thought needing a second line is a wiki page, an ADR, or a task — all three of which already exist, and the rule says which is which. - - **The CLI is the writer, never a gatekeeper.** `scc notes add` allocates the id and gets the format right, because a format nobody can be made to type is one that decays; `find | show | tags | paths | rm | validate` is the rest. What it adds over grep is the questions a substring cannot answer — which tags exist before somebody coins a fourth, what this project already knows about a path, what is new since a date. `--tag` is required and has no default, since a default would be one tag on everything, which is the drift the index exists to prevent. - - **A number is spent, never reused.** `rm` takes the text out and leaves an HTML-comment tombstone where the note stood, so a citation to `n-0042` can dangle but can never come to mean a different note. mdscan blanks comments, so the tombstone is invisible to a rendered read, to a grep for a tag, and to every parser except the one allocating the next id. - - **`@path` is the stale check** — the notes half of the codewiki citation rule, since a note about code that no longer exists is read as current, which is worse than the comment it replaced: that one at least died with the file. At *write* time it is a warning and never a block, because a note about a file this branch has not created yet is the note most worth having; `notes add` otherwise writes under the same verify-and-roll-back contract as `scc patch`. The ninth validator also reports the failure this file cannot tolerate quietly: a hand-written line that missed the grammar, which no query will ever return. - -- **`scc launch --jail`, the sandbox.** An agent needs filesystem access to do its job, and the same access lets it run `rm -rf`, read `~/.aws`, or ship a key somewhere — by accident, on a poisoned instruction in a file it read, or through a dependency it installed. `autonomy: auto` sharpens that rather than softening it: nobody is watching the step where noticing was still possible. `--jail` starts the agent inside [ai-jail](https://github.com/akitaonrails/ai-jail) — bubblewrap on Linux, `sandbox-exec` on macOS — wrapping *outermost*, Headroom included. - - **It is the one integration that refuses.** Headroom, CodeGraph and RTK all degrade to starting the agent bare, because each is an enhancement. A sandbox is the property the user asked for by name, so a missing binary, a declined install, an unattended run, or an unsupported platform all end in **nothing starting** — and the refusal happens before the graph is built or the entry file is touched, so it leaves the workspace as it found it. Somebody who typed `--jail` and watched an agent start believes they are contained, and a false belief about containment is worse than a known absence of it: they would not have run the thing at all. - - **scc asks for two capabilities and its own toolchain.** `--network` and `--agent-state` are function rather than policy — an agent with neither cannot reach its model or authenticate, so `ai-jail claude` bare is a jail that starts nothing. Everything else (lockdown, denied paths, Docker, the browser) is policy, and policy lives in ai-jail's own `~/.ai-jail` / `./.ai-jail`, which it reads by itself and scc never writes; `--jail-arg` is the per-run escape hatch and comes last so it wins. - - **`--map` is the third flag, and it is the same argument one step later.** ai-jail's private home replaces `$HOME` with a fresh tmpfs, binds only the command it was handed ("tools with needs beyond their install directory stay on the `--map` escape hatch"), and then prunes `PATH` to what survived — so `rtk` in `~/.cargo/bin` and an npm-installed `scc` are simply gone, while the entry file still tells the agent to prefix every command with one and the rules still tell it to answer questions with the other. It discovers that one failed command at a time and falls back to reading whole files, which is the cost this methodology exists to remove. So `jailToolchain` maps three binaries back in read-only — `scc`, `rtk`, `codegraph`, the list closed because scc can name exactly what its own guidance names — reported in `jail.maps` and printed in the command line. A compiled binary is mounted at the name `PATH` knows and brings nothing with it; that is also what makes the npm build work, since `os.Executable()` is the real Go binary behind the node shim. A **script** brings its bin directory, its package root (the outermost `node_modules`) and its shebang interpreter, because node resolves a script's real path before looking for anything beside it — that is `codegraph`. - - **`Compose` puts every directory before the files inside it, and that is not cosmetic.** Mounts apply in the order they are given, so a directory bind arriving after a file bind underneath it hides the file. Measured inside a real sandbox: `scc` mapped as "the real binary, at the shim's name" answered `v0.19.0` — the npm shim — because the bin directory its neighbours needed was mounted on top a moment later. Directories first, and the specific mount wins, which is the whole reason it was asked for. - - All three flags are read off `ai-jail --help` rather than compiled in — the lesson `internal/headroom` already paid for — and a build that advertises one of them gets **no substitute**, only a warning, because a sandbox opened by a guess is the failure this whole feature exists to prevent. - - **Windows has no backend and is unlikely to get one**: the sandbox stands on Linux namespaces and Apple's sandbox interface. WSL2 is the answer there, and it is a real one rather than a workaround — scc inside WSL2 is scc on Linux. The idea and the tool are [Fábio Akita's](https://akitaonrails.com/2026/01/10/ai-agents-garantindo-a-protecao-do-seu-sistema/); scc integrates the binary rather than the article's original shell script, because that script has since become a maintained Rust tool with a second platform backend. - -- **The spec records where it is being built: `scc spec track` and `scc spec sync`.** A branch was the one part of this methodology that left no trace in the artifacts — the spec said which boxes were ticked, git said a branch had been unmerged for three weeks, and nothing joined the two, so *which of these actually shipped* was answerable only by somebody holding both halves. Under `autonomy: auto` that is nobody. Three keys on `requirements.md` beside the kickoff answers — `branch:`, `pr:`, `delivery: in-progress|in-review|merged|abandoned` — with the vocabulary closed for the reason a task's flags are, and graded by the validator only when present, so every spec written before this keeps passing. - - **`track` records what the caller knows; `sync` derives what git knows.** `--here` takes the branch from the checkout, `--pr ` the pull request, and `scc spec sync` walks every spec, asks git and (when installed) `gh`, and writes the answer back under the same verify-and-roll-back contract as `scc patch`. `scc spec list` shows the record beside the phases. **Neither guesses**: a deleted branch with no PR to ask about is reported undetermined and left alone, because merged and abandoned are indistinguishable once the ref is gone. - - Two things in `sync` were wrong in the first cut and are worth keeping wrong-proof. **Merged is not "is an ancestor of the base"** — a branch created ten seconds ago satisfies that trivially, and the first run declared a spec delivered before a line of it existed; it is *ahead == 0 and behind > 0*, and the fast-forward case that no ref can resolve is called **not** merged, because this record exists to surface unfinished work. And **a settled record is not re-litigated**: a deleted branch on a spec already `merged` is what a merged branch looks like, and warning about it would put a line on every finished spec forever. - - **Plans are deliberately out for now**, for a naming reason rather than a principle: `pr:` on a plan already means the delivery *shape* plan-run asks for at kickoff (`per-plan`/`per-group`), so one key would carry two meanings on one file. Plan tracking starts by renaming that answer. - -- **The tenth validator, `attribution`, and the rule stops being only a rule.** `rules/delivery.md` has said "no `Co-Authored-By` for an assistant, no session link, no generated-with footer" since the first release, and it binds scc's own history first. What it could not do is hold: every harness appends one of those by default unless the session overrides it, so an instruction that has to be re-read and re-obeyed on each commit survives until the one session that skips it — and under `autonomy: auto` nobody is watching that session. `scc validate` now reads the commits this branch has added and reports the signature where it finds it. - - **A signature is a shape, never a word**, which is the whole reason the check can be trusted. `internal/attribution` matches five: a `Co-Authored-By:`-shaped trailer whose value names an assistant, a "generated with/by" line that names one, a session link or vendor page, a **Markdown badge** whose label or target names one, and a **bare mention** — a line that says nothing but the name. The vocabulary (`claude`, `anthropic`, `codex`, `opencode`, …) never decides on its own that a line is a signature — it only decides whether a signature names an assistant — so `chore(deps): bump the anthropic SDK` and `feat(harness): add opencode support` are ordinary commits. Two entries were removed after they were written down: `bot`, which fired on `dependabot[bot]` — a real author of the commit it signs, rather than somebody signing the user's work — and `amp`, which is a substring of *example*, *amplify* and *champion*. The branch **name** is checked by nothing, for the same reason: `feat/claude-api-client` is an ordinary branch, and with no shape to gate it the check would rest on the vendor's name alone. - - **It never returns an error, and that is a contract.** `Everything` turns a validator's error into exit 1 — *could not run* — and a project with no git, an unborn HEAD, a shallow CI clone, or a checkout sitting exactly on its base branch has nothing on this branch to check, which is what a clean pass means. `internal/git` already held that line for `spec sync` ("absence is a normal answer, never an error to propagate"); this is the same line, and breaking `scc validate` in every workspace that is not a git repository, over a rule about git, is the failure it prevents. **The range is `base..HEAD`** — anything already on the base is somebody else's history, and a check that reported on it would report the same finding on every branch forever. Standing *on* the base branch compares against `origin/` instead, so unpushed commits are still checked; a base branch with no remote leaves genuinely nothing to compare against. - - **The last two shapes are what the footer collapses to when a session is told not to write it.** The measured case: a harness whose footer is a phrase, a badge and a link on one line loses the phrase and keeps `[Claude Code](https://claude.com/claude-code)`, then loses the host and keeps the label, then loses the brackets and keeps the name. So the badge is its own rule rather than a clause of the footer, the link matches **whole vendor hosts** rather than the one path today's footer uses — `claude.com/claude-code` is the spelling that walked past a rule written around `claude.ai` — and the mention rule catches what is left. That last one is the only rule where the vocabulary comes close to deciding on its own, so it is **gated twice**: the line must say nothing but the name (every word either the name or one of a short list of product words, under a cap) *and* sit where a signature sits — behind a symbol, behind a typographic dash, or at the end of the message. Without the second gate it reports `Claude Code, Codex, and opencode`, which is a sentence this project's own pull requests write. A Markdown bullet is deliberately not a trigger, for the same reason. - - **The pull request is the half no hook can reach, and it is checked separately.** A commit message passes through `commit-msg` on the machine that wrote it; a PR body is typed straight into the forge, which is why the footer that dies everywhere else survives there. `validate.AttributionPR` reads the title and body through `gh` and is **off by default** — for cost, not doctrine: it is the one check here that goes to the network, and `scc validate` sits on the pre-commit path, where a gate that costs a second per commit is a gate somebody turns off, taking the other ten with it. `scc validate --pr` turns it on, and the pre-push hook passes it. - -- **`scc hooks`, and the check stops needing to be remembered.** A rule holds until the session that does not re-read it; a validator turns the rule into a check, and a check holds until the session that does not run it. A hook is the same check with the remembering taken out — git runs it, on every commit, whoever typed the command. `install | check | remove | run`, installed by `scc init` **by default** (`--no-hooks` opts out), and three stages, each where its question can first be answered: **pre-commit** runs `scc validate`, **commit-msg** reads the message being written, **pre-push** runs `scc validate --pr --checks`. - - **`commit-msg` is the stage that answers the complaint.** The validator can report a signature that is already in a commit; taking it out then means rewriting history. At `commit-msg` the message is still a file, so a non-zero exit leaves it in `.git/COMMIT_EDITMSG` for the next attempt. What it reads is what git will keep — comment lines and everything below the `--verbose` scissors are stripped first, or a check would fire on git's own template and on the diff, on every commit. - - **The scripts are three lines and call back into `scc hooks run `.** Every decision lives in Go, where it is tested and where upgrading the binary changes the gate without rewriting a file in somebody's `.git`. They **fail closed** when `scc` is not on PATH — a hook that passes silently when its checker is missing reports success it did not verify — and they name the way past themselves in the message they print, because a gate with no documented escape is a gate people delete the first time it is wrong. That escape is `SCC_SKIP_HOOKS=1`, which exists because `--no-verify` does not reach every git invocation that runs hooks. - - **A hook scc did not write is never touched.** A repository already running husky or lefthook has a pre-commit of its own, and appending shell to it would be scc authoring what the user owns; it is reported as foreign, with `--force` as the separate decision — and even then only into a script whose shebang says POSIX sh, because appending `sh` to a Python hook is not a forced decision but a broken repository. Where the hooks go is **asked of git** (`core.hooksPath`, then `rev-parse --git-path hooks`) rather than assumed to be `.git/hooks`: writing a hook where git will not read it is the kind of failure that looks like success for weeks. The block is versioned and spliced by `internal/mdblock` under scc's own markers; `scc update` **reports** a stale one and does not rewrite it, since a "yes" about template files is not consent to edit `.git`. - - **The harness hooks are the same idea one layer earlier, and they are the opposite kind of gate.** A git hook fires when work enters history; the commit is the *second* moment a finding exists, and the first is the end of the turn that wrote it, while the agent still has the file in mind. Claude Code will run a command at that moment and read what it prints back into the next turn ([hooks reference](https://code.claude.com/docs/en/hooks)), so `internal/hooks/harness.go` registers two events in `.claude/settings.json`: **`SessionStart`**, which says once that this workspace has recorded none of its own commands — the one gap only the agent can close — and **`Stop`**, which reports `scc validate` findings and undelivered work. `scc hooks run session-start|stop` are the same command the scripts call, so every decision stays in Go. - - **They never refuse — but on `Stop`, speaking is itself a continuation, and getting that wrong shipped a loop.** The harness treats `additionalContext` on Stop as guidance the conversation *continues* for, under the same loop protections as an outright block; returning 0 is not a passive report there. v0.24.0 asserted the opposite in this very paragraph, never read `stop_hook_active`, and put a branch-scoped finding on a stage that re-fires until the consecutive-block cap — measured in a real session, with the agent reduced to answering `.` to itself nine times. Two rules now hold it: **`stop_hook_active` is honoured unconditionally**, and **Stop may only carry what the next turn can resolve** — a finding clears when fixed, unpushed work clears when pushed, and a fact about the branch clears for nobody, which is why drift moved to `SessionStart`. Refusal still belongs at the commit, which is a decision with a natural place to stand in front of. - - **That second rule had two more violations of its own, and both are the same shape as the loop it was written for.** The stage ran every validator, `attribution` included — whose subject is commits already made, so the fix is a rebase and the line re-appears at the end of every turn for the life of the branch. It is out of this run entirely (`validate.WithoutAttribution`); `commit-msg` catches a signature at the one moment it is still a file. And it reported findings anywhere in the workspace, so a migrated plan or a stale ADR the task never touched asked the agent, every turn, to go and fix a file it had no business in — the findings are now intersected with `git.Changed`, and `scc validate` is where the whole workspace still answers. The undelivered-work line had a third: it fired from the *first* commit of a unit of work, which on a continuation stage means a push per turn, each paying for the pre-push gate. It waits for a clean tree now, because uncommitted changes are the cheapest evidence there is that the work is still in hand. - - **And the stage stopped re-indexing on every turn.** `syncGraph` started a CodeGraph process at the end of each turn whether or not anything had changed; it now compares the graph's mtime against the files this branch touched — a stat per changed file rather than a walk — and skips when nothing is newer. It errs toward syncing: an unnecessary index costs a subprocess, and a skipped one leaves the agent an index that answers confidently about code that changed. - - **`Stop` also reports methodology drift, and that is the one place this design improves on ponytail's.** ponytail assumes an agent drifts and re-asserts its instruction on every prompt and every subagent; that works because its instruction is a *disposition* — reach for the smallest thing that works — and a disposition has no shape, so re-asserting is the only move available. scc's rules are not dispositions. Source changed with no box ticked, a marker left in code, commits sitting on the base branch: each leaves evidence on disk, in a range `internal/git` already computes. So the answer is not to re-assert a rule at somebody who has read it — it is to say what actually happened. Three signals, **each a line only when it is true**, and `TestTheDriftStageIsSilentOnACleanBranch` is what holds that, because a stage that speaks every turn is one the reader learns to skip and then the turn it had something to say is the turn nobody read it. The marker scan requires a comment opener earlier on the line, which is what separates the annotation the rule forbids from a string literal containing the word — including the list the check itself is built from. - - **`UserPromptSubmit` is the third stage, and it is bounded by what it may speak about.** It fires on every prompt of every session — the worst cost profile in the set — so the only thing that makes it defensible is that it almost never speaks: it says something *only* when the prompt names an artifact this workspace actually has, and then says the one thing cheapest to say at that moment, which is how to read that artifact through `scc map` instead of with `Read`. ponytail can afford to fire on every prompt because its instruction is a disposition with no shape; scc's rules are routed by moment, so a stage that spoke unprompted would not be re-asserting anything — it would be interrupting. Three details are load-bearing: it runs **no subprocess** (two `ReadDir` calls and a string scan, with a 10-second ceiling it never expects to use, because it sits between the keystroke and the agent while the other two run when nobody is waiting); a name shorter than four characters is matched **only by its path**, since a spec called `ui` would otherwise fire on "build the ui" and on "guide"; and `-`/`.` are *inside* a name rather than boundaries around it, so `auth` does not match the spec `auth-flow`. A requirement id is deliberately not a trigger — `R1.2` names a requirement without naming a spec, and a real workspace defines it in nine. This is also the one stage where a non-zero exit discards the user's prompt before the agent sees it, which `TestThePromptStageNeverBlocksATurn` pins. **It was planned as a decision to be made on benchmark evidence and was built without it** (see `design/ponytail-port.md` 3.7), so it is conservative by construction rather than by measurement. - - **There is no `SubagentStart` stage, and the absence is gated rather than assumed.** ponytail re-injects its skill there; scc asked the question and got the opposite answer. Claude Code hands a non-fork subagent the whole `CLAUDE.md` hierarchy, project rules included (the built-in Explore and Plan agents are the documented exception, and their results return to a main conversation that has the rules), so the stage would re-send ~26KB the agent already holds — the exact cost `TestRulesStayShortEnoughToBePreloaded` exists to prevent, paid once per subagent instead of once per request. The answer is recorded on `paths.Harness.SubagentsInheritRules` and `TestNoHarnessNeedsASubagentStage` fails the build if a harness ever has a hook surface and does not deliver the rules one level down. **They also never publish**: the end of a turn is a plausible moment to `git push`, and doing it would send work on an event nobody is watching, so the Stop stage *says* the branch has commits that never left the machine and the agent pushes in its next turn, in the open, where a person can still stop it. `git.UndeliveredWork` answers that from refs already in the repository — no fetch, no `gh` — because this runs at the end of every turn, and neither the PR check nor the test suite is enabled here for the same reason. **Silence is the common case**: a clean workspace with nothing undelivered prints nothing at all, which is what keeps the line that does appear worth reading. - - **It is a `paths.Harness` capability, not a `switch h.ID`.** Codex and opencode are not missing a file scc could write — they have no mechanism that would read one, which is why `SettingsSeg` is empty there and they contribute nothing rather than a finding. And `settings.json` is spliced, never authored: scc's own entries are identified by their command, replaced in place, and every other entry, every other event, and every key this build has never heard of survives. A file scc cannot parse is one scc will not rewrite — reformatting somebody's broken JSON into valid JSON of scc's choosing would destroy what they were in the middle of fixing. This is the one place scc writes a *second* file per harness, and it earns that the way `.git/hooks` does: another tool's file, spliced, reported, reversible with `scc hooks remove`. - - **The two halves degrade independently.** A directory that is not a git repository can still have harness hooks, and a repository whose harness has no hook surface still gets its git hooks; only both being absent is an error. `hooks check` reports them in one table and exits 2 when either is missing. - -- **`scc check`, the eleventh validator, and the manifest gains its first input.** "Full suite + lint" was the one step in `rules/delivery.md` that nothing could check. A rule can ask for a green build, a clean linter and tests that mean something; a reviewer can look; under `autonomy: auto` nobody does either, and the step that certifies the work was the one step certified by nobody. The gate now runs: the project records one command per gate — **build, format, lint, test** — and scc runs them in that order and judges them by their exit status, with the test gate additionally printing `{"total": N, "coverage": P}` for a floor that defaults to **86%**. - - **scc still does not know how to build or test anything, and the one-command contract is how it stays that way.** A Go project's build gate is `go build ./...` and a Java one's is `mvn -q compile`; `internal/gate` never learns the difference, which is what keeps the check deterministic — it is a comparison, not a judgment. `Makefile`'s own `test-report` target is the worked example for the one gate whose contract is more than an exit code, since a contract with no reference implementation is one everybody implements differently. - - **`skipped` is a word rather than an absent key, and that is the whole design.** A language with no formatter and no linter is a real answer; a gate left unrecorded is a decision nobody has made. Collapsing the two would either nag every project that has no linter or go quiet about every project that forgot one — so a project declines a gate once, deliberately, and the validator is silent about it forever. `scc check clear` puts it back to undecided, which is a different act with a different consequence. - - **The pipeline stops at the first failure**, because the four presuppose each other: a lint report and a test failure over a broken build are derived noise, and the minutes spent producing them are minutes nobody gets back. Build first for that reason, then cheapest to dearest — a format check is seconds, a linter tens of seconds, a suite minutes. Within a gate the findings stay distinct (`check.not-configured`, `check.failed`, `check.timed-out`, `tests.no-report`, `tests.none`, `tests.below-floor`), because they are different things to go and do. - - **`total` is there because coverage alone is a claim that can be true of a suite that does not exist.** Zero tests at 100% is the shape of a repository whose tests were deleted, and a floor that passed it would certify the one project with nothing to certify. - - **The commands are the agent's to write, so the findings say what to write rather than only what is wrong.** scc cannot derive them: they depend on the project's toolchain, which the agent has just surveyed and scc never sees. So `check.not-configured` carries both ways out — the command that records a gate and the one that declines it — `rules/project.md` carries the one exception to "scc runs none of these commands", and the `init` skill gains the stage that derives all four from the language, names the format gate as the *check* rather than the rewrite (`gofmt -w .` exits 0 having silently changed the tree), and verifies them by running `scc check`. - - **It is off unless asked for, and that is the whole reason it can be trusted to stay on.** The other ten validators read files already on disk and cost milliseconds; this one runs a compiler, a linter and a test suite. `scc validate` sits on the pre-commit path, where a gate costing a minute per commit is a gate somebody uninstalls — taking the other ten with it. So `scc validate --checks` and **pre-push** run it, which is where a branch becomes a pull request and where the methodology says the claim is actually made. The hook enables it only once the workspace has decided something: under the explicit flag a missing command is a finding (asking for a gate and hearing nothing is how a project ships believing it has one), while on `git push` it cannot be, or every workspace that never wired anything up would fail every push over configuration nobody chose. - - **`scc init` writes the base, and that is what makes the configuration findable.** The `check` object is scaffolded with all four keys empty, so the first person or agent to open the manifest sees the four questions this project has to answer instead of a file that says nothing about them — the same reason `rules/project.md` ships as a stub with the command slots already in it. Empty costs nothing in meaning (`""` and absent both read as *nobody has decided*; the state that needed telling apart is the word `skipped`), and it costs one rewrite of every manifest that predates it. The floor is the one part left out: it has a working default, and writing it into the base would pin every workspace to today's number while looking configured. They are **grouped** rather than flat because this file sorts its keys and `files` is most of it — measured flat, `build` landed on line 2 and its three siblings on line 170, on the far side of every content hash in the workspace, which is precisely not the glance the flat shape was chosen for. - - **The commands live in `/scc-manifest.json`, which makes them the first *input* in a file that until now was only record.** No second config file — that rule stands, and a `scc.json` read by nothing inside the binary would be the dead weight it always was. Two consequences were load-bearing enough to fix rather than document. `Manifest.CarryOver` exists because `init` and `update` both build the next manifest from scratch: without it a re-run of `init` silently deleted the gate's configuration, and it had already been silently deleting the unknown-field preservation `UnmarshalJSON` goes to the trouble of doing; `Manifest.commands` is the one table those three operations share, so a fifth gate cannot be preserved on read and dropped on a re-scaffold. And `internal/gate` builds its **own command line on Windows** (`SysProcAttr.CmdLine`) rather than passing an argv to `cmd /c`, because `os/exec` quotes arguments for a C runtime cmd.exe does not use — measured: a recorded `-run "TestX"` arrives as `-run \"TestX\"`, runs a test nobody named, and reports success. - - **The commands are arbitrary code out of a committed file, and that is stated rather than glossed.** A hostile manifest is a hostile command. Three things bound it: nothing runs them on a bare `scc validate`, each is printed before it runs, and the hook that runs them is one the user installed in their own checkout — a project you would not `make test` from a fresh clone is not one to record commands in. - -- **The sandbox is on by default, and Windows gets a dev container.** `--jail` shipped opt-in, which meant the protection reached exactly the people who already knew they wanted it — a sandbox nobody turns on is a sandbox nobody has. It is now the default, and **the install is the opt-in**: ai-jail on PATH means every `scc launch` here is contained, absent means one line naming what would have contained it. That split is forced rather than chosen. `--jail` refuses when it cannot deliver, because somebody typed it and a false belief about containment is worse than a known absence of one; nobody types a default, so a default that refused would break `scc launch` for everyone who has not installed ai-jail. `--no-sandbox` is the deliberate way out, and it contradicts `--jail` rather than outranking it. - - **On Windows the backend is `internal/devcontainer`**, because ai-jail stands on Linux namespaces and Apple's sandbox interface and has neither there. `scc launch` runs `devcontainer up` and then `devcontainer exec`, so the agent runs inside the container without an editor in the loop — which is the whole reason the Dev Containers **CLI** is the integration rather than the VS Code flow. - - **The two boundaries are not equals and scc says so on the run that gets the lesser one.** ai-jail hides `~/.ssh`, `~/.aws` and `~/.gnupg` outright and keeps no escape hatch; a container isolates at the Docker boundary, and [Anthropic's own guidance](https://code.claude.com/docs/en/devcontainer) warns that under `--dangerously-skip-permissions` it "does not prevent a malicious project from exfiltrating anything accessible inside the container, including the Claude Code credentials stored in `~/.claude`" — which are mounted in precisely so a rebuild does not sign you out. So `containerReport.Weaker` is written down for machines, the human line names WSL2, and WSL2 stays the better Windows answer rather than a workaround. - - **`.devcontainer/` is a seed, on the same terms as the `docs/` anchors**: written once when absent, recorded in no manifest, never updated. A Dockerfile is a build environment, and that belongs to whoever has to debug it at three in the morning. It is seeded on every platform even though only Windows launches through it, because the files are committed and a team is not one platform. - - **The image carries the agent's own toolchain — Claude Code, `scc`, `codegraph`, `rtk` — for the reason `jailToolchain` maps the same three past the jail's private home.** Every rule scc scaffolds names one of them: the rules answer questions with `scc`, the entry file says to prefix commands with `rtk`, `code-search.md` says to ask `codegraph` before reading files. An agent in a container missing them discovers that one failed command at a time and falls back to reading whole files, which is the cost this methodology exists to remove. - - **Credentials are forwarded, never mounted.** `~/.claude` is a named volume plus `CLAUDE_CONFIG_DIR` (the token is under `~/.claude`, but `.claude.json` sits outside it, so the volume alone signs you out on rebuild); `.gitconfig` is bound read-only because identity is not a secret; git and `gh` authenticate from `GH_TOKEN`/`GITHUB_TOKEN` forwarded out of the launching shell. Binding `~/.ssh` would make push work in one line and hand a compromised dependency the signing keys — the exact thing the container exists to keep away from them, and what the guidance above tells you not to do. `TestInitSeedsTheDevContainer` asserts the mount is absent, matching the mount rather than the prose that explains why it is not there. - - **`/scc-init` fits the image to the project**, which is the half scc cannot: the language is a survey result, not a scaffold. The bar is mechanical and it is the delivery gate — a container that cannot run `scc check` is a container the gate cannot be verified in. - - -- **The five seeded `docs/` anchors** (`assets.Seeds`). `init` writes `glossary.md`, `stack.md`, `notes.md`, `wiki/index.md`, and `wiki/changelog.md` — the knowledge base's only fixed-name documents, each holding the format its validator checks. A seed is written once and tracked nowhere: not in the manifest, not by `scc update`. - -`scc` is a redesign of `csdd` (`github.com/protonspy/csdd`), narrowed to spec-driven development and deliberately leaner. When reaching for something from there, port the *decision*, not the file. Already decided against: a TUI, an embedded web dashboard, an MCP server, a devcontainer. - -## Commands - -```bash -make build # -> ./scc (set VERSION=vX.Y.Z to stamp the version) -make check # the CI gate: gofmt -l + go vet + go test -race -make test # go test -race -coverprofile=coverage.out ./... -make fmt # fails if anything is not gofmt-clean -make help # every target with its one-line description -``` - -A single test / package: - -```bash -go test ./internal/workspace/ -run TestSafeName -v -go test ./internal/cli/ -run 'TestVersion.*' -race -``` - -Lint locally the way CI does (golangci-lint v2 schema, conservative set in `.golangci.yml`): - -```bash -golangci-lint run # v2.12.2 in CI -govulncheck ./... # CI fails the build on reachable vulns -``` - -Release is manual and CI-driven: `make release VERSION=v0.1.0` dispatches `release.yml`. The `dist`/`npm-build`/`npm-publish` targets exist as a bootstrap/fallback path only. - -## Architecture +## Layout ``` -cmd/scc/main.go os.Exit(cli.Run(os.Args[1:])) - | - internal/cli the whole command surface - | - scaffold · validate · hooks write / check / gate - | \ - assets · manifest ears · mdscan · artifact · notes templates, hashes, grammars - | - paths · workspace · render · textutil · finding - | - plain files on disk: / · specs/ · plans/ · docs/ · CLAUDE.md|AGENTS.md +specs// requirements.md · design.md · tasks.md +plans/.md structure, plus a checklist and/or spec references +docs/ knowledge base — wiki, adr, codewiki, glossary, stack, notes +.claude/rules/ the methodology above +.claude/skills/ authoring each part of docs/, and running a plan group by group + on demand too: /scc-plan-run, /scc-wiki, /scc-adr, … ``` -Five packages sit off to the side of that tree — `rtk`, `headroom`, `codegraph`, `git`, `jail` — reached only from `internal/cli`. They are the third-party integrations, and with one exception they are the only code that starts another process. `git` is the one scc never installs and never writes with: every call in it is a query. The exception is `internal/gate`, which starts processes nobody here chose — the commands the *project* recorded — and is reached from `internal/validate` as well, because the delivery gate is a validator. - -`internal/cli/cli.go` is the whole dispatcher: `Run(args)` switches on `args[0]` and hands off to `run` in a file named for that resource. Each handler owns its own `flag.FlagSet`. Adding a subcommand means adding a case there plus one file — nothing is registered dynamically, so the command set is readable in one place. - -| Package | Role | -|---|---| -| `internal/paths` | Every directory/file name in the on-disk layout, in one place, plus the `Harness` profile (`Claude`, `Codex`, `OpenCode`) that says where each tool keeps things — and, in `PreloadsRules` and `SettingsSeg`, what it does with them and whether it has an in-session hook surface at all. Never hardcode `".claude"` or `"specs"` elsewhere — the harness-relative paths are methods on `Harness`. | -| `internal/workspace` | Resolves the root by walking up for *any* harness's `scc-manifest.json` marker; `Harnesses(root)` says which trees exist. Owns `KebabCheck`, `SafeName`, `AtomicWrite`. Knows nothing about specs or wikis. | -| `internal/render` | CLI terminal output (`✓ ✗ ! •`, `NO_COLOR`/TTY aware), split across stdout/stderr. | -| `internal/textutil` | Line-ending and BOM normalization, in exactly one place. | -| `internal/mdblock` | The marker-delimited splice: keeping one generated block current inside a Markdown file somebody else owns. `Markers.Splice/Block/Version/Remove`, idempotent, CRLF-preserving, and everything outside the markers untouched. Which markers is the integration's decision — `rtk.Markers` are RTK's own, `codegraph.Markers` are namespaced as scc's; what happens after that choice lives here once. | -| `internal/finding` | One finding type and one frozen JSON shape (`{findings, count}`) for every validator, plus the grouped human report. | -| `internal/manifest` | `/scc-manifest.json`: `{path, hash, version}` per managed file plus the harness, deterministic serialization, `Status → pristine\|edited\|missing`. Also a `check` object — `build`, `test`, `lint`, `format`, `min_coverage` — the file's only *input*, grouped so it sits above `files` instead of scattered around it, and written empty by `init` so the four questions are visible before anyone has answered them. The flat keys v0.21.0 wrote are still read and migrated on the next write. Unknown fields are preserved, and `CarryOver` is what keeps them and the recorded commands alive across a scaffold run that rebuilds the file. Every call takes the `paths.Harness` whose manifest it means. | -| `internal/assets` | The embedded template set — rules, review agents, skills, slash commands, artifact templates. **Workspace templates are data-free except for the harness profile** (a `(version, harness)` pair still renders byte-identically everywhere, and the manifest records both, so the future three-way merge can still reconstruct the old side); **artifact templates take data** (`spec new` renders them and the user owns the result); **seeds are the `docs/` anchors** — data-free like a workspace file, untracked like an artifact. `Render(h, file)` is the only way to get a workspace file's bytes: it expands paths and synthesizes the per-harness header for agents and commands. `Version` is the template-set version and must be bumped whenever a workspace template changes. | -| `internal/scaffold` | Applies the template set to a root (`Apply`) and brings an existing one current (`PlanUpdate`/`ApplyUpdate`). Idempotent, never overwrites without being told to, manifest written last. | -| `internal/mdscan` | The only Markdown parser: fence- and HTML-comment-aware headings, checkboxes, links, wikilinks, slugs, plus a small frontmatter reader. `Body` is the comment/fence-stripped text every validator applies its grammar to. | -| `internal/artifact` | The navigable model of one artifact, layered on `mdscan`: sections (two ends — the subtree, and the body before the first child), tasks with their continuation *and their flags*, requirements, spec-reference leaves, paragraph blocks. Owns **every grammar** (task, requirement, spec reference, flag), `Find` for address resolution, `Editor` for line splices resolved against the original and applied bottom-up, `Search`, the schedule (`Ready`/`BlockedTasks`/`Next`/`Cycles`, one implementation shared by `--next`, `--ready` and `--blocked`), and the seal. Knows nothing about findings or exit codes. | -| `internal/ears` | EARS requirement parsing, all five patterns plus complex. | -| `internal/notes` | `docs/notes.md`: the note grammar, the log's regions, the query, and the splice. One note is one line, so a match is a whole note — the property every other decision here protects. Knows nothing about findings or exit codes, the same seam `internal/artifact` keeps with the plan grammar. | -| `internal/attribution` | The shapes an assistant's signature takes in a commit message or a PR body: the `Co-Authored-By` trailer, the "generated with" footer, the vendor link, the Markdown badge, the bare name on a line of its own. A signature is a **shape**, never a word — nothing fires on a vendor's name alone, so `chore(deps): bump the anthropic SDK` and `feat(harness): add opencode support` stay ordinary commits. Knows nothing about findings, exit codes, or git. | -| `internal/gate` | The project's own commands — build, format, lint, test — as something scc can run and judge. Owns the four kinds and their run order, the `skipped` sentinel that tells a declined gate from an undecided one, the test report grammar, the 86% default floor, and the per-platform command line. Knows nothing about findings or exit codes — the same seam `internal/artifact` and `internal/notes` keep with their grammars. The sixth package that starts another process, and the only one whose binary the user chose. | -| `internal/validate` | The ten validators, one file each, sharing `mdscan` and `finding`, plus two that run only when asked: `AttributionPR` under `--pr`, because it is the one check that goes to the network, and `Checks` under `--checks`, because they run a compiler, a linter and a test suite. Two exceptions: `stack_manifests.go`, because the seven dependency-file readers age on their own schedule and sit beside the rule rather than inside it; and `attribution.go` / `tests.go`, the validators whose subject is not a file — one reads this branch's commits through `internal/git`, the other runs commands through `internal/gate`. | -| `internal/rtk` | RTK's marker pair (`rtk.Markers`, spliced by `internal/mdblock`), the foreign-block detection that names Headroom's copy, and finding or `cargo install`ing the binary. | -| `internal/headroom` | Headroom's agent-slug table, the `wrap` argument vector, the MCP opt-out discovered from `wrap --help`, and finding or installing the binary (uv, then pip — never npm, which ships the SDK and no CLI). The slugs live here rather than on `paths.Harness` because they are Headroom's vocabulary, not scc's layout. | -| `internal/devcontainer` | The Dev Containers CLI: `up`/`exec` argument vectors, the `.devcontainer/` presence test, the Docker-daemon check kept apart from the binary check (they fail differently and a user can fix one at a time), and finding or `npm install -g`ing the CLI. The sixth package that starts another process, and the only one that exists because of a platform: ai-jail has no Windows backend. | -| `internal/jail` | [ai-jail](https://github.com/akitaonrails/ai-jail)'s argument vector, its platform backends, the three flags scc asks for (discovered from `--help`), the walk that works out which toolchain paths the sandbox's private home would take away, and finding or `cargo install`ing the binary. Composes a command line and reimplements nothing: a sandbox is security-critical kernel interface work, and a half-copy of one has the confidence of containment without the containment. | -| `internal/hooks` | Both hook layers. The git side: three stages, the versioned block spliced by `mdblock` under scc's own markers, and where git actually keeps hooks (`core.hooksPath`, asked rather than assumed). The harness side (`harness.go`): three events spliced into the harness's own `settings.json`, recognized by their command rather than by a marker field, with every other key and entry preserved. The scripts and entries are one line and call `scc hooks run ` — every decision is Go, so the gate ships with the binary instead of living in somebody's `.git` or settings. A hook scc did not write is reported and left alone. | -| `internal/git` | `git` and `gh`, read-only: does this branch still exist, has it landed, and what did the forge do with the pull request. `Commits` is the third question and the only one that reads a message rather than a ref: what has this branch added that the base does not have. `UndeliveredWork` is the fourth and the cheapest — what this checkout has not sent — answered from refs already here so the Stop hook can ask it every turn. `Changed` is the fifth and the only one that reads content: what this branch did to the tree, against the *working* tree rather than `HEAD`, because at the end of a turn the agent has usually written code and not yet committed it. Two binaries in one package because the question has two halves and no caller should have to route between them — and nothing here installs or writes, so running it over every spec in a workspace is safe by construction. | -| `internal/codegraph` | CodeGraph's argument vectors (`init`/`sync`/`index`/`status`/`query`/`explore`), the `.codegraph/` presence test, `codegraph.Markers` for the usage block `scc launch` splices, the **scope** (`scope.go`: recorded patterns → the trees to index, one graph each), and finding or `npm install -g`ing the binary. Composes command lines and reads nothing inside the graph — the database is CodeGraph's schema on CodeGraph's schedule. | -| `internal/cli` | The dispatcher and every command handler. | - -`internal/rtk`, `internal/headroom`, `internal/codegraph`, `internal/git`, and `internal/jail` are the only packages that shell out to another program. Keep that boundary there rather than in a command handler: a third party's binary name, install command, and argument vocabulary all age on that third party's schedule, and one package per integration is what keeps a version bump from touching the dispatcher. Headroom's renamed MCP flag is the worked example — the fix stayed inside `internal/headroom`, and nothing else in the tree knows the flag exists. - -`go.mod` is stdlib-only. Keep it that way unless a dependency earns its place — the binary is distributed to six platforms and every dep is a supply-chain surface. - -## Conventions - -**Exit codes are the contract.** `0` ok · `1` usage/runtime error · `2` validation findings. Every lint/validate command returns `2` on findings so CI and agents can branch on it. A finding is a legitimate answer to a lint question, not a failure of the tool — don't collapse `2` into `1`. - -**`--help` is `0`**, and it took 52 call sites to be wrong about that at once. Every handler routed `flag.ErrHelp` to `ExitError`, so `scc map tasks --help` reported the same code as a command that could not run — to an agent branching on this contract, help that says the command is broken. `parseFlags` returns the error and `exitFor` maps it; `helpWord` turns a lone `help` positional on a leaf command into the same thing, because `scc validate help` is what somebody types after `scc map help`. - -**A positional never ends the flags.** Go's `flag` stops at the first non-flag argument, so a single pass reads everything after it as more positionals — measured, `scc notes add --tag gotcha "text" --path internal/x` wrote the flag into the note as prose, recorded no path, and exited 0. `parseFlags` alternates between peeling positionals and parsing flags until nothing is left, and honours a bare `--` for a value spelled like a flag. Misfiling an argument while reporting success is worse than rejecting it. - -`scc launch` is the single exception, and it has to be: it returns whatever the agent it started returned. A launcher that flattened the exit status of what it launched into its own vocabulary would be unusable in the scripts people actually write. scc's own failures — no workspace, unknown harness, binary not on PATH — still happen before anything starts and still report `1`. - -**A validator that fires on scc's own output is the worst bug in the product.** The templates carry their instructions in HTML comments and fenced examples, which is exactly what `mdscan` excludes — and `TestFreshArtifactsPassTheirOwnValidators` in `internal/cli` is the gate. Treat it as required reading before changing a template or a validator: one wrong finding teaches the user to disbelieve all eight. - -**Machine-readable output.** Bind the `--json` flag via `addJSON` and emit through `emitJSON` (`internal/cli/jsonout.go`) so the flag name, help text, and stdout/stderr split stay identical across commands — stdout is a clean JSON stream, diagnostics go to stderr. - -**Indented for a person, compact for everything else** — the same test `render` already makes about color. Indentation exists to be read, and the overwhelming reader of a `--json` document here is an agent paying by the byte: measured on a six-task plan, `map tasks --json` was 2198 bytes indented, about a third of it leading whitespace, against 599 for the human listing of the same tasks. It is 1558 now and a terminal still gets the readable form. The corollary is a testing rule: a test may not assert on the *spelling* of a document — `decode` in `cli_test.go` unmarshals it, because a `strings.Contains(out, "\"count\": 0")` is an assertion about the reader rather than about the command, and four of them broke on this change while the commands were correct. - -**Hostile input.** Any positional name that becomes a path segment must pass `workspace.SafeName` *before* `filepath.Join` — without it `scc spec delete .. --force` resolves to the workspace root. Resource names are kebab-case (`workspace.KebabCheck`). - -**And an artifact address is hostile input too.** `artifact.Resolve` accepts a path relative to the working directory, which is what makes `scc map show tasks.md` work from inside a spec directory — and, until `artifact.Within` was put in front of it, what made `scc patch append ../outside.md L3 --text X` write to a file outside the repository and exit 0, reporting only "no validator for this artifact". `requireWorkspace` does not cover this: it checks where scc is, not where the write lands. The reason it belongs in `internal/artifact` rather than in a handler is that an address reaches scc from a file the agent read as readily as from the user, so every command that resolves one needs the same boundary — `map show` dumping a file from outside the tree into context is the read half of the same hole. Symlinks are evaluated on both sides before the comparison, because a `t.TempDir()` on macOS is itself a symlink and a lexical test would call every artifact in it an escape. - -**Writes are atomic.** Use `workspace.AtomicWrite` for anything a concurrent reader might see. - -**The rules are a standing budget, not a place to explain things.** Every unscoped file under `/rules/` is preloaded into every request on a harness that reads them, so a line added there is paid continuously rather than once. `TestRulesStayShortEnoughToBePreloaded` caps a rule at 55 lines (three predate the budget and are capped where they stand; they may shrink, never grow), and `TestScaffoldedEntryFileStaysShort` caps the entry file at 60. A scoped rule is capped on its prose, not on the `paths:` header that took it out of the preloaded set. - -**On Claude Code there are no command files, and the `scc-` prefix is on the skill.** Claude Code registers a skill as `/` by itself and its own documentation now says commands and skills are one mechanism, so `commands/scc-wiki.md` beside `skills/scc-wiki/` was a second entry point in the picker for one thing and a second preloaded `description` — ~980 characters of saying twice what the skill says once. `paths.Harness.SkillsAreCommands` is the capability; opencode still gets command files because nothing there turns a skill into one, and Codex still gets none because its prompts are user-global. **The rename is what makes the removal safe rather than a second collision**: with no command file the skill's own name is what the user types, and `/init` is a command Claude Code already ships — the review agents' collision one version earlier, arriving by the same door. Every `/scc-init` already written keeps resolving. What the command bodies carried and the skills did not is the *default job* — for five of them, "run `scc validate` and clear this artifact's findings" — and that moved into the skill bodies, where the run that invokes it pays for it. - -**A skill's description is on that same budget and its body is not.** The harness loads every `description` at session start to decide when to invoke the skill, and leaves the body until it does — so the body is paid once by the run that needed it, and the description by every request whether the skill runs or not. Eight of them had grown to 4087 characters, mostly enumerating findings `scc validate` already names by rule; `TestSkillDescriptionsStayShortEnoughToPreload` caps one at 340 and the set at 2400. The body is where the detail belongs, on exactly the reasoning that sends a rule's detail into a `--help` string. When a feature does not fit, the answer is to move the detail into a `--help` string — read only when consulted — and keep the question→command table in the rule. The plan-format change was measured in and out on this basis: it landed at +2 lines and +709 bytes across `entry.md` + `artifacts.md` + `tasks.md`, against ~14k tokens saved per plan reread. - -**A command that edits an artifact verifies it afterwards.** `scc patch` snapshots the file, writes, re-runs the validator that owns it, and restores the snapshot if the edit introduced a finding the file did not already have. Two details are load-bearing: the comparison is on `rule + message` and deliberately **not on line number**, because an insertion moves every finding below it and comparing on line would blame this edit for the whole tail of a pre-existing problem; and an artifact scc has no validator for is written and *reported as unverified* rather than silently claimed clean. - -**The marker is the file `/scc-manifest.json`, never the harness directory.** Two reasons, and both are load-bearing: every harness has a global twin in the user's home (`~/.claude`, `~/.codex`, `~/.config/opencode`) that exists on any machine running that tool, so an upward walk accepting the *directory* would resolve the root to `$HOME` for any command run outside a workspace — every command would then read and write the user's global configuration. And those directories exist in every repo that merely *uses* the tool, where scc was never initialized. `workspace.Find` therefore stats a regular file, for each harness in turn. - -**scc has exactly one file per harness and no config file.** The manifest is it — content hashes, doubling as the marker, plus the keys the delivery gate needs. The rule that used to read "scc runs no tests and no linters, so it has nothing to configure" now has exactly one exception: it runs the project's own build, format, lint and test commands, because those were the claims in the methodology nothing could check. The scoped per-task test command stays prose in `/rules/project.md`, which is Markdown the orchestrator already reads; `build`, `test`, `lint`, `format` and `min_coverage` are in the manifest because something inside the binary reads them. That is the bar for a further key, and it is high. Resist adding `scc.json`: a JSON schema to version, read by nothing inside the binary, is dead weight — and a key nothing reads is the same dead weight with a shorter path. - -**Adding a harness touches one place.** A new `paths.Harness` value plus its entry in `paths.Harnesses()` is the whole change — `init`'s flags, the picker, `update`'s targets, the skills validator's search path, and the workspace walk all derive from that list. If a change needs a `switch h.ID` outside `assets.renderAgent`/`renderCommand`, the profile is missing a field. - -**Never author what the user owns.** Upgrades preserve user-edited files rather than overwriting them; the manifest of content hashes is what makes a pristine file distinguishable from an edited one. - -**Determinism.** Normalize rendered output to LF (`textutil.NormalizeNewlines`) so scaffolded files hash identically regardless of the build machine's checkout. Sort before serializing. - -**Windows is a first-class target** and CI runs the suite on Linux/macOS/Windows. `.gitattributes` forces LF everywhere — CRLF would break gofmt, manifest hashes, and shell hooks. Watch for path separators, file-locking/delete-pending behavior, and the fact that the race detector doesn't run on the Windows job. - -## Testing - -Tests live beside the code and lean on a few package-local helpers rather than a framework: - -- `capture(t, f)` in `internal/cli/cli_test.go` swaps `os.Stdout`/`os.Stderr` for pipes — the only safe way to assert on CLI output, since `render` writes to the real files. `run(t, args...)` wraps it around `cli.Run`. It drains both pipes concurrently, so a command that outruns the pipe buffer fails the test instead of deadlocking. -- `cli.Run` returns an exit code and never calls `os.Exit`, so the whole surface is drivable in-process. -- Compare resolved paths with `os.SameFile`, not string equality: `t.TempDir()` can sit under a symlink (`/var` → `/private/var` on macOS) and Windows reports 8.3 short names. -- `_test.go` files are exempt from `errcheck`/`staticcheck` in `.golangci.yml` — deliberate, for patterns like `Hash("a") != Hash("a")` asserting determinism. - -## Releasing - -`release.yml` is `workflow_dispatch` only: validate the version → run the full CI gate → cross-compile 6 targets → publish the npm packages → tag + GitHub Release. Details that are load-bearing: - -- **A version is immutable.** Re-dispatching an already-released version from a *different* commit is refused, because publishing is idempotent and the run would otherwise go green having shipped nothing. -- **Publishing is idempotent.** Already-published packages are skipped, so a run that died after `npm-publish` can be resumed by re-dispatching the same commit. -- **Adding a platform touches three places** that must agree: `TARGETS` in `npm/scripts/build-packages.mjs`, `PLATFORMS` in the `Makefile`, and the `build` matrix in `release.yml`. -- **The launcher is published under every name in `LAUNCHERS`**, from one source, in two tiers. A `required` name is already ours — `@protonspy/scc` is the documented install, and a failure there fails the release. A non-required name is one being tried for the first time: attempted last, allowed to fail with a warning. Both tiers are directories (`npm/dist/launchers/`, `npm/dist/launchers-optional/`) so publish order *and* failure policy are visible in the layout, after `dist/scc-*/` — a launcher reaching the registry ahead of the binaries in its `optionalDependencies` is a broken install for anyone in that window. - - The optional tier exists because **npm's typosquatting similarity check runs only on a real publish**: `npm view` returning 404 means unregistered, not publishable, and `npm publish --dry-run` never reaches the check. v0.9.0 died on `403 — Package name too similar to existing package cp-cli` for a name both of those had called free, and because that name published first, `set -e` took the working launcher with it. Corollary, and it is the load-bearing half: **only a required name may appear in documentation**, the embedded `entry.md` included. Promote a name into the docs in the release *after* the one that proved it publishes. -- Actions are pinned by commit SHA. Keep them pinned. - -## Commits - -Conventional Commits, scoped by package or surface, with a descriptive subject written as a claim about behavior — e.g. `feat(cli): return exit 2 when spec validation reports findings`. Changes land through PRs on `main`. +## Checking your work -**No attribution, in a commit message or a PR body.** No `Co-Authored-By` for an assistant, no session link, no generated-with footer, no naming of a model, vendor, or harness. This is the rule scc ships in `rules/delivery.md`, and it binds scc's own history first — a tool that told every workspace not to sign its work and then signed its own would be worth ignoring on both counts. Harness defaults append these unless told not to, so a session working here overrides that default rather than following it. +`scc validate` — or `npx @protonspy/scc validate` if not installed (`@` pins for CI). +`scc update` brings a newer scc's rules and agents in: it shows the plan, then asks. +Exit `0` ok · `1` could not run · `2` ran and found something. A finding is an answer, not a crash. +`scc` checks artifact *shape* only; it never reads source, so whether the code honors it is on you. ## CodeGraph diff --git a/README.md b/README.md index f5f960c..066aff7 100644 --- a/README.md +++ b/README.md @@ -274,7 +274,8 @@ make build # -> ./scc make help # every target ``` -See [CLAUDE.md](CLAUDE.md) for architecture and conventions. +See [docs/codewiki/packages.md](docs/codewiki/packages.md) for the package map and +[.claude/rules/project.md](.claude/rules/project.md) for this project's commands and conventions. ## License diff --git a/docs/adr/0001-stdlib-only-dependencies.md b/docs/adr/0001-stdlib-only-dependencies.md new file mode 100644 index 0000000..bcf28bf --- /dev/null +++ b/docs/adr/0001-stdlib-only-dependencies.md @@ -0,0 +1,44 @@ +--- +status: accepted +--- + +# Stdlib-only dependencies + +## Context + +Reconstructed from the repository rather than from the discussion that produced it: +`go.mod` declares no `require` block, `internal/validate/stack_manifests.go` +hand-rolls readers for seven dependency-file formats including TOML, the `Makefile` +and `release.yml` cross-compile six targets, and CI runs `govulncheck` with a pinned +version. Those four facts only make sense together under this decision, so it is +recorded here rather than inferred again by the next person who wants to add a +library. + +`scc` is distributed as a binary to six platforms and is run inside other people's +repositories, frequently in CI. Every direct dependency is a supply-chain surface +that ships with it, and every indirect one is a surface nobody chose. + +## Decision + +`go.mod` stays stdlib-only. A dependency is added only when it earns its place +against that cost, and adding one is two acts rather than one: the `require` line, +and an entry in `docs/stack.md` saying why — which the stack validator enforces by +reporting a declared dependency that `stack.md` does not list. + +The rule is scoped to the module that ships. `tools/` is a nested module the root's +`./...` never sees, and the npm packaging under `npm/` is JavaScript build +machinery; neither ends up inside the distributed binary. + +## Consequences + +- Real work is hand-rolled that a library would have done: TOML and `go.mod` + parsing for the stack validator, the Markdown scanner, the BM25 ranking behind + `map find`. The second of those is load-bearing anyway — `mdscan` is the only + parser, and a general-purpose one would not strip HTML comments and fences the way + every validator depends on. +- `govulncheck` still runs and CI still fails on a reachable vulnerability, because + the stdlib itself gets CVEs. Stdlib-only reduces the surface; it does not remove + the need to watch it. +- A contributor who reaches for a library is not making a small call. This record, + `docs/stack.md`, and the boundary line in `.claude/rules/project.md` are the three + places that say so. diff --git a/docs/adr/0002-narrowed-from-csdd.md b/docs/adr/0002-narrowed-from-csdd.md new file mode 100644 index 0000000..69e753f --- /dev/null +++ b/docs/adr/0002-narrowed-from-csdd.md @@ -0,0 +1,41 @@ +--- +status: accepted +--- + +# Narrowed from csdd: no TUI, no dashboard, no MCP server + +## Context + +Reconstructed. `scc` is a redesign of `csdd` (`github.com/protonspy/csdd`), and the +evidence for the narrowing is what is absent: no HTTP server, no terminal UI, and no +MCP transport anywhere under `internal/`, against a command surface that is entirely +`flag.FlagSet` and a `Run(args)` switch. + +The question the narrowing answers is *who actually drives this*. The answer is an +agent or a CI job as often as a person, and a surface built for a person is one an +agent drives badly — a TUI cannot be scripted, a dashboard cannot be asserted on, and +an MCP server makes the tool reachable only from inside a harness that speaks it. + +## Decision + +One surface: a headless CLI, with exit codes as the contract and `--json` wherever a +machine reads the output. Specifically decided against, and to be reopened by a new +record rather than by a pull request: a TUI, an embedded web dashboard, and an MCP +server. + +When something from `csdd` is wanted here, the *decision* is ported and not the file. + +## Consequences + +- Every feature has to be expressible as arguments in and a document out. That is a + real constraint on design — `scc map` and `scc patch` exist in the shape they do + because a plan had to be readable and editable without a screen. +- The agent-facing surface is the same surface a person uses, so it is exercised + constantly instead of by a second code path nobody runs. +- **One item on that list was later reversed, and it is recorded here rather than + quietly dropped.** A dev container was on the "decided against" list and is now + built: `internal/devcontainer` drives the Dev Containers CLI, because ai-jail has + no Windows backend and the alternative was shipping no sandbox at all on one of + the three platforms. The reversal does not weaken the other three — it is the + editor-free `devcontainer up` / `devcontainer exec` path, not the VS Code flow, so + the surface is still arguments in and a process out. diff --git a/docs/codewiki/packages.md b/docs/codewiki/packages.md new file mode 100644 index 0000000..9878644 --- /dev/null +++ b/docs/codewiki/packages.md @@ -0,0 +1,170 @@ +# The package map + +`scc` is one binary and the dependency direction only ever points one way: + +``` +cmd/scc/main.go os.Exit(cli.Run(os.Args[1:])) + | + internal/cli the whole command surface + | + scaffold · validate · hooks write / check / gate + | \ + assets · manifest ears · mdscan · artifact · notes templates, hashes, grammars + | + paths · workspace · render · textutil · finding + | + plain files on disk: / · specs/ · plans/ · docs/ · CLAUDE.md|AGENTS.md +``` + +This page is about why the seams fall where they do. What each package *does* is in +its own doc comments; what the concepts mean is in the wiki. + +## The dispatcher is a switch, on purpose + +[internal/cli/cli.go:48-60]() + +`Run(args)` switches on `args[0]` and hands off to `run` in a file named +for that resource; each handler owns its own `flag.FlagSet`. Nothing is registered +dynamically, so the entire command set is readable in one place, and adding a +subcommand is a case plus a file. + +`Run` returns an exit code and never calls `os.Exit` — which is what makes the whole +surface drivable in-process from the tests. + +## Two flag-parsing bugs the handlers must not each re-discover + +[internal/cli/flags.go:30-95]() + +Go's `flag` stops at the first non-flag argument, so a single pass reads everything +after a positional as more positionals. Measured, `scc notes add --tag gotcha "text" +--path internal/x` wrote the flag into the note as prose, recorded no path, and +exited `0`. `parseFlags` alternates between peeling positionals and parsing flags +until nothing is left, and honours a bare `--` for a value spelled like a flag. +Misfiling an argument while reporting success is worse than rejecting it. + +The second is the exit code for `--help`, which 52 call sites got wrong at once by +routing `flag.ErrHelp` to `ExitError`. `parseFlags` returns the error and `exitFor` +maps it; `helpWord` turns a lone `help` positional on a leaf command into the same +thing. + +## The layout has one vocabulary + +[internal/paths/paths.go:44-124]() + +Every directory and file name in the on-disk layout lives in `internal/paths`, with +the harness-relative paths as methods on `Harness`. The profile carries capabilities +rather than identities — `PreloadsRules`, `SubagentsInheritRules`, `SettingsSeg` — so +a change needing a `switch h.ID` outside the asset renderers means the profile is +missing a field. + +[internal/workspace/workspace.go:94-128]() + +`Find` walks up for a *regular file*, `/scc-manifest.json`, for each harness +in turn. Accepting the directory would resolve the root to `$HOME` for any command +run outside a workspace, because every harness has a global twin there. + +## Templates are data-free, artifacts are not + +[internal/assets/assets.go:773-800]() + +`Render(h, file)` is the only way to get a workspace file's bytes: it expands paths +and synthesizes the per-harness header. A `(version, harness)` pair renders +byte-identically everywhere, and the manifest records both — which is what would let +a future three-way merge reconstruct the old side. Artifact templates take data, +because the user owns the result. + +[internal/scaffold/scaffold.go:92-110]() + +`Apply` is idempotent, never overwrites without being told to, and writes the +manifest last. + +## The grammars live with the reader + +[internal/artifact/artifact.go:460-540]() + +`Resolve` accepts a path relative to the working directory, and `Within` is the +boundary in front of it — an address reaches `scc` from a file the agent read as +readily as from the user, so the check belongs here rather than in a handler. + +[internal/artifact/schedule.go:85-100]() + +One schedule implementation is shared by `--next`, `--ready` and `--blocked`. Two +notions of eligibility would be two answers to *what do I work on*. + +[internal/ears/ears.go:102-120]() · [internal/mdscan/mdscan.go:1-20]() + +`mdscan` is the only Markdown parser, and its `Body` — comments and fences stripped — +is what every validator applies its grammar to. That is also what lets the templates +carry their instructions in HTML comments without tripping the validators they ship +with. + +## The record, and the one thing in it that is an input + +[internal/manifest/manifest.go:67-90]() + +`{path, hash, version}` per managed file, deterministically serialized, plus the +`check` object. + +[internal/manifest/manifest.go:423-445]() + +`CarryOver` is the reason a re-run of `init` no longer deletes the project's own +commands: `init` and `update` both build the next manifest from scratch. + +## The checkers + +[internal/validate/all.go:129-142]() + +Ten validators, one file each. [internal/validate/checks.go:27-40]() and +[internal/validate/attribution.go:74-90]() are the two that run only when asked — +one goes to the network, the other runs a compiler and a test suite. + +[internal/gate/gate.go:72-80]() · [internal/gate/gate.go:323-340]() + +`internal/gate` knows the four kinds and their order and nothing about any language. +It is the only process-starter whose binary the *user* chose. + +## The integrations, one package each + +[internal/mdblock/mdblock.go:93-130]() + +The splice is one implementation — find, compare, replace, append, preserve line +endings. *Which* markers is the integration's decision; everything after that choice +lives here. + +[internal/jail/toolchain.go:125-215]() + +`Needs` works out what the sandbox's private home would take away, and `Compose` puts +every directory before the files inside it — mounts apply in order, so a directory +bind arriving after a file bind underneath it hides the file. + +[internal/codegraph/scope.go:47-70]() + +A recorded scope is N graphs, not one graph filtered, and the fan-out is `cmd.Dir` +per root: the builders never learn that scoping exists. + +[internal/git/git.go:380-395]() · [internal/git/git.go:553-570]() · +[internal/git/git.go:631-650]() + +`git` is read-only in every call, which is what makes running it over every spec in a +workspace safe by construction. `Changed` is the only one that reads content, and it +compares against the *working* tree rather than `HEAD`, because at the end of a turn +the agent has usually written code and not yet committed it. + +## Hooks are events, not scripts + +[internal/hooks/harness.go:42-60]() + +The scripts and settings entries are one line and call back into +`scc hooks run `, so every decision is Go — tested, and upgraded with the +binary rather than by rewriting a file in somebody's `.git`. + +## Testing conventions + +[internal/cli/cli_test.go:1-40]() + +`capture(t, f)` swaps `os.Stdout`/`os.Stderr` for pipes and drains both concurrently, +so a command that outruns the pipe buffer fails the test instead of deadlocking — the +only safe way to assert on output, since `render` writes to the real files. Compare +resolved paths with `os.SameFile` rather than string equality: `t.TempDir()` can sit +under a symlink and Windows reports 8.3 short names. A test may not assert on the +*spelling* of a `--json` document; `decode` unmarshals it. diff --git a/docs/stack.md b/docs/stack.md index a2ca1cd..ee6bfdd 100644 --- a/docs/stack.md +++ b/docs/stack.md @@ -72,7 +72,7 @@ nobody recorded a reason, this file says so rather than inventing one. ## Integrated binaries — driven, never vendored Each is a third-party CLI scc composes a command line for and starts. None is a Go -dependency; none is vendored. The shared reason is in `CLAUDE.md`: a third party's +dependency; none is vendored. The shared reason is `adr:0001-stdlib-only-dependencies`: a third party's binary name, install command and argument vocabulary all age on that third party's schedule, so each lives in its own package and a version bump never touches the dispatcher. The shared cost is that scc cannot guarantee any of them is present, diff --git a/docs/wiki/changelog.md b/docs/wiki/changelog.md index e4cf7f2..ffd3c9c 100644 --- a/docs/wiki/changelog.md +++ b/docs/wiki/changelog.md @@ -7,6 +7,13 @@ thing it describes moved. A page named here that no longer exists is reported, so a rename is recorded as a change rather than left pointing at the old slug. +- 2026-09-19 — `CLAUDE.md` went back to being the entry file the binary ships, so + what it carried moved to the one place that owns it: extended [[delivery-gate]] + (the grouped `check` object, the distinct findings), [[integration-boundary]] (the + Windows dev container, one graph per scoped tree), [[managed-files]] (`CarryOver`), + [[artifact-addressing]] (the reading surface, BM25 over regions, the seal) and + [[spec-or-plan]] (the spec's delivery record); added [[note-log]] and [[release]], + and `docs/codewiki/packages.md` and the first two ADRs. - 2026-09-15 — first pass, reconstructed from `CLAUDE.md`, `.claude/rules/`, `design/` and the tree itself: added [[context-budget]], [[scoped-rules]], [[artifact-addressing]], [[spec-or-plan]], [[harness-profile]], [[managed-files]], diff --git a/docs/wiki/index.md b/docs/wiki/index.md index 9a25f54..e305f08 100644 --- a/docs/wiki/index.md +++ b/docs/wiki/index.md @@ -17,6 +17,8 @@ the source most of it was reconstructed from. reads at session start is paid again in every request. - [[spec-or-plan]] — the two vehicles for work, how the choice is made, and why a plan is a contract rather than a document. +- [[note-log]] — why a gotcha is one line in `docs/notes.md` rather than a comment + beside the code that taught it. ## The artifacts @@ -44,3 +46,8 @@ the source most of it was reconstructed from. - [[integration-boundary]] — six third-party binaries, one package each, and why everything degrades except the sandbox. + +## Shipping it + +- [[release]] — the dispatch-only pipeline, an immutable version, and the two + launcher tiers npm's similarity check forced into existence. diff --git a/docs/wiki/pages/artifact-addressing.md b/docs/wiki/pages/artifact-addressing.md index 02a6b48..d224da5 100644 --- a/docs/wiki/pages/artifact-addressing.md +++ b/docs/wiki/pages/artifact-addressing.md @@ -48,6 +48,70 @@ alone are an index a twentieth of the size. workspace's 31 specs, so `scc map trace R2.5` unscoped answers with the list of specs and stops, rather than concatenating nine traces. +## The reading surface is what gives "never open the plan" its authority + +Forbidding the read without offering the equivalent query produces an agent that +disobeys the rule, correctly — so the surface shipped in the phase before the rule +did. `map brief` is the header, `map tasks` is the checklist, and no command returns +both, so a session pays `brief` once and `--next` per task. + +`--next` is **determined** rather than first-in-file: eligible tasks first, then +priority ascending with absent last, then the number compared *numerically* — which +is also the fix for `1.10` sorting before `1.9`. `--ready`, `--blocked` and `--deps` +share that one implementation, because two notions of eligibility would be two +answers to *what do I work on*. + +**`--next` prints the task whole; the listings clip.** Every task in a real plan runs +past one line, and the line below the checkbox is usually where the decision sits, so +a `--next` that stopped at the line break sent the reader back to the file — the exact +cost this surface exists to remove. It therefore ignores `--width`. A list of sixty +one-line tasks is still a list, so the listings clip: `Task.Continuation()` is the raw +lines with the flags removed, and `Task.Detail` is the same text collapsed to one +line, right for a row in a table and wrong for anything meant to be read. + +## No search engine + +`map find` ranks the workspace with BM25 in the binary, and the obvious reach for it +was an inverted index. At 352KB and 94 artifacts a linear pass ranks the whole corpus +in 55ms, and Tantivy or its kin would cost a CGO surface or a second binary against a +stdlib-only `go.mod` and a six-platform cross-compile. + +What precision needed was not a better index but a better **unit**: BM25 over +addressable regions rather than lines, so a hit comes back as something `show` +accepts. The seam is `artifact.Search` — it takes artifacts and returns hits, and +nothing outside that file knows how it found them. + +The command is now **undocumented rather than removed**. Its stated reason was the +whole corpus, not the plan, and with the plan small, searching *inside* one stopped +making sense — while searching the knowledge base is still the only alternative to +opening a file. Deleting the code would save nothing; deleting the line from the +rules and the entry file saves tokens in every request of every session. + +## The seal is tamper-evidence, not prevention + +`scc plan approve` validates, then writes `status: approved` and a `checksum:` over +the file minus its own checksum line, LF-normalized. `reseal --force` is one command +away and sha256 is public, so this is recorded as evidence rather than as a guarantee, +in case somebody later builds one on it. + +The check runs **before an edit is applied**, which is the whole value: a harness that +edited by hand and then ran `patch check` would otherwise have its edit resealed by +the command that should have reported it. A plan with no `status:` is never checked, +which is what lets every pre-existing plan keep working. + +After approval the work is fixed and only discovery moves. `add` allocates the number +from a high-water mark that includes removed tasks — so nothing is stored anywhere — +and demands `--reason`; `rm` strikes the task out where it stands rather than deleting +it; rewriting a task or the prose is refused. What discovery can never touch is +guaranteed **structurally** rather than by instruction: `Why`, `Out of scope`, `Done +when` and the title are reachable only through `append`, `prepend` and `replace`, and +those are exactly the three that are refused. + +`scc plan migrate` moves a v1 plan across: it renames `Decomposition` to `References`, +moves every other heading into `plans/archive/-notes.md`, creates the missing +required sections **empty and lets the findings appear** — a placeholder that satisfied +the validator would be a plan that lies — and writes `status: draft`, never `approved`. + ## The grammar lives with the reader, not the checker `internal/artifact` owns every grammar — task, requirement, spec reference, flag — diff --git a/docs/wiki/pages/delivery-gate.md b/docs/wiki/pages/delivery-gate.md index eab6b3c..00a0940 100644 --- a/docs/wiki/pages/delivery-gate.md +++ b/docs/wiki/pages/delivery-gate.md @@ -35,6 +35,24 @@ different act with a different consequence. person to open the manifest sees the four questions this project has to answer rather than a file that says nothing about them. +Empty costs nothing in meaning — `""` and absent both read as *nobody has decided*, +and the state that needed telling apart is the word `skipped`. The floor is +deliberately left out: it has a working default, and writing it into the base would +pin every workspace to today's number while looking configured. + +The four keys are **grouped** into one object rather than sitting flat beside the +file hashes, and that was measured rather than chosen: this file sorts its keys, and +flat, `build` landed on line 2 with its three siblings on line 170 — on the far side +of every content hash in the workspace, which is precisely not the glance the shape +exists for. The flat keys an earlier version wrote are still read and migrated on the +next write. + +Within a gate the findings stay distinct — `check.not-configured`, `check.failed`, +`check.timed-out`, `tests.no-report`, `tests.none`, `tests.below-floor` — because they +are different things to go and do. **`scc` cannot derive the commands**: they depend +on a toolchain it never sees, so `check.not-configured` names both ways out, the +command that records a gate and the one that declines it. + ## The test gate asks for one thing more It prints a JSON object on stdout: diff --git a/docs/wiki/pages/integration-boundary.md b/docs/wiki/pages/integration-boundary.md index 1e8d205..9e345bd 100644 --- a/docs/wiki/pages/integration-boundary.md +++ b/docs/wiki/pages/integration-boundary.md @@ -88,6 +88,72 @@ underneath it; and `scc` is mapped as the real binary behind the npm shim, becau `os.Executable()` is the Go binary and mapping it at the name `PATH` knows takes node out of the picture. +## Windows has no jail, so it has a container + +The sandbox stands on Linux namespaces and Apple's sandbox interface, and ai-jail has +neither backend on Windows. There `internal/devcontainer` takes over: `devcontainer +up` and then `devcontainer exec`, so the agent runs inside the container without an +editor in the loop — which is the whole reason the Dev Containers **CLI** is the +integration rather than the VS Code flow. + +**The two boundaries are not equals, and the run that gets the lesser one says so.** +ai-jail hides `~/.ssh`, `~/.aws` and `~/.gnupg` outright and keeps no escape hatch; a +container isolates at the Docker boundary, and Anthropic's own guidance warns that +under `--dangerously-skip-permissions` it does not prevent a malicious project from +exfiltrating anything reachable inside the container, credentials included. So +`containerReport.Weaker` is written down for machines, the human line names WSL2, and +WSL2 stays the better Windows answer rather than a workaround: `scc` inside WSL2 is +`scc` on Linux. + +**Credentials are forwarded, never mounted.** `~/.claude` is a named volume plus +`CLAUDE_CONFIG_DIR` — the token lives under `~/.claude` but `.claude.json` sits +outside it, so the volume alone signs you out on rebuild — `.gitconfig` is bound +read-only because identity is not a secret, and git and `gh` authenticate from +`GH_TOKEN`/`GITHUB_TOKEN` forwarded out of the launching shell. Binding `~/.ssh` +would make push work in one line and hand a compromised dependency the signing keys, +which is the exact thing the container exists to keep away from them. +`TestInitSeedsTheDevContainer` asserts that mount is absent, so the test matches the +mount rather than the prose explaining it. + +The image carries the agent's own toolchain — the harness, `scc`, `codegraph`, `rtk` — +for the same reason the jail maps those three back in. `.devcontainer/` itself is a +**seed** on the terms the `docs/` anchors are: written once when absent, recorded in +no manifest, never updated, because a Dockerfile belongs to whoever has to debug it at +three in the morning. It is seeded on every platform even though only Windows launches +through it, because the files are committed and a team is not one platform. + +## One graph per tree, not one graph filtered + +`scc graph scope set backend/src frontend/src` narrows what gets indexed, and the +distinction in that heading is the whole feature. It is not a design preference: it is +measured against CodeGraph 1.5.0, whose `init` takes a single optional `[path]` and +refuses two, whose commands carry no include or exclude flag, and whose only exclusion +is `.gitignore` — git's file about git, not an index scope. + +So a recorded scope means *two graphs*, one `.codegraph/` inside each directory, and +`build`, `sync`, `status`, `query` and `explore` all answering once per root. That is +said out loud wherever a user meets it, because "scoped to two trees" and "there are +two graphs" are the same fact and only the second explains why `status` answers twice. +An empty scope — the default — is one graph at the root, exactly as before; the scope +earns its cost on a monorepo whose vendored trees are re-walked on every sync, and +costs more than it buys on a repository that is one tree. + +**The patterns are normalized rather than taken literally**, because what people write +is `backend/src/*` and what they mean is the directory. A trailing `/*` or `/**` is +stripped, an interior glob is expanded to the directories it matches with files +dropped, and anything escaping the workspace is refused — a manifest arrives with a +clone, and `..` there would mean writing a `.codegraph/` outside the repository. A +pattern matching nothing is named and skipped; *every* pattern matching nothing stops +the run, because falling back to the whole workspace is the opposite of what the scope +asked for. + +The fan-out touches no argument vector: every CodeGraph command discovers its project +from the working directory, so scoping is `cmd.Dir` per root and the builders never +learn it exists. The one place it shows is `--json` over several roots, where N +documents concatenated are not a document — `graphFanJSON` emits an array of +`{path, output}` and passes each root's output through as `json.RawMessage`, since +nesting a document is not reading one. + ## Only npm, and only sometimes CodeGraph's headline install pipes a remote script into a shell. That is a fine diff --git a/docs/wiki/pages/managed-files.md b/docs/wiki/pages/managed-files.md index 643430d..bb19680 100644 --- a/docs/wiki/pages/managed-files.md +++ b/docs/wiki/pages/managed-files.md @@ -20,6 +20,13 @@ four commands; the bar for a further key is that something inside the binary rea it, and that bar is deliberately high. A `scc.json` read by nothing would be dead weight, and a key nothing reads is the same dead weight with a shorter path. +That input has a failure mode worth naming, because it is silent. `scc init` and +`scc update` both build the next manifest **from scratch**, so without +`Manifest.CarryOver` a re-run of `init` deleted the gate's four commands — and had +already been deleting the unknown fields `UnmarshalJSON` goes to the trouble of +preserving. `Manifest.commands` is the one table read, carry-over and write all +share, so a fifth key cannot be preserved on read and dropped on a re-scaffold. + ## Four categories, four lifecycles | Kind | Rendered from | Tracked | Updated | diff --git a/docs/wiki/pages/note-log.md b/docs/wiki/pages/note-log.md new file mode 100644 index 0000000..cb83c5f --- /dev/null +++ b/docs/wiki/pages/note-log.md @@ -0,0 +1,59 @@ +# The note log + +A comment says what a thing **is** and how to use it. Everything else that used to be +written beside code — the gotcha, the why-not, the *careful, this looks wrong and is +not* — reaches exactly one reader: the one already looking at that line. Nobody asking +*what do we know about this area* ever finds it, no command lists it, and it dies with +the file. + +`docs/notes.md` is where that goes instead, and `scc notes` is how it is queried. + +## One note is one line + +``` +- n-0042 2026-02-09 #gotcha @internal/cli/launch.go — wrap writes MCP config to the agent's own file +``` + +Index fields first, and the single line is the entire design. **A match is a whole +note**, so `grep ' #gotcha ' docs/notes.md` and `scc notes find --tag gotcha` answer +the same question without either one reasoning about where a record ends. That is what +lets the file centralizing every note never be a file anybody reads. + +It also closes the door the v1 plan format left open, where `## Notes` grew to half +the file because nothing forbade it: here there is **nowhere for prose to go**. A +thought needing a second line is a wiki page, an ADR, or a task — all three of which +already exist, and the rule says which is which. See [[spec-or-plan]]. + +## The CLI is the writer, never a gatekeeper + +`scc notes add` allocates the id and gets the format right, because a format nobody +can be made to type is one that decays. `find | show | tags | paths | rm | validate` +is the rest, and what it adds over grep is the questions a substring cannot answer: +which tags exist before somebody coins a fourth name for one concern, what this +project already knows about a path, what is new since a date. + +`--tag` is required and has no default. A default would be one tag on everything, +which is the drift the index exists to prevent. + +## A number is spent, never reused + +`scc notes rm` takes the text out and leaves an HTML-comment tombstone where the note +stood, so a citation to `n-0042` can dangle but can never come to mean a *different* +note. `mdscan` blanks comments, so the tombstone is invisible to a rendered read, to a +grep for a tag, and to every parser except the one allocating the next id. + +## `@path` is the stale check + +A note about code that no longer exists is read as current, which is worse than the +comment it replaced — that one at least died with the file. So a path is checked, and +the ninth validator reports the failure this file cannot tolerate quietly: a +hand-written line that missed the grammar, which no query will ever return. + +At *write* time an unresolvable path is a warning and never a block, because a note +about a file this branch has not created yet is the note most worth having. Otherwise +`notes add` writes under the same verify-and-roll-back contract as `scc patch` — see +[[artifact-addressing]]. + +## Related + +[[artifact-addressing]] · [[validation-contract]] · [[spec-or-plan]] diff --git a/docs/wiki/pages/release.md b/docs/wiki/pages/release.md new file mode 100644 index 0000000..60d28c4 --- /dev/null +++ b/docs/wiki/pages/release.md @@ -0,0 +1,61 @@ +# Releasing + +`release.yml` is `workflow_dispatch` only, and it runs in one order: validate the +version, run the full CI gate, cross-compile the six targets, publish the npm +packages, then tag and cut the GitHub Release. Everything below is a property that +had to be designed rather than a step that had to be listed. + +## A version is immutable + +Re-dispatching an already-released version from a *different* commit is refused. +Publishing is idempotent — see below — so without that refusal the run would go +green having shipped nothing, which is the worst available outcome: a release that +reports success and changes nobody's install. + +## Publishing is idempotent + +Already-published packages are skipped rather than failing the job, so a run that +died after `npm-publish` is resumed by re-dispatching **the same commit**. The two +properties are one design: immutability is what makes re-dispatch safe, and +idempotency is what makes it useful. + +## Adding a platform touches three files that must agree + +`TARGETS` in `npm/scripts/build-packages.mjs`, `PLATFORMS` in the `Makefile`, and +the `build` matrix in `release.yml`. Nothing derives one from another, so a platform +added to two of the three ships a launcher whose `optionalDependencies` name a +package that was never built. + +## The launcher ships in two tiers + +One source, published under every name in `LAUNCHERS`, and the tiers are +**directories** — `npm/dist/launchers/` and `npm/dist/launchers-optional/` — so +publish order *and* failure policy are visible in the layout rather than in a script. +Both go after `dist/scc-*/`: a launcher reaching the registry ahead of the binaries +in its `optionalDependencies` is a broken install for everybody in that window. + +- **Required** — a name that is already ours. `@protonspy/scc` is the documented + install, and a failure there fails the release. +- **Optional** — a name being tried for the first time. Attempted last, allowed to + fail with a warning. + +**The optional tier exists because npm's typosquatting similarity check runs only on +a real publish.** `npm view` returning 404 means unregistered, not publishable, and +`npm publish --dry-run` never reaches the check at all. v0.9.0 died on `403 — Package +name too similar to existing package cp-cli` for a name both of those had called +free, and because that name published first, `set -e` took the working launcher down +with it. + +The load-bearing corollary is a documentation rule: **only a required name may appear +in documentation**, the embedded entry template included. A name is promoted into the +docs in the release *after* the one that proved it publishes. + +## Actions are pinned by commit SHA + +Along with the `govulncheck` and `golangci-lint` versions. A release pipeline that +resolves a tag at run time is a supply-chain surface on every dispatch, which is the +same reasoning `go.mod` is held to: see `adr:0001-stdlib-only-dependencies`. + +## Related + +[[managed-files]] · [[validation-contract]] · [[integration-boundary]] diff --git a/docs/wiki/pages/spec-or-plan.md b/docs/wiki/pages/spec-or-plan.md index 6428490..bd7739e 100644 --- a/docs/wiki/pages/spec-or-plan.md +++ b/docs/wiki/pages/spec-or-plan.md @@ -52,6 +52,39 @@ introduces requirements that conflict, and correctness drops. The same holds for it as a decision somebody made, and honors it. So a heading with nothing real to say is deleted rather than filled, and `scc` requires no section in a design at all. +## The spec records where it is being built + +A branch was the one part of this methodology that left no trace in the artifacts. +The spec said which boxes were ticked, git said a branch had been unmerged for three +weeks, and nothing joined the two — so *which of these actually shipped* was +answerable only by somebody holding both halves. Under `autonomy: auto` that is +nobody. + +Three keys sit on `requirements.md` beside the kickoff answers — `branch:`, `pr:` and +`delivery: in-progress | in-review | merged | abandoned` — with the vocabulary closed +for the reason a task's flags are, and graded by the validator only when present, so +every spec written before them keeps passing. + +**`scc spec track` records what the caller knows; `scc spec sync` derives what git +knows.** `--here` takes the branch from the checkout and `--pr ` the pull request; +`sync` walks every spec, asks git and, where it is installed, `gh`, and writes the +answer back under the same verify-and-roll-back contract as `scc patch`. **Neither +guesses**: a deleted branch with no PR to ask about is reported undetermined and left +alone, because merged and abandoned are indistinguishable once the ref is gone. + +Two things were wrong in the first cut and are worth keeping wrong-proof. **Merged is +not "is an ancestor of the base"** — a branch created ten seconds ago satisfies that +trivially, and the first run declared a spec delivered before a line of it existed. It +is *ahead == 0 and behind > 0*, and the fast-forward case no ref can resolve is called +**not** merged, because this record exists to surface unfinished work. And **a settled +record is not re-litigated**: a deleted branch on a spec already `merged` is what a +merged branch looks like, and warning about it would put a line on every finished spec +forever. + +Plans are deliberately out for now, for a naming reason rather than a principle: `pr:` +on a plan already means the delivery *shape* asked for at kickoff, so one key would +carry two meanings on one file. Plan tracking starts by renaming that answer. + ## A spec meets existing code as a delta Changing an existing spec means amending individual requirements — `(ADDED)`, diff --git a/plans/claude-md-dedup.md b/plans/claude-md-dedup.md new file mode 100644 index 0000000..edd842d --- /dev/null +++ b/plans/claude-md-dedup.md @@ -0,0 +1,59 @@ +--- +autonomy: auto +ci: wait +--- + +# CLAUDE.md back to an entry file + +`CLAUDE.md` is 96.8KB and preloaded into every request. Almost all of it is a third +copy of what `.claude/rules/` and `docs/` already own, and one part of it contradicts +`rules/project.md` outright. This moves each concern to the one place that owns it and +leaves the entry file as the template `scc` itself ships. + +## Why + +The binary's own entry template says the methodology lives in `.claude/rules/` and is +never inlined, and `TestScaffoldedEntryFileStaysShort` holds every workspace `scc` +scaffolds to 60 lines. This workspace breaks the contract its own product enforces: +96.8KB standing cost, `## Commands` telling a session to run `make check` while +`rules/project.md` says `make` is absent on Windows, and `## CodeGraph` restating a +rule that is preloaded three files away. Done means `CLAUDE.md` is the rendered entry +template plus the two spliced usage blocks, nothing load-bearing was lost on the way +out, and `scc validate` is clean. + +## Paths + +- `CLAUDE.md` +- `docs/wiki/pages/` · `docs/wiki/index.md` · `docs/wiki/changelog.md` +- `docs/codewiki/` · `docs/adr/` + +## Out of scope + +- `.claude/rules/*` — the rule set is the product's, and it already owns what it owns. +- `design/` — the historical design documents stay where they are. +- `rules/project.md` — it is at its 55-line cap and already carries the real commands. + +## Tasks + +- [x] 1.1 (Unit) Fold the gate's detail into `docs/wiki/pages/delivery-gate.md` — `min_coverage` and its 86% default, `skipped` as a word rather than an absent key, the four-gate order and why it stops at the first failure, and `total` guarding a suite that does not exist +- [x] 1.2 (Unit) Fold `ScopedRules` into `docs/wiki/pages/scoped-rules.md` — the measured 47KB, which three rules carry a `paths:` header, and the refusals (`artifacts.md`, `prior-art.md`, `caveman.md`) +- [x] 1.3 (Unit) Fold the sandbox and the Windows backend into `docs/wiki/pages/integration-boundary.md` — `jailToolchain` and why `Compose` orders directories first, the container as the weaker boundary, and the RTK-versus-CodeGraph marker decision +- [x] 1.4 (Unit) Fold `CarryOver` and the manifest's `check` object into `docs/wiki/pages/managed-files.md` — the manifest as the first input rather than only a record +- [x] 1.5 (Unit) Fold the reading surface into `docs/wiki/pages/artifact-addressing.md` — the closed plan shape, `--next` printing whole while listings clip, and BM25 over regions instead of a search engine +- [x] 2.1 (Unit) Write `docs/wiki/pages/release.md` — the immutable version, idempotent publishing, the three files a platform touches, and the two launcher tiers + _Depends 1.1_ +- [x] 2.2 (Unit) Write `docs/codewiki/packages.md` — the package map from `## Architecture`, every section citing the lines it explains + _Depends 2.1_ +- [x] 2.3 (Unit) Write the ADRs for the decisions that are hard to reverse and cite no page — stdlib-only, one manifest and no config file, the exit-code contract, sharing RTK's markers, the sandbox on by default, and what was narrowed out of `csdd` + _Depends 2.2_ +- [x] 3.1 (Unit) Replace `CLAUDE.md` with the rendered entry template plus the RTK and CodeGraph blocks + _Depends 2.3_ +- [x] 3.2 (Unit) Relink `docs/wiki/index.md` and log `docs/wiki/changelog.md` + _Depends 3.1_ + +## Done when + +- `CLAUDE.md` is under 6KB and carries no heading the entry template does not. +- `scc validate` exits 0 — no orphan page, no broken wikilink, no unresolved codewiki citation, no ADR numbering gap. +- `scc check` is green. +- Every topic listed in the tasks above resolves to exactly one file under `docs/`. From 36495447bffe954c2ea356dfa202f754a813fd80 Mon Sep 17 00:00:00 2001 From: prode Date: Sat, 19 Sep 2026 19:36:23 -0300 Subject: [PATCH 2/3] feat(assets): run both review agents at medium effort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reviewers ran at the maximum, and the budget is paid twice on every pull request by design: delivery.md dispatches both on every diff, not on the ones that look risky. A gate expensive enough to skip is a gate that gets skipped, and skipping one of these takes the other with it — so the point to sit at is where the chains-of-inference work still happens, not where the number is highest. One constant feeds all three harnesses: `effort:` on Claude Code, `model_reasoning_effort` on Codex, and nothing on opencode, which expresses no budget. assets.Version 35 -> 36, and the workspace's own two agent files re-scaffolded onto it. --- .claude/agents/scc-code-review.md | 2 +- .claude/agents/scc-security-review.md | 2 +- .claude/scc-manifest.json | 52 +++++++++++++-------------- internal/assets/assets.go | 10 ++++-- internal/assets/assets_test.go | 8 ++--- 5 files changed, 40 insertions(+), 34 deletions(-) diff --git a/.claude/agents/scc-code-review.md b/.claude/agents/scc-code-review.md index ecf7bb2..0249b1d 100644 --- a/.claude/agents/scc-code-review.md +++ b/.claude/agents/scc-code-review.md @@ -3,7 +3,7 @@ name: scc-code-review description: Reviews a diff for correctness and quality against the task list, re-runs the feature's tests and lint, and reports. Use it after the last task of a spec or plan is verified and before opening the PR — never on your own work as the author. tools: Read, Grep, Glob, Bash model: sonnet -effort: high +effort: medium --- You review a diff. You do not write code and you do not fix what you find — you diff --git a/.claude/agents/scc-security-review.md b/.claude/agents/scc-security-review.md index 67e24b3..76eb1cf 100644 --- a/.claude/agents/scc-security-review.md +++ b/.claude/agents/scc-security-review.md @@ -3,7 +3,7 @@ name: scc-security-review description: Reviews a diff for exploitable weaknesses, attack-class agnostic — traces attacker-controlled input to effect and reports reachable paths. Use it alongside scc-code-review before opening a PR — the two are deliberately separate lenses. tools: Read, Grep, Glob, Bash model: sonnet -effort: high +effort: medium --- You review a diff for security defects and nothing else. You do not write code and you diff --git a/.claude/scc-manifest.json b/.claude/scc-manifest.json index 75a5780..090ee6e 100644 --- a/.claude/scc-manifest.json +++ b/.claude/scc-manifest.json @@ -8,64 +8,64 @@ "codegraph": [], "files": [ { - "hash": "08141d3f57fbe10f46ea557de9969fe06f96e4aceca90cd14079aea0162e4a73", + "hash": "cd2805bd4090a42c7d9923e614eb3db336802e61b75835f97b0cd1e8091264d2", "path": ".claude/agents/scc-code-review.md", - "version": "35" + "version": "36" }, { - "hash": "86ae07c5864d0c38932cf9781e5b0ac5d6f6e36925577efdef646e779abbe65b", + "hash": "334d1ef443337d7d6b3ae1fe689653bb4c60d17860b9fcba4bc0f222969a0fac", "path": ".claude/agents/scc-security-review.md", - "version": "35" + "version": "36" }, { "hash": "f3132fd6be0b859b5ffb063e22ba3b840226c5bfdde2613cd21993ff12afa477", "path": ".claude/rules/artifacts.md", - "version": "35" + "version": "36" }, { "hash": "2eb97e13f6d088379c51870ff7338ddd36d3e377486408d5a2a2d236fe05a1e1", "path": ".claude/rules/autonomy.md", - "version": "35" + "version": "36" }, { "hash": "8eee2d450faada83295a4a04e5a1267bf5f717df9e7ac7da5fddd7649c666499", "path": ".claude/rules/caveman.md", - "version": "35" + "version": "36" }, { "hash": "cfe46c6623c20b6da46c9f18ef9b35e0f554c6c4568791ff190648dda3524706", "path": ".claude/rules/code-search.md", - "version": "35" + "version": "36" }, { "hash": "3bb9d1336dd9478c76f1957167bbfddba5aa5adf97908b0791caaf75e96c446f", "path": ".claude/rules/delivery.md", - "version": "35" + "version": "36" }, { "hash": "ffca716ea25746d8e100ba3a7a7b884907736adebf3a7131b989fa4e9fcd0d80", "path": ".claude/rules/knowledge-base.md", - "version": "35" + "version": "36" }, { "hash": "59bb916a2002d4176353463b017c366868192cf95c80fd1dce74265188b738c3", "path": ".claude/rules/ladder.md", - "version": "35" + "version": "36" }, { "hash": "79d5cbf1ab7fd09d26e9ea9119f3f283615467dbc35d3a4b1d4a030044b3598f", "path": ".claude/rules/methodology.md", - "version": "35" + "version": "36" }, { "hash": "7dcbf5cd9d2e9e8d08aed4eeaa96ee28af85da1d6de51fc35870b8430e30f331", "path": ".claude/rules/notes.md", - "version": "35" + "version": "36" }, { "hash": "52c5033fc427445b4e669c7865dbd8d5c2fc27d57f7200aeeb2cc48bc8e050d3", "path": ".claude/rules/prior-art.md", - "version": "35" + "version": "36" }, { "hash": "ae0030df2e1f053642a84953648e1d6038b0f0fbae831edf1c349badfb00485c", @@ -75,62 +75,62 @@ { "hash": "c8502ace5978c7b9c0543c49ebb5c9550584b1a3405e12c0217b595647e27722", "path": ".claude/rules/routing.md", - "version": "35" + "version": "36" }, { "hash": "0b86b1a73769c6ebd37c338c5ec3cbee8dda70b21d43b27a1c95eebc5ee70a9f", "path": ".claude/rules/specs.md", - "version": "35" + "version": "36" }, { "hash": "d64a118d493424b10ad8af24861083c34541f7946a92dae92fb153b0ab739c26", "path": ".claude/rules/tasks.md", - "version": "35" + "version": "36" }, { "hash": "403174a8133018b54adc69c3047c46f8ff603b8a91759c62da1e7ca6b7f45463", "path": ".claude/rules/verification.md", - "version": "35" + "version": "36" }, { "hash": "cce99457f203766837ec2b8165a9370924178d9ee377c7848dba65880461cf3f", "path": ".claude/skills/scc-adr/SKILL.md", - "version": "35" + "version": "36" }, { "hash": "fc2ecb43e7e3c157082dc359d1d0fb20c99fdb6fb774cfcead3c8c0961fef922", "path": ".claude/skills/scc-codewiki/SKILL.md", - "version": "35" + "version": "36" }, { "hash": "60e80dcee7e68498679735ac40812ac90641340fef6bd75d3e7de64d209f8716", "path": ".claude/skills/scc-glossary/SKILL.md", - "version": "35" + "version": "36" }, { "hash": "9d21e953cce950ccf495810c94411a98706780b2b8d38817e3ce13bfc0c74625", "path": ".claude/skills/scc-init/SKILL.md", - "version": "35" + "version": "36" }, { "hash": "2385f307f96cc4261e44d1a0d49bb39d6a5842a3205cee317e3228ced0f6aeb6", "path": ".claude/skills/scc-plan-run/SKILL.md", - "version": "35" + "version": "36" }, { "hash": "3d3773f869d73beeab4c2061397c9ad81faf6d4c11d1ef423470aca432c5a9e1", "path": ".claude/skills/scc-prd/SKILL.md", - "version": "35" + "version": "36" }, { "hash": "42dff0d55f15c60da604f62fc1dfe8af5b73de2e2497553411447aae57698e1b", "path": ".claude/skills/scc-stack/SKILL.md", - "version": "35" + "version": "36" }, { "hash": "aa53b7ddf701ea495d68ea12aa23a6d352c202edc0e308b7218a1bf685b09f21", "path": ".claude/skills/scc-wiki/SKILL.md", - "version": "35" + "version": "36" }, { "hash": "9dc2f234756bfdc79aa5697e8470f80766e68a0e72b6cda98107e25ea8a9427a", diff --git a/internal/assets/assets.go b/internal/assets/assets.go index a7a2617..6bf7b86 100644 --- a/internal/assets/assets.go +++ b/internal/assets/assets.go @@ -283,7 +283,7 @@ import ( // `/home/node/.claude` is created and chowned in the image: without it the first // write of every session was `EACCES ... mkdir '/home/node/.claude/session-env'` and // the login it could not store read back as "not logged in". -const Version = "35" +const Version = "36" // The embedded tree. "all:" so nothing is silently dropped for having a name the // default embed pattern skips. @@ -877,7 +877,13 @@ func splitMeta(name, raw string) (meta, error) { // (Claude Code's "sonnet"), because a pinned `gpt-5.6` or `anthropic/claude-x` // would be a guess about a name that churns and a provider the user may not have // configured. -const reviewEffort = "high" +// +// It is medium rather than the maximum because the budget is paid twice on every +// pull request by design: delivery.md dispatches both reviewers on every diff, not +// on the ones that look risky, and a gate expensive enough to skip is a gate that +// gets skipped — taking the other one with it. Medium is the point where the +// inference work still happens and the recurrence stays proportionate. +const reviewEffort = "medium" func renderAgent(h paths.Harness, f File, raw string) (string, error) { m, err := splitMeta(f.Name, raw) diff --git a/internal/assets/assets_test.go b/internal/assets/assets_test.go index c5f386b..8d61464 100644 --- a/internal/assets/assets_test.go +++ b/internal/assets/assets_test.go @@ -415,7 +415,7 @@ func TestReviewAgentsCarryTheHeaderTheirHarnessParses(t *testing.T) { for _, want := range []string{ "name = \"" + name + "\"\n", "description = \"", - "model_reasoning_effort = \"high\"\n", + "model_reasoning_effort = \"medium\"\n", "developer_instructions = '''\n", } { if !strings.Contains(out, want) { @@ -443,8 +443,8 @@ func TestReviewAgentsCarryTheHeaderTheirHarnessParses(t *testing.T) { // provider-prefixed model would name something the user may not have configured. func TestReviewAgentsPinTheirEffort(t *testing.T) { want := map[string][]string{ - paths.Claude.ID: {"\nmodel: sonnet\n", "\neffort: high\n"}, - paths.Codex.ID: {"model_reasoning_effort = \"high\"\n"}, + paths.Claude.ID: {"\nmodel: sonnet\n", "\neffort: medium\n"}, + paths.Codex.ID: {"model_reasoning_effort = \"medium\"\n"}, paths.OpenCode.ID: {"\nmode: subagent\n", "\n edit: deny\n"}, } for _, h := range paths.Harnesses() { @@ -1075,7 +1075,7 @@ func (e errNoSuchRule) Error() string { // uses wherever a change is cheap to make and expensive to make by accident. func TestTheTemplateVersionMovesWithTheTemplates(t *testing.T) { // Bump Version, then replace this with the digest the failure prints. - const fingerprint = "ca004345d7bd327f81c7361b2ceca205d8e2e1609e4b527118b1e1a7e9c65a05" + const fingerprint = "a5612f4b643d1fe01dd406ff1194d1c7a51735f74e09b9d1eca6a23572a14218" sum := sha256.New() // Version goes into the hash, and without it this test does not do the job its From 22602f8badde3f761a3eda2b79f2405b24ad578a Mon Sep 17 00:00:00 2001 From: prode Date: Sat, 19 Sep 2026 19:50:36 -0300 Subject: [PATCH 3/3] fix(assets): let each review agent declare its own effort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The security reviewer reviewed the commit that lowered its own reasoning budget and asked, at medium severity, not to: effort is not verbosity there, it is the resource the job runs on — a traced path from attacker-controlled input to effect, with no checklist to fall back on. It also pointed out the change had nothing behind it, which was true. The cost argument that lowered both is about dispatching *both* on every PR at the maximum, and it does not divide equally between them. So the budget moves out of one shared constant and into each agent's own template: scc-code-review at medium, where the job is the closest thing here to checklist-shaped, and scc-security-review back at high. It is required rather than defaulted, for the reason a declined delivery gate is the word `skipped` rather than an absent key — a third reviewer added without one fails to render instead of inheriting a number nobody chose. TestReviewAgentsPinTheirEffort fails on a reviewer it has no entry for, so the table cannot silently fall behind ReviewAgents. assets.Version 36 -> 37. Also from the reviews: - docs/wiki/pages/managed-files.md gains the Headroom counter-case. The old CLAUDE.md named it and nothing else did: `rtk.ForeignBlock` detects a `headroom:rtk-instructions` block, reports it, and names `headroom unwrap ` — reported and never touched, because the block belongs to Headroom. - .claude/skills/gauntlet-loop/SKILL.md told the agent twice to check four invariants in CLAUDE.md. This branch is what moved all four; the pointers now name where each one actually lives. - Four codewiki citations did not evidence the prose attached to them — `os.SameFile` is not in cli_test.go, `decode` sits outside the cited range, `command` outside the hooks range, and an ears.go range carried a claim about mdscan. scc validate only checks that a range resolves. - design/orchestration.md said both agents pin effort: high. - The plan's task 2.3 asked for six ADRs and two were written; it now says what was decided, which is that the other four already resolve to a wiki page and a second copy is the duplication this work removes. Verified: scc validate --checks — 11 validators, 0 findings, 1002 tests, 86.2% coverage; golangci-lint run 0 issues. --- .claude/agents/scc-security-review.md | 2 +- .claude/scc-manifest.json | 50 +++++++++---------- .claude/skills/gauntlet-loop/SKILL.md | 9 ++-- design/orchestration.md | 3 +- docs/codewiki/packages.md | 41 +++++++++------ docs/wiki/changelog.md | 4 +- docs/wiki/pages/managed-files.md | 8 +++ internal/assets/assets.go | 48 ++++++++++++------ internal/assets/assets_test.go | 41 +++++++++++---- .../templates/agents/scc-code-review.md | 1 + .../templates/agents/scc-security-review.md | 1 + plans/claude-md-dedup.md | 6 ++- 12 files changed, 141 insertions(+), 73 deletions(-) diff --git a/.claude/agents/scc-security-review.md b/.claude/agents/scc-security-review.md index 76eb1cf..67e24b3 100644 --- a/.claude/agents/scc-security-review.md +++ b/.claude/agents/scc-security-review.md @@ -3,7 +3,7 @@ name: scc-security-review description: Reviews a diff for exploitable weaknesses, attack-class agnostic — traces attacker-controlled input to effect and reports reachable paths. Use it alongside scc-code-review before opening a PR — the two are deliberately separate lenses. tools: Read, Grep, Glob, Bash model: sonnet -effort: medium +effort: high --- You review a diff for security defects and nothing else. You do not write code and you diff --git a/.claude/scc-manifest.json b/.claude/scc-manifest.json index 090ee6e..f6bf5fd 100644 --- a/.claude/scc-manifest.json +++ b/.claude/scc-manifest.json @@ -10,62 +10,62 @@ { "hash": "cd2805bd4090a42c7d9923e614eb3db336802e61b75835f97b0cd1e8091264d2", "path": ".claude/agents/scc-code-review.md", - "version": "36" + "version": "37" }, { - "hash": "334d1ef443337d7d6b3ae1fe689653bb4c60d17860b9fcba4bc0f222969a0fac", + "hash": "86ae07c5864d0c38932cf9781e5b0ac5d6f6e36925577efdef646e779abbe65b", "path": ".claude/agents/scc-security-review.md", - "version": "36" + "version": "37" }, { "hash": "f3132fd6be0b859b5ffb063e22ba3b840226c5bfdde2613cd21993ff12afa477", "path": ".claude/rules/artifacts.md", - "version": "36" + "version": "37" }, { "hash": "2eb97e13f6d088379c51870ff7338ddd36d3e377486408d5a2a2d236fe05a1e1", "path": ".claude/rules/autonomy.md", - "version": "36" + "version": "37" }, { "hash": "8eee2d450faada83295a4a04e5a1267bf5f717df9e7ac7da5fddd7649c666499", "path": ".claude/rules/caveman.md", - "version": "36" + "version": "37" }, { "hash": "cfe46c6623c20b6da46c9f18ef9b35e0f554c6c4568791ff190648dda3524706", "path": ".claude/rules/code-search.md", - "version": "36" + "version": "37" }, { "hash": "3bb9d1336dd9478c76f1957167bbfddba5aa5adf97908b0791caaf75e96c446f", "path": ".claude/rules/delivery.md", - "version": "36" + "version": "37" }, { "hash": "ffca716ea25746d8e100ba3a7a7b884907736adebf3a7131b989fa4e9fcd0d80", "path": ".claude/rules/knowledge-base.md", - "version": "36" + "version": "37" }, { "hash": "59bb916a2002d4176353463b017c366868192cf95c80fd1dce74265188b738c3", "path": ".claude/rules/ladder.md", - "version": "36" + "version": "37" }, { "hash": "79d5cbf1ab7fd09d26e9ea9119f3f283615467dbc35d3a4b1d4a030044b3598f", "path": ".claude/rules/methodology.md", - "version": "36" + "version": "37" }, { "hash": "7dcbf5cd9d2e9e8d08aed4eeaa96ee28af85da1d6de51fc35870b8430e30f331", "path": ".claude/rules/notes.md", - "version": "36" + "version": "37" }, { "hash": "52c5033fc427445b4e669c7865dbd8d5c2fc27d57f7200aeeb2cc48bc8e050d3", "path": ".claude/rules/prior-art.md", - "version": "36" + "version": "37" }, { "hash": "ae0030df2e1f053642a84953648e1d6038b0f0fbae831edf1c349badfb00485c", @@ -75,62 +75,62 @@ { "hash": "c8502ace5978c7b9c0543c49ebb5c9550584b1a3405e12c0217b595647e27722", "path": ".claude/rules/routing.md", - "version": "36" + "version": "37" }, { "hash": "0b86b1a73769c6ebd37c338c5ec3cbee8dda70b21d43b27a1c95eebc5ee70a9f", "path": ".claude/rules/specs.md", - "version": "36" + "version": "37" }, { "hash": "d64a118d493424b10ad8af24861083c34541f7946a92dae92fb153b0ab739c26", "path": ".claude/rules/tasks.md", - "version": "36" + "version": "37" }, { "hash": "403174a8133018b54adc69c3047c46f8ff603b8a91759c62da1e7ca6b7f45463", "path": ".claude/rules/verification.md", - "version": "36" + "version": "37" }, { "hash": "cce99457f203766837ec2b8165a9370924178d9ee377c7848dba65880461cf3f", "path": ".claude/skills/scc-adr/SKILL.md", - "version": "36" + "version": "37" }, { "hash": "fc2ecb43e7e3c157082dc359d1d0fb20c99fdb6fb774cfcead3c8c0961fef922", "path": ".claude/skills/scc-codewiki/SKILL.md", - "version": "36" + "version": "37" }, { "hash": "60e80dcee7e68498679735ac40812ac90641340fef6bd75d3e7de64d209f8716", "path": ".claude/skills/scc-glossary/SKILL.md", - "version": "36" + "version": "37" }, { "hash": "9d21e953cce950ccf495810c94411a98706780b2b8d38817e3ce13bfc0c74625", "path": ".claude/skills/scc-init/SKILL.md", - "version": "36" + "version": "37" }, { "hash": "2385f307f96cc4261e44d1a0d49bb39d6a5842a3205cee317e3228ced0f6aeb6", "path": ".claude/skills/scc-plan-run/SKILL.md", - "version": "36" + "version": "37" }, { "hash": "3d3773f869d73beeab4c2061397c9ad81faf6d4c11d1ef423470aca432c5a9e1", "path": ".claude/skills/scc-prd/SKILL.md", - "version": "36" + "version": "37" }, { "hash": "42dff0d55f15c60da604f62fc1dfe8af5b73de2e2497553411447aae57698e1b", "path": ".claude/skills/scc-stack/SKILL.md", - "version": "36" + "version": "37" }, { "hash": "aa53b7ddf701ea495d68ea12aa23a6d352c202edc0e308b7218a1bf685b09f21", "path": ".claude/skills/scc-wiki/SKILL.md", - "version": "36" + "version": "37" }, { "hash": "9dc2f234756bfdc79aa5697e8470f80766e68a0e72b6cda98107e25ea8a9427a", diff --git a/.claude/skills/gauntlet-loop/SKILL.md b/.claude/skills/gauntlet-loop/SKILL.md index 8fab86e..7527e9f 100644 --- a/.claude/skills/gauntlet-loop/SKILL.md +++ b/.claude/skills/gauntlet-loop/SKILL.md @@ -110,9 +110,10 @@ which session produced better work, and names the single biggest remaining gap. goes back to the builder. Score both halves: [STANDING COST] and whether the ticket ended with [EFFECT]. A change -that cuts the cost and loses the effect has failed. Check the invariants in CLAUDE.md -first — exit codes, stdlib-only, make check green, no validator firing on scc's own output -— a builder that wins by breaking one has lost. +that cuts the cost and loses the effect has failed. Check the invariants first — exit +codes and the boundaries in `.claude/rules/project.md`, stdlib-only in +`docs/adr/0001-stdlib-only-dependencies.md`, a green `scc check`, and no validator firing +on scc's own output (`docs/wiki/pages/validation-contract.md`) — a builder that wins by breaking one has lost. The critic should be a harsh critic. Praise is not useful. If the arm carrying the change does not win blind, it keeps going. @@ -171,7 +172,7 @@ session reached for the smaller thing that worked, and names the single biggest gap. Score both halves: bytes preloaded, and whether the ticket ended with scc validate clean, -boxes ticked and requirements cited. Check the invariants in CLAUDE.md first. +boxes ticked and requirements cited. Check the invariants in `.claude/rules/project.md` first. The critic should be a harsh critic. Praise is not useful. diff --git a/design/orchestration.md b/design/orchestration.md index 2edca07..4d6eb3b 100644 --- a/design/orchestration.md +++ b/design/orchestration.md @@ -701,7 +701,8 @@ change is its worst reader — they see what they meant. And a reviewer reads in of writes, so running one on a cheaper model costs far less than delegating authorship would. -**Both pin `model: sonnet` and `effort: high`.** The mid tier is the right trade for +**Both pin `model: sonnet`; the effort is per agent — `scc-code-review` at medium, +`scc-security-review` at high.** The mid tier is the right trade for work that reads and judges rather than authors, and the reasoning budget is where the quality actually comes from here: tracing a value from an argument to a shell, or a ticked box to the code behind it, is chains-of-inference work, not knowledge work. A diff --git a/docs/codewiki/packages.md b/docs/codewiki/packages.md index 9878644..931788a 100644 --- a/docs/codewiki/packages.md +++ b/docs/codewiki/packages.md @@ -91,12 +91,18 @@ readily as from the user, so the check belongs here rather than in a handler. One schedule implementation is shared by `--next`, `--ready` and `--blocked`. Two notions of eligibility would be two answers to *what do I work on*. -[internal/ears/ears.go:102-120]() · [internal/mdscan/mdscan.go:1-20]() +[internal/ears/ears.go:102-120]() -`mdscan` is the only Markdown parser, and its `Body` — comments and fences stripped — -is what every validator applies its grammar to. That is also what lets the templates -carry their instructions in HTML comments without tripping the validators they ship -with. +`internal/ears` owns the EARS grammar the requirement validator reports against, the +way `internal/artifact` owns the task grammar — the checker consumes a grammar it +does not define. + +[internal/mdscan/mdscan.go:215-240]() + +`mdscan` is the only Markdown parser, and `stripComments` is why: the body every +validator applies its grammar to has HTML comments and fences taken out, which is +what lets the templates carry their instructions inline without tripping the +validators they ship with. ## The record, and the one thing in it that is an input @@ -152,19 +158,26 @@ the agent has usually written code and not yet committed it. ## Hooks are events, not scripts -[internal/hooks/harness.go:42-60]() +[internal/hooks/harness.go:42-60]() · [internal/hooks/harness.go:127-140]() -The scripts and settings entries are one line and call back into -`scc hooks run `, so every decision is Go — tested, and upgraded with the -binary rather than by rewriting a file in somebody's `.git`. +Three events, and `command` is the whole of what each entry runs: one line calling +back into `scc hooks run `. Every decision is Go — tested, and upgraded with +the binary rather than by rewriting a file in somebody's `.git` or settings. ## Testing conventions -[internal/cli/cli_test.go:1-40]() +[internal/cli/cli_test.go:14-40]() `capture(t, f)` swaps `os.Stdout`/`os.Stderr` for pipes and drains both concurrently, so a command that outruns the pipe buffer fails the test instead of deadlocking — the -only safe way to assert on output, since `render` writes to the real files. Compare -resolved paths with `os.SameFile` rather than string equality: `t.TempDir()` can sit -under a symlink and Windows reports 8.3 short names. A test may not assert on the -*spelling* of a `--json` document; `decode` unmarshals it. +only safe way to assert on output, since `render` writes to the real files. + +[internal/cli/cli_test.go:86-100]() + +A test may not assert on the *spelling* of a `--json` document, because that is an +assertion about the reader rather than about the command: `decode` unmarshals it. + +[internal/workspace/workspace_test.go:285-295]() + +Resolved paths are compared with `os.SameFile` rather than string equality — +`t.TempDir()` can sit under a symlink and Windows reports 8.3 short names. diff --git a/docs/wiki/changelog.md b/docs/wiki/changelog.md index ffd3c9c..fbdd8f2 100644 --- a/docs/wiki/changelog.md +++ b/docs/wiki/changelog.md @@ -13,7 +13,9 @@ change rather than left pointing at the old slug. Windows dev container, one graph per scoped tree), [[managed-files]] (`CarryOver`), [[artifact-addressing]] (the reading surface, BM25 over regions, the seal) and [[spec-or-plan]] (the spec's delivery record); added [[note-log]] and [[release]], - and `docs/codewiki/packages.md` and the first two ADRs. + and `docs/codewiki/packages.md` and the first two ADRs. Review follow-up: named + Headroom's foreign RTK block in [[managed-files]], and corrected four codewiki + citations to the lines that actually evidence them. - 2026-09-15 — first pass, reconstructed from `CLAUDE.md`, `.claude/rules/`, `design/` and the tree itself: added [[context-budget]], [[scoped-rules]], [[artifact-addressing]], [[spec-or-plan]], [[harness-profile]], [[managed-files]], diff --git a/docs/wiki/pages/managed-files.md b/docs/wiki/pages/managed-files.md index bb19680..16ef8ff 100644 --- a/docs/wiki/pages/managed-files.md +++ b/docs/wiki/pages/managed-files.md @@ -57,6 +57,14 @@ That choice cuts both ways, deliberately: entry file and the block is `scc`'s account of `scc graph`. Namespacing leaves a future CodeGraph release free to add its own. +**A block somebody else wrote for the same job is reported, never touched.** +Headroom namespaces its copy of the RTK guidance as `headroom:rtk-instructions`, +which is not a substring of RTK's own pair — so a workspace wired by both ends up +carrying the instructions twice. `rtk.ForeignBlock` detects exactly that, names +Headroom in the report, and gives `headroom unwrap ` as the fix. It is +reported rather than removed because the block belongs to Headroom: `scc` removing +it would be authoring somebody else's file to undo somebody else's registration. + The same reasoning governs `settings.json`, which is spliced rather than authored: `scc`'s entries are identified by their command, replaced in place, and every other entry, event and unknown key survives. **A file `scc` cannot parse is one `scc` will diff --git a/internal/assets/assets.go b/internal/assets/assets.go index 6bf7b86..3cadde2 100644 --- a/internal/assets/assets.go +++ b/internal/assets/assets.go @@ -283,7 +283,7 @@ import ( // `/home/node/.claude` is created and chowned in the image: without it the first // write of every session was `EACCES ... mkdir '/home/node/.claude/session-env'` and // the login it could not store read back as "not logged in". -const Version = "36" +const Version = "37" // The embedded tree. "all:" so nothing is silently dropped for having a name the // default embed pattern skips. @@ -831,6 +831,7 @@ func expand(name, raw string, l layout) (string, error) { type meta struct { name string description string + effort string extra map[string]string // keys only some harnesses understand body string } @@ -858,6 +859,8 @@ func splitMeta(name, raw string) (meta, error) { switch key { case "name": m.name = value + case "effort": + m.effort = value case "description": m.description = value default: @@ -870,21 +873,33 @@ func splitMeta(name, raw string) (meta, error) { return m, nil } -// The reasoning budget both reviewers run on. Review is chains-of-inference work -// — tracing a value from an argument to a shell, or a ticked box to the code -// behind it — and that is what effort buys. Every harness that expresses it gets -// it; the model tier is pinned only where the harness has a stable alias for one +// A review agent declares its own reasoning budget, in its own template, and the +// two do not share one. +// +// Review is chains-of-inference work — tracing a value from an argument to a +// shell, or a ticked box to the code behind it — and that is what effort buys, so +// the question is per agent rather than per product. `scc-code-review` runs at +// medium: its job is the closest thing here to checklist-shaped, against a task +// list, a test run and a linter. `scc-security-review` runs at high, because it +// has no checklist to fall back on — it is asked for a traced path from +// attacker-controlled input to effect, which is the case where the budget is the +// product rather than a multiplier on it. +// +// The cost argument is real and it is what lowered the first of those: delivery.md +// dispatches both on every diff, not on the ones that look risky, and a gate +// expensive enough to skip is a gate that gets skipped — taking the other one with +// it. But that argument is about running *both* at the maximum, and it does not +// divide equally between them. +// +// It is required rather than defaulted, for the reason a declined delivery gate is +// the word `skipped` rather than an absent key: a budget nobody chose is not the +// same as a budget somebody chose and wrote down. A third reviewer added without +// one fails to render rather than inheriting a number by accident. +// +// The model tier is pinned only where the harness has a stable alias for one // (Claude Code's "sonnet"), because a pinned `gpt-5.6` or `anthropic/claude-x` // would be a guess about a name that churns and a provider the user may not have // configured. -// -// It is medium rather than the maximum because the budget is paid twice on every -// pull request by design: delivery.md dispatches both reviewers on every diff, not -// on the ones that look risky, and a gate expensive enough to skip is a gate that -// gets skipped — taking the other one with it. Medium is the point where the -// inference work still happens and the recurrence stays proportionate. -const reviewEffort = "medium" - func renderAgent(h paths.Harness, f File, raw string) (string, error) { m, err := splitMeta(f.Name, raw) if err != nil { @@ -893,6 +908,9 @@ func renderAgent(h paths.Harness, f File, raw string) (string, error) { if m.name == "" { return "", fmt.Errorf("template %q: an agent needs a name", f.Name) } + if m.effort == "" { + return "", fmt.Errorf("template %q: an agent needs an effort", f.Name) + } switch h.ID { case paths.Codex.ID: // A Codex agent role file: flat TOML, the prose in developer_instructions. @@ -901,7 +919,7 @@ func renderAgent(h paths.Harness, f File, raw string) (string, error) { var b strings.Builder fmt.Fprintf(&b, "name = %q\n", m.name) fmt.Fprintf(&b, "description = %q\n", m.description) - fmt.Fprintf(&b, "model_reasoning_effort = %q\n", reviewEffort) + fmt.Fprintf(&b, "model_reasoning_effort = %q\n", m.effort) fmt.Fprintf(&b, "developer_instructions = '''\n%s'''\n", m.body) return b.String(), nil case paths.OpenCode.ID: @@ -925,7 +943,7 @@ func renderAgent(h paths.Harness, f File, raw string) (string, error) { fmt.Fprintf(&b, "description: %s\n", m.description) b.WriteString("tools: Read, Grep, Glob, Bash\n") b.WriteString("model: sonnet\n") - fmt.Fprintf(&b, "effort: %s\n", reviewEffort) + fmt.Fprintf(&b, "effort: %s\n", m.effort) b.WriteString("---\n\n") b.WriteString(m.body) return b.String(), nil diff --git a/internal/assets/assets_test.go b/internal/assets/assets_test.go index 8d61464..e645869 100644 --- a/internal/assets/assets_test.go +++ b/internal/assets/assets_test.go @@ -415,7 +415,7 @@ func TestReviewAgentsCarryTheHeaderTheirHarnessParses(t *testing.T) { for _, want := range []string{ "name = \"" + name + "\"\n", "description = \"", - "model_reasoning_effort = \"medium\"\n", + "model_reasoning_effort = \"", "developer_instructions = '''\n", } { if !strings.Contains(out, want) { @@ -437,24 +437,43 @@ func TestReviewAgentsCarryTheHeaderTheirHarnessParses(t *testing.T) { } } -// The reasoning budget is pinned wherever the harness expresses one, because -// review is chains-of-inference work and that is what effort buys. The model tier -// is pinned only where the harness has a stable alias for one: a hardcoded -// provider-prefixed model would name something the user may not have configured. +// The reasoning budget is pinned per agent wherever the harness expresses one, +// because review is chains-of-inference work and that is what effort buys — and +// the two reviewers do not need the same amount of it. The code reviewer's job is +// the closest thing here to checklist-shaped; the security reviewer is asked for a +// traced path with no checklist to fall back on. The model tier is pinned only +// where the harness has a stable alias for one: a hardcoded provider-prefixed model +// would name something the user may not have configured. func TestReviewAgentsPinTheirEffort(t *testing.T) { - want := map[string][]string{ - paths.Claude.ID: {"\nmodel: sonnet\n", "\neffort: medium\n"}, - paths.Codex.ID: {"model_reasoning_effort = \"medium\"\n"}, - paths.OpenCode.ID: {"\nmode: subagent\n", "\n edit: deny\n"}, + effort := map[string]string{ + "scc-code-review": "medium", + "scc-security-review": "high", } for _, h := range paths.Harnesses() { for _, name := range ReviewAgents { + // A reviewer this table has never heard of is the failure worth catching: + // the renderer refuses a template with no effort, so the way a new agent + // gets one by accident is somebody copying a template and this test not + // noticing which budget it inherited. + e, ok := effort[name] + if !ok { + t.Fatalf("%s: no effort recorded for it here; decide one rather than letting it inherit", name) + } + var want []string + switch h.ID { + case paths.Claude.ID: + want = []string{"\nmodel: sonnet\n", "\neffort: " + e + "\n"} + case paths.Codex.ID: + want = []string{"model_reasoning_effort = \"" + e + "\"\n"} + default: + want = []string{"\nmode: subagent\n", "\n edit: deny\n"} + } f := findFile(t, h, h.Dir+"/"+h.AgentsSeg+"/"+name+h.AgentExt()) out, err := Render(h, f) if err != nil { t.Fatalf("%s %s: %v", h.ID, name, err) } - for _, w := range want[h.ID] { + for _, w := range want { if !strings.Contains(out, w) { t.Errorf("%s %s: missing %q", h.ID, name, strings.TrimSpace(w)) } @@ -1075,7 +1094,7 @@ func (e errNoSuchRule) Error() string { // uses wherever a change is cheap to make and expensive to make by accident. func TestTheTemplateVersionMovesWithTheTemplates(t *testing.T) { // Bump Version, then replace this with the digest the failure prints. - const fingerprint = "a5612f4b643d1fe01dd406ff1194d1c7a51735f74e09b9d1eca6a23572a14218" + const fingerprint = "794099bc0da9eac98407c16a4700f022284bd4c851a07a8bc2f9972c5378221a" sum := sha256.New() // Version goes into the hash, and without it this test does not do the job its diff --git a/internal/assets/templates/agents/scc-code-review.md b/internal/assets/templates/agents/scc-code-review.md index 470de65..645730b 100644 --- a/internal/assets/templates/agents/scc-code-review.md +++ b/internal/assets/templates/agents/scc-code-review.md @@ -1,5 +1,6 @@ --- name: scc-code-review +effort: medium description: Reviews a diff for correctness and quality against the task list, re-runs the feature's tests and lint, and reports. Use it after the last task of a spec or plan is verified and before opening the PR — never on your own work as the author. --- diff --git a/internal/assets/templates/agents/scc-security-review.md b/internal/assets/templates/agents/scc-security-review.md index 4a84829..f1caf18 100644 --- a/internal/assets/templates/agents/scc-security-review.md +++ b/internal/assets/templates/agents/scc-security-review.md @@ -1,5 +1,6 @@ --- name: scc-security-review +effort: high description: Reviews a diff for exploitable weaknesses, attack-class agnostic — traces attacker-controlled input to effect and reports reachable paths. Use it alongside scc-code-review before opening a PR — the two are deliberately separate lenses. --- diff --git a/plans/claude-md-dedup.md b/plans/claude-md-dedup.md index edd842d..ae37515 100644 --- a/plans/claude-md-dedup.md +++ b/plans/claude-md-dedup.md @@ -44,7 +44,11 @@ out, and `scc validate` is clean. _Depends 1.1_ - [x] 2.2 (Unit) Write `docs/codewiki/packages.md` — the package map from `## Architecture`, every section citing the lines it explains _Depends 2.1_ -- [x] 2.3 (Unit) Write the ADRs for the decisions that are hard to reverse and cite no page — stdlib-only, one manifest and no config file, the exit-code contract, sharing RTK's markers, the sandbox on by default, and what was narrowed out of `csdd` +- [x] 2.3 (Unit) Write ADRs for the decisions that are hard to reverse *and* that no + wiki page already records with its rationale — which is two, stdlib-only and the + narrowing from csdd; the rest (the markers, the sandbox default, the exit-code + contract) resolve to a page already, and a second copy is the duplication this + work removes _Depends 2.2_ - [x] 3.1 (Unit) Replace `CLAUDE.md` with the rendered entry template plus the RTK and CodeGraph blocks _Depends 2.3_