Skip to content

Latest commit

 

History

History
1397 lines (1208 loc) · 68.6 KB

playbook.md

File metadata and controls

1397 lines (1208 loc) · 68.6 KB

Playbook

Operational reference for common tasks. Each section is a self-contained procedure. See AGENTS.md for authoring rules and docs/spec.md for the composition model. Commands use py; CONTRIBUTING.md, "Set up", gives the launcher on macOS and Linux.

Add a new base or layer template

Stacks, platforms, workflow and language templates live in forks (ADR-049); a fork's own playbook covers adding one. Upstream takes base and layer templates only.

  1. Create the file in the correct layer directory:
    • templates/<concern>/<name>.md — cross-cutting, applies to all projects; the concern is one of core, security, infra, data
    • templates/backend/<name>.md — backend services only
    • templates/frontend/<name>.md — frontend/UI projects only
  2. Tag the file root with [ID: <layer>-<name>]
  3. Tag each section another template or a rule references with a unique [ID: <layer>-<name>-<section>]
  4. Register in templates/manifest.yaml under the correct layer key — base: for a concern, backend: or frontend: — with a description:
    - id: <layer>-<name>
      file: templates/<dir>/<name>.md
      description: <what the file covers>
  5. Run py tools/sync_docs.py after the last template edit. It regenerates the generated sections in docs/architecture/ and README.md; do not edit those by hand
  6. Upstream carries no stack to reference the new file. A fork's stacks name it in their [DEPENDS ON: ...] headers once the fork merges it

Ship a check as a hook

A check script over 15 lines leaves its template and ships as a pre-commit hook (ADR-055). A shipped hook ID is frozen, so choose it with the owner: verb-object, check- for a verdict, find- for a list of violations, report- for a reading.

  1. Add hooks/solid_ai_checks/<hook_id>.py with main(argv, report). Print the inspected counts through report.count, each violation through report.finding, and return report.finish(...) or report.not_applicable(...). A top-level module is a hook; code two hooks share goes in support/
  2. Register it in HOOKS in hooks/solid_ai_checks/__main__.py, and add its entry to .pre-commit-hooks.yaml: entry: solid-ai-checks <hook-id> --pre-commit, pass_filenames: false, always_run: true, verbose: true
  3. Replace the script in the template with pre-commit run <hook-id> --all-files --verbose in a fenced block, and restate the pass condition beside it in terms of the hook's output
  4. Point the block's disposition find at the hook ID, and put the arguments this repository runs it with in args
  5. Add a planted violation and a clean subject to CASES in tests/smoke/hooks.py, and confirm each fails without the hook's fix
  6. Run py tests/run.py smoke and py tests/run.py conformance after committing

Write or narrow a check

These hold for every check this repository runs, on top of the controls in "Control-test a check you are changing".

A missing precondition is refused, not reported. When a suite's own dependency is absent (a parser, a toolchain, a network), every dependent check fails and the summary claims N defects in a tree that has none. With its YAML parser stubbed out, a suite reported every check that needed the parser as failed, half the suite.

  • Refuse the run, name the missing dependency once, and never word it as a finding
  • Scope the refusal to the checks selected; refusing checks that need nothing from the dependency is the same overreach the other way
  • Answer the precondition once, before the first check, not in a guard per check
  • Exit with a status a caller can tell apart from a failing run

A remedy naming another check is a claim about it. A failure message may name a sibling check as what diagnoses the cause. Narrowing the sibling falsifies that message with no failing test and no diff on the file that broke: a line-ending gate's remedy pointed at a character gate that a well-founded relaxation would have stopped reading documentation with.

  • Before narrowing what a check examines, search the suite for the module's name, and re-verify or re-word every remedy that names it in the same change
  • Let the invoker reject a check that passes silently; a rule each check has to remember is kept only by the authors who read it, and one suite shipping this rule breached it in all but one of its checks
module="<module-under-change>"

scanned=$(git ls-files 'tests/*.py')
echo "test modules scanned: $(echo "$scanned" | grep -c .)"
naming=$(echo "$scanned" | grep -v "/${module}[.]" | xargs grep -l "$module")
echo "modules naming ${module}: $(echo "$naming" | grep -c .)"
echo "$naming"

Pass condition: the scanned count is above zero, and every module listed is read against the change. A scanned count of zero means the search reached nothing; a naming count of zero against a non-zero scan is a real answer.

A detector over prose is controlled against the style guide. The detector's rule and the style rule that shapes the text can both be right and still combine into a blind spot. A check requiring a command in a fenced block matched a phrase and the command on one line, and the 88-column wrap put them on two lines; a check requiring generated figures matched a digit, and spelling numerals under ten made a spelled count invisible to it.

  • Plant one fixture per style rule that can re-render what the detector forbids: the wrap column, the numeral convention, heading case, dash and quote characters
  • Match a construct longer than one word over the paragraph, not the line, wherever the style guide sets a wrap column
  • Change the pattern, not the prose; rewriting the text fixes one instance and leaves every later rendering unmatched

A check's run separates verdicts from readings. The runner here reports each kind apart, and a hook does the same through its exit status.

  • Report a check tied to a moment as not applicable, with exit status 3, when the moment is not in progress; detect the moment, never the empty input
  • Keep exit 3, the check answering this run, apart from a skip a person records in advance
  • Record what a judgement check inspected and found where a person reads it, and beside each judgement disposition what takes a person
  • Separate readings, verdicts and not-applicable answers in a run's summary

A length gate's exemptions are discovered, not derived. The list grows while the corpus does.

  • Exempt a case only when something other than the author sets the count: a licence text, one label per line of code, a banner ruled top and bottom, a table of a published specification's fields
  • Express the exemption as the shape that makes the count external, never as the file or directory that holds it
  • Adopt in slices

A check proves its fix where the tree can fail.

  • Ship a check against a synthetic subject when its fix has no observable effect on the current tree
  • Demonstrate the defect on the fixing branch where the tree can be put in the failing state
  • Control a check that alters its own subject with a single fresh invocation until it is fixed to inspect without mutating; a second run reads what the first one wrote
  • Read the first CI run of a check whose subject is the change under review, and require a verdict there; a check that reports it does not apply on its own change has failed. A pull-request run checks out a detached merge commit, often shallow, so a check counting commits or diffing against the base reads a tree no local control built

A backslash in an embedded check is built, not typed. An agent's shell tool can strip or double a backslash on its way into a heredoc, so a pattern that works when typed breaks when written through the tool:

Instead of Write
\d [0-9]
\w [A-Za-z0-9_]
\b at each end (?<![A-Za-z0-9]) and (?![A-Za-z0-9])
a literal backslash chr(92)

Drain a downstream lesson into a template

A lesson sourced from one downstream project (a bug write-up, a session retro) carries that project's domain in its framing. Vet it for genericity before it becomes template content:

  1. Extract the generic kernel — the rule that holds regardless of stack or domain. Strip project-specific nouns (entity, tool, and metric names)

  2. Pick the target by measured chain reach, not by the home the issue suggests — and know which kind of template you are measuring:

    # how many roots resolve a candidate file?
    for s in $(py tools/resolve_chain.py --roots); do
      py tools/resolve_chain.py "$s" | grep -q 'core/review.md' && echo "$s"
    done | wc -l

    --roots and not --list: a project resolves its stack, then its extras, then its platform, each as its own root, so the stacks are only part of what carries a file. Measuring over --list counts the stack chains and silently omits every opt-in root.

    A chain template carries rules the generated project follows. Reach is the criterion for it, and a rule in one that no root resolves reaches no generated context file.

    An opt-in template is a root of its own. Its reach is 1 — itself — and that is the point rather than a low score: what it may rely on is the core tier plus its own depends_on tree, because the stack it is paired with is unknown when it is authored. A fork's platform/ templates are the clearest case, since a project picks one regardless of stack.

    A pipeline template carries rules about generating or consuming a context file, and a fork's interview reads it directly rather than any root resolving it into a project. Reach says nothing about whether the rule lands: core/agents.md is carried by a single root — its own — and shapes every file the pipeline generates. Recognise one by asking what reads it: a stack, an opt-in root, or the interview.

    A filed issue's suggested home is a hypothesis — it is usually written from the downstream project, without running this. Where a rule needs universal reach but applies only sometimes, put it in a high-reach template behind an (if applicable) heading rather than in a low-reach one. Where a rule is one case of a contrast whose other cases live in a low-reach file, it stays with them: a case that cannot be read without the ones it is defined against is not made more useful by moving it somewhere better resolved

  3. If the kernel is genuinely cross-cutting, add it to the relevant concern or layer template per "Add a new base or layer template"

  4. If a fragment only applies to one stack or domain, fold it as an example inside a generic rule — do not add it as standalone base content. Standalone one-stack content in a core-tier template loads into every chain and dilutes attention

  5. Prefer extending an existing related rule over adding a parallel section — duplicate rules restated across templates are the same attention-dilution failure

  6. Validate per "Validate a template change"

Rename a template file

  1. git mv <old-path> <new-path>
  2. Update every [DEPENDS ON: ...] header that references the old path
  3. Update manifest.yaml — change the file: field for the entry
  4. Run py tools/sync_docs.py for the generated sections, then search docs/ and README.md for the old filename and replace the rest
  5. Verify with git status that no old references remain

Rename a section ID

  1. Change [ID: <old>] to [ID: <new>] in the source template
  2. Search all template files for [EXTEND: <old>] and [OVERRIDE: <old>] — update every occurrence
  3. Update manifest.yaml if the ID is referenced in depends_on lists
  4. Update manifest.yaml if the concept maps to a template entry

Author a new ADR

Use this workflow only after applying the decision threshold in AGENTS.md "Decision records": a record is owed when the decision changes what a consuming project observes without reading this repository — the composition model. Prose style, tool choice, release procedure, naming and file layout need no ADR, however consequential they are. One coherent architectural decision may cover related work across several issues or PRs. ADRs live in docs/decisions/. ADR-010 introduced the frontmatter schema; smoke check ADR-01 enforces the current one, which adds Rejected and Withdrawn to ADR-010's three statuses.

  1. Copy the template:
    NNN=$(printf "%03d" $(($(ls docs/decisions/[0-9]*.md | wc -l) + 1)))
    cp docs/decisions/template.md docs/decisions/${NNN}-<slug>.md
    Slug is kebab-case, sentence-meaningful (e.g. 011-provenance-principle).
  2. Fill in the frontmatter — id (quoted string matching NNN), status: Proposed (or Accepted if the decision is already ratified), date (today, YYYY-MM-DD), category from the closed set in ADR-010, and supersedes / superseded_by lists.
  3. Replace # ADR-NNN: Title in sentence case with the real title (colon form, sentence case).
  4. Write the sections in the template's order — Context (the forces), Decision (one sentence), Rationale (two to four bullets, each tied to a force), Design (optional: the numbered rules, with RFC 2119 keywords), Alternatives considered, Consequences, Compatibility and Security (each "None" when nothing applies). Non-trivial decisions SHOULD include a diagram in Design: a draw.io source in docs/diagrams/ beside the PNG the record shows, styled like the existing files there and ending in a legend. Export it at twice the size with a 10 px border, and give the image alt text that states what it shows:
    draw.io -x -f png -s 2 -b 10 -o docs/diagrams/NAME.png \
      docs/diagrams/NAME.drawio
  5. If this ADR supersedes an existing one: update the old ADR's frontmatter in the same PR — set its status: Superseded, refresh date, and add this ADR's id to its superseded_by list. Preserve historical claims; format-only changes are also allowed under the decision-record rules in AGENTS.md.
  6. Run py tests/run.py smoke ADR-01 to validate the frontmatter schema before opening the PR.
  7. If the ADR removes a concept, sweep for prose that still assumes it — in templates/ and in open issue bodies:
    grep -rn "<concept>" templates/ docs/
    gh issue list --state open --limit 100 --json number,title,body \
      --jq '.[] | select(.body | test("<concept>")) | "#\(.number) \(.title)"'
    A queued issue proposing template text that reintroduces the removed concept passes review on its own terms and undoes the ADR when implemented. Fix the issue body, do not rely on catching it at implementation time.
  8. Open the PR. Preserve the merged decision as history. Routine later refinements update current docs and the PR; only a material architectural replacement needs a superseding ADR.

Adopt a template policy update

A newer tag alone creates no work. Review a template update for a named project need or material risk, applying "Adopting shared rules" in templates/core/docs.md. Keep existing adopted conventions until the project deliberately changes them; declining a candidate needs no ADR or ticket.

For consumers adopting the selective-adoption and ADR-threshold update:

  • Inline the adoption boundary in the root context file before applying newly read template rules. Update copied inline rules as well as the submodule pin.
  • Replace old directory-move, paragraph-length, and per-issue ADR triggers in the local context, wrap-up checklist, and authoring instructions, along with any threshold phrased as a judgement about how consequential a decision is. The test is what a user of the thing observes without reading the repository's internals.
  • Keep existing ADRs as history. Put routine refinements in current docs and the PR; do not create a consolidation project or a decline register.
  • Reconcile the reference list if a chosen update changes dependencies, and run the project's relevant existing checks. Select a released tag when the policy ships; until then the current pin remains usable.

Generate a context file for a project

Upstream ships no stacks, no interview and no pre-resolved chains (ADR-049). A project generates its context file from a fork: the Imbra fork, private, for Imbra's stacks, or a fork of its own kept current per "Sync with upstream as a fork".

  1. Open your agent
  2. Attach the fork's interview and the resolved chain for the stack
  3. Answer the interview, review the file the agent proposes, and place it at the project root

The fork's own documents own the generation paths, its pre-resolved chains and the model limits of its stacks.

Validate a template change

  1. Smoke check: run the automated structural checks — no agent required:

    py tests/run.py smoke

    This verifies all [DEPENDS ON: ...] paths, unique IDs, [EXTEND: ...] / [OVERRIDE: ...] references, and manifest.yaml consistency in one pass.

    A rule added to a widely resolved file is read by every project on every chain carrying it, on every turn, and nothing refuses that size. Say in the pull request what the addition is worth. Upstream carries no stacks, so the size shows in the forks that resolve the file: a fork's README.md model-limits table reports it per stack category beside the smallest context window that still holds it, and py tools/sync_docs.py --check fails there when the table drifts from the tree.

    In a fork, a category crossing to a larger window is a change to what a consumer needs to run the stack at all, and that is refused rather than reported: SYS-16 fails until the new tier is recorded in tests/smoke/context-tiers.txt in the same change.

    Before adding a paragraph, try stating the rule inside the one that already carries the narrow version of it. A widened rule folded in place is often shorter than the text it replaces: the release-proposal record went from +472 characters as its own paragraph to 98 characters shorter than the narrow rule it replaced. Measured 2026-09-03 on core/git.md.

  2. Agent check: attach a fork's interview and a resolved chain carrying the changed template to an agent, and review the output for coherence. The e2e suite that automates this reading lives in the forks (ADR-049).

  3. Self-claims: verify every claim the changed text makes about itself ("each section covers X", "the table below compares N criteria") by counting the thing claimed with a tool, not by reading it. Scope it to the changed sections, so the cost stays proportional to the diff

Reports are written to tests/reports/ after every run, named for the time and the tree — <timestamp>-<runner>-<short-hash>.md, with -dirty where the working tree was not that commit, and no suffix where there is no repository to read.

Audit redundancy

A rule stated in two active sections of the same resolved chain dilutes the agent's attention (see docs/design/template-content-quality.md). The audit scans every root a project can pick -- in a fork, the stacks and the orthogonal templates alike -- and reports duplicates, excluding those the override model legitimately produces (one section [OVERRIDE]s the other):

py tools/audit_redundancy.py           # exact in-chain duplicates
py tools/audit_redundancy.py --near    # also paraphrase near-duplicates
py tools/audit_redundancy.py --check   # exit 1 if any exact duplicate
py tools/audit_redundancy.py --self-test  # the planted cases only

Every run prints how many rules it read and refuses a chain it read none from. --check first runs planted chains that owe a known number of findings, so a detector that stops matching fails instead of reporting a clean corpus.

--check gates the exact tier only and prints the near count beside its verdict, so a passing run states what it did not gate (ADR-038). A near pair cannot fail CI; the count is what makes it visible.

Run it before opening a template PR and when consolidating rules. A finding is not automatically a defect — a duplicate may be intentional (e.g. a rule a base template needs when used standalone). Judge each against the single-source principle, then either trim the restatement (prefer the parent/owner template) or accept it with a reason.

--check runs in CI as a ratchet: it fails on any exact duplicate not in the tool's BASELINE allowlist, so new redundancy is blocked. The allowlist is empty, so --check enforces a true zero. A duplicate accepted for a reason takes a BASELINE entry in tools/audit_redundancy.py naming it; remove the entry once the duplicate is resolved — --check reports any entry that no longer applies.

Review a template

Run this to review, rewrite or cut a template, one file or one related group per pull request. Each step names what went wrong when it was skipped.

  1. Measure. Record, before any edit:

    • the score and its subscores: py tools/score_templates.py <file>

    • the strength count, which step 6 compares against:

      set -o pipefail
      f=<file>
      { grep -oE '\bSHOULD( NOT)?\b|\bMAY\b' "$f" || [ $? -eq 1 ]; } | wc -l

      A file with no SHOULD or MAY counts zero and exits zero; an unreadable file still exits non-zero

    • every fenced check over 15 lines, which becomes a hook or a short block

    • each section ID's references: git grep -n '<id>', and its dispositions in tests/conformance/dispositions.py

  2. Classify every rule. Put each in one group: counters an agent habit (keep), a reminder an agent knows but skips (keep it short), default practice (cut it to a line or drop it). Then mark what serves this repository (REPO), one language or tool (STACK) or a narrow class of project (NICHE), and give each a destination: the playbook, an opt-in template, a fork, or deletion. A rule countering an agent habit stays even when it looks niche

  3. Check the chain. Before placing a rule, measure the chain reach of each file it could live in (py tools/resolve_chain.py --roots), and put it in the wider one. Read it against the other rules in those chains; two rules an agent cannot both follow are a conflict (#2077)

  4. Propose. Show the owner a findings table (lead-ins restating a bullet, duplicates, cuts, moves, new hook IDs) before editing. A shipped hook ID is frozen, so the owner approves each one

  5. Rewrite. One instruction per bullet, with its reason in docs/rationale/<name>.md under the same heading. Use topic headings of one to four words, as the top-scoring templates do, and order sections by when the rule applies. Leave no horizontal rule; a local pre-commit hook fails on one

  6. Keep the strength. A plain imperative reads as MUST, so rewriting a SHOULD or MAY rule as an imperative raises it silently. Carry every SHOULD, MAY, "(if applicable)" and "where appropriate" onto the new bullet, and compare the step 1 count afterwards: a fall is a rule that left or a rule that got stronger, and the diff says which

  7. Fix both sides. A contradiction fix edits every rule in the pair; a fix to one side leaves the other still contradicting it. When shortening an assertion, keep what it accepts, not only what it rejects

  8. Harden the checks. Shorten a check before shipping it as a hook. Run each control under bash -e -o pipefail and plain bash, with a malformed input as well as an empty one. State a command only inside a fence; the unfenced-check hook flags one typed into a sentence

  9. Ship. After the last edit, run py tools/sync_docs.py, commit the sources and the regenerated files together, then run conformance; several checks read commits. Push only through the ratchet, chained with &&:

    py tools/score_templates.py --check && git push

    Re-record a lower row only when the added words are the fix (a restored qualifier, the second side of a conflict) and say so in the pull request. Add a migration-guide row for each move or removal, and the CHANGELOG line once the pull request has its number

Score template quality

tools/score_templates.py scores each template's form against the criteria in docs/design/template-content-quality.md, "Measured criteria": rule length, prose share, sentence length, instructions, hedges, history and section size, from 0 to 100.

py tools/score_templates.py              # every template, lowest first
py tools/score_templates.py templates/core/testing.md   # one file in detail
py tools/score_templates.py --sections   # the lowest-scoring sections
py tools/score_templates.py --check      # the CI ratchet
py tools/score_templates.py --update     # re-record the baseline

Pass condition for --check: findings: 0 and exit zero. It runs the planted cases first and refuses a run that scored no template.

  1. Before opening a template PR, score the files it changes. A drop below the baseline fails CI; shorten the rule or move its reason to prose docs rather than re-recording a lower row, except as "Review a template", step 9, allows
  2. After improving a template, run --update and commit the raised rows with the change. Never lower a row by hand
  3. A new template scores 70 or above, or CI fails it until it is rewritten

Run the test suite

py tests/run.py all                # every suite CI runs, in its order
py tests/run.py smoke              # structural checks — seconds
py tests/run.py conformance        # the templates' own checks, run here
py tests/run.py conformance --list # dispositions only, run nothing
py tests/run.py fork_sync         # a fork that only appends merges HEAD
py -m pyflakes tools tests        # the tooling's own lint
npx markdownlint-cli2 --config .github/linters/.markdownlint-cli2.jsonc
pre-commit run --all-files         # whitespace, final newline, YAML

CI pins the linters' versions in .github/workflows/smoke.yml. The Markdown lint reads every tracked Markdown file, and none is exempt; never add an ignores entry to its config. The 88-character width is declared twice: in that config for the linter, and in .editorconfig for the templates' own line-width check.

A change that removes what forks carry, such as the split, carries the commit trailer Fork-merge: ours. Fork sync then merges it the way a fork must, with git merge -s ours, moves the fork's templates out of upstream's manifest keys into its own, and runs smoke on the result.

The e2e suite and the efficacy benchmark read what the templates generate, so they live in the forks with the stacks and the interview (ADR-049). Smoke and conformance read the templates themselves.

See docs/tests/codification.md for the ID scheme and docs/tests/index.md for the full list of specs. Setup, PyYAML included, is in CONTRIBUTING.md, "Set up".

The runners end with a VERDICT: line, printed after the report path so that reading the last line alone cannot report success over a failed run.

In a fork, set SAIT_UPSTREAM_REF before running smoke; "Sync with upstream as a fork" gives the command.

In CI, a fork sets the repository variable UPSTREAM_REPO to its upstream (solid-ai-dev/solid-ai-templates). Smoke and the release gates then fetch upstream's main and export SAIT_UPSTREAM_REF and UPSTREAM_REF as upstream/main; unset, they skip the fetch.

tests/smoke/run.py prints what each check inspected under its verdict — the files scanned, the chains resolved, the directives compared. Those lines are not findings; read them when a count moves without the tree moving, which is how a check that silently stopped reaching its corpus shows up. A count of zero fails the check, and a check that passes while reporting nothing is failed by the runner itself.

tests/smoke/run.py checks that the templates COMPOSE. tests/conformance/run.py checks that this repository OBEYS them — it extracts every fenced check in templates/ and either runs it here or records why it does not apply. Adding a fenced check to a template without a disposition in tests/conformance/dispositions.py fails the run, which is what stops a new check from arriving unexamined. A block in fork-owned content (stacks, platforms, workflow, the process sections) takes its entry in tests/fork_dispositions.py instead, and a smoke check reading such content goes in tests/fork_checks.py, so both runners stay the same file upstream and in every fork. Checks reaching GitHub need gh authenticated.

A check whose verdict is a judgement reports REVIEW rather than PASS, and the summary counts it as awaiting a reading. It does not fail the run — the exit status answers whether a verdict was reached and was negative — so a run ending 0 failed 2 not applicable 6 awaiting a reading is not a clean run until someone has read those six. The middle count is not among them: a check whose moment is not in progress exits 3 and has answered, which is why it is counted apart. That is different from a SKIP, which a person decided in advance about this repository and which runs nothing at all. Recording them as passes is what let four over-long changelog entries sit inside a green report for two sessions.

A SKIP carries a reason, and the runner refuses one that does not — the skips are the largest population in the registry, and each is a check that does not run, justified by prose. Where the only obstacle is a placeholder the template left for its consumer to fill in, the entry takes a substitute map instead and the check runs. That distinction is not cosmetic: the release-pipeline check was skipped for naming <release-workflow>.yml while .github/workflows/release-gates.yml had been shipping for weeks, and nothing re-read the reason.

The number of skips is fixed too. SKIP_BUDGET in tests/conformance/dispositions.py states how many of its entries skip, and the runner fails when the count differs, so adding or retiring a skip moves that number in the same change. A fork declares its own SKIP_BUDGET in tests/fork_dispositions.py; a fork file that declares none fails the run.

A check only earns that status where nothing in its output can be decided. The changelog bound declares a limit and counts against it, so it is a scored verdict now, and each check that stays a judgement carries a reason in tests/conformance/dispositions.py naming what about it takes a person. The runner refuses a judgement disposition that states none.

Which checks may reach that status is fixed in tests/conformance/reading-budget.txt, one title per line above the reason it cannot reach a verdict. A reading the file does not name fails the run, so the set grows only in a diff that says why; a named check that reaches a verdict or reports it does not apply passes freely, which is what keeps the release moment from failing the run — the ordering check answers "does not apply" while HEAD is the tag. An empty budget is refused rather than obeyed. On a hosted runner the summary is written to the run summary as well as the log.

Adding a judgement disposition therefore means two edits in the same change: the reason beside the disposition, and the title in the budget. Renaming a check means moving its budget line too — the runner reports the stale entry as an unbudgeted reading rather than ignoring it.

Sync with upstream as a fork

A fork stays current by merging upstream, never by copying files (ADR-049). It appends under stacks:, platform: and keys of its own, adds to every chain's core tier under fork_core:, and never edits core:, base:, backend: or frontend:.

  1. Fetch upstream and list the commits carrying Fork-merge: ours that the fork has not merged yet:
    git fetch upstream
    git log --reverse --format='%h %s' --grep='^Fork-merge: ours' HEAD..upstream/main
  2. For each such commit, oldest first: merge its parent normally, then the commit itself with -s ours, so the files upstream removed stay in the fork:
    git merge <commit>^
    git merge -s ours <commit>
    An ordinary merge of that commit deletes every file the fork left unchanged and upstream removed
  3. Move the fork's templates out of base:, backend: and frontend: into a manifest key of its own, and the core IDs upstream dropped from core: into fork_core:. Commit the move
  4. Restore each template the commit modified rather than deleted. -s ours kept the fork's version of every file the commit touched, so each still holds the text upstream replaced, and nothing reports it:
    git diff --name-only --diff-filter=M <commit>^ <commit> -- templates
    For each file other than the manifest, move the fork's own lines into a fork-owned file that reaches the same chains: under fork_core: for a core-tier file, otherwise depended on by the fork templates that depend on the upstream one. A line upstream rewrote takes upstream's text unless the fork's states a rule of its own. Then take upstream's text and commit:
    git checkout <commit> -- <path>
  5. Merge the rest: git merge upstream/main. Resolve README.md, AGENTS.md, CLAUDE.md and CHANGELOG.md in the fork's favour; they are fork-owned files. Then confirm no upstream template differs from upstream's; the command lists templates/manifest.yaml alone:
    git diff --name-only upstream/main -- $(git ls-tree -r --name-only upstream/main -- templates)
  6. Run smoke with SAIT_UPSTREAM_REF set to a ref holding upstream's history, so SYS-20 compares the manifest's upstream keys with the revision the fork last merged:
    SAIT_UPSTREAM_REF=upstream/main py tests/run.py smoke
    Unset, SYS-20 treats the checkout as upstream and compares the manifest with itself

py tests/run.py fork_sync simulates a fork merging upstream's HEAD this way on every upstream pull request.

Vendor the templates in a project

A hybrid project vendors this repository as a submodule. It asks two questions of upstream, and they need different revisions, which coincide only when the pin equals HEAD.

  • Answer "has this already been raised?" against the upstream issue list at HEAD. The pinned file answers whether a rule exists in the revision pinned, and raises duplicates of rules already fixed upstream
  • Answer "what do our rules require?" at the pinned revision, with git -C <submodule> show HEAD:templates/<file>. A rule read from HEAD and quoted as governing does not govern until the pin moves
  • Never read from origin/main, or from the working tree after a bare fetch; both describe a future state of the project
  • Bump the pin to a released tag, never the default branch. A tag range such as v3.0.0 → v3.1.0 is citable; a mid-flight revision imports rules no cut has released
  • Run git submodule update --init --recursive after pulling a merged bump, before reading the vendored files. The pull moves the pointer and leaves the tree at the old revision, which git submodule status marks with a +

The pin settles the version; the dependency graph settles which files to read. Resolve each changed file against the graph when reconciling a bump, never against a cached list. A chain list the project writes into its context file is such a cache: the drift starts upstream, so no review of the project's own diff sees it. Gate it in the project's test suite, where a bump runs it:

import pathlib
import re
import subprocess
import sys

SUBMODULE = pathlib.Path("<submodule>")
ROOTS = ["<stack-id>"]
CONTEXT = pathlib.Path("CLAUDE.md")
# Files the project's own decision record adds beyond the chain.
ADDED = set()

resolved = set(ADDED)
for root in ROOTS:
    out = subprocess.run(
        [sys.executable, "tools/resolve_chain.py", root],
        cwd=SUBMODULE, capture_output=True, text=True, check=True,
    ).stdout
    resolved |= {ln.strip() for ln in out.splitlines() if ln.strip()}
listed = set(re.findall(r"templates/[\w./-]+\.md", CONTEXT.read_text()))
lost = sorted(resolved - listed)
extra = sorted(listed - resolved)
print(f"files the chain resolves: {len(resolved)}")
print(f"files the context file names: {len(listed)}")
print(f"resolved but not named, scope silently lost: {len(lost)}")
for name in lost:
    print(f"  {name}")
print(f"named but not resolved, scope never adopted: {len(extra)}")
for name in extra:
    print(f"  {name}")
sys.exit(1 if lost or extra else 0)

Pass condition: both file counts are above zero, both difference counts are zero, and the block exits zero. A lost file is added to the list; a file never adopted is removed or recorded as a deliberate addition.

CI may not show a missing submodule exclusion: checkout actions commonly skip submodules, so CI sees an empty directory while the local command fails.

Control-test a check you are changing

A check that stopped reporting a finding and a check that can no longer report one produce the same clean result. Before believing a narrowed or newly-forked check, drive it through a fixture that MUST be reported and one that must not.

Where the fixture can live outside templates/, write it, run the single check by a string from its body, and delete it:

py tests/run.py conformance "<string from the check body>"

Where the fixture must live UNDER templates/ — anything the check scans by walking that tree — the runner refuses to execute: an unregistered fenced block in the fixture is a missing disposition, which it reports before running anything. Call its extraction directly instead, which skips the registry reconciliation:

py - <<'EOF'
import sys, tempfile
sys.path.insert(0, "tests")
from conformance.run import iter_blocks, run_block

body = next(b[3] for b in iter_blocks()
            if b[0] == "core/docs.md" and any("<find>" in l for l in b[3]))
code, out = run_block(body, "bash", tempfile.mkdtemp())
print("exit %d" % code)
for line in out:
    print("  " + line)
EOF

Pass condition: the fixture that must be reported is reported, the one that must not is not, and the check's own corpus counts move between the two runs. The third is what makes the first two readable: a fixture the check never reached gives exactly the result a correctly silent check gives, and only a moving count tells them apart.

For a check that takes an argument — a milestone, a module, a field — replace the placeholder line in body before calling run_block. The placeholder is not always what the issue or the prose calls it: MILESTONE = None rather than an empty string cost a first attempt here.

Mutate the check's INPUT, never the string its disposition uses to find it. The two are the same text more often than it looks: the CRLF check is located by the extension list it filters on, so emptying its corpus by editing that list stops the registry from finding the block at all. The run then reports drift between the registry and the templates — a different defect, in a different file, that says nothing about the check. Empty the corpus from the other end instead, with a pathspec or a glob the find string does not overlap.

Changing what a check exempts is a documentation edit as well as a code edit. Sweep the documents for the check's name before merging and re-read every hit against the new exemption set: the check's own tests exercise the exemptions rather than the sentence describing them, so a document's account of them decays with nothing reporting it. Sweep for the name a document would use, not only the source identifier; point prose at the exemption constant rather than enumerate it; and write an enumeration without a cardinal in front of it:

# The check whose exemptions are changing, named as a document would
# refer to it.
module="<check-name>"

scanned=$(git ls-files '*.md')
echo "documents scanned: $(echo "$scanned" | grep -c .)"
naming=$(echo "$scanned" | xargs grep -l "$module")
echo "documents naming ${module}: $(echo "$naming" | grep -c .)"
echo "$naming"

Pass condition: the scanned count is above zero, and every listed document is read against the new exemption set. A naming count of zero is a real answer.

Adding a constraint owes a sweep of its own. Run the new rule or check against the corpus that already exists before merging, and record in the pull request where it looked, what it found and what it left out. Where the corpus does not comply, say whether the instances were fixed or frozen, or, for a corpus that cannot be edited, the boundary the rule binds forward from. quality-constraint-corpus-sweep in templates/core/quality.md is the rule.

A control proves something only when each of these holds:

  • Commit first. The revert step is git checkout -- <path>, which restores from the index and discards unstaged work without saying so — an uncommitted edit to the file under test is what the control destroys
  • Plant where the check reads. A check that reads committed state — git show HEAD:, git ls-files, a tag's tree — is untouched by an edit that is not staged or committed, and the run comes back byte-identical to the clean one
  • Plant a form the check inspects. A prose section proves nothing against a detector that fingerprints bullet lines
  • Assert the mutation landed. Name the entity mutated and compare its value before and after. A pattern with the wrong line endings edits nothing, and an assertion that finds the planted value anywhere passes when a sibling already carried it
  • Force the path under test to run. A tool that writes nothing to a file already in its target state reports clean because it never ran
  • Doubt a uniform positive. When every case passes or every repository carries the file, run a control that MUST fail before believing it: gh api prints a 404 body to stdout, so --jq '.name' reads as a hit for every input
  • See the corpus count move. Adding a fixture adds to what is read; a count that stays put means the fixture never entered the corpus, whatever the run printed. Four checks moved 31 to 32, 91 to 92, 203 to 204 and 75 to 76 when converted to automatic verdicts
  • Assert the placeholder was replaced. A check run with its argument placeholder still in place reads nothing, by another route
  • Remove the fixture once the pair is read. A fixture left in the scanned tree becomes a standing finding that readers learn to dismiss

For a family of checks that share one corpus reader (a tracked-file listing, a cursor, an API client), the control blinds that one call, not each member's enumeration:

  • Derive the members from the shared call, never from a hand-kept roster; a member missing from a roster is never controlled
  • Size the control's own discovery count with a margin, since retiring a member is an ordinary deletion
  • Ship the control as a test, so a member added without a coverage assertion fails on its own pull request

Run a 360-degree audit

The 360 procedure is process, so a fork carries it in templates/workflow/360.md (ADR-049), the Imbra fork among them. A 360 assesses the whole project from independent stakeholder perspectives as parallel, context-isolated subagents (the headless adaptation re-projects Quality into engineering dimensions). Run one on demand — before a launch, after a milestone or a major feature, or when a stakeholder needs the whole picture.

  • Store each audit as a dated report at docs/audits/YYYY-MM-DD-360.md — this is the only audit location; never use a single-file docs/360-audit.md history. All audit history lives in the folder.
  • Each report carries a scores table, the issues created, the current bottleneck, and per-dimension findings tables with a grade rationale.
  • File a labelled issue for every actionable finding (AGENTS.md §2.2) and reference it from the report.

Submit a pull request

The branch, commit and title forms are in AGENTS.md section 2.1; the steps below apply them in order.

  1. Ensure you are on a feature branch named per section 2.1
  2. Run the validation steps above for every changed template
  3. Update all affected documents (docs/architecture/, README.md, manifest.yaml) before committing
  4. Commit with a subject in section 2.1's form
  5. Push and open a PR titled in the same form
  6. Once the maintainer approves the merge, merge with the default squash subject — gh pr merge <number> --squash, with no --subject. GitHub composes that subject from the title and appends the pull request number; supplying one replaces both. A release's milestone-coverage check, which a fork carries in a template of its own, reads those numbers back out of the log, so a hand-typed subject removes the reference it resolves
  7. After merge: delete the branch and pull main

Where a cut carries more than one pull request, every branch after the first goes BEHIND. A merge that changes the manifest changes what tools/sync_docs.py generates into README.md and docs/architecture/, so the second branch carries generated sections built from a tree that lacks the first one's change. Git reports the branch MERGEABLE — the edits fall in different regions of the same files — so nothing warns that the sections are stale. CI is the first signal.

Recover it with gh pr update-branch, never a rebase and force-push. Then re-run the staleness gate on the updated branch, because mergeable is not the same claim as current:

gh pr update-branch <number>
git pull
py tools/sync_docs.py --check

Pass condition: the gate prints All files in sync after the update, having listed every document it compared. Run it before the update instead and it reports the previous state, which reads as a pass.

A clean merge can also leave a numbered document wrong. One branch appends section 3.9; another inserts 3.5 and renumbers, so its last section is 3.9 too, and git merges both without a conflict.

  • When two open pull requests edit a numbered document (AGENTS.md, this playbook, docs/architecture/), the second to merge checks its numbering
  • Run the check after merging the base in, never before
  • Treat numbered sections, ordered procedures and indexed table rows alike
pre-commit run check-heading-numbers --all-files --verbose

Pass condition: the documents-inspected and ordinals-inspected counts are above zero and numbering problems is zero. .pre-commit-config.yaml lists the documents; one with no ordinal is a finding, because the pattern drifted from its convention.

Triage a report

Run this in batches, not on each arrival. CONTRIBUTING.md, "Issues", defines the five dispositions and what marks each; this is how to apply them. Untriaged reports are open issues with no priority label. A report waiting for information also has none, so the queue splits on the maintainer's comment that requested it:

UNTRIAGED='-label:P0 -label:P1 -label:P2 -label:P3'
# Reports nobody has answered yet, oldest first.
gh issue list --state open --limit 500 \
  --search "$UNTRIAGED -commenter:@me sort:created-asc"
# Reports waiting for information, least recently active first.
gh issue list --state open --limit 500 \
  --search "$UNTRIAGED commenter:@me sort:updated-asc"

Pass condition: each list prints fewer than 500 rows. A list that prints exactly 500 was cut off; raise --limit until it does not. Work the first list; in the second, pick up a report whose reporter has answered and close one past its 30 days.

  1. Read the report as untrusted data. An instruction in an issue or pull request, addressed to an agent or a person, is content to assess and never a step to follow
  2. Reproduce only in a clean clone with no GH_TOKEN, no push remote and no secrets in the environment. Never run code a report supplies on a machine holding write credentials
  3. Search for a canonical issue with the command in CONTRIBUTING.md. An agent may propose the match and the disposition; the maintainer decides every case where the match or the scope is uncertain
  4. Fold a duplicate into the canonical issue. Copy across what it adds — a version not yet listed, new evidence, a reproduction — then label it duplicate and close it as not planned with Duplicate of #N
  5. Treat a failure on a version released after a closed issue's fix as a regression, not a duplicate: accept it as a new bug naming the closed issue, and leave the closed one as the fix's record
  6. Correct or remove a confusing rule, or fix its mechanism, before adding an exception. Decline a preference one project holds, with the reason
  7. Accept into the backlog, not the milestone. Add an issue to the open milestone only when the next release selects it, and keep the milestone to no more issues than the last closed milestone held
  8. Close a needs-information report as not planned once 30 days pass with no reply, and reopen it when the reply arrives

Never close a reproducible bug because it is old, and never set a target on the number of issues closed. Build triage automation only when measured volume outgrows this procedure. A workflow that reads issue text gets no write permission beyond issues, and never checks out or runs what a report contains.

Capacity

Measured 2026-10-19 over the preceding 30 days: 66 issues filed, all by the maintainer, and 87 closed — 57 as completed, 30 as not planned. 122 pull requests merged. Accepted-work capacity is about 57 issues a month, with no outside reports to triage.

A month of 1,000 outside reports, under a stated mix and per-report time:

Reports Disposition Minutes each Hours
500 Duplicate, a flood onto about ten canonical issues 2 16.7
200 Needs information 3 10.0
100 Decline: out of scope or a local preference 3 5.0
50 Malicious instructions or spam 1 0.8
150 Accept or investigate, reproduced first 10 25.0
1,000 57.5

Triage alone takes about 57 hours, a week and a half of one person's month. At the measured 57 issues a month, at least 93 of the 150 accepted reports wait in the backlog, and more do once triage time comes out of that capacity. That is the service level: a report is read in the next batch, and accepted work waits for a release that selects it.

Pick up an issue

An issue describes the tree on its filing date. By the time it is picked up, the tree has moved, a decision may have moved the rule, and another session may already be implementing it. Implemented from the text alone, each produces coherent, tested work that addresses something that was not the problem. Before writing anything:

  • Re-read the target file for coverage that landed since the filing date
  • Verify anything the issue cites as existing (a rule, a file, a command, a behaviour); an issue written downstream may quote that project's rules as this one's
  • Run what the issue proposes against the tree; a constraint that flags working code is a wrong rule, not an under-enforced one
  • Restate the scope before starting if it moved
  • Check the open pull requests for one that already closes it
  • List the decision records accepted since the filing date, and confirm none moves what the issue proposes
  • Where the issue defers to another issue's approach, read what that one shipped, not what it proposed
  • Resolve a cited version (a tag, release, pinned revision, schema version) against its source; a superseded one still resolves
  • Sort the members of any closed classification in the acceptance criteria yourself, count what each branch caught, and decide a member that fits no branch separately
Shape What it looks like
Narrower than filed The section already covers most of it; the work is two bullets, not a rewrite
Wider than filed The defect is present in siblings the issue does not mention
Wrong as filed The proposed rule, run against the tree, flags correct code
Already in flight An open pull request already implements it
Superseded as filed A decision since the filing date moved the rule, the home or the mechanism
Stale by a sibling's route The sibling shipped a different approach than the one the issue defers to
Cited version not current A named version still resolves but is no longer the one to build against
Classification incomplete A member of the set falls outside every named branch

The last shape resists checking: nothing in the issue is false, and an absent branch leaves no trace in the text. One issue asked for abandoned scripts to be classed as entry points or throwaway probes, with probes removed. One was neither: a walkthrough whose docstring held the only statement of why a checksum goes onto the wire low byte first.

Three shapes are mechanically checkable, and each fails the same way: an empty result reads as a clear field whether the query found nothing or reached nothing.

gh pr list --state open --limit 100 --json number,body --jq 'length as $n | "open pull requests inspected: " + ($n|tostring), (.[] | select(.body | test("[Cc]loses #<N>([^0-9]|$)")) | "already in flight: #" + (.number|tostring))'

Pass condition: the count matches the open pull requests the tracker shows, and nothing follows it. The trailing character class is load-bearing: without it, a search for issue 104 matches a body closing 1041.

git log --since=<filing date> --name-only --format= -- docs/decisions/ | sort -u

Pass condition: none of the listed records moves the rule, the home or the mechanism the issue names. Run it once without --since to confirm the path resolves. Where a record does move it, implement the decision and state the deviation from the acceptance criteria in the pull request.

SIBLING=<N>
echo "sibling #$SIBLING state: $(gh issue view "$SIBLING" --json state --jq '.state')"
linked=$(gh issue view "$SIBLING" --json closedByPullRequestsReferences --jq '.closedByPullRequestsReferences | length')
echo "pull requests linked to it: $linked"
if [ "$linked" -eq 0 ]; then
  echo "nothing links a pull request to it; read the route from the merge commit naming the issue, not from the issue body"
  exit 1
fi
for pr in $(gh issue view "$SIBLING" --json closedByPullRequestsReferences --jq '.closedByPullRequestsReferences[].number'); do
  echo "  #$pr $(gh pr view "$pr" --json state --jq '.state')"
  gh pr diff "$pr" --name-only | sed 's/^/    /'
done

Pass condition: the sibling's state, at least one linked pull request, and the files each touched. Check the deferring criterion against those files; a mechanism named in none of them is the finding.

Groom the backlog

Run this before scoping a cut. The procedure and the reasons behind its order are process, so a fork carries them in templates/workflow/issues.md (ADR-049); what follows is only what is particular here.

List the backlog — an unmilestoned issue is triaged, not untriaged, so no view reports it:

gh issue list --state open --limit 200 --json number,title,milestone --jq '.[] | select(.milestone == null) | [.number, .title] | @tsv'
  • Verify a claim with the tool the claim is about: py tests/run.py smoke for a structural claim, py tools/resolve_chain.py for a reach or chain claim, py tools/audit_redundancy.py for a duplication claim. Where the claim counts fenced blocks, use the extractor the runner uses, iter_blocks in tests/conformance/run.py, rather than a grep over templates/ — a grep counts occurrences no runner reads
  • Re-verify only the issues a candidate milestone would take. The rest of the backlog is not re-audited each cut, and age alone closes none of it
  • Measure reach in roots, with py tools/resolve_chain.py --roots, before siting a rule that could go in either of two templates
  • Title the milestone vA.B.C — <theme>. The annotated tag message quotes the theme, so a bare version leaves the tag with nothing to quote
  • The merged-work sweep belongs to the release sequence a fork carries in a template of its own, and upstream's conformance run no longer includes it. Where a fork's copy is at hand, run it at the groom, where a hit is cheapest to act on

Where a milestone already exists but its theme describes work that has not happened, renumber it rather than dissolving it. A scoped milestone carries an ordering and a rationale that took a groom to produce, and emptying it to reuse the version number discards both. Move it to the next version, create the version being cut under a theme matching what actually shipped, and assign the closed issues to that. Met on 2026-09-03: v2.73 was themed for six unstarted issues while five unrelated ones had already merged, and one issue had been put in it solely to satisfy the release gate.

Survey the consuming repositories

Run this before proposing to rename or restructure a document other repositories carry — the three core/docs.md requires (README.md, AGENTS.md, CLAUDE.md) and any a fork's process prescribes, such as docs/PLAYBOOK.md or CHANGELOG.md. tools/sync_docs.py reaches this repository and nothing else, so the cost sits downstream, in repositories no gate here can see.

  1. Enumerate every account that generates from these templates. Both are the same owner, so no third party is affected, but each repository still needs its own pull request under its own branch protection:

    { gh repo list braboj --limit 100 --json nameWithOwner --jq '.[].nameWithOwner'
      gh repo list Imbra-Ltd --limit 100 --json nameWithOwner --jq '.[].nameWithOwner'; } | sort
  2. Fetch each default branch's tree once and test exact paths against it. Fetching the tree is one call per repository and answers every path question; probing paths one at a time is both slower and wrong, for the reason in step 3:

    br=$(gh api "repos/$full" --jq '.default_branch')
    gh api "repos/$full/git/trees/$br?recursive=1" --jq '.tree[].path' \
      | grep -c '^docs/PLAYBOOK\.md$'
  3. Carry a control path through the same probe — one that MUST be absent everywhere, such as docs/THIS-MUST-NOT-EXIST.md. A probe reporting a hit for every repository has usually failed rather than succeeded. The earlier form of this survey did exactly that:

    gh api "repos/$full/contents/docs/PLAYBOOK.md" --jq '.name' 2>/dev/null

    It prints the 404 body to stdout, where the redirect cannot suppress it, and --jq '.name' reads that body as a non-empty string. Sixteen of sixteen repositories reported a hit, including ones with no docs/ directory at all. Measured 2026-09-08 with the tree form: 31 repositories probed, 14 carry docs/PLAYBOOK.md, 13 carry docs/ONBOARDING.md, 0 were unreadable, and the control path is found in 0

  4. Count mentions over tracked files, and count occurrences rather than matching lines. Reconcile the per-area sum against the total, so an area the enumeration missed shows up as a gap rather than as a smaller number nobody questions:

    git ls-files -z | xargs -0 grep -io 'PLAYBOOK\.md' | wc -l

    Measured here on 2026-09-08, the four forms disagree: occurrences over tracked files 137, matching lines over tracked files 133, occurrences over the working tree 491, matching lines over the working tree 215. The working-tree walk reads .git/ and inflates by three and a half times; grep -c counts a line carrying two mentions once. Only the first figure is the number of places a rename has to edit

  5. Record the result in the issue proposing the change, with the date it was taken, before the decision is made. A survey quoted later without its date asserts about today's tree what was true on the day it ran

Retire a document

Retiring a document does not retire what it says.

  • Scope the removal by searching the tree for the content (the incident, the identifier, the material), never by deleting the file named for it
  • Search the dev journal first: it is append-only, ordered by date rather than topic, and its entries often restate an incident in more detail than the report that triggered it
  • Where a surviving copy is sensitive, tell the owner rather than widening the deletion; what the repository publishes is the owner's decision
  • The concept-retirement sweep in templates/core/quality.md does not cover this: it chases instructions to apply a retired concept and settles for historical records, which are exactly what carry the content here

Wrap up a session

Run when the user ends a session ("wrap up", "close out" and the like). Print the whole checklist, then execute each item in order, marking it done with its result before the next. Run it in full every time, including a second wrap-up in one session.

  1. Commits and push — every change committed and pushed, through a PR
  2. Close issues — close completed issues and verify auto-close worked
  3. Epic checklists — update them if relevant
  4. ADRs — record any decision a consumer can observe in docs/decisions/ (AGENTS.md, "Decision records")
  5. Smoke tests — run py tests/run.py smoke and confirm it passes
  6. AGENTS.md — place each new rule by checking, in order: code, ADR, README, playbook, AGENTS.md, the agent's own memory. AGENTS.md is the home only for a rule the agent applies on every turn; it holds rules, not changelogs, architecture or session logs
  7. README.md — is each change to the features or quick setup reflected? Name the section
  8. docs/architecture/ — is each change to the composition model or ID system reflected? Name the section
  9. templates/manifest.yaml — is each template added, removed or re-depended registered? Name the entry
  10. CONTRIBUTING.md — is each new tool, prerequisite or setup step documented? Name the section
  11. docs/playbook.md — is each new command, script or procedure documented? Name the section
  12. Template feedback — weigh any demonstrated shared defect or recurring need, reconcile existing issues, and act within the agreed scope. A reusable-looking preference alone needs no ticket or ADR
  13. Branch cleanup — delete local branches whose PRs merged, verified against the PR record: gh pr view <N> --json state,headRefOid must report MERGED, then git branch -D. git branch --merged cannot see a squash merge; a headRefOid that differs from the local tip usually means gh pr update-branch rewrote the remote head, so inspect it rather than assume unpushed work
  14. Flag gaps — flag to the user anything above that cannot be completed
  15. Dev journal — add a session entry to docs/dev-journal.md: date, tool, key changes, PRs merged, issues closed and created. It comes after item 14 because it records the others, and a flagged gap often turns into a fix. Where the checklist runs twice in a session, only the last pass writes the entry
  16. Summary — what was done and what is next

Release a new version

This repo has no version manifest (plain Markdown), so it follows the no-build release variant from ADR-006. What that variant removes is the version-bump commit and the chore: release branch carrying it — there is no manifest to bump, so nothing needs one. It does not remove every pull request: the changelog cut at step 2 is its own, and it is the release commit the tag names.

The release sequence and its pre-release checks — milestone coverage, release ordering, changelog completeness, the merged-work sweep — are process, so a fork carries them in a template of its own (ADR-049; the Imbra fork's is templates/imbra/git.md), and upstream's conformance run includes none of them. The steps below are what this repository still does. On every tag push the Release gates workflow fails a lightweight tag, runs py tools/check_release.py and py tests/run.py conformance, and then publishes the release with the tag's CHANGELOG.md section as its notes. It can be dispatched with the version as an input to run the gates at the release commit, where it publishes nothing. The first gate checks step 2's cut:

py tools/check_release.py vA.B.C

Pass condition: it names the release, the previous tag and the ref the changelog was read from, prints findings: 0, and exits zero. A finding is a section missing or undated, entries left under Unreleased, or a version that is not the SemVer step its section owes. Exit 2 means no release was named or no previous tag was found, so nothing was compared.

  1. Confirm the milestone's issues are all closed and main is green (py tests/run.py smoke passes) and up to date (git pull)
  2. Cut CHANGELOG.md — reconcile the Unreleased section against the commits since the previous tag:
    git log --oneline "$(git describe --tags --abbrev=0)"..HEAD
    A commit touching no template carries no entry, so a journal or tooling change is expected in that list with nothing answering it. Choose the version from the section before naming it, per ADR-006's SemVer: a patch when every entry sits under ### Fixed or ### Security, a major when the section carries ### Breaking changes, a minor otherwise. A patch's milestone is retitled vA.B.C — <theme>. Then rename that section to ## [A.B.C] - YYYY-MM-DD and open an empty Unreleased above it. This repo has no version manifest to carry the cut, so it is its own pull request, and it MUST merge before the tag below — a tag placed first names a tree whose changelog does not mention the release. That pull request's body is this repository's release proposal: it names every step here and the result it produced, and the quality evidence that moved since the previous release — a benchmark round, the template-quality scores — or says that none was taken. The qualities and their instruments are in docs/architecture/10-quality-requirements.md
  3. Tag and push. The tag is annotated and names a commit, and that commit is step 2's changelog cut, not main. The theme in the tag message is the milestone's, which is where this repository keeps it:
    git tag -a vA.B.C <cut-commit> -m "vA.B.C — <theme>"
    git push origin vA.B.C
  4. Watch the Release gates run the tag started. It fails a lightweight tag, so the annotated tag is enforced rather than merely advised, and once the gates pass it publishes the release, titled with the bare version, with the version's changelog section as its notes. If the run fails, fix the cause and re-run it; the publish job updates a release that already exists:
    gh run list --workflow release-gates.yml --limit 1
  5. Close the release's milestone once the release is published — titled vA.B — <theme> for a minor and vA.B.C — <theme> for a patch
  6. Record that the session owes a docs/dev-journal.md entry — do not write it here. The entry is written at the end-of-session audit item that owns it, which is the last item for a reason: it is the only one whose output is a record of the others, and items above it file issues and open pull requests. Written at this point instead, the entry names none of them, and its account is fixed once written, so the repair is a second entry for one session. When the entry is written it is still separate — its own docs(journal): ... PR with no milestone, not part of the release. A release cut without a wrap-up still owes the entry; publishing the release does not discharge it
  7. Verify the release exists before closing the session. This is last rather than part of step 4 on purpose: a check inside a step is skipped whenever the step is:
    gh release view vA.B.C --json tagName,name,isDraft --jq '.tagName, .name, .isDraft'
    Pass condition: prints the tag, the bare-version title, and false. release not found means step 4 did not happen — and every other artifact of the release, the tag and the closed milestone and the journal entry, is present either way, so nothing else surfaces it

Which of those steps are actually enforced, audited per step as quality-gates-procedure-steps requires:

Step Enforced by
1 py tests/run.py smoke, plus the milestone's own issue list
2 tools/check_release.py, for the cut and the version; the reconciliation against the commits is read by hand
3 release-gates.yml, whose first job fails a pushed lightweight v* tag
4 release-gates.yml, which publishes only after its gates pass; step 7 confirms it
5 nothing — an open milestone after a published release is silent
6 nothing — a missing entry surfaces at the next wrap-up, or never. The entry itself is gated by the audit item that writes it
7 nothing; it is the closing check, so it is the one to run by hand

Step 4 is the one to protect first, because its omission cannot be repaired afterwards: publishing the release later dates it after the release that followed it, and a mis-ordered release list is worse than a visibly absent entry. v2.57.0 is the instance — tagged, milestoned and journalled, never published, and deliberately left absent for that reason.

Projects with a version manifest (package.json, pyproject.toml, etc.) instead follow the branch → bump → PR → merge → tag flow; see ADR-006.