This repository includes an MVP workflow that converts validated issues into AI-generated draft pull requests. The default AI provider is Groq with stage-specific defaults: all four stages (validation, generation, review, autofix) use openai/gpt-oss-120b with reasoning_effort: low (ADR-0025). Anthropic (Claude models) is also supported. With both keys configured, AI_PROVIDER picks the primary provider and the other one is the automatic fallback (ADR-0032). The workflow triggers automatically when the validation agent applies the ready-for-dev label.
For a first-time setup, complete these steps in order:
- Configure required secrets in Settings → Secrets and variables → Actions:
ANTHROPIC_API_KEYand/orGROQ_API_KEYAI_PR_TOKEN(recommended for reliable PR/label/review writes)
- (Optional) Configure provider variables:
AI_PROVIDER—anthropicorgroq: the primary provider. Unset or blank: Anthropic when onlyANTHROPIC_API_KEYis set, Groq otherwise. An unknown value uses that same default, and a provider whose key is missing yields to the one that has a key; both log aprovider_config_fallbackwarn event and never fail the job. The other provider is the fallback when its key is set: it is called with its own key, model and stage settings on any primary failure, 401/403 included, and each fallback logs anllm_fallbackevent (errorlevel, with a GitHub annotation, on 401/403 and 400/404;warnotherwise) (ADR-0032). A Groq call is only made when the prompt fits Groq's<stage>_max_input_tokens: otherwise it is skipped rather than retried into a 413 until the job timeout — an over-budget Groq primary goes to the Anthropic fallback, and an Anthropic primary does not fall back to Groq (groq: skipped (prompt ~N tokens over …)).AI_PROVIDER_VALIDATION,AI_PROVIDER_GENERATION,AI_PROVIDER_REVIEW,AI_PROVIDER_AUTOFIX,AI_PROVIDER_CONFLICTS—anthropicorgroq: the primary provider of that stage only, overAI_PROVIDER(ADR-0032 amendment 2026-10-17). Unset or blank:AI_PROVIDER. Same rules: an unknown value yields to the repo-wide provider, a provider without its key to the one that has a key, each with oneprovider_config_fallbackwarn event (meta.variablenames the variable). The fallback is the other provider, with its own stage config. Move a stage only once its eval suite passes on that provider and model (docs/evals.md).ANTHROPIC_MODEL— Anthropic model for every stage, production included (unset:anthropic_<stage>inconfig/models.yaml: validationclaude-haiku-5-5, generation, review and autofixclaude-sonnet-5-5). Leave it unset to keep the per-stage models; to measure a model, use theevals.ymlanthropic_modelinput instead.ANTHROPIC_EFFORT—low|medium|high|xhigh|maxoverridesanthropic_<stage>_effortfor every stage (sent asoutput_config.effort);offsends none. SetoffwhenANTHROPIC_MODELis Sonnet 4.5 or Haiku 4.5 (they rejecteffort, every call would 400), and avoidxhigh/maxon Opus 4.5.offis rejected (the stage fails before any call, namingANTHROPIC_EFFORTor theanthropic_<stage>_effortkey) forclaude-opus-5-5andclaude-sonnet-5-5: they cannot disable thinking, and without an effort they run at their default (medium / high). Uselowthere.GROQ_MODEL— Groq model name override for all stages (if unset, stage defaults fromconfig/models.yamlare used:openai/gpt-oss-120bfor every stage). If you point it at a non-reasoning model, also setGROQ_REASONING_EFFORT=off.GROQ_REASONING_EFFORT—low|medium|highoverrides<stage>_reasoning_effortfor every stage;offstops sendingreasoning_effort(required for non-reasoningGROQ_MODELoverrides). Unset: per-stage values fromconfig/models.yaml(ADR-0025).GROQ_API_URL— Groq endpoint URL (defaults tohttps://api.groq.com/openai/v1/chat/completionsif unset).
All four workflows pass both provider key sets, so provider selection is driven entirely by which secrets are configured in the repository — no workflow-level override is needed.
| Workflow | Required secret(s) | Optional variables | Fallback |
|---|---|---|---|
validate-issue.yml |
ANTHROPIC_API_KEY or GROQ_API_KEY |
AI_PROVIDER, AI_PROVIDER_VALIDATION, ANTHROPIC_MODEL, ANTHROPIC_EFFORT, GROQ_MODEL, GROQ_REASONING_EFFORT, GROQ_API_URL |
Fails with clear error if neither key is present |
code-generation.yml |
ANTHROPIC_API_KEY or GROQ_API_KEY |
AI_PROVIDER, AI_PROVIDER_GENERATION, ADL_LESSONS (off disables the active lessons, ADR-0051), ANTHROPIC_MODEL, ANTHROPIC_EFFORT, GROQ_MODEL, GROQ_REASONING_EFFORT, GROQ_API_URL |
Fails with clear error if neither key is present |
pr-review.yml |
ANTHROPIC_API_KEY or GROQ_API_KEY |
AI_PROVIDER, AI_PROVIDER_REVIEW, ANTHROPIC_MODEL, ANTHROPIC_EFFORT, GROQ_MODEL, GROQ_REASONING_EFFORT, GROQ_API_URL |
Fails with clear error if neither key is present |
auto-fix-pr.yml |
ANTHROPIC_API_KEY or GROQ_API_KEY |
AI_PROVIDER, AI_PROVIDER_AUTOFIX, ADL_LESSONS (off disables the active lessons, ADR-0051), ANTHROPIC_MODEL, ANTHROPIC_EFFORT, GROQ_MODEL, GROQ_REASONING_EFFORT, GROQ_API_URL |
Fails with clear error if neither key is present |
AI_PR_TOKEN is used only by code-generation.yml, pr-review.yml, and auto-fix-pr.yml for GitHub API write operations.
If the run fails with:
GitHub Actions is not permitted to create or approve pull requests.
you have two supported options:
- Enable repository setting Settings → Actions → General → Workflow permissions → Allow GitHub Actions to create and approve pull requests.
- Set
AI_PR_TOKENand keep the setting disabled (recommended for stricter org policies).
The validation workflow creates and manages these labels automatically:
ready-for-dev— applied when issue quality is sufficient; triggers PR generation.needs-refinement— applied when the issue requires clearer acceptance criteria.
The PR review workflow creates and manages these labels automatically:
review-approved— applied when the automated code review verdict is APPROVED.changes-requested— applied when the automated code review verdict is REQUEST_CHANGES.review-withheld— applied when the model approved but the tool evidence is unverified (ADR-0026); does not trigger auto-fix and is cleared by the next verdict.
All label names, colors, and descriptions are configurable in config/labels.yaml.
prompts/generation-system.md and prompts/generation-user.md enforce the following beyond the base JSON contract:
- Dependency allowlist:
- the prompt consumes an "Allowed npm dependencies" context block as the exhaustive list of external packages the model may import from — any import outside it must be a relative project import or a language/runtime built-in (e.g.
AbortController,fetchneed no import at all;fs,path,node:*modules need an import but never an npm install). scripts/lib/file_injector.mjs'sbuildFileContentsBlock()(used bygenerate_issue_change.mjs) implements the injection: it reads the target repository root'spackage.json, when present, mergingdependencies,devDependencies,peerDependencies, andoptionalDependencies, and prepends the formatted block ahead of the existing file-context block.- Absent or malformed
package.jsonresolves to no allowlist block (not an error), since this pipeline is also used against non-Node.js repositories — but a readablepackage.jsonthat declares zero dependencies still emits an explicit "no dependencies declared" block rather than staying silent, since the model needs to be told an empty allowlist is exhaustive too (this repository, with no runtime npm dependencies, is exactly that case). - The list is capped at 200 entries AND 4000 characters — whichever limit is reached first (truncated alphabetically with a note for larger manifests), since a manifest with fewer but long scoped package names could otherwise stay under the entry cap while still ballooning the prompt.
- This is a self-contained bound; the generation stage doesn't otherwise cap/truncate its input by token budget the way the auto-fix stage does (
autofix_max_input_tokens). - The list reflects the root manifest only — it doesn't supersede the separate rule allowing a package already imported elsewhere in the target file, and it may not cover nested workspace-package manifests in a monorepo.
- the prompt consumes an "Allowed npm dependencies" context block as the exhaustive list of external packages the model may import from — any import outside it must be a relative project import or a language/runtime built-in (e.g.
- Test-writing is issue-driven, not path-driven: a test file is required whenever the issue explicitly requests one, or whenever the change introduces new non-trivial logic (a function, class, hook, component, or endpoint) — regardless of the target file's path. This is broader than the pre-existing
scripts//prompts//.github/workflows/coverage-policy scoping described above, which still applies additionally to those paths. - Self-check before returning: the generation prompt requires the model to trace through the primary success scenario from the issue and verify (a) no assignment targets a known read-only/getter-only built-in property (e.g.
AbortController.prototype.signal), and (b) any value meant to persist across calls/renders is stored viauseRef/module state/a class field rather than a re-initialized local variable.
These were added after a benchmark session found a local coding model violating the dependency guardrail and producing a guaranteed-crash read-only-property bug that the paired PR-review prompt did not catch — see ADR-0019.
The prompt rules above are backed by scripts/lib/static_verifier.mjs (ADR-0019), called by generate_issue_change.mjs and auto_fix_pr.mjs after validateAiOutput() and before writeGeneratedFiles(). findGuardrailViolations() runs, per changed file:
rule |
Rejects |
|---|---|
withheld_file, unshown_file |
an existing file the model did not see in full (ADR-0029). Generation shows the files the issue names, each in full or as a File withheld … do NOT target this file marker above 8,000 chars |
mass_deletion, test_count_drop |
removing more than max(20, 30%) of a file's non-blank lines; a test file whose test(/it( count drops (ADR-0029) |
module_system |
require( added to an .mjs file; a .cjs (or CommonJS .js) file converted to ESM; require( added to an ESM .js file |
exported_signature |
an exported function removed (always), or whose arity, parameter names/shape or sync/async changes — unless the issue (generation) or the review feedback (auto-fix) names the function |
unresolved_import |
an added import that is not a Node builtin, not a relative path existing after the patch, and not a package from package.json (snapshotted before the LLM call) or already imported by the file |
The write denylist and the shrink guard (ADR-0021) still run inside validateAiOutput() / writeGeneratedFiles(). Any rejection is one GuardrailError: nothing is written. On auto-fix it follows the Auto-Fix: Patch Rejected path below. On generation, the issue gets a Code Generation: Patch Rejected comment (rules + reason per file) and needs-human, a codegen_skip metric records rules, the run exits 0 with rejected=true and code-generation.yml opens no PR. Fix the issue text (name the function whose signature must change, the file to edit) or implement by hand, then remove needs-human and re-apply ready-for-dev.
The denylist is the union of a hard-coded floor and config/protected-paths.yaml:
- Floor (
scripts/lib/protected_paths.mjs, never overridable):.git/,.github/,config/protected-paths.yaml, Node manifests, lock files and.npmrc/.yarnrc*, .NET (*.csproj,*.fsproj,*.vbproj,Directory.Packages.props,Directory.Build.props|targets,NuGet.config,global.json,packages.lock.json) and Python (requirements*.txt,pyproject.toml,poetry.lock,setup.py,setup.cfg,Pipfile,Pipfile.lock,uv.lock,pip.conf). The ADR lists and justifies each one. - Repository list:
protected_paths:inconfig/protected-paths.yaml, a- entrylist or[]. Forms:dir/(root-level directory),a/b.json(exact root path),name(file name at any depth, one*allowed: quote it,"*.gen.cs"). A repository adds entries; nothing can remove a floor entry. This repository listsscripts/,config/,prompts/,checkpoints/,metrics/,observability/,docs/,README.md,.claude/,AGENTS.md,CLAUDE.md; a test fails if one is dropped..claude/(root) andAGENTS.md/CLAUDE.md(any depth) cover the hooks and agent rules a developer's Claude Code runs after trusting the folder; the review also orders them with the code and withholds an approval when a cut diff hides them (ADR-0021 and ADR-0038 amendments, 2026-10-25). - No file means floor only. Invalid content (unknown key, bad entry, glob outside a file name, …) fails at startup of every script importing
output_writer.mjs, withconfig/protected-paths.yaml: protected_paths[<i>] …. The file is read from the default branch (ADR-0023): a PR editing it takes effect once merged. - Both system prompts list the floor and this repository's entries; when you change the list, update
prompts/generation-system.mdandprompts/auto-fix-system.md(smoke.test.mjsfails otherwise).
When generation cannot implement an issue it opens no PR. Before this, it wrote a Markdown handoff note instead (#191 for issue #190: notes/issue-190-blocked.md; #196 for #195: patches/issue-195-dependency-manifest.md), the review bot approved it, and merging it would have closed the issue. scripts/lib/generation_blocked.mjs decides, in this order (first match wins):
source |
Check | When |
|---|---|---|
denylist |
deterministic, from the issue text before the LLM call | every path the issue needs is protected (ADR-0021) or withheld from the prompt (ADR-0029). Needed paths: the paths the issue names, URLs removed; a candidate with a / counts only when it exists in the repository or ends in a file extension (.csproj as much as .mjs; prose such as retry/fallback or 401/403 is not a path), with a leading dot restored (.github/…); a bare file name only when it exists at the repository root and is not the base name of a path also named; the root CHANGELOG.md is a companion and never counts. An issue that names no path, or any writable one, is not blocked here |
model |
structured signal in the generation output | "blocked": true (the JSON boolean), with blocked_reason and planned_fix, changes: []. Text in summary is never read as a signal; any other blocked value is a malformed response and fails the run |
notes_only |
deterministic, on the model output | the issue is labelled bug or feature and every changed file is a new .md file outside docs/ |
Precedence: the repository facts win, so a model answering blocked: false (or writing a note) cannot override the deterministic check, and an injected issue body cannot argue its way past it. The model is only trusted to add a blocker the code cannot see (an issue declaring itself blocked by an unmerged PR). The LLM is still called when the deterministic check fires, because the comment needs the model's planned fix.
On blocked: nothing is written, the issue gets a Code Generation: Blocked comment (source, reason, and the planned fix: planned_fix, else the content of the note the model wrote, else its summary, capped at 4,000 chars), ready-for-dev is replaced by needs-human, a codegen_skip metric records reason: "blocked" and source, code_gen.skipped is logged with reason: "blocked" and duration_ms, and the run exits 0 with blocked=true. code-generation.yml skips Create pull request, so no branch is pushed. The workflow passes the issue's labels as ISSUE_LABELS (JSON array). If a GitHub call of the escalation fails, the run ends with code_gen.error (reason: "blocked") and exits 1.
To resume: merge the dependency or implement the protected part by hand, then remove needs-human and re-apply ready-for-dev.
Step outputs carry untrusted text: the validation comment and the generation / auto-fix summary (model output), the issue body and title. A heredoc with a fixed delimiter (EOF, __DELIM__) let a value line equal to it close the block and forge the following lines as outputs (valid=true dispatching code generation, an injected fixed_paths / attempt_number), or fail the step on a stray line (2026-10-19 audit F04, F28).
- Scripts write every output through
scripts/lib/github_output.mjs(appendOutputs): a value without a line break isname=value; any other value is a heredoc whose delimiter isghadelim_<uuid>, drawn per write and redrawn while the value contains it;\r\n/ lone\rbecome\n; an invalid name throws before anything is written. Multi-line values are written first and single-line control outputs (valid,score,attempt_number,rejected,blocked) last, so the runner's last-value-wins can only favour them. A single path ingenerated_paths/fixed_pathsis written asname=value(same value for consumers). adl-code-generation.yml→ Resolve issue data drawsDELIM="ghadelim_$(openssl rand -hex 16)"per run for the issuebodyandtitleheredocs, then writesnumberandlabels. The body is read byghat runtime, never interpolated into the script. An issue body containing__DELIM__no longer fails the step.workflow_gates.test.mjsfails on any workflow writing a fixed heredoc delimiter withecho/printf.
prompts/pr-review-system.md (with prompts/pr-review-repo-rules.md, see below) and prompts/auto-fix-system.md enforce the following beyond the base rubric:
-
Test-coverage gate is not limited to automation paths: whenever the diff's classification context reports
tests_expected: true(any feature/bugfix/refactor change, regardless of path — not just.github/workflows/,scripts/,prompts/), the reviewer checks thehas_test_file_changesfield (computed from the full PR diff, independent of the truncation below — and only counting test files actually added/modified, not ones only deleted) rather than only what's visible in the diff text, reporting at least MEDIUM severity when it's false. This closes a gap where a diff could self-classify "Tests expected: yes" and still beAPPROVEDwith no tests present.has_test_file_changes: trueis a coarse signal, not proof of relevant coverage — when the test file's content is visible in the diff, the reviewer still judges whether it plausibly covers the new/changed logic rather than treating the boolean as conclusive, except whendiff_truncatedmakes that judgment impossible. -
Coverage gate (c) reads the c8 gates, not the diff (ADR-0033):
pr-review-repo-rules.mdstates the rule and names no module: the automation gates context (buildAutomationGateContext) adds agated_files_changedline with the changed files that are inconfig/coverage-gates.json, the one list of the c8 gates (80% lines, branches, functions, statements), whichnode scripts/coverage_gates.mjsruns as the single coverage step oftest.yml. A changed file named on that line satisfies the coverage gate even though neithertest.ymlnor the config is in the diff. The reviewer reports HIGH only when the diff removes an entry from the config or lowers its threshold, and MEDIUM when the diff adds ascripts/lib/*.mjsmodule without an entry. Other automation files are judged on their tests (gate (a)). Before this, the prompt named onlycheckpoint.mjsand reported a false HIGH "missing coverage policy" on any other gated module (PR #184).workflow_gates.test.mjsfails when the prompt's list and the config drift apart (node scripts/coverage_gates.mjs --syncrewrites the prompt's list). -
Named defect checklist: the reviewer explicitly checks every new/changed file for three specific patterns rather than relying on open-ended "look for bugs" judgment: read-only/getter-only property assignment (e.g.
AbortController.prototype.signal), unauthorized dependency imports, and non-persistent "ref" patterns (state meant to survive across calls/renders stored in a re-initialized local variable instead ofuseRef/module state). -
Verdict parsing:
pr_review.mjsreads the verdict after the wordVerdictin any of these forms:### 🚀 Verdicton its own line,Verdict: APPROVED, a bold verdict (**APPROVED**), a bold heading (**🚀 Verdict**) or a bold heading with colon (**Verdict:** APPROVED). Anything else — the template placeholder, prose, or no verdict — is unreadable: the review isWITHHELD(withheld_for: unreadable_verdict, labelreview-withheld, comment note, no auto-fix; ADR-0064) instead ofREQUEST_CHANGES, unless a check failed or a guardrail finding was raised, which keep theirREQUEST_CHANGES. (Therevieweval suite still counts it as an errored run.) Unit-test coverage: 100% of these forms, both verdicts where they apply (AGENTS.md, Test Coverage Policy). -
Truncated-diff disclosure:
pr_review.mjsbounds the diff shown to the model by the provider's limit: on Groq thereview_max_input_tokensbudget, on Anthropic (no input budget)anthropic_review_max_diff_chars, 200,000 characters (ADR-0038 amendment 2026-10-19; it was 12,000 before).- Before that bound, unchanged context lines inside hunks are cut to 200 characters with a visible
…[+N chars]marker, so long context (e.g. a 1–2k-char CHANGELOG entry) does not use up the budget.+/-lines and file/hunk headers are never cut, and this context cut alone never setsdiff_truncated. - When that bound actually cuts content, a
diff_truncated: truefield is added to the classification context and the reviewer is required to disclose in its output that only a partial diff was inspected, rather than implying full coverage in an unqualifiedAPPROVED. - When the cut reaches code (a file that is neither a test nor documentation), an
APPROVEDis not submitted as an approval: the verdict isWITHHELD, withreview-withheldand a note naming the files (ADR-0038). A cut that removes only tests or documentation does not change the verdict. - Both cuts (the Anthropic char cap and the Groq token budget) end on a whole line, preferably a hunk boundary, never inside a line or on a bare header, and are followed by a
… [diff cut: N more lines not shown, in <files>]marker ((partly shown)for a file cut mid-way, which counts as unreviewed). - A model
REQUEST_CHANGESwhose findings all cite only cut code files is posted asWITHHELDtoo ("Changes not requested", no auto-fix); a failing check, or any finding on a shown file, keeps itREQUEST_CHANGES(ADR-0038 amendment, #219).
-
Findings the fixer can act on (ADR-0034): every finding must cite a file and lines in the diff shown, and its fix must not require editing an existing file whose hunks the reviewer did not see (the auto-fixer only edits files it is shown, ADR-0029). With
diff_truncated: true, a concern about unseen content is listed once as "Not verified (diff truncated): …", without severity, and never makes the verdict REQUEST_CHANGES. -
A note is not a fix: a PR that changes no code when the issue asks for a code change (bug fix, feature) is a HIGH "not implemented" finding and REQUEST_CHANGES, however accurate the added note or plan. PRs #191 and #196 (a Markdown handoff note for a bug) were approved: as documentation, no other rule applied. Measured by
incident-note-instead-of-fixin therevieweval (docs/evals.md→ Incident cases). -
Untested claim (#199): a sentence of the PR description asserting changed runtime behavior that the diff implements, with a changed test file visible but no visible test exercising it, is one Low "Untested claim: " finding citing the implementing lines: advisory only, never above Low, never by itself REQUEST_CHANGES. No such finding when a visible test exercises it, the body is empty or claims nothing, the change is docs/config-only (
tests_expected: false), or the tests are past the cut (the "Not verified" note instead). A missing test file (has_test_file_changes: false) is still at least MEDIUM. Measured by theuntested-claimcases of therevieweval (docs/evals.md→ Untested-claim cases). -
Adversarial protocol (ADR-0049): the review answers with a
### 🔬 Adversarial Analysissection, one line per axis (robustness, side effects, type integrity, issue fit), each a concrete risk withfile:lineor "none" and what was checked; every finding ends with anAxis:tag. The lines are observations: they never decide REQUEST_CHANGES.pr_review.mjsparses the section (scripts/lib/adversarial_review.mjs) and turns an APPROVE that names fewer than 2 risks intoWITHHELD(review-withheld, no auto-fix, a note says which axes were empty); the journal recordswithheld_for: protocol,adversarial_pointsand the axis ids. Therevieweval applies it through the same function as production (resolveReviewDecision,scripts/lib/review_decision.mjs): a recorded or live approval without two named risks scoresWITHHELD, withwithheld_for: protocolin the case output, so a replay of responses recorded before the protocol can showAPPROVE→WITHHELDflips (docs/evals.md). -
Verified axes (ADR-0065, shadow):
verifyAxes(adversarial_review.mjs, a string search, no LLM) counts the axis lines that are a real statement and name something that is in the diff: a path, a file name, acodespan or an identifier in camelCase or snake_case found in the diff text as a whole name, not as a piece of a longer one (a.jsis not indata.json,x.a.js,my-a.jsora.js.map: for a file name or path a.or-goes on with the name, and a name written in the review is looked up whole:src/a.js.mapis not looked up asa.js; plain words name nothing). A risk and anoneare treated alike. The count is journaled asverified_axesonreview.verdictand published by thereviewsuite asadversarial_verified_axes; it gates nothing: the gate is still the count of named risks (above) until ADR-0065 is accepted. The prompt asks that every axis line cite a file, symbol or line of the diff. -
Circuit breaker (ADR-0050): the review marker carries
streakandsig, the count of consecutive reviews that reported the same failure (same failing checks and cited files). At 3 the loop is ESCALATED:changes-requestedis not re-pulsed (no auto-fix),needs-humanis set, the comment says so,review.escalatedis journaled;auto_fix_pr.mjsalso skips (circuit_open) before any LLM call unless a trusted human reruns it. A different failure, an APPROVE or a WITHHELD resets the count. -
Active lessons (ADR-0051): the generation and auto-fix system prompts get up to 3 constraints (700 characters) from a fixed catalog (
scripts/lib/lessons.mjs) when the same guardrail rule or review axis recurs at least twice in the last 30 signals ofmetrics/runs.jsonl(30 days). The block is read with one read-onlyGETof themetricsbranch, journaled (lessons.loaded), never fails a run, andADL_LESSONS=offin a workflow's environment turns it off. The review does not get lessons. -
Verdict follows the findings:
pr-review-system.mdallows REQUEST_CHANGES only with a High or Medium finding or a FAIL check; Low notes alone ("verify", "Not verified", untested claim, stale reference) are APPROVED. A High or Medium finding names the concrete input or state that makes it fail, one the visible code, its types or the issue show can occur: an input outside the function's evident contract, a caller that neither the diff nor the Impact analysis shows, or a behavior the issue does not ask for is not a defect. Measured by theapprovecases of therevieweval (docs/evals.md). -
Stated consolidations are not deletions: the large-deletion rule (HIGH) and the removes-more-than-it-adds rule do not fire when the title or description accounts for the removal (remove, trim, merge, deduplicate, replace, move) and the added lines or another visible hunk still carry what was removed. An unaccounted deletion (
bug-deleted-tests,bug-docs-mass-deletion) stays a regression. Withdiff_truncated: true, a test, doc or workflow change the description says exists but the visible diff lacks is unverified, not a finding. -
Code-first diff (ADR-0034):
orderDiffForReview(scripts/lib/review_prompt.mjs) orders the diff shown to the reviewer code → tests → docs (git order within each tier; prompts anddocs/code-generation.mdcount as code), so the token-budget cut drops docs first. The classification and automation-gate contexts still read the raw diff. -
APPROVE resets the fix loop (ADR-0034): on APPROVE,
pr_review.mjsremovesneeds-humanand everyauto-fix-attempt-Nlabel, so a later REQUEST_CHANGES starts at attempt 1. A withheld approval or REQUEST_CHANGES leaves them. -
Auto-fix mirrors the same guardrails as generation:
auto-fix-system.mdrequires including a missing test file (unless its path is protected) when review feedback calls one out, and a self-check for read-only property assignment and non-persistent refs before returning a fix — so a fix pass doesn't reintroduce what it's meant to repair. -
Auto-fix write guard (ADR-0029):
- the model returns whole files, so it only gets to edit files it saw in full.
auto_fix_pr.mjssends each changed file (up to 5) in full, or replaces it with aFile withheld … do NOT target this filemarker when it exceeds 8,000 bytes or the remaining file budget. Nothing is cut silently. - The PR branch is read as data (ADR-0023) through
scripts/lib/pr_tree_reader.mjs: every path component islstat'ed, the open usesO_NOFOLLOW | O_NONBLOCKre-checked withfstat, and the size is checked before any byte is read. - A changed file that is not a regular file (symlink, directory, FIFO, device) is never followed nor read: the prompt gets
File withheld (not a regular file in the PR tree): <path> — do NOT target this file, anautofix.file_withheldwarn event is logged, and a change targeting it (or any other non-regular path) is rejected with the rulenon_regular_target. - An existing target over 8,000 bytes is not read whole: it was never shown, so the write guard rejects it as
withheld_file/unshown_file. - Before writing,
scripts/lib/autofix_guard.mjsrejects a change to an existing file that was withheld or not shown, that removes more thanmax(20, 30%)of its non-blank lines, or that lowers a test file'stest(/it(count; the ADR-0019 static rules (above) run next. - A violation goes through the same escalation as the write denylist and the shrink guard (
autofix.skipped,reason: "guardrail_rejected"): anAuto-Fix: Patch Rejectedcomment lists the reason per file, no file is written,fixed_pathsis empty (nothing is pushed), the attempt label andneeds-humanare applied. - The model may also decline with
"changes": []andblocked_reason: that follows theno_changespath (ADR-0028), whose comment showsblocked_reason.
- the model returns whole files, so it only gets to edit files it saw in full.
Motivated by a benchmark session where a local coding model's generated diff — containing an unauthorized dependency import and a guaranteed-crash read-only-property assignment — was reviewed by this same prompt and returned APPROVED with no findings. See ADR-0019 for the writeup and the static checks that now back these rules.
The "Unauthorized dependency" check above is backed by scripts/lib/dependency_manifest.mjs: pr_review.mjs reads the PR branch's local package.json (merging dependencies, devDependencies, peerDependencies, optionalDependencies) and appends a "Declared npm dependencies" context block to the review prompt, so the reviewer can actually verify an import against the manifest instead of only what's visible in the diff hunks.
Before building the prompt, pr_review.mjs runs two deterministic text searches over the PR tree (git ls-files in the working directory, read as data per ADR-0023 through scripts/lib/pr_tree_reader.mjs; regular files only, symlinks are never followed, and a file over 1 MiB is never read: it is left out with a review.file_skipped warn event):
- Stale references (
scripts/lib/stale_refs.mjs): text the diff removes that is still quoted indocs/**/*.md,.claude/skills/**/*.mdorAGENTS.md, and that the PR tree no longer holds elsewhere.- Blocks are consecutive
-lines of a hunk, at least 60 chars once backticks,*,_, whitespace and case are normalized. Text still in the head version of the block's own source file (a reworded line) or in anyprompts/*.mdis live, not stale. A doc matches on a contiguous quote of ≥ 100 chars of the remaining text, or of a shorter block whole (±10 chars). CHANGELOG.md, Superseded or Deprecated ADRs, the file the text was removed from, and files the diff re-adds it to are excluded.- Hits are informational: a
## Stale referencesblock in the prompt and a### 🔗 Stale Referencessection in the comment (at most 5 each). They never change the verdict.pr-review-system.mdcaps a hit at one Low "Verify stale reference" issue: never Medium/High, never REQUEST_CHANGES.
- Blocks are consecutive
- Related ADRs (
scripts/lib/related_adrs.mjs): ADRs citing a changed path (exact path, or its basename when unique in the repo;CHANGELOG.mdignored).- Superseded or Deprecated ADRs are replaced by the ADR that supersedes them. ADRs the PR itself edits are skipped.
- Ranking: changed non-test paths cited, then all changed paths cited, then exact-path citations, then most recent.
- The prompt gets a
## Related ADRsblock with each ADR's id, title, status and the first 400 chars of its Decision. - Budget: the block is the first thing dropped. Without a token budget (Anthropic) up to 3 ADRs. On Groq (
review_max_input_tokens) at most 1, and only when the diff and the PR body still fit uncut. Otherwise the block is left out before anything else is shrunk, and the run logsRelated ADRs left out of the review prompt.
- A listing or read error in either search logs a warn event (
review.stale_refs_error,review.related_adrs_error) and the review runs without that block.
The reviewer otherwise sees only the diff, so a changed export whose callers were not updated is invisible. scripts/lib/impact_context.mjs (deterministic, no LLM) finds the exports whose declaration lines the diff adds, removes or edits (JS/TS, not tests, not new files; a hunk counts only if it edits real code, not a comment or a hunk header's context) and searches the PR tree for the code outside the diff that references them, plus the tests of the changed modules (changed by the diff or not).
- Read path: the same listing and reader as the stale-reference search (
git ls-files+ the safe reader of ADR-0023): symlinks and files over 1 MiB are never read. Bounds: 12 symbols, 4 references per symbol (call sites before import lines, files importing the module before files merely mentioning the name), 6 tests, 3000 files, 25 M characters scanned; a name shared by 20+ files is marked too generic and listed partially. - Prompt: an
## Impact analysisblock, fenced and labelled data (snippets are stripped of control characters and clipped, the fence cannot be closed by a snippet). The rule ofpr-review-system.mdallows one exception to "review the diff only": a finding may cite a caller from this block as evidence, but it still cites the changed export in the diff, and its fix is on the diff side (the auto-fixer edits only what it is shown). - Budget: 8,000 characters without a token budget, 1,200 on Groq. Dropped after the related ADRs and before the diff or the PR body are shrunk; if it does not fit alone it is left out.
- Events:
review.impact_context(counts only:symbols,with_refs,refs,tests,included,scanned,truncated); a search error logsreview.impact_errorand the review runs without the block. Never changes the verdict.
The "review only the diff" rule keeps findings grounded, but it hides a defect class: a caller, a test or a document the diff does not touch. The prompt now lets a finding concern such code when it quotes it: Evidence: path:line "exact text", copied from the Impact analysis block or, since RV-04b, the Documents block (scripts/lib/finding_evidence.mjs, deterministic, no LLM).
- Outside = the finding cites files and none is in the diff (renames count both sides; a finding with no file, or a diff with no
diff --githeader, is judged as before). An outside finding is verified when every quote is written in the PR-tree file within 5 lines of the line given (whitespace and quote marks ignored, quotes of 12+ characters, read whole even if they hold quote marks, and at least one in a file the finding cites; the PR tree is read through the safe reader, so a symlink, a large file or a path outside the tree proves nothing). - Unproven = a High or Medium finding that is neither verified nor, since RV-05, supported by the diff (see Finding statuses below). It stays in the comment, in an
### 🔎 Evidence Checksection (✅ verified/✔️ supported/⚠️ unverified/❌ refuted). - Decision: a REQUEST_CHANGES whose blocking findings are all unproven becomes WITHHELD (
withheld_for: unproven_findings, labelreview-withheld, no auto-fix: it cannot edit what it is not shown), the same way as a review whose findings cite only cut code (ADR-0038). One proven or in-the-diff blocking finding, a failing check or a guardrail finding keeps its REQUEST_CHANGES. An approval is never changed. - Limits: the quote proves the text exists, not that the finding is right; a PR can plant text for the model to quote, so a verified outside finding is a lead a human still merges on. Events:
review.finding_evidence(counts only),review.finding_evidence_error.
The Impact analysis lists callers and tests, so a document that the change makes false (PR #279: a doc still describing the old behavior of a file the PR edited) had nothing a finding could quote. scripts/lib/doc_mentions.mjs (deterministic, no LLM) takes what the diff changes that a document could name, and searches the repository's documents for it.
- Terms: the exports the diff changes or removes (the same detection as the Impact analysis, 4+ characters) and the files it modifies, renames or deletes, by their old path (not new files, tests, documents, lock files,
package.json,index.*). Removed, deleted and renamed first; at most 12. A file is matched by its path, or by its base name when that is 8+ characters. - Documents searched:
docs/**/*.md,.claude/skills/**/*.md,AGENTS.mdand the rootREADME.md, outside the diff, neverCHANGELOG.md, never a Superseded or Deprecated ADR (they quote the old rule on purpose); ADRs after the other documents. Same listing and safe reader as the Impact analysis (ADR-0023): no symlink, no file over 1 MiB. Bounds: 500 documents, 25 M characters, 4 lines per term (3 when 20+ documents mention it, reported as too generic). - Prompt: a
## Documents that mention what this diff changesblock after the Impact analysis, fenced and labelled data. It is the same optional block as the Impact analysis (kept or dropped with it, after the related ADRs): 4,000 characters without a token budget, 1,200 on Groq. A finding about one of these documents quotes it withEvidence: path:line "exact text"like any outside finding, and is checked the same way: in production the document is read from the PR tree. - Events:
review.doc_mentions(counts only:terms,refs,docs,included,scanned,truncated); a listing error logsreview.doc_mentions_errorand the review runs without the block. Never changes the verdict by itself.
ADR-0061 made a finding about outside code prove itself; every other finding still requested changes and went to the auto-fixer whatever lines it named. substantiateFindings (finding_evidence.mjs, deterministic, no LLM) now gives every finding a status:
| Status | Meaning |
|---|---|
| verified | every quote of its Evidence: field is written in the PR tree near the line given (ADR-0061) |
| supported | it cites a file of the diff and its lines, if given, fall in a hunk of that file (old or new side, ±5 lines); a file with no hunk counts as located; with no diff --git header nothing is judged |
| refuted | not located in the diff, and a quote it gives is not in a file that was read |
| unverified | not located in the diff: outside it without a checked quote, no file, or lines the diff does not show |
A High or Medium finding that is neither verified nor supported is unproven. A REQUEST_CHANGES whose blocking findings are all unproven is WITHHELD (unproven_findings, as in RV-04a); one substantiated finding, a failing check or a guardrail finding keeps it. When changes are requested, the unproven findings move from ### ⚠️ Issues Found to a ### 🔎 Unsubstantiated Findings section (a human reads it) that auto_fix_pr.mjs strips from the review comment it uses as feedback. The Evidence Check section lists every blocking finding with its status. Escalation is unchanged: no proposed change or a rejected patch → needs-human, three attempts, the circuit breaker.
The AGENTS.md Hard Guardrails that the pipeline enforces in code on generated patches (ADR-0019, ADR-0029) are also checked on the PR under review (scripts/lib/guardrail_review.mjs, deterministic, no LLM; this repository only, a target repository of ADR-0045 does not have them). A finding blocks approval: an APPROVE or a WITHHELD becomes REQUEST_CHANGES (review.verdict guardrail_override), the comment starts with a ### 🛡️ Guardrail Findings section (where the auto-fixer reads first) and the circuit breaker signature includes guardrail:<rule>:<path>.
| Rule | Severity | What is flagged | Waived when |
|---|---|---|---|
test_count_drop |
High | a test file that ends with fewer test cases (counted on the whole base and head files after masking comments and string/template contents, so a test( inside a fixture or a comment is not a case; a case is lost only when a raw reading of the file agrees with the masked one (a regex literal that fools the lexer cannot make a kept case look removed), await / void / chained / reformatted calls are the same case, and a file that cannot be read, or lies past the 40-file cap, reports nothing but still receives a moved case (its added case lines count as replacements); a case moved to another file or renamed in place is not a loss; each added case pays for at most one removed case across the whole diff; deleted files are out of scope) |
the PR title or body names the removed case's title (8+ characters) |
exported_signature |
Medium | an exported function whose arity, parameters or sync/async changes, or that is removed (a change is kept only when both parameter lists were read without doubt: a regex literal, a division or a < / > comparison left in the masked list, which the parameter parser can take for a list end or a generic, withholds it; a quoted key of a destructured parameter is compared by its text, not by the position of its string in the file) |
the PR title or body names the function (whole word) |
module_system |
High | require() added to an .mjs, ESM added to a .cjs, a CommonJS file converted (confirmed by a raw count of require( / import-export lines) |
— |
unresolved_import |
High | an added import that does not exist in the PR tree (checked by lstat only: a file over the 1 MiB read limit or a symlink exists; a directory is not a file, so import './lib' needs lib.mjs or lib/index.mjs), a Node builtin, or a package of the nearest package.json (no manifest: bare packages are not judged; a .js specifier is satisfied by its .ts source) |
— |
A function whose module still exports it (a re-export, export * from, an export list on several lines) is not removed. Bounds: 40 JavaScript files per review, 12 findings listed (the rest counted), a 60 s deadline (the checks run in a child process, scripts/lib/guardrail_runner.mjs, because they parse PR text with regular expressions that a try/catch cannot interrupt; an overrun logs review.guardrail_error and the review goes on without them), base content read at the merge base with git show, head content through the safe reader (ADR-0023; symlinks and files over 1 MiB are not read). A failure skips the check (review.guardrail_error, warn); review.guardrail_findings carries counts and rule ids only. Not covered: dependency additions in package.json, the 30 % rewrite rule, deleted test files.
When a PR changes a prompts/*.md file, pr_review.mjs reports what the change costs in input budget (scripts/lib/prompt_budget.mjs). On Groq, every token a stage prompt gains is taken from the stage's variable input (the reviewed diff, the issue, the injected files).
- Delta:
estimateTokens(chars/4,{{placeholders}}excluded). The base version comes from the merge base, read withgit show <base>:<path>(the review job checks out withfetch-depth: 0); the head version comes from the PR tree. A new prompt counts in full; a deleted prompt counts as 0 on the head side; a renamed prompt is read from the base at its old path. - Budget: the stage(s) that load the prompt, and
<stage>_max_input_tokensfrom the PR'sconfig/models.yaml. When that file is missing, is not a regular file (a symlink is never followed, ADR-0023) or cannot be read, the trustedconfig/models.yamlof the pipeline is used instead; the non-regular / unreadable case logs areview.models_config_ignoredwarn event.- The system and user prompts of validation, generation and review count inside that budget, as does
auto-fix-user.md. auto-fix-system.mdsits on top ofautofix_max_input_tokens, so it is measured against the TPM window:floor((8000 − autofix_max_tokens) / 1.10) − autofix_max_input_tokens.headroomTokensis the budget minus every prompt of the stage under it. It ignores the contexts injected at runtime, so it is an upper bound.
- The system and user prompts of validation, generation and review count inside that budget, as does
- Output: a
## Prompt budget impactblock in the prompt and a### 📏 Prompt Budget Impacttable in the comment. LOW HEADROOM (⚠️ ) marks headroom under 10% of the budget.- A stage without a configured budget shows
—. A prompt no stage loads is listed with no stage. - Informational only: it never changes the verdict. A read error logs
review.prompt_budget_error(warn) and the review runs without the block.
- A stage without a configured budget shows
pr-review.yml (body adl-pr-review.yml) runs a pipeline job (which pipeline commit this run uses), then an evidence job before review. It executes the checks declared in config/review-evidence.yaml on the PR head commit and passes the results to the reviewer. Per ADR-0023 and ADR-0045, the runner comes from the pinned pipeline ($RUNNER_TEMP/pipeline) and the repository's own config from its default branch ($RUNNER_TEMP/target-config); the PR tree is only the working directory the checks run in.
-
Isolation: the
evidencejob haspermissions: contents: read, checks out withpersist-credentials: false, and receives no secrets.run_review_evidence.mjsalso strips credential-like env vars (name segmentTOKEN,SECRET,KEY,PASSWORD,PASSWD,CREDENTIAL(S)) and env-injected git config (GIT_CONFIG_*) from each check's environment, as well asADL_CONFIG_DIR/ADL_CONFIG_PATH: the check list is read from the trusted default-branch config before any check starts, and the checks themselves (npm test) run the PR tree against the PR tree's ownconfig/, liketest.yml. A PR that changes a pinned config file (for exampleconfig/protected-paths.yaml) therefore sees its own change in the evidence. -
Config (opt-in per repo):
checks.<name>.command(run viabash -c; one pair of surrounding quotes is stripped, inline comments are rejected) and optionalchecks.<name>.timeout_seconds(default 300). Defaults for this repo:npm test,npm run lint. Without the file, the job exits 0 and the review reports the evidence as missing. -
No install step: the job only checks out and sets up Node. A target repo with dependencies must install them in the command itself, e.g.
command: npm ci && npm test— otherwise every check fails on missing modules and forcesREQUEST_CHANGES. -
Results:
pass/fail(non-zero exit) /timeout/error(spawn failure, or exit 126/127: command not executable / not found), with exit code, duration and the last 2 000 output characters. Written to$RUNNER_TEMP/evidence/review-evidence.json(outside the checkout), uploaded as artifactreview-evidence-<run_id>. -
Review behavior:
- A
## Tool evidenceblock is appended to the review prompt. - Any
failforcesREQUEST_CHANGESin code, whatever the LLM verdict. - A
### 🧪 Tool Evidencesection (status table + failing output tails) is added to the review comment, which auto-fix reads as feedback — right after the heading when a check failed (auto-fix truncates from the end), appended otherwise.
- A
-
Unverified evidence withholds approval (ADR-0026): the review still runs, but an LLM
APPROVEDis not submitted as an approval when the evidence is:- missing — no file, malformed file, or crashed evidence job (
reviewruns withif: !cancelled()); - stale — the evidence
head_shadiffers from the PR head (a push raced the run); - unverified — a check ended in
timeoutorerror.
The review is posted as
COMMENT,review-withheldis applied instead ofreview-approvedorchanges-requested(auto-fix is not triggered; the next verdict clears it), and the comment says Approval withheld with the reason. Push again or re-runpr-reviewonce the cause is fixed. Withoutconfig/review-evidence.yaml(evidence not opted in), missing evidence does not withhold approval. Unit-test coverage: 100% of verdicts (decideVerdict()branches and thepr_review.mjsverdict → review event and labels mapping; AGENTS.md, Test Coverage Policy). - missing — no file, malformed file, or crashed evidence job (
-
Self-modification: changes to the runner or
config/review-evidence.yamlapply only once merged (default-branch execution). If the PR touches a PR-tree path that still controls the evidence (package.json,.github/workflows/pr-review.yml,.github/workflows/adl-pr-review.yml), passing results are flagged as not authoritative in both the prompt and the comment.
To add a check (e.g. ADR-0019's import allowlist), add an entry to config/review-evidence.yaml — no workflow change is needed.
- Ensure secrets above are configured.
- Create a new GitHub issue using the feature or bug template, with a clear title and body.
- Open Actions and confirm run
Issue Validation Agentstarts. - Once validation passes, confirm
Code Generationstarts automatically from theready-for-devlabel event. - Verify logs for:
- prompt construction
- LLM API call success
- branch creation (
ai/issue-<number>) - PR creation
- Confirm PR details:
- title references the issue number/title
- body includes generated summary and
Closes #<number> - changed files are limited to the generated AI target paths (maximum 6 files)
The project now runs as a continuous loop rather than a one-shot generation:
- Issue validation (
validate-issue.yml) reviews issue quality and appliesready-for-devorneeds-refinement. - Code generation (
code-generation.yml) starts only whenready-for-devis applied and opens/updates a PR for that issue. - PR review (
pr-review.yml) runs on branch pushes, posts structured feedback, submits review status, and appliesreview-approvedorchanges-requested. Each head SHA is reviewed by the LLM once: a run whose head the review comment's marker already names, or whose commit is no longer the PR head, exits without an LLM call (ADR-0028). - Auto-fix (
auto-fix-pr.yml) runs whenchanges-requestedis applied, generates a targeted fix commit, and pushes it. - The push from auto-fix re-triggers PR review, creating the iterative review loop.
- The loop ends when either:
- PR review returns
review-approved, or - auto-fix reaches 3 attempts and requests manual intervention.
- PR review returns
sequenceDiagram
actor User
participant Issue
participant validate-issue.yml
participant code-generation.yml
participant pr-review.yml
participant auto-fix-pr.yml
participant PR
User->>Issue: open / edit
Issue->>validate-issue.yml: issues event
validate-issue.yml->>Issue: ready-for-dev or needs-refinement
Issue->>code-generation.yml: labeled ready-for-dev
code-generation.yml->>PR: open PR (branch ai/issue-N)
PR->>pr-review.yml: push / opened
Note over pr-review.yml: evidence job (no secrets) runs declared checks on the PR head
pr-review.yml->>PR: review comment + Tool Evidence, APPROVE or REQUEST_CHANGES (forced on any failing check)
alt changes-requested and attempt ≤ 3
PR->>auto-fix-pr.yml: labeled changes-requested
auto-fix-pr.yml->>PR: push fix(ai): auto-fix attempt N
else review-approved
PR->>User: human merge gate
end
Groundwork for running the pipeline on a repository that does not hold its scripts. A script deals with three roots, all derived in scripts/lib/roots.mjs and nowhere else (a test forbids import.meta.url root arithmetic elsewhere):
| Root | Holds | Resolved from |
|---|---|---|
| Pipeline | scripts/, prompts/, default config/ |
the location of the script itself |
| Target | the repository under work: PR tree, observability/traces, checkpoints, file context, writes |
the working directory (ADL_TARGET_ROOT overrides it in tests) |
| Target config | the target's own models.yaml, labels.yaml, protected-paths.yaml, review-evidence.yaml |
ADL_CONFIG_DIR (absolute, a trusted default-branch checkout; a relative value is a startup error, it would resolve inside the PR tree); ADL_CONFIG_PATH is its repo-relative name (forward slashes, no . or .. segment) |
Neither ADL_* variable set (this repository, every workflow today): config/ of the pipeline is both the defaults and the repository configuration, exactly as before. ADL_CONFIG_DIR set (a target repository):
models.yaml,labels.yaml: the target's keys are laid over the pipeline defaults, leaf by leaf; a key the defaults do not know fails at startup naming the file and the key; a missing file means defaults.protected-paths.yaml: read from the target's directory only. The pipeline repository's own list is not inherited; a missing file means the hard-coded floor only. The target's config directory (ADL_CONFIG_PATH, e.g..autonomous-dev-loop/) is protected as a whole whatever the list says, and an empty list removes no floor entry.review-evidence.yaml: read from the target's directory only; absent means evidence is not required.- Prompts always come from the pipeline root.
- The PR head's copy of
models.yaml(prompt budget impact, ADR-0037) is read at<ADL_CONFIG_PATH>/models.yamland laid over the defaults; a copy the layering rejects is ignored with onereview.models_config_ignoredwarning.
node scripts/print_resolved_config.mjs prints the resolved roots, where each file comes from, the effective protected paths and whether review evidence is required (JSON on stdout, nothing written). print_resolved_config.mjs and protectedPathsFor(env) answer for the environment they are given. roots.mjs and layered_config.mjs are under the c8 ≥ 80% gate. The reusable workflows that set these variables follow in separate changes (ADR-0045).
prompts/pr-review-system.md is a target-neutral core. The rules written for this repository (automation gates (a)/(b)/(c), the stale-reference gate (b) note, the c8 file list, and the Node/React named defect checks: read-only property assignment, unauthorized package.json dependency, non-persistent useRef) live in prompts/pr-review-repo-rules.md, four blocks separated by a <!-- next --> line, and replace the {{repository_rules_N}} placeholders of the core. {{repository_kind}} reads "a Node.js automation repository" for this repository and "a software repository" for any other.
isSelfTarget() (scripts/lib/roots.mjs) decides: ADL_PIPELINE_REPOSITORY unset (every workflow today, a local run) or equal to GITHUB_REPOSITORY (case-insensitive) means this repository. Another repository gets, instead:
- the neutral core only, plus its own
review-guidelines.mdfrom the trusted config directory (ADL_CONFIG_DIR, at most 6000 characters, appended under a "Repository review guidelines" heading; it adds to the rules and never changes the verdict or tool-evidence rules); - no "Automation gates context" block (
buildReviewPrompt({ automationGates: false })); - no prompt budget impact (it describes this repository's
prompts/); - no self-modification skip in auto-fix (
scripts/auto_fix_pr.mjsis only a path there).
For this repository the assembled system prompt is the one it had before the split, verbatim (checked byte for byte when the file was split); the eval suites load it through the same function.
A PR that conflicts with its base is invisible to the loop: pull_request events run on the merge ref, which does not exist while the PR is dirty. conflicts-fix.yml (calling adl-conflicts-fix.yml) closes that gap.
Triggers. A push to the default branch (a base move is how most conflicts are born), a schedule (23 */6 * * *, the safety net) and workflow_dispatch (pr_number: one PR, as a manual run that bypasses the attempt cap and the circuit breaker). A run record belongs to one work item and is keyed by the run id, so a run handles at most one PR: a sweep (push, schedule, or a dispatch without pr_number) only detects, labels and dispatches the caller once per fixable PR (pr_number, sweep: true, which keeps the cap and the breaker; dispatch_conflicts.mjs, needs actions: write), and each of those runs fixes and records its own PR. A PR whose two attempts are spent is not dispatched by a sweep. Every open PR is in scope, from ai/issue-* or a person; a PR from a fork is fixed only when it allows maintainer edits, otherwise it is labelled and told once per head.
Jobs. detect (scripts/detect_conflicts.mjs) reads each open PR's mergeable_state (a null is re-read a few times, then left to the next run), keeps the merge-conflict label in step with it, removes it and the conflict-fix-attempt-N labels from a PR that is clean again, and writes the fixable PRs as a matrix. fix (scripts/conflicts_fix.mjs) runs only in a single-PR run, on the per-PR group pr-pipeline-<head ref> shared with pr-review and auto-fix (ADR-0020), in a checkout of the PR head (data, ADR-0023) with the pipeline from the pinned copy.
Ladder. The fix is git merge of the base into the head (never a rebase, never a force-push), one commit fix(ai): resolve merge conflicts with <base>@<sha7>:
| Rung | What | Writes |
|---|---|---|
| L0 | the merge is clean | none by the pipeline |
| L1 | package-lock.json: the PR's lockfile, then npm install --package-lock-only --ignore-scripts on the merged manifest (no user config, no token in the environment); not run when the PR changes .npmrc |
derived |
| L2 | the model resolves the conflict hunks (conflicts stage, prompts/conflicts-*.md), one call per file, at most 3 files; each hunk is post-checked by its resolver |
conflicted hunks only |
| L3 | needs-human, a comment naming the files, the reason and the rules; the merge is aborted |
none |
Resolvers (named, pinned in planFile): CHANGELOG.md (every entry of both sides kept, nothing invented), docs/adr/README.md (every line of both sides kept, nothing invented), docs/adr/NNNN-*.md (nothing invented), any other path that is not protected (invented lines bounded). A conflict on any other protected path (scripts/, config/, prompts/, .github/, docs/ outside those two, manifests, lockfiles other than the root package-lock.json) is L3: the base's own changes arrive in the merge commit as they are, but a model never writes into them (ADR-0021).
ADR renumber is mechanical, not a model decision: when the merged tree holds a base ADR with the number of an ADR the PR adds, the PR's ADR takes the next free number (file name, title, self references) and the lines the PR itself added elsewhere that cite the old number (index line, CHANGELOG entry) follow it; the base's references are left alone.
Guards, before anything is committed: no conflict marker left, meaning no opening or closing marker line and no bare separator (=======) or diff3 base marker (|||||||) in a model answer unless one of the three sides already had that exact line (a Markdown setext heading is made of =======); an answer that adds one is refused as model_rejected with the rule marker_left, the merge is aborted and the PR escalated; every resolved .js/.mjs/.cjs file passes node --check and .json parses; the ADR-0029/ADR-0019 rules (mass deletion, test-count drop, module system, exported signature, unresolved import) apply to the result against both sides and reject only what neither side already does (taking the base's own change is the merge doing its job). Git runs with hooks disabled and an environment without token or key; the token reaches git fetch/git push only through the environment of that call.
Budget. Own labels conflict-fix-attempt-1..2 (the review budget auto-fix-attempt-N is not touched); a third run is attempts_exhausted. Circuit breaker: the same conflict signature (conflicted files + incoming side) escalated twice in a row (markers in the comments) stops further runs, circuit_open, with no model call. A manual workflow_dispatch overrides both for one run. When changes-requested and a conflict coexist, the conflict goes first: the push changes the head and the review runs again on it (the push uses AI_PR_TOKEN; a GITHUB_TOKEN push triggers no workflow).
Configuration. config/models.yaml conflicts_* (Groq: 4096 output, 2500 input tokens, inside the 8K TPM window) and anthropic_conflicts*; AI_PROVIDER_CONFLICTS overrides the provider of this stage; labels in config/labels.yaml group conflicts.
What runs beside the stages and is not a stage: the journal of the end of a work item (item-closed.yml, ADR-0043 amendment) and the machine-readable findings.json export of the dashboard (ADR-0047). They are described in pipeline-operations.md.
The bodies of the six pipeline workflows are reusable workflows (on: workflow_call only): adl-validate-issue.yml, adl-code-generation.yml, adl-reset-auto-fix.yml, adl-pr-review.yml, adl-auto-fix-pr.yml, adl-conflicts-fix.yml. validate-issue.yml, code-generation.yml, reset-auto-fix.yml, pr-review.yml, auto-fix-pr.yml and conflicts-fix.yml stay as thin callers: they keep the workflow name: (so traces, run records and the workflow_run trigger of pipeline-site.yml see the same names), the trigger and the permission ceiling, and call the body with uses: ./.github/workflows/adl-*.yml. A caller declares no concurrency group, with one exception: each body declares its own at job level (a group on both sides would make the called job wait for its own caller). pr-review.yml keeps the per-PR group (pr-pipeline-<head ref>, shared with auto-fix and reset-auto-fix, ADR-0020) at workflow level, because it must cover the whole run, evidence and review; adl-pr-review.yml declares none, and a caller template copies the same group. In the Actions UI a job reads <caller job> / <called job>.
| Input | Default | Meaning |
|---|---|---|
pipeline_repository |
koydas/autonomous-dev-loop |
Repository holding scripts, prompts and default config |
pipeline_ref |
empty | Self-calls only: the default branch. Any other repository is pinned by the ref of its own uses: line |
config_dir |
.autonomous-dev-loop |
The repository's own configuration directory (this repository: config) |
codegen_workflow_file (validate-issue) |
code-generation.yml |
Caller workflow dispatched when the issue is valid |
issue_number, validate_run_id (code-generation) |
empty | workflow_dispatch values of the caller (strings; empty on a label event) |
pr_number, retrigger (reset-auto-fix) |
– / true |
workflow_dispatch values of the caller |
pr_number, sweep, caller_workflow_file (conflicts-fix) |
empty / empty / conflicts-fix.yml |
pr_number: one PR, a run of its own (a person's dispatch bypasses the cap and the breaker); empty = sweep every open PR and dispatch one run per fixable PR. sweep: true on a run the sweep dispatched (cap and breaker apply). caller_workflow_file: the caller the sweep dispatches |
autofix_workflow_file (pr-review) |
auto-fix-pr.yml |
Caller workflow whose active runs are looked up before the review label is re-pulsed (AUTOFIX_WORKFLOW_FILE for pr_review.mjs) |
Secrets are optional and mapped one by one (never secrets: inherit): ANTHROPIC_API_KEY, GROQ_API_KEY (validate-issue, code-generation, pr-review, auto-fix-pr, conflicts-fix), AI_PR_TOKEN (code-generation, reset-auto-fix, pr-review, auto-fix-pr, conflicts-fix). A caller job grants exactly the union of its called jobs' scopes (a called job can only narrow the caller's token; workflow_gates.test.mjs checks the equality): validate-issue contents: write, issues: write, actions: write; code-generation contents: write, pull-requests: write, issues: write, actions: read; reset-auto-fix contents: write, pull-requests: write, issues: write, actions: read; pr-review and auto-fix-pr contents: write, pull-requests: write, issues: write, actions: read. Inside adl-pr-review.yml the evidence job, which runs the PR's code, declares contents: read only and references no secret; the version lookup is a job of its own (pipeline, actions: read) that review waits for (needs: [pipeline, evidence], !cancelled() && needs.pipeline.result == 'success': a crashed evidence job still degrades the review, a run without a pipeline version has none).
Every job that runs a script first gets, in this order:
- Resolve pipeline version (
id: pipeline, the same shell in the six files, executed by the gate tests withghstubbed). A call from the pipeline repository itself uses the default branch, as ADR-0023 requires (pipeline_ref, if set, must equal it). Any other caller: the commit of theuses:pin, read fromreferenced_workflowsof the run (GET /repos/{repo}/actions/runs/{id}, henceactions: read); the pin must be a 40-hex SHA orvMAJOR.MINOR.PATCH(refs/tags/accepted), never a branch; a missing entry, a branch pin or arefthat disagrees with the pin fails the job with a message (the API reports a tag pinned as@v1.2.3with refrefs/tags/v1.2.3: therefs/tags/prefix is ignored in the comparison). The step also refusespull_request_target. - Checkout pipeline at that commit and Checkout target configuration (default branch): a sparse checkout of
config_dirfromgithub.event.repository.default_branch, never the ref aworkflow_dispatchnamed. Both are moved to$RUNNER_TEMP/pipelineand$RUNNER_TEMP/target-configbefore any script runs (the workspace must hold only the repository forcreate-pull-request), andADL_CONFIG_DIRis exported;ADL_PIPELINE_REPOSITORYandADL_CONFIG_PATHare job environment. Scripts run asnode "$RUNNER_TEMP/pipeline/scripts/<name>.mjs"with the workspace as working directory (see Roots and Layered Configuration).
load-labels of adl-code-generation.yml resolves the trigger label with scripts/resolve_labels.mjs issue.valid.name ready_for_dev and load-labels of adl-auto-fix-pr.yml the changes-requested label with review.changes.name changes_requested (effective labels config, target layer included) instead of an inline Node program. In the pull-request workflows the pull request tree is only data: its checkout is a separate step, and the pipeline and the configuration are moved out of the workspace before any script runs (the auto-fix push cannot publish them).
Setup requirements for a repository other than this one (prerequisites, the six callers, the .autonomous-dev-loop/ configuration directory, updating) are in installation.md (from the browser: the install.yml template, ADL Install, opens the pull request with the callers); releases and breaking changes in releasing.md. Labels are created once with the setup-labels.yml template (ADL Setup Labels, workflow_dispatch, contents: read + issues: write), which runs scripts/ensure_labels.mjs: idempotent, --update converges color and description, --dry-run writes nothing. A run uploads its trace as run-trace-<run_id>.
Every workflow run produces two observable outputs on top of GitHub Actions step logs.
Each instrumented pipeline script writes one JSON line to stderr per meaningful event. Schema:
{ "ts": "<ISO8601>", "run_id": "<GITHUB_RUN_ID>", "stage": "<stage>", "event": "<event>", "level": "info|warn|error", "duration_ms": <number|null>, "meta": {} }duration_ms is populated on all terminal events (*.complete, *.pass, *.fail, *.verdict). Error-level events also emit ::error:: GitHub Actions annotations, which surface in the PR checks UI.
Stages and minimum events:
| Stage | Script | Events |
|---|---|---|
issue_validation |
validate_issue.mjs |
start, pass/fail |
code_gen |
generate_issue_change.mjs |
start, llm_request, llm_response, complete, error |
pr_prepare |
generate_issue_change.mjs |
start, complete, error |
review |
pr_review.mjs |
start, llm_request, llm_response, verdict, error |
autofix |
auto_fix_pr.mjs |
start, llm_request, llm_response, push, max_attempts_reached, error |
Each workflow writes observability/traces/<GITHUB_RUN_ID>.json and uploads it as the artifact run-trace-<GITHUB_RUN_ID> (if: always()). The file is written incrementally so it contains partial data even if the job is killed.
# Read a trace after downloading the artifact:
cat observability/traces/<run_id>.json | jq '[.spans[] | {stage, outcome, duration_ms}]'Full schema and event tables: docs/observability.md. Design rationale: ADR-0018.
All automation scripts share a structured JSON logger. Each line written to stdout/stderr is a valid JSON object.
import { log, error, setLogContext, logStart, logEnd, logSummary } from './lib/logger.mjs';| Function | Output stream | level field |
Description |
|---|---|---|---|
log(msg, data?) |
stdout | info |
General informational event |
error(msg, data?) |
stderr | error |
Error or warning event |
logSummary({ success, stepsCompleted, errors }) |
stdout | info |
Emits a run_summary entry at script exit |
logStart(step) |
— | — | Records start timestamp for a named step |
logEnd(step, result) |
stdout | info |
Emits step_end with elapsed durationMs |
Call setLogContext(fields) once at startup to attach fields (e.g. run_id, step, attempt) to every subsequent log and error call. Per-call data fields override context fields with the same key.
setLogContext({ run_id: process.env.GITHUB_RUN_ID, step: 'auto-fix', attempt: 1 });
log('Starting', { prNumber: 42 });
// → {"level":"info","msg":"Starting","run_id":"…","step":"auto-fix","attempt":1,"prNumber":42}Emit a terminal summary in the unhandledRejection handler and at normal exit so log consumers can detect silent failures:
// on failure
logSummary({ success: false, stepsCompleted: ['labels'], errors: [err.message] });
// on success
logSummary({ success: true, stepsCompleted: ['labels', 'diff', 'llm', 'write', 'label'], errors: [] });logStart('llm-call');
const raw = await callLLM(…);
logEnd('llm-call', 'ok');
// → {"level":"info","msg":"step_end","step":"llm-call","result":"ok","durationMs":1234.5}Modules held to ≥ 80% test coverage (statements, branches, functions, lines) are listed once, in config/coverage-gates.json, and checked in CI by the single coverage step of test.yml, node scripts/coverage_gates.mjs (--only scripts/lib/<m>.mjs reproduces one gate locally). How to gate a module and what the tests pin: testing.md.
Checkpoint resume (scripts/lib/checkpoint.mjs) has a stricter rule than the threshold: every distinct failure branch (ENOENT vs non-ENOENT in readCheckpoint, mkdir propagation in writeCheckpoint) must have a dedicated test case.
config/models.yaml holds one block per stage (validation, generation, review, autofix). The keys below apply to Groq; <key> without a stage prefix is a global fallback. Anthropic has its own keys (ADR-0032), listed after this table.
| Key | Default | Description |
|---|---|---|
<stage> |
openai/gpt-oss-120b |
Groq model. GROQ_MODEL overrides every stage. |
<stage>_temperature |
0 (all stages) |
0–2. Zero reduces sampling variability for verdicts and patches; it does not guarantee identical responses. |
<stage>_max_tokens |
1024 (validation, review), 4096 (generation, autofix) |
Output cap, reasoning tokens included. Prompt + this value must stay under the Groq TPM per request (8K on the free tier), or Groq returns 413. |
<stage>_max_input_tokens |
6300 (validation, review), 3500 (generation), 2500 (autofix, user prompt only) |
Estimated input budget (chars/4). Rule: input × 1.10 + <stage>_max_tokens ≤ 8000 (the estimate runs ~3% low; workflow_gates.test.mjs enforces it). Review shrinks the diff, then the PR body, to fit; if fixed context still exceeds the budget, prompt fitting fails before callLLM, without provider fallback. For requests that reach callLLM, an over-budget Groq prompt is skipped: Anthropic handles it if its key is set, otherwise the stage fails before any API call (ADR-0028 amendment, ADR-0032). |
<stage>_reasoning_effort |
low |
low | medium | high, sent as reasoning_effort only when set. GROQ_REASONING_EFFORT overrides every stage; off stops sending it (ADR-0025). |
Review and auto-fix use temperature zero to reduce sampling variability in verdicts and patches. Input/output budgets are preserved to avoid reducing already constrained review and file context. This tuning does not establish a quality improvement; compare live evals before drawing that conclusion.
Anthropic keys (ADR-0032):
| Key | Default | Description |
|---|---|---|
anthropic_<stage> |
claude-haiku-5-5 (validation), claude-sonnet-5-5 (generation, review, autofix) |
Anthropic model, provisional until measured (ADR-0032 amendment 2026-10-17); claude-opus-5-5 is one line away. ANTHROPIC_MODEL overrides every stage. |
anthropic_<stage>_effort |
low (validation), high (generation), medium (review, autofix) |
low | medium | high | xhigh | max | off, sent as output_config.effort. ANTHROPIC_EFFORT overrides every stage. Without it, claude-opus-5-5 and claude-haiku-5-5 run at medium, claude-sonnet-5-5 at high. off is rejected for claude-opus-5-5 and claude-sonnet-5-5. |
anthropic_<stage>_max_tokens |
16000 |
Output cap, thinking included (adaptive thinking is on by default on Opus 5.5, Sonnet 5.5 and Haiku 5.5). Requests are not streamed: a larger cap risks an HTTP timeout (undici gives up after 300 s; not retried). |
anthropic_review_max_diff_chars |
200000 (also when absent) |
Review diff cap on Anthropic, which has no input budget (ADR-0038 amendment 2026-10-19): the filtered diff is cut past it on a whole line, with the … [diff cut: …] marker, and cut code is withheld as on Groq. ≈ 50k estimated tokens: 5% of the 1M context, ≤ $0.20 of input per review on claude-opus-5-5. Positive integer, else the review fails before any call. Not read on Groq: its diff is bounded by review_max_input_tokens. |
review_eval_cut_<provider>_max_input_tokens / _max_diff_chars |
6300 tokens (groq, anthropic) |
Eval only, never read by production: the cut of the review eval cases marked requires_cut on that provider, as a token budget fit or a char cap (one of the two). Without either, the provider's production limits; a requires_cut case left whole is an errored run (ADR-0038 amendment, docs/evals.md#provider-independent-cases). |
No temperature and no input budget for Anthropic: Opus 4.7+ / 5.x reject sampling parameters (400), and the 8K TPM limit is Groq's. Supported models (claude-opus-5-5, claude-opus-5, claude-sonnet-5-5, claude-fable-5-1) send fallbacks: "default" (beta server-side-fallback-2026-07-01) when ANTHROPIC_API_URL is unset: a safety-classifier refusal is re-run server-side on another Claude model. claude-haiku-5-5 has no server-side fallback. A refusal that still comes back fails the call, and the Groq fallback takes over.
Live eval results record the effective temperature (null for Anthropic); PR eval threshold comparisons are advisory until a baseline with matching recorded temperature is available. Legacy results without the setting are treated as unknown.
The auto-fix stage constructs an LLM prompt from three sources — PR diff, review feedback, and current file contents. The total request size is capped to respect provider per-request limits (notably the 8,000 TPM free-tier limit of openai/gpt-oss-120b on Groq).
Three keys in config/models.yaml control the budget for the autofix stage:
| Key | Default | Description |
|---|---|---|
autofix_max_input_tokens |
2500 |
Hard ceiling on the total user-prompt tokens (wrapper + diff + feedback + files). Set so that (system_tokens + autofix_max_input_tokens) × 1.10 + max_output_tokens ≤ 8000 (Groq free-tier TPM for openai/gpt-oss-120b, ADR-0025, ADR-0028 amendment): (583 + 2500) × 1.10 + 4096 = 7488; autofix_prompt_budget.test.mjs keeps ≥ 10% of the TPM-bounded input (3549) as headroom for the system prompt. The static wrapper text of auto-fix-user.md (~192 tokens) is deducted first; the remainder is divided among the three sections. Remove the key to use the full model context window (e.g. after upgrading to Groq Dev Tier or switching to Anthropic). |
autofix_diff_ratio |
0.15 |
Fraction of the section budget (after wrapper deduction) allocated to the PR diff. Kept low because the diff mostly repeats the shown files, and only files shown in full can be edited (ADR-0029). |
autofix_feedback_ratio |
0.25 |
Fraction of the section budget allocated to review feedback. The remainder goes to file contents; a file that does not fit whole is withheld, never truncated. |
Tuning for your provider tier:
| Provider / Tier | Recommended autofix_max_input_tokens |
|---|---|
Groq free tier (openai/gpt-oss-120b, 8k TPM) |
2500 (default) |
| Groq Developer plan | Raise or remove the key (TPM is far above a single request) |
Anthropic (claude-opus-5-5) |
Not applied: Anthropic configs carry no input budget and no per-request TPM limit. The auto-fix input is bounded only by the context window assumed in MODEL_CONTEXT_WINDOW (scripts/lib/autofix_core.mjs): 200 000 tokens for the 5.x ids (fallback; the map lists the 4.x ids). config/models.yaml states 1M for them, a figure no code reads |
The token_estimate log line emitted by auto_fix_pr.mjs shows the actual token counts for each section:
{"level":"info","msg":"token_estimate","system":459,"wrapper":218,"diff":3231,"feedback":1795,"files":2156,"max_tokens":4096,"total":11955}Monitor this to detect systematic truncation of the diff or feedback. Files are never truncated: look for File withheld markers in the prompt, or for Auto-Fix: Patch Rejected comments citing "file was withheld".
Critical job outputs are persisted to ./checkpoints/<runId>/<step>.json via scripts/lib/checkpoint.mjs and exchanged between jobs as GitHub Actions artifacts. This allows a re-triggered run to resume from the last successful step rather than starting from scratch.
| Workflow chain | RunId format | Artifact name |
|---|---|---|
validate-issue → code-generation |
issue-<number> |
checkpoints-issue-<number> |
pr-review → auto-fix-pr |
pr-<number> |
checkpoints-pr-<number> |
The CHECKPOINT_RUN_ID environment variable overrides the default; each script falls back to deriving the ID from ISSUE_NUMBER or the resolved PR number if the variable is absent.
| Job script | Step name | Persisted fields |
|---|---|---|
validate_issue.mjs |
validate |
valid, score |
generate_issue_change.mjs |
generate |
summary, outputPaths |
pr_review.mjs |
review |
isApproved, prNumber |
auto_fix_pr.mjs |
autofix |
prNumber, attempt, outputPaths |
code-generation (when triggered by the automated pipeline via validate_run_id dispatch input) and auto-fix-pr both fail explicitly with ::error:: if the expected upstream checkpoint file is not present, preventing execution in an incoherent state.
validate-issuepasses itsGITHUB_RUN_IDas thevalidate_run_idworkflow-dispatch input when triggeringcode-generation. The generate job uses this run-id to download the artifact.auto-fix-prusesgh api repos/{owner}/{repo}/actions/artifacts?name=checkpoints-pr-{PR_NUMBER}to locate the latest non-expired checkpoint artifact and downloads it withcurl.
GitHub Actions artifact retention applies (default 90 days). Old checkpoint artifacts for the same issue or PR number are overwritten (overwrite: true) on each new run.
scripts/lib/checkpoint.mjs and all code paths that call writeCheckpoint/readCheckpoint must maintain ≥ 80% test coverage. Every distinct failure branch (e.g. ENOENT vs non-ENOENT in readCheckpoint, mkdir propagation in writeCheckpoint) must have a dedicated test case.
Automation scripts must fail before network calls when required startup inputs are invalid:
- Environment: required vars are validated synchronously at process start (
GITHUB_TOKEN,GITHUB_REPOSITORY,GITHUB_EVENT_PATH, provider API key, etc.). - Prompts: prompt files are loaded and validated as existing + non-empty at startup, with explicit file-path errors when missing/empty.
- GitHub payload: required fields are validated with path-based errors:
- PR number:
pull_request.numberor fallbackissue.number. - Branch reference (when needed):
pull_request.head.refor fallbackref.
- PR number:
- Provider payload parsing: response-shape failures include concrete expected paths:
- Anthropic: the first
textblock ofcontent(thinking blocks come first on Opus 5.x);stop_reason: "refusal"fails with itsstop_details.category,stop_reason: "max_tokens"fails as truncated - Groq:
choices[0].message.content
- Anthropic: the first