From f1fe380643276e392f2f86b2ad18fd7d1f404728 Mon Sep 17 00:00:00 2001 From: John Flavan Date: Wed, 28 Oct 2026 07:47:56 -0500 Subject: [PATCH 01/44] docs: design for standards, the rules every change is held to Co-Authored-By: Claude Opus 5.5 --- .../specs/2026-10-28-standards-design.md | 257 ++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-28-standards-design.md diff --git a/docs/superpowers/specs/2026-10-28-standards-design.md b/docs/superpowers/specs/2026-10-28-standards-design.md new file mode 100644 index 00000000..f919a1f8 --- /dev/null +++ b/docs/superpowers/specs/2026-10-28-standards-design.md @@ -0,0 +1,257 @@ +# Standards: rules every change is held to + +2026-10-28. A way for an engineering or architecture team to write down the rules every change must keep (architecture, security, compliance controls such as SOC 2's), and have the loop check each change against them at every gate, with a record per rule of how the change met it. The user asked for this as a spec and, as before, left the decisions to Claude; each one below is Claude's call, recorded in `docs/decisions.md` when it is built. + +## Why + +Today a repository can tell Loopstra's sessions how to work (`CLAUDE.md`, `.claude/rules`, skills, agents, `REVIEW.md`), but nothing holds a change to that guidance except the reviewer, late, by its own reading: + +- `spec-check` checks that the spec answers the intent and reads plainly. `plan-challenge` checks that the plan can be carried out. Neither is asked whether the design or the plan breaks the repository's rules, so a design that puts a second service on another's database passes both and is found, if at all, after it is built. +- `done-check` checks the owner's Done when list. A requirement that holds for every change ("every endpoint that returns customer data checks the caller's role") reaches it only if each owner writes it into each intent, which nobody will. +- Nothing records, per rule, how a change kept it. A team that answers to an auditor (SOC 2's change management, CC8.1, asks for evidence that each change was designed, reviewed, tested and approved against policy) has review.md and a pass, not "rule SEC-3: met, because…". +- A build session can edit `CLAUDE.md` or `REVIEW.md` on its branch. Since #89 that cannot change what the change's own later sessions are told, but once merged it changes what every later change is told, and no gate looks at it. + +## Goals + +- A team writes its rules once, as Markdown in the repository, and every change is checked against the ones that apply to it: at the spec, at the plan, in review, and after the merge. +- Each check that can be code is code: which rules apply, whether the spec and plan address each one, whether a change edited the rules. The agents judge only what needs judgment: whether a design, plan or diff actually keeps a rule. +- Every change leaves a plain record, in git, of the rules that applied to it and the evidence for each: the spec's and plan's Standards sections, review findings that name the rule, and a Standards section in `outcome.md`. +- No standards, no change: a repository without `loopstra/standards/` runs exactly as before, and its specs and plans keep their current headings. + +## Non-goals + +- Loopstra does not make an application compliant with anything, and does not claim to. SOC 2 is an auditor's opinion of an organisation's controls over a period. Standards let a team state the technical controls it wants in the code and get evidence that each change kept them; access reviews, vendor management, incident response and the rest stay outside. +- No standards library shipped with Loopstra (no "SOC 2 pack"). The README shows an example; the rules are the team's, in its words. +- No agent writes or edits standards. A person changes them, on `main`, by hand. +- Acceptance tests are not written from standards in this change (a follow-up; see the end). +- Chat does not read standards in this change. The orchestrator is not told them, and intents are unchanged: product owners never need to know the standards exist. +- No export of the trace for auditors (a follow-up). The records in `intent//` are the durable evidence this change adds. + +## Anti-goals + +- **An agent must never weaken the rules that judge it.** No session may edit `loopstra/standards/`, and a change whose diff does is blocked, whatever the hook let through. +- **A broken standards file must never mean "no standards".** If a file on `main` cannot be read, the loop does nothing until it is fixed. It does not run changes as if the rules were not there. +- **Standards must not turn the owner's documents into engineering documents.** The parts of the spec and outcome written for the owner stay in plain words; rule ids and technical evidence go in sections of their own. + +--- + +## Standard files + +A standard is one Markdown file in `loopstra/standards/`, named `.md` with lowercase words joined by dashes (like intent slugs). Any other file there is ignored and listed by `loopstra standards`. Files are committed, and Loopstra reads them from `main`'s commit, never from a checkout (see Where they are read from). + +```markdown +--- +applies_to: ["services/**", "api/**"] +needs_person: false +--- +# Service architecture + +Why these rules exist, links to the design records in docs/adr/, examples of the right way. +Sessions are given the whole file, so this text is context, not decoration. + +## Rules +- ARCH-1: Services call each other only through their published HTTP APIs. No service reads + or writes another service's database. +- ARCH-2: New persistent data lives in Postgres, through the repository layer in `src/db/`. +``` + +- **The front matter is optional.** + - `applies_to`: glob patterns, matched against repo-relative paths with `/`, with the same semantics as `gates.merge.risky.paths` (Bun.Glob; a bare name matches only at the top). The standard then applies only to a change that touches a matching file. Without it, the standard applies to every change. + - `needs_person`: `true` makes a change this standard applies to wait for a person before it merges (see Merge). The default is `false`. + - Unknown keys are errors, as in the config. +- **The title is the first `# ` heading.** It is required. +- **The rules are under `## Rules`.** The section is required and holds at least one rule. + - Each rule is a bullet `- ID: text`. Indented lines that follow continue the rule. + - An id starts with a capital letter and has up to 32 letters, digits, `.`, `_` and `-`, ending in a letter or digit: `ARCH-1`, `SOC2-CC6.1`, `SEC_3`. Claude's call: a team's own scheme (including a framework's control numbers) fits, and ids are stable as rules are added. A rule's number in a list is not, so an id is never derived from its position. + - Ids are unique across all files. +- **Everything else in the file is free text.** Sessions read it; Loopstra does not parse it. + +A file breaks these rules when it has no title, no Rules section, an empty one, a bullet without a valid id, a duplicate id, a pattern that is not a valid glob, or front matter that does not parse. Such a file is unreadable (see When a file cannot be read). + +### Which standards apply + +Applicability is a fact about files, never an agent's judgment, except at the spec, where no file list exists yet: + +| When | The files | The standards that apply | +|---|---|---| +| spec | none yet | every standard without `applies_to`; the designer adds any scoped standard whose files its design will touch | +| plan | the plan's Files that change | every standard without `applies_to`, and each one whose patterns match a listed file | +| build, resolve | the plan's Files that change | as for the plan | +| review, merge | the branch's diff against `main` outside `intent/` (as `scoreRisk` reads it, renames by old and new path) | as for the plan, on the diff | +| done | the files the change brought into `main` | as for the plan, on those files | + +A standard that applies is used whole: every rule in it applies. Claude's call: a rule-level scope would let a file of 30 controls send each rule to its own files, but it doubles what a team writes and reads, and splitting such a file in two does the same. + +## Where they are read from + +Standards are read with `git cat-file` from the same commit of `main` that the step's Claude setup comes from: the step's snapshot commit (`ctx.readCommit()`) for read-only steps, and the commit `main`'s setup is read from for steps in a change's branch (#89). A step reads them once, so all its sessions, and the code checks, use one version. + +- A change that edits `loopstra/standards/` cannot change the rules its own steps are judged by (and is blocked anyway; see Protection). +- Uncommitted edits in the main checkout are not standards until committed, just like prompt overrides and the config's agent files. +- A plan written ahead (`speed.plan_ahead`) is used only if it was asked the same prompt, and the standards are part of the prompt, so a standards commit between the two discards it. This needs no new code. + +## When a file cannot be read + +Unreadable standards are treated like a config that does not load, and the check fails closed: + +- At the start of each tick, after the config loads, the tick reads `loopstra/standards/` from `main`'s commit. If any file is unreadable, the tick starts no steps and traces it once while it stays the same (`eventOnChange`), with the reason `standards`. `loopstra status` and the console show it under Needs attention: "loopstra/standards/soc2.md cannot be read: the rule on line 14 has no id (write it as `- ID: text`). Fix it on main and commit; until then no change moves on." +- `loopstra start` refuses with the same sentence, and `loopstra setup --check` lists it under "To fix". +- A step that finds an unreadable file at its own commit (main moved between the tick's read and the step's) fails as `notTheStepsFault`, keeps its status and runs again. It never runs without the rules. + +## `loopstra standards` + +A new command, read-only, needing no Claude: + +``` +loopstra standards # each standard: title, scope (every change, or its patterns), whether it needs a person, its rule ids +loopstra standards --check # also exits 1 when any file is unreadable, naming each problem +``` + +It reads the files from `main`'s last commit and says, under the list, when the main checkout has uncommitted edits in `loopstra/standards/` that are not yet in force. It warns when the standards together are over 40 KB, since every applicable standard goes into every prompt that uses them whole. Claude's call: a warning, not a limit; a team with large standards can scope them. + +--- + +## The stages + +### Prompt variable + +Add `standards` to `PROMPT_VARS`. It renders each applicable standard whole, from its title down, under a line naming its scope, with the files that made a scoped one apply: + +``` +### Service architecture (loopstra/standards/architecture.md) +Applies because the change touches services/billing/api.ts and services/billing/db.ts. + + +``` + +For the spec, scoped standards are listed after the ones that apply to every change, under "These apply only to changes that touch the files named; include one in your Standards section if your design touches them." With no standards, the variable is empty and renders as `(none)`. + +The shipped prompts that get it: `design`, `spec-check`, `plan`, `plan-challenge`, `build`, `resolve`, `review` and `done-check`. `fix`, `reconcile` and `revise` continue the build's session and already have it. A repository's own copy of one of these prompts without `{{standards}}` gets it appended at the end under "Standards this repository holds every change to:", exactly as `{{resolution}}` is appended to `review.md`, but only when the repository has standards, so a repository without them sees no change. + +### Spec (design and spec-check) + +- **`design.md`** says: when standards are listed, add `## Standards` after Areas of concern. It has one bullet per rule of each standard you include, `- ID: how the design keeps it`, or `- ID: does not apply: why`. This section is for engineers and may be technical. Every standard that applies to every change must be there in full. +- **The spec gate gets a code check, `standards`,** run with the headings check when the repository has standards. It checks four things: + - the `Standards` heading is present; + - every rule id of every standard without `applies_to` has a bullet; + - every id there is a real rule (an id that does not exist is a failure, which catches a rule the designer invented); + - a scoped standard is either absent or there in full. + + Evidence: "all 12 rules addressed", or "missing SEC-2, SEC-4; no rule called SEC-9". +- **`spec-check.md`** gets a fourth task, when standards are listed: + - Check each bullet of the Standards section against the Design. Is the claim borne out, and is each "does not apply" sound? + - Does the design break any rule that applies, whether the section says so or not? + - Each problem is a finding that is not met, with `standard` set to the rule id. + + The readability task names Standards with Design and Affected code as the engineers' sections. +- **The envelope.** `requirement` gets an optional `standard: string`. The gate evidence and the rewrite's findings put the id first: "SEC-3: the design logs the request body, which holds card numbers". + +A failure takes the existing path: one rewrite with the findings, then blocked with the stage's `failedNote`. + +### Plan (plan and plan-challenge) + +- **`plan.md`** says: when standards are listed, add `## Standards` after Proof. It has one bullet per rule that applies, `- ID: how the plan keeps it, and which item under Proof shows it`, or `- ID: does not apply: why`. +- **The plan gate's `standards` code check** works as the spec's, on the plan's applicable set, with one difference: every rule of every standard that applies must have a bullet, the scoped ones included, since the files are known now. It runs after the `files` check, on the files that check read. +- **`plan-challenge.md`** gets the same task as spec-check, on the plan. A broken rule, or a claim the Order of work does not carry out, is a concern with `blocking: true`. `concern` gets an optional `standard`. + +### Build and resolve + +`build.md` and `resolve.md` get the plan's applicable standards, with one line: "Keep every rule below. If the plan would make you break one, stop and say so in your summary rather than break it." The build is not checked against them here; review is. + +### Review + +- **`review.md`** gets the standards that apply to the diff, with: "A change that breaks a rule below is an important finding; set `standard` to the rule's id." +- **`finding` gets an optional `standard`.** `review.md`, the record, lists a finding's rule id before its location. +- **`REVIEW.md`'s shipped template** gets a Standards pass: "the change keeps every rule in `loopstra/standards/` that applies to it". That covers a Claude review of the repository outside the loop too. +- **A rule that applies to the diff but was not in the plan's set** (the build touched a scoped standard's files the plan did not list) needs no special handling. The reviewer is given it like any other, and the drift check's reconcile has already rewritten the plan. + +### Merge + +The merge gate gets two checks, both code, both at `passMergeGate` with the risk check, on the same diff: + +1. **`standards-untouched`**: the diff touches nothing under `loopstra/standards/`. If it does, the change is blocked, and the gate row names the files. The note: "This change edits the repository's standards (loopstra/standards/soc2.md), which only a person may change. An engineer can take that edit out of the change's branch and retry the change, or close it." Claude's call: block rather than let a person approve it. An approval at the merge gate is about the change, and a standards edit hidden in a feature is exactly what the reviewer at the gate is least likely to read. +2. **`needs_person`**: a standard with `needs_person: true` that applies to the diff makes the change risky, as a file in `gates.merge.risky.paths` does. The same rule holds as for risk: it only changes who decides when nobody is on the config's merge gate. `scoreRisk` is given the patterns of those standards beside `risky.paths`, and `riskNote` names the standard: "This change falls under the standard Payments data (loopstra/standards/payments.md), which is set to need a person before merging: it touches services/billing/api.ts. Approve the pull request on GitHub, or set status to merge-approved." A `needs_person` standard without `applies_to` makes every change wait. `loopstra standards` says so, since it is the same as `gates.merge.human: pr`. + +The trace gets a `standards` row on the merge gate when the repository has standards. It names the standards that apply and their rule ids, so the record of what applied is fixed at the commit that merged. + +### Done (done-check and outcome.md) + +- **The files** are those the change brought into `main`: the diff of the commit that brought it in, against its first parent (for a squash, the squash commit; for a merge, the merge commit). It is read where the done-check's clean copy of `main` is made. Claude's call: this is the same answer for a local merge, a pull request merged by Loopstra and one merged by hand. If the step cannot name that commit, it uses the merge gate's `standards` row. +- **`done-check.md`** gets the applicable standards. The envelope gets `standards: [{ id, result: met | unmet | needs-person, evidence }]`, one item per applicable rule, judged as the criteria are: `met` only with evidence from the repository, `needs-person` for what the repository cannot show ("the alert exists in the monitoring system"). +- **The runtime writes the section, not the agent.** It appends `## Standards` to `outcome.md`, after the owner's sections: one bullet per rule, `- ID (Title): met: evidence`. The format is the runtime's, so it is the same in every outcome and a script can read it. The section is introduced by one plain sentence for the owner: "The engineering rules this change was checked against:". +- **Unmet rules block,** as unmet criteria do, with their own note when only standards are unmet: "The change merged, but the final check found engineering rules it does not keep. The details are in outcome.md. An engineer should decide whether to open a follow-up change, then set this one to done or closed." When both are unmet, the existing note gets "and engineering rules" after "Done when criteria". `needs-person` rules go under the existing "For a person to confirm" heading too, with their ids. +- **With `gates.done.agent: false`** no rule is judged, and the Standards section lists the rules that applied with "not checked automatically". Claude's call: the record still says what applied. + +## Protection + +The test-protection hook (`templates/hooks/loopstra-protect-tests.ts`) refuses an edit under `loopstra/standards/` in every session that can write, whatever `LOOPSTRA_PHASE` is: acceptance, build, fix, reconcile, revise and resolve. The refusal message says that standards are changed by a person on `main`. The merge check above is the backstop for what a hook cannot see (a write through a shell command the session was allowed). + +Claude's call: a standard is not added to `.claude/rules`, and `.claude/rules` is not treated as a standard. Rules there are guidance Claude Code loads into sessions. Standards are what the gates check, with ids and a record. A team keeps conventions in the first and the must-haves in the second, and can point one at the other. + +## Status, console and records + +- No new screen. The spec, plan, review and outcome pages already render those files, and the new sections show there. +- The session page needs nothing new. The gate rows (`standards` on spec, plan, merge and done, plus findings with ids) show in the trace as rows do today. +- `loopstra status` and the console show unreadable standards under Needs attention (see When a file cannot be read). +- Nothing is added to the intent template, the owner's guide or chat. + +## Setup, skill and docs + +- **`loopstra init`** writes no standards. Off is the default and means absent. +- **`loopstra setup --check`** runs `loopstra standards --check`. +- **The operator skill** (`templates/skill/SKILL.md`) gets a section on writing a standard. It covers the file's shape, choosing ids, scoping with `applies_to`, and writing a rule a judge can check ("every endpoint under api/ checks the caller's role with `requireRole`", not "be secure"). It ends by saying to commit the file on `main`. +- **README** gets a Standards section with two worked examples: + - an architecture standard for every change; + - a SOC 2 technical-controls standard scoped to `services/**` and `infra/**`: authentication and role checks, audit logs for access to customer data, encryption in transit and at rest, no secrets or personal data in logs, keeping and deleting data on schedule. + + It states plainly the limits under Non-goals, and how the records it leaves map to a change-management control (the intent is the request, the spec and plan the design, `review.md` the review, the outcome the evidence, and the pull request's approval with `merge.human: pr` the approval). +- **`docs/architecture.md`** lists `standards.ts` in layer 3. +- **`docs/decisions.md`** gets the calls in this spec. + +## Code + +- **`src/standards.ts`** (layer 3, state on disk). It holds: + - the zod schema of the front matter and the parsed `Standard` (`name`, `path`, `title`, `appliesTo?`, `needsPerson`, `rules: { id, text }[]`, `body`); + - `readStandards(git, commit)`, which returns the standards or the problems, each naming the file and line; + - `applying(standards, files)`; + - `renderStandards(applying, matched)`, for the prompt variable; + - `checkStandardsSection(markdown, required, known)`, for the gate checks. + + The section is found with the same heading reader `headingsPresent` uses. +- **`src/stages/design.ts` and `plan.ts`**: a `standards` check in `checks`, and the variable in `vars` and the judge's `vars`. `ArtifactStage` already has every hook this needs. +- **`src/stages/build.ts`, `review.ts`, `merge.ts` and `verify.ts`**: the variable. `merge.ts` adds the two checks in `passMergeGate`, and `verify.ts` adds the done-check items, the outcome section and the note. +- **`src/risk.ts`**: `scoreRisk` takes the extra patterns with the standard each came from, and `riskNote` names it. +- **`src/envelopes.ts`**: the optional `standard` fields, and `done-check`'s `standards` array (default `[]`, so a prompt override that never asks for it still parses). +- **`src/scheduler.ts`**: the tick's read and the `standards` problem, beside `traceConfigProblem`. +- **`src/commands/standards.ts`**, the command. +- **`src/attention.ts`**, the Needs attention entry. +- **The hook**, the refusal. + +## Tests + +- **Unit, in `tests/unit/standards.test.ts`:** + - parsing: front matter, titles, rules with continuation lines, each kind of unreadable file, and duplicate ids across files; + - applicability, renames included; + - the section check: missing ids, unknown ids, and a scoped standard given in part. + + A property test in `properties.test.ts` checks that parsing a rendered rule list gives back the same ids and texts. +- **Stages, with the fake claude:** + - a spec without a Standards section fails the check and is rewritten once; + - a spec-check finding with `standard` reaches the rewrite's findings and the gate evidence; + - a plan's scoped standards come from its file list; + - a review finding with `standard` is important and is listed with its id; + - a merge whose diff touches `loopstra/standards/` blocks with the note; + - a `needs_person` standard makes a change wait in `merge-review` with the standard named, and only when nobody is on the merge gate; + - the done-check's unmet rule blocks, its `needs-person` rule does not, and `outcome.md` has the runtime-written section; + - a repository without standards produces the same prompts, headings and gate rows as before (a golden comparison). +- **Integration:** + - one tick in a repository with an unreadable standard on `main` starts no step and traces `standards`; + - one change runs from accepted to done with one standard for every change and one scoped standard. +- **The hook:** an edit under `loopstra/standards/` is refused in each writing phase. + +## Follow-ups (not in this change) + +- **Acceptance tests from standards.** Give the `acceptance` step the plan's applicable rules, so the rules a test can check get one, listed in `acceptance.md` by id, which the done-check then reads like the criteria's. +- **Chat.** Tell the orchestrator the standards' titles and scopes, so it can answer "what rules would this change fall under?" while an intent is worked out. +- **An export for auditors.** `loopstra export ` would write each change's records and its trace rows (gates, approvals, who did what, when) into one folder or archive, since `.loopstra/` is local and not kept. +- **`loopstra retro`** could report which rules most often fail at which gate. That is the signal that a rule is unclear, or that a design keeps fighting it. From b2af45b06857e2541f78f492600bd161c67cd819 Mon Sep 17 00:00:00 2001 From: John Flavan Date: Wed, 28 Oct 2026 07:52:05 -0500 Subject: [PATCH 02/44] docs(standards): call a rule's id its rule label Co-Authored-By: Claude Opus 5.5 --- .../specs/2026-10-28-standards-design.md | 56 +++++++++---------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/docs/superpowers/specs/2026-10-28-standards-design.md b/docs/superpowers/specs/2026-10-28-standards-design.md index f919a1f8..80e85e78 100644 --- a/docs/superpowers/specs/2026-10-28-standards-design.md +++ b/docs/superpowers/specs/2026-10-28-standards-design.md @@ -31,7 +31,7 @@ Today a repository can tell Loopstra's sessions how to work (`CLAUDE.md`, `.clau - **An agent must never weaken the rules that judge it.** No session may edit `loopstra/standards/`, and a change whose diff does is blocked, whatever the hook let through. - **A broken standards file must never mean "no standards".** If a file on `main` cannot be read, the loop does nothing until it is fixed. It does not run changes as if the rules were not there. -- **Standards must not turn the owner's documents into engineering documents.** The parts of the spec and outcome written for the owner stay in plain words; rule ids and technical evidence go in sections of their own. +- **Standards must not turn the owner's documents into engineering documents.** The parts of the spec and outcome written for the owner stay in plain words; rule labels and technical evidence go in sections of their own. --- @@ -61,12 +61,12 @@ Sessions are given the whole file, so this text is context, not decoration. - Unknown keys are errors, as in the config. - **The title is the first `# ` heading.** It is required. - **The rules are under `## Rules`.** The section is required and holds at least one rule. - - Each rule is a bullet `- ID: text`. Indented lines that follow continue the rule. - - An id starts with a capital letter and has up to 32 letters, digits, `.`, `_` and `-`, ending in a letter or digit: `ARCH-1`, `SOC2-CC6.1`, `SEC_3`. Claude's call: a team's own scheme (including a framework's control numbers) fits, and ids are stable as rules are added. A rule's number in a list is not, so an id is never derived from its position. - - Ids are unique across all files. + - Each rule is a bullet `- LABEL: text`, where the rule label is a short name the team gives the rule. The spec, the plan, review findings and the outcome point at a rule by its label. Indented lines that follow continue the rule. + - A rule label starts with a capital letter and has up to 32 letters, digits, `.`, `_` and `-`, ending in a letter or digit: `ARCH-1`, `SOC2-CC6.1`, `SEC_3`. Claude's call: a team's own scheme (including a framework's control numbers) fits, and labels stay the same as rules are added. A rule's number in a list does not, so a label is never derived from its position. + - No two rules have the same label, across all files. - **Everything else in the file is free text.** Sessions read it; Loopstra does not parse it. -A file breaks these rules when it has no title, no Rules section, an empty one, a bullet without a valid id, a duplicate id, a pattern that is not a valid glob, or front matter that does not parse. Such a file is unreadable (see When a file cannot be read). +A file breaks these rules when it has no title, no Rules section, an empty one, a bullet without a valid rule label, a label used twice, a pattern that is not a valid glob, or front matter that does not parse. Such a file is unreadable (see When a file cannot be read). ### Which standards apply @@ -94,7 +94,7 @@ Standards are read with `git cat-file` from the same commit of `main` that the s Unreadable standards are treated like a config that does not load, and the check fails closed: -- At the start of each tick, after the config loads, the tick reads `loopstra/standards/` from `main`'s commit. If any file is unreadable, the tick starts no steps and traces it once while it stays the same (`eventOnChange`), with the reason `standards`. `loopstra status` and the console show it under Needs attention: "loopstra/standards/soc2.md cannot be read: the rule on line 14 has no id (write it as `- ID: text`). Fix it on main and commit; until then no change moves on." +- At the start of each tick, after the config loads, the tick reads `loopstra/standards/` from `main`'s commit. If any file is unreadable, the tick starts no steps and traces it once while it stays the same (`eventOnChange`), with the reason `standards`. `loopstra status` and the console show it under Needs attention: "loopstra/standards/soc2.md cannot be read: the rule on line 14 has no rule label (write it as `- LABEL: text`). Fix it on main and commit; until then no change moves on." - `loopstra start` refuses with the same sentence, and `loopstra setup --check` lists it under "To fix". - A step that finds an unreadable file at its own commit (main moved between the tick's read and the step's) fails as `notTheStepsFault`, keeps its status and runs again. It never runs without the rules. @@ -103,7 +103,7 @@ Unreadable standards are treated like a config that does not load, and the check A new command, read-only, needing no Claude: ``` -loopstra standards # each standard: title, scope (every change, or its patterns), whether it needs a person, its rule ids +loopstra standards # each standard: title, scope (every change, or its patterns), whether it needs a person, its rule labels loopstra standards --check # also exits 1 when any file is unreadable, naming each problem ``` @@ -130,27 +130,27 @@ The shipped prompts that get it: `design`, `spec-check`, `plan`, `plan-challenge ### Spec (design and spec-check) -- **`design.md`** says: when standards are listed, add `## Standards` after Areas of concern. It has one bullet per rule of each standard you include, `- ID: how the design keeps it`, or `- ID: does not apply: why`. This section is for engineers and may be technical. Every standard that applies to every change must be there in full. +- **`design.md`** says: when standards are listed, add `## Standards` after Areas of concern. It has one bullet per rule of each standard you include, `- LABEL: how the design keeps it`, or `- LABEL: does not apply: why`. This section is for engineers and may be technical. Every standard that applies to every change must be there in full. - **The spec gate gets a code check, `standards`,** run with the headings check when the repository has standards. It checks four things: - the `Standards` heading is present; - - every rule id of every standard without `applies_to` has a bullet; - - every id there is a real rule (an id that does not exist is a failure, which catches a rule the designer invented); + - the label of every rule of every standard without `applies_to` has a bullet; + - every label there is a real rule's (a label that matches no rule is a failure, which catches a rule the designer invented); - a scoped standard is either absent or there in full. Evidence: "all 12 rules addressed", or "missing SEC-2, SEC-4; no rule called SEC-9". - **`spec-check.md`** gets a fourth task, when standards are listed: - Check each bullet of the Standards section against the Design. Is the claim borne out, and is each "does not apply" sound? - Does the design break any rule that applies, whether the section says so or not? - - Each problem is a finding that is not met, with `standard` set to the rule id. + - Each problem is a finding that is not met, with `standard` set to the rule label. The readability task names Standards with Design and Affected code as the engineers' sections. -- **The envelope.** `requirement` gets an optional `standard: string`. The gate evidence and the rewrite's findings put the id first: "SEC-3: the design logs the request body, which holds card numbers". +- **The envelope.** `requirement` gets an optional `standard: string`. The gate evidence and the rewrite's findings put the rule label first: "SEC-3: the design logs the request body, which holds card numbers". A failure takes the existing path: one rewrite with the findings, then blocked with the stage's `failedNote`. ### Plan (plan and plan-challenge) -- **`plan.md`** says: when standards are listed, add `## Standards` after Proof. It has one bullet per rule that applies, `- ID: how the plan keeps it, and which item under Proof shows it`, or `- ID: does not apply: why`. +- **`plan.md`** says: when standards are listed, add `## Standards` after Proof. It has one bullet per rule that applies, `- LABEL: how the plan keeps it, and which item under Proof shows it`, or `- LABEL: does not apply: why`. - **The plan gate's `standards` code check** works as the spec's, on the plan's applicable set, with one difference: every rule of every standard that applies must have a bullet, the scoped ones included, since the files are known now. It runs after the `files` check, on the files that check read. - **`plan-challenge.md`** gets the same task as spec-check, on the plan. A broken rule, or a claim the Order of work does not carry out, is a concern with `blocking: true`. `concern` gets an optional `standard`. @@ -160,8 +160,8 @@ A failure takes the existing path: one rewrite with the findings, then blocked w ### Review -- **`review.md`** gets the standards that apply to the diff, with: "A change that breaks a rule below is an important finding; set `standard` to the rule's id." -- **`finding` gets an optional `standard`.** `review.md`, the record, lists a finding's rule id before its location. +- **`review.md`** gets the standards that apply to the diff, with: "A change that breaks a rule below is an important finding; set `standard` to the rule's label." +- **`finding` gets an optional `standard`.** `review.md`, the record, lists a finding's rule label before its location. - **`REVIEW.md`'s shipped template** gets a Standards pass: "the change keeps every rule in `loopstra/standards/` that applies to it". That covers a Claude review of the repository outside the loop too. - **A rule that applies to the diff but was not in the plan's set** (the build touched a scoped standard's files the plan did not list) needs no special handling. The reviewer is given it like any other, and the drift check's reconcile has already rewritten the plan. @@ -172,26 +172,26 @@ The merge gate gets two checks, both code, both at `passMergeGate` with the risk 1. **`standards-untouched`**: the diff touches nothing under `loopstra/standards/`. If it does, the change is blocked, and the gate row names the files. The note: "This change edits the repository's standards (loopstra/standards/soc2.md), which only a person may change. An engineer can take that edit out of the change's branch and retry the change, or close it." Claude's call: block rather than let a person approve it. An approval at the merge gate is about the change, and a standards edit hidden in a feature is exactly what the reviewer at the gate is least likely to read. 2. **`needs_person`**: a standard with `needs_person: true` that applies to the diff makes the change risky, as a file in `gates.merge.risky.paths` does. The same rule holds as for risk: it only changes who decides when nobody is on the config's merge gate. `scoreRisk` is given the patterns of those standards beside `risky.paths`, and `riskNote` names the standard: "This change falls under the standard Payments data (loopstra/standards/payments.md), which is set to need a person before merging: it touches services/billing/api.ts. Approve the pull request on GitHub, or set status to merge-approved." A `needs_person` standard without `applies_to` makes every change wait. `loopstra standards` says so, since it is the same as `gates.merge.human: pr`. -The trace gets a `standards` row on the merge gate when the repository has standards. It names the standards that apply and their rule ids, so the record of what applied is fixed at the commit that merged. +The trace gets a `standards` row on the merge gate when the repository has standards. It names the standards that apply and their rule labels, so the record of what applied is fixed at the commit that merged. ### Done (done-check and outcome.md) - **The files** are those the change brought into `main`: the diff of the commit that brought it in, against its first parent (for a squash, the squash commit; for a merge, the merge commit). It is read where the done-check's clean copy of `main` is made. Claude's call: this is the same answer for a local merge, a pull request merged by Loopstra and one merged by hand. If the step cannot name that commit, it uses the merge gate's `standards` row. -- **`done-check.md`** gets the applicable standards. The envelope gets `standards: [{ id, result: met | unmet | needs-person, evidence }]`, one item per applicable rule, judged as the criteria are: `met` only with evidence from the repository, `needs-person` for what the repository cannot show ("the alert exists in the monitoring system"). -- **The runtime writes the section, not the agent.** It appends `## Standards` to `outcome.md`, after the owner's sections: one bullet per rule, `- ID (Title): met: evidence`. The format is the runtime's, so it is the same in every outcome and a script can read it. The section is introduced by one plain sentence for the owner: "The engineering rules this change was checked against:". -- **Unmet rules block,** as unmet criteria do, with their own note when only standards are unmet: "The change merged, but the final check found engineering rules it does not keep. The details are in outcome.md. An engineer should decide whether to open a follow-up change, then set this one to done or closed." When both are unmet, the existing note gets "and engineering rules" after "Done when criteria". `needs-person` rules go under the existing "For a person to confirm" heading too, with their ids. +- **`done-check.md`** gets the applicable standards. The envelope gets `standards: [{ label, result: met | unmet | needs-person, evidence }]`, one item per applicable rule, judged as the criteria are: `met` only with evidence from the repository, `needs-person` for what the repository cannot show ("the alert exists in the monitoring system"). +- **The runtime writes the section, not the agent.** It appends `## Standards` to `outcome.md`, after the owner's sections: one bullet per rule, `- LABEL (Title): met: evidence`. The format is the runtime's, so it is the same in every outcome and a script can read it. The section is introduced by one plain sentence for the owner: "The engineering rules this change was checked against:". +- **Unmet rules block,** as unmet criteria do, with their own note when only standards are unmet: "The change merged, but the final check found engineering rules it does not keep. The details are in outcome.md. An engineer should decide whether to open a follow-up change, then set this one to done or closed." When both are unmet, the existing note gets "and engineering rules" after "Done when criteria". `needs-person` rules go under the existing "For a person to confirm" heading too, with their labels. - **With `gates.done.agent: false`** no rule is judged, and the Standards section lists the rules that applied with "not checked automatically". Claude's call: the record still says what applied. ## Protection The test-protection hook (`templates/hooks/loopstra-protect-tests.ts`) refuses an edit under `loopstra/standards/` in every session that can write, whatever `LOOPSTRA_PHASE` is: acceptance, build, fix, reconcile, revise and resolve. The refusal message says that standards are changed by a person on `main`. The merge check above is the backstop for what a hook cannot see (a write through a shell command the session was allowed). -Claude's call: a standard is not added to `.claude/rules`, and `.claude/rules` is not treated as a standard. Rules there are guidance Claude Code loads into sessions. Standards are what the gates check, with ids and a record. A team keeps conventions in the first and the must-haves in the second, and can point one at the other. +Claude's call: a standard is not added to `.claude/rules`, and `.claude/rules` is not treated as a standard. Rules there are guidance Claude Code loads into sessions. Standards are what the gates check, with rule labels and a record. A team keeps conventions in the first and the must-haves in the second, and can point one at the other. ## Status, console and records - No new screen. The spec, plan, review and outcome pages already render those files, and the new sections show there. -- The session page needs nothing new. The gate rows (`standards` on spec, plan, merge and done, plus findings with ids) show in the trace as rows do today. +- The session page needs nothing new. The gate rows (`standards` on spec, plan, merge and done, plus findings with rule labels) show in the trace as rows do today. - `loopstra status` and the console show unreadable standards under Needs attention (see When a file cannot be read). - Nothing is added to the intent template, the owner's guide or chat. @@ -199,7 +199,7 @@ Claude's call: a standard is not added to `.claude/rules`, and `.claude/rules` i - **`loopstra init`** writes no standards. Off is the default and means absent. - **`loopstra setup --check`** runs `loopstra standards --check`. -- **The operator skill** (`templates/skill/SKILL.md`) gets a section on writing a standard. It covers the file's shape, choosing ids, scoping with `applies_to`, and writing a rule a judge can check ("every endpoint under api/ checks the caller's role with `requireRole`", not "be secure"). It ends by saying to commit the file on `main`. +- **The operator skill** (`templates/skill/SKILL.md`) gets a section on writing a standard. It covers the file's shape, choosing rule labels, scoping with `applies_to`, and writing a rule a judge can check ("every endpoint under api/ checks the caller's role with `requireRole`", not "be secure"). It ends by saying to commit the file on `main`. - **README** gets a Standards section with two worked examples: - an architecture standard for every change; - a SOC 2 technical-controls standard scoped to `services/**` and `infra/**`: authentication and role checks, audit logs for access to customer data, encryption in transit and at rest, no secrets or personal data in logs, keeping and deleting data on schedule. @@ -211,7 +211,7 @@ Claude's call: a standard is not added to `.claude/rules`, and `.claude/rules` i ## Code - **`src/standards.ts`** (layer 3, state on disk). It holds: - - the zod schema of the front matter and the parsed `Standard` (`name`, `path`, `title`, `appliesTo?`, `needsPerson`, `rules: { id, text }[]`, `body`); + - the zod schema of the front matter and the parsed `Standard` (`name`, `path`, `title`, `appliesTo?`, `needsPerson`, `rules: { label, text }[]`, `body`); - `readStandards(git, commit)`, which returns the standards or the problems, each naming the file and line; - `applying(standards, files)`; - `renderStandards(applying, matched)`, for the prompt variable; @@ -230,16 +230,16 @@ Claude's call: a standard is not added to `.claude/rules`, and `.claude/rules` i ## Tests - **Unit, in `tests/unit/standards.test.ts`:** - - parsing: front matter, titles, rules with continuation lines, each kind of unreadable file, and duplicate ids across files; + - parsing: front matter, titles, rules with continuation lines, each kind of unreadable file, and a label used in two files; - applicability, renames included; - - the section check: missing ids, unknown ids, and a scoped standard given in part. + - the section check: missing labels, labels that match no rule, and a scoped standard given in part. - A property test in `properties.test.ts` checks that parsing a rendered rule list gives back the same ids and texts. + A property test in `properties.test.ts` checks that parsing a rendered rule list gives back the same labels and texts. - **Stages, with the fake claude:** - a spec without a Standards section fails the check and is rewritten once; - a spec-check finding with `standard` reaches the rewrite's findings and the gate evidence; - a plan's scoped standards come from its file list; - - a review finding with `standard` is important and is listed with its id; + - a review finding with `standard` is important and is listed with its label; - a merge whose diff touches `loopstra/standards/` blocks with the note; - a `needs_person` standard makes a change wait in `merge-review` with the standard named, and only when nobody is on the merge gate; - the done-check's unmet rule blocks, its `needs-person` rule does not, and `outcome.md` has the runtime-written section; @@ -251,7 +251,7 @@ Claude's call: a standard is not added to `.claude/rules`, and `.claude/rules` i ## Follow-ups (not in this change) -- **Acceptance tests from standards.** Give the `acceptance` step the plan's applicable rules, so the rules a test can check get one, listed in `acceptance.md` by id, which the done-check then reads like the criteria's. +- **Acceptance tests from standards.** Give the `acceptance` step the plan's applicable rules, so the rules a test can check get one, listed in `acceptance.md` by rule label, which the done-check then reads like the criteria's. - **Chat.** Tell the orchestrator the standards' titles and scopes, so it can answer "what rules would this change fall under?" while an intent is worked out. - **An export for auditors.** `loopstra export ` would write each change's records and its trace rows (gates, approvals, who did what, when) into one folder or archive, since `.loopstra/` is local and not kept. - **`loopstra retro`** could report which rules most often fail at which gate. That is the signal that a rule is unclear, or that a design keeps fighting it. From b95944e39dc2c03b7e9720edc5b79458506203b4 Mon Sep 17 00:00:00 2001 From: John Flavan Date: Wed, 28 Oct 2026 08:12:59 -0500 Subject: [PATCH 03/44] docs(standards): versioned record, exceptions, tool checks, plan-only coverage Co-Authored-By: Claude Opus 5.5 --- .../specs/2026-10-28-standards-design.md | 134 ++++++++++++------ 1 file changed, 92 insertions(+), 42 deletions(-) diff --git a/docs/superpowers/specs/2026-10-28-standards-design.md b/docs/superpowers/specs/2026-10-28-standards-design.md index 80e85e78..a8177f43 100644 --- a/docs/superpowers/specs/2026-10-28-standards-design.md +++ b/docs/superpowers/specs/2026-10-28-standards-design.md @@ -1,6 +1,6 @@ # Standards: rules every change is held to -2026-10-28. A way for an engineering or architecture team to write down the rules every change must keep (architecture, security, compliance controls such as SOC 2's), and have the loop check each change against them at every gate, with a record per rule of how the change met it. The user asked for this as a spec and, as before, left the decisions to Claude; each one below is Claude's call, recorded in `docs/decisions.md` when it is built. +2026-10-28. A way for an engineering or architecture team to write down the rules every change must keep (architecture, security, compliance controls such as SOC 2's), and have the loop check each change against them at every gate, with a record per rule of how the change met it. The user asked for this as a spec and left most decisions to Claude; each one marked "Claude's call" is recorded in `docs/decisions.md` when it is built. The user chose, from options, how the record stays accurate as standards change, how exceptions are granted, how tool checks run, how much the spec and plan list, the command, and keeping `needs_person`; those are marked "The user's choice". ## Why @@ -14,8 +14,9 @@ Today a repository can tell Loopstra's sessions how to work (`CLAUDE.md`, `.clau ## Goals - A team writes its rules once, as Markdown in the repository, and every change is checked against the ones that apply to it: at the spec, at the plan, in review, and after the merge. -- Each check that can be code is code: which rules apply, whether the spec and plan address each one, whether a change edited the rules. The agents judge only what needs judgment: whether a design, plan or diff actually keeps a rule. -- Every change leaves a plain record, in git, of the rules that applied to it and the evidence for each: the spec's and plan's Standards sections, review findings that name the rule, and a Standards section in `outcome.md`. +- Each check that can be code is code: which rules apply, whether the plan addresses each one, whether a change edited the rules, and any rule a tool can check (a standard can name the command). The agents judge only what needs judgment: whether a design, plan or diff actually keeps a rule. +- A change that cannot keep a rule can ask for an exception, and only a person can grant it. +- Every change leaves a plain record, in git, of the rules that applied to it and the evidence for each: the spec's and plan's Standards sections, review findings that name the rule, and a Standards section in `outcome.md` that names the version of the standards it was checked against. - No standards, no change: a repository without `loopstra/standards/` runs exactly as before, and its specs and plans keep their current headings. ## Non-goals @@ -29,7 +30,7 @@ Today a repository can tell Loopstra's sessions how to work (`CLAUDE.md`, `.clau ## Anti-goals -- **An agent must never weaken the rules that judge it.** No session may edit `loopstra/standards/`, and a change whose diff does is blocked, whatever the hook let through. +- **An agent must never weaken the rules that judge it.** No session may edit `loopstra/standards/`, and a change whose diff does is blocked, whatever the hook let through. No agent can grant an exception either. - **A broken standards file must never mean "no standards".** If a file on `main` cannot be read, the loop does nothing until it is fixed. It does not run changes as if the rules were not there. - **Standards must not turn the owner's documents into engineering documents.** The parts of the spec and outcome written for the owner stay in plain words; rule labels and technical evidence go in sections of their own. @@ -37,12 +38,13 @@ Today a repository can tell Loopstra's sessions how to work (`CLAUDE.md`, `.clau ## Standard files -A standard is one Markdown file in `loopstra/standards/`, named `.md` with lowercase words joined by dashes (like intent slugs). Any other file there is ignored and listed by `loopstra standards`. Files are committed, and Loopstra reads them from `main`'s commit, never from a checkout (see Where they are read from). +A standard is one Markdown file in `loopstra/standards/`, named `.md` with lowercase words joined by dashes (like intent slugs). Any other file there is ignored, and `loopstra standards` lists it as ignored. Files are committed, and Loopstra reads them from `main`'s commit, never from a checkout (see Where they are read from). ```markdown --- applies_to: ["services/**", "api/**"] needs_person: false +check: bunx depcruise --config .dependency-cruiser.cjs services --- # Service architecture @@ -57,13 +59,15 @@ Sessions are given the whole file, so this text is context, not decoration. - **The front matter is optional.** - `applies_to`: glob patterns, matched against repo-relative paths with `/`, with the same semantics as `gates.merge.risky.paths` (Bun.Glob; a bare name matches only at the top). The standard then applies only to a change that touches a matching file. Without it, the standard applies to every change. - - `needs_person`: `true` makes a change this standard applies to wait for a person before it merges (see Merge). The default is `false`. + - `needs_person`: `true` makes a change this standard applies to wait for a person before it merges (see Merge). The default is `false`. The user's choice: keep it, so a standard's scope and who must approve it stay in one place rather than repeating the patterns in `gates.merge.risky.paths`. + - `check`: a shell command that exits 0 when the code keeps the standard's rules a tool can check (a secret scanner, semgrep rules, a layering checker). It runs in the merge checks of every change the standard applies to (see Merge). Optional. - Unknown keys are errors, as in the config. - **The title is the first `# ` heading.** It is required. - **The rules are under `## Rules`.** The section is required and holds at least one rule. - Each rule is a bullet `- LABEL: text`, where the rule label is a short name the team gives the rule. The spec, the plan, review findings and the outcome point at a rule by its label. Indented lines that follow continue the rule. - A rule label starts with a capital letter and has up to 32 letters, digits, `.`, `_` and `-`, ending in a letter or digit: `ARCH-1`, `SOC2-CC6.1`, `SEC_3`. Claude's call: a team's own scheme (including a framework's control numbers) fits, and labels stay the same as rules are added. A rule's number in a list does not, so a label is never derived from its position. - No two rules have the same label, across all files. + - A label may be given to a new rule after its old rule is deleted, and a rule's wording may change. Each outcome records the commit the standards were read from (see Done), so a past "SEC-4: met" always means SEC-4 as worded then. The user's choice: one line in the record instead of a list of retired labels. - **Everything else in the file is free text.** Sessions read it; Loopstra does not parse it. A file breaks these rules when it has no title, no Rules section, an empty one, a bullet without a valid rule label, a label used twice, a pattern that is not a valid glob, or front matter that does not parse. Such a file is unreadable (see When a file cannot be read). @@ -74,7 +78,7 @@ Applicability is a fact about files, never an agent's judgment, except at the sp | When | The files | The standards that apply | |---|---|---| -| spec | none yet | every standard without `applies_to`; the designer adds any scoped standard whose files its design will touch | +| spec | none yet | every standard is shown, the scoped ones with their patterns; the designer judges which its design touches | | plan | the plan's Files that change | every standard without `applies_to`, and each one whose patterns match a listed file | | build, resolve | the plan's Files that change | as for the plan | | review, merge | the branch's diff against `main` outside `intent/` (as `scoreRisk` reads it, renames by old and new path) | as for the plan, on the diff | @@ -95,19 +99,18 @@ Standards are read with `git cat-file` from the same commit of `main` that the s Unreadable standards are treated like a config that does not load, and the check fails closed: - At the start of each tick, after the config loads, the tick reads `loopstra/standards/` from `main`'s commit. If any file is unreadable, the tick starts no steps and traces it once while it stays the same (`eventOnChange`), with the reason `standards`. `loopstra status` and the console show it under Needs attention: "loopstra/standards/soc2.md cannot be read: the rule on line 14 has no rule label (write it as `- LABEL: text`). Fix it on main and commit; until then no change moves on." -- `loopstra start` refuses with the same sentence, and `loopstra setup --check` lists it under "To fix". +- `loopstra start` refuses with the same sentence, and `loopstra setup --check` lists it under "To fix" and exits 1. - A step that finds an unreadable file at its own commit (main moved between the tick's read and the step's) fails as `notTheStepsFault`, keeps its status and runs again. It never runs without the rules. ## `loopstra standards` -A new command, read-only, needing no Claude: +A new command for whoever writes the standards, read-only, needing no Claude, with no flags: ``` -loopstra standards # each standard: title, scope (every change, or its patterns), whether it needs a person, its rule labels -loopstra standards --check # also exits 1 when any file is unreadable, naming each problem +loopstra standards # each standard: title, scope (every change, or its patterns), whether it needs a person, its check, its rule labels ``` -It reads the files from `main`'s last commit and says, under the list, when the main checkout has uncommitted edits in `loopstra/standards/` that are not yet in force. It warns when the standards together are over 40 KB, since every applicable standard goes into every prompt that uses them whole. Claude's call: a warning, not a limit; a team with large standards can scope them. +It reads the files from `main`'s last commit. It names each problem in a file that cannot be read, and the files it ignores. Under the list, it says when the main checkout has uncommitted edits in `loopstra/standards/` that are not yet in force. It exits 0 either way: validating, with an exit code, is `loopstra setup --check`'s job, beside every other check. The user's choice: one command to list, and no second way to check. It warns when the standards together are over 40 KB, since every applicable standard goes into every prompt that uses them whole. Claude's call: a warning, not a limit; a team with large standards can scope them. --- @@ -124,24 +127,26 @@ Applies because the change touches services/billing/api.ts and services/billing/ ``` -For the spec, scoped standards are listed after the ones that apply to every change, under "These apply only to changes that touch the files named; include one in your Standards section if your design touches them." With no standards, the variable is empty and renders as `(none)`. +For the spec, scoped standards are listed after the ones that apply to every change, under "These apply only to changes that touch the files named." With no standards, the variable is empty and renders as `(none)`. + +Rules under an approved exception (see Exceptions) are listed as such, with the reason, in every prompt after the approval. Later steps then neither break the rest of the standard nor report the exception as a breach. The shipped prompts that get it: `design`, `spec-check`, `plan`, `plan-challenge`, `build`, `resolve`, `review` and `done-check`. `fix`, `reconcile` and `revise` continue the build's session and already have it. A repository's own copy of one of these prompts without `{{standards}}` gets it appended at the end under "Standards this repository holds every change to:", exactly as `{{resolution}}` is appended to `review.md`, but only when the repository has standards, so a repository without them sees no change. ### Spec (design and spec-check) -- **`design.md`** says: when standards are listed, add `## Standards` after Areas of concern. It has one bullet per rule of each standard you include, `- LABEL: how the design keeps it`, or `- LABEL: does not apply: why`. This section is for engineers and may be technical. Every standard that applies to every change must be there in full. -- **The spec gate gets a code check, `standards`,** run with the headings check when the repository has standards. It checks four things: +The spec covers only the rules its design decides something about. The plan, where the files are known, covers every rule that applies. The user's choice: a 40-rule standard would otherwise put about 35 "does not apply" lines in each spec, repeated in its plan, and neither judge loses anything, since both look for breaches of every rule either way. + +- **`design.md`** says: when standards are listed, add `## Standards` after Areas of concern. List each rule your design decides something about, `- LABEL: how the design keeps it`, and each rule it cannot keep, `- LABEL: exception: why` (see Exceptions). Write "None." when there are none. This section is for engineers and may be technical. +- **The spec gate gets a code check, `standards`,** run with the headings check when the repository has standards. It checks two things: - the `Standards` heading is present; - - the label of every rule of every standard without `applies_to` has a bullet; - - every label there is a real rule's (a label that matches no rule is a failure, which catches a rule the designer invented); - - a scoped standard is either absent or there in full. + - every label there is a real rule's. A label that matches no rule is a failure, which catches a rule the designer invented. - Evidence: "all 12 rules addressed", or "missing SEC-2, SEC-4; no rule called SEC-9". + Evidence: "3 rules addressed, 1 exception", or "no rule called SEC-9". - **`spec-check.md`** gets a fourth task, when standards are listed: - - Check each bullet of the Standards section against the Design. Is the claim borne out, and is each "does not apply" sound? - - Does the design break any rule that applies, whether the section says so or not? - - Each problem is a finding that is not met, with `standard` set to the rule label. + - Check each bullet of the Standards section against the Design. Is the claim borne out? + - Does the design break any rule that applies, whether the section mentions it or not? A rule it breaks without asking for an exception is a finding that is not met, with `standard` set to the rule label. + - An exception is not the judge's to grant or refuse. The judge does check that its reason is stated and that the design does what it says. Whether the exception is acceptable is a person's call. The readability task names Standards with Design and Affected code as the engineers' sections. - **The envelope.** `requirement` gets an optional `standard: string`. The gate evidence and the rewrite's findings put the rule label first: "SEC-3: the design logs the request body, which holds card numbers". @@ -150,13 +155,33 @@ A failure takes the existing path: one rewrite with the findings, then blocked w ### Plan (plan and plan-challenge) -- **`plan.md`** says: when standards are listed, add `## Standards` after Proof. It has one bullet per rule that applies, `- LABEL: how the plan keeps it, and which item under Proof shows it`, or `- LABEL: does not apply: why`. -- **The plan gate's `standards` code check** works as the spec's, on the plan's applicable set, with one difference: every rule of every standard that applies must have a bullet, the scoped ones included, since the files are known now. It runs after the `files` check, on the files that check read. -- **`plan-challenge.md`** gets the same task as spec-check, on the plan. A broken rule, or a claim the Order of work does not carry out, is a concern with `blocking: true`. `concern` gets an optional `standard`. +- **`plan.md`** says: when standards are listed, add `## Standards` after Proof. It has one bullet per rule that applies, in one of three forms: + - `- LABEL: how the plan keeps it, and which item under Proof shows it`; + - `- LABEL: does not apply: why`; + - `- LABEL: exception: why`. +- **The plan gate's `standards` code check** checks three things, on the plan's applicable set: + - the heading is present; + - every rule of every standard that applies has a bullet, the scoped ones included; + - every label there is a real rule's. + + It runs after the `files` check, on the files that check read. Evidence: "all 14 rules addressed", or "missing SEC-2, SEC-4; no rule called SEC-9". +- **`plan-challenge.md`** gets the same task as spec-check, on the plan: a "does not apply" that is not sound, a broken rule, or a claim the Order of work does not carry out is a concern with `blocking: true`. `concern` gets an optional `standard`. Exceptions are a person's call, as at the spec. + +### Exceptions + +A spec or plan can say a rule cannot be kept for this change: `- ARCH-1: exception: billing keeps reading the legacy orders table until it is migrated (ADR-12)`. No agent can grant it. The user's choice: inline in the spec or plan, approved by a person at that gate, rather than a separate file of standing waivers. + +- **Who decides.** When a spec or plan passes its checks and has an exception not already approved for this change, its gate waits for a person, even when nobody is on that gate in the config. The runtime writes `spec_gate: status` or `plan_gate: status` in the intent's front matter, in the record that moves the status to `spec-review` or `plan-review`. It works as `merge_gate` does for a risky change: runtime-managed, left out when blank, and read through `changeGates` in `src/intents.ts` beside `merge_gate`, so the scheduler, `status`, the console and chat treat the change as waiting for a person. A person who deletes the line takes the change back to the config's gate. +- **The note.** "The plan asks for an exception to ARCH-1 (Service architecture): billing keeps reading the legacy orders table until it is migrated. Read plan.md. To allow it, set status to plan-approved; to have the plan written again without it, send it back with a note." With more than one exception, it names them all. +- **What counts as approved.** A person moving the status to `spec-approved` or `plan-approved` approves every exception in that artifact. The trace records it as a gate row, `exception`, `pass`, with the labels and who approved, when the approval came as a request that names them (the console, chat). An exception approved at the spec and repeated in the plan for the same rule needs no second approval. An exception that is new in the plan, or a rule whose exception the plan's wording changes, does. +- **Later steps.** Build, resolve and review are told which rules are under an approved exception and why (see Prompt variable). A breach of that rule, within what the exception says, is not a finding. The build cannot ask for an exception itself: when the plan would make it break a rule, it asks a question, as it can today, and a person answers or sends the change back. +- **The record.** The outcome's Standards section lists the rule as `- ARCH-1 (Service architecture): exception, approved by a person at the plan: ` (see Done), and the done-check does not judge it. ### Build and resolve -`build.md` and `resolve.md` get the plan's applicable standards, with one line: "Keep every rule below. If the plan would make you break one, stop and say so in your summary rather than break it." The build is not checked against them here; review is. +`build.md` and `resolve.md` get the plan's applicable standards, with one line: "Keep every rule below. If the plan would make you break one, ask a question rather than break it." The build is not checked against them here; review and the merge checks are. + +A standard's `check` is not run in the build's own test runs. The user's choice: it runs once, in the merge checks, so a build is not slowed by checks run on every fix. The build session may still run it: each applicable standard's `check` is added to the commands the build, fix, reconcile and revise sessions may run, as the configured commands are. ### Review @@ -167,7 +192,16 @@ A failure takes the existing path: one rewrite with the findings, then blocked w ### Merge -The merge gate gets two checks, both code, both at `passMergeGate` with the risk check, on the same diff: +**Tool checks.** After the tests pass in the merge checks, each standard that applies to the diff and has a `check` runs it, in the change's worktree, with the same environment as the project's commands (`LOOPSTRA_SLUG`, `LOOPSTRA_SLOT`, the change's temp folder and ports). +- **Failure.** A check that fails is handled as a failing test there: its output goes to the same test loop (`testLoop`, with the build's fix budget, `stages.build.max_fix_loops`), and fixes then go back to review as other merge-check fixes do. +- **Reruns.** Like the tests, a check that passed on the same code is not run again. +- **Past the fix budget.** The change blocks with a note that names the standard: "The change does not pass the automatic check of the standard Secrets (loopstra/standards/secrets.md), even after 3 tries to fix it. The details are in the trace. An engineer can fix it on the branch and retry the change, or close it." +- **The record.** Each check is a gate row (`merge`, `check:`, `pass` or `fail`), and the outcome's Standards section says it passed. +- **Where the command comes from.** It is read from `main`'s commit with the rest of the standard, so a branch cannot change it. As with hooks from `main`'s settings, a check that runs a script from the repository runs the branch's copy of that script, and a reviewer should read changes to such scripts. + +The user's choice: at the merge checks only, once per merge attempt, rather than in every build test run. + +The merge gate also gets two checks, both code, both at `passMergeGate` with the risk check, on the same diff: 1. **`standards-untouched`**: the diff touches nothing under `loopstra/standards/`. If it does, the change is blocked, and the gate row names the files. The note: "This change edits the repository's standards (loopstra/standards/soc2.md), which only a person may change. An engineer can take that edit out of the change's branch and retry the change, or close it." Claude's call: block rather than let a person approve it. An approval at the merge gate is about the change, and a standards edit hidden in a feature is exactly what the reviewer at the gate is least likely to read. 2. **`needs_person`**: a standard with `needs_person: true` that applies to the diff makes the change risky, as a file in `gates.merge.risky.paths` does. The same rule holds as for risk: it only changes who decides when nobody is on the config's merge gate. `scoreRisk` is given the patterns of those standards beside `risky.paths`, and `riskNote` names the standard: "This change falls under the standard Payments data (loopstra/standards/payments.md), which is set to need a person before merging: it touches services/billing/api.ts. Approve the pull request on GitHub, or set status to merge-approved." A `needs_person` standard without `applies_to` makes every change wait. `loopstra standards` says so, since it is the same as `gates.merge.human: pr`. @@ -177,8 +211,14 @@ The trace gets a `standards` row on the merge gate when the repository has stand ### Done (done-check and outcome.md) - **The files** are those the change brought into `main`: the diff of the commit that brought it in, against its first parent (for a squash, the squash commit; for a merge, the merge commit). It is read where the done-check's clean copy of `main` is made. Claude's call: this is the same answer for a local merge, a pull request merged by Loopstra and one merged by hand. If the step cannot name that commit, it uses the merge gate's `standards` row. -- **`done-check.md`** gets the applicable standards. The envelope gets `standards: [{ label, result: met | unmet | needs-person, evidence }]`, one item per applicable rule, judged as the criteria are: `met` only with evidence from the repository, `needs-person` for what the repository cannot show ("the alert exists in the monitoring system"). -- **The runtime writes the section, not the agent.** It appends `## Standards` to `outcome.md`, after the owner's sections: one bullet per rule, `- LABEL (Title): met: evidence`. The format is the runtime's, so it is the same in every outcome and a script can read it. The section is introduced by one plain sentence for the owner: "The engineering rules this change was checked against:". +- **`done-check.md`** gets the applicable standards, the approved exceptions, and the result of each `check` at merge. The envelope gets `standards: [{ label, result: met | unmet | needs-person, evidence }]`, one item per applicable rule not under an exception, judged as the criteria are: `met` only with evidence from the repository, `needs-person` for what the repository cannot show ("the alert exists in the monitoring system"). A standard's passing `check` can be the evidence for the rules it covers. +- **The runtime writes the section, not the agent.** It appends `## Standards` to `outcome.md`, after the owner's sections. Its first line is a plain sentence for the owner that names the version of the standards: "The engineering rules this change was checked against (the standards as of commit 3f9c2ab):". The commit is the one the done-check's standards were read from. +- **What the section lists:** + - one bullet per rule, `- LABEL (Title): met: evidence`; + - an exception as `- LABEL (Title): exception, approved by a person at the plan: `; + - each tool check as `- Check of Secrets: passed at merge`. + + The format is the runtime's, so it is the same in every outcome and a script can read it. - **Unmet rules block,** as unmet criteria do, with their own note when only standards are unmet: "The change merged, but the final check found engineering rules it does not keep. The details are in outcome.md. An engineer should decide whether to open a follow-up change, then set this one to done or closed." When both are unmet, the existing note gets "and engineering rules" after "Done when criteria". `needs-person` rules go under the existing "For a person to confirm" heading too, with their labels. - **With `gates.done.agent: false`** no rule is judged, and the Standards section lists the rules that applied with "not checked automatically". Claude's call: the record still says what applied. @@ -191,18 +231,19 @@ Claude's call: a standard is not added to `.claude/rules`, and `.claude/rules` i ## Status, console and records - No new screen. The spec, plan, review and outcome pages already render those files, and the new sections show there. -- The session page needs nothing new. The gate rows (`standards` on spec, plan, merge and done, plus findings with rule labels) show in the trace as rows do today. +- The session page needs nothing new. The gate rows (`standards` on spec, plan, merge and done, `exception`, `check:`, and findings with rule labels) show in the trace as rows do today. +- A change waiting for a person to approve an exception shows in `status`, the console's inbox and chat as waiting for a person, with the exception's note, through `changeGates`. - `loopstra status` and the console show unreadable standards under Needs attention (see When a file cannot be read). - Nothing is added to the intent template, the owner's guide or chat. ## Setup, skill and docs - **`loopstra init`** writes no standards. Off is the default and means absent. -- **`loopstra setup --check`** runs `loopstra standards --check`. -- **The operator skill** (`templates/skill/SKILL.md`) gets a section on writing a standard. It covers the file's shape, choosing rule labels, scoping with `applies_to`, and writing a rule a judge can check ("every endpoint under api/ checks the caller's role with `requireRole`", not "be secure"). It ends by saying to commit the file on `main`. +- **`loopstra setup --check`** reads the standards from `main`'s last commit and lists each problem under "To fix". It warns when a standard's `check` names a program that is not installed, as it does for the commands. +- **The operator skill** (`templates/skill/SKILL.md`) gets a section on writing a standard. It covers the file's shape, choosing rule labels, scoping with `applies_to`, putting what a tool can check in `check`, when to ask for an exception, and writing a rule a judge can check ("every endpoint under api/ checks the caller's role with `requireRole`", not "be secure"). It ends by saying to commit the file on `main`. - **README** gets a Standards section with two worked examples: - an architecture standard for every change; - - a SOC 2 technical-controls standard scoped to `services/**` and `infra/**`: authentication and role checks, audit logs for access to customer data, encryption in transit and at rest, no secrets or personal data in logs, keeping and deleting data on schedule. + - a SOC 2 technical-controls standard scoped to `services/**` and `infra/**`: authentication and role checks, audit logs for access to customer data, encryption in transit and at rest, no secrets or personal data in logs, keeping and deleting data on schedule. Its `check` runs a secret scanner. It states plainly the limits under Non-goals, and how the records it leaves map to a change-management control (the intent is the request, the spec and plan the design, `review.md` the review, the outcome the evidence, and the pull request's approval with `merge.human: pr` the approval). - **`docs/architecture.md`** lists `standards.ts` in layer 3. @@ -211,15 +252,18 @@ Claude's call: a standard is not added to `.claude/rules`, and `.claude/rules` i ## Code - **`src/standards.ts`** (layer 3, state on disk). It holds: - - the zod schema of the front matter and the parsed `Standard` (`name`, `path`, `title`, `appliesTo?`, `needsPerson`, `rules: { label, text }[]`, `body`); + - the zod schema of the front matter and the parsed `Standard` (`name`, `path`, `title`, `appliesTo?`, `needsPerson`, `check?`, `rules: { label, text }[]`, `body`); - `readStandards(git, commit)`, which returns the standards or the problems, each naming the file and line; - `applying(standards, files)`; - - `renderStandards(applying, matched)`, for the prompt variable; - - `checkStandardsSection(markdown, required, known)`, for the gate checks. + - `renderStandards(applying, matched, exceptions)`, for the prompt variable; + - `readStandardsSection(markdown)`, which returns the section's bullets as `{ label, kind: keeps | not-applicable | exception, text }`; + - `checkStandardsSection(section, required, known)`, for the gate checks; `required` is empty for the spec; + - `exceptionsToApprove(spec, plan)`, the exceptions in an artifact not already approved for this change. The section is found with the same heading reader `headingsPresent` uses. -- **`src/stages/design.ts` and `plan.ts`**: a `standards` check in `checks`, and the variable in `vars` and the judge's `vars`. `ArtifactStage` already has every hook this needs. -- **`src/stages/build.ts`, `review.ts`, `merge.ts` and `verify.ts`**: the variable. `merge.ts` adds the two checks in `passMergeGate`, and `verify.ts` adds the done-check items, the outcome section and the note. +- **`src/stages/design.ts` and `plan.ts`**: a `standards` check in `checks`, and the variable in `vars` and the judge's `vars`. `ArtifactStage` already has a hook for every check. The exception wait is new in `runArtifactStep`: after the checks pass, a pass with exceptions to approve goes to the review status with `spec_gate` or `plan_gate` written. +- **`src/intents.ts`**: `spec_gate` and `plan_gate` in the front matter, left out when blank, and in `changeGates`. +- **`src/stages/build.ts`, `review.ts`, `merge.ts` and `verify.ts`**: the variable, and the standards' checks in the commands build sessions may run. `merge.ts` runs the tool checks after the tests and adds the two checks in `passMergeGate`. `verify.ts` adds the done-check items, the outcome section and the note. - **`src/risk.ts`**: `scoreRisk` takes the extra patterns with the standard each came from, and `riskNote` names it. - **`src/envelopes.ts`**: the optional `standard` fields, and `done-check`'s `standards` array (default `[]`, so a prompt override that never asks for it still parses). - **`src/scheduler.ts`**: the tick's read and the `standards` problem, beside `traceConfigProblem`. @@ -232,17 +276,22 @@ Claude's call: a standard is not added to `.claude/rules`, and `.claude/rules` i - **Unit, in `tests/unit/standards.test.ts`:** - parsing: front matter, titles, rules with continuation lines, each kind of unreadable file, and a label used in two files; - applicability, renames included; - - the section check: missing labels, labels that match no rule, and a scoped standard given in part. + - the section reader: the three kinds of bullet, and continuation lines; + - the section check: at the spec only labels that match no rule fail, and at the plan missing labels fail too; + - exceptions to approve: new at the spec, repeated in the plan (none), new or reworded in the plan. A property test in `properties.test.ts` checks that parsing a rendered rule list gives back the same labels and texts. - **Stages, with the fake claude:** - - a spec without a Standards section fails the check and is rewritten once; + - a spec without a Standards section fails the check and is rewritten once, and a spec that lists only the rules it decides something about passes; + - a plan missing a rule that applies fails the check; + - a spec with an exception waits in `spec-review` with `spec_gate: status` and the note, even with `gates.spec.human: none`; approving it lets the plan repeat the exception without a second wait, and a new exception in the plan waits in `plan-review`; + - a failing `check` at the merge checks goes to the fix loop, then to review; past the fix budget it blocks with the note naming the standard; a passing one is not run again on the same code; - a spec-check finding with `standard` reaches the rewrite's findings and the gate evidence; - a plan's scoped standards come from its file list; - a review finding with `standard` is important and is listed with its label; - a merge whose diff touches `loopstra/standards/` blocks with the note; - a `needs_person` standard makes a change wait in `merge-review` with the standard named, and only when nobody is on the merge gate; - - the done-check's unmet rule blocks, its `needs-person` rule does not, and `outcome.md` has the runtime-written section; + - the done-check's unmet rule blocks, its `needs-person` rule does not, and `outcome.md` has the runtime-written section, with the standards' commit, the exceptions and the tool checks; - a repository without standards produces the same prompts, headings and gate rows as before (a golden comparison). - **Integration:** - one tick in a repository with an unreadable standard on `main` starts no step and traces `standards`; @@ -254,4 +303,5 @@ Claude's call: a standard is not added to `.claude/rules`, and `.claude/rules` i - **Acceptance tests from standards.** Give the `acceptance` step the plan's applicable rules, so the rules a test can check get one, listed in `acceptance.md` by rule label, which the done-check then reads like the criteria's. - **Chat.** Tell the orchestrator the standards' titles and scopes, so it can answer "what rules would this change fall under?" while an intent is worked out. - **An export for auditors.** `loopstra export ` would write each change's records and its trace rows (gates, approvals, who did what, when) into one folder or archive, since `.loopstra/` is local and not kept. -- **`loopstra retro`** could report which rules most often fail at which gate. That is the signal that a rule is unclear, or that a design keeps fighting it. +- **Standing waivers.** A file of exceptions people approved once for a set of files, with a reason and an end date, which a spec or plan could cite instead of asking each time. Worth it only if the same exception keeps coming back. +- **`loopstra retro`** could report which rules most often fail at which gate, and which exceptions are asked for most. That is the signal that a rule is unclear, or that a design keeps fighting it. From af9db2e7a716297041870ac051b2a66bdc9921af Mon Sep 17 00:00:00 2001 From: John Flavan Date: Wed, 28 Oct 2026 08:22:29 -0500 Subject: [PATCH 04/44] docs(standards): console workflow, per-change scope, setup section Co-Authored-By: Claude Opus 5.5 --- .../specs/2026-10-28-standards-design.md | 172 ++++++++++++++++-- 1 file changed, 158 insertions(+), 14 deletions(-) diff --git a/docs/superpowers/specs/2026-10-28-standards-design.md b/docs/superpowers/specs/2026-10-28-standards-design.md index a8177f43..2f819be4 100644 --- a/docs/superpowers/specs/2026-10-28-standards-design.md +++ b/docs/superpowers/specs/2026-10-28-standards-design.md @@ -1,6 +1,15 @@ # Standards: rules every change is held to -2026-10-28. A way for an engineering or architecture team to write down the rules every change must keep (architecture, security, compliance controls such as SOC 2's), and have the loop check each change against them at every gate, with a record per rule of how the change met it. The user asked for this as a spec and left most decisions to Claude; each one marked "Claude's call" is recorded in `docs/decisions.md` when it is built. The user chose, from options, how the record stays accurate as standards change, how exceptions are granted, how tool checks run, how much the spec and plan list, the command, and keeping `needs_person`; those are marked "The user's choice". +2026-10-28. A way for an engineering or architecture team to write down the rules every change must keep (architecture, security, compliance controls such as SOC 2's), and have the loop check each change against them at every gate, with a record per rule of how the change met it. The user asked for this as a spec and left most decisions to Claude; each one marked "Claude's call" is recorded in `docs/decisions.md` when it is built. The user chose, from options, several parts; those are marked "The user's choice": +- how the record stays accurate as standards change; +- how exceptions are granted, and who may approve them; +- how tool checks run; +- how much the spec and plan list; +- the command, and keeping `needs_person`; +- changing a change's standards by hand; +- the console's editor, and its list of exceptions. + +The user also asked that the whole workflow be possible in the console (writing and scoping standards, approving exceptions, reviewing them), and that `loopstra setup` set up standards and their tool checks. ## Why @@ -16,6 +25,7 @@ Today a repository can tell Loopstra's sessions how to work (`CLAUDE.md`, `.clau - A team writes its rules once, as Markdown in the repository, and every change is checked against the ones that apply to it: at the spec, at the plan, in review, and after the merge. - Each check that can be code is code: which rules apply, whether the plan addresses each one, whether a change edited the rules, and any rule a tool can check (a standard can name the command). The agents judge only what needs judgment: whether a design, plan or diff actually keeps a rule. - A change that cannot keep a rule can ask for an exception, and only a person can grant it. +- Everything a person does with standards can be done in the console: write, scope and remove them, try their tool checks, see which apply to a change and why, add or remove a standard for one change, approve exceptions, and list every exception granted. `loopstra setup standards` does the writing and checking from a terminal. - Every change leaves a plain record, in git, of the rules that applied to it and the evidence for each: the spec's and plan's Standards sections, review findings that name the rule, and a Standards section in `outcome.md` that names the version of the standards it was checked against. - No standards, no change: a repository without `loopstra/standards/` runs exactly as before, and its specs and plans keep their current headings. @@ -23,7 +33,8 @@ Today a repository can tell Loopstra's sessions how to work (`CLAUDE.md`, `.clau - Loopstra does not make an application compliant with anything, and does not claim to. SOC 2 is an auditor's opinion of an organisation's controls over a period. Standards let a team state the technical controls it wants in the code and get evidence that each change kept them; access reviews, vendor management, incident response and the rest stay outside. - No standards library shipped with Loopstra (no "SOC 2 pack"). The README shows an example; the rules are the team's, in its words. -- No agent writes or edits standards. A person changes them, on `main`, by hand. +- No agent writes or edits standards. A person changes them on `main`: by hand, on the console's Standards page, or in `loopstra setup standards`. The console's writes are made by the loop's runtime (code) for the person who asked, as with agent files. +- Chat neither edits standards nor changes which apply to a change (Claude's call: the orchestrator is an agent, and edits made through it would come from an agent). Chat still approves a spec or plan, its exceptions included, as it can today. - Acceptance tests are not written from standards in this change (a follow-up; see the end). - Chat does not read standards in this change. The orchestrator is not told them, and intents are unchanged: product owners never need to know the standards exist. - No export of the trace for auditors (a follow-up). The records in `intent//` are the durable evidence this change adds. @@ -84,6 +95,8 @@ Applicability is a fact about files, never an agent's judgment, except at the sp | review, merge | the branch's diff against `main` outside `intent/` (as `scoreRisk` reads it, renames by old and new path) | as for the plan, on the diff | | done | the files the change brought into `main` | as for the plan, on those files | +A person can change this for one change (see Changing a change's standards): a standard they added applies at every step from then on, whatever the files, and one they removed applies at none. + A standard that applies is used whole: every rule in it applies. Claude's call: a rule-level scope would let a file of 30 controls send each rule to its own files, but it doubles what a team writes and reads, and splitting such a file in two does the same. ## Where they are read from @@ -173,9 +186,42 @@ A spec or plan can say a rule cannot be kept for this change: `- ARCH-1: excepti - **Who decides.** When a spec or plan passes its checks and has an exception not already approved for this change, its gate waits for a person, even when nobody is on that gate in the config. The runtime writes `spec_gate: status` or `plan_gate: status` in the intent's front matter, in the record that moves the status to `spec-review` or `plan-review`. It works as `merge_gate` does for a risky change: runtime-managed, left out when blank, and read through `changeGates` in `src/intents.ts` beside `merge_gate`, so the scheduler, `status`, the console and chat treat the change as waiting for a person. A person who deletes the line takes the change back to the config's gate. - **The note.** "The plan asks for an exception to ARCH-1 (Service architecture): billing keeps reading the legacy orders table until it is migrated. Read plan.md. To allow it, set status to plan-approved; to have the plan written again without it, send it back with a note." With more than one exception, it names them all. -- **What counts as approved.** A person moving the status to `spec-approved` or `plan-approved` approves every exception in that artifact. The trace records it as a gate row, `exception`, `pass`, with the labels and who approved, when the approval came as a request that names them (the console, chat). An exception approved at the spec and repeated in the plan for the same rule needs no second approval. An exception that is new in the plan, or a rule whose exception the plan's wording changes, does. +- **Who approves.** Whoever can approve the spec or plan today: a status edit, the console, or a chat acceptor. The user's choice: no separate list of approvers; the record says who approved and from where. +- **What counts as approved.** A person moving the status to `spec-approved` or `plan-approved` approves every exception in that artifact. The runtime writes the approval into the change's `standards.md` record (see below) in the same record as the status move: the rule, the reason, the gate, who, from where, and the date. Who is the request's person when the approval came as a request (the console, chat), and "a status edit" when it was made by hand. The trace has it too, as a gate row: `exception`, `pass`, with the labels. An exception approved at the spec and repeated in the plan for the same rule needs no second approval. An exception that is new in the plan, or a rule whose exception the plan's wording changes, does. - **Later steps.** Build, resolve and review are told which rules are under an approved exception and why (see Prompt variable). A breach of that rule, within what the exception says, is not a finding. The build cannot ask for an exception itself: when the plan would make it break a rule, it asks a question, as it can today, and a person answers or sends the change back. -- **The record.** The outcome's Standards section lists the rule as `- ARCH-1 (Service architecture): exception, approved by a person at the plan: ` (see Done), and the done-check does not judge it. +- **The record.** The outcome's Standards section lists the rule as `- ARCH-1 (Service architecture): exception, approved by J. at the plan: ` (see Done), and the done-check does not judge it. + +### Changing a change's standards + +A person can add a standard to one change, or remove one from it, with a note saying why. The user's choice: both, with removing recorded like an exception, so a missed case can be covered and a wrong match taken off, and each is on the record. + +- **Where.** From the change's page in the console (see The console), or by editing the change's `standards.md` record by hand. Not from chat. +- **When.** At any status before `done`. It applies from the next step on. + - **Added after the plan was approved:** review, the merge checks and the done-check check the standard; the plan does not cover it. The console's dialog offers "Send the change back to planning" in the same step, for a person who wants the plan to cover it. + - **Removed:** the standard stops applying from the next step, its tool check and its `needs_person` included. Removing one is a person taking responsibility for that, and the record says so. +- **The record.** It is a request (`standards-scope`: the change, the status it expects, add or remove, the standard's name and the note, which removing requires). The loop applies it as one record on `main`, `loopstra(): standard payments added by J. from the dashboard`, writing it into the change's `standards.md`. The trace gets a row, and the outcome's Standards section lists it: "Payments data was added to this change by J.: ", or "Service architecture was removed from this change by M.: ". + +### The change's `standards.md` record + +`intent//standards.md` holds what people decided about standards for one change, written by the runtime in a fixed format: + +```markdown +# Standards decisions for billing-refunds + +## Added +- payments: card data passes through the shared lib the patterns miss (J., from the console, 2026-10-28) + +## Removed +- architecture: prototype behind a flag, see ADR-14 (M., from the console, 2026-10-28) + +## Exceptions approved +- ARCH-1 at the plan: billing keeps reading the legacy orders table until it is migrated (J., from the console, 2026-10-28) +``` + +- It is written only when there is something to record. A change without decisions has no such file. +- The loop reads Added and Removed to work out which standards apply, and Exceptions approved to know which exceptions need no second approval. +- A person may edit it by hand. Their edit wins, as everywhere. A line that does not read (an unknown standard, or a bullet in another form) is listed under Needs attention, and is not applied until fixed. +- It is the durable record that the console's list of exceptions reads. ### Build and resolve @@ -216,7 +262,8 @@ The trace gets a `standards` row on the merge gate when the repository has stand - **What the section lists:** - one bullet per rule, `- LABEL (Title): met: evidence`; - an exception as `- LABEL (Title): exception, approved by a person at the plan: `; - - each tool check as `- Check of Secrets: passed at merge`. + - each tool check as `- Check of Secrets: passed at merge`; + - each standard a person added to or removed from the change, with who and the note, from its `standards.md`. The format is the runtime's, so it is the same in every outcome and a script can read it. - **Unmet rules block,** as unmet criteria do, with their own note when only standards are unmet: "The change merged, but the final check found engineering rules it does not keep. The details are in outcome.md. An engineer should decide whether to open a follow-up change, then set this one to done or closed." When both are unmet, the existing note gets "and engineering rules" after "Done when criteria". `needs-person` rules go under the existing "For a person to confirm" heading too, with their labels. @@ -228,17 +275,82 @@ The test-protection hook (`templates/hooks/loopstra-protect-tests.ts`) refuses a Claude's call: a standard is not added to `.claude/rules`, and `.claude/rules` is not treated as a standard. Rules there are guidance Claude Code loads into sessions. Standards are what the gates check, with rule labels and a record. A team keeps conventions in the first and the must-haves in the second, and can point one at the other. -## Status, console and records - -- No new screen. The spec, plan, review and outcome pages already render those files, and the new sections show there. -- The session page needs nothing new. The gate rows (`standards` on spec, plan, merge and done, `exception`, `check:`, and findings with rule labels) show in the trace as rows do today. -- A change waiting for a person to approve an exception shows in `status`, the console's inbox and chat as waiting for a person, with the exception's note, through `changeGates`. -- `loopstra status` and the console show unreadable standards under Needs attention (see When a file cannot be read). -- Nothing is added to the intent template, the owner's guide or chat. +## The console + +The console never writes the repository itself, here as elsewhere. Each change a person makes is a request the loop applies and commits (see Writes). + +### Settings › Standards + +A third section of the Settings page, after Models and Agents. + +- **Problems at the top:** unreadable files with each problem, standards edited in the main checkout but not yet committed (not in force), lines in a change's `standards.md` that do not read, and a `check` that names a program not installed. +- **The list.** One row per standard: + - its title and file; + - its scope: "every change", or its patterns with how many files on `main` they match now; + - whether it needs a person; + - its tool check; + - how many rules it has; + - how many changes in progress it applies to. + + Actions: New standard (blank, or from one of the README's two examples), Edit, Remove (with a confirmation that names the changes in progress it applies to). +- **The editor.** The user's choice: a form, with "Edit as Markdown" for the whole file. Both are checked before saving. + - **The form:** + - the title; + - the patterns, each with a live count of matching files on `main` and, on request, the list; + - "Needs a person before merging"; + - the tool check, with "Try it on main"; + - the rules, as label and text rows to add, edit or remove; + - the context text. + - **As you edit:** a label used twice, a label that is not valid, or a pattern that is not a glob shows next to its field. + - **Markdown:** the whole file in one editor, with the same problems listed under it and a preview. + - **Saving:** "Save" is offered only when the file would read. The console cannot put an unreadable standard on `main`. + - **Changing the rules a change in progress is held to:** saving says so, naming the changes ("billing-refunds and csv-export are in progress under this standard; their next steps use the new rules"). +- **Try it on main** runs the standard's `check` in a throwaway checkout of `main`'s last commit, as `loopstra setup` runs `commands.test`, with `commands.install` first and `timeout_minutes` as its limit. It shows whether it passed, how long it took and the end of its output. It writes nothing to the repository. +- **Exceptions granted.** The user's choice: one list across all changes, read from every change's `standards.md`. For each exception it shows the rule (with its standard), the change, the reason, who approved it, from where, when, and at which gate. It can be filtered by standard and by rule, and links to the change. Standards added to or removed from a change are listed beside it, under "Changes to scope", with the same columns. + +### The change page + +A **Standards** panel, shown when the repository has standards. It lists each standard that applies and why ("touches services/billing/api.ts", "every change", "added by J."). Under each standard, each rule shows how the change stands on it so far: +- what the spec or plan said; +- findings from review; +- the tool check; +- the result after the merge; +- exceptions, asked for and approved. + +- **Exceptions waiting.** When the change waits for a person over an exception, the panel puts it first: the rule, the reason, and where in the spec or plan it is asked for. The buttons say what they do: "Allow the exception and approve the plan", and "Send back" with a note. Approving is the existing `approve` request; the runtime writes the approval into `standards.md` as it applies it. +- **Add a standard…** and **Remove…** open a dialog. It names the standard, asks for the note (required to remove), and says what happens next (see Changing a change's standards), with the option to send the change back to planning. + +### Inbox, status and chat + +- A change waiting for a person to approve an exception shows in `loopstra status`, the console's inbox and chat as waiting for a person, through `changeGates`, labelled "Waiting for you: exception to ARCH-1" with its note. +- Unreadable standards, and unreadable lines in a change's `standards.md`, show under Needs attention (see When a file cannot be read). +- The session page needs nothing new. The gate rows (`standards` on spec, plan, merge and done, `exception`, `check:`, scope changes, and findings with rule labels) show in the trace as rows do today. +- The spec, plan, review and outcome pages already render those files, and the new sections show there. +- Nothing is added to the intent template or the owner's guide. + +### Writes + +- **Standard files** go through the existing `agent-files` request, which also takes paths under `loopstra/standards/` (`.md` only). The loop refuses, with nothing committed: + - a standard that would not read (the console checks first; the loop checks again); + - a file with uncommitted changes in the main checkout; + - a file that changed since the page read it, as for agent files. + + The record is `loopstra(_settings): standards: edit architecture.md by J. from the dashboard`. +- **A change's scope** goes through the new `standards-scope` request (see Changing a change's standards). +- **Exceptions** go through the existing `approve` and `send-back` requests. +- **The routes:** + - `GET /api/standards`: the list, the problems, the counts; + - `POST /api/standards/check`: a draft's problems and pattern matches; + - `POST /api/standards/try`: Try it on main; + - `GET /api/standards/exceptions`: the list of exceptions granted; + - the change's panel, in the change's existing detail. + + Each route has the Host check every console route has. ## Setup, skill and docs - **`loopstra init`** writes no standards. Off is the default and means absent. +- **`loopstra setup standards`**, a new section (see below). - **`loopstra setup --check`** reads the standards from `main`'s last commit and lists each problem under "To fix". It warns when a standard's `check` names a program that is not installed, as it does for the commands. - **The operator skill** (`templates/skill/SKILL.md`) gets a section on writing a standard. It covers the file's shape, choosing rule labels, scoping with `applies_to`, putting what a tool can check in `check`, when to ask for an exception, and writing a rule a judge can check ("every endpoint under api/ checks the caller's role with `requireRole`", not "be secure"). It ends by saying to commit the file on `main`. - **README** gets a Standards section with two worked examples: @@ -247,6 +359,24 @@ Claude's call: a standard is not added to `.claude/rules`, and `.claude/rules` i It states plainly the limits under Non-goals, and how the records it leaves map to a change-management control (the intent is the request, the spec and plan the design, `review.md` the review, the outcome the evidence, and the pull request's approval with `merge.human: pr` the approval). - **`docs/architecture.md`** lists `standards.ts` in layer 3. + +### `loopstra setup standards` + +A new section, listed with the others and runnable alone. Like agents, it is not part of the first full walk-through unless the person says yes to "Set up standards, rules every change is checked against, now? (y/N)". + +1. **What is there.** It lists the standards on `main`: title, file, scope, whether it needs a person, the tool check, the number of rules. It lists the problems with them too. +2. **The menu:** add a standard, edit one, remove one, or finish. "Add" offers a blank standard or one of the README's two examples, to edit. +3. **Add or edit** asks, showing the current value and a suggestion, with the usual Enter for the suggestion and `-` for empty: + - the file name (add only), as lowercase words joined by dashes; + - the title; + - which files it applies to: comma-separated patterns, `-` for every change. It answers with how many files on `main` they match, and asks again if one is not a valid pattern; + - "Does a change under this standard need a person before it merges? (y/N)"; + - the tool check. The suggestion is the first of these found on PATH, as a command that fails on findings: `gitleaks detect --no-banner`, `semgrep scan --error`, `trivy fs --exit-code 1 .`, `checkov -d . --quiet`. Otherwise there is none. `-` leaves it out; + - **the rules.** It lists the current ones by label, then asks "Add a rule as LABEL: text (Enter when done)", one at a time. A label already used is refused with the rule that has it. Then it asks "Rules to remove (labels, comma-separated)". To reword a rule, a person removes it and adds it again with the same label. + - The context text is not asked. A new file gets a line saying where to write it, and setup says the file is plain Markdown to edit any time. +4. **Saving and checking.** It saves with the rest of setup, once, at the end. If the result would not read, it saves nothing, as for the config. Then it checks, with the section's other checks: every standard reads, and each new or changed tool check passes on `main` in a throwaway checkout, as `commands.test` is checked. What is not ok is listed under "To fix", with `loopstra setup standards` to run again. Like the rest of setup, it does not commit; it says to commit `loopstra/standards/`. + +`loopstra setup --defaults` adds no standards. `loopstra setup --check` reads them and runs each tool check on `main`, as it does `commands.test`. - **`docs/decisions.md`** gets the calls in this spec. ## Code @@ -268,7 +398,10 @@ Claude's call: a standard is not added to `.claude/rules`, and `.claude/rules` i - **`src/envelopes.ts`**: the optional `standard` fields, and `done-check`'s `standards` array (default `[]`, so a prompt override that never asks for it still parses). - **`src/scheduler.ts`**: the tick's read and the `standards` problem, beside `traceConfigProblem`. - **`src/commands/standards.ts`**, the command. -- **`src/attention.ts`**, the Needs attention entry. +- **`src/attention.ts`**, the Needs attention entries. +- **`src/requests.ts` and `src/apply.ts`**: the `standards-scope` request; `agent-files` taking `loopstra/standards/.md` with the readability check; and the `approve` request's record also writing a change's approved exceptions into `standards.md`, as a status edit noticed by the loop does. +- **`src/console/standards.ts`** (the routes), with `src/ui/views/StandardSettings.tsx`, `src/ui/views/Exceptions.tsx` and the change page's `Standards` part. They follow the Agents settings pattern: read-only views from the server, and writes as requests. +- **`src/setup/sections/standards.ts`**, the setup section, sharing the throwaway-checkout run with the commands check. - **The hook**, the refusal. ## Tests @@ -297,11 +430,22 @@ Claude's call: a standard is not added to `.claude/rules`, and `.claude/rules` i - one tick in a repository with an unreadable standard on `main` starts no step and traces `standards`; - one change runs from accepted to done with one standard for every change and one scoped standard. - **The hook:** an edit under `loopstra/standards/` is refused in each writing phase. +- **Requests:** + - `agent-files` writing a standard that would not read is refused with nothing committed, and so is one that changed since the page read it; + - `standards-scope` adds and removes with the note, refuses a removal without one, and the next step uses the new set; + - approving a plan with an exception, by request and by a status edit, writes the approval into `standards.md` with who and from where. +- **Console:** + - the routes, with their Host check; + - a draft's problems and pattern counts; + - Try it on main in a throwaway checkout, which leaves the repository unchanged; + - the exceptions list read from several changes' `standards.md`; + - the UI's views, as the Agents settings views are tested. +- **Setup:** the section adds, edits and removes a standard, refuses a label used twice, suggests a scanner found on PATH, saves nothing when the result would not read, and lists a failing tool check under "To fix". ## Follow-ups (not in this change) - **Acceptance tests from standards.** Give the `acceptance` step the plan's applicable rules, so the rules a test can check get one, listed in `acceptance.md` by rule label, which the done-check then reads like the criteria's. -- **Chat.** Tell the orchestrator the standards' titles and scopes, so it can answer "what rules would this change fall under?" while an intent is worked out. +- **Chat.** Tell the orchestrator the standards' titles and scopes, so it can answer "what rules would this change fall under?" while an intent is worked out. Whether chat may change a change's standards is decided then. - **An export for auditors.** `loopstra export ` would write each change's records and its trace rows (gates, approvals, who did what, when) into one folder or archive, since `.loopstra/` is local and not kept. - **Standing waivers.** A file of exceptions people approved once for a set of files, with a reason and an end date, which a spec or plan could cite instead of asking each time. Worth it only if the same exception keeps coming back. - **`loopstra retro`** could report which rules most often fail at which gate, and which exceptions are asked for most. That is the signal that a rule is unclear, or that a design keeps fighting it. From e17be9062d4500737f6a4ff29f40fd6dfd308a59 Mon Sep 17 00:00:00 2001 From: John Flavan Date: Wed, 28 Oct 2026 08:26:15 -0500 Subject: [PATCH 05/44] docs(standards): onboarding agent step, LOOPSTRA_BASE for tool checks, checks that fail on main Co-Authored-By: Claude Opus 5.5 --- .../specs/2026-10-28-standards-design.md | 63 ++++++++++++++++--- 1 file changed, 53 insertions(+), 10 deletions(-) diff --git a/docs/superpowers/specs/2026-10-28-standards-design.md b/docs/superpowers/specs/2026-10-28-standards-design.md index 2f819be4..e62b6b2c 100644 --- a/docs/superpowers/specs/2026-10-28-standards-design.md +++ b/docs/superpowers/specs/2026-10-28-standards-design.md @@ -9,7 +9,10 @@ - changing a change's standards by hand; - the console's editor, and its list of exceptions. -The user also asked that the whole workflow be possible in the console (writing and scoping standards, approving exceptions, reviewing them), and that `loopstra setup` set up standards and their tool checks. +The user also asked for three more things: +- the whole workflow possible in the console (writing and scoping standards, approving exceptions, reviewing them); +- `loopstra setup` able to set up standards and their tool checks; +- the onboarding agent (the operator skill in a Claude Code session) able to help draft them. ## Why @@ -33,7 +36,7 @@ Today a repository can tell Loopstra's sessions how to work (`CLAUDE.md`, `.clau - Loopstra does not make an application compliant with anything, and does not claim to. SOC 2 is an auditor's opinion of an organisation's controls over a period. Standards let a team state the technical controls it wants in the code and get evidence that each change kept them; access reviews, vendor management, incident response and the rest stay outside. - No standards library shipped with Loopstra (no "SOC 2 pack"). The README shows an example; the rules are the team's, in its words. -- No agent writes or edits standards. A person changes them on `main`: by hand, on the console's Standards page, or in `loopstra setup standards`. The console's writes are made by the loop's runtime (code) for the person who asked, as with agent files. +- No session of the loop writes or edits standards. A person changes them on `main`: by hand, on the console's Standards page, or in `loopstra setup standards`. The console's writes are made by the loop's runtime (code) for the person who asked, as with agent files. The onboarding agent may draft them in the main checkout with a person (see The onboarding agent). Nothing it writes is in force until that person commits it, since the loop reads standards only from `main`'s commits. - Chat neither edits standards nor changes which apply to a change (Claude's call: the orchestrator is an agent, and edits made through it would come from an agent). Chat still approves a spec or plan, its exceptions included, as it can today. - Acceptance tests are not written from standards in this change (a follow-up; see the end). - Chat does not read standards in this change. The orchestrator is not told them, and intents are unchanged: product owners never need to know the standards exist. @@ -41,7 +44,7 @@ Today a repository can tell Loopstra's sessions how to work (`CLAUDE.md`, `.clau ## Anti-goals -- **An agent must never weaken the rules that judge it.** No session may edit `loopstra/standards/`, and a change whose diff does is blocked, whatever the hook let through. No agent can grant an exception either. +- **The loop's agents must never weaken the rules that judge their work.** No session of the loop may edit `loopstra/standards/`, and a change whose diff does is blocked, whatever the hook let through. No agent can grant an exception either. (The onboarding agent is not a session of the loop: it works with a person, outside any change, and only a commit by that person puts what it wrote in force.) - **A broken standards file must never mean "no standards".** If a file on `main` cannot be read, the loop does nothing until it is fixed. It does not run changes as if the rules were not there. - **Standards must not turn the owner's documents into engineering documents.** The parts of the spec and outcome written for the owner stay in plain words; rule labels and technical evidence go in sections of their own. @@ -72,6 +75,8 @@ Sessions are given the whole file, so this text is context, not decoration. - `applies_to`: glob patterns, matched against repo-relative paths with `/`, with the same semantics as `gates.merge.risky.paths` (Bun.Glob; a bare name matches only at the top). The standard then applies only to a change that touches a matching file. Without it, the standard applies to every change. - `needs_person`: `true` makes a change this standard applies to wait for a person before it merges (see Merge). The default is `false`. The user's choice: keep it, so a standard's scope and who must approve it stay in one place rather than repeating the patterns in `gates.merge.risky.paths`. - `check`: a shell command that exits 0 when the code keeps the standard's rules a tool can check (a secret scanner, semgrep rules, a layering checker). It runs in the merge checks of every change the standard applies to (see Merge). Optional. + - It gets `LOOPSTRA_BASE`, the commit where the change's branch left `main`, so a tool can report only what the change introduced: `semgrep scan --error --baseline-commit "$LOOPSTRA_BASE"`, `gitleaks git --log-opts="$LOOPSTRA_BASE..HEAD"`. + - A check that looks at the whole repository fails every change while `main` itself fails it (see Checks that fail on main). - Unknown keys are errors, as in the config. - **The title is the first `# ` heading.** It is required. - **The rules are under `## Rules`.** The section is required and holds at least one rule. @@ -238,7 +243,7 @@ A standard's `check` is not run in the build's own test runs. The user's choice: ### Merge -**Tool checks.** After the tests pass in the merge checks, each standard that applies to the diff and has a `check` runs it, in the change's worktree, with the same environment as the project's commands (`LOOPSTRA_SLUG`, `LOOPSTRA_SLOT`, the change's temp folder and ports). +**Tool checks.** After the tests pass in the merge checks, each standard that applies to the diff and has a `check` runs it, in the change's worktree. It gets the same environment as the project's commands (`LOOPSTRA_SLUG`, `LOOPSTRA_SLOT`, the change's temp folder and ports), plus `LOOPSTRA_BASE`: the merge base of the branch and `main`, the same commit the test-protection hook's `LOOPSTRA_BASE` names. - **Failure.** A check that fails is handled as a failing test there: its output goes to the same test loop (`testLoop`, with the build's fix budget, `stages.build.max_fix_loops`), and fixes then go back to review as other merge-check fixes do. - **Reruns.** Like the tests, a check that passed on the same code is not run again. - **Past the fix budget.** The change blocks with a note that names the standard: "The change does not pass the automatic check of the standard Secrets (loopstra/standards/secrets.md), even after 3 tries to fix it. The details are in the trace. An engineer can fix it on the branch and retry the change, or close it." @@ -247,6 +252,11 @@ A standard's `check` is not run in the build's own test runs. The user's choice: The user's choice: at the merge checks only, once per merge attempt, rather than in every build test run. +**Checks that fail on main.** A tool that scans the whole repository fails on problems already on `main` (an old secret in the history, existing code a new semgrep rule flags). Each change would then fail its merge check for something it did not do, and its fix session would be sent after code outside the change. Loopstra does not try to tell old findings from new ones itself, since only the tool knows its findings. Instead: +- **Base-aware commands.** Every check gets `LOOPSTRA_BASE`, and the README, setup's suggestions, the console's examples and the onboarding agent all use the forms that report only new findings. +- **Trying a check on main.** Setup, `loopstra setup --check`, the console's "Try it on main" and the onboarding agent run each check on `main`'s last commit, with `LOOPSTRA_BASE` set to that commit. A check that fails there is reported with what to do: "The check of Secrets fails on main already, so every change under it would fail it too. Use a form that looks only at what a change adds (with $LOOPSTRA_BASE), or fix main first." In setup it is listed under "To fix", in the console it is a problem at the top of Standards, and `loopstra start` warns once (Claude's call: a warning, not a refusal, since the team may be fixing `main` right now). +- **The block note.** When a change blocks past the fix budget on a check that does not use `LOOPSTRA_BASE`, the note adds: "If main fails this check too, the problem may not be this change's; see loopstra setup --check." + The merge gate also gets two checks, both code, both at `passMergeGate` with the risk check, on the same diff: 1. **`standards-untouched`**: the diff touches nothing under `loopstra/standards/`. If it does, the change is blocked, and the gate row names the files. The note: "This change edits the repository's standards (loopstra/standards/soc2.md), which only a person may change. An engineer can take that edit out of the change's branch and retry the change, or close it." Claude's call: block rather than let a person approve it. An approval at the merge gate is about the change, and a standards edit hidden in a feature is exactly what the reviewer at the gate is least likely to read. @@ -305,7 +315,7 @@ A third section of the Settings page, after Models and Agents. - **Markdown:** the whole file in one editor, with the same problems listed under it and a preview. - **Saving:** "Save" is offered only when the file would read. The console cannot put an unreadable standard on `main`. - **Changing the rules a change in progress is held to:** saving says so, naming the changes ("billing-refunds and csv-export are in progress under this standard; their next steps use the new rules"). -- **Try it on main** runs the standard's `check` in a throwaway checkout of `main`'s last commit, as `loopstra setup` runs `commands.test`, with `commands.install` first and `timeout_minutes` as its limit. It shows whether it passed, how long it took and the end of its output. It writes nothing to the repository. +- **Try it on main** runs the standard's `check` in a throwaway checkout of `main`'s last commit, as `loopstra setup` runs `commands.test`, with `commands.install` first, `LOOPSTRA_BASE` set to that commit, and `timeout_minutes` as its limit. It shows whether it passed, how long it took and the end of its output. A failure on main gets the warning in Checks that fail on main, with the base-aware form of the command when it is one of the known scanners. It writes nothing to the repository. - **Exceptions granted.** The user's choice: one list across all changes, read from every change's `standards.md`. For each exception it shows the rule (with its standard), the change, the reason, who approved it, from where, when, and at which gate. It can be filtered by standard and by rule, and links to the change. Standards added to or removed from a change are listed beside it, under "Changes to scope", with the same columns. ### The change page @@ -352,10 +362,10 @@ A **Standards** panel, shown when the repository has standards. It lists each st - **`loopstra init`** writes no standards. Off is the default and means absent. - **`loopstra setup standards`**, a new section (see below). - **`loopstra setup --check`** reads the standards from `main`'s last commit and lists each problem under "To fix". It warns when a standard's `check` names a program that is not installed, as it does for the commands. -- **The operator skill** (`templates/skill/SKILL.md`) gets a section on writing a standard. It covers the file's shape, choosing rule labels, scoping with `applies_to`, putting what a tool can check in `check`, when to ask for an exception, and writing a rule a judge can check ("every endpoint under api/ checks the caller's role with `requireRole`", not "be secure"). It ends by saying to commit the file on `main`. +- **The operator skill** (`templates/skill/SKILL.md`) gets an Onboard step for standards (see The onboarding agent). Its Tune section says the same flow is how to change standards later, beside `loopstra setup standards` and the console's Standards page. - **README** gets a Standards section with two worked examples: - an architecture standard for every change; - - a SOC 2 technical-controls standard scoped to `services/**` and `infra/**`: authentication and role checks, audit logs for access to customer data, encryption in transit and at rest, no secrets or personal data in logs, keeping and deleting data on schedule. Its `check` runs a secret scanner. + - a SOC 2 technical-controls standard scoped to `services/**` and `infra/**`: authentication and role checks, audit logs for access to customer data, encryption in transit and at rest, no secrets or personal data in logs, keeping and deleting data on schedule. Its `check` runs a secret scanner over what a change adds (`$LOOPSTRA_BASE`). It states plainly the limits under Non-goals, and how the records it leaves map to a change-management control (the intent is the request, the spec and plan the design, `review.md` the review, the outcome the evidence, and the pull request's approval with `merge.human: pr` the approval). - **`docs/architecture.md`** lists `standards.ts` in layer 3. @@ -371,12 +381,42 @@ A new section, listed with the others and runnable alone. Like agents, it is not - the title; - which files it applies to: comma-separated patterns, `-` for every change. It answers with how many files on `main` they match, and asks again if one is not a valid pattern; - "Does a change under this standard need a person before it merges? (y/N)"; - - the tool check. The suggestion is the first of these found on PATH, as a command that fails on findings: `gitleaks detect --no-banner`, `semgrep scan --error`, `trivy fs --exit-code 1 .`, `checkov -d . --quiet`. Otherwise there is none. `-` leaves it out; + - the tool check. The suggestion is the first of these found on PATH, in a form that fails on findings and, where the tool can, looks only at what a change adds: `gitleaks git --no-banner --log-opts="$LOOPSTRA_BASE..HEAD"`, `semgrep scan --error --baseline-commit "$LOOPSTRA_BASE"`, `trivy fs --exit-code 1 .`, `checkov -d . --quiet`. Otherwise there is none. `-` leaves it out. Setup says in one line that `$LOOPSTRA_BASE` is where the change left `main`; - **the rules.** It lists the current ones by label, then asks "Add a rule as LABEL: text (Enter when done)", one at a time. A label already used is refused with the rule that has it. Then it asks "Rules to remove (labels, comma-separated)". To reword a rule, a person removes it and adds it again with the same label. - The context text is not asked. A new file gets a line saying where to write it, and setup says the file is plain Markdown to edit any time. -4. **Saving and checking.** It saves with the rest of setup, once, at the end. If the result would not read, it saves nothing, as for the config. Then it checks, with the section's other checks: every standard reads, and each new or changed tool check passes on `main` in a throwaway checkout, as `commands.test` is checked. What is not ok is listed under "To fix", with `loopstra setup standards` to run again. Like the rest of setup, it does not commit; it says to commit `loopstra/standards/`. +4. **Saving and checking.** It saves with the rest of setup, once, at the end. If the result would not read, it saves nothing, as for the config. Then it checks, with the section's other checks: every standard reads, and each new or changed tool check passes on `main` in a throwaway checkout, as `commands.test` is checked, with `LOOPSTRA_BASE` set to `main`'s commit. A check that fails there gets the warning in Checks that fail on main. What is not ok is listed under "To fix", with `loopstra setup standards` to run again. Like the rest of setup, it does not commit; it says to commit `loopstra/standards/`. `loopstra setup --defaults` adds no standards. `loopstra setup --check` reads them and runs each tool check on `main`, as it does `commands.test`. + +### The onboarding agent + +The `loopstra` operator skill, in a Claude Code session with an engineer, can do more than setup's questions: it can read the team's own material and draft standards from it. The skill's Onboard list gets a new step after "Set up the agents", "Set up the standards". It is offered, not required; a team with no rules to state skips it. + +1. **Ask what governs the code,** in the team's words: + - an architecture team's principles, design records, a compliance framework (SOC 2, HIPAA, PCI), security policies; + - where each lives: in the repository, pasted into the session, or behind an MCP tool the session has (a wiki, say). + + Ask who approves exceptions today, and which kinds of change already need a named person. +2. **Read what is there:** + - `docs/adr/` or wherever design records are, `CLAUDE.md`, `.claude/rules`, `REVIEW.md`, `CODEOWNERS`; + - lint and layering configs (eslint boundaries, dependency-cruiser, import-linter, ArchUnit); + - the CI workflows, for the scanners that already run; + - the code's layout, for where each rule would apply. +3. **Propose the standards,** one file per subject, each shown in full before anything is written: + - rules stated against this repository's real code ("through the repository layer in `src/db/`", not "use good data access"), each one a judge could check from a spec, a plan or a diff; a vague source rule is rewritten so, and the original quoted beside it; + - rule labels in the team's own scheme, a framework's control numbers included (`SOC2-CC6.1`); + - `applies_to` patterns taken from the actual layout, with how many files each matches (from `loopstra standards`, once written, or by globbing); + - `needs_person` where the team already wants a named person; + - a `check` wherever a tool can enforce a rule, preferring a tool the repository or its CI already uses, in a form that looks only at what a change adds (`$LOOPSTRA_BASE`). A tool that would need installing is said so. +4. **Sort what goes where.** Must-haves are standards. Conventions and how-tos belong in `CLAUDE.md` or `.claude/rules`, which the skill can add to with the engineer. Who must approve becomes `needs_person` or the gates (`loopstra setup gates`). Controls that are not about code (access reviews, vendor management, incident response) are outside Loopstra, and the agent says so plainly, rather than writing rules no judge can check. +5. **Write and check.** Once the engineer agrees: + - The agent writes the files in `loopstra/standards/` in the main checkout, with the context text the team's sources gave: why, links to the design records, examples. + - It runs `loopstra setup --check`, which reads them and runs each tool check on `main`. + - It fixes what does not read. For a check that fails on `main`, it explains why (old findings, or a real problem on `main`) and offers the base-aware form, or a change to fix `main` first. + - It shows the diff, and the engineer commits it on `main`. Until then nothing is in force. +6. **Afterwards,** it says where standards are changed: this flow again, `loopstra setup standards`, or the console's Standards page. It also says what the team will see: the Standards sections in specs and plans, exceptions waiting in the inbox, and the record in each outcome. + +The agent never writes a standard the engineer has not seen and agreed to, and never commits. Like the skill's other steps, it does not run `loopstra start`. It does not grant exceptions or change a change's standards: those are a person's actions in the console or the change's records, and the agent can explain them. - **`docs/decisions.md`** gets the calls in this spec. ## Code @@ -440,7 +480,10 @@ A new section, listed with the others and runnable alone. Like agents, it is not - Try it on main in a throwaway checkout, which leaves the repository unchanged; - the exceptions list read from several changes' `standards.md`; - the UI's views, as the Agents settings views are tested. -- **Setup:** the section adds, edits and removes a standard, refuses a label used twice, suggests a scanner found on PATH, saves nothing when the result would not read, and lists a failing tool check under "To fix". +- **Setup:** the section adds, edits and removes a standard, refuses a label used twice, suggests a scanner found on PATH in its base-aware form, saves nothing when the result would not read, and lists a tool check that fails on `main` under "To fix" with the warning. +- **`LOOPSTRA_BASE`:** a tool check at the merge checks gets the merge base. A check that writes it out shows the right commit, after main has moved on, and after a conflict resolution squashed the branch. +- **Checks that fail on main:** "Try it on main" and `setup --check` report the warning, `loopstra start` warns once, and the block note gets its extra sentence only for a check that does not use `LOOPSTRA_BASE`. +- **The skill:** its Onboard list has the standards step, and its Tune section points to it. The skill's existing structure checks cover it, if any. ## Follow-ups (not in this change) From cbd030bc23862c9341a543c376e84507877fdf5a Mon Sep 17 00:00:00 2001 From: John Flavan Date: Wed, 28 Oct 2026 08:47:17 -0500 Subject: [PATCH 06/44] docs: implementation plan for standards, with review fixes Co-Authored-By: Claude Opus 5.5 --- .../superpowers/plans/2026-10-28-standards.md | 658 ++++++++++++++++++ 1 file changed, 658 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-28-standards.md diff --git a/docs/superpowers/plans/2026-10-28-standards.md b/docs/superpowers/plans/2026-10-28-standards.md new file mode 100644 index 00000000..51eb9ec6 --- /dev/null +++ b/docs/superpowers/plans/2026-10-28-standards.md @@ -0,0 +1,658 @@ +# Standards Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a team write rules in `loopstra/standards/*.md` that every change is checked against at the spec, plan, review, merge and done gates, with exceptions only a person grants, per-change scope changes, tool checks, a console workflow, a setup section and an onboarding step. + +**Architecture:** A new layer-3 module `src/standards.ts` parses standard files from a commit of `main` and a change's `intent//standards.md` record (`src/standards-record.ts`). `StepContext` reads them once per step at `readCommit()`. Stages pass a `standards` prompt variable and add code checks to their gates, using the hooks that already exist (`ArtifactStage.checks`, `passMergeGate`, `testLoop`, the done-check envelope). The console and setup follow the Agents pattern: read-only views from the server, and writes as requests the loop commits. + +**Tech Stack:** Bun 1.4.4+, TypeScript (strict), zod, `yaml`, Preact for the console, `bun test` via `bun run test`. + +**Spec:** `docs/superpowers/specs/2026-10-28-standards-design.md`. Read it before any task. This plan names the files, signatures and tests; the spec has the wording of every note, prompt line and record. + +## Global Constraints + +- Repository without `loopstra/standards/`: prompts, headings, gate rows and records are byte-for-byte what they are today (golden test in Task 4). +- Text a person reads is plain sentences; owner notes copied verbatim from the spec. +- Every JSON file, request and front matter is parsed with zod; types come from `z.infer`. +- Every external process goes through `spawnBounded`/`runCommand`/`projectCommand` (bounded, killable). +- **Layers** (`docs/architecture.md`): a module imports only from its own layer or below. + - `src/standards.ts` and `src/standards-record.ts` are layer 3, so they may import only from `text`, `ids`, `paths`, `fsutil`, `stop`, `git`, `shell` and `config`. + - Anything they need from a higher layer moves down first; Tasks 1 and 13 say which. +- **The browser bundle** (`src/ui/*`, bundled from `src/ui/index.html`, under 80 KB gzipped per `tests/unit/console-page.test.ts`) imports nothing that uses Bun, git or `yaml`. Parsing, rendering and glob checks of standards happen on the server, through the routes. +- Writes to `main` go through `commitRecord` (directly or via `writeArtifact`/`setStatus`); the console never writes the repository. +- Rule label: `^[A-Z][A-Za-z0-9._-]{0,30}[A-Za-z0-9]$` or a single capital letter; at most 32 characters; unique across all files. Standard name: `^[a-z0-9]+(?:-[a-z0-9]+)*$`. +- Front matter keys: `applies_to` (string list, Bun.Glob), `needs_person` (boolean, default false), `check` (string); unknown keys are errors. +- `bun run check` (typecheck, lint, deadcode, tests) passes after every task; no unused exports (knip). +- Never run `npm publish` or change `version` in `package.json` (CLAUDE.md). + +## Spec deviations (decided here; recorded in docs/decisions.md in Task 20) + +- **Where standards are read.** Every step reads them at `ctx.readCommit()`, which is already the commit `main`'s Claude setup comes from for branch steps (#89). +- **Which files the done step judges by.** The done step uses the set the merge gate recorded (run-folder marker `standards.applied`), falling back to the plan's Files that change. It does not diff a merge commit: Loopstra does not keep the merge commit's sha for every path (a pull request merged by hand, say). +- **No tool checks at start.** `loopstra start` does not run tool checks on `main`, since that would delay every start. The warning for a check that fails on `main` comes from `loopstra setup --check`, the console, and the block note. +- **Tests run again at merge.** When standards with a `check` apply, the merge checks' "tests" step runs again once even if the build tested the same commit. The tested marker now includes the tool checks, and the build never runs them. +- **The prompts' standards text lives in code.** The shipped prompt templates are not changed. The standards instructions for each phase live in code (`src/standards-prompt.ts`), and `phasePrompt` appends them, with the standards, only when the repository has standards. A template without standards would otherwise render new text, `(none)` included, for every repository. A repository's own prompt may still place `{{standards}}` itself; then the block goes there, not at the end. +- **No "send back to planning" in the scope dialog.** It has no mechanism from `building` or `reviewing`, since send-back covers only review statuses. The dialog says instead that a standard added after the plan is checked in review, at merge and after the merge. +- **Where `standards-untouched` runs.** It is the first of the merge checks (in `checkMerge`), so it blocks before any fix is spent. It is still a merge-gate row. +- **Standards checked as a whole set.** The console and the loop check a standard file to be saved together with `main`'s other standards, so one save cannot make the whole set unreadable (a label already used elsewhere, say). + +## Review Focus + +1. A standard file saved with CRLF line endings (Windows, or `core.autocrlf`) must parse the same as LF. Test in Task 1. +2. Agents write labels decorated: `- **ARCH-1**: …`, `` - `ARCH-1`: … ``, `- ARCH-1 — …`. The section reader must accept these, with labels compared exactly (case kept). Test in Task 2. +3. A standard named in a change's `standards.md` (Added or Removed) is deleted from `main` mid-change. The record line becomes a problem under Needs attention, never a crash or a silent drop. Test in Task 6. +4. Changed files with spaces or non-ASCII characters must match `applies_to` patterns. Reuse the `-z` numstat parser from `src/risk.ts` and do not split on lines. Test in Task 8. +5. A `check` written with `"$LOOPSTRA_BASE"` must expand on every platform. Commands run through `bun exec` (Bun shell), so the env must actually contain `LOOPSTRA_BASE` as a sha. Test in Task 9, which asserts the command saw a 40/64-hex value. + +--- + +## Part A: the files and the gates + +### Task 1: Parse standard files + +**Files:** +- Create: `src/standards.ts` +- Modify: `src/agent-files.ts` (move `splitFrontmatter` down to `src/text.ts`, layer 1, and import it back; behaviour unchanged) +- Test: `tests/unit/standards.test.ts`, `tests/unit/properties.test.ts` (add one property) + +**Interfaces:** +- Produces: + ```ts + export const STANDARDS_DIR = "loopstra/standards"; + export const RULE_LABEL: RegExp; // see Global Constraints + export const STANDARD_NAME: RegExp; + export interface Rule { label: string; text: string; line: number } + export interface Standard { + name: string; path: string; title: string; + appliesTo: string[] | null; needsPerson: boolean; check: string | null; + rules: Rule[]; body: string; // body: the file without front matter + } + export interface StandardsProblem { path: string; line: number | null; text: string } + export interface StandardsRead { standards: Standard[]; problems: StandardsProblem[]; ignored: string[] } + export function parseStandard(name: string, text: string): { ok: true; standard: Standard } | { ok: false; problems: StandardsProblem[] }; + export function parseStandards(files: { path: string; text: string }[]): StandardsRead; // names from paths; non-.md → ignored; cross-file duplicate labels → problems + export function problemText(p: StandardsProblem): string; // "loopstra/standards/soc2.md cannot be read: the rule on line 14 has no rule label (write it as `- LABEL: text`)." + export interface StandardForm { name: string; title: string; patterns: string[]; needsPerson: boolean; check: string; rules: { label: string; text: string }[]; context: string } + export function formToMarkdown(f: StandardForm): string; // the one renderer: setup and the console's server both use it + export function standardToForm(s: Standard): StandardForm; + ``` + +- [ ] **Step 1: Write failing tests** in `tests/unit/standards.test.ts`: + - `parses the spec's example`: the spec's architecture example has title `Service architecture`, `appliesTo` `["services/**","api/**"]`, `needsPerson` false, `check` set, and rules `ARCH-1` and `ARCH-2`. ARCH-1's text joins its continuation line with one space. + - `no front matter means every change, no check`: `appliesTo === null`, `check === null`. + - `CRLF parses the same as LF`: `parseStandard("a", lf.replace(/\n/g, "\r\n"))` deep-equals the LF result, except `body`'s line endings. + - `each unreadable shape is a problem with its line`: + - no `# ` title; + - no `## Rules`; + - an empty Rules section; + - `- no label here`, and `- arch-1: lower case`; + - a label of 33 characters; + - an unknown key `owner: x`; + - `applies_to: "src/**"` (not a list); + - an invalid glob `applies_to: ["["]`; + - YAML that does not parse. + + Each gives `ok: false`, and `problems[0].line` is the 1-based line, where one exists. + - `a label used in two files is a problem in both`, via `parseStandards`. + - `other files are ignored`: `loopstra/standards/README.txt` and `Bad_Name.md` land in `ignored`. + - `problemText` gives exactly the spec's sentence for a missing label. + - `formToMarkdown` round trip: `standardToForm(parseStandard(n, formToMarkdown(f)).standard)` deep-equals `f` for the spec's example. An empty `patterns` writes no `applies_to`, and an empty `check` writes no `check`. +- [ ] **Step 2:** Run `bun test tests/unit/standards.test.ts`. Expected: FAIL (module missing). +- [ ] **Step 3: Implement** in `src/standards.ts`. + - Split the front matter with `splitFrontmatter` from `src/agent-files.ts`. + - Parse the front matter with a strict zod object. + - Validate globs by constructing `new Bun.Glob(p)` inside try. + - A rule starts at a line matching `^- (\S+?):\s+(.*)$` inside `## Rules`, which ends at the next `## `. Indented following lines continue it. +- [ ] **Step 4: Add the property test** in `properties.test.ts`: `fc.array` of distinct valid labels and single-line texts, rendered as a Rules list, parses back to the same `(label, text)` pairs. +- [ ] **Step 5:** Run `bun test tests/unit/standards.test.ts tests/unit/properties.test.ts`. Expected: PASS. +- [ ] **Step 6:** Commit `feat(standards): parse standard files`. + +### Task 2: Read from a commit, applicability, rendering, the Standards section, the change's record + +**Files:** +- Modify: `src/standards.ts`, `src/context.ts` (add `standards()` and `standardsRecord()` to `StepContext`), `src/stop.ts` (`StandardsUnreadable`, counted by `notTheStepsFault`) +- Create: `src/standards-record.ts` +- Test: `tests/unit/standards.test.ts`, `tests/unit/standards-record.test.ts`, `tests/unit/context.test.ts` + +**Interfaces:** +- Consumes: Task 1. +- Produces: + ```ts + export async function readStandards(git: Git, commit: string): Promise; // ls-tree + cat-file of STANDARDS_DIR at commit; none → empty + export interface Scope { added: string[]; removed: string[] } // standard names + export interface Applying { standard: Standard; why: "every" | "files" | "added"; files: string[] } + export function applying(standards: Standard[], files: string[], scope?: Scope): Applying[]; // file order of standards kept; removed wins over every/files; added applies whatever the files + export interface Excepted { label: string; reason: string } + export function renderStandards(list: Applying[], opts?: { scoped?: Standard[]; excepted?: Excepted[] }): string; // "" when list and scoped are empty + export type BulletKind = "keeps" | "not-applicable" | "exception"; + export interface SectionBullet { label: string; kind: BulletKind; text: string } + export function readStandardsSection(markdown: string): SectionBullet[] | null; // null: no "## Standards" heading; "None." → [] + export function checkStandardsSection(section: SectionBullet[] | null, required: string[], known: ReadonlySet): { ok: boolean; evidence: string }; + export function standardsBytes(r: StandardsRead): number; + export interface OutcomeLine { label: string; title: string; result: string; evidence: string } + export function readOutcomeStandards(outcome: string): OutcomeLine[]; // parses Task 10's outcome section + // src/stop.ts + export class StandardsUnreadable extends Error {} // notTheStepsFault(e) is true for it + // src/standards-record.ts (layer 3) + export const STANDARDS_RECORD = "standards.md"; + export interface Decision { name: string; note: string; by: string; via: string; date: string } + export interface ApprovedException { label: string; gate: "spec" | "plan"; reason: string; by: string; via: string; date: string } + export interface ChangeStandards { added: Decision[]; removed: Decision[]; exceptions: ApprovedException[]; problems: string[] } + export function parseRecord(text: string | null, known?: ReadonlySet): ChangeStandards; // forgiving; bad lines, and names not in `known`, become problems + export function renderRecord(slug: string, r: ChangeStandards): string | null; // null when all empty + export function scopeOf(r: ChangeStandards): Scope; + // StepContext: + standards(): Promise; // memoized per step at readCommit(); reset by dropSnapshot(); throws StandardsUnreadable when problems + standardsRecord(): ChangeStandards; // the change's intent//standards.md as on disk now + ``` + +- [ ] **Step 1: Write failing tests:** + - `readStandards reads only the commit`: a temp repo (`tempGitRepo` from `tests/helpers.ts`) commits one standard, then edits it uncommitted. `readStandards` returns the committed text. + - `applying`: + - every / files: the matched files are listed, renames included when passed both paths; + - removed beats both; + - added applies with no matching file and `why: "added"`; + - a path with a space and `é` matches `services/**`. + - `renderStandards`: + - the heading line `### Service architecture (loopstra/standards/architecture.md)`, then `Applies because the change touches a and b.`; + - `Applies to every change.` for an unscoped standard; + - scoped standards come under the spec's sentence "These apply only to changes that touch the files named."; + - excepted rules are listed under "Rules under an approved exception:" with their reasons; + - empty input gives `""`. + - `readStandardsSection accepts decorated labels`: `- **ARCH-1**: x`, `` - `ARCH-1`: x ``, `- ARCH-1 — x` and `- ARCH-1: does not apply: why` all read. The last is kind `not-applicable`, and `- ARCH-1: exception: why` is kind `exception`. `None.` gives `[]`, and no heading gives `null`. + - `checkStandardsSection`: + - missing heading → `{ok:false, evidence:"no Standards section"}`; + - an unknown label → `"no rule called SEC-9"`; + - missing required → `"missing SEC-2, SEC-4"` (both joined by `; `); + - all present → `"all 14 rules addressed"` when required is non-empty, else `"3 rules addressed, 1 exception"`. + - `StepContext.standards reads once per step`: two calls give the same object, and a commit to main in between does not change it. + - `an unreadable standard at the step's commit throws StandardsUnreadable`: `notTheStepsFault` is true for it, and in a stage test the change keeps its status and is not blocked. + - `standards-record`: + - the spec's example parses and renders back unchanged; + - a line naming a standard not in `known` gives the problem `standards.md names payments, which is not a standard on main.`; + - malformed bullets give problems; + - `renderRecord` gives `null` for an empty record. + - `readOutcomeStandards` reads back what Task 10's format writes. Use the spec's three line forms as fixtures. +- [ ] **Step 2:** Run the tests. Expected: FAIL. +- [ ] **Step 3: Implement.** In `readStandards`, use `git.run(["ls-tree", "-z", "--name-only", commit, "--", STANDARDS_DIR + "/"])` and `cat-file blob`, the way `src/agent-files.ts:140` does. In `StepContext`, add the private `reading` memo pattern, as for `readCommit`, and clear it in `dropSnapshot`. +- [ ] **Step 4:** Run the tests. Expected: PASS. +- [ ] **Step 5:** Commit `feat(standards): read from main's commit, applicability, section reader`. + +### Task 3: Fail closed on unreadable standards + +**Files:** +- Modify: `src/scheduler.ts` (in `tick`, after `syncMain`, `applyRequests` and `armApplier` and before the queue is read; and `preflight`, `src/scheduler.ts:~939`), `src/attention.ts` +- Test: `tests/unit/status.test.ts`, `tests/integration/standards-tick.test.ts` (new) + +**Interfaces:** +- Consumes: `readStandards`, `problemText`. +- Produces: + - The tick traces `trace.eventOnChange("_loop", "error", '"where":"standards"', { where: "standards", problems: string[] }, "error")`. That is an existing event type with a `where`, as the config problem uses. + - `attention` reads the standards live, with `readStandards` at `main`'s commit, never from the trace, so a fix clears it at once. It lists them as kind `"standards"` with the first problem's sentence plus " Fix it on main and commit; until then no change moves on." + - A change's unreadable `standards.md` lines (`parseRecord` problems) are listed the same way, naming the change. + +- [ ] **Step 1: Write the failing integration test** `one tick with an unreadable standard on main starts no step`. + - Set up a repo with an accepted intent and `loopstra/standards/bad.md` with no Rules section, then run `tick`. + - Expected: `picked` is null, the trace has the `standards` error, and the intent stays `accepted`. + - Then submit an `agent-files` request that fixes the file (Task 14 adds the path; until then, write the request with `submitRequest` and commit the fix by hand in the test): the next tick applies it, and picks the change. +- [ ] **Step 2: Write the failing unit tests** `attention lists unreadable standards` (with the exact sentence) and `attention clears once main is fixed`. +- [ ] **Step 3: Implement.** In `tick`, after requests are applied, read the standards at `main`'s commit (`git rev-parse
^{commit}`). If there are problems, trace them and start no steps; signals, syncing and the request applier keep running. In `preflight`, refuse with `problemText` of the first problem. +- [ ] **Step 4:** Run `bun test tests/integration/standards-tick.test.ts tests/unit/status.test.ts`. Expected: PASS. +- [ ] **Step 5:** Commit `feat(standards): the loop does nothing while a standard cannot be read`. + +### Task 4: Prompt variable and the spec gate + +**Files:** +- Create: `src/standards-prompt.ts` (layer 5: each phase's standards instructions, the spec's wording) +- Modify: `src/prompts.ts` (`PROMPT_VARS` += `"standards"`), `src/phases.ts` (`phasePrompt`), `src/envelopes.ts` (`requirement.standard`), `src/stages/shared.ts` (`stageStandards`), `src/stages/artifact.ts` (the `standards` hook), `src/stages/design.ts`. The shipped templates under `templates/prompts/` are NOT changed (see Spec deviations). +- Test: `tests/unit/phases.test.ts`, `tests/unit/stages-design.test.ts`, `tests/fake-claude/` fixtures as needed + +**Interfaces:** +- Consumes: Task 2. +- Produces: + ```ts + // src/standards-prompt.ts + export const STANDARDS_HEADING = "Standards this repository holds every change to:"; + export const STANDARDS_INSTRUCTIONS: Partial>; // design, spec-check, plan, plan-challenge, build, resolve, review, done-check + export function standardsBlock(phase: PhaseName, standards: string): string; // "" when standards is ""; else heading, instructions, standards + // src/stages/shared.ts + export interface StageStandards { read: StandardsRead; list: Applying[]; scoped: Standard[]; excepted: Excepted[]; text: string; labels: string[] } // labels: every rule label of `list`, minus the excepted ones + export async function stageStandards(ctx: StepContext, files: string[] | null): Promise; + // src/stages/artifact.ts, ArtifactStage: + standards?: (ctx: StepContext, a: Artifacts) => Promise; // write() and checks() call it once per artifact + ``` + - `phasePrompt`: when `vars.standards` is non-empty, it renders `{{standards}}` in place when the template has it, else appends `standardsBlock(spec.name, vars.standards)`. When it is empty, the prompt is unchanged, byte for byte. + - `requirement` gets `standard: z.string().optional()`. + - `stageStandards(ctx, null)` is the spec view: unscoped standards apply and scoped ones go in `scoped`. Given files, it is the plan view, with `parsePlanFiles(plan)?.map((f) => f.path) ?? []` at its callers. + - The change's scope (`scopeOf(ctx.standardsRecord())`) and its approved exceptions are applied. + - It reads the standards through `ctx.standards()`, which throws `StandardsUnreadable`. + +- [ ] **Step 1: Write failing tests:** + - `a repository without standards renders the same prompts` (golden): render design, spec-check, plan, plan-challenge, build, review and done-check for a fixture change, before and after. Equal strings. The fixture's prompts are stored as files, made by running the test once on `main` before this change. + - `an override without {{standards}} gets them appended`. + - `stages-design`: + - a spec without `## Standards` fails the `standards` check with evidence `no Standards section`, and is rewritten once; + - a spec with `## Standards` and `None.` passes; + - a spec naming `SEC-9` fails with `no rule called SEC-9`; + - a spec-check finding with `standard: "SEC-3"` reaches the rewrite's findings as `SEC-3: …` and the gate evidence. +- [ ] **Step 2:** Run the tests. Expected: FAIL. +- [ ] **Step 3: Implement.** + - `DESIGN.standards` is `(ctx) => stageStandards(ctx, null)`. + - `write` adds `standards: s.text` to the writer's vars, and `checks` adds it to the judge's vars, plus a `{ name: "standards", run }` check only when `read.standards.length > 0`. That check is `checkStandardsSection(readStandardsSection(text), [], allLabels)`. + - `judge.findings` prefixes `${f.standard}: ` when it is set. + - The instructions for design and spec-check in `STANDARDS_INSTRUCTIONS` are the spec's paragraphs under "Spec (design and spec-check)". +- [ ] **Step 4:** Run `bun test tests/unit/phases.test.ts tests/unit/stages-design.test.ts tests/unit/prompts.test.ts tests/unit/templates.test.ts`. Expected: PASS. +- [ ] **Step 5:** Commit `feat(standards): the spec gate checks the Standards section`. + +### Task 5: The plan gate + +**Files:** +- Modify: `src/stages/plan.ts` (`PLAN.standards`, the check, `planAheadRun` and `takePlanAhead`), `src/envelopes.ts` (`plan-challenge` concern `standard`), `src/standards-prompt.ts` (plan and plan-challenge instructions) +- Test: `tests/unit/stages-plan.test.ts` + +**Interfaces:** +- Consumes: + - **The plan writer** sees the spec view, `stageStandards(ctx, null)`: it has no file list yet. Its instructions say a scoped standard applies when the plan lists a file its patterns match. + - **The gate's check and `plan-challenge`** use `stageStandards(ctx, paths)`, with `paths = parsePlanFiles(a.plan)?.map((f) => f.path) ?? []` (null: no Files section, which the headings check reports), and `checkStandardsSection(section, s.labels, allLabels)`. + - **Plan written ahead:** `planAheadRun` (it calls `writerPhase` directly, `plan.ts:~104`) passes the same spec-view `standards` var, so its key matches the planning step's. + +- [ ] **Step 1: Write failing tests:** + - `the plan's scoped standards come from its file list`: a plan listing `services/billing/api.ts` must cover ARCH-*. One listing only `docs/x.md` need not. + - `a plan missing a rule that applies fails` with `missing ARCH-2`. + - `a concern with standard and blocking fails the gate`, with the label in the evidence. + - `plan-ahead is still used when the standards did not change`. + - `plan-ahead's key changes when standards change`: commit a standard between design and plan, and the kept plan is not used. +- [ ] **Step 2:** Run. Expected: FAIL. +- [ ] **Step 3: Implement.** Add the `standards` check after `files`, reading the same `parsePlanFiles`. +- [ ] **Step 4:** Run `bun test tests/unit/stages-plan.test.ts`. Expected: PASS. +- [ ] **Step 5:** Commit `feat(standards): the plan gate covers every rule that applies`. + +### Task 6: The change's record, and exceptions + +**Files:** +- Modify: `src/standards-record.ts`, `src/intents.ts` (front matter `spec_gate`, `plan_gate`; `BLANK_LEFT_OUT`; `changeGates`), `src/stages/artifact.ts` (pass → review when exceptions await a person; the status check at line ~93 uses `changeGates`), `src/stages/shared.ts` (`recordApprovals`), `src/apply.ts` (approve records exceptions), `src/stages/plan.ts` and `src/stages/build.ts` (record status-edit approvals on entry), `src/attention.ts` ("Waiting for you: exception to …") +- Test: `tests/unit/standards-record.test.ts`, `tests/unit/stages-design.test.ts`, `tests/unit/stages-plan.test.ts`, `tests/unit/apply.test.ts`, `tests/unit/intents-frontmatter.test.ts` + +**Interfaces:** +- Consumes: Task 2's record types. +- Produces: + ```ts + // src/standards-record.ts + export function exceptionsToApprove(artifact: string, gate: "spec" | "plan", r: ChangeStandards): SectionBullet[]; // exception bullets not approved for the same label with the same reason + export function exceptionNote(gate: "spec" | "plan", bullets: SectionBullet[], titles: Map): string; // spec's note text + export function withApprovals(r: ChangeStandards, gate: "spec" | "plan", bullets: SectionBullet[], who: { by: string; via: string }, date: string): ChangeStandards; + // src/stages/shared.ts + export async function recordApprovals(ctx: StepContext, gate: "spec" | "plan", who: { by: string; via: string }): Promise; // writes standards.md via writeArtifact when anything is new + ``` + - Front matter: `spec_gate` and `plan_gate`, each `z.enum(["status"]).optional()`, blank read as absent and left out when blank. `changeGates` sets `spec`/`plan` to `"status"` when the config has `none` and the field is set. + - When an artifact passes with exceptions to approve, the review status's note is `exceptionNote`, whether the person comes from the config's gate or from the field. + +- [ ] **Step 1: Write failing tests:** + - `exceptions`: + - a spec with an exception and `gates.spec.human: none` ends in `spec-review` with `spec_gate: status` and the spec's note; + - approving by request writes `## Exceptions approved` with the request's person and `from the console`; + - the plan repeating the same exception goes straight to `plan-approved`; + - a new or reworded exception in the plan waits in `plan-review`; + - approval by a status edit is recorded `by a status edit` when the next stage starts; + - with `gates.plan.human: status`, a plan with an exception waits with `exceptionNote`, not the usual note, and no `plan_gate` is written. + - `changeGates` with `spec_gate`/`plan_gate`; `waitsForPerson` is true for such a change. + - `attention`: an exception wait is labelled `Waiting for you: exception to ARCH-1`. +- [ ] **Step 2:** Run. Expected: FAIL. +- [ ] **Step 3: Implement.** + - In `settle`, after the pass, read the artifact's section and the record. If `exceptionsToApprove` is non-empty and the effective gate is `none`, call `setStatus(ctx, s.review, exceptionNote(...), {}, { set: { [`${s.gate}_gate`]: "status" } })`. + - On every other pass, remove the field. + - `applyStatus` for `approve` from `spec-review`/`plan-review`: in the same record (`commitRecord` draft), write `standards.md` with the newly approved exceptions. + - The plan and build steps, on entering from `spec-approved`/`plan-approved`, call `recordApprovals(ctx, gate, { by: "a status edit", via: "intent.md" })` for exceptions not yet recorded. +- [ ] **Step 4:** Run the tests above. Expected: PASS. +- [ ] **Step 5:** Commit `feat(standards): exceptions wait for a person and are recorded`. + +### Task 7: Build, resolve and review get the standards + +**Files:** +- Modify: `src/stages/build.ts` (build vars), `src/stages/catch-up.ts` (resolve vars), `src/stages/early-review.ts` and `src/stages/review.ts` (review vars, `review.md` lists `standard`), `src/envelopes.ts` (`finding.standard`), `src/standards-prompt.ts` (build, resolve and review instructions), `templates/REVIEW.md` (its Standards pass: this file is the repository's, written by `init`, not a phase prompt) +- Test: `tests/unit/stages-build.test.ts`, `tests/unit/stages-review-merge.test.ts`, `tests/unit/templates.test.ts` + +**Interfaces:** +- Consumes: + - build and resolve: `stageStandards(ctx, plan files)`; + - review: `stageStandards(ctx, diff files)`, with diff files from a new `export async function changedFiles(wt: Git, main: string): Promise` in `src/risk.ts` (the numstat `-z` parser, outside `intent/`, both rename paths). + +- [ ] **Step 1: Write failing tests:** + - the build prompt holds the plan's standards and the line "Keep every rule below. If the plan would make you break one, ask a question rather than break it."; + - the review prompt holds the diff's standards and the important-finding sentence; + - a review finding with `standard: "SEC-3"` is important, and `review.md` lists it as `- important · SEC-3 · src/pay.ts:42: ` (the runtime writes this line format under `## Findings`; Task 15 reads it); + - excepted rules show as excepted in the build and review prompts; + - `templates/REVIEW.md` has the Standards pass. +- [ ] **Step 2:** Run. Expected: FAIL. +- [ ] **Step 3: Implement** as the interfaces say. `changedFiles` is shared with Task 8. +- [ ] **Step 4:** Run. Expected: PASS. +- [ ] **Step 5:** Commit `feat(standards): build, resolve and review are held to the standards`. + +### Task 8: The merge gate's standards checks + +**Files:** +- Modify: `src/stages/merge.ts` (`passMergeGate`, a new `standards-untouched` check in `checkMerge`, the `standards` gate row, the `standards.applied` marker), `src/risk.ts` (`scoreRisk` takes extra patterns; `riskNote` names the standard) +- Test: `tests/unit/risk.test.ts`, `tests/unit/stages-review-merge.test.ts` + +**Interfaces:** +- Produces: + ```ts + export interface RiskSource { patterns: string[]; standard?: { title: string; path: string } } + export async function scoreRisk(wt: Git, main: string, cfg: Config, extra?: RiskSource[]): Promise; // Risk gains `standards: { title: string; path: string; files: string[] }[]` + export const STANDARDS_APPLIED = "standards.applied"; // run-folder marker, JSON { commit, applying: { name, why, files }[] } + ``` + `standards-untouched` blocks with the spec's note, naming the files under `loopstra/standards/`. + +- [ ] **Step 1: Write failing tests:** + - `a diff touching loopstra/standards/ blocks` with the note, and the gate row names `loopstra/standards/soc2.md`; + - `a needs_person standard makes the change wait`: with `gates.merge.human: none` it waits in `merge-review` with `merge_gate` set and the spec's note naming the standard; + - `a person already on the gate is unchanged`: with `gates.merge.human: pr` and the same change, there is no risk row; + - `a needs_person standard without applies_to makes every change risky`; + - `needs_person is scored with the default config` (empty `risky.paths`, no `lines`); + - `the merge gate records standards.applied and a standards row`; + - `risk matches names with spaces and é`. +- [ ] **Step 2:** Run. Expected: FAIL. +- [ ] **Step 3: Implement.** + - `riskOf` builds the `extra` list from applying standards with `needsPerson`. Patterns are the standard's `appliesTo`, or `["**"]` when it is null. + - `riskOf` returns early on `!riskySet(ctx.cfg)` (`merge.ts:~195`). It must not do so when `extra` is non-empty, since `risky.paths` is empty by default. Add a test with the default config. + - Put `standards-untouched` first among the checks in `checkMerge`, before `up-to-date`, so it blocks before any fix is spent. +- [ ] **Step 4:** Run. Expected: PASS. +- [ ] **Step 5:** Commit `feat(standards): the merge gate protects standards and asks a person where they say`. + +### Task 9: Tool checks + +**Files:** +- Modify: + - `src/stages/build.ts`: `testLoop` and `runChecks` take extra checks; + - `src/stages/shared.ts`: `testedMarker`, `alreadyTested` and `testResult` include them; + - `src/stages/merge.ts`: the "tests" check, and a `check:` gate row per tool check; + - `src/phases.ts`: `AgentPhaseSpec.commands?: string[]`, passed into `toolsFor`/`setTools` and `commandsFor`, both at the `toolsFor(ctx, spec.tools, setup)` call in the session runner (`phases.ts:~554`) and at `phasePrompt`'s `commandsFor`; + - every phase spec with `tools: "build"`: `grep -n 'tools: "build"' src/stages/` finds build (`build.ts:~62`), reconcile (`~93`), the verify fix (`~118`), the test-loop fix (`~212`), revise (`review.ts:~91`), and resolve (`catch-up.ts`). Each passes the applying standards' checks. +- Test: `tests/unit/stages-build.test.ts`, `tests/unit/stages-review-merge.test.ts`, `tests/unit/phases.test.ts` + +**Interfaces:** +- Produces: + ```ts + export interface ToolCheck { command: string; title: string; path: string } + export async function testLoop(ctx: StepContext, prefix: string, extra?: ToolCheck[]): Promise<{ ok: true } | Failure>; + export function testedMarker(ctx: StepContext, sha: string, extra?: ToolCheck[]): string; + export async function alreadyTested(ctx: StepContext, wt: Git, extra?: ToolCheck[]): Promise; + export async function toolCheckEnv(ctx: StepContext, wt: Git): Promise>; // LOOPSTRA_SLUG, changeEnv(ctx), LOOPSTRA_BASE = merge-base sha + ``` + Past the fix budget on a tool check, the note is the spec's sentence naming the standard. When the command does not contain `LOOPSTRA_BASE`, it also gets the spec's main hint. + +- [ ] **Step 1: Write failing tests:** + - `a failing check at merge goes to a fix, then to review`: the fake check fails once and then passes; + - `past the fix budget it blocks naming the standard`, with the hint only when the command lacks `$LOOPSTRA_BASE`; + - `a passing check is not run again on the same code`; + - `the check sees LOOPSTRA_BASE as a commit`: `bun -e "process.exit(/^[0-9a-f]{40,64}$/.test(process.env.LOOPSTRA_BASE ?? '') ? 0 : 1)"` passes, including after `main` moved on, and after a conflict resolution squashed the branch; + - `each tool check writes a merge check: gate row`, with pass written again ("already passed on this code") when `alreadyTested` skips the run; + - `testResult tells the judges the tests passed` after a merge-check run that included tool checks; + - `build sessions may run the checks`: `toolsFor(ctx, "build", …)` holds `Bash( *)` when the spec passes `commands`; + - `standards without checks leave the tested marker as it was`. +- [ ] **Step 2:** Run. Expected: FAIL. +- [ ] **Step 3: Implement.** `runChecks` runs the extra commands after test, lint and build, through `projectCommand(ctx, cmd, wt, { seq, env })`. The merge "tests" check passes the applying standards' checks. Build, fix, reconcile and revise specs get `commands`. +- [ ] **Step 4:** Run. Expected: PASS. +- [ ] **Step 5:** Commit `feat(standards): tool checks run in the merge checks`. + +### Task 10: Done-check and outcome.md + +**Files:** +- Modify: `src/stages/verify.ts`, `src/envelopes.ts` (`done-check.standards`, default `[]`), `templates/prompts/done-check.md` +- Test: `tests/unit/stages-verify.test.ts`, `tests/unit/envelopes.test.ts` + +**Interfaces:** +- Consumes: the `STANDARDS_APPLIED` marker (falling back to the plan's files), the record, and the gate rows `merge`/`check:`. +- Produces: `export function standardsSection(commit: string, items, applying, record, checks): string`. It is the outcome's `## Standards`, in exactly the spec's format, with the commit shortened to 7 characters. + +- [ ] **Step 1: Write failing tests:** + - `an unmet rule blocks` with the spec's standards-only note, and with both criteria and rules unmet, `Done when criteria and engineering rules`; + - `a needs-person rule goes under For a person to confirm with its label`; + - `outcome.md has the runtime's Standards section`, with the first line naming the commit, then the rules, the exceptions, the tool checks and the scope changes; + - `gates.done.agent: false` lists the rules with "not checked automatically"; + - `an envelope without standards still parses`. + - **Integration** (`tests/integration/standards-run.test.ts`): one change runs from accepted to done in a repository with one standard for every change and one scoped standard. The fake claude answers each phase with a Standards section. `outcome.md`'s Standards section lists both standards' rules, and the gate rows `standards` exist on spec, plan, merge and done. +- [ ] **Step 2:** Run. Expected: FAIL. +- [ ] **Step 3: Implement.** +- [ ] **Step 4:** Run. Expected: PASS. +- [ ] **Step 5:** Commit `feat(standards): the done-check judges each rule; outcome.md records them`. + +### Task 11: The hook protects the standards + +**Files:** +- Modify: `templates/hooks/loopstra-protect-tests.ts` +- Test: `tests/unit/hook.test.ts` + +- [ ] **Step 1: Write a failing test:** an Edit or Write of `loopstra/standards/x.md` exits 2 in each of the phases acceptance, build, fix, reconcile, revise and resolve, and the stderr says standards are changed by a person on main. Outside those phases it exits 0. +- [ ] **Step 2:** Run `bun test tests/unit/hook.test.ts`. Expected: FAIL. +- [ ] **Step 3: Implement.** Add `resolve` to the phases the hook runs for, and check the standards path before every other rule. The refusal writes only to stderr and never to `$LOOPSTRA_PROTECTED_LOG`, so a block note never calls a standard a protected test. Add an assertion that the log is untouched. +- [ ] **Step 4:** Run. Expected: PASS. +- [ ] **Step 5:** Commit `feat(standards): no session of the loop edits loopstra/standards/`. + +### Task 12: The `standards-scope` request + +**Files:** +- Modify: `src/requests.ts`, `src/apply.ts`, `src/actions.ts` (an offer for the console only; chat never offers it) +- Test: `tests/unit/apply.test.ts`, `tests/unit/requests.test.ts` + +**Interfaces:** +- Produces: `{ kind: "standards-scope", slug, expect: status, action: "add" | "remove", name: string, note: string }`. Applied as one record, `loopstra(): standard added|removed by from the dashboard`, writing `standards.md`. + +- [ ] **Step 1: Write failing tests:** + - adding writes `## Added` with the note; + - removing without a note is refused with "Say why this standard does not apply to this change."; + - an unknown standard is refused; + - adding one already added is a no-op that reports so; + - a request for a change that is `done` or `closed` is refused, and nothing is written; + - the next step's `stageStandards` reflects the change; + - the trace has a `standards-scope` row; + - chat's action list never contains it. +- [ ] **Step 2:** Run. Expected: FAIL. +- [ ] **Step 3: Implement.** +- [ ] **Step 4:** Run. Expected: PASS. +- [ ] **Step 5:** Commit `feat(standards): add or remove a standard for one change`. + +### Task 13: `loopstra standards`, and tool checks on main + +**Files:** +- Create: `src/commands/standards.ts`, `src/standards-check.ts` (layer 5: `checkOnMain`, shared by setup and the console) +- Modify: + - `src/shell.ts`: move `missingProgram` down from `src/setup/sections/commands.ts:~80` and import it back; + - `src/cli.ts`: register the command; + - `src/commands/args.ts`, if it needs to; + - `src/setup/index.ts`: `--check` runs the standards checks even with no section named. +- Test: `tests/unit/standards-command.test.ts`, `tests/unit/setup.test.ts` + +**Interfaces:** +- Produces: + ```ts + export interface CheckOnMain { ok: boolean; timedOut: boolean; code: number; durationMs: number; lastLines: string; missingProgram: string | null; failsOnMain: string | null } // failsOnMain: the spec's warning text + export async function checkOnMain(root: string, cfg: Config, command: string): Promise; // throwaway checkout, install first, LOOPSTRA_BASE = main's commit + export function baseAware(command: string): string | null; // the base-aware form for gitleaks/semgrep, else null + ``` + +- [ ] **Step 1: Write failing tests:** + - `loopstra standards` lists title, file, scope with the count of matching files, needs person, check and labels, plus problems, ignored files and uncommitted edits. It exits 0 even with problems. + - `loopstra standards` says a `needs_person` standard without `applies_to` makes every change wait for a person, like `gates.merge.human: pr`. + - `setup --check` lists unreadable standards under "To fix" and exits 1. + - `setup --check` warns with the spec's sentence for a check that fails on main, suggesting `baseAware` for gitleaks and semgrep. + - `setup --check` warns when a check names a missing program. + - Over 40 KB of standards gives the warning. +- [ ] **Step 2:** Run. Expected: FAIL. +- [ ] **Step 3: Implement.** Extract the throwaway-checkout run from `testOnMain` in `src/setup/sections/commands.ts` into a shared helper, and use it in both. +- [ ] **Step 4:** Run. Expected: PASS. +- [ ] **Step 5:** Commit `feat(standards): loopstra standards, and tool checks tried on main`. + +## Part B: the console + +### Task 14: Console writes to standard files + +**Files:** +- Modify: `src/agent-library.ts` (`repoPath` also accepts `loopstra/standards/.md`), `src/apply.ts` (`applyAgentFiles` refuses a standard that would not read, and words its record `standards: edit `) +- Test: `tests/unit/apply.test.ts`, `tests/unit/agent-library.test.ts` + +- [ ] **Step 1: Write failing tests:** + - an `agent-files` request writing a valid standard lands as `loopstra(_settings): standards: edit architecture.md by J. from the dashboard`; + - one that would not read is refused with the problem and nothing committed. "Would not read" means `parseStandards` of `main`'s other standards with this one written or removed: a label that another file already uses is refused too; + - a stale `base` is refused; + - `loopstra/standards/../x` and `loopstra/standards/a/b.md` are refused. +- [ ] **Step 2:** Run. Expected: FAIL. +- [ ] **Step 3: Implement.** +- [ ] **Step 4:** Run. Expected: PASS. +- [ ] **Step 5:** Commit `feat(console): standard files through agent-files requests`. + +### Task 15: Standards routes + +**Files:** +- Create: `src/console/standards.ts` +- Modify: `src/console/server.ts` (routes), `src/console/types.ts` (views), `src/console/detail.ts` (the change's standards panel data, in `ChangeDetail`) +- Test: `tests/unit/console-standards.test.ts` + +**Interfaces:** +- Produces: + ```ts + // types.ts + export interface StandardRow { name: string; path: string; title: string; scope: string; matches: number | null; needsPerson: boolean; check: string | null; rules: { label: string; text: string }[]; inProgress: string[]; text: string; version: string } + export interface StandardsView { standards: StandardRow[]; problems: string[]; ignored: string[]; uncommitted: string[] } + export interface DraftCheck { problems: StandardsProblem[]; matches: { pattern: string; count: number; sample: string[] }[] } + export interface ExceptionRow { label: string; title: string; slug: string; reason: string; by: string; via: string; date: string; gate: "spec" | "plan" } + export interface ScopeRow { name: string; slug: string; action: "added" | "removed"; note: string; by: string; via: string; date: string } + export interface ChangeStandardsView { applying: { name: string; title: string; why: string; rules: { label: string; text: string; spec?: string; plan?: string; findings: string[]; result?: string }[]; check?: string }[]; waiting: SectionBullet[]; record: ChangeStandards } + ``` + - **Routes:** + - `GET /api/standards`; + - `POST /api/standards/check`, with body `{ name, text } | { name, form: StandardForm }`. It returns `DraftCheck & { text: string; form: StandardForm | null }`, so the page converts form ⇄ Markdown through the server, and validates and counts patterns there. The draft is checked together with `main`'s other standards; + - `POST /api/standards/try`, with body `{ name } | { name, text }`. It runs only that standard's or draft's `check`, never a command the page sends, and returns `CheckOnMain`; + - `GET /api/standards/exceptions`, returning `{ exceptions: ExceptionRow[]; scope: ScopeRow[] }`; + - `POST /api/standards/scope`, which submits a `standards-scope` request. + - Each route has the Host check, and every POST also has the Origin check, through `jsonWrite` in `src/console/routes.ts:~65`, as `/api/agents/files` does. + - **`ChangeDetail`** gains `standards?: ChangeStandardsView` when the repository has standards. Its per-rule data comes from: + - `readStandardsSection` of `spec.md` and `plan.md`; + - `review.md`'s `- important · LABEL · …` lines (Task 7's format); + - the trace's `merge`/`check:` gate rows; + - `readOutcomeStandards(outcome.md)`; + - the change's `standards.md`. + +- [ ] **Step 1: Write failing tests,** using `tests/console-helpers.ts`: + - each route's JSON on a fixture repo; + - a wrong Host is refused, and a POST with a foreign Origin is refused; + - `try` with a body carrying `command` runs nothing it was sent: it runs only the named standard's `check`; + - `try` leaves the repository's status unchanged; + - `check` refuses a draft whose label another file already uses; + - `exceptions` reads three changes' records, newest first; + - the change detail carries `standards` only when the repository has some. +- [ ] **Step 2:** Run. Expected: FAIL. +- [ ] **Step 3: Implement,** following `src/console/agent-settings.ts`. +- [ ] **Step 4:** Run. Expected: PASS. +- [ ] **Step 5:** Commit `feat(console): standards routes`. + +### Task 16: Settings › Standards and Exceptions granted + +**Files:** +- Create: `src/ui/views/StandardSettings.tsx`, `src/ui/views/Exceptions.tsx`, `src/ui/standards.ts` (pure logic with no Bun, git or `yaml`: label checks, save enabling, panel rows; Markdown conversion goes through `POST /api/standards/check`) +- Modify: `src/ui/views/Settings.tsx` (third section), `src/ui/router.ts` and `src/console/server.ts` (`/settings/exceptions` page path) +- Test: `tests/unit/ui-standards.test.ts`, `tests/unit/console-page.test.ts` + +**Interfaces:** +- Consumes: Task 15 routes; the `agent-files` submit as in `AgentSettings.tsx`. +- Produces: + ```ts + // src/ui/standards.ts: StandardForm's shape is declared again in src/console/types.ts (the bundle may not import src/standards.ts) + export function fieldProblems(f: StandardForm, serverProblems: StandardsProblem[]): Partial>; // local: name and label shapes, labels used twice; patterns from the server + export function canSave(f: StandardForm, check: DraftCheck | null): boolean; + ``` + +- [ ] **Step 1: Write failing tests** (`ui-standards.test.ts`): + - a label used twice, and one that is not valid, are each reported next to their own rule rows; + - a server problem about a pattern lands on the patterns field; + - Save is offered only when `fieldProblems` is empty and the server check has no problems; + - the save notice names the changes in progress; + - `console-page` serves `/settings/exceptions`. +- [ ] **Step 2:** Run. Expected: FAIL. +- [ ] **Step 3: Implement the views,** in the style of `AgentSettings.tsx` (one write pending at a time, the `locked`/`wait` props). +- [ ] **Step 4:** Run `bun test tests/unit/ui-standards.test.ts tests/unit/console-page.test.ts`. Expected: PASS. `console-page.test.ts` bundles `src/ui/index.html` and holds its scripts under 80 KB gzipped. Keep the new views small (no new dependencies), and if the limit is hit, split the Standards views into a lazily imported chunk rather than raise it. +- [ ] **Step 5:** Commit `feat(console): Settings › Standards and Exceptions granted`. + +### Task 17: The change page's Standards panel + +**Files:** +- Create: `src/ui/parts/StandardsPanel.tsx` +- Modify: `src/ui/views/Change.tsx`, `src/ui/views/Inbox.tsx` (the exception label) +- Test: `tests/unit/ui-logic.test.ts` (panel logic extracted into `src/ui/standards.ts`) + +**Interfaces:** +- Produces: + - `export function panelRows(v: ChangeStandardsView): PanelRow[]`; + - `export function approveLabel(gate: "spec" | "plan"): string`, giving "Allow the exception and approve the plan" (or "…the spec"). + +- [ ] **Step 1: Write failing tests:** + - waiting exceptions come first; + - `why` texts are "touches services/billing/api.ts", "every change" and "added by J."; + - the remove dialog requires a note; + - the add dialog, at or after `plan-approved`, says "The plan does not cover it; review, the merge checks and the final check will."; + - the inbox label is "Waiting for you: exception to ARCH-1". +- [ ] **Step 2:** Run. Expected: FAIL. +- [ ] **Step 3: Implement.** Approve uses the existing `approve` request, and scope uses `POST /api/standards/scope`. +- [ ] **Step 4:** Run. Expected: PASS. +- [ ] **Step 5:** Commit `feat(console): the change's Standards panel`. + +## Part C: setup and onboarding + +### Task 18: `loopstra setup standards` + +**Files:** +- Create: `src/setup/sections/standards.ts` +- Modify: `src/setup/sections/index.ts` (append after `agents`), `src/setup/types.ts` if `files` needs nothing new (it should not) +- Test: `tests/unit/setup-standards.test.ts` (with `tests/setup-helpers.ts`) + +**Interfaces:** +- Consumes: `parseStandards`, `formToMarkdown`, `standardToForm` (Task 1), `checkOnMain`, `baseAware` (Task 13), and `ctx.files` with `FileWrite` paths under `loopstra/standards/`, which Task 14's `repoPath` allows. +- Produces: `export const standards: Section` (`name: "standards"`, `covers: []`). + +- [ ] **Step 1: Write failing tests,** using scripted answers: + - in a full setup it asks "Set up standards, rules every change is checked against, now?" and answering no asks nothing more; + - `loopstra setup standards` lists the existing ones; + - add a blank one: name, title, patterns (with the match count), needs person, check, two rules, then done; + - a label used twice is refused with the rule that has it; + - an invalid pattern is asked again; + - the check suggestion is the base-aware gitleaks form when a fake `gitleaks` is on PATH; + - edit removes a rule and rewords another; + - remove deletes the file; + - a result that would not read saves nothing; + - the check lists a tool check that fails on main under "To fix", with the warning; + - `--defaults` adds none. +- [ ] **Step 2:** Run `bun test tests/unit/setup-standards.test.ts`. Expected: FAIL. +- [ ] **Step 3: Implement,** following `src/setup/sections/agents.ts`. +- [ ] **Step 4:** Run. Expected: PASS. +- [ ] **Step 5:** Commit `feat(setup): loopstra setup standards`. + +### Task 19: The onboarding step in the operator skill + +**Files:** +- Modify: `templates/skill/SKILL.md` (Onboard step 7, "Set up the standards", renumbering after it; Tune; the `description` mentions standards) +- Test: `tests/unit/templates.test.ts` (and `templates-roundtrip.test.ts` / `shipped.test.ts` if they pin the skill) + +- [ ] **Step 1: Write a failing test:** + - the skill has a step titled "Set up the standards" after "Set up the agents"; + - it names `loopstra setup --check`, `loopstra setup standards` and `$LOOPSTRA_BASE`; + - it says the agent never commits; + - Tune mentions standards. +- [ ] **Step 2:** Run. Expected: FAIL. +- [ ] **Step 3: Write the step** from the spec's "The onboarding agent" (steps 1 to 6 and the closing paragraph), in the skill's existing voice. +- [ ] **Step 4:** Run `bun test tests/unit/templates.test.ts tests/unit/shipped.test.ts tests/unit/upgrade-files.test.ts`. Expected: PASS. A changed shipped file may need its stamp fixture updated; follow how earlier skill changes did it (`git log -p templates/skill/SKILL.md`). +- [ ] **Step 5:** Commit `feat(skill): onboarding step for standards`. + +### Task 20: Documentation + +**Files:** +- Modify: + - `README.md`: a Standards section with the two worked examples (architecture, SOC 2 technical controls with a base-aware secret-scanner check) and the limits; the change-management mapping; setup's sections list; the console's Settings; the step table's unchanged rows; Configuration's mention of `loopstra/standards/`; + - `docs/architecture.md`: `standards.ts`, `standards-record.ts` and `standards-check.ts` in layer 3; the console and setup modules; + - `docs/decisions.md`: a "Standards (2026-10-28)" section with every "Claude's call" from the spec and the deviations above; + - `templates/intent-README.md`: unchanged (owners never see standards). Confirm this. +- Test: `bun run check` + +- [ ] **Step 1:** Write the docs. +- [ ] **Step 2:** Run `bun run check`. Expected: PASS. +- [ ] **Step 3:** Commit `docs: standards`. From 03314379215a51ddfc9518737c40527d69e15def Mon Sep 17 00:00:00 2001 From: John Flavan Date: Wed, 28 Oct 2026 08:52:15 -0500 Subject: [PATCH 07/44] feat(standards): parse standard files Co-Authored-By: Claude Opus 5.5 --- src/agent-files.ts | 19 +-- src/agent-library.ts | 4 +- src/standards.ts | 215 ++++++++++++++++++++++++++++++++++ src/text.ts | 20 ++++ tests/unit/properties.test.ts | 18 +++ tests/unit/standards.test.ts | 163 ++++++++++++++++++++++++++ 6 files changed, 419 insertions(+), 20 deletions(-) create mode 100644 src/standards.ts create mode 100644 tests/unit/standards.test.ts diff --git a/src/agent-files.ts b/src/agent-files.ts index 52b71683..fe2cc939 100644 --- a/src/agent-files.ts +++ b/src/agent-files.ts @@ -1,12 +1,12 @@ import { homedir } from "node:os"; import { join } from "node:path"; -import { parse } from "yaml"; import { BUILTIN_AGENTS, CONTINUES_BUILD, inlineName, type AgentSetup } from "./agents"; import type { RunPhaseInput } from "./claude"; import type { Config } from "./config"; import { readText } from "./fsutil"; import type { Git } from "./git"; import { STAGE_NAMES, STEP_KEYS, type StageName, type StepKey } from "./models"; +import { splitFrontmatter } from "./text"; /** An agent definition read from a Claude Code agent file. */ export interface AgentDefinition { @@ -30,7 +30,6 @@ export interface AgentDefinition { export interface Where { repo: string; home: string; sources: "all" | "repo"; commit?: { git: Git; sha: string } } const KEPT = new Set(["name", "description", "tools", "model", "skills", "color", "source"]); -const FRONTMATTER = /^---\r?\n(?:([\s\S]*?)\r?\n)?---[ \t]*(?:\r?\n([\s\S]*))?$/; /** A comma-separated string or a list as a list of names; null when absent or empty. */ function names(v: unknown): string[] | null { @@ -39,22 +38,6 @@ function names(v: unknown): string[] | null { return out.length ? out : null; } -/** - * The frontmatter keys of a markdown file (CRLF fine, a byte order mark fine), and the rest of it: null - * when it has no frontmatter. Frontmatter that is not YAML keys and values counts as none, and - * `problem` says what it is instead. - */ -export function splitFrontmatter(text: string): { front: Record; body: string | null; problem?: string } { - const m = FRONTMATTER.exec(text.replace(/^\uFEFF/, "")); - if (!m) return { front: {}, body: null }; - const body = m[2] ?? ""; - let v: unknown; - try { v = parse(m[1] ?? ""); } catch { return { front: {}, body, problem: "not YAML" }; } - if (v === null || v === undefined) return { front: {}, body }; - if (typeof v === "object" && !Array.isArray(v)) return { front: v as Record, body }; - return { front: {}, body, problem: "not YAML keys and values" }; -} - /** Parses an agent file: frontmatter via yaml (none is fine, and unreadable counts as none), the body as the prompt. */ export function parseAgentFile(name: string, file: string, from: AgentDefinition["from"], path: string): AgentDefinition { const split = splitFrontmatter(file); diff --git a/src/agent-library.ts b/src/agent-library.ts index d9c5656b..d63f83d3 100644 --- a/src/agent-library.ts +++ b/src/agent-library.ts @@ -2,10 +2,10 @@ import { closeSync, constants, fstatSync, lstatSync, mkdtempSync, openSync, read import { tmpdir } from "node:os"; import { basename, join, posix } from "node:path"; import { stringify } from "yaml"; -import { enabledPlugins, splitFrontmatter } from "./agent-files"; +import { enabledPlugins } from "./agent-files"; import { readText } from "./fsutil"; import { errorText } from "./shell"; -import { printable } from "./text"; +import { printable, splitFrontmatter } from "./text"; /** A skill or an agent that can be put in the repository's `.claude/`. */ export interface LibraryItem { diff --git a/src/standards.ts b/src/standards.ts new file mode 100644 index 00000000..9daff2ba --- /dev/null +++ b/src/standards.ts @@ -0,0 +1,215 @@ +import { stringify } from "yaml"; +import { z } from "zod"; +import { lineText, splitFrontmatter } from "./text"; + +/** Where a repository's standard files are, relative to its root. */ +export const STANDARDS_DIR = "loopstra/standards"; + +/** A rule's label: a capital letter, then up to 30 letters, digits, `.`, `_` and `-`, ending in a letter or digit (32 at most). */ +export const RULE_LABEL = /^(?:[A-Z]|[A-Z][A-Za-z0-9._-]{0,30}[A-Za-z0-9])$/; + +/** A standard's name: lowercase words joined by dashes, as an intent slug is. */ +export const STANDARD_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +export interface Rule { label: string; text: string; line: number } + +export interface Standard { + name: string; + path: string; + title: string; + /** Glob patterns; null when the standard applies to every change. */ + appliesTo: string[] | null; + needsPerson: boolean; + /** A shell command that exits 0 when the code keeps the rules a tool can check; null when there is none. */ + check: string | null; + rules: Rule[]; + /** The file without its front matter, line endings as "\n". */ + body: string; +} + +/** Why a standard cannot be read. `text` is a clause that finishes " cannot be read: ...". */ +export interface StandardsProblem { path: string; line: number | null; text: string } + +export interface StandardsRead { + standards: Standard[]; + problems: StandardsProblem[]; + /** Paths in the standards folder that are not `.md`. */ + ignored: string[]; +} + +const FrontMatter = z.strictObject({ + applies_to: z.array(z.string()).optional(), + needs_person: z.boolean().default(false), + check: z.string().optional(), +}); + +/** A rule bullet: `- LABEL: text`. */ +const RULE_BULLET = /^- (\S+?):\s+(.*)$/; +const TITLE = /^# +(.*\S)\s*$/; +const RULES_HEADING = /^## Rules\s*$/; + +/** What is wrong with a glob pattern, or null. Bun.Glob reads any text, so an unclosed `[` or `{` is caught here. */ +function globProblem(pattern: string): string | null { + if (!pattern.trim()) return "an empty pattern"; + try { new Bun.Glob(pattern); } catch { return "not a valid pattern"; } + let square = 0; + let curly = 0; + for (let i = 0; i < pattern.length; i++) { + const c = pattern[i]; + if (c === "\\") i++; + else if (c === "[") square++; + else if (c === "]" && square > 0) square--; + else if (c === "{") curly++; + else if (c === "}" && curly > 0) curly--; + } + return square > 0 || curly > 0 ? "an unclosed bracket" : null; +} + +/** The line (1-based) a key is written on; the first line when it is not found. */ +function keyLine(front: string, key: string): number { + const i = front.split("\n").findIndex((l) => l.startsWith(`${key}:`)); + return i < 0 ? 1 : i + 1; +} + +/** Reads one standard file. Every way it cannot be read comes back as a problem with its line. */ +export function parseStandard(name: string, text: string): { ok: true; standard: Standard } | { ok: false; problems: StandardsProblem[] } { + const path = `${STANDARDS_DIR}/${name}.md`; + const problems: StandardsProblem[] = []; + const problem = (line: number | null, text: string): void => { problems.push({ path, line, text }); }; + + const lf = text.replace(/\r\n/g, "\n"); + const split = splitFrontmatter(lf); + const body = split.body ?? lf.replace(/^/, ""); + const frontText = split.body === null ? "" : lf.slice(0, lf.length - split.body.length); + // Lines before the body, so a line number counts from the top of the file. + const offset = (frontText.match(/\n/g) ?? []).length; + + if (split.problem) problem(1, `the front matter is ${split.problem === "not YAML" ? "not valid YAML" : "not keys and values"} (write \`key: value\` lines between two \`---\` lines)`); + let appliesTo: string[] | null = null; + let needsPerson = false; + let check: string | null = null; + if (!split.problem) { + const parsed = FrontMatter.safeParse(split.front); + if (parsed.success) { + appliesTo = parsed.data.applies_to ?? null; + needsPerson = parsed.data.needs_person; + check = parsed.data.check?.trim() || null; + for (const p of appliesTo ?? []) { + const bad = globProblem(p); + if (bad) problem(keyLine(frontText, "applies_to"), `applies_to has ${bad}: ${JSON.stringify(p)}`); + } + } else { + for (const issue of parsed.error.issues) { + if (issue.code === "unrecognized_keys") { + for (const k of issue.keys) problem(keyLine(frontText, k), `the front matter has the key ${k}, which is not one of applies_to, needs_person and check`); + } else { + const key = String(issue.path[0] ?? ""); + problem(keyLine(frontText, key), `${key || "the front matter"} is not the right kind of value (applies_to is a list of patterns, needs_person is true or false, check is a command)`); + } + } + } + } + + const lines = body.split("\n").map(lineText); + const titleAt = lines.findIndex((l) => TITLE.test(l)); + const rulesAt = lines.findIndex((l) => RULES_HEADING.test(l)); + const title = titleAt < 0 ? "" : (TITLE.exec(lines[titleAt] ?? "")?.[1] ?? ""); + if (titleAt < 0) problem(null, "it has no title (start the text with `# Title`)"); + + const rules: Rule[] = []; + if (rulesAt < 0) problem(null, "it has no Rules section (add `## Rules`, then one `- LABEL: text` line for each rule)"); + else { + const seen = new Set(); + let open: Rule | null = null; + for (let i = rulesAt + 1; i < lines.length; i++) { + const l = lines[i] ?? ""; + if (l.startsWith("## ")) break; + const line = offset + i + 1; + if (l.startsWith("- ")) { + open = null; + const m = RULE_BULLET.exec(l); + if (!m) { problem(line, `the rule on line ${line} has no rule label (write it as \`- LABEL: text\`)`); continue; } + const label = m[1] ?? ""; + if (!RULE_LABEL.test(label)) { problem(line, `the rule on line ${line} has the label ${label}, which is not valid (start with a capital letter, then letters, digits, \`.\`, \`_\` or \`-\`, 32 characters at most)`); continue; } + if (seen.has(label)) { problem(line, `the label ${label} is used twice (line ${line})`); continue; } + seen.add(label); + open = { label, text: (m[2] ?? "").trim(), line }; + rules.push(open); + } else if (open && /^\s+\S/.test(l)) open.text = `${open.text} ${l.trim()}`; + } + if (!rules.length && !problems.length) problem(offset + rulesAt + 1, `the Rules section on line ${offset + rulesAt + 1} has no rules (write each as \`- LABEL: text\`)`); + } + + if (problems.length) return { ok: false, problems }; + return { ok: true, standard: { name, path, title, appliesTo, needsPerson, check, rules, body } }; +} + +/** Reads the files found in the standards folder: names come from paths, other files are ignored, a label used in two files is a problem in both. */ +export function parseStandards(files: { path: string; text: string }[]): StandardsRead { + const out: StandardsRead = { standards: [], problems: [], ignored: [] }; + const prefix = `${STANDARDS_DIR}/`; + for (const f of files) { + const name = f.path.startsWith(prefix) && f.path.endsWith(".md") ? f.path.slice(prefix.length, -3) : ""; + if (!STANDARD_NAME.test(name)) { out.ignored.push(f.path); continue; } + const r = parseStandard(name, f.text); + if (r.ok) out.standards.push(r.standard); + else out.problems.push(...r.problems); + } + out.standards.sort((a, b) => a.name.localeCompare(b.name)); + const owners = new Map>(); + for (const s of out.standards) for (const r of s.rules) owners.set(r.label, (owners.get(r.label) ?? new Set()).add(s.path)); + const clash = (s: Standard): Rule[] => s.rules.filter((r) => (owners.get(r.label)?.size ?? 0) > 1); + for (const s of out.standards) { + for (const r of clash(s)) { + const others = [...(owners.get(r.label) ?? [])].filter((p) => p !== s.path); + out.problems.push({ path: s.path, line: r.line, text: `the label ${r.label} (line ${r.line}) is also used in ${others.join(" and ")}` }); + } + } + out.standards = out.standards.filter((s) => !clash(s).length); + return out; +} + +/** A problem as one plain sentence. */ +export function problemText(p: StandardsProblem): string { + return `${p.path} cannot be read: ${p.text}.`; +} + +/** What setup and the console edit: a standard's parts, rendered back to a file by `formToMarkdown`. */ +export interface StandardForm { + name: string; + title: string; + patterns: string[]; + needsPerson: boolean; + check: string; + rules: { label: string; text: string }[]; + /** The text between the title and `## Rules`. */ + context: string; +} + +/** The file for a form: the one renderer, used by setup and by the console's server. */ +export function formToMarkdown(f: StandardForm): string { + const front: string[] = []; + if (f.patterns.length) front.push(`applies_to: [${f.patterns.map((p) => JSON.stringify(p)).join(", ")}]`); + if (f.needsPerson) front.push("needs_person: true"); + if (f.check) front.push(stringify({ check: f.check }, { lineWidth: 0 }).trimEnd()); + const head = front.length ? `---\n${front.join("\n")}\n---\n` : ""; + const context = f.context.trim() ? `${f.context.trim()}\n\n` : ""; + const rules = f.rules.map((r) => `- ${r.label}: ${r.text.replace(/\n/g, "\n ")}\n`).join(""); + return `${head}# ${f.title}\n\n${context}## Rules\n${rules}`; +} + +/** The form for a parsed standard, the inverse of `formToMarkdown`. */ +export function standardToForm(s: Standard): StandardForm { + const lines = s.body.split("\n"); + const titleAt = lines.findIndex((l) => TITLE.test(lineText(l))); + const rulesAt = lines.findIndex((l) => RULES_HEADING.test(lineText(l))); + return { + name: s.name, + title: s.title, + patterns: s.appliesTo ?? [], + needsPerson: s.needsPerson, + check: s.check ?? "", + rules: s.rules.map(({ label, text }) => ({ label, text })), + context: titleAt >= 0 && rulesAt > titleAt ? lines.slice(titleAt + 1, rulesAt).join("\n").trim() : "", + }; +} diff --git a/src/text.ts b/src/text.ts index 34b9be73..54492bd9 100644 --- a/src/text.ts +++ b/src/text.ts @@ -1,3 +1,5 @@ +import { parse } from "yaml"; + /** Items as a plain list: "a", "a and b", "a, b and c" ("" for none). */ export function andList(items: readonly string[]): string { return items.length > 1 ? `${items.slice(0, -1).join(", ")} and ${items.at(-1) ?? ""}` : items[0] ?? ""; @@ -19,3 +21,21 @@ export const isCrlf = (s: string): boolean => (s.match(/\r\n/g)?.length ?? 0) * /** Text from outside (a file, a fetched source, git's output) safe to print: control characters become spaces. */ export const printable = (s: string): string => s.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").trim(); + +const FRONTMATTER = /^---\r?\n(?:([\s\S]*?)\r?\n)?---[ \t]*(?:\r?\n([\s\S]*))?$/; + +/** + * The frontmatter keys of a markdown file (CRLF fine, a byte order mark fine), and the rest of it: null + * when it has no frontmatter. Frontmatter that is not YAML keys and values counts as none, and + * `problem` says what it is instead. + */ +export function splitFrontmatter(text: string): { front: Record; body: string | null; problem?: string } { + const m = FRONTMATTER.exec(text.replace(/^\uFEFF/, "")); + if (!m) return { front: {}, body: null }; + const body = m[2] ?? ""; + let v: unknown; + try { v = parse(m[1] ?? ""); } catch { return { front: {}, body, problem: "not YAML" }; } + if (v === null || v === undefined) return { front: {}, body }; + if (typeof v === "object" && !Array.isArray(v)) return { front: v as Record, body }; + return { front: {}, body, problem: "not YAML keys and values" }; +} diff --git a/tests/unit/properties.test.ts b/tests/unit/properties.test.ts index e925f41f..52a481e0 100644 --- a/tests/unit/properties.test.ts +++ b/tests/unit/properties.test.ts @@ -6,6 +6,7 @@ import { nextVersion } from "../../scripts/release"; import { parsePlanFiles } from "../../src/checks"; import { parseArgs } from "../../src/commands/args"; import { parseIntentFile, PRIORITIES, serializeIntentFile, STATUSES } from "../../src/intents"; +import { parseStandard, RULE_LABEL } from "../../src/standards"; import { diffCounts, parseDiff } from "../../src/ui/diff"; import { parseRoute, routePath, TABS, type Route } from "../../src/ui/router"; @@ -149,3 +150,20 @@ describe("the console's addresses", () => { })); }); }); + +describe("standard files", () => { + test("rules rendered as a list parse back to the same labels and texts", () => { + const label = fc.stringMatching(/^[A-Z]([A-Za-z0-9._-]{0,30}[A-Za-z0-9])?$/); + const text = fc.stringMatching(/^[A-Za-z0-9]([A-Za-z0-9 ,.;()-]{0,40}[A-Za-z0-9.])?$/); + fc.assert(fc.property( + fc.uniqueArray(fc.record({ label, text }), { minLength: 1, maxLength: 6, selector: (r) => r.label }), + (rules) => { + expect(rules.every((r) => RULE_LABEL.test(r.label))).toBe(true); + const file = `# Title\n\nContext.\n\n## Rules\n${rules.map((r) => `- ${r.label}: ${r.text}`).join("\n")}\n`; + const parsed = parseStandard("x", file); + expect(parsed.ok).toBe(true); + if (parsed.ok) expect(parsed.standard.rules.map(({ label, text }) => ({ label, text }))).toEqual(rules); + }, + )); + }); +}); diff --git a/tests/unit/standards.test.ts b/tests/unit/standards.test.ts new file mode 100644 index 00000000..032e3980 --- /dev/null +++ b/tests/unit/standards.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, test } from "bun:test"; +import { formToMarkdown, parseStandard, parseStandards, problemText, STANDARD_NAME, STANDARDS_DIR, standardToForm, type Rule, type Standard } from "../../src/standards"; + +const EXAMPLE = `--- +applies_to: ["services/**", "api/**"] +needs_person: false +check: bunx depcruise --config .dependency-cruiser.cjs services +--- +# Service architecture + +Why these rules exist, links to the design records in docs/adr/, examples of the right way. +Sessions are given the whole file, so this text is context, not decoration. + +## Rules +- ARCH-1: Services call each other only through their published HTTP APIs. No service reads + or writes another service's database. +- ARCH-2: New persistent data lives in Postgres, through the repository layer in \`src/db/\`. +`; + +const ok = (name: string, text: string): Standard => { + const r = parseStandard(name, text); + if (!r.ok) throw new Error(`did not parse: ${r.problems.map((p) => p.text).join("; ")}`); + return r.standard; +}; + +const problemsOf = (text: string) => { + const r = parseStandard("a", text); + if (r.ok) throw new Error("parsed"); + return r.problems; +}; + +describe("parseStandard", () => { + test("parses the spec's example", () => { + const s = ok("architecture", EXAMPLE); + expect(s).toMatchObject({ + name: "architecture", + path: "loopstra/standards/architecture.md", + title: "Service architecture", + appliesTo: ["services/**", "api/**"], + needsPerson: false, + check: "bunx depcruise --config .dependency-cruiser.cjs services", + }); + const rules: Rule[] = [ + { label: "ARCH-1", text: "Services call each other only through their published HTTP APIs. No service reads or writes another service's database.", line: 12 }, + { label: "ARCH-2", text: "New persistent data lives in Postgres, through the repository layer in `src/db/`.", line: 14 }, + ]; + expect(s.rules).toEqual(rules); + expect(s.body.startsWith("# Service architecture")).toBe(true); + }); + + test("no front matter means every change, no check", () => { + const s = ok("a", "# T\n\n## Rules\n- A-1: one\n"); + expect(s.appliesTo).toBeNull(); + expect(s.check).toBeNull(); + expect(s.needsPerson).toBe(false); + expect(s.rules[0]?.line).toBe(4); + }); + + test("CRLF parses the same as LF", () => { + const lf = ok("a", EXAMPLE); + const crlf = ok("a", EXAMPLE.replace(/\n/g, "\r\n")); + expect({ ...crlf, body: crlf.body.replace(/\r\n/g, "\n") }).toEqual(lf); + }); + + test("each unreadable shape is a problem with its line", () => { + const rules = (...bullets: string[]) => `# T\n\n## Rules\n${bullets.join("\n")}\n`; + const front = (...lines: string[]) => `---\n${lines.join("\n")}\n---\n# T\n\n## Rules\n- A-1: x\n`; + const cases: Array<[string, string, number | null]> = [ + ["no title", "## Rules\n- A-1: x\n", null], + ["no Rules section", "# T\n\nText.\n", null], + ["an empty Rules section", "# T\n\n## Rules\n\n## Other\n", 3], + ["a bullet with no label", rules("- no label here"), 4], + ["a lower case label", rules("- A-1: fine", "- arch-1: lower case"), 5], + ["a label of 33 characters", rules(`- A${"b".repeat(32)}: long`), 4], + ["a label used twice", rules("- A-1: one", "- A-1: two"), 5], + ["an unknown key", front("owner: x"), 2], + ["applies_to that is not a list", front('applies_to: "src/**"'), 2], + ["an invalid glob", front('applies_to: ["["]'), 2], + ["YAML that does not parse", front("applies_to: [unclosed"), 1], + ]; + for (const [what, text, line] of cases) { + const r = parseStandard("a", text); + expect(r.ok, what).toBe(false); + if (!r.ok) { + expect(r.problems[0]?.line, what).toBe(line); + expect(r.problems[0]?.path, what).toBe("loopstra/standards/a.md"); + } + } + }); + + test("a rule's problem text is a plain sentence naming the line", () => { + expect(problemsOf("# T\n\n## Rules\n- fine: no\n")[0]?.text).toContain("line 4"); + }); +}); + +describe("parseStandards", () => { + test("a label used in two files is a problem in both", () => { + const a = "# A\n\n## Rules\n- X-1: one\n"; + const b = "# B\n\n## Rules\n- X-1: two\n- X-2: three\n"; + const r = parseStandards([ + { path: "loopstra/standards/a.md", text: a }, + { path: "loopstra/standards/b.md", text: b }, + ]); + expect(r.standards).toEqual([]); + expect(r.problems.map((p) => [p.path, p.line])).toEqual([ + ["loopstra/standards/a.md", 4], + ["loopstra/standards/b.md", 4], + ]); + }); + + test("other files are ignored", () => { + const r = parseStandards([ + { path: "loopstra/standards/README.txt", text: "x" }, + { path: "loopstra/standards/Bad_Name.md", text: "x" }, + { path: "loopstra/standards/ok.md", text: "# T\n\n## Rules\n- A-1: x\n" }, + ]); + expect(r.ignored).toEqual(["loopstra/standards/README.txt", "loopstra/standards/Bad_Name.md"]); + expect(r.standards.map((s) => s.name)).toEqual(["ok"]); + expect(r.problems).toEqual([]); + }); +}); + +test("the folder and the name pattern are the ones the spec names", () => { + expect(STANDARDS_DIR).toBe("loopstra/standards"); + expect(["soc2", "a-b-1"].every((n) => STANDARD_NAME.test(n))).toBe(true); + expect(["Soc2", "a--b", "-a", "a_b"].some((n) => STANDARD_NAME.test(n))).toBe(false); +}); + +test("problemText gives the spec's sentence for a missing label", () => { + const text = `# T\n${"\n".repeat(9)}## Rules\n\n- A-1: ok\n- no label\n`; + const p = problemsOf(text)[0]; + expect(p).toBeDefined(); + if (p) { + const withLine = { ...p, path: "loopstra/standards/soc2.md" }; + expect(problemText(withLine)).toBe(`loopstra/standards/soc2.md cannot be read: the rule on line ${p.line} has no rule label (write it as \`- LABEL: text\`).`); + } + expect(problemText({ path: "loopstra/standards/soc2.md", line: 14, text: "the rule on line 14 has no rule label (write it as `- LABEL: text`)" })) + .toBe("loopstra/standards/soc2.md cannot be read: the rule on line 14 has no rule label (write it as `- LABEL: text`)."); +}); + +describe("formToMarkdown", () => { + test("round trips the spec's example", () => { + const f = standardToForm(ok("architecture", EXAMPLE)); + expect(f.context).toStartWith("Why these rules exist"); + const again = standardToForm(ok("architecture", formToMarkdown(f))); + expect(again).toEqual(f); + }); + + test("an empty list of patterns writes no applies_to, and an empty check no check", () => { + const md = formToMarkdown({ name: "a", title: "T", patterns: [], needsPerson: false, check: "", rules: [{ label: "A-1", text: "x" }], context: "" }); + expect(md).toBe("# T\n\n## Rules\n- A-1: x\n"); + const s = ok("a", md); + expect(s.appliesTo).toBeNull(); + expect(s.check).toBeNull(); + }); + + test("needs_person is written only when true, and a hard check survives", () => { + const f = { name: "a", title: "T", patterns: ["src/**"], needsPerson: true, check: 'grep -rn "a: b" src # x', rules: [{ label: "A-1", text: "x" }], context: "Why." }; + const md = formToMarkdown(f); + expect(md).toContain("needs_person: true"); + expect(standardToForm(ok("a", md))).toEqual(f); + }); +}); From cd0f44823a6924b88075f36dabe5169c347466af Mon Sep 17 00:00:00 2001 From: John Flavan Date: Wed, 28 Oct 2026 08:58:54 -0500 Subject: [PATCH 08/44] feat(standards): read from main's commit, applicability, section reader Co-Authored-By: Claude Opus 5.5 --- src/context.ts | 30 +++++- src/standards-record.ts | 73 +++++++++++++ src/standards.ts | 132 ++++++++++++++++++++++- src/stop.ts | 16 ++- tests/unit/context.test.ts | 51 ++++++++- tests/unit/standards-record.test.ts | 61 +++++++++++ tests/unit/standards.test.ts | 155 +++++++++++++++++++++++++++- tests/unit/stop.test.ts | 4 +- 8 files changed, 514 insertions(+), 8 deletions(-) create mode 100644 src/standards-record.ts create mode 100644 tests/unit/standards-record.test.ts diff --git a/src/context.ts b/src/context.ts index 1f83fe81..e69e9eca 100644 --- a/src/context.ts +++ b/src/context.ts @@ -10,7 +10,9 @@ import { effectivePriority, parseIntentFile, readIntent, writeChangeFile, writeI import { RecordDraft, type DraftChange } from "./record-draft"; import { errorCode, errorText } from "./shell"; import { withModels } from "./models"; -import { RecordRaced, RecordUnsettled, throwIfStopping } from "./stop"; +import { readStandards, problemText, type StandardsRead } from "./standards"; +import { parseRecord, STANDARDS_RECORD, type ChangeStandards } from "./standards-record"; +import { RecordRaced, RecordUnsettled, StandardsUnreadable, throwIfStopping } from "./stop"; import type { Trace } from "./trace"; /** The folder that holds each change's snapshot of main (see StepContext.snapshot). */ @@ -25,6 +27,8 @@ export class StepContext { readonly git: Git; /** The commit this step's read-only sessions read, once asked for (see readCommit). */ private reading: Promise | null = null; + /** The standards at readCommit, once asked for (see standards). */ + private standardsRead: Promise | null = null; /** The step's snapshot, once asked for (see snapshot). */ private made: Promise | null = null; /** The kinds of phase (see phaseKind) whose first session of the step has started: only that one may resume an answered question (see answerFor). */ @@ -95,6 +99,29 @@ export class StepContext { return this.reading; } + /** + * The repository's standards at readCommit, read once per step so all its sessions and checks use + * one version. Throws StandardsUnreadable when a file cannot be read: the step never runs without + * the rules. + */ + standards(): Promise { + if (!this.standardsRead) { + const p = this.readCommit().then((sha) => readStandards(this.git, sha)).then((r) => { + if (r.problems.length) throw new StandardsUnreadable(r.problems.map(problemText).join(" ")); + return r; + }); + p.catch(() => { if (this.standardsRead === p) this.standardsRead = null; }); + this.standardsRead = p; + } + return this.standardsRead; + } + + /** What people decided about this change's standards: its standards.md as on disk now. Lines that name a standard are not checked here (the caller has the standards). */ + standardsRecord(): ChangeStandards { + const file = join(this.intent.dir, STANDARDS_RECORD); + return parseRecord(existsSync(file) ? readFileSync(file, "utf8") : null); + } + /** * Where the step's read-only sessions run: a detached worktree of readCommit * (`.loopstra/snapshots/`), made the first time a session asks, so none sees the main @@ -115,6 +142,7 @@ export class StepContext { const made = this.made; this.made = null; this.reading = null; + this.standardsRead = null; if (!made) return; const dir = await made.catch(() => null); if (dir) await removeWorktree(this.git, dir, { cleanup: true }); diff --git a/src/standards-record.ts b/src/standards-record.ts new file mode 100644 index 00000000..6044060d --- /dev/null +++ b/src/standards-record.ts @@ -0,0 +1,73 @@ +import { RULE_LABEL, STANDARD_NAME, type Scope } from "./standards"; + +/** The file in a change's folder that holds what people decided about its standards. */ +export const STANDARDS_RECORD = "standards.md"; + +/** A standard added to a change or removed from it. `via` is where the person did it, without "from" ("the console"). */ +interface Decision { name: string; note: string; by: string; via: string; date: string } + +/** An exception a person approved at the spec or the plan. */ +interface ApprovedException { label: string; gate: "spec" | "plan"; reason: string; by: string; via: string; date: string } + +export interface ChangeStandards { added: Decision[]; removed: Decision[]; exceptions: ApprovedException[]; problems: string[] } + +const TAIL = String.raw`\((.+?), from (.+), (\d{4}-\d{2}-\d{2})\)$`; +const DECISION = new RegExp(String.raw`^- (\S+?): (.+) ${TAIL}`); +const EXCEPTION = new RegExp(String.raw`^- (\S+) at the (spec|plan): (.+) ${TAIL}`); + +const SECTIONS = { added: /^## Added\s*$/i, removed: /^## Removed\s*$/i, exceptions: /^## Exceptions approved\s*$/i }; + +/** + * Reads a change's standards.md. Forgiving: a line that does not read, and a standard not in `known` + * (when given), is a problem in words and is not applied. + */ +export function parseRecord(text: string | null, known?: ReadonlySet): ChangeStandards { + const out: ChangeStandards = { added: [], removed: [], exceptions: [], problems: [] }; + if (text === null) return out; + let section: keyof typeof SECTIONS | "other" | null = null; + for (const raw of text.split(/\r?\n/)) { + const line = raw.trimEnd(); + if (/^#{1,2}\s/.test(line)) { + const found = (Object.keys(SECTIONS) as (keyof typeof SECTIONS)[]).find((k) => SECTIONS[k].test(line)); + if (found) section = found; + else if (line.startsWith("## ")) { section = "other"; out.problems.push(`standards.md has a section ${line.slice(3).trim()}, which is not Added, Removed or Exceptions approved.`); } + continue; + } + if (!line.startsWith("- ")) continue; + if (section === "added" || section === "removed") { + const m = DECISION.exec(line); + if (!m || !STANDARD_NAME.test(m[1] ?? "")) { out.problems.push(unread(line, `- : (, from , )`)); continue; } + const name = m[1] ?? ""; + if (known && !known.has(name)) { out.problems.push(`standards.md names ${name}, which is not a standard on main.`); continue; } + out[section].push({ name, note: m[2] ?? "", by: m[3] ?? "", via: m[4] ?? "", date: m[5] ?? "" }); + } else if (section === "exceptions") { + const m = EXCEPTION.exec(line); + if (!m || !RULE_LABEL.test(m[1] ?? "")) { out.problems.push(unread(line, `-