diff --git a/.agents/checks/ci-gate.sh b/.agents/checks/ci-gate.sh new file mode 100755 index 00000000..4b91be52 --- /dev/null +++ b/.agents/checks/ci-gate.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# The one quality gate. `just ci` and the `quality` job in +# `.github/workflows/ci.yml` both run this file, so a local run and a remote run +# cannot drift; this is the check that remote branch protection requires. +# +# It sequences checks only. The caller supplies the locked toolchain: direnv +# locally, `nix develop` in CI. Every command is read-only over the tracked tree, +# and the last step proves it. +# +# It deliberately never runs `nix fmt`. That formatter applies Rust 2024 +# formatting to this Rust 2021 crate and reflows the retained `docs/audits/` +# evidence, so `cargo fmt` is the accepted formatting check here. +set -euo pipefail + +cd "$(git rev-parse --show-toplevel)" + +step() { + printf '\n== %s ==\n' "$1" +} + +# The tracked tree as the gate found it. On a CI checkout this is empty, so the +# final comparison is literally "the tracked tree is clean"; locally it lets the +# gate run over uncommitted work while still proving the gate changed none of it. +tracked_before=$(git status --porcelain --untracked-files=no) + +step 'cargo fmt' +cargo fmt --all -- --check + +step 'clippy' +cargo clippy --locked --all-targets -- -D warnings + +step 'tests' +cargo test --locked + +step 'repository checks' +cargo run --locked -- checks + +step 'work state' +cargo run --locked -- validate + +step 'actionlint' +actionlint + +step 'reset tripwires' +# The process families RESET.md deleted. Forbidden whether tracked or not: a +# tracked one is what branch protection has to block, and an untracked one would +# otherwise slip past the tracked-tree check below. +returned=() +for artefact in \ + docs/plans \ + docs/metrics/workflow.jsonl \ + .agents/LEDGER.template.md \ + .agents/workflow.toml \ + .agents/reviews; do + if [[ -e ${artefact} || -n $(git ls-files -- "${artefact}") ]]; then + returned+=("${artefact}") + fi +done +if ((${#returned[@]} > 0)); then + printf 'error: deleted reset artefact returned: %s\n' "${returned[*]}" >&2 + exit 1 +fi + +step 'workflow pins' +# actionlint has no rule for this, and an unpinned action is a mutable +# dependency of every merge the gate guards. +if unpinned=$(grep -rEn '^[[:space:]]*-?[[:space:]]*uses:' .github/workflows | + grep -Ev 'uses: [A-Za-z0-9._/-]+@[0-9a-f]{40}([[:space:]]|$)'); then + printf 'error: workflow action is not pinned to a commit SHA:\n%s\n' "${unpinned}" >&2 + exit 1 +fi + +step 'tracked tree' +tracked_after=$(git status --porcelain --untracked-files=no) +if [[ ${tracked_after} != "${tracked_before}" ]]; then + printf 'error: the gate changed the tracked tree.\nbefore:\n%s\nafter:\n%s\n' \ + "${tracked_before}" "${tracked_after}" >&2 + exit 1 +fi +if [[ -n ${tracked_after} ]]; then + printf 'note: tracked files were already modified before this run:\n%s\n' "${tracked_after}" +fi + +printf '\nci: every check passed\n' diff --git a/.agents/work.toml b/.agents/work.toml index d6281ef1..45bbf4b2 100644 --- a/.agents/work.toml +++ b/.agents/work.toml @@ -1,62 +1,65 @@ version = 1 +selected_action = "support-safe-work-paragraphs" [[step]] -id = "make-next-small-and-truthful" +id = "enforce-reset-guardrails" status = "complete" blocked_by = [] -user_problem = "The next command emits about 764 KB, copies stale ledger prose, hides concurrent work, and selects the wrong loop." -change = "Read this bounded work file, project every active unit once, select one action deterministically, and remove ledger and free-form resume output." +user_problem = "Nothing outside this repository stops a change that breaks the build or revives a deleted reset artefact." +change = "Add one locked-toolchain GitHub CI workflow plus one shared local gate, then have the human protect main with a required pull request and that check." acceptance = [ - "Human output is at most 8192 bytes on this repository.", - "JSON output is at most 8192 bytes on this repository.", - "No output contains verbatim ledger text.", - "Every active unit appears exactly once.", - "Exactly one correct selected action appears.", - "Two runs of each format are byte-identical.", + "One read-only job runs on pushes to main and on pull requests.", + "It runs the accepted cargo formatting check, strict Clippy, locked tests, repository checks, real work validation, reset tripwires and actionlint in the locked Nix environment.", + "It never runs nix fmt.", + "Actions are pinned to immutable commit SHAs.", + "Local and CI runs share one gate source.", + "The tracked tree stays clean after a run.", + "Remote protection requires a pull request and this check, with zero approvals.", ] -why_next = "This removes the largest immediate user cost and gives later reset work a bounded control surface." +why_next = "Every later action is safer once a mechanical gate, not a promise, holds the reset boundary." [[step]] -id = "make-default-scaffold-minimal" -status = "complete" -blocked_by = ["make-next-small-and-truthful"] -user_problem = "New projects inherit the workflow that produced the deleted planning tree." -change = "Simplify the default pack to one small work file, one implementation branch, compact guidance, and no ledger, JSONL log, plan-review loop, or review directory." +id = "support-safe-work-paragraphs" +status = "active" +blocked_by = ["enforce-reset-guardrails"] +user_problem = "Every work field must fit one line, so a step cannot explain its problem without cramming it." +change = "Let prose fields carry paragraphs while ids and structural fields stay single-line, and keep both projections safe." acceptance = [ - "A fresh scaffold creates no ledger, JSONL log, docs/plans process tree, or review directory.", - "Generated workflow guidance stays within 65536 bytes.", - "No individual prompt exceeds 4096 bytes.", - "A second scaffold is byte-idempotent.", - "The full product test suite passes.", + "IDs, statuses, blockers and the selected action stay single-line.", + "Prose fields accept paragraphs.", + "Unsafe control characters are still rejected.", + "Human output renders paragraphs without heading injection.", + "JSON preserves paragraph structure.", ] -why_next = "Fixing only this repository would leave the product teaching adopters the failed process." +why_next = "Bounded state is only worth trusting if a step can state its problem honestly inside the size limits." [[step]] -id = "validate-real-work-state" -status = "complete" -blocked_by = ["make-default-scaffold-minimal"] -user_problem = "The current validator can certify self-authored review claims without proving useful work occurred." -change = "Validate the bounded work file and useful state invariants without depending on deleted plans, ledgers, or round logs." +id = "explore-general-review-surface" +status = "pending" +blocked_by = ["support-safe-work-paragraphs"] +user_problem = "The scaffold supports delivery review, but not a standalone review of a current tree or a diff between two states." +change = "Compare a compact prompt, a dedicated CLI command and no product surface, then recommend one." acceptance = [ - "A clean minimal scaffold validates.", - "Validation rejects a sixth step, duplicate selection, unknown status, unresolved blocker, and oversized state.", - "Appending a claimed review record cannot make validation pass.", - "Human and JSON status outputs are deterministic and at most 16384 bytes.", + "Representative current-tree and two-ref review tasks drive the comparison.", + "Every option is judged against the same tasks.", + "The result is a capped scratch decision brief.", + "No product code, pack asset or shipped documentation changes.", ] -why_next = "The minimal workflow needs a mechanical boundary that checks real state without recreating evidence theatre." +why_next = "Deciding the surface first avoids shipping a command the workflow turns out not to need." [[step]] -id = "establish-release-baseline" -status = "complete" -blocked_by = ["validate-real-work-state"] -user_problem = "Process churn obscures whether the retained product still works end to end." -change = "Add the all-complete terminal work state, align user documentation with minimal behaviour, normalise the Rust formatting baseline, remove only proven unreachable product code, and prepare a local release candidate." +id = "investigate-workflow-failure-causes" +status = "pending" +blocked_by = ["explore-general-review-surface"] +user_problem = "The August audit dated the workflow failure and proved immediate mechanisms, but did not identify what caused them to start or worsen." +change = "Trace initiating causes and test delivery-value signals across code, agent-facing text and generated output." acceptance = [ - "An all-complete work file with no selected action validates, and validate, status, and next project its terminal state.", - "Formatting checks, Clippy with warnings denied, and all tests pass.", - "A local install succeeds.", - "An empty-directory scaffold is byte-idempotent across two runs.", - "Validation and both next formats pass from a clean tree.", - "No command requires a deleted process file.", + "Hypotheses and evidence grades are fixed before measurement.", + "The dated change points are traced through human messages, prompts and Git history.", + "Matched work before and after the transition tests each candidate cause.", + "Results separate demonstrated causes, supported causes and unknowns.", + "Each supported cause maps to a bounded preventive control.", + "Self-certified process metrics are excluded.", + "The result is a scratch decision brief of at most 20000 bytes.", ] -why_next = "This turns the reset into a tested product baseline while keeping push and release authority with the human." +why_next = "Causal evidence can prevent another failure without turning activity counts into another target." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..6360fc75 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: ci + +on: + pull_request: + push: + branches: [main] + +# Read-only: the gate inspects the tree and runs no release, publish, or write step. +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + # `quality` is the stable check name remote branch protection requires. Renaming + # the job silently disables that protection, so keep the id and the name together. + quality: + name: quality + runs-on: ubuntu-latest + steps: + # Pinned to immutable commit SHAs, not tags: a tag can be moved onto other code. + - name: Check out the commit under test + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22 + # The committed flake supplies the locked toolchain; `.agents/checks/ci-gate.sh` + # is the same gate `just ci` runs locally. Already inside `nix develop`, so the + # recipe must not wrap the script in direnv a second time. + - name: Run the shared gate + env: + SKIP_DIRENV: "1" + run: nix develop --command just ci diff --git a/README.md b/README.md index f1a8a34e..7f50529b 100644 --- a/README.md +++ b/README.md @@ -354,10 +354,13 @@ just build # cargo build just test # cargo test just clippy # cargo clippy --all-targets just fmt # format all files through the Nix formatter +just ci # the full quality gate, exactly what GitHub CI runs just run -- --help ``` -The verification convention before each commit is `cargo clippy --all-targets -- -D warnings`, `nix fmt`, and keeping all text ASCII-clean. +Run `just ci` before each commit, and keep all text ASCII-clean. It runs `.agents/checks/ci-gate.sh`, the one gate the `quality` job in `.github/workflows/ci.yml` also runs through the locked flake: `cargo fmt --all -- --check`, Clippy with warnings denied, the locked tests, `agent-flow checks`, `agent-flow validate`, `actionlint`, tripwires for the process artefacts `RESET.md` deleted, and a check that the run left the tracked tree unchanged. + +The gate deliberately does not run `nix fmt`. That formatter applies Rust 2024 formatting to this Rust 2021 crate and reflows the retained `docs/audits/` records, so `cargo fmt` is the accepted formatting check. ## License diff --git a/flake.nix b/flake.nix index 2424c672..7b08cbd9 100644 --- a/flake.nix +++ b/flake.nix @@ -91,6 +91,9 @@ pkgs.just pkgs.git pkgs.gh + + # CI gate (`.agents/checks/ci-gate.sh`) + pkgs.actionlint ]; env = { diff --git a/justfile b/justfile index c6edf954..df090b61 100644 --- a/justfile +++ b/justfile @@ -33,6 +33,12 @@ clippy: fmt: {{ direnv_prefix }} nix fmt +# Run the full quality gate: exactly what the `quality` job runs in GitHub CI. +# Invoked through `bash` rather than the script's shebang so the gate runs under the +# bash the flake pins, on any host, including one with no `/usr/bin/env`. +ci: + {{ direnv_prefix }} bash .agents/checks/ci-gate.sh + # Regenerate the project's own reference guidance and role prompts from the built-in # pack. The scaffold run leaves working files alone, notably this repository's live # `.agents/work.toml` and optional `.agents/checks.toml`; copying the refreshed guidance