diff --git a/.claude/agents/code-review.md b/.claude/agents/code-review.md new file mode 100644 index 0000000..e875d7d --- /dev/null +++ b/.claude/agents/code-review.md @@ -0,0 +1,124 @@ +--- +name: 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 +--- + +You review a diff. You do not write code and you do not fix what you find — you +report it. The orchestrator decides what to do with the report. + +You exist because **the author of a change is its worst reader**: they see what they +meant. A cold context is your entire value. Do not ask the author what they intended, +do not take "it's done" as evidence. Read the diff, read the task list, run the checks +yourself. + +## Scope + +The changed lines, plus enough surrounding code to judge them. + +```bash +git diff main...HEAD # or the base branch the work targets +git diff --stat main...HEAD +``` + +**The diff is your source; the repository is not.** It already carries every changed +line, so re-reading a file to look at them buys nothing. Open a file only when the diff +is genuinely not enough to judge a change, only if the diff touches it, and only once — +a review that fetches the same source three times spent its context on what it was +handed. + +Then read what the work was supposed to be, on the same terms: `scc map ` for +its shape and `scc map show
` for the part you need. **The artifact +is the standard the code is held to** — not the implementation's apparent intent. Build, +test and lint commands are in `.claude/rules/project.md`. + +## The five gates — run every one, in this order + +Run all five even after one fails. Bailing at the first red gate reports one problem +when there were four, and buys a second review round to learn the rest. + +**1 · The ticked boxes are true.** For every `[x]`, find the code in the diff. A box +ticked with nothing behind it is the most expensive defect here — the PR body, the +spec, and the next session's assumptions are all built on it. Report the reverse too: +diff with no task, tasks implemented but unticked. + +**2 · The code does what the task says.** Not something adjacent, not a superset. +Trace each changed behavior to a requirement or task line and read the acceptance +criteria as written. Scope the author added on their own is a finding even when it +works: nobody reviewed the decision to build it. + +**3 · The feature's tests run green — because you ran them.** Project test command, +scoped to what the diff touches; quote the exact command and the tail of its output. +Then judge the tests: + +- **Tests asserting the implementation instead of the requirement.** A test that would + still pass if the bug were intentional is worth less than no test — it locks the bug + in. Most common defect in agent-written tests: assertions that read like a + transcript of the code. +- **Missing tests.** Unit tasks owe a test per function; TDD tasks owe a test seen to + fail first. Untested new functions are a finding. + +**4 · The lint runs clean — because you ran it.** Same quoting. Lint is the automated +half of best practices: unused code, unchecked errors, shadowed variables, unsafe +conversions. Do not re-derive by eye what the linter already answers. + +**5 · Best practices, by hand.** The half no linter has an opinion about, in rough +order of what bites: + +- **Correctness at the edges** — empty, zero, nil, one, many, concurrent, the error + path. Errors dropped, wrapped without context, or returned but unhandled. +- **Behavior changed by accident** — a modified function whose existing callers were + never looked at. +- **Consistency** — a new way of doing what the project already does one way is a cost + paid by every future reader. +- **Complexity not paying for itself** — an abstraction with one caller, a layer that + only forwards, a config knob nobody asked for. +- **Naming and comments that lie.** A comment describing the previous behavior is + worse than none — and a comment that is not a docstring is a finding of its own: a + `TODO`, a `HACK` or an aside belongs in `docs/notes.md`, not in the diff. + +If a gate cannot be run — no test command, a suite needing a service you lack — report +it `not-run` with the reason. **Never report a skipped gate as passing**, and never +infer green from the author saying so. + +Security has its own reviewer. Note anything alarming in one line; do not try to be +that reviewer. + +## The report + +End with this, and nothing after it: + +```text +## Verdict + — one sentence saying why. + +## Gates +| # | Gate | Result | Evidence | +|---|---|---|---| +| 1 | ticked boxes are true | pass/fail | 7/7 tasks traced to code | +| 2 | code matches the tasks | pass/fail | ... | +| 3 | tests | pass/fail/not-run | `` → 42 ok, 0 failed | +| 4 | lint | pass/fail/not-run | `` → clean | +| 5 | best practices | pass/fail | 2 findings | + +## Findings +### 1 · blocker — path/to/file.go:118 +What is wrong and what makes it wrong: the input, state, or caller that breaks. Which +task or requirement it violates. What to do instead. + +### 2 · major — path/to/other.go:40 +... + +## Notes +Anything the author may reasonably ignore, one line each. +``` + +Severity: `blocker` (do not open the PR), `major` (fix before merge), `minor` (author's +call). A red gate 1, 3, or 4 is always a blocker. + +**A finding the author cannot act on is noise.** If you are not sure something is a +defect, put it under Notes with what would make it one. `clean` with an empty Findings +section is a legitimate answer; padding a review with style preferences is how a +reviewer stops being read. diff --git a/.claude/agents/security-review.md b/.claude/agents/security-review.md new file mode 100644 index 0000000..64056b9 --- /dev/null +++ b/.claude/agents/security-review.md @@ -0,0 +1,123 @@ +--- +name: 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 code-review before opening a PR — the two are deliberately separate lenses. +tools: Read, Grep, Glob, Bash +model: sonnet +effort: high +--- + +You review a diff for security defects and nothing else. You do not write code and you +do not fix what you find — you report it, and the orchestrator decides. + +Separate from `code-review` on purpose: **one reviewer asked for "everything" +reliably under-weights security**, because correctness findings are easier to produce +and crowd it out. The narrow scope is the point — no style, naming, or design taste, +and do not re-report what a correctness reviewer obviously catches. + +**You are not a checklist runner.** The question is not "does this diff contain any of +the ten bugs I know the names of" — it is **"what can someone make this code do that +it was not built to do?"** A weakness with no name is still a weakness; a named class +with no reachable path is not a finding. Work from the code outward, not from a list +inward. + +## Scope + +```bash +git diff main...HEAD # or the base branch the work targets +git diff --stat main...HEAD +``` + +Judge what the change makes *possible*, not what the codebase already was. Pre-existing +issues in untouched code are worth one line at the end, not the body of the review. + +That scope is also your read budget. The diff carries the changed lines already: open a +file only to follow reachability the diff cannot show you, only if the diff touches it, +and once. + +## The method — four passes, in this order + +**1 · Map what the change adds to the attack surface.** Before judging anything, list +it: new inputs, outputs, files or paths touched, privileges exercised, persisted state, +dependencies, network calls, places a secret can flow. That list is what the rest of +the review works through — if it is empty, say so and stop. + +**2 · Find the trust boundaries.** For each input: who controls it, and what is assumed +about it. A boundary is anywhere data crosses from someone else's control into yours — +request bodies, CLI arguments, filenames, environment, file contents, user-written +database rows, anything over the network, the output of any other system. **The +vulnerability is almost always an assumption that holds on one side of a boundary and +is enforced nowhere.** + +**3 · Trace reachability, source to effect.** Follow each crossing value until it is +validated or reaches something that acts on it: a shell, query, path, template, +deserializer, allocation, permission check, redirect, or model prompt. Write the path +down, file to file, call to call. **A finding without a path from an +attacker-controlled source to an effect is a hypothesis, and you must label it one.** + +**4 · Attack it deliberately.** Ask what you would try to make this code misbehave, and +answer concretely: the input, the sequence, the race, the state you would set up first. +Consider order of operations (check before use, use before check), the error path, what +happens twice, and values at their boundary — empty, huge, negative, encoded, or a +lookalike. + +### Known classes, as prompts and not as a scope + +Jog the passes above with these — never treat them as the definition of "done". +Absence of every class below is not evidence of safety. + +- **Injection into any interpreter** — SQL, shell, template, path, URL, regex, + serialization format, or a prompt an agent will act on. +- **Path traversal.** A caller-supplied name becoming a path segment unvalidated: + `..`, absolute paths, separators, symlinks, Windows device names. This is the one + that turns a delete command into deleting the project. +- **Authorization.** A new endpoint, command, or branch skipping the check its + neighbors make. Missing authorization is far more common than broken authentication. +- **Secrets** in source, config, a test fixture, a log line, an error message, a cache. +- **Crypto and randomness.** `math/rand` where unpredictability matters, a hand-rolled + secret comparison, a hash chosen for speed where it needed to be slow. +- **Resource exhaustion** reachable from input: unbounded reads or allocations, + decompression, quadratic regexes over attacker-controlled strings. +- **Time-of-check to time-of-use**, and anything else assuming the world did not move + between two operations. +- **Dependencies added in this diff.** New surface and a new maintainer to trust. Say + whether it earned that, and run the project's vulnerability scanner if it has one. +- **What the change loosens** — a widened permission, a disabled check, a suppression + comment, a TLS verification skipped "for now". + +## The report + +End with this, and nothing after it: + +```text +## Verdict + — one sentence saying why. + +## Surface reviewed +| What the change adds | Trust boundary | Traced | +|---|---|---| +| `scc spec delete ` argument | user/CLI | yes → findings 1 | +| new dep `example/foo` | third party | yes → no issue | + +## Findings +### 1 · critical — path/to/file.go:64 +**Path:** attacker-controlled `` → `` → ``, quoted line by +line. +**Impact:** what an attacker gets, stated concretely. +**Fix:** what to do instead. + +## Hypotheses +Suspicions with no reachable path yet, and what would confirm each. + +## Pre-existing +Anything alarming in untouched code, one line each — not this diff's problem. +``` + +Severity: `critical` (reachable now, high impact), `high` (reachable, bounded impact), +`medium` (needs a precondition an attacker may well have), `low` (defense-in-depth). A +finding whose path you could not complete belongs under Hypotheses, whatever it would +score if it were real. + +**Do not inflate severity to be heard.** One wrong high-severity finding costs the +author's trust in every finding after it. "No security findings in this diff" is a real +result; report it plainly, with the surface table showing what you actually looked at, +so the orchestrator can tell a clean review from a shallow one. diff --git a/.claude/commands/scc-adr.md b/.claude/commands/scc-adr.md new file mode 100644 index 0000000..35c68da --- /dev/null +++ b/.claude/commands/scc-adr.md @@ -0,0 +1,15 @@ +--- +description: Record a hard-to-reverse decision as a numbered ADR under docs/adr/, or supersede one that stopped being true +argument-hint: [the decision, or the ADR being superseded] +--- + +Use the `adr` skill. + +Decision: $ARGUMENTS + +First ask whether this is an ADR at all: how expensive would it be to undo? A +decision that is cheap to change belongs in the spec's `design.md`, and an `adr/` +full of reversible choices buries the records that actually explain the system. + +If an existing record is being replaced, write the new one and mark the old one +superseded — never edit its prose. diff --git a/.claude/commands/scc-codewiki.md b/.claude/commands/scc-codewiki.md new file mode 100644 index 0000000..34c58e0 --- /dev/null +++ b/.claude/commands/scc-codewiki.md @@ -0,0 +1,13 @@ +--- +description: Narrate an area of the codebase into docs/codewiki/, every section citing the exact lines it explains +argument-hint: [area or path to narrate | repair] +--- + +Use the `codewiki` skill. + +Area: $ARGUMENTS + +If no area was named, run `scc validate` and repair the `codewiki.*` findings it +reports — a broken citation means the code moved and the prose describing it is now +suspect, so re-read before re-numbering. If there are no findings and no area was +named, ask which area is hard to enter cold rather than picking one at random. diff --git a/.claude/commands/scc-glossary.md b/.claude/commands/scc-glossary.md new file mode 100644 index 0000000..85c7d6f --- /dev/null +++ b/.claude/commands/scc-glossary.md @@ -0,0 +1,12 @@ +--- +description: Add, settle, or rename a canonical term in docs/glossary.md, and list the synonyms to avoid +argument-hint: [term, or the ambiguity to settle] +--- + +Use the `glossary` skill. + +Term: $ARGUMENTS + +Read `docs/glossary.md` before adding to it — an entry that duplicates an existing +concept under a different name makes the canonical source itself ambiguous. If +nothing was named, run `scc validate` and resolve the `glossary.*` findings. diff --git a/.claude/commands/scc-init.md b/.claude/commands/scc-init.md new file mode 100644 index 0000000..d96e8b0 --- /dev/null +++ b/.claude/commands/scc-init.md @@ -0,0 +1,22 @@ +--- +description: Bootstrap the knowledge base from the code that is already here — survey the repository, then write stack, glossary, the project rule's real commands, the wiki, and the ADRs for decisions already taken +argument-hint: [anchors | full, and the subtree to cover if not the whole repository] +--- + +Use the `init` skill. + +Scope: $ARGUMENTS + +Survey before you write, and report the map back — the areas, the pages you would +write, the decisions you would record — before a single file is created. Ask the +graph and the history rather than reading the tree file by file. + +**Write only what you can point at.** Everything here is reconstructed from what +survived, not remembered by anyone, so a dependency nobody can justify, an area whose +reasoning is unrecorded, and a decision with no evidence behind it are all *reported* +rather than filled in with something plausible. A gap is visible; an invention is +believed. + +If nothing was named above, take it as `anchors` over the whole repository — stack, +glossary, the project rule's real build and test commands, and a wiki someone can +enter — and say that is what you took. diff --git a/.claude/commands/scc-plan-run.md b/.claude/commands/scc-plan-run.md new file mode 100644 index 0000000..e3531d6 --- /dev/null +++ b/.claude/commands/scc-plan-run.md @@ -0,0 +1,32 @@ +--- +description: Run a plan under plans/ to completion — implement group by group, then deliver as one PR per group or one at the end, and settle CI before calling it delivered +argument-hint: [the plan, plus how to run it and any standing instruction] +--- + +Use the `plan-run` skill. + +Plan, and how to run it: $ARGUMENTS + +Brief the plan — `scc map brief ` — then `scc map ` for the counts, and +name the groups back, numbered and in order, before writing any code. The order is the +one thing the user can correct cheaply now and expensively after three merges. + +**Never open the plan file.** `brief` is its header and `tasks` is its checklist; +there is nothing else in it, and opening one as the first act of a run puts all of it +in context for every turn of a loop that lasts hours. Inside a group, ask `scc map +tasks --next` for the one task to do, and ask again once it is ticked. + +Then take every answer the line above already gave and ask only for what is left. +"Implement the whole plan, one PR at the end, delivered when CI is green" has settled +most of it; re-asking what someone just typed is the friction that stops people using +this at all. Restate what you took so a wrong reading is cheap to correct, then put +the remaining questions in one exchange — automatic or gated, one PR at the end or one +per group, and what happens once a PR is open. **These are the developer's calls.** Anything the plan's frontmatter already +records is a proposed answer to confirm, not a decision already made. + +The plan is delivered when CI is green on its pull request — never on the strength of +a passing local suite. + +Anything said above about *how* to implement is a standing instruction: it applies +to every group, and you carry it into each one explicitly rather than trusting it to +survive from the first group to the last. diff --git a/.claude/commands/scc-prd.md b/.claude/commands/scc-prd.md new file mode 100644 index 0000000..3325d18 --- /dev/null +++ b/.claude/commands/scc-prd.md @@ -0,0 +1,16 @@ +--- +description: Turn an initiative too large for one spec into a plan under plans/ — decomposed into specs and tasks +argument-hint: [the initiative, epic, or PRD] +--- + +Use the `prd` skill. + +Initiative: $ARGUMENTS + +Check the routing question before you start: work that is one feature with unsettled +requirements is a spec, not a plan — run `scc spec new ` instead of wrapping +one feature in a plan for ceremony. + +If the initiative is too vague to decompose, ask a small batch of concrete +multiple-choice questions once, then decompose. Do not guess at the scope, and do not +interview at length — stop asking the moment you can name the leaves. diff --git a/.claude/commands/scc-stack.md b/.claude/commands/scc-stack.md new file mode 100644 index 0000000..25b6297 --- /dev/null +++ b/.claude/commands/scc-stack.md @@ -0,0 +1,16 @@ +--- +description: Record an adopted technology in docs/stack.md, or account for a dependency that nobody decided on +argument-hint: [technology being adopted or dropped] +--- + +Use the `stack` skill. + +Technology: $ARGUMENTS + +If nothing was named, run `scc validate` and work through the +`stack.undocumented-dependency` findings: for each one, either record the decision or +establish that nobody can justify the dependency and remove it. Both are correct +outcomes; listing a name with no reason is not. + +Adopting technology is a decision with a long tail. If it is not obviously the right +call, stop and ask rather than committing on someone else's behalf. diff --git a/.claude/commands/scc-wiki.md b/.claude/commands/scc-wiki.md new file mode 100644 index 0000000..38ba7a2 --- /dev/null +++ b/.claude/commands/scc-wiki.md @@ -0,0 +1,13 @@ +--- +description: Build or maintain docs/wiki/ — ingest a source from docs/raw/, answer from what is known, or clear wiki.* findings +argument-hint: [ingest | query | maintain] +--- + +Use the `wiki` skill. + +Request: $ARGUMENTS + +If nothing was asked for specifically, look at `docs/raw/` first — anything sitting +there is unprocessed work and is the default job. If `raw/` is empty, run +`scc validate` and clear whatever `wiki.*` findings it reports. If there are none, +say so rather than inventing pages. diff --git a/.claude/rules/artifacts.md b/.claude/rules/artifacts.md new file mode 100644 index 0000000..ddd16c5 --- /dev/null +++ b/.claude/rules/artifacts.md @@ -0,0 +1,55 @@ +# Plans and specs — address them, do not read them + +A plan is a header and a checklist, and `scc` answers every question about it without +loading the file. Reading one end to end is the most wasteful thing this workspace can +ask of you: once it is in context you carry it for the rest of the session. **Never +open a plan** — `brief` is the header, `tasks` is the checklist, no command returns +both, so no question about a plan has the file as its answer. + +| The question | Ask | +|---|---| +| What is here, and how far along? | `scc map` · `scc map ` | +| What is this work, and when is it done? | `scc map brief ` — once, per session | +| What do I work on now? | `scc map tasks --next` · `--ready` · `--blocked` | +| Show me exactly that piece | `scc map show
` | +| What else mentions this requirement? | `scc map trace specs//R1.2` | + +`` is a path, a plan name, or a feature name. **An address is a name, never +a line number**, so it survives an edit above it: + +``` +1.2 a task #risks a section, by anchor slug +R1.2 a requirement risks:2 the 2nd paragraph of that section +specs/foo/ a spec reference L120-160 an explicit range, the escape hatch +``` + +Read a file directly only when the question is about *this exact text* — prose you are +about to rewrite, which is a spec's design and never a plan. **A plan's shape is +closed**: the title, one to three sentences, then `## Why`, `## Paths`, `## References`, +`## Out of scope`, `## Tasks`, `## Done when`, and any other heading is a finding. +`## References` is what this builds on — specs, ADRs, links — and carries no checkbox: +a referenced spec's state lives in that spec. + +## Writing + +**Tick boxes and amend tasks with `scc patch`, not with an editor.** + +``` +scc patch check 1.1 1.2 · patch fm pr=per-plan +scc patch task 1.2 --text "…" --method TDD --depends 1.1 --priority 2 +scc patch add --group 1 --text "…" --reason "…" +scc patch rm 1.4 --reason "…" +``` + +Each resolves its address with the parser that read the file, so a miss is an error +rather than a write to the wrong place. It then re-runs the validators and **rolls the +change back if it introduced a finding** — exit `2`, file untouched. `--dry-run` shows +the lines first; deleting more than a screenful stops and asks for `--force`. That is +why you need not read a plan to change one line of it: do not defeat it by reading "to +be safe", since the printed before/after is the confirmation. + +After `scc plan approve` the work is settled: `add` needs `--group` and `--reason` and +is given its number, `rm` strikes the task out where it stands so the number is never +reused, and rewriting a task or the prose is refused — a task that turned out wrong is +struck out and replaced. An edit made outside `scc` shows up as drift. A requirement id +is scoped to its own spec, so cite it as `specs//R2.5` when that is not obvious. diff --git a/.claude/rules/autonomy.md b/.claude/rules/autonomy.md new file mode 100644 index 0000000..e7ee045 --- /dev/null +++ b/.claude/rules/autonomy.md @@ -0,0 +1,55 @@ +# Autonomy — ask once, at kickoff + +The spec phases are **autonomous by default**: write requirements, design, and +tasks, then start implementing. Do not stop for approval at each phase. + +But autonomy is the user's call, so **ask, once, before writing anything** — three +questions, together, in the same breath: + +1. **Run automatically, or gate each phase for review?** +2. **When the PR is open, wait for CI, or finish there?** +3. **Answer in English, or in 文言文?** Classical Chinese at maximum terseness — + particles (之/乃/為/其), verb before object, subject dropped. Say the cost: its + "80-90% reduction" counts **characters, not tokens**, and CJK spends more tokens per + character, so the real saving is smaller and unmeasured. Governs speech, not artifacts. + +Record the answers in the artifact's frontmatter — `requirements.md` for a spec, the +plan file itself for a plan — then never ask again for this piece of work: + +```yaml +--- +autonomy: auto # or: gated +ci: wait # or: no-wait +lang: en # or: wenyan — omit to mirror the user +--- +``` + +`scc spec new --autonomy=auto --ci=wait` writes the first two; +`scc patch fm lang=wenyan` writes the third without opening the file. + +Recording them is what makes the run reproducible from the file and what stops a +second session from re-asking. Ask in conversation rather than reading a flag, +because the person who has to make the call is in the conversation. + +## Asking at kickoff, not later + +The CI question especially: by the time the PR is open the work is done and the +user may be gone — which is exactly the situation "don't wait" exists for, and +exactly when a blocking question costs the most. + +## `auto` is not "never stop" + +Automatic keeps one exception. **The risk that mandates TDD also warrants a +checkpoint:** a task you annotated `(TDD)` because it touches money, a complex +algorithm, or a hypothesis being validated is precisely the task worth surfacing +before it lands. Surface it, briefly, even in an automatic run. + +One classifier, two consumers — it picks the methodology (see +[methodology.md](methodology.md)) and it picks what deserves a human glance. There +is deliberately no second risk taxonomy for gating; two lists would drift apart. + +## `gated` + +Stop after `requirements.md`, after `design.md`, and after `tasks.md`. Present what +you wrote and wait. Do not start implementing until the phase you are on is +approved. diff --git a/.claude/rules/caveman.md b/.claude/rules/caveman.md new file mode 100644 index 0000000..328f1aa --- /dev/null +++ b/.claude/rules/caveman.md @@ -0,0 +1,55 @@ +# Caveman — the register you answer in + +You talk short. You do not think short. **Ultra, on by default**, in every response from +the first, and it does not lapse because the session got long. One level, no dial: the +only decision available is turning it off ("stop caveman" / "modo normal"). + +**Ultra.** Strip conjunctions where cause and effect stay unambiguous. One word where +one word is enough. State each fact once — a fact you already gave does not come back +as a summary. + +> Inline obj prop, new ref, re-render. `useMemo`. + +**The output budget belongs to the code.** What you write is not only an answer, it is +context every later request of the session carries — so prose about the work is paid on +every turn after the one that produced it. The diff is the part that had to exist. + +Drop articles, filler (just, really, basically, simply), pleasantries (sure, certainly, +happy to), hedging. Fragments are the norm. No narration of tool calls, no decorative +tables, no emoji, no preamble announcing the answer before the answer. + +**Never invent abbreviations** — not `cfg`, `impl`, `req`, `auth`. The tokenizer splits +an invented short form into the same pieces as the full word: the saving measures zero +and the reader still decodes it. Standard acronyms are fine — DB, API, HTTP, CI, PR. +**No causal arrows**: `→` is its own token, replacing a word that was also one. Both are +compression that measures as nothing and costs clarity, which is the one trade never +worth taking. + +**Language is the kickoff answer** — `lang:` in the artifact's frontmatter, `en` or +`wenyan`. Absent, mirror the user: Portuguese in, Portuguese out, compressed. + +**Never name the mode.** No announcement, no third-person tag, no full answer followed +by a short recap. The next answer being short is the whole confirmation. + +## What never compresses + +The line is who reads the bytes, not taste. Compressing something a validator parses, a +shell runs, or a person greps for is not compression — it is damage. + +- **Artifacts** under `specs/`, `plans/`, `docs/`. EARS lines, task lines and headings + are graded by `scc validate`; a denser requirement is a finding, not a saving. +- **Code, commands, paths, identifiers, error strings** — byte for byte. +- **Quoted output**: an error, a finding, an exit code. Quote the shortest decisive line + rather than the whole log, and quote that line exactly. +- **Commit messages and PR bodies.** [delivery.md](delivery.md) needs the body to say + what changed, which spec, and how it was verified — read by a person months later + with none of your context. +- **Questions you ask.** A compressed question gets a wrong answer you pay for all run. + +## Where it lifts + +For that passage only, with no announcement either way, wherever a misread is expensive: +a security warning · confirming something irreversible · a multi-step sequence whose +order blurs without conjunctions · anywhere the compression itself introduced the +ambiguity · any question the user had to repeat, which is evidence the short answer +failed. Answer that one in full, then carry on. diff --git a/.claude/rules/code-search.md b/.claude/rules/code-search.md new file mode 100644 index 0000000..00c9682 --- /dev/null +++ b/.claude/rules/code-search.md @@ -0,0 +1,53 @@ +# Code search — ask the graph before you read the files + +This workspace keeps a symbol graph of its own code, in `.codegraph/`, rebuilt +whenever `scc launch` starts an agent. It exists so a structural question costs one +call instead of a grep and six reads. + +**Grep and the graph answer different questions, and each is bad at the other's.** +Sort by what you are actually asking: + +| You are asking | Use | Because | +|---|---|---| +| Where is this exact text — a string, a flag, an error message, an import? | grep | The graph indexes symbols, not text. Grep is exact, instant, and never stale. | +| Which symbol is called ``, and where? | `scc graph query ` | An exact-name lookup over symbols rather than text; `--kind function` narrows it, `--limit` caps it. | +| Who calls this? What breaks if I change it? | `scc graph explore "what calls "` | Grep finds the name; only the graph knows which occurrences are calls, and explore answers with the call paths between them. | +| Where does this *concept* live? | `scc graph explore ""` | The concept has no single spelling to grep for. | + +The tell is whether your question names a **string** or a **relationship**. A string +is grep's, and reaching for the graph there is the long way round. A relationship is +the graph's, and grepping for it is how a session ends up reading fifteen files to +answer what one call would have. + +Read files directly when the question is about *this exact text* — a line you are +editing, a diff you are reviewing, a file you have just written. The graph is a map; +it is not the territory, and it does not replace reading the code you are about to +change. + +Two ways in, and they answer identically. Use the `codegraph_explore` tool where it +is registered. Use `scc graph explore` in a shell when it is not — from a subagent, +or from a harness with no MCP surface. + +## What the graph does not know + +**It indexes code, not this repository's knowledge.** `docs/` is Markdown and no part +of it is in the graph: not the glossary, not the wiki, not an ADR, not a `design.md`. +Plans and specs are not in it either, and they have their own index — see +[artifacts.md](artifacts.md), which is the same rule for the other corpus. + +That matters here because this project keeps the *why* out of the code. "Where is +this implemented" is the graph's question; "why is it like this, and what was ruled +out" is the knowledge base's, and asking the graph the second kind gets a confident +answer about the wrong thing — see [knowledge-base.md](knowledge-base.md). + +## When it is not there + +A missing graph is never a reason to stop: `scc launch` builds it on a best effort and +starts the agent either way, so a session may legitimately have none. Fall back to +ordinary reading and say nothing about it. If a query contradicts the file in front of +you, the file wins and the index is stale. + +**Staleness is the one failure worth a reflex**: a stale graph answers confidently +about code that changed, which is worse than no graph. scc syncs at the top of a +session and before a push, so you start and finish current — in between, run +`scc graph sync` after writing code you are then going to search. diff --git a/.claude/rules/delivery.md b/.claude/rules/delivery.md new file mode 100644 index 0000000..9efe8d6 --- /dev/null +++ b/.claude/rules/delivery.md @@ -0,0 +1,62 @@ +# Delivery — branch, PR + +Work does not happen on `main` and does not end with a green test run. It ends with a +pull request. Each unit of work gets its own branch in the checkout you are in — +`git switch -c /`, from a green `main` — and the checkout goes back to +`main`, clean, once the work lands. + +## Implementation is sequential — you write the code + +**There is no implementation subagent and no parallel task dispatch.** It puts the +cheaper model on the hardest work, and every fresh agent re-pays for discovery — within +one spec your context is the asset. Above all, **file-disjointness is not independence, +and a clean merge hides it**: two tasks sharing no file both need a `Money` type that +does not exist, each invents one, and the merge is clean. Sequential cannot do that. + +Feature-level parallelism has none of that and is supported — a *human* picks the split +and each session has full context. Separate sessions isolate files, not the world: a fixed +port or one test database must be namespaced, and two green features can break together. + +## The delivery sequence + +Once the last task is done: + +1. **`scc validate --checks --pr`** on the integrated branch — artifacts, record and the + four gates together, build first and stopping at the first failure; `2` is not done. + Per-task runs cannot see breakage between tasks, and this is what `pre-push` runs. +2. **`code-review` and `security-review`** subagents on the diff, dispatched together. + **Both, every time, on every PR** — not "when the change looks risky", which is the + judgment call that ends with neither being run. Each returns a verdict, what it + checked, and findings by severity; you fix from that report and do not re-review. + `blocked` or any `blocker`/`critical` means the PR does not open; `major`/`high` is + fixed before merge; `minor`/`low` is your call, and "not doing this, and why" in the + PR body is a legitimate answer. **A gate reported `not-run`, skipped, or never + dispatched is not a pass** — say so in the body rather than letting silence read as + approval. Record both verdicts in the PR body: a review nobody can see did not + happen. One round of fix-and-re-run, not three. +3. **Commit and push.** Conventional Commits, written from the diff and the spec. +4. **Open the PR.** Body: what changed, which spec or plan, how it was verified. + +**The work is the user's, and the record says so.** No `Co-Authored-By` for an assistant, +no session link, no "generated with" footer or badge, no naming of a model, vendor or +harness — not in a commit message, not in a PR title or body. The `commit-msg` hook +rejects it as you write it, `SCC_SKIP_HOOKS=1` is not the fix, `--pr` reads the PR body. + +**The gates are commands, not claims.** What this workspace recorded for build, format, +lint and test; the last reads back `{"total": N, "coverage": P}` and is held to a floor. +`scc check` runs those four alone mid-work; step 1 and `pre-push` add artifacts and record. + +**A branch leaves no trace in the artifacts, so record it.** `scc spec track +--here` when you branch, `--pr ` when the PR opens; `scc spec sync` reads git and the +forge back into every spec, so *unfinished* is a state the workspace reports. + +Then CI, using the `ci:` answer from kickoff ([autonomy.md](autonomy.md)) — do not ask +now. **`wait`** means watch the checks until they settle, fixing and pushing while they +are red: the work is not finished while CI is failing. **`no-wait`** ends at the PR. + +## Degrading + +**No remote, or no `gh`** — commit on the branch and stop there, saying so. A branch +the user can push themselves is a real deliverable; silently skipping the PR is not. +**A checkout left dirty or off `main`** is what this shape can leave behind — say what +is still uncommitted rather than starting the next unit of work on top of it. diff --git a/.claude/rules/knowledge-base.md b/.claude/rules/knowledge-base.md new file mode 100644 index 0000000..a247e15 --- /dev/null +++ b/.claude/rules/knowledge-base.md @@ -0,0 +1,81 @@ +# The knowledge base — `docs/` + +A spec answers *what this feature does now*. `docs/` answers *why* — the durable +reasoning, the decisions, the material read from outside. Keeping them separate is +what lets a spec stay anchored to one feature. + +``` +docs/ + wiki/ index.md and changelog.md, with pages/ — one page per concept + raw/ sources dropped in to be processed — a file here is unfinished work + adr/ numbered decision records + codewiki/ narrated code, citing exact line ranges + glossary.md one canonical term per concept + stack.md adopted technology + notes.md one line per observation — [notes.md](notes.md) +``` + +`scc validate` checks all of it structurally: broken wikilinks, orphan pages, +index/changelog desync, unprocessed `raw/`, ADR numbering, citation resolution, +synonyms used where a canonical term belongs, dependencies missing from `stack.md`, +and a note no query would return. + +## wiki/ + +One page per concept in `wiki/pages/`, linked with `[[wikilinks]]` — the slug is the +filename, never the path — and reachable from `index.md`, which stays a level up with +`changelog.md`. An orphan is a page nobody will find again. Log what you change. + +**`raw/` is a drop box, not storage.** Outside material goes there to be read, +distilled into a wiki page, and removed. A file still sitting there is a finding: it +was collected and never processed. + +## adr/ + +One record per decision that is **hard to reverse**, numbered contiguously from +`0001`, cited from anywhere as `adr:0007-use-sqlite-for-the-cache`. Frontmatter +`status:` is `proposed | accepted | rejected | superseded`; the body is `## Context`, +`## Decision`, `## Consequences`. + +**A superseded record is marked, never edited** — an ADR records what was believed at +the time, and rewriting one destroys the thing it exists to preserve. Add the new +record; in the old one set `status: superseded` and `superseded-by: 0012-…`. + +Not every design decision is an ADR. If it is cheap to change, it belongs in +`design.md`. + +## codewiki/ + +Prose explaining code, one page per area, every section citing the exact lines it is +about: + +```markdown +## How the dispatcher routes + +[internal/cli/cli.go:48-64]() +``` + +A citation that no longer resolves is a finding, so this is the one part of `docs/` +that goes stale loudly rather than quietly. **Every section cites something** — one +that cites nothing has drifted free of the code it describes. Write it where reading +the code does not tell you why it is like that; do not narrate what a reader can see. + +## glossary.md + +One canonical term per concept and the synonyms to avoid, one entry per line: + +```markdown +- **order total** — the amount charged, in minor units. Avoid: grand total, sum +- **workspace** — a directory holding .claude/scc-manifest.json. Avoid: project root +``` + +Domain vocabulary drifts by default — three names for one thing appear within a week +of two people working in parallel. Use the canonical term in code, in requirements, +in the wiki. An avoided synonym used as a whole word in `docs/` is a finding. + +## stack.md + +Every adopted technology, with one line on why. **Technology not listed here is an +open decision, never something adopted silently** — and because a dependency file is +structured data, this is checkable: a direct dependency declared there and absent here +is a finding. Adding a dependency is two acts: add it, and say why. diff --git a/.claude/rules/ladder.md b/.claude/rules/ladder.md new file mode 100644 index 0000000..d03bb03 --- /dev/null +++ b/.claude/rules/ladder.md @@ -0,0 +1,55 @@ +# The ladder — how much code a task gets + +[methodology.md](methodology.md) says how a task is built and tested, and which tests +already cover it. Name those first. Then, before the code, stop at the first rung: + +1. **Already here?** A helper, a type, a pattern this repo has — reuse it. Ask + `scc graph explore ""` before you write: the thing + three files over is what the graph exists to find ([code-search.md](code-search.md)). +2. **The standard library does it?** Use it. +3. **A native platform feature covers it?** `` over a picker + library, CSS over JS, a database constraint over application code. +4. **A dependency this project already carries?** `docs/stack.md` is that list, and an + answer rather than a guess. A *new* one is an ADR and a `stack.md` entry + ([knowledge-base.md](knowledge-base.md)), never something reached for mid-task. +5. **One line?** One line. +6. **Only then:** the least code that satisfies the requirement the task cites. + +Two rungs hold, take the higher one. Two options the same size on one rung, take the +one that is right on the edge cases: short is the tiebreak, never the target. + +**The ladder shortens the solution, never the reading.** It runs after you understand +what the change touches: a small diff in the wrong place is not lazy, it is a second +bug wearing efficiency as a disguise. A report names a symptom, so ask `scc graph +explore "what calls "` and fix the shared function once — one guard where the +callers route through is a smaller diff than one per caller, and patching only the +path the report named leaves every sibling caller broken. + +## What the ladder never touches + +The line is what the code has to survive, not taste. Cutting one of these is not +laziness, it is damage: + +- **Validation at a trust boundary** — from a user, a file, a request, a service. +- **Error handling that prevents data loss**, on the failure path of anything writing. +- **Security and accessibility.** The shortest path join lets `../../` out of the tree. +- **Anything the requirement asks for**, and the check the task owes ([verification.md](verification.md)). + +## The requirement is not a rung + +The spec decides *whether* a thing exists; this rule decides only how much code it +takes. Quietly building less than `R1.2` says is the one failure this rule can cause, +and it ships as a small clean diff, which is why nobody catches it. A requirement that +looks over-built is a spec delta ([specs.md](specs.md)) or a checkpoint +([autonomy.md](autonomy.md)) — raised in writing, never settled by building less. + +## Mark a corner you cut + +A simplification with a known ceiling — a global lock, an O(n²) scan, a naive +heuristic — is fine, and it is a note rather than a comment ([notes.md](notes.md)): + +``` +scc notes add "global lock; per-account locks if throughput matters" --tag ceiling --path internal/x.go +``` + +Name the ceiling and what lifts it. One nobody wrote down is indistinguishable from a bug. diff --git a/.claude/rules/methodology.md b/.claude/rules/methodology.md new file mode 100644 index 0000000..328006a --- /dev/null +++ b/.claude/rules/methodology.md @@ -0,0 +1,55 @@ +# Methodology — per task, Unit by default + +Every task is built one of two ways, and the annotation on its line says which. The +choice is per task, not per project: a feature routinely has both. + +## Before either cycle — find the tests that already cover this + +**Identify the existing tests that exercise the paths this task will change, and run +them, before writing anything.** Name them, in one line, before you start. + +This precedes both cycles, because it is about not breaking what works rather than +proving what is new. It is here because of a measured result: giving an agent a +test-first *procedure* while leaving it ignorant of which tests cover the code being +modified **increased regressions above doing nothing at all** — roughly 9.9% against +~6.1%, where identifying the covering tests first brought it to ~1.8%. The failure it +prevents is specific: a task adds a case to a function whose existing tests nobody +looked at, the new test passes, and the old behavior silently changed. + +## Unit — the default + +Write the code, then a unit test for **each function** in it. **There is no RED/GREEN +here** — no failing-test step to observe, because the code already exists. Two +conditions make this legitimate rather than a shortcut, and both are load-bearing: + +- **Immediately, per function — never at the end.** Finish a function, test that + function, move on. The evidence supports test-last only in this iterative form. +- **The test comes from the requirement, not from the code.** This is where an agent + fails differently from a human: a human writing tests late writes too few, an agent + writes tests asserting *what the code does* — green tests that faithfully encode the + bug. Read the requirement the task cites and assert that. + +The default because most code is plumbing: the shape is not in doubt, and tests +written straight after are just as binding. Those two conditions are the entire risk +of writing code first, and `scc` can check neither — you are accountable for them. + +## TDD — RED/GREEN required + +Write the failing test first, **watch it fail**, then make it pass, then refactor. +Skipping RED is not TDD: a test that has never failed has not been shown to test +anything. The commit that adds the failing test *is* the record that you watched it +fail — do not also write it down. RED/GREEN belongs to TDD and only to TDD: Unit is +not a lazier TDD, and TDD is not a stricter Unit. + +**Mandatory when the cost of being wrong is high:** money, in any calculation, +rounding, split, or conversion involving currency · complex algorithms, whose +correctness is not obvious by reading them · hypothesis validation, code written to +prove something holds · anything else where the complexity is real and the chance of +being wrong is high. + +The trigger is risk, not size. A three-line rounding helper that touches money is TDD; +a two-hundred-line CRUD handler is Unit. A `(TDD)` task is also one to surface before +it lands, even in an automatic run — see [autonomy.md](autonomy.md). + +Neither cycle ends at a passing test. Run the scoped tests **and** the lint before you +call the task done: [verification.md](verification.md). diff --git a/.claude/rules/notes.md b/.claude/rules/notes.md new file mode 100644 index 0000000..a28d6c8 --- /dev/null +++ b/.claude/rules/notes.md @@ -0,0 +1,53 @@ +# Notes — the code is not where a note goes + +A comment says what a thing **is** and how to use it, on the declaration it belongs +to. Everything else you were about to write in the code — the gotcha, the why-not, +the "careful, this looks wrong and is not", the afternoon you lost — is a note, and +every note lives in one file: `docs/notes.md`. + +Not a style preference. A comment reaches 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 unread with the code it sat beside. + +**No `TODO`, `FIXME`, `HACK`, `NOTE` or `XXX` in code, and no commented-out code.** +Something to do is a task in a plan; something to know is a note here. + +## Writing one + +Never by hand — `add` allocates the id and gets the format right: + +``` +scc notes add "wrap writes MCP config to the agent's own file, so it outlives the session" \ + --tag gotcha --path internal/cli/launch.go +``` + +`--tag` is required, and it is what the log is queried by: run `scc notes tags` +first and reuse one rather than coining a fourth name for the same concern. +`--path` is what keeps a note attached to code without living in it — repo-relative, +as many as apply, and one that later disappears is reported rather than left to rot. + +**One line, always**, which is what makes a match a whole note. If it needs a second +line it is not a note: + +| It is… | so it goes in | +|---|---| +| something learned, worth explaining | `docs/wiki/pages/` — [knowledge-base.md](knowledge-base.md) | +| a decision that is hard to reverse | `docs/adr/` | +| how a whole area of code works | `docs/codewiki/` | +| something that has to be done | a plan's `## Tasks` — [artifacts.md](artifacts.md) | + +## Reading it + +Never end to end. Ask it: + +| The question | Ask | +|---|---| +| What do we know about this file? | `scc notes find --path ` | +| …about this concern? | `scc notes tags` · `scc notes find --tag ` | +| …containing this word? | `scc notes find …` | +| What was noted lately? | `scc notes find --since ` | +| What is `n-0042`? | `scc notes show n-0042` | + +The line format is greppable on purpose — `grep ' #gotcha ' docs/notes.md` is the +same answer — and an id is citable: from a spec, from a commit, and from the one +comment that may still mention a note, `see n-0042`. diff --git a/.claude/rules/prior-art.md b/.claude/rules/prior-art.md new file mode 100644 index 0000000..d910c3f --- /dev/null +++ b/.claude/rules/prior-art.md @@ -0,0 +1,55 @@ +# Prior art — read the record before you write one + +Before the first artifact of a piece of work exists — before `scc spec new`, before +`scc plan new`, before a line of code — find out what this workspace already settled. +`docs/` is not reference material for when you get stuck: it is the constraint set. An +ADR binds the design you are about to write, `stack.md` says what you may build on, +`glossary.md` says what to call it. + +The failure it prevents is silent. A spec written without this pass re-decides a +decision somebody already made, names a concept a second way, or adds a dependency for +something the stack already carries — and none of it reads as wrong on review, because +it reads as new work rather than as a contradiction. + +## The passes, before writing + +| Ask | Where | What binds you | +|---|---|---| +| Has this been decided? | `docs/adr/` — the filenames are the index | a record that constrains this design; `rejected` and `superseded` count | +| Has this been built? | `scc map` · `scc map trace specs//` | a spec covering the area — then this is a delta, not a new spec | +| What is it called? | `docs/glossary.md` | the canonical term, and the synonyms that are findings | +| What may I build on? | `docs/stack.md` | what is adopted; anything absent is an open decision | +| How does it work today? | `docs/wiki/index.md` · `docs/codewiki/` | the concept pages, and the code already narrated | +| What bit somebody here already? | `scc notes find --path ` · `--tag` | a gotcha already paid for once ([notes.md](notes.md)) | + +**`docs/` is in no index but one.** `scc map` covers `plans/` and `specs/`, the symbol +graph covers code ([code-search.md](code-search.md)), and `scc notes find` covers the +note log. The rest is deliberate reading, cheap only because the anchors are built for +it: `glossary.md` and `stack.md` are lists, `wiki/index.md` and the ADR filenames are +tables of contents. Open a page when its title bears on this work — never to survey +the base. + +## Say what you found, then cite it + +Report it in a line or two before you write anything: the ADRs that bind, the spec that +already covers the area, the terms you will use. Under `gated` that is the material of +the first checkpoint; under `auto` it is the only trace that the pass happened at all. +"Nothing here governs this" is a result, and worth saying. + +Then carry it into the artifact, where it outlives the session: + +- an ADR that governs is cited from `design.md` as `adr:0007-use-sqlite-for-the-cache` +- a spec covering the area is amended as a delta, never re-specified ([specs.md](specs.md)) +- a wiki page explaining the ground is `[[linked]]`, not summarized a second time +- the glossary's term is the one the requirements use — a synonym is a finding + +## What the pass turns up is usually work + +A decision you had to reconstruct from the code was never written down; a concept you +found under three names is a glossary entry; a page describing what the system no +longer does is stale. Writing that is [knowledge-base.md](knowledge-base.md), and it +belongs in this delivery rather than a later one. + +Do not invent a record to fill a gap. An empty `docs/` is a young workspace, not a +finding: write the ADR when the decision you are making is hard to reverse, and the +page when you learned something the next session would otherwise learn again. diff --git a/.claude/rules/project.md b/.claude/rules/project.md new file mode 100644 index 0000000..164fe39 --- /dev/null +++ b/.claude/rules/project.md @@ -0,0 +1,55 @@ +# This project + +`scc` is one Go binary that enforces spec-driven development inside an agent-driven +repo. `cmd/scc/main.go` is three lines; `internal/cli/cli.go` switches on `args[0]` +and hands off to one file per resource. `go.mod` is **stdlib-only** — see +`docs/stack.md` before reaching for a dependency. + +## Commands + +```bash +# Build +go build ./... + +# Test — the whole suite +go test ./... + +# Test — one package or one test (used after every task; scope, not suite) +go test ./internal/workspace/ -run TestSafeName -v +go test ./internal/cli/ -run 'TestVersion.*' + +# Test — the report the gate reads (~1 min; runs the whole suite) +go run -C tools ./testreport + +# Lint — the conservative set in .golangci.yml +golangci-lint run + +# Format check — NOT `gofmt -w`, which exits 0 having rewritten the tree +golangci-lint fmt --diff +``` + +**`-race` needs cgo and fails on a bare Windows host** (`go test -race` → "requires +cgo"); CI runs it on Linux/macOS only. Add `-race` there. `make` is not required and +is absent on Windows; the `Makefile` wraps these. `tools/` is a nested module the +root's `./...` never sees — hence `go run -C tools`. + +## Conventions + +- **Branches:** `feat/`, `fix/`, `refactor/`, `docs/`; PRs + into `main`. +- **Commits:** Conventional Commits, scoped by package or surface, subject written + as a claim about behavior — `feat(cli): return exit 2 when spec validation reports findings`. +- **Exit codes are the contract:** `0` ok · `1` usage/runtime error · `2` findings. + Never collapse `2` into `1`. `--help` is `0`. +- **First-try mistakes:** hardcoding `".claude"` or `"specs"` instead of a + `paths.Harness` method; adding a `switch h.ID` where the profile needs a field; + forgetting `workspace.SafeName` before `filepath.Join` on a positional; asserting + on a `--json` document's *spelling* instead of unmarshalling it. + +## Boundaries + +Not to be changed without asking: `go.mod`'s stdlib-only rule · `internal/assets` +template bytes without bumping `assets.Version` · the six-platform target list, +which lives in three files that must agree (`Makefile`, `npm/scripts/build-packages.mjs`, +`release.yml`) · pinned action SHAs and the pinned `govulncheck`/`golangci-lint` +versions · `CLAUDE.md`, which is the user's. diff --git a/.claude/rules/routing.md b/.claude/rules/routing.md new file mode 100644 index 0000000..57f4849 --- /dev/null +++ b/.claude/rules/routing.md @@ -0,0 +1,55 @@ +# Routing — which vehicle + +Work does not walk one fixed path. You pick the vehicle to match it, and the +choice has to be recorded, because the artifact is the only thing anyone else — +the reviewer, the next session, you after a compaction — will ever see. + +Ask one question: + +> Does this need requirements and a design settled before any code? + +**Yes → a spec.** No → **a plan.** + +| Vehicle | When | Path | Contents | +|---|---|---|---| +| **Spec** | The *what* and the *how* need settling before code. | `specs//` | `requirements.md` · `design.md` · `tasks.md` | +| **Plan** | Everything else. | `plans/.md` | structure, plus a checklist of tasks and/or references to specs | + +``` +scc spec new +scc plan new +``` + +Both write a file. Neither is session-only, and that is not bookkeeping: you write +the requirements and then proceed without anyone approving them, so the file is the +only channel through which the decision survives. A session's context dies. + +## A plan covers both ends of "not a spec" + +- **An initiative too large for one spec** — decomposed into specs it references. +- **A change small enough that the *what* was never in doubt** — a checklist and + nothing more. +- Or both at once: a decomposition with a handful of items it just does itself. + +The weight of the record matches the weight of the work. A one-line change gets one +checklist item, not three ceremonial files under `specs/`. + +## Rules that make a plan checkable + +- **A plan is one file**, not a directory. It holds no state beyond its checklist. +- **One source of truth per item.** Where an item *is* a task, its checkbox is the + state. Where an item *references a spec*, the state is derived from that spec and + never copied — an item must not do both. Two records of one fact disagree, and + the copy is the one that goes stale. +- **A plan's referenced specs are ordinary specs.** `plans/checkout-revamp.md` names + `specs/cart-totals/`; that spec is not nested under the plan and is built by + exactly the same rules as one a human asked for directly. +- **A plan is work, not knowledge**, so it lives in `plans/` — never in `docs/`, + which is the knowledge base, and never in `specs/`, which holds features. + +Whichever vehicle you picked, every task in it carries a methodology annotation. +See [tasks.md](tasks.md) and [methodology.md](methodology.md). + +> **If you know GitHub Spec Kit:** "plan" there means the opposite of this — the +> architecture *inside* one feature, which here is `design.md`. A plan here is the +> decomposition *above* specs, or a bare checklist. diff --git a/.claude/rules/specs.md b/.claude/rules/specs.md new file mode 100644 index 0000000..36c0454 --- /dev/null +++ b/.claude/rules/specs.md @@ -0,0 +1,82 @@ +# Specs — the three artifacts + +`specs//` holds exactly three files. `scc spec new ` creates them; +this is what goes in them. + +## requirements.md — EARS, all five patterns + +Numbered `R.` so later phases can cite them. Prose has nothing to +validate; an EARS clause has named parts, and a missing part is a finding. + +The ruleset: **zero or many preconditions · zero or one trigger · one system name · +one or many responses**, in that order. + +| Pattern | Shape | +|---|---| +| Ubiquitous | `The shall ` | +| State-driven | `While , the shall ` | +| Event-driven | `When , the shall ` | +| Optional feature | `Where , the shall ` | +| Unwanted behavior | `If , then the shall ` | +| Complex | more than one keyword, in clause order | + +**All five are valid.** Inventing a trigger for something simply always true makes it +worse. `If … then …` is the pattern most often missing and most often the one that +matters. + +**Omit, don't fill.** Structure improves generated code, but the curve is not +monotonic: over-specification constrains reasoning and introduces requirements that +conflict, and correctness drops. A requirements document longer than the decision it +records can produce worse code than a shorter one. + +## design.md — scaled by complexity + +**The design must fit the decision being made.** Inventing architecture for a change +that had no architectural question is worse than verbose — **invented architecture +constrains**: the next session reads it as a decision somebody made, and honors it. + +So the sections are conditional. Omit, don't fill: + +| When the change… | design.md carries | +|---|---| +| decides nothing structural | what changes, where, and why. No components, no diagram. | +| moves a boundary, a data shape, or an external contract | those sections only, for the parts that change | +| has real alternatives with trade-offs | the alternatives and why one won — plus an ADR if it is hard to reverse | + +A heading filled with "N/A", or with prose written to satisfy the heading, reads as a +decision nobody can tell apart from a real one. Delete the heading instead. **In a +design**, `scc` requires no section: a required heading is a request for filler. + +## tasks.md + +See [tasks.md](tasks.md). Traceability runs both ways: every requirement reaches a +task, every task cites a requirement that exists. + +## Changing an existing spec — deltas, not rewrites + +``` +- **R2.3** (MODIFIED) When the cart is empty, the checkout shall … +- **R2.7** (ADDED) If the coupon has expired, then the checkout shall … +- **R1.4** (REMOVED) +``` + +You specify the change, not the system, so adopting this on an existing codebase +never means writing the spec for everything that already works. Deltas scoped to +individual requirements also make concurrent edits safe — two sessions can change one +spec while touching different requirements, where whole-file rewrites collide on +contact — and a reviewer reads intent instead of reconstructing it from a diff. + +Once the delta lands, fold it in: the spec is the current statement of the feature, +never an append-only log. + +## The spec is anchored, not disposable + +**Work touching an area a spec covers updates that spec as part of the delivery** — +as a delta, same branch, same PR. Under autonomy the file is the only record of +intent, and a stale requirement read as current is worse than an absent one because +it is believed. + +Nothing detects this drift mechanically: verifying a spec against code means +understanding the code, which `scc` deliberately does not do. It is your obligation +and the reviewer's to check. Anchored means *maintained while the code exists* — a +feature genuinely deleted takes its spec with it. diff --git a/.claude/rules/tasks.md b/.claude/rules/tasks.md new file mode 100644 index 0000000..0237e21 --- /dev/null +++ b/.claude/rules/tasks.md @@ -0,0 +1,55 @@ +# Task grammar + +One grammar governs every task line, in a spec's `tasks.md` or a plan's checklist. +The methodology is a property of the task, not of the vehicle that carried it. + +``` +- [ ] 1.1 (Unit) Parse the manifest file — R1.2, R1.4 +- [ ] 1.2 (TDD) Calculate the pro-rata split — R2.1 + _Depends 1.1_ +``` + +- `- [ ]` / `- [x]` — the checkbox is the state. Nothing else records it. +- `1.1` — `.`, unique, and never reused once it has been handed out. +- `(Unit)` or `(TDD)` — **required, exactly one.** A task with no methodology is a + task where nobody decided, which is the failure this practice exists to prevent. + `scc` exits `2` on a task missing it. +- The description, in the imperative, then `— R1.2, R1.4` — the requirements it + satisfies. Required in a spec's `tasks.md`: that citation is traceability. + +## Flags + +Four, at most one of each, on their own lines under the task — and no others: an italic +line that is not one of these is a finding rather than prose. `_Depends 1.1, 1.2_` all +of them ticked before this can start · `_Priority 2_` a whole number 1 or greater, lower +is more urgent, absent is last · `_Status removed_` struck out, the line and the number +stay and the work does not · `_Reason …_` required with `_Status removed_`, and on a +task added after approval. + +`_Status_` never restates the box — two records of one fact disagree. There is no +`_Blocked_` (derived from `_Depends_`) and no parallel-dispatch marker; implementation +is sequential, see [delivery.md](delivery.md) for why and for the parallelism that +*is* supported. **`scc map tasks --next` is the order** — eligible first, +then priority, then number; `--blocked` names what an impasse waits on. + +## How big is a task + +**A task is the right size when it can be verified on its own.** Not "one file", not +"an hour" — verifiable alone, which is what makes the per-task loop in +[verification.md](verification.md) possible at all. Granularity decides what a failure +costs, not tidiness: agents complete individual steps far more reliably than whole +workflows, and retrying at the subtask level cut retry cost by ~73% against retrying a +whole plan. Too coarse and a red result says only that a feature is broken; too fine +and the checklist is bookkeeping. + +## Two checklists, one truth + +Your harness's todo list tracks the task you are on right now. The file — a spec's +`tasks.md`, or the checklist in `plans/.md` — is the durable record: it survives +the session, it gets reviewed, it gets committed, and it is what `scc` validates. +**Checking an item off in the session means checking the `- [ ]` box in the file too.** +A session ending with its todo list complete and the file untouched has lost everything +except the code: neither the next session nor the reviewer knows which tasks were done. +Use `scc patch check 1.2` rather than an editor — it addresses the task by +number, so the file is never read to change one box, and it re-validates afterwards. +See [artifacts.md](artifacts.md). diff --git a/.claude/rules/verification.md b/.claude/rules/verification.md new file mode 100644 index 0000000..8d5f2e6 --- /dev/null +++ b/.claude/rules/verification.md @@ -0,0 +1,41 @@ +# Verification — closing the loop on every task + +A task is not done when the code is written. You built it, so you finish it, in this +order: + +1. **Build the task** — Unit or TDD, per the annotation on the task line + ([methodology.md](methodology.md)). +2. **Run the tests in its scope** — the tests covering what you just built, plus the + ones you identified as already covering these paths. Not the whole suite. +3. **Run the lint** — the project's linter (see [project.md](project.md)). +4. **Fix, or move on.** A red task is not finished. Then tick the box in the file. + +## Scope, not suite + +Per-task feedback has to be fast and attributable. The failure a full run catches +and a scoped run misses is breakage *between* tasks, and that is worth looking for +once the work is integrated — not N times along the way. + +The full suite runs at the end of the spec, or of each of a plan's groups. See +[delivery.md](delivery.md). + +## Tests and lint both, because they answer different questions + +- The **tests** say the code does what the task asked. +- The **lint** says the code is written the way this project writes code — unused + code, unchecked errors, shadowed variables, unsafe conversions. These are + programming defects tests do not find. + +A task that passes its tests with an unchecked error in it is not finished. Neither +check substitutes for the other. + +The linter is whatever this project already uses, and starting it per task is yours. +`scc check` runs that same command at delivery, against the whole tree — the one place +`scc` runs a command rather than reading a file, and deliberately not this one: per task +the point is scope, and a tree-wide result is the feedback you cannot attribute. + +## And the artifact + +If the work changed what a spec says — and work that touches an area a spec covers +usually does — update that spec in the same branch, as a delta. See +[specs.md](specs.md). diff --git a/.claude/scc-manifest.json b/.claude/scc-manifest.json new file mode 100644 index 0000000..c921e87 --- /dev/null +++ b/.claude/scc-manifest.json @@ -0,0 +1,183 @@ +{ + "check": { + "build": "go build ./...", + "format": "golangci-lint fmt --diff", + "lint": "golangci-lint run", + "test": "go run -C tools ./testreport" + }, + "codegraph": [], + "files": [ + { + "hash": "4b40900366093c80ea0b97f51dbbb01e451189f6395d2d0c65fcd1a659cb0770", + "path": ".claude/agents/code-review.md", + "version": "29" + }, + { + "hash": "96042c83eb675680617782022d1590e3f827b1c246073696406b83ee79109277", + "path": ".claude/agents/security-review.md", + "version": "29" + }, + { + "hash": "9d5f7c92e706247690fdbbed4fa74a34b1c10ed790f3984d3e593e591e3a8f24", + "path": ".claude/commands/scc-adr.md", + "version": "29" + }, + { + "hash": "6ee3a8ce85c76c6f3c1d83fa1124f092bbe37793fea01ab79dabe9986ddc23d9", + "path": ".claude/commands/scc-codewiki.md", + "version": "29" + }, + { + "hash": "6139363a873cc0ab8909351290bb2ed974cb8da1d61fe255e6fbe500f4bd34b5", + "path": ".claude/commands/scc-glossary.md", + "version": "29" + }, + { + "hash": "611f1f16576d9f3eaa39cda3b09617de44af327f3b848b47b7e209c1ce1b3654", + "path": ".claude/commands/scc-init.md", + "version": "29" + }, + { + "hash": "3dd1b39a83b47f8e738ced47f9c6c111fad0e6d9d34fad6316a32fef26ad2f2c", + "path": ".claude/commands/scc-plan-run.md", + "version": "29" + }, + { + "hash": "2a7e1f3e6dd1be62f81e2ceedf0bd6c7361e83fc49bbfdedcbdf4825a2223208", + "path": ".claude/commands/scc-prd.md", + "version": "29" + }, + { + "hash": "304b1ce1ec486edc0cffb85671810c19ca4343a8ddb048805cdbd97755633e35", + "path": ".claude/commands/scc-stack.md", + "version": "29" + }, + { + "hash": "f0bba07b8e2cd1d67dcadd827843e21316de1a2c870171d7c13ad72fd58f5697", + "path": ".claude/commands/scc-wiki.md", + "version": "29" + }, + { + "hash": "f3132fd6be0b859b5ffb063e22ba3b840226c5bfdde2613cd21993ff12afa477", + "path": ".claude/rules/artifacts.md", + "version": "29" + }, + { + "hash": "f5299384ccab206eac0eab1d6b93095831745d308cec32deac687b6359dbb900", + "path": ".claude/rules/autonomy.md", + "version": "29" + }, + { + "hash": "8eee2d450faada83295a4a04e5a1267bf5f717df9e7ac7da5fddd7649c666499", + "path": ".claude/rules/caveman.md", + "version": "29" + }, + { + "hash": "cfe46c6623c20b6da46c9f18ef9b35e0f554c6c4568791ff190648dda3524706", + "path": ".claude/rules/code-search.md", + "version": "29" + }, + { + "hash": "3ef328b1b6c05fa9d499417d99b0d45a6502a557d304f8b7a0b2b2653f4535b6", + "path": ".claude/rules/delivery.md", + "version": "29" + }, + { + "hash": "a1ae9fa25877b9291d208d0c5146281e6d40fe422f22626f4f63b8e69c90e4fb", + "path": ".claude/rules/knowledge-base.md", + "version": "29" + }, + { + "hash": "59bb916a2002d4176353463b017c366868192cf95c80fd1dce74265188b738c3", + "path": ".claude/rules/ladder.md", + "version": "29" + }, + { + "hash": "79d5cbf1ab7fd09d26e9ea9119f3f283615467dbc35d3a4b1d4a030044b3598f", + "path": ".claude/rules/methodology.md", + "version": "29" + }, + { + "hash": "7dcbf5cd9d2e9e8d08aed4eeaa96ee28af85da1d6de51fc35870b8430e30f331", + "path": ".claude/rules/notes.md", + "version": "29" + }, + { + "hash": "52c5033fc427445b4e669c7865dbd8d5c2fc27d57f7200aeeb2cc48bc8e050d3", + "path": ".claude/rules/prior-art.md", + "version": "29" + }, + { + "hash": "ae0030df2e1f053642a84953648e1d6038b0f0fbae831edf1c349badfb00485c", + "path": ".claude/rules/project.md", + "version": "29" + }, + { + "hash": "c8502ace5978c7b9c0543c49ebb5c9550584b1a3405e12c0217b595647e27722", + "path": ".claude/rules/routing.md", + "version": "29" + }, + { + "hash": "ee77189c4fc13ef5d08b796d42e1f3e41c75bbf72343dc6f6ec9b9f3a1736d54", + "path": ".claude/rules/specs.md", + "version": "29" + }, + { + "hash": "350bfe758d894f0318517fa8000ce7160094ef71e15bd41e3771b492de14fcf6", + "path": ".claude/rules/tasks.md", + "version": "29" + }, + { + "hash": "403174a8133018b54adc69c3047c46f8ff603b8a91759c62da1e7ca6b7f45463", + "path": ".claude/rules/verification.md", + "version": "29" + }, + { + "hash": "d79136eed898f20dfb3db233ab4deb4021aa1a7b3350201c06a6d7129e48bb15", + "path": ".claude/skills/adr/SKILL.md", + "version": "29" + }, + { + "hash": "4cc0b1ac151610cfb6a9e4cf5735d44b1351d9bc24c5ff53ba2aac01035cec12", + "path": ".claude/skills/codewiki/SKILL.md", + "version": "29" + }, + { + "hash": "88b152ef75a40b6691554730d86a58e240152ed7683333a8638fec5107474f09", + "path": ".claude/skills/glossary/SKILL.md", + "version": "29" + }, + { + "hash": "ef9bef5dd84dc63ed9b62ac4b87a36319f42b850c8b7de133cec9e198b308d64", + "path": ".claude/skills/init/SKILL.md", + "version": "29" + }, + { + "hash": "de8a01ad55219f152ec682d4a49a452f61cdd3379c08f932e3cccbcf87f7b0b8", + "path": ".claude/skills/plan-run/SKILL.md", + "version": "29" + }, + { + "hash": "1e9206e87770898bc7382348db30f6446f63c8716770669c1d84f0df9b413275", + "path": ".claude/skills/prd/SKILL.md", + "version": "29" + }, + { + "hash": "865f88cef91fa59f6a42650ef1c518141f662d194058caf5fa8a1cc6093bc1dc", + "path": ".claude/skills/stack/SKILL.md", + "version": "29" + }, + { + "hash": "15aa510d8b7af6f7b72c1093ae772b66135c63e8192af1d0662f1e17de232765", + "path": ".claude/skills/wiki/SKILL.md", + "version": "29" + }, + { + "hash": "9dc2f234756bfdc79aa5697e8470f80766e68a0e72b6cda98107e25ea8a9427a", + "path": "CLAUDE.md", + "version": "29" + } + ], + "harness": "claude", + "scc": "v0.24.1" +} diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..b3688d8 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,40 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "command": "scc hooks run session-start", + "statusMessage": "scc", + "timeout": 60, + "type": "command" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "command": "scc hooks run stop", + "statusMessage": "scc", + "timeout": 120, + "type": "command" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "command": "scc hooks run user-prompt", + "statusMessage": "scc", + "timeout": 10, + "type": "command" + } + ] + } + ] + } +} diff --git a/.claude/skills/adr/SKILL.md b/.claude/skills/adr/SKILL.md new file mode 100644 index 0000000..83f2ad2 --- /dev/null +++ b/.claude/skills/adr/SKILL.md @@ -0,0 +1,88 @@ +--- +name: adr +description: Record a decision that is hard to reverse as a numbered ADR under docs/adr/, and supersede an old record rather than editing it. Use it when a choice is made that would be expensive to undo — storage, wire protocol, a module boundary, a vendor holding data, a framework the code will shape itself around — when someone asks why the project is built this way, and when `scc validate` reports adr.* findings such as a numbering gap, a superseded record with no successor, or a citation resolving to no record. +--- + +You write the record of a decision, so that the reasoning survives the people who +were in the room. The filename shape, the frontmatter, and the citation form are in +`.claude/rules/knowledge-base.md`. This skill is when to write one +and how to write one worth reading. + +## Is this an ADR? + +One question: **how expensive would it be to undo?** + +Write one when the answer is "expensive" — a datastore, a wire format, an +authentication model, a boundary between services, a vendor that will end up holding +data, a framework the rest of the code will be shaped around. Also write one when a +previous decision is being **reversed**, which is the case people skip and the case a +reader most needs. + +Do not write one for a decision that is cheap to change. That belongs in the spec's +`design.md`, where it is read by the people building the feature and forgotten +harmlessly once it stops being true. An `adr/` directory full of reversible choices +buries the four records that actually explain the system. + +Rough test: if changing your mind next month means editing one package, it is not an +ADR. If it means a migration, it is. + +## Writing one + +```markdown +--- +status: accepted # proposed | accepted | rejected | superseded +--- + +# 0007 · Use SQLite for the cache + +## Context +## Decision +## Consequences +``` + +Numbered contiguously from `0001`; the filename is `NNNN-kebab-slug.md`, and the +number is never reused — gaps are findings because a gap means a record went +missing. Take the next number after the highest one present. + +**Context** is the section that does the work. Write what was true when the decision +was made — the constraint, the load, the deadline, the thing that was already built. +A reader a year from now is trying to learn whether the reasons still hold, and they +can only do that if the reasons are written down. "We chose SQLite because it is +simple" tells them nothing; "single writer, under 10GB, and operating a second +service was the cost we were avoiding" tells them exactly when to revisit. + +**Decision** is one paragraph in the active voice. What was chosen, and what was +chosen against — name the alternatives that were real, and say what ruled each out. + +**Consequences** includes the ones you do not like. A record listing only benefits +is marketing, and the next reader will discover the downside on their own and trust +nothing else in the file. + +## Superseding + +**A superseded record is marked, never edited.** The point of an ADR is that it +records what was believed at the time; rewriting it destroys the thing it exists to +preserve — and the new record loses the context that explains why the change was +needed. + +Write the new record, then in the old one set only the frontmatter: + +```yaml +status: superseded +superseded-by: 0012-move-the-cache-to-redis +``` + +Leave the prose exactly as it was. The new record's Context says what changed. + +## Findings + +| Finding | What it means | The fix | +|---|---|---| +| `adr.malformed-filename` | Not `NNNN-kebab-slug.md`. | Rename it. The number is how it is cited. | +| `adr.duplicate-number` | Two records share a number. | Renumber the later one; a citation cannot resolve to two records. | +| `adr.numbering-gap` | A number is missing from the sequence. | A record was deleted or never committed. Restore it, or renumber — do not leave the hole, because a gap reads as a lost decision. | +| `adr.missing-status` / `adr.status-invalid` | The frontmatter `status` is absent or not one of the four. | Set it. A record with no status does not say whether it is in force. | +| `adr.superseded-without-successor` | Marked superseded with no `superseded-by`. | Name the record that replaced it, or the reader is told this is obsolete and given nowhere to go. | +| `adr.unknown-successor` / `adr.unknown-citation` | A reference resolves to no record. | Fix the slug. | + +Cite a record from anywhere in `docs/` as `adr:0007-use-sqlite-for-the-cache`. diff --git a/.claude/skills/codewiki/SKILL.md b/.claude/skills/codewiki/SKILL.md new file mode 100644 index 0000000..759451a --- /dev/null +++ b/.claude/skills/codewiki/SKILL.md @@ -0,0 +1,78 @@ +--- +name: codewiki +description: Narrate an area of this codebase into docs/codewiki/, where every section cites the exact lines it explains. Use it when a subsystem is hard to enter cold and the code does not say why it is shaped that way, when someone asks for onboarding notes or an architecture walkthrough, and when `scc validate` reports codewiki.* findings — a citation that no longer resolves, one that runs past the end of its file, or a section that cites nothing at all. +--- + +You write prose that explains code, and every section of it points at the lines it +is about. The citation is what makes this different from a wiki page: it goes stale +loudly. When the code moves, `scc validate` fails instead of the page quietly +becoming a lie. + +The format is in `.claude/rules/knowledge-base.md`. This skill is +the procedure and the judgment. + +## The citation form + +A link whose text is `path:start-end` and whose target is empty: + +```markdown +## How the dispatcher routes + +[internal/cli/cli.go:48-64]() + +One switch, no registration. Adding a subcommand means... +``` + +- The path is relative to the workspace root, always — never to the page. +- A single line is `path:42`. A range is `path:42-58`. +- The target stays empty. `scc validate` resolves the path itself, confirms the file + exists, and confirms the range is inside it. + +## Writing a page + +1. **Read the area first — all of it.** You are claiming to explain this code. Every + sentence you write is checkable against the lines you cited, and a reader who + catches one wrong claim stops believing the page. +2. **Pick the sections from the reader's questions**, not from the file layout. "How + the dispatcher routes" is a section. "cli.go" is a table of contents. +3. **Cite before you narrate.** Put the citation directly under the heading, then + explain. It anchors the section and it is how you notice you are about to write a + paragraph that is not about any particular code. +4. **Every `##` section cites something.** This is checked. A section with no + citation is prose that has drifted free of what it describes — either find the + lines it is about, or the section belongs in `docs/wiki/` instead. +5. **Headings must be unique on the page.** Two identical headings share one anchor, + and a link to it lands on whichever came first. +6. `scc validate` before you call it done. + +## What to narrate, and what to leave alone + +Write about the parts where **reading the code does not tell you why it is like +that**: + +- A constraint the shape exists to satisfy, which is invisible from any one file. +- An invariant several files cooperate to hold. +- A deliberate choice that looks wrong until you know what it prevents. +- The entry point — where execution actually begins, which is often the single + hardest thing to find in an unfamiliar codebase. + +Do not narrate what a reader can see. "This function takes a name and returns a +path" is the signature restated at greater length, and a page full of that trains +people to skip the page. + +Prefer the range that makes the point. Citing a whole 400-line file says "the answer +is in here somewhere", which the reader already knew. + +## Repairing drift + +| Finding | What it means | The fix | +|---|---|---| +| `codewiki.citation-unresolved` | The file is gone or was renamed. | Find where the code went. If it was deleted, delete the section — a section about code that no longer exists is not repairable. | +| `codewiki.citation-out-of-range` | The file is shorter than the citation claims. | The code moved. **Re-read it before re-numbering**: the lines may now be something else entirely, and shifting the range to make the finding go away without reading is how a page becomes confidently wrong. | +| `codewiki.citation-invalid` | Malformed, or the path escapes the workspace. | A citation names a path inside the checkout. Fix the path. | +| `codewiki.section-cites-nothing` | A section explains no specific code. | Cite the lines, or move the section to `docs/wiki/`. | +| `codewiki.duplicate-heading` | Two sections share one anchor. | Rename one. | + +**A finding here is usually a real problem, not a bookkeeping chore.** The citation +broke because the code changed, which means the prose describing it is now suspect +in a way no validator can measure. Re-read, then rewrite what is no longer true. diff --git a/.claude/skills/glossary/SKILL.md b/.claude/skills/glossary/SKILL.md new file mode 100644 index 0000000..396d9d4 --- /dev/null +++ b/.claude/skills/glossary/SKILL.md @@ -0,0 +1,67 @@ +--- +name: glossary +description: Own docs/glossary.md — one canonical term per concept, and the synonyms nobody should use for it. Use it when a domain term is coined, contested, fuzzy, or renamed; before naming a spec, a plan, a wiki page, or an identifier that will outlive the session; and when `scc validate` reports glossary.* findings — a duplicate entry, a term listed as both canonical and avoided, or an avoided synonym used somewhere in docs/. +--- + +You own the project's vocabulary. Domain language drifts by default: two people +working in parallel produce three names for one thing inside a week, and every one +of them ends up in code, requirements, and the wiki, where nobody can tell whether +two documents are describing the same concept or two different ones. + +The entry format is in `.claude/rules/knowledge-base.md`. This +skill is when to reach for it and how to decide. + +## The entry + +One entry per line, in `docs/glossary.md`: + +```markdown +- **order total** — the amount charged, in minor units. Avoid: grand total, sum +- **workspace** — a directory holding .claude/scc-manifest.json. Avoid: project root +``` + +- The term is bold. The definition follows an em dash. +- `Avoid:` lists the synonyms, comma-separated. It is optional — a term with no + contested spellings does not need one. +- Every listed synonym becomes a finding wherever it appears as a whole word in + `docs/`. That is the enforcement, so list a synonym only when you mean it. + +## Adding a term + +1. **Check it is not already there** under another name. Read the file before adding + to it — a glossary with two entries for one concept is worse than none, because + now the canonical source is itself ambiguous. +2. **Define what it is, not what it is called.** "The amount charged, in minor + units" settles arguments. "The total of the order" settles nothing. +3. **List the synonyms you are banning**, and only those. Do not ban a word the + project uses correctly in an unrelated sense — the check is whole-word across all + of `docs/`, so banning a common word turns the glossary into noise the first time + it fires on an innocent sentence. +4. **Then use it.** Rename the occurrences in `docs/` that the validator will now + flag, and prefer the canonical term in new requirements, wiki pages, and + identifiers. +5. `scc validate` before you call it done. + +## When to reach for this + +- **A term is coined.** Something in the domain just got a name for the first time. +- **A term is contested.** Two documents or two people use different words and you + cannot tell whether they mean the same thing. They probably do; that is the point. +- **A term is renamed.** Move the old name into the new entry's `Avoid:` list — this + is the case the enforcement is most useful for, because it finds every place the + rename was not applied. +- **Before naming anything durable** — a spec directory, a plan, a wiki slug, a + type. The name will be read far more often than it was chosen. + +## Findings + +| Finding | What it means | The fix | +|---|---|---| +| `glossary.duplicate-term` | Two entries define the same term. | Merge them. One canonical term means one entry. | +| `glossary.synonym-is-canonical` | A term is defined as canonical and also listed as a synonym to avoid. | The glossary is contradicting itself. Decide which one the project uses. | +| `glossary.avoided-synonym` | A banned synonym is used in `docs/`. | Rewrite it to the canonical term — that is the entire point of listing it. If the sentence is genuinely about something else, the ban was too broad: narrow the `Avoid:` list. | + +**Do not define what is obvious to anyone in the domain.** A glossary that opens +with "user — a person who uses the system" is one nobody reads to the entry that +mattered. Define what is specific to this project, ambiguous in general use, or +already being said three ways. diff --git a/.claude/skills/init/SKILL.md b/.claude/skills/init/SKILL.md new file mode 100644 index 0000000..74d4f4c --- /dev/null +++ b/.claude/skills/init/SKILL.md @@ -0,0 +1,224 @@ +--- +name: init +description: Bootstrap this project's knowledge base from the code that already exists — survey the repository, then write docs/stack.md, docs/glossary.md, the wiki, the codewiki, the ADRs for decisions already taken, and the project rule's real build, test, and lint commands, and the delivery gate's four. Use it on a workspace whose docs/ is still the four seeded anchors, when someone asks to document an existing codebase, when a repository has just been scaffolded and nothing under docs/ is filled in, or when someone runs /scc-init. Not for one page or one new decision — the wiki, glossary, stack, codewiki, and adr skills each own their own artifact, and this run is what calls them. +--- + +You fill an empty knowledge base from a repository that already exists. + +The formats are not yours. Each artifact has an owner — the `stack`, `glossary`, +`wiki`, `codewiki`, and `adr` skills — and the rule they all enforce is +`.claude/rules/knowledge-base.md`. **Call them; do not restate them.** What this skill +owns is what no rule can: the *order* across artifacts, the survey that precedes all +of them, and the bar for what may be written at all. + +## The bar — nothing without evidence + +Everything here is **reconstructed, not remembered.** Nobody is telling you why this +system is the way it is; you are inferring it from what survived. So write only what +you can point at — a file, a dependency manifest, a CI workflow, a commit, a comment, +a migration. + +Where the code cannot tell you why, **say the reasoning is unrecorded** and move on. A +plausible reason invented here is worse than a gap, because a gap is visible and an +invention is believed. The knowledge base's whole value is that it can be trusted +without checking, and this run is where that trust is either earned or spent. + +## Before anything — is there a workspace + +`scc validate` fails outside one. If `.claude/scc-manifest.json` is absent the repository was +never scaffolded, and which harness it is scaffolded for is **the user's call, not +yours** — `scc init --claude`, `--codex`, or `--opencode`. Ask, then carry on. + +## Ask twice, then start + +One exchange, before the survey, because both answers change how much you read: + +| Ask | Answers | +|---|---| +| How deep — the anchors, or everything? | **anchors** (`stack.md`, `glossary.md`, the project rule, and a wiki a newcomer can enter) · **full** (also `codewiki/` and the ADRs for decisions already taken) | +| The whole repository, or one subtree? | the root · one package, service, or app inside a monorepo | + +Anchors is the right default for a first run. State what you took, so a wrong reading +costs a sentence rather than a session. + +## The survey — read the map, not the territory + +Cheapest sources first, and stop when another pass stops changing the map: + +1. **What was written for a newcomer** — README, CI workflows, `Makefile` or the + scripts block, the dependency manifests, the top-level directories. +2. **The structure** — `scc graph explore ""`, per + `.claude/rules/code-search.md`. A survey is exactly the shape of question the graph + answers, and exactly the one that ruins a context window when answered by reading + files. +3. **The history, for what was argued about** — `git log` over a directory that moved, + a revert, a migration, a dependency swapped out. This is the cheapest evidence that + a decision was expensive, which is what an ADR needs and what code alone never says. + +**Report the map back before writing anything** — the areas, the concepts you would +give pages, the decisions you would record. That is the last moment a correction is +cheap. + +## Then write, in this order + +Cheapest and most checkable first, most interpretive last, with `scc validate` between +stages so no stage inherits the previous one's findings. + +1. **`docs/stack.md`** — the `stack` skill. It has a finish line nothing else here + has: `stack.undocumented-dependency` reaching zero, because the validator reads the + direct dependencies straight out of the manifest. Where nobody can justify one, say + what it is *used for* — the import sites are a fact you can point at — and mark the + decision unrecorded. Do not invent a rationale, and do not delete a dependency to + silence a finding. +2. **`.claude/rules/project.md`** — the build, test, lint, and format commands. **Run each + one before you write it.** Take them from CI first, since a workflow file is the one + place somebody maintains them, then verify locally. A guessed command that exits `0` + looks exactly like a passing suite, which is the whole reason that rule exists. + + **Then record the same four as the delivery gate — `scc check set + ""`.** The rule above is prose somebody reads; these are run, and their + answers decide whether a branch becomes a pull request. There are four gates and + they run in this order, stopping at the first failure: + + | Gate | What it has to do | Typical source | + |---|---|---| + | `build` | the project compiles | `go build ./...`, `tsc --noEmit`, `cargo build`, `mvn -q compile` | + | `format` | the formatter has nothing to say — the *check*, not the rewrite | `gofmt -l .`, `prettier --check`, `black --check`, `cargo fmt --check` | + | `lint` | the best-practices layer | `golangci-lint run`, `eslint .`, `ruff check`, `clippy -- -D warnings` | + | `test` | the suite passes, and covers enough | see below | + + **A language that genuinely has no such step gets `scc check skip `, once.** + That is a decision and it is recorded like one — the validator then stays quiet + about it forever. Leaving a gate unrecorded is *not* the same thing: that is a + decision nobody has made, and it is reported on every run until somebody makes it. + Skip deliberately and rarely: most languages have a formatter, and "we have no + linter" is usually "nobody has picked one yet", which is a finding worth keeping. + + **The format gate is the check, never the rewrite.** `gofmt -w .` exits `0` having + silently changed the tree; the gate has to be the one that *fails* on unformatted + code, or it certifies every run. + + The test gate asks for one thing more — a JSON object anywhere in its output: + + ``` + {"total": 412, "coverage": 88.4} + ``` + + Nothing in scc knows how your project produces those, which is why writing this is + *your* job on this run. Build it from what the survey already told you: + + | Stack | Where the two numbers come from | + |---|---| + | Go | `go test -json -coverprofile=…` for the count, `go tool cover -func` for the total line | + | Node / TS | `jest --coverage --json`, `vitest run --coverage --reporter=json` — both already emit totals | + | Python | `pytest --cov --cov-report=json -q`, and the summary line for the count | + | Rust | `cargo test` for the count, `cargo llvm-cov --summary-only` for coverage | + | Anything else | whatever CI already runs, plus three lines of shell that print the object | + + **Put a pipeline in the repository, not in the manifest.** Record a target — `make + test-report`, `npm run test:report`, `./scripts/test-report.sh` — and set *that* as + the command. A pipeline recorded inline is one nobody can run by hand, fix, or review + in a diff. A single command that already does the job goes in as it is. + + Three things to get right, because each has a failure mode that looks like success: + a wrapped command's **exit status has to survive** the wrapper (a script ending in + `echo` returns the echo's `0` and reports a broken build as green); the test report + goes to **stdout** and the runner's own noise to stderr; and **`total` is a real + count**, not a constant — zero tests with high coverage is a finding, and so is a + number nobody updates. + + Then run `scc check` and read what it says. A gate that does not do what it claims + is exactly as useless as no gate, and this is the one moment where fixing it costs + nothing. +3. **`.devcontainer/Dockerfile`** — fit the container to this project. `scc init` + seeds it with the agent's own toolchain (Claude Code, `scc`, `codegraph`, `rtk`) + and a placeholder for yours; what it cannot know is the language, which you have + just surveyed. Fill the marked block with what **`scc check` actually runs** — + the compiler, the package manager, the linter and the formatter you recorded in + step 2 — and nothing that only a human would want. + + | This project | Add | + |---|---| + | Go | `golang` at the version in `go.mod`, plus `golangci-lint` if the lint gate uses it | + | Node / TS | the Node version in `.nvmrc` or `engines`, and the package manager the lockfile implies | + | Python | `python` plus `uv`/`poetry`/`pip` — whichever the lockfile is for | + | Rust | `rustup` with the toolchain in `rust-toolchain.toml` | + + **The bar is the gate, not the language.** A container that cannot run + `scc check` is a container the delivery gate cannot be verified in, which is the + whole reason this file exists. So the test is mechanical: build it, open a shell + in it, and run `scc check`. If a gate fails for a missing tool, that tool belongs + in the Dockerfile. + + This matters most on **Windows**, where it is not a convenience: ai-jail's + sandbox has no Windows backend, so the container is the only isolation + `scc launch` can give. On Linux and macOS the same file is useful and optional — + the kernel sandbox is what runs there. + + Leave the credentials alone. The seeded `devcontainer.json` forwards a token and + mounts `.gitconfig` read-only on purpose; **do not add a mount for `~/.ssh` or a + cloud credential file**, which is the one change that would hand a compromised + dependency the keys the container exists to keep away from it. +4. **`docs/glossary.md`** — the `glossary` skill, conservatively. Every synonym listed + after `Avoid:` becomes a finding wherever it appears under `docs/`, so list one only + where you actually saw two names used for one thing. Take terms from the domain this + project is about, never from its framework. +5. **`docs/wiki/`** — the `wiki` skill. One page per concept a newcomer has to hold to + read the code, never one per directory: a wiki that mirrors the file tree *is* the + file tree, and it goes stale faster. Link every page from `index.md` and log the run + in `changelog.md`. +6. **`docs/codewiki/`** — the `codewiki` skill, only where reading the code does not + tell you why it is shaped that way. Every section cites the lines it explains, and a + citation is a promise to keep the page current, so cite the part that is stable. +7. **`docs/adr/`** — the `adr` skill, last, and the one to be strictest about. A + decision qualifies only when both hold: undoing it would be expensive, **and** there + is evidence in the repository that it was taken. Number from `0001` in the order the + history says they happened. + + **Say that the record is reconstructed.** An ADR is what was believed at the time, + and these were not written at the time — so open `## Context` with one line naming + what it was reconstructed from, and when. `status: accepted` where the code shows + the decision in force; never `proposed` for something already built. + +8. **`docs/notes.md`** — the `TODO`, `FIXME`, `HACK` and aside comments already in the + code, moved into the log one line each with the file they sit on: + `scc notes add "…" --tag --path `. Leave the comment where it is; this run + does not touch code, and it goes when that file is next edited. What does not make + the move is anything that is really work — that is a task in a plan, not a note. + +## What this does not do + +- **It does not write specs.** Documenting a system that already works as `specs/` + means restating the whole product as requirements. `.claude/rules/specs.md` is explicit + that a spec meets existing code as a **delta**, so the first spec here is written by + the next change and not by this run. +- **It does not rewrite `CLAUDE.md`.** That file is the user's. +- **It does not touch code.** A defect you notice is worth reporting at the end; it is + not this run's to fix, and a documentation branch that also changes behavior is one + nobody can review. + +## Finishing + +`scc validate --checks` exits `0` — the artifacts in shape and every gate actually +doing what it claims — then deliver it as ordinary work: one branch, one pull +request, `.claude/rules/delivery.md`. + +Then report **what was left unknown**, by name: the dependency nobody could justify, +the area whose reasoning is unrecorded, the decision that looked expensive and had no +evidence behind it. That list is the most valuable thing this run produces, and it is +the part that disappears if you do not write it down. + +## Re-running + +It is additive and resumable, and it takes its position from the repository rather +than from memory. Read what `docs/` already holds, **never rewrite a page somebody +else wrote**, clear `scc validate` findings first and fill gaps second. A second run +over a documented workspace should report that there is nothing to add — not produce a +second account of the same system. + +## Degrading + +No graph, no CI file, no history worth reading — none of that stops the run. Say which +one is missing, fall back to what is there, and lower the claim rather than the +honesty: five pages somebody can check beat a whole knowledge base assembled out of +inference. diff --git a/.claude/skills/plan-run/SKILL.md b/.claude/skills/plan-run/SKILL.md new file mode 100644 index 0000000..62c8431 --- /dev/null +++ b/.claude/skills/plan-run/SKILL.md @@ -0,0 +1,259 @@ +--- +name: plan-run +description: Drive a whole plan under plans/ to completion — brief the plan once, report the groups, take whatever the invocation already decided and ask only for the rest, then implement group by group, asking scc for the next task rather than opening the file, and deliver either one PR per group or one at the end, settling CI before calling the plan delivered. Resumes from the repository rather than from memory. Use it when someone asks to implement an entire plan, to keep going until the plan is finished, or runs /scc-plan-run. Not for a single spec or a one-off change, which delivery.md already carries end to end on its own. +--- + +You run a plan to the end. + +The mechanics of delivering *one* unit of work are not here — they are in +`.claude/rules/delivery.md`, and repeating them would give this project two +copies of one procedure. This skill owns only what a loop adds on top: choosing the +next group, knowing when to stop, and finding your place again after a session dies. + +And it owns one thing that is nobody's default to assume — **how the loop runs is the +developer's decision, taken once, after they have seen the groups.** + +## What "delivered" means + +The plan is delivered when **CI is green on the pull request that carries it** — not +when you believe the work is done. + +That distinction is the point of this whole skill. Your own assessment of a finished +group is a claim; a green pipeline on a pushed branch is a fact about the repository +that a person can check without you. Every stopping rule below is written against +that fact. Never report a plan as delivered on the strength of a passing local suite: +say the PR is open, say where, and say what CI is doing. + +## What a group is + +A **group** is one family of task numbers sharing a major number — `1.1`, `1.2` → group +1 — and it is the smallest part of the plan that can merge on its own. Implementing it +means those tasks, in order. + +A task that names a spec under `## References` is that spec: run the ordinary cycle for +it, and tick the task when the spec closes. The reference itself is never ticked — +its state lives in that spec and is read from there. + +**You do not decide the order and you do not look for prose that overrides it.** +`scc map tasks --next` is the order: eligible first, then priority, then number. +A task that names a dependency waits for it; `--blocked` says what an impasse is on. + +A plan with a flat, unnumbered checklist has exactly one group. Say so and run it +once, rather than inventing a decomposition the author did not write. + +## Before the first group — brief, report, then ask what is still open + +1. **Brief the plan.** `scc map brief ` gives you the title, why it exists, the + paths, the references and what "done" means — the header and nothing else. Then + `scc map ` for the group counts. **Never open the plan file**: `brief` reads + the header, `tasks` reads the checklist, and there is nothing else in it. Ask + nothing yet — the questions below are only answerable by someone who can see what + they are agreeing to. +2. **Name the groups back, numbered, in order.** Order is the one thing a person can + correct cheaply now and expensively after three merges. +3. **Take every answer the invocation already gave, and ask only for what is left.** + A prompt like *"implement the whole plan, open one PR at the end, and if CI passes + it is delivered"* has answered most of the table below in one sentence. + Re-asking what somebody just typed is the friction that stops people using this + skill at all. Restate what you took, so a wrong reading is cheap to correct, then + ask for the remainder in a single exchange. + +| Ask | Answers | Recorded as | +|---|---|---| +| Run every group straight through, or stop at each group boundary for review? | automatic · gated | `autonomy: auto` · `gated` | +| One PR at the end of the plan, or one per group? | at the end · per group | `pr: per-plan` · `per-group` | +| Once a PR is open — wait for CI and merge, merge without waiting, or stop and let me merge? | wait and merge · merge now · stop at the PR | `ci` + `merge`, below | + +The plan's frontmatter may already carry `autonomy` and `ci` from when it was written. +**Show those as the proposed answers and confirm them; do not ask blind, and do not +treat them as decided.** They were given for authoring the plan — this is a loop that +will open and merge pull requests for hours, which is a larger thing to agree to. + +Say what each answer costs, in one line each, because some of them have a consequence +that only shows up later: + +- **wait and merge** (`ci: wait`, `merge: auto`) — the loop runs to the end unattended. +- **merge now** (`ci: no-wait`, `merge: auto`) — fastest, and every later group + branches from a base CI never checked. Say this out loud before accepting it. +- **stop at the PR** (`merge: manual`) — the loop pauses after every group until the + merge lands. It is no longer unattended, and that is a legitimate thing to want. +- **one PR at the end** (`pr: per-plan`) — the cheap shape, and measurably the fast + one: the groups become sequential commits on a single branch, the review subagents + run once over the whole diff instead of once per group, and CI settles once. What + it costs is granularity — a large diff to review, a red pipeline that does not say + which group broke it, and nothing landed on `main` if the run stops half way. Right + for a plan whose groups are one coherent piece of work; wrong when the groups ship + to users independently or when someone else has to review them as they land. + +**Write every answer into the plan's frontmatter before starting**, then never ask +again for this plan: + +```bash +scc patch fm autonomy=auto ci=wait pr=per-plan merge=auto +``` + +```yaml +--- +autonomy: auto +ci: wait +pr: per-plan +merge: auto +--- +``` + +`patch fm` writes each key by name — replacing one that is already there, adding one +that is not — and re-validates afterwards, so a typo'd value is refused rather than +recorded as an answer nobody gave. It is also the first write of the loop, and doing +it this way means the plan never has to be in context to be configured. + +That is what makes a resumed session pick up where this one stopped instead of +interrogating the developer a second time. `scc validate` checks the values. + +4. If the invocation carried a standing instruction, restate it and apply it to + every group — carry it into each group explicitly, rather than trusting it to + survive the context between the first group and the fifth. + +## The loop + +Both shapes implement the same groups in the same order. What differs is how often +you stop to deliver. + +### `pr: per-group` — one merged pull request per group + +1. **Start green.** In the primary checkout, `git switch main && git pull --ff-only`. + Every group branches from the previous group's merge, which is the whole reason + this is a loop and not a fan-out. +2. **Branch** in the checkout you are in, as `.claude/rules/delivery.md` has + it. One directory for the whole run, which means the checkout goes back to `main` + and clean when the group ends. +3. **Implement the group, one `--next` at a time.** `scc map tasks --next + --group N --json` gives you the one task to do; do it, verify it, tick it, ask + again. That loop — one call per task — is why the plan never has to be in context. + A task naming a spec gets the spec cycle. +4. **Deliver**, following delivery.md's sequence in full — suite and lint, `scc + validate`, both review subagents, commit, push, open the PR. Then record it on every + spec the group delivered: `scc spec track --here --pr `. A plan run + opens more branches than anything else here, so it is the loop where an unrecorded + one is most likely to be the branch nobody comes back to. +5. **Record the group's state in that same PR.** The checkboxes are ticked in the plan + file, in the branch that does the work, so `main` and the plan agree the moment the + merge lands — `scc patch check 1.1 1.2 …`, which addresses each task by + number and re-validates the file. +6. **CI and merge, exactly as answered.** `ci: wait` means watch the checks until they + settle and fix what is red before merging. `merge: auto` means you merge once that + answer is satisfied; `merge: manual` means you open the PR, say where it is, and + stop — the loop resumes when the developer's merge is on `main`. +7. **Back to a green main** — `git switch main && git pull --ff-only`, with nothing + uncommitted left behind. The next group branches from there. +8. **Report the group in one line**, then start the next. + +### `pr: per-plan` — one pull request at the end + +Branch once from a green `main`, then for each group in order: + +1. **Implement the group**, exactly as above — one `--next` at a time, each verified + before the next. +2. **Run the suite, the lint, and `scc validate` before moving on.** These stay per + group and are not deferred with the rest. They are cheap, and they are what makes a + later failure attributable: a break caught at group 3 is group 3's, while the same + break found after group 9 costs a bisect. +3. **Commit the group on its own**, with the group in the subject, and tick its + checkboxes in the same commit — `scc patch check 2.1 2.2 …`, as above. The + commits are the granularity this shape gives up in pull requests — do not squash + the plan into one. +4. **Report the group in one line**, then start the next. Do not push a PR yet. + +Then, once — and only once every group is in: + +5. **Run both review subagents over the whole branch diff.** This is the deferral that + makes the shape cheap. Their findings are fixed on the same branch before anything + is pushed. +6. **Push and open one PR** covering the plan, its body naming every group it carries. +7. **CI and merge, exactly as answered** — the same rules as step 6 above, applied + once. Green CI here is the plan delivered. + +## Where the loop stops + +Stopping part-way is a legitimate outcome, and continuing past any of these is not: + +- A review subagent returns `blocked`, or any `blocker`/`critical` finding survives a + round of fixes. +- CI is red twice on the same cause. A second identical failure is a person's + problem, not a third push. +- The branch conflicts with `main` in a way that is not mechanical — something landed + that the plan did not account for. +- The group turns out to need a decision the plan never made. Bring the decision back; + do not make it silently in the middle of a loop nobody is watching. +- `--next` reports nothing eligible while open tasks remain. `--blocked` names what + each is waiting on; a cycle or a dependency on a struck-out task is a defect in the + plan and exits `2`. +- The plan reports **drift** — it was edited outside `scc` after being approved. Say + so and stop: something changed the work the developer signed off on, and `git diff` + is the answer, not `plan reseal`. + +**Work that turns up mid-loop is discovery, not an edit.** In an approved plan, +`scc patch add --group N --text "…" --reason "…"` allocates the next number +and records why it was not in the plan; `scc patch rm 2.3 --reason "…"` strikes +a task out where it stands. Neither touches the prose, and neither is a reason to open +the file. + +Under `autonomy: gated`, stop at every group boundary and wait, having reported what +merged. + +Whenever you stop, say which groups are done, where the work is, and which are +untouched. A half-run plan the user cannot locate is worse than one that never +started — and under `pr: per-plan` that is the whole run, sitting unmerged on a +branch, so name the branch every time. + +## Resuming + +Derive your position from the repository, never from what you remember. A compaction, +a crash, or a fresh session must land in the same place. **The answers are in the +plan's frontmatter — read them and carry on, do not ask again.** They were the +developer's call once; asking a second time because your context died makes them pay +for your problem. + +Resuming is a question about state, not about prose, so read it as state: `scc map +` for the counts and `scc map tasks --next` for what to do. Re-reading a +whole plan to find one unticked box is the cost this loop would otherwise pay every +time a session dies. `brief` again only if you have lost what the plan is for. + +Which branch you read that from depends on the shape: + +- **`pr: per-group` — read `main`.** Switch to it, pull, and map the plan there; a + half-finished group branch is stale by construction. A group whose boxes are ticked + on `main` is done, as is a task naming a spec whose `tasks.md` is fully ticked + there — `scc map + trace specs//` answers that in one call, without opening either file. An + open PR means that group is mid-flight; under `merge: manual` that is the expected + resting state. Finish it before starting another — two open groups is the fan-out + this loop exists to avoid. +- **`pr: per-plan` — read the plan's branch.** Nothing reaches `main` until the end, so + `main` will say no group is done and it will be wrong. Find the branch, read its log + for the per-group commits, and map the plan **there**. If every group is committed + but no PR is open, the run died between the last group and the review pass: run the + subagents and push. If the PR is open, the run died waiting on CI. + +A plan whose frontmatter carries no `pr` or `merge` is a plan no loop has +run over. Ask what the invocation did not already answer. + +## Degrading + +- **No remote, or no `gh`.** delivery.md stops at the branch, and a loop cannot — + under `pr: per-group` the next group needs the last one on `main`, and under + `pr: per-plan` there is no pipeline to make "delivered" mean anything. Say so *when + you ask the questions*, not after the first group is written: "there is no remote + here, so this runs local-only — the work merges into your `main` with `git merge + --no-ff`, no PR is opened and no CI runs, so the suite passing locally is all the + evidence you will get." That turns a degraded run into something the developer + chose. Never let it look like every group was reviewed, and never call such a plan + delivered. +- **The plan grows while the loop runs.** Re-map it at each group boundary — `scc map + ` — because this is the read the loop performs most often and the one that + most tempts you to reach for the file instead. The group list from step 2 is a + report, not a contract, and a group appended after you started is still part of the + plan. +- **The plan is still a draft.** `scc plan approve ` before the first group: it + validates, fixes the content, and seals it, so a later edit made outside `scc` is + visible rather than silent. A plan that will not approve has findings — report them + and stop, rather than running a plan whose own validator rejects it. diff --git a/.claude/skills/prd/SKILL.md b/.claude/skills/prd/SKILL.md new file mode 100644 index 0000000..eef5865 --- /dev/null +++ b/.claude/skills/prd/SKILL.md @@ -0,0 +1,90 @@ +--- +name: prd +description: Turn an initiative that spans more than one feature into a plan under plans/ — a short header, a checklist of tasks, and references to the specs that will be built separately. Use it when someone arrives with a PRD, a roadmap item, an epic, or a rough idea too large for one spec, and the first job is to find out what it actually decomposes into. For a single feature whose shape is already clear, skip this and run `scc spec new`. +--- + +You take an initiative that is too big for one spec and turn it into a plan: one +file under `plans/`, holding the decomposition and nothing else. + +**A note on the word.** "PRD" is what you arrive with — a product document, a +roadmap line, a paragraph in a chat. It is not an artifact this project keeps. The +artifact is a **plan**, and the vocabulary in +`.claude/rules/routing.md` is what the rest of the workspace uses. Do not +create a `docs/prd/` directory. + +## First, check this is a plan at all + +`.claude/rules/routing.md` asks one question, and it is worth asking +before you start: does this need requirements and a design settled before any code? + +- **Too large for one spec** — decomposes into several. That is a plan. Continue. +- **One feature, requirements not yet settled** — that is a spec. Run + `scc spec new ` and write `requirements.md`. Do not wrap one feature in a + plan for ceremony. +- **Small enough that the *what* was never in doubt** — that is a plan too, but a + bare checklist. Skip the interrogation below and write the list. + +## Then find out what it really is + +An initiative arrives underspecified. Your job before writing anything is to make it +specific enough to decompose, and the fastest way there is **a small batch of +concrete multiple-choice questions**, asked once — not a long interview, and not +guessing: + +1. **Who is this for, and what can they not do today?** An initiative that cannot + name the user is not ready to be decomposed. +2. **What is in and what is explicitly out?** The out-list is the more valuable + half; it is the one that prevents the third spec from growing a fourth. +3. **What does "done" mean for the whole of it?** Not per feature — the condition + under which the initiative is finished and the plan can be closed. +4. **What is already decided and not up for discussion?** A deadline, a vendor, an + existing system that has to keep working. + +Ask them together, offer concrete options rather than open prompts, and stop asking +once you can name the leaves. **If a question's answer would not change the +decomposition, do not ask it.** + +## Decompose + +``` +scc plan new +``` + +Then fill it in. **The plan's sections are closed** — the title, one to three +sentences, then `## Why`, `## Paths`, `## References`, `## Out of scope`, `## Tasks`, +`## Done when`, and nothing else. There is deliberately nowhere for prose to grow: the +file is carried by every session that runs it. The rules it must hold to: + +- **`## Tasks` holds the work; `## References` names the specs.** A task carries a + checkbox and the box is its state. A reference carries none — that spec's state + lives in that spec and is read from there. An item that does both keeps two records + of one fact, and `scc validate` reports it as `plan.item-has-two-records`. +- **A referenced spec is an ordinary spec.** `specs//` is not nested under + the plan and is built by exactly the same rules as one somebody asked for directly. + A task that consumes one is ticked when that spec closes. +- **Each spec-sized piece is one coherent feature** — something a person could + describe in a sentence and verify on its own. If it needs three sentences and an + "and", it is two. +- **Order is `_Depends_`, not position.** A task that cannot start until another is + done says so: `_Depends 1.1_`. `_Priority 2_` breaks a tie. Nothing else records + order, and nothing restates it in prose. +- **A plan is work, not knowledge.** It lives in `plans/`, never in `docs/`. + +Referenced specs must exist, or `scc validate` reports `plan.unknown-spec`. Create +them with `scc spec new` as you name them — an empty spec directory is a real +placeholder; a reference to nothing is a broken plan. + +## Before you hand it over + +- `scc validate` — exit 0, or fix what it names. Then `scc plan approve `, + which fixes the content and seals it, so a later edit outside `scc` is visible + rather than silent. Approving is the hand-over. +- **Read the list back as a whole and ask what is missing.** Migration, backfill, the + switch-over, the thing that has to keep working while this ships, and how it gets + turned off if it goes wrong. Decompositions fail at the seams, not in the middle of + a feature. +- **Check the vocabulary.** Every term the plan coins is a term three specs will + inherit. If any of them is contested or new, use the `glossary` skill now, while + it costs one edit. +- **A choice made here that is hard to reverse is an ADR**, not prose in the plan. + Use the `adr` skill, and cite the record from the item it governs. diff --git a/.claude/skills/stack/SKILL.md b/.claude/skills/stack/SKILL.md new file mode 100644 index 0000000..9ad8ce4 --- /dev/null +++ b/.claude/skills/stack/SKILL.md @@ -0,0 +1,74 @@ +--- +name: stack +description: Own docs/stack.md — every adopted technology with one line on why it earned its place. Use it before adding a dependency to go.mod, package.json, or any other manifest; when a technology is being swapped, dropped, or evaluated; and when `scc validate` reports stack.missing or stack.undocumented-dependency, which means something is installed that nobody recorded a decision about. +--- + +You own `docs/stack.md`. The rule it enforces is in +`.claude/rules/knowledge-base.md`, and it is short: **technology +not listed here is an open decision, never something adopted silently.** + +This is checkable because dependency manifests are structured data. `scc validate` +reads the direct dependencies out of `go.mod` and `package.json` and reports any +that `stack.md` does not mention. Indirect dependencies are skipped — nobody decided +those. + +## Adding a dependency is two steps + +Adding it to the manifest is the first. The second is here, and it happens in the +same change, not later: + +1. **Say what problem it solves** — the one you actually have, not the category. +2. **Say what it was chosen over**, if anything real was considered. This is the + line that pays off in a year, when someone asks why not the obvious alternative. +3. **Say what it costs.** Every dependency is a supply-chain surface, a version to + keep current, and an API someone will have to learn. If you cannot name the cost + you have not finished evaluating it. + +```markdown +## Go + +- **stdlib only** — the binary ships to six platforms and every dependency is a + supply-chain surface. A dependency has to be worth that. +``` + +The validator matches on the module path or its last segment, so writing `chi` for +`github.com/go-chi/chi` is enough. It is looking for a decision, not a spelling. + +## Before you add anything + +Ask the three questions, in this order: + +1. **Can the standard library do it?** For most of what a dependency is reached for + first, it can, at the cost of code you then own. Sometimes that trade is right. +2. **Is this dependency load-bearing or convenient?** A convenience with a + maintainer who stops answering is a migration you did not plan. +3. **Does the project already have something that does this?** Two libraries solving + one problem is a cost paid by everyone who reads the code afterwards, forever. + +**If the answer is not obviously yes, stop and ask.** Adopting technology is a +decision with a long tail, and it is one of the few things worth interrupting an +automatic run for. Bringing a dependency in unasked is not autonomy, it is a +commitment made on someone else's behalf. + +## Removing one + +Delete it from the manifest, delete the entry, and say in the commit what replaced +it. A stack entry for something no longer installed is worse than a missing one: it +describes a project that does not exist. + +## When it is also an ADR + +`stack.md` is the inventory — what is adopted, and why. An **ADR** is the record of +a decision that is hard to reverse. Reach for the `adr` skill as well when the +choice locks something in: a database, a wire protocol, a framework the code will +shape itself around, a vendor with data in it. + +The two do not duplicate each other. The stack entry stays one line and names the +ADR; the ADR carries the context, the alternatives, and the consequences. + +## Findings + +| Finding | What it means | The fix | +|---|---|---| +| `stack.missing` | The project declares dependencies and has no `stack.md`. | Write it, one entry per direct dependency already installed. Expect to discover at least one nobody can justify. | +| `stack.undocumented-dependency` | Something is installed that `stack.md` does not mention. | Record the decision — or, if no one can state what it is for, remove the dependency. Both are correct outcomes. Silencing the finding by listing the name with no reason is not. | diff --git a/.claude/skills/wiki/SKILL.md b/.claude/skills/wiki/SKILL.md new file mode 100644 index 0000000..d54a641 --- /dev/null +++ b/.claude/skills/wiki/SKILL.md @@ -0,0 +1,89 @@ +--- +name: wiki +description: Build and maintain docs/wiki/ — one page per concept, linked with wikilinks and reachable from index.md. Use it when a source lands in docs/raw/ and has to be distilled into a page, when a question is better answered from accumulated knowledge than from re-reading the code, and when `scc validate` reports wiki.* findings — a broken wikilink, an orphan page, a changelog naming a page that no longer exists, or a source still sitting unprocessed in docs/raw/. +--- + +You own `docs/wiki/`: the durable half of what this project knows. A spec says what +one feature does now; the wiki says what is true across features and outlasts them. + +The format is not yours to invent — it is in +`.claude/rules/knowledge-base.md`, and `scc validate` enforces it. +This skill is the procedure. + +## Ingest — something landed in `docs/raw/` + +`raw/` is a drop box, not storage. A file still there is unfinished work, and +`scc validate` says so by name. Empty it: + +1. **Read the source completely** before writing anything. A page distilled from a + skim is worse than no page — it looks authoritative and is not. +2. **Decide the concept, not the document.** One page per concept. A source that + covers three concepts becomes three pages, or one page and two edits to pages + that already exist. Do not mirror the source's own structure. +3. **Check `docs/glossary.md` first.** If the source names something the project + already has a canonical term for, use the canonical term. If it coins a term + worth keeping, that is the `glossary` skill's job — do it, then come back. +4. **Write the page** at `docs/wiki/pages/.md`. The filename is the slug, so + `order-total.md` is what `[[order-total]]` resolves to — the slug never carries the + directory. `index.md` and `changelog.md` stay one level up, in `docs/wiki/`: they + are the wiki's fixed documents rather than pages, and keeping them out of `pages/` + is what lets the validator tell content from structure without matching filenames. + + **Name it for the concept, as something that stands on its own.** A `[[wikilink]]` + shows the reader the slug and nothing else, so the slug carries the whole signal: + it has to answer "what is this page about?" with no other context on screen. + `order-total.md`, `retry-policy.md`, `sprite-atlas.md` do. A slug lifted out of a + sentence does not — `into-an-engine.md` is the tail of somebody's heading, and a + year later nobody can tell what page that was without opening it. Test it by + reading the filename alone: if it is not a thing this project has, rename it. + + No validator catches this. Every wiki check is about the graph — whether the link + resolves, whether the page is reachable — and a badly named page passes all of + them, which is exactly why it is on you here. +5. **Link it in.** Add a `[[wikilink]]` from `index.md`, or from a page already + reachable from it. A page nobody links is a page nobody finds again. +6. **Record it in `changelog.md`** — what changed, naming the pages with wikilinks. +7. **Delete the source from `docs/raw/`.** This is the step that gets skipped. The + finding exists because of it. +8. `scc validate` before you call it done. + +A source that turns out not to be worth a page still gets deleted. "Read and +rejected" is a complete outcome; leaving the file behind pretends the work is +pending. + +## Query — answer from what is already known + +Read `index.md` first, follow wikilinks from there, and answer from the pages. Cite +the page you answered from so the reader can check you. + +If the wiki does not cover it, say so plainly and go read the code. Then consider +whether what you just had to reconstruct should have been a page — the second time +someone reconstructs the same thing is the signal, not the first. + +## Maintain — clear what `scc validate` reports + +| Finding | What it means | The fix | +|---|---|---| +| `wiki.missing-index` | Pages exist with no entry point, so every page is an orphan. | Write `index.md` and link the pages. | +| `wiki.broken-link` | A `[[wikilink]]` resolves to no page. | Fix the slug, or write the page it was reaching for. | +| `wiki.orphan-page` | The page exists and nothing reaches it. | Link it from `index.md` or from a reachable page. Or delete it — an unreachable page nobody has missed is a candidate. | +| `wiki.missing-changelog` | The wiki has pages and no log of how it got that way. | Write `changelog.md`. | +| `wiki.changelog-desync` | The log names a page that no longer exists. | The page was renamed or removed; the log is a record, so correct the entry rather than rewriting history around it. | +| `wiki.legacy-page` | A page sits directly in `docs/wiki/`, from before `pages/`. | `git mv` it into `docs/wiki/pages/`. The slug does not change, so no wikilink moves with it. | +| `wiki.duplicate-page` | Two files claim one slug, so `[[it]]` is ambiguous. | Usually half a migration: keep the copy in `pages/`, delete the other once you have merged anything only it has. | +| `wiki.unprocessed-source` | A file is still in `docs/raw/`. | Ingest it, above. | + +**`index.md` is a map, not a dump.** When it grows into a flat list of every page, +group it — the entry point earns its keep by making the shape of the knowledge +visible, and a hundred unsorted links do the opposite. + +## What does not belong here + +- **A decision that is hard to reverse.** That is an ADR — use the `adr` skill. The + wiki can link to it; it does not restate it. +- **An explanation of specific code.** That is `docs/codewiki/`, where every claim + cites the lines it is about — use the `codewiki` skill. +- **What one feature does now.** That is `specs//requirements.md`. +- **Anything you have not verified.** The wiki's whole value is that it can be + trusted without checking. One confidently wrong page costs more than the ten + correct ones beside it earn. diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..a16cb21 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,102 @@ +# A starting point, not a base image — this file is yours from the moment it is +# written. scc scaffolds it once and never updates it, the way it seeds docs/. +# +# `/scc-init` adjusts it to this project: the language toolchain, the package +# manager, and whatever the delivery gate's commands actually need. A container +# that cannot run `scc check` is a container the gate cannot be verified in, which +# defeats the point of having one. +# +# The base is node-shaped whatever the project is written in, and that is about +# the *agent's* toolchain rather than the project's: three of the four tools below +# ship on npm, so an image without node cannot install them — and features cannot +# supply it either, since they are layered on after this file has already run. +# A Go or Python project installs its own toolchain at the bottom and is none the +# worse for the node underneath. +FROM mcr.microsoft.com/devcontainers/javascript-node:22 + +# The agent's own toolchain. Every one of these is named by something scc +# scaffolds, which is the whole reason they are here rather than left to taste: +# the rules answer questions with `scc`, the entry file tells the agent to prefix +# commands with `rtk`, and `code-search.md` tells it to ask `codegraph` before +# reading files. An agent inside a container missing them discovers that one +# failed command at a time and falls back to reading whole files — the exact cost +# this methodology exists to remove. +# +# As `node` rather than as root, because this image puts that user's global prefix +# (/usr/local/share/npm-global) on PATH and a root install would land somewhere +# the agent does not look. +RUN su node -c "npm install -g \ + @anthropic-ai/claude-code \ + @protonspy/scc \ + @colbymchenry/codegraph" + +# `gh` — the delivery flow opens the pull request with it, and `scc validate --pr` +# reads the PR body through it. +RUN apt-get update \ + && apt-get install -y --no-install-recommends gh \ + && rm -rf /var/lib/apt/lists/* + +# RTK is the slow layer: it is a Rust build, so this installs a toolchain, builds +# one binary and throws the toolchain away. Delete this block if you would rather +# have fast rebuilds than filtered command output — nothing else depends on it, +# and `scc launch` will simply report that rtk is not here. +# +# Built under /tmp and installed into /usr/local/bin rather than into a home +# directory: that way the binary is on PATH for every user and every kind of +# shell, instead of only an interactive one that has sourced a .bashrc. +RUN export CARGO_HOME=/tmp/cargo RUSTUP_HOME=/tmp/rustup \ + && curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --profile minimal --no-modify-path \ + && "$CARGO_HOME/bin/cargo" install --git https://github.com/rtk-ai/rtk --locked \ + && install -m 0755 "$CARGO_HOME/bin/rtk" /usr/local/bin/rtk \ + && rm -rf "$CARGO_HOME" "$RUSTUP_HOME" + +# The project toolchain — exactly what `scc check` runs, and nothing a human would +# merely like to have. The four recorded gates are `go build ./...`, +# `golangci-lint fmt --diff`, `golangci-lint run` and `go run -C tools ./testreport`, +# so this block is Go plus golangci-lint and stops there. A container that cannot +# run `scc check` is a container the delivery gate cannot be verified in. +# +# The Go version tracks CI's pin rather than go.mod's directive: go.mod says 1.25.0 +# and `.github/workflows/ci.yml` builds with 1.25.13, deliberately ahead of it, for +# the GO-2026-6088 encoding/xml fix the pom reader reaches through xml.Unmarshal. +# Keep the two in step. +ARG GO_VERSION=1.25.13 +ARG GOLANGCI_VERSION=v2.12.2 +# TARGETARCH is supplied by BuildKit, so one Dockerfile serves amd64 and arm64 +# hosts — which matters here, where an Apple-silicon laptop and an x86 CI runner +# build the same file. +ARG TARGETARCH +# +# Symlinked into /usr/local/bin rather than left on an ENV PATH, for the same reason +# rtk is installed there: a login shell sources /etc/profile, which rebuilds PATH +# from scratch and discards every ENV set in this file. A tool reachable only +# through ENV is a tool `bash -lc` cannot find — measured, and exactly how +# `scc check` failed with `go: not found` in a container that had Go installed. +# +# The tarball is checksum-verified before it is extracted. Be honest about what +# that buys: the digest comes from the same host as the archive, so it catches a +# truncated download or a bad mirror, not a compromised dl.google.com. It is a +# cheap check and this file already reasons about supply chain elsewhere, so +# leaving it out would be inconsistent rather than principled. +RUN curl -fsSL "https://dl.google.com/go/go${GO_VERSION}.linux-${TARGETARCH}.tar.gz" -o /tmp/go.tgz \ + && curl -fsSL "https://dl.google.com/go/go${GO_VERSION}.linux-${TARGETARCH}.tar.gz.sha256" -o /tmp/go.tgz.sha256 \ + && echo "$(cat /tmp/go.tgz.sha256) /tmp/go.tgz" | sha256sum -c - \ + && rm -rf /usr/local/go \ + && tar -C /usr/local -xzf /tmp/go.tgz \ + && rm /tmp/go.tgz /tmp/go.tgz.sha256 \ + && ln -sf /usr/local/go/bin/go /usr/local/bin/go \ + && ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt + +# `go install` at a pinned version rather than the upstream install script: the +# version is the one CI enforces, and piping a remote script into a shell is a thing +# this project declines to do on somebody's behalf (see docs/stack.md). GOBIN puts +# the binary where every shell looks, as above. +RUN GOBIN=/usr/local/bin go install \ + github.com/golangci/golangci-lint/v2/cmd/golangci-lint@${GOLANGCI_VERSION} + +# The module and build caches under the node user's home, since that is who runs the +# gate; a root-owned cache is the failure that looks like a network problem the first +# time the agent runs `go build`. +ENV GOPATH=/home/node/go +RUN mkdir -p /home/node/go && chown -R node:node /home/node/go diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..39e526c --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,55 @@ +{ + "name": "scc workspace", + "build": { "dockerfile": "Dockerfile" }, + "remoteUser": "node", + + // Claude Code's login, kept across rebuilds. Without this every rebuild signs + // you out: the token lives under ~/.claude, and .claude.json sits *outside* + // that directory unless CLAUDE_CONFIG_DIR points back into it — so the volume + // alone is not enough. ${devcontainerId} keeps one project's state out of + // another's. + "mounts": [ + "source=scc-agent-config-${devcontainerId},target=/home/node/.claude,type=volume", + + // Your git identity — name, email, aliases. Read-only, and not a secret: + // this is what makes a commit say who wrote it. Credentials are handled + // below, and deliberately not by mounting them. + "source=${localEnv:HOME}${localEnv:USERPROFILE}/.gitconfig,target=/home/node/.gitconfig,type=bind,readonly" + ], + + "containerEnv": { + "CLAUDE_CONFIG_DIR": "/home/node/.claude" + }, + + // Git push and `gh` authenticate with a token forwarded from your shell, not + // with a mounted key. + // + // This is the one place the container's isolation is easy to give away. Binding + // ~/.ssh in would make push work immediately and would also hand a compromised + // dependency your signing keys — Anthropic's own dev container guidance says to + // avoid mounting host secrets and prefer "repository-scoped or short-lived + // tokens", and ai-jail hides ~/.ssh outright rather than mounting it. A token is + // scoped, expires, and is revocable from a web page; a private key is none of + // those. + // + // Export one before launching — `gh auth token` prints yours: + // export GH_TOKEN="$(gh auth token)" + // An empty value is fine: commits still work, and only push and `gh` will ask. + // + // If you would rather use SSH, forward the *agent socket* rather than the keys — + // the container can then sign with a key it cannot read. That needs a mount of + // $SSH_AUTH_SOCK and SSH_AUTH_SOCK set to it, which is editor- and + // platform-specific enough that it is left to you. + "remoteEnv": { + "GH_TOKEN": "${localEnv:GH_TOKEN}", + "GITHUB_TOKEN": "${localEnv:GITHUB_TOKEN}" + }, + + "postCreateCommand": "scc version && git config --global --add safe.directory ${containerWorkspaceFolder}", + + "customizations": { + "vscode": { + "extensions": ["anthropic.claude-code"] + } + } +} diff --git a/.gitignore b/.gitignore index f62cab4..7e8d206 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,7 @@ go.work.sum /dist/ /artifacts/ npm/dist/ + +# CodeGraph's symbol index. Not an scc artifact: it is CodeGraph's schema on +# CodeGraph's schedule, rebuilt by `scc launch`, and it is machine-specific. +/.codegraph/ diff --git a/.golangci.yml b/.golangci.yml index 4a6175b..d4c84b5 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -30,3 +30,12 @@ linters: - linters: - staticcheck text: "S1016" + +# Formatters are a separate stage in the v2 schema, and enabling gofmt here is what +# gives the delivery gate a portable `format` command: `golangci-lint fmt --diff` +# exits non-zero on a diff, where a bare `gofmt -l .` lists the offending files and +# still exits 0 — a format gate that certifies every run. One binary invocation with +# no shell operators, so the same string works under `sh -c` and `cmd /c`. +formatters: + enable: + - gofmt diff --git a/docs/glossary.md b/docs/glossary.md new file mode 100644 index 0000000..30f7e26 --- /dev/null +++ b/docs/glossary.md @@ -0,0 +1,97 @@ +# Glossary + +One canonical term per concept, and the synonyms nobody should use for it. Every +name listed after `Avoid:` is reported wherever it appears as a whole word under +`docs/`, so this file lists one only where two names were actually observed. + +Reconstructed on 2026-09-15 from `CLAUDE.md`, `.claude/rules/`, `design/`, and the +`internal/paths`, `internal/artifact` and `internal/manifest` packages. The +vocabulary turned out to be near-uniform across all four, which is why there is +exactly one `Avoid:` entry below. + +## The layout + +- **workspace** — a directory holding `/scc-manifest.json`. That file is the + marker, never the harness directory, because every harness has a global twin in + the user's home. Avoid: project root +- **harness** — the agent tool a workspace is scaffolded for: Claude Code, Codex, or + opencode. Modelled as a `paths.Harness` profile that names where that tool keeps + things and what it does with them. +- **entry file** — the Markdown file a harness preloads at launch: `CLAUDE.md` for + Claude Code, `AGENTS.md` for Codex and opencode. Owned by the user; scc only + splices marker-delimited blocks into it. +- **artifact** — one file scc parses and can address: a spec's three documents, a + plan, or a page under `docs/`. Not every file in a workspace is one. +- **seed** — a `docs/` anchor `scc init` writes once and then tracks nowhere: not in + the manifest, never touched by `scc update`. Distinct from a template, which is + tracked, and from an artifact, which the user creates. +- **manifest** — `/scc-manifest.json`: one `{path, hash, version}` per + managed file, plus the delivery gate's four commands. scc's only file per harness, + and the reason it needs no configuration file. + +## The vehicles + +- **spec** — `specs//`, exactly three files: `requirements.md`, + `design.md`, `tasks.md`. The vehicle for work whose *what* and *how* need settling + before code. +- **plan** — `plans/.md`, one file: a header plus a checklist. The vehicle for + everything else. Note this is the opposite of GitHub Spec Kit's sense of the word, + where "plan" means the architecture inside one feature — which here is `design.md`. +- **delta** — a change to an existing spec written as amendments to individual + requirements, marked `(ADDED)`, `(MODIFIED)` or `(REMOVED)`, rather than a rewrite + of the file. What makes adopting the methodology on existing code possible. +- **leaf** — a plan item that references a spec rather than being a task itself. + Recognised by a `specs//` citation anywhere in the file, and carrying no + checkbox: its state is derived from that spec and never copied. + +## The grammar + +- **EARS** — Easy Approach to Requirements Syntax: the five requirement patterns + (ubiquitous, state-driven, event-driven, optional-feature, unwanted-behavior) plus + complex. A clause has named parts, so a missing part is mechanically detectable. +- **requirement id** — `R.`, and it is **scoped to its own spec**: + `R2.5` is defined in many specs at once, so it is cited as + `specs//R2.5` wherever that is not obvious from context. +- **task** — one checklist line, `- [ ] . (Unit|TDD) `, + where the checkbox is the only record of its state. Right-sized when it can be + verified on its own. +- **methodology annotation** — the required `(Unit)` or `(TDD)` on a task line, + saying how that task is built and tested. Per task, never per project. +- **flag** — one of exactly four italic one-liners under a task: `_Depends_`, + `_Priority_`, `_Status removed_`, `_Reason_`. The vocabulary is closed, so an + unrecognised italic line is a finding rather than prose. +- **address** — how `scc map` and `scc patch` name a piece of an artifact without + reading it: `1.2` a task, `R1.2` a requirement, `#notes` a section, `notes:7` a + paragraph, `specs/foo/` a leaf. `L120-160` is the escape hatch and the only form + that is a line number. +- **seal** — the `status: approved` plus `checksum:` pair `scc plan approve` writes + over a plan. Tamper-**evidence**, not prevention: `reseal --force` is one command + away and sha256 is public. + +## Checking and delivery + +- **validator** — one of the ten checks behind `scc validate`, each owning one rule + and one family of findings. Two more run only when asked: the PR check under + `--pr` and the delivery gate under `--checks`. +- **finding** — a legitimate answer to a lint question, reported as + `{rule, message, path, line}` and carrying exit code `2`. Deliberately not an + error: exit `1` means the tool could not run. +- **gate** — one of the four commands a project records for `scc check` — build, + format, lint, test — run in that order and judged by exit status. `skipped` is a + recorded decision; unrecorded is a decision nobody has made. +- **rule** — a Markdown file under `/rules/` stating one piece of the + methodology. Unscoped rules are preloaded into every request on a harness that + reads them, which is why they are a standing budget rather than documentation. +- **scoped rule** — a rule carrying a `paths:` header, so the harness loads it only + when the agent touches the tree it governs. Permitted only where a validator + reports what the rule prevents. +- **block** — a marker-delimited region scc keeps current inside a file somebody + else owns, spliced by `internal/mdblock`. Whose markers is the integration's + decision: RTK's own, so `rtk init` and `scc rtk` converge on one copy; scc's own + for CodeGraph, which writes none of its own. +- **note** — one line in `docs/notes.md`, index fields first, replacing the gotcha + comment that used to sit beside the code. One note is one line, which is what + makes a grep match a whole note. +- **kickoff answers** — the three questions asked once before the first artifact — + `autonomy`, `ci`, `lang` — recorded in that artifact's frontmatter so a second + session never re-asks. diff --git a/docs/notes.md b/docs/notes.md new file mode 100644 index 0000000..0d5abce --- /dev/null +++ b/docs/notes.md @@ -0,0 +1,45 @@ +# Notes + +Every small durable observation about this project, one per line. The gotcha, the +why-not, the "careful, this looks wrong and is not" — the things that used to be a +comment beside the code, where only the reader already looking at that line ever +found them. + +**Notes do not live in the code.** A comment says what a thing is and how to use +it; anything else is a note, and it belongs here with the path it is about +attached to it. + +One note is one line, index fields first: + +```markdown +- n-0000 2026-02-09 #gotcha @internal/cli/launch.go — wrap writes MCP config to the agent's own file, so it outlives the session +``` + +`n-0000` is the id; real ones start at `n-0001` and are never reused, so a note can +be cited as `n-0042` from anywhere — including the one place code may still mention +one. Then the date. Then `#tags`, at least one, which is what the log is queried by. +Then `@paths`, repo-relative, which is what keeps a note attached to the code without +living inside it. Then an em dash, and the note itself. + +The one line is the contract: a match is a whole note, never a fragment of one, so +`grep ' #gotcha ' docs/notes.md` and `scc notes find --tag gotcha` answer the same +question. Write with `scc notes add "…" --tag gotcha --path `, which +allocates the id and gets the format right; read with `scc notes find`, +`scc notes tags`, `scc notes show n-0001`. + +**If it needs a second line, it is not a note.** Something learned and worth +explaining is a `wiki/` page. A decision that is hard to reverse is an `adr/` +record. Something that has to be done is a task in a plan. A note is the thing +none of those three would take. + + + +## Log + + +- n-0001 2026-09-15 #gotcha @.golangci.yml — gofmt -l lists unformatted files and still exits 0, so it cannot be a format gate; golangci-lint fmt --diff exits 1 +- n-0002 2026-09-15 #platform @.github/workflows/ci.yml — go test -race needs cgo and fails on a bare Windows host; CI runs race on Linux and macOS only +- n-0003 2026-09-15 #platform @Makefile — make is absent on a default Windows host, so the delivery gate calls go and golangci-lint directly rather than Makefile targets +- n-0004 2026-09-15 #gotcha @tools/go.mod — tools/ is a nested module so the root's ./... never sees it — a helper in the main module would land in the coverage profile at 0% +- n-0005 2026-09-15 #gotcha @.devcontainer/Dockerfile — bash -lc re-runs /etc/profile and rebuilds PATH, discarding every Docker ENV, so container tooling must be linked into /usr/local/bin diff --git a/docs/stack.md b/docs/stack.md new file mode 100644 index 0000000..a2ca1cd --- /dev/null +++ b/docs/stack.md @@ -0,0 +1,111 @@ +# Stack + +Every adopted technology, with one line on why it earned its place. **Technology +that is not listed here is an open decision, never something adopted silently.** + +Reconstructed on 2026-09-15 from `go.mod`, `Makefile`, `.github/workflows/`, +`npm/`, `.devcontainer/`, and the integration packages under `internal/`. Where +nobody recorded a reason, this file says so rather than inventing one. + +## The module + +- **Go 1.25** — `go.mod` declares `go 1.25.0`; CI pins the toolchain ahead of it at + `1.25.13`, deliberately, so `GOTOOLCHAIN=auto` still installs for older setups. + Chosen for what the product is: one static binary, cross-compiled to six targets, + that an agent or a CI job can run with nothing installed underneath it. +- **The standard library, and nothing else** — `go.mod` has no `require` block at + all. The binary ships to six platforms and every dependency is a supply-chain + surface, so a dependency has to be worth that. The cost is real and visible in the + tree: `internal/mdscan` is a hand-written Markdown reader, `internal/manifest` + hand-rolls TOML and `go.mod` parsing for seven dependency-file formats, and + `internal/artifact` owns every grammar. That is code this project owns forever + instead of a version it would have to keep current. + +## Build and gate + +- **GNU make** — `Makefile` is the documented developer entry point (`make build`, + `make check`, `make test-report`, the release and npm targets). CI does not use + it; it calls `go` directly, so the Makefile is convenience rather than + load-bearing. It is not available on every developer machine — Windows hosts + typically have no `make` — which is why the delivery gate runs `go` and + `golangci-lint` directly instead. +- **gofmt** — the formatting authority, enforced in CI and by the `format` gate via + `golangci-lint fmt --diff`, which exits non-zero on a diff where bare `gofmt -l` + exits `0`. `.gitattributes` forces LF everywhere for the same reason: CRLF breaks + gofmt and the manifest's content hashes. +- **golangci-lint v2** — CI pins `v2.12.2`. `.golangci.yml` enables a deliberately + conservative set (`errcheck`, `govet`, `ineffassign`, `staticcheck`, `unused`) so + the gate stays high-signal rather than drowning in style nits. Cost: a v2 schema + that is not the v1 one most examples online are written against. +- **govulncheck v1.7.0** — pinned, and fails the build on *reachable* vulnerable + code paths. Pinned rather than `@latest` after v1.8.0 raised its `go` directive to + 1.26 and broke a job nothing else needed to change. It is the one scanner a + stdlib-only module still needs, because the stdlib itself gets CVEs. +- **`tools/` — a nested, stdlib-only Go module** holding the delivery gate's test + report. Nested rather than part of the main module so the root's `./...` never + sees it: an untested helper package in the main module would land in the coverage + profile at 0% and drag the floor down. Run as `go run -C tools ./testreport`, + which behaves identically under `sh -c` and `cmd /c` — the two shells `scc check` + uses. + +## CI and release + +- **GitHub Actions** — `ci.yml` (the gate: vet, build, race+coverage on Linux, + macOS and Windows, plus gofmt, govulncheck and golangci-lint on Linux) and + `release.yml` (manual `workflow_dispatch`: validate, gate, cross-compile six + targets, publish npm, tag). Every action is pinned by commit SHA. +- **GitHub CLI (`gh`)** — dispatches the release workflow (`make release`), and + `internal/git` reads a pull request's title and body through it for + `scc validate --pr`. Read-only from scc's side; scc never writes with it. + +## Distribution + +- **npm** — the published install surface. One launcher package (`@protonspy/scc`) + with six platform packages in `optionalDependencies`, so `npm i -g @protonspy/scc` + pulls exactly one binary. Chosen because the audience already has node: every + harness this tool targets is distributed that way. +- **Node.js ≥ 16** — `npm/scc/package.json` `engines`. The launcher is a small JS + shim (`npm/scc/bin/scc.js`) that execs the real Go binary; `npm/scripts/build-packages.mjs` + assembles the publish tree. Cost: a node dependency for people who only wanted a + Go binary — which is why direct download from the GitHub Release stays supported. + +## 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 +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, +which is why every one of them degrades to starting the agent without it — except +the sandbox, which refuses. + +- **RTK** (`internal/rtk`, `cargo install`) — a CLI proxy that filters command + output before it reaches the agent's context. scc splices RTK's own usage block + into the entry file so the agent actually uses it. +- **CodeGraph** (`internal/codegraph`, `npm install -g`) — the symbol graph behind + `scc graph` and `.claude/rules/code-search.md`. npm is the only installer scc will run + for it: CodeGraph's headline install pipes a remote script into a shell, which is + a fine thing for a person to type and not a thing scc executes on their behalf. +- **Headroom** (`internal/headroom`, `uv` then `pip`) — a compression proxy for the + agent's context. Opt-in behind `--headroom` because `headroom wrap` registers MCP + servers into the agent's own config, and those outlive the session. +- **ai-jail** (`internal/jail`, `cargo install`) — the sandbox: bubblewrap on Linux, + `sandbox-exec` on macOS. Integrated as a binary rather than reimplemented because + a sandbox is security-critical kernel-interface work and a half-copy of one has + the confidence of containment without the containment. No Windows backend. +- **Dev Containers CLI** (`internal/devcontainer`, `npm install -g`) — the Windows + sandbox backend, `up` then `exec`, so the agent runs in the container without an + editor in the loop. Weaker than ai-jail's boundary, and scc says so on the run + that gets it. +- **Docker** — what the container backend runs on. Checked separately from the CLI + because the two fail differently and a user can fix one at a time. +- **git** (`internal/git`) — read-only, always: does this branch still exist, has it + landed, what has it added, what has not been pushed, what did it change. Nothing + in that package installs or writes. + +## The dev container image + +- **`mcr.microsoft.com/devcontainers/javascript-node:22`** — `.devcontainer/Dockerfile`. + Node-shaped whatever the project is written in, because three of the four agent + tools it installs ship on npm and features are layered on after the Dockerfile has + already run. The Go toolchain is installed on top of it. diff --git a/docs/wiki/changelog.md b/docs/wiki/changelog.md new file mode 100644 index 0000000..e4cf7f2 --- /dev/null +++ b/docs/wiki/changelog.md @@ -0,0 +1,14 @@ +# Changelog + +What changed in the wiki, and when. Newest first, one line per change, naming the +pages it touched — this is the log that says whether a page was revisited after the +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-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]], + [[validation-contract]], [[delivery-gate]], [[hooks]] and + [[integration-boundary]], and wrote `index.md` to group them. diff --git a/docs/wiki/index.md b/docs/wiki/index.md new file mode 100644 index 0000000..9a25f54 --- /dev/null +++ b/docs/wiki/index.md @@ -0,0 +1,46 @@ +# Wiki + +The entry point. Every page under `wiki/pages/` has to be reachable from here — +directly, or through a page that is. + +Pages link to each other as `[[page-slug]]`, where the slug is the filename without +its extension and without its directory. + +These pages are about `scc` **as a codebase** — the concepts a newcomer has to hold +before the code reads as deliberate. What one feature does now belongs in a spec; +`design/` holds the product's own design documents, which predate this wiki and are +the source most of it was reconstructed from. + +## Start here + +- [[context-budget]] — the one idea most of the design falls out of: what an agent + 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. + +## The artifacts + +- [[artifact-addressing]] — how `scc map` and `scc patch` read and change a piece of + a file nobody opened, and why an address is never a line number. +- [[scoped-rules]] — the `paths:` header, the only lever on what the methodology + costs, and the bar a rule has to clear to use it. + +## The layout on disk + +- [[harness-profile]] — three agent tools from one template set, and why a + `switch h.ID` means the profile is missing a field. +- [[managed-files]] — the manifest, the four kinds of file `scc` writes, and the + rule that it never authors what the user owns. + +## Checking the work + +- [[validation-contract]] — exit codes as a contract, the ten validators, and the + two whose subject is not a file. +- [[delivery-gate]] — the project's own build, format, lint and test commands, as + something `scc` can run and judge. +- [[hooks]] — the git and harness layers, and the rule a `Stop` hook has to obey. + +## The outside world + +- [[integration-boundary]] — six third-party binaries, one package each, and why + everything degrades except the sandbox. diff --git a/docs/wiki/pages/artifact-addressing.md b/docs/wiki/pages/artifact-addressing.md new file mode 100644 index 0000000..02a6b48 --- /dev/null +++ b/docs/wiki/pages/artifact-addressing.md @@ -0,0 +1,74 @@ +# Artifact addressing + +`scc map` reads a piece of an artifact and `scc patch` changes one, both without the +caller ever opening the file. The whole design is in what they take as an argument. + +## An address is a name, never a line number + +``` +1.2 a task #risks a section, by anchor slug +R1.2 a requirement risks:2 the 2nd paragraph of that section +specs/foo/ a spec reference L120-160 an explicit range — the escape hatch +``` + +A line number stops being true the moment anything above it moves, so an editor that +addresses by line has to read the file first — which is the cost this package exists +to remove (see [[context-budget]]). A name survives an edit above it, so it can be +resolved against a file nobody has read. + +`L120-160` is the one form that is a line number, and it is documented as the escape +hatch rather than as an equal. + +## What replaces reading first + +Reading before editing was providing a guard, and three stronger ones replace it for +a structured file: + +1. **An address that does not resolve is an error**, never an insert at a guess. +2. **The file is re-validated afterwards and rolled back** if the edit introduced a + finding the file did not already have — exit `2`, file untouched. 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. +3. **The displaced and written lines are printed back**, elided past a few lines — + a confirmation that echoed 400 lines would put the file in context by the back + door. + +An artifact `scc` has no validator for is written and *reported as unverified*, +rather than silently claimed clean. + +## Two things measurement decided + +**`blocks` exists because section addressing bottoms out.** One measured plan's +`## Notes` ran to 411 lines — half the file — with no headings inside it, so there +was nothing to address. Every paragraph opened with a bolded thesis, so the leads +alone are an index a twentieth of the size. + +**A requirement id is scoped to its spec.** `R2.5` was defined in nine of one +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 grammar lives with the reader, not the checker + +`internal/artifact` owns every grammar — task, requirement, spec reference, flag — +and `internal/validate` consumes it. The task grammar used to live in the validator, +and a reader that disagreed with the validator about what a task *is* would be worse +than no reader at all. The parser now states facts about a line (`Methodologies`, +`Loose`, `HasCitation`); turning a fact into a finding stays in the validator. That +split is also what lets `map` read a malformed artifact instead of refusing exactly +the file a user most needs to inspect. + +## An address is hostile input + +`artifact.Resolve` accepts a path relative to the working directory — that is what +makes `scc map show tasks.md` work from inside a spec directory. Until +`artifact.Within` was put in front of it, it was also what let +`scc patch append ../outside.md L3 --text X` write outside the repository and exit +`0`. The boundary belongs in `internal/artifact` rather than in a command handler, +because an address reaches `scc` from a file the agent read as readily as from the +user, so every command that resolves one needs it. Symlinks are evaluated on both +sides before the comparison. + +## Related + +[[context-budget]] · [[spec-or-plan]] · [[validation-contract]] diff --git a/docs/wiki/pages/context-budget.md b/docs/wiki/pages/context-budget.md new file mode 100644 index 0000000..976cb69 --- /dev/null +++ b/docs/wiki/pages/context-budget.md @@ -0,0 +1,66 @@ +# The context budget + +The single idea most of `scc`'s design decisions come out of: **what an agent reads +is not free, and what it reads at session start is paid again in every request of +that session.** A file loaded once sits in the conversation for the rest of the run, +so a byte added to a preloaded file is a byte multiplied by the number of turns. + +That turns several apparently unrelated choices into one choice. + +## The three tiers + +Everything the methodology ships falls into one of three, and the tier decides how +much it may say. + +| Tier | Paid | Consequence | +|---|---|---| +| The entry file and unscoped rules | every request, whole | capped by test: a rule at 55 lines, the entry file at 60 | +| A skill's `description` | every request, description only | capped at 340 characters each, 2400 for the set | +| A skill's body, a `--help` string, a wiki page | once, by the run that needed it | where detail belongs | + +The corollary is a writing rule rather than a style preference: when something does +not fit in a rule, the answer is to move the detail down a tier — into a `--help` +string, read only when consulted — and leave the question-to-command table in the +rule. It is the same reasoning that sends a skill's detail out of its description +and into its body. + +## Where the lever actually is + +Measured on a freshly scaffolded Claude Code workspace, the entry file plus fifteen +rules came to 47KB in every request. Three of those rules now carry a `paths:` +header, so the harness loads them only when the agent touches the tree they govern — +about 10.4KB, a quarter of the standing cost, moved without changing a word of the +methodology. See [[scoped-rules]] for why only those three qualify. + +## Why the artifacts became queryable + +A plan measured at 56KB, and its workspace's 31 specs brought the corpus to roughly +90k tokens. An agent answering *what is the next open task?* by opening the plan +carries the whole plan for the rest of the session — so `scc map` exists to answer +the question without the file, and `scc patch` exists to change one line of it +without reading it first. See [[artifact-addressing]]. + +This is also why no `scc map` command returns both a plan's header and its +checklist: `brief` is paid once per session, `tasks --next` is paid per task, and a +command returning both would quietly restore the cost the split removed. + +## Why the agent talks in fragments + +The `caveman` rule sets the register the agent answers in, and its justification is +this same arithmetic: prose about the work is context every later request carries, +so narration is the part of a long run that can be cut without losing a fact. What +it must never compress is anything another reader parses — artifacts, commands, +quoted output, commit bodies, and questions put to the user. A denser EARS line is a +finding, not a saving. + +## Why blocks are condensed rather than inherited + +`scc` splices RTK's usage block into the entry file and replaces a larger one that +says the same thing: measured on rtk 0.42.4, the upstream block is 139 lines against +`scc`'s 18, both stamped the same version. The difference is paid continuously +rather than once, which is what makes replacing it worth the rudeness. See +[[integration-boundary]]. + +## Related + +[[artifact-addressing]] · [[scoped-rules]] · [[integration-boundary]] · [[validation-contract]] diff --git a/docs/wiki/pages/delivery-gate.md b/docs/wiki/pages/delivery-gate.md new file mode 100644 index 0000000..eab6b3c --- /dev/null +++ b/docs/wiki/pages/delivery-gate.md @@ -0,0 +1,90 @@ +# The delivery gate + +`.claude/rules/delivery.md` asks for a green build, a clean linter, and tests that mean +something. A reviewer can look; under `autonomy: auto` nobody does — so the step +that certifies the work was the one step certified by nobody. `scc check` turns that +claim into a command. + +## One command per gate, four gates + +The project records one command each for **build, format, lint, test**, in +`/scc-manifest.json`. `scc` runs them in that order and judges them by +their exit status. + +**`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. + +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. + +## `skipped` is a word, not an absent key + +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, with `scc check skip `, 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. + +`scc init` scaffolds the `check` object with all four keys **empty**, so the first +person to open the manifest sees the four questions this project has to answer +rather than a file that says nothing about them. + +## The test gate asks for one thing more + +It prints a JSON object on stdout: + +``` +{"total": 412, "coverage": 88.4} +``` + +**`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 coverage floor defaults to 86%. + +Three properties of a wrapped test command each have a failure mode that looks like +success: the **exit status has to survive** the wrapper (a script ending in `echo` +returns the echo's `0` and reports a broken build as green), the report goes to +**stdout** with the runner's noise on stderr, and **`total` is a real count** rather +than a constant. + +The pipeline belongs in the repository — a `make` target, an npm script, a small +program — not inline in the manifest, because a pipeline recorded inline is one +nobody can run by hand, fix, or review in a diff. + +## Why it is off unless asked for + +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. So `scc validate --checks` and **pre-push** run it — see [[hooks]] — which is +where a branch becomes a pull request and where the claim is actually made. The +explicit flag treats a missing command as a finding; the push hook cannot, or every +workspace that never wired anything up would fail every push over configuration +nobody chose. + +## The commands are arbitrary code + +A hostile manifest is a hostile command, and that is stated rather than glossed. +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 build from a fresh clone is not one to record +commands in. + +## Windows builds its own command line + +`internal/gate` sets `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 practical consequence for whoever records the +commands: one string has to work under both `sh -c` and `cmd /c`, so shell operators +and POSIX-only syntax are out. + +## Related + +[[validation-contract]] · [[hooks]] · [[integration-boundary]] diff --git a/docs/wiki/pages/harness-profile.md b/docs/wiki/pages/harness-profile.md new file mode 100644 index 0000000..9dc39c3 --- /dev/null +++ b/docs/wiki/pages/harness-profile.md @@ -0,0 +1,59 @@ +# The harness profile + +`scc` supports three agent tools — Claude Code, Codex, and opencode — from **one** +set of prose templates. `paths.Harness` is the type that makes that possible: a +profile per tool saying where it keeps things and what it does with them. + +## One prose source, a synthesized header + +A template is written once, data-free except for the profile. `assets.Render(h, file)` +expands the paths and synthesizes whatever header that tool's loader parses: YAML +frontmatter for Claude Code and opencode, a TOML agent role file for Codex. A +`(version, harness)` pair therefore renders byte-identically on every machine, and +the manifest records both — which is what would let a future three-way merge +reconstruct the old side. + +## The profile carries capabilities, not identities + +**If a change needs a `switch h.ID` outside the two render functions, the profile is +missing a field.** Adding a harness is a new `paths.Harness` value plus its entry in +`paths.Harnesses()`; `init`'s flags, the picker, `update`'s targets, the skills +validator's search path and the workspace walk all derive from that list. + +Four fields are worth knowing by name, because each encodes a fact about the tool +that `scc` is not free to assume: + +- **`PreloadsRules`** — whether the tool loads `rules/*.md` at launch. Claude Code + does; Codex and opencode do not, where `rules/` is just `scc`'s own directory. + The entry file branches on it, because telling an agent that already holds the + rules to "read the rule when the concern is live" makes it put the same bytes in + twice — and teaches it that the one document it is meant to trust is wrong about + its own environment. See [[context-budget]]. +- **`SkillsAreCommands`** — whether registering a skill already gives the user a + `/name`. On Claude Code it does, so shipping `commands/scc-wiki.md` beside + `skills/scc-wiki/` was a second picker entry and a second preloaded `description` + for one thing. The `scc-` prefix moved onto the skill itself, which is also what + keeps `/scc-init` from colliding with the `/init` Claude Code already ships. +- **`SettingsSeg`** — whether the tool has an in-session hook surface at all. Empty + for Codex and opencode, which are not missing a file `scc` could write: they have + no mechanism that would read one. They therefore contribute nothing rather than a + finding. +- **`SubagentsInheritRules`** — whether a subagent gets the rules one level down. + Claude Code hands a non-fork subagent the whole hierarchy, which is why there is + deliberately **no** `SubagentStart` hook: re-injecting would re-send ~26KB the + agent already holds, once per subagent. `TestNoHarnessNeedsASubagentStage` fails + the build if a harness ever has a hook surface and does not deliver the rules. + +## The marker is a file, never a directory + +`workspace.Find` walks up looking for `/scc-manifest.json` — a regular +file, for each harness in turn. Accepting the *directory* would be catastrophic in +two ways: every harness has a global twin in the user's home (`~/.claude`, +`~/.codex`, `~/.config/opencode`) that exists on any machine running that tool, so +any command run outside a workspace would resolve its root to `$HOME` and start +reading and writing the user's global configuration; and those directories exist in +every repository that merely *uses* the tool, where `scc` was never initialized. + +## Related + +[[managed-files]] · [[context-budget]] · [[scoped-rules]] diff --git a/docs/wiki/pages/hooks.md b/docs/wiki/pages/hooks.md new file mode 100644 index 0000000..487227e --- /dev/null +++ b/docs/wiki/pages/hooks.md @@ -0,0 +1,118 @@ +# Hooks — the check that 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** — something else runs it, on every +commit or at the end of every turn, whoever typed the command. + +`scc` installs two layers, and they 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. + +## The git layer — three stages, each where its question can first be answered + +| Stage | Runs | +|---|---| +| `pre-commit` | `scc validate` | +| `commit-msg` | the message being written | +| `pre-push` | `scc validate --pr --checks` | + +**`commit-msg` is the stage that answers the complaint.** The validator can report +an assistant's 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 the check would fire on git's own template and on the diff, every commit. + +The scripts are three lines and call back into `scc hooks run `, so **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 +(`SCC_SKIP_HOOKS=1`), because a gate with no documented escape is a gate people +delete the first time it is wrong. + +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. +A hook `scc` did not write is reported as foreign and left alone — see +[[managed-files]]. + +## The harness layer — the end of a turn + +A git hook fires when work enters history. The commit is the *second* moment a +finding exists; 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 then and read what it +prints into the next turn, so three events are spliced into `settings.json`: +`SessionStart`, `Stop`, and `UserPromptSubmit`. + +### Stop may only carry what the next turn can resolve + +This is the rule the design was missing, and getting it wrong shipped a loop. The +harness treats output on `Stop` as guidance the conversation *continues* for, under +the same loop protections as an outright block — so returning `0` is not a passive +report there. One release asserted the opposite, 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, the agent answered `.` 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; a fact about the branch clears for nobody, which +is why drift moved to `SessionStart`. + +That second rule had two more violations of the same shape. 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. And it reported findings anywhere in the +workspace, so a stale file the task never touched asked the agent, every turn, to go +and fix something it had no business in; findings are now intersected with what this +branch changed, and `scc validate` is where the whole workspace still answers. + +### Silence is the common case + +Three drift signals, **each a line only when it is true** — source changed with no +box ticked, a forbidden marker left in code, commits sitting on the base branch. +`TestTheDriftStageIsSilentOnACleanBranch` 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. + +This is where the design differs from the tool it was ported from. That one assumes +an agent drifts and re-asserts its instruction on every prompt, which 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: each leaves evidence on disk when it is broken. So the answer +is not to re-assert a rule at somebody who has read it — it is to say what actually +happened. + +### `UserPromptSubmit` is bounded by what it may speak about + +It fires on every prompt of every session — the worst cost profile in the set — so +what makes it defensible is that it almost never speaks: only when the prompt names +an artifact this workspace actually has, and then only to say how to read that +artifact through `scc map` instead of opening it (see [[artifact-addressing]]). + +Three details are load-bearing: it runs **no subprocess**, 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 `-`/`.` are *inside* a name rather than +boundaries around it, so `auth` does not match the spec `auth-flow`. This is also +the one stage where a non-zero exit discards the user's prompt before the agent sees +it, which a test pins. + +### They 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. The question is answered from refs already in the +repository — no fetch, no `gh` — because this runs at the end of every turn. + +## No `SubagentStart` + +The absence is gated rather than assumed: Claude Code hands a non-fork subagent the +whole rule hierarchy already, so the stage would re-send ~26KB the agent holds, once +per subagent. See [[harness-profile]] and [[context-budget]]. + +## Related + +[[validation-contract]] · [[delivery-gate]] · [[managed-files]] · [[context-budget]] diff --git a/docs/wiki/pages/integration-boundary.md b/docs/wiki/pages/integration-boundary.md new file mode 100644 index 0000000..1e8d205 --- /dev/null +++ b/docs/wiki/pages/integration-boundary.md @@ -0,0 +1,101 @@ +# The integration boundary + +`scc` drives six third-party binaries and vendors none of them: RTK, CodeGraph, +Headroom, ai-jail, the Dev Containers CLI, and git/`gh`. Each gets **one package** +under `internal/`, reached only from `internal/cli`, and that package owns the +binary's name, its install command, and its argument vocabulary. + +The reason is release cadence: all three of those age on the third party's schedule, +and one package per integration is what keeps a version bump from touching the +dispatcher. `internal/gate` is the seventh process-starter and the odd one out — the +commands it runs are the *project's*, not a vendor's. See [[delivery-gate]]. + +## Flags are discovered, not compiled in + +Headroom renamed one control twice — `--no-serena` became `--no-tokensave` became +`--code-memory none` — and its own profiles still disagree: `wrap opencode` 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. + +So `internal/headroom` and `internal/jail` both read ` --help` and pick the +spelling that build advertises. **A build advertising no opt-out is reported, never +substituted** — a sandbox opened by a guess is the failure the sandbox exists to +prevent. + +## Everything degrades — except the sandbox, which refuses + +This is the load-bearing asymmetry of `scc launch`. + +Headroom, CodeGraph and RTK are **enhancements**: a missing binary, a declined +install, an unattended run, or a harness Headroom does not wrap all end in the agent +starting anyway, with one line saying why. A launcher that refused to run because a +compression proxy was missing would be putting its own preference above the thing +the user actually asked for. + +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. + +**The default splits that.** The sandbox is on by default and **the install is the +opt-in**: ai-jail on PATH means every launch here is contained, absent means one +line naming what would have contained it. A default that refused would break +`scc launch` for everyone who has not installed it, and nobody *typed* a default. +`--no-sandbox` is the deliberate way out, and it contradicts `--jail` rather than +outranking it. + +## What a bare launch may leave behind + +One question settles what is on by default: **does this outlive the session?** + +- **RTK's usage block** does, and it is what the agent needs — an agent that never + read the block never types the prefix, so installing the binary and leaving the + file alone buys nothing. On by default, and *bounded by the block*: the step runs + when an entry file carries none and does nothing when they all do, so it fires + once per workspace rather than once per session. +- **The symbol graph** does, and launch is the one moment indexing is free. +- **`headroom wrap`** does too — it registers MCP servers into the agent's own + config, which is why Headroom ships `unwrap` at all. Compression for one session + is not worth a config edit the user did not ask for and will not see, so it is + opt-in behind `--headroom`, and even then `--headroom-mcp none` is the default. + +Two further details are easy to get wrong. `headroom wrap` also wants to append its +own RTK guidance to the entry file, behind markers that are not a substring of +`scc`'s — so both tools' idempotency checks pass and the file ends up carrying the +same instructions twice. `scc launch` passes `--no-context-tool`. And `wrap` parses +every flag it recognizes out of the tail before forwarding the rest, so a +pass-through argument colliding with one of Headroom's (`--verbose` is defined by +both) is silently eaten; a second terminator forces it through: +`scc launch claude -- -- -p`. + +## The sandbox takes the toolchain away, so it is mapped back + +ai-jail's private home replaces `$HOME` with a fresh tmpfs 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 (see [[context-budget]]). + +So three binaries are mapped back read-only — `scc`, `rtk`, `codegraph` — and the +list is closed because `scc` can name exactly what its own guidance names. Two +details were measured rather than reasoned: mounts apply in order, so **every +directory goes before the files inside it** or a directory bind hides a file bind +underneath it; and `scc` is mapped as the real binary behind the npm shim, because +`os.Executable()` is the Go binary and mapping it at the name `PATH` knows takes +node out of the picture. + +## Only npm, and only sometimes + +CodeGraph's headline install pipes a remote script into a shell. That is a fine +thing for a person to type and not a thing `scc` executes on their behalf, so npm is +the only installer it will run for it and `InstallHint` names the rest. `cargo` and +`uv`/`pip` installs are prompted for, because a Rust build is a toolchain and +minutes; an unattended run declines rather than deciding silently. + +## Related + +[[managed-files]] · [[delivery-gate]] · [[context-budget]] · [[hooks]] diff --git a/docs/wiki/pages/managed-files.md b/docs/wiki/pages/managed-files.md new file mode 100644 index 0000000..643430d --- /dev/null +++ b/docs/wiki/pages/managed-files.md @@ -0,0 +1,73 @@ +# Managed files and ownership + +`scc` writes into directories other people own — the workspace's entry file, the +harness's settings, `.git/hooks`. The rule that governs all of it is one line: +**never author what the user owns.** Everything below is how that is made +mechanical rather than aspirational. + +## The manifest is what makes ownership knowable + +`/scc-manifest.json` records `{path, hash, version}` for every file `scc` +laid down, so `manifest.Status` can answer `pristine | edited | missing` for each. +That is the whole basis for upgrading: `scc update` hashes every managed file +against this build and against the manifest, prints the plan grouped by outcome, +asks, and replaces only what is safe to replace. An edited file is **kept and +named**; `--force` is a separate decision. + +The manifest doubles as the workspace marker — see [[harness-profile]] — and it is +`scc`'s only file per harness. It gained its first *input* with the delivery gate's +four commands; the bar for a further key is that something inside the binary reads +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. + +## Four categories, four lifecycles + +| Kind | Rendered from | Tracked | Updated | +|---|---|---|---| +| Workspace template — rules, agents, skills | profile only, data-free | yes | by `scc update` | +| Artifact template — a new spec or plan | takes data | no | never; the user owns the result | +| Seed — the five `docs/` anchors | data-free | **no** | never | +| Foreign file — entry file, `settings.json`, `.git/hooks` | not rendered | no | spliced, never authored | + +A seed is the odd one out on purpose: data-free like a workspace file, untracked +like an artifact. `glossary.md`, `stack.md`, `notes.md`, `wiki/index.md` and +`wiki/changelog.md` are written once holding the format their validators check, and +then they are yours. + +## Splicing: one block inside somebody else's file + +`internal/mdblock` is the whole mechanism — find the marker pair, compare, replace +or append, preserve the file's line endings, leave everything outside the markers +untouched. **Which markers is the integration's decision; what happens after that +choice lives in one place.** + +That choice cuts both ways, deliberately: + +- **RTK's own markers**, because `rtk init` writes them too, and sharing them is + what makes the two tools converge on one copy of a block they both write. A + namespaced `scc:rtk-instructions` would leave the file carrying both. +- **`scc`'s own markers for CodeGraph**, because CodeGraph writes nothing into the + entry file and the block is `scc`'s account of `scc graph`. Namespacing leaves a + future CodeGraph release free to add its own. + +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 +not rewrite** — reformatting somebody's broken JSON into valid JSON of `scc`'s +choosing would destroy what they were in the middle of fixing. + +A `.git` hook `scc` did not write is reported as foreign and left alone, 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. + +## Determinism + +Rendered output is normalized to LF (`textutil.NormalizeNewlines`) so scaffolded +files hash identically regardless of the build machine's checkout, and anything +serialized is sorted first. `.gitattributes` forces LF across the repository for the +same reason: CRLF would break gofmt, the manifest hashes, and the shell hooks. + +## Related + +[[harness-profile]] · [[integration-boundary]] · [[validation-contract]] diff --git a/docs/wiki/pages/scoped-rules.md b/docs/wiki/pages/scoped-rules.md new file mode 100644 index 0000000..3c2b886 --- /dev/null +++ b/docs/wiki/pages/scoped-rules.md @@ -0,0 +1,49 @@ +# Scoped rules + +A rule under `/rules/` is normally preloaded: on Claude Code every file +there is loaded at launch with the same priority as the entry file, and therefore +carried in every request of the session. A **scoped** rule carries a `paths:` header +instead, so the harness loads it only when the agent touches the tree it governs. + +Scoping is the only lever there is on what the methodology costs — see +[[context-budget]] — which is exactly why it is not a matter of taste. + +## The bar + +**A rule may be scoped only where a validator reports what it prevents.** + +Scoping trades *in context before the decision* for *in context when the file is +open*. That trade is survivable precisely 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. Where nothing mechanical reports the +failure, the rule has to be in context before the agent decides — which means +unscoped. + +`TestOnlyRulesAValidatorBacksAreScoped` holds the bar in the test suite, so the +argument cannot be lost to a later edit. + +## The three that qualify + +| Rule | Scope | Backed by | +|---|---|---| +| `specs.md` | `specs/**` | the EARS and spec validators | +| `tasks.md` | `specs/**`, `plans/**` | the task-grammar findings | +| `knowledge-base.md` | `docs/**` | the wiki, glossary, ADR, codewiki and stack validators | + +## The refusals, and why they are clearer than the borderline cases + +The rules that look most temptingly scopable are the firmest noes: + +- **`artifacts.md`** would scope to `plans/**`, and its central instruction is + *never open the plan*. A scoped copy would load only at the moment the thing it + prevents has already happened. +- **`prior-art.md`** and **`routing.md`** fire *before any file exists* — one says + read the record before writing an artifact, the other picks which artifact to + write. There is no path to attach them to. +- **`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. + +## Related + +[[context-budget]] · [[validation-contract]] · [[harness-profile]] diff --git a/docs/wiki/pages/spec-or-plan.md b/docs/wiki/pages/spec-or-plan.md new file mode 100644 index 0000000..6428490 --- /dev/null +++ b/docs/wiki/pages/spec-or-plan.md @@ -0,0 +1,65 @@ +# Spec or plan + +Every piece of work picks one vehicle, and the choice is recorded in a file because +a session's context dies and the artifact is the only channel through which the +decision survives. + +One question decides it: + +> Does this need requirements and a design settled before any code? + +**Yes → a spec.** No → **a plan.** + +| Vehicle | Path | Contents | +|---|---|---| +| Spec | `specs//` | `requirements.md` · `design.md` · `tasks.md` | +| Plan | `plans/.md` | a header, plus a checklist and/or references to specs | + +A plan covers both ends of *not a spec*: an initiative too large for one spec, +decomposed into the specs it references; or a change small enough that the *what* +was never in doubt, which is a checklist and nothing more. + +> If you know GitHub Spec Kit, note that "plan" there means the opposite of this — +> the architecture *inside* one feature, which here is `design.md`. + +## One source of truth per item + +Where a plan item *is* a task, its checkbox is the state. Where it *references a +spec*, the state is derived from that spec and never copied — and an item must not +do both. Two records of one fact disagree, and the copy is the one that goes stale. + +The same principle closes the task flag vocabulary: there is no `_Blocked_` flag, +because blocked is derived from `_Depends_`, and `_Status_` never takes `open` or +`completed`, because the checkbox already says that. + +## The plan is a contract, not a document + +A plan has six sections and no others — `Why`, `Paths`, `References`, `Out of +scope`, `Tasks`, `Done when` — and any other heading is a finding. The closed set is +the only thing that ever capped a plan's size: the 56KB plan that motivated this got +there through a `## Notes` section that nothing forbade, growing to half the file. + +There is no line limit anywhere in the contract except on the description, because 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**. `docs/notes.md` is the same move +applied to code comments. + +## Requirements are omitted, not filled + +Structure improves generated code, but the curve is not monotonic: over-specification +introduces requirements that conflict, and correctness drops. The same holds for +`design.md`, where invented architecture actively constrains — the next session reads +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. + +## A spec meets existing code as a delta + +Changing an existing spec means amending individual requirements — `(ADDED)`, +`(MODIFIED)`, `(REMOVED)` — not rewriting the file. That is what makes adopting the +methodology on a working system possible without restating the whole product as +requirements, and it is why `/scc-init` writes no specs. It also makes concurrent +edits safe: two sessions can change one spec while touching different requirements. + +## Related + +[[artifact-addressing]] · [[context-budget]] · [[validation-contract]] diff --git a/docs/wiki/pages/validation-contract.md b/docs/wiki/pages/validation-contract.md new file mode 100644 index 0000000..a87ddc1 --- /dev/null +++ b/docs/wiki/pages/validation-contract.md @@ -0,0 +1,90 @@ +# The validation contract + +`scc` is a lint tool for a methodology. Ten validators read files already on disk; +two more run only when asked. What makes the whole thing usable by an agent or a CI +job is a contract narrow enough to branch on. + +## Exit codes + +``` +0 ok +1 usage or runtime error — the tool could not run +2 validation findings +``` + +**A finding is a legitimate answer to a lint question, not a failure of the tool.** +Collapsing `2` into `1` destroys the distinction a caller needs most: *the check ran +and found something* against *the check did not run*. + +`--help` is `0`, and it took 52 call sites to be wrong about that at once — every +handler routed `flag.ErrHelp` to the error exit, so `scc map tasks --help` reported +the same code as a command that could not run. `scc launch` is the single deliberate +exception to the whole contract: it returns whatever the agent it started returned, +because a launcher that flattened its child's exit status would be unusable in the +scripts people actually write. + +A related trap sits one layer down. Go's `flag` package 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. **Misfiling an argument +while reporting success is worse than rejecting it.** + +## A validator that fires on `scc`'s own output is the worst bug in the product + +One wrong finding teaches the user to disbelieve all ten. The templates therefore +carry their instructions in HTML comments and fenced examples, which is exactly what +`mdscan` excludes, and `TestFreshArtifactsPassTheirOwnValidators` is the gate on it. +Treat that test as required reading before changing a template or a validator. + +## Two validators whose subject is not a file + +Eight validators read Markdown. Two do not, and both exist because a rule that has +to be *remembered* survives only until the session that skips it. + +- **`attribution`** reads the commits this branch added — `base..HEAD` — for an + assistant's signature. **A signature is a shape, never a word**: a trailer, a + "generated with" line, a vendor link, a Markdown badge, or a bare name on a line + of its own. The vocabulary (`claude`, `anthropic`, `codex`, and so on) never + decides on its own that a line *is* a signature — only whether a signature names + an assistant — so a commit bumping a vendor's SDK stays an ordinary commit. It + **never returns an error**: a project with no git, an unborn HEAD, or a shallow CI + clone has nothing on this branch to check, which is a clean pass rather than a + broken `scc validate` in every workspace that is not a repository. +- **`tests`** runs the project's own recorded commands. See [[delivery-gate]]. + +## Cost decides what runs by default + +The ten file validators cost milliseconds. The two that do not are opt-in for +reasons of cost rather than doctrine, and the reasoning is identical in both cases: +`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**. + +- `--pr` enables the pull-request check, the one check that goes to the network. +- `--checks` enables the delivery gate, which runs a compiler, a linter and a suite. + +`pre-push` enables both, because that is where a branch becomes a pull request and +where the methodology says the claim is actually made. See [[hooks]]. + +## Findings have one shape + +`internal/finding` owns one type and one frozen JSON document for every validator, +plus the grouped human report. Machine output is compact and human output is +indented: measured on a six-task plan, `map tasks --json` was 2198 bytes indented +against 599 for the human listing of the same tasks, about a third of it leading +whitespace. The corollary is a testing rule — **a test may not assert on the +spelling of a document**; it unmarshals it, because a substring check against +formatted JSON is an assertion about the reader rather than about the command. Four +such tests broke on that change while the commands were correct. + +## Hostile input + +Any positional name that becomes a path segment passes `workspace.SafeName` *before* +`filepath.Join` — without it, `scc spec delete .. --force` resolves to the workspace +root. Resource names are kebab-case. The same boundary applies to artifact +addresses, for a reason worth stating: an address reaches `scc` from a file the +agent read as readily as from the user. See [[artifact-addressing]]. + +## Related + +[[delivery-gate]] · [[artifact-addressing]] · [[scoped-rules]] · [[hooks]] diff --git a/tools/go.mod b/tools/go.mod new file mode 100644 index 0000000..7723e43 --- /dev/null +++ b/tools/go.mod @@ -0,0 +1,3 @@ +module github.com/protonspy/spec-claude-code/tools + +go 1.25.0 diff --git a/tools/testreport/main.go b/tools/testreport/main.go new file mode 100644 index 0000000..b5a5fb1 --- /dev/null +++ b/tools/testreport/main.go @@ -0,0 +1,200 @@ +// testreport runs the suite and prints the one object `scc check` asks a project +// for in its test gate: +// +// {"total": 412, "coverage": 88.4} +// +// It lives in a nested module so the root's `./...` never sees it. A helper +// package inside the main module would be compiled by `go build ./...`, matched by +// `go test ./...`, and land in the coverage profile at 0% — dragging down the very +// number this program exists to report. +// +// Three properties are the whole contract, and each has a failure mode that looks +// like success: +// +// - the report goes to stdout and the suite's own output to stderr, so a caller +// parsing stdout never has to pick the object out of a test log; +// - the exit status is the suite's, because a runner that swallows a failure +// hides the thing the gate exists to catch; +// - total is a real count of tests that ran, not a constant — zero tests at high +// coverage is the shape of a repository whose tests were deleted. +package main + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// module is the root module this reports on. Used to find the repository root +// rather than assuming a working directory: `go run -C tools ./testreport` leaves +// the process in tools/, and a plain `go run ./testreport` from inside tools/ is +// the same situation, but neither is guaranteed by the caller. +const module = "github.com/protonspy/spec-claude-code" + +// main does nothing but exit, so that everything below it can use defer. os.Exit +// skips deferred calls, and the one deferred call here removes the coverage +// profile — inside main that would leave a file behind on every run. +func main() { os.Exit(run()) } + +func run() int { + root, err := findRoot() + if err != nil { + fmt.Fprintln(os.Stderr, "testreport:", err) + return 1 + } + + // A created-exclusive temp file rather than a fixed name under os.TempDir(). + // `go test -coverprofile` opens the path it is handed with create-and-truncate + // semantics and follows a symlink, so a predictable name on a machine with a + // shared temp directory lets a co-resident user pre-plant a link and have this + // process — which the pre-push hook runs — write through it into a file the + // caller owns. + f, err := os.CreateTemp("", "scc-testreport-coverage-*.out") + if err != nil { + fmt.Fprintln(os.Stderr, "testreport:", err) + return 1 + } + profile := f.Name() + f.Close() + defer os.Remove(profile) + + total, status, err := runSuite(root, profile) + if err != nil { + fmt.Fprintln(os.Stderr, "testreport:", err) + return 1 + } + + // Coverage is reported even when the suite failed: a red run still says how + // much of the tree it reached, and the exit status below is what the gate + // judges. + coverage := coverageTotal(root, profile) + + fmt.Printf("{\"total\": %d, \"coverage\": %s}\n", total, coverage) + return status +} + +// findRoot walks up from the working directory for the go.mod that declares the +// root module. +func findRoot() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + for { + if b, err := os.ReadFile(filepath.Join(dir, "go.mod")); err == nil { + if strings.Contains(string(b), "module "+module+"\n") { + return dir, nil + } + } + parent := filepath.Dir(dir) + if parent == dir { + return "", fmt.Errorf("no go.mod declaring %s above the working directory", module) + } + dir = parent + } +} + +// runSuite runs `go test -json` over the whole module, forwards what the tests +// printed to stderr, and returns how many tests passed alongside the suite's exit +// status. +// +// -json rather than parsing the human output because the count has to be a count: +// a pass event carries a Test name, a package-level pass event does not, and only +// the machine stream tells them apart. +func runSuite(root, profile string) (total, status int, err error) { + cmd := exec.Command("go", "test", "-json", "-coverprofile="+profile, "./...") + cmd.Dir = root + stdout, err := cmd.StdoutPipe() + if err != nil { + return 0, 0, err + } + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + return 0, 0, err + } + + type event struct { + Action string `json:"Action"` + Test string `json:"Test"` + Output string `json:"Output"` + } + scan := bufio.NewScanner(stdout) + scan.Buffer(make([]byte, 0, 64*1024), 8*1024*1024) + for scan.Scan() { + var e event + if json.Unmarshal(scan.Bytes(), &e) != nil { + // Not an event line — `go test` writes build errors as plain text. + // Pass it through rather than dropping it. + fmt.Fprintln(os.Stderr, scan.Text()) + continue + } + switch e.Action { + case "pass": + if e.Test != "" { + total++ + } + case "output": + // The suite's own writing, to stderr: stdout carries the report. + fmt.Fprint(os.Stderr, e.Output) + } + } + + // A scanner that stopped early is not EOF, and treating it as one is the worst + // failure this program has: a single -json line past the buffer cap ends the + // loop silently, `total` undercounts with nothing said, and `go test` is left + // blocked writing into a pipe nobody reads — so the gate hangs rather than + // going red. Drain first, so the child can always exit, then report it as + // *could not run* rather than as a result. + scanErr := scan.Err() + if scanErr != nil { + _, _ = io.Copy(io.Discard, stdout) + } + + // A non-zero exit is the suite failing, which is a result rather than an error + // here — it is what the gate is asking about. + waitErr := cmd.Wait() + if scanErr != nil { + return total, 0, fmt.Errorf("reading `go test -json` output: %w", scanErr) + } + if waitErr != nil { + var exit *exec.ExitError + if !errors.As(waitErr, &exit) { + return total, 0, waitErr + } + return total, exit.ExitCode(), nil + } + return total, 0, nil +} + +// coverageTotal reads the percentage off `go tool cover -func`'s total line. +// A profile that does not exist or a tool that fails yields "0" rather than an +// error: the suite's status is the answer the gate wants, and a missing coverage +// number below any floor is the honest report. +func coverageTotal(root, profile string) string { + if _, err := os.Stat(profile); err != nil { + return "0" + } + cmd := exec.Command("go", "tool", "cover", "-func="+profile) + cmd.Dir = root + out, err := cmd.Output() + if err != nil { + return "0" + } + for _, line := range strings.Split(string(out), "\n") { + if !strings.HasPrefix(line, "total:") { + continue + } + fields := strings.Fields(line) + if len(fields) == 0 { + break + } + return strings.TrimSuffix(fields[len(fields)-1], "%") + } + return "0" +}