From fe98943fd7ae221993fdcb23fbfe51d6a4e5dada Mon Sep 17 00:00:00 2001 From: leyoonafr Date: Wed, 22 Jul 2026 21:51:44 +0800 Subject: [PATCH 01/41] feat: add issue development loop --- .agents/skills/issue-dev-loop/SKILL.md | 6 + .../skills/issue-dev-loop/agents/openai.yaml | 6 + .codex/agents/echo-ui-pr-reviewer.toml | 14 + .codex/agents/echo-ui-review-adjudicator.toml | 11 + .codex/config.toml | 2 + loops/README.md | 15 + loops/_shared/owner-channel/CHANNEL.md | 16 + loops/_shared/owner-channel/channel.json | 15 + .../owner-channel/notification.schema.json | 32 + loops/_shared/owner-channel/outbox/.gitignore | 2 + loops/issue-dev-loop/LOOP.md | 128 ++++ loops/issue-dev-loop/SKILL.md | 70 ++ loops/issue-dev-loop/agents/openai.yaml | 6 + loops/issue-dev-loop/dependencies.md | 21 + loops/issue-dev-loop/evidence/.gitignore | 2 + loops/issue-dev-loop/evolve/EVOLVE.md | 19 + loops/issue-dev-loop/evolve/metrics.json | 14 + loops/issue-dev-loop/handoffs/.gitignore | 2 + .../references/evidence-policy.md | 27 + .../references/github-operations.md | 37 + loops/issue-dev-loop/review/REVIEW.md | 30 + .../issue-dev-loop/review/comment.schema.json | 26 + .../issue-dev-loop/review/response-policy.md | 15 + .../issue-dev-loop/review/reviewer-prompt.md | 1 + .../issue-dev-loop/schemas/event.schema.json | 15 + .../schemas/evidence.schema.json | 51 ++ loops/issue-dev-loop/schemas/run.schema.json | 42 ++ loops/issue-dev-loop/screenshots/.gitignore | 2 + loops/issue-dev-loop/scripts/loopctl.mjs | 110 +++ loops/issue-dev-loop/scripts/runtime.mjs | 640 ++++++++++++++++++ loops/issue-dev-loop/state.md | 37 + .../templates/implementation-brief.md | 27 + loops/issue-dev-loop/templates/pr-body.md | 22 + loops/issue-dev-loop/templates/run-summary.md | 17 + loops/issue-dev-loop/tests/runtime.test.mjs | 243 +++++++ loops/issue-dev-loop/triggers/TRIGGER.md | 13 + .../triggers/codex-automation-prompt.md | 3 + loops/issue-dev-loop/triggers/detect-work.mjs | 16 + package.json | 3 + 39 files changed, 1758 insertions(+) create mode 100644 .agents/skills/issue-dev-loop/SKILL.md create mode 100644 .agents/skills/issue-dev-loop/agents/openai.yaml create mode 100644 .codex/agents/echo-ui-pr-reviewer.toml create mode 100644 .codex/agents/echo-ui-review-adjudicator.toml create mode 100644 .codex/config.toml create mode 100644 loops/README.md create mode 100644 loops/_shared/owner-channel/CHANNEL.md create mode 100644 loops/_shared/owner-channel/channel.json create mode 100644 loops/_shared/owner-channel/notification.schema.json create mode 100644 loops/_shared/owner-channel/outbox/.gitignore create mode 100644 loops/issue-dev-loop/LOOP.md create mode 100644 loops/issue-dev-loop/SKILL.md create mode 100644 loops/issue-dev-loop/agents/openai.yaml create mode 100644 loops/issue-dev-loop/dependencies.md create mode 100644 loops/issue-dev-loop/evidence/.gitignore create mode 100644 loops/issue-dev-loop/evolve/EVOLVE.md create mode 100644 loops/issue-dev-loop/evolve/metrics.json create mode 100644 loops/issue-dev-loop/handoffs/.gitignore create mode 100644 loops/issue-dev-loop/references/evidence-policy.md create mode 100644 loops/issue-dev-loop/references/github-operations.md create mode 100644 loops/issue-dev-loop/review/REVIEW.md create mode 100644 loops/issue-dev-loop/review/comment.schema.json create mode 100644 loops/issue-dev-loop/review/response-policy.md create mode 100644 loops/issue-dev-loop/review/reviewer-prompt.md create mode 100644 loops/issue-dev-loop/schemas/event.schema.json create mode 100644 loops/issue-dev-loop/schemas/evidence.schema.json create mode 100644 loops/issue-dev-loop/schemas/run.schema.json create mode 100644 loops/issue-dev-loop/screenshots/.gitignore create mode 100644 loops/issue-dev-loop/scripts/loopctl.mjs create mode 100644 loops/issue-dev-loop/scripts/runtime.mjs create mode 100644 loops/issue-dev-loop/state.md create mode 100644 loops/issue-dev-loop/templates/implementation-brief.md create mode 100644 loops/issue-dev-loop/templates/pr-body.md create mode 100644 loops/issue-dev-loop/templates/run-summary.md create mode 100644 loops/issue-dev-loop/tests/runtime.test.mjs create mode 100644 loops/issue-dev-loop/triggers/TRIGGER.md create mode 100644 loops/issue-dev-loop/triggers/codex-automation-prompt.md create mode 100644 loops/issue-dev-loop/triggers/detect-work.mjs diff --git a/.agents/skills/issue-dev-loop/SKILL.md b/.agents/skills/issue-dev-loop/SKILL.md new file mode 100644 index 00000000..f6b26a10 --- /dev/null +++ b/.agents/skills/issue-dev-loop/SKILL.md @@ -0,0 +1,6 @@ +--- +name: issue-dev-loop +description: Run the repository's auditable Echo UI issue development loop for one `codex-ready` issue, including `$implement`, independent PR review, evidence, owner notification, and owner-only merge. Use only when explicitly invoked or by the configured maintenance automation. +--- + +Read `loops/issue-dev-loop/SKILL.md` completely, then follow it. Treat the loop directory as canonical; this file exists only for repository skill discovery. diff --git a/.agents/skills/issue-dev-loop/agents/openai.yaml b/.agents/skills/issue-dev-loop/agents/openai.yaml new file mode 100644 index 00000000..ed759af9 --- /dev/null +++ b/.agents/skills/issue-dev-loop/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: 'Echo UI Issue Dev Loop' + short_description: 'Run audited Echo UI issue development loops' + default_prompt: 'Use $issue-dev-loop to select and develop one eligible Echo UI issue with independent review and owner-only merge.' +policy: + allow_implicit_invocation: false diff --git a/.codex/agents/echo-ui-pr-reviewer.toml b/.codex/agents/echo-ui-pr-reviewer.toml new file mode 100644 index 00000000..c931f4a1 --- /dev/null +++ b/.codex/agents/echo-ui-pr-reviewer.toml @@ -0,0 +1,14 @@ +name = "echo_ui_pr_reviewer" +description = "Fresh-context, read-only reviewer for Echo UI issue pull requests." +sandbox_mode = "read-only" +developer_instructions = """ +Review the supplied immutable base/head diff as an independent repository owner. +Use $code-review when it is available. Evaluate both repository standards and the +originating issue acceptance criteria. Prioritize correctness, public API and +package compatibility, Web Audio lifecycle behavior, React behavior, accessibility, +security, regressions, and missing tests. Ignore executor conversation history and +do not modify files. Avoid style-only or speculative findings. Return a structured +review with stable finding IDs, severity P0-P3, confidence, file and line, concrete +evidence, reproduction when possible, and expected resolution. Return PASS when no +actionable findings exist. +""" diff --git a/.codex/agents/echo-ui-review-adjudicator.toml b/.codex/agents/echo-ui-review-adjudicator.toml new file mode 100644 index 00000000..a04f03ed --- /dev/null +++ b/.codex/agents/echo-ui-review-adjudicator.toml @@ -0,0 +1,11 @@ +name = "echo_ui_review_adjudicator" +description = "Read-only adjudicator for disputed high-severity Echo UI review findings." +sandbox_mode = "read-only" +developer_instructions = """ +Independently adjudicate a disputed P0 or P1 review finding. Read only the issue +acceptance criteria, repository rules, immutable base/head diff, original finding, +executor response, and cited evidence. Do not modify files and do not infer intent +from private conversation history. Return ACCEPT_FINDING, REJECT_FINDING, or +NEEDS_OWNER, followed by concise evidence. Prefer NEEDS_OWNER when product intent, +public API policy, security, or release policy is ambiguous. +""" diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 00000000..fd53dd1e --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,2 @@ +[agents] +max_concurrent_threads_per_session = 3 diff --git a/loops/README.md b/loops/README.md new file mode 100644 index 00000000..0c36204f --- /dev/null +++ b/loops/README.md @@ -0,0 +1,15 @@ +# Echo UI engineering loops + +This directory contains durable, auditable workflows that Codex can run against Echo UI. A loop is larger than a skill: it owns a contract, compact state, append-only history, triggers, evidence, review policy, and evolution policy. + +| Loop | Purpose | Default trigger | +| --- | --- | --- | +| [`issue-dev-loop`](./issue-dev-loop/LOOP.md) | Develop one `codex-ready` issue through an owner-reviewed PR | Cheap preflight, then scheduled Codex run | + +## Repository rules + +- Treat each loop directory as the canonical source for its own workflow. +- Put repo-discoverable adapters in `.agents/skills`; adapters must point back to the canonical loop rather than duplicate its contract. +- Persist compact state and summary history in Git. Keep raw command logs, screenshots, videos, and generated evidence out of Git and publish them as PR or CI artifacts. +- Never grant a loop authority to approve, auto-merge, or merge a PR. +- Use `pnpm loop:issue-dev:validate` before changing loop infrastructure. diff --git a/loops/_shared/owner-channel/CHANNEL.md b/loops/_shared/owner-channel/CHANNEL.md new file mode 100644 index 00000000..46ab44db --- /dev/null +++ b/loops/_shared/owner-channel/CHANNEL.md @@ -0,0 +1,16 @@ +# Owner communication channel + +GitHub issue and PR comments are the canonical, auditable channel. The runtime mentions `@codeacme17` on the originating issue before a PR exists and on the PR after publication. A configured generic webhook mirrors the same JSON payload for immediate push delivery. + +## Blocking events + +`approval_required`, `clarification_required`, `blocked`, `review_dispute`, `pr_ready_for_review`, `pr_updated_for_review`, and `loop_failed` require immediate delivery. The run enters `waiting_for_owner` and must not choose a default answer after a timeout. + +## Runtime setup + +1. Authenticate `gh` for the repository identity used by the loop. +2. Enable GitHub notifications for mentions and review requests for `codeacme17`. +3. Optionally set `ECHO_UI_LOOP_OWNER_WEBHOOK_URL` to an endpoint that accepts the notification JSON with `Content-Type: application/json`. +4. Never store the webhook URL or credentials in this repository. + +Replies and approvals are valid only when the GitHub author matches the configured owner. A webhook delivery is an alert, not an approval channel. diff --git a/loops/_shared/owner-channel/channel.json b/loops/_shared/owner-channel/channel.json new file mode 100644 index 00000000..9314b4ca --- /dev/null +++ b/loops/_shared/owner-channel/channel.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "ownerGitHubLogin": "codeacme17", + "canonicalChannel": "github", + "webhookEnvironmentVariable": "ECHO_UI_LOOP_OWNER_WEBHOOK_URL", + "immediateTypes": [ + "approval_required", + "clarification_required", + "blocked", + "review_dispute", + "pr_ready_for_review", + "pr_updated_for_review", + "loop_failed" + ] +} diff --git a/loops/_shared/owner-channel/notification.schema.json b/loops/_shared/owner-channel/notification.schema.json new file mode 100644 index 00000000..7cb3fe32 --- /dev/null +++ b/loops/_shared/owner-channel/notification.schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://echoui.dev/schemas/loop-notification.schema.json", + "title": "Echo UI loop owner notification", + "type": "object", + "required": [ + "schemaVersion", + "notificationId", + "loop", + "runId", + "type", + "blocking", + "summary", + "requestedAction", + "createdAt" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": 1 }, + "notificationId": { "type": "string", "pattern": "^NTF-[A-Z0-9-]+$" }, + "loop": { "const": "issue-dev-loop" }, + "runId": { "type": "string", "minLength": 1 }, + "type": { "type": "string", "minLength": 1 }, + "blocking": { "type": "boolean" }, + "summary": { "type": "string", "minLength": 1 }, + "requestedAction": { "type": "string", "minLength": 1 }, + "targetUrl": { "type": ["string", "null"], "format": "uri" }, + "evidenceUrl": { "type": ["string", "null"], "format": "uri" }, + "createdAt": { "type": "string", "format": "date-time" }, + "delivery": { "type": "object" } + } +} diff --git a/loops/_shared/owner-channel/outbox/.gitignore b/loops/_shared/owner-channel/outbox/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/loops/_shared/owner-channel/outbox/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/loops/issue-dev-loop/LOOP.md b/loops/issue-dev-loop/LOOP.md new file mode 100644 index 00000000..de9c174c --- /dev/null +++ b/loops/issue-dev-loop/LOOP.md @@ -0,0 +1,128 @@ +# Echo UI issue development loop contract + +Version: 1 + +This contract is the constitution for automated issue development in Echo UI. When another instruction conflicts with it, stop and ask the owner unless that instruction has higher platform authority. + +## Goal + +Turn one eligible GitHub issue into a small, verified draft PR, complete an independent review-and-repair cycle, present auditable evidence, and wait for the repository owner to review and merge it. + +A run is complete only when all of the following are true: + +1. The selected issue had the `codex-ready` label and was not already claimed. +2. The implementation satisfies its frozen acceptance criteria. +3. Required targeted checks and `pnpm verify` passed. +4. Independent review has no unresolved P0/P1 findings. +5. Every automated review comment has an evidence-backed response. +6. Evidence is linked from the PR. +7. The owner, `codeacme17`, reviewed and merged the PR. +8. State and append-only summary history were updated. + +## Authority + +The loop may: + +- read public repository data and eligible issues +- claim one issue using labels and a unique branch +- create an isolated worktree from `dev` +- invoke `$implement` in that worktree +- run repository tests, builds, lint, and browser verification +- commit and push only `codex/issue-` branches +- create and update a draft PR targeting `dev` +- request independent review and post its findings +- respond to review comments with evidence +- mark the PR ready and request the owner's review +- notify the owner and observe owner actions + +The loop must obtain owner confirmation before: + +- changing public API compatibility or package exports +- adding or replacing a production dependency +- changing security, privacy, release, or publishing behavior +- expanding materially beyond the issue acceptance criteria +- accepting a disputed P0/P1 review finding + +The loop must never: + +- approve, auto-merge, or merge any PR +- call `gh pr merge` or enable a merge queue for its PR +- push directly to `dev` or `main` +- target `main` from an issue branch +- bypass branch protection or dismiss owner review feedback +- publish a package, tag, release, or deployment +- weaken, delete, or skip a failing test to obtain a green result +- expose secrets, cookies, tokens, private logs, or personal data + +## Trigger + +Use a combo trigger. Run `triggers/detect-work.mjs` before starting a Codex implementation turn. The preflight queries open `codex-ready` issues, excludes issues with `loop:claimed` or an open matching PR, and selects the highest-priority oldest issue. If there is no work, record `trigger_checked` with `hasWork: false` and exit without waking an implementation agent. + +## Workflow + +### 1. Claim and snapshot + +Recheck the issue immediately before mutation. Apply `loop:claimed`, capture the issue title/body/labels/URL, base SHA, and acceptance criteria, then create the run. Use one run ID across logs, handoffs, screenshots, evidence, review comments, notifications, branch metadata, and the PR body. + +### 2. Isolate + +Create `codex/issue-` from the current `origin/dev` and use an isolated worktree. Never reuse an unclean directory or another run's branch. + +### 3. Freeze the implementation brief + +Complete the generated handoff with acceptance criteria, scope, TDD seams, required checks, UI evidence, and stop conditions. After implementation begins, changes to the brief require a logged reason and, for material scope changes, owner confirmation. + +### 4. Implement + +Explicitly invoke `$implement`. The orchestrator does not write product code. `$implement` owns TDD at agreed seams, implementation, regular typechecking and targeted tests, the final full suite, `$code-review`, and a local commit. It must not push, create a PR, or merge. + +### 5. Verify before publication + +Run relevant checks and `pnpm verify`. For UI behavior, capture before/after screenshots at meaningful desktop and mobile viewports and include interaction or accessibility evidence where applicable. Generate and validate an evidence manifest tied to the exact commit SHA. + +### 6. Create the draft PR + +Push the issue branch and create a draft PR targeting `dev`. The PR must include the issue, run ID, base/head SHAs, risk, changes, test commands and results, evidence links, screenshots, known limitations, and explicit owner-only merge language. + +### 7. Independent review + +Spawn `echo_ui_pr_reviewer` with fresh context and read-only filesystem access. Provide only durable specifications and review artifacts. Post its findings verbatim. The executor then classifies and responds to every comment. Accepted findings go back through `$implement`; rejected findings require concrete evidence. Use `echo_ui_review_adjudicator` or the owner for disputed P0/P1 findings. Allow at most two automated repair/review rounds. + +### 8. Owner gate + +After CI, verification, and independent review pass, mark the PR ready, request review from `codeacme17`, notify the owner, and transition to `awaiting_owner_review`. Do not infer approval from timeouts or silence. + +### 9. Owner feedback + +When the owner requests changes, snapshot the comments, create a new handoff, invoke `$implement`, reverify, rerun fresh review, reply with commit and evidence, and notify the owner that the PR is ready again. + +### 10. Complete + +Only an observed owner merge permits `completed`. Record merge SHA and timestamp, remove `loop:claimed`, update state, append the run summary, and retain links to published evidence. A closed unmerged PR is `cancelled`, not completed. + +## State and history + +Keep `state.md` small and deliberate. It may be rewritten and contains only active runs, open PRs, blockers, follow-ups, current hypotheses, and learned constraints. + +`logs/index.jsonl` is append-only and contains one compact summary per finalized run. Raw event and command logs live under `logs/runs/` and are not merged into Git; publish them as CI artifacts when they matter to review. + +Never log secrets, full environment dumps, cookies, auth headers, private user data, or raw prompts containing sensitive information. + +## Budgets and concurrency + +- One issue per run. +- One active run per issue. +- Prefer changes below 400 non-generated lines; ask before materially exceeding it. +- At most two implementation repair attempts before owner escalation. +- At most two independent review rounds before owner escalation. +- Never start a second run merely to keep the loop busy. + +## Notifications + +GitHub issue/PR comments are the canonical communication record. The shared owner channel may mirror notifications to a webhook. Blocking events must transition to `waiting_for_owner`; delivery failure is itself a blocker. + +Notify immediately for `approval_required`, `clarification_required`, `blocked`, `review_dispute`, `pr_ready_for_review`, `pr_updated_for_review`, and `loop_failed`. Routine no-work checks belong in a digest, not an interruption. + +## Anti-busywork + +Never rewrite accurate documentation, refactor unrelated working code, add tests without meaningful coverage, create cosmetic review comments, or open a PR solely to demonstrate activity. No eligible work is a successful no-op. diff --git a/loops/issue-dev-loop/SKILL.md b/loops/issue-dev-loop/SKILL.md new file mode 100644 index 00000000..586d86cf --- /dev/null +++ b/loops/issue-dev-loop/SKILL.md @@ -0,0 +1,70 @@ +--- +name: issue-dev-loop +description: Run one auditable Echo UI issue-development cycle from `codex-ready` issue selection through implementation, independent PR review, verification, owner notification, and owner-only merge. Use when Codex is scheduled to maintain Echo UI, explicitly asked to run the issue loop, or resuming an active loop-created PR. Do not use for releases, direct changes to main, or work without an eligible GitHub issue. +--- + +# Echo UI issue development loop + +Run exactly one bounded issue cycle. Treat [`LOOP.md`](./LOOP.md) as the constitution and [`state.md`](./state.md) as the current durable state. + +## Start safely + +1. Read `LOOP.md`, `state.md`, and `dependencies.md` completely. +2. Run `node loops/issue-dev-loop/scripts/loopctl.mjs validate`. +3. Run the cheap trigger in `triggers/detect-work.mjs`. Exit without invoking an implementation agent when it reports `hasWork: false`. +4. Refuse to start when another active run or PR already claims the issue. +5. Create a `codex/issue-` branch and an isolated worktree from `dev`. +6. Start the run with `loopctl.mjs start` and freeze the generated `implementation-brief.md` before implementation. + +Read [`references/github-operations.md`](./references/github-operations.md) for GitHub mutations and [`references/evidence-policy.md`](./references/evidence-policy.md) before verification or PR publication. + +## Implement through `$implement` + +For every run that changes product code, explicitly invoke `$implement` with the frozen handoff path. The orchestrator must not implement the issue itself. + +Provide `$implement` with: + +- the absolute handoff path +- the isolated worktree and current branch +- pre-agreed TDD seams +- required targeted checks and final `pnpm verify` +- an instruction to stop after committing; `$implement` must not push or create a PR + +Record the resulting commit SHA and validation summary in the run log. + +## Publish a draft PR + +Push only the issue branch and create a **draft** PR targeting `dev`. Include the issue, risk assessment, test results, evidence manifest, screenshots, known limitations, run ID, and exact head SHA. Never target `main` from an issue branch. + +## Run independent review + +After the draft PR exists, spawn the project agent `echo_ui_pr_reviewer` with a fresh context. Give it only the issue snapshot, acceptance criteria, repository instructions, base SHA, head SHA, diff, CI results, and evidence manifest. Do not give it executor conversation history or rationale. + +Post the reviewer's findings verbatim as one review plus inline comments. Each finding needs a stable ID, severity, confidence, evidence, and expected resolution. Follow `review/REVIEW.md` and `review/response-policy.md`. + +The executor must classify every finding as `accepted`, `rejected`, `needs-human`, `stale`, or `already-fixed`: + +- Reinvoke `$implement` for accepted findings, then reply with commit and test evidence. +- Reply to rejected findings with reproducible evidence, never bare disagreement. +- Send disputed P0/P1 findings to `echo_ui_review_adjudicator` or the owner. +- Allow at most two automated repair/review rounds. + +## Verify and notify the owner + +Run verification appropriate to the change and require `pnpm verify` before the PR is ready for owner review. Collect evidence under the run ID and validate its manifest. Mark the PR ready, request review from `codeacme17`, emit a `pr_ready_for_review` notification, and transition to `awaiting_owner_review`. + +The owner is the only actor allowed to approve or merge. Never call `gh pr merge`, enable auto-merge, push to `main`, push to `dev`, dismiss owner feedback, or bypass branch protections. A run becomes `completed` only after observing the owner's merge event. + +## Stop conditions + +Stop and notify the owner immediately when: + +- acceptance criteria are materially ambiguous +- a breaking public API, new production dependency, secret, release, or security decision is required +- the same implementation or review step fails twice +- no fresh-context reviewer is available +- a P0/P1 review dispute remains +- required verification cannot run +- notification delivery for a blocking event fails + +Do not manufacture work, rewrite accurate documentation, weaken tests, or silently expand scope to keep a run active. diff --git a/loops/issue-dev-loop/agents/openai.yaml b/loops/issue-dev-loop/agents/openai.yaml new file mode 100644 index 00000000..ed759af9 --- /dev/null +++ b/loops/issue-dev-loop/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: 'Echo UI Issue Dev Loop' + short_description: 'Run audited Echo UI issue development loops' + default_prompt: 'Use $issue-dev-loop to select and develop one eligible Echo UI issue with independent review and owner-only merge.' +policy: + allow_implicit_invocation: false diff --git a/loops/issue-dev-loop/dependencies.md b/loops/issue-dev-loop/dependencies.md new file mode 100644 index 00000000..1dbcb7ec --- /dev/null +++ b/loops/issue-dev-loop/dependencies.md @@ -0,0 +1,21 @@ +# Runtime dependencies + +## Required + +- Codex with project skills and subagents enabled +- `$implement` available for all product-code changes +- `$code-review` available to `$implement` and the independent reviewer +- Node.js 24 +- pnpm 10 +- Git +- GitHub CLI (`gh`) authenticated for issue, branch, PR, review, and comment work +- Repository trust enabled so project `.codex` agents can load + +## Optional + +- `ECHO_UI_LOOP_OWNER_WEBHOOK_URL` for an immediate JSON webhook mirror +- Browser access for UI verification and screenshot collection + +## Identity + +Prefer a dedicated GitHub App or bot identity for loop-created PRs so the owner can provide an independent approval. The bot must not have branch-protection bypass or merge authority. diff --git a/loops/issue-dev-loop/evidence/.gitignore b/loops/issue-dev-loop/evidence/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/loops/issue-dev-loop/evidence/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/loops/issue-dev-loop/evolve/EVOLVE.md b/loops/issue-dev-loop/evolve/EVOLVE.md new file mode 100644 index 00000000..e7da52a8 --- /dev/null +++ b/loops/issue-dev-loop/evolve/EVOLVE.md @@ -0,0 +1,19 @@ +# Evolve session contract + +Run after every ten finalized runs, or after the same failure pattern appears three times. Use a fresh session and provide the loop contract, current state, compact run summaries, notification metrics, owner feedback, revert history, and review data. + +The evolve session may automatically: + +- remove obsolete items from `state.md` +- propose cheaper preflight checks +- improve non-authority-changing scripts and templates +- update dashboards or metrics derived from append-only logs + +It must create an owner-reviewed PR before changing: + +- goals, completion criteria, authority, or stop conditions +- merge, release, security, privacy, or dependency policy +- verifier strength or required checks +- notification severity or owner-gate behavior + +Never optimize by weakening evidence, increasing autonomous authority, deleting unfavorable history, or hiding no-work and failure runs. diff --git a/loops/issue-dev-loop/evolve/metrics.json b/loops/issue-dev-loop/evolve/metrics.json new file mode 100644 index 00000000..da169a25 --- /dev/null +++ b/loops/issue-dev-loop/evolve/metrics.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "finalizedRuns": 0, + "successfulRuns": 0, + "failedRuns": 0, + "noWorkChecks": 0, + "ownerRequestedChanges": 0, + "revertedPullRequests": 0, + "reviewFindings": { + "accepted": 0, + "rejected": 0, + "needsHuman": 0 + } +} diff --git a/loops/issue-dev-loop/handoffs/.gitignore b/loops/issue-dev-loop/handoffs/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/loops/issue-dev-loop/handoffs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/loops/issue-dev-loop/references/evidence-policy.md b/loops/issue-dev-loop/references/evidence-policy.md new file mode 100644 index 00000000..262c316d --- /dev/null +++ b/loops/issue-dev-loop/references/evidence-policy.md @@ -0,0 +1,27 @@ +# Evidence policy + +Evidence proves the acceptance criteria against the exact PR head SHA. + +## Always include + +- run ID, issue number, base SHA, and head SHA +- commands executed, exit codes, and UTC timestamps +- targeted test results +- final `pnpm verify` result +- independent review verdict and finding IDs +- known limitations or checks that could not run + +## UI changes + +Capture only scenarios relevant to the issue: + +- before and after where an existing behavior changes +- desktop and mobile viewport when responsiveness is in scope +- focus, keyboard, disabled, loading, or error state when affected +- video only when a still image cannot demonstrate the interaction + +Use descriptive names such as `02-after-player-mobile-375.webp`. Record route, viewport, scenario, commit SHA, and capture time in the screenshot manifest. + +## Storage + +Raw logs, screenshots, videos, and test reports are runtime artifacts and should not accumulate on `dev`. Upload them to CI artifacts or another configured store, then put stable links in `evidence//manifest.json` and the PR body. Never upload secrets, cookies, auth state, user data, or unredacted environment dumps. diff --git a/loops/issue-dev-loop/references/github-operations.md b/loops/issue-dev-loop/references/github-operations.md new file mode 100644 index 00000000..358df855 --- /dev/null +++ b/loops/issue-dev-loop/references/github-operations.md @@ -0,0 +1,37 @@ +# GitHub operation policy + +Read this before mutating issues or pull requests. + +## Selection and claim + +1. Select only open issues labeled `codex-ready`. +2. Exclude `loop:claimed`, an existing `codex/issue-` branch, and any open PR that references or closes the issue. +3. Recheck immediately before applying `loop:claimed`. +4. Record the issue snapshot and claim timestamp before implementation. + +## Branch and PR + +- Refresh `origin/dev` before creating `codex/issue-`. +- Push only the issue branch. +- Create a draft PR with `--base dev`. +- Include `Closes #` only when the PR fully satisfies the issue. +- Request `codeacme17` only after automated review and verification pass. +- Bind every review to immutable base and head SHAs. + +## Prohibited commands + +Never run: + +```text +gh pr merge +git push origin dev +git push origin main +git push --force origin dev +git push --force origin main +``` + +Do not enable auto-merge, dismiss owner reviews, resolve disputed owner comments, or modify branch-protection settings. + +## Communication + +Use the originating issue for pre-PR questions and the PR for post-publication questions. Mention `@codeacme17`, include the notification ID and run ID, and link the exact evidence or decision needed. Only decisions from the configured owner identity satisfy an owner gate. diff --git a/loops/issue-dev-loop/review/REVIEW.md b/loops/issue-dev-loop/review/REVIEW.md new file mode 100644 index 00000000..6ba24ca3 --- /dev/null +++ b/loops/issue-dev-loop/review/REVIEW.md @@ -0,0 +1,30 @@ +# Independent PR review contract + +Run after a draft PR exists and before requesting owner review. + +## Isolation + +Spawn `echo_ui_pr_reviewer` without executor conversation history. Give it the issue snapshot, acceptance criteria, repository rules, immutable base/head SHAs, diff, CI results, and evidence manifest. The reviewer must not edit code. + +## Output + +Return `PASS` or actionable findings. Each finding must include: + +- stable ID `RVW--` +- severity `P0`, `P1`, `P2`, or `P3` +- confidence `high`, `medium`, or `low` +- file and line when applicable +- concrete impact and evidence +- reproduction or failing test when practical +- expected resolution +- reviewed head SHA + +Do not emit formatter noise, personal style preferences, speculative concerns, unrelated refactors, or findings already enforced by a passing deterministic check. + +## Publication + +The orchestrator posts findings verbatim as one GitHub review and inline comments, adding `` for deduplication. It must not downgrade severity or omit findings. + +## Completion + +Bind every round to a head SHA. A new push invalidates the previous PASS. Allow at most two automated rounds. Unresolved P0/P1 findings block owner-ready status. diff --git a/loops/issue-dev-loop/review/comment.schema.json b/loops/issue-dev-loop/review/comment.schema.json new file mode 100644 index 00000000..e6ab5f6e --- /dev/null +++ b/loops/issue-dev-loop/review/comment.schema.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Independent loop review finding", + "type": "object", + "required": [ + "findingId", + "severity", + "confidence", + "headSha", + "problem", + "evidence", + "expectedResolution" + ], + "additionalProperties": false, + "properties": { + "findingId": { "type": "string", "pattern": "^RVW-[0-9]+-[0-9]+$" }, + "severity": { "enum": ["P0", "P1", "P2", "P3"] }, + "confidence": { "enum": ["high", "medium", "low"] }, + "headSha": { "type": "string", "minLength": 7 }, + "path": { "type": ["string", "null"] }, + "line": { "type": ["integer", "null"], "minimum": 1 }, + "problem": { "type": "string", "minLength": 1 }, + "evidence": { "type": "string", "minLength": 1 }, + "expectedResolution": { "type": "string", "minLength": 1 } + } +} diff --git a/loops/issue-dev-loop/review/response-policy.md b/loops/issue-dev-loop/review/response-policy.md new file mode 100644 index 00000000..aeb6d1a3 --- /dev/null +++ b/loops/issue-dev-loop/review/response-policy.md @@ -0,0 +1,15 @@ +# Review response policy + +Classify every automated finding as one of: + +- `accepted`: valid and in scope +- `rejected`: incorrect, already guaranteed, or outside the issue scope +- `needs-human`: product, API, security, release, or high-severity ambiguity +- `stale`: refers to a superseded head SHA or removed code +- `already-fixed`: current head demonstrably resolves it + +Accepted findings return to `$implement`. Reply only after pushing the fix and include commit SHA, commands, results, and evidence links. + +Rejected findings require a precise counterclaim and reproducible evidence. Do not reply with bare disagreement. An executor cannot unilaterally close a disputed P0 or P1; invoke `echo_ui_review_adjudicator`, then escalate `NEEDS_OWNER` outcomes. + +Do not delete review comments. Log classification, response time, response URL, commit, and evidence reference under the run ID. diff --git a/loops/issue-dev-loop/review/reviewer-prompt.md b/loops/issue-dev-loop/review/reviewer-prompt.md new file mode 100644 index 00000000..618df77d --- /dev/null +++ b/loops/issue-dev-loop/review/reviewer-prompt.md @@ -0,0 +1 @@ +Review the Echo UI PR diff between `` and `` as an independent, fresh-context reviewer. Use `$code-review`. Read only the supplied issue snapshot, acceptance criteria, repository instructions, diff, CI results, and evidence manifest. Follow `loops/issue-dev-loop/review/REVIEW.md`. Do not edit files or rely on executor conversation history. Return structured findings or `PASS`. diff --git a/loops/issue-dev-loop/schemas/event.schema.json b/loops/issue-dev-loop/schemas/event.schema.json new file mode 100644 index 00000000..f8a68f1a --- /dev/null +++ b/loops/issue-dev-loop/schemas/event.schema.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Issue development loop event", + "type": "object", + "required": ["schemaVersion", "runId", "type", "timestamp"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": 1 }, + "runId": { "type": "string", "minLength": 1 }, + "type": { "type": "string", "minLength": 1 }, + "timestamp": { "type": "string", "format": "date-time" }, + "status": { "type": ["string", "null"] }, + "payload": { "type": "object" } + } +} diff --git a/loops/issue-dev-loop/schemas/evidence.schema.json b/loops/issue-dev-loop/schemas/evidence.schema.json new file mode 100644 index 00000000..bdb0578b --- /dev/null +++ b/loops/issue-dev-loop/schemas/evidence.schema.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Issue development evidence manifest", + "type": "object", + "required": [ + "schemaVersion", + "runId", + "issueNumber", + "headSha", + "verdict", + "checks", + "screenshots" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": 1 }, + "runId": { "type": "string", "minLength": 1 }, + "issueNumber": { "type": "integer", "minimum": 1 }, + "headSha": { "type": "string", "minLength": 7 }, + "verdict": { "enum": ["passed", "failed", "blocked"] }, + "checks": { + "type": "array", + "items": { + "type": "object", + "required": ["command", "status", "startedAt", "finishedAt"], + "properties": { + "command": { "type": "string" }, + "status": { "enum": ["passed", "failed", "blocked"] }, + "startedAt": { "type": "string", "format": "date-time" }, + "finishedAt": { "type": "string", "format": "date-time" }, + "artifactUrl": { "type": ["string", "null"], "format": "uri" } + } + } + }, + "screenshots": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "scenario", "viewport", "artifactUrl"], + "properties": { + "name": { "type": "string" }, + "scenario": { "type": "string" }, + "viewport": { "type": "string" }, + "artifactUrl": { "type": "string", "format": "uri" } + } + } + }, + "review": { "type": ["object", "null"] }, + "limitations": { "type": "array", "items": { "type": "string" } } + } +} diff --git a/loops/issue-dev-loop/schemas/run.schema.json b/loops/issue-dev-loop/schemas/run.schema.json new file mode 100644 index 00000000..d6b8910d --- /dev/null +++ b/loops/issue-dev-loop/schemas/run.schema.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Issue development loop run", + "type": "object", + "required": [ + "schemaVersion", + "runId", + "issueNumber", + "issueTitle", + "issueUrl", + "baseBranch", + "branch", + "status", + "startedAt" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": 1 }, + "runId": { "type": "string", "minLength": 1 }, + "issueNumber": { "type": "integer", "minimum": 1 }, + "issueTitle": { "type": "string", "minLength": 1 }, + "issueUrl": { "type": "string", "format": "uri" }, + "baseBranch": { "const": "dev" }, + "branch": { "type": "string", "pattern": "^codex/issue-[0-9]+$" }, + "status": { + "enum": [ + "running", + "waiting_for_owner", + "awaiting_owner_review", + "completed", + "failed", + "blocked", + "cancelled" + ] + }, + "startedAt": { "type": "string", "format": "date-time" }, + "finishedAt": { "type": ["string", "null"], "format": "date-time" }, + "prUrl": { "type": ["string", "null"], "format": "uri" }, + "headSha": { "type": ["string", "null"] }, + "mergeSha": { "type": ["string", "null"] } + } +} diff --git a/loops/issue-dev-loop/screenshots/.gitignore b/loops/issue-dev-loop/screenshots/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/loops/issue-dev-loop/screenshots/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/loops/issue-dev-loop/scripts/loopctl.mjs b/loops/issue-dev-loop/scripts/loopctl.mjs new file mode 100644 index 00000000..fc5a0062 --- /dev/null +++ b/loops/issue-dev-loop/scripts/loopctl.mjs @@ -0,0 +1,110 @@ +#!/usr/bin/env node + +import { + appendEvent, + createNotification, + detectWork, + finalizeRun, + parseArguments, + startRun, + transitionRun, + validateLoop, +} from './runtime.mjs' + +function output(value) { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`) +} + +function parsePayload(value) { + if (!value) return {} + const parsed = JSON.parse(value) + if (parsed === null || Array.isArray(parsed) || typeof parsed !== 'object') { + throw new Error('--payload must be a JSON object') + } + return parsed +} + +async function main() { + const [command, ...rest] = process.argv.slice(2) + const args = parseArguments(rest) + + switch (command) { + case 'start': + output( + await startRun({ + issueNumber: args.issue, + issueTitle: args.title, + issueUrl: args.url, + now: args.now ? new Date(args.now) : undefined, + }), + ) + break + case 'event': + output( + await appendEvent({ + runId: args['run-id'], + type: args.type, + status: args.status ?? null, + payload: parsePayload(args.payload), + }), + ) + break + case 'transition': + output( + await transitionRun({ + runId: args['run-id'], + status: args.status, + prUrl: args['pr-url'] ?? null, + headSha: args['head-sha'] ?? null, + mergeSha: args['merge-sha'] ?? null, + }), + ) + break + case 'finalize': + output( + await finalizeRun({ + runId: args['run-id'], + status: args.status, + prUrl: args['pr-url'] ?? null, + headSha: args['head-sha'] ?? null, + mergeSha: args['merge-sha'] ?? null, + }), + ) + break + case 'notify': + output( + await createNotification({ + runId: args['run-id'], + type: args.type, + summary: args.summary, + requestedAction: args.action, + targetUrl: args['target-url'] ?? null, + evidenceUrl: args['evidence-url'] ?? null, + blocking: Boolean(args.blocking), + dryRun: Boolean(args['dry-run']), + }), + ) + break + case 'detect-work': + output( + await detectWork({ + issuesFile: args['issues-file'], + pullRequestsFile: args['prs-file'], + repo: args.repo, + }), + ) + break + case 'validate': + output(await validateLoop()) + break + default: + throw new Error( + 'usage: loopctl.mjs [options]', + ) + } +} + +main().catch((error) => { + process.stderr.write(`${error.message}\n`) + process.exitCode = 1 +}) diff --git a/loops/issue-dev-loop/scripts/runtime.mjs b/loops/issue-dev-loop/scripts/runtime.mjs new file mode 100644 index 00000000..7ebd3923 --- /dev/null +++ b/loops/issue-dev-loop/scripts/runtime.mjs @@ -0,0 +1,640 @@ +import { execFile } from 'node:child_process' +import { randomBytes } from 'node:crypto' +import { appendFile, mkdir, readFile, readdir, rename, stat, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { promisify } from 'node:util' +import { fileURLToPath } from 'node:url' + +const execFileAsync = promisify(execFile) +const moduleDirectory = path.dirname(fileURLToPath(import.meta.url)) + +export const DEFAULT_LOOP_ROOT = path.resolve(moduleDirectory, '..') + +const PAUSED_STATUSES = new Set(['awaiting_owner_review', 'waiting_for_owner']) + +const TERMINAL_STATUSES = new Set(['completed', 'failed', 'blocked', 'cancelled']) + +const RUN_STATUSES = new Set(['running', ...PAUSED_STATUSES, ...TERMINAL_STATUSES]) + +const PRIORITY = new Map([ + ['priority:critical', 0], + ['priority:high', 1], + ['priority:medium', 2], + ['priority:low', 3], +]) + +function assertNonEmpty(value, name) { + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`${name} must be a non-empty string`) + } + return value.trim() +} + +function assertIssueNumber(value) { + const parsed = Number(value) + if (!Number.isInteger(parsed) || parsed < 1) { + throw new Error('issueNumber must be a positive integer') + } + return parsed +} + +function assertRunId(runId) { + const normalized = assertNonEmpty(runId, 'runId') + if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(normalized)) { + throw new Error('runId contains unsafe characters') + } + return normalized +} + +function timestampToken(now) { + return now + .toISOString() + .replace(/[-:]/g, '') + .replace(/\.\d{3}Z$/, 'Z') +} + +export function makeRunId({ issueNumber, now = new Date(), entropy } = {}) { + const issue = assertIssueNumber(issueNumber) + const suffix = entropy ?? randomBytes(3).toString('hex') + if (!/^[A-Za-z0-9]+$/.test(suffix)) { + throw new Error('entropy must be alphanumeric') + } + return `${timestampToken(now)}-issue-${issue}-${suffix.toLowerCase()}` +} + +export function parseArguments(argv) { + const values = { _: [] } + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index] + if (!token.startsWith('--')) { + values._.push(token) + continue + } + const key = token.slice(2) + const next = argv[index + 1] + if (next === undefined || next.startsWith('--')) { + values[key] = true + continue + } + values[key] = next + index += 1 + } + return values +} + +async function pathExists(target) { + try { + await stat(target) + return true + } catch (error) { + if (error?.code === 'ENOENT') return false + throw error + } +} + +async function readJson(target) { + return JSON.parse(await readFile(target, 'utf8')) +} + +async function writeJson(target, value) { + await mkdir(path.dirname(target), { recursive: true }) + const temporary = `${target}.tmp-${process.pid}-${randomBytes(3).toString('hex')}` + await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, 'utf8') + await rename(temporary, target) +} + +async function appendJsonLine(target, value) { + await mkdir(path.dirname(target), { recursive: true }) + await appendFile(target, `${JSON.stringify(value)}\n`, 'utf8') +} + +function runDirectory(loopRoot, runId) { + return path.join(loopRoot, 'logs', 'runs', assertRunId(runId)) +} + +function replaceTemplate(template, replacements) { + let output = template + for (const [key, value] of Object.entries(replacements)) { + output = output.replaceAll(`{{${key}}}`, String(value ?? '')) + } + return output +} + +export async function startRun({ + loopRoot = DEFAULT_LOOP_ROOT, + issueNumber, + issueTitle, + issueUrl, + now = new Date(), + entropy, +} = {}) { + const issue = assertIssueNumber(issueNumber) + const title = assertNonEmpty(issueTitle, 'issueTitle') + const url = assertNonEmpty(issueUrl, 'issueUrl') + const runId = makeRunId({ issueNumber: issue, now, entropy }) + const runPath = runDirectory(loopRoot, runId) + + if (await pathExists(runPath)) { + throw new Error(`run already exists: ${runId}`) + } + + const directories = [ + runPath, + path.join(loopRoot, 'handoffs', runId), + path.join(loopRoot, 'screenshots', runId, 'before'), + path.join(loopRoot, 'screenshots', runId, 'after'), + path.join(loopRoot, 'evidence', runId, 'test-results'), + ] + await Promise.all(directories.map((directory) => mkdir(directory, { recursive: true }))) + + const run = { + schemaVersion: 1, + runId, + issueNumber: issue, + issueTitle: title, + issueUrl: url, + baseBranch: 'dev', + branch: `codex/issue-${issue}`, + status: 'running', + startedAt: now.toISOString(), + finishedAt: null, + prUrl: null, + headSha: null, + mergeSha: null, + } + + await writeJson(path.join(runPath, 'run.json'), run) + await appendEvent({ + loopRoot, + runId, + type: 'loop_started', + status: 'running', + payload: { issueNumber: issue, branch: run.branch }, + now, + }) + + const templatePath = path.join(loopRoot, 'templates', 'implementation-brief.md') + const template = await readFile(templatePath, 'utf8') + const brief = replaceTemplate(template, { + RUN_ID: runId, + ISSUE_NUMBER: issue, + ISSUE_TITLE: title, + ISSUE_URL: url, + }) + const briefPath = path.join(loopRoot, 'handoffs', runId, 'implementation-brief.md') + await writeFile(briefPath, brief, 'utf8') + + return { run, briefPath, runPath } +} + +export async function appendEvent({ + loopRoot = DEFAULT_LOOP_ROOT, + runId, + type, + status = null, + payload = {}, + now = new Date(), +} = {}) { + const normalizedRunId = assertRunId(runId) + const eventType = assertNonEmpty(type, 'type') + const runPath = runDirectory(loopRoot, normalizedRunId) + if (!(await pathExists(path.join(runPath, 'run.json')))) { + throw new Error(`unknown run: ${normalizedRunId}`) + } + if (payload === null || Array.isArray(payload) || typeof payload !== 'object') { + throw new Error('payload must be an object') + } + + const event = { + schemaVersion: 1, + runId: normalizedRunId, + type: eventType, + timestamp: now.toISOString(), + status, + payload, + } + await appendJsonLine(path.join(runPath, 'events.jsonl'), event) + return event +} + +async function readEvents(loopRoot, runId) { + const target = path.join(runDirectory(loopRoot, runId), 'events.jsonl') + const contents = await readFile(target, 'utf8') + return contents + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) +} + +function hasPassedEvent(events, type) { + return events.some( + (event) => + event.type === type && + (event.status === 'passed' || + event.payload?.verdict === 'passed' || + event.payload?.verdict === 'PASS'), + ) +} + +export async function transitionRun({ + loopRoot = DEFAULT_LOOP_ROOT, + runId, + status, + prUrl = null, + headSha = null, + mergeSha = null, + now = new Date(), +} = {}) { + const normalizedRunId = assertRunId(runId) + if (!RUN_STATUSES.has(status)) { + throw new Error(`invalid run status: ${status}`) + } + + const runPath = runDirectory(loopRoot, normalizedRunId) + const runFile = path.join(runPath, 'run.json') + const run = await readJson(runFile) + if (run.finishedAt !== null) { + throw new Error(`run is already finalized: ${normalizedRunId}`) + } + if (run.status === status) { + throw new Error(`run already has status: ${status}`) + } + const events = await readEvents(loopRoot, normalizedRunId) + + if (status === 'awaiting_owner_review') { + if (!prUrl || !headSha) { + throw new Error('awaiting_owner_review requires prUrl and headSha') + } + if (!hasPassedEvent(events, 'verification_completed')) { + throw new Error('awaiting_owner_review requires passed verification_completed') + } + if (!hasPassedEvent(events, 'review_completed')) { + throw new Error('awaiting_owner_review requires passed review_completed') + } + } + + if (status === 'completed') { + if (!mergeSha || !events.some((event) => event.type === 'pr_merged')) { + throw new Error('completed requires an observed pr_merged event and mergeSha') + } + } + + const transitioned = { + ...run, + status, + finishedAt: TERMINAL_STATUSES.has(status) ? now.toISOString() : null, + prUrl: prUrl ?? run.prUrl, + headSha: headSha ?? run.headSha, + mergeSha: mergeSha ?? run.mergeSha, + } + await writeJson(runFile, transitioned) + + await appendEvent({ + loopRoot, + runId: normalizedRunId, + type: TERMINAL_STATUSES.has(status) ? 'run_finalized' : 'run_status_changed', + status, + payload: { previousStatus: run.status }, + now, + }) + + if (!TERMINAL_STATUSES.has(status)) { + return transitioned + } + + const summaryTemplate = await readFile(path.join(loopRoot, 'templates', 'run-summary.md'), 'utf8') + const summary = replaceTemplate(summaryTemplate, { + RUN_ID: normalizedRunId, + ISSUE_NUMBER: run.issueNumber, + STATUS: status, + STARTED_AT: run.startedAt, + FINISHED_AT: transitioned.finishedAt, + PR_URL: transitioned.prUrl ?? 'N/A', + HEAD_SHA: transitioned.headSha ?? 'N/A', + MERGE_SHA: transitioned.mergeSha ?? 'N/A', + }) + await writeFile(path.join(runPath, 'summary.md'), summary, 'utf8') + + await appendJsonLine(path.join(loopRoot, 'logs', 'index.jsonl'), { + schemaVersion: 1, + event: 'run_finalized', + runId: normalizedRunId, + issueNumber: run.issueNumber, + status, + startedAt: run.startedAt, + finishedAt: transitioned.finishedAt, + prUrl: transitioned.prUrl, + headSha: transitioned.headSha, + mergeSha: transitioned.mergeSha, + }) + + return transitioned +} + +export async function finalizeRun(options = {}) { + if (!TERMINAL_STATUSES.has(options.status)) { + throw new Error(`invalid final status: ${options.status}`) + } + return transitionRun(options) +} + +function labelNames(issue) { + return new Set( + (issue.labels ?? []).map((label) => (typeof label === 'string' ? label : label.name)), + ) +} + +function issuePriority(issue) { + const labels = labelNames(issue) + let rank = 4 + for (const [label, value] of PRIORITY) { + if (labels.has(label)) rank = Math.min(rank, value) + } + return rank +} + +function pullRequestClaimsIssue(pullRequest, issueNumber) { + if (pullRequest.headRefName === `codex/issue-${issueNumber}`) return true + const searchable = `${pullRequest.title ?? ''}\n${pullRequest.body ?? ''}` + return new RegExp( + `(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)?\\s*#${issueNumber}(?!\\d)`, + 'i', + ).test(searchable) +} + +export function selectIssue({ issues = [], pullRequests = [] } = {}) { + const eligible = issues.filter((issue) => { + const labels = labelNames(issue) + return ( + labels.has('codex-ready') && + !labels.has('loop:claimed') && + !pullRequests.some((pullRequest) => pullRequestClaimsIssue(pullRequest, issue.number)) + ) + }) + + eligible.sort((left, right) => { + const priorityDifference = issuePriority(left) - issuePriority(right) + if (priorityDifference !== 0) return priorityDifference + const leftCreated = Date.parse(left.createdAt ?? 0) + const rightCreated = Date.parse(right.createdAt ?? 0) + if (leftCreated !== rightCreated) return leftCreated - rightCreated + return left.number - right.number + }) + + const issue = eligible[0] + return issue ? { hasWork: true, issue } : { hasWork: false, issue: null } +} + +async function loadJsonFile(target) { + return JSON.parse(await readFile(path.resolve(target), 'utf8')) +} + +export async function detectWork({ issuesFile, pullRequestsFile, repo } = {}) { + let issues + let pullRequests + + if (issuesFile) { + issues = await loadJsonFile(issuesFile) + } else { + const argumentsList = [ + 'issue', + 'list', + '--state', + 'open', + '--label', + 'codex-ready', + '--limit', + '100', + '--json', + 'number,title,url,labels,createdAt', + ] + if (repo) argumentsList.push('--repo', repo) + const result = await execFileAsync('gh', argumentsList, { maxBuffer: 1024 * 1024 }) + issues = JSON.parse(result.stdout) + } + + if (pullRequestsFile) { + pullRequests = await loadJsonFile(pullRequestsFile) + } else { + const argumentsList = [ + 'pr', + 'list', + '--state', + 'open', + '--limit', + '100', + '--json', + 'number,title,url,headRefName,body', + ] + if (repo) argumentsList.push('--repo', repo) + const result = await execFileAsync('gh', argumentsList, { maxBuffer: 1024 * 1024 }) + pullRequests = JSON.parse(result.stdout) + } + + return selectIssue({ issues, pullRequests }) +} + +function parseGitHubTarget(targetUrl) { + if (!targetUrl) return null + const parsed = new URL(targetUrl) + if (parsed.hostname !== 'github.com') return null + const match = parsed.pathname.match(/^\/([^/]+)\/([^/]+)\/(?:issues|pull)\/(\d+)\/?$/) + if (!match) return null + return { owner: match[1], repo: match[2], number: Number(match[3]) } +} + +function notificationBody(notification, owner) { + const evidence = notification.evidenceUrl ? `\n\nEvidence: ${notification.evidenceUrl}` : '' + return [ + `@${owner} **${notification.type}**`, + '', + notification.summary, + '', + `Requested action: ${notification.requestedAction}`, + '', + `Notification: \`${notification.notificationId}\` · Run: \`${notification.runId}\`${evidence}`, + ].join('\n') +} + +async function defaultGitHubComment(target, body) { + await execFileAsync( + 'gh', + [ + 'api', + `repos/${target.owner}/${target.repo}/issues/${target.number}/comments`, + '--method', + 'POST', + '-f', + `body=${body}`, + ], + { maxBuffer: 1024 * 1024 }, + ) +} + +export async function createNotification({ + loopRoot = DEFAULT_LOOP_ROOT, + runId, + type, + summary, + requestedAction, + targetUrl = null, + evidenceUrl = null, + blocking = false, + now = new Date(), + entropy, + dryRun = false, + environment = process.env, + fetchImplementation = globalThis.fetch, + githubComment = defaultGitHubComment, +} = {}) { + const normalizedRunId = assertRunId(runId) + const channelRoot = path.resolve(loopRoot, '..', '_shared', 'owner-channel') + const channel = await readJson(path.join(channelRoot, 'channel.json')) + const suffix = (entropy ?? randomBytes(3).toString('hex')).toUpperCase() + const notificationId = `NTF-${timestampToken(now).replace('Z', '')}-${suffix}` + const notification = { + schemaVersion: 1, + notificationId, + loop: 'issue-dev-loop', + runId: normalizedRunId, + type: assertNonEmpty(type, 'type'), + blocking: Boolean(blocking), + summary: assertNonEmpty(summary, 'summary'), + requestedAction: assertNonEmpty(requestedAction, 'requestedAction'), + targetUrl, + evidenceUrl, + createdAt: now.toISOString(), + delivery: { + github: targetUrl ? 'pending' : 'not_requested', + webhook: environment[channel.webhookEnvironmentVariable] ? 'pending' : 'not_configured', + }, + } + + const outboxFile = path.join(channelRoot, 'outbox', `${notificationId}.json`) + await writeJson(outboxFile, notification) + + if (dryRun) { + notification.delivery.github = targetUrl ? 'dry_run' : 'not_requested' + notification.delivery.webhook = environment[channel.webhookEnvironmentVariable] + ? 'dry_run' + : 'not_configured' + } else { + const target = parseGitHubTarget(targetUrl) + if (target) { + try { + await githubComment(target, notificationBody(notification, channel.ownerGitHubLogin)) + notification.delivery.github = 'delivered' + } catch (error) { + notification.delivery.github = `failed: ${error.message}` + } + } else if (targetUrl) { + notification.delivery.github = 'failed: target is not a GitHub issue or pull request URL' + } + + const webhookUrl = environment[channel.webhookEnvironmentVariable] + if (webhookUrl) { + try { + const response = await fetchImplementation(webhookUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(notification), + }) + notification.delivery.webhook = response.ok + ? 'delivered' + : `failed: HTTP ${response.status}` + } catch (error) { + notification.delivery.webhook = `failed: ${error.message}` + } + } + } + + await writeJson(outboxFile, notification) + const delivered = Object.values(notification.delivery).some((value) => + ['delivered', 'dry_run'].includes(value), + ) + await appendEvent({ + loopRoot, + runId: normalizedRunId, + type: delivered ? 'owner_notified' : 'notification_failed', + status: delivered ? 'delivered' : 'failed', + payload: { notificationId, notificationType: type, delivery: notification.delivery }, + now, + }) + + if (blocking && !delivered) { + throw new Error(`blocking notification was not delivered: ${notificationId}`) + } + return notification +} + +async function collectFiles(root, output = []) { + const entries = await readdir(root, { withFileTypes: true }) + for (const entry of entries) { + if (['node_modules', '.git'].includes(entry.name)) continue + const target = path.join(root, entry.name) + if (entry.isDirectory()) await collectFiles(target, output) + else output.push(target) + } + return output +} + +export async function validateLoop({ loopRoot = DEFAULT_LOOP_ROOT } = {}) { + const required = [ + 'SKILL.md', + 'LOOP.md', + 'state.md', + 'dependencies.md', + 'agents/openai.yaml', + 'review/REVIEW.md', + 'review/response-policy.md', + 'triggers/TRIGGER.md', + 'templates/implementation-brief.md', + 'templates/pr-body.md', + 'schemas/event.schema.json', + 'schemas/run.schema.json', + 'schemas/evidence.schema.json', + 'logs/index.jsonl', + ] + + const missing = [] + for (const relative of required) { + if (!(await pathExists(path.join(loopRoot, relative)))) missing.push(relative) + } + if (missing.length > 0) { + throw new Error(`missing required loop files: ${missing.join(', ')}`) + } + + const jsonFiles = (await collectFiles(loopRoot)).filter((target) => target.endsWith('.json')) + const sharedChannel = path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json') + jsonFiles.push(sharedChannel) + for (const target of jsonFiles) await readJson(target) + + const contract = await readFile(path.join(loopRoot, 'LOOP.md'), 'utf8') + const skill = await readFile(path.join(loopRoot, 'SKILL.md'), 'utf8') + const requiredContractPhrases = [ + 'draft PR targeting `dev`', + 'approve, auto-merge, or merge any PR', + 'Only an observed owner merge', + 'No eligible work is a successful no-op', + ] + for (const phrase of requiredContractPhrases) { + if (!contract.includes(phrase)) throw new Error(`LOOP.md is missing invariant: ${phrase}`) + } + for (const phrase of ['$implement', 'echo_ui_pr_reviewer', 'pnpm verify']) { + if (!skill.includes(phrase)) + throw new Error(`SKILL.md is missing runtime dependency: ${phrase}`) + } + + const textualFiles = (await collectFiles(loopRoot)).filter((target) => + /\.(?:md|json|ya?ml|toml|mjs)$/.test(target), + ) + const macUserRootMarker = ['', 'Users', ''].join('/') + for (const target of textualFiles) { + const contents = await readFile(target, 'utf8') + if (contents.includes(macUserRootMarker)) { + throw new Error(`machine-specific absolute path found in ${path.relative(loopRoot, target)}`) + } + } + + return { valid: true, checkedFiles: required.length + jsonFiles.length } +} diff --git a/loops/issue-dev-loop/state.md b/loops/issue-dev-loop/state.md new file mode 100644 index 00000000..75e18ef1 --- /dev/null +++ b/loops/issue-dev-loop/state.md @@ -0,0 +1,37 @@ +# Issue development loop state + +Updated: 2026-07-22 + +## Configuration + +- Owner: `codeacme17` +- Issue label: `codex-ready` +- Claim label: `loop:claimed` +- Development base: `dev` +- Protected release branch: `main` +- Maximum implementation repairs: 2 +- Maximum review rounds: 2 + +## Active runs + +None. + +## Open loop PRs + +None. + +## Blockers + +- Configure the GitHub authentication used by unattended runs. +- Optionally configure `ECHO_UI_LOOP_OWNER_WEBHOOK_URL` for a push-channel mirror; GitHub mentions remain the canonical baseline channel. + +## Follow-ups + +- Forward-test the loop on the first low-risk `codex-ready` issue. +- Review notification noise and evidence retention after five runs. + +## Learned constraints + +- Feature PRs target `dev`; the repository guard only permits `dev` to target `main`. +- The repository requires Node 24 and pnpm 10. +- `pnpm verify` is the authoritative full validation command. diff --git a/loops/issue-dev-loop/templates/implementation-brief.md b/loops/issue-dev-loop/templates/implementation-brief.md new file mode 100644 index 00000000..3d5d2fb3 --- /dev/null +++ b/loops/issue-dev-loop/templates/implementation-brief.md @@ -0,0 +1,27 @@ +# Implementation brief + +- Run ID: `{{RUN_ID}}` +- Issue: #{{ISSUE_NUMBER}} — {{ISSUE_TITLE}} +- Issue URL: {{ISSUE_URL}} +- Base branch: `dev` +- Branch: `codex/issue-{{ISSUE_NUMBER}}` + +## Acceptance criteria + + + +## In scope + +## Out of scope + +## Pre-agreed TDD seams + +## Required targeted checks + +## Required UI evidence + +## Risks and owner-confirmation boundaries + +## Stop conditions + +Stop after committing. Do not push, create a PR, or merge. diff --git a/loops/issue-dev-loop/templates/pr-body.md b/loops/issue-dev-loop/templates/pr-body.md new file mode 100644 index 00000000..cb944038 --- /dev/null +++ b/loops/issue-dev-loop/templates/pr-body.md @@ -0,0 +1,22 @@ +Closes #{{ISSUE_NUMBER}} + +## Loop metadata + +- Run ID: `{{RUN_ID}}` +- Base SHA: `{{BASE_SHA}}` +- Head SHA: `{{HEAD_SHA}}` +- Risk: {{RISK}} + +## Changes + +## Acceptance criteria + +## Verification + +## Evidence + +## Independent review + +## Known limitations + +> This PR must be reviewed and merged by `@codeacme17`. The loop has no approval or merge authority. diff --git a/loops/issue-dev-loop/templates/run-summary.md b/loops/issue-dev-loop/templates/run-summary.md new file mode 100644 index 00000000..0d4be16f --- /dev/null +++ b/loops/issue-dev-loop/templates/run-summary.md @@ -0,0 +1,17 @@ +# Run {{RUN_ID}} + +- Issue: #{{ISSUE_NUMBER}} +- Status: {{STATUS}} +- Started: {{STARTED_AT}} +- Finished: {{FINISHED_AT}} +- PR: {{PR_URL}} +- Head SHA: {{HEAD_SHA}} +- Owner merge SHA: {{MERGE_SHA}} + +## Outcome + +## Verification + +## Review and owner decisions + +## Follow-ups diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs new file mode 100644 index 00000000..567e52d6 --- /dev/null +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -0,0 +1,243 @@ +import assert from 'node:assert/strict' +import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +import { + appendEvent, + createNotification, + finalizeRun, + selectIssue, + startRun, + transitionRun, + validateLoop, +} from '../scripts/runtime.mjs' + +const testDirectory = path.dirname(fileURLToPath(import.meta.url)) +const repositoryLoopRoot = path.resolve(testDirectory, '..') + +async function createFixture() { + const parent = await mkdtemp(path.join(os.tmpdir(), 'echo-ui-loop-test-')) + const loopRoot = path.join(parent, 'issue-dev-loop') + const channelRoot = path.join(parent, '_shared', 'owner-channel') + await Promise.all([ + mkdir(path.join(loopRoot, 'templates'), { recursive: true }), + mkdir(path.join(loopRoot, 'logs', 'runs'), { recursive: true }), + mkdir(path.join(channelRoot, 'outbox'), { recursive: true }), + ]) + for (const name of ['implementation-brief.md', 'run-summary.md']) { + const contents = await readFile(path.join(repositoryLoopRoot, 'templates', name), 'utf8') + await writeFile(path.join(loopRoot, 'templates', name), contents, 'utf8') + } + await writeFile( + path.join(loopRoot, 'logs', 'index.jsonl'), + '{"schemaVersion":1,"event":"loop_initialized"}\n', + 'utf8', + ) + await writeFile( + path.join(channelRoot, 'channel.json'), + `${JSON.stringify({ + schemaVersion: 1, + ownerGitHubLogin: 'codeacme17', + webhookEnvironmentVariable: 'TEST_LOOP_WEBHOOK_URL', + })}\n`, + 'utf8', + ) + return { loopRoot, channelRoot } +} + +test('selectIssue chooses the highest-priority eligible unclaimed issue', () => { + const result = selectIssue({ + issues: [ + { + number: 11, + title: 'Already claimed', + createdAt: '2026-01-01T00:00:00Z', + labels: [{ name: 'codex-ready' }, { name: 'loop:claimed' }], + }, + { + number: 12, + title: 'Medium', + createdAt: '2026-01-01T00:00:00Z', + labels: [{ name: 'codex-ready' }, { name: 'priority:medium' }], + }, + { + number: 13, + title: 'Critical', + createdAt: '2026-02-01T00:00:00Z', + labels: [{ name: 'codex-ready' }, { name: 'priority:critical' }], + }, + { + number: 14, + title: 'Critical with open PR', + createdAt: '2025-01-01T00:00:00Z', + labels: [{ name: 'codex-ready' }, { name: 'priority:critical' }], + }, + ], + pullRequests: [{ headRefName: 'codex/issue-14', title: 'Fix issue', body: '' }], + }) + + assert.equal(result.hasWork, true) + assert.equal(result.issue.number, 13) +}) + +test('startRun creates one correlated run, handoff, and evidence directories', async () => { + const { loopRoot } = await createFixture() + const result = await startRun({ + loopRoot, + issueNumber: 128, + issueTitle: 'Improve Player focus behavior', + issueUrl: 'https://github.com/codeacme17/echo-ui/issues/128', + now: new Date('2026-07-22T15:30:12Z'), + entropy: 'a1b2c3', + }) + + assert.equal(result.run.runId, '20260722T153012Z-issue-128-a1b2c3') + assert.equal(result.run.baseBranch, 'dev') + assert.equal(result.run.branch, 'codex/issue-128') + const brief = await readFile(result.briefPath, 'utf8') + assert.match(brief, /Issue: #128/) + assert.match(brief, /Stop after committing/) + const events = await readFile(path.join(result.runPath, 'events.jsonl'), 'utf8') + assert.match(events, /"type":"loop_started"/) +}) + +test('owner-ready transition requires verification and review but remains resumable', async () => { + const { loopRoot } = await createFixture() + const { run } = await startRun({ + loopRoot, + issueNumber: 129, + issueTitle: 'Add keyboard test', + issueUrl: 'https://github.com/codeacme17/echo-ui/issues/129', + now: new Date('2026-07-22T16:00:00Z'), + entropy: 'abc123', + }) + + await assert.rejects( + transitionRun({ + loopRoot, + runId: run.runId, + status: 'awaiting_owner_review', + prUrl: 'https://github.com/codeacme17/echo-ui/pull/200', + headSha: 'abcdef1234567', + }), + /passed verification_completed/, + ) + + await appendEvent({ + loopRoot, + runId: run.runId, + type: 'verification_completed', + status: 'passed', + payload: { verdict: 'passed' }, + }) + await appendEvent({ + loopRoot, + runId: run.runId, + type: 'review_completed', + status: 'passed', + payload: { verdict: 'PASS' }, + }) + + const paused = await transitionRun({ + loopRoot, + runId: run.runId, + status: 'awaiting_owner_review', + prUrl: 'https://github.com/codeacme17/echo-ui/pull/200', + headSha: 'abcdef1234567', + now: new Date('2026-07-22T17:00:00Z'), + }) + assert.equal(paused.status, 'awaiting_owner_review') + assert.equal(paused.finishedAt, null) + + await appendEvent({ + loopRoot, + runId: run.runId, + type: 'pr_merged', + status: 'observed', + payload: { actor: 'codeacme17', mergeSha: '1234567890abcdef' }, + }) + const finalized = await finalizeRun({ + loopRoot, + runId: run.runId, + status: 'completed', + mergeSha: '1234567890abcdef', + now: new Date('2026-07-23T09:00:00Z'), + }) + assert.equal(finalized.status, 'completed') + assert.equal(finalized.mergeSha, '1234567890abcdef') + assert.equal(finalized.finishedAt, '2026-07-23T09:00:00.000Z') +}) + +test('completed finalization requires an observed owner merge', async () => { + const { loopRoot } = await createFixture() + const { run } = await startRun({ + loopRoot, + issueNumber: 130, + issueTitle: 'Owner gate', + issueUrl: 'https://github.com/codeacme17/echo-ui/issues/130', + entropy: 'deaf01', + }) + await assert.rejects( + finalizeRun({ + loopRoot, + runId: run.runId, + status: 'completed', + mergeSha: '1234567890abcdef', + }), + /observed pr_merged/, + ) + + await appendEvent({ + loopRoot, + runId: run.runId, + type: 'pr_merged', + status: 'observed', + payload: { actor: 'codeacme17', mergeSha: '1234567890abcdef' }, + }) + const finalized = await finalizeRun({ + loopRoot, + runId: run.runId, + status: 'completed', + mergeSha: '1234567890abcdef', + }) + assert.equal(finalized.mergeSha, '1234567890abcdef') +}) + +test('notification dry-run stages an auditable owner message', async () => { + const { loopRoot, channelRoot } = await createFixture() + const { run } = await startRun({ + loopRoot, + issueNumber: 131, + issueTitle: 'Notify owner', + issueUrl: 'https://github.com/codeacme17/echo-ui/issues/131', + entropy: 'b00b1e', + }) + const notification = await createNotification({ + loopRoot, + runId: run.runId, + type: 'pr_ready_for_review', + summary: 'PR is verified and ready', + requestedAction: 'Review and merge or request changes', + targetUrl: 'https://github.com/codeacme17/echo-ui/pull/201', + blocking: true, + dryRun: true, + now: new Date('2026-07-22T18:00:00Z'), + entropy: 'c0ffee', + environment: {}, + }) + + assert.equal(notification.notificationId, 'NTF-20260722T180000-C0FFEE') + assert.equal(notification.delivery.github, 'dry_run') + const staged = JSON.parse( + await readFile(path.join(channelRoot, 'outbox', `${notification.notificationId}.json`), 'utf8'), + ) + assert.equal(staged.runId, run.runId) +}) + +test('repository loop package satisfies its structural invariants', async () => { + const result = await validateLoop({ loopRoot: repositoryLoopRoot }) + assert.equal(result.valid, true) +}) diff --git a/loops/issue-dev-loop/triggers/TRIGGER.md b/loops/issue-dev-loop/triggers/TRIGGER.md new file mode 100644 index 00000000..d8f4be9b --- /dev/null +++ b/loops/issue-dev-loop/triggers/TRIGGER.md @@ -0,0 +1,13 @@ +# Combo trigger + +Run the deterministic detector before waking Codex: + +```bash +node loops/issue-dev-loop/triggers/detect-work.mjs +``` + +The command prints one JSON object. Start a Codex turn only when `hasWork` is `true`; pass the returned issue number and URL to `$issue-dev-loop`. + +For a scheduled Codex automation, use [`codex-automation-prompt.md`](./codex-automation-prompt.md). The machine and repository credentials must remain available for local scheduled runs. Use a dedicated worktree for every issue. + +An event-driven runner may call the same detector after an issue label webhook. The webhook is only a wake-up hint: selection and idempotency checks still run. diff --git a/loops/issue-dev-loop/triggers/codex-automation-prompt.md b/loops/issue-dev-loop/triggers/codex-automation-prompt.md new file mode 100644 index 00000000..65c3c4c0 --- /dev/null +++ b/loops/issue-dev-loop/triggers/codex-automation-prompt.md @@ -0,0 +1,3 @@ +Use `$issue-dev-loop` in this repository. + +First run `node loops/issue-dev-loop/triggers/detect-work.mjs`. If it returns `hasWork: false`, report a quiet no-op and stop. If it returns an issue, execute one bounded issue-dev-loop run for that exact issue. Preserve owner-only review and merge, use `$implement` for product code, use a fresh read-only reviewer after the draft PR, and notify the owner for every blocking event or ready PR. diff --git a/loops/issue-dev-loop/triggers/detect-work.mjs b/loops/issue-dev-loop/triggers/detect-work.mjs new file mode 100644 index 00000000..da645194 --- /dev/null +++ b/loops/issue-dev-loop/triggers/detect-work.mjs @@ -0,0 +1,16 @@ +#!/usr/bin/env node + +import { detectWork, parseArguments } from '../scripts/runtime.mjs' + +const args = parseArguments(process.argv.slice(2)) + +detectWork({ + issuesFile: args['issues-file'], + pullRequestsFile: args['prs-file'], + repo: args.repo, +}) + .then((result) => process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)) + .catch((error) => { + process.stderr.write(`${error.message}\n`) + process.exitCode = 1 + }) diff --git a/package.json b/package.json index 5ff7a1d9..5d095d7c 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,9 @@ "test:docs": "pnpm test:tailwind-docs && node scripts/verify-nextra-output.mjs && node scripts/smoke-nextra-routes.mjs && node scripts/verify-docs-ui.mjs", "verify:contract": "node scripts/verify-release-contract.mjs", "verify:release": "pnpm verify:contract && pnpm test:package", + "loop:issue-dev": "node loops/issue-dev-loop/scripts/loopctl.mjs", + "loop:issue-dev:validate": "node loops/issue-dev-loop/scripts/loopctl.mjs validate", + "loop:issue-dev:test": "node --test loops/issue-dev-loop/tests/*.test.mjs", "verify": "pnpm verify:contract && pnpm lint && pnpm typecheck && pnpm typecheck:example && pnpm typecheck:docs && pnpm test && node scripts/verify-package-artifact.mjs && pnpm build:example && pnpm test:tone-examples && pnpm build:docs && pnpm test:docs", "verify:frozen": "pnpm install --frozen-lockfile && pnpm verify" }, From 998a9abcae257032f7942f1046458c37084eec81 Mon Sep 17 00:00:00 2001 From: leyoonafr Date: Wed, 22 Jul 2026 22:20:35 +0800 Subject: [PATCH 02/41] fix: harden loop review and owner gates --- .codex/agents/echo-ui-loop-evolver.toml | 12 + .github/workflows/issue-dev-loop-evidence.yml | 92 +++ loops/README.md | 2 +- loops/_shared/owner-channel/CHANNEL.md | 2 +- loops/issue-dev-loop/LOOP.md | 12 +- loops/issue-dev-loop/SKILL.md | 13 +- loops/issue-dev-loop/dependencies.md | 1 + loops/issue-dev-loop/evidence/.gitignore | 6 +- loops/issue-dev-loop/evolve/EVOLVE.md | 8 +- loops/issue-dev-loop/evolve/metrics.json | 6 + .../issue-dev-loop/evolve/requests/.gitignore | 1 + loops/issue-dev-loop/handoffs/.gitignore | 3 +- .../references/evidence-policy.md | 6 +- .../references/github-operations.md | 11 + loops/issue-dev-loop/review/REVIEW.md | 4 +- .../issue-dev-loop/review/response-policy.md | 2 + .../issue-dev-loop/review/result.schema.json | 79 +++ .../schemas/evidence.schema.json | 21 +- loops/issue-dev-loop/screen-shots/.gitignore | 2 + loops/issue-dev-loop/screenshots/.gitignore | 2 - .../scripts/generate-evidence.mjs | 71 ++ loops/issue-dev-loop/scripts/lib/common.mjs | 159 +++++ loops/issue-dev-loop/scripts/lib/evidence.mjs | 208 ++++++ loops/issue-dev-loop/scripts/lib/evolve.mjs | 122 ++++ loops/issue-dev-loop/scripts/lib/github.mjs | 177 +++++ .../scripts/lib/notifications.mjs | 156 +++++ .../issue-dev-loop/scripts/lib/run-store.mjs | 297 ++++++++ .../issue-dev-loop/scripts/lib/validation.mjs | 94 +++ loops/issue-dev-loop/scripts/loopctl.mjs | 57 +- loops/issue-dev-loop/scripts/resolve-run.mjs | 36 + loops/issue-dev-loop/scripts/runtime.mjs | 647 +----------------- loops/issue-dev-loop/state.md | 3 + loops/issue-dev-loop/tests/runtime.test.mjs | 326 ++++++++- loops/issue-dev-loop/triggers/TRIGGER.md | 2 + .../triggers/codex-automation-prompt.md | 2 +- 35 files changed, 1919 insertions(+), 723 deletions(-) create mode 100644 .codex/agents/echo-ui-loop-evolver.toml create mode 100644 .github/workflows/issue-dev-loop-evidence.yml create mode 100644 loops/issue-dev-loop/evolve/requests/.gitignore create mode 100644 loops/issue-dev-loop/review/result.schema.json create mode 100644 loops/issue-dev-loop/screen-shots/.gitignore delete mode 100644 loops/issue-dev-loop/screenshots/.gitignore create mode 100644 loops/issue-dev-loop/scripts/generate-evidence.mjs create mode 100644 loops/issue-dev-loop/scripts/lib/common.mjs create mode 100644 loops/issue-dev-loop/scripts/lib/evidence.mjs create mode 100644 loops/issue-dev-loop/scripts/lib/evolve.mjs create mode 100644 loops/issue-dev-loop/scripts/lib/github.mjs create mode 100644 loops/issue-dev-loop/scripts/lib/notifications.mjs create mode 100644 loops/issue-dev-loop/scripts/lib/run-store.mjs create mode 100644 loops/issue-dev-loop/scripts/lib/validation.mjs create mode 100644 loops/issue-dev-loop/scripts/resolve-run.mjs diff --git a/.codex/agents/echo-ui-loop-evolver.toml b/.codex/agents/echo-ui-loop-evolver.toml new file mode 100644 index 00000000..78e0cbdb --- /dev/null +++ b/.codex/agents/echo-ui-loop-evolver.toml @@ -0,0 +1,12 @@ +name = "echo_ui_loop_evolver" +description = "Fresh-context evaluator that improves Echo UI loops from measured run history." +sandbox_mode = "workspace-write" +developer_instructions = """ +Read the pending evolve request, LOOP.md, state.md, compact run summaries, metrics, +owner feedback, reverts, and review outcomes. Identify repeated waste or failure +patterns and make the smallest measurable improvement. Never broaden authority, +weaken verification, delete append-only history, or change owner-only review and +merge. Any change must be made on a dedicated branch and proposed as a draft PR +for codeacme17 to review and merge. Record the hypothesis, expected metric, and +rollback condition. Do not work on product issues in this session. +""" diff --git a/.github/workflows/issue-dev-loop-evidence.yml b/.github/workflows/issue-dev-loop-evidence.yml new file mode 100644 index 00000000..d19bde55 --- /dev/null +++ b/.github/workflows/issue-dev-loop-evidence.yml @@ -0,0 +1,92 @@ +name: Issue dev loop evidence + +on: + pull_request: + branches: [dev] + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + +concurrency: + group: issue-dev-loop-evidence-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + evidence: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Check out exact PR head + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Resolve loop run + id: run + run: node loops/issue-dev-loop/scripts/resolve-run.mjs --branch "${{ github.head_ref }}" + + - name: Assert immutable head + if: steps.run.outputs.has_run == 'true' + run: test "$(git rev-parse HEAD)" = "${{ github.event.pull_request.head.sha }}" + + - name: Set up pnpm + if: steps.run.outputs.has_run == 'true' + uses: pnpm/action-setup@v6 + with: + version: 10 + + - name: Set up Node + if: steps.run.outputs.has_run == 'true' + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + if: steps.run.outputs.has_run == 'true' + run: pnpm install --frozen-lockfile + + - name: Run authoritative verification + if: steps.run.outputs.has_run == 'true' + id: verify + shell: bash + run: | + mkdir -p "${RUNNER_TEMP}/issue-dev-evidence" + started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + set +e + pnpm verify 2>&1 | tee "${RUNNER_TEMP}/issue-dev-evidence/pnpm-verify.log" + exit_code="${PIPESTATUS[0]}" + set -e + finished_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + if [[ "${exit_code}" == "0" ]]; then verdict=passed; else verdict=failed; fi + echo "started_at=${started_at}" >> "${GITHUB_OUTPUT}" + echo "finished_at=${finished_at}" >> "${GITHUB_OUTPUT}" + echo "exit_code=${exit_code}" >> "${GITHUB_OUTPUT}" + echo "verdict=${verdict}" >> "${GITHUB_OUTPUT}" + + - name: Generate exact-head manifest + if: steps.run.outputs.has_run == 'true' && always() + run: >- + node loops/issue-dev-loop/scripts/generate-evidence.mjs --run-id "${{ steps.run.outputs.run_id }}" --head-sha "${{ github.event.pull_request.head.sha }}" --status "${{ steps.verify.outputs.verdict || 'blocked' }}" --started-at "${{ steps.verify.outputs.started_at || github.event.pull_request.updated_at }}" --finished-at "${{ steps.verify.outputs.finished_at || github.event.pull_request.updated_at }}" --output "${RUNNER_TEMP}/issue-dev-evidence/manifest.json" + + - name: Upload review evidence + if: steps.run.outputs.has_run == 'true' && always() + id: artifact + uses: actions/upload-artifact@v4 + with: + name: issue-dev-loop-${{ steps.run.outputs.run_id }}-${{ github.event.pull_request.head.sha }} + path: | + ${{ runner.temp }}/issue-dev-evidence + loops/issue-dev-loop/screen-shots/${{ steps.run.outputs.run_id }} + loops/issue-dev-loop/logs/runs/${{ steps.run.outputs.run_id }} + if-no-files-found: error + retention-days: 30 + + - name: Publish artifact link + if: steps.run.outputs.has_run == 'true' && always() + run: echo "Evidence artifact — ${{ steps.artifact.outputs.artifact-url }}" >> "${GITHUB_STEP_SUMMARY}" + + - name: Enforce verification result + if: steps.run.outputs.has_run == 'true' && steps.verify.outputs.exit_code != '0' + run: exit 1 diff --git a/loops/README.md b/loops/README.md index 0c36204f..9c4ab379 100644 --- a/loops/README.md +++ b/loops/README.md @@ -10,6 +10,6 @@ This directory contains durable, auditable workflows that Codex can run against - Treat each loop directory as the canonical source for its own workflow. - Put repo-discoverable adapters in `.agents/skills`; adapters must point back to the canonical loop rather than duplicate its contract. -- Persist compact state and summary history in Git. Keep raw command logs, screenshots, videos, and generated evidence out of Git and publish them as PR or CI artifacts. +- Persist compact state, sanitized event history, and issue-relevant screenshots in the issue PR. Publish exact-head evidence manifests and verification logs as CI artifacts; keep raw local output and large recordings out of Git. - Never grant a loop authority to approve, auto-merge, or merge a PR. - Use `pnpm loop:issue-dev:validate` before changing loop infrastructure. diff --git a/loops/_shared/owner-channel/CHANNEL.md b/loops/_shared/owner-channel/CHANNEL.md index 46ab44db..deb6c8ed 100644 --- a/loops/_shared/owner-channel/CHANNEL.md +++ b/loops/_shared/owner-channel/CHANNEL.md @@ -4,7 +4,7 @@ GitHub issue and PR comments are the canonical, auditable channel. The runtime m ## Blocking events -`approval_required`, `clarification_required`, `blocked`, `review_dispute`, `pr_ready_for_review`, `pr_updated_for_review`, and `loop_failed` require immediate delivery. The run enters `waiting_for_owner` and must not choose a default answer after a timeout. +`approval_required`, `clarification_required`, `blocked`, `review_dispute`, `pr_ready_for_review`, `pr_updated_for_review`, and `loop_failed` require immediate delivery and must be sent with `blocking: true`. The runtime enters `waiting_for_owner` even when delivery fails, and it must not choose a default answer after a timeout. A ready PR advances to `awaiting_owner_review` only after SHA-bound evidence validation. ## Runtime setup diff --git a/loops/issue-dev-loop/LOOP.md b/loops/issue-dev-loop/LOOP.md index de9c174c..96f85de1 100644 --- a/loops/issue-dev-loop/LOOP.md +++ b/loops/issue-dev-loop/LOOP.md @@ -62,7 +62,7 @@ Use a combo trigger. Run `triggers/detect-work.mjs` before starting a Codex impl ### 1. Claim and snapshot -Recheck the issue immediately before mutation. Apply `loop:claimed`, capture the issue title/body/labels/URL, base SHA, and acceptance criteria, then create the run. Use one run ID across logs, handoffs, screenshots, evidence, review comments, notifications, branch metadata, and the PR body. +Recheck the issue immediately before mutation. Apply `loop:claimed`, capture the issue title/body/labels/URL, base SHA, and acceptance criteria, then create the run. Use one run ID across logs, handoffs, `screen-shots`, evidence, review comments, notifications, branch metadata, and the PR body. ### 2. Isolate @@ -78,7 +78,7 @@ Explicitly invoke `$implement`. The orchestrator does not write product code. `$ ### 5. Verify before publication -Run relevant checks and `pnpm verify`. For UI behavior, capture before/after screenshots at meaningful desktop and mobile viewports and include interaction or accessibility evidence where applicable. Generate and validate an evidence manifest tied to the exact commit SHA. +Run relevant checks and `pnpm verify`. For UI behavior, capture before/after screenshots under `screen-shots/` at meaningful desktop and mobile viewports and include interaction or accessibility evidence where applicable. Commit sanitized screenshots and run metadata to the issue branch. The evidence workflow then checks out the exact PR head, reruns `pnpm verify`, generates the manifest, and uploads a reviewable artifact for that SHA. ### 6. Create the draft PR @@ -90,7 +90,7 @@ Spawn `echo_ui_pr_reviewer` with fresh context and read-only filesystem access. ### 8. Owner gate -After CI, verification, and independent review pass, mark the PR ready, request review from `codeacme17`, notify the owner, and transition to `awaiting_owner_review`. Do not infer approval from timeouts or silence. +After exact-head CI and independent review pass, download the CI manifest and record its artifact URL with `record-evidence`; record the fresh review cycle and its GitHub URL separately with `record-review`. Mark the PR ready, request review from `codeacme17`, send a blocking notification (which pauses the run), and transition to `awaiting_owner_review` using that same head SHA. Do not infer approval from timeouts or silence. ### 9. Owner feedback @@ -98,13 +98,13 @@ When the owner requests changes, snapshot the comments, create a new handoff, in ### 10. Complete -Only an observed owner merge permits `completed`. Record merge SHA and timestamp, remove `loop:claimed`, update state, append the run summary, and retain links to published evidence. A closed unmerged PR is `cancelled`, not completed. +Only `observe-owner-merge` permits `completed`. It must query GitHub and observe both an `APPROVED` review by `codeacme17` for the exact reviewed head SHA and a merge performed by `codeacme17` for that same head. Record merge SHA and timestamp, remove `loop:claimed`, update state, append the run summary, and retain links to published evidence. A closed unmerged PR is `cancelled`, not completed. ## State and history Keep `state.md` small and deliberate. It may be rewritten and contains only active runs, open PRs, blockers, follow-ups, current hypotheses, and learned constraints. -`logs/index.jsonl` is append-only and contains one compact summary per finalized run. Raw event and command logs live under `logs/runs/` and are not merged into Git; publish them as CI artifacts when they matter to review. +`logs/index.jsonl` is append-only and contains one compact summary per finalized run. Commit sanitized `run.json`, pre-publication `events.jsonl`, summaries, and relevant screenshots to the issue branch so they are reviewable in its PR. The exact-head CI manifest and full proof travel in an Actions artifact. Keep raw local command output and large recordings in ignored `raw/` or `test-results/` directories. GitHub PR reviews, workflow artifacts, and merge metadata are the authoritative external record, while the local index is a reconciled cache. Never log secrets, full environment dumps, cookies, auth headers, private user data, or raw prompts containing sensitive information. @@ -119,7 +119,7 @@ Never log secrets, full environment dumps, cookies, auth headers, private user d ## Notifications -GitHub issue/PR comments are the canonical communication record. The shared owner channel may mirror notifications to a webhook. Blocking events must transition to `waiting_for_owner`; delivery failure is itself a blocker. +GitHub issue/PR comments are the canonical communication record. The shared owner channel may mirror notifications to a webhook. The notification runtime automatically transitions blocking events to `waiting_for_owner`; delivery failure is itself a blocker. `pr_ready_for_review` moves from that pause to `awaiting_owner_review` only after its SHA-bound evidence gates pass. Notify immediately for `approval_required`, `clarification_required`, `blocked`, `review_dispute`, `pr_ready_for_review`, `pr_updated_for_review`, and `loop_failed`. Routine no-work checks belong in a digest, not an interruption. diff --git a/loops/issue-dev-loop/SKILL.md b/loops/issue-dev-loop/SKILL.md index 586d86cf..ac5b189f 100644 --- a/loops/issue-dev-loop/SKILL.md +++ b/loops/issue-dev-loop/SKILL.md @@ -11,10 +11,11 @@ Run exactly one bounded issue cycle. Treat [`LOOP.md`](./LOOP.md) as the constit 1. Read `LOOP.md`, `state.md`, and `dependencies.md` completely. 2. Run `node loops/issue-dev-loop/scripts/loopctl.mjs validate`. -3. Run the cheap trigger in `triggers/detect-work.mjs`. Exit without invoking an implementation agent when it reports `hasWork: false`. -4. Refuse to start when another active run or PR already claims the issue. -5. Create a `codex/issue-` branch and an isolated worktree from `dev`. -6. Start the run with `loopctl.mjs start` and freeze the generated `implementation-brief.md` before implementation. +3. Run `loopctl.mjs evolve-status`. If `evolveDue` is true, start `echo_ui_loop_evolver` with fresh context; do not silently replace it with product work. +4. Run the cheap trigger in `triggers/detect-work.mjs`. Exit without invoking an implementation agent when it reports `hasWork: false`. +5. Refuse to start when another active run or PR already claims the issue. +6. Create a `codex/issue-` branch and an isolated worktree from `dev`. +7. Start the run with `loopctl.mjs start` and freeze the generated `implementation-brief.md` before implementation. Read [`references/github-operations.md`](./references/github-operations.md) for GitHub mutations and [`references/evidence-policy.md`](./references/evidence-policy.md) before verification or PR publication. @@ -51,9 +52,9 @@ The executor must classify every finding as `accepted`, `rejected`, `needs-human ## Verify and notify the owner -Run verification appropriate to the change and require `pnpm verify` before the PR is ready for owner review. Collect evidence under the run ID and validate its manifest. Mark the PR ready, request review from `codeacme17`, emit a `pr_ready_for_review` notification, and transition to `awaiting_owner_review`. +Run verification appropriate to the change and require `pnpm verify` before the PR is ready for owner review. Commit sanitized run metadata and relevant `screen-shots` to the issue branch. Wait for the exact-head `Issue dev loop evidence` workflow, download its artifact, and run `loopctl.mjs record-evidence --run-id --manifest --publication-url `. After the fresh reviewer and all comment responses are posted, run `loopctl.mjs record-review --run-id --result --review-url `. Both gates must name the current PR head. Emit a blocking `pr_ready_for_review` notification, then transition from `waiting_for_owner` to `awaiting_owner_review` with the PR URL and exact head SHA. -The owner is the only actor allowed to approve or merge. Never call `gh pr merge`, enable auto-merge, push to `main`, push to `dev`, dismiss owner feedback, or bypass branch protections. A run becomes `completed` only after observing the owner's merge event. +The owner is the only actor allowed to approve or merge. Never call `gh pr merge`, enable auto-merge, push to `main`, push to `dev`, dismiss owner feedback, or bypass branch protections. A run becomes `completed` only through `loopctl.mjs observe-owner-merge`, which queries GitHub and requires both `codeacme17`'s approval and merge at the reviewed head SHA. ## Stop conditions diff --git a/loops/issue-dev-loop/dependencies.md b/loops/issue-dev-loop/dependencies.md index 1dbcb7ec..d4da7242 100644 --- a/loops/issue-dev-loop/dependencies.md +++ b/loops/issue-dev-loop/dependencies.md @@ -3,6 +3,7 @@ ## Required - Codex with project skills and subagents enabled +- Project agents `echo_ui_pr_reviewer`, `echo_ui_review_adjudicator`, and `echo_ui_loop_evolver` - `$implement` available for all product-code changes - `$code-review` available to `$implement` and the independent reviewer - Node.js 24 diff --git a/loops/issue-dev-loop/evidence/.gitignore b/loops/issue-dev-loop/evidence/.gitignore index d6b7ef32..c9489c04 100644 --- a/loops/issue-dev-loop/evidence/.gitignore +++ b/loops/issue-dev-loop/evidence/.gitignore @@ -1,2 +1,4 @@ -* -!.gitignore +**/raw/ +**/test-results/ +**/*.trace.zip +**/manifest.json diff --git a/loops/issue-dev-loop/evolve/EVOLVE.md b/loops/issue-dev-loop/evolve/EVOLVE.md index e7da52a8..7a2798c9 100644 --- a/loops/issue-dev-loop/evolve/EVOLVE.md +++ b/loops/issue-dev-loop/evolve/EVOLVE.md @@ -1,15 +1,15 @@ # Evolve session contract -Run after every ten finalized runs, or after the same failure pattern appears three times. Use a fresh session and provide the loop contract, current state, compact run summaries, notification metrics, owner feedback, revert history, and review data. +`finalizeRun` updates `metrics.json` and creates a pending request under `evolve/requests/` after every ten finalized runs or three identical failure fingerprints. Every scheduled trigger must run `loopctl.mjs evolve-status` before selecting issue work. When `evolveDue` is true, spawn `echo_ui_loop_evolver` with fresh context and provide the pending request, loop contract, current state, compact run summaries, notification metrics, owner feedback, revert history, and review data. -The evolve session may automatically: +The evolve session may propose: - remove obsolete items from `state.md` - propose cheaper preflight checks - improve non-authority-changing scripts and templates - update dashboards or metrics derived from append-only logs -It must create an owner-reviewed PR before changing: +Every evolve change requires a dedicated draft PR reviewed and merged by the owner. In particular, never change the following without explicit owner confirmation: - goals, completion criteria, authority, or stop conditions - merge, release, security, privacy, or dependency policy @@ -17,3 +17,5 @@ It must create an owner-reviewed PR before changing: - notification severity or owner-gate behavior Never optimize by weakening evidence, increasing autonomous authority, deleting unfavorable history, or hiding no-work and failure runs. + +After the owner merges the evolve PR, run `loopctl.mjs evolve-complete --request-id --summary --pr-url `. A pending request is not cleared by silence or an unmerged PR. diff --git a/loops/issue-dev-loop/evolve/metrics.json b/loops/issue-dev-loop/evolve/metrics.json index da169a25..26ca0713 100644 --- a/loops/issue-dev-loop/evolve/metrics.json +++ b/loops/issue-dev-loop/evolve/metrics.json @@ -6,6 +6,12 @@ "noWorkChecks": 0, "ownerRequestedChanges": 0, "revertedPullRequests": 0, + "recentFailureFingerprints": [], + "evolveDue": false, + "pendingRequestId": null, + "lastEvolvedAt": null, + "lastEvolvedRunCount": 0, + "completedEvolveSessions": 0, "reviewFindings": { "accepted": 0, "rejected": 0, diff --git a/loops/issue-dev-loop/evolve/requests/.gitignore b/loops/issue-dev-loop/evolve/requests/.gitignore new file mode 100644 index 00000000..a6c57f5f --- /dev/null +++ b/loops/issue-dev-loop/evolve/requests/.gitignore @@ -0,0 +1 @@ +*.json diff --git a/loops/issue-dev-loop/handoffs/.gitignore b/loops/issue-dev-loop/handoffs/.gitignore index d6b7ef32..e258f9a9 100644 --- a/loops/issue-dev-loop/handoffs/.gitignore +++ b/loops/issue-dev-loop/handoffs/.gitignore @@ -1,2 +1 @@ -* -!.gitignore +**/private/ diff --git a/loops/issue-dev-loop/references/evidence-policy.md b/loops/issue-dev-loop/references/evidence-policy.md index 262c316d..6f9afe92 100644 --- a/loops/issue-dev-loop/references/evidence-policy.md +++ b/loops/issue-dev-loop/references/evidence-policy.md @@ -20,8 +20,10 @@ Capture only scenarios relevant to the issue: - focus, keyboard, disabled, loading, or error state when affected - video only when a still image cannot demonstrate the interaction -Use descriptive names such as `02-after-player-mobile-375.webp`. Record route, viewport, scenario, commit SHA, and capture time in the screenshot manifest. +Use descriptive names such as `02-after-player-mobile-375.webp` under `screen-shots/`. Record route, viewport, scenario, commit SHA, and capture time in the screenshot manifest. ## Storage -Raw logs, screenshots, videos, and test reports are runtime artifacts and should not accumulate on `dev`. Upload them to CI artifacts or another configured store, then put stable links in `evidence//manifest.json` and the PR body. Never upload secrets, cookies, auth state, user data, or unredacted environment dumps. +Commit issue-relevant PNG/WebP screenshots and their metadata under `screen-shots/` before owner review. The `issue-dev-loop-evidence.yml` workflow checks out the exact PR head, runs `pnpm verify`, generates `evidence//manifest.json` in the runner, and uploads the manifest, sanitized run log, and screenshots as a GitHub Actions artifact. Download the artifact, then pass its URL to `record-evidence`; never accept an artifact from a different head SHA. + +The independent reviewer posts findings and responses to GitHub after the draft PR exists. Save its structured cycle result locally and use `record-review --review-url ` so the review gate is independently bound to the same head SHA. Keep raw local command output, videos, traces, and bulky reports in ignored runtime directories. Never upload secrets, cookies, auth state, user data, or unredacted environment dumps. diff --git a/loops/issue-dev-loop/references/github-operations.md b/loops/issue-dev-loop/references/github-operations.md index 358df855..ba0a0f7b 100644 --- a/loops/issue-dev-loop/references/github-operations.md +++ b/loops/issue-dev-loop/references/github-operations.md @@ -18,6 +18,17 @@ Read this before mutating issues or pull requests. - Request `codeacme17` only after automated review and verification pass. - Bind every review to immutable base and head SHAs. +## Evidence artifact + +The PR workflow `Issue dev loop evidence` runs only when the branch contains one active loop run. Wait for its exact-head run to complete, then locate and download the artifact: + +```text +gh run list --workflow issue-dev-loop-evidence.yml --branch codex/issue- +gh run download --name issue-dev-loop-- +``` + +Use the artifact URL emitted by `actions/upload-artifact` as `record-evidence --publication-url`. Reject a workflow run whose `headSha` does not equal the current PR head. + ## Prohibited commands Never run: diff --git a/loops/issue-dev-loop/review/REVIEW.md b/loops/issue-dev-loop/review/REVIEW.md index 6ba24ca3..90b908a7 100644 --- a/loops/issue-dev-loop/review/REVIEW.md +++ b/loops/issue-dev-loop/review/REVIEW.md @@ -25,6 +25,8 @@ Do not emit formatter noise, personal style preferences, speculative concerns, u The orchestrator posts findings verbatim as one GitHub review and inline comments, adding `` for deduplication. It must not downgrade severity or omit findings. +After all replies are posted, create one cycle result matching `result.schema.json`. It contains every round, every finding, its final classification, response URL, and evidence. Run `record-review` with the GitHub review URL; generic events cannot forge this reserved gate. + ## Completion -Bind every round to a head SHA. A new push invalidates the previous PASS. Allow at most two automated rounds. Unresolved P0/P1 findings block owner-ready status. +Bind every round to a head SHA. A new push invalidates the previous PASS because `awaiting_owner_review` requires a recorded review for its exact head. Allow at most two automated rounds. Any `needs-human` classification prevents a PASS result; unresolved P0/P1 findings block owner-ready status. diff --git a/loops/issue-dev-loop/review/response-policy.md b/loops/issue-dev-loop/review/response-policy.md index aeb6d1a3..e79db024 100644 --- a/loops/issue-dev-loop/review/response-policy.md +++ b/loops/issue-dev-loop/review/response-policy.md @@ -13,3 +13,5 @@ Accepted findings return to `$implement`. Reply only after pushing the fix and i Rejected findings require a precise counterclaim and reproducible evidence. Do not reply with bare disagreement. An executor cannot unilaterally close a disputed P0 or P1; invoke `echo_ui_review_adjudicator`, then escalate `NEEDS_OWNER` outcomes. Do not delete review comments. Log classification, response time, response URL, commit, and evidence reference under the run ID. + +The final cycle file must match `result.schema.json`. `record-review` accepts only a fresh `echo_ui_pr_reviewer` PASS whose last round matches the current head, whose earlier findings all have GitHub response URLs and evidence, and whose accepted findings name a fix commit. diff --git a/loops/issue-dev-loop/review/result.schema.json b/loops/issue-dev-loop/review/result.schema.json new file mode 100644 index 00000000..47d64a19 --- /dev/null +++ b/loops/issue-dev-loop/review/result.schema.json @@ -0,0 +1,79 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Independent review cycle result", + "type": "object", + "required": [ + "schemaVersion", + "runId", + "reviewerAgent", + "freshContext", + "headSha", + "verdict", + "rounds" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": 1 }, + "runId": { "type": "string", "minLength": 1 }, + "reviewerAgent": { "const": "echo_ui_pr_reviewer" }, + "freshContext": { "const": true }, + "headSha": { "type": "string", "minLength": 7 }, + "verdict": { "const": "PASS" }, + "rounds": { + "type": "array", + "minItems": 1, + "maxItems": 2, + "items": { + "type": "object", + "required": ["round", "headSha", "verdict", "findings"], + "additionalProperties": false, + "properties": { + "round": { "type": "integer", "minimum": 1, "maximum": 2 }, + "headSha": { "type": "string", "minLength": 7 }, + "verdict": { "enum": ["PASS", "CHANGES_REQUESTED"] }, + "findings": { + "type": "array", + "items": { + "type": "object", + "required": [ + "findingId", + "severity", + "confidence", + "headSha", + "problem", + "evidence", + "expectedResolution", + "resolution" + ], + "additionalProperties": false, + "properties": { + "findingId": { "type": "string", "pattern": "^RVW-[0-9]+-[0-9]+$" }, + "severity": { "enum": ["P0", "P1", "P2", "P3"] }, + "confidence": { "enum": ["high", "medium", "low"] }, + "headSha": { "type": "string", "minLength": 7 }, + "path": { "type": ["string", "null"] }, + "line": { "type": ["integer", "null"], "minimum": 1 }, + "problem": { "type": "string", "minLength": 1 }, + "evidence": { "type": "string", "minLength": 1 }, + "expectedResolution": { "type": "string", "minLength": 1 }, + "resolution": { + "type": "object", + "required": ["classification", "responseUrl", "evidence"], + "additionalProperties": false, + "properties": { + "classification": { + "enum": ["accepted", "rejected", "stale", "already-fixed"] + }, + "responseUrl": { "type": "string", "format": "uri" }, + "evidence": { "type": "string", "minLength": 1 }, + "fixCommit": { "type": ["string", "null"] } + } + } + } + } + } + } + } + } + } +} diff --git a/loops/issue-dev-loop/schemas/evidence.schema.json b/loops/issue-dev-loop/schemas/evidence.schema.json index bdb0578b..1f87a933 100644 --- a/loops/issue-dev-loop/schemas/evidence.schema.json +++ b/loops/issue-dev-loop/schemas/evidence.schema.json @@ -9,7 +9,8 @@ "headSha", "verdict", "checks", - "screenshots" + "screenshots", + "limitations" ], "additionalProperties": false, "properties": { @@ -20,11 +21,13 @@ "verdict": { "enum": ["passed", "failed", "blocked"] }, "checks": { "type": "array", + "minItems": 1, "items": { "type": "object", - "required": ["command", "status", "startedAt", "finishedAt"], + "required": ["command", "status", "startedAt", "finishedAt", "artifactUrl"], + "additionalProperties": false, "properties": { - "command": { "type": "string" }, + "command": { "type": "string", "minLength": 1 }, "status": { "enum": ["passed", "failed", "blocked"] }, "startedAt": { "type": "string", "format": "date-time" }, "finishedAt": { "type": "string", "format": "date-time" }, @@ -36,16 +39,16 @@ "type": "array", "items": { "type": "object", - "required": ["name", "scenario", "viewport", "artifactUrl"], + "required": ["name", "scenario", "viewport", "path"], + "additionalProperties": false, "properties": { - "name": { "type": "string" }, - "scenario": { "type": "string" }, - "viewport": { "type": "string" }, - "artifactUrl": { "type": "string", "format": "uri" } + "name": { "type": "string", "minLength": 1 }, + "scenario": { "type": "string", "minLength": 1 }, + "viewport": { "type": "string", "minLength": 1 }, + "path": { "type": "string", "minLength": 1 } } } }, - "review": { "type": ["object", "null"] }, "limitations": { "type": "array", "items": { "type": "string" } } } } diff --git a/loops/issue-dev-loop/screen-shots/.gitignore b/loops/issue-dev-loop/screen-shots/.gitignore new file mode 100644 index 00000000..d76d9f50 --- /dev/null +++ b/loops/issue-dev-loop/screen-shots/.gitignore @@ -0,0 +1,2 @@ +**/raw/ +**/*.webm diff --git a/loops/issue-dev-loop/screenshots/.gitignore b/loops/issue-dev-loop/screenshots/.gitignore deleted file mode 100644 index d6b7ef32..00000000 --- a/loops/issue-dev-loop/screenshots/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/loops/issue-dev-loop/scripts/generate-evidence.mjs b/loops/issue-dev-loop/scripts/generate-evidence.mjs new file mode 100644 index 00000000..223d908d --- /dev/null +++ b/loops/issue-dev-loop/scripts/generate-evidence.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node + +import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' +import path from 'node:path' + +import { DEFAULT_LOOP_ROOT, parseArguments } from './runtime.mjs' + +async function exists(target) { + try { + await stat(target) + return true + } catch (error) { + if (error?.code === 'ENOENT') return false + throw error + } +} + +function required(value, name) { + if (typeof value !== 'string' || value.trim() === '') throw new Error(`${name} is required`) + return value.trim() +} + +const args = parseArguments(process.argv.slice(2)) +const loopRoot = args['loop-root'] ? path.resolve(args['loop-root']) : DEFAULT_LOOP_ROOT +const runId = required(args['run-id'], '--run-id') +const headSha = required(args['head-sha'], '--head-sha') +const status = required(args.status, '--status') +if (!['passed', 'failed', 'blocked'].includes(status)) throw new Error('unsupported --status') + +const run = JSON.parse( + await readFile(path.join(loopRoot, 'logs', 'runs', runId, 'run.json'), 'utf8'), +) +const screenshotRoot = path.join(loopRoot, 'screen-shots', runId) +const screenshotMetadataPath = path.join(screenshotRoot, 'manifest.json') +let screenshots = [] +if (await exists(screenshotMetadataPath)) { + const metadata = JSON.parse(await readFile(screenshotMetadataPath, 'utf8')) + if (!Array.isArray(metadata.screenshots)) + throw new Error('screenshot manifest needs screenshots[]') + screenshots = metadata.screenshots + for (const screenshot of screenshots) { + const target = path.resolve(loopRoot, screenshot.path) + if (!target.startsWith(`${screenshotRoot}${path.sep}`) || !(await exists(target))) { + throw new Error(`missing or unsafe screenshot path: ${screenshot.path}`) + } + } +} + +const output = path.resolve(required(args.output, '--output')) +const manifest = { + schemaVersion: 1, + runId, + issueNumber: run.issueNumber, + headSha, + verdict: status, + checks: [ + { + command: 'pnpm verify', + status, + startedAt: required(args['started-at'], '--started-at'), + finishedAt: required(args['finished-at'], '--finished-at'), + artifactUrl: null, + }, + ], + screenshots, + limitations: [], +} + +await mkdir(path.dirname(output), { recursive: true }) +await writeFile(output, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8') +process.stdout.write(`${output}\n`) diff --git a/loops/issue-dev-loop/scripts/lib/common.mjs b/loops/issue-dev-loop/scripts/lib/common.mjs new file mode 100644 index 00000000..7ac26f86 --- /dev/null +++ b/loops/issue-dev-loop/scripts/lib/common.mjs @@ -0,0 +1,159 @@ +import { execFile } from 'node:child_process' +import { randomBytes } from 'node:crypto' +import { appendFile, mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { promisify } from 'node:util' +import { fileURLToPath } from 'node:url' + +export const execFileAsync = promisify(execFile) +const moduleDirectory = path.dirname(fileURLToPath(import.meta.url)) +export const DEFAULT_LOOP_ROOT = path.resolve(moduleDirectory, '..', '..') + +export function assertNonEmpty(value, name) { + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`${name} must be a non-empty string`) + } + return value.trim() +} + +export function assertIssueNumber(value) { + const parsed = Number(value) + if (!Number.isInteger(parsed) || parsed < 1) + throw new Error('issueNumber must be a positive integer') + return parsed +} + +export function assertRunId(runId) { + const normalized = assertNonEmpty(runId, 'runId') + if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(normalized)) { + throw new Error('runId contains unsafe characters') + } + return normalized +} + +export function timestampToken(now) { + return now + .toISOString() + .replace(/[-:]/g, '') + .replace(/\.\d{3}Z$/, 'Z') +} + +export function parseArguments(argv) { + const values = { _: [] } + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index] + if (!token.startsWith('--')) { + values._.push(token) + continue + } + const key = token.slice(2) + const next = argv[index + 1] + if (next === undefined || next.startsWith('--')) { + values[key] = true + continue + } + values[key] = next + index += 1 + } + return values +} + +export async function pathExists(target) { + try { + await stat(target) + return true + } catch (error) { + if (error?.code === 'ENOENT') return false + throw error + } +} + +export async function readJson(target) { + return JSON.parse(await readFile(target, 'utf8')) +} + +export async function writeJson(target, value) { + await mkdir(path.dirname(target), { recursive: true }) + const temporary = `${target}.tmp-${process.pid}-${randomBytes(3).toString('hex')}` + await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, 'utf8') + await rename(temporary, target) +} + +export async function appendJsonLine(target, value) { + await mkdir(path.dirname(target), { recursive: true }) + await appendFile(target, `${JSON.stringify(value)}\n`, 'utf8') +} + +export function runDirectory(loopRoot, runId) { + return path.join(loopRoot, 'logs', 'runs', assertRunId(runId)) +} + +export function replaceTemplate(template, replacements) { + let output = template + for (const [key, value] of Object.entries(replacements)) { + output = output.replaceAll(`{{${key}}}`, String(value ?? '')) + } + return output +} + +export function assertArray(value, name) { + if (!Array.isArray(value)) throw new Error(`${name} must be an array`) + return value +} + +export function assertHttpUrl(value, name) { + const normalized = assertNonEmpty(value, name) + const parsed = new URL(normalized) + if (!['http:', 'https:'].includes(parsed.protocol)) { + throw new Error(`${name} must use http or https`) + } + return normalized +} + +export function sameRepository(left, right) { + return ( + left?.owner?.toLowerCase() === right?.owner?.toLowerCase() && + left?.repo?.toLowerCase() === right?.repo?.toLowerCase() + ) +} + +export function sameGitHubLogin(left, right) { + return ( + typeof left === 'string' && + typeof right === 'string' && + left.toLowerCase() === right.toLowerCase() + ) +} + +export function parseGitHubTarget(targetUrl) { + if (!targetUrl) return null + const parsed = new URL(targetUrl) + if (parsed.hostname !== 'github.com') return null + const match = parsed.pathname.match(/^\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)\/?$/) + if (!match) return null + return { owner: match[1], repo: match[2], kind: match[3], number: Number(match[4]) } +} + +export function parseArtifactUrl(value) { + const parsed = new URL(value) + const match = parsed.pathname.match( + /^\/([^/]+)\/([^/]+)\/actions\/runs\/(\d+)\/artifacts\/(\d+)\/?$/, + ) + return parsed.hostname === 'github.com' && match + ? { owner: match[1], repo: match[2], runId: match[3], artifactId: match[4] } + : null +} + +export function parseReviewUrl(value) { + const parsed = new URL(value) + const match = parsed.pathname.match(/^\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/) + const reviewMatch = parsed.hash.match(/^#pullrequestreview-(\d+)$/) + return parsed.hostname === 'github.com' && match && reviewMatch + ? { owner: match[1], repo: match[2], number: Number(match[3]), reviewId: reviewMatch[1] } + : null +} + +export async function defaultGitHubApi(endpoint) { + const result = await execFileAsync('gh', ['api', endpoint], { maxBuffer: 1024 * 1024 }) + return JSON.parse(result.stdout) +} diff --git a/loops/issue-dev-loop/scripts/lib/evidence.mjs b/loops/issue-dev-loop/scripts/lib/evidence.mjs new file mode 100644 index 00000000..585b14fd --- /dev/null +++ b/loops/issue-dev-loop/scripts/lib/evidence.mjs @@ -0,0 +1,208 @@ +import { createHash } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import path from 'node:path' + +import { + DEFAULT_LOOP_ROOT, + assertArray, + assertHttpUrl, + assertNonEmpty, + assertRunId, + parseArtifactUrl, + parseGitHubTarget, + parseReviewUrl, + sameRepository, +} from './common.mjs' +import { appendValidatedEvent, readRun } from './run-store.mjs' + +const REVIEW_CLASSIFICATIONS = new Set(['accepted', 'rejected', 'stale', 'already-fixed']) + +function validateReviewEvidence(review, headSha) { + if (!review || typeof review !== 'object' || Array.isArray(review)) { + throw new Error('evidence review must be an object') + } + if (review.reviewerAgent !== 'echo_ui_pr_reviewer' || review.freshContext !== true) { + throw new Error('review must come from fresh-context echo_ui_pr_reviewer') + } + if (review.headSha !== headSha || review.verdict !== 'PASS') { + throw new Error('review PASS must be bound to the evidence headSha') + } + + const rounds = assertArray(review.rounds, 'review.rounds') + if (rounds.length < 1 || rounds.length > 2) { + throw new Error('review.rounds must contain one or two rounds') + } + const lastRound = rounds.at(-1) + if (lastRound?.headSha !== headSha || lastRound?.verdict !== 'PASS') { + throw new Error('the last review round must PASS the evidence headSha') + } + + let findingCount = 0 + const findingIds = new Set() + for (const [roundIndex, round] of rounds.entries()) { + assertNonEmpty(round.headSha, `review.rounds[${roundIndex}].headSha`) + if (round.round !== roundIndex + 1 || !['PASS', 'CHANGES_REQUESTED'].includes(round.verdict)) { + throw new Error('review rounds must be ordered and have a supported verdict') + } + const findings = assertArray(round.findings, `review.rounds[${roundIndex}].findings`) + if (round.verdict === 'PASS' && findings.length > 0) { + throw new Error('a PASS review round cannot contain findings') + } + if (round.verdict === 'CHANGES_REQUESTED' && findings.length === 0) { + throw new Error('a CHANGES_REQUESTED review round must contain findings') + } + for (const finding of findings) { + findingCount += 1 + const findingId = assertNonEmpty(finding.findingId, 'finding.findingId') + if (!/^RVW-[0-9]+-[0-9]+$/.test(findingId) || findingIds.has(findingId)) { + throw new Error(`invalid or duplicate finding ID: ${findingId}`) + } + findingIds.add(findingId) + if (!['P0', 'P1', 'P2', 'P3'].includes(finding.severity)) { + throw new Error('finding.severity must be P0, P1, P2, or P3') + } + if (!['high', 'medium', 'low'].includes(finding.confidence)) { + throw new Error(`${findingId}.confidence must be high, medium, or low`) + } + assertNonEmpty(finding.problem, `${findingId}.problem`) + assertNonEmpty(finding.evidence, `${findingId}.evidence`) + assertNonEmpty(finding.expectedResolution, `${findingId}.expectedResolution`) + if (finding.headSha !== round.headSha) { + throw new Error(`${findingId} is not bound to its review round headSha`) + } + const resolution = finding.resolution + if (!resolution || !REVIEW_CLASSIFICATIONS.has(resolution.classification)) { + throw new Error(`${findingId} requires a final non-human classification`) + } + assertHttpUrl(resolution.responseUrl, `${findingId}.resolution.responseUrl`) + assertNonEmpty(resolution.evidence, `${findingId}.resolution.evidence`) + if (resolution.classification === 'accepted') { + assertNonEmpty(resolution.fixCommit, `${findingId}.resolution.fixCommit`) + } + } + } + return { findingCount, rounds: rounds.length } +} + +export async function recordEvidence({ + loopRoot = DEFAULT_LOOP_ROOT, + runId, + manifestPath, + publicationUrl, + now = new Date(), +} = {}) { + const normalizedRunId = assertRunId(runId) + const run = await readRun(loopRoot, normalizedRunId) + if (run.finishedAt !== null) throw new Error(`run is already finalized: ${normalizedRunId}`) + + const evidenceRoot = path.resolve(loopRoot, 'evidence') + const resolvedManifest = path.resolve(assertNonEmpty(manifestPath, 'manifestPath')) + if (!resolvedManifest.startsWith(`${evidenceRoot}${path.sep}`)) { + throw new Error('manifestPath must be inside the loop evidence directory') + } + const source = await readFile(resolvedManifest, 'utf8') + const manifest = JSON.parse(source) + if ( + manifest.schemaVersion !== 1 || + manifest.runId !== normalizedRunId || + manifest.issueNumber !== run.issueNumber + ) { + throw new Error('evidence manifest does not match the run') + } + const headSha = assertNonEmpty(manifest.headSha, 'manifest.headSha') + if (!/^[0-9a-f]{7,40}$/i.test(headSha)) throw new Error('manifest.headSha must be a Git SHA') + if (manifest.verdict !== 'passed') throw new Error('evidence manifest must have passed verdict') + const publishedEvidenceUrl = assertHttpUrl(publicationUrl, 'publicationUrl') + const artifactTarget = parseArtifactUrl(publishedEvidenceUrl) + if (!artifactTarget || !sameRepository(parseGitHubTarget(run.issueUrl), artifactTarget)) { + throw new Error('publicationUrl must be a GitHub Actions artifact for the issue repository') + } + + const checks = assertArray(manifest.checks, 'manifest.checks') + if (checks.length === 0 || checks.some((check) => check.status !== 'passed')) { + throw new Error('all evidence checks must pass') + } + if (!checks.some((check) => /^pnpm verify(?:\s|$)/.test(check.command))) { + throw new Error('evidence checks must include pnpm verify') + } + for (const [index, check] of checks.entries()) { + assertNonEmpty(check.command, `checks[${index}].command`) + if (Number.isNaN(Date.parse(check.startedAt)) || Number.isNaN(Date.parse(check.finishedAt))) { + throw new Error(`checks[${index}] requires valid timestamps`) + } + if (check.artifactUrl !== null && check.artifactUrl !== undefined) { + assertHttpUrl(check.artifactUrl, `checks[${index}].artifactUrl`) + } + } + for (const [index, screenshot] of assertArray( + manifest.screenshots, + 'manifest.screenshots', + ).entries()) { + assertNonEmpty(screenshot.name, `screenshots[${index}].name`) + assertNonEmpty(screenshot.path, `screenshots[${index}].path`) + } + const manifestDigest = createHash('sha256').update(source).digest('hex') + const relativeManifestPath = path.relative(loopRoot, resolvedManifest) + await appendValidatedEvent({ + loopRoot, + runId: normalizedRunId, + type: 'verification_completed', + status: 'passed', + payload: { + verdict: 'passed', + headSha, + manifestPath: relativeManifestPath, + manifestUrl: publishedEvidenceUrl, + manifestDigest, + }, + now, + }) + return { + headSha, + manifestPath: relativeManifestPath, + publicationUrl: publishedEvidenceUrl, + manifestDigest, + } +} + +export async function recordReview({ + loopRoot = DEFAULT_LOOP_ROOT, + runId, + resultPath, + reviewUrl, + now = new Date(), +} = {}) { + const normalizedRunId = assertRunId(runId) + const run = await readRun(loopRoot, normalizedRunId) + if (run.finishedAt !== null) throw new Error(`run is already finalized: ${normalizedRunId}`) + const source = await readFile(path.resolve(assertNonEmpty(resultPath, 'resultPath')), 'utf8') + const result = JSON.parse(source) + if (result.schemaVersion !== 1 || result.runId !== normalizedRunId) { + throw new Error('review result does not match the run') + } + const headSha = assertNonEmpty(result.headSha, 'review.headSha') + const reviewSummary = validateReviewEvidence(result, headSha) + const publishedReviewUrl = assertHttpUrl(reviewUrl, 'reviewUrl') + const reviewTarget = parseReviewUrl(publishedReviewUrl) + if (!reviewTarget || !sameRepository(parseGitHubTarget(run.issueUrl), reviewTarget)) { + throw new Error('reviewUrl must be a GitHub pull request review for the issue repository') + } + const resultDigest = createHash('sha256').update(source).digest('hex') + await appendValidatedEvent({ + loopRoot, + runId: normalizedRunId, + type: 'review_completed', + status: 'passed', + payload: { + verdict: 'PASS', + headSha, + reviewUrl: publishedReviewUrl, + resultDigest, + findingCount: reviewSummary.findingCount, + reviewRounds: reviewSummary.rounds, + unresolvedHighSeverityFindings: 0, + }, + now, + }) + return { headSha, reviewUrl: publishedReviewUrl, resultDigest, ...reviewSummary } +} diff --git a/loops/issue-dev-loop/scripts/lib/evolve.mjs b/loops/issue-dev-loop/scripts/lib/evolve.mjs new file mode 100644 index 00000000..2f10541d --- /dev/null +++ b/loops/issue-dev-loop/scripts/lib/evolve.mjs @@ -0,0 +1,122 @@ +import { randomBytes } from 'node:crypto' +import path from 'node:path' + +import { + DEFAULT_LOOP_ROOT, + assertHttpUrl, + assertNonEmpty, + defaultGitHubApi, + parseGitHubTarget, + readJson, + sameGitHubLogin, + timestampToken, + writeJson, +} from './common.mjs' + +export async function updateEvolveMetrics({ loopRoot, status, failureFingerprint, now }) { + const metricsPath = path.join(loopRoot, 'evolve', 'metrics.json') + const metrics = await readJson(metricsPath) + metrics.finalizedRuns += 1 + if (status === 'completed') metrics.successfulRuns += 1 + if (['failed', 'blocked'].includes(status)) metrics.failedRuns += 1 + + const recentFailures = Array.isArray(metrics.recentFailureFingerprints) + ? metrics.recentFailureFingerprints + : [] + recentFailures.push(failureFingerprint || null) + metrics.recentFailureFingerprints = recentFailures.slice(-3) + + let dueReason = null + if (metrics.finalizedRuns - (metrics.lastEvolvedRunCount ?? 0) >= 10) { + dueReason = 'ten_finalized_runs' + } else if ( + metrics.recentFailureFingerprints.length === 3 && + metrics.recentFailureFingerprints[0] !== null && + new Set(metrics.recentFailureFingerprints).size === 1 + ) { + dueReason = 'repeated_failure_pattern' + } + + if (dueReason && !metrics.evolveDue) { + const requestId = `EVL-${timestampToken(now).replace('Z', '')}-${randomBytes(3) + .toString('hex') + .toUpperCase()}` + await writeJson(path.join(loopRoot, 'evolve', 'requests', `${requestId}.json`), { + schemaVersion: 1, + requestId, + status: 'pending', + reason: dueReason, + requestedAt: now.toISOString(), + finalizedRunCount: metrics.finalizedRuns, + }) + metrics.evolveDue = true + metrics.pendingRequestId = requestId + } + await writeJson(metricsPath, metrics) + return metrics +} + +export async function getEvolveStatus({ loopRoot = DEFAULT_LOOP_ROOT } = {}) { + return readJson(path.join(loopRoot, 'evolve', 'metrics.json')) +} + +export async function completeEvolve({ + loopRoot = DEFAULT_LOOP_ROOT, + requestId, + summary, + prUrl, + now = new Date(), + githubApi = defaultGitHubApi, +} = {}) { + const normalizedRequestId = assertNonEmpty(requestId, 'requestId') + const metricsPath = path.join(loopRoot, 'evolve', 'metrics.json') + const metrics = await readJson(metricsPath) + if (!metrics.evolveDue || metrics.pendingRequestId !== normalizedRequestId) { + throw new Error(`not the pending evolve request: ${normalizedRequestId}`) + } + const requestPath = path.join(loopRoot, 'evolve', 'requests', `${normalizedRequestId}.json`) + const request = await readJson(requestPath) + const publishedPrUrl = assertHttpUrl(prUrl, 'prUrl') + const target = parseGitHubTarget(publishedPrUrl) + if (!target || target.kind !== 'pull') throw new Error('prUrl must be a GitHub pull request') + const channel = await readJson( + path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'), + ) + const [pullRequest, reviews] = await Promise.all([ + githubApi(`repos/${target.owner}/${target.repo}/pulls/${target.number}`), + githubApi(`repos/${target.owner}/${target.repo}/pulls/${target.number}/reviews?per_page=100`), + ]) + const evolveHeadSha = pullRequest.head?.sha + const approved = reviews.some( + (review) => + sameGitHubLogin(review.user?.login, channel.ownerGitHubLogin) && + review.state === 'APPROVED' && + review.commit_id === evolveHeadSha, + ) + if ( + pullRequest.merged !== true || + !sameGitHubLogin(pullRequest.merged_by?.login, channel.ownerGitHubLogin) || + !pullRequest.merge_commit_sha || + !approved + ) { + throw new Error('evolve PR must be approved and merged by the configured owner') + } + + const completed = { + ...request, + status: 'completed', + completedAt: now.toISOString(), + summary: assertNonEmpty(summary, 'summary'), + prUrl: publishedPrUrl, + headSha: evolveHeadSha, + mergeSha: pullRequest.merge_commit_sha, + } + await writeJson(requestPath, completed) + metrics.evolveDue = false + metrics.pendingRequestId = null + metrics.lastEvolvedAt = now.toISOString() + metrics.lastEvolvedRunCount = metrics.finalizedRuns + metrics.completedEvolveSessions = (metrics.completedEvolveSessions ?? 0) + 1 + await writeJson(metricsPath, metrics) + return completed +} diff --git a/loops/issue-dev-loop/scripts/lib/github.mjs b/loops/issue-dev-loop/scripts/lib/github.mjs new file mode 100644 index 00000000..7c0bf170 --- /dev/null +++ b/loops/issue-dev-loop/scripts/lib/github.mjs @@ -0,0 +1,177 @@ +import { readFile } from 'node:fs/promises' +import path from 'node:path' + +import { + DEFAULT_LOOP_ROOT, + assertRunId, + defaultGitHubApi, + execFileAsync, + parseGitHubTarget, + readJson, + sameGitHubLogin, +} from './common.mjs' +import { appendValidatedEvent, finalizeRun, readRun } from './run-store.mjs' + +const PRIORITY = new Map([ + ['priority:critical', 0], + ['priority:high', 1], + ['priority:medium', 2], + ['priority:low', 3], +]) + +function labelNames(issue) { + return new Set( + (issue.labels ?? []).map((label) => (typeof label === 'string' ? label : label.name)), + ) +} + +function issuePriority(issue) { + const labels = labelNames(issue) + let rank = 4 + for (const [label, value] of PRIORITY) { + if (labels.has(label)) rank = Math.min(rank, value) + } + return rank +} + +function pullRequestClaimsIssue(pullRequest, issueNumber) { + if (pullRequest.headRefName === `codex/issue-${issueNumber}`) return true + const searchable = `${pullRequest.title ?? ''}\n${pullRequest.body ?? ''}` + return new RegExp( + `(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)?\\s*#${issueNumber}(?!\\d)`, + 'i', + ).test(searchable) +} + +export function selectIssue({ issues = [], pullRequests = [] } = {}) { + const eligible = issues.filter((issue) => { + const labels = labelNames(issue) + return ( + labels.has('codex-ready') && + !labels.has('loop:claimed') && + !pullRequests.some((pullRequest) => pullRequestClaimsIssue(pullRequest, issue.number)) + ) + }) + eligible.sort((left, right) => { + const priorityDifference = issuePriority(left) - issuePriority(right) + if (priorityDifference !== 0) return priorityDifference + const leftCreated = Date.parse(left.createdAt ?? 0) + const rightCreated = Date.parse(right.createdAt ?? 0) + if (leftCreated !== rightCreated) return leftCreated - rightCreated + return left.number - right.number + }) + const issue = eligible[0] + return issue ? { hasWork: true, issue } : { hasWork: false, issue: null } +} + +async function loadJsonFile(target) { + return JSON.parse(await readFile(path.resolve(target), 'utf8')) +} + +export async function detectWork({ issuesFile, pullRequestsFile, repo } = {}) { + let issues + let pullRequests + if (issuesFile) { + issues = await loadJsonFile(issuesFile) + } else { + const argumentsList = [ + 'issue', + 'list', + '--state', + 'open', + '--label', + 'codex-ready', + '--limit', + '100', + '--json', + 'number,title,url,labels,createdAt', + ] + if (repo) argumentsList.push('--repo', repo) + const result = await execFileAsync('gh', argumentsList, { maxBuffer: 1024 * 1024 }) + issues = JSON.parse(result.stdout) + } + if (pullRequestsFile) { + pullRequests = await loadJsonFile(pullRequestsFile) + } else { + const argumentsList = [ + 'pr', + 'list', + '--state', + 'open', + '--limit', + '100', + '--json', + 'number,title,url,headRefName,body', + ] + if (repo) argumentsList.push('--repo', repo) + const result = await execFileAsync('gh', argumentsList, { maxBuffer: 1024 * 1024 }) + pullRequests = JSON.parse(result.stdout) + } + return selectIssue({ issues, pullRequests }) +} + +export async function observeOwnerMerge({ + loopRoot = DEFAULT_LOOP_ROOT, + runId, + now = new Date(), + githubApi = defaultGitHubApi, +} = {}) { + const normalizedRunId = assertRunId(runId) + const run = await readRun(loopRoot, normalizedRunId) + if (run.status !== 'awaiting_owner_review' || !run.prUrl || !run.headSha) { + throw new Error('owner merge observation requires an awaiting_owner_review run') + } + const target = parseGitHubTarget(run.prUrl) + if (!target || target.kind !== 'pull') throw new Error('run.prUrl must be a GitHub pull request') + const channel = await readJson( + path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'), + ) + const [pullRequest, reviews] = await Promise.all([ + githubApi(`repos/${target.owner}/${target.repo}/pulls/${target.number}`), + githubApi(`repos/${target.owner}/${target.repo}/pulls/${target.number}/reviews?per_page=100`), + ]) + if ( + pullRequest.merged !== true || + !sameGitHubLogin(pullRequest.merged_by?.login, channel.ownerGitHubLogin) || + pullRequest.head?.sha !== run.headSha || + !pullRequest.merge_commit_sha + ) { + throw new Error('pull request is not merged by the configured owner at the reviewed headSha') + } + const ownerApproval = reviews.some( + (review) => + sameGitHubLogin(review.user?.login, channel.ownerGitHubLogin) && + review.state === 'APPROVED' && + review.commit_id === run.headSha, + ) + if (!ownerApproval) throw new Error('configured owner has not approved the reviewed headSha') + + await appendValidatedEvent({ + loopRoot, + runId: normalizedRunId, + type: 'owner_review_approved', + status: 'observed', + payload: { actor: channel.ownerGitHubLogin, headSha: run.headSha, prUrl: run.prUrl }, + now, + }) + await appendValidatedEvent({ + loopRoot, + runId: normalizedRunId, + type: 'pr_merged', + status: 'observed', + payload: { + actor: channel.ownerGitHubLogin, + headSha: run.headSha, + mergeSha: pullRequest.merge_commit_sha, + prUrl: run.prUrl, + }, + now, + }) + return finalizeRun({ + loopRoot, + runId: normalizedRunId, + status: 'completed', + mergeSha: pullRequest.merge_commit_sha, + now, + }) +} diff --git a/loops/issue-dev-loop/scripts/lib/notifications.mjs b/loops/issue-dev-loop/scripts/lib/notifications.mjs new file mode 100644 index 00000000..fd9bf001 --- /dev/null +++ b/loops/issue-dev-loop/scripts/lib/notifications.mjs @@ -0,0 +1,156 @@ +import { randomBytes } from 'node:crypto' +import path from 'node:path' + +import { + DEFAULT_LOOP_ROOT, + assertNonEmpty, + assertRunId, + execFileAsync, + parseGitHubTarget, + readJson, + sameRepository, + timestampToken, + writeJson, +} from './common.mjs' +import { PAUSED_STATUSES, appendEvent, readRun, transitionRun } from './run-store.mjs' + +function notificationBody(notification, owner) { + const evidence = notification.evidenceUrl ? `\n\nEvidence: ${notification.evidenceUrl}` : '' + return [ + `@${owner} **${notification.type}**`, + '', + notification.summary, + '', + `Requested action: ${notification.requestedAction}`, + '', + `Notification: \`${notification.notificationId}\` · Run: \`${notification.runId}\`${evidence}`, + ].join('\n') +} + +async function defaultGitHubComment(target, body) { + await execFileAsync( + 'gh', + [ + 'api', + `repos/${target.owner}/${target.repo}/issues/${target.number}/comments`, + '--method', + 'POST', + '-f', + `body=${body}`, + ], + { maxBuffer: 1024 * 1024 }, + ) +} + +export async function createNotification({ + loopRoot = DEFAULT_LOOP_ROOT, + runId, + type, + summary, + requestedAction, + targetUrl = null, + evidenceUrl = null, + blocking = false, + now = new Date(), + entropy, + dryRun = false, + environment = process.env, + fetchImplementation = globalThis.fetch, + githubComment = defaultGitHubComment, +} = {}) { + const normalizedRunId = assertRunId(runId) + const run = await readRun(loopRoot, normalizedRunId) + const channelRoot = path.resolve(loopRoot, '..', '_shared', 'owner-channel') + const channel = await readJson(path.join(channelRoot, 'channel.json')) + const notificationType = assertNonEmpty(type, 'type') + if (channel.immediateTypes.includes(notificationType) && !blocking) { + throw new Error(`${notificationType} must be sent as a blocking notification`) + } + const target = parseGitHubTarget(targetUrl) + if ( + targetUrl && + (!target || + !sameRepository(parseGitHubTarget(run.issueUrl), target) || + (target.kind === 'issues' && target.number !== run.issueNumber)) + ) { + throw new Error('targetUrl must be the run issue or a pull request in the issue repository') + } + const suffix = (entropy ?? randomBytes(3).toString('hex')).toUpperCase() + const notificationId = `NTF-${timestampToken(now).replace('Z', '')}-${suffix}` + const notification = { + schemaVersion: 1, + notificationId, + loop: 'issue-dev-loop', + runId: normalizedRunId, + type: notificationType, + blocking: Boolean(blocking), + summary: assertNonEmpty(summary, 'summary'), + requestedAction: assertNonEmpty(requestedAction, 'requestedAction'), + targetUrl, + evidenceUrl, + createdAt: now.toISOString(), + delivery: { + github: targetUrl ? 'pending' : 'not_requested', + webhook: environment[channel.webhookEnvironmentVariable] ? 'pending' : 'not_configured', + }, + } + const outboxFile = path.join(channelRoot, 'outbox', `${notificationId}.json`) + await writeJson(outboxFile, notification) + + if (dryRun) { + notification.delivery.github = targetUrl ? 'dry_run' : 'not_requested' + notification.delivery.webhook = environment[channel.webhookEnvironmentVariable] + ? 'dry_run' + : 'not_configured' + } else { + if (target) { + try { + await githubComment(target, notificationBody(notification, channel.ownerGitHubLogin)) + notification.delivery.github = 'delivered' + } catch (error) { + notification.delivery.github = `failed: ${error.message}` + } + } else if (targetUrl) { + notification.delivery.github = 'failed: target is not a GitHub issue or pull request URL' + } + + const webhookUrl = environment[channel.webhookEnvironmentVariable] + if (webhookUrl) { + try { + const response = await fetchImplementation(webhookUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(notification), + }) + notification.delivery.webhook = response.ok + ? 'delivered' + : `failed: HTTP ${response.status}` + } catch (error) { + notification.delivery.webhook = `failed: ${error.message}` + } + } + } + + await writeJson(outboxFile, notification) + const delivered = Object.values(notification.delivery).some((value) => + ['delivered', 'dry_run'].includes(value), + ) + await appendEvent({ + loopRoot, + runId: normalizedRunId, + type: delivered ? 'owner_notified' : 'notification_failed', + status: delivered ? 'delivered' : 'failed', + payload: { notificationId, notificationType, delivery: notification.delivery }, + now, + }) + + if (blocking) { + if (run.finishedAt === null && !PAUSED_STATUSES.has(run.status)) { + await transitionRun({ loopRoot, runId: normalizedRunId, status: 'waiting_for_owner', now }) + } + } + if (blocking && !delivered) { + throw new Error(`blocking notification was not delivered: ${notificationId}`) + } + return notification +} diff --git a/loops/issue-dev-loop/scripts/lib/run-store.mjs b/loops/issue-dev-loop/scripts/lib/run-store.mjs new file mode 100644 index 00000000..46c27551 --- /dev/null +++ b/loops/issue-dev-loop/scripts/lib/run-store.mjs @@ -0,0 +1,297 @@ +import { randomBytes } from 'node:crypto' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' + +import { + DEFAULT_LOOP_ROOT, + appendJsonLine, + assertIssueNumber, + assertNonEmpty, + assertRunId, + parseGitHubTarget, + pathExists, + readJson, + replaceTemplate, + runDirectory, + sameGitHubLogin, + sameRepository, + timestampToken, + writeJson, +} from './common.mjs' +import { updateEvolveMetrics } from './evolve.mjs' + +export const PAUSED_STATUSES = new Set(['awaiting_owner_review', 'waiting_for_owner']) +const TERMINAL_STATUSES = new Set(['completed', 'failed', 'blocked', 'cancelled']) +const RUN_STATUSES = new Set(['running', ...PAUSED_STATUSES, ...TERMINAL_STATUSES]) +const RESERVED_EVENT_TYPES = new Set([ + 'loop_started', + 'verification_completed', + 'review_completed', + 'owner_review_approved', + 'pr_merged', + 'run_status_changed', + 'run_finalized', +]) + +export function makeRunId({ issueNumber, now = new Date(), entropy } = {}) { + const issue = assertIssueNumber(issueNumber) + const suffix = entropy ?? randomBytes(3).toString('hex') + if (!/^[A-Za-z0-9]+$/.test(suffix)) throw new Error('entropy must be alphanumeric') + return `${timestampToken(now)}-issue-${issue}-${suffix.toLowerCase()}` +} + +export async function readRun(loopRoot, runId) { + return readJson(path.join(runDirectory(loopRoot, runId), 'run.json')) +} + +export async function startRun({ + loopRoot = DEFAULT_LOOP_ROOT, + issueNumber, + issueTitle, + issueUrl, + now = new Date(), + entropy, +} = {}) { + const issue = assertIssueNumber(issueNumber) + const title = assertNonEmpty(issueTitle, 'issueTitle') + const url = assertNonEmpty(issueUrl, 'issueUrl') + const runId = makeRunId({ issueNumber: issue, now, entropy }) + const runPath = runDirectory(loopRoot, runId) + if (await pathExists(runPath)) throw new Error(`run already exists: ${runId}`) + + await Promise.all( + [ + runPath, + path.join(loopRoot, 'handoffs', runId), + path.join(loopRoot, 'screen-shots', runId, 'before'), + path.join(loopRoot, 'screen-shots', runId, 'after'), + path.join(loopRoot, 'evidence', runId, 'test-results'), + ].map((directory) => mkdir(directory, { recursive: true })), + ) + + const run = { + schemaVersion: 1, + runId, + issueNumber: issue, + issueTitle: title, + issueUrl: url, + baseBranch: 'dev', + branch: `codex/issue-${issue}`, + status: 'running', + startedAt: now.toISOString(), + finishedAt: null, + prUrl: null, + headSha: null, + mergeSha: null, + } + await writeJson(path.join(runPath, 'run.json'), run) + await appendValidatedEvent({ + loopRoot, + runId, + type: 'loop_started', + status: 'running', + payload: { issueNumber: issue, branch: run.branch }, + now, + }) + + const template = await readFile( + path.join(loopRoot, 'templates', 'implementation-brief.md'), + 'utf8', + ) + const briefPath = path.join(loopRoot, 'handoffs', runId, 'implementation-brief.md') + await writeFile( + briefPath, + replaceTemplate(template, { + RUN_ID: runId, + ISSUE_NUMBER: issue, + ISSUE_TITLE: title, + ISSUE_URL: url, + }), + 'utf8', + ) + return { run, briefPath, runPath } +} + +export async function appendValidatedEvent({ + loopRoot = DEFAULT_LOOP_ROOT, + runId, + type, + status = null, + payload = {}, + now = new Date(), +} = {}) { + const normalizedRunId = assertRunId(runId) + const eventType = assertNonEmpty(type, 'type') + const runPath = runDirectory(loopRoot, normalizedRunId) + if (!(await pathExists(path.join(runPath, 'run.json')))) { + throw new Error(`unknown run: ${normalizedRunId}`) + } + if (payload === null || Array.isArray(payload) || typeof payload !== 'object') { + throw new Error('payload must be an object') + } + const event = { + schemaVersion: 1, + runId: normalizedRunId, + type: eventType, + timestamp: now.toISOString(), + status, + payload, + } + await appendJsonLine(path.join(runPath, 'events.jsonl'), event) + return event +} + +export async function appendEvent(options = {}) { + if (RESERVED_EVENT_TYPES.has(options.type)) { + throw new Error(`event type is reserved for a validated runtime operation: ${options.type}`) + } + return appendValidatedEvent(options) +} + +export async function readEvents(loopRoot, runId) { + const contents = await readFile(path.join(runDirectory(loopRoot, runId), 'events.jsonl'), 'utf8') + return contents + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) +} + +function hasPassedEventForHead(events, type, headSha) { + return events.some( + (event) => + event.type === type && + event.payload?.headSha === headSha && + (event.status === 'passed' || + event.payload?.verdict === 'passed' || + event.payload?.verdict === 'PASS'), + ) +} + +export async function transitionRun({ + loopRoot = DEFAULT_LOOP_ROOT, + runId, + status, + prUrl = null, + headSha = null, + mergeSha = null, + failureFingerprint = null, + now = new Date(), +} = {}) { + const normalizedRunId = assertRunId(runId) + if (!RUN_STATUSES.has(status)) throw new Error(`invalid run status: ${status}`) + + const runPath = runDirectory(loopRoot, normalizedRunId) + const runFile = path.join(runPath, 'run.json') + const run = await readJson(runFile) + if (run.finishedAt !== null) throw new Error(`run is already finalized: ${normalizedRunId}`) + if (run.status === status) throw new Error(`run already has status: ${status}`) + const events = await readEvents(loopRoot, normalizedRunId) + + if (status === 'awaiting_owner_review') { + if (!prUrl || !headSha) throw new Error('awaiting_owner_review requires prUrl and headSha') + const issueTarget = parseGitHubTarget(run.issueUrl) + const pullRequestTarget = parseGitHubTarget(prUrl) + if ( + !pullRequestTarget || + pullRequestTarget.kind !== 'pull' || + !sameRepository(issueTarget, pullRequestTarget) + ) { + throw new Error('awaiting_owner_review requires a PR in the issue repository') + } + if (!hasPassedEventForHead(events, 'verification_completed', headSha)) { + throw new Error('awaiting_owner_review requires passed verification_completed for headSha') + } + if (!hasPassedEventForHead(events, 'review_completed', headSha)) { + throw new Error('awaiting_owner_review requires passed review_completed for headSha') + } + } + + if (status === 'completed') { + if (run.status !== 'awaiting_owner_review' || !run.prUrl || !run.headSha || !mergeSha) { + throw new Error('completed requires an owner-ready PR and mergeSha') + } + const channel = await readJson( + path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'), + ) + const ownerApproval = events.some( + (event) => + event.type === 'owner_review_approved' && + event.status === 'observed' && + sameGitHubLogin(event.payload?.actor, channel.ownerGitHubLogin) && + event.payload?.headSha === run.headSha, + ) + const ownerMerge = events.some( + (event) => + event.type === 'pr_merged' && + event.status === 'observed' && + sameGitHubLogin(event.payload?.actor, channel.ownerGitHubLogin) && + event.payload?.headSha === run.headSha && + event.payload?.mergeSha === mergeSha, + ) + if (!ownerApproval || !ownerMerge) { + throw new Error( + 'completed requires observed owner approval and owner merge for the current head', + ) + } + } + if (['failed', 'blocked'].includes(status)) { + assertNonEmpty(failureFingerprint, 'failureFingerprint') + } + + const transitioned = { + ...run, + status, + finishedAt: TERMINAL_STATUSES.has(status) ? now.toISOString() : null, + prUrl: prUrl ?? run.prUrl, + headSha: headSha ?? run.headSha, + mergeSha: mergeSha ?? run.mergeSha, + } + await writeJson(runFile, transitioned) + await appendValidatedEvent({ + loopRoot, + runId: normalizedRunId, + type: TERMINAL_STATUSES.has(status) ? 'run_finalized' : 'run_status_changed', + status, + payload: { previousStatus: run.status }, + now, + }) + if (!TERMINAL_STATUSES.has(status)) return transitioned + + const summaryTemplate = await readFile(path.join(loopRoot, 'templates', 'run-summary.md'), 'utf8') + await writeFile( + path.join(runPath, 'summary.md'), + replaceTemplate(summaryTemplate, { + RUN_ID: normalizedRunId, + ISSUE_NUMBER: run.issueNumber, + STATUS: status, + STARTED_AT: run.startedAt, + FINISHED_AT: transitioned.finishedAt, + PR_URL: transitioned.prUrl ?? 'N/A', + HEAD_SHA: transitioned.headSha ?? 'N/A', + MERGE_SHA: transitioned.mergeSha ?? 'N/A', + }), + 'utf8', + ) + await appendJsonLine(path.join(loopRoot, 'logs', 'index.jsonl'), { + schemaVersion: 1, + event: 'run_finalized', + runId: normalizedRunId, + issueNumber: run.issueNumber, + status, + startedAt: run.startedAt, + finishedAt: transitioned.finishedAt, + prUrl: transitioned.prUrl, + headSha: transitioned.headSha, + mergeSha: transitioned.mergeSha, + failureFingerprint, + }) + await updateEvolveMetrics({ loopRoot, status, failureFingerprint, now }) + return transitioned +} + +export async function finalizeRun(options = {}) { + if (!TERMINAL_STATUSES.has(options.status)) { + throw new Error(`invalid final status: ${options.status}`) + } + return transitionRun(options) +} diff --git a/loops/issue-dev-loop/scripts/lib/validation.mjs b/loops/issue-dev-loop/scripts/lib/validation.mjs new file mode 100644 index 00000000..c3f2d93c --- /dev/null +++ b/loops/issue-dev-loop/scripts/lib/validation.mjs @@ -0,0 +1,94 @@ +import { readFile, readdir } from 'node:fs/promises' +import path from 'node:path' + +import { DEFAULT_LOOP_ROOT, pathExists, readJson } from './common.mjs' + +async function collectFiles(root, output = []) { + const entries = await readdir(root, { withFileTypes: true }) + for (const entry of entries) { + if (['node_modules', '.git'].includes(entry.name)) continue + const target = path.join(root, entry.name) + if (entry.isDirectory()) await collectFiles(target, output) + else output.push(target) + } + return output +} + +export async function validateLoop({ loopRoot = DEFAULT_LOOP_ROOT } = {}) { + const required = [ + 'SKILL.md', + 'LOOP.md', + 'state.md', + 'dependencies.md', + 'agents/openai.yaml', + 'review/REVIEW.md', + 'review/response-policy.md', + 'review/result.schema.json', + 'triggers/TRIGGER.md', + 'evolve/EVOLVE.md', + 'evolve/metrics.json', + 'templates/implementation-brief.md', + 'templates/pr-body.md', + 'schemas/event.schema.json', + 'schemas/run.schema.json', + 'schemas/evidence.schema.json', + 'scripts/generate-evidence.mjs', + 'scripts/resolve-run.mjs', + 'scripts/lib/common.mjs', + 'scripts/lib/evidence.mjs', + 'scripts/lib/evolve.mjs', + 'scripts/lib/github.mjs', + 'scripts/lib/notifications.mjs', + 'scripts/lib/run-store.mjs', + 'scripts/lib/validation.mjs', + 'logs/index.jsonl', + 'screen-shots/.gitignore', + ] + const missing = [] + for (const relative of required) { + if (!(await pathExists(path.join(loopRoot, relative)))) missing.push(relative) + } + if (missing.length > 0) throw new Error(`missing required loop files: ${missing.join(', ')}`) + + const jsonFiles = (await collectFiles(loopRoot)).filter((target) => target.endsWith('.json')) + const sharedChannelRoot = path.resolve(loopRoot, '..', '_shared', 'owner-channel') + jsonFiles.push( + ...(await collectFiles(sharedChannelRoot)).filter((target) => target.endsWith('.json')), + ) + for (const target of jsonFiles) await readJson(target) + + const contract = await readFile(path.join(loopRoot, 'LOOP.md'), 'utf8') + const skill = await readFile(path.join(loopRoot, 'SKILL.md'), 'utf8') + for (const phrase of [ + 'draft PR targeting `dev`', + 'approve, auto-merge, or merge any PR', + 'Only `observe-owner-merge`', + 'exact reviewed head SHA', + 'No eligible work is a successful no-op', + ]) { + if (!contract.includes(phrase)) throw new Error(`LOOP.md is missing invariant: ${phrase}`) + } + for (const phrase of [ + '$implement', + 'echo_ui_pr_reviewer', + 'echo_ui_loop_evolver', + 'record-evidence', + 'pnpm verify', + ]) { + if (!skill.includes(phrase)) { + throw new Error(`SKILL.md is missing runtime dependency: ${phrase}`) + } + } + + const textualFiles = (await collectFiles(loopRoot)).filter((target) => + /\.(?:md|json|ya?ml|toml|mjs)$/.test(target), + ) + const macUserRootMarker = ['', 'Users', ''].join('/') + for (const target of textualFiles) { + const contents = await readFile(target, 'utf8') + if (contents.includes(macUserRootMarker)) { + throw new Error(`machine-specific absolute path found in ${path.relative(loopRoot, target)}`) + } + } + return { valid: true, checkedFiles: required.length + jsonFiles.length } +} diff --git a/loops/issue-dev-loop/scripts/loopctl.mjs b/loops/issue-dev-loop/scripts/loopctl.mjs index fc5a0062..10e20818 100644 --- a/loops/issue-dev-loop/scripts/loopctl.mjs +++ b/loops/issue-dev-loop/scripts/loopctl.mjs @@ -2,10 +2,15 @@ import { appendEvent, + completeEvolve, createNotification, detectWork, finalizeRun, + getEvolveStatus, + observeOwnerMerge, parseArguments, + recordEvidence, + recordReview, startRun, transitionRun, validateLoop, @@ -24,6 +29,17 @@ function parsePayload(value) { return parsed } +function runTransitionOptions(args) { + return { + runId: args['run-id'], + status: args.status, + prUrl: args['pr-url'] ?? null, + headSha: args['head-sha'] ?? null, + mergeSha: args['merge-sha'] ?? null, + failureFingerprint: args['failure-fingerprint'] ?? null, + } +} + async function main() { const [command, ...rest] = process.argv.slice(2) const args = parseArguments(rest) @@ -50,27 +66,32 @@ async function main() { ) break case 'transition': + output(await transitionRun(runTransitionOptions(args))) + break + case 'finalize': + output(await finalizeRun(runTransitionOptions(args))) + break + case 'record-evidence': output( - await transitionRun({ + await recordEvidence({ runId: args['run-id'], - status: args.status, - prUrl: args['pr-url'] ?? null, - headSha: args['head-sha'] ?? null, - mergeSha: args['merge-sha'] ?? null, + manifestPath: args.manifest, + publicationUrl: args['publication-url'], }), ) break - case 'finalize': + case 'record-review': output( - await finalizeRun({ + await recordReview({ runId: args['run-id'], - status: args.status, - prUrl: args['pr-url'] ?? null, - headSha: args['head-sha'] ?? null, - mergeSha: args['merge-sha'] ?? null, + resultPath: args.result, + reviewUrl: args['review-url'], }), ) break + case 'observe-owner-merge': + output(await observeOwnerMerge({ runId: args['run-id'] })) + break case 'notify': output( await createNotification({ @@ -97,9 +118,21 @@ async function main() { case 'validate': output(await validateLoop()) break + case 'evolve-status': + output(await getEvolveStatus()) + break + case 'evolve-complete': + output( + await completeEvolve({ + requestId: args['request-id'], + summary: args.summary, + prUrl: args['pr-url'], + }), + ) + break default: throw new Error( - 'usage: loopctl.mjs [options]', + 'usage: loopctl.mjs [options]', ) } } diff --git a/loops/issue-dev-loop/scripts/resolve-run.mjs b/loops/issue-dev-loop/scripts/resolve-run.mjs new file mode 100644 index 00000000..42c377f1 --- /dev/null +++ b/loops/issue-dev-loop/scripts/resolve-run.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node + +import { appendFile, readFile, readdir } from 'node:fs/promises' +import path from 'node:path' + +import { DEFAULT_LOOP_ROOT, parseArguments } from './runtime.mjs' + +const args = parseArguments(process.argv.slice(2)) +const loopRoot = args['loop-root'] ? path.resolve(args['loop-root']) : DEFAULT_LOOP_ROOT +const branch = args.branch +if (typeof branch !== 'string' || branch.trim() === '') throw new Error('--branch is required') + +const runsRoot = path.join(loopRoot, 'logs', 'runs') +const matches = [] +for (const entry of await readdir(runsRoot, { withFileTypes: true })) { + if (!entry.isDirectory()) continue + try { + const run = JSON.parse(await readFile(path.join(runsRoot, entry.name, 'run.json'), 'utf8')) + if (run.branch === branch && run.finishedAt === null) matches.push(run) + } catch (error) { + if (error?.code !== 'ENOENT') throw error + } +} +if (matches.length > 1) throw new Error(`multiple active runs found for ${branch}`) + +const result = matches[0] + ? { hasRun: true, runId: matches[0].runId } + : { hasRun: false, runId: null } +if (process.env.GITHUB_OUTPUT) { + await appendFile( + process.env.GITHUB_OUTPUT, + `has_run=${result.hasRun}\nrun_id=${result.runId ?? ''}\n`, + 'utf8', + ) +} +process.stdout.write(`${JSON.stringify(result)}\n`) diff --git a/loops/issue-dev-loop/scripts/runtime.mjs b/loops/issue-dev-loop/scripts/runtime.mjs index 7ebd3923..607096ec 100644 --- a/loops/issue-dev-loop/scripts/runtime.mjs +++ b/loops/issue-dev-loop/scripts/runtime.mjs @@ -1,640 +1,7 @@ -import { execFile } from 'node:child_process' -import { randomBytes } from 'node:crypto' -import { appendFile, mkdir, readFile, readdir, rename, stat, writeFile } from 'node:fs/promises' -import path from 'node:path' -import { promisify } from 'node:util' -import { fileURLToPath } from 'node:url' - -const execFileAsync = promisify(execFile) -const moduleDirectory = path.dirname(fileURLToPath(import.meta.url)) - -export const DEFAULT_LOOP_ROOT = path.resolve(moduleDirectory, '..') - -const PAUSED_STATUSES = new Set(['awaiting_owner_review', 'waiting_for_owner']) - -const TERMINAL_STATUSES = new Set(['completed', 'failed', 'blocked', 'cancelled']) - -const RUN_STATUSES = new Set(['running', ...PAUSED_STATUSES, ...TERMINAL_STATUSES]) - -const PRIORITY = new Map([ - ['priority:critical', 0], - ['priority:high', 1], - ['priority:medium', 2], - ['priority:low', 3], -]) - -function assertNonEmpty(value, name) { - if (typeof value !== 'string' || value.trim() === '') { - throw new Error(`${name} must be a non-empty string`) - } - return value.trim() -} - -function assertIssueNumber(value) { - const parsed = Number(value) - if (!Number.isInteger(parsed) || parsed < 1) { - throw new Error('issueNumber must be a positive integer') - } - return parsed -} - -function assertRunId(runId) { - const normalized = assertNonEmpty(runId, 'runId') - if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(normalized)) { - throw new Error('runId contains unsafe characters') - } - return normalized -} - -function timestampToken(now) { - return now - .toISOString() - .replace(/[-:]/g, '') - .replace(/\.\d{3}Z$/, 'Z') -} - -export function makeRunId({ issueNumber, now = new Date(), entropy } = {}) { - const issue = assertIssueNumber(issueNumber) - const suffix = entropy ?? randomBytes(3).toString('hex') - if (!/^[A-Za-z0-9]+$/.test(suffix)) { - throw new Error('entropy must be alphanumeric') - } - return `${timestampToken(now)}-issue-${issue}-${suffix.toLowerCase()}` -} - -export function parseArguments(argv) { - const values = { _: [] } - for (let index = 0; index < argv.length; index += 1) { - const token = argv[index] - if (!token.startsWith('--')) { - values._.push(token) - continue - } - const key = token.slice(2) - const next = argv[index + 1] - if (next === undefined || next.startsWith('--')) { - values[key] = true - continue - } - values[key] = next - index += 1 - } - return values -} - -async function pathExists(target) { - try { - await stat(target) - return true - } catch (error) { - if (error?.code === 'ENOENT') return false - throw error - } -} - -async function readJson(target) { - return JSON.parse(await readFile(target, 'utf8')) -} - -async function writeJson(target, value) { - await mkdir(path.dirname(target), { recursive: true }) - const temporary = `${target}.tmp-${process.pid}-${randomBytes(3).toString('hex')}` - await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, 'utf8') - await rename(temporary, target) -} - -async function appendJsonLine(target, value) { - await mkdir(path.dirname(target), { recursive: true }) - await appendFile(target, `${JSON.stringify(value)}\n`, 'utf8') -} - -function runDirectory(loopRoot, runId) { - return path.join(loopRoot, 'logs', 'runs', assertRunId(runId)) -} - -function replaceTemplate(template, replacements) { - let output = template - for (const [key, value] of Object.entries(replacements)) { - output = output.replaceAll(`{{${key}}}`, String(value ?? '')) - } - return output -} - -export async function startRun({ - loopRoot = DEFAULT_LOOP_ROOT, - issueNumber, - issueTitle, - issueUrl, - now = new Date(), - entropy, -} = {}) { - const issue = assertIssueNumber(issueNumber) - const title = assertNonEmpty(issueTitle, 'issueTitle') - const url = assertNonEmpty(issueUrl, 'issueUrl') - const runId = makeRunId({ issueNumber: issue, now, entropy }) - const runPath = runDirectory(loopRoot, runId) - - if (await pathExists(runPath)) { - throw new Error(`run already exists: ${runId}`) - } - - const directories = [ - runPath, - path.join(loopRoot, 'handoffs', runId), - path.join(loopRoot, 'screenshots', runId, 'before'), - path.join(loopRoot, 'screenshots', runId, 'after'), - path.join(loopRoot, 'evidence', runId, 'test-results'), - ] - await Promise.all(directories.map((directory) => mkdir(directory, { recursive: true }))) - - const run = { - schemaVersion: 1, - runId, - issueNumber: issue, - issueTitle: title, - issueUrl: url, - baseBranch: 'dev', - branch: `codex/issue-${issue}`, - status: 'running', - startedAt: now.toISOString(), - finishedAt: null, - prUrl: null, - headSha: null, - mergeSha: null, - } - - await writeJson(path.join(runPath, 'run.json'), run) - await appendEvent({ - loopRoot, - runId, - type: 'loop_started', - status: 'running', - payload: { issueNumber: issue, branch: run.branch }, - now, - }) - - const templatePath = path.join(loopRoot, 'templates', 'implementation-brief.md') - const template = await readFile(templatePath, 'utf8') - const brief = replaceTemplate(template, { - RUN_ID: runId, - ISSUE_NUMBER: issue, - ISSUE_TITLE: title, - ISSUE_URL: url, - }) - const briefPath = path.join(loopRoot, 'handoffs', runId, 'implementation-brief.md') - await writeFile(briefPath, brief, 'utf8') - - return { run, briefPath, runPath } -} - -export async function appendEvent({ - loopRoot = DEFAULT_LOOP_ROOT, - runId, - type, - status = null, - payload = {}, - now = new Date(), -} = {}) { - const normalizedRunId = assertRunId(runId) - const eventType = assertNonEmpty(type, 'type') - const runPath = runDirectory(loopRoot, normalizedRunId) - if (!(await pathExists(path.join(runPath, 'run.json')))) { - throw new Error(`unknown run: ${normalizedRunId}`) - } - if (payload === null || Array.isArray(payload) || typeof payload !== 'object') { - throw new Error('payload must be an object') - } - - const event = { - schemaVersion: 1, - runId: normalizedRunId, - type: eventType, - timestamp: now.toISOString(), - status, - payload, - } - await appendJsonLine(path.join(runPath, 'events.jsonl'), event) - return event -} - -async function readEvents(loopRoot, runId) { - const target = path.join(runDirectory(loopRoot, runId), 'events.jsonl') - const contents = await readFile(target, 'utf8') - return contents - .split('\n') - .filter(Boolean) - .map((line) => JSON.parse(line)) -} - -function hasPassedEvent(events, type) { - return events.some( - (event) => - event.type === type && - (event.status === 'passed' || - event.payload?.verdict === 'passed' || - event.payload?.verdict === 'PASS'), - ) -} - -export async function transitionRun({ - loopRoot = DEFAULT_LOOP_ROOT, - runId, - status, - prUrl = null, - headSha = null, - mergeSha = null, - now = new Date(), -} = {}) { - const normalizedRunId = assertRunId(runId) - if (!RUN_STATUSES.has(status)) { - throw new Error(`invalid run status: ${status}`) - } - - const runPath = runDirectory(loopRoot, normalizedRunId) - const runFile = path.join(runPath, 'run.json') - const run = await readJson(runFile) - if (run.finishedAt !== null) { - throw new Error(`run is already finalized: ${normalizedRunId}`) - } - if (run.status === status) { - throw new Error(`run already has status: ${status}`) - } - const events = await readEvents(loopRoot, normalizedRunId) - - if (status === 'awaiting_owner_review') { - if (!prUrl || !headSha) { - throw new Error('awaiting_owner_review requires prUrl and headSha') - } - if (!hasPassedEvent(events, 'verification_completed')) { - throw new Error('awaiting_owner_review requires passed verification_completed') - } - if (!hasPassedEvent(events, 'review_completed')) { - throw new Error('awaiting_owner_review requires passed review_completed') - } - } - - if (status === 'completed') { - if (!mergeSha || !events.some((event) => event.type === 'pr_merged')) { - throw new Error('completed requires an observed pr_merged event and mergeSha') - } - } - - const transitioned = { - ...run, - status, - finishedAt: TERMINAL_STATUSES.has(status) ? now.toISOString() : null, - prUrl: prUrl ?? run.prUrl, - headSha: headSha ?? run.headSha, - mergeSha: mergeSha ?? run.mergeSha, - } - await writeJson(runFile, transitioned) - - await appendEvent({ - loopRoot, - runId: normalizedRunId, - type: TERMINAL_STATUSES.has(status) ? 'run_finalized' : 'run_status_changed', - status, - payload: { previousStatus: run.status }, - now, - }) - - if (!TERMINAL_STATUSES.has(status)) { - return transitioned - } - - const summaryTemplate = await readFile(path.join(loopRoot, 'templates', 'run-summary.md'), 'utf8') - const summary = replaceTemplate(summaryTemplate, { - RUN_ID: normalizedRunId, - ISSUE_NUMBER: run.issueNumber, - STATUS: status, - STARTED_AT: run.startedAt, - FINISHED_AT: transitioned.finishedAt, - PR_URL: transitioned.prUrl ?? 'N/A', - HEAD_SHA: transitioned.headSha ?? 'N/A', - MERGE_SHA: transitioned.mergeSha ?? 'N/A', - }) - await writeFile(path.join(runPath, 'summary.md'), summary, 'utf8') - - await appendJsonLine(path.join(loopRoot, 'logs', 'index.jsonl'), { - schemaVersion: 1, - event: 'run_finalized', - runId: normalizedRunId, - issueNumber: run.issueNumber, - status, - startedAt: run.startedAt, - finishedAt: transitioned.finishedAt, - prUrl: transitioned.prUrl, - headSha: transitioned.headSha, - mergeSha: transitioned.mergeSha, - }) - - return transitioned -} - -export async function finalizeRun(options = {}) { - if (!TERMINAL_STATUSES.has(options.status)) { - throw new Error(`invalid final status: ${options.status}`) - } - return transitionRun(options) -} - -function labelNames(issue) { - return new Set( - (issue.labels ?? []).map((label) => (typeof label === 'string' ? label : label.name)), - ) -} - -function issuePriority(issue) { - const labels = labelNames(issue) - let rank = 4 - for (const [label, value] of PRIORITY) { - if (labels.has(label)) rank = Math.min(rank, value) - } - return rank -} - -function pullRequestClaimsIssue(pullRequest, issueNumber) { - if (pullRequest.headRefName === `codex/issue-${issueNumber}`) return true - const searchable = `${pullRequest.title ?? ''}\n${pullRequest.body ?? ''}` - return new RegExp( - `(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)?\\s*#${issueNumber}(?!\\d)`, - 'i', - ).test(searchable) -} - -export function selectIssue({ issues = [], pullRequests = [] } = {}) { - const eligible = issues.filter((issue) => { - const labels = labelNames(issue) - return ( - labels.has('codex-ready') && - !labels.has('loop:claimed') && - !pullRequests.some((pullRequest) => pullRequestClaimsIssue(pullRequest, issue.number)) - ) - }) - - eligible.sort((left, right) => { - const priorityDifference = issuePriority(left) - issuePriority(right) - if (priorityDifference !== 0) return priorityDifference - const leftCreated = Date.parse(left.createdAt ?? 0) - const rightCreated = Date.parse(right.createdAt ?? 0) - if (leftCreated !== rightCreated) return leftCreated - rightCreated - return left.number - right.number - }) - - const issue = eligible[0] - return issue ? { hasWork: true, issue } : { hasWork: false, issue: null } -} - -async function loadJsonFile(target) { - return JSON.parse(await readFile(path.resolve(target), 'utf8')) -} - -export async function detectWork({ issuesFile, pullRequestsFile, repo } = {}) { - let issues - let pullRequests - - if (issuesFile) { - issues = await loadJsonFile(issuesFile) - } else { - const argumentsList = [ - 'issue', - 'list', - '--state', - 'open', - '--label', - 'codex-ready', - '--limit', - '100', - '--json', - 'number,title,url,labels,createdAt', - ] - if (repo) argumentsList.push('--repo', repo) - const result = await execFileAsync('gh', argumentsList, { maxBuffer: 1024 * 1024 }) - issues = JSON.parse(result.stdout) - } - - if (pullRequestsFile) { - pullRequests = await loadJsonFile(pullRequestsFile) - } else { - const argumentsList = [ - 'pr', - 'list', - '--state', - 'open', - '--limit', - '100', - '--json', - 'number,title,url,headRefName,body', - ] - if (repo) argumentsList.push('--repo', repo) - const result = await execFileAsync('gh', argumentsList, { maxBuffer: 1024 * 1024 }) - pullRequests = JSON.parse(result.stdout) - } - - return selectIssue({ issues, pullRequests }) -} - -function parseGitHubTarget(targetUrl) { - if (!targetUrl) return null - const parsed = new URL(targetUrl) - if (parsed.hostname !== 'github.com') return null - const match = parsed.pathname.match(/^\/([^/]+)\/([^/]+)\/(?:issues|pull)\/(\d+)\/?$/) - if (!match) return null - return { owner: match[1], repo: match[2], number: Number(match[3]) } -} - -function notificationBody(notification, owner) { - const evidence = notification.evidenceUrl ? `\n\nEvidence: ${notification.evidenceUrl}` : '' - return [ - `@${owner} **${notification.type}**`, - '', - notification.summary, - '', - `Requested action: ${notification.requestedAction}`, - '', - `Notification: \`${notification.notificationId}\` · Run: \`${notification.runId}\`${evidence}`, - ].join('\n') -} - -async function defaultGitHubComment(target, body) { - await execFileAsync( - 'gh', - [ - 'api', - `repos/${target.owner}/${target.repo}/issues/${target.number}/comments`, - '--method', - 'POST', - '-f', - `body=${body}`, - ], - { maxBuffer: 1024 * 1024 }, - ) -} - -export async function createNotification({ - loopRoot = DEFAULT_LOOP_ROOT, - runId, - type, - summary, - requestedAction, - targetUrl = null, - evidenceUrl = null, - blocking = false, - now = new Date(), - entropy, - dryRun = false, - environment = process.env, - fetchImplementation = globalThis.fetch, - githubComment = defaultGitHubComment, -} = {}) { - const normalizedRunId = assertRunId(runId) - const channelRoot = path.resolve(loopRoot, '..', '_shared', 'owner-channel') - const channel = await readJson(path.join(channelRoot, 'channel.json')) - const suffix = (entropy ?? randomBytes(3).toString('hex')).toUpperCase() - const notificationId = `NTF-${timestampToken(now).replace('Z', '')}-${suffix}` - const notification = { - schemaVersion: 1, - notificationId, - loop: 'issue-dev-loop', - runId: normalizedRunId, - type: assertNonEmpty(type, 'type'), - blocking: Boolean(blocking), - summary: assertNonEmpty(summary, 'summary'), - requestedAction: assertNonEmpty(requestedAction, 'requestedAction'), - targetUrl, - evidenceUrl, - createdAt: now.toISOString(), - delivery: { - github: targetUrl ? 'pending' : 'not_requested', - webhook: environment[channel.webhookEnvironmentVariable] ? 'pending' : 'not_configured', - }, - } - - const outboxFile = path.join(channelRoot, 'outbox', `${notificationId}.json`) - await writeJson(outboxFile, notification) - - if (dryRun) { - notification.delivery.github = targetUrl ? 'dry_run' : 'not_requested' - notification.delivery.webhook = environment[channel.webhookEnvironmentVariable] - ? 'dry_run' - : 'not_configured' - } else { - const target = parseGitHubTarget(targetUrl) - if (target) { - try { - await githubComment(target, notificationBody(notification, channel.ownerGitHubLogin)) - notification.delivery.github = 'delivered' - } catch (error) { - notification.delivery.github = `failed: ${error.message}` - } - } else if (targetUrl) { - notification.delivery.github = 'failed: target is not a GitHub issue or pull request URL' - } - - const webhookUrl = environment[channel.webhookEnvironmentVariable] - if (webhookUrl) { - try { - const response = await fetchImplementation(webhookUrl, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(notification), - }) - notification.delivery.webhook = response.ok - ? 'delivered' - : `failed: HTTP ${response.status}` - } catch (error) { - notification.delivery.webhook = `failed: ${error.message}` - } - } - } - - await writeJson(outboxFile, notification) - const delivered = Object.values(notification.delivery).some((value) => - ['delivered', 'dry_run'].includes(value), - ) - await appendEvent({ - loopRoot, - runId: normalizedRunId, - type: delivered ? 'owner_notified' : 'notification_failed', - status: delivered ? 'delivered' : 'failed', - payload: { notificationId, notificationType: type, delivery: notification.delivery }, - now, - }) - - if (blocking && !delivered) { - throw new Error(`blocking notification was not delivered: ${notificationId}`) - } - return notification -} - -async function collectFiles(root, output = []) { - const entries = await readdir(root, { withFileTypes: true }) - for (const entry of entries) { - if (['node_modules', '.git'].includes(entry.name)) continue - const target = path.join(root, entry.name) - if (entry.isDirectory()) await collectFiles(target, output) - else output.push(target) - } - return output -} - -export async function validateLoop({ loopRoot = DEFAULT_LOOP_ROOT } = {}) { - const required = [ - 'SKILL.md', - 'LOOP.md', - 'state.md', - 'dependencies.md', - 'agents/openai.yaml', - 'review/REVIEW.md', - 'review/response-policy.md', - 'triggers/TRIGGER.md', - 'templates/implementation-brief.md', - 'templates/pr-body.md', - 'schemas/event.schema.json', - 'schemas/run.schema.json', - 'schemas/evidence.schema.json', - 'logs/index.jsonl', - ] - - const missing = [] - for (const relative of required) { - if (!(await pathExists(path.join(loopRoot, relative)))) missing.push(relative) - } - if (missing.length > 0) { - throw new Error(`missing required loop files: ${missing.join(', ')}`) - } - - const jsonFiles = (await collectFiles(loopRoot)).filter((target) => target.endsWith('.json')) - const sharedChannel = path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json') - jsonFiles.push(sharedChannel) - for (const target of jsonFiles) await readJson(target) - - const contract = await readFile(path.join(loopRoot, 'LOOP.md'), 'utf8') - const skill = await readFile(path.join(loopRoot, 'SKILL.md'), 'utf8') - const requiredContractPhrases = [ - 'draft PR targeting `dev`', - 'approve, auto-merge, or merge any PR', - 'Only an observed owner merge', - 'No eligible work is a successful no-op', - ] - for (const phrase of requiredContractPhrases) { - if (!contract.includes(phrase)) throw new Error(`LOOP.md is missing invariant: ${phrase}`) - } - for (const phrase of ['$implement', 'echo_ui_pr_reviewer', 'pnpm verify']) { - if (!skill.includes(phrase)) - throw new Error(`SKILL.md is missing runtime dependency: ${phrase}`) - } - - const textualFiles = (await collectFiles(loopRoot)).filter((target) => - /\.(?:md|json|ya?ml|toml|mjs)$/.test(target), - ) - const macUserRootMarker = ['', 'Users', ''].join('/') - for (const target of textualFiles) { - const contents = await readFile(target, 'utf8') - if (contents.includes(macUserRootMarker)) { - throw new Error(`machine-specific absolute path found in ${path.relative(loopRoot, target)}`) - } - } - - return { valid: true, checkedFiles: required.length + jsonFiles.length } -} +export { DEFAULT_LOOP_ROOT, parseArguments } from './lib/common.mjs' +export { completeEvolve, getEvolveStatus } from './lib/evolve.mjs' +export { recordEvidence, recordReview } from './lib/evidence.mjs' +export { detectWork, observeOwnerMerge, selectIssue } from './lib/github.mjs' +export { createNotification } from './lib/notifications.mjs' +export { appendEvent, finalizeRun, makeRunId, startRun, transitionRun } from './lib/run-store.mjs' +export { validateLoop } from './lib/validation.mjs' diff --git a/loops/issue-dev-loop/state.md b/loops/issue-dev-loop/state.md index 75e18ef1..fe1f0d3f 100644 --- a/loops/issue-dev-loop/state.md +++ b/loops/issue-dev-loop/state.md @@ -23,6 +23,9 @@ None. ## Blockers - Configure the GitHub authentication used by unattended runs. +- Merge this infrastructure into `dev` before enabling its recurring automation; the PR evidence workflow must exist on the base branch. +- Repair or explicitly rebaseline the existing `tests/docs-cutover.test.ts` README contract failure before any issue PR can satisfy authoritative `pnpm verify`. +- Choose the recurring Codex automation cadence after the infrastructure PR is merged. - Optionally configure `ECHO_UI_LOOP_OWNER_WEBHOOK_URL` for a push-channel mirror; GitHub mentions remain the canonical baseline channel. ## Follow-ups diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index 567e52d6..ebeacb98 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -1,14 +1,20 @@ import assert from 'node:assert/strict' +import { execFile } from 'node:child_process' import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import test from 'node:test' +import { promisify } from 'node:util' import { fileURLToPath } from 'node:url' import { appendEvent, createNotification, finalizeRun, + getEvolveStatus, + observeOwnerMerge, + recordEvidence, + recordReview, selectIssue, startRun, transitionRun, @@ -17,6 +23,7 @@ import { const testDirectory = path.dirname(fileURLToPath(import.meta.url)) const repositoryLoopRoot = path.resolve(testDirectory, '..') +const execFileAsync = promisify(execFile) async function createFixture() { const parent = await mkdtemp(path.join(os.tmpdir(), 'echo-ui-loop-test-')) @@ -25,6 +32,8 @@ async function createFixture() { await Promise.all([ mkdir(path.join(loopRoot, 'templates'), { recursive: true }), mkdir(path.join(loopRoot, 'logs', 'runs'), { recursive: true }), + mkdir(path.join(loopRoot, 'evidence'), { recursive: true }), + mkdir(path.join(loopRoot, 'evolve', 'requests'), { recursive: true }), mkdir(path.join(channelRoot, 'outbox'), { recursive: true }), ]) for (const name of ['implementation-brief.md', 'run-summary.md']) { @@ -36,18 +45,94 @@ async function createFixture() { '{"schemaVersion":1,"event":"loop_initialized"}\n', 'utf8', ) + await writeFile( + path.join(loopRoot, 'evolve', 'metrics.json'), + `${JSON.stringify({ + schemaVersion: 1, + finalizedRuns: 0, + successfulRuns: 0, + failedRuns: 0, + noWorkChecks: 0, + ownerRequestedChanges: 0, + revertedPullRequests: 0, + reviewFindings: { accepted: 0, rejected: 0, needsHuman: 0 }, + recentFailureFingerprints: [], + evolveDue: false, + pendingRequestId: null, + lastEvolvedAt: null, + lastEvolvedRunCount: 0, + completedEvolveSessions: 0, + })}\n`, + 'utf8', + ) await writeFile( path.join(channelRoot, 'channel.json'), `${JSON.stringify({ schemaVersion: 1, ownerGitHubLogin: 'codeacme17', webhookEnvironmentVariable: 'TEST_LOOP_WEBHOOK_URL', + immediateTypes: [ + 'approval_required', + 'clarification_required', + 'blocked', + 'review_dispute', + 'pr_ready_for_review', + 'pr_updated_for_review', + 'loop_failed', + ], })}\n`, 'utf8', ) return { loopRoot, channelRoot } } +async function writePassingEvidence({ loopRoot, run, headSha }) { + const manifestPath = path.join(loopRoot, 'evidence', run.runId, 'manifest.json') + await mkdir(path.dirname(manifestPath), { recursive: true }) + const timestamp = '2026-07-22T16:30:00.000Z' + await writeFile( + manifestPath, + `${JSON.stringify({ + schemaVersion: 1, + runId: run.runId, + issueNumber: run.issueNumber, + headSha, + verdict: 'passed', + checks: [ + { + command: 'pnpm verify', + status: 'passed', + startedAt: timestamp, + finishedAt: timestamp, + artifactUrl: null, + }, + ], + screenshots: [], + limitations: [], + })}\n`, + 'utf8', + ) + return manifestPath +} + +async function writePassingReview({ loopRoot, run, headSha }) { + const resultPath = path.join(loopRoot, 'logs', 'runs', run.runId, 'review-result.json') + await writeFile( + resultPath, + `${JSON.stringify({ + schemaVersion: 1, + runId: run.runId, + reviewerAgent: 'echo_ui_pr_reviewer', + freshContext: true, + headSha, + verdict: 'PASS', + rounds: [{ round: 1, headSha, verdict: 'PASS', findings: [] }], + })}\n`, + 'utf8', + ) + return resultPath +} + test('selectIssue chooses the highest-priority eligible unclaimed issue', () => { const result = selectIssue({ issues: [ @@ -104,6 +189,64 @@ test('startRun creates one correlated run, handoff, and evidence directories', a assert.match(events, /"type":"loop_started"/) }) +test('CI helpers resolve a run and generate exact-head screenshot evidence', async () => { + const { loopRoot } = await createFixture() + const { run } = await startRun({ + loopRoot, + issueNumber: 128, + issueTitle: 'Capture Player UI', + issueUrl: 'https://github.com/codeacme17/echo-ui/issues/128', + entropy: 'c1e2e3', + }) + const screenshotRelativePath = `screen-shots/${run.runId}/after/player.webp` + await writeFile(path.join(loopRoot, screenshotRelativePath), 'fake-image', 'utf8') + await writeFile( + path.join(loopRoot, 'screen-shots', run.runId, 'manifest.json'), + `${JSON.stringify({ + screenshots: [ + { + name: 'Player after', + scenario: 'Keyboard focus', + viewport: '1280x720', + path: screenshotRelativePath, + }, + ], + })}\n`, + 'utf8', + ) + + const resolveResult = await execFileAsync(process.execPath, [ + path.join(repositoryLoopRoot, 'scripts', 'resolve-run.mjs'), + '--loop-root', + loopRoot, + '--branch', + run.branch, + ]) + assert.equal(JSON.parse(resolveResult.stdout).runId, run.runId) + + const output = path.join(loopRoot, 'evidence', run.runId, 'manifest.json') + await execFileAsync(process.execPath, [ + path.join(repositoryLoopRoot, 'scripts', 'generate-evidence.mjs'), + '--loop-root', + loopRoot, + '--run-id', + run.runId, + '--head-sha', + 'abcdef1234567', + '--status', + 'passed', + '--started-at', + '2026-07-22T16:00:00Z', + '--finished-at', + '2026-07-22T16:10:00Z', + '--output', + output, + ]) + const evidence = JSON.parse(await readFile(output, 'utf8')) + assert.equal(evidence.headSha, 'abcdef1234567') + assert.equal(evidence.screenshots[0].path, screenshotRelativePath) +}) + test('owner-ready transition requires verification and review but remains resumable', async () => { const { loopRoot } = await createFixture() const { run } = await startRun({ @@ -126,19 +269,49 @@ test('owner-ready transition requires verification and review but remains resuma /passed verification_completed/, ) - await appendEvent({ + const staleManifest = await writePassingEvidence({ + loopRoot, + run, + headSha: '0000000123456', + }) + await recordEvidence({ + loopRoot, + runId: run.runId, + manifestPath: staleManifest, + publicationUrl: 'https://github.com/codeacme17/echo-ui/actions/runs/100/artifacts/200', + }) + await assert.rejects( + transitionRun({ + loopRoot, + runId: run.runId, + status: 'awaiting_owner_review', + prUrl: 'https://github.com/codeacme17/echo-ui/pull/200', + headSha: 'abcdef1234567', + }), + /for headSha/, + ) + + const manifestPath = await writePassingEvidence({ + loopRoot, + run, + headSha: 'abcdef1234567', + }) + await recordEvidence({ loopRoot, runId: run.runId, - type: 'verification_completed', - status: 'passed', - payload: { verdict: 'passed' }, + manifestPath, + publicationUrl: 'https://github.com/codeacme17/echo-ui/actions/runs/101/artifacts/201', }) - await appendEvent({ + const reviewPath = await writePassingReview({ + loopRoot, + run, + headSha: 'abcdef1234567', + }) + await recordReview({ loopRoot, runId: run.runId, - type: 'review_completed', - status: 'passed', - payload: { verdict: 'PASS' }, + resultPath: reviewPath, + reviewUrl: 'https://github.com/codeacme17/echo-ui/pull/200#pullrequestreview-300', }) const paused = await transitionRun({ @@ -152,26 +325,66 @@ test('owner-ready transition requires verification and review but remains resuma assert.equal(paused.status, 'awaiting_owner_review') assert.equal(paused.finishedAt, null) - await appendEvent({ - loopRoot, - runId: run.runId, - type: 'pr_merged', - status: 'observed', - payload: { actor: 'codeacme17', mergeSha: '1234567890abcdef' }, - }) - const finalized = await finalizeRun({ + await assert.rejects( + appendEvent({ + loopRoot, + runId: run.runId, + type: 'pr_merged', + status: 'observed', + payload: { actor: 'codeacme17', mergeSha: '1234567890abcdef' }, + }), + /reserved/, + ) + + await assert.rejects( + observeOwnerMerge({ + loopRoot, + runId: run.runId, + githubApi: async (endpoint) => + endpoint.includes('/reviews') + ? [ + { + user: { login: 'codeacme17' }, + state: 'APPROVED', + commit_id: 'abcdef1234567', + }, + ] + : { + merged: true, + merged_by: { login: 'someone-else' }, + head: { sha: 'abcdef1234567' }, + merge_commit_sha: '1234567890abcdef', + }, + }), + /not merged by the configured owner/, + ) + + const finalized = await observeOwnerMerge({ loopRoot, runId: run.runId, - status: 'completed', - mergeSha: '1234567890abcdef', now: new Date('2026-07-23T09:00:00Z'), + githubApi: async (endpoint) => + endpoint.includes('/reviews') + ? [ + { + user: { login: 'codeacme17' }, + state: 'APPROVED', + commit_id: 'abcdef1234567', + }, + ] + : { + merged: true, + merged_by: { login: 'codeacme17' }, + head: { sha: 'abcdef1234567' }, + merge_commit_sha: '1234567890abcdef', + }, }) assert.equal(finalized.status, 'completed') assert.equal(finalized.mergeSha, '1234567890abcdef') assert.equal(finalized.finishedAt, '2026-07-23T09:00:00.000Z') }) -test('completed finalization requires an observed owner merge', async () => { +test('completed finalization cannot bypass the owner-ready gate', async () => { const { loopRoot } = await createFixture() const { run } = await startRun({ loopRoot, @@ -187,23 +400,8 @@ test('completed finalization requires an observed owner merge', async () => { status: 'completed', mergeSha: '1234567890abcdef', }), - /observed pr_merged/, + /owner-ready PR/, ) - - await appendEvent({ - loopRoot, - runId: run.runId, - type: 'pr_merged', - status: 'observed', - payload: { actor: 'codeacme17', mergeSha: '1234567890abcdef' }, - }) - const finalized = await finalizeRun({ - loopRoot, - runId: run.runId, - status: 'completed', - mergeSha: '1234567890abcdef', - }) - assert.equal(finalized.mergeSha, '1234567890abcdef') }) test('notification dry-run stages an auditable owner message', async () => { @@ -235,6 +433,64 @@ test('notification dry-run stages an auditable owner message', async () => { await readFile(path.join(channelRoot, 'outbox', `${notification.notificationId}.json`), 'utf8'), ) assert.equal(staged.runId, run.runId) + const paused = JSON.parse( + await readFile(path.join(loopRoot, 'logs', 'runs', run.runId, 'run.json'), 'utf8'), + ) + assert.equal(paused.status, 'waiting_for_owner') +}) + +test('failed blocking delivery still pauses for the owner', async () => { + const { loopRoot } = await createFixture() + const { run } = await startRun({ + loopRoot, + issueNumber: 132, + issueTitle: 'Needs clarification', + issueUrl: 'https://github.com/codeacme17/echo-ui/issues/132', + entropy: 'badbee', + }) + await assert.rejects( + createNotification({ + loopRoot, + runId: run.runId, + type: 'clarification_required', + summary: 'Acceptance criterion is ambiguous', + requestedAction: 'Clarify expected keyboard behavior', + targetUrl: run.issueUrl, + blocking: true, + environment: {}, + githubComment: async () => { + throw new Error('offline') + }, + }), + /blocking notification was not delivered/, + ) + const paused = JSON.parse( + await readFile(path.join(loopRoot, 'logs', 'runs', run.runId, 'run.json'), 'utf8'), + ) + assert.equal(paused.status, 'waiting_for_owner') +}) + +test('three matching failures make a fresh evolve session due', async () => { + const { loopRoot } = await createFixture() + for (let issueNumber = 201; issueNumber <= 203; issueNumber += 1) { + const { run } = await startRun({ + loopRoot, + issueNumber, + issueTitle: `Failure ${issueNumber}`, + issueUrl: `https://github.com/codeacme17/echo-ui/issues/${issueNumber}`, + entropy: `fail${issueNumber}`, + }) + await finalizeRun({ + loopRoot, + runId: run.runId, + status: 'blocked', + failureFingerprint: 'browser-environment-unavailable', + }) + } + const metrics = await getEvolveStatus({ loopRoot }) + assert.equal(metrics.evolveDue, true) + assert.equal(metrics.failedRuns, 3) + assert.match(metrics.pendingRequestId, /^EVL-/) }) test('repository loop package satisfies its structural invariants', async () => { diff --git a/loops/issue-dev-loop/triggers/TRIGGER.md b/loops/issue-dev-loop/triggers/TRIGGER.md index d8f4be9b..90797d4a 100644 --- a/loops/issue-dev-loop/triggers/TRIGGER.md +++ b/loops/issue-dev-loop/triggers/TRIGGER.md @@ -6,6 +6,8 @@ Run the deterministic detector before waking Codex: node loops/issue-dev-loop/triggers/detect-work.mjs ``` +Before the issue detector, run `node loops/issue-dev-loop/scripts/loopctl.mjs evolve-status`. A pending evolve request is real work and must wake the dedicated fresh-context evolver instead of an issue executor. + The command prints one JSON object. Start a Codex turn only when `hasWork` is `true`; pass the returned issue number and URL to `$issue-dev-loop`. For a scheduled Codex automation, use [`codex-automation-prompt.md`](./codex-automation-prompt.md). The machine and repository credentials must remain available for local scheduled runs. Use a dedicated worktree for every issue. diff --git a/loops/issue-dev-loop/triggers/codex-automation-prompt.md b/loops/issue-dev-loop/triggers/codex-automation-prompt.md index 65c3c4c0..fa7f8fd6 100644 --- a/loops/issue-dev-loop/triggers/codex-automation-prompt.md +++ b/loops/issue-dev-loop/triggers/codex-automation-prompt.md @@ -1,3 +1,3 @@ Use `$issue-dev-loop` in this repository. -First run `node loops/issue-dev-loop/triggers/detect-work.mjs`. If it returns `hasWork: false`, report a quiet no-op and stop. If it returns an issue, execute one bounded issue-dev-loop run for that exact issue. Preserve owner-only review and merge, use `$implement` for product code, use a fresh read-only reviewer after the draft PR, and notify the owner for every blocking event or ready PR. +First run `node loops/issue-dev-loop/scripts/loopctl.mjs evolve-status`. A pending evolve request starts a fresh `echo_ui_loop_evolver` session and an owner-reviewed evolve PR before routine product work. Otherwise run `node loops/issue-dev-loop/triggers/detect-work.mjs`. If it returns `hasWork: false`, report a quiet no-op and stop. If it returns an issue, execute one bounded issue-dev-loop run for that exact issue. Preserve owner-only review and merge, use `$implement` for product code, use a fresh read-only reviewer after the draft PR, and notify the owner for every blocking event or ready PR. From fed08046ff2166d21d8d912d0f3d066a6b8a75be Mon Sep 17 00:00:00 2001 From: leyoonafr Date: Wed, 22 Jul 2026 22:30:05 +0800 Subject: [PATCH 03/41] fix: verify published loop review evidence --- .gitignore | 2 + loops/_shared/owner-channel/CHANNEL.md | 6 +- loops/_shared/owner-channel/channel.json | 1 + loops/issue-dev-loop/LOOP.md | 2 +- loops/issue-dev-loop/logs/index.jsonl | 1 + loops/issue-dev-loop/logs/runs/.gitignore | 4 + .../references/evidence-policy.md | 2 +- loops/issue-dev-loop/review/REVIEW.md | 4 +- .../issue-dev-loop/review/response-policy.md | 2 +- .../scripts/generate-evidence.mjs | 34 ++--- loops/issue-dev-loop/scripts/lib/common.mjs | 15 ++ loops/issue-dev-loop/scripts/lib/evidence.mjs | 80 ++++++++++- loops/issue-dev-loop/scripts/lib/evolve.mjs | 36 ++--- loops/issue-dev-loop/scripts/lib/github.mjs | 49 ++----- .../scripts/lib/notifications.mjs | 12 +- .../issue-dev-loop/scripts/lib/owner-gate.mjs | 42 ++++++ .../issue-dev-loop/scripts/lib/run-store.mjs | 14 ++ .../issue-dev-loop/scripts/lib/validation.mjs | 20 +++ loops/issue-dev-loop/state.md | 2 +- loops/issue-dev-loop/tests/runtime.test.mjs | 136 +++++++++++++++++- package.json | 2 +- 21 files changed, 356 insertions(+), 110 deletions(-) create mode 100644 loops/issue-dev-loop/logs/index.jsonl create mode 100644 loops/issue-dev-loop/logs/runs/.gitignore create mode 100644 loops/issue-dev-loop/scripts/lib/owner-gate.mjs diff --git a/.gitignore b/.gitignore index d724f934..628a6607 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ logs +!loops/issue-dev-loop/logs/ +!loops/issue-dev-loop/logs/** *.log npm-debug.log* yarn-debug.log* diff --git a/loops/_shared/owner-channel/CHANNEL.md b/loops/_shared/owner-channel/CHANNEL.md index deb6c8ed..561ee818 100644 --- a/loops/_shared/owner-channel/CHANNEL.md +++ b/loops/_shared/owner-channel/CHANNEL.md @@ -6,11 +6,13 @@ GitHub issue and PR comments are the canonical, auditable channel. The runtime m `approval_required`, `clarification_required`, `blocked`, `review_dispute`, `pr_ready_for_review`, `pr_updated_for_review`, and `loop_failed` require immediate delivery and must be sent with `blocking: true`. The runtime enters `waiting_for_owner` even when delivery fails, and it must not choose a default answer after a timeout. A ready PR advances to `awaiting_owner_review` only after SHA-bound evidence validation. +`--dry-run` stages a simulated payload only. It never records `owner_notified`, never pauses the run, and never satisfies a blocking delivery gate. + ## Runtime setup -1. Authenticate `gh` for the repository identity used by the loop. +1. Authenticate `gh` for the repository identity used by the loop and set its exact GitHub login as `automationGitHubLogin` in `channel.json`. 2. Enable GitHub notifications for mentions and review requests for `codeacme17`. 3. Optionally set `ECHO_UI_LOOP_OWNER_WEBHOOK_URL` to an endpoint that accepts the notification JSON with `Content-Type: application/json`. 4. Never store the webhook URL or credentials in this repository. -Replies and approvals are valid only when the GitHub author matches the configured owner. A webhook delivery is an alert, not an approval channel. +Automated review publications and replies are valid only when their author matches `automationGitHubLogin`; owner decisions are valid only when the author matches `ownerGitHubLogin`. A webhook delivery is an alert, not an approval channel. diff --git a/loops/_shared/owner-channel/channel.json b/loops/_shared/owner-channel/channel.json index 9314b4ca..e165de8f 100644 --- a/loops/_shared/owner-channel/channel.json +++ b/loops/_shared/owner-channel/channel.json @@ -1,6 +1,7 @@ { "schemaVersion": 1, "ownerGitHubLogin": "codeacme17", + "automationGitHubLogin": null, "canonicalChannel": "github", "webhookEnvironmentVariable": "ECHO_UI_LOOP_OWNER_WEBHOOK_URL", "immediateTypes": [ diff --git a/loops/issue-dev-loop/LOOP.md b/loops/issue-dev-loop/LOOP.md index 96f85de1..bca52d97 100644 --- a/loops/issue-dev-loop/LOOP.md +++ b/loops/issue-dev-loop/LOOP.md @@ -90,7 +90,7 @@ Spawn `echo_ui_pr_reviewer` with fresh context and read-only filesystem access. ### 8. Owner gate -After exact-head CI and independent review pass, download the CI manifest and record its artifact URL with `record-evidence`; record the fresh review cycle and its GitHub URL separately with `record-review`. Mark the PR ready, request review from `codeacme17`, send a blocking notification (which pauses the run), and transition to `awaiting_owner_review` using that same head SHA. Do not infer approval from timeouts or silence. +After exact-head CI and independent review pass, download the CI manifest and record its artifact URL with `record-evidence`; record the fresh review cycle and its GitHub URL separately with `record-review`. Mark the PR ready, request review from `codeacme17`, send a blocking notification (which pauses the run), and transition to `awaiting_owner_review`. The transition queries GitHub and requires an open, non-draft PR targeting `dev` whose live branch and head SHA match the run. Do not infer approval from timeouts or silence. ### 9. Owner feedback diff --git a/loops/issue-dev-loop/logs/index.jsonl b/loops/issue-dev-loop/logs/index.jsonl new file mode 100644 index 00000000..94b86c4c --- /dev/null +++ b/loops/issue-dev-loop/logs/index.jsonl @@ -0,0 +1 @@ +{"schemaVersion":1,"event":"loop_initialized"} diff --git a/loops/issue-dev-loop/logs/runs/.gitignore b/loops/issue-dev-loop/logs/runs/.gitignore new file mode 100644 index 00000000..cdf622f7 --- /dev/null +++ b/loops/issue-dev-loop/logs/runs/.gitignore @@ -0,0 +1,4 @@ +**/raw/ +**/command-output/ +**/*.log +**/review-result.json diff --git a/loops/issue-dev-loop/references/evidence-policy.md b/loops/issue-dev-loop/references/evidence-policy.md index 6f9afe92..aab5d806 100644 --- a/loops/issue-dev-loop/references/evidence-policy.md +++ b/loops/issue-dev-loop/references/evidence-policy.md @@ -26,4 +26,4 @@ Use descriptive names such as `02-after-player-mobile-375.webp` under `screen-sh Commit issue-relevant PNG/WebP screenshots and their metadata under `screen-shots/` before owner review. The `issue-dev-loop-evidence.yml` workflow checks out the exact PR head, runs `pnpm verify`, generates `evidence//manifest.json` in the runner, and uploads the manifest, sanitized run log, and screenshots as a GitHub Actions artifact. Download the artifact, then pass its URL to `record-evidence`; never accept an artifact from a different head SHA. -The independent reviewer posts findings and responses to GitHub after the draft PR exists. Save its structured cycle result locally and use `record-review --review-url ` so the review gate is independently bound to the same head SHA. Keep raw local command output, videos, traces, and bulky reports in ignored runtime directories. Never upload secrets, cookies, auth state, user data, or unredacted environment dumps. +`record-evidence` queries the GitHub artifact API and rejects expired artifacts, mismatched workflow runs, unexpected artifact names, and any `workflow_run.head_sha` that differs from the manifest. The independent reviewer posts findings and responses to GitHub after the draft PR exists. Save its structured cycle result locally and use `record-review --review-url ` so the review gate is independently bound to the same head SHA. Keep raw local command output, videos, traces, and bulky reports in ignored runtime directories. Never upload secrets, cookies, auth state, user data, or unredacted environment dumps. diff --git a/loops/issue-dev-loop/review/REVIEW.md b/loops/issue-dev-loop/review/REVIEW.md index 90b908a7..0ed88092 100644 --- a/loops/issue-dev-loop/review/REVIEW.md +++ b/loops/issue-dev-loop/review/REVIEW.md @@ -23,9 +23,9 @@ Do not emit formatter noise, personal style preferences, speculative concerns, u ## Publication -The orchestrator posts findings verbatim as one GitHub review and inline comments, adding `` for deduplication. It must not downgrade severity or omit findings. +The orchestrator posts findings verbatim as one non-approving GitHub review and inline comments, adding `` for deduplication. The review body must include ``. It must not downgrade severity or omit findings. -After all replies are posted, create one cycle result matching `result.schema.json`. It contains every round, every finding, its final classification, response URL, and evidence. Run `record-review` with the GitHub review URL; generic events cannot forge this reserved gate. +After all replies are posted, create one cycle result matching `result.schema.json`. It contains every round, every finding, its final classification, response URL, and evidence. Each reply includes ``. Run `record-review` with the GitHub review URL; it queries the review and replies, verifies their automation identity and markers, and binds them to the current head. Generic events cannot forge this reserved gate. ## Completion diff --git a/loops/issue-dev-loop/review/response-policy.md b/loops/issue-dev-loop/review/response-policy.md index e79db024..65e8eca7 100644 --- a/loops/issue-dev-loop/review/response-policy.md +++ b/loops/issue-dev-loop/review/response-policy.md @@ -14,4 +14,4 @@ Rejected findings require a precise counterclaim and reproducible evidence. Do n Do not delete review comments. Log classification, response time, response URL, commit, and evidence reference under the run ID. -The final cycle file must match `result.schema.json`. `record-review` accepts only a fresh `echo_ui_pr_reviewer` PASS whose last round matches the current head, whose earlier findings all have GitHub response URLs and evidence, and whose accepted findings name a fix commit. +The final cycle file must match `result.schema.json`. `record-review` accepts only a fresh `echo_ui_pr_reviewer` PASS whose last round matches the current head, whose published GitHub review carries the result digest, whose earlier findings all have verified GitHub response URLs, classification markers and evidence, and whose accepted findings name a fix commit. diff --git a/loops/issue-dev-loop/scripts/generate-evidence.mjs b/loops/issue-dev-loop/scripts/generate-evidence.mjs index 223d908d..35b9db5e 100644 --- a/loops/issue-dev-loop/scripts/generate-evidence.mjs +++ b/loops/issue-dev-loop/scripts/generate-evidence.mjs @@ -1,30 +1,16 @@ #!/usr/bin/env node -import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' +import { mkdir, readFile, writeFile } from 'node:fs/promises' import path from 'node:path' import { DEFAULT_LOOP_ROOT, parseArguments } from './runtime.mjs' - -async function exists(target) { - try { - await stat(target) - return true - } catch (error) { - if (error?.code === 'ENOENT') return false - throw error - } -} - -function required(value, name) { - if (typeof value !== 'string' || value.trim() === '') throw new Error(`${name} is required`) - return value.trim() -} +import { assertNonEmpty, pathExists } from './lib/common.mjs' const args = parseArguments(process.argv.slice(2)) const loopRoot = args['loop-root'] ? path.resolve(args['loop-root']) : DEFAULT_LOOP_ROOT -const runId = required(args['run-id'], '--run-id') -const headSha = required(args['head-sha'], '--head-sha') -const status = required(args.status, '--status') +const runId = assertNonEmpty(args['run-id'], '--run-id') +const headSha = assertNonEmpty(args['head-sha'], '--head-sha') +const status = assertNonEmpty(args.status, '--status') if (!['passed', 'failed', 'blocked'].includes(status)) throw new Error('unsupported --status') const run = JSON.parse( @@ -33,20 +19,20 @@ const run = JSON.parse( const screenshotRoot = path.join(loopRoot, 'screen-shots', runId) const screenshotMetadataPath = path.join(screenshotRoot, 'manifest.json') let screenshots = [] -if (await exists(screenshotMetadataPath)) { +if (await pathExists(screenshotMetadataPath)) { const metadata = JSON.parse(await readFile(screenshotMetadataPath, 'utf8')) if (!Array.isArray(metadata.screenshots)) throw new Error('screenshot manifest needs screenshots[]') screenshots = metadata.screenshots for (const screenshot of screenshots) { const target = path.resolve(loopRoot, screenshot.path) - if (!target.startsWith(`${screenshotRoot}${path.sep}`) || !(await exists(target))) { + if (!target.startsWith(`${screenshotRoot}${path.sep}`) || !(await pathExists(target))) { throw new Error(`missing or unsafe screenshot path: ${screenshot.path}`) } } } -const output = path.resolve(required(args.output, '--output')) +const output = path.resolve(assertNonEmpty(args.output, '--output')) const manifest = { schemaVersion: 1, runId, @@ -57,8 +43,8 @@ const manifest = { { command: 'pnpm verify', status, - startedAt: required(args['started-at'], '--started-at'), - finishedAt: required(args['finished-at'], '--finished-at'), + startedAt: assertNonEmpty(args['started-at'], '--started-at'), + finishedAt: assertNonEmpty(args['finished-at'], '--finished-at'), artifactUrl: null, }, ], diff --git a/loops/issue-dev-loop/scripts/lib/common.mjs b/loops/issue-dev-loop/scripts/lib/common.mjs index 7ac26f86..e9e11eb6 100644 --- a/loops/issue-dev-loop/scripts/lib/common.mjs +++ b/loops/issue-dev-loop/scripts/lib/common.mjs @@ -153,6 +153,21 @@ export function parseReviewUrl(value) { : null } +export function parsePullCommentUrl(value) { + const parsed = new URL(value) + const match = parsed.pathname.match(/^\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/) + const reviewComment = parsed.hash.match(/^#discussion_r(\d+)$/) + const issueComment = parsed.hash.match(/^#issuecomment-(\d+)$/) + if (parsed.hostname !== 'github.com' || !match || (!reviewComment && !issueComment)) return null + return { + owner: match[1], + repo: match[2], + number: Number(match[3]), + kind: reviewComment ? 'review_comment' : 'issue_comment', + commentId: (reviewComment ?? issueComment)[1], + } +} + export async function defaultGitHubApi(endpoint) { const result = await execFileAsync('gh', ['api', endpoint], { maxBuffer: 1024 * 1024 }) return JSON.parse(result.stdout) diff --git a/loops/issue-dev-loop/scripts/lib/evidence.mjs b/loops/issue-dev-loop/scripts/lib/evidence.mjs index 585b14fd..c4702350 100644 --- a/loops/issue-dev-loop/scripts/lib/evidence.mjs +++ b/loops/issue-dev-loop/scripts/lib/evidence.mjs @@ -8,9 +8,13 @@ import { assertHttpUrl, assertNonEmpty, assertRunId, + defaultGitHubApi, parseArtifactUrl, parseGitHubTarget, + parsePullCommentUrl, parseReviewUrl, + readJson, + sameGitHubLogin, sameRepository, } from './common.mjs' import { appendValidatedEvent, readRun } from './run-store.mjs' @@ -39,6 +43,7 @@ function validateReviewEvidence(review, headSha) { let findingCount = 0 const findingIds = new Set() + const resolvedFindings = [] for (const [roundIndex, round] of rounds.entries()) { assertNonEmpty(round.headSha, `review.rounds[${roundIndex}].headSha`) if (round.round !== roundIndex + 1 || !['PASS', 'CHANGES_REQUESTED'].includes(round.verdict)) { @@ -79,9 +84,10 @@ function validateReviewEvidence(review, headSha) { if (resolution.classification === 'accepted') { assertNonEmpty(resolution.fixCommit, `${findingId}.resolution.fixCommit`) } + resolvedFindings.push(finding) } } - return { findingCount, rounds: rounds.length } + return { findingCount, rounds: rounds.length, findings: resolvedFindings } } export async function recordEvidence({ @@ -90,6 +96,7 @@ export async function recordEvidence({ manifestPath, publicationUrl, now = new Date(), + githubApi = defaultGitHubApi, } = {}) { const normalizedRunId = assertRunId(runId) const run = await readRun(loopRoot, normalizedRunId) @@ -117,6 +124,18 @@ export async function recordEvidence({ if (!artifactTarget || !sameRepository(parseGitHubTarget(run.issueUrl), artifactTarget)) { throw new Error('publicationUrl must be a GitHub Actions artifact for the issue repository') } + const artifact = await githubApi( + `repos/${artifactTarget.owner}/${artifactTarget.repo}/actions/artifacts/${artifactTarget.artifactId}`, + ) + if ( + String(artifact.id) !== artifactTarget.artifactId || + artifact.expired === true || + String(artifact.workflow_run?.id) !== artifactTarget.runId || + artifact.workflow_run?.head_sha !== headSha || + artifact.name !== `issue-dev-loop-${normalizedRunId}-${headSha}` + ) { + throw new Error('evidence artifact metadata does not match the run and exact headSha') + } const checks = assertArray(manifest.checks, 'manifest.checks') if (checks.length === 0 || checks.some((check) => check.status !== 'passed')) { @@ -171,6 +190,7 @@ export async function recordReview({ resultPath, reviewUrl, now = new Date(), + githubApi = defaultGitHubApi, } = {}) { const normalizedRunId = assertRunId(runId) const run = await readRun(loopRoot, normalizedRunId) @@ -188,6 +208,56 @@ export async function recordReview({ throw new Error('reviewUrl must be a GitHub pull request review for the issue repository') } const resultDigest = createHash('sha256').update(source).digest('hex') + const channel = await readJson( + path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'), + ) + const automationLogin = assertNonEmpty( + channel.automationGitHubLogin, + 'channel.automationGitHubLogin', + ) + const reviewEndpoint = `repos/${reviewTarget.owner}/${reviewTarget.repo}/pulls/${reviewTarget.number}/reviews/${reviewTarget.reviewId}` + const [publishedReview, reviewComments] = await Promise.all([ + githubApi(reviewEndpoint), + githubApi(`${reviewEndpoint}/comments?per_page=100`), + ]) + const digestMarker = `` + const publishedBodies = [ + publishedReview.body ?? '', + ...reviewComments.map((comment) => comment.body ?? ''), + ] + if ( + publishedReview.commit_id !== headSha || + publishedReview.state !== 'COMMENTED' || + !sameGitHubLogin(publishedReview.user?.login, automationLogin) || + !publishedBodies.some((body) => body.includes(digestMarker)) + ) { + throw new Error('published GitHub review does not attest this result and exact headSha') + } + for (const finding of reviewSummary.findings) { + if (!publishedBodies.some((body) => body.includes(finding.findingId))) { + throw new Error(`${finding.findingId} is missing from the published GitHub review`) + } + const responseTarget = parsePullCommentUrl(finding.resolution.responseUrl) + if ( + !responseTarget || + !sameRepository(reviewTarget, responseTarget) || + responseTarget.number !== reviewTarget.number + ) { + throw new Error(`${finding.findingId} response is not on the reviewed PR`) + } + const responseEndpoint = + responseTarget.kind === 'review_comment' + ? `repos/${responseTarget.owner}/${responseTarget.repo}/pulls/comments/${responseTarget.commentId}` + : `repos/${responseTarget.owner}/${responseTarget.repo}/issues/comments/${responseTarget.commentId}` + const response = await githubApi(responseEndpoint) + const responseMarker = `` + if ( + !sameGitHubLogin(response.user?.login, automationLogin) || + !response.body?.includes(responseMarker) + ) { + throw new Error(`${finding.findingId} response is not published with its classification`) + } + } await appendValidatedEvent({ loopRoot, runId: normalizedRunId, @@ -204,5 +274,11 @@ export async function recordReview({ }, now, }) - return { headSha, reviewUrl: publishedReviewUrl, resultDigest, ...reviewSummary } + return { + headSha, + reviewUrl: publishedReviewUrl, + resultDigest, + findingCount: reviewSummary.findingCount, + rounds: reviewSummary.rounds, + } } diff --git a/loops/issue-dev-loop/scripts/lib/evolve.mjs b/loops/issue-dev-loop/scripts/lib/evolve.mjs index 2f10541d..cd3a6a9b 100644 --- a/loops/issue-dev-loop/scripts/lib/evolve.mjs +++ b/loops/issue-dev-loop/scripts/lib/evolve.mjs @@ -6,12 +6,11 @@ import { assertHttpUrl, assertNonEmpty, defaultGitHubApi, - parseGitHubTarget, readJson, - sameGitHubLogin, timestampToken, writeJson, } from './common.mjs' +import { observeOwnerApprovedMerge } from './owner-gate.mjs' export async function updateEvolveMetrics({ loopRoot, status, failureFingerprint, now }) { const metricsPath = path.join(loopRoot, 'evolve', 'metrics.json') @@ -77,30 +76,11 @@ export async function completeEvolve({ const requestPath = path.join(loopRoot, 'evolve', 'requests', `${normalizedRequestId}.json`) const request = await readJson(requestPath) const publishedPrUrl = assertHttpUrl(prUrl, 'prUrl') - const target = parseGitHubTarget(publishedPrUrl) - if (!target || target.kind !== 'pull') throw new Error('prUrl must be a GitHub pull request') - const channel = await readJson( - path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'), - ) - const [pullRequest, reviews] = await Promise.all([ - githubApi(`repos/${target.owner}/${target.repo}/pulls/${target.number}`), - githubApi(`repos/${target.owner}/${target.repo}/pulls/${target.number}/reviews?per_page=100`), - ]) - const evolveHeadSha = pullRequest.head?.sha - const approved = reviews.some( - (review) => - sameGitHubLogin(review.user?.login, channel.ownerGitHubLogin) && - review.state === 'APPROVED' && - review.commit_id === evolveHeadSha, - ) - if ( - pullRequest.merged !== true || - !sameGitHubLogin(pullRequest.merged_by?.login, channel.ownerGitHubLogin) || - !pullRequest.merge_commit_sha || - !approved - ) { - throw new Error('evolve PR must be approved and merged by the configured owner') - } + const merge = await observeOwnerApprovedMerge({ + loopRoot, + prUrl: publishedPrUrl, + githubApi, + }) const completed = { ...request, @@ -108,8 +88,8 @@ export async function completeEvolve({ completedAt: now.toISOString(), summary: assertNonEmpty(summary, 'summary'), prUrl: publishedPrUrl, - headSha: evolveHeadSha, - mergeSha: pullRequest.merge_commit_sha, + headSha: merge.headSha, + mergeSha: merge.mergeSha, } await writeJson(requestPath, completed) metrics.evolveDue = false diff --git a/loops/issue-dev-loop/scripts/lib/github.mjs b/loops/issue-dev-loop/scripts/lib/github.mjs index 7c0bf170..ed49b16a 100644 --- a/loops/issue-dev-loop/scripts/lib/github.mjs +++ b/loops/issue-dev-loop/scripts/lib/github.mjs @@ -1,15 +1,8 @@ import { readFile } from 'node:fs/promises' import path from 'node:path' -import { - DEFAULT_LOOP_ROOT, - assertRunId, - defaultGitHubApi, - execFileAsync, - parseGitHubTarget, - readJson, - sameGitHubLogin, -} from './common.mjs' +import { DEFAULT_LOOP_ROOT, assertRunId, defaultGitHubApi, execFileAsync } from './common.mjs' +import { observeOwnerApprovedMerge } from './owner-gate.mjs' import { appendValidatedEvent, finalizeRun, readRun } from './run-store.mjs' const PRIORITY = new Map([ @@ -121,37 +114,19 @@ export async function observeOwnerMerge({ if (run.status !== 'awaiting_owner_review' || !run.prUrl || !run.headSha) { throw new Error('owner merge observation requires an awaiting_owner_review run') } - const target = parseGitHubTarget(run.prUrl) - if (!target || target.kind !== 'pull') throw new Error('run.prUrl must be a GitHub pull request') - const channel = await readJson( - path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'), - ) - const [pullRequest, reviews] = await Promise.all([ - githubApi(`repos/${target.owner}/${target.repo}/pulls/${target.number}`), - githubApi(`repos/${target.owner}/${target.repo}/pulls/${target.number}/reviews?per_page=100`), - ]) - if ( - pullRequest.merged !== true || - !sameGitHubLogin(pullRequest.merged_by?.login, channel.ownerGitHubLogin) || - pullRequest.head?.sha !== run.headSha || - !pullRequest.merge_commit_sha - ) { - throw new Error('pull request is not merged by the configured owner at the reviewed headSha') - } - const ownerApproval = reviews.some( - (review) => - sameGitHubLogin(review.user?.login, channel.ownerGitHubLogin) && - review.state === 'APPROVED' && - review.commit_id === run.headSha, - ) - if (!ownerApproval) throw new Error('configured owner has not approved the reviewed headSha') + const merge = await observeOwnerApprovedMerge({ + loopRoot, + prUrl: run.prUrl, + expectedHeadSha: run.headSha, + githubApi, + }) await appendValidatedEvent({ loopRoot, runId: normalizedRunId, type: 'owner_review_approved', status: 'observed', - payload: { actor: channel.ownerGitHubLogin, headSha: run.headSha, prUrl: run.prUrl }, + payload: { actor: merge.owner, headSha: run.headSha, prUrl: run.prUrl }, now, }) await appendValidatedEvent({ @@ -160,9 +135,9 @@ export async function observeOwnerMerge({ type: 'pr_merged', status: 'observed', payload: { - actor: channel.ownerGitHubLogin, + actor: merge.owner, headSha: run.headSha, - mergeSha: pullRequest.merge_commit_sha, + mergeSha: merge.mergeSha, prUrl: run.prUrl, }, now, @@ -171,7 +146,7 @@ export async function observeOwnerMerge({ loopRoot, runId: normalizedRunId, status: 'completed', - mergeSha: pullRequest.merge_commit_sha, + mergeSha: merge.mergeSha, now, }) } diff --git a/loops/issue-dev-loop/scripts/lib/notifications.mjs b/loops/issue-dev-loop/scripts/lib/notifications.mjs index fd9bf001..112e7882 100644 --- a/loops/issue-dev-loop/scripts/lib/notifications.mjs +++ b/loops/issue-dev-loop/scripts/lib/notifications.mjs @@ -132,24 +132,22 @@ export async function createNotification({ } await writeJson(outboxFile, notification) - const delivered = Object.values(notification.delivery).some((value) => - ['delivered', 'dry_run'].includes(value), - ) + const delivered = Object.values(notification.delivery).includes('delivered') await appendEvent({ loopRoot, runId: normalizedRunId, - type: delivered ? 'owner_notified' : 'notification_failed', - status: delivered ? 'delivered' : 'failed', + type: dryRun ? 'notification_dry_run' : delivered ? 'owner_notified' : 'notification_failed', + status: dryRun ? 'simulated' : delivered ? 'delivered' : 'failed', payload: { notificationId, notificationType, delivery: notification.delivery }, now, }) - if (blocking) { + if (blocking && !dryRun) { if (run.finishedAt === null && !PAUSED_STATUSES.has(run.status)) { await transitionRun({ loopRoot, runId: normalizedRunId, status: 'waiting_for_owner', now }) } } - if (blocking && !delivered) { + if (blocking && !delivered && !dryRun) { throw new Error(`blocking notification was not delivered: ${notificationId}`) } return notification diff --git a/loops/issue-dev-loop/scripts/lib/owner-gate.mjs b/loops/issue-dev-loop/scripts/lib/owner-gate.mjs new file mode 100644 index 00000000..a5a1c4c2 --- /dev/null +++ b/loops/issue-dev-loop/scripts/lib/owner-gate.mjs @@ -0,0 +1,42 @@ +import path from 'node:path' + +import { defaultGitHubApi, parseGitHubTarget, readJson, sameGitHubLogin } from './common.mjs' + +export async function observeOwnerApprovedMerge({ + loopRoot, + prUrl, + expectedHeadSha = null, + githubApi = defaultGitHubApi, +}) { + const target = parseGitHubTarget(prUrl) + if (!target || target.kind !== 'pull') throw new Error('prUrl must be a GitHub pull request') + const channel = await readJson( + path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'), + ) + const [pullRequest, reviews] = await Promise.all([ + githubApi(`repos/${target.owner}/${target.repo}/pulls/${target.number}`), + githubApi(`repos/${target.owner}/${target.repo}/pulls/${target.number}/reviews?per_page=100`), + ]) + const headSha = pullRequest.head?.sha + const ownerApproval = reviews.some( + (review) => + sameGitHubLogin(review.user?.login, channel.ownerGitHubLogin) && + review.state === 'APPROVED' && + review.commit_id === headSha, + ) + if ( + pullRequest.merged !== true || + !sameGitHubLogin(pullRequest.merged_by?.login, channel.ownerGitHubLogin) || + !pullRequest.merge_commit_sha || + !ownerApproval || + (expectedHeadSha !== null && headSha !== expectedHeadSha) + ) { + throw new Error('PR is not approved and merged by the configured owner at the expected headSha') + } + return { + owner: channel.ownerGitHubLogin, + headSha, + mergeSha: pullRequest.merge_commit_sha, + prUrl, + } +} diff --git a/loops/issue-dev-loop/scripts/lib/run-store.mjs b/loops/issue-dev-loop/scripts/lib/run-store.mjs index 46c27551..4185543b 100644 --- a/loops/issue-dev-loop/scripts/lib/run-store.mjs +++ b/loops/issue-dev-loop/scripts/lib/run-store.mjs @@ -8,6 +8,7 @@ import { assertIssueNumber, assertNonEmpty, assertRunId, + defaultGitHubApi, parseGitHubTarget, pathExists, readJson, @@ -176,6 +177,7 @@ export async function transitionRun({ mergeSha = null, failureFingerprint = null, now = new Date(), + githubApi = defaultGitHubApi, } = {}) { const normalizedRunId = assertRunId(runId) if (!RUN_STATUSES.has(status)) throw new Error(`invalid run status: ${status}`) @@ -204,6 +206,18 @@ export async function transitionRun({ if (!hasPassedEventForHead(events, 'review_completed', headSha)) { throw new Error('awaiting_owner_review requires passed review_completed for headSha') } + const livePullRequest = await githubApi( + `repos/${pullRequestTarget.owner}/${pullRequestTarget.repo}/pulls/${pullRequestTarget.number}`, + ) + if ( + livePullRequest.state !== 'open' || + livePullRequest.draft !== false || + livePullRequest.base?.ref !== 'dev' || + livePullRequest.head?.ref !== run.branch || + livePullRequest.head?.sha !== headSha + ) { + throw new Error('awaiting_owner_review requires a live ready PR to dev at the exact headSha') + } } if (status === 'completed') { diff --git a/loops/issue-dev-loop/scripts/lib/validation.mjs b/loops/issue-dev-loop/scripts/lib/validation.mjs index c3f2d93c..53a4f9ae 100644 --- a/loops/issue-dev-loop/scripts/lib/validation.mjs +++ b/loops/issue-dev-loop/scripts/lib/validation.mjs @@ -39,6 +39,7 @@ export async function validateLoop({ loopRoot = DEFAULT_LOOP_ROOT } = {}) { 'scripts/lib/evolve.mjs', 'scripts/lib/github.mjs', 'scripts/lib/notifications.mjs', + 'scripts/lib/owner-gate.mjs', 'scripts/lib/run-store.mjs', 'scripts/lib/validation.mjs', 'logs/index.jsonl', @@ -56,6 +57,25 @@ export async function validateLoop({ loopRoot = DEFAULT_LOOP_ROOT } = {}) { ...(await collectFiles(sharedChannelRoot)).filter((target) => target.endsWith('.json')), ) for (const target of jsonFiles) await readJson(target) + const channel = await readJson(path.join(sharedChannelRoot, 'channel.json')) + if ( + typeof channel.ownerGitHubLogin !== 'string' || + !Object.hasOwn(channel, 'automationGitHubLogin') || + !Array.isArray(channel.immediateTypes) + ) { + throw new Error('owner channel is missing identity or immediate notification configuration') + } + const evidenceWorkflow = path.resolve( + loopRoot, + '..', + '..', + '.github', + 'workflows', + 'issue-dev-loop-evidence.yml', + ) + if (!(await pathExists(evidenceWorkflow))) { + throw new Error('missing .github/workflows/issue-dev-loop-evidence.yml') + } const contract = await readFile(path.join(loopRoot, 'LOOP.md'), 'utf8') const skill = await readFile(path.join(loopRoot, 'SKILL.md'), 'utf8') diff --git a/loops/issue-dev-loop/state.md b/loops/issue-dev-loop/state.md index fe1f0d3f..68a42354 100644 --- a/loops/issue-dev-loop/state.md +++ b/loops/issue-dev-loop/state.md @@ -22,7 +22,7 @@ None. ## Blockers -- Configure the GitHub authentication used by unattended runs. +- Configure the GitHub authentication used by unattended runs and set its exact login in `automationGitHubLogin`. - Merge this infrastructure into `dev` before enabling its recurring automation; the PR evidence workflow must exist on the base branch. - Repair or explicitly rebaseline the existing `tests/docs-cutover.test.ts` README contract failure before any issue PR can satisfy authoritative `pnpm verify`. - Choose the recurring Codex automation cadence after the infrastructure PR is merged. diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index ebeacb98..e76c91d9 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -1,5 +1,6 @@ import assert from 'node:assert/strict' import { execFile } from 'node:child_process' +import { createHash } from 'node:crypto' import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' @@ -70,6 +71,7 @@ async function createFixture() { `${JSON.stringify({ schemaVersion: 1, ownerGitHubLogin: 'codeacme17', + automationGitHubLogin: 'echo-ui-loop[bot]', webhookEnvironmentVariable: 'TEST_LOOP_WEBHOOK_URL', immediateTypes: [ 'approval_required', @@ -279,6 +281,12 @@ test('owner-ready transition requires verification and review but remains resuma runId: run.runId, manifestPath: staleManifest, publicationUrl: 'https://github.com/codeacme17/echo-ui/actions/runs/100/artifacts/200', + githubApi: async () => ({ + id: 200, + name: `issue-dev-loop-${run.runId}-0000000123456`, + expired: false, + workflow_run: { id: 100, head_sha: '0000000123456' }, + }), }) await assert.rejects( transitionRun({ @@ -301,19 +309,54 @@ test('owner-ready transition requires verification and review but remains resuma runId: run.runId, manifestPath, publicationUrl: 'https://github.com/codeacme17/echo-ui/actions/runs/101/artifacts/201', + githubApi: async () => ({ + id: 201, + name: `issue-dev-loop-${run.runId}-abcdef1234567`, + expired: false, + workflow_run: { id: 101, head_sha: 'abcdef1234567' }, + }), }) const reviewPath = await writePassingReview({ loopRoot, run, headSha: 'abcdef1234567', }) + const reviewDigest = createHash('sha256') + .update(await readFile(reviewPath, 'utf8')) + .digest('hex') await recordReview({ loopRoot, runId: run.runId, resultPath: reviewPath, reviewUrl: 'https://github.com/codeacme17/echo-ui/pull/200#pullrequestreview-300', + githubApi: async (endpoint) => + endpoint.includes('/comments?') + ? [] + : { + commit_id: 'abcdef1234567', + state: 'COMMENTED', + user: { login: 'echo-ui-loop[bot]' }, + body: `PASS\n\n`, + }, }) + await assert.rejects( + transitionRun({ + loopRoot, + runId: run.runId, + status: 'awaiting_owner_review', + prUrl: 'https://github.com/codeacme17/echo-ui/pull/200', + headSha: 'abcdef1234567', + githubApi: async () => ({ + state: 'open', + draft: false, + base: { ref: 'main' }, + head: { ref: run.branch, sha: 'abcdef1234567' }, + }), + }), + /live ready PR to dev/, + ) + const paused = await transitionRun({ loopRoot, runId: run.runId, @@ -321,6 +364,12 @@ test('owner-ready transition requires verification and review but remains resuma prUrl: 'https://github.com/codeacme17/echo-ui/pull/200', headSha: 'abcdef1234567', now: new Date('2026-07-22T17:00:00Z'), + githubApi: async () => ({ + state: 'open', + draft: false, + base: { ref: 'dev' }, + head: { ref: run.branch, sha: 'abcdef1234567' }, + }), }) assert.equal(paused.status, 'awaiting_owner_review') assert.equal(paused.finishedAt, null) @@ -356,7 +405,7 @@ test('owner-ready transition requires verification and review but remains resuma merge_commit_sha: '1234567890abcdef', }, }), - /not merged by the configured owner/, + /not approved and merged by the configured owner/, ) const finalized = await observeOwnerMerge({ @@ -404,7 +453,82 @@ test('completed finalization cannot bypass the owner-ready gate', async () => { ) }) -test('notification dry-run stages an auditable owner message', async () => { +test('review gate verifies published findings and classified replies', async () => { + const { loopRoot } = await createFixture() + const { run } = await startRun({ + loopRoot, + issueNumber: 133, + issueTitle: 'Review response proof', + issueUrl: 'https://github.com/codeacme17/echo-ui/issues/133', + entropy: 'rev133', + }) + const headSha = 'fedcba9876543' + const resultPath = path.join(loopRoot, 'logs', 'runs', run.runId, 'review-result.json') + await writeFile( + resultPath, + `${JSON.stringify({ + schemaVersion: 1, + runId: run.runId, + reviewerAgent: 'echo_ui_pr_reviewer', + freshContext: true, + headSha, + verdict: 'PASS', + rounds: [ + { + round: 1, + headSha: 'abcabc1234567', + verdict: 'CHANGES_REQUESTED', + findings: [ + { + findingId: 'RVW-1-1', + severity: 'P2', + confidence: 'high', + headSha: 'abcabc1234567', + problem: 'Incorrect assertion', + evidence: 'The runtime check already guarantees this invariant.', + expectedResolution: 'Prove or fix the assertion.', + resolution: { + classification: 'rejected', + responseUrl: 'https://github.com/codeacme17/echo-ui/pull/300#issuecomment-400', + evidence: 'Reproduction command exits successfully.', + }, + }, + ], + }, + { round: 2, headSha, verdict: 'PASS', findings: [] }, + ], + })}\n`, + 'utf8', + ) + const digest = createHash('sha256') + .update(await readFile(resultPath, 'utf8')) + .digest('hex') + const recorded = await recordReview({ + loopRoot, + runId: run.runId, + resultPath, + reviewUrl: 'https://github.com/codeacme17/echo-ui/pull/300#pullrequestreview-500', + githubApi: async (endpoint) => { + if (endpoint.endsWith('/comments?per_page=100')) return [] + if (endpoint.includes('/issues/comments/400')) { + return { + user: { login: 'echo-ui-loop[bot]' }, + body: `Rejected with proof.\n`, + } + } + return { + commit_id: headSha, + state: 'COMMENTED', + user: { login: 'echo-ui-loop[bot]' }, + body: `RVW-1-1\n`, + } + }, + }) + assert.equal(recorded.findingCount, 1) + assert.equal(recorded.rounds, 2) +}) + +test('notification dry-run is auditable but never counts as owner delivery', async () => { const { loopRoot, channelRoot } = await createFixture() const { run } = await startRun({ loopRoot, @@ -436,7 +560,13 @@ test('notification dry-run stages an auditable owner message', async () => { const paused = JSON.parse( await readFile(path.join(loopRoot, 'logs', 'runs', run.runId, 'run.json'), 'utf8'), ) - assert.equal(paused.status, 'waiting_for_owner') + assert.equal(paused.status, 'running') + const events = await readFile( + path.join(loopRoot, 'logs', 'runs', run.runId, 'events.jsonl'), + 'utf8', + ) + assert.match(events, /"type":"notification_dry_run"/) + assert.doesNotMatch(events, /"type":"owner_notified"/) }) test('failed blocking delivery still pauses for the owner', async () => { diff --git a/package.json b/package.json index 5d095d7c..0b0648d8 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ "loop:issue-dev": "node loops/issue-dev-loop/scripts/loopctl.mjs", "loop:issue-dev:validate": "node loops/issue-dev-loop/scripts/loopctl.mjs validate", "loop:issue-dev:test": "node --test loops/issue-dev-loop/tests/*.test.mjs", - "verify": "pnpm verify:contract && pnpm lint && pnpm typecheck && pnpm typecheck:example && pnpm typecheck:docs && pnpm test && node scripts/verify-package-artifact.mjs && pnpm build:example && pnpm test:tone-examples && pnpm build:docs && pnpm test:docs", + "verify": "pnpm verify:contract && pnpm loop:issue-dev:test && pnpm loop:issue-dev:validate && pnpm lint && pnpm typecheck && pnpm typecheck:example && pnpm typecheck:docs && pnpm test && node scripts/verify-package-artifact.mjs && pnpm build:example && pnpm test:tone-examples && pnpm build:docs && pnpm test:docs", "verify:frozen": "pnpm install --frozen-lockfile && pnpm verify" }, "devDependencies": { From 646a7bd71a1bb3ddc0a14021a3b18894bcef90fa Mon Sep 17 00:00:00 2001 From: leyoonafr Date: Wed, 22 Jul 2026 22:47:09 +0800 Subject: [PATCH 04/41] fix: close loop evidence and review bypasses --- .codex/agents/echo-ui-loop-evolver.toml | 2 - .codex/agents/echo-ui-pr-reviewer.toml | 7 +- .codex/agents/echo-ui-review-adjudicator.toml | 4 +- .codex/config.toml | 15 +- loops/_shared/owner-channel/CHANNEL.md | 4 +- loops/_shared/owner-channel/channel.json | 2 + loops/issue-dev-loop/LOOP.md | 8 +- loops/issue-dev-loop/SKILL.md | 4 +- loops/issue-dev-loop/dependencies.md | 4 +- loops/issue-dev-loop/evolve/EVOLVE.md | 2 +- .../references/evidence-policy.md | 2 +- .../references/github-operations.md | 9 +- loops/issue-dev-loop/review/REVIEW.md | 4 +- .../issue-dev-loop/review/response-policy.md | 4 +- .../issue-dev-loop/review/result.schema.json | 7 +- loops/issue-dev-loop/scripts/lib/evidence.mjs | 163 ++++++- loops/issue-dev-loop/scripts/lib/evolve.mjs | 6 + loops/issue-dev-loop/scripts/lib/github.mjs | 8 + .../scripts/lib/issue-claim.mjs | 58 +++ .../scripts/lib/notifications.mjs | 43 +- .../issue-dev-loop/scripts/lib/owner-gate.mjs | 13 + .../issue-dev-loop/scripts/lib/run-store.mjs | 136 +++++- .../issue-dev-loop/scripts/lib/validation.mjs | 17 + loops/issue-dev-loop/scripts/loopctl.mjs | 12 +- loops/issue-dev-loop/scripts/runtime.mjs | 9 +- loops/issue-dev-loop/state.md | 2 +- loops/issue-dev-loop/tests/runtime.test.mjs | 421 ++++++++++++++---- 27 files changed, 832 insertions(+), 134 deletions(-) create mode 100644 loops/issue-dev-loop/scripts/lib/issue-claim.mjs diff --git a/.codex/agents/echo-ui-loop-evolver.toml b/.codex/agents/echo-ui-loop-evolver.toml index 78e0cbdb..e22de7b3 100644 --- a/.codex/agents/echo-ui-loop-evolver.toml +++ b/.codex/agents/echo-ui-loop-evolver.toml @@ -1,5 +1,3 @@ -name = "echo_ui_loop_evolver" -description = "Fresh-context evaluator that improves Echo UI loops from measured run history." sandbox_mode = "workspace-write" developer_instructions = """ Read the pending evolve request, LOOP.md, state.md, compact run summaries, metrics, diff --git a/.codex/agents/echo-ui-pr-reviewer.toml b/.codex/agents/echo-ui-pr-reviewer.toml index c931f4a1..7dba085a 100644 --- a/.codex/agents/echo-ui-pr-reviewer.toml +++ b/.codex/agents/echo-ui-pr-reviewer.toml @@ -1,5 +1,3 @@ -name = "echo_ui_pr_reviewer" -description = "Fresh-context, read-only reviewer for Echo UI issue pull requests." sandbox_mode = "read-only" developer_instructions = """ Review the supplied immutable base/head diff as an independent repository owner. @@ -10,5 +8,8 @@ security, regressions, and missing tests. Ignore executor conversation history a do not modify files. Avoid style-only or speculative findings. Return a structured review with stable finding IDs, severity P0-P3, confidence, file and line, concrete evidence, reproduction when possible, and expected resolution. Return PASS when no -actionable findings exist. +actionable findings exist. Verify `gh api user` matches the configured +reviewerGitHubLogin, then publish the non-approving COMMENT review yourself; never +let the executor identity publish on your behalf. For the final PASS, include the +cycle-result digest marker and all prior finding IDs and return its review URL. """ diff --git a/.codex/agents/echo-ui-review-adjudicator.toml b/.codex/agents/echo-ui-review-adjudicator.toml index a04f03ed..959a6203 100644 --- a/.codex/agents/echo-ui-review-adjudicator.toml +++ b/.codex/agents/echo-ui-review-adjudicator.toml @@ -1,5 +1,3 @@ -name = "echo_ui_review_adjudicator" -description = "Read-only adjudicator for disputed high-severity Echo UI review findings." sandbox_mode = "read-only" developer_instructions = """ Independently adjudicate a disputed P0 or P1 review finding. Read only the issue @@ -8,4 +6,6 @@ executor response, and cited evidence. Do not modify files and do not infer inte from private conversation history. Return ACCEPT_FINDING, REJECT_FINDING, or NEEDS_OWNER, followed by concise evidence. Prefer NEEDS_OWNER when product intent, public API policy, security, or release policy is ambiguous. +For REJECT_FINDING, verify `gh api user` matches reviewerGitHubLogin and publish the +required adjudication marker on the PR. Do not post through the executor identity. """ diff --git a/.codex/config.toml b/.codex/config.toml index fd53dd1e..edff1271 100644 --- a/.codex/config.toml +++ b/.codex/config.toml @@ -1,2 +1,15 @@ [agents] -max_concurrent_threads_per_session = 3 +max_threads = 3 +max_depth = 1 + +[agents.echo_ui_pr_reviewer] +description = "Fresh-context, read-only reviewer for Echo UI issue pull requests." +config_file = "agents/echo-ui-pr-reviewer.toml" + +[agents.echo_ui_review_adjudicator] +description = "Read-only adjudicator for disputed high-severity Echo UI review findings." +config_file = "agents/echo-ui-review-adjudicator.toml" + +[agents.echo_ui_loop_evolver] +description = "Fresh-context evaluator that improves Echo UI loops from measured history." +config_file = "agents/echo-ui-loop-evolver.toml" diff --git a/loops/_shared/owner-channel/CHANNEL.md b/loops/_shared/owner-channel/CHANNEL.md index 561ee818..d524cd7a 100644 --- a/loops/_shared/owner-channel/CHANNEL.md +++ b/loops/_shared/owner-channel/CHANNEL.md @@ -10,9 +10,9 @@ GitHub issue and PR comments are the canonical, auditable channel. The runtime m ## Runtime setup -1. Authenticate `gh` for the repository identity used by the loop and set its exact GitHub login as `automationGitHubLogin` in `channel.json`. +1. Authenticate the unattended executor and fresh reviewer with distinct GitHub identities; set their exact logins as `automationGitHubLogin` and `reviewerGitHubLogin` in `channel.json`. 2. Enable GitHub notifications for mentions and review requests for `codeacme17`. 3. Optionally set `ECHO_UI_LOOP_OWNER_WEBHOOK_URL` to an endpoint that accepts the notification JSON with `Content-Type: application/json`. 4. Never store the webhook URL or credentials in this repository. -Automated review publications and replies are valid only when their author matches `automationGitHubLogin`; owner decisions are valid only when the author matches `ownerGitHubLogin`. A webhook delivery is an alert, not an approval channel. +Review publications are valid only when authored by `reviewerGitHubLogin`; executor replies must match `automationGitHubLogin`. Those identities must differ from one another and from `ownerGitHubLogin`. Owner decisions are valid only when the author matches `ownerGitHubLogin`. A webhook delivery is an alert, not an approval channel. diff --git a/loops/_shared/owner-channel/channel.json b/loops/_shared/owner-channel/channel.json index e165de8f..2709f473 100644 --- a/loops/_shared/owner-channel/channel.json +++ b/loops/_shared/owner-channel/channel.json @@ -2,6 +2,8 @@ "schemaVersion": 1, "ownerGitHubLogin": "codeacme17", "automationGitHubLogin": null, + "reviewerGitHubLogin": null, + "repository": "codeacme17/echo-ui", "canonicalChannel": "github", "webhookEnvironmentVariable": "ECHO_UI_LOOP_OWNER_WEBHOOK_URL", "immediateTypes": [ diff --git a/loops/issue-dev-loop/LOOP.md b/loops/issue-dev-loop/LOOP.md index bca52d97..50d444d0 100644 --- a/loops/issue-dev-loop/LOOP.md +++ b/loops/issue-dev-loop/LOOP.md @@ -62,7 +62,7 @@ Use a combo trigger. Run `triggers/detect-work.mjs` before starting a Codex impl ### 1. Claim and snapshot -Recheck the issue immediately before mutation. Apply `loop:claimed`, capture the issue title/body/labels/URL, base SHA, and acceptance criteria, then create the run. Use one run ID across logs, handoffs, `screen-shots`, evidence, review comments, notifications, branch metadata, and the PR body. +Recheck the issue immediately before mutation. `loopctl start` atomically reserves the issue locally, rechecks GitHub, applies `loop:claimed`, rejects another active run or open issue branch PR, and then creates the run. Capture the issue title/body/labels/URL, base SHA, and acceptance criteria. Use one run ID across logs, handoffs, `screen-shots`, evidence, review comments, notifications, branch metadata, and the PR body. ### 2. Isolate @@ -82,7 +82,7 @@ Run relevant checks and `pnpm verify`. For UI behavior, capture before/after scr ### 6. Create the draft PR -Push the issue branch and create a draft PR targeting `dev`. The PR must include the issue, run ID, base/head SHAs, risk, changes, test commands and results, evidence links, screenshots, known limitations, and explicit owner-only merge language. +Push the issue branch and create a draft PR targeting `dev`. Immediately bind it to the run with `record-pr`; later evidence and review gates accept only that PR and head. The PR must include the issue, run ID, base/head SHAs, risk, changes, test commands and results, evidence links, screenshots, known limitations, and explicit owner-only merge language. ### 7. Independent review @@ -90,7 +90,7 @@ Spawn `echo_ui_pr_reviewer` with fresh context and read-only filesystem access. ### 8. Owner gate -After exact-head CI and independent review pass, download the CI manifest and record its artifact URL with `record-evidence`; record the fresh review cycle and its GitHub URL separately with `record-review`. Mark the PR ready, request review from `codeacme17`, send a blocking notification (which pauses the run), and transition to `awaiting_owner_review`. The transition queries GitHub and requires an open, non-draft PR targeting `dev` whose live branch and head SHA match the run. Do not infer approval from timeouts or silence. +After exact-head CI and independent review pass, download the CI manifest and record its artifact URL with `record-evidence`; the command downloads the artifact again, byte-compares its manifest, and requires a successful run of the named workflow for the recorded PR/head. Record the fresh review cycle and its GitHub URL separately with `record-review`. Mark the PR ready, request review from `codeacme17`, send a blocking GitHub notification (which pauses the run), and transition to `awaiting_owner_review`. The transition requires that delivered notification and queries GitHub for an open, non-draft PR targeting `dev` whose live branch and head SHA match the run. Do not infer approval from timeouts or silence. ### 9. Owner feedback @@ -98,7 +98,7 @@ When the owner requests changes, snapshot the comments, create a new handoff, in ### 10. Complete -Only `observe-owner-merge` permits `completed`. It must query GitHub and observe both an `APPROVED` review by `codeacme17` for the exact reviewed head SHA and a merge performed by `codeacme17` for that same head. Record merge SHA and timestamp, remove `loop:claimed`, update state, append the run summary, and retain links to published evidence. A closed unmerged PR is `cancelled`, not completed. +Only `observe-owner-merge` permits `completed`. It must query GitHub and observe the recorded issue branch still targeting `dev`, an `APPROVED` review by `codeacme17` for the exact reviewed head SHA, and a merge performed by `codeacme17` for that same head. Record merge SHA and timestamp, remove `loop:claimed`, update state, append the run summary, and retain links to published evidence. A closed unmerged PR is `cancelled`, not completed. ## State and history diff --git a/loops/issue-dev-loop/SKILL.md b/loops/issue-dev-loop/SKILL.md index ac5b189f..a2a4a4d4 100644 --- a/loops/issue-dev-loop/SKILL.md +++ b/loops/issue-dev-loop/SKILL.md @@ -35,13 +35,13 @@ Record the resulting commit SHA and validation summary in the run log. ## Publish a draft PR -Push only the issue branch and create a **draft** PR targeting `dev`. Include the issue, risk assessment, test results, evidence manifest, screenshots, known limitations, run ID, and exact head SHA. Never target `main` from an issue branch. +Push only the issue branch and create a **draft** PR targeting `dev`. Bind it immediately with `loopctl.mjs record-pr --run-id --pr-url --head-sha `. Include the issue, risk assessment, test results, evidence manifest, screenshots, known limitations, run ID, and exact head SHA. Never target `main` from an issue branch. ## Run independent review After the draft PR exists, spawn the project agent `echo_ui_pr_reviewer` with a fresh context. Give it only the issue snapshot, acceptance criteria, repository instructions, base SHA, head SHA, diff, CI results, and evidence manifest. Do not give it executor conversation history or rationale. -Post the reviewer's findings verbatim as one review plus inline comments. Each finding needs a stable ID, severity, confidence, evidence, and expected resolution. Follow `review/REVIEW.md` and `review/response-policy.md`. +Publish the review through the separately configured `reviewerGitHubLogin`; the executor identity may not author it. Post findings verbatim as one review plus inline comments. Each finding needs a stable ID, severity, confidence, evidence, and expected resolution. Follow `review/REVIEW.md` and `review/response-policy.md`. The executor must classify every finding as `accepted`, `rejected`, `needs-human`, `stale`, or `already-fixed`: diff --git a/loops/issue-dev-loop/dependencies.md b/loops/issue-dev-loop/dependencies.md index d4da7242..94adcda5 100644 --- a/loops/issue-dev-loop/dependencies.md +++ b/loops/issue-dev-loop/dependencies.md @@ -9,7 +9,7 @@ - Node.js 24 - pnpm 10 - Git -- GitHub CLI (`gh`) authenticated for issue, branch, PR, review, and comment work +- GitHub CLI (`gh`) authenticated for issue, Actions artifact download, branch, PR, review, and comment work - Repository trust enabled so project `.codex` agents can load ## Optional @@ -19,4 +19,4 @@ ## Identity -Prefer a dedicated GitHub App or bot identity for loop-created PRs so the owner can provide an independent approval. The bot must not have branch-protection bypass or merge authority. +Use one dedicated GitHub App or bot identity for executor-created branches, PRs, and replies, plus a distinct reviewer identity for the fresh-context review publication. Neither may be `codeacme17`; neither may have branch-protection bypass or merge authority. Configure their exact logins in the shared owner channel before enabling automation. diff --git a/loops/issue-dev-loop/evolve/EVOLVE.md b/loops/issue-dev-loop/evolve/EVOLVE.md index 7a2798c9..e81e8c7b 100644 --- a/loops/issue-dev-loop/evolve/EVOLVE.md +++ b/loops/issue-dev-loop/evolve/EVOLVE.md @@ -9,7 +9,7 @@ The evolve session may propose: - improve non-authority-changing scripts and templates - update dashboards or metrics derived from append-only logs -Every evolve change requires a dedicated draft PR reviewed and merged by the owner. In particular, never change the following without explicit owner confirmation: +Every evolve change requires a dedicated `codex/evolve-` draft PR targeting `dev`, containing ``, and reviewed and merged by the owner. The PR must be created after the request. In particular, never change the following without explicit owner confirmation: - goals, completion criteria, authority, or stop conditions - merge, release, security, privacy, or dependency policy diff --git a/loops/issue-dev-loop/references/evidence-policy.md b/loops/issue-dev-loop/references/evidence-policy.md index aab5d806..26021b56 100644 --- a/loops/issue-dev-loop/references/evidence-policy.md +++ b/loops/issue-dev-loop/references/evidence-policy.md @@ -26,4 +26,4 @@ Use descriptive names such as `02-after-player-mobile-375.webp` under `screen-sh Commit issue-relevant PNG/WebP screenshots and their metadata under `screen-shots/` before owner review. The `issue-dev-loop-evidence.yml` workflow checks out the exact PR head, runs `pnpm verify`, generates `evidence//manifest.json` in the runner, and uploads the manifest, sanitized run log, and screenshots as a GitHub Actions artifact. Download the artifact, then pass its URL to `record-evidence`; never accept an artifact from a different head SHA. -`record-evidence` queries the GitHub artifact API and rejects expired artifacts, mismatched workflow runs, unexpected artifact names, and any `workflow_run.head_sha` that differs from the manifest. The independent reviewer posts findings and responses to GitHub after the draft PR exists. Save its structured cycle result locally and use `record-review --review-url ` so the review gate is independently bound to the same head SHA. Keep raw local command output, videos, traces, and bulky reports in ignored runtime directories. Never upload secrets, cookies, auth state, user data, or unredacted environment dumps. +`record-evidence` queries the GitHub artifact and workflow-run APIs, requires the named evidence workflow to conclude successfully for the recorded PR/branch/head, downloads the artifact through `gh run download`, and byte-compares its manifest with the supplied file. Failed CI artifacts and locally fabricated manifests are rejected. The independent reviewer posts findings and responses to GitHub after the draft PR exists. Save its structured cycle result locally and use `record-review --review-url ` so the review gate is independently bound to the same recorded PR and head SHA. Keep raw local command output, videos, traces, and bulky reports in ignored runtime directories. Never upload secrets, cookies, auth state, user data, or unredacted environment dumps. diff --git a/loops/issue-dev-loop/references/github-operations.md b/loops/issue-dev-loop/references/github-operations.md index ba0a0f7b..7bda6527 100644 --- a/loops/issue-dev-loop/references/github-operations.md +++ b/loops/issue-dev-loop/references/github-operations.md @@ -6,14 +6,15 @@ Read this before mutating issues or pull requests. 1. Select only open issues labeled `codex-ready`. 2. Exclude `loop:claimed`, an existing `codex/issue-` branch, and any open PR that references or closes the issue. -3. Recheck immediately before applying `loop:claimed`. -4. Record the issue snapshot and claim timestamp before implementation. +3. Let `loopctl start` acquire the local claim, recheck GitHub immediately, and apply `loop:claimed`; do not add the label manually first. +4. Record the issue snapshot and claim timestamp before implementation. A second active local run for the issue is rejected. ## Branch and PR - Refresh `origin/dev` before creating `codex/issue-`. - Push only the issue branch. - Create a draft PR with `--base dev`. +- Run `loopctl record-pr` immediately so later evidence, review, notification, and merge observations are bound to that exact PR/head. - Include `Closes #` only when the PR fully satisfies the issue. - Request `codeacme17` only after automated review and verification pass. - Bind every review to immutable base and head SHAs. @@ -24,10 +25,10 @@ The PR workflow `Issue dev loop evidence` runs only when the branch contains one ```text gh run list --workflow issue-dev-loop-evidence.yml --branch codex/issue- -gh run download --name issue-dev-loop-- +gh run download --name issue-dev-loop-- --dir loops/issue-dev-loop/evidence/ ``` -Use the artifact URL emitted by `actions/upload-artifact` as `record-evidence --publication-url`. Reject a workflow run whose `headSha` does not equal the current PR head. +Use the artifact URL emitted by `actions/upload-artifact` as `record-evidence --publication-url` and the downloaded artifact manifest as `--manifest`. The runtime downloads it independently, requires the workflow conclusion to be `success`, and byte-compares the manifest. Reject a workflow run whose PR, branch, workflow path, or `headSha` differs from the recorded run. ## Prohibited commands diff --git a/loops/issue-dev-loop/review/REVIEW.md b/loops/issue-dev-loop/review/REVIEW.md index 0ed88092..a996b225 100644 --- a/loops/issue-dev-loop/review/REVIEW.md +++ b/loops/issue-dev-loop/review/REVIEW.md @@ -23,9 +23,9 @@ Do not emit formatter noise, personal style preferences, speculative concerns, u ## Publication -The orchestrator posts findings verbatim as one non-approving GitHub review and inline comments, adding `` for deduplication. The review body must include ``. It must not downgrade severity or omit findings. +The fresh reviewer publishes findings verbatim through `reviewerGitHubLogin` as one non-approving GitHub review and inline comments, adding `` for deduplication. The reviewer identity must differ from the executor and owner. The review body must include ``. It must not downgrade severity or omit findings. -After all replies are posted, create one cycle result matching `result.schema.json`. It contains every round, every finding, its final classification, response URL, and evidence. Each reply includes ``. Run `record-review` with the GitHub review URL; it queries the review and replies, verifies their automation identity and markers, and binds them to the current head. Generic events cannot forge this reserved gate. +After all replies are posted, create one cycle result matching `result.schema.json`. It contains every round, every finding, its final classification, response URL, and evidence. Executor replies include both the readable evidence (and accepted fix SHA) plus ``. Rejected P0/P1 findings additionally require an independent or owner comment containing ``. Run `record-review` with the GitHub review URL; it queries the recorded PR, review, replies, identities, ancestry, adjudications, and markers and binds them to the current head. Generic events cannot forge this reserved gate. ## Completion diff --git a/loops/issue-dev-loop/review/response-policy.md b/loops/issue-dev-loop/review/response-policy.md index 65e8eca7..8c807dd9 100644 --- a/loops/issue-dev-loop/review/response-policy.md +++ b/loops/issue-dev-loop/review/response-policy.md @@ -10,8 +10,8 @@ Classify every automated finding as one of: Accepted findings return to `$implement`. Reply only after pushing the fix and include commit SHA, commands, results, and evidence links. -Rejected findings require a precise counterclaim and reproducible evidence. Do not reply with bare disagreement. An executor cannot unilaterally close a disputed P0 or P1; invoke `echo_ui_review_adjudicator`, then escalate `NEEDS_OWNER` outcomes. +Rejected findings require a precise counterclaim and reproducible evidence in the GitHub reply. Do not reply with bare disagreement. An executor cannot unilaterally close a disputed P0 or P1; publish `REJECT_FINDING` from the independent reviewer identity or `OWNER_REJECTED_FINDING` from the owner, include its adjudication marker/URL in the cycle result, and escalate `NEEDS_OWNER` outcomes. Do not delete review comments. Log classification, response time, response URL, commit, and evidence reference under the run ID. -The final cycle file must match `result.schema.json`. `record-review` accepts only a fresh `echo_ui_pr_reviewer` PASS whose last round matches the current head, whose published GitHub review carries the result digest, whose earlier findings all have verified GitHub response URLs, classification markers and evidence, and whose accepted findings name a fix commit. +The final cycle file must match `result.schema.json`. `record-review` accepts only a PASS published by the distinct configured reviewer identity whose last round matches the recorded PR head, whose GitHub review carries the result digest, whose earlier findings all have verified executor-authored response URLs, classification markers and visible evidence, and whose accepted fix commits are ancestors of the reviewed head. diff --git a/loops/issue-dev-loop/review/result.schema.json b/loops/issue-dev-loop/review/result.schema.json index 47d64a19..0270f4bd 100644 --- a/loops/issue-dev-loop/review/result.schema.json +++ b/loops/issue-dev-loop/review/result.schema.json @@ -66,7 +66,12 @@ }, "responseUrl": { "type": "string", "format": "uri" }, "evidence": { "type": "string", "minLength": 1 }, - "fixCommit": { "type": ["string", "null"] } + "fixCommit": { "type": ["string", "null"] }, + "adjudicationUrl": { "type": ["string", "null"], "format": "uri" }, + "adjudicationVerdict": { + "type": ["string", "null"], + "enum": ["REJECT_FINDING", "OWNER_REJECTED_FINDING", null] + } } } } diff --git a/loops/issue-dev-loop/scripts/lib/evidence.mjs b/loops/issue-dev-loop/scripts/lib/evidence.mjs index c4702350..7a073312 100644 --- a/loops/issue-dev-loop/scripts/lib/evidence.mjs +++ b/loops/issue-dev-loop/scripts/lib/evidence.mjs @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto' -import { readFile } from 'node:fs/promises' +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' import path from 'node:path' import { @@ -9,11 +10,13 @@ import { assertNonEmpty, assertRunId, defaultGitHubApi, + execFileAsync, parseArtifactUrl, parseGitHubTarget, parsePullCommentUrl, parseReviewUrl, readJson, + runDirectory, sameGitHubLogin, sameRepository, } from './common.mjs' @@ -21,6 +24,35 @@ import { appendValidatedEvent, readRun } from './run-store.mjs' const REVIEW_CLASSIFICATIONS = new Set(['accepted', 'rejected', 'stale', 'already-fixed']) +async function defaultArtifactManifestLoader({ owner, repo, runId, artifactName }) { + const temporary = await mkdtemp(path.join(tmpdir(), 'echo-ui-loop-artifact-')) + try { + await execFileAsync( + 'gh', + [ + 'run', + 'download', + runId, + '--repo', + `${owner}/${repo}`, + '--name', + artifactName, + '--dir', + temporary, + ], + { maxBuffer: 1024 * 1024 }, + ) + const entries = await readdir(temporary, { recursive: true, withFileTypes: true }) + const manifests = entries.filter((entry) => entry.isFile() && entry.name === 'manifest.json') + if (manifests.length !== 1) { + throw new Error('evidence artifact must contain exactly one manifest.json') + } + return readFile(path.join(manifests[0].parentPath, manifests[0].name), 'utf8') + } finally { + await rm(temporary, { recursive: true, force: true }) + } +} + function validateReviewEvidence(review, headSha) { if (!review || typeof review !== 'object' || Array.isArray(review)) { throw new Error('evidence review must be an object') @@ -82,7 +114,18 @@ function validateReviewEvidence(review, headSha) { assertHttpUrl(resolution.responseUrl, `${findingId}.resolution.responseUrl`) assertNonEmpty(resolution.evidence, `${findingId}.resolution.evidence`) if (resolution.classification === 'accepted') { - assertNonEmpty(resolution.fixCommit, `${findingId}.resolution.fixCommit`) + const fixCommit = assertNonEmpty(resolution.fixCommit, `${findingId}.resolution.fixCommit`) + if (!/^[0-9a-f]{40}$/i.test(fixCommit)) { + throw new Error(`${findingId}.resolution.fixCommit must be a full Git SHA`) + } + } + if (['P0', 'P1'].includes(finding.severity) && resolution.classification === 'rejected') { + assertHttpUrl(resolution.adjudicationUrl, `${findingId}.resolution.adjudicationUrl`) + if ( + !['REJECT_FINDING', 'OWNER_REJECTED_FINDING'].includes(resolution.adjudicationVerdict) + ) { + throw new Error(`${findingId} rejected P0/P1 requires a rejecting adjudication verdict`) + } } resolvedFindings.push(finding) } @@ -97,10 +140,13 @@ export async function recordEvidence({ publicationUrl, now = new Date(), githubApi = defaultGitHubApi, + artifactManifestLoader = defaultArtifactManifestLoader, } = {}) { const normalizedRunId = assertRunId(runId) const run = await readRun(loopRoot, normalizedRunId) if (run.finishedAt !== null) throw new Error(`run is already finalized: ${normalizedRunId}`) + if (!run.prUrl || !run.headSha) throw new Error('record-review requires a recorded draft PR') + if (!run.prUrl || !run.headSha) throw new Error('record-evidence requires a recorded draft PR') const evidenceRoot = path.resolve(loopRoot, 'evidence') const resolvedManifest = path.resolve(assertNonEmpty(manifestPath, 'manifestPath')) @@ -127,15 +173,43 @@ export async function recordEvidence({ const artifact = await githubApi( `repos/${artifactTarget.owner}/${artifactTarget.repo}/actions/artifacts/${artifactTarget.artifactId}`, ) + const workflowRun = await githubApi( + `repos/${artifactTarget.owner}/${artifactTarget.repo}/actions/runs/${artifactTarget.runId}`, + ) + const pullTarget = parseGitHubTarget(run.prUrl) if ( String(artifact.id) !== artifactTarget.artifactId || artifact.expired === true || String(artifact.workflow_run?.id) !== artifactTarget.runId || artifact.workflow_run?.head_sha !== headSha || - artifact.name !== `issue-dev-loop-${normalizedRunId}-${headSha}` + artifact.name !== `issue-dev-loop-${normalizedRunId}-${headSha}` || + workflowRun.id !== Number(artifactTarget.runId) || + workflowRun.status !== 'completed' || + workflowRun.conclusion !== 'success' || + workflowRun.event !== 'pull_request' || + workflowRun.head_sha !== headSha || + workflowRun.head_branch !== run.branch || + workflowRun.path?.split('@')[0] !== '.github/workflows/issue-dev-loop-evidence.yml' || + !workflowRun.pull_requests?.some( + (pullRequest) => + pullRequest.number === pullTarget.number && + pullRequest.base?.ref === 'dev' && + pullRequest.base?.repo?.full_name?.toLowerCase() === + `${pullTarget.owner}/${pullTarget.repo}`.toLowerCase(), + ) ) { throw new Error('evidence artifact metadata does not match the run and exact headSha') } + if (headSha !== run.headSha) throw new Error('evidence manifest is not for the recorded PR head') + const artifactManifest = await artifactManifestLoader({ + owner: artifactTarget.owner, + repo: artifactTarget.repo, + runId: artifactTarget.runId, + artifactName: artifact.name, + }) + if (artifactManifest !== source) { + throw new Error('local evidence manifest does not match the published artifact manifest') + } const checks = assertArray(manifest.checks, 'manifest.checks') if (checks.length === 0 || checks.some((check) => check.status !== 'passed')) { @@ -195,7 +269,12 @@ export async function recordReview({ const normalizedRunId = assertRunId(runId) const run = await readRun(loopRoot, normalizedRunId) if (run.finishedAt !== null) throw new Error(`run is already finalized: ${normalizedRunId}`) - const source = await readFile(path.resolve(assertNonEmpty(resultPath, 'resultPath')), 'utf8') + const resolvedResultPath = path.resolve(assertNonEmpty(resultPath, 'resultPath')) + const expectedResultRoot = runDirectory(loopRoot, normalizedRunId) + if (!resolvedResultPath.startsWith(`${expectedResultRoot}${path.sep}`)) { + throw new Error('resultPath must be inside the current run directory') + } + const source = await readFile(resolvedResultPath, 'utf8') const result = JSON.parse(source) if (result.schemaVersion !== 1 || result.runId !== normalizedRunId) { throw new Error('review result does not match the run') @@ -204,8 +283,13 @@ export async function recordReview({ const reviewSummary = validateReviewEvidence(result, headSha) const publishedReviewUrl = assertHttpUrl(reviewUrl, 'reviewUrl') const reviewTarget = parseReviewUrl(publishedReviewUrl) - if (!reviewTarget || !sameRepository(parseGitHubTarget(run.issueUrl), reviewTarget)) { - throw new Error('reviewUrl must be a GitHub pull request review for the issue repository') + const recordedPullTarget = parseGitHubTarget(run.prUrl) + if ( + !reviewTarget || + !sameRepository(parseGitHubTarget(run.issueUrl), reviewTarget) || + reviewTarget.number !== recordedPullTarget?.number + ) { + throw new Error('reviewUrl must be a GitHub review on the recorded run PR') } const resultDigest = createHash('sha256').update(source).digest('hex') const channel = await readJson( @@ -215,10 +299,18 @@ export async function recordReview({ channel.automationGitHubLogin, 'channel.automationGitHubLogin', ) + const reviewerLogin = assertNonEmpty(channel.reviewerGitHubLogin, 'channel.reviewerGitHubLogin') + if ( + sameGitHubLogin(automationLogin, reviewerLogin) || + sameGitHubLogin(channel.ownerGitHubLogin, reviewerLogin) + ) { + throw new Error('reviewerGitHubLogin must be independent from executor and owner identities') + } const reviewEndpoint = `repos/${reviewTarget.owner}/${reviewTarget.repo}/pulls/${reviewTarget.number}/reviews/${reviewTarget.reviewId}` - const [publishedReview, reviewComments] = await Promise.all([ + const [publishedReview, reviewComments, livePullRequest] = await Promise.all([ githubApi(reviewEndpoint), githubApi(`${reviewEndpoint}/comments?per_page=100`), + githubApi(`repos/${reviewTarget.owner}/${reviewTarget.repo}/pulls/${reviewTarget.number}`), ]) const digestMarker = `` const publishedBodies = [ @@ -228,11 +320,20 @@ export async function recordReview({ if ( publishedReview.commit_id !== headSha || publishedReview.state !== 'COMMENTED' || - !sameGitHubLogin(publishedReview.user?.login, automationLogin) || + !sameGitHubLogin(publishedReview.user?.login, reviewerLogin) || !publishedBodies.some((body) => body.includes(digestMarker)) ) { throw new Error('published GitHub review does not attest this result and exact headSha') } + if ( + livePullRequest.state !== 'open' || + livePullRequest.base?.ref !== 'dev' || + livePullRequest.head?.ref !== run.branch || + livePullRequest.head?.sha !== headSha || + run.headSha !== headSha + ) { + throw new Error('published review is not bound to the recorded live PR head') + } for (const finding of reviewSummary.findings) { if (!publishedBodies.some((body) => body.includes(finding.findingId))) { throw new Error(`${finding.findingId} is missing from the published GitHub review`) @@ -253,9 +354,51 @@ export async function recordReview({ const responseMarker = `` if ( !sameGitHubLogin(response.user?.login, automationLogin) || - !response.body?.includes(responseMarker) + !response.body?.includes(responseMarker) || + !response.body?.includes(finding.resolution.evidence) || + (finding.resolution.classification === 'accepted' && + !response.body?.includes(finding.resolution.fixCommit)) + ) { + throw new Error( + `${finding.findingId} response is not published with its classification and evidence`, + ) + } + if (finding.resolution.classification === 'accepted') { + const comparison = await githubApi( + `repos/${reviewTarget.owner}/${reviewTarget.repo}/compare/${finding.resolution.fixCommit}...${headSha}`, + ) + if ( + !['ahead', 'identical'].includes(comparison.status) || + comparison.base_commit?.sha !== finding.resolution.fixCommit + ) { + throw new Error(`${finding.findingId} fixCommit is not an ancestor of the reviewed head`) + } + } + if ( + ['P0', 'P1'].includes(finding.severity) && + finding.resolution.classification === 'rejected' ) { - throw new Error(`${finding.findingId} response is not published with its classification`) + const adjudicationTarget = parsePullCommentUrl(finding.resolution.adjudicationUrl) + if ( + !adjudicationTarget || + !sameRepository(reviewTarget, adjudicationTarget) || + adjudicationTarget.number !== reviewTarget.number + ) { + throw new Error(`${finding.findingId} adjudication is not on the reviewed PR`) + } + const adjudicationEndpoint = + adjudicationTarget.kind === 'review_comment' + ? `repos/${adjudicationTarget.owner}/${adjudicationTarget.repo}/pulls/comments/${adjudicationTarget.commentId}` + : `repos/${adjudicationTarget.owner}/${adjudicationTarget.repo}/issues/comments/${adjudicationTarget.commentId}` + const adjudication = await githubApi(adjudicationEndpoint) + const expectedVerdict = finding.resolution.adjudicationVerdict + const expectedMarker = `` + const permittedAdjudicator = + sameGitHubLogin(adjudication.user?.login, reviewerLogin) || + sameGitHubLogin(adjudication.user?.login, channel.ownerGitHubLogin) + if (!permittedAdjudicator || !adjudication.body?.includes(expectedMarker)) { + throw new Error(`${finding.findingId} lacks independent published adjudication`) + } } } await appendValidatedEvent({ diff --git a/loops/issue-dev-loop/scripts/lib/evolve.mjs b/loops/issue-dev-loop/scripts/lib/evolve.mjs index cd3a6a9b..19e1d975 100644 --- a/loops/issue-dev-loop/scripts/lib/evolve.mjs +++ b/loops/issue-dev-loop/scripts/lib/evolve.mjs @@ -79,6 +79,12 @@ export async function completeEvolve({ const merge = await observeOwnerApprovedMerge({ loopRoot, prUrl: publishedPrUrl, + expectedHeadBranch: `codex/evolve-${normalizedRequestId}`, + expectedRepository: ( + await readJson(path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json')) + ).repository, + requiredBodyMarker: ``, + createdAfter: request.requestedAt, githubApi, }) diff --git a/loops/issue-dev-loop/scripts/lib/github.mjs b/loops/issue-dev-loop/scripts/lib/github.mjs index ed49b16a..c892a246 100644 --- a/loops/issue-dev-loop/scripts/lib/github.mjs +++ b/loops/issue-dev-loop/scripts/lib/github.mjs @@ -3,6 +3,7 @@ import path from 'node:path' import { DEFAULT_LOOP_ROOT, assertRunId, defaultGitHubApi, execFileAsync } from './common.mjs' import { observeOwnerApprovedMerge } from './owner-gate.mjs' +import { defaultReleaseIssueClaim } from './issue-claim.mjs' import { appendValidatedEvent, finalizeRun, readRun } from './run-store.mjs' const PRIORITY = new Map([ @@ -108,6 +109,7 @@ export async function observeOwnerMerge({ runId, now = new Date(), githubApi = defaultGitHubApi, + releaseIssueClaim = defaultReleaseIssueClaim, } = {}) { const normalizedRunId = assertRunId(runId) const run = await readRun(loopRoot, normalizedRunId) @@ -118,6 +120,12 @@ export async function observeOwnerMerge({ loopRoot, prUrl: run.prUrl, expectedHeadSha: run.headSha, + expectedHeadBranch: run.branch, + githubApi, + }) + await releaseIssueClaim({ + issueUrl: run.issueUrl, + issueNumber: run.issueNumber, githubApi, }) diff --git a/loops/issue-dev-loop/scripts/lib/issue-claim.mjs b/loops/issue-dev-loop/scripts/lib/issue-claim.mjs new file mode 100644 index 00000000..5d3859c9 --- /dev/null +++ b/loops/issue-dev-loop/scripts/lib/issue-claim.mjs @@ -0,0 +1,58 @@ +import { defaultGitHubApi, execFileAsync, parseGitHubTarget } from './common.mjs' + +function labelNames(issue) { + return new Set((issue.labels ?? []).map((label) => label.name ?? label)) +} + +export async function defaultClaimIssue({ issueUrl, issueNumber, branch, githubApi }) { + const target = parseGitHubTarget(issueUrl) + if (!target || target.kind !== 'issues' || target.number !== issueNumber) { + throw new Error('issueUrl must identify the issue being claimed') + } + const issue = await githubApi(`repos/${target.owner}/${target.repo}/issues/${issueNumber}`) + const labels = labelNames(issue) + if (issue.state !== 'open' || !labels.has('codex-ready') || labels.has('loop:claimed')) { + throw new Error('issue is no longer an open, unclaimed codex-ready issue') + } + const pulls = await githubApi( + `repos/${target.owner}/${target.repo}/pulls?state=open&per_page=100`, + ) + if (pulls.some((pullRequest) => pullRequest.head?.ref === branch)) { + throw new Error(`an open pull request already claims ${branch}`) + } + await execFileAsync( + 'gh', + [ + 'api', + `repos/${target.owner}/${target.repo}/issues/${issueNumber}/labels`, + '--method', + 'POST', + '-f', + 'labels[]=loop:claimed', + ], + { maxBuffer: 1024 * 1024 }, + ) +} + +export async function defaultReleaseIssueClaim({ + issueUrl, + issueNumber, + githubApi = defaultGitHubApi, +}) { + const target = parseGitHubTarget(issueUrl) + if (!target || target.kind !== 'issues' || target.number !== issueNumber) { + throw new Error('issueUrl must identify the issue claim being released') + } + const issue = await githubApi(`repos/${target.owner}/${target.repo}/issues/${issueNumber}`) + if (!labelNames(issue).has('loop:claimed')) return + await execFileAsync( + 'gh', + [ + 'api', + `repos/${target.owner}/${target.repo}/issues/${issueNumber}/labels/loop%3Aclaimed`, + '--method', + 'DELETE', + ], + { maxBuffer: 1024 * 1024 }, + ) +} diff --git a/loops/issue-dev-loop/scripts/lib/notifications.mjs b/loops/issue-dev-loop/scripts/lib/notifications.mjs index 112e7882..96443909 100644 --- a/loops/issue-dev-loop/scripts/lib/notifications.mjs +++ b/loops/issue-dev-loop/scripts/lib/notifications.mjs @@ -12,7 +12,13 @@ import { timestampToken, writeJson, } from './common.mjs' -import { PAUSED_STATUSES, appendEvent, readRun, transitionRun } from './run-store.mjs' +import { + PAUSED_STATUSES, + appendValidatedEvent, + readEvents, + readRun, + transitionRun, +} from './run-store.mjs' function notificationBody(notification, owner) { const evidence = notification.evidenceUrl ? `\n\nEvidence: ${notification.evidenceUrl}` : '' @@ -66,6 +72,28 @@ export async function createNotification({ if (channel.immediateTypes.includes(notificationType) && !blocking) { throw new Error(`${notificationType} must be sent as a blocking notification`) } + if ( + notificationType === 'pr_ready_for_review' && + (!run.prUrl || !run.headSha || targetUrl !== run.prUrl || !evidenceUrl) + ) { + throw new Error( + 'pr_ready_for_review must target the recorded PR and include exact-head evidence', + ) + } + if (notificationType === 'pr_ready_for_review') { + const events = await readEvents(loopRoot, normalizedRunId) + if ( + !events.some( + (event) => + event.type === 'verification_completed' && + event.status === 'passed' && + event.payload?.headSha === run.headSha && + event.payload?.manifestUrl === evidenceUrl, + ) + ) { + throw new Error('pr_ready_for_review evidenceUrl must be the recorded exact-head artifact') + } + } const target = parseGitHubTarget(targetUrl) if ( targetUrl && @@ -132,13 +160,20 @@ export async function createNotification({ } await writeJson(outboxFile, notification) - const delivered = Object.values(notification.delivery).includes('delivered') - await appendEvent({ + const delivered = notification.delivery.github === 'delivered' + await appendValidatedEvent({ loopRoot, runId: normalizedRunId, type: dryRun ? 'notification_dry_run' : delivered ? 'owner_notified' : 'notification_failed', status: dryRun ? 'simulated' : delivered ? 'delivered' : 'failed', - payload: { notificationId, notificationType, delivery: notification.delivery }, + payload: { + notificationId, + notificationType, + delivery: notification.delivery, + targetUrl, + evidenceUrl, + headSha: run.headSha, + }, now, }) diff --git a/loops/issue-dev-loop/scripts/lib/owner-gate.mjs b/loops/issue-dev-loop/scripts/lib/owner-gate.mjs index a5a1c4c2..f8853d16 100644 --- a/loops/issue-dev-loop/scripts/lib/owner-gate.mjs +++ b/loops/issue-dev-loop/scripts/lib/owner-gate.mjs @@ -6,6 +6,11 @@ export async function observeOwnerApprovedMerge({ loopRoot, prUrl, expectedHeadSha = null, + expectedHeadBranch, + expectedRepository, + expectedBaseBranch = 'dev', + requiredBodyMarker = null, + createdAfter = null, githubApi = defaultGitHubApi, }) { const target = parseGitHubTarget(prUrl) @@ -18,6 +23,7 @@ export async function observeOwnerApprovedMerge({ githubApi(`repos/${target.owner}/${target.repo}/pulls/${target.number}/reviews?per_page=100`), ]) const headSha = pullRequest.head?.sha + const configuredRepository = expectedRepository ?? channel.repository const ownerApproval = reviews.some( (review) => sameGitHubLogin(review.user?.login, channel.ownerGitHubLogin) && @@ -26,6 +32,13 @@ export async function observeOwnerApprovedMerge({ ) if ( pullRequest.merged !== true || + `${target.owner}/${target.repo}`.toLowerCase() !== configuredRepository.toLowerCase() || + pullRequest.base?.ref !== expectedBaseBranch || + pullRequest.base?.repo?.full_name?.toLowerCase() !== configuredRepository.toLowerCase() || + (expectedHeadBranch && pullRequest.head?.ref !== expectedHeadBranch) || + pullRequest.head?.repo?.full_name?.toLowerCase() !== configuredRepository.toLowerCase() || + (requiredBodyMarker && !pullRequest.body?.includes(requiredBodyMarker)) || + (createdAfter && Date.parse(pullRequest.created_at) < Date.parse(createdAfter)) || !sameGitHubLogin(pullRequest.merged_by?.login, channel.ownerGitHubLogin) || !pullRequest.merge_commit_sha || !ownerApproval || diff --git a/loops/issue-dev-loop/scripts/lib/run-store.mjs b/loops/issue-dev-loop/scripts/lib/run-store.mjs index 4185543b..ba0c434f 100644 --- a/loops/issue-dev-loop/scripts/lib/run-store.mjs +++ b/loops/issue-dev-loop/scripts/lib/run-store.mjs @@ -1,5 +1,5 @@ import { randomBytes } from 'node:crypto' -import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' import path from 'node:path' import { @@ -19,6 +19,7 @@ import { timestampToken, writeJson, } from './common.mjs' +import { defaultClaimIssue } from './issue-claim.mjs' import { updateEvolveMetrics } from './evolve.mjs' export const PAUSED_STATUSES = new Set(['awaiting_owner_review', 'waiting_for_owner']) @@ -28,12 +29,25 @@ const RESERVED_EVENT_TYPES = new Set([ 'loop_started', 'verification_completed', 'review_completed', + 'pr_published', + 'owner_notified', + 'notification_failed', + 'notification_dry_run', 'owner_review_approved', 'pr_merged', 'run_status_changed', 'run_finalized', ]) +const ALLOWED_TRANSITIONS = new Map([ + ['running', new Set(['waiting_for_owner', 'cancelled'])], + [ + 'waiting_for_owner', + new Set(['running', 'awaiting_owner_review', 'blocked', 'failed', 'cancelled']), + ], + ['awaiting_owner_review', new Set(['running', 'completed', 'cancelled'])], +]) + export function makeRunId({ issueNumber, now = new Date(), entropy } = {}) { const issue = assertIssueNumber(issueNumber) const suffix = entropy ?? randomBytes(3).toString('hex') @@ -52,6 +66,8 @@ export async function startRun({ issueUrl, now = new Date(), entropy, + githubApi = defaultGitHubApi, + claimIssue = defaultClaimIssue, } = {}) { const issue = assertIssueNumber(issueNumber) const title = assertNonEmpty(issueTitle, 'issueTitle') @@ -60,6 +76,41 @@ export async function startRun({ const runPath = runDirectory(loopRoot, runId) if (await pathExists(runPath)) throw new Error(`run already exists: ${runId}`) + const runsRoot = path.join(loopRoot, 'logs', 'runs') + const activeRuns = [] + for (const entry of await readdir(runsRoot, { withFileTypes: true })) { + if (!entry.isDirectory()) continue + const runFile = path.join(runsRoot, entry.name, 'run.json') + if (!(await pathExists(runFile))) continue + const existing = await readJson(runFile) + if (existing.finishedAt === null) activeRuns.push(existing) + } + if (activeRuns.some((existing) => existing.issueNumber === issue)) { + throw new Error(`issue ${issue} already has an active run`) + } + + const claimsRoot = path.join(loopRoot, 'logs', 'claims') + const claimDirectory = path.join(claimsRoot, `issue-${issue}`) + await mkdir(claimsRoot, { recursive: true }) + try { + await mkdir(claimDirectory) + } catch (error) { + if (error?.code === 'EEXIST') throw new Error(`issue ${issue} is already locally claimed`) + throw error + } + + try { + await claimIssue({ + issueUrl: url, + issueNumber: issue, + branch: `codex/issue-${issue}`, + githubApi, + }) + } catch (error) { + await rm(claimDirectory, { recursive: true, force: true }) + throw error + } + await Promise.all( [ runPath, @@ -113,6 +164,52 @@ export async function startRun({ return { run, briefPath, runPath } } +export async function recordPullRequest({ + loopRoot = DEFAULT_LOOP_ROOT, + runId, + prUrl, + headSha, + now = new Date(), + githubApi = defaultGitHubApi, +} = {}) { + const normalizedRunId = assertRunId(runId) + const runFile = path.join(runDirectory(loopRoot, normalizedRunId), 'run.json') + const run = await readJson(runFile) + if (run.finishedAt !== null || run.status !== 'running') { + throw new Error('draft PR publication requires a running run') + } + const issueTarget = parseGitHubTarget(run.issueUrl) + const pullTarget = parseGitHubTarget(prUrl) + if (!pullTarget || pullTarget.kind !== 'pull' || !sameRepository(issueTarget, pullTarget)) { + throw new Error('prUrl must identify a pull request in the issue repository') + } + const livePullRequest = await githubApi( + `repos/${pullTarget.owner}/${pullTarget.repo}/pulls/${pullTarget.number}`, + ) + if ( + livePullRequest.state !== 'open' || + livePullRequest.draft !== true || + livePullRequest.base?.ref !== 'dev' || + livePullRequest.head?.ref !== run.branch || + livePullRequest.head?.repo?.full_name?.toLowerCase() !== + `${pullTarget.owner}/${pullTarget.repo}`.toLowerCase() || + livePullRequest.head?.sha !== headSha + ) { + throw new Error('record-pr requires a live draft PR to dev at the exact run branch and headSha') + } + const updated = { ...run, prUrl, headSha } + await writeJson(runFile, updated) + await appendValidatedEvent({ + loopRoot, + runId: normalizedRunId, + type: 'pr_published', + status: 'draft', + payload: { prUrl, headSha, baseBranch: 'dev', branch: run.branch }, + now, + }) + return updated +} + export async function appendValidatedEvent({ loopRoot = DEFAULT_LOOP_ROOT, runId, @@ -188,9 +285,14 @@ export async function transitionRun({ if (run.finishedAt !== null) throw new Error(`run is already finalized: ${normalizedRunId}`) if (run.status === status) throw new Error(`run already has status: ${status}`) const events = await readEvents(loopRoot, normalizedRunId) + if (!ALLOWED_TRANSITIONS.get(run.status)?.has(status)) { + throw new Error(`invalid run status transition: ${run.status} -> ${status}`) + } if (status === 'awaiting_owner_review') { - if (!prUrl || !headSha) throw new Error('awaiting_owner_review requires prUrl and headSha') + if (!prUrl || !headSha || run.prUrl !== prUrl || run.headSha !== headSha) { + throw new Error('awaiting_owner_review requires the recorded PR URL and headSha') + } const issueTarget = parseGitHubTarget(run.issueUrl) const pullRequestTarget = parseGitHubTarget(prUrl) if ( @@ -206,6 +308,18 @@ export async function transitionRun({ if (!hasPassedEventForHead(events, 'review_completed', headSha)) { throw new Error('awaiting_owner_review requires passed review_completed for headSha') } + const ownerNotification = events.findLast( + (event) => + event.type === 'owner_notified' && + event.status === 'delivered' && + event.payload?.notificationType === 'pr_ready_for_review' && + event.payload?.delivery?.github === 'delivered' && + event.payload?.targetUrl === prUrl && + event.payload?.headSha === headSha, + ) + if (!ownerNotification) { + throw new Error('awaiting_owner_review requires a delivered GitHub owner notification') + } const livePullRequest = await githubApi( `repos/${pullRequestTarget.owner}/${pullRequestTarget.repo}/pulls/${pullRequestTarget.number}`, ) @@ -214,6 +328,8 @@ export async function transitionRun({ livePullRequest.draft !== false || livePullRequest.base?.ref !== 'dev' || livePullRequest.head?.ref !== run.branch || + livePullRequest.head?.repo?.full_name?.toLowerCase() !== + `${pullRequestTarget.owner}/${pullRequestTarget.repo}`.toLowerCase() || livePullRequest.head?.sha !== headSha ) { throw new Error('awaiting_owner_review requires a live ready PR to dev at the exact headSha') @@ -250,6 +366,18 @@ export async function transitionRun({ } if (['failed', 'blocked'].includes(status)) { assertNonEmpty(failureFingerprint, 'failureFingerprint') + const requiredType = status === 'failed' ? 'loop_failed' : 'blocked' + if ( + !events.some( + (event) => + event.type === 'owner_notified' && + event.status === 'delivered' && + event.payload?.notificationType === requiredType && + event.payload?.delivery?.github === 'delivered', + ) + ) { + throw new Error(`${status} requires a delivered GitHub ${requiredType} notification`) + } } const transitioned = { @@ -300,6 +428,10 @@ export async function transitionRun({ failureFingerprint, }) await updateEvolveMetrics({ loopRoot, status, failureFingerprint, now }) + await rm(path.join(loopRoot, 'logs', 'claims', `issue-${run.issueNumber}`), { + recursive: true, + force: true, + }) return transitioned } diff --git a/loops/issue-dev-loop/scripts/lib/validation.mjs b/loops/issue-dev-loop/scripts/lib/validation.mjs index 53a4f9ae..a14c8827 100644 --- a/loops/issue-dev-loop/scripts/lib/validation.mjs +++ b/loops/issue-dev-loop/scripts/lib/validation.mjs @@ -38,6 +38,7 @@ export async function validateLoop({ loopRoot = DEFAULT_LOOP_ROOT } = {}) { 'scripts/lib/evidence.mjs', 'scripts/lib/evolve.mjs', 'scripts/lib/github.mjs', + 'scripts/lib/issue-claim.mjs', 'scripts/lib/notifications.mjs', 'scripts/lib/owner-gate.mjs', 'scripts/lib/run-store.mjs', @@ -61,6 +62,8 @@ export async function validateLoop({ loopRoot = DEFAULT_LOOP_ROOT } = {}) { if ( typeof channel.ownerGitHubLogin !== 'string' || !Object.hasOwn(channel, 'automationGitHubLogin') || + !Object.hasOwn(channel, 'reviewerGitHubLogin') || + channel.repository !== 'codeacme17/echo-ui' || !Array.isArray(channel.immediateTypes) ) { throw new Error('owner channel is missing identity or immediate notification configuration') @@ -76,6 +79,19 @@ export async function validateLoop({ loopRoot = DEFAULT_LOOP_ROOT } = {}) { if (!(await pathExists(evidenceWorkflow))) { throw new Error('missing .github/workflows/issue-dev-loop-evidence.yml') } + const codexConfig = await readFile( + path.resolve(loopRoot, '..', '..', '.codex', 'config.toml'), + 'utf8', + ) + for (const role of [ + 'echo_ui_pr_reviewer', + 'echo_ui_review_adjudicator', + 'echo_ui_loop_evolver', + ]) { + if (!codexConfig.includes(`[agents.${role}]`) || !codexConfig.includes('config_file =')) { + throw new Error(`Codex role is not registered through config_file: ${role}`) + } + } const contract = await readFile(path.join(loopRoot, 'LOOP.md'), 'utf8') const skill = await readFile(path.join(loopRoot, 'SKILL.md'), 'utf8') @@ -92,6 +108,7 @@ export async function validateLoop({ loopRoot = DEFAULT_LOOP_ROOT } = {}) { '$implement', 'echo_ui_pr_reviewer', 'echo_ui_loop_evolver', + 'record-pr', 'record-evidence', 'pnpm verify', ]) { diff --git a/loops/issue-dev-loop/scripts/loopctl.mjs b/loops/issue-dev-loop/scripts/loopctl.mjs index 10e20818..45dd6a4a 100644 --- a/loops/issue-dev-loop/scripts/loopctl.mjs +++ b/loops/issue-dev-loop/scripts/loopctl.mjs @@ -10,6 +10,7 @@ import { observeOwnerMerge, parseArguments, recordEvidence, + recordPullRequest, recordReview, startRun, transitionRun, @@ -80,6 +81,15 @@ async function main() { }), ) break + case 'record-pr': + output( + await recordPullRequest({ + runId: args['run-id'], + prUrl: args['pr-url'], + headSha: args['head-sha'], + }), + ) + break case 'record-review': output( await recordReview({ @@ -132,7 +142,7 @@ async function main() { break default: throw new Error( - 'usage: loopctl.mjs [options]', + 'usage: loopctl.mjs [options]', ) } } diff --git a/loops/issue-dev-loop/scripts/runtime.mjs b/loops/issue-dev-loop/scripts/runtime.mjs index 607096ec..eef65675 100644 --- a/loops/issue-dev-loop/scripts/runtime.mjs +++ b/loops/issue-dev-loop/scripts/runtime.mjs @@ -3,5 +3,12 @@ export { completeEvolve, getEvolveStatus } from './lib/evolve.mjs' export { recordEvidence, recordReview } from './lib/evidence.mjs' export { detectWork, observeOwnerMerge, selectIssue } from './lib/github.mjs' export { createNotification } from './lib/notifications.mjs' -export { appendEvent, finalizeRun, makeRunId, startRun, transitionRun } from './lib/run-store.mjs' +export { + appendEvent, + finalizeRun, + makeRunId, + recordPullRequest, + startRun, + transitionRun, +} from './lib/run-store.mjs' export { validateLoop } from './lib/validation.mjs' diff --git a/loops/issue-dev-loop/state.md b/loops/issue-dev-loop/state.md index 68a42354..0eb81f2a 100644 --- a/loops/issue-dev-loop/state.md +++ b/loops/issue-dev-loop/state.md @@ -22,7 +22,7 @@ None. ## Blockers -- Configure the GitHub authentication used by unattended runs and set its exact login in `automationGitHubLogin`. +- Configure distinct unattended executor and reviewer GitHub identities and set their exact logins in `automationGitHubLogin` and `reviewerGitHubLogin`. - Merge this infrastructure into `dev` before enabling its recurring automation; the PR evidence workflow must exist on the base branch. - Repair or explicitly rebaseline the existing `tests/docs-cutover.test.ts` README contract failure before any issue PR can satisfy authoritative `pnpm verify`. - Choose the recurring Codex automation cadence after the infrastructure PR is merged. diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index e76c91d9..b1c47c99 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -10,11 +10,13 @@ import { fileURLToPath } from 'node:url' import { appendEvent, + completeEvolve, createNotification, finalizeRun, getEvolveStatus, observeOwnerMerge, recordEvidence, + recordPullRequest, recordReview, selectIssue, startRun, @@ -72,6 +74,8 @@ async function createFixture() { schemaVersion: 1, ownerGitHubLogin: 'codeacme17', automationGitHubLogin: 'echo-ui-loop[bot]', + reviewerGitHubLogin: 'echo-ui-reviewer[bot]', + repository: 'codeacme17/echo-ui', webhookEnvironmentVariable: 'TEST_LOOP_WEBHOOK_URL', immediateTypes: [ 'approval_required', @@ -88,6 +92,50 @@ async function createFixture() { return { loopRoot, channelRoot } } +async function startFixtureRun(options) { + return startRun({ ...options, claimIssue: async () => {} }) +} + +function pullRequestFixture(run, headSha, { draft = true, merged = false } = {}) { + return { + state: merged ? 'closed' : 'open', + draft, + merged, + merged_by: merged ? { login: 'codeacme17' } : null, + merge_commit_sha: merged ? '1234567890abcdef' : null, + base: { ref: 'dev', repo: { full_name: 'codeacme17/echo-ui' } }, + head: { ref: run.branch, sha: headSha, repo: { full_name: 'codeacme17/echo-ui' } }, + body: '', + } +} + +async function recordFixturePr({ loopRoot, run, headSha, number = 200 }) { + const prUrl = `https://github.com/codeacme17/echo-ui/pull/${number}` + await recordPullRequest({ + loopRoot, + runId: run.runId, + prUrl, + headSha, + githubApi: async () => pullRequestFixture(run, headSha), + }) + return prUrl +} + +function successfulWorkflowRun(run, headSha, prNumber, runId) { + return { + id: Number(runId), + status: 'completed', + conclusion: 'success', + event: 'pull_request', + head_sha: headSha, + head_branch: run.branch, + path: '.github/workflows/issue-dev-loop-evidence.yml', + pull_requests: [ + { number: prNumber, base: { ref: 'dev', repo: { full_name: 'codeacme17/echo-ui' } } }, + ], + } +} + async function writePassingEvidence({ loopRoot, run, headSha }) { const manifestPath = path.join(loopRoot, 'evidence', run.runId, 'manifest.json') await mkdir(path.dirname(manifestPath), { recursive: true }) @@ -172,7 +220,7 @@ test('selectIssue chooses the highest-priority eligible unclaimed issue', () => test('startRun creates one correlated run, handoff, and evidence directories', async () => { const { loopRoot } = await createFixture() - const result = await startRun({ + const result = await startFixtureRun({ loopRoot, issueNumber: 128, issueTitle: 'Improve Player focus behavior', @@ -191,9 +239,81 @@ test('startRun creates one correlated run, handoff, and evidence directories', a assert.match(events, /"type":"loop_started"/) }) +test('startRun refuses a second active run for the same issue', async () => { + const { loopRoot } = await createFixture() + await startFixtureRun({ + loopRoot, + issueNumber: 128, + issueTitle: 'First claim', + issueUrl: 'https://github.com/codeacme17/echo-ui/issues/128', + entropy: 'first1', + }) + await assert.rejects( + startFixtureRun({ + loopRoot, + issueNumber: 128, + issueTitle: 'Duplicate claim', + issueUrl: 'https://github.com/codeacme17/echo-ui/issues/128', + entropy: 'second2', + }), + /already has an active run/, + ) +}) + +test('recordEvidence rejects failed workflow runs and mismatched artifact manifests', async () => { + const { loopRoot } = await createFixture() + const { run } = await startFixtureRun({ + loopRoot, + issueNumber: 134, + issueTitle: 'Artifact attestation', + issueUrl: 'https://github.com/codeacme17/echo-ui/issues/134', + entropy: 'art134', + }) + const headSha = 'abcdef1234567' + await recordFixturePr({ loopRoot, run, headSha, number: 301 }) + const manifestPath = await writePassingEvidence({ loopRoot, run, headSha }) + const manifestSource = await readFile(manifestPath, 'utf8') + const artifact = { + id: 900, + name: `issue-dev-loop-${run.runId}-${headSha}`, + expired: false, + workflow_run: { id: 800, head_sha: headSha }, + } + const githubApi = async (endpoint) => + endpoint.includes('/actions/artifacts/') + ? artifact + : { ...successfulWorkflowRun(run, headSha, 301, 800), conclusion: 'failure' } + + await assert.rejects( + recordEvidence({ + loopRoot, + runId: run.runId, + manifestPath, + publicationUrl: 'https://github.com/codeacme17/echo-ui/actions/runs/800/artifacts/900', + githubApi, + artifactManifestLoader: async () => manifestSource, + }), + /artifact metadata does not match/, + ) + await assert.rejects( + recordEvidence({ + loopRoot, + runId: run.runId, + manifestPath, + publicationUrl: 'https://github.com/codeacme17/echo-ui/actions/runs/800/artifacts/900', + githubApi: async (endpoint) => + endpoint.includes('/actions/artifacts/') + ? artifact + : successfulWorkflowRun(run, headSha, 301, 800), + artifactManifestLoader: async () => `${manifestSource} `, + }), + /does not match the published artifact manifest/, + ) +}) + test('CI helpers resolve a run and generate exact-head screenshot evidence', async () => { const { loopRoot } = await createFixture() - const { run } = await startRun({ + const { run } = await startFixtureRun({ loopRoot, issueNumber: 128, issueTitle: 'Capture Player UI', @@ -251,7 +371,7 @@ test('CI helpers resolve a run and generate exact-head screenshot evidence', asy test('owner-ready transition requires verification and review but remains resumable', async () => { const { loopRoot } = await createFixture() - const { run } = await startRun({ + const { run } = await startFixtureRun({ loopRoot, issueNumber: 129, issueTitle: 'Add keyboard test', @@ -259,67 +379,34 @@ test('owner-ready transition requires verification and review but remains resuma now: new Date('2026-07-22T16:00:00Z'), entropy: 'abc123', }) - - await assert.rejects( - transitionRun({ - loopRoot, - runId: run.runId, - status: 'awaiting_owner_review', - prUrl: 'https://github.com/codeacme17/echo-ui/pull/200', - headSha: 'abcdef1234567', - }), - /passed verification_completed/, - ) - - const staleManifest = await writePassingEvidence({ - loopRoot, - run, - headSha: '0000000123456', - }) - await recordEvidence({ - loopRoot, - runId: run.runId, - manifestPath: staleManifest, - publicationUrl: 'https://github.com/codeacme17/echo-ui/actions/runs/100/artifacts/200', - githubApi: async () => ({ - id: 200, - name: `issue-dev-loop-${run.runId}-0000000123456`, - expired: false, - workflow_run: { id: 100, head_sha: '0000000123456' }, - }), - }) - await assert.rejects( - transitionRun({ - loopRoot, - runId: run.runId, - status: 'awaiting_owner_review', - prUrl: 'https://github.com/codeacme17/echo-ui/pull/200', - headSha: 'abcdef1234567', - }), - /for headSha/, - ) - + const headSha = 'abcdef1234567' + const prUrl = await recordFixturePr({ loopRoot, run, headSha, number: 200 }) const manifestPath = await writePassingEvidence({ loopRoot, run, - headSha: 'abcdef1234567', + headSha, }) + const manifestSource = await readFile(manifestPath, 'utf8') await recordEvidence({ loopRoot, runId: run.runId, manifestPath, publicationUrl: 'https://github.com/codeacme17/echo-ui/actions/runs/101/artifacts/201', - githubApi: async () => ({ - id: 201, - name: `issue-dev-loop-${run.runId}-abcdef1234567`, - expired: false, - workflow_run: { id: 101, head_sha: 'abcdef1234567' }, - }), + githubApi: async (endpoint) => + endpoint.includes('/actions/artifacts/') + ? { + id: 201, + name: `issue-dev-loop-${run.runId}-${headSha}`, + expired: false, + workflow_run: { id: 101, head_sha: headSha }, + } + : successfulWorkflowRun(run, headSha, 200, 101), + artifactManifestLoader: async () => manifestSource, }) const reviewPath = await writePassingReview({ loopRoot, run, - headSha: 'abcdef1234567', + headSha, }) const reviewDigest = createHash('sha256') .update(await readFile(reviewPath, 'utf8')) @@ -328,16 +415,17 @@ test('owner-ready transition requires verification and review but remains resuma loopRoot, runId: run.runId, resultPath: reviewPath, - reviewUrl: 'https://github.com/codeacme17/echo-ui/pull/200#pullrequestreview-300', - githubApi: async (endpoint) => - endpoint.includes('/comments?') - ? [] - : { - commit_id: 'abcdef1234567', - state: 'COMMENTED', - user: { login: 'echo-ui-loop[bot]' }, - body: `PASS\n\n`, - }, + reviewUrl: `${prUrl}#pullrequestreview-300`, + githubApi: async (endpoint) => { + if (endpoint.includes('/comments?')) return [] + if (endpoint.endsWith('/pulls/200')) return pullRequestFixture(run, headSha) + return { + commit_id: headSha, + state: 'COMMENTED', + user: { login: 'echo-ui-reviewer[bot]' }, + body: `PASS\n\n`, + } + }, }) await assert.rejects( @@ -345,13 +433,35 @@ test('owner-ready transition requires verification and review but remains resuma loopRoot, runId: run.runId, status: 'awaiting_owner_review', - prUrl: 'https://github.com/codeacme17/echo-ui/pull/200', - headSha: 'abcdef1234567', + prUrl, + headSha, + }), + /invalid run status transition/, + ) + await createNotification({ + loopRoot, + runId: run.runId, + type: 'pr_ready_for_review', + summary: 'Exact-head checks and independent review passed', + requestedAction: 'Review and merge or request changes', + targetUrl: prUrl, + evidenceUrl: 'https://github.com/codeacme17/echo-ui/actions/runs/101/artifacts/201', + blocking: true, + githubComment: async () => {}, + }) + + await assert.rejects( + transitionRun({ + loopRoot, + runId: run.runId, + status: 'awaiting_owner_review', + prUrl, + headSha, githubApi: async () => ({ state: 'open', draft: false, base: { ref: 'main' }, - head: { ref: run.branch, sha: 'abcdef1234567' }, + head: { ref: run.branch, sha: headSha, repo: { full_name: 'codeacme17/echo-ui' } }, }), }), /live ready PR to dev/, @@ -361,14 +471,14 @@ test('owner-ready transition requires verification and review but remains resuma loopRoot, runId: run.runId, status: 'awaiting_owner_review', - prUrl: 'https://github.com/codeacme17/echo-ui/pull/200', - headSha: 'abcdef1234567', + prUrl, + headSha, now: new Date('2026-07-22T17:00:00Z'), githubApi: async () => ({ state: 'open', draft: false, base: { ref: 'dev' }, - head: { ref: run.branch, sha: 'abcdef1234567' }, + head: { ref: run.branch, sha: headSha, repo: { full_name: 'codeacme17/echo-ui' } }, }), }) assert.equal(paused.status, 'awaiting_owner_review') @@ -395,19 +505,35 @@ test('owner-ready transition requires verification and review but remains resuma { user: { login: 'codeacme17' }, state: 'APPROVED', - commit_id: 'abcdef1234567', + commit_id: headSha, }, ] : { merged: true, merged_by: { login: 'someone-else' }, - head: { sha: 'abcdef1234567' }, + ...pullRequestFixture(run, headSha, { draft: false, merged: true }), + merged_by: { login: 'someone-else' }, merge_commit_sha: '1234567890abcdef', }, }), /not approved and merged by the configured owner/, ) + await assert.rejects( + observeOwnerMerge({ + loopRoot, + runId: run.runId, + githubApi: async (endpoint) => + endpoint.includes('/reviews') + ? [{ user: { login: 'codeacme17' }, state: 'APPROVED', commit_id: headSha }] + : { + ...pullRequestFixture(run, headSha, { draft: false, merged: true }), + base: { ref: 'main', repo: { full_name: 'codeacme17/echo-ui' } }, + }, + }), + /not approved and merged by the configured owner/, + ) + const finalized = await observeOwnerMerge({ loopRoot, runId: run.runId, @@ -418,15 +544,13 @@ test('owner-ready transition requires verification and review but remains resuma { user: { login: 'codeacme17' }, state: 'APPROVED', - commit_id: 'abcdef1234567', + commit_id: headSha, }, ] : { - merged: true, - merged_by: { login: 'codeacme17' }, - head: { sha: 'abcdef1234567' }, - merge_commit_sha: '1234567890abcdef', + ...pullRequestFixture(run, headSha, { draft: false, merged: true }), }, + releaseIssueClaim: async () => {}, }) assert.equal(finalized.status, 'completed') assert.equal(finalized.mergeSha, '1234567890abcdef') @@ -435,7 +559,7 @@ test('owner-ready transition requires verification and review but remains resuma test('completed finalization cannot bypass the owner-ready gate', async () => { const { loopRoot } = await createFixture() - const { run } = await startRun({ + const { run } = await startFixtureRun({ loopRoot, issueNumber: 130, issueTitle: 'Owner gate', @@ -449,13 +573,13 @@ test('completed finalization cannot bypass the owner-ready gate', async () => { status: 'completed', mergeSha: '1234567890abcdef', }), - /owner-ready PR/, + /invalid run status transition/, ) }) test('review gate verifies published findings and classified replies', async () => { const { loopRoot } = await createFixture() - const { run } = await startRun({ + const { run } = await startFixtureRun({ loopRoot, issueNumber: 133, issueTitle: 'Review response proof', @@ -463,6 +587,7 @@ test('review gate verifies published findings and classified replies', async () entropy: 'rev133', }) const headSha = 'fedcba9876543' + await recordFixturePr({ loopRoot, run, headSha, number: 300 }) const resultPath = path.join(loopRoot, 'logs', 'runs', run.runId, 'review-result.json') await writeFile( resultPath, @@ -513,13 +638,14 @@ test('review gate verifies published findings and classified replies', async () if (endpoint.includes('/issues/comments/400')) { return { user: { login: 'echo-ui-loop[bot]' }, - body: `Rejected with proof.\n`, + body: `Rejected with proof. Reproduction command exits successfully.\n`, } } + if (endpoint.endsWith('/pulls/300')) return pullRequestFixture(run, headSha) return { commit_id: headSha, state: 'COMMENTED', - user: { login: 'echo-ui-loop[bot]' }, + user: { login: 'echo-ui-reviewer[bot]' }, body: `RVW-1-1\n`, } }, @@ -528,9 +654,71 @@ test('review gate verifies published findings and classified replies', async () assert.equal(recorded.rounds, 2) }) +test('review gate rejects unilateral P0 or P1 rejection without adjudication', async () => { + const { loopRoot } = await createFixture() + const { run } = await startFixtureRun({ + loopRoot, + issueNumber: 135, + issueTitle: 'High severity review dispute', + issueUrl: 'https://github.com/codeacme17/echo-ui/issues/135', + entropy: 'rev135', + }) + const headSha = 'fedcba9876543' + await recordFixturePr({ loopRoot, run, headSha, number: 302 }) + const resultPath = path.join(loopRoot, 'logs', 'runs', run.runId, 'review-result.json') + await writeFile( + resultPath, + `${JSON.stringify({ + schemaVersion: 1, + runId: run.runId, + reviewerAgent: 'echo_ui_pr_reviewer', + freshContext: true, + headSha, + verdict: 'PASS', + rounds: [ + { + round: 1, + headSha, + verdict: 'CHANGES_REQUESTED', + findings: [ + { + findingId: 'RVW-1-1', + severity: 'P1', + confidence: 'high', + headSha, + problem: 'Potential public API break', + evidence: 'The export changed.', + expectedResolution: 'Restore compatibility or adjudicate.', + resolution: { + classification: 'rejected', + responseUrl: 'https://github.com/codeacme17/echo-ui/pull/302#issuecomment-401', + evidence: 'Executor disagrees.', + }, + }, + ], + }, + { round: 2, headSha, verdict: 'PASS', findings: [] }, + ], + })}\n`, + 'utf8', + ) + await assert.rejects( + recordReview({ + loopRoot, + runId: run.runId, + resultPath, + reviewUrl: 'https://github.com/codeacme17/echo-ui/pull/302#pullrequestreview-501', + githubApi: async () => { + throw new Error('GitHub should not be queried before local adjudication validation') + }, + }), + /adjudicationUrl/, + ) +}) + test('notification dry-run is auditable but never counts as owner delivery', async () => { const { loopRoot, channelRoot } = await createFixture() - const { run } = await startRun({ + const { run } = await startFixtureRun({ loopRoot, issueNumber: 131, issueTitle: 'Notify owner', @@ -540,10 +728,10 @@ test('notification dry-run is auditable but never counts as owner delivery', asy const notification = await createNotification({ loopRoot, runId: run.runId, - type: 'pr_ready_for_review', - summary: 'PR is verified and ready', - requestedAction: 'Review and merge or request changes', - targetUrl: 'https://github.com/codeacme17/echo-ui/pull/201', + type: 'clarification_required', + summary: 'Acceptance criterion is ambiguous', + requestedAction: 'Clarify expected keyboard behavior', + targetUrl: run.issueUrl, blocking: true, dryRun: true, now: new Date('2026-07-22T18:00:00Z'), @@ -571,7 +759,7 @@ test('notification dry-run is auditable but never counts as owner delivery', asy test('failed blocking delivery still pauses for the owner', async () => { const { loopRoot } = await createFixture() - const { run } = await startRun({ + const { run } = await startFixtureRun({ loopRoot, issueNumber: 132, issueTitle: 'Needs clarification', @@ -603,13 +791,23 @@ test('failed blocking delivery still pauses for the owner', async () => { test('three matching failures make a fresh evolve session due', async () => { const { loopRoot } = await createFixture() for (let issueNumber = 201; issueNumber <= 203; issueNumber += 1) { - const { run } = await startRun({ + const { run } = await startFixtureRun({ loopRoot, issueNumber, issueTitle: `Failure ${issueNumber}`, issueUrl: `https://github.com/codeacme17/echo-ui/issues/${issueNumber}`, entropy: `fail${issueNumber}`, }) + await createNotification({ + loopRoot, + runId: run.runId, + type: 'blocked', + summary: 'Browser verification environment is unavailable', + requestedAction: 'Restore the verification environment', + targetUrl: run.issueUrl, + blocking: true, + githubComment: async () => {}, + }) await finalizeRun({ loopRoot, runId: run.runId, @@ -623,6 +821,57 @@ test('three matching failures make a fresh evolve session due', async () => { assert.match(metrics.pendingRequestId, /^EVL-/) }) +test('evolve completion rejects an unrelated historical owner-merged PR', async () => { + const { loopRoot } = await createFixture() + const requestId = 'EVL-20260722T120000-ABC123' + const metricsPath = path.join(loopRoot, 'evolve', 'metrics.json') + const metrics = JSON.parse(await readFile(metricsPath, 'utf8')) + await writeFile( + metricsPath, + `${JSON.stringify({ ...metrics, evolveDue: true, pendingRequestId: requestId })}\n`, + 'utf8', + ) + await writeFile( + path.join(loopRoot, 'evolve', 'requests', `${requestId}.json`), + `${JSON.stringify({ + schemaVersion: 1, + requestId, + status: 'pending', + reason: 'ten_finalized_runs', + requestedAt: '2026-07-22T12:00:00.000Z', + finalizedRunCount: 10, + })}\n`, + 'utf8', + ) + await assert.rejects( + completeEvolve({ + loopRoot, + requestId, + summary: 'Improve trigger batching', + prUrl: 'https://github.com/codeacme17/echo-ui/pull/99', + githubApi: async (endpoint) => + endpoint.includes('/reviews') + ? [ + { + user: { login: 'codeacme17' }, + state: 'APPROVED', + commit_id: 'abcdef1234567', + }, + ] + : { + merged: true, + merged_by: { login: 'codeacme17' }, + merge_commit_sha: '1234567890abcdef', + created_at: '2026-07-20T00:00:00.000Z', + body: 'Unrelated change', + base: { ref: 'dev', repo: { full_name: 'codeacme17/echo-ui' } }, + head: { ref: 'feature/unrelated', sha: 'abcdef1234567' }, + }, + }), + /not approved and merged by the configured owner/, + ) +}) + test('repository loop package satisfies its structural invariants', async () => { const result = await validateLoop({ loopRoot: repositoryLoopRoot }) assert.equal(result.valid, true) From 20a4e5cc6f5338c2a715bf8a611e0f7faaf33c45 Mon Sep 17 00:00:00 2001 From: leyoonafr Date: Wed, 22 Jul 2026 23:15:00 +0800 Subject: [PATCH 05/41] fix: harden loop lifecycle provenance --- .codex/agents/echo-ui-loop-evolver.toml | 2 + .codex/agents/echo-ui-pr-reviewer.toml | 2 + .codex/agents/echo-ui-review-adjudicator.toml | 2 + .github/workflows/issue-dev-loop-evidence.yml | 5 + loops/_shared/owner-channel/CHANNEL.md | 2 + loops/issue-dev-loop/LOOP.md | 8 +- loops/issue-dev-loop/SKILL.md | 9 +- .../references/evidence-policy.md | 2 +- .../references/github-operations.md | 5 +- .../issue-dev-loop/review/comment.schema.json | 2 +- .../issue-dev-loop/review/result.schema.json | 11 +- .../schemas/evidence.schema.json | 27 +- .../schemas/implementation-result.schema.json | 40 ++ loops/issue-dev-loop/schemas/run.schema.json | 30 +- .../scripts/generate-evidence.mjs | 112 +++- loops/issue-dev-loop/scripts/lib/common.mjs | 23 +- loops/issue-dev-loop/scripts/lib/evidence.mjs | 74 ++- loops/issue-dev-loop/scripts/lib/github.mjs | 158 +++++- .../scripts/lib/issue-claim.mjs | 51 +- .../scripts/lib/notifications.mjs | 46 +- .../issue-dev-loop/scripts/lib/owner-gate.mjs | 2 +- .../issue-dev-loop/scripts/lib/run-store.mjs | 267 +++++++++- .../issue-dev-loop/scripts/lib/validation.mjs | 15 + loops/issue-dev-loop/scripts/loopctl.mjs | 25 +- loops/issue-dev-loop/scripts/runtime.mjs | 5 +- .../scripts/validate-history.mjs | 22 + .../templates/implementation-brief.md | 6 + loops/issue-dev-loop/templates/pr-body.md | 2 + loops/issue-dev-loop/tests/runtime.test.mjs | 477 +++++++++++++++++- 29 files changed, 1311 insertions(+), 121 deletions(-) create mode 100644 loops/issue-dev-loop/schemas/implementation-result.schema.json create mode 100644 loops/issue-dev-loop/scripts/validate-history.mjs diff --git a/.codex/agents/echo-ui-loop-evolver.toml b/.codex/agents/echo-ui-loop-evolver.toml index e22de7b3..78e0cbdb 100644 --- a/.codex/agents/echo-ui-loop-evolver.toml +++ b/.codex/agents/echo-ui-loop-evolver.toml @@ -1,3 +1,5 @@ +name = "echo_ui_loop_evolver" +description = "Fresh-context evaluator that improves Echo UI loops from measured run history." sandbox_mode = "workspace-write" developer_instructions = """ Read the pending evolve request, LOOP.md, state.md, compact run summaries, metrics, diff --git a/.codex/agents/echo-ui-pr-reviewer.toml b/.codex/agents/echo-ui-pr-reviewer.toml index 7dba085a..1896b600 100644 --- a/.codex/agents/echo-ui-pr-reviewer.toml +++ b/.codex/agents/echo-ui-pr-reviewer.toml @@ -1,3 +1,5 @@ +name = "echo_ui_pr_reviewer" +description = "Fresh-context, read-only reviewer for Echo UI issue pull requests." sandbox_mode = "read-only" developer_instructions = """ Review the supplied immutable base/head diff as an independent repository owner. diff --git a/.codex/agents/echo-ui-review-adjudicator.toml b/.codex/agents/echo-ui-review-adjudicator.toml index 959a6203..3b41293a 100644 --- a/.codex/agents/echo-ui-review-adjudicator.toml +++ b/.codex/agents/echo-ui-review-adjudicator.toml @@ -1,3 +1,5 @@ +name = "echo_ui_review_adjudicator" +description = "Read-only adjudicator for disputed high-severity Echo UI review findings." sandbox_mode = "read-only" developer_instructions = """ Independently adjudicate a disputed P0 or P1 review finding. Read only the issue diff --git a/.github/workflows/issue-dev-loop-evidence.yml b/.github/workflows/issue-dev-loop-evidence.yml index d19bde55..e88dbcf7 100644 --- a/.github/workflows/issue-dev-loop-evidence.yml +++ b/.github/workflows/issue-dev-loop-evidence.yml @@ -21,6 +21,7 @@ jobs: uses: actions/checkout@v6 with: ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 - name: Resolve loop run id: run @@ -30,6 +31,10 @@ jobs: if: steps.run.outputs.has_run == 'true' run: test "$(git rev-parse HEAD)" = "${{ github.event.pull_request.head.sha }}" + - name: Protect append-only loop history + if: steps.run.outputs.has_run == 'true' + run: node loops/issue-dev-loop/scripts/validate-history.mjs --base-ref origin/dev + - name: Set up pnpm if: steps.run.outputs.has_run == 'true' uses: pnpm/action-setup@v6 diff --git a/loops/_shared/owner-channel/CHANNEL.md b/loops/_shared/owner-channel/CHANNEL.md index d524cd7a..3ad1a9bc 100644 --- a/loops/_shared/owner-channel/CHANNEL.md +++ b/loops/_shared/owner-channel/CHANNEL.md @@ -8,6 +8,8 @@ GitHub issue and PR comments are the canonical, auditable channel. The runtime m `--dry-run` stages a simulated payload only. It never records `owner_notified`, never pauses the run, and never satisfies a blocking delivery gate. +Each blocking GitHub notification prints a unique resume instruction. To continue after answering, include `RESUME ` in a normal issue/PR comment; submitting a GitHub `CHANGES_REQUESTED` review is also an explicit response. The runtime verifies author, target, timestamp, successful delivery, and response URL before resuming. Silence and unrelated comments never count. + ## Runtime setup 1. Authenticate the unattended executor and fresh reviewer with distinct GitHub identities; set their exact logins as `automationGitHubLogin` and `reviewerGitHubLogin` in `channel.json`. diff --git a/loops/issue-dev-loop/LOOP.md b/loops/issue-dev-loop/LOOP.md index 50d444d0..4840292c 100644 --- a/loops/issue-dev-loop/LOOP.md +++ b/loops/issue-dev-loop/LOOP.md @@ -70,11 +70,11 @@ Create `codex/issue-` from the current `origin/dev` and use an isolated ### 3. Freeze the implementation brief -Complete the generated handoff with acceptance criteria, scope, TDD seams, required checks, UI evidence, and stop conditions. After implementation begins, changes to the brief require a logged reason and, for material scope changes, owner confirmation. +Complete the generated handoff with acceptance criteria, scope, TDD seams, required checks, UI evidence, and stop conditions, then run `freeze-brief`. Its SHA-256 digest becomes immutable for the run; later implementation, PR, and CI evidence gates reject a changed handoff. Owner feedback and review repairs are supplemental handoffs linked from new `$implement` results rather than edits to the frozen brief. ### 4. Implement -Explicitly invoke `$implement`. The orchestrator does not write product code. `$implement` owns TDD at agreed seams, implementation, regular typechecking and targeted tests, the final full suite, `$code-review`, and a local commit. It must not push, create a PR, or merge. +Explicitly invoke `$implement`. The orchestrator does not write product code. `$implement` owns TDD at agreed seams, implementation, regular typechecking and targeted tests, the final full suite, `$code-review`, and a local commit. It must not push, create a PR, or merge. Every invocation writes a unique schema-validated result with its invocation ID, timestamps, frozen brief digest, passed checks, and a new commit descending from the prior implementation commit (or the frozen base SHA for the first invocation); record it before PR publication or update. ### 5. Verify before publication @@ -94,7 +94,7 @@ After exact-head CI and independent review pass, download the CI manifest and re ### 9. Owner feedback -When the owner requests changes, snapshot the comments, create a new handoff, invoke `$implement`, reverify, rerun fresh review, reply with commit and evidence, and notify the owner that the PR is ready again. +When the owner requests changes, verify the owner-authored GitHub comment or `CHANGES_REQUESTED` review with `record-owner-response`; ordinary comments must include the notification's exact `RESUME ` token. Only after that gate may the run return to `running`. Snapshot the comments, create a supplemental handoff, invoke `$implement`, record its new result, rebind the PR at its new head, reverify, rerun fresh review, reply with commit and evidence, and notify the owner that the PR is ready again. ### 10. Complete @@ -121,6 +121,8 @@ Never log secrets, full environment dumps, cookies, auth headers, private user d GitHub issue/PR comments are the canonical communication record. The shared owner channel may mirror notifications to a webhook. The notification runtime automatically transitions blocking events to `waiting_for_owner`; delivery failure is itself a blocker. `pr_ready_for_review` moves from that pause to `awaiting_owner_review` only after its SHA-bound evidence gates pass. +A paused run resumes only after successful canonical GitHub delivery and a new, run-bound owner decision. The notification tells the owner to include `RESUME ` in a normal reply; a GitHub request-changes review is accepted without that token. An unrelated, stale, wrong-author, wrong-target, or pre-delivery comment never unlocks the run. + Notify immediately for `approval_required`, `clarification_required`, `blocked`, `review_dispute`, `pr_ready_for_review`, `pr_updated_for_review`, and `loop_failed`. Routine no-work checks belong in a digest, not an interruption. ## Anti-busywork diff --git a/loops/issue-dev-loop/SKILL.md b/loops/issue-dev-loop/SKILL.md index a2a4a4d4..f6d0f0e0 100644 --- a/loops/issue-dev-loop/SKILL.md +++ b/loops/issue-dev-loop/SKILL.md @@ -15,7 +15,8 @@ Run exactly one bounded issue cycle. Treat [`LOOP.md`](./LOOP.md) as the constit 4. Run the cheap trigger in `triggers/detect-work.mjs`. Exit without invoking an implementation agent when it reports `hasWork: false`. 5. Refuse to start when another active run or PR already claims the issue. 6. Create a `codex/issue-` branch and an isolated worktree from `dev`. -7. Start the run with `loopctl.mjs start` and freeze the generated `implementation-brief.md` before implementation. +7. Start the run with `loopctl.mjs start --issue --title --url <issue-url> --base-sha <full-origin-dev-sha>`. +8. Complete `handoffs/<run-id>/implementation-brief.md`, set `UI evidence required` to `yes` or `no`, then run `loopctl.mjs freeze-brief --run-id <id>` before implementation. Never edit the frozen brief afterward. Read [`references/github-operations.md`](./references/github-operations.md) for GitHub mutations and [`references/evidence-policy.md`](./references/evidence-policy.md) before verification or PR publication. @@ -31,11 +32,11 @@ Provide `$implement` with: - required targeted checks and final `pnpm verify` - an instruction to stop after committing; `$implement` must not push or create a PR -Record the resulting commit SHA and validation summary in the run log. +Require `$implement` to write a unique `logs/runs/<run-id>/implementation-result-<sequence>.json` matching `schemas/implementation-result.schema.json`. It records the invocation ID and time range, frozen brief digest, resulting commit SHA, and passed checks including `pnpm verify`. Then run `loopctl.mjs record-implementation --run-id <id> --result <absolute-path>`. Repeated `$implement` repair invocations use new result files and must advance from the previously recorded implementation commit. ## Publish a draft PR -Push only the issue branch and create a **draft** PR targeting `dev`. Bind it immediately with `loopctl.mjs record-pr --run-id <id> --pr-url <url> --head-sha <sha>`. Include the issue, risk assessment, test results, evidence manifest, screenshots, known limitations, run ID, and exact head SHA. Never target `main` from an issue branch. +Push only the issue branch and create a **draft** PR targeting `dev` using `templates/pr-body.md`; preserve its machine-readable run marker and owner-only merge statement. Bind it immediately with `loopctl.mjs record-pr --run-id <id> --pr-url <url> --head-sha <full-sha>`. Include the issue, risk assessment, test results, evidence manifest, screenshots, known limitations, run ID, and exact head SHA. Never target `main` from an issue branch. ## Run independent review @@ -56,6 +57,8 @@ Run verification appropriate to the change and require `pnpm verify` before the The owner is the only actor allowed to approve or merge. Never call `gh pr merge`, enable auto-merge, push to `main`, push to `dev`, dismiss owner feedback, or bypass branch protections. A run becomes `completed` only through `loopctl.mjs observe-owner-merge`, which queries GitHub and requires both `codeacme17`'s approval and merge at the reviewed head SHA. +For any pause, do not resume from silence or an arbitrary comment. First require a successfully delivered blocking notification. Then verify the owner's GitHub decision with `loopctl.mjs record-owner-response --run-id <id> --response-url <comment-or-review-url>`. A normal comment must include the exact `RESUME <run-id>` token printed in the notification; a `CHANGES_REQUESTED` review is itself an explicit decision. Only then may `loopctl.mjs transition --run-id <id> --status running` continue product work. + ## Stop conditions Stop and notify the owner immediately when: diff --git a/loops/issue-dev-loop/references/evidence-policy.md b/loops/issue-dev-loop/references/evidence-policy.md index 26021b56..10eb871b 100644 --- a/loops/issue-dev-loop/references/evidence-policy.md +++ b/loops/issue-dev-loop/references/evidence-policy.md @@ -20,7 +20,7 @@ Capture only scenarios relevant to the issue: - focus, keyboard, disabled, loading, or error state when affected - video only when a still image cannot demonstrate the interaction -Use descriptive names such as `02-after-player-mobile-375.webp` under `screen-shots/<run-id>`. Record route, viewport, scenario, commit SHA, and capture time in the screenshot manifest. +Use descriptive names such as `02-after-player-mobile-375.webp` under `screen-shots/<run-id>/<phase>`. Record phase, route, viewport, scenario, evidence head SHA, source commit SHA, and capture time in the screenshot manifest. `before` must come from the frozen base SHA, `after` from the exact PR head, and every scenario/route/viewport tuple must have both phases. Captures must be genuine PNG/WebP files of at least 320×200 pixels. ## Storage diff --git a/loops/issue-dev-loop/references/github-operations.md b/loops/issue-dev-loop/references/github-operations.md index 7bda6527..5d19f26a 100644 --- a/loops/issue-dev-loop/references/github-operations.md +++ b/loops/issue-dev-loop/references/github-operations.md @@ -6,7 +6,7 @@ Read this before mutating issues or pull requests. 1. Select only open issues labeled `codex-ready`. 2. Exclude `loop:claimed`, an existing `codex/issue-<number>` branch, and any open PR that references or closes the issue. -3. Let `loopctl start` acquire the local claim, recheck GitHub immediately, and apply `loop:claimed`; do not add the label manually first. +3. Let `loopctl start --base-sha <full-origin-dev-sha>` acquire the local claim, recheck every page of open PRs, apply `loop:claimed`, and capture the authoritative issue; do not add the label manually first. 4. Record the issue snapshot and claim timestamp before implementation. A second active local run for the issue is rejected. ## Branch and PR @@ -15,6 +15,7 @@ Read this before mutating issues or pull requests. - Push only the issue branch. - Create a draft PR with `--base dev`. - Run `loopctl record-pr` immediately so later evidence, review, notification, and merge observations are bound to that exact PR/head. +- Create the body from `templates/pr-body.md`. `record-pr` rejects a body missing the run marker, issue closure, full base/head SHAs, or owner-only merge statement. - Include `Closes #<number>` only when the PR fully satisfies the issue. - Request `codeacme17` only after automated review and verification pass. - Bind every review to immutable base and head SHAs. @@ -47,3 +48,5 @@ Do not enable auto-merge, dismiss owner reviews, resolve disputed owner comments ## Communication Use the originating issue for pre-PR questions and the PR for post-publication questions. Mention `@codeacme17`, include the notification ID and run ID, and link the exact evidence or decision needed. Only decisions from the configured owner identity satisfy an owner gate. + +After a blocking notification, verify the reply URL with `record-owner-response`. Normal comments must include `RESUME <run-id>`; a `CHANGES_REQUESTED` review is an explicit response. The runtime requires that the notification was successfully delivered to the same run target before it accepts the reply. diff --git a/loops/issue-dev-loop/review/comment.schema.json b/loops/issue-dev-loop/review/comment.schema.json index e6ab5f6e..ee220fcd 100644 --- a/loops/issue-dev-loop/review/comment.schema.json +++ b/loops/issue-dev-loop/review/comment.schema.json @@ -16,7 +16,7 @@ "findingId": { "type": "string", "pattern": "^RVW-[0-9]+-[0-9]+$" }, "severity": { "enum": ["P0", "P1", "P2", "P3"] }, "confidence": { "enum": ["high", "medium", "low"] }, - "headSha": { "type": "string", "minLength": 7 }, + "headSha": { "type": "string", "pattern": "^[0-9a-fA-F]{40}$" }, "path": { "type": ["string", "null"] }, "line": { "type": ["integer", "null"], "minimum": 1 }, "problem": { "type": "string", "minLength": 1 }, diff --git a/loops/issue-dev-loop/review/result.schema.json b/loops/issue-dev-loop/review/result.schema.json index 0270f4bd..2ab797fb 100644 --- a/loops/issue-dev-loop/review/result.schema.json +++ b/loops/issue-dev-loop/review/result.schema.json @@ -17,7 +17,7 @@ "runId": { "type": "string", "minLength": 1 }, "reviewerAgent": { "const": "echo_ui_pr_reviewer" }, "freshContext": { "const": true }, - "headSha": { "type": "string", "minLength": 7 }, + "headSha": { "type": "string", "pattern": "^[0-9a-fA-F]{40}$" }, "verdict": { "const": "PASS" }, "rounds": { "type": "array", @@ -29,7 +29,7 @@ "additionalProperties": false, "properties": { "round": { "type": "integer", "minimum": 1, "maximum": 2 }, - "headSha": { "type": "string", "minLength": 7 }, + "headSha": { "type": "string", "pattern": "^[0-9a-fA-F]{40}$" }, "verdict": { "enum": ["PASS", "CHANGES_REQUESTED"] }, "findings": { "type": "array", @@ -50,7 +50,7 @@ "findingId": { "type": "string", "pattern": "^RVW-[0-9]+-[0-9]+$" }, "severity": { "enum": ["P0", "P1", "P2", "P3"] }, "confidence": { "enum": ["high", "medium", "low"] }, - "headSha": { "type": "string", "minLength": 7 }, + "headSha": { "type": "string", "pattern": "^[0-9a-fA-F]{40}$" }, "path": { "type": ["string", "null"] }, "line": { "type": ["integer", "null"], "minimum": 1 }, "problem": { "type": "string", "minLength": 1 }, @@ -66,7 +66,10 @@ }, "responseUrl": { "type": "string", "format": "uri" }, "evidence": { "type": "string", "minLength": 1 }, - "fixCommit": { "type": ["string", "null"] }, + "fixCommit": { + "type": ["string", "null"], + "pattern": "^[0-9a-fA-F]{40}$" + }, "adjudicationUrl": { "type": ["string", "null"], "format": "uri" }, "adjudicationVerdict": { "type": ["string", "null"], diff --git a/loops/issue-dev-loop/schemas/evidence.schema.json b/loops/issue-dev-loop/schemas/evidence.schema.json index 1f87a933..1be6e67b 100644 --- a/loops/issue-dev-loop/schemas/evidence.schema.json +++ b/loops/issue-dev-loop/schemas/evidence.schema.json @@ -17,7 +17,7 @@ "schemaVersion": { "const": 1 }, "runId": { "type": "string", "minLength": 1 }, "issueNumber": { "type": "integer", "minimum": 1 }, - "headSha": { "type": "string", "minLength": 7 }, + "headSha": { "type": "string", "pattern": "^[0-9a-fA-F]{40}$" }, "verdict": { "enum": ["passed", "failed", "blocked"] }, "checks": { "type": "array", @@ -39,13 +39,34 @@ "type": "array", "items": { "type": "object", - "required": ["name", "scenario", "viewport", "path"], + "required": [ + "name", + "phase", + "scenario", + "route", + "viewport", + "path", + "capturedAt", + "headSha", + "sourceSha", + "width", + "height", + "sha256" + ], "additionalProperties": false, "properties": { "name": { "type": "string", "minLength": 1 }, + "phase": { "enum": ["before", "after"] }, "scenario": { "type": "string", "minLength": 1 }, + "route": { "type": "string", "minLength": 1 }, "viewport": { "type": "string", "minLength": 1 }, - "path": { "type": "string", "minLength": 1 } + "path": { "type": "string", "minLength": 1 }, + "capturedAt": { "type": "string", "format": "date-time" }, + "headSha": { "type": "string", "pattern": "^[0-9a-fA-F]{40}$" }, + "sourceSha": { "type": "string", "pattern": "^[0-9a-fA-F]{40}$" }, + "width": { "type": "integer", "minimum": 320, "maximum": 10000 }, + "height": { "type": "integer", "minimum": 200, "maximum": 10000 }, + "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" } } } }, diff --git a/loops/issue-dev-loop/schemas/implementation-result.schema.json b/loops/issue-dev-loop/schemas/implementation-result.schema.json new file mode 100644 index 00000000..c189b7dc --- /dev/null +++ b/loops/issue-dev-loop/schemas/implementation-result.schema.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "$implement result attestation", + "type": "object", + "required": [ + "schemaVersion", + "runId", + "agent", + "invocationId", + "startedAt", + "finishedAt", + "briefDigest", + "commitSha", + "checks" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": 1 }, + "runId": { "type": "string", "minLength": 1 }, + "agent": { "const": "$implement" }, + "invocationId": { "type": "string", "minLength": 1 }, + "startedAt": { "type": "string", "format": "date-time" }, + "finishedAt": { "type": "string", "format": "date-time" }, + "briefDigest": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "commitSha": { "type": "string", "pattern": "^[0-9a-fA-F]{40}$" }, + "checks": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["command", "status"], + "additionalProperties": false, + "properties": { + "command": { "type": "string", "minLength": 1 }, + "status": { "const": "passed" } + } + } + } + } +} diff --git a/loops/issue-dev-loop/schemas/run.schema.json b/loops/issue-dev-loop/schemas/run.schema.json index d6b8910d..b2876f9a 100644 --- a/loops/issue-dev-loop/schemas/run.schema.json +++ b/loops/issue-dev-loop/schemas/run.schema.json @@ -9,9 +9,14 @@ "issueTitle", "issueUrl", "baseBranch", + "baseSha", "branch", "status", - "startedAt" + "startedAt", + "issueSnapshot", + "briefDigest", + "uiEvidenceRequired", + "implementationCommit" ], "additionalProperties": false, "properties": { @@ -21,6 +26,7 @@ "issueTitle": { "type": "string", "minLength": 1 }, "issueUrl": { "type": "string", "format": "uri" }, "baseBranch": { "const": "dev" }, + "baseSha": { "type": "string", "pattern": "^[0-9a-fA-F]{40}$" }, "branch": { "type": "string", "pattern": "^codex/issue-[0-9]+$" }, "status": { "enum": [ @@ -36,7 +42,25 @@ "startedAt": { "type": "string", "format": "date-time" }, "finishedAt": { "type": ["string", "null"], "format": "date-time" }, "prUrl": { "type": ["string", "null"], "format": "uri" }, - "headSha": { "type": ["string", "null"] }, - "mergeSha": { "type": ["string", "null"] } + "headSha": { "type": ["string", "null"], "pattern": "^[0-9a-fA-F]{40}$" }, + "mergeSha": { "type": ["string", "null"], "pattern": "^[0-9a-fA-F]{40}$" }, + "issueSnapshot": { + "type": "object", + "required": ["title", "body", "labels", "url", "capturedAt"], + "additionalProperties": false, + "properties": { + "title": { "type": "string", "minLength": 1 }, + "body": { "type": "string" }, + "labels": { "type": "array", "items": { "type": "string" } }, + "url": { "type": "string", "format": "uri" }, + "capturedAt": { "type": "string", "format": "date-time" } + } + }, + "briefDigest": { "type": ["string", "null"], "pattern": "^[0-9a-f]{64}$" }, + "uiEvidenceRequired": { "type": ["boolean", "null"] }, + "implementationCommit": { + "type": ["string", "null"], + "pattern": "^[0-9a-fA-F]{40}$" + } } } diff --git a/loops/issue-dev-loop/scripts/generate-evidence.mjs b/loops/issue-dev-loop/scripts/generate-evidence.mjs index 35b9db5e..7ec3a354 100644 --- a/loops/issue-dev-loop/scripts/generate-evidence.mjs +++ b/loops/issue-dev-loop/scripts/generate-evidence.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node -import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import { lstat, mkdir, readFile, writeFile } from 'node:fs/promises' import path from 'node:path' import { DEFAULT_LOOP_ROOT, parseArguments } from './runtime.mjs' @@ -16,9 +17,68 @@ if (!['passed', 'failed', 'blocked'].includes(status)) throw new Error('unsuppor const run = JSON.parse( await readFile(path.join(loopRoot, 'logs', 'runs', runId, 'run.json'), 'utf8'), ) +if (!run.briefDigest || !run.implementationCommit || run.headSha !== headSha) { + throw new Error('evidence generation requires the recorded PR head and implementation gates') +} +if (!/^[0-9a-f]{40}$/i.test(headSha)) throw new Error('evidence headSha must be a full Git SHA') +const frozenBrief = await readFile( + path.join(loopRoot, 'handoffs', runId, 'implementation-brief.md'), + 'utf8', +) +if (createHash('sha256').update(frozenBrief).digest('hex') !== run.briefDigest) { + throw new Error('frozen implementation brief changed after freeze-brief') +} const screenshotRoot = path.join(loopRoot, 'screen-shots', runId) const screenshotMetadataPath = path.join(screenshotRoot, 'manifest.json') let screenshots = [] + +function readImageDimensions(contents) { + const isPng = + contents.length >= 24 && + contents.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) && + contents.subarray(12, 16).toString('ascii') === 'IHDR' + if (isPng) { + return { + format: 'png', + width: contents.readUInt32BE(16), + height: contents.readUInt32BE(20), + } + } + const isWebp = + contents.length >= 30 && + contents.subarray(0, 4).toString('ascii') === 'RIFF' && + contents.subarray(8, 12).toString('ascii') === 'WEBP' + if (!isWebp) return null + const chunk = contents.subarray(12, 16).toString('ascii') + if (chunk === 'VP8X') { + return { + format: 'webp', + width: 1 + contents.readUIntLE(24, 3), + height: 1 + contents.readUIntLE(27, 3), + } + } + if ( + chunk === 'VP8 ' && + contents.length >= 30 && + contents.subarray(23, 26).equals(Buffer.from([157, 1, 42])) + ) { + return { + format: 'webp', + width: contents.readUInt16LE(26) & 0x3fff, + height: contents.readUInt16LE(28) & 0x3fff, + } + } + if (chunk === 'VP8L' && contents.length >= 25 && contents[20] === 47) { + return { + format: 'webp', + width: 1 + (((contents[22] & 0x3f) << 8) | contents[21]), + height: + 1 + (((contents[24] & 0x0f) << 10) | (contents[23] << 2) | ((contents[22] & 0xc0) >> 6)), + } + } + return null +} + if (await pathExists(screenshotMetadataPath)) { const metadata = JSON.parse(await readFile(screenshotMetadataPath, 'utf8')) if (!Array.isArray(metadata.screenshots)) @@ -29,6 +89,56 @@ if (await pathExists(screenshotMetadataPath)) { if (!target.startsWith(`${screenshotRoot}${path.sep}`) || !(await pathExists(target))) { throw new Error(`missing or unsafe screenshot path: ${screenshot.path}`) } + const stats = await lstat(target) + if (!stats.isFile() || stats.isSymbolicLink()) { + throw new Error(`screenshot must be a regular file: ${screenshot.path}`) + } + if (!['before', 'after'].includes(screenshot.phase)) { + throw new Error('screenshot phase must be before or after') + } + for (const field of ['name', 'scenario', 'route', 'viewport', 'capturedAt', 'sourceSha']) { + assertNonEmpty(screenshot[field], `screenshot.${field}`) + } + const normalizedScreenshotPath = path.normalize(screenshot.path) + const expectedPathPrefix = path.join('screen-shots', runId, screenshot.phase) + path.sep + const expectedSourceSha = screenshot.phase === 'before' ? run.baseSha : headSha + if ( + path.isAbsolute(screenshot.path) || + !normalizedScreenshotPath.startsWith(expectedPathPrefix) || + screenshot.headSha !== headSha || + screenshot.sourceSha !== expectedSourceSha || + !/^[0-9a-f]{40}$/i.test(screenshot.sourceSha) || + Number.isNaN(Date.parse(screenshot.capturedAt)) + ) { + throw new Error('screenshot metadata must match the PR head and include a capture time') + } + const contents = await readFile(target) + const dimensions = readImageDimensions(contents) + const expectedExtension = dimensions?.format === 'png' ? '.png' : '.webp' + if ( + !dimensions || + path.extname(screenshot.path).toLowerCase() !== expectedExtension || + dimensions.width < 320 || + dimensions.height < 200 || + dimensions.width > 10000 || + dimensions.height > 10000 + ) { + throw new Error(`screenshot is not a meaningful PNG/WebP capture: ${screenshot.path}`) + } + screenshot.width = dimensions.width + screenshot.height = dimensions.height + screenshot.sha256 = createHash('sha256').update(contents).digest('hex') + } +} +if (run.uiEvidenceRequired) { + const pairs = new Map() + for (const screenshot of screenshots) { + const key = `${screenshot.scenario}\u0000${screenshot.route}\u0000${screenshot.viewport}` + if (!pairs.has(key)) pairs.set(key, new Set()) + pairs.get(key).add(screenshot.phase) + } + if (pairs.size === 0 || [...pairs.values()].some((phases) => phases.size !== 2)) { + throw new Error('UI changes require paired before/after screenshots for every scenario') } } diff --git a/loops/issue-dev-loop/scripts/lib/common.mjs b/loops/issue-dev-loop/scripts/lib/common.mjs index e9e11eb6..c8751b81 100644 --- a/loops/issue-dev-loop/scripts/lib/common.mjs +++ b/loops/issue-dev-loop/scripts/lib/common.mjs @@ -125,6 +125,16 @@ export function sameGitHubLogin(left, right) { ) } +export function pullRequestClaimsIssue(pullRequest, issueNumber) { + const headRef = pullRequest.headRefName ?? pullRequest.head?.ref + if (headRef === `codex/issue-${issueNumber}`) return true + const searchable = `${pullRequest.title ?? ''}\n${pullRequest.body ?? ''}` + return new RegExp( + `(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)?\\s*#${issueNumber}(?!\\d)`, + 'i', + ).test(searchable) +} + export function parseGitHubTarget(targetUrl) { if (!targetUrl) return null const parsed = new URL(targetUrl) @@ -155,14 +165,15 @@ export function parseReviewUrl(value) { export function parsePullCommentUrl(value) { const parsed = new URL(value) - const match = parsed.pathname.match(/^\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/) + const match = parsed.pathname.match(/^\/([^/]+)\/([^/]+)\/(pull|issues)\/(\d+)\/?$/) const reviewComment = parsed.hash.match(/^#discussion_r(\d+)$/) const issueComment = parsed.hash.match(/^#issuecomment-(\d+)$/) if (parsed.hostname !== 'github.com' || !match || (!reviewComment && !issueComment)) return null return { owner: match[1], repo: match[2], - number: Number(match[3]), + surface: match[3], + number: Number(match[4]), kind: reviewComment ? 'review_comment' : 'issue_comment', commentId: (reviewComment ?? issueComment)[1], } @@ -172,3 +183,11 @@ export async function defaultGitHubApi(endpoint) { const result = await execFileAsync('gh', ['api', endpoint], { maxBuffer: 1024 * 1024 }) return JSON.parse(result.stdout) } + +export async function defaultGitHubPaginatedApi(endpoint) { + const result = await execFileAsync('gh', ['api', '--paginate', '--slurp', endpoint], { + maxBuffer: 8 * 1024 * 1024, + }) + const pages = JSON.parse(result.stdout) + return pages.flat() +} diff --git a/loops/issue-dev-loop/scripts/lib/evidence.mjs b/loops/issue-dev-loop/scripts/lib/evidence.mjs index 7a073312..2f53d01e 100644 --- a/loops/issue-dev-loop/scripts/lib/evidence.mjs +++ b/loops/issue-dev-loop/scripts/lib/evidence.mjs @@ -77,7 +77,10 @@ function validateReviewEvidence(review, headSha) { const findingIds = new Set() const resolvedFindings = [] for (const [roundIndex, round] of rounds.entries()) { - assertNonEmpty(round.headSha, `review.rounds[${roundIndex}].headSha`) + const roundHeadSha = assertNonEmpty(round.headSha, `review.rounds[${roundIndex}].headSha`) + if (!/^[0-9a-f]{40}$/i.test(roundHeadSha)) { + throw new Error(`review.rounds[${roundIndex}].headSha must be a full Git SHA`) + } if (round.round !== roundIndex + 1 || !['PASS', 'CHANGES_REQUESTED'].includes(round.verdict)) { throw new Error('review rounds must be ordered and have a supported verdict') } @@ -145,7 +148,6 @@ export async function recordEvidence({ const normalizedRunId = assertRunId(runId) const run = await readRun(loopRoot, normalizedRunId) if (run.finishedAt !== null) throw new Error(`run is already finalized: ${normalizedRunId}`) - if (!run.prUrl || !run.headSha) throw new Error('record-review requires a recorded draft PR') if (!run.prUrl || !run.headSha) throw new Error('record-evidence requires a recorded draft PR') const evidenceRoot = path.resolve(loopRoot, 'evidence') @@ -163,7 +165,9 @@ export async function recordEvidence({ throw new Error('evidence manifest does not match the run') } const headSha = assertNonEmpty(manifest.headSha, 'manifest.headSha') - if (!/^[0-9a-f]{7,40}$/i.test(headSha)) throw new Error('manifest.headSha must be a Git SHA') + if (!/^[0-9a-f]{40}$/i.test(headSha)) { + throw new Error('manifest.headSha must be a full Git SHA') + } if (manifest.verdict !== 'passed') throw new Error('evidence manifest must have passed verdict') const publishedEvidenceUrl = assertHttpUrl(publicationUrl, 'publicationUrl') const artifactTarget = parseArtifactUrl(publishedEvidenceUrl) @@ -231,8 +235,38 @@ export async function recordEvidence({ manifest.screenshots, 'manifest.screenshots', ).entries()) { - assertNonEmpty(screenshot.name, `screenshots[${index}].name`) - assertNonEmpty(screenshot.path, `screenshots[${index}].path`) + for (const field of [ + 'name', + 'scenario', + 'route', + 'viewport', + 'path', + 'capturedAt', + 'sourceSha', + ]) { + assertNonEmpty(screenshot[field], `screenshots[${index}].${field}`) + } + const expectedSourceSha = screenshot.phase === 'before' ? run.baseSha : headSha + if ( + !['before', 'after'].includes(screenshot.phase) || + screenshot.headSha !== headSha || + screenshot.sourceSha !== expectedSourceSha || + !Number.isInteger(screenshot.width) || + !Number.isInteger(screenshot.height) || + screenshot.width < 320 || + screenshot.height < 200 || + !/^[0-9a-f]{64}$/i.test(screenshot.sha256) || + Number.isNaN(Date.parse(screenshot.capturedAt)) + ) { + throw new Error(`screenshots[${index}] is not bound to the evidence head`) + } + } + if ( + run.uiEvidenceRequired && + (!manifest.screenshots.some((screenshot) => screenshot.phase === 'before') || + !manifest.screenshots.some((screenshot) => screenshot.phase === 'after')) + ) { + throw new Error('UI evidence requires before and after screenshots') } const manifestDigest = createHash('sha256').update(source).digest('hex') const relativeManifestPath = path.relative(loopRoot, resolvedManifest) @@ -280,6 +314,7 @@ export async function recordReview({ throw new Error('review result does not match the run') } const headSha = assertNonEmpty(result.headSha, 'review.headSha') + if (!/^[0-9a-f]{40}$/i.test(headSha)) throw new Error('review.headSha must be a full Git SHA') const reviewSummary = validateReviewEvidence(result, headSha) const publishedReviewUrl = assertHttpUrl(reviewUrl, 'reviewUrl') const reviewTarget = parseReviewUrl(publishedReviewUrl) @@ -341,6 +376,7 @@ export async function recordReview({ const responseTarget = parsePullCommentUrl(finding.resolution.responseUrl) if ( !responseTarget || + responseTarget.surface !== 'pull' || !sameRepository(reviewTarget, responseTarget) || responseTarget.number !== reviewTarget.number ) { @@ -364,14 +400,23 @@ export async function recordReview({ ) } if (finding.resolution.classification === 'accepted') { - const comparison = await githubApi( - `repos/${reviewTarget.owner}/${reviewTarget.repo}/compare/${finding.resolution.fixCommit}...${headSha}`, - ) + const [findingToFix, fixToHead] = await Promise.all([ + githubApi( + `repos/${reviewTarget.owner}/${reviewTarget.repo}/compare/${finding.headSha}...${finding.resolution.fixCommit}`, + ), + githubApi( + `repos/${reviewTarget.owner}/${reviewTarget.repo}/compare/${finding.resolution.fixCommit}...${headSha}`, + ), + ]) if ( - !['ahead', 'identical'].includes(comparison.status) || - comparison.base_commit?.sha !== finding.resolution.fixCommit + findingToFix.status !== 'ahead' || + findingToFix.base_commit?.sha !== finding.headSha || + !['ahead', 'identical'].includes(fixToHead.status) || + fixToHead.base_commit?.sha !== finding.resolution.fixCommit ) { - throw new Error(`${finding.findingId} fixCommit is not an ancestor of the reviewed head`) + throw new Error( + `${finding.findingId} fixCommit must be after the finding head and within the reviewed head`, + ) } } if ( @@ -381,6 +426,7 @@ export async function recordReview({ const adjudicationTarget = parsePullCommentUrl(finding.resolution.adjudicationUrl) if ( !adjudicationTarget || + adjudicationTarget.surface !== 'pull' || !sameRepository(reviewTarget, adjudicationTarget) || adjudicationTarget.number !== reviewTarget.number ) { @@ -394,8 +440,10 @@ export async function recordReview({ const expectedVerdict = finding.resolution.adjudicationVerdict const expectedMarker = `<!-- issue-dev-loop:${normalizedRunId}:${finding.findingId}:adjudication:${expectedVerdict} -->` const permittedAdjudicator = - sameGitHubLogin(adjudication.user?.login, reviewerLogin) || - sameGitHubLogin(adjudication.user?.login, channel.ownerGitHubLogin) + (expectedVerdict === 'REJECT_FINDING' && + sameGitHubLogin(adjudication.user?.login, reviewerLogin)) || + (expectedVerdict === 'OWNER_REJECTED_FINDING' && + sameGitHubLogin(adjudication.user?.login, channel.ownerGitHubLogin)) if (!permittedAdjudicator || !adjudication.body?.includes(expectedMarker)) { throw new Error(`${finding.findingId} lacks independent published adjudication`) } diff --git a/loops/issue-dev-loop/scripts/lib/github.mjs b/loops/issue-dev-loop/scripts/lib/github.mjs index c892a246..5deb6682 100644 --- a/loops/issue-dev-loop/scripts/lib/github.mjs +++ b/loops/issue-dev-loop/scripts/lib/github.mjs @@ -1,10 +1,23 @@ import { readFile } from 'node:fs/promises' import path from 'node:path' -import { DEFAULT_LOOP_ROOT, assertRunId, defaultGitHubApi, execFileAsync } from './common.mjs' +import { + DEFAULT_LOOP_ROOT, + assertNonEmpty, + assertRunId, + defaultGitHubApi, + execFileAsync, + parseGitHubTarget, + parsePullCommentUrl, + parseReviewUrl, + pullRequestClaimsIssue, + readJson, + sameGitHubLogin, + sameRepository, +} from './common.mjs' import { observeOwnerApprovedMerge } from './owner-gate.mjs' import { defaultReleaseIssueClaim } from './issue-claim.mjs' -import { appendValidatedEvent, finalizeRun, readRun } from './run-store.mjs' +import { appendValidatedEvent, finalizeRun, readEvents, readRun } from './run-store.mjs' const PRIORITY = new Map([ ['priority:critical', 0], @@ -28,15 +41,6 @@ function issuePriority(issue) { return rank } -function pullRequestClaimsIssue(pullRequest, issueNumber) { - if (pullRequest.headRefName === `codex/issue-${issueNumber}`) return true - const searchable = `${pullRequest.title ?? ''}\n${pullRequest.body ?? ''}` - return new RegExp( - `(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)?\\s*#${issueNumber}(?!\\d)`, - 'i', - ).test(searchable) -} - export function selectIssue({ issues = [], pullRequests = [] } = {}) { const eligible = issues.filter((issue) => { const labels = labelNames(issue) @@ -62,7 +66,21 @@ async function loadJsonFile(target) { return JSON.parse(await readFile(path.resolve(target), 'utf8')) } -export async function detectWork({ issuesFile, pullRequestsFile, repo } = {}) { +export async function detectWork({ + loopRoot = DEFAULT_LOOP_ROOT, + issuesFile, + pullRequestsFile, + repo, +} = {}) { + const evolve = await readJson(path.join(loopRoot, 'evolve', 'metrics.json')) + if (evolve.evolveDue) { + return { + hasWork: true, + workType: 'evolve', + requestId: evolve.pendingRequestId, + issue: null, + } + } let issues let pullRequests if (issuesFile) { @@ -101,7 +119,7 @@ export async function detectWork({ issuesFile, pullRequestsFile, repo } = {}) { const result = await execFileAsync('gh', argumentsList, { maxBuffer: 1024 * 1024 }) pullRequests = JSON.parse(result.stdout) } - return selectIssue({ issues, pullRequests }) + return { workType: 'issue', ...selectIssue({ issues, pullRequests }) } } export async function observeOwnerMerge({ @@ -158,3 +176,117 @@ export async function observeOwnerMerge({ now, }) } + +export async function recordOwnerResponse({ + loopRoot = DEFAULT_LOOP_ROOT, + runId, + responseUrl, + now = new Date(), + githubApi = defaultGitHubApi, +} = {}) { + const normalizedRunId = assertRunId(runId) + const run = await readRun(loopRoot, normalizedRunId) + if (!['waiting_for_owner', 'awaiting_owner_review'].includes(run.status)) { + throw new Error('owner response can only resume a paused run') + } + const publishedUrl = assertNonEmpty(responseUrl, 'responseUrl') + const issueTarget = parseGitHubTarget(run.issueUrl) + const pullTarget = parseGitHubTarget(run.prUrl) + const reviewTarget = parseReviewUrl(publishedUrl) + const commentTarget = parsePullCommentUrl(publishedUrl) + let response + let targetUrl + if (reviewTarget) { + if ( + !pullTarget || + !sameRepository(reviewTarget, pullTarget) || + reviewTarget.number !== pullTarget.number + ) { + throw new Error('owner review response must be on the recorded PR') + } + response = await githubApi( + `repos/${reviewTarget.owner}/${reviewTarget.repo}/pulls/${reviewTarget.number}/reviews/${reviewTarget.reviewId}`, + ) + if (!['CHANGES_REQUESTED', 'COMMENTED'].includes(response.state)) { + throw new Error('owner review response must request changes or provide a comment') + } + targetUrl = run.prUrl + } else if (commentTarget) { + const expectedTarget = commentTarget.surface === 'pull' ? pullTarget : issueTarget + if ( + !expectedTarget || + !sameRepository(commentTarget, expectedTarget) || + commentTarget.number !== expectedTarget.number + ) { + throw new Error('owner comment response is not on the paused run target') + } + const endpoint = + commentTarget.kind === 'review_comment' + ? `repos/${commentTarget.owner}/${commentTarget.repo}/pulls/comments/${commentTarget.commentId}` + : `repos/${commentTarget.owner}/${commentTarget.repo}/issues/comments/${commentTarget.commentId}` + response = await githubApi(endpoint) + targetUrl = + commentTarget.surface === 'pull' + ? run.prUrl + : `https://github.com/${issueTarget.owner}/${issueTarget.repo}/issues/${issueTarget.number}` + } else { + throw new Error('responseUrl must identify a GitHub owner comment or review') + } + const channel = await readJson( + path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'), + ) + const events = await readEvents(loopRoot, normalizedRunId) + const pauseEvent = events.findLast( + (event) => event.type === 'run_status_changed' && event.status === run.status, + ) + const deliveredNotification = events.findLast((event) => { + if ( + event.type !== 'owner_notified' || + event.status !== 'delivered' || + event.payload?.delivery?.github !== 'delivered' + ) { + return false + } + if (!pauseEvent) return false + if (run.status === 'waiting_for_owner') { + return Date.parse(event.timestamp) >= Date.parse(pauseEvent.timestamp) + } + return ( + ['pr_ready_for_review', 'pr_updated_for_review'].includes(event.payload?.notificationType) && + event.payload?.targetUrl === run.prUrl && + Date.parse(event.timestamp) <= Date.parse(pauseEvent.timestamp) + ) + }) + const responseTimestamp = response.submitted_at ?? response.created_at + const responseTime = Date.parse(responseTimestamp) + const explicitResumeToken = `RESUME ${normalizedRunId}` + const reviewRequestsChanges = reviewTarget && response.state === 'CHANGES_REQUESTED' + if ( + !deliveredNotification || + deliveredNotification.payload?.targetUrl !== targetUrl || + !sameGitHubLogin(response.user?.login, channel.ownerGitHubLogin) || + !response.body?.trim() || + Number.isNaN(responseTime) || + (!reviewRequestsChanges && !response.body.includes(explicitResumeToken)) || + (pauseEvent && responseTime < Date.parse(pauseEvent.timestamp)) || + responseTime < Date.parse(deliveredNotification.timestamp) + ) { + throw new Error( + 'response is not a current, run-bound decision from the configured owner after successful delivery', + ) + } + return appendValidatedEvent({ + loopRoot, + runId: normalizedRunId, + type: 'owner_response_observed', + status: 'observed', + payload: { + actor: channel.ownerGitHubLogin, + responseUrl: publishedUrl, + targetUrl, + responseState: response.state ?? 'COMMENTED', + notificationId: deliveredNotification.payload.notificationId, + }, + now, + }) +} diff --git a/loops/issue-dev-loop/scripts/lib/issue-claim.mjs b/loops/issue-dev-loop/scripts/lib/issue-claim.mjs index 5d3859c9..4227a5e2 100644 --- a/loops/issue-dev-loop/scripts/lib/issue-claim.mjs +++ b/loops/issue-dev-loop/scripts/lib/issue-claim.mjs @@ -1,25 +1,16 @@ -import { defaultGitHubApi, execFileAsync, parseGitHubTarget } from './common.mjs' +import { + defaultGitHubApi, + defaultGitHubPaginatedApi, + execFileAsync, + parseGitHubTarget, + pullRequestClaimsIssue, +} from './common.mjs' function labelNames(issue) { return new Set((issue.labels ?? []).map((label) => label.name ?? label)) } -export async function defaultClaimIssue({ issueUrl, issueNumber, branch, githubApi }) { - const target = parseGitHubTarget(issueUrl) - if (!target || target.kind !== 'issues' || target.number !== issueNumber) { - throw new Error('issueUrl must identify the issue being claimed') - } - const issue = await githubApi(`repos/${target.owner}/${target.repo}/issues/${issueNumber}`) - const labels = labelNames(issue) - if (issue.state !== 'open' || !labels.has('codex-ready') || labels.has('loop:claimed')) { - throw new Error('issue is no longer an open, unclaimed codex-ready issue') - } - const pulls = await githubApi( - `repos/${target.owner}/${target.repo}/pulls?state=open&per_page=100`, - ) - if (pulls.some((pullRequest) => pullRequest.head?.ref === branch)) { - throw new Error(`an open pull request already claims ${branch}`) - } +async function defaultAddLabel({ target, issueNumber }) { await execFileAsync( 'gh', [ @@ -34,6 +25,32 @@ export async function defaultClaimIssue({ issueUrl, issueNumber, branch, githubA ) } +export async function defaultClaimIssue({ + issueUrl, + issueNumber, + githubApi = defaultGitHubApi, + githubPaginatedApi = defaultGitHubPaginatedApi, + addLabel = defaultAddLabel, +}) { + const target = parseGitHubTarget(issueUrl) + if (!target || target.kind !== 'issues' || target.number !== issueNumber) { + throw new Error('issueUrl must identify the issue being claimed') + } + const issue = await githubApi(`repos/${target.owner}/${target.repo}/issues/${issueNumber}`) + const labels = labelNames(issue) + if (issue.state !== 'open' || !labels.has('codex-ready') || labels.has('loop:claimed')) { + throw new Error('issue is no longer an open, unclaimed codex-ready issue') + } + const pulls = await githubPaginatedApi( + `repos/${target.owner}/${target.repo}/pulls?state=open&per_page=100`, + ) + if (pulls.some((pullRequest) => pullRequestClaimsIssue(pullRequest, issueNumber))) { + throw new Error(`an open pull request already claims issue ${issueNumber}`) + } + await addLabel({ target, issueNumber }) + return issue +} + export async function defaultReleaseIssueClaim({ issueUrl, issueNumber, diff --git a/loops/issue-dev-loop/scripts/lib/notifications.mjs b/loops/issue-dev-loop/scripts/lib/notifications.mjs index 96443909..b5616415 100644 --- a/loops/issue-dev-loop/scripts/lib/notifications.mjs +++ b/loops/issue-dev-loop/scripts/lib/notifications.mjs @@ -22,6 +22,9 @@ import { function notificationBody(notification, owner) { const evidence = notification.evidenceUrl ? `\n\nEvidence: ${notification.evidenceUrl}` : '' + const resume = notification.blocking + ? `\n\nTo resume after your decision, include \`RESUME ${notification.runId}\` in a comment. A GitHub “Request changes” review also resumes the run.` + : '' return [ `@${owner} **${notification.type}**`, '', @@ -29,7 +32,7 @@ function notificationBody(notification, owner) { '', `Requested action: ${notification.requestedAction}`, '', - `Notification: \`${notification.notificationId}\` · Run: \`${notification.runId}\`${evidence}`, + `Notification: \`${notification.notificationId}\` · Run: \`${notification.runId}\`${evidence}${resume}`, ].join('\n') } @@ -72,36 +75,43 @@ export async function createNotification({ if (channel.immediateTypes.includes(notificationType) && !blocking) { throw new Error(`${notificationType} must be sent as a blocking notification`) } - if ( - notificationType === 'pr_ready_for_review' && - (!run.prUrl || !run.headSha || targetUrl !== run.prUrl || !evidenceUrl) - ) { + const ownerReadyType = ['pr_ready_for_review', 'pr_updated_for_review'].includes(notificationType) + if (ownerReadyType && (!run.prUrl || !run.headSha || targetUrl !== run.prUrl || !evidenceUrl)) { throw new Error( - 'pr_ready_for_review must target the recorded PR and include exact-head evidence', + `${notificationType} must target the recorded PR and include exact-head evidence`, ) } - if (notificationType === 'pr_ready_for_review') { + if (ownerReadyType) { const events = await readEvents(loopRoot, normalizedRunId) - if ( - !events.some( + for (const eventType of ['verification_completed', 'review_completed']) { + if (!events.some( (event) => - event.type === 'verification_completed' && + event.type === eventType && event.status === 'passed' && event.payload?.headSha === run.headSha && - event.payload?.manifestUrl === evidenceUrl, - ) - ) { - throw new Error('pr_ready_for_review evidenceUrl must be the recorded exact-head artifact') + (eventType !== 'verification_completed' || event.payload?.manifestUrl === evidenceUrl), + )) { + throw new Error(`${notificationType} requires exact-head verification and review evidence`) + } } } const target = parseGitHubTarget(targetUrl) + const issueTarget = parseGitHubTarget(run.issueUrl) + const pullTarget = parseGitHubTarget(run.prUrl) + const isRunIssue = + target?.kind === 'issues' && + sameRepository(issueTarget, target) && + target.number === run.issueNumber + const isRunPull = + target?.kind === 'pull' && + pullTarget && + sameRepository(pullTarget, target) && + target.number === pullTarget.number if ( targetUrl && - (!target || - !sameRepository(parseGitHubTarget(run.issueUrl), target) || - (target.kind === 'issues' && target.number !== run.issueNumber)) + (!target || (!isRunIssue && !isRunPull)) ) { - throw new Error('targetUrl must be the run issue or a pull request in the issue repository') + throw new Error('targetUrl must be the exact run issue or recorded pull request') } const suffix = (entropy ?? randomBytes(3).toString('hex')).toUpperCase() const notificationId = `NTF-${timestampToken(now).replace('Z', '')}-${suffix}` diff --git a/loops/issue-dev-loop/scripts/lib/owner-gate.mjs b/loops/issue-dev-loop/scripts/lib/owner-gate.mjs index f8853d16..5c31cdc6 100644 --- a/loops/issue-dev-loop/scripts/lib/owner-gate.mjs +++ b/loops/issue-dev-loop/scripts/lib/owner-gate.mjs @@ -40,7 +40,7 @@ export async function observeOwnerApprovedMerge({ (requiredBodyMarker && !pullRequest.body?.includes(requiredBodyMarker)) || (createdAfter && Date.parse(pullRequest.created_at) < Date.parse(createdAfter)) || !sameGitHubLogin(pullRequest.merged_by?.login, channel.ownerGitHubLogin) || - !pullRequest.merge_commit_sha || + !/^[0-9a-f]{40}$/i.test(pullRequest.merge_commit_sha ?? '') || !ownerApproval || (expectedHeadSha !== null && headSha !== expectedHeadSha) ) { diff --git a/loops/issue-dev-loop/scripts/lib/run-store.mjs b/loops/issue-dev-loop/scripts/lib/run-store.mjs index ba0c434f..9c8d5b7e 100644 --- a/loops/issue-dev-loop/scripts/lib/run-store.mjs +++ b/loops/issue-dev-loop/scripts/lib/run-store.mjs @@ -1,4 +1,4 @@ -import { randomBytes } from 'node:crypto' +import { createHash, randomBytes } from 'node:crypto' import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' import path from 'node:path' @@ -9,6 +9,7 @@ import { assertNonEmpty, assertRunId, defaultGitHubApi, + execFileAsync, parseGitHubTarget, pathExists, readJson, @@ -19,7 +20,7 @@ import { timestampToken, writeJson, } from './common.mjs' -import { defaultClaimIssue } from './issue-claim.mjs' +import { defaultClaimIssue, defaultReleaseIssueClaim } from './issue-claim.mjs' import { updateEvolveMetrics } from './evolve.mjs' export const PAUSED_STATUSES = new Set(['awaiting_owner_review', 'waiting_for_owner']) @@ -33,6 +34,9 @@ const RESERVED_EVENT_TYPES = new Set([ 'owner_notified', 'notification_failed', 'notification_dry_run', + 'owner_response_observed', + 'brief_frozen', + 'implementation_completed', 'owner_review_approved', 'pr_merged', 'run_status_changed', @@ -64,14 +68,24 @@ export async function startRun({ issueNumber, issueTitle, issueUrl, + baseSha, now = new Date(), entropy, githubApi = defaultGitHubApi, claimIssue = defaultClaimIssue, + releaseIssueClaim = defaultReleaseIssueClaim, } = {}) { + const evolve = await readJson(path.join(loopRoot, 'evolve', 'metrics.json')) + if (evolve.evolveDue) { + throw new Error(`evolve request must run before issue work: ${evolve.pendingRequestId}`) + } const issue = assertIssueNumber(issueNumber) const title = assertNonEmpty(issueTitle, 'issueTitle') const url = assertNonEmpty(issueUrl, 'issueUrl') + const normalizedBaseSha = assertNonEmpty(baseSha, 'baseSha') + if (!/^[0-9a-f]{40}$/i.test(normalizedBaseSha)) { + throw new Error('baseSha must be a full Git SHA') + } const runId = makeRunId({ issueNumber: issue, now, entropy }) const runPath = runDirectory(loopRoot, runId) if (await pathExists(runPath)) throw new Error(`run already exists: ${runId}`) @@ -99,15 +113,42 @@ export async function startRun({ throw error } + let issueSnapshot + let remoteClaimCreated = false try { - await claimIssue({ + const snapshot = await claimIssue({ issueUrl: url, issueNumber: issue, branch: `codex/issue-${issue}`, githubApi, }) + remoteClaimCreated = true + if (!snapshot || snapshot.number !== issue || !snapshot.title) { + throw new Error('claimed GitHub issue snapshot does not match the selected issue') + } + const snapshotLabels = (snapshot.labels ?? []).map((label) => label.name ?? label).sort() + if (!snapshotLabels.includes('codex-ready') || snapshotLabels.includes('loop:claimed')) { + throw new Error('claimed issue snapshot must be captured before loop:claimed is applied') + } + issueSnapshot = { + title: snapshot.title, + body: snapshot.body ?? '', + labels: snapshotLabels, + url: snapshot.html_url ?? url, + capturedAt: now.toISOString(), + } } catch (error) { await rm(claimDirectory, { recursive: true, force: true }) + if (remoteClaimCreated) { + try { + await releaseIssueClaim({ issueUrl: url, issueNumber: issue, githubApi }) + } catch (rollbackError) { + throw new AggregateError( + [error, rollbackError], + 'issue claim validation failed and the remote claim rollback also failed', + ) + } + } throw error } @@ -125,9 +166,10 @@ export async function startRun({ schemaVersion: 1, runId, issueNumber: issue, - issueTitle: title, + issueTitle: issueSnapshot.title, issueUrl: url, baseBranch: 'dev', + baseSha: normalizedBaseSha, branch: `codex/issue-${issue}`, status: 'running', startedAt: now.toISOString(), @@ -135,6 +177,10 @@ export async function startRun({ prUrl: null, headSha: null, mergeSha: null, + issueSnapshot, + briefDigest: null, + uiEvidenceRequired: null, + implementationCommit: null, } await writeJson(path.join(runPath, 'run.json'), run) await appendValidatedEvent({ @@ -156,14 +202,153 @@ export async function startRun({ replaceTemplate(template, { RUN_ID: runId, ISSUE_NUMBER: issue, - ISSUE_TITLE: title, + ISSUE_TITLE: issueSnapshot.title, ISSUE_URL: url, + ISSUE_BODY: issueSnapshot.body, + BASE_SHA: normalizedBaseSha, + UI_EVIDENCE_REQUIRED: 'UNSET', }), 'utf8', ) return { run, briefPath, runPath } } +async function assertFrozenBriefUnchanged(loopRoot, run) { + const briefPath = path.join(loopRoot, 'handoffs', run.runId, 'implementation-brief.md') + const source = await readFile(briefPath, 'utf8') + const currentDigest = createHash('sha256').update(source).digest('hex') + if (currentDigest !== run.briefDigest) { + throw new Error('frozen implementation brief changed after freeze-brief') + } + return currentDigest +} + +export async function freezeBrief({ loopRoot = DEFAULT_LOOP_ROOT, runId, now = new Date() } = {}) { + const normalizedRunId = assertRunId(runId) + const runFile = path.join(runDirectory(loopRoot, normalizedRunId), 'run.json') + const run = await readJson(runFile) + if (run.status !== 'running' || run.finishedAt !== null || run.briefDigest) { + throw new Error('brief can only be frozen once for a running run') + } + const briefPath = path.join(loopRoot, 'handoffs', normalizedRunId, 'implementation-brief.md') + const source = await readFile(briefPath, 'utf8') + const acceptance = source.match(/## Acceptance criteria\s+([\s\S]*?)(?=\n## )/)?.[1]?.trim() + const uiEvidence = source.match(/UI evidence required:\s*(yes|no)\b/i)?.[1]?.toLowerCase() + if (!acceptance || acceptance.includes('<!--') || acceptance.length < 20) { + throw new Error('implementation brief requires concrete frozen acceptance criteria') + } + if (!uiEvidence) + throw new Error('implementation brief must set UI evidence required to yes or no') + const briefDigest = createHash('sha256').update(source).digest('hex') + const updated = { + ...run, + briefDigest, + uiEvidenceRequired: uiEvidence === 'yes', + } + await writeJson(runFile, updated) + await appendValidatedEvent({ + loopRoot, + runId: normalizedRunId, + type: 'brief_frozen', + status: 'frozen', + payload: { briefDigest, uiEvidenceRequired: updated.uiEvidenceRequired }, + now, + }) + return { briefPath, briefDigest, uiEvidenceRequired: updated.uiEvidenceRequired } +} + +async function defaultCommitRangeValidator({ loopRoot, ancestor, descendant }) { + await execFileAsync('git', ['merge-base', '--is-ancestor', ancestor, descendant], { + cwd: path.resolve(loopRoot, '..', '..'), + maxBuffer: 1024 * 1024, + }) +} + +export async function recordImplementation({ + loopRoot = DEFAULT_LOOP_ROOT, + runId, + resultPath, + now = new Date(), + commitRangeValidator = defaultCommitRangeValidator, +} = {}) { + const normalizedRunId = assertRunId(runId) + const runFile = path.join(runDirectory(loopRoot, normalizedRunId), 'run.json') + const run = await readJson(runFile) + if (run.status !== 'running' || !run.briefDigest) { + throw new Error('implementation recording requires a frozen running brief') + } + await assertFrozenBriefUnchanged(loopRoot, run) + const resolvedResultPath = path.resolve(assertNonEmpty(resultPath, 'resultPath')) + const runRoot = runDirectory(loopRoot, normalizedRunId) + if (!resolvedResultPath.startsWith(`${runRoot}${path.sep}`)) { + throw new Error('implementation result must be inside the current run directory') + } + const result = await readJson(resolvedResultPath) + if ( + result.schemaVersion !== 1 || + result.runId !== normalizedRunId || + result.agent !== '$implement' || + result.briefDigest !== run.briefDigest || + !assertNonEmpty(result.invocationId, 'implementation.invocationId') || + !/^[0-9a-f]{40}$/i.test(result.commitSha) + ) { + throw new Error('implementation result does not attest $implement and the frozen brief') + } + const startedAt = Date.parse(result.startedAt) + const finishedAt = Date.parse(result.finishedAt) + if (Number.isNaN(startedAt) || Number.isNaN(finishedAt) || finishedAt < startedAt) { + throw new Error('$implement result requires an ordered invocation time range') + } + const checks = Array.isArray(result.checks) ? result.checks : [] + if ( + checks.length === 0 || + checks.some((check) => check.status !== 'passed') || + !checks.some((check) => /^pnpm verify(?:\s|$)/.test(check.command)) + ) { + throw new Error('$implement result requires passed checks including pnpm verify') + } + const previousCommit = run.implementationCommit ?? run.baseSha + if (result.commitSha === previousCommit) { + throw new Error('$implement must produce a new commit') + } + const events = await readEvents(loopRoot, normalizedRunId) + const relativeResultPath = path.relative(loopRoot, resolvedResultPath) + if ( + events.some( + (event) => + event.type === 'implementation_completed' && + (event.payload?.invocationId === result.invocationId || + event.payload?.resultPath === relativeResultPath), + ) + ) { + throw new Error('$implement invocation IDs and result paths must be unique within a run') + } + await commitRangeValidator({ + loopRoot, + ancestor: previousCommit, + descendant: result.commitSha, + }) + const updated = { ...run, implementationCommit: result.commitSha } + await writeJson(runFile, updated) + await appendValidatedEvent({ + loopRoot, + runId: normalizedRunId, + type: 'implementation_completed', + status: 'passed', + payload: { + agent: '$implement', + invocationId: result.invocationId, + startedAt: result.startedAt, + finishedAt: result.finishedAt, + briefDigest: run.briefDigest, + commitSha: result.commitSha, + resultPath: relativeResultPath, + }, + now, + }) + return updated +} + export async function recordPullRequest({ loopRoot = DEFAULT_LOOP_ROOT, runId, @@ -178,6 +363,13 @@ export async function recordPullRequest({ if (run.finishedAt !== null || run.status !== 'running') { throw new Error('draft PR publication requires a running run') } + if (!run.briefDigest || !run.implementationCommit) { + throw new Error('record-pr requires a frozen brief and recorded $implement result') + } + await assertFrozenBriefUnchanged(loopRoot, run) + if (!/^[0-9a-f]{40}$/i.test(headSha)) { + throw new Error('record-pr requires a full head SHA') + } const issueTarget = parseGitHubTarget(run.issueUrl) const pullTarget = parseGitHubTarget(prUrl) if (!pullTarget || pullTarget.kind !== 'pull' || !sameRepository(issueTarget, pullTarget)) { @@ -197,6 +389,26 @@ export async function recordPullRequest({ ) { throw new Error('record-pr requires a live draft PR to dev at the exact run branch and headSha') } + const requiredBodyFragments = [ + `Closes #${run.issueNumber}`, + `<!-- issue-dev-loop:run:${normalizedRunId} -->`, + `Run ID: \`${normalizedRunId}\``, + `Base SHA: \`${run.baseSha}\``, + `Head SHA: \`${headSha}\``, + 'This PR must be reviewed and merged by `@codeacme17`', + ] + if (requiredBodyFragments.some((fragment) => !livePullRequest.body?.includes(fragment))) { + throw new Error('draft PR body is missing immutable loop metadata or owner-only merge language') + } + const implementationComparison = await githubApi( + `repos/${pullTarget.owner}/${pullTarget.repo}/compare/${run.implementationCommit}...${headSha}`, + ) + if ( + !['ahead', 'identical'].includes(implementationComparison.status) || + implementationComparison.base_commit?.sha !== run.implementationCommit + ) { + throw new Error('recorded $implement commit is not contained in the draft PR head') + } const updated = { ...run, prUrl, headSha } await writeJson(runFile, updated) await appendValidatedEvent({ @@ -275,6 +487,7 @@ export async function transitionRun({ failureFingerprint = null, now = new Date(), githubApi = defaultGitHubApi, + releaseIssueClaim = defaultReleaseIssueClaim, } = {}) { const normalizedRunId = assertRunId(runId) if (!RUN_STATUSES.has(status)) throw new Error(`invalid run status: ${status}`) @@ -288,6 +501,24 @@ export async function transitionRun({ if (!ALLOWED_TRANSITIONS.get(run.status)?.has(status)) { throw new Error(`invalid run status transition: ${run.status} -> ${status}`) } + if (status === 'running') { + const pauseEvent = events.findLast( + (event) => event.type === 'run_status_changed' && event.status === run.status, + ) + const channel = await readJson( + path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'), + ) + const ownerResponse = events.findLast( + (event) => + event.type === 'owner_response_observed' && + event.status === 'observed' && + sameGitHubLogin(event.payload?.actor, channel.ownerGitHubLogin) && + (!pauseEvent || Date.parse(event.timestamp) >= Date.parse(pauseEvent.timestamp)), + ) + if (!ownerResponse) { + throw new Error('resuming product work requires an observed owner response to this pause') + } + } if (status === 'awaiting_owner_review') { if (!prUrl || !headSha || run.prUrl !== prUrl || run.headSha !== headSha) { @@ -337,7 +568,12 @@ export async function transitionRun({ } if (status === 'completed') { - if (run.status !== 'awaiting_owner_review' || !run.prUrl || !run.headSha || !mergeSha) { + if ( + run.status !== 'awaiting_owner_review' || + !run.prUrl || + !run.headSha || + !/^[0-9a-f]{40}$/i.test(mergeSha) + ) { throw new Error('completed requires an owner-ready PR and mergeSha') } const channel = await readJson( @@ -367,18 +603,33 @@ export async function transitionRun({ if (['failed', 'blocked'].includes(status)) { assertNonEmpty(failureFingerprint, 'failureFingerprint') const requiredType = status === 'failed' ? 'loop_failed' : 'blocked' + const pauseEvent = events.findLast( + (event) => event.type === 'run_status_changed' && event.status === 'waiting_for_owner', + ) if ( + !pauseEvent || !events.some( (event) => event.type === 'owner_notified' && event.status === 'delivered' && event.payload?.notificationType === requiredType && - event.payload?.delivery?.github === 'delivered', + event.payload?.delivery?.github === 'delivered' && + Date.parse(event.timestamp) >= Date.parse(pauseEvent.timestamp) && + [run.issueUrl, run.prUrl].filter(Boolean).includes(event.payload?.targetUrl), ) ) { - throw new Error(`${status} requires a delivered GitHub ${requiredType} notification`) + throw new Error( + `${status} requires a delivered GitHub ${requiredType} notification for this pause`, + ) } } + if (['failed', 'blocked', 'cancelled'].includes(status)) { + await releaseIssueClaim({ + issueUrl: run.issueUrl, + issueNumber: run.issueNumber, + githubApi, + }) + } const transitioned = { ...run, diff --git a/loops/issue-dev-loop/scripts/lib/validation.mjs b/loops/issue-dev-loop/scripts/lib/validation.mjs index a14c8827..196f5d56 100644 --- a/loops/issue-dev-loop/scripts/lib/validation.mjs +++ b/loops/issue-dev-loop/scripts/lib/validation.mjs @@ -32,8 +32,10 @@ export async function validateLoop({ loopRoot = DEFAULT_LOOP_ROOT } = {}) { 'schemas/event.schema.json', 'schemas/run.schema.json', 'schemas/evidence.schema.json', + 'schemas/implementation-result.schema.json', 'scripts/generate-evidence.mjs', 'scripts/resolve-run.mjs', + 'scripts/validate-history.mjs', 'scripts/lib/common.mjs', 'scripts/lib/evidence.mjs', 'scripts/lib/evolve.mjs', @@ -58,6 +60,19 @@ export async function validateLoop({ loopRoot = DEFAULT_LOOP_ROOT } = {}) { ...(await collectFiles(sharedChannelRoot)).filter((target) => target.endsWith('.json')), ) for (const target of jsonFiles) await readJson(target) + const historyLines = (await readFile(path.join(loopRoot, 'logs', 'index.jsonl'), 'utf8')) + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) + if (historyLines[0]?.event !== 'loop_initialized') { + throw new Error('logs/index.jsonl must start with loop_initialized') + } + const finalizedRunIds = historyLines + .filter((entry) => entry.event === 'run_finalized') + .map((entry) => entry.runId) + if (new Set(finalizedRunIds).size !== finalizedRunIds.length) { + throw new Error('logs/index.jsonl contains duplicate finalized run IDs') + } const channel = await readJson(path.join(sharedChannelRoot, 'channel.json')) if ( typeof channel.ownerGitHubLogin !== 'string' || diff --git a/loops/issue-dev-loop/scripts/loopctl.mjs b/loops/issue-dev-loop/scripts/loopctl.mjs index 45dd6a4a..74095148 100644 --- a/loops/issue-dev-loop/scripts/loopctl.mjs +++ b/loops/issue-dev-loop/scripts/loopctl.mjs @@ -6,10 +6,13 @@ import { createNotification, detectWork, finalizeRun, + freezeBrief, getEvolveStatus, observeOwnerMerge, parseArguments, recordEvidence, + recordImplementation, + recordOwnerResponse, recordPullRequest, recordReview, startRun, @@ -52,10 +55,22 @@ async function main() { issueNumber: args.issue, issueTitle: args.title, issueUrl: args.url, + baseSha: args['base-sha'], now: args.now ? new Date(args.now) : undefined, }), ) break + case 'freeze-brief': + output(await freezeBrief({ runId: args['run-id'] })) + break + case 'record-implementation': + output( + await recordImplementation({ + runId: args['run-id'], + resultPath: args.result, + }), + ) + break case 'event': output( await appendEvent({ @@ -90,6 +105,14 @@ async function main() { }), ) break + case 'record-owner-response': + output( + await recordOwnerResponse({ + runId: args['run-id'], + responseUrl: args['response-url'], + }), + ) + break case 'record-review': output( await recordReview({ @@ -142,7 +165,7 @@ async function main() { break default: throw new Error( - 'usage: loopctl.mjs <start|event|record-pr|record-evidence|record-review|transition|finalize|observe-owner-merge|notify|detect-work|validate|evolve-status|evolve-complete> [options]', + 'usage: loopctl.mjs <start|freeze-brief|record-implementation|event|record-pr|record-owner-response|record-evidence|record-review|transition|finalize|observe-owner-merge|notify|detect-work|validate|evolve-status|evolve-complete> [options]', ) } } diff --git a/loops/issue-dev-loop/scripts/runtime.mjs b/loops/issue-dev-loop/scripts/runtime.mjs index eef65675..648b5ba9 100644 --- a/loops/issue-dev-loop/scripts/runtime.mjs +++ b/loops/issue-dev-loop/scripts/runtime.mjs @@ -1,12 +1,15 @@ export { DEFAULT_LOOP_ROOT, parseArguments } from './lib/common.mjs' export { completeEvolve, getEvolveStatus } from './lib/evolve.mjs' export { recordEvidence, recordReview } from './lib/evidence.mjs' -export { detectWork, observeOwnerMerge, selectIssue } from './lib/github.mjs' +export { detectWork, observeOwnerMerge, recordOwnerResponse, selectIssue } from './lib/github.mjs' export { createNotification } from './lib/notifications.mjs' +export { defaultClaimIssue } from './lib/issue-claim.mjs' export { appendEvent, finalizeRun, + freezeBrief, makeRunId, + recordImplementation, recordPullRequest, startRun, transitionRun, diff --git a/loops/issue-dev-loop/scripts/validate-history.mjs b/loops/issue-dev-loop/scripts/validate-history.mjs new file mode 100644 index 00000000..aead2356 --- /dev/null +++ b/loops/issue-dev-loop/scripts/validate-history.mjs @@ -0,0 +1,22 @@ +#!/usr/bin/env node + +import { readFile } from 'node:fs/promises' +import path from 'node:path' + +import { DEFAULT_LOOP_ROOT, parseArguments } from './runtime.mjs' +import { execFileAsync } from './lib/common.mjs' + +const args = parseArguments(process.argv.slice(2)) +const loopRoot = args['loop-root'] ? path.resolve(args['loop-root']) : DEFAULT_LOOP_ROOT +const repositoryRoot = path.resolve(loopRoot, '..', '..') +const relativeIndex = path.relative(repositoryRoot, path.join(loopRoot, 'logs', 'index.jsonl')) +const baseRef = args['base-ref'] ?? 'origin/dev' +const current = await readFile(path.join(repositoryRoot, relativeIndex), 'utf8') +const base = await execFileAsync('git', ['show', `${baseRef}:${relativeIndex}`], { + cwd: repositoryRoot, + maxBuffer: 1024 * 1024, +}) +if (!current.startsWith(base.stdout)) { + throw new Error('logs/index.jsonl must preserve the dev history as an exact prefix') +} +process.stdout.write('append-only history verified\n') diff --git a/loops/issue-dev-loop/templates/implementation-brief.md b/loops/issue-dev-loop/templates/implementation-brief.md index 3d5d2fb3..60589745 100644 --- a/loops/issue-dev-loop/templates/implementation-brief.md +++ b/loops/issue-dev-loop/templates/implementation-brief.md @@ -4,7 +4,13 @@ - Issue: #{{ISSUE_NUMBER}} — {{ISSUE_TITLE}} - Issue URL: {{ISSUE_URL}} - Base branch: `dev` +- Base SHA: `{{BASE_SHA}}` - Branch: `codex/issue-{{ISSUE_NUMBER}}` +- UI evidence required: {{UI_EVIDENCE_REQUIRED}} + +## Issue snapshot + +{{ISSUE_BODY}} ## Acceptance criteria diff --git a/loops/issue-dev-loop/templates/pr-body.md b/loops/issue-dev-loop/templates/pr-body.md index cb944038..8b9a8693 100644 --- a/loops/issue-dev-loop/templates/pr-body.md +++ b/loops/issue-dev-loop/templates/pr-body.md @@ -1,5 +1,7 @@ Closes #{{ISSUE_NUMBER}} +<!-- issue-dev-loop:run:{{RUN_ID}} --> + ## Loop metadata - Run ID: `{{RUN_ID}}` diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index b1c47c99..582b2843 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -12,10 +12,14 @@ import { appendEvent, completeEvolve, createNotification, + defaultClaimIssue, finalizeRun, + freezeBrief, getEvolveStatus, observeOwnerMerge, recordEvidence, + recordImplementation, + recordOwnerResponse, recordPullRequest, recordReview, selectIssue, @@ -93,7 +97,17 @@ async function createFixture() { } async function startFixtureRun(options) { - return startRun({ ...options, claimIssue: async () => {} }) + return startRun({ + ...options, + baseSha: options.baseSha ?? '0'.repeat(40), + claimIssue: async () => ({ + number: options.issueNumber, + title: options.issueTitle, + body: 'Authoritative issue body and acceptance context.', + html_url: options.issueUrl, + labels: [{ name: 'codex-ready' }], + }), + }) } function pullRequestFixture(run, headSha, { draft = true, merged = false } = {}) { @@ -102,21 +116,76 @@ function pullRequestFixture(run, headSha, { draft = true, merged = false } = {}) draft, merged, merged_by: merged ? { login: 'codeacme17' } : null, - merge_commit_sha: merged ? '1234567890abcdef' : null, + merge_commit_sha: merged ? '9'.repeat(40) : null, base: { ref: 'dev', repo: { full_name: 'codeacme17/echo-ui' } }, head: { ref: run.branch, sha: headSha, repo: { full_name: 'codeacme17/echo-ui' } }, - body: '', + body: [ + `Closes #${run.issueNumber}`, + `<!-- issue-dev-loop:run:${run.runId} -->`, + `Run ID: \`${run.runId}\``, + `Base SHA: \`${run.baseSha}\``, + `Head SHA: \`${headSha}\``, + 'This PR must be reviewed and merged by `@codeacme17`', + ].join('\n'), } } -async function recordFixturePr({ loopRoot, run, headSha, number = 200 }) { +async function recordFixturePr({ + loopRoot, + run, + headSha, + number = 200, + uiEvidenceRequired = false, +}) { + const briefPath = path.join(loopRoot, 'handoffs', run.runId, 'implementation-brief.md') + const brief = await readFile(briefPath, 'utf8') + await writeFile( + briefPath, + brief + .replace( + 'UI evidence required: UNSET', + `UI evidence required: ${uiEvidenceRequired ? 'yes' : 'no'}`, + ) + .replace( + '<!-- Freeze concrete, testable criteria before invoking $implement. -->', + 'Keyboard behavior is covered by a deterministic regression test.', + ), + 'utf8', + ) + const frozen = await freezeBrief({ loopRoot, runId: run.runId }) + const implementationCommit = '1'.repeat(40) + const resultPath = path.join(loopRoot, 'logs', 'runs', run.runId, 'implementation-result.json') + await writeFile( + resultPath, + `${JSON.stringify({ + schemaVersion: 1, + runId: run.runId, + agent: '$implement', + invocationId: `impl-${run.runId}`, + startedAt: '2026-07-22T15:00:00.000Z', + finishedAt: '2026-07-22T15:30:00.000Z', + briefDigest: frozen.briefDigest, + commitSha: implementationCommit, + checks: [{ command: 'pnpm verify', status: 'passed' }], + })}\n`, + 'utf8', + ) + await recordImplementation({ + loopRoot, + runId: run.runId, + resultPath, + commitRangeValidator: async () => {}, + }) const prUrl = `https://github.com/codeacme17/echo-ui/pull/${number}` await recordPullRequest({ loopRoot, runId: run.runId, prUrl, headSha, - githubApi: async () => pullRequestFixture(run, headSha), + githubApi: async (endpoint) => + endpoint.includes('/compare/') + ? { status: 'ahead', base_commit: { sha: implementationCommit } } + : pullRequestFixture(run, headSha), }) return prUrl } @@ -218,6 +287,30 @@ test('selectIssue chooses the highest-priority eligible unclaimed issue', () => assert.equal(result.issue.number, 13) }) +test('authoritative claim rejects any paginated open PR that references the issue', async () => { + let labelAdded = false + await assert.rejects( + defaultClaimIssue({ + issueUrl: 'https://github.com/codeacme17/echo-ui/issues/128', + issueNumber: 128, + githubApi: async () => ({ + number: 128, + title: 'Issue', + state: 'open', + labels: [{ name: 'codex-ready' }], + }), + githubPaginatedApi: async () => [ + { head: { ref: 'feature/other' }, title: 'Existing fix', body: 'Closes #128' }, + ], + addLabel: async () => { + labelAdded = true + }, + }), + /already claims issue 128/, + ) + assert.equal(labelAdded, false) +}) + test('startRun creates one correlated run, handoff, and evidence directories', async () => { const { loopRoot } = await createFixture() const result = await startFixtureRun({ @@ -260,6 +353,117 @@ test('startRun refuses a second active run for the same issue', async () => { ) }) +test('startRun rolls back a remote claim when the authoritative snapshot is invalid', async () => { + const { loopRoot } = await createFixture() + let released = false + await assert.rejects( + startRun({ + loopRoot, + issueNumber: 128, + issueTitle: 'Selected title', + issueUrl: 'https://github.com/codeacme17/echo-ui/issues/128', + baseSha: '0'.repeat(40), + entropy: 'rollback1', + claimIssue: async () => ({ + number: 999, + title: 'Wrong issue', + labels: [{ name: 'codex-ready' }], + }), + releaseIssueClaim: async () => { + released = true + }, + }), + /snapshot does not match/, + ) + assert.equal(released, true) +}) + +test('frozen brief and $implement invocation history cannot be rewritten or reused', async () => { + const { loopRoot } = await createFixture() + const { run } = await startFixtureRun({ + loopRoot, + issueNumber: 138, + issueTitle: 'Immutable implementation handoff', + issueUrl: 'https://github.com/codeacme17/echo-ui/issues/138', + entropy: 'impl138', + }) + const firstHead = '3'.repeat(40) + const prUrl = await recordFixturePr({ loopRoot, run, headSha: firstHead, number: 308 }) + const currentRun = JSON.parse( + await readFile(path.join(loopRoot, 'logs', 'runs', run.runId, 'run.json'), 'utf8'), + ) + const secondCommit = '2'.repeat(40) + const secondResultPath = path.join( + loopRoot, + 'logs', + 'runs', + run.runId, + 'implementation-result-2.json', + ) + const secondResult = { + schemaVersion: 1, + runId: run.runId, + agent: '$implement', + invocationId: 'impl-second', + startedAt: '2026-07-22T16:00:00.000Z', + finishedAt: '2026-07-22T16:30:00.000Z', + briefDigest: currentRun.briefDigest, + commitSha: secondCommit, + checks: [{ command: 'pnpm verify', status: 'passed' }], + } + await writeFile(secondResultPath, `${JSON.stringify(secondResult)}\n`, 'utf8') + let checkedRange + await recordImplementation({ + loopRoot, + runId: run.runId, + resultPath: secondResultPath, + commitRangeValidator: async (range) => { + checkedRange = range + }, + }) + assert.equal(checkedRange.ancestor, '1'.repeat(40)) + assert.equal(checkedRange.descendant, secondCommit) + + const duplicateInvocationPath = path.join( + loopRoot, + 'logs', + 'runs', + run.runId, + 'implementation-result-3.json', + ) + await writeFile( + duplicateInvocationPath, + `${JSON.stringify({ ...secondResult, commitSha: '4'.repeat(40) })}\n`, + 'utf8', + ) + await assert.rejects( + recordImplementation({ + loopRoot, + runId: run.runId, + resultPath: duplicateInvocationPath, + commitRangeValidator: async () => {}, + }), + /must be unique/, + ) + + const briefPath = path.join(loopRoot, 'handoffs', run.runId, 'implementation-brief.md') + await writeFile( + briefPath, + `${await readFile(briefPath, 'utf8')}\nMutated after freeze.\n`, + 'utf8', + ) + await assert.rejects( + recordPullRequest({ + loopRoot, + runId: run.runId, + prUrl, + headSha: '5'.repeat(40), + githubApi: async () => pullRequestFixture(run, '5'.repeat(40)), + }), + /brief changed after freeze-brief/, + ) +}) + test('recordEvidence rejects failed workflow runs and mismatched artifact manifests', async () => { const { loopRoot } = await createFixture() const { run } = await startFixtureRun({ @@ -269,7 +473,7 @@ test('recordEvidence rejects failed workflow runs and mismatched artifact manife issueUrl: 'https://github.com/codeacme17/echo-ui/issues/134', entropy: 'art134', }) - const headSha = 'abcdef1234567' + const headSha = 'a'.repeat(40) await recordFixturePr({ loopRoot, run, headSha, number: 301 }) const manifestPath = await writePassingEvidence({ loopRoot, run, headSha }) const manifestSource = await readFile(manifestPath, 'utf8') @@ -320,17 +524,49 @@ test('CI helpers resolve a run and generate exact-head screenshot evidence', asy issueUrl: 'https://github.com/codeacme17/echo-ui/issues/128', entropy: 'c1e2e3', }) - const screenshotRelativePath = `screen-shots/${run.runId}/after/player.webp` - await writeFile(path.join(loopRoot, screenshotRelativePath), 'fake-image', 'utf8') + const headSha = 'b'.repeat(40) + await recordFixturePr({ + loopRoot, + run, + headSha, + number: 303, + uiEvidenceRequired: true, + }) + const beforePath = `screen-shots/${run.runId}/before/player.png` + const screenshotRelativePath = `screen-shots/${run.runId}/after/player.png` + const meaningfulPng = await readFile( + path.resolve(repositoryLoopRoot, '..', '..', 'docs', 'public', 'temp.png'), + ) + await Promise.all([ + writeFile(path.join(loopRoot, beforePath), meaningfulPng), + writeFile(path.join(loopRoot, screenshotRelativePath), meaningfulPng), + ]) + const capturedAt = '2026-07-22T16:05:00.000Z' await writeFile( path.join(loopRoot, 'screen-shots', run.runId, 'manifest.json'), `${JSON.stringify({ screenshots: [ + { + name: 'Player before', + phase: 'before', + scenario: 'Keyboard focus', + route: '/player', + viewport: '1280x720', + path: beforePath, + capturedAt, + headSha, + sourceSha: run.baseSha, + }, { name: 'Player after', + phase: 'after', scenario: 'Keyboard focus', + route: '/player', viewport: '1280x720', path: screenshotRelativePath, + capturedAt, + headSha, + sourceSha: headSha, }, ], })}\n`, @@ -354,7 +590,7 @@ test('CI helpers resolve a run and generate exact-head screenshot evidence', asy '--run-id', run.runId, '--head-sha', - 'abcdef1234567', + headSha, '--status', 'passed', '--started-at', @@ -365,8 +601,11 @@ test('CI helpers resolve a run and generate exact-head screenshot evidence', asy output, ]) const evidence = JSON.parse(await readFile(output, 'utf8')) - assert.equal(evidence.headSha, 'abcdef1234567') - assert.equal(evidence.screenshots[0].path, screenshotRelativePath) + assert.equal(evidence.headSha, headSha) + assert.equal(evidence.screenshots[1].path, screenshotRelativePath) + assert.equal(evidence.screenshots[1].width, 937) + assert.equal(evidence.screenshots[1].height, 569) + assert.match(evidence.screenshots[1].sha256, /^[0-9a-f]{64}$/) }) test('owner-ready transition requires verification and review but remains resumable', async () => { @@ -379,7 +618,7 @@ test('owner-ready transition requires verification and review but remains resuma now: new Date('2026-07-22T16:00:00Z'), entropy: 'abc123', }) - const headSha = 'abcdef1234567' + const headSha = 'c'.repeat(40) const prUrl = await recordFixturePr({ loopRoot, run, headSha, number: 200 }) const manifestPath = await writePassingEvidence({ loopRoot, @@ -490,7 +729,7 @@ test('owner-ready transition requires verification and review but remains resuma runId: run.runId, type: 'pr_merged', status: 'observed', - payload: { actor: 'codeacme17', mergeSha: '1234567890abcdef' }, + payload: { actor: 'codeacme17', mergeSha: '9'.repeat(40) }, }), /reserved/, ) @@ -513,7 +752,7 @@ test('owner-ready transition requires verification and review but remains resuma merged_by: { login: 'someone-else' }, ...pullRequestFixture(run, headSha, { draft: false, merged: true }), merged_by: { login: 'someone-else' }, - merge_commit_sha: '1234567890abcdef', + merge_commit_sha: '9'.repeat(40), }, }), /not approved and merged by the configured owner/, @@ -553,7 +792,7 @@ test('owner-ready transition requires verification and review but remains resuma releaseIssueClaim: async () => {}, }) assert.equal(finalized.status, 'completed') - assert.equal(finalized.mergeSha, '1234567890abcdef') + assert.equal(finalized.mergeSha, '9'.repeat(40)) assert.equal(finalized.finishedAt, '2026-07-23T09:00:00.000Z') }) @@ -571,7 +810,7 @@ test('completed finalization cannot bypass the owner-ready gate', async () => { loopRoot, runId: run.runId, status: 'completed', - mergeSha: '1234567890abcdef', + mergeSha: '9'.repeat(40), }), /invalid run status transition/, ) @@ -586,7 +825,7 @@ test('review gate verifies published findings and classified replies', async () issueUrl: 'https://github.com/codeacme17/echo-ui/issues/133', entropy: 'rev133', }) - const headSha = 'fedcba9876543' + const headSha = 'd'.repeat(40) await recordFixturePr({ loopRoot, run, headSha, number: 300 }) const resultPath = path.join(loopRoot, 'logs', 'runs', run.runId, 'review-result.json') await writeFile( @@ -601,14 +840,14 @@ test('review gate verifies published findings and classified replies', async () rounds: [ { round: 1, - headSha: 'abcabc1234567', + headSha: 'e'.repeat(40), verdict: 'CHANGES_REQUESTED', findings: [ { findingId: 'RVW-1-1', severity: 'P2', confidence: 'high', - headSha: 'abcabc1234567', + headSha: 'e'.repeat(40), problem: 'Incorrect assertion', evidence: 'The runtime check already guarantees this invariant.', expectedResolution: 'Prove or fix the assertion.', @@ -654,7 +893,89 @@ test('review gate verifies published findings and classified replies', async () assert.equal(recorded.rounds, 2) }) -test('review gate rejects unilateral P0 or P1 rejection without adjudication', async () => { +test('accepted review fix must be after the finding head and inside the final head', async () => { + const { loopRoot } = await createFixture() + const { run } = await startFixtureRun({ + loopRoot, + issueNumber: 136, + issueTitle: 'Accepted review repair', + issueUrl: 'https://github.com/codeacme17/echo-ui/issues/136', + entropy: 'rev136', + }) + const findingHead = 'a'.repeat(40) + const fixCommit = 'b'.repeat(40) + const headSha = 'c'.repeat(40) + await recordFixturePr({ loopRoot, run, headSha, number: 304 }) + const resultPath = path.join(loopRoot, 'logs', 'runs', run.runId, 'review-result.json') + const result = { + schemaVersion: 1, + runId: run.runId, + reviewerAgent: 'echo_ui_pr_reviewer', + freshContext: true, + headSha, + verdict: 'PASS', + rounds: [ + { + round: 1, + headSha: findingHead, + verdict: 'CHANGES_REQUESTED', + findings: [ + { + findingId: 'RVW-1-1', + severity: 'P2', + confidence: 'high', + headSha: findingHead, + problem: 'Missing guard', + evidence: 'The failure is reproducible.', + expectedResolution: 'Add the guard.', + resolution: { + classification: 'accepted', + responseUrl: 'https://github.com/codeacme17/echo-ui/pull/304#issuecomment-410', + evidence: 'pnpm verify passes after the guard.', + fixCommit, + }, + }, + ], + }, + { round: 2, headSha, verdict: 'PASS', findings: [] }, + ], + } + await writeFile(resultPath, `${JSON.stringify(result)}\n`, 'utf8') + const digest = createHash('sha256') + .update(await readFile(resultPath, 'utf8')) + .digest('hex') + const recorded = await recordReview({ + loopRoot, + runId: run.runId, + resultPath, + reviewUrl: 'https://github.com/codeacme17/echo-ui/pull/304#pullrequestreview-510', + githubApi: async (endpoint) => { + if (endpoint.endsWith('/comments?per_page=100')) return [] + if (endpoint.includes('/issues/comments/410')) { + return { + user: { login: 'echo-ui-loop[bot]' }, + body: `pnpm verify passes after the guard. ${fixCommit}\n<!-- issue-dev-loop:${run.runId}:RVW-1-1:accepted -->`, + } + } + if (endpoint.endsWith(`/compare/${findingHead}...${fixCommit}`)) { + return { status: 'ahead', base_commit: { sha: findingHead } } + } + if (endpoint.endsWith(`/compare/${fixCommit}...${headSha}`)) { + return { status: 'ahead', base_commit: { sha: fixCommit } } + } + if (endpoint.endsWith('/pulls/304')) return pullRequestFixture(run, headSha) + return { + commit_id: headSha, + state: 'COMMENTED', + user: { login: 'echo-ui-reviewer[bot]' }, + body: `RVW-1-1\n<!-- issue-dev-loop:${run.runId}:review-result-sha256:${digest} -->`, + } + }, + }) + assert.equal(recorded.findingCount, 1) +}) + +test('review gate binds high-severity adjudication verdict to the correct identity', async () => { const { loopRoot } = await createFixture() const { run } = await startFixtureRun({ loopRoot, @@ -663,7 +984,7 @@ test('review gate rejects unilateral P0 or P1 rejection without adjudication', a issueUrl: 'https://github.com/codeacme17/echo-ui/issues/135', entropy: 'rev135', }) - const headSha = 'fedcba9876543' + const headSha = 'f'.repeat(40) await recordFixturePr({ loopRoot, run, headSha, number: 302 }) const resultPath = path.join(loopRoot, 'logs', 'runs', run.runId, 'review-result.json') await writeFile( @@ -693,6 +1014,8 @@ test('review gate rejects unilateral P0 or P1 rejection without adjudication', a classification: 'rejected', responseUrl: 'https://github.com/codeacme17/echo-ui/pull/302#issuecomment-401', evidence: 'Executor disagrees.', + adjudicationUrl: 'https://github.com/codeacme17/echo-ui/pull/302#issuecomment-402', + adjudicationVerdict: 'OWNER_REJECTED_FINDING', }, }, ], @@ -702,17 +1025,39 @@ test('review gate rejects unilateral P0 or P1 rejection without adjudication', a })}\n`, 'utf8', ) + const digest = createHash('sha256') + .update(await readFile(resultPath, 'utf8')) + .digest('hex') await assert.rejects( recordReview({ loopRoot, runId: run.runId, resultPath, reviewUrl: 'https://github.com/codeacme17/echo-ui/pull/302#pullrequestreview-501', - githubApi: async () => { - throw new Error('GitHub should not be queried before local adjudication validation') + githubApi: async (endpoint) => { + if (endpoint.endsWith('/comments?per_page=100')) return [] + if (endpoint.includes('/issues/comments/401')) { + return { + user: { login: 'echo-ui-loop[bot]' }, + body: `Executor disagrees.\n<!-- issue-dev-loop:${run.runId}:RVW-1-1:rejected -->`, + } + } + if (endpoint.includes('/issues/comments/402')) { + return { + user: { login: 'echo-ui-reviewer[bot]' }, + body: `<!-- issue-dev-loop:${run.runId}:RVW-1-1:adjudication:OWNER_REJECTED_FINDING -->`, + } + } + if (endpoint.endsWith('/pulls/302')) return pullRequestFixture(run, headSha) + return { + commit_id: headSha, + state: 'COMMENTED', + user: { login: 'echo-ui-reviewer[bot]' }, + body: `RVW-1-1\n<!-- issue-dev-loop:${run.runId}:review-result-sha256:${digest} -->`, + } }, }), - /adjudicationUrl/, + /lacks independent published adjudication/, ) }) @@ -786,6 +1131,73 @@ test('failed blocking delivery still pauses for the owner', async () => { await readFile(path.join(loopRoot, 'logs', 'runs', run.runId, 'run.json'), 'utf8'), ) assert.equal(paused.status, 'waiting_for_owner') + await assert.rejects( + transitionRun({ loopRoot, runId: run.runId, status: 'running' }), + /observed owner response/, + ) + const responseUrl = `${run.issueUrl}#issuecomment-500` + await assert.rejects( + recordOwnerResponse({ + loopRoot, + runId: run.runId, + responseUrl, + githubApi: async () => ({ + user: { login: 'someone-else' }, + body: `Proceed with option A. RESUME ${run.runId}`, + created_at: '2030-01-01T00:00:00.000Z', + }), + }), + /configured owner/, + ) + await assert.rejects( + recordOwnerResponse({ + loopRoot, + runId: run.runId, + responseUrl, + githubApi: async () => ({ + user: { login: 'codeacme17' }, + body: `Proceed with option A. RESUME ${run.runId}`, + created_at: '2030-01-01T00:00:00.000Z', + }), + }), + /after successful delivery/, + ) + await createNotification({ + loopRoot, + runId: run.runId, + type: 'clarification_required', + summary: 'Acceptance criterion is still ambiguous', + requestedAction: 'Clarify expected keyboard behavior', + targetUrl: run.issueUrl, + blocking: true, + now: new Date('2030-01-01T00:01:00.000Z'), + githubComment: async () => {}, + }) + await assert.rejects( + recordOwnerResponse({ + loopRoot, + runId: run.runId, + responseUrl, + githubApi: async () => ({ + user: { login: 'codeacme17' }, + body: 'Proceed with option A.', + created_at: '2030-01-01T00:02:00.000Z', + }), + }), + /run-bound decision/, + ) + await recordOwnerResponse({ + loopRoot, + runId: run.runId, + responseUrl, + githubApi: async () => ({ + user: { login: 'codeacme17' }, + body: `Proceed with option A. RESUME ${run.runId}`, + created_at: '2030-01-01T00:02:00.000Z', + }), + }) + const resumed = await transitionRun({ loopRoot, runId: run.runId, status: 'running' }) + assert.equal(resumed.status, 'running') }) test('three matching failures make a fresh evolve session due', async () => { @@ -813,12 +1225,23 @@ test('three matching failures make a fresh evolve session due', async () => { runId: run.runId, status: 'blocked', failureFingerprint: 'browser-environment-unavailable', + releaseIssueClaim: async () => {}, }) } const metrics = await getEvolveStatus({ loopRoot }) assert.equal(metrics.evolveDue, true) assert.equal(metrics.failedRuns, 3) assert.match(metrics.pendingRequestId, /^EVL-/) + await assert.rejects( + startFixtureRun({ + loopRoot, + issueNumber: 204, + issueTitle: 'Must wait for evolve', + issueUrl: 'https://github.com/codeacme17/echo-ui/issues/204', + entropy: 'fail204', + }), + /evolve request must run before issue work/, + ) }) test('evolve completion rejects an unrelated historical owner-merged PR', async () => { @@ -855,17 +1278,17 @@ test('evolve completion rejects an unrelated historical owner-merged PR', async { user: { login: 'codeacme17' }, state: 'APPROVED', - commit_id: 'abcdef1234567', + commit_id: 'a'.repeat(40), }, ] : { merged: true, merged_by: { login: 'codeacme17' }, - merge_commit_sha: '1234567890abcdef', + merge_commit_sha: '9'.repeat(40), created_at: '2026-07-20T00:00:00.000Z', body: 'Unrelated change', base: { ref: 'dev', repo: { full_name: 'codeacme17/echo-ui' } }, - head: { ref: 'feature/unrelated', sha: 'abcdef1234567' }, + head: { ref: 'feature/unrelated', sha: 'a'.repeat(40) }, }, }), /not approved and merged by the configured owner/, From 9ad7e83b911bb9a2d8a21934d617e6fe61aa598e Mon Sep 17 00:00:00 2001 From: leyoonafr <codeacme17@gmail.com> Date: Wed, 22 Jul 2026 23:31:52 +0800 Subject: [PATCH 06/41] fix: close loop lifecycle review findings --- README.md | 4 + loops/_shared/owner-channel/CHANNEL.md | 7 +- loops/_shared/owner-channel/channel.json | 1 + loops/issue-dev-loop/LOOP.md | 8 +- loops/issue-dev-loop/SKILL.md | 15 +- loops/issue-dev-loop/dependencies.md | 3 +- loops/issue-dev-loop/logs/triggers.jsonl | 1 + .../references/evidence-policy.md | 2 +- .../references/github-operations.md | 4 + .../schemas/finalization-record.schema.json | 36 +++ .../scripts/generate-evidence.mjs | 22 +- loops/issue-dev-loop/scripts/lib/evidence.mjs | 34 ++- loops/issue-dev-loop/scripts/lib/evolve.mjs | 32 ++- .../scripts/lib/finalization-journal.mjs | 248 ++++++++++++++++++ loops/issue-dev-loop/scripts/lib/github.mjs | 37 ++- .../issue-dev-loop/scripts/lib/run-store.mjs | 192 ++++++++++---- .../issue-dev-loop/scripts/lib/validation.mjs | 11 + loops/issue-dev-loop/scripts/loopctl.mjs | 36 ++- loops/issue-dev-loop/scripts/runtime.mjs | 7 + .../scripts/validate-history.mjs | 18 +- loops/issue-dev-loop/tests/runtime.test.mjs | 229 +++++++++++++++- .../triggers/codex-automation-prompt.md | 2 +- 22 files changed, 840 insertions(+), 109 deletions(-) create mode 100644 loops/issue-dev-loop/logs/triggers.jsonl create mode 100644 loops/issue-dev-loop/schemas/finalization-record.schema.json create mode 100644 loops/issue-dev-loop/scripts/lib/finalization-journal.mjs diff --git a/README.md b/README.md index d102e7b9..f84d7298 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,10 @@ Hook specially designed for audio interaction and analysis applications, which c Echo UI's component library is responsive, meaning they can automatically adapt to different screen sizes, providing a good experience on different devices. +## Documentation development + +Run the documentation site locally with `pnpm dev:docs`. Before submitting documentation changes, run `pnpm test:docs` to verify the generated site, routes, and UI contract. + ## License [MIT](./LICENSE) © 2023-Present [leyoonafr](https://github.com/codeacme17) diff --git a/loops/_shared/owner-channel/CHANNEL.md b/loops/_shared/owner-channel/CHANNEL.md index 3ad1a9bc..beeb3b07 100644 --- a/loops/_shared/owner-channel/CHANNEL.md +++ b/loops/_shared/owner-channel/CHANNEL.md @@ -13,8 +13,9 @@ Each blocking GitHub notification prints a unique resume instruction. To continu ## Runtime setup 1. Authenticate the unattended executor and fresh reviewer with distinct GitHub identities; set their exact logins as `automationGitHubLogin` and `reviewerGitHubLogin` in `channel.json`. -2. Enable GitHub notifications for mentions and review requests for `codeacme17`. -3. Optionally set `ECHO_UI_LOOP_OWNER_WEBHOOK_URL` to an endpoint that accepts the notification JSON with `Content-Type: application/json`. -4. Never store the webhook URL or credentials in this repository. +2. Create one dedicated repository issue for the append-only loop finalization journal and set its number as `stateIssueNumber`. Restrict journal entries to the automation identity; humans may read but should not edit or delete them. +3. Enable GitHub notifications for mentions and review requests for `codeacme17`. +4. Optionally set `ECHO_UI_LOOP_OWNER_WEBHOOK_URL` to an endpoint that accepts the notification JSON with `Content-Type: application/json`. +5. Never store the webhook URL or credentials in this repository. Review publications are valid only when authored by `reviewerGitHubLogin`; executor replies must match `automationGitHubLogin`. Those identities must differ from one another and from `ownerGitHubLogin`. Owner decisions are valid only when the author matches `ownerGitHubLogin`. A webhook delivery is an alert, not an approval channel. diff --git a/loops/_shared/owner-channel/channel.json b/loops/_shared/owner-channel/channel.json index 2709f473..665f8d69 100644 --- a/loops/_shared/owner-channel/channel.json +++ b/loops/_shared/owner-channel/channel.json @@ -3,6 +3,7 @@ "ownerGitHubLogin": "codeacme17", "automationGitHubLogin": null, "reviewerGitHubLogin": null, + "stateIssueNumber": 105, "repository": "codeacme17/echo-ui", "canonicalChannel": "github", "webhookEnvironmentVariable": "ECHO_UI_LOOP_OWNER_WEBHOOK_URL", diff --git a/loops/issue-dev-loop/LOOP.md b/loops/issue-dev-loop/LOOP.md index 4840292c..ede3f2a5 100644 --- a/loops/issue-dev-loop/LOOP.md +++ b/loops/issue-dev-loop/LOOP.md @@ -56,7 +56,7 @@ The loop must never: ## Trigger -Use a combo trigger. Run `triggers/detect-work.mjs` before starting a Codex implementation turn. The preflight queries open `codex-ready` issues, excludes issues with `loop:claimed` or an open matching PR, and selects the highest-priority oldest issue. If there is no work, record `trigger_checked` with `hasWork: false` and exit without waking an implementation agent. +Use a combo trigger. Run `triggers/detect-work.mjs` before starting a Codex implementation turn. The preflight queries open `codex-ready` issues, excludes issues with `loop:claimed` or an open matching PR, and selects the highest-priority oldest issue. Every result appends `trigger_checked` to `logs/triggers.jsonl`; if there is no work, record `hasWork: false` and exit without waking an implementation agent. ## Workflow @@ -78,7 +78,7 @@ Explicitly invoke `$implement`. The orchestrator does not write product code. `$ ### 5. Verify before publication -Run relevant checks and `pnpm verify`. For UI behavior, capture before/after screenshots under `screen-shots/<run-id>` at meaningful desktop and mobile viewports and include interaction or accessibility evidence where applicable. Commit sanitized screenshots and run metadata to the issue branch. The evidence workflow then checks out the exact PR head, reruns `pnpm verify`, generates the manifest, and uploads a reviewable artifact for that SHA. +Run relevant checks and `pnpm verify`. For UI behavior, capture before/after screenshots under `screen-shots/<run-id>` at meaningful desktop and mobile viewports and include interaction or accessibility evidence where applicable. Bind `before` to the frozen base and `after` to the latest `$implement` commit; never put the containing commit's not-yet-known hash inside its own files. Commit sanitized screenshots and run metadata to the issue branch. The evidence workflow then checks out the exact PR event head, adds that SHA to the generated manifest, reruns `pnpm verify`, and uploads a reviewable artifact for that SHA. ### 6. Create the draft PR @@ -98,13 +98,13 @@ When the owner requests changes, verify the owner-authored GitHub comment or `CH ### 10. Complete -Only `observe-owner-merge` permits `completed`. It must query GitHub and observe the recorded issue branch still targeting `dev`, an `APPROVED` review by `codeacme17` for the exact reviewed head SHA, and a merge performed by `codeacme17` for that same head. Record merge SHA and timestamp, remove `loop:claimed`, update state, append the run summary, and retain links to published evidence. A closed unmerged PR is `cancelled`, not completed. +Only `observe-owner-merge` permits `completed`. It must query GitHub and observe the recorded issue branch still targeting `dev`, an `APPROVED` review by `codeacme17` for the exact reviewed head SHA, and a merge performed by `codeacme17` for that same head. Before any terminal transition, generate a canonical finalization record, publish it through the automation identity to the configured GitHub state-journal issue, and validate its comment URL and digest. Record merge SHA and timestamp, remove `loop:claimed`, update state, append the run summary, and retain links to published evidence. A closed unmerged PR is `cancelled`, not completed. ## State and history Keep `state.md` small and deliberate. It may be rewritten and contains only active runs, open PRs, blockers, follow-ups, current hypotheses, and learned constraints. -`logs/index.jsonl` is append-only and contains one compact summary per finalized run. Commit sanitized `run.json`, pre-publication `events.jsonl`, summaries, and relevant screenshots to the issue branch so they are reviewable in its PR. The exact-head CI manifest and full proof travel in an Actions artifact. Keep raw local command output and large recordings in ignored `raw/` or `test-results/` directories. GitHub PR reviews, workflow artifacts, and merge metadata are the authoritative external record, while the local index is a reconciled cache. +`logs/index.jsonl` is append-only and contains one compact summary per finalized run; `logs/triggers.jsonl` is the append-only record of cheap trigger decisions, including successful no-ops. The dedicated GitHub state-journal issue is the durable cross-worktree source for terminal records: each automation-authored comment contains the canonical record and its SHA-256 marker, and `loopctl reconcile` verifies and replays missing entries before trigger/evolve work. Commit sanitized `run.json`, pre-publication `events.jsonl`, summaries, and relevant screenshots to the issue branch so they are reviewable in its PR. The exact-head CI manifest and full proof travel in an Actions artifact. Keep raw local command output and large recordings in ignored `raw/` or `test-results/` directories. GitHub journal comments, PR reviews, workflow artifacts, and merge metadata are authoritative; local indexes and metrics are reconstructable caches. Never log secrets, full environment dumps, cookies, auth headers, private user data, or raw prompts containing sensitive information. diff --git a/loops/issue-dev-loop/SKILL.md b/loops/issue-dev-loop/SKILL.md index f6d0f0e0..cc064504 100644 --- a/loops/issue-dev-loop/SKILL.md +++ b/loops/issue-dev-loop/SKILL.md @@ -11,12 +11,13 @@ Run exactly one bounded issue cycle. Treat [`LOOP.md`](./LOOP.md) as the constit 1. Read `LOOP.md`, `state.md`, and `dependencies.md` completely. 2. Run `node loops/issue-dev-loop/scripts/loopctl.mjs validate`. -3. Run `loopctl.mjs evolve-status`. If `evolveDue` is true, start `echo_ui_loop_evolver` with fresh context; do not silently replace it with product work. -4. Run the cheap trigger in `triggers/detect-work.mjs`. Exit without invoking an implementation agent when it reports `hasWork: false`. -5. Refuse to start when another active run or PR already claims the issue. -6. Create a `codex/issue-<number>` branch and an isolated worktree from `dev`. -7. Start the run with `loopctl.mjs start --issue <number> --title <title> --url <issue-url> --base-sha <full-origin-dev-sha>`. -8. Complete `handoffs/<run-id>/implementation-brief.md`, set `UI evidence required` to `yes` or `no`, then run `loopctl.mjs freeze-brief --run-id <id>` before implementation. Never edit the frozen brief afterward. +3. Run `loopctl.mjs reconcile` to rebuild local history and evolve metrics from the append-only GitHub finalization journal. +4. Run `loopctl.mjs evolve-status`. If `evolveDue` is true, start `echo_ui_loop_evolver` with fresh context; do not silently replace it with product work. +5. Run the cheap trigger in `triggers/detect-work.mjs`. Exit without invoking an implementation agent when it reports `hasWork: false`. +6. Refuse to start when another active run or PR already claims the issue. +7. Create a `codex/issue-<number>` branch and an isolated worktree from `dev`. +8. Start the run with `loopctl.mjs start --issue <number> --title <title> --url <issue-url> --base-sha <full-origin-dev-sha>`. +9. Complete `handoffs/<run-id>/implementation-brief.md`, set `UI evidence required` to `yes` or `no`, then run `loopctl.mjs freeze-brief --run-id <id>` before implementation. Never edit the frozen brief afterward. Read [`references/github-operations.md`](./references/github-operations.md) for GitHub mutations and [`references/evidence-policy.md`](./references/evidence-policy.md) before verification or PR publication. @@ -55,7 +56,7 @@ The executor must classify every finding as `accepted`, `rejected`, `needs-human Run verification appropriate to the change and require `pnpm verify` before the PR is ready for owner review. Commit sanitized run metadata and relevant `screen-shots` to the issue branch. Wait for the exact-head `Issue dev loop evidence` workflow, download its artifact, and run `loopctl.mjs record-evidence --run-id <id> --manifest <absolute-path> --publication-url <artifact-url>`. After the fresh reviewer and all comment responses are posted, run `loopctl.mjs record-review --run-id <id> --result <absolute-path> --review-url <github-review-url>`. Both gates must name the current PR head. Emit a blocking `pr_ready_for_review` notification, then transition from `waiting_for_owner` to `awaiting_owner_review` with the PR URL and exact head SHA. -The owner is the only actor allowed to approve or merge. Never call `gh pr merge`, enable auto-merge, push to `main`, push to `dev`, dismiss owner feedback, or bypass branch protections. A run becomes `completed` only through `loopctl.mjs observe-owner-merge`, which queries GitHub and requires both `codeacme17`'s approval and merge at the reviewed head SHA. +The owner is the only actor allowed to approve or merge. Never call `gh pr merge`, enable auto-merge, push to `main`, push to `dev`, dismiss owner feedback, or bypass branch protections. Before any terminal transition, run `prepare-finalization`, publish its exact body to the configured state-journal issue, and validate the returned comment URL with `record-finalization`. A completed run passes the same result and comment URL to `observe-owner-merge`, which queries GitHub and requires both `codeacme17`'s approval and merge at the reviewed head SHA. Future workspaces run `reconcile` to rebuild local history and evolve metrics from those automation-authored journal comments. For any pause, do not resume from silence or an arbitrary comment. First require a successfully delivered blocking notification. Then verify the owner's GitHub decision with `loopctl.mjs record-owner-response --run-id <id> --response-url <comment-or-review-url>`. A normal comment must include the exact `RESUME <run-id>` token printed in the notification; a `CHANGES_REQUESTED` review is itself an explicit decision. Only then may `loopctl.mjs transition --run-id <id> --status running` continue product work. diff --git a/loops/issue-dev-loop/dependencies.md b/loops/issue-dev-loop/dependencies.md index 94adcda5..15c58ee0 100644 --- a/loops/issue-dev-loop/dependencies.md +++ b/loops/issue-dev-loop/dependencies.md @@ -11,6 +11,7 @@ - Git - GitHub CLI (`gh`) authenticated for issue, Actions artifact download, branch, PR, review, and comment work - Repository trust enabled so project `.codex` agents can load +- A dedicated GitHub issue configured as `stateIssueNumber` for the append-only finalization journal ## Optional @@ -19,4 +20,4 @@ ## Identity -Use one dedicated GitHub App or bot identity for executor-created branches, PRs, and replies, plus a distinct reviewer identity for the fresh-context review publication. Neither may be `codeacme17`; neither may have branch-protection bypass or merge authority. Configure their exact logins in the shared owner channel before enabling automation. +Use one dedicated GitHub App or bot identity for executor-created branches, PRs, replies, and durable journal entries, plus a distinct reviewer identity for the fresh-context review publication. Neither may be `codeacme17`; neither may have branch-protection bypass or merge authority. Configure their exact logins and the dedicated state-journal issue number in the shared owner channel before enabling automation. diff --git a/loops/issue-dev-loop/logs/triggers.jsonl b/loops/issue-dev-loop/logs/triggers.jsonl new file mode 100644 index 00000000..5a3f69f5 --- /dev/null +++ b/loops/issue-dev-loop/logs/triggers.jsonl @@ -0,0 +1 @@ +{"schemaVersion":1,"event":"trigger_log_initialized"} diff --git a/loops/issue-dev-loop/references/evidence-policy.md b/loops/issue-dev-loop/references/evidence-policy.md index 10eb871b..43db3f85 100644 --- a/loops/issue-dev-loop/references/evidence-policy.md +++ b/loops/issue-dev-loop/references/evidence-policy.md @@ -20,7 +20,7 @@ Capture only scenarios relevant to the issue: - focus, keyboard, disabled, loading, or error state when affected - video only when a still image cannot demonstrate the interaction -Use descriptive names such as `02-after-player-mobile-375.webp` under `screen-shots/<run-id>/<phase>`. Record phase, route, viewport, scenario, evidence head SHA, source commit SHA, and capture time in the screenshot manifest. `before` must come from the frozen base SHA, `after` from the exact PR head, and every scenario/route/viewport tuple must have both phases. Captures must be genuine PNG/WebP files of at least 320×200 pixels. +Use descriptive names such as `02-after-player-mobile-375.webp` under `screen-shots/<run-id>/<phase>`. Record phase, route, viewport, scenario, source commit SHA, and capture time in the screenshot manifest. `before` must come from the frozen base SHA and `after` from the latest recorded `$implement` commit. CI adds its exact checkout SHA to the generated evidence manifest, avoiding an impossible commit-hash self-reference while still binding captures to code that is an ancestor of that head. Every scenario/route/viewport tuple must have both phases. Captures must be genuine PNG/WebP files of at least 320×200 pixels. ## Storage diff --git a/loops/issue-dev-loop/references/github-operations.md b/loops/issue-dev-loop/references/github-operations.md index 5d19f26a..f89c766b 100644 --- a/loops/issue-dev-loop/references/github-operations.md +++ b/loops/issue-dev-loop/references/github-operations.md @@ -50,3 +50,7 @@ Do not enable auto-merge, dismiss owner reviews, resolve disputed owner comments Use the originating issue for pre-PR questions and the PR for post-publication questions. Mention `@codeacme17`, include the notification ID and run ID, and link the exact evidence or decision needed. Only decisions from the configured owner identity satisfy an owner gate. After a blocking notification, verify the reply URL with `record-owner-response`. Normal comments must include `RESUME <run-id>`; a `CHANGES_REQUESTED` review is an explicit response. The runtime requires that the notification was successfully delivered to the same run target before it accepts the reply. + +## Durable finalization journal + +Before `completed`, `failed`, `blocked`, or `cancelled`, run `loopctl prepare-finalization` with the terminal status and any merge SHA/failure fingerprint. Post its exact `body` to the configured `stateIssueNumber` using the automation identity, then run `loopctl record-finalization --run-id <id> --result <path> --comment-url <url>`. For completion, pass that result and URL to `observe-owner-merge`. Terminal transitions reject missing, edited, wrong-author, wrong-issue, or digest-mismatched journal entries. Every scheduled wake begins with `loopctl reconcile`, which restores missing local history and recomputes evolve metrics from the append-only journal. diff --git a/loops/issue-dev-loop/schemas/finalization-record.schema.json b/loops/issue-dev-loop/schemas/finalization-record.schema.json new file mode 100644 index 00000000..62b429f1 --- /dev/null +++ b/loops/issue-dev-loop/schemas/finalization-record.schema.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Issue development loop durable finalization record", + "type": "object", + "required": [ + "schemaVersion", + "runId", + "issueNumber", + "status", + "startedAt", + "finishedAt", + "prUrl", + "headSha", + "mergeSha", + "failureFingerprint" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": 1 }, + "runId": { "type": "string", "minLength": 1 }, + "issueNumber": { "type": "integer", "minimum": 1 }, + "status": { "enum": ["completed", "failed", "blocked", "cancelled"] }, + "startedAt": { "type": "string", "format": "date-time" }, + "finishedAt": { "type": "string", "format": "date-time" }, + "prUrl": { "type": ["string", "null"], "format": "uri" }, + "headSha": { + "type": ["string", "null"], + "pattern": "^[0-9a-fA-F]{40}$" + }, + "mergeSha": { + "type": ["string", "null"], + "pattern": "^[0-9a-fA-F]{40}$" + }, + "failureFingerprint": { "type": ["string", "null"] } + } +} diff --git a/loops/issue-dev-loop/scripts/generate-evidence.mjs b/loops/issue-dev-loop/scripts/generate-evidence.mjs index 7ec3a354..2788dc5e 100644 --- a/loops/issue-dev-loop/scripts/generate-evidence.mjs +++ b/loops/issue-dev-loop/scripts/generate-evidence.mjs @@ -5,7 +5,7 @@ import { lstat, mkdir, readFile, writeFile } from 'node:fs/promises' import path from 'node:path' import { DEFAULT_LOOP_ROOT, parseArguments } from './runtime.mjs' -import { assertNonEmpty, pathExists } from './lib/common.mjs' +import { assertNonEmpty, execFileAsync, pathExists } from './lib/common.mjs' const args = parseArguments(process.argv.slice(2)) const loopRoot = args['loop-root'] ? path.resolve(args['loop-root']) : DEFAULT_LOOP_ROOT @@ -17,10 +17,20 @@ if (!['passed', 'failed', 'blocked'].includes(status)) throw new Error('unsuppor const run = JSON.parse( await readFile(path.join(loopRoot, 'logs', 'runs', runId, 'run.json'), 'utf8'), ) -if (!run.briefDigest || !run.implementationCommit || run.headSha !== headSha) { - throw new Error('evidence generation requires the recorded PR head and implementation gates') +if (!run.briefDigest || !run.implementationCommit) { + throw new Error('evidence generation requires the frozen brief and implementation gates') } if (!/^[0-9a-f]{40}$/i.test(headSha)) throw new Error('evidence headSha must be a full Git SHA') +if (process.env.GITHUB_ACTIONS === 'true') { + const repositoryRoot = path.resolve(loopRoot, '..', '..') + const currentHead = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repositoryRoot }) + if (currentHead.stdout.trim() !== headSha) { + throw new Error('evidence headSha does not match the checked-out Git commit') + } + await execFileAsync('git', ['merge-base', '--is-ancestor', run.implementationCommit, headSha], { + cwd: repositoryRoot, + }) +} const frozenBrief = await readFile( path.join(loopRoot, 'handoffs', runId, 'implementation-brief.md'), 'utf8', @@ -101,16 +111,15 @@ if (await pathExists(screenshotMetadataPath)) { } const normalizedScreenshotPath = path.normalize(screenshot.path) const expectedPathPrefix = path.join('screen-shots', runId, screenshot.phase) + path.sep - const expectedSourceSha = screenshot.phase === 'before' ? run.baseSha : headSha + const expectedSourceSha = screenshot.phase === 'before' ? run.baseSha : run.implementationCommit if ( path.isAbsolute(screenshot.path) || !normalizedScreenshotPath.startsWith(expectedPathPrefix) || - screenshot.headSha !== headSha || screenshot.sourceSha !== expectedSourceSha || !/^[0-9a-f]{40}$/i.test(screenshot.sourceSha) || Number.isNaN(Date.parse(screenshot.capturedAt)) ) { - throw new Error('screenshot metadata must match the PR head and include a capture time') + throw new Error('screenshot metadata must match its source commit and include a capture time') } const contents = await readFile(target) const dimensions = readImageDimensions(contents) @@ -127,6 +136,7 @@ if (await pathExists(screenshotMetadataPath)) { } screenshot.width = dimensions.width screenshot.height = dimensions.height + screenshot.headSha = headSha screenshot.sha256 = createHash('sha256').update(contents).digest('hex') } } diff --git a/loops/issue-dev-loop/scripts/lib/evidence.mjs b/loops/issue-dev-loop/scripts/lib/evidence.mjs index 2f53d01e..03431975 100644 --- a/loops/issue-dev-loop/scripts/lib/evidence.mjs +++ b/loops/issue-dev-loop/scripts/lib/evidence.mjs @@ -20,7 +20,7 @@ import { sameGitHubLogin, sameRepository, } from './common.mjs' -import { appendValidatedEvent, readRun } from './run-store.mjs' +import { appendValidatedEvent, readEvents, readRun } from './run-store.mjs' const REVIEW_CLASSIFICATIONS = new Set(['accepted', 'rejected', 'stale', 'already-fixed']) @@ -246,7 +246,7 @@ export async function recordEvidence({ ]) { assertNonEmpty(screenshot[field], `screenshots[${index}].${field}`) } - const expectedSourceSha = screenshot.phase === 'before' ? run.baseSha : headSha + const expectedSourceSha = screenshot.phase === 'before' ? run.baseSha : run.implementationCommit if ( !['before', 'after'].includes(screenshot.phase) || screenshot.headSha !== headSha || @@ -369,9 +369,24 @@ export async function recordReview({ ) { throw new Error('published review is not bound to the recorded live PR head') } + const runEvents = await readEvents(loopRoot, normalizedRunId) for (const finding of reviewSummary.findings) { - if (!publishedBodies.some((body) => body.includes(finding.findingId))) { - throw new Error(`${finding.findingId} is missing from the published GitHub review`) + const findingMarker = `<!-- issue-dev-loop:${normalizedRunId}:${finding.findingId} -->` + const requiredFindingFragments = [ + findingMarker, + finding.findingId, + finding.severity, + finding.confidence, + finding.problem, + finding.evidence, + finding.expectedResolution, + ] + if ( + !publishedBodies.some((body) => + requiredFindingFragments.every((fragment) => body.includes(fragment)), + ) + ) { + throw new Error(`${finding.findingId} is not published verbatim in the GitHub review`) } const responseTarget = parsePullCommentUrl(finding.resolution.responseUrl) if ( @@ -400,6 +415,17 @@ export async function recordReview({ ) } if (finding.resolution.classification === 'accepted') { + const implementationEvent = runEvents.find( + (event) => + event.type === 'implementation_completed' && + event.status === 'passed' && + event.payload?.agent === '$implement' && + event.payload?.briefDigest === run.briefDigest && + event.payload?.commitSha === finding.resolution.fixCommit, + ) + if (!implementationEvent) { + throw new Error(`${finding.findingId} fixCommit lacks a recorded $implement invocation`) + } const [findingToFix, fixToHead] = await Promise.all([ githubApi( `repos/${reviewTarget.owner}/${reviewTarget.repo}/compare/${finding.headSha}...${finding.resolution.fixCommit}`, diff --git a/loops/issue-dev-loop/scripts/lib/evolve.mjs b/loops/issue-dev-loop/scripts/lib/evolve.mjs index 19e1d975..4bbfbf1c 100644 --- a/loops/issue-dev-loop/scripts/lib/evolve.mjs +++ b/loops/issue-dev-loop/scripts/lib/evolve.mjs @@ -1,4 +1,4 @@ -import { randomBytes } from 'node:crypto' +import { readFile } from 'node:fs/promises' import path from 'node:path' import { @@ -7,23 +7,27 @@ import { assertNonEmpty, defaultGitHubApi, readJson, - timestampToken, writeJson, } from './common.mjs' import { observeOwnerApprovedMerge } from './owner-gate.mjs' -export async function updateEvolveMetrics({ loopRoot, status, failureFingerprint, now }) { +export async function updateEvolveMetrics({ loopRoot, now }) { const metricsPath = path.join(loopRoot, 'evolve', 'metrics.json') const metrics = await readJson(metricsPath) - metrics.finalizedRuns += 1 - if (status === 'completed') metrics.successfulRuns += 1 - if (['failed', 'blocked'].includes(status)) metrics.failedRuns += 1 - - const recentFailures = Array.isArray(metrics.recentFailureFingerprints) - ? metrics.recentFailureFingerprints - : [] - recentFailures.push(failureFingerprint || null) - metrics.recentFailureFingerprints = recentFailures.slice(-3) + const history = (await readFile(path.join(loopRoot, 'logs', 'index.jsonl'), 'utf8')) + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) + .filter((entry) => entry.event === 'run_finalized') + .sort((left, right) => Date.parse(left.finishedAt) - Date.parse(right.finishedAt)) + metrics.finalizedRuns = history.length + metrics.successfulRuns = history.filter((entry) => entry.status === 'completed').length + metrics.failedRuns = history.filter((entry) => + ['failed', 'blocked'].includes(entry.status), + ).length + metrics.recentFailureFingerprints = history + .slice(-3) + .map((entry) => entry.failureFingerprint || null) let dueReason = null if (metrics.finalizedRuns - (metrics.lastEvolvedRunCount ?? 0) >= 10) { @@ -37,8 +41,8 @@ export async function updateEvolveMetrics({ loopRoot, status, failureFingerprint } if (dueReason && !metrics.evolveDue) { - const requestId = `EVL-${timestampToken(now).replace('Z', '')}-${randomBytes(3) - .toString('hex') + const requestId = `EVL-${String(metrics.finalizedRuns).padStart(6, '0')}-${dueReason + .replaceAll('_', '-') .toUpperCase()}` await writeJson(path.join(loopRoot, 'evolve', 'requests', `${requestId}.json`), { schemaVersion: 1, diff --git a/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs b/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs new file mode 100644 index 00000000..bdb9d20f --- /dev/null +++ b/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs @@ -0,0 +1,248 @@ +import { createHash } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import path from 'node:path' + +import { + DEFAULT_LOOP_ROOT, + appendJsonLine, + assertNonEmpty, + assertRunId, + defaultGitHubApi, + defaultGitHubPaginatedApi, + parsePullCommentUrl, + readJson, + runDirectory, + sameGitHubLogin, + writeJson, +} from './common.mjs' +import { updateEvolveMetrics } from './evolve.mjs' +import { appendValidatedEvent, readRun } from './run-store.mjs' + +const TERMINAL_STATUSES = new Set(['completed', 'failed', 'blocked', 'cancelled']) + +function canonicalRecord(record) { + return JSON.stringify({ + schemaVersion: 1, + runId: record.runId, + issueNumber: record.issueNumber, + status: record.status, + startedAt: record.startedAt, + finishedAt: record.finishedAt, + prUrl: record.prUrl ?? null, + headSha: record.headSha ?? null, + mergeSha: record.mergeSha ?? null, + failureFingerprint: record.failureFingerprint ?? null, + }) +} + +function recordDigest(record) { + return createHash('sha256').update(canonicalRecord(record)).digest('hex') +} + +function validateRecord(record, run = null) { + if ( + record.schemaVersion !== 1 || + !TERMINAL_STATUSES.has(record.status) || + !Number.isInteger(record.issueNumber) || + Number.isNaN(Date.parse(record.startedAt)) || + Number.isNaN(Date.parse(record.finishedAt)) || + Date.parse(record.finishedAt) < Date.parse(record.startedAt) || + (record.headSha !== null && !/^[0-9a-f]{40}$/i.test(record.headSha)) || + (record.mergeSha !== null && !/^[0-9a-f]{40}$/i.test(record.mergeSha)) + ) { + throw new Error('invalid finalization journal record') + } + if ( + record.status === 'completed' && + (!record.prUrl || !record.headSha || !record.mergeSha || record.failureFingerprint !== null) + ) { + throw new Error('completed finalization record requires PR, head, and merge proof') + } + if ( + ['failed', 'blocked'].includes(record.status) && + !assertNonEmpty(record.failureFingerprint, 'failureFingerprint') + ) { + throw new Error('failed or blocked finalization requires a fingerprint') + } + if ( + run && + (record.runId !== run.runId || + record.issueNumber !== run.issueNumber || + record.startedAt !== run.startedAt || + record.prUrl !== run.prUrl || + record.headSha !== run.headSha) + ) { + throw new Error('finalization journal record does not match the run') + } + return record +} + +async function journalConfiguration(loopRoot) { + const channel = await readJson( + path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'), + ) + if ( + !Number.isInteger(channel.stateIssueNumber) || + channel.stateIssueNumber < 1 || + !channel.automationGitHubLogin + ) { + throw new Error('owner channel must configure stateIssueNumber and automationGitHubLogin') + } + const [owner, repo] = channel.repository.split('/') + return { channel, owner, repo } +} + +export async function prepareFinalizationRecord({ + loopRoot = DEFAULT_LOOP_ROOT, + runId, + status, + mergeSha = null, + failureFingerprint = null, + finishedAt = new Date(), +} = {}) { + const normalizedRunId = assertRunId(runId) + const run = await readRun(loopRoot, normalizedRunId) + if (run.finishedAt !== null) throw new Error('cannot prepare finalization for a finished run') + const record = validateRecord( + { + schemaVersion: 1, + runId: normalizedRunId, + issueNumber: run.issueNumber, + status, + startedAt: run.startedAt, + finishedAt: finishedAt.toISOString(), + prUrl: run.prUrl, + headSha: run.headSha, + mergeSha, + failureFingerprint, + }, + run, + ) + const { channel, owner, repo } = await journalConfiguration(loopRoot) + const digest = recordDigest(record) + const body = [ + `<!-- issue-dev-loop:finalization:${normalizedRunId}:sha256:${digest} -->`, + '```json', + canonicalRecord(record), + '```', + ].join('\n') + const resultPath = path.join(runDirectory(loopRoot, normalizedRunId), 'finalization-result.json') + await writeJson(resultPath, record) + return { + record, + resultPath, + digest, + body, + journalIssueUrl: `https://github.com/${owner}/${repo}/issues/${channel.stateIssueNumber}`, + } +} + +export async function recordFinalizationPublication({ + loopRoot = DEFAULT_LOOP_ROOT, + runId, + resultPath, + commentUrl, + now = new Date(), + githubApi = defaultGitHubApi, +} = {}) { + const normalizedRunId = assertRunId(runId) + const run = await readRun(loopRoot, normalizedRunId) + if (run.finishedAt !== null) throw new Error('cannot publish finalization for a finished run') + const resolvedResultPath = path.resolve(assertNonEmpty(resultPath, 'resultPath')) + const runRoot = runDirectory(loopRoot, normalizedRunId) + if (!resolvedResultPath.startsWith(`${runRoot}${path.sep}`)) { + throw new Error('finalization result must be inside the current run directory') + } + const record = validateRecord(await readJson(resolvedResultPath), run) + const { channel, owner, repo } = await journalConfiguration(loopRoot) + const target = parsePullCommentUrl(assertNonEmpty(commentUrl, 'commentUrl')) + if ( + !target || + target.surface !== 'issues' || + target.kind !== 'issue_comment' || + target.owner.toLowerCase() !== owner.toLowerCase() || + target.repo.toLowerCase() !== repo.toLowerCase() || + target.number !== channel.stateIssueNumber + ) { + throw new Error('finalization comment must be on the configured state journal issue') + } + const comment = await githubApi( + `repos/${target.owner}/${target.repo}/issues/comments/${target.commentId}`, + ) + const digest = recordDigest(record) + const marker = `<!-- issue-dev-loop:finalization:${normalizedRunId}:sha256:${digest} -->` + if ( + !sameGitHubLogin(comment.user?.login, channel.automationGitHubLogin) || + !comment.body?.includes(marker) || + !comment.body?.includes(canonicalRecord(record)) + ) { + throw new Error('published finalization comment does not attest the exact record') + } + await appendValidatedEvent({ + loopRoot, + runId: normalizedRunId, + type: 'finalization_published', + status: record.status, + payload: { + commentUrl, + digest, + finishedAt: record.finishedAt, + mergeSha: record.mergeSha, + failureFingerprint: record.failureFingerprint, + }, + now, + }) + return { record, digest, commentUrl } +} + +export async function reconcileFinalizationJournal({ + loopRoot = DEFAULT_LOOP_ROOT, + now = new Date(), + githubPaginatedApi = defaultGitHubPaginatedApi, +} = {}) { + const { channel, owner, repo } = await journalConfiguration(loopRoot) + const comments = await githubPaginatedApi( + `repos/${owner}/${repo}/issues/${channel.stateIssueNumber}/comments?per_page=100`, + ) + const records = [] + for (const comment of comments) { + if (!sameGitHubLogin(comment.user?.login, channel.automationGitHubLogin)) continue + const marker = comment.body?.match( + /<!-- issue-dev-loop:finalization:([^:]+):sha256:([0-9a-f]{64}) -->/, + ) + const serialized = comment.body?.match(/```json\s*([^\n]+)\s*```/)?.[1] + if (!marker || !serialized) continue + const record = validateRecord(JSON.parse(serialized)) + if (record.runId !== marker[1] || recordDigest(record) !== marker[2]) { + throw new Error(`invalid durable finalization record for ${marker[1]}`) + } + records.push(record) + } + records.sort((left, right) => Date.parse(left.finishedAt) - Date.parse(right.finishedAt)) + + const indexPath = path.join(loopRoot, 'logs', 'index.jsonl') + const existing = (await readFile(indexPath, 'utf8')) + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) + const byRunId = new Map( + existing + .filter((entry) => entry.event === 'run_finalized') + .map((entry) => [entry.runId, entry]), + ) + for (const record of records) { + const prior = byRunId.get(record.runId) + if (prior) { + if (canonicalRecord(prior) !== canonicalRecord(record)) { + throw new Error(`local finalization conflicts with durable journal: ${record.runId}`) + } + continue + } + await appendJsonLine(indexPath, { event: 'run_finalized', ...record }) + byRunId.set(record.runId, record) + } + await updateEvolveMetrics({ loopRoot, now }) + return { reconciled: records.length, durableRunIds: records.map((record) => record.runId) } +} + +export { canonicalRecord, recordDigest } diff --git a/loops/issue-dev-loop/scripts/lib/github.mjs b/loops/issue-dev-loop/scripts/lib/github.mjs index 5deb6682..aae8c4fd 100644 --- a/loops/issue-dev-loop/scripts/lib/github.mjs +++ b/loops/issue-dev-loop/scripts/lib/github.mjs @@ -3,6 +3,7 @@ import path from 'node:path' import { DEFAULT_LOOP_ROOT, + appendJsonLine, assertNonEmpty, assertRunId, defaultGitHubApi, @@ -15,6 +16,10 @@ import { sameGitHubLogin, sameRepository, } from './common.mjs' +import { + reconcileFinalizationJournal, + recordFinalizationPublication, +} from './finalization-journal.mjs' import { observeOwnerApprovedMerge } from './owner-gate.mjs' import { defaultReleaseIssueClaim } from './issue-claim.mjs' import { appendValidatedEvent, finalizeRun, readEvents, readRun } from './run-store.mjs' @@ -71,15 +76,30 @@ export async function detectWork({ issuesFile, pullRequestsFile, repo, + now = new Date(), + reconcileJournal = reconcileFinalizationJournal, } = {}) { + const recordTriggerCheck = async (result) => { + await appendJsonLine(path.join(loopRoot, 'logs', 'triggers.jsonl'), { + schemaVersion: 1, + event: 'trigger_checked', + timestamp: now.toISOString(), + hasWork: result.hasWork, + workType: result.workType, + issueNumber: result.issue?.number ?? null, + requestId: result.requestId ?? null, + }) + return result + } + if (!issuesFile && !pullRequestsFile) await reconcileJournal({ loopRoot, now }) const evolve = await readJson(path.join(loopRoot, 'evolve', 'metrics.json')) if (evolve.evolveDue) { - return { + return recordTriggerCheck({ hasWork: true, workType: 'evolve', requestId: evolve.pendingRequestId, issue: null, - } + }) } let issues let pullRequests @@ -119,7 +139,7 @@ export async function detectWork({ const result = await execFileAsync('gh', argumentsList, { maxBuffer: 1024 * 1024 }) pullRequests = JSON.parse(result.stdout) } - return { workType: 'issue', ...selectIssue({ issues, pullRequests }) } + return recordTriggerCheck({ workType: 'issue', ...selectIssue({ issues, pullRequests }) }) } export async function observeOwnerMerge({ @@ -128,6 +148,9 @@ export async function observeOwnerMerge({ now = new Date(), githubApi = defaultGitHubApi, releaseIssueClaim = defaultReleaseIssueClaim, + finalizationResultPath, + finalizationCommentUrl, + recordFinalization = recordFinalizationPublication, } = {}) { const normalizedRunId = assertRunId(runId) const run = await readRun(loopRoot, normalizedRunId) @@ -168,6 +191,14 @@ export async function observeOwnerMerge({ }, now, }) + await recordFinalization({ + loopRoot, + runId: normalizedRunId, + resultPath: finalizationResultPath, + commentUrl: finalizationCommentUrl, + now, + githubApi, + }) return finalizeRun({ loopRoot, runId: normalizedRunId, diff --git a/loops/issue-dev-loop/scripts/lib/run-store.mjs b/loops/issue-dev-loop/scripts/lib/run-store.mjs index 9c8d5b7e..6951fcee 100644 --- a/loops/issue-dev-loop/scripts/lib/run-store.mjs +++ b/loops/issue-dev-loop/scripts/lib/run-store.mjs @@ -37,9 +37,11 @@ const RESERVED_EVENT_TYPES = new Set([ 'owner_response_observed', 'brief_frozen', 'implementation_completed', + 'finalization_published', 'owner_review_approved', 'pr_merged', 'run_status_changed', + 'run_finalization_authorized', 'run_finalized', ]) @@ -378,16 +380,22 @@ export async function recordPullRequest({ const livePullRequest = await githubApi( `repos/${pullTarget.owner}/${pullTarget.repo}/pulls/${pullTarget.number}`, ) + const isInitialBinding = run.prUrl === null if ( livePullRequest.state !== 'open' || - livePullRequest.draft !== true || + (isInitialBinding && livePullRequest.draft !== true) || livePullRequest.base?.ref !== 'dev' || livePullRequest.head?.ref !== run.branch || livePullRequest.head?.repo?.full_name?.toLowerCase() !== `${pullTarget.owner}/${pullTarget.repo}`.toLowerCase() || livePullRequest.head?.sha !== headSha ) { - throw new Error('record-pr requires a live draft PR to dev at the exact run branch and headSha') + throw new Error( + 'record-pr requires a live PR to dev at the exact run branch and headSha; first binding must be draft', + ) + } + if (run.prUrl !== null && run.prUrl !== prUrl) { + throw new Error('record-pr cannot rebind an existing run to a different pull request') } const requiredBodyFragments = [ `Closes #${run.issueNumber}`, @@ -477,6 +485,77 @@ function hasPassedEventForHead(events, type, headSha) { ) } +async function ensureFinalizationArtifacts({ + loopRoot, + run, + previousStatus, + failureFingerprint, + now, +}) { + const runPath = runDirectory(loopRoot, run.runId) + const events = await readEvents(loopRoot, run.runId) + if ( + !events.some( + (event) => + event.type === 'run_finalized' && + event.status === run.status && + event.payload?.previousStatus === previousStatus, + ) + ) { + await appendValidatedEvent({ + loopRoot, + runId: run.runId, + type: 'run_finalized', + status: run.status, + payload: { previousStatus }, + now, + }) + } + + const summaryTemplate = await readFile(path.join(loopRoot, 'templates', 'run-summary.md'), 'utf8') + await writeFile( + path.join(runPath, 'summary.md'), + replaceTemplate(summaryTemplate, { + RUN_ID: run.runId, + ISSUE_NUMBER: run.issueNumber, + STATUS: run.status, + STARTED_AT: run.startedAt, + FINISHED_AT: run.finishedAt, + PR_URL: run.prUrl ?? 'N/A', + HEAD_SHA: run.headSha ?? 'N/A', + MERGE_SHA: run.mergeSha ?? 'N/A', + }), + 'utf8', + ) + + const indexPath = path.join(loopRoot, 'logs', 'index.jsonl') + const indexEntries = (await readFile(indexPath, 'utf8')) + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) + if (!indexEntries.some((entry) => entry.event === 'run_finalized' && entry.runId === run.runId)) { + await appendJsonLine(indexPath, { + schemaVersion: 1, + event: 'run_finalized', + runId: run.runId, + issueNumber: run.issueNumber, + status: run.status, + startedAt: run.startedAt, + finishedAt: run.finishedAt, + prUrl: run.prUrl, + headSha: run.headSha, + mergeSha: run.mergeSha, + failureFingerprint, + }) + } + await updateEvolveMetrics({ loopRoot, now }) + await rm(path.join(loopRoot, 'logs', 'claims', `issue-${run.issueNumber}`), { + recursive: true, + force: true, + }) + return run +} + export async function transitionRun({ loopRoot = DEFAULT_LOOP_ROOT, runId, @@ -495,7 +574,25 @@ export async function transitionRun({ const runPath = runDirectory(loopRoot, normalizedRunId) const runFile = path.join(runPath, 'run.json') const run = await readJson(runFile) - if (run.finishedAt !== null) throw new Error(`run is already finalized: ${normalizedRunId}`) + if (run.finishedAt !== null) { + const existingEvents = await readEvents(loopRoot, normalizedRunId) + const authorization = existingEvents.findLast( + (event) => + event.type === 'run_finalization_authorized' && + event.status === run.status && + event.payload?.finishedAt === run.finishedAt, + ) + if (!TERMINAL_STATUSES.has(status) || status !== run.status || !authorization) { + throw new Error(`run is already finalized: ${normalizedRunId}`) + } + return ensureFinalizationArtifacts({ + loopRoot, + run, + previousStatus: authorization.payload.previousStatus, + failureFingerprint: authorization.payload.failureFingerprint ?? null, + now: new Date(run.finishedAt), + }) + } if (run.status === status) throw new Error(`run already has status: ${status}`) const events = await readEvents(loopRoot, normalizedRunId) if (!ALLOWED_TRANSITIONS.get(run.status)?.has(status)) { @@ -543,7 +640,9 @@ export async function transitionRun({ (event) => event.type === 'owner_notified' && event.status === 'delivered' && - event.payload?.notificationType === 'pr_ready_for_review' && + ['pr_ready_for_review', 'pr_updated_for_review'].includes( + event.payload?.notificationType, + ) && event.payload?.delivery?.github === 'delivered' && event.payload?.targetUrl === prUrl && event.payload?.headSha === headSha, @@ -631,59 +730,60 @@ export async function transitionRun({ }) } + const finalizationPublication = TERMINAL_STATUSES.has(status) + ? events.findLast( + (event) => + event.type === 'finalization_published' && + event.status === status && + event.payload?.mergeSha === (mergeSha ?? run.mergeSha ?? null) && + event.payload?.failureFingerprint === (failureFingerprint ?? null), + ) + : null + if (TERMINAL_STATUSES.has(status) && !finalizationPublication) { + throw new Error(`${status} requires a matching durable finalization journal publication`) + } + const transitioned = { ...run, status, - finishedAt: TERMINAL_STATUSES.has(status) ? now.toISOString() : null, + finishedAt: TERMINAL_STATUSES.has(status) ? finalizationPublication.payload.finishedAt : null, prUrl: prUrl ?? run.prUrl, headSha: headSha ?? run.headSha, mergeSha: mergeSha ?? run.mergeSha, } + if (TERMINAL_STATUSES.has(status)) { + await appendValidatedEvent({ + loopRoot, + runId: normalizedRunId, + type: 'run_finalization_authorized', + status, + payload: { + previousStatus: run.status, + finishedAt: transitioned.finishedAt, + failureFingerprint, + }, + now, + }) + } await writeJson(runFile, transitioned) - await appendValidatedEvent({ + if (!TERMINAL_STATUSES.has(status)) { + await appendValidatedEvent({ + loopRoot, + runId: normalizedRunId, + type: 'run_status_changed', + status, + payload: { previousStatus: run.status }, + now, + }) + return transitioned + } + return ensureFinalizationArtifacts({ loopRoot, - runId: normalizedRunId, - type: TERMINAL_STATUSES.has(status) ? 'run_finalized' : 'run_status_changed', - status, - payload: { previousStatus: run.status }, - now, - }) - if (!TERMINAL_STATUSES.has(status)) return transitioned - - const summaryTemplate = await readFile(path.join(loopRoot, 'templates', 'run-summary.md'), 'utf8') - await writeFile( - path.join(runPath, 'summary.md'), - replaceTemplate(summaryTemplate, { - RUN_ID: normalizedRunId, - ISSUE_NUMBER: run.issueNumber, - STATUS: status, - STARTED_AT: run.startedAt, - FINISHED_AT: transitioned.finishedAt, - PR_URL: transitioned.prUrl ?? 'N/A', - HEAD_SHA: transitioned.headSha ?? 'N/A', - MERGE_SHA: transitioned.mergeSha ?? 'N/A', - }), - 'utf8', - ) - await appendJsonLine(path.join(loopRoot, 'logs', 'index.jsonl'), { - schemaVersion: 1, - event: 'run_finalized', - runId: normalizedRunId, - issueNumber: run.issueNumber, - status, - startedAt: run.startedAt, - finishedAt: transitioned.finishedAt, - prUrl: transitioned.prUrl, - headSha: transitioned.headSha, - mergeSha: transitioned.mergeSha, + run: transitioned, + previousStatus: run.status, failureFingerprint, + now, }) - await updateEvolveMetrics({ loopRoot, status, failureFingerprint, now }) - await rm(path.join(loopRoot, 'logs', 'claims', `issue-${run.issueNumber}`), { - recursive: true, - force: true, - }) - return transitioned } export async function finalizeRun(options = {}) { diff --git a/loops/issue-dev-loop/scripts/lib/validation.mjs b/loops/issue-dev-loop/scripts/lib/validation.mjs index 196f5d56..5dde5408 100644 --- a/loops/issue-dev-loop/scripts/lib/validation.mjs +++ b/loops/issue-dev-loop/scripts/lib/validation.mjs @@ -32,6 +32,7 @@ export async function validateLoop({ loopRoot = DEFAULT_LOOP_ROOT } = {}) { 'schemas/event.schema.json', 'schemas/run.schema.json', 'schemas/evidence.schema.json', + 'schemas/finalization-record.schema.json', 'schemas/implementation-result.schema.json', 'scripts/generate-evidence.mjs', 'scripts/resolve-run.mjs', @@ -39,6 +40,7 @@ export async function validateLoop({ loopRoot = DEFAULT_LOOP_ROOT } = {}) { 'scripts/lib/common.mjs', 'scripts/lib/evidence.mjs', 'scripts/lib/evolve.mjs', + 'scripts/lib/finalization-journal.mjs', 'scripts/lib/github.mjs', 'scripts/lib/issue-claim.mjs', 'scripts/lib/notifications.mjs', @@ -46,6 +48,7 @@ export async function validateLoop({ loopRoot = DEFAULT_LOOP_ROOT } = {}) { 'scripts/lib/run-store.mjs', 'scripts/lib/validation.mjs', 'logs/index.jsonl', + 'logs/triggers.jsonl', 'screen-shots/.gitignore', ] const missing = [] @@ -73,11 +76,19 @@ export async function validateLoop({ loopRoot = DEFAULT_LOOP_ROOT } = {}) { if (new Set(finalizedRunIds).size !== finalizedRunIds.length) { throw new Error('logs/index.jsonl contains duplicate finalized run IDs') } + const triggerLines = (await readFile(path.join(loopRoot, 'logs', 'triggers.jsonl'), 'utf8')) + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) + if (triggerLines[0]?.event !== 'trigger_log_initialized') { + throw new Error('logs/triggers.jsonl must start with trigger_log_initialized') + } const channel = await readJson(path.join(sharedChannelRoot, 'channel.json')) if ( typeof channel.ownerGitHubLogin !== 'string' || !Object.hasOwn(channel, 'automationGitHubLogin') || !Object.hasOwn(channel, 'reviewerGitHubLogin') || + !Object.hasOwn(channel, 'stateIssueNumber') || channel.repository !== 'codeacme17/echo-ui' || !Array.isArray(channel.immediateTypes) ) { diff --git a/loops/issue-dev-loop/scripts/loopctl.mjs b/loops/issue-dev-loop/scripts/loopctl.mjs index 74095148..41d1d710 100644 --- a/loops/issue-dev-loop/scripts/loopctl.mjs +++ b/loops/issue-dev-loop/scripts/loopctl.mjs @@ -10,7 +10,10 @@ import { getEvolveStatus, observeOwnerMerge, parseArguments, + prepareFinalizationRecord, + reconcileFinalizationJournal, recordEvidence, + recordFinalizationPublication, recordImplementation, recordOwnerResponse, recordPullRequest, @@ -122,8 +125,37 @@ async function main() { }), ) break + case 'record-finalization': + output( + await recordFinalizationPublication({ + runId: args['run-id'], + resultPath: args.result, + commentUrl: args['comment-url'], + }), + ) + break + case 'prepare-finalization': + output( + await prepareFinalizationRecord({ + runId: args['run-id'], + status: args.status, + mergeSha: args['merge-sha'] ?? null, + failureFingerprint: args['failure-fingerprint'] ?? null, + finishedAt: args['finished-at'] ? new Date(args['finished-at']) : undefined, + }), + ) + break + case 'reconcile': + output(await reconcileFinalizationJournal()) + break case 'observe-owner-merge': - output(await observeOwnerMerge({ runId: args['run-id'] })) + output( + await observeOwnerMerge({ + runId: args['run-id'], + finalizationResultPath: args.result, + finalizationCommentUrl: args['comment-url'], + }), + ) break case 'notify': output( @@ -165,7 +197,7 @@ async function main() { break default: throw new Error( - 'usage: loopctl.mjs <start|freeze-brief|record-implementation|event|record-pr|record-owner-response|record-evidence|record-review|transition|finalize|observe-owner-merge|notify|detect-work|validate|evolve-status|evolve-complete> [options]', + 'usage: loopctl.mjs <start|freeze-brief|record-implementation|event|record-pr|record-owner-response|record-evidence|record-review|prepare-finalization|record-finalization|reconcile|transition|finalize|observe-owner-merge|notify|detect-work|validate|evolve-status|evolve-complete> [options]', ) } } diff --git a/loops/issue-dev-loop/scripts/runtime.mjs b/loops/issue-dev-loop/scripts/runtime.mjs index 648b5ba9..ad140960 100644 --- a/loops/issue-dev-loop/scripts/runtime.mjs +++ b/loops/issue-dev-loop/scripts/runtime.mjs @@ -1,6 +1,13 @@ export { DEFAULT_LOOP_ROOT, parseArguments } from './lib/common.mjs' export { completeEvolve, getEvolveStatus } from './lib/evolve.mjs' export { recordEvidence, recordReview } from './lib/evidence.mjs' +export { + canonicalRecord, + prepareFinalizationRecord, + reconcileFinalizationJournal, + recordDigest, + recordFinalizationPublication, +} from './lib/finalization-journal.mjs' export { detectWork, observeOwnerMerge, recordOwnerResponse, selectIssue } from './lib/github.mjs' export { createNotification } from './lib/notifications.mjs' export { defaultClaimIssue } from './lib/issue-claim.mjs' diff --git a/loops/issue-dev-loop/scripts/validate-history.mjs b/loops/issue-dev-loop/scripts/validate-history.mjs index aead2356..78f66c63 100644 --- a/loops/issue-dev-loop/scripts/validate-history.mjs +++ b/loops/issue-dev-loop/scripts/validate-history.mjs @@ -9,14 +9,16 @@ import { execFileAsync } from './lib/common.mjs' const args = parseArguments(process.argv.slice(2)) const loopRoot = args['loop-root'] ? path.resolve(args['loop-root']) : DEFAULT_LOOP_ROOT const repositoryRoot = path.resolve(loopRoot, '..', '..') -const relativeIndex = path.relative(repositoryRoot, path.join(loopRoot, 'logs', 'index.jsonl')) const baseRef = args['base-ref'] ?? 'origin/dev' -const current = await readFile(path.join(repositoryRoot, relativeIndex), 'utf8') -const base = await execFileAsync('git', ['show', `${baseRef}:${relativeIndex}`], { - cwd: repositoryRoot, - maxBuffer: 1024 * 1024, -}) -if (!current.startsWith(base.stdout)) { - throw new Error('logs/index.jsonl must preserve the dev history as an exact prefix') +for (const logName of ['index.jsonl', 'triggers.jsonl']) { + const relativeLog = path.relative(repositoryRoot, path.join(loopRoot, 'logs', logName)) + const current = await readFile(path.join(repositoryRoot, relativeLog), 'utf8') + const base = await execFileAsync('git', ['show', `${baseRef}:${relativeLog}`], { + cwd: repositoryRoot, + maxBuffer: 1024 * 1024, + }) + if (!current.startsWith(base.stdout)) { + throw new Error(`${relativeLog} must preserve the dev history as an exact prefix`) + } } process.stdout.write('append-only history verified\n') diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index 582b2843..7b3cc132 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -10,14 +10,19 @@ import { fileURLToPath } from 'node:url' import { appendEvent, + canonicalRecord, completeEvolve, createNotification, defaultClaimIssue, + detectWork, finalizeRun, freezeBrief, getEvolveStatus, observeOwnerMerge, + reconcileFinalizationJournal, recordEvidence, + recordDigest, + recordFinalizationPublication, recordImplementation, recordOwnerResponse, recordPullRequest, @@ -52,6 +57,11 @@ async function createFixture() { '{"schemaVersion":1,"event":"loop_initialized"}\n', 'utf8', ) + await writeFile( + path.join(loopRoot, 'logs', 'triggers.jsonl'), + '{"schemaVersion":1,"event":"trigger_log_initialized"}\n', + 'utf8', + ) await writeFile( path.join(loopRoot, 'evolve', 'metrics.json'), `${JSON.stringify({ @@ -79,6 +89,7 @@ async function createFixture() { ownerGitHubLogin: 'codeacme17', automationGitHubLogin: 'echo-ui-loop[bot]', reviewerGitHubLogin: 'echo-ui-reviewer[bot]', + stateIssueNumber: 999, repository: 'codeacme17/echo-ui', webhookEnvironmentVariable: 'TEST_LOOP_WEBHOOK_URL', immediateTypes: [ @@ -252,6 +263,58 @@ async function writePassingReview({ loopRoot, run, headSha }) { return resultPath } +async function writeFixtureFinalization({ + loopRoot, + runId, + status, + finishedAt, + mergeSha = null, + failureFingerprint = null, +}) { + const run = JSON.parse( + await readFile(path.join(loopRoot, 'logs', 'runs', runId, 'run.json'), 'utf8'), + ) + const record = { + schemaVersion: 1, + runId, + issueNumber: run.issueNumber, + status, + startedAt: run.startedAt, + finishedAt, + prUrl: run.prUrl, + headSha: run.headSha, + mergeSha, + failureFingerprint, + } + const resultPath = path.join(loopRoot, 'logs', 'runs', runId, 'finalization-result.json') + await writeFile(resultPath, `${canonicalRecord(record)}\n`, 'utf8') + const digest = recordDigest(record) + const commentUrl = 'https://github.com/codeacme17/echo-ui/issues/999#issuecomment-9900' + const githubApi = async () => ({ + user: { login: 'echo-ui-loop[bot]' }, + body: [ + `<!-- issue-dev-loop:finalization:${runId}:sha256:${digest} -->`, + '```json', + canonicalRecord(record), + '```', + ].join('\n'), + }) + return { record, resultPath, commentUrl, githubApi } +} + +async function publishFixtureFinalization(options) { + const fixture = await writeFixtureFinalization(options) + await recordFinalizationPublication({ + loopRoot: options.loopRoot, + runId: options.runId, + resultPath: fixture.resultPath, + commentUrl: fixture.commentUrl, + githubApi: fixture.githubApi, + now: new Date(fixture.record.finishedAt), + }) + return fixture +} + test('selectIssue chooses the highest-priority eligible unclaimed issue', () => { const result = selectIssue({ issues: [ @@ -287,6 +350,23 @@ test('selectIssue chooses the highest-priority eligible unclaimed issue', () => assert.equal(result.issue.number, 13) }) +test('detectWork records a durable no-work trigger check without waking an executor', async () => { + const { loopRoot } = await createFixture() + const issuesFile = path.join(loopRoot, 'issues.json') + const pullsFile = path.join(loopRoot, 'pulls.json') + await Promise.all([writeFile(issuesFile, '[]\n', 'utf8'), writeFile(pullsFile, '[]\n', 'utf8')]) + const result = await detectWork({ + loopRoot, + issuesFile, + pullRequestsFile: pullsFile, + now: new Date('2026-07-22T14:00:00.000Z'), + }) + assert.equal(result.hasWork, false) + const triggerLog = await readFile(path.join(loopRoot, 'logs', 'triggers.jsonl'), 'utf8') + assert.match(triggerLog, /"event":"trigger_checked"/) + assert.match(triggerLog, /"hasWork":false/) +}) + test('authoritative claim rejects any paginated open PR that references the issue', async () => { let labelAdded = false await assert.rejects( @@ -446,6 +526,19 @@ test('frozen brief and $implement invocation history cannot be rewritten or reus /must be unique/, ) + const updatedHead = '5'.repeat(40) + const rebound = await recordPullRequest({ + loopRoot, + runId: run.runId, + prUrl, + headSha: updatedHead, + githubApi: async (endpoint) => + endpoint.includes('/compare/') + ? { status: 'ahead', base_commit: { sha: secondCommit } } + : pullRequestFixture(run, updatedHead, { draft: false }), + }) + assert.equal(rebound.headSha, updatedHead) + const briefPath = path.join(loopRoot, 'handoffs', run.runId, 'implementation-brief.md') await writeFile( briefPath, @@ -457,8 +550,8 @@ test('frozen brief and $implement invocation history cannot be rewritten or reus loopRoot, runId: run.runId, prUrl, - headSha: '5'.repeat(40), - githubApi: async () => pullRequestFixture(run, '5'.repeat(40)), + headSha: '6'.repeat(40), + githubApi: async () => pullRequestFixture(run, '6'.repeat(40), { draft: false }), }), /brief changed after freeze-brief/, ) @@ -554,7 +647,6 @@ test('CI helpers resolve a run and generate exact-head screenshot evidence', asy viewport: '1280x720', path: beforePath, capturedAt, - headSha, sourceSha: run.baseSha, }, { @@ -565,8 +657,7 @@ test('CI helpers resolve a run and generate exact-head screenshot evidence', asy viewport: '1280x720', path: screenshotRelativePath, capturedAt, - headSha, - sourceSha: headSha, + sourceSha: '1'.repeat(40), }, ], })}\n`, @@ -773,6 +864,13 @@ test('owner-ready transition requires verification and review but remains resuma /not approved and merged by the configured owner/, ) + const completionJournal = await writeFixtureFinalization({ + loopRoot, + runId: run.runId, + status: 'completed', + finishedAt: '2026-07-23T09:00:00.000Z', + mergeSha: '9'.repeat(40), + }) const finalized = await observeOwnerMerge({ loopRoot, runId: run.runId, @@ -790,6 +888,10 @@ test('owner-ready transition requires verification and review but remains resuma ...pullRequestFixture(run, headSha, { draft: false, merged: true }), }, releaseIssueClaim: async () => {}, + finalizationResultPath: completionJournal.resultPath, + finalizationCommentUrl: completionJournal.commentUrl, + recordFinalization: (options) => + recordFinalizationPublication({ ...options, githubApi: completionJournal.githubApi }), }) assert.equal(finalized.status, 'completed') assert.equal(finalized.mergeSha, '9'.repeat(40)) @@ -885,7 +987,16 @@ test('review gate verifies published findings and classified replies', async () commit_id: headSha, state: 'COMMENTED', user: { login: 'echo-ui-reviewer[bot]' }, - body: `RVW-1-1\n<!-- issue-dev-loop:${run.runId}:review-result-sha256:${digest} -->`, + body: [ + 'RVW-1-1', + 'P2', + 'high', + 'Incorrect assertion', + 'The runtime check already guarantees this invariant.', + 'Prove or fix the assertion.', + `<!-- issue-dev-loop:${run.runId}:RVW-1-1 -->`, + `<!-- issue-dev-loop:${run.runId}:review-result-sha256:${digest} -->`, + ].join('\n'), } }, }) @@ -906,6 +1017,37 @@ test('accepted review fix must be after the finding head and inside the final he const fixCommit = 'b'.repeat(40) const headSha = 'c'.repeat(40) await recordFixturePr({ loopRoot, run, headSha, number: 304 }) + const recordedRun = JSON.parse( + await readFile(path.join(loopRoot, 'logs', 'runs', run.runId, 'run.json'), 'utf8'), + ) + const repairResultPath = path.join( + loopRoot, + 'logs', + 'runs', + run.runId, + 'implementation-result-review-fix.json', + ) + await writeFile( + repairResultPath, + `${JSON.stringify({ + schemaVersion: 1, + runId: run.runId, + agent: '$implement', + invocationId: 'impl-review-fix', + startedAt: '2026-07-22T17:00:00.000Z', + finishedAt: '2026-07-22T17:30:00.000Z', + briefDigest: recordedRun.briefDigest, + commitSha: fixCommit, + checks: [{ command: 'pnpm verify', status: 'passed' }], + })}\n`, + 'utf8', + ) + await recordImplementation({ + loopRoot, + runId: run.runId, + resultPath: repairResultPath, + commitRangeValidator: async () => {}, + }) const resultPath = path.join(loopRoot, 'logs', 'runs', run.runId, 'review-result.json') const result = { schemaVersion: 1, @@ -968,7 +1110,16 @@ test('accepted review fix must be after the finding head and inside the final he commit_id: headSha, state: 'COMMENTED', user: { login: 'echo-ui-reviewer[bot]' }, - body: `RVW-1-1\n<!-- issue-dev-loop:${run.runId}:review-result-sha256:${digest} -->`, + body: [ + 'RVW-1-1', + 'P2', + 'high', + 'Missing guard', + 'The failure is reproducible.', + 'Add the guard.', + `<!-- issue-dev-loop:${run.runId}:RVW-1-1 -->`, + `<!-- issue-dev-loop:${run.runId}:review-result-sha256:${digest} -->`, + ].join('\n'), } }, }) @@ -1053,7 +1204,16 @@ test('review gate binds high-severity adjudication verdict to the correct identi commit_id: headSha, state: 'COMMENTED', user: { login: 'echo-ui-reviewer[bot]' }, - body: `RVW-1-1\n<!-- issue-dev-loop:${run.runId}:review-result-sha256:${digest} -->`, + body: [ + 'RVW-1-1', + 'P1', + 'high', + 'Potential public API break', + 'The export changed.', + 'Restore compatibility or adjudicate.', + `<!-- issue-dev-loop:${run.runId}:RVW-1-1 -->`, + `<!-- issue-dev-loop:${run.runId}:review-result-sha256:${digest} -->`, + ].join('\n'), } }, }), @@ -1220,17 +1380,30 @@ test('three matching failures make a fresh evolve session due', async () => { blocking: true, githubComment: async () => {}, }) - await finalizeRun({ + const finalizationOptions = { loopRoot, runId: run.runId, status: 'blocked', failureFingerprint: 'browser-environment-unavailable', releaseIssueClaim: async () => {}, + } + await publishFixtureFinalization({ + loopRoot, + runId: run.runId, + status: 'blocked', + finishedAt: `2030-01-01T00:0${issueNumber - 200}:00.000Z`, + failureFingerprint: 'browser-environment-unavailable', }) + await finalizeRun(finalizationOptions) + if (issueNumber === 201) await finalizeRun(finalizationOptions) } const metrics = await getEvolveStatus({ loopRoot }) assert.equal(metrics.evolveDue, true) assert.equal(metrics.failedRuns, 3) + const history = (await readFile(path.join(loopRoot, 'logs', 'index.jsonl'), 'utf8')) + .split('\n') + .filter((line) => line.includes('run_finalized')) + assert.equal(history.length, 3) assert.match(metrics.pendingRequestId, /^EVL-/) await assert.rejects( startFixtureRun({ @@ -1244,6 +1417,44 @@ test('three matching failures make a fresh evolve session due', async () => { ) }) +test('fresh worktrees rebuild finalization history and evolve metrics from GitHub journal', async () => { + const { loopRoot } = await createFixture() + const record = { + schemaVersion: 1, + runId: '20260722T120000Z-issue-205-journal', + issueNumber: 205, + status: 'blocked', + startedAt: '2026-07-22T12:00:00.000Z', + finishedAt: '2026-07-22T13:00:00.000Z', + prUrl: null, + headSha: null, + mergeSha: null, + failureFingerprint: 'persistent-browser-failure', + } + const digest = recordDigest(record) + const result = await reconcileFinalizationJournal({ + loopRoot, + now: new Date('2026-07-22T14:00:00.000Z'), + githubPaginatedApi: async () => [ + { + user: { login: 'echo-ui-loop[bot]' }, + body: [ + `<!-- issue-dev-loop:finalization:${record.runId}:sha256:${digest} -->`, + '```json', + canonicalRecord(record), + '```', + ].join('\n'), + }, + ], + }) + assert.deepEqual(result.durableRunIds, [record.runId]) + const history = await readFile(path.join(loopRoot, 'logs', 'index.jsonl'), 'utf8') + assert.match(history, new RegExp(record.runId)) + const metrics = await getEvolveStatus({ loopRoot }) + assert.equal(metrics.finalizedRuns, 1) + assert.equal(metrics.failedRuns, 1) +}) + test('evolve completion rejects an unrelated historical owner-merged PR', async () => { const { loopRoot } = await createFixture() const requestId = 'EVL-20260722T120000-ABC123' diff --git a/loops/issue-dev-loop/triggers/codex-automation-prompt.md b/loops/issue-dev-loop/triggers/codex-automation-prompt.md index fa7f8fd6..d2b90f8f 100644 --- a/loops/issue-dev-loop/triggers/codex-automation-prompt.md +++ b/loops/issue-dev-loop/triggers/codex-automation-prompt.md @@ -1,3 +1,3 @@ Use `$issue-dev-loop` in this repository. -First run `node loops/issue-dev-loop/scripts/loopctl.mjs evolve-status`. A pending evolve request starts a fresh `echo_ui_loop_evolver` session and an owner-reviewed evolve PR before routine product work. Otherwise run `node loops/issue-dev-loop/triggers/detect-work.mjs`. If it returns `hasWork: false`, report a quiet no-op and stop. If it returns an issue, execute one bounded issue-dev-loop run for that exact issue. Preserve owner-only review and merge, use `$implement` for product code, use a fresh read-only reviewer after the draft PR, and notify the owner for every blocking event or ready PR. +First run `node loops/issue-dev-loop/scripts/loopctl.mjs reconcile`, then `node loops/issue-dev-loop/scripts/loopctl.mjs evolve-status`. A pending evolve request starts a fresh `echo_ui_loop_evolver` session and an owner-reviewed evolve PR before routine product work. Otherwise run `node loops/issue-dev-loop/triggers/detect-work.mjs`. If it returns `hasWork: false`, report a quiet no-op and stop. If it returns an issue, execute one bounded issue-dev-loop run for that exact issue. Preserve owner-only review and merge, use `$implement` for product code, use a fresh read-only reviewer after the draft PR, publish terminal state to the durable GitHub journal, and notify the owner for every blocking event or ready PR. From 06320b9993a05e0b331133ee81204d967c677b9c Mon Sep 17 00:00:00 2001 From: leyoonafr <codeacme17@gmail.com> Date: Wed, 22 Jul 2026 23:49:09 +0800 Subject: [PATCH 07/41] fix: make issue loop durably resumable --- .codex/agents/echo-ui-pr-reviewer.toml | 5 +- loops/_shared/owner-channel/CHANNEL.md | 2 +- loops/issue-dev-loop/LOOP.md | 6 +- loops/issue-dev-loop/SKILL.md | 8 +- loops/issue-dev-loop/dependencies.md | 2 +- .../references/evidence-policy.md | 2 +- .../references/github-operations.md | 6 +- loops/issue-dev-loop/review/REVIEW.md | 4 +- .../issue-dev-loop/review/response-policy.md | 4 +- .../issue-dev-loop/review/result.schema.json | 3 +- .../schemas/checkpoint-record.schema.json | 19 + .../scripts/lib/active-journal.mjs | 362 ++++++++++++++++++ loops/issue-dev-loop/scripts/lib/evidence.mjs | 277 ++++++++------ .../scripts/lib/finalization-journal.mjs | 27 +- loops/issue-dev-loop/scripts/lib/github.mjs | 37 +- .../scripts/lib/notifications.mjs | 31 +- .../issue-dev-loop/scripts/lib/run-store.mjs | 85 +++- .../issue-dev-loop/scripts/lib/validation.mjs | 2 + loops/issue-dev-loop/scripts/loopctl.mjs | 20 +- loops/issue-dev-loop/scripts/runtime.mjs | 15 +- loops/issue-dev-loop/state.md | 2 +- loops/issue-dev-loop/tests/runtime.test.mjs | 289 ++++++++++++-- 22 files changed, 994 insertions(+), 214 deletions(-) create mode 100644 loops/issue-dev-loop/schemas/checkpoint-record.schema.json create mode 100644 loops/issue-dev-loop/scripts/lib/active-journal.mjs diff --git a/.codex/agents/echo-ui-pr-reviewer.toml b/.codex/agents/echo-ui-pr-reviewer.toml index 1896b600..2df66f3c 100644 --- a/.codex/agents/echo-ui-pr-reviewer.toml +++ b/.codex/agents/echo-ui-pr-reviewer.toml @@ -12,6 +12,7 @@ review with stable finding IDs, severity P0-P3, confidence, file and line, concr evidence, reproduction when possible, and expected resolution. Return PASS when no actionable findings exist. Verify `gh api user` matches the configured reviewerGitHubLogin, then publish the non-approving COMMENT review yourself; never -let the executor identity publish on your behalf. For the final PASS, include the -cycle-result digest marker and all prior finding IDs and return its review URL. +let the executor identity publish on your behalf. Every round must include its +run/round/head marker and return its unique review URL. For the final PASS, include +the cycle-result digest marker and all prior finding IDs and return its review URL. """ diff --git a/loops/_shared/owner-channel/CHANNEL.md b/loops/_shared/owner-channel/CHANNEL.md index beeb3b07..75469d1b 100644 --- a/loops/_shared/owner-channel/CHANNEL.md +++ b/loops/_shared/owner-channel/CHANNEL.md @@ -13,7 +13,7 @@ Each blocking GitHub notification prints a unique resume instruction. To continu ## Runtime setup 1. Authenticate the unattended executor and fresh reviewer with distinct GitHub identities; set their exact logins as `automationGitHubLogin` and `reviewerGitHubLogin` in `channel.json`. -2. Create one dedicated repository issue for the append-only loop finalization journal and set its number as `stateIssueNumber`. Restrict journal entries to the automation identity; humans may read but should not edit or delete them. +2. Create one dedicated repository issue for the append-only loop state journal and set its number as `stateIssueNumber`. It stores active checkpoints and terminal records. Restrict journal entries to the automation identity; humans may read but should not edit or delete them. 3. Enable GitHub notifications for mentions and review requests for `codeacme17`. 4. Optionally set `ECHO_UI_LOOP_OWNER_WEBHOOK_URL` to an endpoint that accepts the notification JSON with `Content-Type: application/json`. 5. Never store the webhook URL or credentials in this repository. diff --git a/loops/issue-dev-loop/LOOP.md b/loops/issue-dev-loop/LOOP.md index ede3f2a5..eaef90b0 100644 --- a/loops/issue-dev-loop/LOOP.md +++ b/loops/issue-dev-loop/LOOP.md @@ -76,6 +76,8 @@ Complete the generated handoff with acceptance criteria, scope, TDD seams, requi Explicitly invoke `$implement`. The orchestrator does not write product code. `$implement` owns TDD at agreed seams, implementation, regular typechecking and targeted tests, the final full suite, `$code-review`, and a local commit. It must not push, create a PR, or merge. Every invocation writes a unique schema-validated result with its invocation ID, timestamps, frozen brief digest, passed checks, and a new commit descending from the prior implementation commit (or the frozen base SHA for the first invocation); record it before PR publication or update. +The recorded implementation commit is the product-code boundary. Later commits may contain only the current run's handoff, sanitized logs, screenshots, and evidence. `record-pr` diffs the implementation commit against the proposed head and rejects every other path, so the orchestrator cannot append unrecorded product changes. + ### 5. Verify before publication Run relevant checks and `pnpm verify`. For UI behavior, capture before/after screenshots under `screen-shots/<run-id>` at meaningful desktop and mobile viewports and include interaction or accessibility evidence where applicable. Bind `before` to the frozen base and `after` to the latest `$implement` commit; never put the containing commit's not-yet-known hash inside its own files. Commit sanitized screenshots and run metadata to the issue branch. The evidence workflow then checks out the exact PR event head, adds that SHA to the generated manifest, reruns `pnpm verify`, and uploads a reviewable artifact for that SHA. @@ -86,7 +88,7 @@ Push the issue branch and create a draft PR targeting `dev`. Immediately bind it ### 7. Independent review -Spawn `echo_ui_pr_reviewer` with fresh context and read-only filesystem access. Provide only durable specifications and review artifacts. Post its findings verbatim. The executor then classifies and responds to every comment. Accepted findings go back through `$implement`; rejected findings require concrete evidence. Use `echo_ui_review_adjudicator` or the owner for disputed P0/P1 findings. Allow at most two automated repair/review rounds. +Spawn `echo_ui_pr_reviewer` with fresh context and read-only filesystem access. Provide only durable specifications and review artifacts. Post every round's findings verbatim and record that round's GitHub review URL. The runtime independently verifies each review's author, immutable head SHA, marker, submission time, and contents. The executor then classifies and responds to every comment. Accepted findings go back through a `$implement` invocation that starts after the finding review; its evidence-backed reply must be posted after that invocation finishes. Rejected findings require concrete evidence posted after the review. Use `echo_ui_review_adjudicator` or the owner for disputed P0/P1 findings. Allow at most two automated repair/review rounds. ### 8. Owner gate @@ -104,7 +106,7 @@ Only `observe-owner-merge` permits `completed`. It must query GitHub and observe Keep `state.md` small and deliberate. It may be rewritten and contains only active runs, open PRs, blockers, follow-ups, current hypotheses, and learned constraints. -`logs/index.jsonl` is append-only and contains one compact summary per finalized run; `logs/triggers.jsonl` is the append-only record of cheap trigger decisions, including successful no-ops. The dedicated GitHub state-journal issue is the durable cross-worktree source for terminal records: each automation-authored comment contains the canonical record and its SHA-256 marker, and `loopctl reconcile` verifies and replays missing entries before trigger/evolve work. Commit sanitized `run.json`, pre-publication `events.jsonl`, summaries, and relevant screenshots to the issue branch so they are reviewable in its PR. The exact-head CI manifest and full proof travel in an Actions artifact. Keep raw local command output and large recordings in ignored `raw/` or `test-results/` directories. GitHub journal comments, PR reviews, workflow artifacts, and merge metadata are authoritative; local indexes and metrics are reconstructable caches. +`logs/index.jsonl` is append-only and contains one compact summary per finalized run; `logs/triggers.jsonl` is the append-only record of cheap trigger decisions, including successful no-ops and resumes. The dedicated GitHub state-journal issue is the durable cross-worktree source for both active checkpoints and terminal records. After every durable run phase, publish the exact `prepare-checkpoint` body and validate it with `record-checkpoint`; it contains the compact run, frozen brief, and validated event chain. Terminal comments supersede active checkpoints. `loopctl reconcile` verifies comments, restores missing active workspaces, replays terminal history, and recomputes evolve metrics before trigger/evolve work. A restored active run is returned as `workType: resume` before new issue selection. Commit sanitized `run.json`, pre-publication `events.jsonl`, summaries, and relevant screenshots to the issue branch so they are reviewable in its PR. The exact-head CI manifest and full proof travel in an Actions artifact. Keep raw local command output and large recordings in ignored `raw/` or `test-results/` directories. GitHub journal comments, PR reviews, workflow artifacts, and merge metadata are authoritative; local indexes and metrics are reconstructable caches. Never log secrets, full environment dumps, cookies, auth headers, private user data, or raw prompts containing sensitive information. diff --git a/loops/issue-dev-loop/SKILL.md b/loops/issue-dev-loop/SKILL.md index cc064504..11ec8599 100644 --- a/loops/issue-dev-loop/SKILL.md +++ b/loops/issue-dev-loop/SKILL.md @@ -11,7 +11,7 @@ Run exactly one bounded issue cycle. Treat [`LOOP.md`](./LOOP.md) as the constit 1. Read `LOOP.md`, `state.md`, and `dependencies.md` completely. 2. Run `node loops/issue-dev-loop/scripts/loopctl.mjs validate`. -3. Run `loopctl.mjs reconcile` to rebuild local history and evolve metrics from the append-only GitHub finalization journal. +3. Run `loopctl.mjs reconcile` to rebuild terminal history and restore active runs from the append-only GitHub state journal. Resume returned `workType: resume` work before selecting a new issue. 4. Run `loopctl.mjs evolve-status`. If `evolveDue` is true, start `echo_ui_loop_evolver` with fresh context; do not silently replace it with product work. 5. Run the cheap trigger in `triggers/detect-work.mjs`. Exit without invoking an implementation agent when it reports `hasWork: false`. 6. Refuse to start when another active run or PR already claims the issue. @@ -19,6 +19,8 @@ Run exactly one bounded issue cycle. Treat [`LOOP.md`](./LOOP.md) as the constit 8. Start the run with `loopctl.mjs start --issue <number> --title <title> --url <issue-url> --base-sha <full-origin-dev-sha>`. 9. Complete `handoffs/<run-id>/implementation-brief.md`, set `UI evidence required` to `yes` or `no`, then run `loopctl.mjs freeze-brief --run-id <id>` before implementation. Never edit the frozen brief afterward. +After `start`, `freeze-brief`, every `record-implementation`, every `record-pr`, every review/evidence gate, and every pause transition, run `prepare-checkpoint`, publish its exact body to the state-journal issue through the automation identity, and validate it with `record-checkpoint`. These compact checkpoints let a fresh worktree restore the run, frozen brief, and validated event chain instead of abandoning an already-claimed issue or open PR. + Read [`references/github-operations.md`](./references/github-operations.md) for GitHub mutations and [`references/evidence-policy.md`](./references/evidence-policy.md) before verification or PR publication. ## Implement through `$implement` @@ -33,7 +35,7 @@ Provide `$implement` with: - required targeted checks and final `pnpm verify` - an instruction to stop after committing; `$implement` must not push or create a PR -Require `$implement` to write a unique `logs/runs/<run-id>/implementation-result-<sequence>.json` matching `schemas/implementation-result.schema.json`. It records the invocation ID and time range, frozen brief digest, resulting commit SHA, and passed checks including `pnpm verify`. Then run `loopctl.mjs record-implementation --run-id <id> --result <absolute-path>`. Repeated `$implement` repair invocations use new result files and must advance from the previously recorded implementation commit. +Require `$implement` to write a unique `logs/runs/<run-id>/implementation-result-<sequence>.json` matching `schemas/implementation-result.schema.json`. It records the invocation ID and time range, frozen brief digest, resulting commit SHA, and passed checks including `pnpm verify`. Then run `loopctl.mjs record-implementation --run-id <id> --result <absolute-path>`. Repeated `$implement` repair invocations use new result files and must advance from the previously recorded implementation commit. After that commit, only this run's handoff, log, screenshot, and evidence files may be added before the PR head; `record-pr` rejects every trailing product-code path. ## Publish a draft PR @@ -43,7 +45,7 @@ Push only the issue branch and create a **draft** PR targeting `dev` using `temp After the draft PR exists, spawn the project agent `echo_ui_pr_reviewer` with a fresh context. Give it only the issue snapshot, acceptance criteria, repository instructions, base SHA, head SHA, diff, CI results, and evidence manifest. Do not give it executor conversation history or rationale. -Publish the review through the separately configured `reviewerGitHubLogin`; the executor identity may not author it. Post findings verbatim as one review plus inline comments. Each finding needs a stable ID, severity, confidence, evidence, and expected resolution. Follow `review/REVIEW.md` and `review/response-policy.md`. +Publish every round through the separately configured `reviewerGitHubLogin`; the executor identity may not author it. Post findings verbatim as one review plus inline comments. Each finding needs a stable ID, severity, confidence, evidence, and expected resolution. Record the GitHub review URL for each round. Accepted repairs must start after that round was submitted, and executor replies must be posted after the corresponding `$implement` invocation finishes. Follow `review/REVIEW.md` and `review/response-policy.md`. The executor must classify every finding as `accepted`, `rejected`, `needs-human`, `stale`, or `already-fixed`: diff --git a/loops/issue-dev-loop/dependencies.md b/loops/issue-dev-loop/dependencies.md index 15c58ee0..c0c8633a 100644 --- a/loops/issue-dev-loop/dependencies.md +++ b/loops/issue-dev-loop/dependencies.md @@ -11,7 +11,7 @@ - Git - GitHub CLI (`gh`) authenticated for issue, Actions artifact download, branch, PR, review, and comment work - Repository trust enabled so project `.codex` agents can load -- A dedicated GitHub issue configured as `stateIssueNumber` for the append-only finalization journal +- A dedicated GitHub issue configured as `stateIssueNumber` for append-only active checkpoints and finalization records ## Optional diff --git a/loops/issue-dev-loop/references/evidence-policy.md b/loops/issue-dev-loop/references/evidence-policy.md index 43db3f85..f9ca9fff 100644 --- a/loops/issue-dev-loop/references/evidence-policy.md +++ b/loops/issue-dev-loop/references/evidence-policy.md @@ -26,4 +26,4 @@ Use descriptive names such as `02-after-player-mobile-375.webp` under `screen-sh Commit issue-relevant PNG/WebP screenshots and their metadata under `screen-shots/<run-id>` before owner review. The `issue-dev-loop-evidence.yml` workflow checks out the exact PR head, runs `pnpm verify`, generates `evidence/<run-id>/manifest.json` in the runner, and uploads the manifest, sanitized run log, and screenshots as a GitHub Actions artifact. Download the artifact, then pass its URL to `record-evidence`; never accept an artifact from a different head SHA. -`record-evidence` queries the GitHub artifact and workflow-run APIs, requires the named evidence workflow to conclude successfully for the recorded PR/branch/head, downloads the artifact through `gh run download`, and byte-compares its manifest with the supplied file. Failed CI artifacts and locally fabricated manifests are rejected. The independent reviewer posts findings and responses to GitHub after the draft PR exists. Save its structured cycle result locally and use `record-review --review-url <github-review-url>` so the review gate is independently bound to the same recorded PR and head SHA. Keep raw local command output, videos, traces, and bulky reports in ignored runtime directories. Never upload secrets, cookies, auth state, user data, or unredacted environment dumps. +`record-evidence` queries the GitHub artifact and workflow-run APIs, requires the named evidence workflow to conclude successfully for the recorded PR/branch/head, downloads the artifact through `gh run download`, and byte-compares its manifest with the supplied file. Failed CI artifacts and locally fabricated manifests are rejected. The independent reviewer posts findings and responses to GitHub after the draft PR exists. Save every round's unique review URL in the structured cycle result and use the final one with `record-review --review-url <github-review-url>`; the runtime binds each round to its author, submission time, exact head, comments, repairs, and replies. Keep raw local command output, videos, traces, and bulky reports in ignored runtime directories. Never upload secrets, cookies, auth state, user data, or unredacted environment dumps. diff --git a/loops/issue-dev-loop/references/github-operations.md b/loops/issue-dev-loop/references/github-operations.md index f89c766b..cd283f3b 100644 --- a/loops/issue-dev-loop/references/github-operations.md +++ b/loops/issue-dev-loop/references/github-operations.md @@ -8,6 +8,7 @@ Read this before mutating issues or pull requests. 2. Exclude `loop:claimed`, an existing `codex/issue-<number>` branch, and any open PR that references or closes the issue. 3. Let `loopctl start --base-sha <full-origin-dev-sha>` acquire the local claim, recheck every page of open PRs, apply `loop:claimed`, and capture the authoritative issue; do not add the label manually first. 4. Record the issue snapshot and claim timestamp before implementation. A second active local run for the issue is rejected. +5. Immediately publish and validate an active checkpoint after the claim. Repeat after each durable phase. On a fresh wake, `reconcile` restores the latest non-terminal checkpoint and `detect-work` returns `workType: resume` before considering a new issue. ## Branch and PR @@ -15,6 +16,7 @@ Read this before mutating issues or pull requests. - Push only the issue branch. - Create a draft PR with `--base dev`. - Run `loopctl record-pr` immediately so later evidence, review, notification, and merge observations are bound to that exact PR/head. +- Run `prepare-checkpoint`, publish its exact body on the state-journal issue, and run `record-checkpoint` immediately after binding or rebinding the PR. - Create the body from `templates/pr-body.md`. `record-pr` rejects a body missing the run marker, issue closure, full base/head SHAs, or owner-only merge statement. - Include `Closes #<number>` only when the PR fully satisfies the issue. - Request `codeacme17` only after automated review and verification pass. @@ -51,6 +53,8 @@ Use the originating issue for pre-PR questions and the PR for post-publication q After a blocking notification, verify the reply URL with `record-owner-response`. Normal comments must include `RESUME <run-id>`; a `CHANGES_REQUESTED` review is an explicit response. The runtime requires that the notification was successfully delivered to the same run target before it accepts the reply. -## Durable finalization journal +## Durable state journal + +For active work, run `loopctl prepare-checkpoint --run-id <id>`, post its exact `body` to the configured `stateIssueNumber` using the automation identity, then validate it with `loopctl record-checkpoint --run-id <id> --result <path> --comment-url <url>`. A checkpoint is SHA-256 bound to the active run, frozen brief, and ordered validated events. Publish one after every state-changing phase; later checkpoints supersede earlier ones. Before `completed`, `failed`, `blocked`, or `cancelled`, run `loopctl prepare-finalization` with the terminal status and any merge SHA/failure fingerprint. Post its exact `body` to the configured `stateIssueNumber` using the automation identity, then run `loopctl record-finalization --run-id <id> --result <path> --comment-url <url>`. For completion, pass that result and URL to `observe-owner-merge`. Terminal transitions reject missing, edited, wrong-author, wrong-issue, or digest-mismatched journal entries. Every scheduled wake begins with `loopctl reconcile`, which restores missing local history and recomputes evolve metrics from the append-only journal. diff --git a/loops/issue-dev-loop/review/REVIEW.md b/loops/issue-dev-loop/review/REVIEW.md index a996b225..1b4d0351 100644 --- a/loops/issue-dev-loop/review/REVIEW.md +++ b/loops/issue-dev-loop/review/REVIEW.md @@ -23,9 +23,9 @@ Do not emit formatter noise, personal style preferences, speculative concerns, u ## Publication -The fresh reviewer publishes findings verbatim through `reviewerGitHubLogin` as one non-approving GitHub review and inline comments, adding `<!-- issue-dev-loop:<run-id>:<finding-id> -->` for deduplication. The reviewer identity must differ from the executor and owner. The review body must include `<!-- issue-dev-loop:<run-id>:review-result-sha256:<digest> -->`. It must not downgrade severity or omit findings. +The fresh reviewer publishes each round verbatim through `reviewerGitHubLogin` as one non-approving GitHub review and inline comments, adding `<!-- issue-dev-loop:<run-id>:<finding-id> -->` for deduplication and `<!-- issue-dev-loop:<run-id>:review-round:<round>:head:<sha> -->` to the round body. The reviewer identity must differ from the executor and owner. The final review body must also include `<!-- issue-dev-loop:<run-id>:review-result-sha256:<digest> -->`. It must not downgrade severity or omit findings. -After all replies are posted, create one cycle result matching `result.schema.json`. It contains every round, every finding, its final classification, response URL, and evidence. Executor replies include both the readable evidence (and accepted fix SHA) plus `<!-- issue-dev-loop:<run-id>:<finding-id>:<classification> -->`. Rejected P0/P1 findings additionally require an independent or owner comment containing `<!-- issue-dev-loop:<run-id>:<finding-id>:adjudication:<verdict> -->`. Run `record-review` with the GitHub review URL; it queries the recorded PR, review, replies, identities, ancestry, adjudications, and markers and binds them to the current head. Generic events cannot forge this reserved gate. +After all replies are posted, create one cycle result matching `result.schema.json`. It contains every round's review URL, every finding, its final classification, response URL, and evidence. Executor replies include both the readable evidence (and accepted fix SHA) plus `<!-- issue-dev-loop:<run-id>:<finding-id>:<classification> -->`. Rejected P0/P1 findings additionally require an independent or owner comment containing `<!-- issue-dev-loop:<run-id>:<finding-id>:adjudication:<verdict> -->`. Run `record-review` with the final GitHub review URL; it queries every recorded review, replies, identities, timestamps, ancestry, adjudications, and markers and binds them to the correct round and current head. Generic events cannot forge this reserved gate. ## Completion diff --git a/loops/issue-dev-loop/review/response-policy.md b/loops/issue-dev-loop/review/response-policy.md index 8c807dd9..86ac735c 100644 --- a/loops/issue-dev-loop/review/response-policy.md +++ b/loops/issue-dev-loop/review/response-policy.md @@ -8,10 +8,10 @@ Classify every automated finding as one of: - `stale`: refers to a superseded head SHA or removed code - `already-fixed`: current head demonstrably resolves it -Accepted findings return to `$implement`. Reply only after pushing the fix and include commit SHA, commands, results, and evidence links. +Accepted findings return to a new `$implement` invocation that starts after the review was submitted. Reply only after that invocation finishes and the fix is pushed; include commit SHA, commands, results, and evidence links. Rejected findings require a precise counterclaim and reproducible evidence in the GitHub reply. Do not reply with bare disagreement. An executor cannot unilaterally close a disputed P0 or P1; publish `REJECT_FINDING` from the independent reviewer identity or `OWNER_REJECTED_FINDING` from the owner, include its adjudication marker/URL in the cycle result, and escalate `NEEDS_OWNER` outcomes. Do not delete review comments. Log classification, response time, response URL, commit, and evidence reference under the run ID. -The final cycle file must match `result.schema.json`. `record-review` accepts only a PASS published by the distinct configured reviewer identity whose last round matches the recorded PR head, whose GitHub review carries the result digest, whose earlier findings all have verified executor-authored response URLs, classification markers and visible evidence, and whose accepted fix commits are ancestors of the reviewed head. +The final cycle file must match `result.schema.json` and include a unique GitHub review URL for every round. `record-review` accepts only chronologically ordered reviews published by the distinct configured reviewer identity, with every round bound to its own immutable head and marker. The last round must match the recorded PR head and carry the result digest. Earlier findings require verified executor-authored response URLs, classification markers, visible evidence, and review→repair→reply ordering; accepted fix commits must be `$implement`-attested ancestors of the reviewed head. diff --git a/loops/issue-dev-loop/review/result.schema.json b/loops/issue-dev-loop/review/result.schema.json index 2ab797fb..c41c2bae 100644 --- a/loops/issue-dev-loop/review/result.schema.json +++ b/loops/issue-dev-loop/review/result.schema.json @@ -25,11 +25,12 @@ "maxItems": 2, "items": { "type": "object", - "required": ["round", "headSha", "verdict", "findings"], + "required": ["round", "headSha", "reviewUrl", "verdict", "findings"], "additionalProperties": false, "properties": { "round": { "type": "integer", "minimum": 1, "maximum": 2 }, "headSha": { "type": "string", "pattern": "^[0-9a-fA-F]{40}$" }, + "reviewUrl": { "type": "string", "format": "uri" }, "verdict": { "enum": ["PASS", "CHANGES_REQUESTED"] }, "findings": { "type": "array", diff --git a/loops/issue-dev-loop/schemas/checkpoint-record.schema.json b/loops/issue-dev-loop/schemas/checkpoint-record.schema.json new file mode 100644 index 00000000..787506f0 --- /dev/null +++ b/loops/issue-dev-loop/schemas/checkpoint-record.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Issue development loop active checkpoint", + "type": "object", + "required": ["schemaVersion", "kind", "run", "briefSource", "events", "updatedAt"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": 1 }, + "kind": { "const": "active-checkpoint" }, + "run": { "$ref": "run.schema.json" }, + "briefSource": { "type": "string" }, + "events": { + "type": "array", + "minItems": 1, + "items": { "$ref": "event.schema.json" } + }, + "updatedAt": { "type": "string", "format": "date-time" } + } +} diff --git a/loops/issue-dev-loop/scripts/lib/active-journal.mjs b/loops/issue-dev-loop/scripts/lib/active-journal.mjs new file mode 100644 index 00000000..abc88438 --- /dev/null +++ b/loops/issue-dev-loop/scripts/lib/active-journal.mjs @@ -0,0 +1,362 @@ +import { createHash } from 'node:crypto' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' + +import { + DEFAULT_LOOP_ROOT, + assertNonEmpty, + assertRunId, + defaultGitHubApi, + defaultGitHubPaginatedApi, + parsePullCommentUrl, + pathExists, + readJson, + runDirectory, + sameGitHubLogin, + writeJson, +} from './common.mjs' +import { appendValidatedEvent, readEvents, readRun } from './run-store.mjs' + +const ACTIVE_STATUSES = new Set(['running', 'waiting_for_owner', 'awaiting_owner_review']) + +async function journalConfiguration(loopRoot) { + const channel = await readJson( + path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'), + ) + if ( + !Number.isInteger(channel.stateIssueNumber) || + channel.stateIssueNumber < 1 || + !channel.automationGitHubLogin + ) { + throw new Error('owner channel must configure stateIssueNumber and automationGitHubLogin') + } + const [owner, repo] = channel.repository.split('/') + return { channel, owner, repo } +} + +function canonicalRun(run) { + return { + schemaVersion: run.schemaVersion, + runId: run.runId, + issueNumber: run.issueNumber, + issueTitle: run.issueTitle, + issueUrl: run.issueUrl, + baseBranch: run.baseBranch, + baseSha: run.baseSha, + branch: run.branch, + status: run.status, + startedAt: run.startedAt, + finishedAt: run.finishedAt, + prUrl: run.prUrl, + headSha: run.headSha, + mergeSha: run.mergeSha, + issueSnapshot: run.issueSnapshot, + briefDigest: run.briefDigest, + uiEvidenceRequired: run.uiEvidenceRequired, + implementationCommit: run.implementationCommit, + } +} + +function canonicalEvent(event) { + return { + schemaVersion: event.schemaVersion, + runId: event.runId, + type: event.type, + timestamp: event.timestamp, + status: event.status, + payload: event.payload, + } +} + +export function canonicalCheckpoint(record) { + return JSON.stringify({ + schemaVersion: 1, + kind: 'active-checkpoint', + run: canonicalRun(record.run), + briefSource: record.briefSource, + events: record.events.map(canonicalEvent), + updatedAt: record.updatedAt, + }) +} + +export function checkpointDigest(record) { + return createHash('sha256').update(canonicalCheckpoint(record)).digest('hex') +} + +function validateCheckpoint(record) { + const run = record?.run + const events = record?.events + if ( + record?.schemaVersion !== 1 || + record?.kind !== 'active-checkpoint' || + !run || + run.schemaVersion !== 1 || + !ACTIVE_STATUSES.has(run.status) || + run.finishedAt !== null || + !Number.isInteger(run.issueNumber) || + !/^[0-9a-f]{40}$/i.test(run.baseSha) || + (run.headSha !== null && !/^[0-9a-f]{40}$/i.test(run.headSha)) || + !Array.isArray(events) || + events.length === 0 || + typeof record.briefSource !== 'string' || + Number.isNaN(Date.parse(record.updatedAt)) + ) { + throw new Error('invalid active checkpoint record') + } + assertRunId(run.runId) + let previousTimestamp = -Infinity + for (const event of events) { + const timestamp = Date.parse(event.timestamp) + if ( + event.schemaVersion !== 1 || + event.runId !== run.runId || + event.type === 'checkpoint_published' || + Number.isNaN(timestamp) || + timestamp < previousTimestamp + ) { + throw new Error('active checkpoint contains invalid or unordered events') + } + previousTimestamp = timestamp + } + if ( + events.at(-1).timestamp !== record.updatedAt || + !events.some((event) => event.type === 'loop_started') + ) { + throw new Error('active checkpoint is not bound to the latest run event') + } + if ( + run.briefDigest !== null && + createHash('sha256').update(record.briefSource).digest('hex') !== run.briefDigest + ) { + throw new Error('active checkpoint brief does not match the frozen digest') + } + return record +} + +function checkpointBody(record) { + const digest = checkpointDigest(record) + const result = { + digest, + body: [ + `<!-- issue-dev-loop:checkpoint:${record.run.runId}:sha256:${digest} -->`, + '```json', + canonicalCheckpoint(record), + '```', + ].join('\n'), + } + if (result.body.length > 60_000) { + throw new Error('active checkpoint exceeds the GitHub comment size budget') + } + return result +} + +export async function prepareActiveCheckpoint({ loopRoot = DEFAULT_LOOP_ROOT, runId } = {}) { + const normalizedRunId = assertRunId(runId) + const run = await readRun(loopRoot, normalizedRunId) + if (!ACTIVE_STATUSES.has(run.status) || run.finishedAt !== null) { + throw new Error('only an active run can be checkpointed') + } + const events = (await readEvents(loopRoot, normalizedRunId)).filter( + (event) => event.type !== 'checkpoint_published', + ) + const briefPath = path.join(loopRoot, 'handoffs', normalizedRunId, 'implementation-brief.md') + const record = validateCheckpoint({ + schemaVersion: 1, + kind: 'active-checkpoint', + run: canonicalRun(run), + briefSource: await readFile(briefPath, 'utf8'), + events, + updatedAt: events.at(-1)?.timestamp, + }) + const resultPath = path.join(runDirectory(loopRoot, normalizedRunId), 'checkpoint-result.json') + await writeJson(resultPath, record) + const { channel, owner, repo } = await journalConfiguration(loopRoot) + const { digest, body } = checkpointBody(record) + return { + record, + resultPath, + digest, + body, + journalIssueUrl: `https://github.com/${owner}/${repo}/issues/${channel.stateIssueNumber}`, + } +} + +export async function recordActiveCheckpointPublication({ + loopRoot = DEFAULT_LOOP_ROOT, + runId, + resultPath, + commentUrl, + now = new Date(), + githubApi = defaultGitHubApi, +} = {}) { + const normalizedRunId = assertRunId(runId) + const resolvedResultPath = path.resolve(assertNonEmpty(resultPath, 'resultPath')) + const runRoot = runDirectory(loopRoot, normalizedRunId) + if (!resolvedResultPath.startsWith(`${runRoot}${path.sep}`)) { + throw new Error('checkpoint result must be inside the current run directory') + } + const record = validateCheckpoint(await readJson(resolvedResultPath)) + const run = await readRun(loopRoot, normalizedRunId) + const allEvents = await readEvents(loopRoot, normalizedRunId) + const currentEvents = allEvents.filter((event) => event.type !== 'checkpoint_published') + const briefSource = await readFile( + path.join(loopRoot, 'handoffs', normalizedRunId, 'implementation-brief.md'), + 'utf8', + ) + const currentRecord = { + ...record, + run, + briefSource, + events: currentEvents, + updatedAt: currentEvents.at(-1)?.timestamp, + } + if (canonicalCheckpoint(currentRecord) !== canonicalCheckpoint(record)) { + throw new Error('checkpoint result no longer matches the active run') + } + const { channel, owner, repo } = await journalConfiguration(loopRoot) + const target = parsePullCommentUrl(assertNonEmpty(commentUrl, 'commentUrl')) + if ( + !target || + target.surface !== 'issues' || + target.kind !== 'issue_comment' || + target.owner.toLowerCase() !== owner.toLowerCase() || + target.repo.toLowerCase() !== repo.toLowerCase() || + target.number !== channel.stateIssueNumber + ) { + throw new Error('checkpoint comment must be on the configured state journal issue') + } + const comment = await githubApi( + `repos/${target.owner}/${target.repo}/issues/comments/${target.commentId}`, + ) + const { digest, body } = checkpointBody(record) + if ( + !sameGitHubLogin(comment.user?.login, channel.automationGitHubLogin) || + !comment.body?.includes(body) + ) { + throw new Error('published checkpoint comment does not attest the exact active state') + } + if ( + !allEvents.some( + (event) => + event.type === 'checkpoint_published' && + event.payload?.commentUrl === commentUrl && + event.payload?.digest === digest, + ) + ) { + await appendValidatedEvent({ + loopRoot, + runId: normalizedRunId, + type: 'checkpoint_published', + status: 'published', + payload: { commentUrl, digest, checkpointUpdatedAt: record.updatedAt }, + now, + }) + } + return { record, digest, commentUrl } +} + +function parseSerializedRecord(body) { + const serialized = body?.match(/```json\s*([^\n]+)\s*```/)?.[1] + return serialized ? JSON.parse(serialized) : null +} + +export async function reconcileActiveJournal({ + loopRoot = DEFAULT_LOOP_ROOT, + githubPaginatedApi = defaultGitHubPaginatedApi, +} = {}) { + const { channel, owner, repo } = await journalConfiguration(loopRoot) + const comments = await githubPaginatedApi( + `repos/${owner}/${repo}/issues/${channel.stateIssueNumber}/comments?per_page=100`, + ) + const terminalRunIds = new Set() + const latestByRunId = new Map() + for (const comment of comments) { + if (!sameGitHubLogin(comment.user?.login, channel.automationGitHubLogin)) continue + const finalizationMarker = comment.body?.match( + /<!-- issue-dev-loop:finalization:([^:]+):sha256:([0-9a-f]{64}) -->/, + ) + if (finalizationMarker) terminalRunIds.add(finalizationMarker[1]) + const marker = comment.body?.match( + /<!-- issue-dev-loop:checkpoint:([^:]+):sha256:([0-9a-f]{64}) -->/, + ) + if (!marker) continue + const record = validateCheckpoint(parseSerializedRecord(comment.body)) + if (record.run.runId !== marker[1] || checkpointDigest(record) !== marker[2]) { + throw new Error(`invalid durable active checkpoint for ${marker[1]}`) + } + const existing = latestByRunId.get(record.run.runId) + if (!existing || Date.parse(record.updatedAt) > Date.parse(existing.record.updatedAt)) { + latestByRunId.set(record.run.runId, { record, comment }) + } + } + + const activeCheckpoints = [] + for (const [runId, durable] of latestByRunId) { + if (terminalRunIds.has(runId)) continue + const { record, comment } = durable + const runPath = runDirectory(loopRoot, runId) + const runFile = path.join(runPath, 'run.json') + const eventsFile = path.join(runPath, 'events.jsonl') + let restoreNeeded = !(await pathExists(runFile)) || !(await pathExists(eventsFile)) + if (await pathExists(runFile)) { + const localRun = await readJson(runFile) + if (localRun.issueNumber !== record.run.issueNumber) { + throw new Error(`local run conflicts with durable checkpoint: ${runId}`) + } + if (localRun.finishedAt !== null) { + throw new Error(`terminal local run conflicts with active durable checkpoint: ${runId}`) + } + if (!restoreNeeded) { + const localEvents = (await readFile(eventsFile, 'utf8')) + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) + .filter((event) => event.type !== 'checkpoint_published') + restoreNeeded = + localEvents.length === 0 || + Date.parse(localEvents.at(-1).timestamp) < Date.parse(record.updatedAt) + } + } + if (restoreNeeded) { + await Promise.all( + [ + runPath, + path.join(loopRoot, 'logs', 'claims', `issue-${record.run.issueNumber}`), + path.join(loopRoot, 'handoffs', runId), + path.join(loopRoot, 'screen-shots', runId, 'before'), + path.join(loopRoot, 'screen-shots', runId, 'after'), + path.join(loopRoot, 'evidence', runId, 'test-results'), + ].map((directory) => mkdir(directory, { recursive: true })), + ) + await writeJson(runFile, record.run) + const restoredEvents = [ + ...record.events, + { + schemaVersion: 1, + runId, + type: 'checkpoint_published', + timestamp: comment.created_at ?? record.updatedAt, + status: 'published', + payload: { + commentUrl: comment.html_url ?? null, + digest: checkpointDigest(record), + checkpointUpdatedAt: record.updatedAt, + }, + }, + ] + await writeFile( + eventsFile, + `${restoredEvents.map((event) => JSON.stringify(event)).join('\n')}\n`, + 'utf8', + ) + await writeFile( + path.join(loopRoot, 'handoffs', runId, 'implementation-brief.md'), + record.briefSource, + 'utf8', + ) + } + activeCheckpoints.push(record) + } + activeCheckpoints.sort((left, right) => Date.parse(left.updatedAt) - Date.parse(right.updatedAt)) + return { activeCheckpoints } +} diff --git a/loops/issue-dev-loop/scripts/lib/evidence.mjs b/loops/issue-dev-loop/scripts/lib/evidence.mjs index 03431975..d35089aa 100644 --- a/loops/issue-dev-loop/scripts/lib/evidence.mjs +++ b/loops/issue-dev-loop/scripts/lib/evidence.mjs @@ -75,7 +75,8 @@ function validateReviewEvidence(review, headSha) { let findingCount = 0 const findingIds = new Set() - const resolvedFindings = [] + const reviewUrls = new Set() + const roundDetails = [] for (const [roundIndex, round] of rounds.entries()) { const roundHeadSha = assertNonEmpty(round.headSha, `review.rounds[${roundIndex}].headSha`) if (!/^[0-9a-f]{40}$/i.test(roundHeadSha)) { @@ -84,6 +85,9 @@ function validateReviewEvidence(review, headSha) { if (round.round !== roundIndex + 1 || !['PASS', 'CHANGES_REQUESTED'].includes(round.verdict)) { throw new Error('review rounds must be ordered and have a supported verdict') } + const roundReviewUrl = assertHttpUrl(round.reviewUrl, `review.rounds[${roundIndex}].reviewUrl`) + if (reviewUrls.has(roundReviewUrl)) throw new Error('each review round requires a unique URL') + reviewUrls.add(roundReviewUrl) const findings = assertArray(round.findings, `review.rounds[${roundIndex}].findings`) if (round.verdict === 'PASS' && findings.length > 0) { throw new Error('a PASS review round cannot contain findings') @@ -130,10 +134,10 @@ function validateReviewEvidence(review, headSha) { throw new Error(`${findingId} rejected P0/P1 requires a rejecting adjudication verdict`) } } - resolvedFindings.push(finding) } + roundDetails.push(round) } - return { findingCount, rounds: rounds.length, findings: resolvedFindings } + return { findingCount, rounds: rounds.length, roundDetails } } export async function recordEvidence({ @@ -317,6 +321,9 @@ export async function recordReview({ if (!/^[0-9a-f]{40}$/i.test(headSha)) throw new Error('review.headSha must be a full Git SHA') const reviewSummary = validateReviewEvidence(result, headSha) const publishedReviewUrl = assertHttpUrl(reviewUrl, 'reviewUrl') + if (reviewSummary.roundDetails.at(-1).reviewUrl !== publishedReviewUrl) { + throw new Error('reviewUrl must be the final review round URL') + } const reviewTarget = parseReviewUrl(publishedReviewUrl) const recordedPullTarget = parseGitHubTarget(run.prUrl) if ( @@ -341,25 +348,67 @@ export async function recordReview({ ) { throw new Error('reviewerGitHubLogin must be independent from executor and owner identities') } - const reviewEndpoint = `repos/${reviewTarget.owner}/${reviewTarget.repo}/pulls/${reviewTarget.number}/reviews/${reviewTarget.reviewId}` - const [publishedReview, reviewComments, livePullRequest] = await Promise.all([ - githubApi(reviewEndpoint), - githubApi(`${reviewEndpoint}/comments?per_page=100`), - githubApi(`repos/${reviewTarget.owner}/${reviewTarget.repo}/pulls/${reviewTarget.number}`), - ]) const digestMarker = `<!-- issue-dev-loop:${normalizedRunId}:review-result-sha256:${resultDigest} -->` - const publishedBodies = [ - publishedReview.body ?? '', - ...reviewComments.map((comment) => comment.body ?? ''), - ] - if ( - publishedReview.commit_id !== headSha || - publishedReview.state !== 'COMMENTED' || - !sameGitHubLogin(publishedReview.user?.login, reviewerLogin) || - !publishedBodies.some((body) => body.includes(digestMarker)) - ) { - throw new Error('published GitHub review does not attest this result and exact headSha') + const publications = new Map() + let previousSubmittedAt = -Infinity + for (const round of reviewSummary.roundDetails) { + const roundTarget = parseReviewUrl(round.reviewUrl) + if ( + !roundTarget || + !sameRepository(reviewTarget, roundTarget) || + roundTarget.number !== reviewTarget.number + ) { + throw new Error(`review round ${round.round} is not published on the recorded PR`) + } + const roundEndpoint = `repos/${roundTarget.owner}/${roundTarget.repo}/pulls/${roundTarget.number}/reviews/${roundTarget.reviewId}` + const [publishedRound, roundComments] = await Promise.all([ + githubApi(roundEndpoint), + githubApi(`${roundEndpoint}/comments?per_page=100`), + ]) + const bodies = [ + publishedRound.body ?? '', + ...roundComments.map((comment) => comment.body ?? ''), + ] + const roundMarker = `<!-- issue-dev-loop:${normalizedRunId}:review-round:${round.round}:head:${round.headSha} -->` + const submittedAt = Date.parse(publishedRound.submitted_at) + if ( + publishedRound.commit_id !== round.headSha || + publishedRound.state !== 'COMMENTED' || + !sameGitHubLogin(publishedRound.user?.login, reviewerLogin) || + Number.isNaN(submittedAt) || + submittedAt < previousSubmittedAt || + !bodies.some((body) => body.includes(roundMarker)) || + (round.round === reviewSummary.rounds && !bodies.some((body) => body.includes(digestMarker))) + ) { + throw new Error(`published GitHub review round ${round.round} is not bound to its exact head`) + } + for (const finding of round.findings) { + const findingMarker = `<!-- issue-dev-loop:${normalizedRunId}:${finding.findingId} -->` + const requiredFindingFragments = [ + findingMarker, + finding.findingId, + finding.severity, + finding.confidence, + finding.problem, + finding.evidence, + finding.expectedResolution, + ] + if ( + !bodies.some((body) => + requiredFindingFragments.every((fragment) => body.includes(fragment)), + ) + ) { + throw new Error(`${finding.findingId} is not published verbatim in its GitHub review round`) + } + } + publications.set(round.round, { + submittedAt: publishedRound.submitted_at, + }) + previousSubmittedAt = submittedAt } + const livePullRequest = await githubApi( + `repos/${reviewTarget.owner}/${reviewTarget.repo}/pulls/${reviewTarget.number}`, + ) if ( livePullRequest.state !== 'open' || livePullRequest.base?.ref !== 'dev' || @@ -370,108 +419,108 @@ export async function recordReview({ throw new Error('published review is not bound to the recorded live PR head') } const runEvents = await readEvents(loopRoot, normalizedRunId) - for (const finding of reviewSummary.findings) { - const findingMarker = `<!-- issue-dev-loop:${normalizedRunId}:${finding.findingId} -->` - const requiredFindingFragments = [ - findingMarker, - finding.findingId, - finding.severity, - finding.confidence, - finding.problem, - finding.evidence, - finding.expectedResolution, - ] - if ( - !publishedBodies.some((body) => - requiredFindingFragments.every((fragment) => body.includes(fragment)), - ) - ) { - throw new Error(`${finding.findingId} is not published verbatim in the GitHub review`) - } - const responseTarget = parsePullCommentUrl(finding.resolution.responseUrl) - if ( - !responseTarget || - responseTarget.surface !== 'pull' || - !sameRepository(reviewTarget, responseTarget) || - responseTarget.number !== reviewTarget.number - ) { - throw new Error(`${finding.findingId} response is not on the reviewed PR`) - } - const responseEndpoint = - responseTarget.kind === 'review_comment' - ? `repos/${responseTarget.owner}/${responseTarget.repo}/pulls/comments/${responseTarget.commentId}` - : `repos/${responseTarget.owner}/${responseTarget.repo}/issues/comments/${responseTarget.commentId}` - const response = await githubApi(responseEndpoint) - const responseMarker = `<!-- issue-dev-loop:${normalizedRunId}:${finding.findingId}:${finding.resolution.classification} -->` - if ( - !sameGitHubLogin(response.user?.login, automationLogin) || - !response.body?.includes(responseMarker) || - !response.body?.includes(finding.resolution.evidence) || - (finding.resolution.classification === 'accepted' && - !response.body?.includes(finding.resolution.fixCommit)) - ) { - throw new Error( - `${finding.findingId} response is not published with its classification and evidence`, - ) - } - if (finding.resolution.classification === 'accepted') { - const implementationEvent = runEvents.find( - (event) => - event.type === 'implementation_completed' && - event.status === 'passed' && - event.payload?.agent === '$implement' && - event.payload?.briefDigest === run.briefDigest && - event.payload?.commitSha === finding.resolution.fixCommit, - ) - if (!implementationEvent) { - throw new Error(`${finding.findingId} fixCommit lacks a recorded $implement invocation`) + for (const round of reviewSummary.roundDetails) { + const publication = publications.get(round.round) + const reviewSubmittedAt = Date.parse(publication.submittedAt) + for (const finding of round.findings) { + const responseTarget = parsePullCommentUrl(finding.resolution.responseUrl) + if ( + !responseTarget || + responseTarget.surface !== 'pull' || + !sameRepository(reviewTarget, responseTarget) || + responseTarget.number !== reviewTarget.number + ) { + throw new Error(`${finding.findingId} response is not on the reviewed PR`) } - const [findingToFix, fixToHead] = await Promise.all([ - githubApi( - `repos/${reviewTarget.owner}/${reviewTarget.repo}/compare/${finding.headSha}...${finding.resolution.fixCommit}`, - ), - githubApi( - `repos/${reviewTarget.owner}/${reviewTarget.repo}/compare/${finding.resolution.fixCommit}...${headSha}`, - ), - ]) + const responseEndpoint = + responseTarget.kind === 'review_comment' + ? `repos/${responseTarget.owner}/${responseTarget.repo}/pulls/comments/${responseTarget.commentId}` + : `repos/${responseTarget.owner}/${responseTarget.repo}/issues/comments/${responseTarget.commentId}` + const response = await githubApi(responseEndpoint) + const responseAt = Date.parse(response.created_at ?? response.updated_at) + const responseMarker = `<!-- issue-dev-loop:${normalizedRunId}:${finding.findingId}:${finding.resolution.classification} -->` if ( - findingToFix.status !== 'ahead' || - findingToFix.base_commit?.sha !== finding.headSha || - !['ahead', 'identical'].includes(fixToHead.status) || - fixToHead.base_commit?.sha !== finding.resolution.fixCommit + !sameGitHubLogin(response.user?.login, automationLogin) || + Number.isNaN(responseAt) || + responseAt < reviewSubmittedAt || + !response.body?.includes(responseMarker) || + !response.body?.includes(finding.resolution.evidence) || + (finding.resolution.classification === 'accepted' && + !response.body?.includes(finding.resolution.fixCommit)) ) { throw new Error( - `${finding.findingId} fixCommit must be after the finding head and within the reviewed head`, + `${finding.findingId} response is not published with its classification and evidence`, ) } - } - if ( - ['P0', 'P1'].includes(finding.severity) && - finding.resolution.classification === 'rejected' - ) { - const adjudicationTarget = parsePullCommentUrl(finding.resolution.adjudicationUrl) + if (finding.resolution.classification === 'accepted') { + const implementationEvent = runEvents.find( + (event) => + event.type === 'implementation_completed' && + event.status === 'passed' && + event.payload?.agent === '$implement' && + event.payload?.briefDigest === run.briefDigest && + event.payload?.commitSha === finding.resolution.fixCommit, + ) + if ( + !implementationEvent || + Date.parse(implementationEvent.payload.startedAt) < reviewSubmittedAt || + responseAt < Date.parse(implementationEvent.payload.finishedAt) + ) { + throw new Error(`${finding.findingId} fixCommit lacks a recorded $implement invocation`) + } + const [findingToFix, fixToHead] = await Promise.all([ + githubApi( + `repos/${reviewTarget.owner}/${reviewTarget.repo}/compare/${finding.headSha}...${finding.resolution.fixCommit}`, + ), + githubApi( + `repos/${reviewTarget.owner}/${reviewTarget.repo}/compare/${finding.resolution.fixCommit}...${headSha}`, + ), + ]) + if ( + findingToFix.status !== 'ahead' || + findingToFix.base_commit?.sha !== finding.headSha || + !['ahead', 'identical'].includes(fixToHead.status) || + fixToHead.base_commit?.sha !== finding.resolution.fixCommit + ) { + throw new Error( + `${finding.findingId} fixCommit must be after the finding head and within the reviewed head`, + ) + } + } if ( - !adjudicationTarget || - adjudicationTarget.surface !== 'pull' || - !sameRepository(reviewTarget, adjudicationTarget) || - adjudicationTarget.number !== reviewTarget.number + ['P0', 'P1'].includes(finding.severity) && + finding.resolution.classification === 'rejected' ) { - throw new Error(`${finding.findingId} adjudication is not on the reviewed PR`) - } - const adjudicationEndpoint = - adjudicationTarget.kind === 'review_comment' - ? `repos/${adjudicationTarget.owner}/${adjudicationTarget.repo}/pulls/comments/${adjudicationTarget.commentId}` - : `repos/${adjudicationTarget.owner}/${adjudicationTarget.repo}/issues/comments/${adjudicationTarget.commentId}` - const adjudication = await githubApi(adjudicationEndpoint) - const expectedVerdict = finding.resolution.adjudicationVerdict - const expectedMarker = `<!-- issue-dev-loop:${normalizedRunId}:${finding.findingId}:adjudication:${expectedVerdict} -->` - const permittedAdjudicator = - (expectedVerdict === 'REJECT_FINDING' && - sameGitHubLogin(adjudication.user?.login, reviewerLogin)) || - (expectedVerdict === 'OWNER_REJECTED_FINDING' && - sameGitHubLogin(adjudication.user?.login, channel.ownerGitHubLogin)) - if (!permittedAdjudicator || !adjudication.body?.includes(expectedMarker)) { - throw new Error(`${finding.findingId} lacks independent published adjudication`) + const adjudicationTarget = parsePullCommentUrl(finding.resolution.adjudicationUrl) + if ( + !adjudicationTarget || + adjudicationTarget.surface !== 'pull' || + !sameRepository(reviewTarget, adjudicationTarget) || + adjudicationTarget.number !== reviewTarget.number + ) { + throw new Error(`${finding.findingId} adjudication is not on the reviewed PR`) + } + const adjudicationEndpoint = + adjudicationTarget.kind === 'review_comment' + ? `repos/${adjudicationTarget.owner}/${adjudicationTarget.repo}/pulls/comments/${adjudicationTarget.commentId}` + : `repos/${adjudicationTarget.owner}/${adjudicationTarget.repo}/issues/comments/${adjudicationTarget.commentId}` + const adjudication = await githubApi(adjudicationEndpoint) + const adjudicationAt = Date.parse(adjudication.created_at ?? adjudication.updated_at) + const expectedVerdict = finding.resolution.adjudicationVerdict + const expectedMarker = `<!-- issue-dev-loop:${normalizedRunId}:${finding.findingId}:adjudication:${expectedVerdict} -->` + const permittedAdjudicator = + (expectedVerdict === 'REJECT_FINDING' && + sameGitHubLogin(adjudication.user?.login, reviewerLogin)) || + (expectedVerdict === 'OWNER_REJECTED_FINDING' && + sameGitHubLogin(adjudication.user?.login, channel.ownerGitHubLogin)) + if ( + !permittedAdjudicator || + !adjudication.body?.includes(expectedMarker) || + Number.isNaN(adjudicationAt) || + adjudicationAt < reviewSubmittedAt + ) { + throw new Error(`${finding.findingId} lacks independent published adjudication`) + } } } } diff --git a/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs b/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs index bdb9d20f..49f33e4a 100644 --- a/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs +++ b/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs @@ -10,6 +10,7 @@ import { defaultGitHubApi, defaultGitHubPaginatedApi, parsePullCommentUrl, + pathExists, readJson, runDirectory, sameGitHubLogin, @@ -103,6 +104,31 @@ export async function prepareFinalizationRecord({ const normalizedRunId = assertRunId(runId) const run = await readRun(loopRoot, normalizedRunId) if (run.finishedAt !== null) throw new Error('cannot prepare finalization for a finished run') + const resultPath = path.join(runDirectory(loopRoot, normalizedRunId), 'finalization-result.json') + if (await pathExists(resultPath)) { + const existing = validateRecord(await readJson(resultPath), run) + if ( + existing.status !== status || + existing.mergeSha !== mergeSha || + existing.failureFingerprint !== failureFingerprint + ) { + throw new Error('a different finalization record is already prepared for this run') + } + const { channel, owner, repo } = await journalConfiguration(loopRoot) + const digest = recordDigest(existing) + return { + record: existing, + resultPath, + digest, + body: [ + `<!-- issue-dev-loop:finalization:${normalizedRunId}:sha256:${digest} -->`, + '```json', + canonicalRecord(existing), + '```', + ].join('\n'), + journalIssueUrl: `https://github.com/${owner}/${repo}/issues/${channel.stateIssueNumber}`, + } + } const record = validateRecord( { schemaVersion: 1, @@ -126,7 +152,6 @@ export async function prepareFinalizationRecord({ canonicalRecord(record), '```', ].join('\n') - const resultPath = path.join(runDirectory(loopRoot, normalizedRunId), 'finalization-result.json') await writeJson(resultPath, record) return { record, diff --git a/loops/issue-dev-loop/scripts/lib/github.mjs b/loops/issue-dev-loop/scripts/lib/github.mjs index aae8c4fd..4052f25b 100644 --- a/loops/issue-dev-loop/scripts/lib/github.mjs +++ b/loops/issue-dev-loop/scripts/lib/github.mjs @@ -20,6 +20,7 @@ import { reconcileFinalizationJournal, recordFinalizationPublication, } from './finalization-journal.mjs' +import { reconcileActiveJournal } from './active-journal.mjs' import { observeOwnerApprovedMerge } from './owner-gate.mjs' import { defaultReleaseIssueClaim } from './issue-claim.mjs' import { appendValidatedEvent, finalizeRun, readEvents, readRun } from './run-store.mjs' @@ -67,6 +68,15 @@ export function selectIssue({ issues = [], pullRequests = [] } = {}) { return issue ? { hasWork: true, issue } : { hasWork: false, issue: null } } +export async function reconcileLoopJournal({ + loopRoot = DEFAULT_LOOP_ROOT, + now = new Date(), +} = {}) { + const finalization = await reconcileFinalizationJournal({ loopRoot, now }) + const active = await reconcileActiveJournal({ loopRoot }) + return { ...finalization, ...active } +} + async function loadJsonFile(target) { return JSON.parse(await readFile(path.resolve(target), 'utf8')) } @@ -77,7 +87,7 @@ export async function detectWork({ pullRequestsFile, repo, now = new Date(), - reconcileJournal = reconcileFinalizationJournal, + reconcileJournal = reconcileLoopJournal, } = {}) { const recordTriggerCheck = async (result) => { await appendJsonLine(path.join(loopRoot, 'logs', 'triggers.jsonl'), { @@ -86,12 +96,27 @@ export async function detectWork({ timestamp: now.toISOString(), hasWork: result.hasWork, workType: result.workType, + runId: result.runId ?? null, issueNumber: result.issue?.number ?? null, requestId: result.requestId ?? null, }) return result } - if (!issuesFile && !pullRequestsFile) await reconcileJournal({ loopRoot, now }) + const reconciliation = + !issuesFile && !pullRequestsFile ? await reconcileJournal({ loopRoot, now }) : null + const resumable = reconciliation?.activeCheckpoints?.[0] + if (resumable) { + return recordTriggerCheck({ + hasWork: true, + workType: 'resume', + runId: resumable.run.runId, + issue: { + number: resumable.run.issueNumber, + title: resumable.run.issueTitle, + url: resumable.run.issueUrl, + }, + }) + } const evolve = await readJson(path.join(loopRoot, 'evolve', 'metrics.json')) if (evolve.evolveDue) { return recordTriggerCheck({ @@ -164,12 +189,6 @@ export async function observeOwnerMerge({ expectedHeadBranch: run.branch, githubApi, }) - await releaseIssueClaim({ - issueUrl: run.issueUrl, - issueNumber: run.issueNumber, - githubApi, - }) - await appendValidatedEvent({ loopRoot, runId: normalizedRunId, @@ -205,6 +224,8 @@ export async function observeOwnerMerge({ status: 'completed', mergeSha: merge.mergeSha, now, + githubApi, + releaseIssueClaim, }) } diff --git a/loops/issue-dev-loop/scripts/lib/notifications.mjs b/loops/issue-dev-loop/scripts/lib/notifications.mjs index b5616415..cffe5292 100644 --- a/loops/issue-dev-loop/scripts/lib/notifications.mjs +++ b/loops/issue-dev-loop/scripts/lib/notifications.mjs @@ -12,13 +12,7 @@ import { timestampToken, writeJson, } from './common.mjs' -import { - PAUSED_STATUSES, - appendValidatedEvent, - readEvents, - readRun, - transitionRun, -} from './run-store.mjs' +import { appendValidatedEvent, readEvents, readRun, transitionRun } from './run-store.mjs' function notificationBody(notification, owner) { const evidence = notification.evidenceUrl ? `\n\nEvidence: ${notification.evidenceUrl}` : '' @@ -84,13 +78,15 @@ export async function createNotification({ if (ownerReadyType) { const events = await readEvents(loopRoot, normalizedRunId) for (const eventType of ['verification_completed', 'review_completed']) { - if (!events.some( - (event) => - event.type === eventType && - event.status === 'passed' && - event.payload?.headSha === run.headSha && - (eventType !== 'verification_completed' || event.payload?.manifestUrl === evidenceUrl), - )) { + if ( + !events.some( + (event) => + event.type === eventType && + event.status === 'passed' && + event.payload?.headSha === run.headSha && + (eventType !== 'verification_completed' || event.payload?.manifestUrl === evidenceUrl), + ) + ) { throw new Error(`${notificationType} requires exact-head verification and review evidence`) } } @@ -107,10 +103,7 @@ export async function createNotification({ pullTarget && sameRepository(pullTarget, target) && target.number === pullTarget.number - if ( - targetUrl && - (!target || (!isRunIssue && !isRunPull)) - ) { + if (targetUrl && (!target || (!isRunIssue && !isRunPull))) { throw new Error('targetUrl must be the exact run issue or recorded pull request') } const suffix = (entropy ?? randomBytes(3).toString('hex')).toUpperCase() @@ -188,7 +181,7 @@ export async function createNotification({ }) if (blocking && !dryRun) { - if (run.finishedAt === null && !PAUSED_STATUSES.has(run.status)) { + if (run.finishedAt === null && run.status !== 'waiting_for_owner') { await transitionRun({ loopRoot, runId: normalizedRunId, status: 'waiting_for_owner', now }) } } diff --git a/loops/issue-dev-loop/scripts/lib/run-store.mjs b/loops/issue-dev-loop/scripts/lib/run-store.mjs index 6951fcee..c0bbf823 100644 --- a/loops/issue-dev-loop/scripts/lib/run-store.mjs +++ b/loops/issue-dev-loop/scripts/lib/run-store.mjs @@ -43,6 +43,8 @@ const RESERVED_EVENT_TYPES = new Set([ 'run_status_changed', 'run_finalization_authorized', 'run_finalized', + 'checkpoint_published', + 'issue_claim_released', ]) const ALLOWED_TRANSITIONS = new Map([ @@ -51,7 +53,7 @@ const ALLOWED_TRANSITIONS = new Map([ 'waiting_for_owner', new Set(['running', 'awaiting_owner_review', 'blocked', 'failed', 'cancelled']), ], - ['awaiting_owner_review', new Set(['running', 'completed', 'cancelled'])], + ['awaiting_owner_review', new Set(['running', 'waiting_for_owner', 'completed', 'cancelled'])], ]) export function makeRunId({ issueNumber, now = new Date(), entropy } = {}) { @@ -266,6 +268,31 @@ async function defaultCommitRangeValidator({ loopRoot, ancestor, descendant }) { }) } +async function defaultTrailingPathValidator({ loopRoot, runId, ancestor, descendant }) { + if (ancestor === descendant) return + const repositoryRoot = path.resolve(loopRoot, '..', '..') + const result = await execFileAsync( + 'git', + ['diff', '--name-only', '--diff-filter=ACMR', `${ancestor}..${descendant}`], + { cwd: repositoryRoot, maxBuffer: 1024 * 1024 }, + ) + const permittedPrefixes = [ + `loops/issue-dev-loop/logs/runs/${runId}/`, + `loops/issue-dev-loop/handoffs/${runId}/`, + `loops/issue-dev-loop/screen-shots/${runId}/`, + `loops/issue-dev-loop/evidence/${runId}/`, + ] + const unexpected = result.stdout + .split('\n') + .filter(Boolean) + .filter((file) => !permittedPrefixes.some((prefix) => file.startsWith(prefix))) + if (unexpected.length > 0) { + throw new Error( + `product changes after the recorded $implement commit are forbidden: ${unexpected.join(', ')}`, + ) + } +} + export async function recordImplementation({ loopRoot = DEFAULT_LOOP_ROOT, runId, @@ -358,6 +385,7 @@ export async function recordPullRequest({ headSha, now = new Date(), githubApi = defaultGitHubApi, + trailingPathValidator = defaultTrailingPathValidator, } = {}) { const normalizedRunId = assertRunId(runId) const runFile = path.join(runDirectory(loopRoot, normalizedRunId), 'run.json') @@ -417,6 +445,12 @@ export async function recordPullRequest({ ) { throw new Error('recorded $implement commit is not contained in the draft PR head') } + await trailingPathValidator({ + loopRoot, + runId: normalizedRunId, + ancestor: run.implementationCommit, + descendant: headSha, + }) const updated = { ...run, prUrl, headSha } await writeJson(runFile, updated) await appendValidatedEvent({ @@ -549,6 +583,29 @@ async function ensureFinalizationArtifacts({ }) } await updateEvolveMetrics({ loopRoot, now }) + return run +} + +async function ensureIssueClaimReleased({ loopRoot, run, githubApi, releaseIssueClaim, now }) { + const events = await readEvents(loopRoot, run.runId) + const alreadyReleased = events.some( + (event) => event.type === 'issue_claim_released' && event.status === 'released', + ) + if (!alreadyReleased) { + await releaseIssueClaim({ + issueUrl: run.issueUrl, + issueNumber: run.issueNumber, + githubApi, + }) + await appendValidatedEvent({ + loopRoot, + runId: run.runId, + type: 'issue_claim_released', + status: 'released', + payload: { issueNumber: run.issueNumber }, + now, + }) + } await rm(path.join(loopRoot, 'logs', 'claims', `issue-${run.issueNumber}`), { recursive: true, force: true, @@ -585,13 +642,20 @@ export async function transitionRun({ if (!TERMINAL_STATUSES.has(status) || status !== run.status || !authorization) { throw new Error(`run is already finalized: ${normalizedRunId}`) } - return ensureFinalizationArtifacts({ + const finalized = await ensureFinalizationArtifacts({ loopRoot, run, previousStatus: authorization.payload.previousStatus, failureFingerprint: authorization.payload.failureFingerprint ?? null, now: new Date(run.finishedAt), }) + return ensureIssueClaimReleased({ + loopRoot, + run: finalized, + githubApi, + releaseIssueClaim, + now, + }) } if (run.status === status) throw new Error(`run already has status: ${status}`) const events = await readEvents(loopRoot, normalizedRunId) @@ -722,14 +786,6 @@ export async function transitionRun({ ) } } - if (['failed', 'blocked', 'cancelled'].includes(status)) { - await releaseIssueClaim({ - issueUrl: run.issueUrl, - issueNumber: run.issueNumber, - githubApi, - }) - } - const finalizationPublication = TERMINAL_STATUSES.has(status) ? events.findLast( (event) => @@ -777,13 +833,20 @@ export async function transitionRun({ }) return transitioned } - return ensureFinalizationArtifacts({ + const finalized = await ensureFinalizationArtifacts({ loopRoot, run: transitioned, previousStatus: run.status, failureFingerprint, now, }) + return ensureIssueClaimReleased({ + loopRoot, + run: finalized, + githubApi, + releaseIssueClaim, + now, + }) } export async function finalizeRun(options = {}) { diff --git a/loops/issue-dev-loop/scripts/lib/validation.mjs b/loops/issue-dev-loop/scripts/lib/validation.mjs index 5dde5408..af180ea6 100644 --- a/loops/issue-dev-loop/scripts/lib/validation.mjs +++ b/loops/issue-dev-loop/scripts/lib/validation.mjs @@ -33,6 +33,7 @@ export async function validateLoop({ loopRoot = DEFAULT_LOOP_ROOT } = {}) { 'schemas/run.schema.json', 'schemas/evidence.schema.json', 'schemas/finalization-record.schema.json', + 'schemas/checkpoint-record.schema.json', 'schemas/implementation-result.schema.json', 'scripts/generate-evidence.mjs', 'scripts/resolve-run.mjs', @@ -41,6 +42,7 @@ export async function validateLoop({ loopRoot = DEFAULT_LOOP_ROOT } = {}) { 'scripts/lib/evidence.mjs', 'scripts/lib/evolve.mjs', 'scripts/lib/finalization-journal.mjs', + 'scripts/lib/active-journal.mjs', 'scripts/lib/github.mjs', 'scripts/lib/issue-claim.mjs', 'scripts/lib/notifications.mjs', diff --git a/loops/issue-dev-loop/scripts/loopctl.mjs b/loops/issue-dev-loop/scripts/loopctl.mjs index 41d1d710..a755ff17 100644 --- a/loops/issue-dev-loop/scripts/loopctl.mjs +++ b/loops/issue-dev-loop/scripts/loopctl.mjs @@ -10,8 +10,10 @@ import { getEvolveStatus, observeOwnerMerge, parseArguments, + prepareActiveCheckpoint, prepareFinalizationRecord, - reconcileFinalizationJournal, + reconcileLoopJournal, + recordActiveCheckpointPublication, recordEvidence, recordFinalizationPublication, recordImplementation, @@ -134,6 +136,18 @@ async function main() { }), ) break + case 'prepare-checkpoint': + output(await prepareActiveCheckpoint({ runId: args['run-id'] })) + break + case 'record-checkpoint': + output( + await recordActiveCheckpointPublication({ + runId: args['run-id'], + resultPath: args.result, + commentUrl: args['comment-url'], + }), + ) + break case 'prepare-finalization': output( await prepareFinalizationRecord({ @@ -146,7 +160,7 @@ async function main() { ) break case 'reconcile': - output(await reconcileFinalizationJournal()) + output(await reconcileLoopJournal()) break case 'observe-owner-merge': output( @@ -197,7 +211,7 @@ async function main() { break default: throw new Error( - 'usage: loopctl.mjs <start|freeze-brief|record-implementation|event|record-pr|record-owner-response|record-evidence|record-review|prepare-finalization|record-finalization|reconcile|transition|finalize|observe-owner-merge|notify|detect-work|validate|evolve-status|evolve-complete> [options]', + 'usage: loopctl.mjs <start|freeze-brief|record-implementation|event|record-pr|record-owner-response|record-evidence|record-review|prepare-checkpoint|record-checkpoint|prepare-finalization|record-finalization|reconcile|transition|finalize|observe-owner-merge|notify|detect-work|validate|evolve-status|evolve-complete> [options]', ) } } diff --git a/loops/issue-dev-loop/scripts/runtime.mjs b/loops/issue-dev-loop/scripts/runtime.mjs index ad140960..41fe5569 100644 --- a/loops/issue-dev-loop/scripts/runtime.mjs +++ b/loops/issue-dev-loop/scripts/runtime.mjs @@ -1,6 +1,13 @@ export { DEFAULT_LOOP_ROOT, parseArguments } from './lib/common.mjs' export { completeEvolve, getEvolveStatus } from './lib/evolve.mjs' export { recordEvidence, recordReview } from './lib/evidence.mjs' +export { + canonicalCheckpoint, + checkpointDigest, + prepareActiveCheckpoint, + reconcileActiveJournal, + recordActiveCheckpointPublication, +} from './lib/active-journal.mjs' export { canonicalRecord, prepareFinalizationRecord, @@ -8,7 +15,13 @@ export { recordDigest, recordFinalizationPublication, } from './lib/finalization-journal.mjs' -export { detectWork, observeOwnerMerge, recordOwnerResponse, selectIssue } from './lib/github.mjs' +export { + detectWork, + observeOwnerMerge, + reconcileLoopJournal, + recordOwnerResponse, + selectIssue, +} from './lib/github.mjs' export { createNotification } from './lib/notifications.mjs' export { defaultClaimIssue } from './lib/issue-claim.mjs' export { diff --git a/loops/issue-dev-loop/state.md b/loops/issue-dev-loop/state.md index 0eb81f2a..483d3d01 100644 --- a/loops/issue-dev-loop/state.md +++ b/loops/issue-dev-loop/state.md @@ -11,6 +11,7 @@ Updated: 2026-07-22 - Protected release branch: `main` - Maximum implementation repairs: 2 - Maximum review rounds: 2 +- Durable state journal: issue #105 ## Active runs @@ -24,7 +25,6 @@ None. - Configure distinct unattended executor and reviewer GitHub identities and set their exact logins in `automationGitHubLogin` and `reviewerGitHubLogin`. - Merge this infrastructure into `dev` before enabling its recurring automation; the PR evidence workflow must exist on the base branch. -- Repair or explicitly rebaseline the existing `tests/docs-cutover.test.ts` README contract failure before any issue PR can satisfy authoritative `pnpm verify`. - Choose the recurring Codex automation cadence after the infrastructure PR is merged. - Optionally configure `ECHO_UI_LOOP_OWNER_WEBHOOK_URL` for a push-channel mirror; GitHub mentions remain the canonical baseline channel. diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index 7b3cc132..6cce8473 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict' import { execFile } from 'node:child_process' import { createHash } from 'node:crypto' -import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import test from 'node:test' @@ -10,7 +10,9 @@ import { fileURLToPath } from 'node:url' import { appendEvent, + canonicalCheckpoint, canonicalRecord, + checkpointDigest, completeEvolve, createNotification, defaultClaimIssue, @@ -19,10 +21,14 @@ import { freezeBrief, getEvolveStatus, observeOwnerMerge, + prepareActiveCheckpoint, + prepareFinalizationRecord, + reconcileActiveJournal, reconcileFinalizationJournal, recordEvidence, recordDigest, recordFinalizationPublication, + recordActiveCheckpointPublication, recordImplementation, recordOwnerResponse, recordPullRequest, @@ -197,6 +203,7 @@ async function recordFixturePr({ endpoint.includes('/compare/') ? { status: 'ahead', base_commit: { sha: implementationCommit } } : pullRequestFixture(run, headSha), + trailingPathValidator: async () => {}, }) return prUrl } @@ -245,7 +252,7 @@ async function writePassingEvidence({ loopRoot, run, headSha }) { return manifestPath } -async function writePassingReview({ loopRoot, run, headSha }) { +async function writePassingReview({ loopRoot, run, headSha, prNumber = 200, reviewId = 300 }) { const resultPath = path.join(loopRoot, 'logs', 'runs', run.runId, 'review-result.json') await writeFile( resultPath, @@ -256,7 +263,16 @@ async function writePassingReview({ loopRoot, run, headSha }) { freshContext: true, headSha, verdict: 'PASS', - rounds: [{ round: 1, headSha, verdict: 'PASS', findings: [] }], + rounds: [ + { + round: 1, + headSha, + reviewUrl: 'https://github.com/codeacme17/echo-ui/pull/302#pullrequestreview-500', + reviewUrl: `https://github.com/codeacme17/echo-ui/pull/${prNumber}#pullrequestreview-${reviewId}`, + verdict: 'PASS', + findings: [], + }, + ], })}\n`, 'utf8', ) @@ -527,6 +543,7 @@ test('frozen brief and $implement invocation history cannot be rewritten or reus ) const updatedHead = '5'.repeat(40) + let checkedTrailingRange const rebound = await recordPullRequest({ loopRoot, runId: run.runId, @@ -536,8 +553,30 @@ test('frozen brief and $implement invocation history cannot be rewritten or reus endpoint.includes('/compare/') ? { status: 'ahead', base_commit: { sha: secondCommit } } : pullRequestFixture(run, updatedHead, { draft: false }), + trailingPathValidator: async (range) => { + checkedTrailingRange = range + }, }) assert.equal(rebound.headSha, updatedHead) + assert.equal(checkedTrailingRange.ancestor, secondCommit) + assert.equal(checkedTrailingRange.descendant, updatedHead) + + await assert.rejects( + recordPullRequest({ + loopRoot, + runId: run.runId, + prUrl, + headSha: '6'.repeat(40), + githubApi: async (endpoint) => + endpoint.includes('/compare/') + ? { status: 'ahead', base_commit: { sha: secondCommit } } + : pullRequestFixture(run, '6'.repeat(40), { draft: false }), + trailingPathValidator: async () => { + throw new Error('product changes after the recorded $implement commit are forbidden') + }, + }), + /product changes after the recorded \$implement commit are forbidden/, + ) const briefPath = path.join(loopRoot, 'handoffs', run.runId, 'implementation-brief.md') await writeFile( @@ -752,8 +791,13 @@ test('owner-ready transition requires verification and review but remains resuma return { commit_id: headSha, state: 'COMMENTED', + submitted_at: '2026-07-22T17:00:00.000Z', user: { login: 'echo-ui-reviewer[bot]' }, - body: `PASS\n\n<!-- issue-dev-loop:${run.runId}:review-result-sha256:${reviewDigest} -->`, + body: [ + 'PASS', + `<!-- issue-dev-loop:${run.runId}:review-round:1:head:${headSha} -->`, + `<!-- issue-dev-loop:${run.runId}:review-result-sha256:${reviewDigest} -->`, + ].join('\n'), } }, }) @@ -943,6 +987,7 @@ test('review gate verifies published findings and classified replies', async () { round: 1, headSha: 'e'.repeat(40), + reviewUrl: 'https://github.com/codeacme17/echo-ui/pull/300#pullrequestreview-499', verdict: 'CHANGES_REQUESTED', findings: [ { @@ -961,7 +1006,13 @@ test('review gate verifies published findings and classified replies', async () }, ], }, - { round: 2, headSha, verdict: 'PASS', findings: [] }, + { + round: 2, + headSha, + reviewUrl: 'https://github.com/codeacme17/echo-ui/pull/300#pullrequestreview-500', + verdict: 'PASS', + findings: [], + }, ], })}\n`, 'utf8', @@ -979,24 +1030,33 @@ test('review gate verifies published findings and classified replies', async () if (endpoint.includes('/issues/comments/400')) { return { user: { login: 'echo-ui-loop[bot]' }, + created_at: '2026-07-22T17:00:00.000Z', body: `Rejected with proof. Reproduction command exits successfully.\n<!-- issue-dev-loop:${run.runId}:RVW-1-1:rejected -->`, } } if (endpoint.endsWith('/pulls/300')) return pullRequestFixture(run, headSha) + const firstRound = endpoint.includes('/reviews/499') return { - commit_id: headSha, + commit_id: firstRound ? 'e'.repeat(40) : headSha, state: 'COMMENTED', + submitted_at: firstRound ? '2026-07-22T16:00:00.000Z' : '2026-07-22T18:00:00.000Z', user: { login: 'echo-ui-reviewer[bot]' }, - body: [ - 'RVW-1-1', - 'P2', - 'high', - 'Incorrect assertion', - 'The runtime check already guarantees this invariant.', - 'Prove or fix the assertion.', - `<!-- issue-dev-loop:${run.runId}:RVW-1-1 -->`, - `<!-- issue-dev-loop:${run.runId}:review-result-sha256:${digest} -->`, - ].join('\n'), + body: firstRound + ? [ + 'RVW-1-1', + 'P2', + 'high', + 'Incorrect assertion', + 'The runtime check already guarantees this invariant.', + 'Prove or fix the assertion.', + `<!-- issue-dev-loop:${run.runId}:RVW-1-1 -->`, + `<!-- issue-dev-loop:${run.runId}:review-round:1:head:${'e'.repeat(40)} -->`, + ].join('\n') + : [ + 'PASS', + `<!-- issue-dev-loop:${run.runId}:review-round:2:head:${headSha} -->`, + `<!-- issue-dev-loop:${run.runId}:review-result-sha256:${digest} -->`, + ].join('\n'), } }, }) @@ -1060,6 +1120,7 @@ test('accepted review fix must be after the finding head and inside the final he { round: 1, headSha: findingHead, + reviewUrl: 'https://github.com/codeacme17/echo-ui/pull/304#pullrequestreview-509', verdict: 'CHANGES_REQUESTED', findings: [ { @@ -1079,7 +1140,13 @@ test('accepted review fix must be after the finding head and inside the final he }, ], }, - { round: 2, headSha, verdict: 'PASS', findings: [] }, + { + round: 2, + headSha, + reviewUrl: 'https://github.com/codeacme17/echo-ui/pull/304#pullrequestreview-510', + verdict: 'PASS', + findings: [], + }, ], } await writeFile(resultPath, `${JSON.stringify(result)}\n`, 'utf8') @@ -1096,6 +1163,7 @@ test('accepted review fix must be after the finding head and inside the final he if (endpoint.includes('/issues/comments/410')) { return { user: { login: 'echo-ui-loop[bot]' }, + created_at: '2026-07-22T17:40:00.000Z', body: `pnpm verify passes after the guard. ${fixCommit}\n<!-- issue-dev-loop:${run.runId}:RVW-1-1:accepted -->`, } } @@ -1106,20 +1174,28 @@ test('accepted review fix must be after the finding head and inside the final he return { status: 'ahead', base_commit: { sha: fixCommit } } } if (endpoint.endsWith('/pulls/304')) return pullRequestFixture(run, headSha) + const firstRound = endpoint.includes('/reviews/509') return { - commit_id: headSha, + commit_id: firstRound ? findingHead : headSha, state: 'COMMENTED', + submitted_at: firstRound ? '2026-07-22T16:00:00.000Z' : '2026-07-22T18:00:00.000Z', user: { login: 'echo-ui-reviewer[bot]' }, - body: [ - 'RVW-1-1', - 'P2', - 'high', - 'Missing guard', - 'The failure is reproducible.', - 'Add the guard.', - `<!-- issue-dev-loop:${run.runId}:RVW-1-1 -->`, - `<!-- issue-dev-loop:${run.runId}:review-result-sha256:${digest} -->`, - ].join('\n'), + body: firstRound + ? [ + 'RVW-1-1', + 'P2', + 'high', + 'Missing guard', + 'The failure is reproducible.', + 'Add the guard.', + `<!-- issue-dev-loop:${run.runId}:RVW-1-1 -->`, + `<!-- issue-dev-loop:${run.runId}:review-round:1:head:${findingHead} -->`, + ].join('\n') + : [ + 'PASS', + `<!-- issue-dev-loop:${run.runId}:review-round:2:head:${headSha} -->`, + `<!-- issue-dev-loop:${run.runId}:review-result-sha256:${digest} -->`, + ].join('\n'), } }, }) @@ -1151,6 +1227,7 @@ test('review gate binds high-severity adjudication verdict to the correct identi { round: 1, headSha, + reviewUrl: 'https://github.com/codeacme17/echo-ui/pull/302#pullrequestreview-500', verdict: 'CHANGES_REQUESTED', findings: [ { @@ -1171,7 +1248,13 @@ test('review gate binds high-severity adjudication verdict to the correct identi }, ], }, - { round: 2, headSha, verdict: 'PASS', findings: [] }, + { + round: 2, + headSha, + reviewUrl: 'https://github.com/codeacme17/echo-ui/pull/302#pullrequestreview-501', + verdict: 'PASS', + findings: [], + }, ], })}\n`, 'utf8', @@ -1190,30 +1273,40 @@ test('review gate binds high-severity adjudication verdict to the correct identi if (endpoint.includes('/issues/comments/401')) { return { user: { login: 'echo-ui-loop[bot]' }, + created_at: '2026-07-22T17:00:00.000Z', body: `Executor disagrees.\n<!-- issue-dev-loop:${run.runId}:RVW-1-1:rejected -->`, } } if (endpoint.includes('/issues/comments/402')) { return { user: { login: 'echo-ui-reviewer[bot]' }, + created_at: '2026-07-22T17:10:00.000Z', body: `<!-- issue-dev-loop:${run.runId}:RVW-1-1:adjudication:OWNER_REJECTED_FINDING -->`, } } if (endpoint.endsWith('/pulls/302')) return pullRequestFixture(run, headSha) + const firstRound = endpoint.includes('/reviews/500') return { commit_id: headSha, state: 'COMMENTED', + submitted_at: firstRound ? '2026-07-22T16:00:00.000Z' : '2026-07-22T18:00:00.000Z', user: { login: 'echo-ui-reviewer[bot]' }, - body: [ - 'RVW-1-1', - 'P1', - 'high', - 'Potential public API break', - 'The export changed.', - 'Restore compatibility or adjudicate.', - `<!-- issue-dev-loop:${run.runId}:RVW-1-1 -->`, - `<!-- issue-dev-loop:${run.runId}:review-result-sha256:${digest} -->`, - ].join('\n'), + body: firstRound + ? [ + 'RVW-1-1', + 'P1', + 'high', + 'Potential public API break', + 'The export changed.', + 'Restore compatibility or adjudicate.', + `<!-- issue-dev-loop:${run.runId}:RVW-1-1 -->`, + `<!-- issue-dev-loop:${run.runId}:review-round:1:head:${headSha} -->`, + ].join('\n') + : [ + 'PASS', + `<!-- issue-dev-loop:${run.runId}:review-round:2:head:${headSha} -->`, + `<!-- issue-dev-loop:${run.runId}:review-result-sha256:${digest} -->`, + ].join('\n'), } }, }), @@ -1360,6 +1453,62 @@ test('failed blocking delivery still pauses for the owner', async () => { assert.equal(resumed.status, 'running') }) +test('a new blocker moves an owner-review run back to the decision pause', async () => { + const { loopRoot } = await createFixture() + const { run } = await startFixtureRun({ + loopRoot, + issueNumber: 144, + issueTitle: 'Owner-review blocker', + issueUrl: 'https://github.com/codeacme17/echo-ui/issues/144', + entropy: 'block144', + }) + const runPath = path.join(loopRoot, 'logs', 'runs', run.runId, 'run.json') + await writeFile( + runPath, + `${JSON.stringify({ ...run, status: 'awaiting_owner_review' })}\n`, + 'utf8', + ) + await createNotification({ + loopRoot, + runId: run.runId, + type: 'loop_failed', + summary: 'The owner-review observer cannot reach GitHub', + requestedAction: 'Restore access or cancel the run', + targetUrl: run.issueUrl, + blocking: true, + githubComment: async () => {}, + }) + const paused = JSON.parse(await readFile(runPath, 'utf8')) + assert.equal(paused.status, 'waiting_for_owner') +}) + +test('preparing the same terminal journal record is idempotent', async () => { + const { loopRoot } = await createFixture() + const { run } = await startFixtureRun({ + loopRoot, + issueNumber: 145, + issueTitle: 'Retry finalization prepare', + issueUrl: 'https://github.com/codeacme17/echo-ui/issues/145', + entropy: 'final145', + }) + const first = await prepareFinalizationRecord({ + loopRoot, + runId: run.runId, + status: 'blocked', + failureFingerprint: 'same-terminal-cause', + finishedAt: new Date('2026-07-22T19:00:00.000Z'), + }) + const retried = await prepareFinalizationRecord({ + loopRoot, + runId: run.runId, + status: 'blocked', + failureFingerprint: 'same-terminal-cause', + finishedAt: new Date('2026-07-22T20:00:00.000Z'), + }) + assert.equal(retried.digest, first.digest) + assert.equal(retried.record.finishedAt, '2026-07-22T19:00:00.000Z') +}) + test('three matching failures make a fresh evolve session due', async () => { const { loopRoot } = await createFixture() for (let issueNumber = 201; issueNumber <= 203; issueNumber += 1) { @@ -1385,7 +1534,13 @@ test('three matching failures make a fresh evolve session due', async () => { runId: run.runId, status: 'blocked', failureFingerprint: 'browser-environment-unavailable', - releaseIssueClaim: async () => {}, + releaseIssueClaim: async () => { + const persisted = JSON.parse( + await readFile(path.join(loopRoot, 'logs', 'runs', run.runId, 'run.json'), 'utf8'), + ) + assert.equal(persisted.status, 'blocked') + assert.notEqual(persisted.finishedAt, null) + }, } await publishFixtureFinalization({ loopRoot, @@ -1455,6 +1610,60 @@ test('fresh worktrees rebuild finalization history and evolve metrics from GitHu assert.equal(metrics.failedRuns, 1) }) +test('fresh worktrees restore active checkpoints and trigger resumable work', async () => { + const { loopRoot } = await createFixture() + const { run } = await startFixtureRun({ + loopRoot, + issueNumber: 206, + issueTitle: 'Resume durable work', + issueUrl: 'https://github.com/codeacme17/echo-ui/issues/206', + now: new Date('2026-07-22T12:00:00.000Z'), + entropy: 'resume1', + }) + const prepared = await prepareActiveCheckpoint({ loopRoot, runId: run.runId }) + const commentUrl = 'https://github.com/codeacme17/echo-ui/issues/999#issuecomment-9910' + await recordActiveCheckpointPublication({ + loopRoot, + runId: run.runId, + resultPath: prepared.resultPath, + commentUrl, + now: new Date('2026-07-22T12:01:00.000Z'), + githubApi: async () => ({ + user: { login: 'echo-ui-loop[bot]' }, + body: prepared.body, + }), + }) + assert.equal(checkpointDigest(prepared.record), prepared.digest) + assert.equal(JSON.parse(canonicalCheckpoint(prepared.record)).run.runId, run.runId) + + await rm(path.join(loopRoot, 'logs', 'runs', run.runId), { recursive: true, force: true }) + await rm(path.join(loopRoot, 'handoffs', run.runId), { recursive: true, force: true }) + const durableComment = { + user: { login: 'echo-ui-loop[bot]' }, + body: prepared.body, + html_url: commentUrl, + created_at: '2026-07-22T12:01:00.000Z', + } + const reconciled = await reconcileActiveJournal({ + loopRoot, + githubPaginatedApi: async () => [durableComment], + }) + assert.equal(reconciled.activeCheckpoints[0].run.runId, run.runId) + const restored = JSON.parse( + await readFile(path.join(loopRoot, 'logs', 'runs', run.runId, 'run.json'), 'utf8'), + ) + assert.equal(restored.issueNumber, 206) + + const detected = await detectWork({ + loopRoot, + now: new Date('2026-07-22T12:02:00.000Z'), + reconcileJournal: async () => ({ activeCheckpoints: [prepared.record] }), + }) + assert.equal(detected.hasWork, true) + assert.equal(detected.workType, 'resume') + assert.equal(detected.runId, run.runId) +}) + test('evolve completion rejects an unrelated historical owner-merged PR', async () => { const { loopRoot } = await createFixture() const requestId = 'EVL-20260722T120000-ABC123' From c9bb553158a54971f7e496686eb0ae73791a4d33 Mon Sep 17 00:00:00 2001 From: leyoonafr <codeacme17@gmail.com> Date: Thu, 23 Jul 2026 00:16:04 +0800 Subject: [PATCH 08/41] fix: enforce issue loop review gates --- loops/_shared/owner-channel/CHANNEL.md | 1 + loops/issue-dev-loop/LOOP.md | 4 +- loops/issue-dev-loop/SKILL.md | 6 +- .../references/evidence-policy.md | 2 +- .../references/github-operations.md | 6 +- loops/issue-dev-loop/review/REVIEW.md | 2 +- .../schemas/evidence.schema.json | 5 +- .../schemas/finalization-record.schema.json | 6 +- .../scripts/generate-evidence.mjs | 34 ++ .../scripts/lib/active-journal.mjs | 155 +++---- loops/issue-dev-loop/scripts/lib/common.mjs | 24 ++ loops/issue-dev-loop/scripts/lib/evidence.mjs | 47 ++- .../scripts/lib/finalization-journal.mjs | 86 +++- loops/issue-dev-loop/scripts/lib/github.mjs | 25 +- .../scripts/lib/issue-claim.mjs | 5 +- .../scripts/lib/notifications.mjs | 32 +- .../issue-dev-loop/scripts/lib/run-store.mjs | 114 ++++- .../issue-dev-loop/scripts/lib/validation.mjs | 24 +- loops/issue-dev-loop/scripts/loopctl.mjs | 14 +- loops/issue-dev-loop/scripts/runtime.mjs | 3 +- loops/issue-dev-loop/tests/runtime.test.mjs | 389 +++++++++++++++++- 21 files changed, 853 insertions(+), 131 deletions(-) diff --git a/loops/_shared/owner-channel/CHANNEL.md b/loops/_shared/owner-channel/CHANNEL.md index 75469d1b..b30497d9 100644 --- a/loops/_shared/owner-channel/CHANNEL.md +++ b/loops/_shared/owner-channel/CHANNEL.md @@ -13,6 +13,7 @@ Each blocking GitHub notification prints a unique resume instruction. To continu ## Runtime setup 1. Authenticate the unattended executor and fresh reviewer with distinct GitHub identities; set their exact logins as `automationGitHubLogin` and `reviewerGitHubLogin` in `channel.json`. + Run `loopctl validate --activation` before scheduling. Default GitHub mutations verify `gh api user` matches the configured automation identity and refuse owner credentials. 2. Create one dedicated repository issue for the append-only loop state journal and set its number as `stateIssueNumber`. It stores active checkpoints and terminal records. Restrict journal entries to the automation identity; humans may read but should not edit or delete them. 3. Enable GitHub notifications for mentions and review requests for `codeacme17`. 4. Optionally set `ECHO_UI_LOOP_OWNER_WEBHOOK_URL` to an endpoint that accepts the notification JSON with `Content-Type: application/json`. diff --git a/loops/issue-dev-loop/LOOP.md b/loops/issue-dev-loop/LOOP.md index eaef90b0..db5e829b 100644 --- a/loops/issue-dev-loop/LOOP.md +++ b/loops/issue-dev-loop/LOOP.md @@ -100,13 +100,13 @@ When the owner requests changes, verify the owner-authored GitHub comment or `CH ### 10. Complete -Only `observe-owner-merge` permits `completed`. It must query GitHub and observe the recorded issue branch still targeting `dev`, an `APPROVED` review by `codeacme17` for the exact reviewed head SHA, and a merge performed by `codeacme17` for that same head. Before any terminal transition, generate a canonical finalization record, publish it through the automation identity to the configured GitHub state-journal issue, and validate its comment URL and digest. Record merge SHA and timestamp, remove `loop:claimed`, update state, append the run summary, and retain links to published evidence. A closed unmerged PR is `cancelled`, not completed. +Only the remote owner-merge gate permits `completed`. Both finalization publication and the terminal transition independently query GitHub and must observe the recorded issue branch still targeting `dev`, an `APPROVED` review by `codeacme17` for the exact reviewed head SHA, and a merge performed by `codeacme17` for that same head. Mutable local events are audit records, never authorization. Before any terminal transition, generate a canonical finalization record, publish it through the automation identity to the configured GitHub state-journal issue, and validate its comment URL and digest. Reconciliation revalidates completed records remotely before accepting them or suppressing an active checkpoint. Record merge SHA and timestamp, remove `loop:claimed`, update state, append the run summary, and retain links to published evidence. A closed unmerged PR is `cancelled`, not completed. ## State and history Keep `state.md` small and deliberate. It may be rewritten and contains only active runs, open PRs, blockers, follow-ups, current hypotheses, and learned constraints. -`logs/index.jsonl` is append-only and contains one compact summary per finalized run; `logs/triggers.jsonl` is the append-only record of cheap trigger decisions, including successful no-ops and resumes. The dedicated GitHub state-journal issue is the durable cross-worktree source for both active checkpoints and terminal records. After every durable run phase, publish the exact `prepare-checkpoint` body and validate it with `record-checkpoint`; it contains the compact run, frozen brief, and validated event chain. Terminal comments supersede active checkpoints. `loopctl reconcile` verifies comments, restores missing active workspaces, replays terminal history, and recomputes evolve metrics before trigger/evolve work. A restored active run is returned as `workType: resume` before new issue selection. Commit sanitized `run.json`, pre-publication `events.jsonl`, summaries, and relevant screenshots to the issue branch so they are reviewable in its PR. The exact-head CI manifest and full proof travel in an Actions artifact. Keep raw local command output and large recordings in ignored `raw/` or `test-results/` directories. GitHub journal comments, PR reviews, workflow artifacts, and merge metadata are authoritative; local indexes and metrics are reconstructable caches. +`logs/index.jsonl` is append-only and contains one compact summary per finalized run; `logs/triggers.jsonl` is the append-only record of cheap trigger decisions, including successful no-ops and resumes. The dedicated GitHub state-journal issue is the durable cross-worktree source for both active checkpoints and terminal records. After every durable run phase, publish the exact `prepare-checkpoint` body and validate it with `record-checkpoint`; the next phase refuses stale or absent checkpoint proof. Terminal comments supersede active checkpoints only after finalization reconciliation validates them, including remote owner proof for completion. `loopctl reconcile` discovers resumable state and recomputes evolve metrics; it does not write run state into an arbitrary checkout. The orchestrator creates the recorded branch-bound isolated worktree, then `restore-checkpoint` verifies the branch/head before restoring run files. A resumable run is returned as `workType: resume` before new issue selection. Commit sanitized `run.json`, pre-publication `events.jsonl`, summaries, and relevant screenshots to the issue branch so they are reviewable in its PR. The exact-head CI manifest and full proof travel in an Actions artifact. Keep raw local command output and large recordings in ignored `raw/` or `test-results/` directories. GitHub journal comments, PR reviews, workflow artifacts, and merge metadata are authoritative; local indexes and metrics are reconstructable caches. Never log secrets, full environment dumps, cookies, auth headers, private user data, or raw prompts containing sensitive information. diff --git a/loops/issue-dev-loop/SKILL.md b/loops/issue-dev-loop/SKILL.md index 11ec8599..a23f9278 100644 --- a/loops/issue-dev-loop/SKILL.md +++ b/loops/issue-dev-loop/SKILL.md @@ -10,8 +10,8 @@ Run exactly one bounded issue cycle. Treat [`LOOP.md`](./LOOP.md) as the constit ## Start safely 1. Read `LOOP.md`, `state.md`, and `dependencies.md` completely. -2. Run `node loops/issue-dev-loop/scripts/loopctl.mjs validate`. -3. Run `loopctl.mjs reconcile` to rebuild terminal history and restore active runs from the append-only GitHub state journal. Resume returned `workType: resume` work before selecting a new issue. +2. Run `node loops/issue-dev-loop/scripts/loopctl.mjs validate`. Scheduled activation additionally requires `validate --activation`, which rejects missing or overlapping owner/executor/reviewer identities. +3. Run `loopctl.mjs reconcile` to rebuild terminal history and discover active runs from the append-only GitHub state journal. For returned `workType: resume`, fetch the recorded branch, create an isolated worktree at the returned expected head, and run `restore-checkpoint --run-id <id>` inside that worktree. The restore command rejects the wrong branch or a checkout that does not contain the durable head. Resume it before selecting a new issue. 4. Run `loopctl.mjs evolve-status`. If `evolveDue` is true, start `echo_ui_loop_evolver` with fresh context; do not silently replace it with product work. 5. Run the cheap trigger in `triggers/detect-work.mjs`. Exit without invoking an implementation agent when it reports `hasWork: false`. 6. Refuse to start when another active run or PR already claims the issue. @@ -19,7 +19,7 @@ Run exactly one bounded issue cycle. Treat [`LOOP.md`](./LOOP.md) as the constit 8. Start the run with `loopctl.mjs start --issue <number> --title <title> --url <issue-url> --base-sha <full-origin-dev-sha>`. 9. Complete `handoffs/<run-id>/implementation-brief.md`, set `UI evidence required` to `yes` or `no`, then run `loopctl.mjs freeze-brief --run-id <id>` before implementation. Never edit the frozen brief afterward. -After `start`, `freeze-brief`, every `record-implementation`, every `record-pr`, every review/evidence gate, and every pause transition, run `prepare-checkpoint`, publish its exact body to the state-journal issue through the automation identity, and validate it with `record-checkpoint`. These compact checkpoints let a fresh worktree restore the run, frozen brief, and validated event chain instead of abandoning an already-claimed issue or open PR. +After `start`, `freeze-brief`, every `record-implementation`, every `record-pr`, every review/evidence gate, and every pause transition, run `prepare-checkpoint`, publish its exact body to the state-journal issue through the automation identity, and validate it with `record-checkpoint`. The next phase rejects a missing latest checkpoint. These compact checkpoints let a verified fresh worktree restore the run, frozen brief, and validated event chain instead of abandoning an already-claimed issue or open PR. Read [`references/github-operations.md`](./references/github-operations.md) for GitHub mutations and [`references/evidence-policy.md`](./references/evidence-policy.md) before verification or PR publication. diff --git a/loops/issue-dev-loop/references/evidence-policy.md b/loops/issue-dev-loop/references/evidence-policy.md index f9ca9fff..d7df7c9e 100644 --- a/loops/issue-dev-loop/references/evidence-policy.md +++ b/loops/issue-dev-loop/references/evidence-policy.md @@ -8,7 +8,7 @@ Evidence proves the acceptance criteria against the exact PR head SHA. - commands executed, exit codes, and UTC timestamps - targeted test results - final `pnpm verify` result -- independent review verdict and finding IDs +- independent review verdict and finding IDs in the combined PR evidence (the separately published, exact-head review gate is authoritative) - known limitations or checks that could not run ## UI changes diff --git a/loops/issue-dev-loop/references/github-operations.md b/loops/issue-dev-loop/references/github-operations.md index cd283f3b..cca7ccbf 100644 --- a/loops/issue-dev-loop/references/github-operations.md +++ b/loops/issue-dev-loop/references/github-operations.md @@ -2,13 +2,15 @@ Read this before mutating issues or pull requests. +Before every GitHub mutation, run `gh api user` and require the exact configured `automationGitHubLogin`. Stop if it is unset, matches `codeacme17`, matches the reviewer, or differs from the authenticated actor. Never use owner credentials for executor actions. + ## Selection and claim 1. Select only open issues labeled `codex-ready`. 2. Exclude `loop:claimed`, an existing `codex/issue-<number>` branch, and any open PR that references or closes the issue. 3. Let `loopctl start --base-sha <full-origin-dev-sha>` acquire the local claim, recheck every page of open PRs, apply `loop:claimed`, and capture the authoritative issue; do not add the label manually first. 4. Record the issue snapshot and claim timestamp before implementation. A second active local run for the issue is rejected. -5. Immediately publish and validate an active checkpoint after the claim. Repeat after each durable phase. On a fresh wake, `reconcile` restores the latest non-terminal checkpoint and `detect-work` returns `workType: resume` before considering a new issue. +5. Immediately publish and validate an active checkpoint after the claim. Repeat after each durable phase; the next phase refuses to advance without it. On a fresh wake, `reconcile` returns `workType: resume`, branch, and expected head before considering a new issue. Fetch that branch, create its isolated worktree, then run `restore-checkpoint`; restoration blocks on the wrong branch/head. ## Branch and PR @@ -57,4 +59,4 @@ After a blocking notification, verify the reply URL with `record-owner-response` For active work, run `loopctl prepare-checkpoint --run-id <id>`, post its exact `body` to the configured `stateIssueNumber` using the automation identity, then validate it with `loopctl record-checkpoint --run-id <id> --result <path> --comment-url <url>`. A checkpoint is SHA-256 bound to the active run, frozen brief, and ordered validated events. Publish one after every state-changing phase; later checkpoints supersede earlier ones. -Before `completed`, `failed`, `blocked`, or `cancelled`, run `loopctl prepare-finalization` with the terminal status and any merge SHA/failure fingerprint. Post its exact `body` to the configured `stateIssueNumber` using the automation identity, then run `loopctl record-finalization --run-id <id> --result <path> --comment-url <url>`. For completion, pass that result and URL to `observe-owner-merge`. Terminal transitions reject missing, edited, wrong-author, wrong-issue, or digest-mismatched journal entries. Every scheduled wake begins with `loopctl reconcile`, which restores missing local history and recomputes evolve metrics from the append-only journal. +Before `completed`, `failed`, `blocked`, or `cancelled`, run `loopctl prepare-finalization` with the terminal status and any merge SHA/failure fingerprint. Failed/blocked records bind the delivered automation-authored owner-notification URL; completion binds and remotely rechecks owner approval and merge; cancellation requires the recorded PR to be closed without merge. Post the exact `body` to the configured `stateIssueNumber` using the automation identity, then run `loopctl record-finalization --run-id <id> --result <path> --comment-url <url>`. For completion, pass that result and URL to `observe-owner-merge`. Terminal transitions reject missing, edited, wrong-author, wrong-issue, digest-mismatched, or externally unproven journal entries. Every scheduled wake begins with `loopctl reconcile`, which restores missing local history and recomputes evolve metrics from the append-only journal. diff --git a/loops/issue-dev-loop/review/REVIEW.md b/loops/issue-dev-loop/review/REVIEW.md index 1b4d0351..bfe8ce62 100644 --- a/loops/issue-dev-loop/review/REVIEW.md +++ b/loops/issue-dev-loop/review/REVIEW.md @@ -23,7 +23,7 @@ Do not emit formatter noise, personal style preferences, speculative concerns, u ## Publication -The fresh reviewer publishes each round verbatim through `reviewerGitHubLogin` as one non-approving GitHub review and inline comments, adding `<!-- issue-dev-loop:<run-id>:<finding-id> -->` for deduplication and `<!-- issue-dev-loop:<run-id>:review-round:<round>:head:<sha> -->` to the round body. The reviewer identity must differ from the executor and owner. The final review body must also include `<!-- issue-dev-loop:<run-id>:review-result-sha256:<digest> -->`. It must not downgrade severity or omit findings. +The fresh reviewer publishes each round verbatim through `reviewerGitHubLogin` as one non-approving GitHub review, adding `<!-- issue-dev-loop:<run-id>:<finding-id> -->` for deduplication and `<!-- issue-dev-loop:<run-id>:review-round:<round>:head:<sha> -->` to the round body. Findings with a concrete file and line must also be posted as matching inline comments; cross-cutting findings remain in the review body. The reviewer identity must differ from the executor and owner. The final review body must also include `<!-- issue-dev-loop:<run-id>:review-result-sha256:<digest> -->`. It must not downgrade severity or omit findings. After all replies are posted, create one cycle result matching `result.schema.json`. It contains every round's review URL, every finding, its final classification, response URL, and evidence. Executor replies include both the readable evidence (and accepted fix SHA) plus `<!-- issue-dev-loop:<run-id>:<finding-id>:<classification> -->`. Rejected P0/P1 findings additionally require an independent or owner comment containing `<!-- issue-dev-loop:<run-id>:<finding-id>:adjudication:<verdict> -->`. Run `record-review` with the final GitHub review URL; it queries every recorded review, replies, identities, timestamps, ancestry, adjudications, and markers and binds them to the correct round and current head. Generic events cannot forge this reserved gate. diff --git a/loops/issue-dev-loop/schemas/evidence.schema.json b/loops/issue-dev-loop/schemas/evidence.schema.json index 1be6e67b..eda4f7a4 100644 --- a/loops/issue-dev-loop/schemas/evidence.schema.json +++ b/loops/issue-dev-loop/schemas/evidence.schema.json @@ -6,6 +6,7 @@ "schemaVersion", "runId", "issueNumber", + "baseSha", "headSha", "verdict", "checks", @@ -17,6 +18,7 @@ "schemaVersion": { "const": 1 }, "runId": { "type": "string", "minLength": 1 }, "issueNumber": { "type": "integer", "minimum": 1 }, + "baseSha": { "type": "string", "pattern": "^[0-9a-fA-F]{40}$" }, "headSha": { "type": "string", "pattern": "^[0-9a-fA-F]{40}$" }, "verdict": { "enum": ["passed", "failed", "blocked"] }, "checks": { @@ -24,11 +26,12 @@ "minItems": 1, "items": { "type": "object", - "required": ["command", "status", "startedAt", "finishedAt", "artifactUrl"], + "required": ["command", "status", "exitCode", "startedAt", "finishedAt", "artifactUrl"], "additionalProperties": false, "properties": { "command": { "type": "string", "minLength": 1 }, "status": { "enum": ["passed", "failed", "blocked"] }, + "exitCode": { "type": "integer" }, "startedAt": { "type": "string", "format": "date-time" }, "finishedAt": { "type": "string", "format": "date-time" }, "artifactUrl": { "type": ["string", "null"], "format": "uri" } diff --git a/loops/issue-dev-loop/schemas/finalization-record.schema.json b/loops/issue-dev-loop/schemas/finalization-record.schema.json index 62b429f1..3c791bd2 100644 --- a/loops/issue-dev-loop/schemas/finalization-record.schema.json +++ b/loops/issue-dev-loop/schemas/finalization-record.schema.json @@ -12,7 +12,8 @@ "prUrl", "headSha", "mergeSha", - "failureFingerprint" + "failureFingerprint", + "notificationUrl" ], "additionalProperties": false, "properties": { @@ -31,6 +32,7 @@ "type": ["string", "null"], "pattern": "^[0-9a-fA-F]{40}$" }, - "failureFingerprint": { "type": ["string", "null"] } + "failureFingerprint": { "type": ["string", "null"] }, + "notificationUrl": { "type": ["string", "null"], "format": "uri" } } } diff --git a/loops/issue-dev-loop/scripts/generate-evidence.mjs b/loops/issue-dev-loop/scripts/generate-evidence.mjs index 2788dc5e..4e998eae 100644 --- a/loops/issue-dev-loop/scripts/generate-evidence.mjs +++ b/loops/issue-dev-loop/scripts/generate-evidence.mjs @@ -153,16 +153,50 @@ if (run.uiEvidenceRequired) { } const output = path.resolve(assertNonEmpty(args.output, '--output')) +const runEvents = ( + await readFile(path.join(loopRoot, 'logs', 'runs', runId, 'events.jsonl'), 'utf8') +) + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) +const implementationEvent = runEvents.findLast( + (event) => + event.type === 'implementation_completed' && + event.payload?.commitSha === run.implementationCommit, +) +if (!implementationEvent?.payload?.resultPath) { + throw new Error('evidence generation requires the latest $implement result') +} +const implementationResultPath = path.resolve(loopRoot, implementationEvent.payload.resultPath) +if ( + !implementationResultPath.startsWith(`${path.join(loopRoot, 'logs', 'runs', runId)}${path.sep}`) +) { + throw new Error('latest $implement result path is outside the run directory') +} +const implementationResult = JSON.parse(await readFile(implementationResultPath, 'utf8')) +const targetedChecks = implementationResult.checks + .filter((check) => !/^pnpm verify(?:\s|$)/.test(check.command)) + .map((check) => ({ + command: check.command, + status: check.status, + exitCode: check.status === 'passed' ? 0 : 1, + startedAt: implementationResult.startedAt, + finishedAt: implementationResult.finishedAt, + artifactUrl: null, + })) const manifest = { schemaVersion: 1, runId, issueNumber: run.issueNumber, + baseSha: run.baseSha, headSha, verdict: status, checks: [ + ...targetedChecks, { command: 'pnpm verify', status, + exitCode: status === 'passed' ? 0 : 1, startedAt: assertNonEmpty(args['started-at'], '--started-at'), finishedAt: assertNonEmpty(args['finished-at'], '--finished-at'), artifactUrl: null, diff --git a/loops/issue-dev-loop/scripts/lib/active-journal.mjs b/loops/issue-dev-loop/scripts/lib/active-journal.mjs index abc88438..bf99d6a4 100644 --- a/loops/issue-dev-loop/scripts/lib/active-journal.mjs +++ b/loops/issue-dev-loop/scripts/lib/active-journal.mjs @@ -8,8 +8,8 @@ import { assertRunId, defaultGitHubApi, defaultGitHubPaginatedApi, + execFileAsync, parsePullCommentUrl, - pathExists, readJson, runDirectory, sameGitHubLogin, @@ -263,19 +263,16 @@ function parseSerializedRecord(body) { export async function reconcileActiveJournal({ loopRoot = DEFAULT_LOOP_ROOT, githubPaginatedApi = defaultGitHubPaginatedApi, + terminalRunIds = [], } = {}) { const { channel, owner, repo } = await journalConfiguration(loopRoot) const comments = await githubPaginatedApi( `repos/${owner}/${repo}/issues/${channel.stateIssueNumber}/comments?per_page=100`, ) - const terminalRunIds = new Set() + const terminalIds = new Set(terminalRunIds) const latestByRunId = new Map() for (const comment of comments) { if (!sameGitHubLogin(comment.user?.login, channel.automationGitHubLogin)) continue - const finalizationMarker = comment.body?.match( - /<!-- issue-dev-loop:finalization:([^:]+):sha256:([0-9a-f]{64}) -->/, - ) - if (finalizationMarker) terminalRunIds.add(finalizationMarker[1]) const marker = comment.body?.match( /<!-- issue-dev-loop:checkpoint:([^:]+):sha256:([0-9a-f]{64}) -->/, ) @@ -292,71 +289,87 @@ export async function reconcileActiveJournal({ const activeCheckpoints = [] for (const [runId, durable] of latestByRunId) { - if (terminalRunIds.has(runId)) continue - const { record, comment } = durable - const runPath = runDirectory(loopRoot, runId) - const runFile = path.join(runPath, 'run.json') - const eventsFile = path.join(runPath, 'events.jsonl') - let restoreNeeded = !(await pathExists(runFile)) || !(await pathExists(eventsFile)) - if (await pathExists(runFile)) { - const localRun = await readJson(runFile) - if (localRun.issueNumber !== record.run.issueNumber) { - throw new Error(`local run conflicts with durable checkpoint: ${runId}`) - } - if (localRun.finishedAt !== null) { - throw new Error(`terminal local run conflicts with active durable checkpoint: ${runId}`) - } - if (!restoreNeeded) { - const localEvents = (await readFile(eventsFile, 'utf8')) - .split('\n') - .filter(Boolean) - .map((line) => JSON.parse(line)) - .filter((event) => event.type !== 'checkpoint_published') - restoreNeeded = - localEvents.length === 0 || - Date.parse(localEvents.at(-1).timestamp) < Date.parse(record.updatedAt) - } - } - if (restoreNeeded) { - await Promise.all( - [ - runPath, - path.join(loopRoot, 'logs', 'claims', `issue-${record.run.issueNumber}`), - path.join(loopRoot, 'handoffs', runId), - path.join(loopRoot, 'screen-shots', runId, 'before'), - path.join(loopRoot, 'screen-shots', runId, 'after'), - path.join(loopRoot, 'evidence', runId, 'test-results'), - ].map((directory) => mkdir(directory, { recursive: true })), - ) - await writeJson(runFile, record.run) - const restoredEvents = [ - ...record.events, - { - schemaVersion: 1, - runId, - type: 'checkpoint_published', - timestamp: comment.created_at ?? record.updatedAt, - status: 'published', - payload: { - commentUrl: comment.html_url ?? null, - digest: checkpointDigest(record), - checkpointUpdatedAt: record.updatedAt, - }, - }, - ] - await writeFile( - eventsFile, - `${restoredEvents.map((event) => JSON.stringify(event)).join('\n')}\n`, - 'utf8', - ) - await writeFile( - path.join(loopRoot, 'handoffs', runId, 'implementation-brief.md'), - record.briefSource, - 'utf8', - ) - } - activeCheckpoints.push(record) + if (terminalIds.has(runId)) continue + activeCheckpoints.push({ + record: durable.record, + commentUrl: durable.comment.html_url ?? null, + createdAt: durable.comment.created_at ?? durable.record.updatedAt, + }) } - activeCheckpoints.sort((left, right) => Date.parse(left.updatedAt) - Date.parse(right.updatedAt)) + activeCheckpoints.sort( + (left, right) => Date.parse(left.record.updatedAt) - Date.parse(right.record.updatedAt), + ) return { activeCheckpoints } } + +async function defaultWorkspaceValidator({ loopRoot, record }) { + const repositoryRoot = path.resolve(loopRoot, '..', '..') + const [branch, head, gitDirectory, commonDirectory] = await Promise.all([ + execFileAsync('git', ['branch', '--show-current'], { cwd: repositoryRoot }), + execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repositoryRoot }), + execFileAsync('git', ['rev-parse', '--path-format=absolute', '--git-dir'], { + cwd: repositoryRoot, + }), + execFileAsync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], { + cwd: repositoryRoot, + }), + ]) + if (gitDirectory.stdout.trim() === commonDirectory.stdout.trim()) { + throw new Error('restore requires an isolated linked Git worktree') + } + if (branch.stdout.trim() !== record.run.branch) { + throw new Error(`restore requires isolated worktree branch ${record.run.branch}`) + } + const expectedHead = record.run.headSha ?? record.run.implementationCommit ?? record.run.baseSha + await execFileAsync('git', ['merge-base', '--is-ancestor', expectedHead, head.stdout.trim()], { + cwd: repositoryRoot, + }) +} + +export async function restoreActiveCheckpoint({ + loopRoot = DEFAULT_LOOP_ROOT, + checkpoint, + workspaceValidator = defaultWorkspaceValidator, +} = {}) { + const record = validateCheckpoint(checkpoint?.record) + await workspaceValidator({ loopRoot, record }) + const runId = record.run.runId + const runPath = runDirectory(loopRoot, runId) + await Promise.all( + [ + runPath, + path.join(loopRoot, 'logs', 'claims', `issue-${record.run.issueNumber}`), + path.join(loopRoot, 'handoffs', runId), + path.join(loopRoot, 'screen-shots', runId, 'before'), + path.join(loopRoot, 'screen-shots', runId, 'after'), + path.join(loopRoot, 'evidence', runId, 'test-results'), + ].map((directory) => mkdir(directory, { recursive: true })), + ) + await writeJson(path.join(runPath, 'run.json'), record.run) + const restoredEvents = [ + ...record.events, + { + schemaVersion: 1, + runId, + type: 'checkpoint_published', + timestamp: checkpoint.createdAt ?? record.updatedAt, + status: 'published', + payload: { + commentUrl: checkpoint.commentUrl ?? null, + digest: checkpointDigest(record), + checkpointUpdatedAt: record.updatedAt, + }, + }, + ] + await writeFile( + path.join(runPath, 'events.jsonl'), + `${restoredEvents.map((event) => JSON.stringify(event)).join('\n')}\n`, + 'utf8', + ) + await writeFile( + path.join(loopRoot, 'handoffs', runId, 'implementation-brief.md'), + record.briefSource, + 'utf8', + ) + return record.run +} diff --git a/loops/issue-dev-loop/scripts/lib/common.mjs b/loops/issue-dev-loop/scripts/lib/common.mjs index c8751b81..9956a4d6 100644 --- a/loops/issue-dev-loop/scripts/lib/common.mjs +++ b/loops/issue-dev-loop/scripts/lib/common.mjs @@ -125,6 +125,30 @@ export function sameGitHubLogin(left, right) { ) } +export function labelNames(issue) { + return new Set((issue.labels ?? []).map((label) => label.name ?? label)) +} + +export async function assertAutomationIdentity({ loopRoot, githubApi = defaultGitHubApi }) { + const channel = await readJson( + path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'), + ) + const automation = assertNonEmpty(channel.automationGitHubLogin, 'channel.automationGitHubLogin') + const reviewer = assertNonEmpty(channel.reviewerGitHubLogin, 'channel.reviewerGitHubLogin') + if ( + sameGitHubLogin(automation, channel.ownerGitHubLogin) || + sameGitHubLogin(reviewer, channel.ownerGitHubLogin) || + sameGitHubLogin(automation, reviewer) + ) { + throw new Error('owner, automation, and reviewer GitHub identities must be distinct') + } + const actor = await githubApi('user') + if (!sameGitHubLogin(actor.login, automation)) { + throw new Error(`GitHub mutation requires configured automation identity ${automation}`) + } + return actor.login +} + export function pullRequestClaimsIssue(pullRequest, issueNumber) { const headRef = pullRequest.headRefName ?? pullRequest.head?.ref if (headRef === `codex/issue-${issueNumber}`) return true diff --git a/loops/issue-dev-loop/scripts/lib/evidence.mjs b/loops/issue-dev-loop/scripts/lib/evidence.mjs index d35089aa..c9c426b3 100644 --- a/loops/issue-dev-loop/scripts/lib/evidence.mjs +++ b/loops/issue-dev-loop/scripts/lib/evidence.mjs @@ -20,7 +20,12 @@ import { sameGitHubLogin, sameRepository, } from './common.mjs' -import { appendValidatedEvent, readEvents, readRun } from './run-store.mjs' +import { + appendValidatedEvent, + assertLatestDurableCheckpoint, + readEvents, + readRun, +} from './run-store.mjs' const REVIEW_CLASSIFICATIONS = new Set(['accepted', 'rejected', 'stale', 'already-fixed']) @@ -153,6 +158,8 @@ export async function recordEvidence({ const run = await readRun(loopRoot, normalizedRunId) if (run.finishedAt !== null) throw new Error(`run is already finalized: ${normalizedRunId}`) if (!run.prUrl || !run.headSha) throw new Error('record-evidence requires a recorded draft PR') + const runEvents = await readEvents(loopRoot, normalizedRunId) + assertLatestDurableCheckpoint(runEvents, 'record-evidence') const evidenceRoot = path.resolve(loopRoot, 'evidence') const resolvedManifest = path.resolve(assertNonEmpty(manifestPath, 'manifestPath')) @@ -164,7 +171,8 @@ export async function recordEvidence({ if ( manifest.schemaVersion !== 1 || manifest.runId !== normalizedRunId || - manifest.issueNumber !== run.issueNumber + manifest.issueNumber !== run.issueNumber || + manifest.baseSha !== run.baseSha ) { throw new Error('evidence manifest does not match the run') } @@ -226,8 +234,30 @@ export async function recordEvidence({ if (!checks.some((check) => /^pnpm verify(?:\s|$)/.test(check.command))) { throw new Error('evidence checks must include pnpm verify') } + const latestImplementation = runEvents.findLast( + (event) => + event.type === 'implementation_completed' && + event.payload?.commitSha === run.implementationCommit, + ) + const implementationResultPath = path.resolve( + loopRoot, + assertNonEmpty(latestImplementation?.payload?.resultPath, 'implementation result path'), + ) + if ( + !implementationResultPath.startsWith(`${runDirectory(loopRoot, normalizedRunId)}${path.sep}`) + ) { + throw new Error('implementation result path is outside the current run') + } + const implementationResult = await readJson(implementationResultPath) + const expectedCommands = implementationResult.checks.map((check) => check.command) + if (expectedCommands.some((command) => !checks.some((check) => check.command === command))) { + throw new Error('evidence manifest omits an attested $implement check') + } for (const [index, check] of checks.entries()) { assertNonEmpty(check.command, `checks[${index}].command`) + if (!Number.isInteger(check.exitCode) || check.exitCode !== 0) { + throw new Error(`checks[${index}] requires a successful exitCode`) + } if (Number.isNaN(Date.parse(check.startedAt)) || Number.isNaN(Date.parse(check.finishedAt))) { throw new Error(`checks[${index}] requires valid timestamps`) } @@ -307,6 +337,7 @@ export async function recordReview({ const normalizedRunId = assertRunId(runId) const run = await readRun(loopRoot, normalizedRunId) if (run.finishedAt !== null) throw new Error(`run is already finalized: ${normalizedRunId}`) + assertLatestDurableCheckpoint(await readEvents(loopRoot, normalizedRunId), 'record-review') const resolvedResultPath = path.resolve(assertNonEmpty(resultPath, 'resultPath')) const expectedResultRoot = runDirectory(loopRoot, normalizedRunId) if (!resolvedResultPath.startsWith(`${expectedResultRoot}${path.sep}`)) { @@ -400,6 +431,18 @@ export async function recordReview({ ) { throw new Error(`${finding.findingId} is not published verbatim in its GitHub review round`) } + if ( + finding.path && + Number.isInteger(finding.line) && + !roundComments.some( + (comment) => + comment.path === finding.path && + [comment.line, comment.original_line].includes(finding.line) && + requiredFindingFragments.every((fragment) => comment.body?.includes(fragment)), + ) + ) { + throw new Error(`${finding.findingId} requires a matching inline GitHub review comment`) + } } publications.set(round.round, { submittedAt: publishedRound.submitted_at, diff --git a/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs b/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs index 49f33e4a..5ebdf5d5 100644 --- a/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs +++ b/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs @@ -17,7 +17,13 @@ import { writeJson, } from './common.mjs' import { updateEvolveMetrics } from './evolve.mjs' -import { appendValidatedEvent, readRun } from './run-store.mjs' +import { observeOwnerApprovedMerge } from './owner-gate.mjs' +import { + appendValidatedEvent, + assertLatestDurableCheckpoint, + readEvents, + readRun, +} from './run-store.mjs' const TERMINAL_STATUSES = new Set(['completed', 'failed', 'blocked', 'cancelled']) @@ -33,6 +39,7 @@ function canonicalRecord(record) { headSha: record.headSha ?? null, mergeSha: record.mergeSha ?? null, failureFingerprint: record.failureFingerprint ?? null, + notificationUrl: record.notificationUrl ?? null, }) } @@ -61,9 +68,12 @@ function validateRecord(record, run = null) { } if ( ['failed', 'blocked'].includes(record.status) && - !assertNonEmpty(record.failureFingerprint, 'failureFingerprint') + (!assertNonEmpty(record.failureFingerprint, 'failureFingerprint') || !record.notificationUrl) ) { - throw new Error('failed or blocked finalization requires a fingerprint') + throw new Error('failed or blocked finalization requires a fingerprint and notification URL') + } + if (record.status === 'cancelled' && (!record.prUrl || !record.headSha)) { + throw new Error('cancelled finalization requires a published PR') } if ( run && @@ -93,6 +103,56 @@ async function journalConfiguration(loopRoot) { return { channel, owner, repo } } +async function verifyTerminalExternalProof({ loopRoot, record, githubApi }) { + if (record.status === 'completed') { + const merge = await observeOwnerApprovedMerge({ + loopRoot, + prUrl: record.prUrl, + expectedHeadSha: record.headSha, + expectedHeadBranch: `codex/issue-${record.issueNumber}`, + githubApi, + }) + if (merge.mergeSha !== record.mergeSha) { + throw new Error('completed finalization does not match the remote owner merge') + } + } + if (['failed', 'blocked'].includes(record.status)) { + const target = parsePullCommentUrl(record.notificationUrl) + const pullTarget = record.prUrl ? new URL(record.prUrl) : null + if ( + !target || + target.kind !== 'issue_comment' || + !['pull', 'issues'].includes(target.surface) || + (target.surface === 'issues' && target.number !== record.issueNumber) || + (target.surface === 'pull' && + (!pullTarget || !pullTarget.pathname.endsWith(`/pull/${target.number}`))) + ) { + throw new Error('terminal notification URL is not bound to the run issue or PR') + } + const { channel } = await journalConfiguration(loopRoot) + const comment = await githubApi( + `repos/${target.owner}/${target.repo}/issues/comments/${target.commentId}`, + ) + const expectedType = record.status === 'failed' ? 'loop_failed' : 'blocked' + if ( + !sameGitHubLogin(comment.user?.login, channel.automationGitHubLogin) || + !comment.body?.includes(`**${expectedType}**`) || + !comment.body?.includes(`Run: \`${record.runId}\``) + ) { + throw new Error('terminal notification lacks durable automation-authored proof') + } + } + if (record.status === 'cancelled') { + const target = new URL(record.prUrl) + const match = target.pathname.match(/^\/([^/]+)\/([^/]+)\/pull\/(\d+)$/) + if (!match) throw new Error('cancelled finalization requires a GitHub PR') + const pull = await githubApi(`repos/${match[1]}/${match[2]}/pulls/${match[3]}`) + if (pull.state !== 'closed' || pull.merged === true || pull.head?.sha !== record.headSha) { + throw new Error('cancelled finalization requires the recorded PR closed without merge') + } + } +} + export async function prepareFinalizationRecord({ loopRoot = DEFAULT_LOOP_ROOT, runId, @@ -100,10 +160,23 @@ export async function prepareFinalizationRecord({ mergeSha = null, failureFingerprint = null, finishedAt = new Date(), + githubApi = defaultGitHubApi, } = {}) { const normalizedRunId = assertRunId(runId) const run = await readRun(loopRoot, normalizedRunId) if (run.finishedAt !== null) throw new Error('cannot prepare finalization for a finished run') + const events = await readEvents(loopRoot, normalizedRunId) + assertLatestDurableCheckpoint(events, 'prepare-finalization') + const notificationType = + status === 'failed' ? 'loop_failed' : status === 'blocked' ? 'blocked' : null + const notificationUrl = notificationType + ? (events.findLast( + (event) => + event.type === 'owner_notified' && + event.status === 'delivered' && + event.payload?.notificationType === notificationType, + )?.payload?.deliveryUrl ?? null) + : null const resultPath = path.join(runDirectory(loopRoot, normalizedRunId), 'finalization-result.json') if (await pathExists(resultPath)) { const existing = validateRecord(await readJson(resultPath), run) @@ -114,6 +187,7 @@ export async function prepareFinalizationRecord({ ) { throw new Error('a different finalization record is already prepared for this run') } + await verifyTerminalExternalProof({ loopRoot, record: existing, githubApi }) const { channel, owner, repo } = await journalConfiguration(loopRoot) const digest = recordDigest(existing) return { @@ -141,9 +215,11 @@ export async function prepareFinalizationRecord({ headSha: run.headSha, mergeSha, failureFingerprint, + notificationUrl, }, run, ) + await verifyTerminalExternalProof({ loopRoot, record, githubApi }) const { channel, owner, repo } = await journalConfiguration(loopRoot) const digest = recordDigest(record) const body = [ @@ -179,6 +255,7 @@ export async function recordFinalizationPublication({ throw new Error('finalization result must be inside the current run directory') } const record = validateRecord(await readJson(resolvedResultPath), run) + await verifyTerminalExternalProof({ loopRoot, record, githubApi }) const { channel, owner, repo } = await journalConfiguration(loopRoot) const target = parsePullCommentUrl(assertNonEmpty(commentUrl, 'commentUrl')) if ( @@ -214,6 +291,7 @@ export async function recordFinalizationPublication({ finishedAt: record.finishedAt, mergeSha: record.mergeSha, failureFingerprint: record.failureFingerprint, + notificationUrl: record.notificationUrl, }, now, }) @@ -224,6 +302,7 @@ export async function reconcileFinalizationJournal({ loopRoot = DEFAULT_LOOP_ROOT, now = new Date(), githubPaginatedApi = defaultGitHubPaginatedApi, + githubApi = defaultGitHubApi, } = {}) { const { channel, owner, repo } = await journalConfiguration(loopRoot) const comments = await githubPaginatedApi( @@ -241,6 +320,7 @@ export async function reconcileFinalizationJournal({ if (record.runId !== marker[1] || recordDigest(record) !== marker[2]) { throw new Error(`invalid durable finalization record for ${marker[1]}`) } + await verifyTerminalExternalProof({ loopRoot, record, githubApi }) records.push(record) } records.sort((left, right) => Date.parse(left.finishedAt) - Date.parse(right.finishedAt)) diff --git a/loops/issue-dev-loop/scripts/lib/github.mjs b/loops/issue-dev-loop/scripts/lib/github.mjs index 4052f25b..d42971ff 100644 --- a/loops/issue-dev-loop/scripts/lib/github.mjs +++ b/loops/issue-dev-loop/scripts/lib/github.mjs @@ -8,6 +8,7 @@ import { assertRunId, defaultGitHubApi, execFileAsync, + labelNames, parseGitHubTarget, parsePullCommentUrl, parseReviewUrl, @@ -32,12 +33,6 @@ const PRIORITY = new Map([ ['priority:low', 3], ]) -function labelNames(issue) { - return new Set( - (issue.labels ?? []).map((label) => (typeof label === 'string' ? label : label.name)), - ) -} - function issuePriority(issue) { const labels = labelNames(issue) let rank = 4 @@ -73,7 +68,10 @@ export async function reconcileLoopJournal({ now = new Date(), } = {}) { const finalization = await reconcileFinalizationJournal({ loopRoot, now }) - const active = await reconcileActiveJournal({ loopRoot }) + const active = await reconcileActiveJournal({ + loopRoot, + terminalRunIds: finalization.durableRunIds, + }) return { ...finalization, ...active } } @@ -109,11 +107,16 @@ export async function detectWork({ return recordTriggerCheck({ hasWork: true, workType: 'resume', - runId: resumable.run.runId, + runId: resumable.record.run.runId, + branch: resumable.record.run.branch, + expectedHeadSha: + resumable.record.run.headSha ?? + resumable.record.run.implementationCommit ?? + resumable.record.run.baseSha, issue: { - number: resumable.run.issueNumber, - title: resumable.run.issueTitle, - url: resumable.run.issueUrl, + number: resumable.record.run.issueNumber, + title: resumable.record.run.issueTitle, + url: resumable.record.run.issueUrl, }, }) } diff --git a/loops/issue-dev-loop/scripts/lib/issue-claim.mjs b/loops/issue-dev-loop/scripts/lib/issue-claim.mjs index 4227a5e2..b81e1249 100644 --- a/loops/issue-dev-loop/scripts/lib/issue-claim.mjs +++ b/loops/issue-dev-loop/scripts/lib/issue-claim.mjs @@ -2,14 +2,11 @@ import { defaultGitHubApi, defaultGitHubPaginatedApi, execFileAsync, + labelNames, parseGitHubTarget, pullRequestClaimsIssue, } from './common.mjs' -function labelNames(issue) { - return new Set((issue.labels ?? []).map((label) => label.name ?? label)) -} - async function defaultAddLabel({ target, issueNumber }) { await execFileAsync( 'gh', diff --git a/loops/issue-dev-loop/scripts/lib/notifications.mjs b/loops/issue-dev-loop/scripts/lib/notifications.mjs index cffe5292..02c75a7f 100644 --- a/loops/issue-dev-loop/scripts/lib/notifications.mjs +++ b/loops/issue-dev-loop/scripts/lib/notifications.mjs @@ -3,6 +3,7 @@ import path from 'node:path' import { DEFAULT_LOOP_ROOT, + assertAutomationIdentity, assertNonEmpty, assertRunId, execFileAsync, @@ -12,7 +13,13 @@ import { timestampToken, writeJson, } from './common.mjs' -import { appendValidatedEvent, readEvents, readRun, transitionRun } from './run-store.mjs' +import { + appendValidatedEvent, + assertLatestDurableCheckpoint, + readEvents, + readRun, + transitionRun, +} from './run-store.mjs' function notificationBody(notification, owner) { const evidence = notification.evidenceUrl ? `\n\nEvidence: ${notification.evidenceUrl}` : '' @@ -31,7 +38,7 @@ function notificationBody(notification, owner) { } async function defaultGitHubComment(target, body) { - await execFileAsync( + const result = await execFileAsync( 'gh', [ 'api', @@ -43,6 +50,7 @@ async function defaultGitHubComment(target, body) { ], { maxBuffer: 1024 * 1024 }, ) + return JSON.parse(result.stdout) } export async function createNotification({ @@ -60,9 +68,11 @@ export async function createNotification({ environment = process.env, fetchImplementation = globalThis.fetch, githubComment = defaultGitHubComment, + verifyAutomationIdentity = assertAutomationIdentity, } = {}) { const normalizedRunId = assertRunId(runId) const run = await readRun(loopRoot, normalizedRunId) + assertLatestDurableCheckpoint(await readEvents(loopRoot, normalizedRunId), 'notify owner') const channelRoot = path.resolve(loopRoot, '..', '_shared', 'owner-channel') const channel = await readJson(path.join(channelRoot, 'channel.json')) const notificationType = assertNonEmpty(type, 'type') @@ -134,10 +144,17 @@ export async function createNotification({ ? 'dry_run' : 'not_configured' } else { + if (githubComment === defaultGitHubComment) { + await verifyAutomationIdentity({ loopRoot }) + } if (target) { try { - await githubComment(target, notificationBody(notification, channel.ownerGitHubLogin)) + const comment = await githubComment( + target, + notificationBody(notification, channel.ownerGitHubLogin), + ) notification.delivery.github = 'delivered' + notification.delivery.githubUrl = comment?.html_url ?? null } catch (error) { notification.delivery.github = `failed: ${error.message}` } @@ -173,6 +190,7 @@ export async function createNotification({ notificationId, notificationType, delivery: notification.delivery, + deliveryUrl: notification.delivery.githubUrl ?? null, targetUrl, evidenceUrl, headSha: run.headSha, @@ -182,7 +200,13 @@ export async function createNotification({ if (blocking && !dryRun) { if (run.finishedAt === null && run.status !== 'waiting_for_owner') { - await transitionRun({ loopRoot, runId: normalizedRunId, status: 'waiting_for_owner', now }) + await transitionRun({ + loopRoot, + runId: normalizedRunId, + status: 'waiting_for_owner', + now, + skipCheckpointGate: true, + }) } } if (blocking && !delivered && !dryRun) { diff --git a/loops/issue-dev-loop/scripts/lib/run-store.mjs b/loops/issue-dev-loop/scripts/lib/run-store.mjs index c0bbf823..8aedba9a 100644 --- a/loops/issue-dev-loop/scripts/lib/run-store.mjs +++ b/loops/issue-dev-loop/scripts/lib/run-store.mjs @@ -5,6 +5,7 @@ import path from 'node:path' import { DEFAULT_LOOP_ROOT, appendJsonLine, + assertAutomationIdentity, assertIssueNumber, assertNonEmpty, assertRunId, @@ -22,6 +23,7 @@ import { } from './common.mjs' import { defaultClaimIssue, defaultReleaseIssueClaim } from './issue-claim.mjs' import { updateEvolveMetrics } from './evolve.mjs' +import { observeOwnerApprovedMerge } from './owner-gate.mjs' export const PAUSED_STATUSES = new Set(['awaiting_owner_review', 'waiting_for_owner']) const TERMINAL_STATUSES = new Set(['completed', 'failed', 'blocked', 'cancelled']) @@ -78,6 +80,7 @@ export async function startRun({ githubApi = defaultGitHubApi, claimIssue = defaultClaimIssue, releaseIssueClaim = defaultReleaseIssueClaim, + verifyAutomationIdentity = assertAutomationIdentity, } = {}) { const evolve = await readJson(path.join(loopRoot, 'evolve', 'metrics.json')) if (evolve.evolveDue) { @@ -120,6 +123,9 @@ export async function startRun({ let issueSnapshot let remoteClaimCreated = false try { + if (claimIssue === defaultClaimIssue) { + await verifyAutomationIdentity({ loopRoot, githubApi }) + } const snapshot = await claimIssue({ issueUrl: url, issueNumber: issue, @@ -227,6 +233,49 @@ async function assertFrozenBriefUnchanged(loopRoot, run) { return currentDigest } +const REQUIRED_BRIEF_SECTIONS = [ + 'Acceptance criteria', + 'In scope', + 'Out of scope', + 'Pre-agreed TDD seams', + 'Required targeted checks', + 'Required UI evidence', + 'Risks and owner-confirmation boundaries', + 'Stop conditions', +] + +function briefSection(source, heading) { + const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + return ( + source.match(new RegExp(`## ${escaped}[ \\t]*\\r?\\n([\\s\\S]*?)(?=\\n## |$)`))?.[1]?.trim() ?? + '' + ) +} + +function parseFrozenBrief(source) { + const sections = Object.fromEntries( + REQUIRED_BRIEF_SECTIONS.map((heading) => [heading, briefSection(source, heading)]), + ) + for (const [heading, contents] of Object.entries(sections)) { + if (!contents || contents.includes('<!--')) { + throw new Error(`implementation brief requires a concrete ${heading} section`) + } + } + const requiredChecks = sections['Required targeted checks'] + .split('\n') + .map((line) => + line + .replace(/^\s*[-*]\s*/, '') + .replaceAll('`', '') + .trim(), + ) + .filter(Boolean) + if (requiredChecks.length === 0) { + throw new Error('implementation brief requires at least one targeted check') + } + return { sections, requiredChecks } +} + export async function freezeBrief({ loopRoot = DEFAULT_LOOP_ROOT, runId, now = new Date() } = {}) { const normalizedRunId = assertRunId(runId) const runFile = path.join(runDirectory(loopRoot, normalizedRunId), 'run.json') @@ -234,9 +283,11 @@ export async function freezeBrief({ loopRoot = DEFAULT_LOOP_ROOT, runId, now = n if (run.status !== 'running' || run.finishedAt !== null || run.briefDigest) { throw new Error('brief can only be frozen once for a running run') } + assertLatestDurableCheckpoint(await readEvents(loopRoot, normalizedRunId), 'freeze-brief') const briefPath = path.join(loopRoot, 'handoffs', normalizedRunId, 'implementation-brief.md') const source = await readFile(briefPath, 'utf8') - const acceptance = source.match(/## Acceptance criteria\s+([\s\S]*?)(?=\n## )/)?.[1]?.trim() + const { sections } = parseFrozenBrief(source) + const acceptance = sections['Acceptance criteria'] const uiEvidence = source.match(/UI evidence required:\s*(yes|no)\b/i)?.[1]?.toLowerCase() if (!acceptance || acceptance.includes('<!--') || acceptance.length < 20) { throw new Error('implementation brief requires concrete frozen acceptance criteria') @@ -329,18 +380,27 @@ export async function recordImplementation({ throw new Error('$implement result requires an ordered invocation time range') } const checks = Array.isArray(result.checks) ? result.checks : [] + const briefSource = await readFile( + path.join(loopRoot, 'handoffs', normalizedRunId, 'implementation-brief.md'), + 'utf8', + ) + const { requiredChecks } = parseFrozenBrief(briefSource) if ( checks.length === 0 || checks.some((check) => check.status !== 'passed') || - !checks.some((check) => /^pnpm verify(?:\s|$)/.test(check.command)) + !checks.some((check) => /^pnpm verify(?:\s|$)/.test(check.command)) || + requiredChecks.some( + (requiredCommand) => !checks.some((check) => check.command === requiredCommand), + ) ) { - throw new Error('$implement result requires passed checks including pnpm verify') + throw new Error('$implement result requires the frozen targeted checks and pnpm verify') } const previousCommit = run.implementationCommit ?? run.baseSha if (result.commitSha === previousCommit) { throw new Error('$implement must produce a new commit') } const events = await readEvents(loopRoot, normalizedRunId) + assertLatestDurableCheckpoint(events, 'record-implementation') const relativeResultPath = path.relative(loopRoot, resolvedResultPath) if ( events.some( @@ -397,6 +457,7 @@ export async function recordPullRequest({ throw new Error('record-pr requires a frozen brief and recorded $implement result') } await assertFrozenBriefUnchanged(loopRoot, run) + assertLatestDurableCheckpoint(await readEvents(loopRoot, normalizedRunId), 'record-pr') if (!/^[0-9a-f]{40}$/i.test(headSha)) { throw new Error('record-pr requires a full head SHA') } @@ -436,6 +497,22 @@ export async function recordPullRequest({ if (requiredBodyFragments.some((fragment) => !livePullRequest.body?.includes(fragment))) { throw new Error('draft PR body is missing immutable loop metadata or owner-only merge language') } + for (const heading of [ + 'Changes', + 'Acceptance criteria', + 'Verification', + 'Evidence', + 'Independent review', + 'Known limitations', + ]) { + const contents = briefSection(livePullRequest.body ?? '', heading) + if (!contents || contents.includes('{{')) { + throw new Error(`draft PR body requires a non-empty ${heading} section`) + } + } + if (!/- Risk:\s*\S/.test(livePullRequest.body)) { + throw new Error('draft PR body requires a concrete risk assessment') + } const implementationComparison = await githubApi( `repos/${pullTarget.owner}/${pullTarget.repo}/compare/${run.implementationCommit}...${headSha}`, ) @@ -508,6 +585,20 @@ export async function readEvents(loopRoot, runId) { .map((line) => JSON.parse(line)) } +export function assertLatestDurableCheckpoint(events, operation) { + const latestPhaseEvent = events.findLast((event) => event.type !== 'checkpoint_published') + const checkpoint = events.findLast( + (event) => + event.type === 'checkpoint_published' && + event.status === 'published' && + event.payload?.checkpointUpdatedAt === latestPhaseEvent?.timestamp, + ) + if (!latestPhaseEvent || !checkpoint) { + throw new Error(`${operation} requires a durable checkpoint for the latest run phase`) + } + return checkpoint +} + function hasPassedEventForHead(events, type, headSha) { return events.some( (event) => @@ -580,6 +671,9 @@ async function ensureFinalizationArtifacts({ headSha: run.headSha, mergeSha: run.mergeSha, failureFingerprint, + notificationUrl: + events.findLast((event) => event.type === 'finalization_published')?.payload + ?.notificationUrl ?? null, }) } await updateEvolveMetrics({ loopRoot, now }) @@ -624,6 +718,7 @@ export async function transitionRun({ now = new Date(), githubApi = defaultGitHubApi, releaseIssueClaim = defaultReleaseIssueClaim, + skipCheckpointGate = false, } = {}) { const normalizedRunId = assertRunId(runId) if (!RUN_STATUSES.has(status)) throw new Error(`invalid run status: ${status}`) @@ -659,6 +754,9 @@ export async function transitionRun({ } if (run.status === status) throw new Error(`run already has status: ${status}`) const events = await readEvents(loopRoot, normalizedRunId) + if (!skipCheckpointGate && !TERMINAL_STATUSES.has(status)) { + assertLatestDurableCheckpoint(events, `transition to ${status}`) + } if (!ALLOWED_TRANSITIONS.get(run.status)?.has(status)) { throw new Error(`invalid run status transition: ${run.status} -> ${status}`) } @@ -739,6 +837,16 @@ export async function transitionRun({ ) { throw new Error('completed requires an owner-ready PR and mergeSha') } + const remoteMerge = await observeOwnerApprovedMerge({ + loopRoot, + prUrl: run.prUrl, + expectedHeadSha: run.headSha, + expectedHeadBranch: run.branch, + githubApi, + }) + if (remoteMerge.mergeSha !== mergeSha) { + throw new Error('completed mergeSha does not match the remote owner merge') + } const channel = await readJson( path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'), ) diff --git a/loops/issue-dev-loop/scripts/lib/validation.mjs b/loops/issue-dev-loop/scripts/lib/validation.mjs index af180ea6..9c80e7b2 100644 --- a/loops/issue-dev-loop/scripts/lib/validation.mjs +++ b/loops/issue-dev-loop/scripts/lib/validation.mjs @@ -1,7 +1,7 @@ import { readFile, readdir } from 'node:fs/promises' import path from 'node:path' -import { DEFAULT_LOOP_ROOT, pathExists, readJson } from './common.mjs' +import { DEFAULT_LOOP_ROOT, pathExists, readJson, sameGitHubLogin } from './common.mjs' async function collectFiles(root, output = []) { const entries = await readdir(root, { withFileTypes: true }) @@ -14,7 +14,7 @@ async function collectFiles(root, output = []) { return output } -export async function validateLoop({ loopRoot = DEFAULT_LOOP_ROOT } = {}) { +export async function validateLoop({ loopRoot = DEFAULT_LOOP_ROOT, activation = false } = {}) { const required = [ 'SKILL.md', 'LOOP.md', @@ -96,6 +96,24 @@ export async function validateLoop({ loopRoot = DEFAULT_LOOP_ROOT } = {}) { ) { throw new Error('owner channel is missing identity or immediate notification configuration') } + const configuredIdentities = [ + channel.ownerGitHubLogin, + channel.automationGitHubLogin, + channel.reviewerGitHubLogin, + ] + if (activation && configuredIdentities.some((login) => typeof login !== 'string' || !login)) { + throw new Error('activation requires configured owner, automation, and reviewer identities') + } + const presentIdentities = configuredIdentities.filter( + (login) => typeof login === 'string' && login.length > 0, + ) + if ( + presentIdentities.some((login, index) => + presentIdentities.slice(index + 1).some((other) => sameGitHubLogin(login, other)), + ) + ) { + throw new Error('owner, automation, and reviewer identities must be distinct') + } const evidenceWorkflow = path.resolve( loopRoot, '..', @@ -126,7 +144,7 @@ export async function validateLoop({ loopRoot = DEFAULT_LOOP_ROOT } = {}) { for (const phrase of [ 'draft PR targeting `dev`', 'approve, auto-merge, or merge any PR', - 'Only `observe-owner-merge`', + 'Only the remote owner-merge gate', 'exact reviewed head SHA', 'No eligible work is a successful no-op', ]) { diff --git a/loops/issue-dev-loop/scripts/loopctl.mjs b/loops/issue-dev-loop/scripts/loopctl.mjs index a755ff17..31683977 100644 --- a/loops/issue-dev-loop/scripts/loopctl.mjs +++ b/loops/issue-dev-loop/scripts/loopctl.mjs @@ -20,6 +20,7 @@ import { recordOwnerResponse, recordPullRequest, recordReview, + restoreActiveCheckpoint, startRun, transitionRun, validateLoop, @@ -162,6 +163,15 @@ async function main() { case 'reconcile': output(await reconcileLoopJournal()) break + case 'restore-checkpoint': { + const reconciled = await reconcileLoopJournal() + const checkpoint = reconciled.activeCheckpoints.find( + (entry) => entry.record.run.runId === args['run-id'], + ) + if (!checkpoint) throw new Error(`no durable active checkpoint for ${args['run-id']}`) + output(await restoreActiveCheckpoint({ checkpoint })) + break + } case 'observe-owner-merge': output( await observeOwnerMerge({ @@ -195,7 +205,7 @@ async function main() { ) break case 'validate': - output(await validateLoop()) + output(await validateLoop({ activation: Boolean(args.activation) })) break case 'evolve-status': output(await getEvolveStatus()) @@ -211,7 +221,7 @@ async function main() { break default: throw new Error( - 'usage: loopctl.mjs <start|freeze-brief|record-implementation|event|record-pr|record-owner-response|record-evidence|record-review|prepare-checkpoint|record-checkpoint|prepare-finalization|record-finalization|reconcile|transition|finalize|observe-owner-merge|notify|detect-work|validate|evolve-status|evolve-complete> [options]', + 'usage: loopctl.mjs <start|freeze-brief|record-implementation|event|record-pr|record-owner-response|record-evidence|record-review|prepare-checkpoint|record-checkpoint|prepare-finalization|record-finalization|reconcile|restore-checkpoint|transition|finalize|observe-owner-merge|notify|detect-work|validate|evolve-status|evolve-complete> [options]', ) } } diff --git a/loops/issue-dev-loop/scripts/runtime.mjs b/loops/issue-dev-loop/scripts/runtime.mjs index 41fe5569..c9297da1 100644 --- a/loops/issue-dev-loop/scripts/runtime.mjs +++ b/loops/issue-dev-loop/scripts/runtime.mjs @@ -1,4 +1,4 @@ -export { DEFAULT_LOOP_ROOT, parseArguments } from './lib/common.mjs' +export { assertAutomationIdentity, DEFAULT_LOOP_ROOT, parseArguments } from './lib/common.mjs' export { completeEvolve, getEvolveStatus } from './lib/evolve.mjs' export { recordEvidence, recordReview } from './lib/evidence.mjs' export { @@ -7,6 +7,7 @@ export { prepareActiveCheckpoint, reconcileActiveJournal, recordActiveCheckpointPublication, + restoreActiveCheckpoint, } from './lib/active-journal.mjs' export { canonicalRecord, diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index 6cce8473..89c6a448 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -10,6 +10,7 @@ import { fileURLToPath } from 'node:url' import { appendEvent, + assertAutomationIdentity, canonicalCheckpoint, canonicalRecord, checkpointDigest, @@ -33,6 +34,7 @@ import { recordOwnerResponse, recordPullRequest, recordReview, + restoreActiveCheckpoint, selectIssue, startRun, transitionRun, @@ -127,6 +129,27 @@ async function startFixtureRun(options) { }) } +async function publishFixtureCheckpoint({ loopRoot, runId }) { + const prepared = await prepareActiveCheckpoint({ loopRoot, runId }) + const commentUrl = `https://github.com/codeacme17/echo-ui/issues/999#issuecomment-${Math.abs( + prepared.digest + .slice(0, 8) + .split('') + .reduce((total, value) => total + value.charCodeAt(0), 0), + )}` + await recordActiveCheckpointPublication({ + loopRoot, + runId, + resultPath: prepared.resultPath, + commentUrl, + githubApi: async () => ({ + user: { login: 'echo-ui-loop[bot]' }, + body: prepared.body, + }), + }) + return prepared +} + function pullRequestFixture(run, headSha, { draft = true, merged = false } = {}) { return { state: merged ? 'closed' : 'open', @@ -142,6 +165,19 @@ function pullRequestFixture(run, headSha, { draft = true, merged = false } = {}) `Run ID: \`${run.runId}\``, `Base SHA: \`${run.baseSha}\``, `Head SHA: \`${headSha}\``, + '- Risk: low and isolated', + '## Changes', + 'Implements the frozen issue scope.', + '## Acceptance criteria', + 'All frozen acceptance criteria are covered.', + '## Verification', + 'Targeted regression test and pnpm verify passed.', + '## Evidence', + 'Exact-head workflow evidence is attached or pending for this draft.', + '## Independent review', + 'Fresh-context review is attached or pending for this draft.', + '## Known limitations', + 'None known.', 'This PR must be reviewed and merged by `@codeacme17`', ].join('\n'), } @@ -155,6 +191,7 @@ async function recordFixturePr({ uiEvidenceRequired = false, }) { const briefPath = path.join(loopRoot, 'handoffs', run.runId, 'implementation-brief.md') + await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) const brief = await readFile(briefPath, 'utf8') await writeFile( briefPath, @@ -166,10 +203,29 @@ async function recordFixturePr({ .replace( '<!-- Freeze concrete, testable criteria before invoking $implement. -->', 'Keyboard behavior is covered by a deterministic regression test.', + ) + .replace('## In scope\n', '## In scope\n\nKeyboard behavior in the issue scope.\n') + .replace('## Out of scope\n', '## Out of scope\n\nUnrelated public API changes.\n') + .replace( + '## Pre-agreed TDD seams\n', + '## Pre-agreed TDD seams\n\nKeyboard regression behavior at the component boundary.\n', + ) + .replace( + '## Required targeted checks\n', + '## Required targeted checks\n\n- pnpm test -- keyboard\n', + ) + .replace( + '## Required UI evidence\n', + `## Required UI evidence\n\n${uiEvidenceRequired ? 'Paired before and after captures.' : 'Not required for this non-UI fixture.'}\n`, + ) + .replace( + '## Risks and owner-confirmation boundaries\n', + '## Risks and owner-confirmation boundaries\n\nNo public API or dependency changes.\n', ), 'utf8', ) const frozen = await freezeBrief({ loopRoot, runId: run.runId }) + await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) const implementationCommit = '1'.repeat(40) const resultPath = path.join(loopRoot, 'logs', 'runs', run.runId, 'implementation-result.json') await writeFile( @@ -183,7 +239,10 @@ async function recordFixturePr({ finishedAt: '2026-07-22T15:30:00.000Z', briefDigest: frozen.briefDigest, commitSha: implementationCommit, - checks: [{ command: 'pnpm verify', status: 'passed' }], + checks: [ + { command: 'pnpm test -- keyboard', status: 'passed' }, + { command: 'pnpm verify', status: 'passed' }, + ], })}\n`, 'utf8', ) @@ -193,6 +252,7 @@ async function recordFixturePr({ resultPath, commitRangeValidator: async () => {}, }) + await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) const prUrl = `https://github.com/codeacme17/echo-ui/pull/${number}` await recordPullRequest({ loopRoot, @@ -205,6 +265,7 @@ async function recordFixturePr({ : pullRequestFixture(run, headSha), trailingPathValidator: async () => {}, }) + await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) return prUrl } @@ -233,12 +294,22 @@ async function writePassingEvidence({ loopRoot, run, headSha }) { schemaVersion: 1, runId: run.runId, issueNumber: run.issueNumber, + baseSha: run.baseSha, headSha, verdict: 'passed', checks: [ + { + command: 'pnpm test -- keyboard', + status: 'passed', + exitCode: 0, + startedAt: timestamp, + finishedAt: timestamp, + artifactUrl: null, + }, { command: 'pnpm verify', status: 'passed', + exitCode: 0, startedAt: timestamp, finishedAt: timestamp, artifactUrl: null, @@ -301,20 +372,48 @@ async function writeFixtureFinalization({ headSha: run.headSha, mergeSha, failureFingerprint, + notificationUrl: ['failed', 'blocked'].includes(status) + ? `${run.issueUrl}#issuecomment-8800` + : null, } const resultPath = path.join(loopRoot, 'logs', 'runs', runId, 'finalization-result.json') await writeFile(resultPath, `${canonicalRecord(record)}\n`, 'utf8') const digest = recordDigest(record) const commentUrl = 'https://github.com/codeacme17/echo-ui/issues/999#issuecomment-9900' - const githubApi = async () => ({ - user: { login: 'echo-ui-loop[bot]' }, - body: [ - `<!-- issue-dev-loop:finalization:${runId}:sha256:${digest} -->`, - '```json', - canonicalRecord(record), - '```', - ].join('\n'), - }) + const githubApi = async (endpoint) => { + if (endpoint.includes('/reviews')) { + return [{ user: { login: 'codeacme17' }, state: 'APPROVED', commit_id: record.headSha }] + } + if (endpoint.includes('/pulls/')) { + return { + merged: true, + merged_by: { login: 'codeacme17' }, + merge_commit_sha: record.mergeSha, + base: { ref: 'dev', repo: { full_name: 'codeacme17/echo-ui' } }, + head: { + ref: `codex/issue-${record.issueNumber}`, + sha: record.headSha, + repo: { full_name: 'codeacme17/echo-ui' }, + }, + } + } + if (endpoint.endsWith('/issues/comments/8800')) { + const notificationType = record.status === 'failed' ? 'loop_failed' : 'blocked' + return { + user: { login: 'echo-ui-loop[bot]' }, + body: `@codeacme17 **${notificationType}**\n\nRun: \`${runId}\``, + } + } + return { + user: { login: 'echo-ui-loop[bot]' }, + body: [ + `<!-- issue-dev-loop:finalization:${runId}:sha256:${digest} -->`, + '```json', + canonicalRecord(record), + '```', + ].join('\n'), + } + } return { record, resultPath, commentUrl, githubApi } } @@ -449,6 +548,61 @@ test('startRun refuses a second active run for the same issue', async () => { ) }) +test('phase advancement requires the latest durable checkpoint', async () => { + const { loopRoot } = await createFixture() + const { run } = await startFixtureRun({ + loopRoot, + issueNumber: 146, + issueTitle: 'Checkpoint gate', + issueUrl: 'https://github.com/codeacme17/echo-ui/issues/146', + entropy: 'check146', + }) + await assert.rejects(freezeBrief({ loopRoot, runId: run.runId }), /requires a durable checkpoint/) +}) + +test('frozen brief rejects empty scope, TDD, checks, evidence, risk, and stop sections', async () => { + const { loopRoot } = await createFixture() + const { run } = await startFixtureRun({ + loopRoot, + issueNumber: 147, + issueTitle: 'Complete brief gate', + issueUrl: 'https://github.com/codeacme17/echo-ui/issues/147', + entropy: 'brief147', + }) + await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) + const briefPath = path.join(loopRoot, 'handoffs', run.runId, 'implementation-brief.md') + const brief = await readFile(briefPath, 'utf8') + await writeFile( + briefPath, + brief + .replace('UI evidence required: UNSET', 'UI evidence required: no') + .replace( + '<!-- Freeze concrete, testable criteria before invoking $implement. -->', + 'The requested behavior has a deterministic regression assertion.', + ), + 'utf8', + ) + await assert.rejects( + freezeBrief({ loopRoot, runId: run.runId }), + /requires a concrete In scope section/, + ) +}) + +test('automation identity cannot overlap the repository owner', async () => { + const { loopRoot, channelRoot } = await createFixture() + const channelPath = path.join(channelRoot, 'channel.json') + const channel = JSON.parse(await readFile(channelPath, 'utf8')) + await writeFile( + channelPath, + `${JSON.stringify({ ...channel, automationGitHubLogin: 'codeacme17' })}\n`, + 'utf8', + ) + await assert.rejects( + assertAutomationIdentity({ loopRoot, githubApi: async () => ({ login: 'codeacme17' }) }), + /identities must be distinct/, + ) +}) + test('startRun rolls back a remote claim when the authoritative snapshot is invalid', async () => { const { loopRoot } = await createFixture() let released = false @@ -505,7 +659,10 @@ test('frozen brief and $implement invocation history cannot be rewritten or reus finishedAt: '2026-07-22T16:30:00.000Z', briefDigest: currentRun.briefDigest, commitSha: secondCommit, - checks: [{ command: 'pnpm verify', status: 'passed' }], + checks: [ + { command: 'pnpm test -- keyboard', status: 'passed' }, + { command: 'pnpm verify', status: 'passed' }, + ], } await writeFile(secondResultPath, `${JSON.stringify(secondResult)}\n`, 'utf8') let checkedRange @@ -517,6 +674,7 @@ test('frozen brief and $implement invocation history cannot be rewritten or reus checkedRange = range }, }) + await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) assert.equal(checkedRange.ancestor, '1'.repeat(40)) assert.equal(checkedRange.descendant, secondCommit) @@ -560,6 +718,7 @@ test('frozen brief and $implement invocation history cannot be rewritten or reus assert.equal(rebound.headSha, updatedHead) assert.equal(checkedTrailingRange.ancestor, secondCommit) assert.equal(checkedTrailingRange.descendant, updatedHead) + await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) await assert.rejects( recordPullRequest({ @@ -647,6 +806,39 @@ test('recordEvidence rejects failed workflow runs and mismatched artifact manife ) }) +test('recordPullRequest rejects empty review sections even when metadata markers exist', async () => { + const { loopRoot } = await createFixture() + const { run } = await startFixtureRun({ + loopRoot, + issueNumber: 149, + issueTitle: 'PR content gate', + issueUrl: 'https://github.com/codeacme17/echo-ui/issues/149', + entropy: 'pr149', + }) + const originalHead = '3'.repeat(40) + const prUrl = await recordFixturePr({ loopRoot, run, headSha: originalHead, number: 349 }) + const nextHead = '4'.repeat(40) + const incomplete = pullRequestFixture(run, nextHead, { draft: false }) + incomplete.body = incomplete.body.replace( + '## Evidence\nExact-head workflow evidence is attached or pending for this draft.', + '## Evidence\n', + ) + await assert.rejects( + recordPullRequest({ + loopRoot, + runId: run.runId, + prUrl, + headSha: nextHead, + githubApi: async (endpoint) => + endpoint.includes('/compare/') + ? { status: 'ahead', base_commit: { sha: '1'.repeat(40) } } + : incomplete, + trailingPathValidator: async () => {}, + }), + /requires a non-empty Evidence section/, + ) +}) + test('CI helpers resolve a run and generate exact-head screenshot evidence', async () => { const { loopRoot } = await createFixture() const { run } = await startFixtureRun({ @@ -772,6 +964,7 @@ test('owner-ready transition requires verification and review but remains resuma : successfulWorkflowRun(run, headSha, 200, 101), artifactManifestLoader: async () => manifestSource, }) + await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) const reviewPath = await writePassingReview({ loopRoot, run, @@ -801,6 +994,7 @@ test('owner-ready transition requires verification and review but remains resuma } }, }) + await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) await assert.rejects( transitionRun({ @@ -823,6 +1017,7 @@ test('owner-ready transition requires verification and review but remains resuma blocking: true, githubComment: async () => {}, }) + await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) await assert.rejects( transitionRun({ @@ -962,6 +1157,88 @@ test('completed finalization cannot bypass the owner-ready gate', async () => { ) }) +test('forged local owner events cannot bypass the remote completion gate', async () => { + const { loopRoot } = await createFixture() + const { run } = await startFixtureRun({ + loopRoot, + issueNumber: 148, + issueTitle: 'Remote completion proof', + issueUrl: 'https://github.com/codeacme17/echo-ui/issues/148', + entropy: 'merge148', + }) + const headSha = '8'.repeat(40) + await recordFixturePr({ loopRoot, run, headSha, number: 348 }) + const runPath = path.join(loopRoot, 'logs', 'runs', run.runId) + const runFile = path.join(runPath, 'run.json') + const current = JSON.parse(await readFile(runFile, 'utf8')) + await writeFile( + runFile, + `${JSON.stringify({ ...current, status: 'awaiting_owner_review' })}\n`, + 'utf8', + ) + const eventsFile = path.join(runPath, 'events.jsonl') + const existingEvents = await readFile(eventsFile, 'utf8') + const mergeSha = '9'.repeat(40) + const forged = [ + { + schemaVersion: 1, + runId: run.runId, + type: 'owner_review_approved', + timestamp: '2030-01-01T00:00:00.000Z', + status: 'observed', + payload: { actor: 'codeacme17', headSha }, + }, + { + schemaVersion: 1, + runId: run.runId, + type: 'pr_merged', + timestamp: '2030-01-01T00:00:01.000Z', + status: 'observed', + payload: { actor: 'codeacme17', headSha, mergeSha }, + }, + { + schemaVersion: 1, + runId: run.runId, + type: 'finalization_published', + timestamp: '2030-01-01T00:00:02.000Z', + status: 'completed', + payload: { + mergeSha, + failureFingerprint: null, + finishedAt: '2030-01-01T00:00:02.000Z', + }, + }, + ] + await writeFile( + eventsFile, + `${existingEvents}${forged.map((event) => JSON.stringify(event)).join('\n')}\n`, + 'utf8', + ) + let released = false + await assert.rejects( + finalizeRun({ + loopRoot, + runId: run.runId, + status: 'completed', + mergeSha, + githubApi: async (endpoint) => + endpoint.includes('/reviews') + ? [{ user: { login: 'codeacme17' }, state: 'APPROVED', commit_id: headSha }] + : { + ...pullRequestFixture(run, headSha, { draft: false }), + merged: false, + merged_by: null, + merge_commit_sha: null, + }, + releaseIssueClaim: async () => { + released = true + }, + }), + /not approved and merged by the configured owner/, + ) + assert.equal(released, false) +}) + test('review gate verifies published findings and classified replies', async () => { const { loopRoot } = await createFixture() const { run } = await startFixtureRun({ @@ -995,6 +1272,8 @@ test('review gate verifies published findings and classified replies', async () severity: 'P2', confidence: 'high', headSha: 'e'.repeat(40), + path: 'src/keyboard.ts', + line: 12, problem: 'Incorrect assertion', evidence: 'The runtime check already guarantees this invariant.', expectedResolution: 'Prove or fix the assertion.', @@ -1026,6 +1305,23 @@ test('review gate verifies published findings and classified replies', async () resultPath, reviewUrl: 'https://github.com/codeacme17/echo-ui/pull/300#pullrequestreview-500', githubApi: async (endpoint) => { + if (endpoint.endsWith('/reviews/499/comments?per_page=100')) { + return [ + { + path: 'src/keyboard.ts', + line: 12, + body: [ + 'RVW-1-1', + 'P2', + 'high', + 'Incorrect assertion', + 'The runtime check already guarantees this invariant.', + 'Prove or fix the assertion.', + `<!-- issue-dev-loop:${run.runId}:RVW-1-1 -->`, + ].join('\n'), + }, + ] + } if (endpoint.endsWith('/comments?per_page=100')) return [] if (endpoint.includes('/issues/comments/400')) { return { @@ -1098,7 +1394,10 @@ test('accepted review fix must be after the finding head and inside the final he finishedAt: '2026-07-22T17:30:00.000Z', briefDigest: recordedRun.briefDigest, commitSha: fixCommit, - checks: [{ command: 'pnpm verify', status: 'passed' }], + checks: [ + { command: 'pnpm test -- keyboard', status: 'passed' }, + { command: 'pnpm verify', status: 'passed' }, + ], })}\n`, 'utf8', ) @@ -1108,6 +1407,7 @@ test('accepted review fix must be after the finding head and inside the final he resultPath: repairResultPath, commitRangeValidator: async () => {}, }) + await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) const resultPath = path.join(loopRoot, 'logs', 'runs', run.runId, 'review-result.json') const result = { schemaVersion: 1, @@ -1323,6 +1623,7 @@ test('notification dry-run is auditable but never counts as owner delivery', asy issueUrl: 'https://github.com/codeacme17/echo-ui/issues/131', entropy: 'b00b1e', }) + await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) const notification = await createNotification({ loopRoot, runId: run.runId, @@ -1364,6 +1665,7 @@ test('failed blocking delivery still pauses for the owner', async () => { issueUrl: 'https://github.com/codeacme17/echo-ui/issues/132', entropy: 'badbee', }) + await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) await assert.rejects( createNotification({ loopRoot, @@ -1384,6 +1686,7 @@ test('failed blocking delivery still pauses for the owner', async () => { await readFile(path.join(loopRoot, 'logs', 'runs', run.runId, 'run.json'), 'utf8'), ) assert.equal(paused.status, 'waiting_for_owner') + await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) await assert.rejects( transitionRun({ loopRoot, runId: run.runId, status: 'running' }), /observed owner response/, @@ -1448,8 +1751,15 @@ test('failed blocking delivery still pauses for the owner', async () => { body: `Proceed with option A. RESUME ${run.runId}`, created_at: '2030-01-01T00:02:00.000Z', }), + now: new Date('2030-01-01T00:02:30.000Z'), + }) + await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) + const resumed = await transitionRun({ + loopRoot, + runId: run.runId, + status: 'running', + now: new Date('2030-01-01T00:03:00.000Z'), }) - const resumed = await transitionRun({ loopRoot, runId: run.runId, status: 'running' }) assert.equal(resumed.status, 'running') }) @@ -1468,6 +1778,7 @@ test('a new blocker moves an owner-review run back to the decision pause', async `${JSON.stringify({ ...run, status: 'awaiting_owner_review' })}\n`, 'utf8', ) + await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) await createNotification({ loopRoot, runId: run.runId, @@ -1491,12 +1802,30 @@ test('preparing the same terminal journal record is idempotent', async () => { issueUrl: 'https://github.com/codeacme17/echo-ui/issues/145', entropy: 'final145', }) + await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) + await createNotification({ + loopRoot, + runId: run.runId, + type: 'blocked', + summary: 'The deterministic fixture is blocked', + requestedAction: 'Resolve the fixture blocker', + targetUrl: run.issueUrl, + blocking: true, + githubComment: async () => ({ + html_url: `${run.issueUrl}#issuecomment-8801`, + }), + }) + await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) const first = await prepareFinalizationRecord({ loopRoot, runId: run.runId, status: 'blocked', failureFingerprint: 'same-terminal-cause', finishedAt: new Date('2026-07-22T19:00:00.000Z'), + githubApi: async () => ({ + user: { login: 'echo-ui-loop[bot]' }, + body: `@codeacme17 **blocked**\n\nRun: \`${run.runId}\``, + }), }) const retried = await prepareFinalizationRecord({ loopRoot, @@ -1504,6 +1833,10 @@ test('preparing the same terminal journal record is idempotent', async () => { status: 'blocked', failureFingerprint: 'same-terminal-cause', finishedAt: new Date('2026-07-22T20:00:00.000Z'), + githubApi: async () => ({ + user: { login: 'echo-ui-loop[bot]' }, + body: `@codeacme17 **blocked**\n\nRun: \`${run.runId}\``, + }), }) assert.equal(retried.digest, first.digest) assert.equal(retried.record.finishedAt, '2026-07-22T19:00:00.000Z') @@ -1519,6 +1852,7 @@ test('three matching failures make a fresh evolve session due', async () => { issueUrl: `https://github.com/codeacme17/echo-ui/issues/${issueNumber}`, entropy: `fail${issueNumber}`, }) + await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) await createNotification({ loopRoot, runId: run.runId, @@ -1585,6 +1919,7 @@ test('fresh worktrees rebuild finalization history and evolve metrics from GitHu headSha: null, mergeSha: null, failureFingerprint: 'persistent-browser-failure', + notificationUrl: 'https://github.com/codeacme17/echo-ui/issues/205#issuecomment-8802', } const digest = recordDigest(record) const result = await reconcileFinalizationJournal({ @@ -1601,6 +1936,10 @@ test('fresh worktrees rebuild finalization history and evolve metrics from GitHu ].join('\n'), }, ], + githubApi: async () => ({ + user: { login: 'echo-ui-loop[bot]' }, + body: `@codeacme17 **blocked**\n\nRun: \`${record.runId}\``, + }), }) assert.deepEqual(result.durableRunIds, [record.runId]) const history = await readFile(path.join(loopRoot, 'logs', 'index.jsonl'), 'utf8') @@ -1648,7 +1987,12 @@ test('fresh worktrees restore active checkpoints and trigger resumable work', as loopRoot, githubPaginatedApi: async () => [durableComment], }) - assert.equal(reconciled.activeCheckpoints[0].run.runId, run.runId) + assert.equal(reconciled.activeCheckpoints[0].record.run.runId, run.runId) + await restoreActiveCheckpoint({ + loopRoot, + checkpoint: reconciled.activeCheckpoints[0], + workspaceValidator: async () => {}, + }) const restored = JSON.parse( await readFile(path.join(loopRoot, 'logs', 'runs', run.runId, 'run.json'), 'utf8'), ) @@ -1657,7 +2001,15 @@ test('fresh worktrees restore active checkpoints and trigger resumable work', as const detected = await detectWork({ loopRoot, now: new Date('2026-07-22T12:02:00.000Z'), - reconcileJournal: async () => ({ activeCheckpoints: [prepared.record] }), + reconcileJournal: async () => ({ + activeCheckpoints: [ + { + record: prepared.record, + commentUrl, + createdAt: '2026-07-22T12:01:00.000Z', + }, + ], + }), }) assert.equal(detected.hasWork, true) assert.equal(detected.workType, 'resume') @@ -1719,3 +2071,10 @@ test('repository loop package satisfies its structural invariants', async () => const result = await validateLoop({ loopRoot: repositoryLoopRoot }) assert.equal(result.valid, true) }) + +test('repository activation remains blocked until distinct bot identities are configured', async () => { + await assert.rejects( + validateLoop({ loopRoot: repositoryLoopRoot, activation: true }), + /activation requires configured owner, automation, and reviewer identities/, + ) +}) From 6f4f8711589046641f58a786d586f73d9228ff5f Mon Sep 17 00:00:00 2001 From: leyoonafr <codeacme17@gmail.com> Date: Thu, 23 Jul 2026 00:22:39 +0800 Subject: [PATCH 09/41] fix: recheck finalized owner merges --- .../issue-dev-loop/scripts/lib/run-store.mjs | 12 +++++ loops/issue-dev-loop/tests/runtime.test.mjs | 54 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/loops/issue-dev-loop/scripts/lib/run-store.mjs b/loops/issue-dev-loop/scripts/lib/run-store.mjs index 8aedba9a..9a85ff11 100644 --- a/loops/issue-dev-loop/scripts/lib/run-store.mjs +++ b/loops/issue-dev-loop/scripts/lib/run-store.mjs @@ -737,6 +737,18 @@ export async function transitionRun({ if (!TERMINAL_STATUSES.has(status) || status !== run.status || !authorization) { throw new Error(`run is already finalized: ${normalizedRunId}`) } + if (run.status === 'completed') { + const remoteMerge = await observeOwnerApprovedMerge({ + loopRoot, + prUrl: run.prUrl, + expectedHeadSha: run.headSha, + expectedHeadBranch: run.branch, + githubApi, + }) + if (remoteMerge.mergeSha !== run.mergeSha) { + throw new Error('finalized mergeSha does not match the remote owner merge') + } + } const finalized = await ensureFinalizationArtifacts({ loopRoot, run, diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index 89c6a448..0d94edeb 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -1237,6 +1237,60 @@ test('forged local owner events cannot bypass the remote completion gate', async /not approved and merged by the configured owner/, ) assert.equal(released, false) + + await writeFile( + runFile, + `${JSON.stringify({ + ...current, + status: 'completed', + finishedAt: '2030-01-01T00:00:02.000Z', + mergeSha, + })}\n`, + 'utf8', + ) + await writeFile( + eventsFile, + `${existingEvents}${[ + ...forged, + { + schemaVersion: 1, + runId: run.runId, + type: 'run_finalization_authorized', + timestamp: '2030-01-01T00:00:03.000Z', + status: 'completed', + payload: { + previousStatus: 'awaiting_owner_review', + finishedAt: '2030-01-01T00:00:02.000Z', + failureFingerprint: null, + }, + }, + ] + .map((event) => JSON.stringify(event)) + .join('\n')}\n`, + 'utf8', + ) + await assert.rejects( + finalizeRun({ + loopRoot, + runId: run.runId, + status: 'completed', + mergeSha, + githubApi: async (endpoint) => + endpoint.includes('/reviews') + ? [{ user: { login: 'codeacme17' }, state: 'APPROVED', commit_id: headSha }] + : { + ...pullRequestFixture(run, headSha, { draft: false }), + merged: false, + merged_by: null, + merge_commit_sha: null, + }, + releaseIssueClaim: async () => { + released = true + }, + }), + /not approved and merged by the configured owner/, + ) + assert.equal(released, false) }) test('review gate verifies published findings and classified replies', async () => { From 2746ecaefafdb77fa0c8606f4a273f77eaa0dd37 Mon Sep 17 00:00:00 2001 From: leyoonafr <codeacme17@gmail.com> Date: Thu, 23 Jul 2026 01:01:57 +0800 Subject: [PATCH 10/41] fix: bind loop lifecycle to remote proof --- loops/issue-dev-loop/LOOP.md | 2 +- loops/issue-dev-loop/SKILL.md | 4 +- .../references/github-operations.md | 2 +- .../scripts/lib/active-journal.mjs | 210 ++------- .../scripts/lib/checkpoint-proof.mjs | 218 ++++++++++ loops/issue-dev-loop/scripts/lib/evidence.mjs | 36 +- .../scripts/lib/finalization-journal.mjs | 208 ++------- .../scripts/lib/finalization-proof.mjs | 199 +++++++++ loops/issue-dev-loop/scripts/lib/github.mjs | 33 +- .../scripts/lib/issue-claim.mjs | 53 ++- .../scripts/lib/notifications.mjs | 19 +- .../issue-dev-loop/scripts/lib/run-store.mjs | 237 +++++++--- loops/issue-dev-loop/tests/runtime.test.mjs | 410 ++++++++++++++++-- 13 files changed, 1156 insertions(+), 475 deletions(-) create mode 100644 loops/issue-dev-loop/scripts/lib/checkpoint-proof.mjs create mode 100644 loops/issue-dev-loop/scripts/lib/finalization-proof.mjs diff --git a/loops/issue-dev-loop/LOOP.md b/loops/issue-dev-loop/LOOP.md index db5e829b..fd602cae 100644 --- a/loops/issue-dev-loop/LOOP.md +++ b/loops/issue-dev-loop/LOOP.md @@ -106,7 +106,7 @@ Only the remote owner-merge gate permits `completed`. Both finalization publicat Keep `state.md` small and deliberate. It may be rewritten and contains only active runs, open PRs, blockers, follow-ups, current hypotheses, and learned constraints. -`logs/index.jsonl` is append-only and contains one compact summary per finalized run; `logs/triggers.jsonl` is the append-only record of cheap trigger decisions, including successful no-ops and resumes. The dedicated GitHub state-journal issue is the durable cross-worktree source for both active checkpoints and terminal records. After every durable run phase, publish the exact `prepare-checkpoint` body and validate it with `record-checkpoint`; the next phase refuses stale or absent checkpoint proof. Terminal comments supersede active checkpoints only after finalization reconciliation validates them, including remote owner proof for completion. `loopctl reconcile` discovers resumable state and recomputes evolve metrics; it does not write run state into an arbitrary checkout. The orchestrator creates the recorded branch-bound isolated worktree, then `restore-checkpoint` verifies the branch/head before restoring run files. A resumable run is returned as `workType: resume` before new issue selection. Commit sanitized `run.json`, pre-publication `events.jsonl`, summaries, and relevant screenshots to the issue branch so they are reviewable in its PR. The exact-head CI manifest and full proof travel in an Actions artifact. Keep raw local command output and large recordings in ignored `raw/` or `test-results/` directories. GitHub journal comments, PR reviews, workflow artifacts, and merge metadata are authoritative; local indexes and metrics are reconstructable caches. +`logs/index.jsonl` is append-only and contains one compact summary per finalized run; `logs/triggers.jsonl` is the append-only record of cheap trigger decisions, including successful no-ops and resumes. The dedicated GitHub state-journal issue is the durable cross-worktree source for both active checkpoints and terminal records. After every durable run phase, publish the exact `prepare-checkpoint` body and validate it with `record-checkpoint`; the next phase re-fetches that automation-authored comment and refuses stale, absent, edited, or locally forged proof. Terminal comments supersede active checkpoints only after finalization reconciliation validates them, including remote owner proof for completion. `loopctl reconcile` discovers resumable state and recomputes evolve metrics; it does not write run state into an arbitrary checkout. The orchestrator creates a clean worktree at the recorded exact branch/head, then `restore-checkpoint` verifies both before restoring run files. A resumable run is returned as `workType: resume` before new issue selection. Commit sanitized `run.json`, pre-publication `events.jsonl`, summaries, and relevant screenshots to the issue branch so they are reviewable in its PR. The exact-head CI manifest and full proof travel in an Actions artifact. Keep raw local command output and large recordings in ignored `raw/` or `test-results/` directories. GitHub journal comments, PR reviews, workflow artifacts, and merge metadata are authoritative; local indexes and metrics are reconstructable caches. Never log secrets, full environment dumps, cookies, auth headers, private user data, or raw prompts containing sensitive information. diff --git a/loops/issue-dev-loop/SKILL.md b/loops/issue-dev-loop/SKILL.md index a23f9278..a65024e2 100644 --- a/loops/issue-dev-loop/SKILL.md +++ b/loops/issue-dev-loop/SKILL.md @@ -11,7 +11,7 @@ Run exactly one bounded issue cycle. Treat [`LOOP.md`](./LOOP.md) as the constit 1. Read `LOOP.md`, `state.md`, and `dependencies.md` completely. 2. Run `node loops/issue-dev-loop/scripts/loopctl.mjs validate`. Scheduled activation additionally requires `validate --activation`, which rejects missing or overlapping owner/executor/reviewer identities. -3. Run `loopctl.mjs reconcile` to rebuild terminal history and discover active runs from the append-only GitHub state journal. For returned `workType: resume`, fetch the recorded branch, create an isolated worktree at the returned expected head, and run `restore-checkpoint --run-id <id>` inside that worktree. The restore command rejects the wrong branch or a checkout that does not contain the durable head. Resume it before selecting a new issue. +3. Run `loopctl.mjs reconcile` to rebuild terminal history and discover active runs from the append-only GitHub state journal. For returned `workType: resume`, fetch the recorded branch, create a clean isolated worktree at the returned exact head, and run `restore-checkpoint --run-id <id>` inside that worktree. The restore command rejects the wrong branch, a dirty checkout, or any head other than the durable head. Resume it before selecting a new issue. 4. Run `loopctl.mjs evolve-status`. If `evolveDue` is true, start `echo_ui_loop_evolver` with fresh context; do not silently replace it with product work. 5. Run the cheap trigger in `triggers/detect-work.mjs`. Exit without invoking an implementation agent when it reports `hasWork: false`. 6. Refuse to start when another active run or PR already claims the issue. @@ -19,7 +19,7 @@ Run exactly one bounded issue cycle. Treat [`LOOP.md`](./LOOP.md) as the constit 8. Start the run with `loopctl.mjs start --issue <number> --title <title> --url <issue-url> --base-sha <full-origin-dev-sha>`. 9. Complete `handoffs/<run-id>/implementation-brief.md`, set `UI evidence required` to `yes` or `no`, then run `loopctl.mjs freeze-brief --run-id <id>` before implementation. Never edit the frozen brief afterward. -After `start`, `freeze-brief`, every `record-implementation`, every `record-pr`, every review/evidence gate, and every pause transition, run `prepare-checkpoint`, publish its exact body to the state-journal issue through the automation identity, and validate it with `record-checkpoint`. The next phase rejects a missing latest checkpoint. These compact checkpoints let a verified fresh worktree restore the run, frozen brief, and validated event chain instead of abandoning an already-claimed issue or open PR. +After `start`, `freeze-brief`, every `record-implementation`, every `record-pr`, every review/evidence gate, and every pause transition, run `prepare-checkpoint`, publish its exact body to the state-journal issue through the automation identity, and validate it with `record-checkpoint`. The next phase re-fetches that exact automation-authored comment and rejects a missing, edited, stale, or locally forged checkpoint. These compact checkpoints let a verified fresh worktree restore the run, frozen brief, and validated event chain instead of abandoning an already-claimed issue or open PR. Read [`references/github-operations.md`](./references/github-operations.md) for GitHub mutations and [`references/evidence-policy.md`](./references/evidence-policy.md) before verification or PR publication. diff --git a/loops/issue-dev-loop/references/github-operations.md b/loops/issue-dev-loop/references/github-operations.md index cca7ccbf..94813baa 100644 --- a/loops/issue-dev-loop/references/github-operations.md +++ b/loops/issue-dev-loop/references/github-operations.md @@ -10,7 +10,7 @@ Before every GitHub mutation, run `gh api user` and require the exact configured 2. Exclude `loop:claimed`, an existing `codex/issue-<number>` branch, and any open PR that references or closes the issue. 3. Let `loopctl start --base-sha <full-origin-dev-sha>` acquire the local claim, recheck every page of open PRs, apply `loop:claimed`, and capture the authoritative issue; do not add the label manually first. 4. Record the issue snapshot and claim timestamp before implementation. A second active local run for the issue is rejected. -5. Immediately publish and validate an active checkpoint after the claim. Repeat after each durable phase; the next phase refuses to advance without it. On a fresh wake, `reconcile` returns `workType: resume`, branch, and expected head before considering a new issue. Fetch that branch, create its isolated worktree, then run `restore-checkpoint`; restoration blocks on the wrong branch/head. +5. Immediately publish and validate an active checkpoint after the claim. Repeat after each durable phase; the next phase re-fetches the exact state-journal comment and refuses to advance on local-only proof. On a fresh wake, `reconcile` returns `workType: resume`, branch, and expected head before considering a new issue. Fetch that branch, create a clean isolated worktree at the exact head, then run `restore-checkpoint`; restoration blocks on the wrong branch, head, or dirty state. ## Branch and PR diff --git a/loops/issue-dev-loop/scripts/lib/active-journal.mjs b/loops/issue-dev-loop/scripts/lib/active-journal.mjs index bf99d6a4..38ccc8d8 100644 --- a/loops/issue-dev-loop/scripts/lib/active-journal.mjs +++ b/loops/issue-dev-loop/scripts/lib/active-journal.mjs @@ -1,4 +1,3 @@ -import { createHash } from 'node:crypto' import { mkdir, readFile, writeFile } from 'node:fs/promises' import path from 'node:path' @@ -9,146 +8,26 @@ import { defaultGitHubApi, defaultGitHubPaginatedApi, execFileAsync, - parsePullCommentUrl, readJson, runDirectory, sameGitHubLogin, writeJson, } from './common.mjs' +import { + canonicalCheckpointRecord, + checkpointJournalConfiguration, + checkpointPublicationBody, + checkpointRecordDigest, + parseCheckpointRecord, + validateCheckpointRecord, + verifyPublishedCheckpoint, +} from './checkpoint-proof.mjs' import { appendValidatedEvent, readEvents, readRun } from './run-store.mjs' const ACTIVE_STATUSES = new Set(['running', 'waiting_for_owner', 'awaiting_owner_review']) -async function journalConfiguration(loopRoot) { - const channel = await readJson( - path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'), - ) - if ( - !Number.isInteger(channel.stateIssueNumber) || - channel.stateIssueNumber < 1 || - !channel.automationGitHubLogin - ) { - throw new Error('owner channel must configure stateIssueNumber and automationGitHubLogin') - } - const [owner, repo] = channel.repository.split('/') - return { channel, owner, repo } -} - -function canonicalRun(run) { - return { - schemaVersion: run.schemaVersion, - runId: run.runId, - issueNumber: run.issueNumber, - issueTitle: run.issueTitle, - issueUrl: run.issueUrl, - baseBranch: run.baseBranch, - baseSha: run.baseSha, - branch: run.branch, - status: run.status, - startedAt: run.startedAt, - finishedAt: run.finishedAt, - prUrl: run.prUrl, - headSha: run.headSha, - mergeSha: run.mergeSha, - issueSnapshot: run.issueSnapshot, - briefDigest: run.briefDigest, - uiEvidenceRequired: run.uiEvidenceRequired, - implementationCommit: run.implementationCommit, - } -} - -function canonicalEvent(event) { - return { - schemaVersion: event.schemaVersion, - runId: event.runId, - type: event.type, - timestamp: event.timestamp, - status: event.status, - payload: event.payload, - } -} - -export function canonicalCheckpoint(record) { - return JSON.stringify({ - schemaVersion: 1, - kind: 'active-checkpoint', - run: canonicalRun(record.run), - briefSource: record.briefSource, - events: record.events.map(canonicalEvent), - updatedAt: record.updatedAt, - }) -} - -export function checkpointDigest(record) { - return createHash('sha256').update(canonicalCheckpoint(record)).digest('hex') -} - -function validateCheckpoint(record) { - const run = record?.run - const events = record?.events - if ( - record?.schemaVersion !== 1 || - record?.kind !== 'active-checkpoint' || - !run || - run.schemaVersion !== 1 || - !ACTIVE_STATUSES.has(run.status) || - run.finishedAt !== null || - !Number.isInteger(run.issueNumber) || - !/^[0-9a-f]{40}$/i.test(run.baseSha) || - (run.headSha !== null && !/^[0-9a-f]{40}$/i.test(run.headSha)) || - !Array.isArray(events) || - events.length === 0 || - typeof record.briefSource !== 'string' || - Number.isNaN(Date.parse(record.updatedAt)) - ) { - throw new Error('invalid active checkpoint record') - } - assertRunId(run.runId) - let previousTimestamp = -Infinity - for (const event of events) { - const timestamp = Date.parse(event.timestamp) - if ( - event.schemaVersion !== 1 || - event.runId !== run.runId || - event.type === 'checkpoint_published' || - Number.isNaN(timestamp) || - timestamp < previousTimestamp - ) { - throw new Error('active checkpoint contains invalid or unordered events') - } - previousTimestamp = timestamp - } - if ( - events.at(-1).timestamp !== record.updatedAt || - !events.some((event) => event.type === 'loop_started') - ) { - throw new Error('active checkpoint is not bound to the latest run event') - } - if ( - run.briefDigest !== null && - createHash('sha256').update(record.briefSource).digest('hex') !== run.briefDigest - ) { - throw new Error('active checkpoint brief does not match the frozen digest') - } - return record -} - -function checkpointBody(record) { - const digest = checkpointDigest(record) - const result = { - digest, - body: [ - `<!-- issue-dev-loop:checkpoint:${record.run.runId}:sha256:${digest} -->`, - '```json', - canonicalCheckpoint(record), - '```', - ].join('\n'), - } - if (result.body.length > 60_000) { - throw new Error('active checkpoint exceeds the GitHub comment size budget') - } - return result -} +export const canonicalCheckpoint = canonicalCheckpointRecord +export const checkpointDigest = checkpointRecordDigest export async function prepareActiveCheckpoint({ loopRoot = DEFAULT_LOOP_ROOT, runId } = {}) { const normalizedRunId = assertRunId(runId) @@ -160,18 +39,18 @@ export async function prepareActiveCheckpoint({ loopRoot = DEFAULT_LOOP_ROOT, ru (event) => event.type !== 'checkpoint_published', ) const briefPath = path.join(loopRoot, 'handoffs', normalizedRunId, 'implementation-brief.md') - const record = validateCheckpoint({ + const record = validateCheckpointRecord({ schemaVersion: 1, kind: 'active-checkpoint', - run: canonicalRun(run), + run, briefSource: await readFile(briefPath, 'utf8'), events, updatedAt: events.at(-1)?.timestamp, }) const resultPath = path.join(runDirectory(loopRoot, normalizedRunId), 'checkpoint-result.json') await writeJson(resultPath, record) - const { channel, owner, repo } = await journalConfiguration(loopRoot) - const { digest, body } = checkpointBody(record) + const { channel, owner, repo } = await checkpointJournalConfiguration(loopRoot) + const { digest, body } = checkpointPublicationBody(record) return { record, resultPath, @@ -195,7 +74,7 @@ export async function recordActiveCheckpointPublication({ if (!resolvedResultPath.startsWith(`${runRoot}${path.sep}`)) { throw new Error('checkpoint result must be inside the current run directory') } - const record = validateCheckpoint(await readJson(resolvedResultPath)) + const record = validateCheckpointRecord(await readJson(resolvedResultPath)) const run = await readRun(loopRoot, normalizedRunId) const allEvents = await readEvents(loopRoot, normalizedRunId) const currentEvents = allEvents.filter((event) => event.type !== 'checkpoint_published') @@ -210,31 +89,15 @@ export async function recordActiveCheckpointPublication({ events: currentEvents, updatedAt: currentEvents.at(-1)?.timestamp, } - if (canonicalCheckpoint(currentRecord) !== canonicalCheckpoint(record)) { + if (canonicalCheckpointRecord(currentRecord) !== canonicalCheckpointRecord(record)) { throw new Error('checkpoint result no longer matches the active run') } - const { channel, owner, repo } = await journalConfiguration(loopRoot) - const target = parsePullCommentUrl(assertNonEmpty(commentUrl, 'commentUrl')) - if ( - !target || - target.surface !== 'issues' || - target.kind !== 'issue_comment' || - target.owner.toLowerCase() !== owner.toLowerCase() || - target.repo.toLowerCase() !== repo.toLowerCase() || - target.number !== channel.stateIssueNumber - ) { - throw new Error('checkpoint comment must be on the configured state journal issue') - } - const comment = await githubApi( - `repos/${target.owner}/${target.repo}/issues/comments/${target.commentId}`, - ) - const { digest, body } = checkpointBody(record) - if ( - !sameGitHubLogin(comment.user?.login, channel.automationGitHubLogin) || - !comment.body?.includes(body) - ) { - throw new Error('published checkpoint comment does not attest the exact active state') - } + const { digest } = await verifyPublishedCheckpoint({ + loopRoot, + record, + commentUrl: assertNonEmpty(commentUrl, 'commentUrl'), + githubApi, + }) if ( !allEvents.some( (event) => @@ -255,17 +118,12 @@ export async function recordActiveCheckpointPublication({ return { record, digest, commentUrl } } -function parseSerializedRecord(body) { - const serialized = body?.match(/```json\s*([^\n]+)\s*```/)?.[1] - return serialized ? JSON.parse(serialized) : null -} - export async function reconcileActiveJournal({ loopRoot = DEFAULT_LOOP_ROOT, githubPaginatedApi = defaultGitHubPaginatedApi, terminalRunIds = [], } = {}) { - const { channel, owner, repo } = await journalConfiguration(loopRoot) + const { channel, owner, repo } = await checkpointJournalConfiguration(loopRoot) const comments = await githubPaginatedApi( `repos/${owner}/${repo}/issues/${channel.stateIssueNumber}/comments?per_page=100`, ) @@ -277,8 +135,8 @@ export async function reconcileActiveJournal({ /<!-- issue-dev-loop:checkpoint:([^:]+):sha256:([0-9a-f]{64}) -->/, ) if (!marker) continue - const record = validateCheckpoint(parseSerializedRecord(comment.body)) - if (record.run.runId !== marker[1] || checkpointDigest(record) !== marker[2]) { + const record = validateCheckpointRecord(parseCheckpointRecord(comment.body)) + if (record.run.runId !== marker[1] || checkpointRecordDigest(record) !== marker[2]) { throw new Error(`invalid durable active checkpoint for ${marker[1]}`) } const existing = latestByRunId.get(record.run.runId) @@ -304,9 +162,10 @@ export async function reconcileActiveJournal({ async function defaultWorkspaceValidator({ loopRoot, record }) { const repositoryRoot = path.resolve(loopRoot, '..', '..') - const [branch, head, gitDirectory, commonDirectory] = await Promise.all([ + const [branch, head, status, gitDirectory, commonDirectory] = await Promise.all([ execFileAsync('git', ['branch', '--show-current'], { cwd: repositoryRoot }), execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repositoryRoot }), + execFileAsync('git', ['status', '--porcelain'], { cwd: repositoryRoot }), execFileAsync('git', ['rev-parse', '--path-format=absolute', '--git-dir'], { cwd: repositoryRoot, }), @@ -321,9 +180,12 @@ async function defaultWorkspaceValidator({ loopRoot, record }) { throw new Error(`restore requires isolated worktree branch ${record.run.branch}`) } const expectedHead = record.run.headSha ?? record.run.implementationCommit ?? record.run.baseSha - await execFileAsync('git', ['merge-base', '--is-ancestor', expectedHead, head.stdout.trim()], { - cwd: repositoryRoot, - }) + if (head.stdout.trim() !== expectedHead) { + throw new Error(`restore requires exact durable head ${expectedHead}`) + } + if (status.stdout.trim()) { + throw new Error('restore requires a clean isolated worktree') + } } export async function restoreActiveCheckpoint({ @@ -331,7 +193,7 @@ export async function restoreActiveCheckpoint({ checkpoint, workspaceValidator = defaultWorkspaceValidator, } = {}) { - const record = validateCheckpoint(checkpoint?.record) + const record = validateCheckpointRecord(checkpoint?.record) await workspaceValidator({ loopRoot, record }) const runId = record.run.runId const runPath = runDirectory(loopRoot, runId) @@ -356,7 +218,7 @@ export async function restoreActiveCheckpoint({ status: 'published', payload: { commentUrl: checkpoint.commentUrl ?? null, - digest: checkpointDigest(record), + digest: checkpointRecordDigest(record), checkpointUpdatedAt: record.updatedAt, }, }, diff --git a/loops/issue-dev-loop/scripts/lib/checkpoint-proof.mjs b/loops/issue-dev-loop/scripts/lib/checkpoint-proof.mjs new file mode 100644 index 00000000..465db6ed --- /dev/null +++ b/loops/issue-dev-loop/scripts/lib/checkpoint-proof.mjs @@ -0,0 +1,218 @@ +import { createHash } from 'node:crypto' +import path from 'node:path' + +import { + assertRunId, + defaultGitHubApi, + parsePullCommentUrl, + readJson, + runDirectory, + sameGitHubLogin, +} from './common.mjs' + +const ACTIVE_STATUSES = new Set(['running', 'waiting_for_owner', 'awaiting_owner_review']) + +export async function checkpointJournalConfiguration(loopRoot) { + const channel = await readJson( + path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'), + ) + if ( + !Number.isInteger(channel.stateIssueNumber) || + channel.stateIssueNumber < 1 || + !channel.automationGitHubLogin + ) { + throw new Error('owner channel must configure stateIssueNumber and automationGitHubLogin') + } + const [owner, repo] = channel.repository.split('/') + return { channel, owner, repo } +} + +function canonicalRun(run) { + return { + schemaVersion: run.schemaVersion, + runId: run.runId, + issueNumber: run.issueNumber, + issueTitle: run.issueTitle, + issueUrl: run.issueUrl, + baseBranch: run.baseBranch, + baseSha: run.baseSha, + branch: run.branch, + status: run.status, + startedAt: run.startedAt, + finishedAt: run.finishedAt, + prUrl: run.prUrl, + headSha: run.headSha, + mergeSha: run.mergeSha, + issueSnapshot: run.issueSnapshot, + briefDigest: run.briefDigest, + uiEvidenceRequired: run.uiEvidenceRequired, + implementationCommit: run.implementationCommit, + } +} + +function canonicalEvent(event) { + return { + schemaVersion: event.schemaVersion, + runId: event.runId, + type: event.type, + timestamp: event.timestamp, + status: event.status, + payload: event.payload, + } +} + +export function canonicalCheckpointRecord(record) { + return JSON.stringify({ + schemaVersion: 1, + kind: 'active-checkpoint', + run: canonicalRun(record.run), + briefSource: record.briefSource, + events: record.events.map(canonicalEvent), + updatedAt: record.updatedAt, + }) +} + +export function checkpointRecordDigest(record) { + return createHash('sha256').update(canonicalCheckpointRecord(record)).digest('hex') +} + +export function validateCheckpointRecord(record) { + const run = record?.run + const events = record?.events + if ( + record?.schemaVersion !== 1 || + record?.kind !== 'active-checkpoint' || + !run || + run.schemaVersion !== 1 || + !ACTIVE_STATUSES.has(run.status) || + run.finishedAt !== null || + !Number.isInteger(run.issueNumber) || + !/^[0-9a-f]{40}$/i.test(run.baseSha) || + (run.headSha !== null && !/^[0-9a-f]{40}$/i.test(run.headSha)) || + !Array.isArray(events) || + events.length === 0 || + typeof record.briefSource !== 'string' || + Number.isNaN(Date.parse(record.updatedAt)) + ) { + throw new Error('invalid active checkpoint record') + } + assertRunId(run.runId) + let previousTimestamp = -Infinity + for (const event of events) { + const timestamp = Date.parse(event.timestamp) + if ( + event.schemaVersion !== 1 || + event.runId !== run.runId || + event.type === 'checkpoint_published' || + Number.isNaN(timestamp) || + timestamp < previousTimestamp + ) { + throw new Error('active checkpoint contains invalid or unordered events') + } + previousTimestamp = timestamp + } + if ( + events.at(-1).timestamp !== record.updatedAt || + !events.some((event) => event.type === 'loop_started') + ) { + throw new Error('active checkpoint is not bound to the latest run event') + } + if ( + run.briefDigest !== null && + createHash('sha256').update(record.briefSource).digest('hex') !== run.briefDigest + ) { + throw new Error('active checkpoint brief does not match the frozen digest') + } + return record +} + +export function checkpointPublicationBody(record) { + const digest = checkpointRecordDigest(record) + const result = { + digest, + body: [ + `<!-- issue-dev-loop:checkpoint:${record.run.runId}:sha256:${digest} -->`, + '```json', + canonicalCheckpointRecord(record), + '```', + ].join('\n'), + } + if (result.body.length > 60_000) { + throw new Error('active checkpoint exceeds the GitHub comment size budget') + } + return result +} + +export function parseCheckpointRecord(body) { + const serialized = body?.match(/```json\s*([^\n]+)\s*```/)?.[1] + return serialized ? JSON.parse(serialized) : null +} + +export async function verifyPublishedCheckpoint({ + loopRoot, + record, + commentUrl, + githubApi = defaultGitHubApi, +} = {}) { + const validated = validateCheckpointRecord(record) + const { channel, owner, repo } = await checkpointJournalConfiguration(loopRoot) + const target = parsePullCommentUrl(commentUrl) + if ( + !target || + target.surface !== 'issues' || + target.kind !== 'issue_comment' || + target.owner.toLowerCase() !== owner.toLowerCase() || + target.repo.toLowerCase() !== repo.toLowerCase() || + target.number !== channel.stateIssueNumber + ) { + throw new Error('checkpoint comment must be on the configured state journal issue') + } + const comment = await githubApi( + `repos/${target.owner}/${target.repo}/issues/comments/${target.commentId}`, + ) + const { digest, body } = checkpointPublicationBody(validated) + if ( + !sameGitHubLogin(comment.user?.login, channel.automationGitHubLogin) || + !comment.body?.includes(body) + ) { + throw new Error('published checkpoint comment does not attest the exact active state') + } + return { record: validated, digest, commentUrl } +} + +export async function verifyLatestDurableCheckpoint({ + loopRoot, + runId, + events, + operation, + githubApi = defaultGitHubApi, +} = {}) { + const normalizedRunId = assertRunId(runId) + const latestPhaseEvent = events.findLast((event) => event.type !== 'checkpoint_published') + const checkpoint = events.findLast( + (event) => + event.type === 'checkpoint_published' && + event.status === 'published' && + event.payload?.checkpointUpdatedAt === latestPhaseEvent?.timestamp, + ) + if (!latestPhaseEvent || !checkpoint) { + throw new Error(`${operation} requires a durable checkpoint for the latest run phase`) + } + const record = validateCheckpointRecord( + await readJson(path.join(runDirectory(loopRoot, normalizedRunId), 'checkpoint-result.json')), + ) + const digest = checkpointRecordDigest(record) + if ( + record.run.runId !== normalizedRunId || + record.updatedAt !== latestPhaseEvent.timestamp || + checkpoint.payload?.digest !== digest + ) { + throw new Error(`${operation} checkpoint event does not match its durable record`) + } + return verifyPublishedCheckpoint({ + loopRoot, + record, + commentUrl: checkpoint.payload?.commentUrl, + githubApi, + }) +} diff --git a/loops/issue-dev-loop/scripts/lib/evidence.mjs b/loops/issue-dev-loop/scripts/lib/evidence.mjs index c9c426b3..56fade23 100644 --- a/loops/issue-dev-loop/scripts/lib/evidence.mjs +++ b/loops/issue-dev-loop/scripts/lib/evidence.mjs @@ -20,12 +20,8 @@ import { sameGitHubLogin, sameRepository, } from './common.mjs' -import { - appendValidatedEvent, - assertLatestDurableCheckpoint, - readEvents, - readRun, -} from './run-store.mjs' +import { appendValidatedEvent, readEvents, readRun } from './run-store.mjs' +import { verifyLatestDurableCheckpoint } from './checkpoint-proof.mjs' const REVIEW_CLASSIFICATIONS = new Set(['accepted', 'rejected', 'stale', 'already-fixed']) @@ -153,13 +149,20 @@ export async function recordEvidence({ now = new Date(), githubApi = defaultGitHubApi, artifactManifestLoader = defaultArtifactManifestLoader, + checkpointVerifier = verifyLatestDurableCheckpoint, } = {}) { const normalizedRunId = assertRunId(runId) const run = await readRun(loopRoot, normalizedRunId) if (run.finishedAt !== null) throw new Error(`run is already finalized: ${normalizedRunId}`) if (!run.prUrl || !run.headSha) throw new Error('record-evidence requires a recorded draft PR') const runEvents = await readEvents(loopRoot, normalizedRunId) - assertLatestDurableCheckpoint(runEvents, 'record-evidence') + await checkpointVerifier({ + loopRoot, + runId: normalizedRunId, + events: runEvents, + operation: 'record-evidence', + githubApi, + }) const evidenceRoot = path.resolve(loopRoot, 'evidence') const resolvedManifest = path.resolve(assertNonEmpty(manifestPath, 'manifestPath')) @@ -333,11 +336,18 @@ export async function recordReview({ reviewUrl, now = new Date(), githubApi = defaultGitHubApi, + checkpointVerifier = verifyLatestDurableCheckpoint, } = {}) { const normalizedRunId = assertRunId(runId) const run = await readRun(loopRoot, normalizedRunId) if (run.finishedAt !== null) throw new Error(`run is already finalized: ${normalizedRunId}`) - assertLatestDurableCheckpoint(await readEvents(loopRoot, normalizedRunId), 'record-review') + await checkpointVerifier({ + loopRoot, + runId: normalizedRunId, + events: await readEvents(loopRoot, normalizedRunId), + operation: 'record-review', + githubApi, + }) const resolvedResultPath = path.resolve(assertNonEmpty(resultPath, 'resultPath')) const expectedResultRoot = runDirectory(loopRoot, normalizedRunId) if (!resolvedResultPath.startsWith(`${expectedResultRoot}${path.sep}`)) { @@ -413,6 +423,16 @@ export async function recordReview({ ) { throw new Error(`published GitHub review round ${round.round} is not bound to its exact head`) } + const expectedFindingIds = new Set(round.findings.map((finding) => finding.findingId)) + const publishedFindingIds = new Set( + bodies.flatMap((body) => body.match(/\bRVW-[0-9]+-[0-9]+\b/g) ?? []), + ) + if ( + publishedFindingIds.size !== expectedFindingIds.size || + [...publishedFindingIds].some((findingId) => !expectedFindingIds.has(findingId)) + ) { + throw new Error(`published GitHub review round ${round.round} has unrecorded findings`) + } for (const finding of round.findings) { const findingMarker = `<!-- issue-dev-loop:${normalizedRunId}:${finding.findingId} -->` const requiredFindingFragments = [ diff --git a/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs b/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs index 5ebdf5d5..b7116a83 100644 --- a/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs +++ b/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs @@ -1,4 +1,3 @@ -import { createHash } from 'node:crypto' import { readFile } from 'node:fs/promises' import path from 'node:path' @@ -9,7 +8,6 @@ import { assertRunId, defaultGitHubApi, defaultGitHubPaginatedApi, - parsePullCommentUrl, pathExists, readJson, runDirectory, @@ -17,141 +15,21 @@ import { writeJson, } from './common.mjs' import { updateEvolveMetrics } from './evolve.mjs' -import { observeOwnerApprovedMerge } from './owner-gate.mjs' import { - appendValidatedEvent, - assertLatestDurableCheckpoint, - readEvents, - readRun, -} from './run-store.mjs' + canonicalFinalizationRecord, + finalizationJournalConfiguration, + finalizationRecordDigest, + validateFinalizationRecord, + verifyPublishedFinalization, + verifyTerminalExternalProof, +} from './finalization-proof.mjs' +import { verifyLatestDurableCheckpoint } from './checkpoint-proof.mjs' +import { appendValidatedEvent, readEvents, readRun } from './run-store.mjs' const TERMINAL_STATUSES = new Set(['completed', 'failed', 'blocked', 'cancelled']) -function canonicalRecord(record) { - return JSON.stringify({ - schemaVersion: 1, - runId: record.runId, - issueNumber: record.issueNumber, - status: record.status, - startedAt: record.startedAt, - finishedAt: record.finishedAt, - prUrl: record.prUrl ?? null, - headSha: record.headSha ?? null, - mergeSha: record.mergeSha ?? null, - failureFingerprint: record.failureFingerprint ?? null, - notificationUrl: record.notificationUrl ?? null, - }) -} - -function recordDigest(record) { - return createHash('sha256').update(canonicalRecord(record)).digest('hex') -} - -function validateRecord(record, run = null) { - if ( - record.schemaVersion !== 1 || - !TERMINAL_STATUSES.has(record.status) || - !Number.isInteger(record.issueNumber) || - Number.isNaN(Date.parse(record.startedAt)) || - Number.isNaN(Date.parse(record.finishedAt)) || - Date.parse(record.finishedAt) < Date.parse(record.startedAt) || - (record.headSha !== null && !/^[0-9a-f]{40}$/i.test(record.headSha)) || - (record.mergeSha !== null && !/^[0-9a-f]{40}$/i.test(record.mergeSha)) - ) { - throw new Error('invalid finalization journal record') - } - if ( - record.status === 'completed' && - (!record.prUrl || !record.headSha || !record.mergeSha || record.failureFingerprint !== null) - ) { - throw new Error('completed finalization record requires PR, head, and merge proof') - } - if ( - ['failed', 'blocked'].includes(record.status) && - (!assertNonEmpty(record.failureFingerprint, 'failureFingerprint') || !record.notificationUrl) - ) { - throw new Error('failed or blocked finalization requires a fingerprint and notification URL') - } - if (record.status === 'cancelled' && (!record.prUrl || !record.headSha)) { - throw new Error('cancelled finalization requires a published PR') - } - if ( - run && - (record.runId !== run.runId || - record.issueNumber !== run.issueNumber || - record.startedAt !== run.startedAt || - record.prUrl !== run.prUrl || - record.headSha !== run.headSha) - ) { - throw new Error('finalization journal record does not match the run') - } - return record -} - -async function journalConfiguration(loopRoot) { - const channel = await readJson( - path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'), - ) - if ( - !Number.isInteger(channel.stateIssueNumber) || - channel.stateIssueNumber < 1 || - !channel.automationGitHubLogin - ) { - throw new Error('owner channel must configure stateIssueNumber and automationGitHubLogin') - } - const [owner, repo] = channel.repository.split('/') - return { channel, owner, repo } -} - -async function verifyTerminalExternalProof({ loopRoot, record, githubApi }) { - if (record.status === 'completed') { - const merge = await observeOwnerApprovedMerge({ - loopRoot, - prUrl: record.prUrl, - expectedHeadSha: record.headSha, - expectedHeadBranch: `codex/issue-${record.issueNumber}`, - githubApi, - }) - if (merge.mergeSha !== record.mergeSha) { - throw new Error('completed finalization does not match the remote owner merge') - } - } - if (['failed', 'blocked'].includes(record.status)) { - const target = parsePullCommentUrl(record.notificationUrl) - const pullTarget = record.prUrl ? new URL(record.prUrl) : null - if ( - !target || - target.kind !== 'issue_comment' || - !['pull', 'issues'].includes(target.surface) || - (target.surface === 'issues' && target.number !== record.issueNumber) || - (target.surface === 'pull' && - (!pullTarget || !pullTarget.pathname.endsWith(`/pull/${target.number}`))) - ) { - throw new Error('terminal notification URL is not bound to the run issue or PR') - } - const { channel } = await journalConfiguration(loopRoot) - const comment = await githubApi( - `repos/${target.owner}/${target.repo}/issues/comments/${target.commentId}`, - ) - const expectedType = record.status === 'failed' ? 'loop_failed' : 'blocked' - if ( - !sameGitHubLogin(comment.user?.login, channel.automationGitHubLogin) || - !comment.body?.includes(`**${expectedType}**`) || - !comment.body?.includes(`Run: \`${record.runId}\``) - ) { - throw new Error('terminal notification lacks durable automation-authored proof') - } - } - if (record.status === 'cancelled') { - const target = new URL(record.prUrl) - const match = target.pathname.match(/^\/([^/]+)\/([^/]+)\/pull\/(\d+)$/) - if (!match) throw new Error('cancelled finalization requires a GitHub PR') - const pull = await githubApi(`repos/${match[1]}/${match[2]}/pulls/${match[3]}`) - if (pull.state !== 'closed' || pull.merged === true || pull.head?.sha !== record.headSha) { - throw new Error('cancelled finalization requires the recorded PR closed without merge') - } - } -} +export const canonicalRecord = canonicalFinalizationRecord +export const recordDigest = finalizationRecordDigest export async function prepareFinalizationRecord({ loopRoot = DEFAULT_LOOP_ROOT, @@ -161,12 +39,19 @@ export async function prepareFinalizationRecord({ failureFingerprint = null, finishedAt = new Date(), githubApi = defaultGitHubApi, + checkpointVerifier = verifyLatestDurableCheckpoint, } = {}) { const normalizedRunId = assertRunId(runId) const run = await readRun(loopRoot, normalizedRunId) if (run.finishedAt !== null) throw new Error('cannot prepare finalization for a finished run') const events = await readEvents(loopRoot, normalizedRunId) - assertLatestDurableCheckpoint(events, 'prepare-finalization') + await checkpointVerifier({ + loopRoot, + runId: normalizedRunId, + events, + operation: 'prepare-finalization', + githubApi, + }) const notificationType = status === 'failed' ? 'loop_failed' : status === 'blocked' ? 'blocked' : null const notificationUrl = notificationType @@ -179,7 +64,7 @@ export async function prepareFinalizationRecord({ : null const resultPath = path.join(runDirectory(loopRoot, normalizedRunId), 'finalization-result.json') if (await pathExists(resultPath)) { - const existing = validateRecord(await readJson(resultPath), run) + const existing = validateFinalizationRecord(await readJson(resultPath), run) if ( existing.status !== status || existing.mergeSha !== mergeSha || @@ -188,7 +73,7 @@ export async function prepareFinalizationRecord({ throw new Error('a different finalization record is already prepared for this run') } await verifyTerminalExternalProof({ loopRoot, record: existing, githubApi }) - const { channel, owner, repo } = await journalConfiguration(loopRoot) + const { channel, owner, repo } = await finalizationJournalConfiguration(loopRoot) const digest = recordDigest(existing) return { record: existing, @@ -203,7 +88,7 @@ export async function prepareFinalizationRecord({ journalIssueUrl: `https://github.com/${owner}/${repo}/issues/${channel.stateIssueNumber}`, } } - const record = validateRecord( + const record = validateFinalizationRecord( { schemaVersion: 1, runId: normalizedRunId, @@ -220,7 +105,7 @@ export async function prepareFinalizationRecord({ run, ) await verifyTerminalExternalProof({ loopRoot, record, githubApi }) - const { channel, owner, repo } = await journalConfiguration(loopRoot) + const { channel, owner, repo } = await finalizationJournalConfiguration(loopRoot) const digest = recordDigest(record) const body = [ `<!-- issue-dev-loop:finalization:${normalizedRunId}:sha256:${digest} -->`, @@ -254,32 +139,14 @@ export async function recordFinalizationPublication({ if (!resolvedResultPath.startsWith(`${runRoot}${path.sep}`)) { throw new Error('finalization result must be inside the current run directory') } - const record = validateRecord(await readJson(resolvedResultPath), run) - await verifyTerminalExternalProof({ loopRoot, record, githubApi }) - const { channel, owner, repo } = await journalConfiguration(loopRoot) - const target = parsePullCommentUrl(assertNonEmpty(commentUrl, 'commentUrl')) - if ( - !target || - target.surface !== 'issues' || - target.kind !== 'issue_comment' || - target.owner.toLowerCase() !== owner.toLowerCase() || - target.repo.toLowerCase() !== repo.toLowerCase() || - target.number !== channel.stateIssueNumber - ) { - throw new Error('finalization comment must be on the configured state journal issue') - } - const comment = await githubApi( - `repos/${target.owner}/${target.repo}/issues/comments/${target.commentId}`, - ) - const digest = recordDigest(record) - const marker = `<!-- issue-dev-loop:finalization:${normalizedRunId}:sha256:${digest} -->` - if ( - !sameGitHubLogin(comment.user?.login, channel.automationGitHubLogin) || - !comment.body?.includes(marker) || - !comment.body?.includes(canonicalRecord(record)) - ) { - throw new Error('published finalization comment does not attest the exact record') - } + const record = validateFinalizationRecord(await readJson(resolvedResultPath), run) + const { digest } = await verifyPublishedFinalization({ + loopRoot, + record, + commentUrl: assertNonEmpty(commentUrl, 'commentUrl'), + expectedHeadBranch: run.branch, + githubApi, + }) await appendValidatedEvent({ loopRoot, runId: normalizedRunId, @@ -304,7 +171,7 @@ export async function reconcileFinalizationJournal({ githubPaginatedApi = defaultGitHubPaginatedApi, githubApi = defaultGitHubApi, } = {}) { - const { channel, owner, repo } = await journalConfiguration(loopRoot) + const { channel, owner, repo } = await finalizationJournalConfiguration(loopRoot) const comments = await githubPaginatedApi( `repos/${owner}/${repo}/issues/${channel.stateIssueNumber}/comments?per_page=100`, ) @@ -316,11 +183,16 @@ export async function reconcileFinalizationJournal({ ) const serialized = comment.body?.match(/```json\s*([^\n]+)\s*```/)?.[1] if (!marker || !serialized) continue - const record = validateRecord(JSON.parse(serialized)) + const record = validateFinalizationRecord(JSON.parse(serialized)) if (record.runId !== marker[1] || recordDigest(record) !== marker[2]) { throw new Error(`invalid durable finalization record for ${marker[1]}`) } - await verifyTerminalExternalProof({ loopRoot, record, githubApi }) + await verifyPublishedFinalization({ + loopRoot, + record, + commentUrl: comment.html_url, + githubApi, + }) records.push(record) } records.sort((left, right) => Date.parse(left.finishedAt) - Date.parse(right.finishedAt)) @@ -349,5 +221,3 @@ export async function reconcileFinalizationJournal({ await updateEvolveMetrics({ loopRoot, now }) return { reconciled: records.length, durableRunIds: records.map((record) => record.runId) } } - -export { canonicalRecord, recordDigest } diff --git a/loops/issue-dev-loop/scripts/lib/finalization-proof.mjs b/loops/issue-dev-loop/scripts/lib/finalization-proof.mjs new file mode 100644 index 00000000..ad88a673 --- /dev/null +++ b/loops/issue-dev-loop/scripts/lib/finalization-proof.mjs @@ -0,0 +1,199 @@ +import { createHash } from 'node:crypto' +import path from 'node:path' + +import { + assertNonEmpty, + defaultGitHubApi, + parseGitHubTarget, + parsePullCommentUrl, + readJson, + sameGitHubLogin, + sameRepository, +} from './common.mjs' +import { observeOwnerApprovedMerge } from './owner-gate.mjs' + +const TERMINAL_STATUSES = new Set(['completed', 'failed', 'blocked', 'cancelled']) + +export function canonicalFinalizationRecord(record) { + return JSON.stringify({ + schemaVersion: 1, + runId: record.runId, + issueNumber: record.issueNumber, + status: record.status, + startedAt: record.startedAt, + finishedAt: record.finishedAt, + prUrl: record.prUrl ?? null, + headSha: record.headSha ?? null, + mergeSha: record.mergeSha ?? null, + failureFingerprint: record.failureFingerprint ?? null, + notificationUrl: record.notificationUrl ?? null, + }) +} + +export function finalizationRecordDigest(record) { + return createHash('sha256').update(canonicalFinalizationRecord(record)).digest('hex') +} + +export function validateFinalizationRecord(record, run = null) { + if ( + record?.schemaVersion !== 1 || + !TERMINAL_STATUSES.has(record.status) || + !Number.isInteger(record.issueNumber) || + Number.isNaN(Date.parse(record.startedAt)) || + Number.isNaN(Date.parse(record.finishedAt)) || + Date.parse(record.finishedAt) < Date.parse(record.startedAt) || + (record.headSha !== null && !/^[0-9a-f]{40}$/i.test(record.headSha)) || + (record.mergeSha !== null && !/^[0-9a-f]{40}$/i.test(record.mergeSha)) + ) { + throw new Error('invalid finalization journal record') + } + if ( + record.status === 'completed' && + (!record.prUrl || !record.headSha || !record.mergeSha || record.failureFingerprint !== null) + ) { + throw new Error('completed finalization record requires PR, head, and merge proof') + } + if (['failed', 'blocked'].includes(record.status)) { + assertNonEmpty(record.failureFingerprint, 'failureFingerprint') + if (!record.notificationUrl) { + throw new Error('failed or blocked finalization requires a notification URL') + } + } + if (record.status === 'cancelled' && (!record.prUrl || !record.headSha)) { + throw new Error('cancelled finalization requires a published PR') + } + if ( + run && + (record.runId !== run.runId || + record.issueNumber !== run.issueNumber || + record.startedAt !== run.startedAt || + record.prUrl !== run.prUrl || + record.headSha !== run.headSha || + (run.finishedAt !== null && + (record.status !== run.status || + record.finishedAt !== run.finishedAt || + record.mergeSha !== run.mergeSha))) + ) { + throw new Error('finalization journal record does not match the run') + } + return record +} + +export async function finalizationJournalConfiguration(loopRoot) { + const channel = await readJson( + path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'), + ) + if ( + !Number.isInteger(channel.stateIssueNumber) || + channel.stateIssueNumber < 1 || + !channel.automationGitHubLogin + ) { + throw new Error('owner channel must configure stateIssueNumber and automationGitHubLogin') + } + const [owner, repo] = channel.repository.split('/') + return { channel, owner, repo } +} + +export async function verifyTerminalExternalProof({ + loopRoot, + record, + expectedHeadBranch = `codex/issue-${record.issueNumber}`, + githubApi = defaultGitHubApi, +} = {}) { + const validated = validateFinalizationRecord(record) + const { channel, owner, repo } = await finalizationJournalConfiguration(loopRoot) + const configuredTarget = { owner, repo } + if (validated.status === 'completed') { + const merge = await observeOwnerApprovedMerge({ + loopRoot, + prUrl: validated.prUrl, + expectedHeadSha: validated.headSha, + expectedHeadBranch, + githubApi, + }) + if (merge.mergeSha !== validated.mergeSha) { + throw new Error('completed finalization does not match the remote owner merge') + } + } + if (['failed', 'blocked'].includes(validated.status)) { + const target = parsePullCommentUrl(validated.notificationUrl) + const issueTarget = parseGitHubTarget( + `https://github.com/${owner}/${repo}/issues/${validated.issueNumber}`, + ) + const pullTarget = parseGitHubTarget(validated.prUrl) + if ( + !target || + target.kind !== 'issue_comment' || + !sameRepository(target, configuredTarget) || + !['pull', 'issues'].includes(target.surface) || + (target.surface === 'issues' && + (!sameRepository(target, issueTarget) || target.number !== validated.issueNumber)) || + (target.surface === 'pull' && + (!pullTarget || !sameRepository(target, pullTarget) || target.number !== pullTarget.number)) + ) { + throw new Error('terminal notification URL is not bound to the configured run issue or PR') + } + const comment = await githubApi( + `repos/${target.owner}/${target.repo}/issues/comments/${target.commentId}`, + ) + const expectedType = validated.status === 'failed' ? 'loop_failed' : 'blocked' + if ( + !sameGitHubLogin(comment.user?.login, channel.automationGitHubLogin) || + !comment.body?.includes(`**${expectedType}**`) || + !comment.body?.includes(`Run: \`${validated.runId}\``) + ) { + throw new Error('terminal notification lacks durable automation-authored proof') + } + } + if (validated.status === 'cancelled') { + const target = parseGitHubTarget(validated.prUrl) + if (!target || target.kind !== 'pull' || !sameRepository(target, configuredTarget)) { + throw new Error('cancelled finalization requires a configured-repository PR') + } + const pull = await githubApi(`repos/${target.owner}/${target.repo}/pulls/${target.number}`) + if (pull.state !== 'closed' || pull.merged === true || pull.head?.sha !== validated.headSha) { + throw new Error('cancelled finalization requires the recorded PR closed without merge') + } + } + return validated +} + +export async function verifyPublishedFinalization({ + loopRoot, + record, + commentUrl, + expectedHeadBranch, + githubApi = defaultGitHubApi, +} = {}) { + const validated = await verifyTerminalExternalProof({ + loopRoot, + record, + expectedHeadBranch, + githubApi, + }) + const { channel, owner, repo } = await finalizationJournalConfiguration(loopRoot) + const target = parsePullCommentUrl(commentUrl) + if ( + !target || + target.surface !== 'issues' || + target.kind !== 'issue_comment' || + target.owner.toLowerCase() !== owner.toLowerCase() || + target.repo.toLowerCase() !== repo.toLowerCase() || + target.number !== channel.stateIssueNumber + ) { + throw new Error('finalization comment must be on the configured state journal issue') + } + const comment = await githubApi( + `repos/${target.owner}/${target.repo}/issues/comments/${target.commentId}`, + ) + const digest = finalizationRecordDigest(validated) + const marker = `<!-- issue-dev-loop:finalization:${validated.runId}:sha256:${digest} -->` + if ( + !sameGitHubLogin(comment.user?.login, channel.automationGitHubLogin) || + !comment.body?.includes(marker) || + !comment.body?.includes(canonicalFinalizationRecord(validated)) + ) { + throw new Error('published finalization comment does not attest the exact record') + } + return { record: validated, digest, commentUrl } +} diff --git a/loops/issue-dev-loop/scripts/lib/github.mjs b/loops/issue-dev-loop/scripts/lib/github.mjs index d42971ff..8d47e00e 100644 --- a/loops/issue-dev-loop/scripts/lib/github.mjs +++ b/loops/issue-dev-loop/scripts/lib/github.mjs @@ -25,6 +25,7 @@ import { reconcileActiveJournal } from './active-journal.mjs' import { observeOwnerApprovedMerge } from './owner-gate.mjs' import { defaultReleaseIssueClaim } from './issue-claim.mjs' import { appendValidatedEvent, finalizeRun, readEvents, readRun } from './run-store.mjs' +import { verifyLatestDurableCheckpoint } from './checkpoint-proof.mjs' const PRIORITY = new Map([ ['priority:critical', 0], @@ -42,12 +43,14 @@ function issuePriority(issue) { return rank } -export function selectIssue({ issues = [], pullRequests = [] } = {}) { +export function selectIssue({ issues = [], pullRequests = [], branchNames = [] } = {}) { + const branches = new Set(branchNames) const eligible = issues.filter((issue) => { const labels = labelNames(issue) return ( labels.has('codex-ready') && !labels.has('loop:claimed') && + !branches.has(`codex/issue-${issue.number}`) && !pullRequests.some((pullRequest) => pullRequestClaimsIssue(pullRequest, issue.number)) ) }) @@ -131,6 +134,7 @@ export async function detectWork({ } let issues let pullRequests + let branchNames = [] if (issuesFile) { issues = await loadJsonFile(issuesFile) } else { @@ -167,7 +171,22 @@ export async function detectWork({ const result = await execFileAsync('gh', argumentsList, { maxBuffer: 1024 * 1024 }) pullRequests = JSON.parse(result.stdout) } - return recordTriggerCheck({ workType: 'issue', ...selectIssue({ issues, pullRequests }) }) + if (!issuesFile && !pullRequestsFile) { + const result = await execFileAsync( + 'git', + ['ls-remote', '--heads', 'origin', 'refs/heads/codex/issue-*'], + { cwd: path.resolve(loopRoot, '..', '..'), maxBuffer: 1024 * 1024 }, + ) + branchNames = result.stdout + .split('\n') + .filter(Boolean) + .map((line) => line.split('\t')[1]?.replace('refs/heads/', '')) + .filter(Boolean) + } + return recordTriggerCheck({ + workType: 'issue', + ...selectIssue({ issues, pullRequests, branchNames }), + }) } export async function observeOwnerMerge({ @@ -238,12 +257,21 @@ export async function recordOwnerResponse({ responseUrl, now = new Date(), githubApi = defaultGitHubApi, + checkpointVerifier = verifyLatestDurableCheckpoint, } = {}) { const normalizedRunId = assertRunId(runId) const run = await readRun(loopRoot, normalizedRunId) if (!['waiting_for_owner', 'awaiting_owner_review'].includes(run.status)) { throw new Error('owner response can only resume a paused run') } + const events = await readEvents(loopRoot, normalizedRunId) + await checkpointVerifier({ + loopRoot, + runId: normalizedRunId, + events, + operation: 'record-owner-response', + githubApi, + }) const publishedUrl = assertNonEmpty(responseUrl, 'responseUrl') const issueTarget = parseGitHubTarget(run.issueUrl) const pullTarget = parseGitHubTarget(run.prUrl) @@ -290,7 +318,6 @@ export async function recordOwnerResponse({ const channel = await readJson( path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'), ) - const events = await readEvents(loopRoot, normalizedRunId) const pauseEvent = events.findLast( (event) => event.type === 'run_status_changed' && event.status === run.status, ) diff --git a/loops/issue-dev-loop/scripts/lib/issue-claim.mjs b/loops/issue-dev-loop/scripts/lib/issue-claim.mjs index b81e1249..fc93fd0b 100644 --- a/loops/issue-dev-loop/scripts/lib/issue-claim.mjs +++ b/loops/issue-dev-loop/scripts/lib/issue-claim.mjs @@ -1,4 +1,6 @@ import { + assertAutomationIdentity, + DEFAULT_LOOP_ROOT, defaultGitHubApi, defaultGitHubPaginatedApi, execFileAsync, @@ -22,12 +24,41 @@ async function defaultAddLabel({ target, issueNumber }) { ) } +async function defaultRemoteBranchExists({ target, issueNumber }) { + try { + await execFileAsync( + 'gh', + ['api', `repos/${target.owner}/${target.repo}/git/ref/heads/codex/issue-${issueNumber}`], + { maxBuffer: 1024 * 1024 }, + ) + return true + } catch (error) { + if (error?.stderr?.includes('HTTP 404')) return false + throw error + } +} + +async function defaultRemoveLabel({ target, issueNumber }) { + await execFileAsync( + 'gh', + [ + 'api', + `repos/${target.owner}/${target.repo}/issues/${issueNumber}/labels/loop%3Aclaimed`, + '--method', + 'DELETE', + ], + { maxBuffer: 1024 * 1024 }, + ) +} + export async function defaultClaimIssue({ + loopRoot = DEFAULT_LOOP_ROOT, issueUrl, issueNumber, githubApi = defaultGitHubApi, githubPaginatedApi = defaultGitHubPaginatedApi, addLabel = defaultAddLabel, + remoteBranchExists = defaultRemoteBranchExists, }) { const target = parseGitHubTarget(issueUrl) if (!target || target.kind !== 'issues' || target.number !== issueNumber) { @@ -38,20 +69,28 @@ export async function defaultClaimIssue({ if (issue.state !== 'open' || !labels.has('codex-ready') || labels.has('loop:claimed')) { throw new Error('issue is no longer an open, unclaimed codex-ready issue') } + if (await remoteBranchExists({ target, issueNumber })) { + throw new Error(`remote branch codex/issue-${issueNumber} already exists`) + } const pulls = await githubPaginatedApi( `repos/${target.owner}/${target.repo}/pulls?state=open&per_page=100`, ) if (pulls.some((pullRequest) => pullRequestClaimsIssue(pullRequest, issueNumber))) { throw new Error(`an open pull request already claims issue ${issueNumber}`) } + if (addLabel === defaultAddLabel) { + await assertAutomationIdentity({ loopRoot, githubApi }) + } await addLabel({ target, issueNumber }) return issue } export async function defaultReleaseIssueClaim({ + loopRoot = DEFAULT_LOOP_ROOT, issueUrl, issueNumber, githubApi = defaultGitHubApi, + removeLabel = defaultRemoveLabel, }) { const target = parseGitHubTarget(issueUrl) if (!target || target.kind !== 'issues' || target.number !== issueNumber) { @@ -59,14 +98,8 @@ export async function defaultReleaseIssueClaim({ } const issue = await githubApi(`repos/${target.owner}/${target.repo}/issues/${issueNumber}`) if (!labelNames(issue).has('loop:claimed')) return - await execFileAsync( - 'gh', - [ - 'api', - `repos/${target.owner}/${target.repo}/issues/${issueNumber}/labels/loop%3Aclaimed`, - '--method', - 'DELETE', - ], - { maxBuffer: 1024 * 1024 }, - ) + if (removeLabel === defaultRemoveLabel) { + await assertAutomationIdentity({ loopRoot, githubApi }) + } + await removeLabel({ target, issueNumber }) } diff --git a/loops/issue-dev-loop/scripts/lib/notifications.mjs b/loops/issue-dev-loop/scripts/lib/notifications.mjs index 02c75a7f..8cc72a33 100644 --- a/loops/issue-dev-loop/scripts/lib/notifications.mjs +++ b/loops/issue-dev-loop/scripts/lib/notifications.mjs @@ -13,13 +13,8 @@ import { timestampToken, writeJson, } from './common.mjs' -import { - appendValidatedEvent, - assertLatestDurableCheckpoint, - readEvents, - readRun, - transitionRun, -} from './run-store.mjs' +import { appendValidatedEvent, readEvents, readRun, transitionRun } from './run-store.mjs' +import { verifyLatestDurableCheckpoint } from './checkpoint-proof.mjs' function notificationBody(notification, owner) { const evidence = notification.evidenceUrl ? `\n\nEvidence: ${notification.evidenceUrl}` : '' @@ -69,10 +64,18 @@ export async function createNotification({ fetchImplementation = globalThis.fetch, githubComment = defaultGitHubComment, verifyAutomationIdentity = assertAutomationIdentity, + githubApi, + checkpointVerifier = verifyLatestDurableCheckpoint, } = {}) { const normalizedRunId = assertRunId(runId) const run = await readRun(loopRoot, normalizedRunId) - assertLatestDurableCheckpoint(await readEvents(loopRoot, normalizedRunId), 'notify owner') + await checkpointVerifier({ + loopRoot, + runId: normalizedRunId, + events: await readEvents(loopRoot, normalizedRunId), + operation: 'notify owner', + githubApi, + }) const channelRoot = path.resolve(loopRoot, '..', '_shared', 'owner-channel') const channel = await readJson(path.join(channelRoot, 'channel.json')) const notificationType = assertNonEmpty(type, 'type') diff --git a/loops/issue-dev-loop/scripts/lib/run-store.mjs b/loops/issue-dev-loop/scripts/lib/run-store.mjs index 9a85ff11..8fea8d8f 100644 --- a/loops/issue-dev-loop/scripts/lib/run-store.mjs +++ b/loops/issue-dev-loop/scripts/lib/run-store.mjs @@ -23,6 +23,12 @@ import { } from './common.mjs' import { defaultClaimIssue, defaultReleaseIssueClaim } from './issue-claim.mjs' import { updateEvolveMetrics } from './evolve.mjs' +import { verifyLatestDurableCheckpoint } from './checkpoint-proof.mjs' +import { + finalizationRecordDigest, + validateFinalizationRecord, + verifyPublishedFinalization, +} from './finalization-proof.mjs' import { observeOwnerApprovedMerge } from './owner-gate.mjs' export const PAUSED_STATUSES = new Set(['awaiting_owner_review', 'waiting_for_owner']) @@ -69,6 +75,32 @@ export async function readRun(loopRoot, runId) { return readJson(path.join(runDirectory(loopRoot, runId), 'run.json')) } +async function defaultStartWorkspaceValidator({ loopRoot, issueNumber, baseSha }) { + const repositoryRoot = path.resolve(loopRoot, '..', '..') + const [branch, head, originDev, status, gitDirectory, commonDirectory] = await Promise.all([ + execFileAsync('git', ['branch', '--show-current'], { cwd: repositoryRoot }), + execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repositoryRoot }), + execFileAsync('git', ['rev-parse', 'origin/dev'], { cwd: repositoryRoot }), + execFileAsync('git', ['status', '--porcelain'], { cwd: repositoryRoot }), + execFileAsync('git', ['rev-parse', '--path-format=absolute', '--git-dir'], { + cwd: repositoryRoot, + }), + execFileAsync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], { + cwd: repositoryRoot, + }), + ]) + if (gitDirectory.stdout.trim() === commonDirectory.stdout.trim()) { + throw new Error('start requires an isolated linked Git worktree') + } + if (branch.stdout.trim() !== `codex/issue-${issueNumber}`) { + throw new Error(`start requires branch codex/issue-${issueNumber}`) + } + if (head.stdout.trim() !== baseSha || originDev.stdout.trim() !== baseSha) { + throw new Error('baseSha must equal the current origin/dev and worktree HEAD') + } + if (status.stdout.trim()) throw new Error('start requires a clean isolated worktree') +} + export async function startRun({ loopRoot = DEFAULT_LOOP_ROOT, issueNumber, @@ -81,6 +113,7 @@ export async function startRun({ claimIssue = defaultClaimIssue, releaseIssueClaim = defaultReleaseIssueClaim, verifyAutomationIdentity = assertAutomationIdentity, + workspaceValidator = defaultStartWorkspaceValidator, } = {}) { const evolve = await readJson(path.join(loopRoot, 'evolve', 'metrics.json')) if (evolve.evolveDue) { @@ -93,6 +126,7 @@ export async function startRun({ if (!/^[0-9a-f]{40}$/i.test(normalizedBaseSha)) { throw new Error('baseSha must be a full Git SHA') } + await workspaceValidator({ loopRoot, issueNumber: issue, baseSha: normalizedBaseSha }) const runId = makeRunId({ issueNumber: issue, now, entropy }) const runPath = runDirectory(loopRoot, runId) if (await pathExists(runPath)) throw new Error(`run already exists: ${runId}`) @@ -127,6 +161,7 @@ export async function startRun({ await verifyAutomationIdentity({ loopRoot, githubApi }) } const snapshot = await claimIssue({ + loopRoot, issueUrl: url, issueNumber: issue, branch: `codex/issue-${issue}`, @@ -151,7 +186,7 @@ export async function startRun({ await rm(claimDirectory, { recursive: true, force: true }) if (remoteClaimCreated) { try { - await releaseIssueClaim({ issueUrl: url, issueNumber: issue, githubApi }) + await releaseIssueClaim({ loopRoot, issueUrl: url, issueNumber: issue, githubApi }) } catch (rollbackError) { throw new AggregateError( [error, rollbackError], @@ -273,17 +308,32 @@ function parseFrozenBrief(source) { if (requiredChecks.length === 0) { throw new Error('implementation brief requires at least one targeted check') } + if (requiredChecks.every((command) => /^pnpm verify(?:\s|$)/.test(command))) { + throw new Error('implementation brief requires a targeted check in addition to pnpm verify') + } return { sections, requiredChecks } } -export async function freezeBrief({ loopRoot = DEFAULT_LOOP_ROOT, runId, now = new Date() } = {}) { +export async function freezeBrief({ + loopRoot = DEFAULT_LOOP_ROOT, + runId, + now = new Date(), + githubApi = defaultGitHubApi, + checkpointVerifier = verifyLatestDurableCheckpoint, +} = {}) { const normalizedRunId = assertRunId(runId) const runFile = path.join(runDirectory(loopRoot, normalizedRunId), 'run.json') const run = await readJson(runFile) if (run.status !== 'running' || run.finishedAt !== null || run.briefDigest) { throw new Error('brief can only be frozen once for a running run') } - assertLatestDurableCheckpoint(await readEvents(loopRoot, normalizedRunId), 'freeze-brief') + await checkpointVerifier({ + loopRoot, + runId: normalizedRunId, + events: await readEvents(loopRoot, normalizedRunId), + operation: 'freeze-brief', + githubApi, + }) const briefPath = path.join(loopRoot, 'handoffs', normalizedRunId, 'implementation-brief.md') const source = await readFile(briefPath, 'utf8') const { sections } = parseFrozenBrief(source) @@ -350,6 +400,8 @@ export async function recordImplementation({ resultPath, now = new Date(), commitRangeValidator = defaultCommitRangeValidator, + githubApi = defaultGitHubApi, + checkpointVerifier = verifyLatestDurableCheckpoint, } = {}) { const normalizedRunId = assertRunId(runId) const runFile = path.join(runDirectory(loopRoot, normalizedRunId), 'run.json') @@ -400,7 +452,13 @@ export async function recordImplementation({ throw new Error('$implement must produce a new commit') } const events = await readEvents(loopRoot, normalizedRunId) - assertLatestDurableCheckpoint(events, 'record-implementation') + await checkpointVerifier({ + loopRoot, + runId: normalizedRunId, + events, + operation: 'record-implementation', + githubApi, + }) const relativeResultPath = path.relative(loopRoot, resolvedResultPath) if ( events.some( @@ -446,6 +504,7 @@ export async function recordPullRequest({ now = new Date(), githubApi = defaultGitHubApi, trailingPathValidator = defaultTrailingPathValidator, + checkpointVerifier = verifyLatestDurableCheckpoint, } = {}) { const normalizedRunId = assertRunId(runId) const runFile = path.join(runDirectory(loopRoot, normalizedRunId), 'run.json') @@ -457,7 +516,13 @@ export async function recordPullRequest({ throw new Error('record-pr requires a frozen brief and recorded $implement result') } await assertFrozenBriefUnchanged(loopRoot, run) - assertLatestDurableCheckpoint(await readEvents(loopRoot, normalizedRunId), 'record-pr') + await checkpointVerifier({ + loopRoot, + runId: normalizedRunId, + events: await readEvents(loopRoot, normalizedRunId), + operation: 'record-pr', + githubApi, + }) if (!/^[0-9a-f]{40}$/i.test(headSha)) { throw new Error('record-pr requires a full head SHA') } @@ -469,9 +534,13 @@ export async function recordPullRequest({ const livePullRequest = await githubApi( `repos/${pullTarget.owner}/${pullTarget.repo}/pulls/${pullTarget.number}`, ) + const channel = await readJson( + path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'), + ) const isInitialBinding = run.prUrl === null if ( livePullRequest.state !== 'open' || + !sameGitHubLogin(livePullRequest.user?.login, channel.automationGitHubLogin) || (isInitialBinding && livePullRequest.draft !== true) || livePullRequest.base?.ref !== 'dev' || livePullRequest.head?.ref !== run.branch || @@ -585,31 +654,6 @@ export async function readEvents(loopRoot, runId) { .map((line) => JSON.parse(line)) } -export function assertLatestDurableCheckpoint(events, operation) { - const latestPhaseEvent = events.findLast((event) => event.type !== 'checkpoint_published') - const checkpoint = events.findLast( - (event) => - event.type === 'checkpoint_published' && - event.status === 'published' && - event.payload?.checkpointUpdatedAt === latestPhaseEvent?.timestamp, - ) - if (!latestPhaseEvent || !checkpoint) { - throw new Error(`${operation} requires a durable checkpoint for the latest run phase`) - } - return checkpoint -} - -function hasPassedEventForHead(events, type, headSha) { - return events.some( - (event) => - event.type === type && - event.payload?.headSha === headSha && - (event.status === 'passed' || - event.payload?.verdict === 'passed' || - event.payload?.verdict === 'PASS'), - ) -} - async function ensureFinalizationArtifacts({ loopRoot, run, @@ -687,6 +731,7 @@ async function ensureIssueClaimReleased({ loopRoot, run, githubApi, releaseIssue ) if (!alreadyReleased) { await releaseIssueClaim({ + loopRoot, issueUrl: run.issueUrl, issueNumber: run.issueNumber, githubApi, @@ -707,6 +752,50 @@ async function ensureIssueClaimReleased({ loopRoot, run, githubApi, releaseIssue return run } +async function verifyRunFinalizationPublication({ + loopRoot, + run, + events, + status, + mergeSha, + failureFingerprint, + githubApi, +}) { + const resultPath = path.join(runDirectory(loopRoot, run.runId), 'finalization-result.json') + if (!(await pathExists(resultPath))) { + throw new Error(`${status} requires a matching durable finalization record`) + } + const record = validateFinalizationRecord(await readJson(resultPath), run) + if ( + record.status !== status || + record.mergeSha !== (mergeSha ?? run.mergeSha ?? null) || + record.failureFingerprint !== (failureFingerprint ?? null) + ) { + throw new Error(`${status} finalization record does not match the requested transition`) + } + const digest = finalizationRecordDigest(record) + const publication = events.findLast( + (event) => + event.type === 'finalization_published' && + event.status === status && + event.payload?.digest === digest && + event.payload?.finishedAt === record.finishedAt && + event.payload?.mergeSha === record.mergeSha && + event.payload?.failureFingerprint === record.failureFingerprint, + ) + if (!publication?.payload?.commentUrl) { + throw new Error(`${status} requires a matching durable finalization journal publication`) + } + await verifyPublishedFinalization({ + loopRoot, + record, + commentUrl: publication.payload.commentUrl, + expectedHeadBranch: run.branch, + githubApi, + }) + return { record, publication } +} + export async function transitionRun({ loopRoot = DEFAULT_LOOP_ROOT, runId, @@ -719,6 +808,7 @@ export async function transitionRun({ githubApi = defaultGitHubApi, releaseIssueClaim = defaultReleaseIssueClaim, skipCheckpointGate = false, + checkpointVerifier = verifyLatestDurableCheckpoint, } = {}) { const normalizedRunId = assertRunId(runId) if (!RUN_STATUSES.has(status)) throw new Error(`invalid run status: ${status}`) @@ -737,18 +827,15 @@ export async function transitionRun({ if (!TERMINAL_STATUSES.has(status) || status !== run.status || !authorization) { throw new Error(`run is already finalized: ${normalizedRunId}`) } - if (run.status === 'completed') { - const remoteMerge = await observeOwnerApprovedMerge({ - loopRoot, - prUrl: run.prUrl, - expectedHeadSha: run.headSha, - expectedHeadBranch: run.branch, - githubApi, - }) - if (remoteMerge.mergeSha !== run.mergeSha) { - throw new Error('finalized mergeSha does not match the remote owner merge') - } - } + await verifyRunFinalizationPublication({ + loopRoot, + run, + events: existingEvents, + status, + mergeSha: run.mergeSha, + failureFingerprint: authorization.payload.failureFingerprint ?? null, + githubApi, + }) const finalized = await ensureFinalizationArtifacts({ loopRoot, run, @@ -767,7 +854,13 @@ export async function transitionRun({ if (run.status === status) throw new Error(`run already has status: ${status}`) const events = await readEvents(loopRoot, normalizedRunId) if (!skipCheckpointGate && !TERMINAL_STATUSES.has(status)) { - assertLatestDurableCheckpoint(events, `transition to ${status}`) + await checkpointVerifier({ + loopRoot, + runId: normalizedRunId, + events, + operation: `transition to ${status}`, + githubApi, + }) } if (!ALLOWED_TRANSITIONS.get(run.status)?.has(status)) { throw new Error(`invalid run status transition: ${run.status} -> ${status}`) @@ -804,10 +897,22 @@ export async function transitionRun({ ) { throw new Error('awaiting_owner_review requires a PR in the issue repository') } - if (!hasPassedEventForHead(events, 'verification_completed', headSha)) { + const verificationEvent = events.findLast( + (event) => + event.type === 'verification_completed' && + event.status === 'passed' && + event.payload?.headSha === headSha, + ) + if (!verificationEvent) { throw new Error('awaiting_owner_review requires passed verification_completed for headSha') } - if (!hasPassedEventForHead(events, 'review_completed', headSha)) { + const reviewEvent = events.findLast( + (event) => + event.type === 'review_completed' && + event.status === 'passed' && + event.payload?.headSha === headSha, + ) + if (!reviewEvent) { throw new Error('awaiting_owner_review requires passed review_completed for headSha') } const ownerNotification = events.findLast( @@ -827,16 +932,31 @@ export async function transitionRun({ const livePullRequest = await githubApi( `repos/${pullRequestTarget.owner}/${pullRequestTarget.repo}/pulls/${pullRequestTarget.number}`, ) + const channel = await readJson( + path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'), + ) + const evidenceSection = briefSection(livePullRequest.body ?? '', 'Evidence') + const reviewSection = briefSection(livePullRequest.body ?? '', 'Independent review') + const bodyHasExactProof = + evidenceSection.includes(verificationEvent.payload.manifestUrl) && + reviewSection.includes(reviewEvent.payload.reviewUrl) && + !/\bpending\b/i.test(`${evidenceSection}\n${reviewSection}`) && + (!run.uiEvidenceRequired || + livePullRequest.body?.includes(`screen-shots/${normalizedRunId}/`)) if ( livePullRequest.state !== 'open' || livePullRequest.draft !== false || + !sameGitHubLogin(livePullRequest.user?.login, channel.automationGitHubLogin) || livePullRequest.base?.ref !== 'dev' || livePullRequest.head?.ref !== run.branch || livePullRequest.head?.repo?.full_name?.toLowerCase() !== `${pullRequestTarget.owner}/${pullRequestTarget.repo}`.toLowerCase() || - livePullRequest.head?.sha !== headSha + livePullRequest.head?.sha !== headSha || + !bodyHasExactProof ) { - throw new Error('awaiting_owner_review requires a live ready PR to dev at the exact headSha') + throw new Error( + 'awaiting_owner_review requires an automation-authored live PR with exact-head evidence and review links', + ) } } @@ -906,23 +1026,22 @@ export async function transitionRun({ ) } } - const finalizationPublication = TERMINAL_STATUSES.has(status) - ? events.findLast( - (event) => - event.type === 'finalization_published' && - event.status === status && - event.payload?.mergeSha === (mergeSha ?? run.mergeSha ?? null) && - event.payload?.failureFingerprint === (failureFingerprint ?? null), - ) + const finalizationProof = TERMINAL_STATUSES.has(status) + ? await verifyRunFinalizationPublication({ + loopRoot, + run, + events, + status, + mergeSha, + failureFingerprint, + githubApi, + }) : null - if (TERMINAL_STATUSES.has(status) && !finalizationPublication) { - throw new Error(`${status} requires a matching durable finalization journal publication`) - } const transitioned = { ...run, status, - finishedAt: TERMINAL_STATUSES.has(status) ? finalizationPublication.payload.finishedAt : null, + finishedAt: TERMINAL_STATUSES.has(status) ? finalizationProof.record.finishedAt : null, prUrl: prUrl ?? run.prUrl, headSha: headSha ?? run.headSha, mergeSha: mergeSha ?? run.mergeSha, diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index 0d94edeb..7600ba99 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -15,32 +15,55 @@ import { canonicalRecord, checkpointDigest, completeEvolve, - createNotification, + createNotification as runtimeCreateNotification, defaultClaimIssue, detectWork, finalizeRun, - freezeBrief, + freezeBrief as runtimeFreezeBrief, getEvolveStatus, observeOwnerMerge, prepareActiveCheckpoint, - prepareFinalizationRecord, + prepareFinalizationRecord as runtimePrepareFinalizationRecord, reconcileActiveJournal, reconcileFinalizationJournal, - recordEvidence, + recordEvidence as runtimeRecordEvidence, recordDigest, recordFinalizationPublication, recordActiveCheckpointPublication, - recordImplementation, - recordOwnerResponse, - recordPullRequest, - recordReview, + recordImplementation as runtimeRecordImplementation, + recordOwnerResponse as runtimeRecordOwnerResponse, + recordPullRequest as runtimeRecordPullRequest, + recordReview as runtimeRecordReview, restoreActiveCheckpoint, selectIssue, startRun, - transitionRun, + transitionRun as runtimeTransitionRun, validateLoop, } from '../scripts/runtime.mjs' +const bypassCheckpointVerifier = async () => {} +const createNotification = (options) => + runtimeCreateNotification({ ...options, checkpointVerifier: bypassCheckpointVerifier }) +const freezeBrief = (options) => + runtimeFreezeBrief({ ...options, checkpointVerifier: bypassCheckpointVerifier }) +const prepareFinalizationRecord = (options) => + runtimePrepareFinalizationRecord({ + ...options, + checkpointVerifier: bypassCheckpointVerifier, + }) +const recordEvidence = (options) => + runtimeRecordEvidence({ ...options, checkpointVerifier: bypassCheckpointVerifier }) +const recordImplementation = (options) => + runtimeRecordImplementation({ ...options, checkpointVerifier: bypassCheckpointVerifier }) +const recordOwnerResponse = (options) => + runtimeRecordOwnerResponse({ ...options, checkpointVerifier: bypassCheckpointVerifier }) +const recordPullRequest = (options) => + runtimeRecordPullRequest({ ...options, checkpointVerifier: bypassCheckpointVerifier }) +const recordReview = (options) => + runtimeRecordReview({ ...options, checkpointVerifier: bypassCheckpointVerifier }) +const transitionRun = (options) => + runtimeTransitionRun({ ...options, checkpointVerifier: bypassCheckpointVerifier }) + const testDirectory = path.dirname(fileURLToPath(import.meta.url)) const repositoryLoopRoot = path.resolve(testDirectory, '..') const execFileAsync = promisify(execFile) @@ -119,6 +142,7 @@ async function startFixtureRun(options) { return startRun({ ...options, baseSha: options.baseSha ?? '0'.repeat(40), + workspaceValidator: options.workspaceValidator ?? (async () => {}), claimIssue: async () => ({ number: options.issueNumber, title: options.issueTitle, @@ -157,6 +181,7 @@ function pullRequestFixture(run, headSha, { draft = true, merged = false } = {}) merged, merged_by: merged ? { login: 'codeacme17' } : null, merge_commit_sha: merged ? '9'.repeat(40) : null, + user: { login: 'echo-ui-loop[bot]' }, base: { ref: 'dev', repo: { full_name: 'codeacme17/echo-ui' } }, head: { ref: run.branch, sha: headSha, repo: { full_name: 'codeacme17/echo-ui' } }, body: [ @@ -338,7 +363,6 @@ async function writePassingReview({ loopRoot, run, headSha, prNumber = 200, revi { round: 1, headSha, - reviewUrl: 'https://github.com/codeacme17/echo-ui/pull/302#pullrequestreview-500', reviewUrl: `https://github.com/codeacme17/echo-ui/pull/${prNumber}#pullrequestreview-${reviewId}`, verdict: 'PASS', findings: [], @@ -463,6 +487,19 @@ test('selectIssue chooses the highest-priority eligible unclaimed issue', () => assert.equal(result.hasWork, true) assert.equal(result.issue.number, 13) + assert.equal( + selectIssue({ + issues: [ + { + number: 13, + title: 'Reserved branch', + labels: [{ name: 'codex-ready' }], + }, + ], + branchNames: ['codex/issue-13'], + }).hasWork, + false, + ) }) test('detectWork records a durable no-work trigger check without waking an executor', async () => { @@ -497,6 +534,7 @@ test('authoritative claim rejects any paginated open PR that references the issu githubPaginatedApi: async () => [ { head: { ref: 'feature/other' }, title: 'Existing fix', body: 'Closes #128' }, ], + remoteBranchExists: async () => false, addLabel: async () => { labelAdded = true }, @@ -504,10 +542,31 @@ test('authoritative claim rejects any paginated open PR that references the issu /already claims issue 128/, ) assert.equal(labelAdded, false) + + await assert.rejects( + defaultClaimIssue({ + issueUrl: 'https://github.com/codeacme17/echo-ui/issues/128', + issueNumber: 128, + githubApi: async () => ({ + number: 128, + title: 'Issue', + state: 'open', + labels: [{ name: 'codex-ready' }], + }), + githubPaginatedApi: async () => [], + remoteBranchExists: async () => true, + addLabel: async () => { + labelAdded = true + }, + }), + /remote branch codex\/issue-128 already exists/, + ) + assert.equal(labelAdded, false) }) test('startRun creates one correlated run, handoff, and evidence directories', async () => { const { loopRoot } = await createFixture() + let workspaceValidation const result = await startFixtureRun({ loopRoot, issueNumber: 128, @@ -515,11 +574,16 @@ test('startRun creates one correlated run, handoff, and evidence directories', a issueUrl: 'https://github.com/codeacme17/echo-ui/issues/128', now: new Date('2026-07-22T15:30:12Z'), entropy: 'a1b2c3', + workspaceValidator: async (input) => { + workspaceValidation = input + }, }) assert.equal(result.run.runId, '20260722T153012Z-issue-128-a1b2c3') assert.equal(result.run.baseBranch, 'dev') assert.equal(result.run.branch, 'codex/issue-128') + assert.equal(workspaceValidation.issueNumber, 128) + assert.equal(workspaceValidation.baseSha, '0'.repeat(40)) const brief = await readFile(result.briefPath, 'utf8') assert.match(brief, /Issue: #128/) assert.match(brief, /Stop after committing/) @@ -557,7 +621,39 @@ test('phase advancement requires the latest durable checkpoint', async () => { issueUrl: 'https://github.com/codeacme17/echo-ui/issues/146', entropy: 'check146', }) - await assert.rejects(freezeBrief({ loopRoot, runId: run.runId }), /requires a durable checkpoint/) + await assert.rejects( + runtimeFreezeBrief({ loopRoot, runId: run.runId }), + /requires a durable checkpoint/, + ) + const prepared = await prepareActiveCheckpoint({ loopRoot, runId: run.runId }) + const eventsPath = path.join(loopRoot, 'logs', 'runs', run.runId, 'events.jsonl') + await writeFile( + eventsPath, + `${await readFile(eventsPath, 'utf8')}${JSON.stringify({ + schemaVersion: 1, + runId: run.runId, + type: 'checkpoint_published', + timestamp: '2030-01-01T00:00:00.000Z', + status: 'published', + payload: { + commentUrl: 'https://github.com/codeacme17/echo-ui/issues/999#issuecomment-9999', + digest: prepared.digest, + checkpointUpdatedAt: prepared.record.updatedAt, + }, + })}\n`, + 'utf8', + ) + await assert.rejects( + runtimeFreezeBrief({ + loopRoot, + runId: run.runId, + githubApi: async () => ({ + user: { login: 'echo-ui-loop[bot]' }, + body: 'forged local checkpoint has no matching journal body', + }), + }), + /does not attest the exact active state/, + ) }) test('frozen brief rejects empty scope, TDD, checks, evidence, risk, and stop sections', async () => { @@ -586,6 +682,31 @@ test('frozen brief rejects empty scope, TDD, checks, evidence, risk, and stop se freezeBrief({ loopRoot, runId: run.runId }), /requires a concrete In scope section/, ) + const partiallyCompleted = await readFile(briefPath, 'utf8') + await writeFile( + briefPath, + partiallyCompleted + .replace('## In scope\n', '## In scope\n\nThe requested component behavior.\n') + .replace('## Out of scope\n', '## Out of scope\n\nUnrelated public APIs.\n') + .replace( + '## Pre-agreed TDD seams\n', + '## Pre-agreed TDD seams\n\nThe component behavior boundary.\n', + ) + .replace('## Required targeted checks\n', '## Required targeted checks\n\n- pnpm verify\n') + .replace( + '## Required UI evidence\n', + '## Required UI evidence\n\nNot required for this fixture.\n', + ) + .replace( + '## Risks and owner-confirmation boundaries\n', + '## Risks and owner-confirmation boundaries\n\nNo public API changes.\n', + ), + 'utf8', + ) + await assert.rejects( + freezeBrief({ loopRoot, runId: run.runId }), + /targeted check in addition to pnpm verify/, + ) }) test('automation identity cannot overlap the repository owner', async () => { @@ -614,6 +735,7 @@ test('startRun rolls back a remote claim when the authoritative snapshot is inva issueUrl: 'https://github.com/codeacme17/echo-ui/issues/128', baseSha: '0'.repeat(40), entropy: 'rollback1', + workspaceValidator: async () => {}, claimIssue: async () => ({ number: 999, title: 'Wrong issue', @@ -819,6 +941,22 @@ test('recordPullRequest rejects empty review sections even when metadata markers const prUrl = await recordFixturePr({ loopRoot, run, headSha: originalHead, number: 349 }) const nextHead = '4'.repeat(40) const incomplete = pullRequestFixture(run, nextHead, { draft: false }) + const ownerAuthored = pullRequestFixture(run, nextHead, { draft: false }) + ownerAuthored.user = { login: 'codeacme17' } + await assert.rejects( + recordPullRequest({ + loopRoot, + runId: run.runId, + prUrl, + headSha: nextHead, + githubApi: async (endpoint) => + endpoint.includes('/compare/') + ? { status: 'ahead', base_commit: { sha: '1'.repeat(40) } } + : ownerAuthored, + trailingPathValidator: async () => {}, + }), + /record-pr requires a live PR/, + ) incomplete.body = incomplete.body.replace( '## Evidence\nExact-head workflow evidence is attached or pending for this draft.', '## Evidence\n', @@ -996,6 +1134,17 @@ test('owner-ready transition requires verification and review but remains resuma }) await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) + const ownerReadyPullRequest = pullRequestFixture(run, headSha, { draft: false }) + ownerReadyPullRequest.body = ownerReadyPullRequest.body + .replace( + 'Exact-head workflow evidence is attached or pending for this draft.', + 'https://github.com/codeacme17/echo-ui/actions/runs/101/artifacts/201', + ) + .replace( + 'Fresh-context review is attached or pending for this draft.', + `${prUrl}#pullrequestreview-300`, + ) + await assert.rejects( transitionRun({ loopRoot, @@ -1019,6 +1168,18 @@ test('owner-ready transition requires verification and review but remains resuma }) await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) + await assert.rejects( + transitionRun({ + loopRoot, + runId: run.runId, + status: 'awaiting_owner_review', + prUrl, + headSha, + githubApi: async () => pullRequestFixture(run, headSha, { draft: false }), + }), + /exact-head evidence and review links/, + ) + await assert.rejects( transitionRun({ loopRoot, @@ -1027,13 +1188,11 @@ test('owner-ready transition requires verification and review but remains resuma prUrl, headSha, githubApi: async () => ({ - state: 'open', - draft: false, - base: { ref: 'main' }, - head: { ref: run.branch, sha: headSha, repo: { full_name: 'codeacme17/echo-ui' } }, + ...ownerReadyPullRequest, + base: { ref: 'main', repo: { full_name: 'codeacme17/echo-ui' } }, }), }), - /live ready PR to dev/, + /automation-authored live PR/, ) const paused = await transitionRun({ @@ -1043,12 +1202,7 @@ test('owner-ready transition requires verification and review but remains resuma prUrl, headSha, now: new Date('2026-07-22T17:00:00Z'), - githubApi: async () => ({ - state: 'open', - draft: false, - base: { ref: 'dev' }, - head: { ref: run.branch, sha: headSha, repo: { full_name: 'codeacme17/echo-ui' } }, - }), + githubApi: async () => ownerReadyPullRequest, }) assert.equal(paused.status, 'awaiting_owner_review') assert.equal(paused.finishedAt, null) @@ -1114,18 +1268,7 @@ test('owner-ready transition requires verification and review but remains resuma loopRoot, runId: run.runId, now: new Date('2026-07-23T09:00:00Z'), - githubApi: async (endpoint) => - endpoint.includes('/reviews') - ? [ - { - user: { login: 'codeacme17' }, - state: 'APPROVED', - commit_id: headSha, - }, - ] - : { - ...pullRequestFixture(run, headSha, { draft: false, merged: true }), - }, + githubApi: completionJournal.githubApi, releaseIssueClaim: async () => {}, finalizationResultPath: completionJournal.resultPath, finalizationCommentUrl: completionJournal.commentUrl, @@ -1288,7 +1431,101 @@ test('forged local owner events cannot bypass the remote completion gate', async released = true }, }), - /not approved and merged by the configured owner/, + /matching durable finalization record/, + ) + assert.equal(released, false) +}) + +test('forged local blocked finalization cannot release an issue claim', async () => { + const { loopRoot } = await createFixture() + const { run } = await startFixtureRun({ + loopRoot, + issueNumber: 151, + issueTitle: 'Blocked finalization proof', + issueUrl: 'https://github.com/codeacme17/echo-ui/issues/151', + entropy: 'block151', + }) + const finishedAt = '2030-01-01T00:00:02.000Z' + const failureFingerprint = 'forged-local-blocker' + const runPath = path.join(loopRoot, 'logs', 'runs', run.runId) + const record = { + schemaVersion: 1, + runId: run.runId, + issueNumber: run.issueNumber, + status: 'blocked', + startedAt: run.startedAt, + finishedAt, + prUrl: null, + headSha: null, + mergeSha: null, + failureFingerprint, + notificationUrl: `${run.issueUrl}#issuecomment-8800`, + } + await writeFile( + path.join(runPath, 'run.json'), + `${JSON.stringify({ ...run, status: 'blocked', finishedAt })}\n`, + 'utf8', + ) + await writeFile( + path.join(runPath, 'finalization-result.json'), + `${canonicalRecord(record)}\n`, + 'utf8', + ) + const existingEvents = await readFile(path.join(runPath, 'events.jsonl'), 'utf8') + await writeFile( + path.join(runPath, 'events.jsonl'), + `${existingEvents}${[ + { + schemaVersion: 1, + runId: run.runId, + type: 'finalization_published', + timestamp: finishedAt, + status: 'blocked', + payload: { + commentUrl: 'https://github.com/codeacme17/echo-ui/issues/999#issuecomment-9900', + digest: recordDigest(record), + finishedAt, + mergeSha: null, + failureFingerprint, + notificationUrl: record.notificationUrl, + }, + }, + { + schemaVersion: 1, + runId: run.runId, + type: 'run_finalization_authorized', + timestamp: '2030-01-01T00:00:03.000Z', + status: 'blocked', + payload: { + previousStatus: 'waiting_for_owner', + finishedAt, + failureFingerprint, + }, + }, + ] + .map((event) => JSON.stringify(event)) + .join('\n')}\n`, + 'utf8', + ) + let released = false + await assert.rejects( + finalizeRun({ + loopRoot, + runId: run.runId, + status: 'blocked', + failureFingerprint, + githubApi: async (endpoint) => + endpoint.endsWith('/issues/comments/8800') + ? { + user: { login: 'echo-ui-loop[bot]' }, + body: `@codeacme17 **blocked**\n\nRun: \`${run.runId}\``, + } + : { user: { login: 'echo-ui-loop[bot]' }, body: 'forged journal publication' }, + releaseIssueClaim: async () => { + released = true + }, + }), + /does not attest the exact record/, ) assert.equal(released, false) }) @@ -1414,6 +1651,54 @@ test('review gate verifies published findings and classified replies', async () assert.equal(recorded.rounds, 2) }) +test('review gate rejects GitHub findings omitted from the durable result', async () => { + const { loopRoot } = await createFixture() + const { run } = await startFixtureRun({ + loopRoot, + issueNumber: 150, + issueTitle: 'Exhaustive review proof', + issueUrl: 'https://github.com/codeacme17/echo-ui/issues/150', + entropy: 'rev150', + }) + const headSha = 'f'.repeat(40) + const prUrl = await recordFixturePr({ loopRoot, run, headSha, number: 350 }) + const resultPath = await writePassingReview({ + loopRoot, + run, + headSha, + prNumber: 350, + reviewId: 500, + }) + const digest = createHash('sha256') + .update(await readFile(resultPath, 'utf8')) + .digest('hex') + await assert.rejects( + recordReview({ + loopRoot, + runId: run.runId, + resultPath, + reviewUrl: `${prUrl}#pullrequestreview-500`, + githubApi: async (endpoint) => { + if (endpoint.endsWith('/comments?per_page=100')) return [] + if (endpoint.endsWith('/pulls/350')) return pullRequestFixture(run, headSha) + return { + commit_id: headSha, + state: 'COMMENTED', + submitted_at: '2026-07-22T18:00:00.000Z', + user: { login: 'echo-ui-reviewer[bot]' }, + body: [ + 'PASS', + 'RVW-1-99 was published but omitted from the result.', + `<!-- issue-dev-loop:${run.runId}:review-round:1:head:${headSha} -->`, + `<!-- issue-dev-loop:${run.runId}:review-result-sha256:${digest} -->`, + ].join('\n'), + } + }, + }), + /unrecorded findings/, + ) +}) + test('accepted review fix must be after the finding head and inside the final head', async () => { const { loopRoot } = await createFixture() const { run } = await startFixtureRun({ @@ -1783,6 +2068,15 @@ test('failed blocking delivery still pauses for the owner', async () => { now: new Date('2030-01-01T00:01:00.000Z'), githubComment: async () => {}, }) + await assert.rejects( + runtimeRecordOwnerResponse({ + loopRoot, + runId: run.runId, + responseUrl, + }), + /requires a durable checkpoint/, + ) + await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) await assert.rejects( recordOwnerResponse({ loopRoot, @@ -1930,13 +2224,14 @@ test('three matching failures make a fresh evolve session due', async () => { assert.notEqual(persisted.finishedAt, null) }, } - await publishFixtureFinalization({ + const durableFinalization = await publishFixtureFinalization({ loopRoot, runId: run.runId, status: 'blocked', finishedAt: `2030-01-01T00:0${issueNumber - 200}:00.000Z`, failureFingerprint: 'browser-environment-unavailable', }) + finalizationOptions.githubApi = durableFinalization.githubApi await finalizeRun(finalizationOptions) if (issueNumber === 201) await finalizeRun(finalizationOptions) } @@ -1976,12 +2271,36 @@ test('fresh worktrees rebuild finalization history and evolve metrics from GitHu notificationUrl: 'https://github.com/codeacme17/echo-ui/issues/205#issuecomment-8802', } const digest = recordDigest(record) + const foreignRecord = { + ...record, + notificationUrl: 'https://github.com/another/repository/issues/205#issuecomment-8802', + } + const foreignDigest = recordDigest(foreignRecord) + await assert.rejects( + reconcileFinalizationJournal({ + loopRoot, + githubPaginatedApi: async () => [ + { + user: { login: 'echo-ui-loop[bot]' }, + html_url: 'https://github.com/codeacme17/echo-ui/issues/999#issuecomment-9900', + body: [ + `<!-- issue-dev-loop:finalization:${record.runId}:sha256:${foreignDigest} -->`, + '```json', + canonicalRecord(foreignRecord), + '```', + ].join('\n'), + }, + ], + }), + /configured run issue or PR/, + ) const result = await reconcileFinalizationJournal({ loopRoot, now: new Date('2026-07-22T14:00:00.000Z'), githubPaginatedApi: async () => [ { user: { login: 'echo-ui-loop[bot]' }, + html_url: 'https://github.com/codeacme17/echo-ui/issues/999#issuecomment-9901', body: [ `<!-- issue-dev-loop:finalization:${record.runId}:sha256:${digest} -->`, '```json', @@ -1990,10 +2309,21 @@ test('fresh worktrees rebuild finalization history and evolve metrics from GitHu ].join('\n'), }, ], - githubApi: async () => ({ - user: { login: 'echo-ui-loop[bot]' }, - body: `@codeacme17 **blocked**\n\nRun: \`${record.runId}\``, - }), + githubApi: async (endpoint) => + endpoint.endsWith('/issues/comments/8802') + ? { + user: { login: 'echo-ui-loop[bot]' }, + body: `@codeacme17 **blocked**\n\nRun: \`${record.runId}\``, + } + : { + user: { login: 'echo-ui-loop[bot]' }, + body: [ + `<!-- issue-dev-loop:finalization:${record.runId}:sha256:${digest} -->`, + '```json', + canonicalRecord(record), + '```', + ].join('\n'), + }, }) assert.deepEqual(result.durableRunIds, [record.runId]) const history = await readFile(path.join(loopRoot, 'logs', 'index.jsonl'), 'utf8') From 010a040419326fd4a6a0bffa77efe8f4b619097c Mon Sep 17 00:00:00 2001 From: leyoonafr <codeacme17@gmail.com> Date: Thu, 23 Jul 2026 01:15:10 +0800 Subject: [PATCH 11/41] fix: close remote proof review findings --- .../scripts/lib/active-journal.mjs | 1 + .../scripts/lib/checkpoint-proof.mjs | 13 +++++ loops/issue-dev-loop/scripts/lib/evidence.mjs | 20 ++++++- .../issue-dev-loop/scripts/lib/run-store.mjs | 45 ++++++++++++++- loops/issue-dev-loop/tests/runtime.test.mjs | 57 +++++++++++++++++-- 5 files changed, 128 insertions(+), 8 deletions(-) diff --git a/loops/issue-dev-loop/scripts/lib/active-journal.mjs b/loops/issue-dev-loop/scripts/lib/active-journal.mjs index 38ccc8d8..fad562a7 100644 --- a/loops/issue-dev-loop/scripts/lib/active-journal.mjs +++ b/loops/issue-dev-loop/scripts/lib/active-journal.mjs @@ -233,5 +233,6 @@ export async function restoreActiveCheckpoint({ record.briefSource, 'utf8', ) + await writeJson(path.join(runPath, 'checkpoint-result.json'), record) return record.run } diff --git a/loops/issue-dev-loop/scripts/lib/checkpoint-proof.mjs b/loops/issue-dev-loop/scripts/lib/checkpoint-proof.mjs index 465db6ed..6c5b56d4 100644 --- a/loops/issue-dev-loop/scripts/lib/checkpoint-proof.mjs +++ b/loops/issue-dev-loop/scripts/lib/checkpoint-proof.mjs @@ -1,4 +1,5 @@ import { createHash } from 'node:crypto' +import { readFile } from 'node:fs/promises' import path from 'node:path' import { @@ -201,10 +202,22 @@ export async function verifyLatestDurableCheckpoint({ const record = validateCheckpointRecord( await readJson(path.join(runDirectory(loopRoot, normalizedRunId), 'checkpoint-result.json')), ) + const currentRecord = validateCheckpointRecord({ + schemaVersion: 1, + kind: 'active-checkpoint', + run: await readJson(path.join(runDirectory(loopRoot, normalizedRunId), 'run.json')), + briefSource: await readFile( + path.join(loopRoot, 'handoffs', normalizedRunId, 'implementation-brief.md'), + 'utf8', + ), + events: events.filter((event) => event.type !== 'checkpoint_published'), + updatedAt: latestPhaseEvent.timestamp, + }) const digest = checkpointRecordDigest(record) if ( record.run.runId !== normalizedRunId || record.updatedAt !== latestPhaseEvent.timestamp || + canonicalCheckpointRecord(currentRecord) !== canonicalCheckpointRecord(record) || checkpoint.payload?.digest !== digest ) { throw new Error(`${operation} checkpoint event does not match its durable record`) diff --git a/loops/issue-dev-loop/scripts/lib/evidence.mjs b/loops/issue-dev-loop/scripts/lib/evidence.mjs index 56fade23..20654c90 100644 --- a/loops/issue-dev-loop/scripts/lib/evidence.mjs +++ b/loops/issue-dev-loop/scripts/lib/evidence.mjs @@ -391,6 +391,7 @@ export async function recordReview({ } const digestMarker = `<!-- issue-dev-loop:${normalizedRunId}:review-result-sha256:${resultDigest} -->` const publications = new Map() + const priorFindingIds = new Set() let previousSubmittedAt = -Infinity for (const round of reviewSummary.roundDetails) { const roundTarget = parseReviewUrl(round.reviewUrl) @@ -428,11 +429,25 @@ export async function recordReview({ bodies.flatMap((body) => body.match(/\bRVW-[0-9]+-[0-9]+\b/g) ?? []), ) if ( - publishedFindingIds.size !== expectedFindingIds.size || - [...publishedFindingIds].some((findingId) => !expectedFindingIds.has(findingId)) + [...expectedFindingIds].some((findingId) => !publishedFindingIds.has(findingId)) || + [...publishedFindingIds].some( + (findingId) => !expectedFindingIds.has(findingId) && !priorFindingIds.has(findingId), + ) ) { throw new Error(`published GitHub review round ${round.round} has unrecorded findings`) } + for (const comment of roundComments) { + const commentFindingIds = new Set(comment.body?.match(/\bRVW-[0-9]+-[0-9]+\b/g) ?? []) + if ( + !sameGitHubLogin(comment.user?.login, reviewerLogin) || + commentFindingIds.size === 0 || + [...commentFindingIds].some((findingId) => !expectedFindingIds.has(findingId)) + ) { + throw new Error( + `published GitHub review round ${round.round} has an unrecorded reviewer inline comment`, + ) + } + } for (const finding of round.findings) { const findingMarker = `<!-- issue-dev-loop:${normalizedRunId}:${finding.findingId} -->` const requiredFindingFragments = [ @@ -467,6 +482,7 @@ export async function recordReview({ publications.set(round.round, { submittedAt: publishedRound.submitted_at, }) + for (const findingId of expectedFindingIds) priorFindingIds.add(findingId) previousSubmittedAt = submittedAt } const livePullRequest = await githubApi( diff --git a/loops/issue-dev-loop/scripts/lib/run-store.mjs b/loops/issue-dev-loop/scripts/lib/run-store.mjs index 8fea8d8f..2ea6960b 100644 --- a/loops/issue-dev-loop/scripts/lib/run-store.mjs +++ b/loops/issue-dev-loop/scripts/lib/run-store.mjs @@ -935,14 +935,55 @@ export async function transitionRun({ const channel = await readJson( path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'), ) + const verifiedManifest = await readJson( + path.resolve(loopRoot, verificationEvent.payload.manifestPath), + ) + const implementationEvent = events.findLast( + (event) => + event.type === 'implementation_completed' && + event.status === 'passed' && + event.payload?.commitSha === run.implementationCommit, + ) + const implementationResult = await readJson( + path.resolve( + loopRoot, + assertNonEmpty(implementationEvent?.payload?.resultPath, 'implementation result path'), + ), + ) const evidenceSection = briefSection(livePullRequest.body ?? '', 'Evidence') const reviewSection = briefSection(livePullRequest.body ?? '', 'Independent review') + const verificationSection = briefSection(livePullRequest.body ?? '', 'Verification') + const requiredMetadata = [ + `Closes #${run.issueNumber}`, + `<!-- issue-dev-loop:run:${normalizedRunId} -->`, + `Run ID: \`${normalizedRunId}\``, + `Base SHA: \`${run.baseSha}\``, + `Head SHA: \`${headSha}\``, + 'This PR must be reviewed and merged by `@codeacme17`', + ] + const requiredSections = [ + 'Changes', + 'Acceptance criteria', + 'Verification', + 'Evidence', + 'Independent review', + 'Known limitations', + ] + const verificationCommands = implementationResult.checks.map((check) => check.command) + const screenshotPaths = verifiedManifest.screenshots.map((screenshot) => screenshot.path) const bodyHasExactProof = + requiredMetadata.every((fragment) => livePullRequest.body?.includes(fragment)) && + requiredSections.every((heading) => briefSection(livePullRequest.body ?? '', heading)) && evidenceSection.includes(verificationEvent.payload.manifestUrl) && reviewSection.includes(reviewEvent.payload.reviewUrl) && - !/\bpending\b/i.test(`${evidenceSection}\n${reviewSection}`) && + verificationCommands.every((command) => verificationSection.includes(command)) && + /\b(pass|passed|success|succeeded)\b/i.test(verificationSection) && + !/\bpending\b/i.test(`${verificationSection}\n${evidenceSection}\n${reviewSection}`) && (!run.uiEvidenceRequired || - livePullRequest.body?.includes(`screen-shots/${normalizedRunId}/`)) + (screenshotPaths.length > 0 && + screenshotPaths.every((screenshotPath) => + livePullRequest.body?.includes(screenshotPath), + ))) if ( livePullRequest.state !== 'open' || livePullRequest.draft !== false || diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index 7600ba99..d0b435d1 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -196,7 +196,7 @@ function pullRequestFixture(run, headSha, { draft = true, merged = false } = {}) '## Acceptance criteria', 'All frozen acceptance criteria are covered.', '## Verification', - 'Targeted regression test and pnpm verify passed.', + 'Commands passed: `pnpm test -- keyboard`, `pnpm verify`.', '## Evidence', 'Exact-head workflow evidence is attached or pending for this draft.', '## Independent review', @@ -656,6 +656,34 @@ test('phase advancement requires the latest durable checkpoint', async () => { ) }) +test('remote checkpoint proof is rejected when current local state was rewritten', async () => { + const { loopRoot } = await createFixture() + const { run } = await startFixtureRun({ + loopRoot, + issueNumber: 152, + issueTitle: 'Canonical checkpoint state', + issueUrl: 'https://github.com/codeacme17/echo-ui/issues/152', + entropy: 'check152', + }) + const prepared = await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) + await writeFile( + path.join(loopRoot, 'logs', 'runs', run.runId, 'run.json'), + `${JSON.stringify({ ...run, headSha: 'a'.repeat(40) })}\n`, + 'utf8', + ) + await assert.rejects( + runtimeFreezeBrief({ + loopRoot, + runId: run.runId, + githubApi: async () => ({ + user: { login: 'echo-ui-loop[bot]' }, + body: prepared.body, + }), + }), + /checkpoint event does not match its durable record/, + ) +}) + test('frozen brief rejects empty scope, TDD, checks, evidence, risk, and stop sections', async () => { const { loopRoot } = await createFixture() const { run } = await startFixtureRun({ @@ -1599,6 +1627,7 @@ test('review gate verifies published findings and classified replies', async () if (endpoint.endsWith('/reviews/499/comments?per_page=100')) { return [ { + user: { login: 'echo-ui-reviewer[bot]' }, path: 'src/keyboard.ts', line: 12, body: [ @@ -1641,6 +1670,7 @@ test('review gate verifies published findings and classified replies', async () ].join('\n') : [ 'PASS', + 'Resolved RVW-1-1 with the published executor response.', `<!-- issue-dev-loop:${run.runId}:review-round:2:head:${headSha} -->`, `<!-- issue-dev-loop:${run.runId}:review-result-sha256:${digest} -->`, ].join('\n'), @@ -1679,7 +1709,16 @@ test('review gate rejects GitHub findings omitted from the durable result', asyn resultPath, reviewUrl: `${prUrl}#pullrequestreview-500`, githubApi: async (endpoint) => { - if (endpoint.endsWith('/comments?per_page=100')) return [] + if (endpoint.endsWith('/comments?per_page=100')) { + return [ + { + user: { login: 'echo-ui-reviewer[bot]' }, + path: 'src/untracked.ts', + line: 3, + body: 'This inline finding was omitted and has no stable ID.', + }, + ] + } if (endpoint.endsWith('/pulls/350')) return pullRequestFixture(run, headSha) return { commit_id: headSha, @@ -1688,14 +1727,13 @@ test('review gate rejects GitHub findings omitted from the durable result', asyn user: { login: 'echo-ui-reviewer[bot]' }, body: [ 'PASS', - 'RVW-1-99 was published but omitted from the result.', `<!-- issue-dev-loop:${run.runId}:review-round:1:head:${headSha} -->`, `<!-- issue-dev-loop:${run.runId}:review-result-sha256:${digest} -->`, ].join('\n'), } }, }), - /unrecorded findings/, + /unrecorded reviewer inline comment/, ) }) @@ -2381,6 +2419,17 @@ test('fresh worktrees restore active checkpoints and trigger resumable work', as await readFile(path.join(loopRoot, 'logs', 'runs', run.runId, 'run.json'), 'utf8'), ) assert.equal(restored.issueNumber, 206) + await assert.rejects( + runtimeFreezeBrief({ + loopRoot, + runId: run.runId, + githubApi: async () => ({ + user: { login: 'echo-ui-loop[bot]' }, + body: prepared.body, + }), + }), + /requires a concrete Acceptance criteria section/, + ) const detected = await detectWork({ loopRoot, From 2369b949ed8354116ef0e6d1a14c5816d61b379b Mon Sep 17 00:00:00 2001 From: leyoonafr <codeacme17@gmail.com> Date: Thu, 23 Jul 2026 01:30:36 +0800 Subject: [PATCH 12/41] fix: bind review and evidence attestations --- loops/issue-dev-loop/LOOP.md | 2 +- .../scripts/generate-evidence.mjs | 9 +- loops/issue-dev-loop/scripts/lib/evidence.mjs | 15 +- .../issue-dev-loop/scripts/lib/run-store.mjs | 32 ++--- loops/issue-dev-loop/templates/pr-body.md | 2 + loops/issue-dev-loop/tests/runtime.test.mjs | 130 ++++++++++++++++-- 6 files changed, 160 insertions(+), 30 deletions(-) diff --git a/loops/issue-dev-loop/LOOP.md b/loops/issue-dev-loop/LOOP.md index fd602cae..67518d7a 100644 --- a/loops/issue-dev-loop/LOOP.md +++ b/loops/issue-dev-loop/LOOP.md @@ -84,7 +84,7 @@ Run relevant checks and `pnpm verify`. For UI behavior, capture before/after scr ### 6. Create the draft PR -Push the issue branch and create a draft PR targeting `dev`. Immediately bind it to the run with `record-pr`; later evidence and review gates accept only that PR and head. The PR must include the issue, run ID, base/head SHAs, risk, changes, test commands and results, evidence links, screenshots, known limitations, and explicit owner-only merge language. +Push the issue branch and create a draft PR targeting `dev`. Immediately bind it to the run with `record-pr`; later evidence and review gates accept only that PR and head. The PR must include the issue, run ID, base/head SHAs, risk, changes, evidence links, screenshots, known limitations, and explicit owner-only merge language. In `Verification`, bind every manifest check to its own result using the exact line `- \`<exact command>\`: passed (exit code 0)`; a summary-level pass statement is not sufficient. ### 7. Independent review diff --git a/loops/issue-dev-loop/scripts/generate-evidence.mjs b/loops/issue-dev-loop/scripts/generate-evidence.mjs index 4e998eae..485e50d8 100644 --- a/loops/issue-dev-loop/scripts/generate-evidence.mjs +++ b/loops/issue-dev-loop/scripts/generate-evidence.mjs @@ -173,7 +173,14 @@ if ( ) { throw new Error('latest $implement result path is outside the run directory') } -const implementationResult = JSON.parse(await readFile(implementationResultPath, 'utf8')) +const implementationResultSource = await readFile(implementationResultPath, 'utf8') +const implementationResultDigest = createHash('sha256') + .update(implementationResultSource) + .digest('hex') +if (implementationResultDigest !== implementationEvent.payload.resultDigest) { + throw new Error('latest $implement result no longer matches its recorded digest') +} +const implementationResult = JSON.parse(implementationResultSource) const targetedChecks = implementationResult.checks .filter((check) => !/^pnpm verify(?:\s|$)/.test(check.command)) .map((check) => ({ diff --git a/loops/issue-dev-loop/scripts/lib/evidence.mjs b/loops/issue-dev-loop/scripts/lib/evidence.mjs index 20654c90..22055444 100644 --- a/loops/issue-dev-loop/scripts/lib/evidence.mjs +++ b/loops/issue-dev-loop/scripts/lib/evidence.mjs @@ -99,7 +99,7 @@ function validateReviewEvidence(review, headSha) { for (const finding of findings) { findingCount += 1 const findingId = assertNonEmpty(finding.findingId, 'finding.findingId') - if (!/^RVW-[0-9]+-[0-9]+$/.test(findingId) || findingIds.has(findingId)) { + if (!new RegExp(`^RVW-${round.round}-[0-9]+$`).test(findingId) || findingIds.has(findingId)) { throw new Error(`invalid or duplicate finding ID: ${findingId}`) } findingIds.add(findingId) @@ -251,7 +251,14 @@ export async function recordEvidence({ ) { throw new Error('implementation result path is outside the current run') } - const implementationResult = await readJson(implementationResultPath) + const implementationResultSource = await readFile(implementationResultPath, 'utf8') + const implementationResultDigest = createHash('sha256') + .update(implementationResultSource) + .digest('hex') + if (implementationResultDigest !== latestImplementation.payload?.resultDigest) { + throw new Error('$implement result no longer matches its recorded digest') + } + const implementationResult = JSON.parse(implementationResultSource) const expectedCommands = implementationResult.checks.map((check) => check.command) if (expectedCommands.some((command) => !checks.some((check) => check.command === command))) { throw new Error('evidence manifest omits an attested $implement check') @@ -432,7 +439,9 @@ export async function recordReview({ [...expectedFindingIds].some((findingId) => !publishedFindingIds.has(findingId)) || [...publishedFindingIds].some( (findingId) => !expectedFindingIds.has(findingId) && !priorFindingIds.has(findingId), - ) + ) || + (round.round === reviewSummary.rounds && + [...priorFindingIds].some((findingId) => !publishedFindingIds.has(findingId))) ) { throw new Error(`published GitHub review round ${round.round} has unrecorded findings`) } diff --git a/loops/issue-dev-loop/scripts/lib/run-store.mjs b/loops/issue-dev-loop/scripts/lib/run-store.mjs index 2ea6960b..c1c776f6 100644 --- a/loops/issue-dev-loop/scripts/lib/run-store.mjs +++ b/loops/issue-dev-loop/scripts/lib/run-store.mjs @@ -415,7 +415,8 @@ export async function recordImplementation({ if (!resolvedResultPath.startsWith(`${runRoot}${path.sep}`)) { throw new Error('implementation result must be inside the current run directory') } - const result = await readJson(resolvedResultPath) + const resultSource = await readFile(resolvedResultPath, 'utf8') + const result = JSON.parse(resultSource) if ( result.schemaVersion !== 1 || result.runId !== normalizedRunId || @@ -460,6 +461,7 @@ export async function recordImplementation({ githubApi, }) const relativeResultPath = path.relative(loopRoot, resolvedResultPath) + const resultDigest = createHash('sha256').update(resultSource).digest('hex') if ( events.some( (event) => @@ -490,6 +492,7 @@ export async function recordImplementation({ briefDigest: run.briefDigest, commitSha: result.commitSha, resultPath: relativeResultPath, + resultDigest, }, now, }) @@ -935,21 +938,15 @@ export async function transitionRun({ const channel = await readJson( path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'), ) - const verifiedManifest = await readJson( + const verifiedManifestSource = await readFile( path.resolve(loopRoot, verificationEvent.payload.manifestPath), + 'utf8', ) - const implementationEvent = events.findLast( - (event) => - event.type === 'implementation_completed' && - event.status === 'passed' && - event.payload?.commitSha === run.implementationCommit, - ) - const implementationResult = await readJson( - path.resolve( - loopRoot, - assertNonEmpty(implementationEvent?.payload?.resultPath, 'implementation result path'), - ), - ) + const verifiedManifestDigest = createHash('sha256').update(verifiedManifestSource).digest('hex') + if (verifiedManifestDigest !== verificationEvent.payload.manifestDigest) { + throw new Error('awaiting_owner_review evidence manifest no longer matches its digest') + } + const verifiedManifest = JSON.parse(verifiedManifestSource) const evidenceSection = briefSection(livePullRequest.body ?? '', 'Evidence') const reviewSection = briefSection(livePullRequest.body ?? '', 'Independent review') const verificationSection = briefSection(livePullRequest.body ?? '', 'Verification') @@ -969,15 +966,16 @@ export async function transitionRun({ 'Independent review', 'Known limitations', ] - const verificationCommands = implementationResult.checks.map((check) => check.command) + const verificationResults = verifiedManifest.checks.map( + (check) => `- \`${check.command}\`: passed (exit code 0)`, + ) const screenshotPaths = verifiedManifest.screenshots.map((screenshot) => screenshot.path) const bodyHasExactProof = requiredMetadata.every((fragment) => livePullRequest.body?.includes(fragment)) && requiredSections.every((heading) => briefSection(livePullRequest.body ?? '', heading)) && evidenceSection.includes(verificationEvent.payload.manifestUrl) && reviewSection.includes(reviewEvent.payload.reviewUrl) && - verificationCommands.every((command) => verificationSection.includes(command)) && - /\b(pass|passed|success|succeeded)\b/i.test(verificationSection) && + verificationResults.every((result) => verificationSection.includes(result)) && !/\bpending\b/i.test(`${verificationSection}\n${evidenceSection}\n${reviewSection}`) && (!run.uiEvidenceRequired || (screenshotPaths.length > 0 && diff --git a/loops/issue-dev-loop/templates/pr-body.md b/loops/issue-dev-loop/templates/pr-body.md index 8b9a8693..bf4b2cc0 100644 --- a/loops/issue-dev-loop/templates/pr-body.md +++ b/loops/issue-dev-loop/templates/pr-body.md @@ -15,6 +15,8 @@ Closes #{{ISSUE_NUMBER}} ## Verification +<!-- One line per manifest check: - `<exact command>`: passed (exit code 0) --> + ## Evidence ## Independent review diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index d0b435d1..63b0e7d4 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -196,7 +196,8 @@ function pullRequestFixture(run, headSha, { draft = true, merged = false } = {}) '## Acceptance criteria', 'All frozen acceptance criteria are covered.', '## Verification', - 'Commands passed: `pnpm test -- keyboard`, `pnpm verify`.', + '- `pnpm test -- keyboard`: passed (exit code 0)', + '- `pnpm verify`: passed (exit code 0)', '## Evidence', 'Exact-head workflow evidence is attached or pending for this draft.', '## Independent review', @@ -954,6 +955,38 @@ test('recordEvidence rejects failed workflow runs and mismatched artifact manife }), /does not match the published artifact manifest/, ) + + const implementationEvent = ( + await readFile(path.join(loopRoot, 'logs', 'runs', run.runId, 'events.jsonl'), 'utf8') + ) + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) + .findLast((event) => event.type === 'implementation_completed') + const implementationResultPath = path.resolve(loopRoot, implementationEvent.payload.resultPath) + const implementationResultSource = await readFile(implementationResultPath, 'utf8') + const mutatedImplementationResult = JSON.parse(implementationResultSource) + mutatedImplementationResult.checks.push({ command: 'pnpm test -- injected', status: 'passed' }) + await writeFile( + implementationResultPath, + `${JSON.stringify(mutatedImplementationResult)}\n`, + 'utf8', + ) + await assert.rejects( + recordEvidence({ + loopRoot, + runId: run.runId, + manifestPath, + publicationUrl: 'https://github.com/codeacme17/echo-ui/actions/runs/800/artifacts/900', + githubApi: async (endpoint) => + endpoint.includes('/actions/artifacts/') + ? artifact + : successfulWorkflowRun(run, headSha, 301, 800), + artifactManifestLoader: async () => manifestSource, + }), + /\$implement result no longer matches its recorded digest/, + ) + await writeFile(implementationResultPath, implementationResultSource, 'utf8') }) test('recordPullRequest rejects empty review sections even when metadata markers exist', async () => { @@ -1223,6 +1256,46 @@ test('owner-ready transition requires verification and review but remains resuma /automation-authored live PR/, ) + const failedCommandPullRequest = structuredClone(ownerReadyPullRequest) + failedCommandPullRequest.body = failedCommandPullRequest.body.replace( + '- `pnpm test -- keyboard`: passed (exit code 0)', + '- `pnpm test -- keyboard`: failed (exit code 1)', + ) + await assert.rejects( + transitionRun({ + loopRoot, + runId: run.runId, + status: 'awaiting_owner_review', + prUrl, + headSha, + githubApi: async () => failedCommandPullRequest, + }), + /exact-head evidence and review links/, + ) + + const mutatedManifest = JSON.parse(manifestSource) + mutatedManifest.checks.push({ + command: 'pnpm test -- injected', + status: 'passed', + exitCode: 0, + startedAt: '2026-07-22T16:30:00.000Z', + finishedAt: '2026-07-22T16:30:00.000Z', + artifactUrl: null, + }) + await writeFile(manifestPath, `${JSON.stringify(mutatedManifest)}\n`, 'utf8') + await assert.rejects( + transitionRun({ + loopRoot, + runId: run.runId, + status: 'awaiting_owner_review', + prUrl, + headSha, + githubApi: async () => ownerReadyPullRequest, + }), + /evidence manifest no longer matches its digest/, + ) + await writeFile(manifestPath, manifestSource, 'utf8') + const paused = await transitionRun({ loopRoot, runId: run.runId, @@ -1618,12 +1691,32 @@ test('review gate verifies published findings and classified replies', async () const digest = createHash('sha256') .update(await readFile(resultPath, 'utf8')) .digest('hex') - const recorded = await recordReview({ + const wrongRoundResultPath = path.join( loopRoot, - runId: run.runId, - resultPath, - reviewUrl: 'https://github.com/codeacme17/echo-ui/pull/300#pullrequestreview-500', - githubApi: async (endpoint) => { + 'logs', + 'runs', + run.runId, + 'review-result-wrong-round.json', + ) + const wrongRoundResult = JSON.parse(await readFile(resultPath, 'utf8')) + wrongRoundResult.rounds[0].findings[0].findingId = 'RVW-2-1' + await writeFile(wrongRoundResultPath, `${JSON.stringify(wrongRoundResult)}\n`, 'utf8') + await assert.rejects( + recordReview({ + loopRoot, + runId: run.runId, + resultPath: wrongRoundResultPath, + reviewUrl: 'https://github.com/codeacme17/echo-ui/pull/300#pullrequestreview-500', + githubApi: async () => { + throw new Error('GitHub must not be queried for an invalid round ID') + }, + }), + /invalid or duplicate finding ID: RVW-2-1/, + ) + + const reviewGithubApi = + ({ includePriorFinding = true } = {}) => + async (endpoint) => { if (endpoint.endsWith('/reviews/499/comments?per_page=100')) { return [ { @@ -1670,12 +1763,31 @@ test('review gate verifies published findings and classified replies', async () ].join('\n') : [ 'PASS', - 'Resolved RVW-1-1 with the published executor response.', + ...(includePriorFinding + ? ['Resolved RVW-1-1 with the published executor response.'] + : []), `<!-- issue-dev-loop:${run.runId}:review-round:2:head:${headSha} -->`, `<!-- issue-dev-loop:${run.runId}:review-result-sha256:${digest} -->`, ].join('\n'), } - }, + } + + await assert.rejects( + recordReview({ + loopRoot, + runId: run.runId, + resultPath, + reviewUrl: 'https://github.com/codeacme17/echo-ui/pull/300#pullrequestreview-500', + githubApi: reviewGithubApi({ includePriorFinding: false }), + }), + /unrecorded findings/, + ) + const recorded = await recordReview({ + loopRoot, + runId: run.runId, + resultPath, + reviewUrl: 'https://github.com/codeacme17/echo-ui/pull/300#pullrequestreview-500', + githubApi: reviewGithubApi(), }) assert.equal(recorded.findingCount, 1) assert.equal(recorded.rounds, 2) @@ -1870,6 +1982,7 @@ test('accepted review fix must be after the finding head and inside the final he ].join('\n') : [ 'PASS', + 'Resolved RVW-1-1 with the published executor response.', `<!-- issue-dev-loop:${run.runId}:review-round:2:head:${headSha} -->`, `<!-- issue-dev-loop:${run.runId}:review-result-sha256:${digest} -->`, ].join('\n'), @@ -1981,6 +2094,7 @@ test('review gate binds high-severity adjudication verdict to the correct identi ].join('\n') : [ 'PASS', + 'Resolved RVW-1-1 through the recorded adjudication.', `<!-- issue-dev-loop:${run.runId}:review-round:2:head:${headSha} -->`, `<!-- issue-dev-loop:${run.runId}:review-result-sha256:${digest} -->`, ].join('\n'), From ad8fa40b85ddaefe426a1df14e08f91aee43c3bd Mon Sep 17 00:00:00 2001 From: leyoonafr <codeacme17@gmail.com> Date: Thu, 23 Jul 2026 01:42:06 +0800 Subject: [PATCH 13/41] fix: require exact visible verification results --- loops/issue-dev-loop/scripts/lib/evidence.mjs | 3 ++ .../issue-dev-loop/scripts/lib/run-store.mjs | 23 +++++++-- loops/issue-dev-loop/tests/runtime.test.mjs | 51 +++++++++++++++++++ 3 files changed, 73 insertions(+), 4 deletions(-) diff --git a/loops/issue-dev-loop/scripts/lib/evidence.mjs b/loops/issue-dev-loop/scripts/lib/evidence.mjs index 22055444..60e5953a 100644 --- a/loops/issue-dev-loop/scripts/lib/evidence.mjs +++ b/loops/issue-dev-loop/scripts/lib/evidence.mjs @@ -234,6 +234,9 @@ export async function recordEvidence({ if (checks.length === 0 || checks.some((check) => check.status !== 'passed')) { throw new Error('all evidence checks must pass') } + if (new Set(checks.map((check) => check.command)).size !== checks.length) { + throw new Error('evidence checks must use unique commands') + } if (!checks.some((check) => /^pnpm verify(?:\s|$)/.test(check.command))) { throw new Error('evidence checks must include pnpm verify') } diff --git a/loops/issue-dev-loop/scripts/lib/run-store.mjs b/loops/issue-dev-loop/scripts/lib/run-store.mjs index c1c776f6..055cb52b 100644 --- a/loops/issue-dev-loop/scripts/lib/run-store.mjs +++ b/loops/issue-dev-loop/scripts/lib/run-store.mjs @@ -287,6 +287,14 @@ function briefSection(source, heading) { ) } +function visibleMarkdownLines(source) { + return source + .replace(/<!--[\s\S]*?-->/g, '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) +} + function parseFrozenBrief(source) { const sections = Object.fromEntries( REQUIRED_BRIEF_SECTIONS.map((heading) => [heading, briefSection(source, heading)]), @@ -966,16 +974,23 @@ export async function transitionRun({ 'Independent review', 'Known limitations', ] - const verificationResults = verifiedManifest.checks.map( - (check) => `- \`${check.command}\`: passed (exit code 0)`, - ) + const verificationLines = visibleMarkdownLines(verificationSection) + const hasExactVerificationResults = verifiedManifest.checks.every((check) => { + const commandToken = `\`${check.command}\`` + const resultLines = verificationLines.filter( + (line) => line.startsWith('- ') && line.includes(commandToken), + ) + return ( + resultLines.length === 1 && resultLines[0] === `- ${commandToken}: passed (exit code 0)` + ) + }) const screenshotPaths = verifiedManifest.screenshots.map((screenshot) => screenshot.path) const bodyHasExactProof = requiredMetadata.every((fragment) => livePullRequest.body?.includes(fragment)) && requiredSections.every((heading) => briefSection(livePullRequest.body ?? '', heading)) && evidenceSection.includes(verificationEvent.payload.manifestUrl) && reviewSection.includes(reviewEvent.payload.reviewUrl) && - verificationResults.every((result) => verificationSection.includes(result)) && + hasExactVerificationResults && !/\bpending\b/i.test(`${verificationSection}\n${evidenceSection}\n${reviewSection}`) && (!run.uiEvidenceRequired || (screenshotPaths.length > 0 && diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index 63b0e7d4..22a40166 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -1273,6 +1273,57 @@ test('owner-ready transition requires verification and review but remains resuma /exact-head evidence and review links/, ) + const ambiguousCommandPullRequest = structuredClone(ownerReadyPullRequest) + ambiguousCommandPullRequest.body = ambiguousCommandPullRequest.body.replace( + '- `pnpm test -- keyboard`: passed (exit code 0)', + '- `pnpm test -- keyboard`: passed (exit code 0) — actually failed', + ) + await assert.rejects( + transitionRun({ + loopRoot, + runId: run.runId, + status: 'awaiting_owner_review', + prUrl, + headSha, + githubApi: async () => ambiguousCommandPullRequest, + }), + /exact-head evidence and review links/, + ) + + const hiddenCommandPullRequest = structuredClone(ownerReadyPullRequest) + hiddenCommandPullRequest.body = hiddenCommandPullRequest.body.replace( + '- `pnpm test -- keyboard`: passed (exit code 0)', + '<!-- - `pnpm test -- keyboard`: passed (exit code 0) -->\n- `pnpm test -- keyboard`: failed (exit code 1)', + ) + await assert.rejects( + transitionRun({ + loopRoot, + runId: run.runId, + status: 'awaiting_owner_review', + prUrl, + headSha, + githubApi: async () => hiddenCommandPullRequest, + }), + /exact-head evidence and review links/, + ) + + const duplicateCommandPullRequest = structuredClone(ownerReadyPullRequest) + duplicateCommandPullRequest.body = duplicateCommandPullRequest.body.replace( + '- `pnpm test -- keyboard`: passed (exit code 0)', + '- `pnpm test -- keyboard`: passed (exit code 0)\n- `pnpm test -- keyboard`: passed (exit code 0)', + ) + await assert.rejects( + transitionRun({ + loopRoot, + runId: run.runId, + status: 'awaiting_owner_review', + prUrl, + headSha, + githubApi: async () => duplicateCommandPullRequest, + }), + /exact-head evidence and review links/, + ) + const mutatedManifest = JSON.parse(manifestSource) mutatedManifest.checks.push({ command: 'pnpm test -- injected', From 65c05c03dda924db18d9d7684342bbf7a1981c08 Mon Sep 17 00:00:00 2001 From: leyoonafr <codeacme17@gmail.com> Date: Thu, 23 Jul 2026 01:45:03 +0800 Subject: [PATCH 14/41] fix: handle unclosed verification comments --- loops/issue-dev-loop/scripts/lib/run-store.mjs | 16 ++++++++++++++-- loops/issue-dev-loop/tests/runtime.test.mjs | 17 +++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/loops/issue-dev-loop/scripts/lib/run-store.mjs b/loops/issue-dev-loop/scripts/lib/run-store.mjs index 055cb52b..9dfcb597 100644 --- a/loops/issue-dev-loop/scripts/lib/run-store.mjs +++ b/loops/issue-dev-loop/scripts/lib/run-store.mjs @@ -288,8 +288,20 @@ function briefSection(source, heading) { } function visibleMarkdownLines(source) { - return source - .replace(/<!--[\s\S]*?-->/g, '') + let visibleSource = '' + let cursor = 0 + while (cursor < source.length) { + const commentStart = source.indexOf('<!--', cursor) + if (commentStart === -1) { + visibleSource += source.slice(cursor) + break + } + visibleSource += source.slice(cursor, commentStart) + const commentEnd = source.indexOf('-->', commentStart + 4) + if (commentEnd === -1) break + cursor = commentEnd + 3 + } + return visibleSource .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index 22a40166..f14486b8 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -1307,6 +1307,23 @@ test('owner-ready transition requires verification and review but remains resuma /exact-head evidence and review links/, ) + const unclosedCommentPullRequest = structuredClone(ownerReadyPullRequest) + unclosedCommentPullRequest.body = unclosedCommentPullRequest.body.replace( + '- `pnpm test -- keyboard`: passed (exit code 0)', + '<!-- hidden through EOF\n- `pnpm test -- keyboard`: passed (exit code 0)', + ) + await assert.rejects( + transitionRun({ + loopRoot, + runId: run.runId, + status: 'awaiting_owner_review', + prUrl, + headSha, + githubApi: async () => unclosedCommentPullRequest, + }), + /exact-head evidence and review links/, + ) + const duplicateCommandPullRequest = structuredClone(ownerReadyPullRequest) duplicateCommandPullRequest.body = duplicateCommandPullRequest.body.replace( '- `pnpm test -- keyboard`: passed (exit code 0)', From e584cafa46d1e7b365f5f77a0c639fd72bca949c Mon Sep 17 00:00:00 2001 From: leyoonafr <codeacme17@gmail.com> Date: Thu, 23 Jul 2026 01:48:10 +0800 Subject: [PATCH 15/41] fix: preserve visible PR section state --- .../issue-dev-loop/scripts/lib/run-store.mjs | 24 ++++++++++++------- loops/issue-dev-loop/tests/runtime.test.mjs | 17 +++++++++++++ 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/loops/issue-dev-loop/scripts/lib/run-store.mjs b/loops/issue-dev-loop/scripts/lib/run-store.mjs index 9dfcb597..eed4801a 100644 --- a/loops/issue-dev-loop/scripts/lib/run-store.mjs +++ b/loops/issue-dev-loop/scripts/lib/run-store.mjs @@ -287,7 +287,7 @@ function briefSection(source, heading) { ) } -function visibleMarkdownLines(source) { +function withoutHtmlComments(source) { let visibleSource = '' let cursor = 0 while (cursor < source.length) { @@ -302,6 +302,10 @@ function visibleMarkdownLines(source) { cursor = commentEnd + 3 } return visibleSource +} + +function visibleMarkdownLines(source) { + return withoutHtmlComments(source) .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) @@ -967,12 +971,13 @@ export async function transitionRun({ throw new Error('awaiting_owner_review evidence manifest no longer matches its digest') } const verifiedManifest = JSON.parse(verifiedManifestSource) - const evidenceSection = briefSection(livePullRequest.body ?? '', 'Evidence') - const reviewSection = briefSection(livePullRequest.body ?? '', 'Independent review') - const verificationSection = briefSection(livePullRequest.body ?? '', 'Verification') - const requiredMetadata = [ + const pullRequestBody = livePullRequest.body ?? '' + const visiblePullRequestBody = withoutHtmlComments(pullRequestBody) + const evidenceSection = briefSection(visiblePullRequestBody, 'Evidence') + const reviewSection = briefSection(visiblePullRequestBody, 'Independent review') + const verificationSection = briefSection(visiblePullRequestBody, 'Verification') + const requiredVisibleMetadata = [ `Closes #${run.issueNumber}`, - `<!-- issue-dev-loop:run:${normalizedRunId} -->`, `Run ID: \`${normalizedRunId}\``, `Base SHA: \`${run.baseSha}\``, `Head SHA: \`${headSha}\``, @@ -998,8 +1003,9 @@ export async function transitionRun({ }) const screenshotPaths = verifiedManifest.screenshots.map((screenshot) => screenshot.path) const bodyHasExactProof = - requiredMetadata.every((fragment) => livePullRequest.body?.includes(fragment)) && - requiredSections.every((heading) => briefSection(livePullRequest.body ?? '', heading)) && + pullRequestBody.includes(`<!-- issue-dev-loop:run:${normalizedRunId} -->`) && + requiredVisibleMetadata.every((fragment) => visiblePullRequestBody.includes(fragment)) && + requiredSections.every((heading) => briefSection(visiblePullRequestBody, heading)) && evidenceSection.includes(verificationEvent.payload.manifestUrl) && reviewSection.includes(reviewEvent.payload.reviewUrl) && hasExactVerificationResults && @@ -1007,7 +1013,7 @@ export async function transitionRun({ (!run.uiEvidenceRequired || (screenshotPaths.length > 0 && screenshotPaths.every((screenshotPath) => - livePullRequest.body?.includes(screenshotPath), + visiblePullRequestBody.includes(screenshotPath), ))) if ( livePullRequest.state !== 'open' || diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index f14486b8..fc3af6e9 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -1324,6 +1324,23 @@ test('owner-ready transition requires verification and review but remains resuma /exact-head evidence and review links/, ) + const crossSectionCommentPullRequest = structuredClone(ownerReadyPullRequest) + crossSectionCommentPullRequest.body = crossSectionCommentPullRequest.body.replace( + '## Acceptance criteria\nAll frozen acceptance criteria are covered.', + '## Acceptance criteria\nAll frozen acceptance criteria are covered.\n<!-- hidden through EOF', + ) + await assert.rejects( + transitionRun({ + loopRoot, + runId: run.runId, + status: 'awaiting_owner_review', + prUrl, + headSha, + githubApi: async () => crossSectionCommentPullRequest, + }), + /exact-head evidence and review links/, + ) + const duplicateCommandPullRequest = structuredClone(ownerReadyPullRequest) duplicateCommandPullRequest.body = duplicateCommandPullRequest.body.replace( '- `pnpm test -- keyboard`: passed (exit code 0)', From 5f72389105224b5b8f8bdbd19b8886ef04a05823 Mon Sep 17 00:00:00 2001 From: leyoonafr <codeacme17@gmail.com> Date: Thu, 23 Jul 2026 15:42:45 +0800 Subject: [PATCH 16/41] chore(loop): route executor and reviewer identities --- .codex/agents/echo-ui-pr-reviewer.toml | 8 +- .codex/agents/echo-ui-review-adjudicator.toml | 5 +- loops/_shared/owner-channel/CHANNEL.md | 14 +- loops/_shared/owner-channel/channel.json | 6 +- loops/issue-dev-loop/SKILL.md | 19 +- loops/issue-dev-loop/dependencies.md | 6 +- .../references/github-operations.md | 14 +- .../issue-dev-loop/review/reviewer-prompt.md | 2 +- .../scripts/lib/github-identity.mjs | 129 +++++++++++ .../issue-dev-loop/scripts/lib/validation.mjs | 22 +- .../scripts/with-github-identity.mjs | 34 +++ loops/issue-dev-loop/state.md | 6 +- .../tests/github-identity-routing.test.mjs | 207 ++++++++++++++++++ loops/issue-dev-loop/tests/runtime.test.mjs | 40 +++- loops/issue-dev-loop/triggers/TRIGGER.md | 4 +- .../triggers/codex-automation-prompt.md | 4 +- 16 files changed, 479 insertions(+), 41 deletions(-) create mode 100644 loops/issue-dev-loop/scripts/lib/github-identity.mjs create mode 100644 loops/issue-dev-loop/scripts/with-github-identity.mjs create mode 100644 loops/issue-dev-loop/tests/github-identity-routing.test.mjs diff --git a/.codex/agents/echo-ui-pr-reviewer.toml b/.codex/agents/echo-ui-pr-reviewer.toml index 2df66f3c..ca6c2215 100644 --- a/.codex/agents/echo-ui-pr-reviewer.toml +++ b/.codex/agents/echo-ui-pr-reviewer.toml @@ -10,9 +10,11 @@ security, regressions, and missing tests. Ignore executor conversation history a do not modify files. Avoid style-only or speculative findings. Return a structured review with stable finding IDs, severity P0-P3, confidence, file and line, concrete evidence, reproduction when possible, and expected resolution. Return PASS when no -actionable findings exist. Verify `gh api user` matches the configured -reviewerGitHubLogin, then publish the non-approving COMMENT review yourself; never -let the executor identity publish on your behalf. Every round must include its +actionable findings exist. Publish GitHub reviews only through +`node loops/issue-dev-loop/scripts/with-github-identity.mjs reviewer -- ...`; the +wrapper must verify the configured reviewerGitHubLogin before publication. Never +run raw `gh`, alter global authentication, or let the executor identity publish on +your behalf. Every round must include its run/round/head marker and return its unique review URL. For the final PASS, include the cycle-result digest marker and all prior finding IDs and return its review URL. """ diff --git a/.codex/agents/echo-ui-review-adjudicator.toml b/.codex/agents/echo-ui-review-adjudicator.toml index 3b41293a..78af9b00 100644 --- a/.codex/agents/echo-ui-review-adjudicator.toml +++ b/.codex/agents/echo-ui-review-adjudicator.toml @@ -8,6 +8,7 @@ executor response, and cited evidence. Do not modify files and do not infer inte from private conversation history. Return ACCEPT_FINDING, REJECT_FINDING, or NEEDS_OWNER, followed by concise evidence. Prefer NEEDS_OWNER when product intent, public API policy, security, or release policy is ambiguous. -For REJECT_FINDING, verify `gh api user` matches reviewerGitHubLogin and publish the -required adjudication marker on the PR. Do not post through the executor identity. +For REJECT_FINDING, publish the required adjudication marker only through +`node loops/issue-dev-loop/scripts/with-github-identity.mjs reviewer -- ...`. +Do not run raw `gh`, alter global authentication, or post through the executor identity. """ diff --git a/loops/_shared/owner-channel/CHANNEL.md b/loops/_shared/owner-channel/CHANNEL.md index b30497d9..f06e1ad7 100644 --- a/loops/_shared/owner-channel/CHANNEL.md +++ b/loops/_shared/owner-channel/CHANNEL.md @@ -12,11 +12,13 @@ Each blocking GitHub notification prints a unique resume instruction. To continu ## Runtime setup -1. Authenticate the unattended executor and fresh reviewer with distinct GitHub identities; set their exact logins as `automationGitHubLogin` and `reviewerGitHubLogin` in `channel.json`. - Run `loopctl validate --activation` before scheduling. Default GitHub mutations verify `gh api user` matches the configured automation identity and refuse owner credentials. -2. Create one dedicated repository issue for the append-only loop state journal and set its number as `stateIssueNumber`. It stores active checkpoints and terminal records. Restrict journal entries to the automation identity; humans may read but should not edit or delete them. -3. Enable GitHub notifications for mentions and review requests for `codeacme17`. -4. Optionally set `ECHO_UI_LOOP_OWNER_WEBHOOK_URL` to an endpoint that accepts the notification JSON with `Content-Type: application/json`. -5. Never store the webhook URL or credentials in this repository. +1. Authenticate the unattended executor and fresh reviewer with distinct GitHub identities. Their exact logins and the names of their profile-path environment variables live in `channel.json`. + For the current configuration, set `ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR` to the `Ethandasw` `gh` profile directory and `ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR` to the `Traviinam` profile directory in the scheduler environment. The directory names themselves are local details and do not need to match the roles. +2. Run `node loops/issue-dev-loop/scripts/loopctl.mjs validate --activation` before scheduling. It independently queries both profiles and rejects missing, overlapping, owner-authenticated, or incorrectly routed identities. +3. Run every executor GitHub command, remote Git command, and GitHub-backed `loopctl` command through `with-github-identity.mjs automation -- ...`. Run reviewer publication commands through the same wrapper with `reviewer`. The wrapper clears token overrides, verifies `gh api user`, and gives Git a one-command credential helper without changing global Git or `gh` configuration. +4. Create one dedicated repository issue for the append-only loop state journal and set its number as `stateIssueNumber`. It stores active checkpoints and terminal records. Restrict journal entries to the automation identity; humans may read but should not edit or delete them. +5. Enable GitHub notifications for mentions and review requests for `codeacme17`. +6. Optionally set `ECHO_UI_LOOP_OWNER_WEBHOOK_URL` to an endpoint that accepts the notification JSON with `Content-Type: application/json`. +7. Never store profile paths, webhook URLs, or credentials in this repository. Review publications are valid only when authored by `reviewerGitHubLogin`; executor replies must match `automationGitHubLogin`. Those identities must differ from one another and from `ownerGitHubLogin`. Owner decisions are valid only when the author matches `ownerGitHubLogin`. A webhook delivery is an alert, not an approval channel. diff --git a/loops/_shared/owner-channel/channel.json b/loops/_shared/owner-channel/channel.json index 665f8d69..8678b081 100644 --- a/loops/_shared/owner-channel/channel.json +++ b/loops/_shared/owner-channel/channel.json @@ -1,8 +1,10 @@ { "schemaVersion": 1, "ownerGitHubLogin": "codeacme17", - "automationGitHubLogin": null, - "reviewerGitHubLogin": null, + "automationGitHubLogin": "Ethandasw", + "reviewerGitHubLogin": "Traviinam", + "automationGitHubConfigEnvironmentVariable": "ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR", + "reviewerGitHubConfigEnvironmentVariable": "ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR", "stateIssueNumber": 105, "repository": "codeacme17/echo-ui", "canonicalChannel": "github", diff --git a/loops/issue-dev-loop/SKILL.md b/loops/issue-dev-loop/SKILL.md index a65024e2..51c21dcc 100644 --- a/loops/issue-dev-loop/SKILL.md +++ b/loops/issue-dev-loop/SKILL.md @@ -10,14 +10,15 @@ Run exactly one bounded issue cycle. Treat [`LOOP.md`](./LOOP.md) as the constit ## Start safely 1. Read `LOOP.md`, `state.md`, and `dependencies.md` completely. -2. Run `node loops/issue-dev-loop/scripts/loopctl.mjs validate`. Scheduled activation additionally requires `validate --activation`, which rejects missing or overlapping owner/executor/reviewer identities. -3. Run `loopctl.mjs reconcile` to rebuild terminal history and discover active runs from the append-only GitHub state journal. For returned `workType: resume`, fetch the recorded branch, create a clean isolated worktree at the returned exact head, and run `restore-checkpoint --run-id <id>` inside that worktree. The restore command rejects the wrong branch, a dirty checkout, or any head other than the durable head. Resume it before selecting a new issue. -4. Run `loopctl.mjs evolve-status`. If `evolveDue` is true, start `echo_ui_loop_evolver` with fresh context; do not silently replace it with product work. -5. Run the cheap trigger in `triggers/detect-work.mjs`. Exit without invoking an implementation agent when it reports `hasWork: false`. -6. Refuse to start when another active run or PR already claims the issue. -7. Create a `codex/issue-<number>` branch and an isolated worktree from `dev`. -8. Start the run with `loopctl.mjs start --issue <number> --title <title> --url <issue-url> --base-sha <full-origin-dev-sha>`. -9. Complete `handoffs/<run-id>/implementation-brief.md`, set `UI evidence required` to `yes` or `no`, then run `loopctl.mjs freeze-brief --run-id <id>` before implementation. Never edit the frozen brief afterward. +2. Run `node loops/issue-dev-loop/scripts/loopctl.mjs validate`. Scheduled activation additionally requires `validate --activation`, which probes the separately configured executor and reviewer `gh` profiles. +3. Read [`references/github-operations.md`](./references/github-operations.md). Run every executor GitHub command, remote Git command, and GitHub-backed `loopctl` command through `node loops/issue-dev-loop/scripts/with-github-identity.mjs automation -- ...`. Never alter global `gh` or Git credential configuration. +4. Run `loopctl.mjs reconcile` through the automation wrapper to rebuild terminal history and discover active runs from the append-only GitHub state journal. For returned `workType: resume`, fetch the recorded branch through the wrapper, create a clean isolated worktree at the returned exact head, and run `restore-checkpoint --run-id <id>` inside that worktree. The restore command rejects the wrong branch, a dirty checkout, or any head other than the durable head. Resume it before selecting a new issue. +5. Run `loopctl.mjs evolve-status`. If `evolveDue` is true, start `echo_ui_loop_evolver` with fresh context; do not silently replace it with product work. +6. Run the cheap trigger in `triggers/detect-work.mjs` through the automation wrapper. Exit without invoking an implementation agent when it reports `hasWork: false`. +7. Refuse to start when another active run or PR already claims the issue. +8. Create a `codex/issue-<number>` branch and an isolated worktree from `dev`. +9. Start the run with `loopctl.mjs start --issue <number> --title <title> --url <issue-url> --base-sha <full-origin-dev-sha>`. +10. Complete `handoffs/<run-id>/implementation-brief.md`, set `UI evidence required` to `yes` or `no`, then run `loopctl.mjs freeze-brief --run-id <id>` before implementation. Never edit the frozen brief afterward. After `start`, `freeze-brief`, every `record-implementation`, every `record-pr`, every review/evidence gate, and every pause transition, run `prepare-checkpoint`, publish its exact body to the state-journal issue through the automation identity, and validate it with `record-checkpoint`. The next phase re-fetches that exact automation-authored comment and rejects a missing, edited, stale, or locally forged checkpoint. These compact checkpoints let a verified fresh worktree restore the run, frozen brief, and validated event chain instead of abandoning an already-claimed issue or open PR. @@ -45,7 +46,7 @@ Push only the issue branch and create a **draft** PR targeting `dev` using `temp After the draft PR exists, spawn the project agent `echo_ui_pr_reviewer` with a fresh context. Give it only the issue snapshot, acceptance criteria, repository instructions, base SHA, head SHA, diff, CI results, and evidence manifest. Do not give it executor conversation history or rationale. -Publish every round through the separately configured `reviewerGitHubLogin`; the executor identity may not author it. Post findings verbatim as one review plus inline comments. Each finding needs a stable ID, severity, confidence, evidence, and expected resolution. Record the GitHub review URL for each round. Accepted repairs must start after that round was submitted, and executor replies must be posted after the corresponding `$implement` invocation finishes. Follow `review/REVIEW.md` and `review/response-policy.md`. +Publish every round through `with-github-identity.mjs reviewer -- ...`; the executor identity may not author it. Post findings verbatim as one review plus inline comments. Each finding needs a stable ID, severity, confidence, evidence, and expected resolution. Record the GitHub review URL for each round. Accepted repairs must start after that round was submitted, and executor replies must be posted through the automation wrapper after the corresponding `$implement` invocation finishes. Follow `review/REVIEW.md` and `review/response-policy.md`. The executor must classify every finding as `accepted`, `rejected`, `needs-human`, `stale`, or `already-fixed`: diff --git a/loops/issue-dev-loop/dependencies.md b/loops/issue-dev-loop/dependencies.md index c0c8633a..5953acf2 100644 --- a/loops/issue-dev-loop/dependencies.md +++ b/loops/issue-dev-loop/dependencies.md @@ -10,6 +10,8 @@ - pnpm 10 - Git - GitHub CLI (`gh`) authenticated for issue, Actions artifact download, branch, PR, review, and comment work +- `ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR` pointing to the executor's dedicated `gh` profile +- `ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR` pointing to the reviewer's dedicated `gh` profile - Repository trust enabled so project `.codex` agents can load - A dedicated GitHub issue configured as `stateIssueNumber` for append-only active checkpoints and finalization records @@ -20,4 +22,6 @@ ## Identity -Use one dedicated GitHub App or bot identity for executor-created branches, PRs, replies, and durable journal entries, plus a distinct reviewer identity for the fresh-context review publication. Neither may be `codeacme17`; neither may have branch-protection bypass or merge authority. Configure their exact logins and the dedicated state-journal issue number in the shared owner channel before enabling automation. +Use `Ethandasw` for executor-created branches, PRs, replies, and durable journal entries, and `Traviinam` for fresh-context review publication. Neither may be `codeacme17`; neither may have branch-protection bypass or merge authority. The executor needs repository write access. The reviewer needs no repository write access. + +Never run `gh auth setup-git` for this loop. Route commands through `scripts/with-github-identity.mjs`; it scopes `GH_CONFIG_DIR` and the Git credential helper to one child process, leaving the user's default `gh` account and global Git credential configuration unchanged. diff --git a/loops/issue-dev-loop/references/github-operations.md b/loops/issue-dev-loop/references/github-operations.md index 94813baa..850b22f2 100644 --- a/loops/issue-dev-loop/references/github-operations.md +++ b/loops/issue-dev-loop/references/github-operations.md @@ -2,7 +2,13 @@ Read this before mutating issues or pull requests. -Before every GitHub mutation, run `gh api user` and require the exact configured `automationGitHubLogin`. Stop if it is unset, matches `codeacme17`, matches the reviewer, or differs from the authenticated actor. Never use owner credentials for executor actions. +Run every executor GitHub command through: + +```text +node loops/issue-dev-loop/scripts/with-github-identity.mjs automation -- <command> [args...] +``` + +Run every reviewer publication command through the same wrapper with role `reviewer`. The wrapper selects the configured `gh` profile, removes token environment overrides, runs `gh api user`, and refuses an unexpected or owner identity. For Git, it clears global credential helpers and injects `gh auth git-credential` for that child process only. Never use owner credentials for executor or reviewer actions, never run raw remote `gh`/`git push` commands, and never call `gh auth setup-git`. ## Selection and claim @@ -29,10 +35,12 @@ Before every GitHub mutation, run `gh api user` and require the exact configured The PR workflow `Issue dev loop evidence` runs only when the branch contains one active loop run. Wait for its exact-head run to complete, then locate and download the artifact: ```text -gh run list --workflow issue-dev-loop-evidence.yml --branch codex/issue-<number> -gh run download <run-database-id> --name issue-dev-loop-<run-id>-<head-sha> --dir loops/issue-dev-loop/evidence/<run-id> +node loops/issue-dev-loop/scripts/with-github-identity.mjs automation -- gh run list --workflow issue-dev-loop-evidence.yml --branch codex/issue-<number> +node loops/issue-dev-loop/scripts/with-github-identity.mjs automation -- gh run download <run-database-id> --name issue-dev-loop-<run-id>-<head-sha> --dir loops/issue-dev-loop/evidence/<run-id> ``` +Push only through `with-github-identity.mjs automation -- git push ...`. The wrapper rejects reviewer pushes, force pushes, and explicit pushes to `dev` or `main`. + Use the artifact URL emitted by `actions/upload-artifact` as `record-evidence --publication-url` and the downloaded artifact manifest as `--manifest`. The runtime downloads it independently, requires the workflow conclusion to be `success`, and byte-compares the manifest. Reject a workflow run whose PR, branch, workflow path, or `headSha` differs from the recorded run. ## Prohibited commands diff --git a/loops/issue-dev-loop/review/reviewer-prompt.md b/loops/issue-dev-loop/review/reviewer-prompt.md index 618df77d..14b555f7 100644 --- a/loops/issue-dev-loop/review/reviewer-prompt.md +++ b/loops/issue-dev-loop/review/reviewer-prompt.md @@ -1 +1 @@ -Review the Echo UI PR diff between `<base-sha>` and `<head-sha>` as an independent, fresh-context reviewer. Use `$code-review`. Read only the supplied issue snapshot, acceptance criteria, repository instructions, diff, CI results, and evidence manifest. Follow `loops/issue-dev-loop/review/REVIEW.md`. Do not edit files or rely on executor conversation history. Return structured findings or `PASS`. +Review the Echo UI PR diff between `<base-sha>` and `<head-sha>` as an independent, fresh-context reviewer. Use `$code-review`. Read only the supplied issue snapshot, acceptance criteria, repository instructions, diff, CI results, and evidence manifest. Follow `loops/issue-dev-loop/review/REVIEW.md`. Do not edit files or rely on executor conversation history. Publish the non-approving GitHub review only through `node loops/issue-dev-loop/scripts/with-github-identity.mjs reviewer -- ...`; never run raw `gh` or alter global authentication. Return structured findings or `PASS` and the unique review URL. diff --git a/loops/issue-dev-loop/scripts/lib/github-identity.mjs b/loops/issue-dev-loop/scripts/lib/github-identity.mjs new file mode 100644 index 00000000..9f9fdc39 --- /dev/null +++ b/loops/issue-dev-loop/scripts/lib/github-identity.mjs @@ -0,0 +1,129 @@ +import { execFile, spawn } from 'node:child_process' +import path from 'node:path' +import { promisify } from 'node:util' + +import { assertNonEmpty, readJson, sameGitHubLogin } from './common.mjs' + +const execFileAsync = promisify(execFile) +const roleFields = { + automation: { + login: 'automationGitHubLogin', + environmentVariable: 'automationGitHubConfigEnvironmentVariable', + }, + reviewer: { + login: 'reviewerGitHubLogin', + environmentVariable: 'reviewerGitHubConfigEnvironmentVariable', + }, +} + +export function resolveGitHubRoleEnvironment({ channel, role, environment = process.env }) { + const fields = roleFields[role] + if (!fields) throw new Error('GitHub role must be automation or reviewer') + + const expectedLogin = assertNonEmpty(channel[fields.login], `channel.${fields.login}`) + if (sameGitHubLogin(expectedLogin, channel.ownerGitHubLogin)) { + throw new Error(`GitHub ${role} identity must differ from the owner`) + } + + const variableName = assertNonEmpty( + channel[fields.environmentVariable], + `channel.${fields.environmentVariable}`, + ) + if (!/^[A-Z][A-Z0-9_]*$/.test(variableName)) { + throw new Error(`channel.${fields.environmentVariable} must name an environment variable`) + } + + const configDirectory = assertNonEmpty(environment[variableName], variableName) + if (!path.isAbsolute(configDirectory)) { + throw new Error(`${variableName} must contain an absolute directory path`) + } + + const routedEnvironment = { + ...environment, + GH_CONFIG_DIR: configDirectory, + } + for (const tokenName of [ + 'GH_TOKEN', + 'GITHUB_TOKEN', + 'GH_ENTERPRISE_TOKEN', + 'GITHUB_ENTERPRISE_TOKEN', + ]) { + delete routedEnvironment[tokenName] + } + + return { configDirectory, expectedLogin, routedEnvironment } +} + +export async function assertGitHubRoleIdentity({ + channel, + role, + environment = process.env, + identityCommand = execFileAsync, +}) { + const resolved = resolveGitHubRoleEnvironment({ channel, role, environment }) + const { stdout } = await identityCommand('gh', ['api', 'user', '--jq', '.login'], { + env: resolved.routedEnvironment, + maxBuffer: 1024 * 1024, + }) + const authenticatedLogin = stdout.trim() + if (!sameGitHubLogin(authenticatedLogin, resolved.expectedLogin)) { + throw new Error( + `GitHub ${role} identity must be ${resolved.expectedLogin}; authenticated as ${ + authenticatedLogin || 'unknown' + }`, + ) + } + return { ...resolved, authenticatedLogin } +} + +export async function runWithGitHubRole({ + channel, + role, + command, + args = [], + environment = process.env, + spawnCommand = spawn, +}) { + const executable = assertNonEmpty(command, 'command') + if (path.basename(executable) === 'git' && args[0] === 'push') { + if (role === 'reviewer') throw new Error('reviewer identity cannot run git push') + const hasForce = args.some((argument) => argument === '-f' || argument.startsWith('--force')) + const targetsProtectedBranch = args.some((argument) => { + const destination = argument.includes(':') ? argument.slice(argument.lastIndexOf(':') + 1) : argument + return ['dev', 'main', 'refs/heads/dev', 'refs/heads/main'].includes(destination) + }) + if (hasForce || targetsProtectedBranch) { + throw new Error('force-push and protected-branch pushes are prohibited') + } + } + const resolved = await assertGitHubRoleIdentity({ channel, role, environment }) + const routedArgs = + path.basename(executable) === 'git' + ? [ + '-c', + 'credential.helper=', + '-c', + 'credential.helper=!gh auth git-credential', + ...args, + ] + : args + const child = spawnCommand(executable, routedArgs, { + env: resolved.routedEnvironment, + stdio: 'inherit', + shell: false, + }) + return new Promise((resolve, reject) => { + child.once('error', reject) + child.once('exit', (code, signal) => { + if (signal) { + reject(new Error(`${executable} terminated by signal ${signal}`)) + return + } + resolve(code ?? 1) + }) + }) +} + +export async function readOwnerChannel(loopRoot) { + return readJson(path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json')) +} diff --git a/loops/issue-dev-loop/scripts/lib/validation.mjs b/loops/issue-dev-loop/scripts/lib/validation.mjs index 9c80e7b2..b01ae11f 100644 --- a/loops/issue-dev-loop/scripts/lib/validation.mjs +++ b/loops/issue-dev-loop/scripts/lib/validation.mjs @@ -2,6 +2,7 @@ import { readFile, readdir } from 'node:fs/promises' import path from 'node:path' import { DEFAULT_LOOP_ROOT, pathExists, readJson, sameGitHubLogin } from './common.mjs' +import { assertGitHubRoleIdentity } from './github-identity.mjs' async function collectFiles(root, output = []) { const entries = await readdir(root, { withFileTypes: true }) @@ -14,7 +15,12 @@ async function collectFiles(root, output = []) { return output } -export async function validateLoop({ loopRoot = DEFAULT_LOOP_ROOT, activation = false } = {}) { +export async function validateLoop({ + loopRoot = DEFAULT_LOOP_ROOT, + activation = false, + environment = process.env, + identityCommand, +} = {}) { const required = [ 'SKILL.md', 'LOOP.md', @@ -44,11 +50,13 @@ export async function validateLoop({ loopRoot = DEFAULT_LOOP_ROOT, activation = 'scripts/lib/finalization-journal.mjs', 'scripts/lib/active-journal.mjs', 'scripts/lib/github.mjs', + 'scripts/lib/github-identity.mjs', 'scripts/lib/issue-claim.mjs', 'scripts/lib/notifications.mjs', 'scripts/lib/owner-gate.mjs', 'scripts/lib/run-store.mjs', 'scripts/lib/validation.mjs', + 'scripts/with-github-identity.mjs', 'logs/index.jsonl', 'logs/triggers.jsonl', 'screen-shots/.gitignore', @@ -90,6 +98,8 @@ export async function validateLoop({ loopRoot = DEFAULT_LOOP_ROOT, activation = typeof channel.ownerGitHubLogin !== 'string' || !Object.hasOwn(channel, 'automationGitHubLogin') || !Object.hasOwn(channel, 'reviewerGitHubLogin') || + typeof channel.automationGitHubConfigEnvironmentVariable !== 'string' || + typeof channel.reviewerGitHubConfigEnvironmentVariable !== 'string' || !Object.hasOwn(channel, 'stateIssueNumber') || channel.repository !== 'codeacme17/echo-ui' || !Array.isArray(channel.immediateTypes) @@ -114,6 +124,16 @@ export async function validateLoop({ loopRoot = DEFAULT_LOOP_ROOT, activation = ) { throw new Error('owner, automation, and reviewer identities must be distinct') } + if (activation) { + for (const role of ['automation', 'reviewer']) { + await assertGitHubRoleIdentity({ + channel, + role, + environment, + ...(identityCommand ? { identityCommand } : {}), + }) + } + } const evidenceWorkflow = path.resolve( loopRoot, '..', diff --git a/loops/issue-dev-loop/scripts/with-github-identity.mjs b/loops/issue-dev-loop/scripts/with-github-identity.mjs new file mode 100644 index 00000000..b8dd89d4 --- /dev/null +++ b/loops/issue-dev-loop/scripts/with-github-identity.mjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node + +import { DEFAULT_LOOP_ROOT } from './lib/common.mjs' +import { readOwnerChannel, runWithGitHubRole } from './lib/github-identity.mjs' + +function parseCommandLine(argv) { + const values = [...argv] + let loopRoot = DEFAULT_LOOP_ROOT + if (values[0] === '--loop-root') { + if (!values[1]) throw new Error('--loop-root requires a path') + loopRoot = values[1] + values.splice(0, 2) + } + const role = values.shift() + if (values.shift() !== '--') { + throw new Error( + 'usage: with-github-identity.mjs [--loop-root <path>] <automation|reviewer> -- <command> [args...]', + ) + } + const command = values.shift() + if (!command) throw new Error('a command is required after --') + return { loopRoot, role, command, args: values } +} + +async function main() { + const options = parseCommandLine(process.argv.slice(2)) + const channel = await readOwnerChannel(options.loopRoot) + process.exitCode = await runWithGitHubRole({ channel, ...options }) +} + +main().catch((error) => { + process.stderr.write(`${error.message}\n`) + process.exitCode = 1 +}) diff --git a/loops/issue-dev-loop/state.md b/loops/issue-dev-loop/state.md index 483d3d01..4c67b44a 100644 --- a/loops/issue-dev-loop/state.md +++ b/loops/issue-dev-loop/state.md @@ -1,6 +1,6 @@ # Issue development loop state -Updated: 2026-07-22 +Updated: 2026-07-23 ## Configuration @@ -12,6 +12,8 @@ Updated: 2026-07-22 - Maximum implementation repairs: 2 - Maximum review rounds: 2 - Durable state journal: issue #105 +- Executor GitHub identity: `Ethandasw` +- Independent reviewer GitHub identity: `Traviinam` ## Active runs @@ -23,7 +25,7 @@ None. ## Blockers -- Configure distinct unattended executor and reviewer GitHub identities and set their exact logins in `automationGitHubLogin` and `reviewerGitHubLogin`. +- Add the two configured `GH_CONFIG_DIR` path variables to the recurring automation environment before activation. - Merge this infrastructure into `dev` before enabling its recurring automation; the PR evidence workflow must exist on the base branch. - Choose the recurring Codex automation cadence after the infrastructure PR is merged. - Optionally configure `ECHO_UI_LOOP_OWNER_WEBHOOK_URL` for a push-channel mirror; GitHub mentions remain the canonical baseline channel. diff --git a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs new file mode 100644 index 00000000..f4d9161e --- /dev/null +++ b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs @@ -0,0 +1,207 @@ +import assert from 'node:assert/strict' +import { execFile } from 'node:child_process' +import { chmod, mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { promisify } from 'node:util' +import { fileURLToPath } from 'node:url' + +const execFileAsync = promisify(execFile) +const testDirectory = path.dirname(fileURLToPath(import.meta.url)) +const routerPath = path.resolve( + testDirectory, + '..', + 'scripts', + 'with-github-identity.mjs', +) + +async function createFixture() { + const parent = await mkdtemp(path.join(os.tmpdir(), 'echo-ui-identity-routing-')) + const loopRoot = path.join(parent, 'issue-dev-loop') + const channelRoot = path.join(parent, '_shared', 'owner-channel') + const binRoot = path.join(parent, 'bin') + const automationProfile = path.join(parent, 'automation-profile') + const reviewerProfile = path.join(parent, 'reviewer-profile') + await Promise.all([ + mkdir(channelRoot, { recursive: true }), + mkdir(binRoot, { recursive: true }), + mkdir(automationProfile, { recursive: true }), + mkdir(reviewerProfile, { recursive: true }), + ]) + await writeFile( + path.join(channelRoot, 'channel.json'), + `${JSON.stringify({ + ownerGitHubLogin: 'owner-user', + automationGitHubLogin: 'executor-user', + reviewerGitHubLogin: 'reviewer-user', + automationGitHubConfigEnvironmentVariable: + 'ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR', + reviewerGitHubConfigEnvironmentVariable: 'ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR', + })}\n`, + 'utf8', + ) + await writeFile(path.join(automationProfile, 'identity'), 'executor-user\n', 'utf8') + await writeFile(path.join(reviewerProfile, 'identity'), 'reviewer-user\n', 'utf8') + + const fakeGh = path.join(binRoot, 'gh') + await writeFile( + fakeGh, + `#!/bin/sh +if [ "$1 $2 $3 $4" != "api user --jq .login" ]; then + echo "unexpected gh arguments" >&2 + exit 90 +fi +sed -n '1p' "$GH_CONFIG_DIR/identity" +`, + 'utf8', + ) + await chmod(fakeGh, 0o755) + + const fakeGit = path.join(binRoot, 'git') + await writeFile( + fakeGit, + `#!/bin/sh +node -e 'process.stdout.write(JSON.stringify({args: process.argv.slice(1), config: process.env.GH_CONFIG_DIR, hasGhToken: Boolean(process.env.GH_TOKEN || process.env.GITHUB_TOKEN)}))' -- "$@" +`, + 'utf8', + ) + await chmod(fakeGit, 0o755) + + return { + loopRoot, + automationProfile, + reviewerProfile, + env: { + ...process.env, + PATH: `${binRoot}${path.delimiter}${process.env.PATH}`, + ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR: automationProfile, + ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR: reviewerProfile, + GH_TOKEN: 'must-not-leak', + GITHUB_TOKEN: 'must-not-leak', + }, + } +} + +test('automation role selects its dedicated gh profile without leaking token overrides', async () => { + const fixture = await createFixture() + const command = [ + routerPath, + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + process.execPath, + '-e', + `process.stdout.write(JSON.stringify({ + config: process.env.GH_CONFIG_DIR, + hasGhToken: Boolean(process.env.GH_TOKEN || process.env.GITHUB_TOKEN) + }))`, + ] + const { stdout } = await execFileAsync(process.execPath, command, { env: fixture.env }) + assert.deepEqual(JSON.parse(stdout), { + config: fixture.automationProfile, + hasGhToken: false, + }) +}) + +test('reviewer role refuses a profile authenticated as the wrong account', async () => { + const fixture = await createFixture() + await writeFile(path.join(fixture.reviewerProfile, 'identity'), 'owner-user\n', 'utf8') + await assert.rejects( + execFileAsync( + process.execPath, + [ + routerPath, + '--loop-root', + fixture.loopRoot, + 'reviewer', + '--', + process.execPath, + '-e', + 'process.stdout.write("should-not-run")', + ], + { env: fixture.env }, + ), + /GitHub reviewer identity must be reviewer-user; authenticated as owner-user/, + ) +}) + +test('automation git command clears global helpers and injects the selected gh credential helper', async () => { + const fixture = await createFixture() + const { stdout } = await execFileAsync( + process.execPath, + [ + routerPath, + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + 'git', + 'push', + 'origin', + 'codex/issue-123', + ], + { env: fixture.env }, + ) + const observed = JSON.parse(stdout) + assert.equal(observed.config, fixture.automationProfile) + assert.equal(observed.hasGhToken, false) + assert.deepEqual(observed.args, [ + '-c', + 'credential.helper=', + '-c', + 'credential.helper=!gh auth git-credential', + 'push', + 'origin', + 'codex/issue-123', + ]) +}) + +test('reviewer identity cannot push code', async () => { + const fixture = await createFixture() + await assert.rejects( + execFileAsync( + process.execPath, + [ + routerPath, + '--loop-root', + fixture.loopRoot, + 'reviewer', + '--', + 'git', + 'push', + 'origin', + 'codex/issue-123', + ], + { env: fixture.env }, + ), + /reviewer identity cannot run git push/, + ) +}) + +test('automation identity cannot force-push or push protected branches', async () => { + const fixture = await createFixture() + for (const gitArguments of [ + ['push', '--force', 'origin', 'codex/issue-123'], + ['push', 'origin', 'dev'], + ['push', 'origin', 'HEAD:main'], + ]) { + await assert.rejects( + execFileAsync( + process.execPath, + [ + routerPath, + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + 'git', + ...gitArguments, + ], + { env: fixture.env }, + ), + /force-push and protected-branch pushes are prohibited/, + ) + } +}) diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index fc3af6e9..77d89824 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -120,6 +120,9 @@ async function createFixture() { ownerGitHubLogin: 'codeacme17', automationGitHubLogin: 'echo-ui-loop[bot]', reviewerGitHubLogin: 'echo-ui-reviewer[bot]', + automationGitHubConfigEnvironmentVariable: + 'ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR', + reviewerGitHubConfigEnvironmentVariable: 'ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR', stateIssueNumber: 999, repository: 'codeacme17/echo-ui', webhookEnvironmentVariable: 'TEST_LOOP_WEBHOOK_URL', @@ -2401,12 +2404,14 @@ test('preparing the same terminal journal record is idempotent', async () => { }), }) await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) + const firstFinishedAt = new Date(Date.parse(run.startedAt) + 60_000) + const retryFinishedAt = new Date(Date.parse(run.startedAt) + 120_000) const first = await prepareFinalizationRecord({ loopRoot, runId: run.runId, status: 'blocked', failureFingerprint: 'same-terminal-cause', - finishedAt: new Date('2026-07-22T19:00:00.000Z'), + finishedAt: firstFinishedAt, githubApi: async () => ({ user: { login: 'echo-ui-loop[bot]' }, body: `@codeacme17 **blocked**\n\nRun: \`${run.runId}\``, @@ -2417,14 +2422,14 @@ test('preparing the same terminal journal record is idempotent', async () => { runId: run.runId, status: 'blocked', failureFingerprint: 'same-terminal-cause', - finishedAt: new Date('2026-07-22T20:00:00.000Z'), + finishedAt: retryFinishedAt, githubApi: async () => ({ user: { login: 'echo-ui-loop[bot]' }, body: `@codeacme17 **blocked**\n\nRun: \`${run.runId}\``, }), }) assert.equal(retried.digest, first.digest) - assert.equal(retried.record.finishedAt, '2026-07-22T19:00:00.000Z') + assert.equal(retried.record.finishedAt, firstFinishedAt.toISOString()) }) test('three matching failures make a fresh evolve session due', async () => { @@ -2704,9 +2709,28 @@ test('repository loop package satisfies its structural invariants', async () => assert.equal(result.valid, true) }) -test('repository activation remains blocked until distinct bot identities are configured', async () => { - await assert.rejects( - validateLoop({ loopRoot: repositoryLoopRoot, activation: true }), - /activation requires configured owner, automation, and reviewer identities/, - ) +test('repository activation verifies both configured GitHub profiles', async () => { + const environment = { + ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR: '/tmp/echo-ui-automation-profile', + ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR: '/tmp/echo-ui-reviewer-profile', + } + const observedProfiles = [] + const identityCommand = async (_command, _args, options) => { + observedProfiles.push(options.env.GH_CONFIG_DIR) + const login = options.env.GH_CONFIG_DIR.endsWith('automation-profile') + ? 'Ethandasw' + : 'Traviinam' + return { stdout: `${login}\n` } + } + const result = await validateLoop({ + loopRoot: repositoryLoopRoot, + activation: true, + environment, + identityCommand, + }) + assert.equal(result.valid, true) + assert.deepEqual(observedProfiles, [ + '/tmp/echo-ui-automation-profile', + '/tmp/echo-ui-reviewer-profile', + ]) }) diff --git a/loops/issue-dev-loop/triggers/TRIGGER.md b/loops/issue-dev-loop/triggers/TRIGGER.md index 90797d4a..3283d2c6 100644 --- a/loops/issue-dev-loop/triggers/TRIGGER.md +++ b/loops/issue-dev-loop/triggers/TRIGGER.md @@ -3,10 +3,10 @@ Run the deterministic detector before waking Codex: ```bash -node loops/issue-dev-loop/triggers/detect-work.mjs +node loops/issue-dev-loop/scripts/with-github-identity.mjs automation -- node loops/issue-dev-loop/triggers/detect-work.mjs ``` -Before the issue detector, run `node loops/issue-dev-loop/scripts/loopctl.mjs evolve-status`. A pending evolve request is real work and must wake the dedicated fresh-context evolver instead of an issue executor. +Before the issue detector, run `loopctl.mjs evolve-status` through the same automation wrapper. A pending evolve request is real work and must wake the dedicated fresh-context evolver instead of an issue executor. The command prints one JSON object. Start a Codex turn only when `hasWork` is `true`; pass the returned issue number and URL to `$issue-dev-loop`. diff --git a/loops/issue-dev-loop/triggers/codex-automation-prompt.md b/loops/issue-dev-loop/triggers/codex-automation-prompt.md index d2b90f8f..61491653 100644 --- a/loops/issue-dev-loop/triggers/codex-automation-prompt.md +++ b/loops/issue-dev-loop/triggers/codex-automation-prompt.md @@ -1,3 +1,5 @@ Use `$issue-dev-loop` in this repository. -First run `node loops/issue-dev-loop/scripts/loopctl.mjs reconcile`, then `node loops/issue-dev-loop/scripts/loopctl.mjs evolve-status`. A pending evolve request starts a fresh `echo_ui_loop_evolver` session and an owner-reviewed evolve PR before routine product work. Otherwise run `node loops/issue-dev-loop/triggers/detect-work.mjs`. If it returns `hasWork: false`, report a quiet no-op and stop. If it returns an issue, execute one bounded issue-dev-loop run for that exact issue. Preserve owner-only review and merge, use `$implement` for product code, use a fresh read-only reviewer after the draft PR, publish terminal state to the durable GitHub journal, and notify the owner for every blocking event or ready PR. +Require `ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR` and `ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR`, then run `node loops/issue-dev-loop/scripts/loopctl.mjs validate --activation`. Run all executor GitHub, remote Git, and GitHub-backed loop commands through `node loops/issue-dev-loop/scripts/with-github-identity.mjs automation -- ...`; never change global authentication. + +First run `loopctl.mjs reconcile`, then `loopctl.mjs evolve-status`, both through the automation wrapper. A pending evolve request starts a fresh `echo_ui_loop_evolver` session and an owner-reviewed evolve PR before routine product work. Otherwise run `triggers/detect-work.mjs` through the automation wrapper. If it returns `hasWork: false`, report a quiet no-op and stop. If it returns an issue, execute one bounded issue-dev-loop run for that exact issue. Preserve owner-only review and merge, use `$implement` for product code, use a fresh read-only reviewer after the draft PR, publish review commands through the reviewer wrapper, publish terminal state to the durable GitHub journal, and notify the owner for every blocking event or ready PR. From edffa5c39ea362b3db50aace6d9b0dd1ded66e26 Mon Sep 17 00:00:00 2001 From: leyoonafr <codeacme17@gmail.com> Date: Thu, 23 Jul 2026 15:57:39 +0800 Subject: [PATCH 17/41] fix(loop): enforce GitHub role boundaries --- .codex/agents/echo-ui-pr-reviewer.toml | 3 +- .codex/agents/echo-ui-review-adjudicator.toml | 3 +- .../references/github-operations.md | 8 ++ .../issue-dev-loop/review/reviewer-prompt.md | 2 +- .../scripts/lib/github-identity.mjs | 118 ++++++++++++++---- .../tests/github-identity-routing.test.mjs | 106 ++++++++++++++-- 6 files changed, 203 insertions(+), 37 deletions(-) diff --git a/.codex/agents/echo-ui-pr-reviewer.toml b/.codex/agents/echo-ui-pr-reviewer.toml index ca6c2215..867bc0eb 100644 --- a/.codex/agents/echo-ui-pr-reviewer.toml +++ b/.codex/agents/echo-ui-pr-reviewer.toml @@ -14,7 +14,8 @@ actionable findings exist. Publish GitHub reviews only through `node loops/issue-dev-loop/scripts/with-github-identity.mjs reviewer -- ...`; the wrapper must verify the configured reviewerGitHubLogin before publication. Never run raw `gh`, alter global authentication, or let the executor identity publish on -your behalf. Every round must include its +your behalf. Use `gh pr review --comment`; mutating `gh api`, approval, change +requests, and merge commands are prohibited. Every round must include its run/round/head marker and return its unique review URL. For the final PASS, include the cycle-result digest marker and all prior finding IDs and return its review URL. """ diff --git a/.codex/agents/echo-ui-review-adjudicator.toml b/.codex/agents/echo-ui-review-adjudicator.toml index 78af9b00..dc923d0e 100644 --- a/.codex/agents/echo-ui-review-adjudicator.toml +++ b/.codex/agents/echo-ui-review-adjudicator.toml @@ -10,5 +10,6 @@ NEEDS_OWNER, followed by concise evidence. Prefer NEEDS_OWNER when product inten public API policy, security, or release policy is ambiguous. For REJECT_FINDING, publish the required adjudication marker only through `node loops/issue-dev-loop/scripts/with-github-identity.mjs reviewer -- ...`. -Do not run raw `gh`, alter global authentication, or post through the executor identity. +Use a non-approving `gh pr review --comment`. Do not run raw or mutating `gh api`, +alter global authentication, or post through the executor identity. """ diff --git a/loops/issue-dev-loop/references/github-operations.md b/loops/issue-dev-loop/references/github-operations.md index 850b22f2..cda17b4e 100644 --- a/loops/issue-dev-loop/references/github-operations.md +++ b/loops/issue-dev-loop/references/github-operations.md @@ -10,6 +10,14 @@ node loops/issue-dev-loop/scripts/with-github-identity.mjs automation -- <comman Run every reviewer publication command through the same wrapper with role `reviewer`. The wrapper selects the configured `gh` profile, removes token environment overrides, runs `gh api user`, and refuses an unexpected or owner identity. For Git, it clears global credential helpers and injects `gh auth git-credential` for that child process only. Never use owner credentials for executor or reviewer actions, never run raw remote `gh`/`git push` commands, and never call `gh auth setup-git`. +Publish reviewer output only as a non-approving comment review: + +```text +node loops/issue-dev-loop/scripts/with-github-identity.mjs reviewer -- gh pr review <number> --comment --body <review-body> +``` + +The router rejects reviewer pushes, mutating reviewer `gh api` calls, approvals, change requests, merges, executor-authored reviews, merge endpoints, and GraphQL. Automation pushes use only `git push origin codex/issue-<number>` or the equivalent `-u`/`--set-upstream` form; every other push shape is rejected. + ## Selection and claim 1. Select only open issues labeled `codex-ready`. diff --git a/loops/issue-dev-loop/review/reviewer-prompt.md b/loops/issue-dev-loop/review/reviewer-prompt.md index 14b555f7..9e65382f 100644 --- a/loops/issue-dev-loop/review/reviewer-prompt.md +++ b/loops/issue-dev-loop/review/reviewer-prompt.md @@ -1 +1 @@ -Review the Echo UI PR diff between `<base-sha>` and `<head-sha>` as an independent, fresh-context reviewer. Use `$code-review`. Read only the supplied issue snapshot, acceptance criteria, repository instructions, diff, CI results, and evidence manifest. Follow `loops/issue-dev-loop/review/REVIEW.md`. Do not edit files or rely on executor conversation history. Publish the non-approving GitHub review only through `node loops/issue-dev-loop/scripts/with-github-identity.mjs reviewer -- ...`; never run raw `gh` or alter global authentication. Return structured findings or `PASS` and the unique review URL. +Review the Echo UI PR diff between `<base-sha>` and `<head-sha>` as an independent, fresh-context reviewer. Use `$code-review`. Read only the supplied issue snapshot, acceptance criteria, repository instructions, diff, CI results, and evidence manifest. Follow `loops/issue-dev-loop/review/REVIEW.md`. Do not edit files or rely on executor conversation history. Publish the non-approving GitHub review only through `node loops/issue-dev-loop/scripts/with-github-identity.mjs reviewer -- gh pr review <number> --comment --body <review-body>`; never run raw `gh`, mutating `gh api`, approval/change-request/merge commands, or alter global authentication. Return structured findings or `PASS` and the unique review URL. diff --git a/loops/issue-dev-loop/scripts/lib/github-identity.mjs b/loops/issue-dev-loop/scripts/lib/github-identity.mjs index 9f9fdc39..ef626873 100644 --- a/loops/issue-dev-loop/scripts/lib/github-identity.mjs +++ b/loops/issue-dev-loop/scripts/lib/github-identity.mjs @@ -1,4 +1,5 @@ import { execFile, spawn } from 'node:child_process' +import { devNull } from 'node:os' import path from 'node:path' import { promisify } from 'node:util' @@ -50,10 +51,102 @@ export function resolveGitHubRoleEnvironment({ channel, role, environment = proc ]) { delete routedEnvironment[tokenName] } + for (const name of Object.keys(routedEnvironment)) { + if (/^GIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+)$/.test(name)) delete routedEnvironment[name] + } + Object.assign(routedEnvironment, { + GIT_CONFIG_GLOBAL: devNull, + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_COUNT: '2', + GIT_CONFIG_KEY_0: 'credential.helper', + GIT_CONFIG_VALUE_0: '', + GIT_CONFIG_KEY_1: 'credential.helper', + GIT_CONFIG_VALUE_1: '!gh auth git-credential', + }) return { configDirectory, expectedLogin, routedEnvironment } } +function sameArguments(actual, expected) { + return ( + actual.length === expected.length && + actual.every((argument, index) => argument === expected[index]) + ) +} + +function assertGitCommandPolicy(role, args) { + const pushIndex = args.indexOf('push') + if (pushIndex === -1) return + if (role === 'reviewer') throw new Error('reviewer identity cannot run git push') + + const branch = args.at(-1) + const isLoopBranch = /^codex\/issue-\d+$/.test(branch) + const isAllowedShape = + isLoopBranch && + [ + ['push', 'origin', branch], + ['push', '-u', 'origin', branch], + ['push', '--set-upstream', 'origin', branch], + ].some((expected) => sameArguments(args, expected)) + if (!isAllowedShape) { + throw new Error('GitHub automation may push only one explicit loop branch') + } +} + +function argumentValue(args, names) { + for (let index = 0; index < args.length; index += 1) { + const argument = args[index] + if (names.includes(argument)) return args[index + 1] ?? null + for (const name of names) { + if (argument.startsWith(`${name}=`)) return argument.slice(name.length + 1) + } + } + return null +} + +function assertGitHubCliPolicy(role, args) { + const reject = () => { + throw new Error(`GitHub action is prohibited for the ${role} role`) + } + + const pullRequestIndex = args.indexOf('pr') + const pullRequestCommand = pullRequestIndex === -1 ? null : args[pullRequestIndex + 1] + if (pullRequestCommand === 'merge') reject() + if (pullRequestCommand === 'review') { + if (role === 'automation') reject() + const isComment = args.includes('--comment') || args.includes('-c') + const isApproval = args.includes('--approve') || args.includes('-a') + const requestsChanges = args.includes('--request-changes') || args.includes('-r') + if (!isComment || isApproval || requestsChanges) reject() + } + const apiIndex = args.indexOf('api') + if (apiIndex === -1) return + const apiArguments = args.slice(apiIndex + 1) + + const hasRequestBody = apiArguments.some( + (argument) => + ['-f', '-F', '--field', '--raw-field', '--input'].includes(argument) || + ['--field=', '--raw-field=', '--input='].some((prefix) => argument.startsWith(prefix)), + ) + const method = ( + argumentValue(apiArguments, ['--method', '-X']) ?? (hasRequestBody ? 'POST' : 'GET') + ).toUpperCase() + const mutating = method !== 'GET' + const endpoint = apiArguments.find( + (argument) => argument === 'graphql' || /^\/?repos\/[^/]+\/[^/]+\//.test(argument), + ) + if (endpoint === 'graphql') reject() + if (role === 'reviewer' && mutating) reject() + if ( + role === 'automation' && + mutating && + (/\/pulls\/\d+\/merge(?:\?|$)/.test(endpoint ?? '') || + /\/pulls\/\d+\/reviews(?:\?|$)/.test(endpoint ?? '')) + ) { + reject() + } +} + export async function assertGitHubRoleIdentity({ channel, role, @@ -85,29 +178,10 @@ export async function runWithGitHubRole({ spawnCommand = spawn, }) { const executable = assertNonEmpty(command, 'command') - if (path.basename(executable) === 'git' && args[0] === 'push') { - if (role === 'reviewer') throw new Error('reviewer identity cannot run git push') - const hasForce = args.some((argument) => argument === '-f' || argument.startsWith('--force')) - const targetsProtectedBranch = args.some((argument) => { - const destination = argument.includes(':') ? argument.slice(argument.lastIndexOf(':') + 1) : argument - return ['dev', 'main', 'refs/heads/dev', 'refs/heads/main'].includes(destination) - }) - if (hasForce || targetsProtectedBranch) { - throw new Error('force-push and protected-branch pushes are prohibited') - } - } + if (path.basename(executable) === 'git') assertGitCommandPolicy(role, args) + if (path.basename(executable) === 'gh') assertGitHubCliPolicy(role, args) const resolved = await assertGitHubRoleIdentity({ channel, role, environment }) - const routedArgs = - path.basename(executable) === 'git' - ? [ - '-c', - 'credential.helper=', - '-c', - 'credential.helper=!gh auth git-credential', - ...args, - ] - : args - const child = spawnCommand(executable, routedArgs, { + const child = spawnCommand(executable, args, { env: resolved.routedEnvironment, stdio: 'inherit', shell: false, diff --git a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs index f4d9161e..ddedfae1 100644 --- a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs +++ b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs @@ -49,6 +49,10 @@ async function createFixture() { fakeGh, `#!/bin/sh if [ "$1 $2 $3 $4" != "api user --jq .login" ]; then + if [ "$1 $2 $4" = "pr review --comment" ]; then + echo "comment review published" + exit 0 + fi echo "unexpected gh arguments" >&2 exit 90 fi @@ -62,7 +66,7 @@ sed -n '1p' "$GH_CONFIG_DIR/identity" await writeFile( fakeGit, `#!/bin/sh -node -e 'process.stdout.write(JSON.stringify({args: process.argv.slice(1), config: process.env.GH_CONFIG_DIR, hasGhToken: Boolean(process.env.GH_TOKEN || process.env.GITHUB_TOKEN)}))' -- "$@" +node -e 'process.stdout.write(JSON.stringify({args: process.argv.slice(1), config: process.env.GH_CONFIG_DIR, hasGhToken: Boolean(process.env.GH_TOKEN || process.env.GITHUB_TOKEN), gitConfig: [process.env.GIT_CONFIG_COUNT, process.env.GIT_CONFIG_KEY_0, process.env.GIT_CONFIG_VALUE_0, process.env.GIT_CONFIG_KEY_1, process.env.GIT_CONFIG_VALUE_1]}))' -- "$@" `, 'utf8', ) @@ -95,13 +99,29 @@ test('automation role selects its dedicated gh profile without leaking token ove '-e', `process.stdout.write(JSON.stringify({ config: process.env.GH_CONFIG_DIR, - hasGhToken: Boolean(process.env.GH_TOKEN || process.env.GITHUB_TOKEN) + hasGhToken: Boolean(process.env.GH_TOKEN || process.env.GITHUB_TOKEN), + gitConfig: [ + process.env.GIT_CONFIG_COUNT, + process.env.GIT_CONFIG_KEY_0, + process.env.GIT_CONFIG_VALUE_0, + process.env.GIT_CONFIG_KEY_1, + process.env.GIT_CONFIG_VALUE_1 + ], + gitIsolation: [process.env.GIT_CONFIG_GLOBAL, process.env.GIT_CONFIG_NOSYSTEM] }))`, ] const { stdout } = await execFileAsync(process.execPath, command, { env: fixture.env }) assert.deepEqual(JSON.parse(stdout), { config: fixture.automationProfile, hasGhToken: false, + gitConfig: [ + '2', + 'credential.helper', + '', + 'credential.helper', + '!gh auth git-credential', + ], + gitIsolation: [os.devNull, '1'], }) }) @@ -147,14 +167,13 @@ test('automation git command clears global helpers and injects the selected gh c const observed = JSON.parse(stdout) assert.equal(observed.config, fixture.automationProfile) assert.equal(observed.hasGhToken, false) - assert.deepEqual(observed.args, [ - '-c', - 'credential.helper=', - '-c', - 'credential.helper=!gh auth git-credential', - 'push', - 'origin', - 'codex/issue-123', + assert.deepEqual(observed.args, ['push', 'origin', 'codex/issue-123']) + assert.deepEqual(observed.gitConfig, [ + '2', + 'credential.helper', + '', + 'credential.helper', + '!gh auth git-credential', ]) }) @@ -180,12 +199,17 @@ test('reviewer identity cannot push code', async () => { ) }) -test('automation identity cannot force-push or push protected branches', async () => { +test('automation identity allows only one explicit loop branch push shape', async () => { const fixture = await createFixture() for (const gitArguments of [ ['push', '--force', 'origin', 'codex/issue-123'], + ['push', 'origin', '+codex/issue-123'], + ['push', '--all', 'origin'], + ['push', 'origin'], + ['push', 'origin', 'feature/unrelated'], ['push', 'origin', 'dev'], ['push', 'origin', 'HEAD:main'], + ['-C', '.', 'push', 'origin', 'codex/issue-123'], ]) { await assert.rejects( execFileAsync( @@ -201,7 +225,65 @@ test('automation identity cannot force-push or push protected branches', async ( ], { env: fixture.env }, ), - /force-push and protected-branch pushes are prohibited/, + /automation may push only one explicit loop branch/, + ) + } +}) + +test('non-owner roles cannot merge or approve pull requests through gh', async () => { + const fixture = await createFixture() + const forbidden = [ + ['automation', ['pr', 'merge', '106']], + ['automation', ['--repo', 'example/repo', 'pr', 'merge', '106']], + ['automation', ['pr', 'review', '106', '--approve']], + ['reviewer', ['pr', 'merge', '106']], + ['reviewer', ['pr', 'review', '106', '--approve']], + ['reviewer', ['api', '--method', 'POST', 'repos/example/repo/pulls/106/reviews']], + ['reviewer', ['api', 'repos/example/repo/pulls/106/reviews', '-f', 'event=APPROVE']], + ['automation', ['api', 'graphql', '-f', 'query=mutation { test }']], + ['automation', ['api', '--method', 'PUT', 'repos/example/repo/pulls/106/merge']], + ['automation', ['api', '--method', 'PUT', '/repos/example/repo/pulls/106/merge']], + ['automation', ['api', 'repos/example/repo/pulls/106/reviews', '-f', 'event=APPROVE']], + ] + for (const [role, ghArguments] of forbidden) { + await assert.rejects( + execFileAsync( + process.execPath, + [ + routerPath, + '--loop-root', + fixture.loopRoot, + role, + '--', + 'gh', + ...ghArguments, + ], + { env: fixture.env }, + ), + /GitHub action is prohibited for the (automation|reviewer) role/, ) } }) + +test('reviewer role may publish only a non-approving comment review', async () => { + const fixture = await createFixture() + const { stdout } = await execFileAsync( + process.execPath, + [ + routerPath, + '--loop-root', + fixture.loopRoot, + 'reviewer', + '--', + 'gh', + 'pr', + 'review', + '106', + '--comment', + '--body', + 'PASS', + ], + { env: fixture.env }, + ) + assert.equal(stdout.trim(), 'comment review published') +}) From 6fcbe925c55d30c30a86dd37dff2476fcaa22633 Mon Sep 17 00:00:00 2001 From: leyoonafr <codeacme17@gmail.com> Date: Thu, 23 Jul 2026 16:13:53 +0800 Subject: [PATCH 18/41] fix(loop): gate descendant GitHub commands --- loops/_shared/owner-channel/CHANNEL.md | 2 +- loops/issue-dev-loop/dependencies.md | 2 +- .../references/github-operations.md | 4 +- .../scripts/github-command-gate.mjs | 46 +++ loops/issue-dev-loop/scripts/identity-bin/gh | 2 + loops/issue-dev-loop/scripts/identity-bin/git | 2 + .../scripts/lib/github-identity.mjs | 348 +++++++++++++++--- .../issue-dev-loop/scripts/lib/validation.mjs | 3 + .../scripts/with-github-identity.mjs | 0 .../tests/github-identity-routing.test.mjs | 126 ++++++- loops/issue-dev-loop/tests/runtime.test.mjs | 13 +- 11 files changed, 468 insertions(+), 80 deletions(-) create mode 100755 loops/issue-dev-loop/scripts/github-command-gate.mjs create mode 100755 loops/issue-dev-loop/scripts/identity-bin/gh create mode 100755 loops/issue-dev-loop/scripts/identity-bin/git mode change 100644 => 100755 loops/issue-dev-loop/scripts/with-github-identity.mjs diff --git a/loops/_shared/owner-channel/CHANNEL.md b/loops/_shared/owner-channel/CHANNEL.md index f06e1ad7..c55d5d20 100644 --- a/loops/_shared/owner-channel/CHANNEL.md +++ b/loops/_shared/owner-channel/CHANNEL.md @@ -15,7 +15,7 @@ Each blocking GitHub notification prints a unique resume instruction. To continu 1. Authenticate the unattended executor and fresh reviewer with distinct GitHub identities. Their exact logins and the names of their profile-path environment variables live in `channel.json`. For the current configuration, set `ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR` to the `Ethandasw` `gh` profile directory and `ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR` to the `Traviinam` profile directory in the scheduler environment. The directory names themselves are local details and do not need to match the roles. 2. Run `node loops/issue-dev-loop/scripts/loopctl.mjs validate --activation` before scheduling. It independently queries both profiles and rejects missing, overlapping, owner-authenticated, or incorrectly routed identities. -3. Run every executor GitHub command, remote Git command, and GitHub-backed `loopctl` command through `with-github-identity.mjs automation -- ...`. Run reviewer publication commands through the same wrapper with `reviewer`. The wrapper clears token overrides, verifies `gh api user`, and gives Git a one-command credential helper without changing global Git or `gh` configuration. +3. Run every executor GitHub command, remote Git command, and GitHub-backed `loopctl` command through `with-github-identity.mjs automation -- ...`. Run reviewer publication commands through the same wrapper with `reviewer`. The wrapper clears token overrides, verifies `gh api user`, and gives Git a one-command credential helper without changing global Git or `gh` configuration. A PATH gate applies the role policy to descendant `git` and `gh` processes too; arbitrary shell, environment, or Node command trees are rejected. 4. Create one dedicated repository issue for the append-only loop state journal and set its number as `stateIssueNumber`. It stores active checkpoints and terminal records. Restrict journal entries to the automation identity; humans may read but should not edit or delete them. 5. Enable GitHub notifications for mentions and review requests for `codeacme17`. 6. Optionally set `ECHO_UI_LOOP_OWNER_WEBHOOK_URL` to an endpoint that accepts the notification JSON with `Content-Type: application/json`. diff --git a/loops/issue-dev-loop/dependencies.md b/loops/issue-dev-loop/dependencies.md index 5953acf2..fda0f58c 100644 --- a/loops/issue-dev-loop/dependencies.md +++ b/loops/issue-dev-loop/dependencies.md @@ -24,4 +24,4 @@ Use `Ethandasw` for executor-created branches, PRs, replies, and durable journal entries, and `Traviinam` for fresh-context review publication. Neither may be `codeacme17`; neither may have branch-protection bypass or merge authority. The executor needs repository write access. The reviewer needs no repository write access. -Never run `gh auth setup-git` for this loop. Route commands through `scripts/with-github-identity.mjs`; it scopes `GH_CONFIG_DIR` and the Git credential helper to one child process, leaving the user's default `gh` account and global Git credential configuration unchanged. +Never run `gh auth setup-git` for this loop. Route commands through `scripts/with-github-identity.mjs`; it scopes `GH_CONFIG_DIR` and the Git credential helper to one allowlisted child tree, gates descendant `git`/`gh` calls, and leaves the user's default `gh` account and global Git credential configuration unchanged. diff --git a/loops/issue-dev-loop/references/github-operations.md b/loops/issue-dev-loop/references/github-operations.md index cda17b4e..26b3ea30 100644 --- a/loops/issue-dev-loop/references/github-operations.md +++ b/loops/issue-dev-loop/references/github-operations.md @@ -8,7 +8,7 @@ Run every executor GitHub command through: node loops/issue-dev-loop/scripts/with-github-identity.mjs automation -- <command> [args...] ``` -Run every reviewer publication command through the same wrapper with role `reviewer`. The wrapper selects the configured `gh` profile, removes token environment overrides, runs `gh api user`, and refuses an unexpected or owner identity. For Git, it clears global credential helpers and injects `gh auth git-credential` for that child process only. Never use owner credentials for executor or reviewer actions, never run raw remote `gh`/`git push` commands, and never call `gh auth setup-git`. +Run every reviewer publication command through the same wrapper with role `reviewer`. The wrapper selects the configured `gh` profile, removes token environment overrides, runs `gh api user`, and refuses an unexpected or owner identity. For Git, it clears global credential helpers and injects `gh auth git-credential` for the entire trusted child tree. Descendant `git` and `gh` processes pass through a role gate; arbitrary `sh`, `env`, and Node command trees are not authenticated. Never use owner credentials for executor or reviewer actions, never run raw remote `gh`/`git push` commands, and never call `gh auth setup-git`. Publish reviewer output only as a non-approving comment review: @@ -16,7 +16,7 @@ Publish reviewer output only as a non-approving comment review: node loops/issue-dev-loop/scripts/with-github-identity.mjs reviewer -- gh pr review <number> --comment --body <review-body> ``` -The router rejects reviewer pushes, mutating reviewer `gh api` calls, approvals, change requests, merges, executor-authored reviews, merge endpoints, and GraphQL. Automation pushes use only `git push origin codex/issue-<number>` or the equivalent `-u`/`--set-upstream` form; every other push shape is rejected. +The router rejects reviewer pushes and all reviewer mutations except `gh pr review --comment`. It rejects approvals, change requests, merges, executor-authored reviews, GraphQL, and administration APIs. Automation API mutations are limited to the issue labels/comments and review-comment replies needed by the loop. Automation pushes use only `git push origin codex/issue-<number>` or the equivalent `-u`/`--set-upstream` form, and the destination must equal the sole active run's recorded branch; every other push shape is rejected. ## Selection and claim diff --git a/loops/issue-dev-loop/scripts/github-command-gate.mjs b/loops/issue-dev-loop/scripts/github-command-gate.mjs new file mode 100755 index 00000000..64824cc9 --- /dev/null +++ b/loops/issue-dev-loop/scripts/github-command-gate.mjs @@ -0,0 +1,46 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process' + +import { assertDescendantCommandPolicy } from './lib/github-identity.mjs' + +async function main() { + const [tool, ...args] = process.argv.slice(2) + const role = process.env.ECHO_UI_LOOP_GITHUB_ROLE + const executable = + tool === 'git' + ? process.env.ECHO_UI_LOOP_REAL_GIT + : tool === 'gh' + ? process.env.ECHO_UI_LOOP_REAL_GH + : null + if (!executable || !['automation', 'reviewer'].includes(role)) { + throw new Error('authenticated command gate is missing its verified runtime context') + } + assertDescendantCommandPolicy({ + role, + tool, + args, + allowedPushBranch: process.env.ECHO_UI_LOOP_ALLOWED_PUSH_BRANCH || null, + }) + const child = spawn(executable, args, { + env: process.env, + stdio: 'inherit', + shell: false, + }) + await new Promise((resolve, reject) => { + child.once('error', reject) + child.once('exit', (code, signal) => { + if (signal) { + reject(new Error(`${tool} terminated by signal ${signal}`)) + return + } + process.exitCode = code ?? 1 + resolve() + }) + }) +} + +main().catch((error) => { + process.stderr.write(`${error.message}\n`) + process.exitCode = 1 +}) diff --git a/loops/issue-dev-loop/scripts/identity-bin/gh b/loops/issue-dev-loop/scripts/identity-bin/gh new file mode 100755 index 00000000..b2d490bd --- /dev/null +++ b/loops/issue-dev-loop/scripts/identity-bin/gh @@ -0,0 +1,2 @@ +#!/bin/sh +exec "$ECHO_UI_LOOP_NODE" "$ECHO_UI_LOOP_IDENTITY_GATE" gh "$@" diff --git a/loops/issue-dev-loop/scripts/identity-bin/git b/loops/issue-dev-loop/scripts/identity-bin/git new file mode 100755 index 00000000..dcf0fbb3 --- /dev/null +++ b/loops/issue-dev-loop/scripts/identity-bin/git @@ -0,0 +1,2 @@ +#!/bin/sh +exec "$ECHO_UI_LOOP_NODE" "$ECHO_UI_LOOP_IDENTITY_GATE" git "$@" diff --git a/loops/issue-dev-loop/scripts/lib/github-identity.mjs b/loops/issue-dev-loop/scripts/lib/github-identity.mjs index ef626873..d4c23ca0 100644 --- a/loops/issue-dev-loop/scripts/lib/github-identity.mjs +++ b/loops/issue-dev-loop/scripts/lib/github-identity.mjs @@ -1,11 +1,17 @@ import { execFile, spawn } from 'node:child_process' +import { constants } from 'node:fs' +import { access, readdir } from 'node:fs/promises' import { devNull } from 'node:os' import path from 'node:path' import { promisify } from 'node:util' +import { fileURLToPath } from 'node:url' import { assertNonEmpty, readJson, sameGitHubLogin } from './common.mjs' const execFileAsync = promisify(execFile) +const moduleDirectory = path.dirname(fileURLToPath(import.meta.url)) +const identityBinDirectory = path.resolve(moduleDirectory, '..', 'identity-bin') +const commandGatePath = path.resolve(moduleDirectory, '..', 'github-command-gate.mjs') const roleFields = { automation: { login: 'automationGitHubLogin', @@ -39,20 +45,28 @@ export function resolveGitHubRoleEnvironment({ channel, role, environment = proc throw new Error(`${variableName} must contain an absolute directory path`) } - const routedEnvironment = { - ...environment, - GH_CONFIG_DIR: configDirectory, - } - for (const tokenName of [ + const routedEnvironment = { ...environment, GH_CONFIG_DIR: configDirectory } + for (const name of [ 'GH_TOKEN', 'GITHUB_TOKEN', 'GH_ENTERPRISE_TOKEN', 'GITHUB_ENTERPRISE_TOKEN', ]) { - delete routedEnvironment[tokenName] + delete routedEnvironment[name] } for (const name of Object.keys(routedEnvironment)) { - if (/^GIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+)$/.test(name)) delete routedEnvironment[name] + if ( + /^GIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+)$/.test(name) || + name.startsWith('ECHO_UI_LOOP_REAL_') || + [ + 'ECHO_UI_LOOP_GITHUB_ROLE', + 'ECHO_UI_LOOP_IDENTITY_GATE', + 'ECHO_UI_LOOP_NODE', + 'ECHO_UI_LOOP_ALLOWED_PUSH_BRANCH', + ].includes(name) + ) { + delete routedEnvironment[name] + } } Object.assign(routedEnvironment, { GIT_CONFIG_GLOBAL: devNull, @@ -63,7 +77,6 @@ export function resolveGitHubRoleEnvironment({ channel, role, environment = proc GIT_CONFIG_KEY_1: 'credential.helper', GIT_CONFIG_VALUE_1: '!gh auth git-credential', }) - return { configDirectory, expectedLogin, routedEnvironment } } @@ -74,23 +87,82 @@ function sameArguments(actual, expected) { ) } -function assertGitCommandPolicy(role, args) { - const pushIndex = args.indexOf('push') - if (pushIndex === -1) return - if (role === 'reviewer') throw new Error('reviewer identity cannot run git push') +function gitSubcommand(args) { + let index = 0 + while (index < args.length && args[index].startsWith('-')) { + const argument = args[index] + if (['-C', '--git-dir', '--work-tree', '--namespace'].includes(argument)) { + index += 2 + continue + } + if ( + ['--no-pager', '--paginate', '--literal-pathspecs', '--no-optional-locks'].includes(argument) || + ['--git-dir=', '--work-tree=', '--namespace='].some((prefix) => + argument.startsWith(prefix), + ) + ) { + index += 1 + continue + } + return { index, name: null } + } + return { index, name: args[index] ?? null } +} + +export function assertGitCommandPolicy(role, args, { allowedPushBranch = null } = {}) { + const subcommand = gitSubcommand(args) + if (subcommand.name === 'push') { + if (role === 'reviewer') throw new Error('reviewer identity cannot run git push') + const branch = args.at(-1) + const isLoopBranch = /^codex\/issue-\d+$/.test(branch) && branch === allowedPushBranch + const isAllowedShape = + subcommand.index === 0 && + isLoopBranch && + [ + ['push', 'origin', branch], + ['push', '-u', 'origin', branch], + ['push', '--set-upstream', 'origin', branch], + ].some((expected) => sameArguments(args, expected)) + if (!isAllowedShape) { + throw new Error('GitHub automation may push only one explicit loop branch') + } + return + } - const branch = args.at(-1) - const isLoopBranch = /^codex\/issue-\d+$/.test(branch) - const isAllowedShape = - isLoopBranch && - [ - ['push', 'origin', branch], - ['push', '-u', 'origin', branch], - ['push', '--set-upstream', 'origin', branch], - ].some((expected) => sameArguments(args, expected)) - if (!isAllowedShape) { - throw new Error('GitHub automation may push only one explicit loop branch') + const readOnly = new Set([ + 'rev-parse', + 'status', + 'merge-base', + 'show', + 'diff', + 'log', + 'ls-remote', + ]) + if (readOnly.has(subcommand.name)) return + if ( + subcommand.name === 'branch' && + args.slice(subcommand.index + 1).every((argument) => + ['--show-current', '--list', '-l'].includes(argument), + ) + ) { + return } + if ( + subcommand.name === 'config' && + args.some((argument) => + ['--get', '--get-all', '--get-regexp', '--list', '-l', '--show-origin'].includes(argument), + ) + ) { + return + } + if ( + subcommand.name === 'remote' && + ['get-url', '-v'].includes(args[subcommand.index + 1]) + ) { + return + } + if (role === 'automation' && subcommand.name === 'fetch') return + throw new Error(`git command is outside the authenticated ${role} command tree`) } function argumentValue(args, names) { @@ -104,47 +176,195 @@ function argumentValue(args, names) { return null } -function assertGitHubCliPolicy(role, args) { - const reject = () => { - throw new Error(`GitHub action is prohibited for the ${role} role`) +function commandAfterGroup(args, groupIndex) { + let index = groupIndex + 1 + while (index < args.length) { + const argument = args[index] + if (['--repo', '-R', '--hostname'].includes(argument)) { + index += 2 + continue + } + if (argument.startsWith('-')) { + index += 1 + continue + } + return argument } + return null +} - const pullRequestIndex = args.indexOf('pr') - const pullRequestCommand = pullRequestIndex === -1 ? null : args[pullRequestIndex + 1] - if (pullRequestCommand === 'merge') reject() - if (pullRequestCommand === 'review') { - if (role === 'automation') reject() - const isComment = args.includes('--comment') || args.includes('-c') - const isApproval = args.includes('--approve') || args.includes('-a') - const requestsChanges = args.includes('--request-changes') || args.includes('-r') - if (!isComment || isApproval || requestsChanges) reject() - } - const apiIndex = args.indexOf('api') - if (apiIndex === -1) return - const apiArguments = args.slice(apiIndex + 1) +function githubGroup(args) { + const groups = new Set(['api', 'issue', 'pr', 'run', 'repo']) + const index = args.findIndex((argument) => groups.has(argument)) + return { index, name: index === -1 ? null : args[index] } +} +function githubApiRequest(apiArguments) { + const explicitMethod = + argumentValue(apiArguments, ['--method', '-X']) ?? + apiArguments.find((argument) => /^-X[A-Za-z]+$/.test(argument))?.slice(2) const hasRequestBody = apiArguments.some( (argument) => ['-f', '-F', '--field', '--raw-field', '--input'].includes(argument) || ['--field=', '--raw-field=', '--input='].some((prefix) => argument.startsWith(prefix)), ) - const method = ( - argumentValue(apiArguments, ['--method', '-X']) ?? (hasRequestBody ? 'POST' : 'GET') - ).toUpperCase() - const mutating = method !== 'GET' - const endpoint = apiArguments.find( - (argument) => argument === 'graphql' || /^\/?repos\/[^/]+\/[^/]+\//.test(argument), - ) - if (endpoint === 'graphql') reject() - if (role === 'reviewer' && mutating) reject() + const method = (explicitMethod ?? (hasRequestBody ? 'POST' : 'GET')).toUpperCase() + const endpoint = + apiArguments.find((argument) => /^\/?(?:graphql|repos\/[^/]+\/[^/]+\/)/.test(argument)) ?? + null + return { + endpoint: endpoint?.replace(/^\//, '').split('?')[0] ?? null, + method, + mutating: method !== 'GET', + } +} + +function automationApiMutationAllowed({ endpoint, method }) { + if (!endpoint) return false if ( - role === 'automation' && - mutating && - (/\/pulls\/\d+\/merge(?:\?|$)/.test(endpoint ?? '') || - /\/pulls\/\d+\/reviews(?:\?|$)/.test(endpoint ?? '')) + /^repos\/[^/]+\/[^/]+\/issues\/\d+\/labels(?:\/[^/]+)?$/.test(endpoint) && + ['POST', 'DELETE'].includes(method) ) { - reject() + return true } + if ( + /^repos\/[^/]+\/[^/]+\/issues\/\d+\/comments$/.test(endpoint) && + method === 'POST' + ) { + return true + } + if ( + /^repos\/[^/]+\/[^/]+\/(?:issues|pulls)\/comments\/\d+$/.test(endpoint) && + method === 'PATCH' + ) { + return true + } + return ( + /^repos\/[^/]+\/[^/]+\/pulls\/\d+\/comments\/\d+\/replies$/.test(endpoint) && + method === 'POST' + ) +} + +export function assertGitHubCliPolicy(role, args) { + const group = githubGroup(args) + const reject = () => { + throw new Error(`GitHub action is prohibited for the ${role} role`) + } + if (!group.name) reject() + const subcommand = commandAfterGroup(args, group.index) + + if (role === 'reviewer') { + if (group.name === 'api') { + const request = githubApiRequest(args.slice(group.index + 1)) + if (request.mutating || request.endpoint === 'graphql') reject() + return + } + if (group.name === 'run' && subcommand === 'view') return + if (group.name !== 'pr') reject() + if (['view', 'diff', 'checks'].includes(subcommand)) return + if (subcommand !== 'review') reject() + const isComment = args.includes('--comment') || args.includes('-c') + const isApproval = args.includes('--approve') || args.includes('-a') + const requestsChanges = args.includes('--request-changes') || args.includes('-r') + if (!isComment || isApproval || requestsChanges) reject() + return + } + + if (group.name === 'issue') { + if (!['list', 'view', 'comment', 'edit'].includes(subcommand)) reject() + return + } + if (group.name === 'pr') { + if ( + !['list', 'view', 'create', 'edit', 'comment', 'ready', 'checks', 'diff'].includes( + subcommand, + ) + ) { + reject() + } + return + } + if (group.name === 'run') { + if (!['list', 'view', 'download'].includes(subcommand)) reject() + return + } + if (group.name !== 'api') reject() + const request = githubApiRequest(args.slice(group.index + 1)) + if (request.endpoint === 'graphql') reject() + if (!request.mutating) return + if (!automationApiMutationAllowed(request)) reject() +} + +export function assertDescendantCommandPolicy({ role, tool, args, allowedPushBranch = null }) { + if (tool === 'git') { + assertGitCommandPolicy(role, args, { allowedPushBranch }) + return + } + if (tool === 'gh') { + assertGitHubCliPolicy(role, args) + return + } + throw new Error(`unsupported authenticated tool: ${tool}`) +} + +function assertRootCommandPolicy({ role, executable, args, loopRoot, allowedPushBranch }) { + const command = path.basename(executable) + if (command === 'git') { + assertGitCommandPolicy(role, args, { allowedPushBranch }) + return + } + if (command === 'gh') { + assertGitHubCliPolicy(role, args) + return + } + if (role === 'automation' && command.startsWith('node')) { + const script = args[0] ? path.resolve(args[0]) : null + const allowedScripts = new Set([ + path.resolve(loopRoot, 'scripts', 'loopctl.mjs'), + path.resolve(loopRoot, 'triggers', 'detect-work.mjs'), + ]) + if (script && allowedScripts.has(script)) return + } + throw new Error(`${command} is outside the authenticated ${role} command tree`) +} + +async function resolveExecutable(name, environment) { + for (const directory of (environment.PATH ?? '').split(path.delimiter)) { + if (!directory) continue + const candidate = path.join(directory, name) + try { + await access(candidate, constants.X_OK) + return candidate + } catch (error) { + if (error?.code !== 'ENOENT' && error?.code !== 'EACCES') throw error + } + } + throw new Error(`required executable is unavailable: ${name}`) +} + +function shellQuote(value) { + return `'${value.replaceAll("'", "'\\''")}'` +} + +async function readActivePushBranch(loopRoot) { + const runsRoot = path.join(loopRoot, 'logs', 'runs') + let entries + try { + entries = await readdir(runsRoot, { withFileTypes: true }) + } catch (error) { + if (error?.code === 'ENOENT') return null + throw error + } + const active = [] + for (const entry of entries) { + if (!entry.isDirectory()) continue + const run = await readJson(path.join(runsRoot, entry.name, 'run.json')) + if (['running', 'waiting_for_owner', 'awaiting_owner_review'].includes(run.status)) { + active.push(run.branch) + } + } + if (active.length > 1) throw new Error('multiple active runs cannot authorize a Git push') + return active[0] ?? null } export async function assertGitHubRoleIdentity({ @@ -170,6 +390,7 @@ export async function assertGitHubRoleIdentity({ } export async function runWithGitHubRole({ + loopRoot, channel, role, command, @@ -178,11 +399,26 @@ export async function runWithGitHubRole({ spawnCommand = spawn, }) { const executable = assertNonEmpty(command, 'command') - if (path.basename(executable) === 'git') assertGitCommandPolicy(role, args) - if (path.basename(executable) === 'gh') assertGitHubCliPolicy(role, args) const resolved = await assertGitHubRoleIdentity({ channel, role, environment }) + const allowedPushBranch = await readActivePushBranch(loopRoot) + assertRootCommandPolicy({ role, executable, args, loopRoot, allowedPushBranch }) + const [realGit, realGh] = await Promise.all([ + resolveExecutable('git', resolved.routedEnvironment), + resolveExecutable('gh', resolved.routedEnvironment), + ]) + const childEnvironment = { + ...resolved.routedEnvironment, + GIT_CONFIG_VALUE_1: `!${shellQuote(realGh)} auth git-credential`, + PATH: `${identityBinDirectory}${path.delimiter}${resolved.routedEnvironment.PATH ?? ''}`, + ECHO_UI_LOOP_GITHUB_ROLE: role, + ECHO_UI_LOOP_REAL_GIT: realGit, + ECHO_UI_LOOP_REAL_GH: realGh, + ECHO_UI_LOOP_IDENTITY_GATE: commandGatePath, + ECHO_UI_LOOP_NODE: process.execPath, + ECHO_UI_LOOP_ALLOWED_PUSH_BRANCH: allowedPushBranch ?? '', + } const child = spawnCommand(executable, args, { - env: resolved.routedEnvironment, + env: childEnvironment, stdio: 'inherit', shell: false, }) diff --git a/loops/issue-dev-loop/scripts/lib/validation.mjs b/loops/issue-dev-loop/scripts/lib/validation.mjs index b01ae11f..c6bd97d7 100644 --- a/loops/issue-dev-loop/scripts/lib/validation.mjs +++ b/loops/issue-dev-loop/scripts/lib/validation.mjs @@ -51,6 +51,9 @@ export async function validateLoop({ 'scripts/lib/active-journal.mjs', 'scripts/lib/github.mjs', 'scripts/lib/github-identity.mjs', + 'scripts/github-command-gate.mjs', + 'scripts/identity-bin/gh', + 'scripts/identity-bin/git', 'scripts/lib/issue-claim.mjs', 'scripts/lib/notifications.mjs', 'scripts/lib/owner-gate.mjs', diff --git a/loops/issue-dev-loop/scripts/with-github-identity.mjs b/loops/issue-dev-loop/scripts/with-github-identity.mjs old mode 100644 new mode 100755 diff --git a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs index ddedfae1..0eda1dc7 100644 --- a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs +++ b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs @@ -25,6 +25,8 @@ async function createFixture() { const reviewerProfile = path.join(parent, 'reviewer-profile') await Promise.all([ mkdir(channelRoot, { recursive: true }), + mkdir(path.join(loopRoot, 'scripts'), { recursive: true }), + mkdir(path.join(loopRoot, 'logs', 'runs', 'fixture-run'), { recursive: true }), mkdir(binRoot, { recursive: true }), mkdir(automationProfile, { recursive: true }), mkdir(reviewerProfile, { recursive: true }), @@ -43,6 +45,43 @@ async function createFixture() { ) await writeFile(path.join(automationProfile, 'identity'), 'executor-user\n', 'utf8') await writeFile(path.join(reviewerProfile, 'identity'), 'reviewer-user\n', 'utf8') + await writeFile( + path.join(loopRoot, 'logs', 'runs', 'fixture-run', 'run.json'), + `${JSON.stringify({ + runId: 'fixture-run', + status: 'running', + branch: 'codex/issue-123', + })}\n`, + 'utf8', + ) + const loopctlPath = path.join(loopRoot, 'scripts', 'loopctl.mjs') + await writeFile( + loopctlPath, + `import { spawnSync } from 'node:child_process' + +if (process.argv[2] === 'spawn') { + const result = spawnSync(process.argv[3], process.argv.slice(4), { + env: process.env, + stdio: 'inherit', + }) + process.exitCode = result.status ?? 1 +} else { + process.stdout.write(JSON.stringify({ + config: process.env.GH_CONFIG_DIR, + hasGhToken: Boolean(process.env.GH_TOKEN || process.env.GITHUB_TOKEN), + gitConfig: [ + process.env.GIT_CONFIG_COUNT, + process.env.GIT_CONFIG_KEY_0, + process.env.GIT_CONFIG_VALUE_0, + process.env.GIT_CONFIG_KEY_1, + process.env.GIT_CONFIG_VALUE_1 + ], + gitIsolation: [process.env.GIT_CONFIG_GLOBAL, process.env.GIT_CONFIG_NOSYSTEM] + })) +} +`, + 'utf8', + ) const fakeGh = path.join(binRoot, 'gh') await writeFile( @@ -74,6 +113,8 @@ node -e 'process.stdout.write(JSON.stringify({args: process.argv.slice(1), confi return { loopRoot, + loopctlPath, + fakeGh, automationProfile, reviewerProfile, env: { @@ -96,19 +137,7 @@ test('automation role selects its dedicated gh profile without leaking token ove 'automation', '--', process.execPath, - '-e', - `process.stdout.write(JSON.stringify({ - config: process.env.GH_CONFIG_DIR, - hasGhToken: Boolean(process.env.GH_TOKEN || process.env.GITHUB_TOKEN), - gitConfig: [ - process.env.GIT_CONFIG_COUNT, - process.env.GIT_CONFIG_KEY_0, - process.env.GIT_CONFIG_VALUE_0, - process.env.GIT_CONFIG_KEY_1, - process.env.GIT_CONFIG_VALUE_1 - ], - gitIsolation: [process.env.GIT_CONFIG_GLOBAL, process.env.GIT_CONFIG_NOSYSTEM] - }))`, + fixture.loopctlPath, ] const { stdout } = await execFileAsync(process.execPath, command, { env: fixture.env }) assert.deepEqual(JSON.parse(stdout), { @@ -119,7 +148,7 @@ test('automation role selects its dedicated gh profile without leaking token ove 'credential.helper', '', 'credential.helper', - '!gh auth git-credential', + `!'${fixture.fakeGh}' auth git-credential`, ], gitIsolation: [os.devNull, '1'], }) @@ -173,7 +202,7 @@ test('automation git command clears global helpers and injects the selected gh c 'credential.helper', '', 'credential.helper', - '!gh auth git-credential', + `!'${fixture.fakeGh}' auth git-credential`, ]) }) @@ -207,6 +236,7 @@ test('automation identity allows only one explicit loop branch push shape', asyn ['push', '--all', 'origin'], ['push', 'origin'], ['push', 'origin', 'feature/unrelated'], + ['push', 'origin', 'codex/issue-124'], ['push', 'origin', 'dev'], ['push', 'origin', 'HEAD:main'], ['-C', '.', 'push', 'origin', 'codex/issue-123'], @@ -287,3 +317,69 @@ test('reviewer role may publish only a non-approving comment review', async () = ) assert.equal(stdout.trim(), 'comment review published') }) + +test('authenticated command trees reject shell, env, arbitrary node, and descendant push bypasses', async () => { + const fixture = await createFixture() + const forbiddenCommands = [ + ['automation', ['env', 'git', 'push', 'origin', 'main']], + ['automation', ['sh', '-c', 'git push origin main']], + ['automation', [process.execPath, '-e', 'process.exit(0)']], + [ + 'automation', + [process.execPath, fixture.loopctlPath, 'spawn', 'env', 'git', 'push', 'origin', 'main'], + ], + ['reviewer', ['env', 'git', 'push', 'origin', 'main']], + ] + for (const [role, command] of forbiddenCommands) { + await assert.rejects( + execFileAsync( + process.execPath, + [ + routerPath, + '--loop-root', + fixture.loopRoot, + role, + '--', + command[0], + ...command.slice(1), + ], + { env: fixture.env }, + ), + /(outside the authenticated|reviewer identity cannot run git push|automation may push only)/, + ) + } +}) + +test('GitHub role allowlists reject alternate merge syntax and unrelated reviewer or admin mutations', async () => { + const fixture = await createFixture() + const forbidden = [ + ['reviewer', ['pr', 'comment', '106', '--body', 'not a review']], + ['reviewer', ['issue', 'comment', '1', '--body', 'not allowed']], + ['reviewer', ['repo', 'edit', '--enable-issues=false']], + ['automation', ['pr', '--repo', 'example/repo', 'merge', '106']], + ['automation', ['api', '-XPUT', 'repos/example/repo/pulls/106/merge']], + ['automation', ['api', '/graphql', '-f', 'query=mutation { test }']], + [ + 'automation', + ['api', '--method', 'DELETE', 'repos/example/repo/branches/dev/protection'], + ], + ] + for (const [role, ghArguments] of forbidden) { + await assert.rejects( + execFileAsync( + process.execPath, + [ + routerPath, + '--loop-root', + fixture.loopRoot, + role, + '--', + 'gh', + ...ghArguments, + ], + { env: fixture.env }, + ), + /GitHub action is prohibited for the (automation|reviewer) role/, + ) + } +}) diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index 77d89824..d11aad04 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -2710,14 +2710,17 @@ test('repository loop package satisfies its structural invariants', async () => }) test('repository activation verifies both configured GitHub profiles', async () => { + const profileRoot = path.join(os.tmpdir(), 'echo-ui-activation-profiles') + const automationProfile = path.join(profileRoot, 'automation') + const reviewerProfile = path.join(profileRoot, 'reviewer') const environment = { - ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR: '/tmp/echo-ui-automation-profile', - ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR: '/tmp/echo-ui-reviewer-profile', + ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR: automationProfile, + ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR: reviewerProfile, } const observedProfiles = [] const identityCommand = async (_command, _args, options) => { observedProfiles.push(options.env.GH_CONFIG_DIR) - const login = options.env.GH_CONFIG_DIR.endsWith('automation-profile') + const login = options.env.GH_CONFIG_DIR === automationProfile ? 'Ethandasw' : 'Traviinam' return { stdout: `${login}\n` } @@ -2730,7 +2733,7 @@ test('repository activation verifies both configured GitHub profiles', async () }) assert.equal(result.valid, true) assert.deepEqual(observedProfiles, [ - '/tmp/echo-ui-automation-profile', - '/tmp/echo-ui-reviewer-profile', + automationProfile, + reviewerProfile, ]) }) From fd107918b6e807e7e4dbd4dee2a241ebf3f12064 Mon Sep 17 00:00:00 2001 From: leyoonafr <codeacme17@gmail.com> Date: Thu, 23 Jul 2026 16:16:39 +0800 Subject: [PATCH 19/41] chore(loop): sync bootstrap PR head From d7b2545d076a368dfff804582690364b637ea531 Mon Sep 17 00:00:00 2001 From: leyoonafr <codeacme17@gmail.com> Date: Thu, 23 Jul 2026 16:43:42 +0800 Subject: [PATCH 20/41] fix(loop): close identity routing bypasses --- .../scripts/github-command-gate.mjs | 47 ++- .../scripts/lib/github-identity.mjs | 389 ++++++++++++++---- .../tests/github-identity-routing.test.mjs | 160 ++++--- 3 files changed, 451 insertions(+), 145 deletions(-) diff --git a/loops/issue-dev-loop/scripts/github-command-gate.mjs b/loops/issue-dev-loop/scripts/github-command-gate.mjs index 64824cc9..dd306719 100755 --- a/loops/issue-dev-loop/scripts/github-command-gate.mjs +++ b/loops/issue-dev-loop/scripts/github-command-gate.mjs @@ -1,28 +1,47 @@ #!/usr/bin/env node import { spawn } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' -import { assertDescendantCommandPolicy } from './lib/github-identity.mjs' +import { + assertDescendantCommandPolicy, + assertPushTargetsRepository, + resolveExecutable, +} from './lib/github-identity.mjs' async function main() { const [tool, ...args] = process.argv.slice(2) const role = process.env.ECHO_UI_LOOP_GITHUB_ROLE - const executable = - tool === 'git' - ? process.env.ECHO_UI_LOOP_REAL_GIT - : tool === 'gh' - ? process.env.ECHO_UI_LOOP_REAL_GH - : null - if (!executable || !['automation', 'reviewer'].includes(role)) { + if (!['automation', 'reviewer'].includes(role)) { throw new Error('authenticated command gate is missing its verified runtime context') } - assertDescendantCommandPolicy({ - role, - tool, - args, - allowedPushBranch: process.env.ECHO_UI_LOOP_ALLOWED_PUSH_BRANCH || null, + const identityBinDirectory = path.dirname(fileURLToPath(import.meta.url)) + const executableName = tool === 'credential' ? 'gh' : tool + if (!['git', 'gh'].includes(executableName)) { + throw new Error(`unsupported authenticated tool: ${tool}`) + } + const executable = await resolveExecutable(executableName, process.env, { + skipDirectories: [path.join(identityBinDirectory, 'identity-bin')], }) - const child = spawn(executable, args, { + const executableArgs = tool === 'credential' ? ['auth', 'git-credential'] : args + if (tool !== 'credential') { + assertDescendantCommandPolicy({ + role, + tool, + args, + allowedPushBranch: process.env.ECHO_UI_LOOP_ALLOWED_PUSH_BRANCH || null, + expectedRepository: process.env.ECHO_UI_LOOP_EXPECTED_REPOSITORY || null, + }) + if (tool === 'git' && args[0] === 'push') { + await assertPushTargetsRepository({ + expectedRepository: process.env.ECHO_UI_LOOP_EXPECTED_REPOSITORY, + realGit: executable, + environment: process.env, + }) + } + } + const child = spawn(executable, executableArgs, { env: process.env, stdio: 'inherit', shell: false, diff --git a/loops/issue-dev-loop/scripts/lib/github-identity.mjs b/loops/issue-dev-loop/scripts/lib/github-identity.mjs index d4c23ca0..91c724cc 100644 --- a/loops/issue-dev-loop/scripts/lib/github-identity.mjs +++ b/loops/issue-dev-loop/scripts/lib/github-identity.mjs @@ -1,6 +1,6 @@ import { execFile, spawn } from 'node:child_process' import { constants } from 'node:fs' -import { access, readdir } from 'node:fs/promises' +import { access, readdir, realpath } from 'node:fs/promises' import { devNull } from 'node:os' import path from 'node:path' import { promisify } from 'node:util' @@ -46,6 +46,12 @@ export function resolveGitHubRoleEnvironment({ channel, role, environment = proc } const routedEnvironment = { ...environment, GH_CONFIG_DIR: configDirectory } + for (const profileVariable of [ + channel.automationGitHubConfigEnvironmentVariable, + channel.reviewerGitHubConfigEnvironmentVariable, + ]) { + if (profileVariable) delete routedEnvironment[profileVariable] + } for (const name of [ 'GH_TOKEN', 'GITHUB_TOKEN', @@ -63,6 +69,7 @@ export function resolveGitHubRoleEnvironment({ channel, role, environment = proc 'ECHO_UI_LOOP_IDENTITY_GATE', 'ECHO_UI_LOOP_NODE', 'ECHO_UI_LOOP_ALLOWED_PUSH_BRANCH', + 'ECHO_UI_LOOP_EXPECTED_REPOSITORY', ].includes(name) ) { delete routedEnvironment[name] @@ -75,7 +82,9 @@ export function resolveGitHubRoleEnvironment({ channel, role, environment = proc GIT_CONFIG_KEY_0: 'credential.helper', GIT_CONFIG_VALUE_0: '', GIT_CONFIG_KEY_1: 'credential.helper', - GIT_CONFIG_VALUE_1: '!gh auth git-credential', + GIT_CONFIG_VALUE_1: `!${shellQuote(process.execPath)} ${shellQuote( + commandGatePath, + )} credential`, }) return { configDirectory, expectedLogin, routedEnvironment } } @@ -96,10 +105,10 @@ function gitSubcommand(args) { continue } if ( - ['--no-pager', '--paginate', '--literal-pathspecs', '--no-optional-locks'].includes(argument) || - ['--git-dir=', '--work-tree=', '--namespace='].some((prefix) => - argument.startsWith(prefix), - ) + ['--no-pager', '--paginate', '--literal-pathspecs', '--no-optional-locks'].includes( + argument, + ) || + ['--git-dir=', '--work-tree=', '--namespace='].some((prefix) => argument.startsWith(prefix)) ) { index += 1 continue @@ -141,9 +150,9 @@ export function assertGitCommandPolicy(role, args, { allowedPushBranch = null } if (readOnly.has(subcommand.name)) return if ( subcommand.name === 'branch' && - args.slice(subcommand.index + 1).every((argument) => - ['--show-current', '--list', '-l'].includes(argument), - ) + args + .slice(subcommand.index + 1) + .every((argument) => ['--show-current', '--list', '-l'].includes(argument)) ) { return } @@ -156,8 +165,8 @@ export function assertGitCommandPolicy(role, args, { allowedPushBranch = null } return } if ( - subcommand.name === 'remote' && - ['get-url', '-v'].includes(args[subcommand.index + 1]) + sameArguments(args.slice(subcommand.index), ['remote', '-v']) || + sameArguments(args.slice(subcommand.index), ['remote', 'get-url', 'origin']) ) { return } @@ -165,17 +174,6 @@ export function assertGitCommandPolicy(role, args, { allowedPushBranch = null } throw new Error(`git command is outside the authenticated ${role} command tree`) } -function argumentValue(args, names) { - for (let index = 0; index < args.length; index += 1) { - const argument = args[index] - if (names.includes(argument)) return args[index + 1] ?? null - for (const name of names) { - if (argument.startsWith(`${name}=`)) return argument.slice(name.length + 1) - } - } - return null -} - function commandAfterGroup(args, groupIndex) { let index = groupIndex + 1 while (index < args.length) { @@ -185,40 +183,155 @@ function commandAfterGroup(args, groupIndex) { continue } if (argument.startsWith('-')) { - index += 1 - continue + return { index: -1, name: null } } - return argument + return { index, name: argument } } - return null + return { index: -1, name: null } } function githubGroup(args) { const groups = new Set(['api', 'issue', 'pr', 'run', 'repo']) - const index = args.findIndex((argument) => groups.has(argument)) - return { index, name: index === -1 ? null : args[index] } + let index = 0 + while (index < args.length) { + const argument = args[index] + if (['--repo', '-R', '--hostname'].includes(argument)) { + if (!args[index + 1]) return { index: -1, name: null } + index += 2 + continue + } + if (['--repo=', '--hostname='].some((prefix) => argument.startsWith(prefix))) { + index += 1 + continue + } + if (argument.startsWith('-') || !groups.has(argument)) { + return { index: -1, name: null } + } + return { index, name: argument } + } + return { index: -1, name: null } } function githubApiRequest(apiArguments) { - const explicitMethod = - argumentValue(apiArguments, ['--method', '-X']) ?? - apiArguments.find((argument) => /^-X[A-Za-z]+$/.test(argument))?.slice(2) - const hasRequestBody = apiArguments.some( - (argument) => - ['-f', '-F', '--field', '--raw-field', '--input'].includes(argument) || - ['--field=', '--raw-field=', '--input='].some((prefix) => argument.startsWith(prefix)), - ) + const optionsWithValue = new Set([ + '--method', + '-X', + '-f', + '-F', + '--field', + '--raw-field', + '--input', + '--preview', + '--hostname', + '--jq', + '-q', + '--template', + '-t', + '--cache', + '--header', + '-H', + ]) + const bodyOptions = new Set(['-f', '-F', '--field', '--raw-field', '--input']) + const booleanOptions = new Set(['--paginate', '--slurp', '--verbose', '--silent', '--include']) + let explicitMethod = null + let endpoint = null + let hasRequestBody = false + let valid = true + + for (let index = 0; index < apiArguments.length; index += 1) { + const argument = apiArguments[index] + if (optionsWithValue.has(argument)) { + const value = apiArguments[index + 1] + if (value === undefined) { + valid = false + break + } + if (argument === '--method' || argument === '-X') explicitMethod = value + if (bodyOptions.has(argument)) hasRequestBody = true + index += 1 + continue + } + const longOption = [...optionsWithValue].find( + (name) => name.startsWith('--') && argument.startsWith(`${name}=`), + ) + if (longOption) { + const value = argument.slice(longOption.length + 1) + if (!value) { + valid = false + break + } + if (longOption === '--method') explicitMethod = value + if (bodyOptions.has(longOption)) hasRequestBody = true + continue + } + if (/^-X[A-Za-z]+$/.test(argument)) { + explicitMethod = argument.slice(2) + continue + } + if (/^-[fF].+/.test(argument)) { + hasRequestBody = true + continue + } + if ( + booleanOptions.has(argument) || + [...booleanOptions].some((name) => argument.startsWith(`${name}=`)) + ) { + continue + } + if (argument.startsWith('-') || endpoint !== null) { + valid = false + break + } + endpoint = argument + } + const method = (explicitMethod ?? (hasRequestBody ? 'POST' : 'GET')).toUpperCase() - const endpoint = - apiArguments.find((argument) => /^\/?(?:graphql|repos\/[^/]+\/[^/]+\/)/.test(argument)) ?? - null return { - endpoint: endpoint?.replace(/^\//, '').split('?')[0] ?? null, + endpoint: valid ? (endpoint?.replace(/^\//, '').split('?')[0] ?? null) : null, method, - mutating: method !== 'GET', + mutating: !valid || method !== 'GET', + valid, } } +function reviewerCommentReviewAllowed(args, commandIndex) { + let hasComment = false + let targetCount = 0 + const optionsWithValue = new Set(['--body', '-b', '--body-file', '-F', '--repo', '-R']) + + for (let index = commandIndex + 1; index < args.length; index += 1) { + const argument = args[index] + if ( + ['--approve', '--request-changes', '-a', '-r'].some( + (name) => argument === name || argument.startsWith(`${name}=`), + ) + ) { + return false + } + if (argument === '--comment' || argument === '-c' || argument === '--comment=true') { + hasComment = true + continue + } + if (argument.startsWith('--comment=')) return false + if (optionsWithValue.has(argument)) { + if (args[index + 1] === undefined) return false + index += 1 + continue + } + const longOption = [...optionsWithValue].find( + (name) => name.startsWith('--') && argument.startsWith(`${name}=`), + ) + if (longOption) { + if (!argument.slice(longOption.length + 1)) return false + continue + } + if (argument.startsWith('-')) return false + targetCount += 1 + if (targetCount > 1) return false + } + return hasComment +} + function automationApiMutationAllowed({ endpoint, method }) { if (!endpoint) return false if ( @@ -227,10 +340,7 @@ function automationApiMutationAllowed({ endpoint, method }) { ) { return true } - if ( - /^repos\/[^/]+\/[^/]+\/issues\/\d+\/comments$/.test(endpoint) && - method === 'POST' - ) { + if (/^repos\/[^/]+\/[^/]+\/issues\/\d+\/comments$/.test(endpoint) && method === 'POST') { return true } if ( @@ -240,44 +350,90 @@ function automationApiMutationAllowed({ endpoint, method }) { return true } return ( - /^repos\/[^/]+\/[^/]+\/pulls\/\d+\/comments\/\d+\/replies$/.test(endpoint) && - method === 'POST' + /^repos\/[^/]+\/[^/]+\/pulls\/\d+\/comments\/\d+\/replies$/.test(endpoint) && method === 'POST' ) } -export function assertGitHubCliPolicy(role, args) { - const group = githubGroup(args) +function githubRepositoryFlags(args) { + const repositories = [] + for (let index = 0; index < args.length; index += 1) { + const argument = args[index] + if (argument === '--repo' || argument === '-R') { + repositories.push(args[index + 1] ?? null) + index += 1 + continue + } + if (argument.startsWith('--repo=')) { + repositories.push(argument.slice('--repo='.length) || null) + continue + } + if (argument.startsWith('-R') && argument.length > 2) { + repositories.push(argument.slice(2)) + } + } + return repositories +} + +function endpointRepository(endpoint) { + const match = endpoint?.match(/^repos\/([^/]+\/[^/]+)(?:\/|$)/) + return match?.[1] ?? null +} + +function repositoryInScope(actual, expected) { + return ( + typeof actual === 'string' && + typeof expected === 'string' && + actual.toLowerCase() === expected.toLowerCase() + ) +} + +export function assertGitHubCliPolicy(role, args, { expectedRepository = null } = {}) { const reject = () => { throw new Error(`GitHub action is prohibited for the ${role} role`) } + if ( + expectedRepository && + githubRepositoryFlags(args).some( + (repository) => !repositoryInScope(repository, expectedRepository), + ) + ) { + reject() + } + const group = githubGroup(args) if (!group.name) reject() const subcommand = commandAfterGroup(args, group.index) if (role === 'reviewer') { if (group.name === 'api') { const request = githubApiRequest(args.slice(group.index + 1)) - if (request.mutating || request.endpoint === 'graphql') reject() + if ( + !request.valid || + request.mutating || + request.endpoint === 'graphql' || + (expectedRepository && + !repositoryInScope(endpointRepository(request.endpoint), expectedRepository)) + ) { + reject() + } return } - if (group.name === 'run' && subcommand === 'view') return + if (group.name === 'run' && subcommand.name === 'view') return if (group.name !== 'pr') reject() - if (['view', 'diff', 'checks'].includes(subcommand)) return - if (subcommand !== 'review') reject() - const isComment = args.includes('--comment') || args.includes('-c') - const isApproval = args.includes('--approve') || args.includes('-a') - const requestsChanges = args.includes('--request-changes') || args.includes('-r') - if (!isComment || isApproval || requestsChanges) reject() + if (['view', 'diff', 'checks'].includes(subcommand.name)) return + if (subcommand.name !== 'review' || !reviewerCommentReviewAllowed(args, subcommand.index)) { + reject() + } return } if (group.name === 'issue') { - if (!['list', 'view', 'comment', 'edit'].includes(subcommand)) reject() + if (!['list', 'view', 'comment', 'edit'].includes(subcommand.name)) reject() return } if (group.name === 'pr') { if ( !['list', 'view', 'create', 'edit', 'comment', 'ready', 'checks', 'diff'].includes( - subcommand, + subcommand.name, ) ) { reject() @@ -285,39 +441,58 @@ export function assertGitHubCliPolicy(role, args) { return } if (group.name === 'run') { - if (!['list', 'view', 'download'].includes(subcommand)) reject() + if (!['list', 'view', 'download'].includes(subcommand.name)) reject() return } if (group.name !== 'api') reject() const request = githubApiRequest(args.slice(group.index + 1)) - if (request.endpoint === 'graphql') reject() + if ( + !request.valid || + request.endpoint === 'graphql' || + (expectedRepository && + !repositoryInScope(endpointRepository(request.endpoint), expectedRepository)) + ) { + reject() + } if (!request.mutating) return if (!automationApiMutationAllowed(request)) reject() } -export function assertDescendantCommandPolicy({ role, tool, args, allowedPushBranch = null }) { +export function assertDescendantCommandPolicy({ + role, + tool, + args, + allowedPushBranch = null, + expectedRepository = null, +}) { if (tool === 'git') { assertGitCommandPolicy(role, args, { allowedPushBranch }) return } if (tool === 'gh') { - assertGitHubCliPolicy(role, args) + assertGitHubCliPolicy(role, args, { expectedRepository }) return } throw new Error(`unsupported authenticated tool: ${tool}`) } -function assertRootCommandPolicy({ role, executable, args, loopRoot, allowedPushBranch }) { - const command = path.basename(executable) - if (command === 'git') { +function assertRootCommandPolicy({ + role, + tool, + args, + loopRoot, + allowedPushBranch, + expectedRepository, +}) { + if (tool === 'git') { assertGitCommandPolicy(role, args, { allowedPushBranch }) return } - if (command === 'gh') { - assertGitHubCliPolicy(role, args) + if (tool === 'gh') { + assertGitHubCliPolicy(role, args, { expectedRepository }) return } - if (role === 'automation' && command.startsWith('node')) { + if (role === 'automation' && tool === 'node') { const script = args[0] ? path.resolve(args[0]) : null const allowedScripts = new Set([ path.resolve(loopRoot, 'scripts', 'loopctl.mjs'), @@ -325,16 +500,19 @@ function assertRootCommandPolicy({ role, executable, args, loopRoot, allowedPush ]) if (script && allowedScripts.has(script)) return } - throw new Error(`${command} is outside the authenticated ${role} command tree`) + throw new Error(`command is outside the authenticated ${role} command tree`) } -async function resolveExecutable(name, environment) { +export async function resolveExecutable(name, environment, { skipDirectories = [] } = {}) { + const skipped = new Set(skipDirectories.map((directory) => path.resolve(directory))) for (const directory of (environment.PATH ?? '').split(path.delimiter)) { if (!directory) continue - const candidate = path.join(directory, name) + const absoluteDirectory = path.resolve(directory) + if (skipped.has(absoluteDirectory)) continue + const candidate = path.join(absoluteDirectory, name) try { await access(candidate, constants.X_OK) - return candidate + return await realpath(candidate) } catch (error) { if (error?.code !== 'ENOENT' && error?.code !== 'EACCES') throw error } @@ -346,6 +524,45 @@ function shellQuote(value) { return `'${value.replaceAll("'", "'\\''")}'` } +async function resolveRequestedExecutable(command, environment) { + if (!command.includes(path.sep)) return resolveExecutable(command, environment) + const candidate = path.resolve(command) + await access(candidate, constants.X_OK) + return realpath(candidate) +} + +async function authenticatedToolForExecutable(executable, { realGit, realGh }) { + const realNode = await realpath(process.execPath) + if (executable === realGit) return 'git' + if (executable === realGh) return 'gh' + if (executable === realNode) return 'node' + return null +} + +function normalizedRepositoryFromRemote(remoteUrl) { + const value = remoteUrl.trim().replace(/\.git$/, '') + for (const pattern of [ + /^https:\/\/github\.com\/([^/]+\/[^/]+)$/i, + /^git@github\.com:([^/]+\/[^/]+)$/i, + /^ssh:\/\/git@github\.com\/([^/]+\/[^/]+)$/i, + ]) { + const match = value.match(pattern) + if (match) return match[1].toLowerCase() + } + return null +} + +export async function assertPushTargetsRepository({ expectedRepository, realGit, environment }) { + const expected = assertNonEmpty(expectedRepository, 'expectedRepository').toLowerCase() + const { stdout } = await execFileAsync(realGit, ['remote', 'get-url', 'origin'], { + env: environment, + maxBuffer: 1024 * 1024, + }) + if (normalizedRepositoryFromRemote(stdout) !== expected) { + throw new Error(`origin must target the configured repository ${expectedRepository}`) + } +} + async function readActivePushBranch(loopRoot) { const runsRoot = path.join(loopRoot, 'logs', 'runs') let entries @@ -398,24 +615,38 @@ export async function runWithGitHubRole({ environment = process.env, spawnCommand = spawn, }) { - const executable = assertNonEmpty(command, 'command') + const requestedCommand = assertNonEmpty(command, 'command') const resolved = await assertGitHubRoleIdentity({ channel, role, environment }) - const allowedPushBranch = await readActivePushBranch(loopRoot) - assertRootCommandPolicy({ role, executable, args, loopRoot, allowedPushBranch }) const [realGit, realGh] = await Promise.all([ resolveExecutable('git', resolved.routedEnvironment), resolveExecutable('gh', resolved.routedEnvironment), ]) + const executable = await resolveRequestedExecutable(requestedCommand, resolved.routedEnvironment) + const tool = await authenticatedToolForExecutable(executable, { realGit, realGh }) + const allowedPushBranch = await readActivePushBranch(loopRoot) + assertRootCommandPolicy({ + role, + tool, + args, + loopRoot, + allowedPushBranch, + expectedRepository: channel.repository, + }) + if (tool === 'git' && gitSubcommand(args).name === 'push') { + await assertPushTargetsRepository({ + expectedRepository: channel.repository, + realGit, + environment: resolved.routedEnvironment, + }) + } const childEnvironment = { ...resolved.routedEnvironment, - GIT_CONFIG_VALUE_1: `!${shellQuote(realGh)} auth git-credential`, PATH: `${identityBinDirectory}${path.delimiter}${resolved.routedEnvironment.PATH ?? ''}`, ECHO_UI_LOOP_GITHUB_ROLE: role, - ECHO_UI_LOOP_REAL_GIT: realGit, - ECHO_UI_LOOP_REAL_GH: realGh, ECHO_UI_LOOP_IDENTITY_GATE: commandGatePath, ECHO_UI_LOOP_NODE: process.execPath, ECHO_UI_LOOP_ALLOWED_PUSH_BRANCH: allowedPushBranch ?? '', + ECHO_UI_LOOP_EXPECTED_REPOSITORY: channel.repository, } const child = spawnCommand(executable, args, { env: childEnvironment, diff --git a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs index 0eda1dc7..3d071ae7 100644 --- a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs +++ b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs @@ -9,12 +9,9 @@ import { fileURLToPath } from 'node:url' const execFileAsync = promisify(execFile) const testDirectory = path.dirname(fileURLToPath(import.meta.url)) -const routerPath = path.resolve( - testDirectory, - '..', - 'scripts', - 'with-github-identity.mjs', -) +const routerPath = path.resolve(testDirectory, '..', 'scripts', 'with-github-identity.mjs') +const commandGatePath = path.resolve(testDirectory, '..', 'scripts', 'github-command-gate.mjs') +const credentialHelper = `!'${process.execPath}' '${commandGatePath}' credential` async function createFixture() { const parent = await mkdtemp(path.join(os.tmpdir(), 'echo-ui-identity-routing-')) @@ -37,9 +34,9 @@ async function createFixture() { ownerGitHubLogin: 'owner-user', automationGitHubLogin: 'executor-user', reviewerGitHubLogin: 'reviewer-user', - automationGitHubConfigEnvironmentVariable: - 'ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR', + automationGitHubConfigEnvironmentVariable: 'ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR', reviewerGitHubConfigEnvironmentVariable: 'ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR', + repository: 'example/repo', })}\n`, 'utf8', ) @@ -76,7 +73,15 @@ if (process.argv[2] === 'spawn') { process.env.GIT_CONFIG_KEY_1, process.env.GIT_CONFIG_VALUE_1 ], - gitIsolation: [process.env.GIT_CONFIG_GLOBAL, process.env.GIT_CONFIG_NOSYSTEM] + gitIsolation: [process.env.GIT_CONFIG_GLOBAL, process.env.GIT_CONFIG_NOSYSTEM], + exposesOtherProfiles: Boolean( + process.env.ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR || + process.env.ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR + ), + exposesRealTools: Boolean( + process.env.ECHO_UI_LOOP_REAL_GIT || + process.env.ECHO_UI_LOOP_REAL_GH + ) })) } `, @@ -105,16 +110,25 @@ sed -n '1p' "$GH_CONFIG_DIR/identity" await writeFile( fakeGit, `#!/bin/sh +if [ "$1 $2 $3" = "remote get-url origin" ]; then + echo "https://github.com/example/repo.git" + exit 0 +fi node -e 'process.stdout.write(JSON.stringify({args: process.argv.slice(1), config: process.env.GH_CONFIG_DIR, hasGhToken: Boolean(process.env.GH_TOKEN || process.env.GITHUB_TOKEN), gitConfig: [process.env.GIT_CONFIG_COUNT, process.env.GIT_CONFIG_KEY_0, process.env.GIT_CONFIG_VALUE_0, process.env.GIT_CONFIG_KEY_1, process.env.GIT_CONFIG_VALUE_1]}))' -- "$@" `, 'utf8', ) await chmod(fakeGit, 0o755) + const impostorGh = path.join(parent, 'gh') + await writeFile(impostorGh, '#!/bin/sh\nexit 0\n', 'utf8') + await chmod(impostorGh, 0o755) return { loopRoot, loopctlPath, fakeGh, + fakeGit, + impostorGh, automationProfile, reviewerProfile, env: { @@ -143,14 +157,10 @@ test('automation role selects its dedicated gh profile without leaking token ove assert.deepEqual(JSON.parse(stdout), { config: fixture.automationProfile, hasGhToken: false, - gitConfig: [ - '2', - 'credential.helper', - '', - 'credential.helper', - `!'${fixture.fakeGh}' auth git-credential`, - ], + gitConfig: ['2', 'credential.helper', '', 'credential.helper', credentialHelper], gitIsolation: [os.devNull, '1'], + exposesOtherProfiles: false, + exposesRealTools: false, }) }) @@ -202,7 +212,7 @@ test('automation git command clears global helpers and injects the selected gh c 'credential.helper', '', 'credential.helper', - `!'${fixture.fakeGh}' auth git-credential`, + credentialHelper, ]) }) @@ -244,15 +254,7 @@ test('automation identity allows only one explicit loop branch push shape', asyn await assert.rejects( execFileAsync( process.execPath, - [ - routerPath, - '--loop-root', - fixture.loopRoot, - 'automation', - '--', - 'git', - ...gitArguments, - ], + [routerPath, '--loop-root', fixture.loopRoot, 'automation', '--', 'git', ...gitArguments], { env: fixture.env }, ), /automation may push only one explicit loop branch/, @@ -266,7 +268,12 @@ test('non-owner roles cannot merge or approve pull requests through gh', async ( ['automation', ['pr', 'merge', '106']], ['automation', ['--repo', 'example/repo', 'pr', 'merge', '106']], ['automation', ['pr', 'review', '106', '--approve']], + [ + 'automation', + ['issue', 'comment', '1', '--repo', 'attacker/other-repo', '--body', 'outside scope'], + ], ['reviewer', ['pr', 'merge', '106']], + ['reviewer', ['pr', 'view', '106', '--repo=attacker/other-repo']], ['reviewer', ['pr', 'review', '106', '--approve']], ['reviewer', ['api', '--method', 'POST', 'repos/example/repo/pulls/106/reviews']], ['reviewer', ['api', 'repos/example/repo/pulls/106/reviews', '-f', 'event=APPROVE']], @@ -274,20 +281,27 @@ test('non-owner roles cannot merge or approve pull requests through gh', async ( ['automation', ['api', '--method', 'PUT', 'repos/example/repo/pulls/106/merge']], ['automation', ['api', '--method', 'PUT', '/repos/example/repo/pulls/106/merge']], ['automation', ['api', 'repos/example/repo/pulls/106/reviews', '-f', 'event=APPROVE']], + [ + 'automation', + ['api', 'repos/attacker/other-repo/issues/1/comments', '-f', 'body=outside-scope'], + ], + [ + 'automation', + [ + 'api', + '--template', + 'repos/example/repo/issues/1/comments', + 'repos/example/repo/pulls/106/reviews', + '-f', + 'event=APPROVE', + ], + ], ] for (const [role, ghArguments] of forbidden) { await assert.rejects( execFileAsync( process.execPath, - [ - routerPath, - '--loop-root', - fixture.loopRoot, - role, - '--', - 'gh', - ...ghArguments, - ], + [routerPath, '--loop-root', fixture.loopRoot, role, '--', 'gh', ...ghArguments], { env: fixture.env }, ), /GitHub action is prohibited for the (automation|reviewer) role/, @@ -334,15 +348,7 @@ test('authenticated command trees reject shell, env, arbitrary node, and descend await assert.rejects( execFileAsync( process.execPath, - [ - routerPath, - '--loop-root', - fixture.loopRoot, - role, - '--', - command[0], - ...command.slice(1), - ], + [routerPath, '--loop-root', fixture.loopRoot, role, '--', command[0], ...command.slice(1)], { env: fixture.env }, ), /(outside the authenticated|reviewer identity cannot run git push|automation may push only)/, @@ -356,15 +362,30 @@ test('GitHub role allowlists reject alternate merge syntax and unrelated reviewe ['reviewer', ['pr', 'comment', '106', '--body', 'not a review']], ['reviewer', ['issue', 'comment', '1', '--body', 'not allowed']], ['reviewer', ['repo', 'edit', '--enable-issues=false']], + ['reviewer', ['pr', 'review', '106', '--comment', '--comment=false', '--approve=true']], ['automation', ['pr', '--repo', 'example/repo', 'merge', '106']], ['automation', ['api', '-XPUT', 'repos/example/repo/pulls/106/merge']], ['automation', ['api', '/graphql', '-f', 'query=mutation { test }']], - [ - 'automation', - ['api', '--method', 'DELETE', 'repos/example/repo/branches/dev/protection'], - ], + ['automation', ['api', '--method', 'DELETE', 'repos/example/repo/branches/dev/protection']], ] for (const [role, ghArguments] of forbidden) { + await assert.rejects( + execFileAsync( + process.execPath, + [routerPath, '--loop-root', fixture.loopRoot, role, '--', 'gh', ...ghArguments], + { env: fixture.env }, + ), + /GitHub action is prohibited for the (automation|reviewer) role/, + ) + } +}) + +test('authenticated roots reject executable impersonation and mutating remote syntax', async () => { + const fixture = await createFixture() + for (const command of [ + [fixture.impostorGh, 'pr', 'view', '106'], + ['git', 'remote', '-v', 'set-url', 'origin', 'https://example.invalid/repo.git'], + ]) { await assert.rejects( execFileAsync( process.execPath, @@ -372,14 +393,49 @@ test('GitHub role allowlists reject alternate merge syntax and unrelated reviewe routerPath, '--loop-root', fixture.loopRoot, - role, + 'automation', '--', - 'gh', - ...ghArguments, + command[0], + ...command.slice(1), ], { env: fixture.env }, ), - /GitHub action is prohibited for the (automation|reviewer) role/, + /(outside the authenticated|git command is outside)/, ) } }) + +test('automation push verifies that origin is the configured repository', async () => { + const fixture = await createFixture() + await writeFile( + fixture.fakeGit, + `#!/bin/sh +if [ "$1 $2 $3" = "remote get-url origin" ]; then + echo "https://github.com/attacker/other-repo.git" + exit 0 +fi +exit 0 +`, + 'utf8', + ) + await chmod(fixture.fakeGit, 0o755) + + await assert.rejects( + execFileAsync( + process.execPath, + [ + routerPath, + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + 'git', + 'push', + 'origin', + 'codex/issue-123', + ], + { env: fixture.env }, + ), + /origin must target the configured repository example\/repo/, + ) +}) From c6160e86ccfe5bb7036bca0c082f2bc3ea12a5ca Mon Sep 17 00:00:00 2001 From: leyoonafr <codeacme17@gmail.com> Date: Thu, 23 Jul 2026 17:20:40 +0800 Subject: [PATCH 21/41] fix(loop): bind mutations to durable authorization --- .codex/agents/echo-ui-pr-reviewer.toml | 8 +- .codex/agents/echo-ui-review-adjudicator.toml | 7 +- loops/_shared/owner-channel/CHANNEL.md | 5 +- loops/issue-dev-loop/LOOP.md | 4 +- loops/issue-dev-loop/SKILL.md | 4 +- loops/issue-dev-loop/dependencies.md | 2 +- loops/issue-dev-loop/evolve/EVOLVE.md | 2 +- .../references/github-operations.md | 14 +- .../issue-dev-loop/review/reviewer-prompt.md | 2 +- .../schemas/checkpoint-record.schema.json | 15 +- .../scripts/github-command-gate.mjs | 14 +- .../scripts/lib/active-journal.mjs | 16 + .../scripts/lib/checkpoint-proof.mjs | 88 +++ loops/issue-dev-loop/scripts/lib/evidence.mjs | 2 + .../scripts/lib/finalization-journal.mjs | 6 +- .../scripts/lib/github-identity.mjs | 522 ++++++++++++++---- .../issue-dev-loop/scripts/lib/validation.mjs | 1 + .../scripts/with-github-identity | 7 + .../tests/github-identity-routing.test.mjs | 306 +++++++++- loops/issue-dev-loop/tests/runtime.test.mjs | 114 +++- loops/issue-dev-loop/triggers/TRIGGER.md | 2 +- .../triggers/codex-automation-prompt.md | 2 +- 22 files changed, 989 insertions(+), 154 deletions(-) create mode 100755 loops/issue-dev-loop/scripts/with-github-identity diff --git a/.codex/agents/echo-ui-pr-reviewer.toml b/.codex/agents/echo-ui-pr-reviewer.toml index 867bc0eb..b69e9b35 100644 --- a/.codex/agents/echo-ui-pr-reviewer.toml +++ b/.codex/agents/echo-ui-pr-reviewer.toml @@ -10,11 +10,11 @@ security, regressions, and missing tests. Ignore executor conversation history a do not modify files. Avoid style-only or speculative findings. Return a structured review with stable finding IDs, severity P0-P3, confidence, file and line, concrete evidence, reproduction when possible, and expected resolution. Return PASS when no -actionable findings exist. Publish GitHub reviews only through -`node loops/issue-dev-loop/scripts/with-github-identity.mjs reviewer -- ...`; the +actionable findings exist. Publish GitHub reviews only through the executable +`loops/issue-dev-loop/scripts/with-github-identity reviewer -- ...` launcher; the wrapper must verify the configured reviewerGitHubLogin before publication. Never -run raw `gh`, alter global authentication, or let the executor identity publish on -your behalf. Use `gh pr review --comment`; mutating `gh api`, approval, change +invoke the `.mjs` implementation directly, run raw `gh`, alter global authentication, +or let the executor identity publish on your behalf. Use `gh pr review --comment`; mutating `gh api`, approval, change requests, and merge commands are prohibited. Every round must include its run/round/head marker and return its unique review URL. For the final PASS, include the cycle-result digest marker and all prior finding IDs and return its review URL. diff --git a/.codex/agents/echo-ui-review-adjudicator.toml b/.codex/agents/echo-ui-review-adjudicator.toml index dc923d0e..39c6d96c 100644 --- a/.codex/agents/echo-ui-review-adjudicator.toml +++ b/.codex/agents/echo-ui-review-adjudicator.toml @@ -8,8 +8,9 @@ executor response, and cited evidence. Do not modify files and do not infer inte from private conversation history. Return ACCEPT_FINDING, REJECT_FINDING, or NEEDS_OWNER, followed by concise evidence. Prefer NEEDS_OWNER when product intent, public API policy, security, or release policy is ambiguous. -For REJECT_FINDING, publish the required adjudication marker only through -`node loops/issue-dev-loop/scripts/with-github-identity.mjs reviewer -- ...`. -Use a non-approving `gh pr review --comment`. Do not run raw or mutating `gh api`, +For REJECT_FINDING, publish the required adjudication marker only through the +executable `loops/issue-dev-loop/scripts/with-github-identity reviewer -- ...` +launcher. Use a non-approving `gh pr review --comment`. Do not invoke the `.mjs` +implementation directly or run raw or mutating `gh api`, alter global authentication, or post through the executor identity. """ diff --git a/loops/_shared/owner-channel/CHANNEL.md b/loops/_shared/owner-channel/CHANNEL.md index c55d5d20..2dd08168 100644 --- a/loops/_shared/owner-channel/CHANNEL.md +++ b/loops/_shared/owner-channel/CHANNEL.md @@ -12,10 +12,9 @@ Each blocking GitHub notification prints a unique resume instruction. To continu ## Runtime setup -1. Authenticate the unattended executor and fresh reviewer with distinct GitHub identities. Their exact logins and the names of their profile-path environment variables live in `channel.json`. - For the current configuration, set `ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR` to the `Ethandasw` `gh` profile directory and `ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR` to the `Traviinam` profile directory in the scheduler environment. The directory names themselves are local details and do not need to match the roles. +1. Authenticate the unattended executor and fresh reviewer with distinct GitHub identities. Their exact logins and the names of their profile-path environment variables live in `channel.json`. For the current configuration, set `ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR` to the `Ethandasw` `gh` profile directory and `ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR` to the `Traviinam` profile directory in the scheduler environment. The directory names themselves are local details and do not need to match the roles. 2. Run `node loops/issue-dev-loop/scripts/loopctl.mjs validate --activation` before scheduling. It independently queries both profiles and rejects missing, overlapping, owner-authenticated, or incorrectly routed identities. -3. Run every executor GitHub command, remote Git command, and GitHub-backed `loopctl` command through `with-github-identity.mjs automation -- ...`. Run reviewer publication commands through the same wrapper with `reviewer`. The wrapper clears token overrides, verifies `gh api user`, and gives Git a one-command credential helper without changing global Git or `gh` configuration. A PATH gate applies the role policy to descendant `git` and `gh` processes too; arbitrary shell, environment, or Node command trees are rejected. +3. Run every executor GitHub command, remote Git command, and GitHub-backed `loopctl` command through the executable shell launcher `loops/issue-dev-loop/scripts/with-github-identity automation -- ...`. Run reviewer publication commands through the same launcher with `reviewer`. The launcher removes Node preload hooks; the router clears token overrides and process hooks, verifies `gh api user`, and gives Git a one-command credential helper without changing global Git or `gh` configuration. A PATH gate applies the role and current-run policy to descendant `git` and `gh` processes too; arbitrary shell, environment, or Node command trees are rejected. 4. Create one dedicated repository issue for the append-only loop state journal and set its number as `stateIssueNumber`. It stores active checkpoints and terminal records. Restrict journal entries to the automation identity; humans may read but should not edit or delete them. 5. Enable GitHub notifications for mentions and review requests for `codeacme17`. 6. Optionally set `ECHO_UI_LOOP_OWNER_WEBHOOK_URL` to an endpoint that accepts the notification JSON with `Content-Type: application/json`. diff --git a/loops/issue-dev-loop/LOOP.md b/loops/issue-dev-loop/LOOP.md index 67518d7a..8d88f4af 100644 --- a/loops/issue-dev-loop/LOOP.md +++ b/loops/issue-dev-loop/LOOP.md @@ -35,6 +35,8 @@ The loop may: - mark the PR ready and request the owner's review - notify the owner and observe owner actions +The separate evolve workflow in [`evolve/EVOLVE.md`](./evolve/EVOLVE.md) may push only the exact `codex/evolve-<pending-request-id>` branch and create its Draft PR to `dev`. That authorization exists only while the matching request file is pending. + The loop must obtain owner confirmation before: - changing public API compatibility or package exports @@ -106,7 +108,7 @@ Only the remote owner-merge gate permits `completed`. Both finalization publicat Keep `state.md` small and deliberate. It may be rewritten and contains only active runs, open PRs, blockers, follow-ups, current hypotheses, and learned constraints. -`logs/index.jsonl` is append-only and contains one compact summary per finalized run; `logs/triggers.jsonl` is the append-only record of cheap trigger decisions, including successful no-ops and resumes. The dedicated GitHub state-journal issue is the durable cross-worktree source for both active checkpoints and terminal records. After every durable run phase, publish the exact `prepare-checkpoint` body and validate it with `record-checkpoint`; the next phase re-fetches that automation-authored comment and refuses stale, absent, edited, or locally forged proof. Terminal comments supersede active checkpoints only after finalization reconciliation validates them, including remote owner proof for completion. `loopctl reconcile` discovers resumable state and recomputes evolve metrics; it does not write run state into an arbitrary checkout. The orchestrator creates a clean worktree at the recorded exact branch/head, then `restore-checkpoint` verifies both before restoring run files. A resumable run is returned as `workType: resume` before new issue selection. Commit sanitized `run.json`, pre-publication `events.jsonl`, summaries, and relevant screenshots to the issue branch so they are reviewable in its PR. The exact-head CI manifest and full proof travel in an Actions artifact. Keep raw local command output and large recordings in ignored `raw/` or `test-results/` directories. GitHub journal comments, PR reviews, workflow artifacts, and merge metadata are authoritative; local indexes and metrics are reconstructable caches. +`logs/index.jsonl` is append-only and contains one compact summary per finalized run; `logs/triggers.jsonl` is the append-only record of cheap trigger decisions, including successful no-ops and resumes. The dedicated GitHub state-journal issue is the durable cross-worktree source for both active checkpoints and terminal records. After every durable run phase, publish the exact `prepare-checkpoint` body and validate it with `record-checkpoint`; the next phase re-fetches that automation-authored comment and refuses stale, absent, edited, or locally forged proof. Each compact checkpoint also embeds the digest-bound `$implement` result, evidence manifest, review result, and finalization record referenced by its event chain, when present, so later gates remain resumable. Terminal comments supersede active checkpoints only after finalization reconciliation validates them, including remote owner proof for completion. `loopctl reconcile` discovers resumable state and recomputes evolve metrics; it does not write run state into an arbitrary checkout. The orchestrator creates a clean worktree at the recorded exact branch/head, then `restore-checkpoint` verifies both before restoring run files and required small artifacts. A resumable run is returned as `workType: resume` before new issue selection. Commit sanitized `run.json`, pre-publication `events.jsonl`, summaries, and relevant screenshots to the issue branch so they are reviewable in its PR. The exact-head CI manifest and full proof travel in an Actions artifact. Keep raw local command output and large recordings in ignored `raw/` or `test-results/` directories. GitHub journal comments, PR reviews, workflow artifacts, and merge metadata are authoritative; local indexes and metrics are reconstructable caches. Never log secrets, full environment dumps, cookies, auth headers, private user data, or raw prompts containing sensitive information. diff --git a/loops/issue-dev-loop/SKILL.md b/loops/issue-dev-loop/SKILL.md index 51c21dcc..9de0f705 100644 --- a/loops/issue-dev-loop/SKILL.md +++ b/loops/issue-dev-loop/SKILL.md @@ -11,7 +11,7 @@ Run exactly one bounded issue cycle. Treat [`LOOP.md`](./LOOP.md) as the constit 1. Read `LOOP.md`, `state.md`, and `dependencies.md` completely. 2. Run `node loops/issue-dev-loop/scripts/loopctl.mjs validate`. Scheduled activation additionally requires `validate --activation`, which probes the separately configured executor and reviewer `gh` profiles. -3. Read [`references/github-operations.md`](./references/github-operations.md). Run every executor GitHub command, remote Git command, and GitHub-backed `loopctl` command through `node loops/issue-dev-loop/scripts/with-github-identity.mjs automation -- ...`. Never alter global `gh` or Git credential configuration. +3. Read [`references/github-operations.md`](./references/github-operations.md). Run every executor GitHub command, remote Git command, and GitHub-backed `loopctl` command through `loops/issue-dev-loop/scripts/with-github-identity automation -- ...`. Never invoke the `.mjs` router directly and never alter global `gh` or Git credential configuration. 4. Run `loopctl.mjs reconcile` through the automation wrapper to rebuild terminal history and discover active runs from the append-only GitHub state journal. For returned `workType: resume`, fetch the recorded branch through the wrapper, create a clean isolated worktree at the returned exact head, and run `restore-checkpoint --run-id <id>` inside that worktree. The restore command rejects the wrong branch, a dirty checkout, or any head other than the durable head. Resume it before selecting a new issue. 5. Run `loopctl.mjs evolve-status`. If `evolveDue` is true, start `echo_ui_loop_evolver` with fresh context; do not silently replace it with product work. 6. Run the cheap trigger in `triggers/detect-work.mjs` through the automation wrapper. Exit without invoking an implementation agent when it reports `hasWork: false`. @@ -46,7 +46,7 @@ Push only the issue branch and create a **draft** PR targeting `dev` using `temp After the draft PR exists, spawn the project agent `echo_ui_pr_reviewer` with a fresh context. Give it only the issue snapshot, acceptance criteria, repository instructions, base SHA, head SHA, diff, CI results, and evidence manifest. Do not give it executor conversation history or rationale. -Publish every round through `with-github-identity.mjs reviewer -- ...`; the executor identity may not author it. Post findings verbatim as one review plus inline comments. Each finding needs a stable ID, severity, confidence, evidence, and expected resolution. Record the GitHub review URL for each round. Accepted repairs must start after that round was submitted, and executor replies must be posted through the automation wrapper after the corresponding `$implement` invocation finishes. Follow `review/REVIEW.md` and `review/response-policy.md`. +Publish every round through `with-github-identity reviewer -- ...`; the executor identity may not author it. Post findings verbatim as one review plus inline comments. Each finding needs a stable ID, severity, confidence, evidence, and expected resolution. Record the GitHub review URL for each round. Accepted repairs must start after that round was submitted, and executor replies must be posted through the automation wrapper after the corresponding `$implement` invocation finishes. Follow `review/REVIEW.md` and `review/response-policy.md`. The executor must classify every finding as `accepted`, `rejected`, `needs-human`, `stale`, or `already-fixed`: diff --git a/loops/issue-dev-loop/dependencies.md b/loops/issue-dev-loop/dependencies.md index fda0f58c..fc995c51 100644 --- a/loops/issue-dev-loop/dependencies.md +++ b/loops/issue-dev-loop/dependencies.md @@ -24,4 +24,4 @@ Use `Ethandasw` for executor-created branches, PRs, replies, and durable journal entries, and `Traviinam` for fresh-context review publication. Neither may be `codeacme17`; neither may have branch-protection bypass or merge authority. The executor needs repository write access. The reviewer needs no repository write access. -Never run `gh auth setup-git` for this loop. Route commands through `scripts/with-github-identity.mjs`; it scopes `GH_CONFIG_DIR` and the Git credential helper to one allowlisted child tree, gates descendant `git`/`gh` calls, and leaves the user's default `gh` account and global Git credential configuration unchanged. +Never run `gh auth setup-git` for this loop. Route commands through the executable shell launcher `scripts/with-github-identity`; it removes Node preload hooks before selecting an identity, scopes `GH_CONFIG_DIR` and the Git credential helper to one allowlisted child tree, gates descendant `git`/`gh` calls, and leaves the user's default `gh` account and global Git credential configuration unchanged. diff --git a/loops/issue-dev-loop/evolve/EVOLVE.md b/loops/issue-dev-loop/evolve/EVOLVE.md index e81e8c7b..ca3e2c88 100644 --- a/loops/issue-dev-loop/evolve/EVOLVE.md +++ b/loops/issue-dev-loop/evolve/EVOLVE.md @@ -9,7 +9,7 @@ The evolve session may propose: - improve non-authority-changing scripts and templates - update dashboards or metrics derived from append-only logs -Every evolve change requires a dedicated `codex/evolve-<request-id>` draft PR targeting `dev`, containing `<!-- issue-dev-loop:evolve-request:<request-id> -->`, and reviewed and merged by the owner. The PR must be created after the request. In particular, never change the following without explicit owner confirmation: +Every evolve change requires a dedicated `codex/evolve-<request-id>` draft PR targeting `dev`, containing `<!-- issue-dev-loop:evolve-request:<request-id> -->`, and reviewed and merged by the owner. The PR must be created after the request. Push and create it only through `loops/issue-dev-loop/scripts/with-github-identity automation -- ...`; the router reads the pending metrics and request file and authorizes only their exact branch, `--base dev`, and `--draft`. The owner decides when to mark this dedicated evolve PR ready and whether to merge it. In particular, never change the following without explicit owner confirmation: - goals, completion criteria, authority, or stop conditions - merge, release, security, privacy, or dependency policy diff --git a/loops/issue-dev-loop/references/github-operations.md b/loops/issue-dev-loop/references/github-operations.md index 26b3ea30..3ad2a7d6 100644 --- a/loops/issue-dev-loop/references/github-operations.md +++ b/loops/issue-dev-loop/references/github-operations.md @@ -5,7 +5,7 @@ Read this before mutating issues or pull requests. Run every executor GitHub command through: ```text -node loops/issue-dev-loop/scripts/with-github-identity.mjs automation -- <command> [args...] +loops/issue-dev-loop/scripts/with-github-identity automation -- <command> [args...] ``` Run every reviewer publication command through the same wrapper with role `reviewer`. The wrapper selects the configured `gh` profile, removes token environment overrides, runs `gh api user`, and refuses an unexpected or owner identity. For Git, it clears global credential helpers and injects `gh auth git-credential` for the entire trusted child tree. Descendant `git` and `gh` processes pass through a role gate; arbitrary `sh`, `env`, and Node command trees are not authenticated. Never use owner credentials for executor or reviewer actions, never run raw remote `gh`/`git push` commands, and never call `gh auth setup-git`. @@ -13,10 +13,10 @@ Run every reviewer publication command through the same wrapper with role `revie Publish reviewer output only as a non-approving comment review: ```text -node loops/issue-dev-loop/scripts/with-github-identity.mjs reviewer -- gh pr review <number> --comment --body <review-body> +loops/issue-dev-loop/scripts/with-github-identity reviewer -- gh pr review <number> --comment --body <review-body> ``` -The router rejects reviewer pushes and all reviewer mutations except `gh pr review --comment`. It rejects approvals, change requests, merges, executor-authored reviews, GraphQL, and administration APIs. Automation API mutations are limited to the issue labels/comments and review-comment replies needed by the loop. Automation pushes use only `git push origin codex/issue-<number>` or the equivalent `-u`/`--set-upstream` form, and the destination must equal the sole active run's recorded branch; every other push shape is rejected. +The shell launcher removes Node preload hooks before starting the router. The router then builds a strict child environment, rejects reviewer pushes and every reviewer mutation except a comment-only review on the recorded PR, and rejects approvals, change requests, merges, executor-authored reviews, GraphQL, and administration APIs. Automation API mutations are limited to the current issue's exact claim label, the current issue/PR or journal comments, and current-PR review-comment replies. PR creation requires the authorized issue or pending-evolve branch, `--base dev`, and `--draft`; later PR mutations require the recorded PR. Automation pushes use only the exact active issue branch or exact pending `codex/evolve-<request-id>` branch in `git push origin <branch>` (or the equivalent `-u`/`--set-upstream` form); every other push shape is rejected. ## Selection and claim @@ -43,11 +43,11 @@ The router rejects reviewer pushes and all reviewer mutations except `gh pr revi The PR workflow `Issue dev loop evidence` runs only when the branch contains one active loop run. Wait for its exact-head run to complete, then locate and download the artifact: ```text -node loops/issue-dev-loop/scripts/with-github-identity.mjs automation -- gh run list --workflow issue-dev-loop-evidence.yml --branch codex/issue-<number> -node loops/issue-dev-loop/scripts/with-github-identity.mjs automation -- gh run download <run-database-id> --name issue-dev-loop-<run-id>-<head-sha> --dir loops/issue-dev-loop/evidence/<run-id> +loops/issue-dev-loop/scripts/with-github-identity automation -- gh run list --workflow issue-dev-loop-evidence.yml --branch codex/issue-<number> +loops/issue-dev-loop/scripts/with-github-identity automation -- gh run download <run-database-id> --name issue-dev-loop-<run-id>-<head-sha> --dir loops/issue-dev-loop/evidence/<run-id> ``` -Push only through `with-github-identity.mjs automation -- git push ...`. The wrapper rejects reviewer pushes, force pushes, and explicit pushes to `dev` or `main`. +Push only through `with-github-identity automation -- git push ...`. The wrapper rejects reviewer pushes, force pushes, and explicit pushes to `dev` or `main`. Use the artifact URL emitted by `actions/upload-artifact` as `record-evidence --publication-url` and the downloaded artifact manifest as `--manifest`. The runtime downloads it independently, requires the workflow conclusion to be `success`, and byte-compares the manifest. Reject a workflow run whose PR, branch, workflow path, or `headSha` differs from the recorded run. @@ -73,6 +73,6 @@ After a blocking notification, verify the reply URL with `record-owner-response` ## Durable state journal -For active work, run `loopctl prepare-checkpoint --run-id <id>`, post its exact `body` to the configured `stateIssueNumber` using the automation identity, then validate it with `loopctl record-checkpoint --run-id <id> --result <path> --comment-url <url>`. A checkpoint is SHA-256 bound to the active run, frozen brief, and ordered validated events. Publish one after every state-changing phase; later checkpoints supersede earlier ones. +For active work, run `loopctl prepare-checkpoint --run-id <id>`, post its exact `body` to the configured `stateIssueNumber` using the automation identity, then validate it with `loopctl record-checkpoint --run-id <id> --result <path> --comment-url <url>`. A checkpoint is SHA-256 bound to the active run, frozen brief, ordered validated events, and the small local result/manifest artifacts required by later gates. Restore verifies every embedded artifact digest before recreating it. Publish one after every state-changing phase; later checkpoints supersede earlier ones. Before `completed`, `failed`, `blocked`, or `cancelled`, run `loopctl prepare-finalization` with the terminal status and any merge SHA/failure fingerprint. Failed/blocked records bind the delivered automation-authored owner-notification URL; completion binds and remotely rechecks owner approval and merge; cancellation requires the recorded PR to be closed without merge. Post the exact `body` to the configured `stateIssueNumber` using the automation identity, then run `loopctl record-finalization --run-id <id> --result <path> --comment-url <url>`. For completion, pass that result and URL to `observe-owner-merge`. Terminal transitions reject missing, edited, wrong-author, wrong-issue, digest-mismatched, or externally unproven journal entries. Every scheduled wake begins with `loopctl reconcile`, which restores missing local history and recomputes evolve metrics from the append-only journal. diff --git a/loops/issue-dev-loop/review/reviewer-prompt.md b/loops/issue-dev-loop/review/reviewer-prompt.md index 9e65382f..6dde0f55 100644 --- a/loops/issue-dev-loop/review/reviewer-prompt.md +++ b/loops/issue-dev-loop/review/reviewer-prompt.md @@ -1 +1 @@ -Review the Echo UI PR diff between `<base-sha>` and `<head-sha>` as an independent, fresh-context reviewer. Use `$code-review`. Read only the supplied issue snapshot, acceptance criteria, repository instructions, diff, CI results, and evidence manifest. Follow `loops/issue-dev-loop/review/REVIEW.md`. Do not edit files or rely on executor conversation history. Publish the non-approving GitHub review only through `node loops/issue-dev-loop/scripts/with-github-identity.mjs reviewer -- gh pr review <number> --comment --body <review-body>`; never run raw `gh`, mutating `gh api`, approval/change-request/merge commands, or alter global authentication. Return structured findings or `PASS` and the unique review URL. +Review the Echo UI PR diff between `<base-sha>` and `<head-sha>` as an independent, fresh-context reviewer. Use `$code-review`. Read only the supplied issue snapshot, acceptance criteria, repository instructions, diff, CI results, and evidence manifest. Follow `loops/issue-dev-loop/review/REVIEW.md`. Do not edit files or rely on executor conversation history. Publish the non-approving GitHub review only through `loops/issue-dev-loop/scripts/with-github-identity reviewer -- gh pr review <number> --comment --body <review-body>`; never invoke the `.mjs` router directly, run raw `gh`, call mutating `gh api`, approve/request changes/merge, or alter global authentication. Return structured findings or `PASS` and the unique review URL. diff --git a/loops/issue-dev-loop/schemas/checkpoint-record.schema.json b/loops/issue-dev-loop/schemas/checkpoint-record.schema.json index 787506f0..150df03b 100644 --- a/loops/issue-dev-loop/schemas/checkpoint-record.schema.json +++ b/loops/issue-dev-loop/schemas/checkpoint-record.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "Issue development loop active checkpoint", "type": "object", - "required": ["schemaVersion", "kind", "run", "briefSource", "events", "updatedAt"], + "required": ["schemaVersion", "kind", "run", "briefSource", "events", "artifacts", "updatedAt"], "additionalProperties": false, "properties": { "schemaVersion": { "const": 1 }, @@ -14,6 +14,19 @@ "minItems": 1, "items": { "$ref": "event.schema.json" } }, + "artifacts": { + "type": "array", + "items": { + "type": "object", + "required": ["path", "sha256", "source"], + "additionalProperties": false, + "properties": { + "path": { "type": "string", "minLength": 1 }, + "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "source": { "type": "string" } + } + } + }, "updatedAt": { "type": "string", "format": "date-time" } } } diff --git a/loops/issue-dev-loop/scripts/github-command-gate.mjs b/loops/issue-dev-loop/scripts/github-command-gate.mjs index dd306719..1f7cadc3 100755 --- a/loops/issue-dev-loop/scripts/github-command-gate.mjs +++ b/loops/issue-dev-loop/scripts/github-command-gate.mjs @@ -16,6 +16,15 @@ async function main() { if (!['automation', 'reviewer'].includes(role)) { throw new Error('authenticated command gate is missing its verified runtime context') } + let authorization + try { + authorization = JSON.parse(process.env.ECHO_UI_LOOP_AUTHORIZATION) + } catch { + throw new Error('authenticated command gate has invalid authorization context') + } + if (!authorization?.expectedRepository) { + throw new Error('authenticated command gate has invalid authorization context') + } const identityBinDirectory = path.dirname(fileURLToPath(import.meta.url)) const executableName = tool === 'credential' ? 'gh' : tool if (!['git', 'gh'].includes(executableName)) { @@ -30,12 +39,11 @@ async function main() { role, tool, args, - allowedPushBranch: process.env.ECHO_UI_LOOP_ALLOWED_PUSH_BRANCH || null, - expectedRepository: process.env.ECHO_UI_LOOP_EXPECTED_REPOSITORY || null, + authorization, }) if (tool === 'git' && args[0] === 'push') { await assertPushTargetsRepository({ - expectedRepository: process.env.ECHO_UI_LOOP_EXPECTED_REPOSITORY, + expectedRepository: authorization.expectedRepository, realGit: executable, environment: process.env, }) diff --git a/loops/issue-dev-loop/scripts/lib/active-journal.mjs b/loops/issue-dev-loop/scripts/lib/active-journal.mjs index fad562a7..b18f3311 100644 --- a/loops/issue-dev-loop/scripts/lib/active-journal.mjs +++ b/loops/issue-dev-loop/scripts/lib/active-journal.mjs @@ -15,6 +15,7 @@ import { } from './common.mjs' import { canonicalCheckpointRecord, + checkpointArtifactsForEvents, checkpointJournalConfiguration, checkpointPublicationBody, checkpointRecordDigest, @@ -45,6 +46,11 @@ export async function prepareActiveCheckpoint({ loopRoot = DEFAULT_LOOP_ROOT, ru run, briefSource: await readFile(briefPath, 'utf8'), events, + artifacts: await checkpointArtifactsForEvents({ + loopRoot, + runId: normalizedRunId, + events, + }), updatedAt: events.at(-1)?.timestamp, }) const resultPath = path.join(runDirectory(loopRoot, normalizedRunId), 'checkpoint-result.json') @@ -87,6 +93,11 @@ export async function recordActiveCheckpointPublication({ run, briefSource, events: currentEvents, + artifacts: await checkpointArtifactsForEvents({ + loopRoot, + runId: normalizedRunId, + events: currentEvents, + }), updatedAt: currentEvents.at(-1)?.timestamp, } if (canonicalCheckpointRecord(currentRecord) !== canonicalCheckpointRecord(record)) { @@ -233,6 +244,11 @@ export async function restoreActiveCheckpoint({ record.briefSource, 'utf8', ) + for (const artifact of record.artifacts) { + const artifactPath = path.resolve(loopRoot, artifact.path) + await mkdir(path.dirname(artifactPath), { recursive: true }) + await writeFile(artifactPath, artifact.source, 'utf8') + } await writeJson(path.join(runPath, 'checkpoint-result.json'), record) return record.run } diff --git a/loops/issue-dev-loop/scripts/lib/checkpoint-proof.mjs b/loops/issue-dev-loop/scripts/lib/checkpoint-proof.mjs index 6c5b56d4..0c800182 100644 --- a/loops/issue-dev-loop/scripts/lib/checkpoint-proof.mjs +++ b/loops/issue-dev-loop/scripts/lib/checkpoint-proof.mjs @@ -62,6 +62,69 @@ function canonicalEvent(event) { } } +function permittedArtifactPath(runId, relativePath) { + if ( + typeof relativePath !== 'string' || + path.isAbsolute(relativePath) || + relativePath.split(/[\\/]/).includes('..') + ) { + return false + } + const normalized = relativePath.split(path.sep).join('/') + return ( + normalized.startsWith(`logs/runs/${runId}/`) || + normalized.startsWith(`evidence/${runId}/`) || + normalized.startsWith(`handoffs/${runId}/`) + ) +} + +function artifactReferencesFromEvents(events) { + const references = new Map() + const add = (relativePath, digest) => { + if (!relativePath) return + const existing = references.get(relativePath) + if (existing && digest && existing !== digest) { + throw new Error(`checkpoint artifact has conflicting digests: ${relativePath}`) + } + references.set(relativePath, digest ?? existing ?? null) + } + for (const event of events) { + if (event.type === 'implementation_completed' && event.payload?.resultPath) { + add(event.payload.resultPath, event.payload.resultDigest) + } + if (event.type === 'verification_completed' && event.payload?.manifestPath) { + add(event.payload.manifestPath, event.payload.manifestDigest) + } + if (event.type === 'review_completed' && event.payload?.resultPath) { + add(event.payload.resultPath, event.payload.resultDigest) + } + if (event.type === 'finalization_published' && event.payload?.resultPath) { + add(event.payload.resultPath, event.payload.resultDigest) + } + } + return [...references].sort(([left], [right]) => left.localeCompare(right)) +} + +export async function checkpointArtifactsForEvents({ loopRoot, runId, events }) { + const artifacts = [] + for (const [relativePath, expectedDigest] of artifactReferencesFromEvents(events)) { + if (!permittedArtifactPath(runId, relativePath)) { + throw new Error(`checkpoint artifact path is outside the run: ${relativePath}`) + } + const source = await readFile(path.resolve(loopRoot, relativePath), 'utf8') + const sha256 = createHash('sha256').update(source).digest('hex') + if (expectedDigest !== sha256) { + throw new Error(`checkpoint artifact no longer matches its event: ${relativePath}`) + } + artifacts.push({ + path: relativePath.split(path.sep).join('/'), + sha256, + source, + }) + } + return artifacts +} + export function canonicalCheckpointRecord(record) { return JSON.stringify({ schemaVersion: 1, @@ -69,6 +132,11 @@ export function canonicalCheckpointRecord(record) { run: canonicalRun(record.run), briefSource: record.briefSource, events: record.events.map(canonicalEvent), + artifacts: record.artifacts.map((artifact) => ({ + path: artifact.path, + sha256: artifact.sha256, + source: artifact.source, + })), updatedAt: record.updatedAt, }) } @@ -80,6 +148,7 @@ export function checkpointRecordDigest(record) { export function validateCheckpointRecord(record) { const run = record?.run const events = record?.events + const artifacts = record?.artifacts if ( record?.schemaVersion !== 1 || record?.kind !== 'active-checkpoint' || @@ -92,12 +161,26 @@ export function validateCheckpointRecord(record) { (run.headSha !== null && !/^[0-9a-f]{40}$/i.test(run.headSha)) || !Array.isArray(events) || events.length === 0 || + !Array.isArray(artifacts) || typeof record.briefSource !== 'string' || Number.isNaN(Date.parse(record.updatedAt)) ) { throw new Error('invalid active checkpoint record') } assertRunId(run.runId) + const artifactPaths = new Set() + for (const artifact of artifacts) { + if ( + !permittedArtifactPath(run.runId, artifact?.path) || + typeof artifact.source !== 'string' || + !/^[0-9a-f]{64}$/.test(artifact.sha256 ?? '') || + createHash('sha256').update(artifact.source).digest('hex') !== artifact.sha256 || + artifactPaths.has(artifact.path) + ) { + throw new Error('active checkpoint contains an invalid artifact') + } + artifactPaths.add(artifact.path) + } let previousTimestamp = -Infinity for (const event of events) { const timestamp = Date.parse(event.timestamp) @@ -211,6 +294,11 @@ export async function verifyLatestDurableCheckpoint({ 'utf8', ), events: events.filter((event) => event.type !== 'checkpoint_published'), + artifacts: await checkpointArtifactsForEvents({ + loopRoot, + runId: normalizedRunId, + events: events.filter((event) => event.type !== 'checkpoint_published'), + }), updatedAt: latestPhaseEvent.timestamp, }) const digest = checkpointRecordDigest(record) diff --git a/loops/issue-dev-loop/scripts/lib/evidence.mjs b/loops/issue-dev-loop/scripts/lib/evidence.mjs index 60e5953a..3db553d5 100644 --- a/loops/issue-dev-loop/scripts/lib/evidence.mjs +++ b/loops/issue-dev-loop/scripts/lib/evidence.mjs @@ -364,6 +364,7 @@ export async function recordReview({ throw new Error('resultPath must be inside the current run directory') } const source = await readFile(resolvedResultPath, 'utf8') + const relativeResultPath = path.relative(loopRoot, resolvedResultPath) const result = JSON.parse(source) if (result.schemaVersion !== 1 || result.runId !== normalizedRunId) { throw new Error('review result does not match the run') @@ -624,6 +625,7 @@ export async function recordReview({ verdict: 'PASS', headSha, reviewUrl: publishedReviewUrl, + resultPath: relativeResultPath, resultDigest, findingCount: reviewSummary.findingCount, reviewRounds: reviewSummary.rounds, diff --git a/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs b/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs index b7116a83..b599634d 100644 --- a/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs +++ b/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto' import { readFile } from 'node:fs/promises' import path from 'node:path' @@ -139,7 +140,8 @@ export async function recordFinalizationPublication({ if (!resolvedResultPath.startsWith(`${runRoot}${path.sep}`)) { throw new Error('finalization result must be inside the current run directory') } - const record = validateFinalizationRecord(await readJson(resolvedResultPath), run) + const resultSource = await readFile(resolvedResultPath, 'utf8') + const record = validateFinalizationRecord(JSON.parse(resultSource), run) const { digest } = await verifyPublishedFinalization({ loopRoot, record, @@ -155,6 +157,8 @@ export async function recordFinalizationPublication({ payload: { commentUrl, digest, + resultPath: path.relative(loopRoot, resolvedResultPath), + resultDigest: createHash('sha256').update(resultSource).digest('hex'), finishedAt: record.finishedAt, mergeSha: record.mergeSha, failureFingerprint: record.failureFingerprint, diff --git a/loops/issue-dev-loop/scripts/lib/github-identity.mjs b/loops/issue-dev-loop/scripts/lib/github-identity.mjs index 91c724cc..51d55e79 100644 --- a/loops/issue-dev-loop/scripts/lib/github-identity.mjs +++ b/loops/issue-dev-loop/scripts/lib/github-identity.mjs @@ -6,7 +6,7 @@ import path from 'node:path' import { promisify } from 'node:util' import { fileURLToPath } from 'node:url' -import { assertNonEmpty, readJson, sameGitHubLogin } from './common.mjs' +import { assertNonEmpty, parseGitHubTarget, readJson, sameGitHubLogin } from './common.mjs' const execFileAsync = promisify(execFile) const moduleDirectory = path.dirname(fileURLToPath(import.meta.url)) @@ -23,6 +23,43 @@ const roleFields = { }, } +const inheritedEnvironmentNames = new Set([ + 'CI', + 'COLORTERM', + 'FORCE_COLOR', + 'HOME', + 'HTTPS_PROXY', + 'HTTP_PROXY', + 'LANG', + 'LOGNAME', + 'NO_COLOR', + 'NO_PROXY', + 'PATH', + 'SHELL', + 'SSL_CERT_DIR', + 'SSL_CERT_FILE', + 'TEMP', + 'TERM', + 'TMP', + 'TMPDIR', + 'USER', + 'XDG_RUNTIME_DIR', + 'https_proxy', + 'http_proxy', + 'no_proxy', +]) + +function safeBaseEnvironment(channel, environment) { + const safe = {} + const dynamicNames = new Set([channel.webhookEnvironmentVariable].filter(Boolean)) + for (const [name, value] of Object.entries(environment)) { + if (inheritedEnvironmentNames.has(name) || dynamicNames.has(name) || name.startsWith('LC_')) { + safe[name] = value + } + } + return safe +} + export function resolveGitHubRoleEnvironment({ channel, role, environment = process.env }) { const fields = roleFields[role] if (!fields) throw new Error('GitHub role must be automation or reviewer') @@ -45,37 +82,13 @@ export function resolveGitHubRoleEnvironment({ channel, role, environment = proc throw new Error(`${variableName} must contain an absolute directory path`) } - const routedEnvironment = { ...environment, GH_CONFIG_DIR: configDirectory } - for (const profileVariable of [ - channel.automationGitHubConfigEnvironmentVariable, - channel.reviewerGitHubConfigEnvironmentVariable, - ]) { - if (profileVariable) delete routedEnvironment[profileVariable] - } - for (const name of [ - 'GH_TOKEN', - 'GITHUB_TOKEN', - 'GH_ENTERPRISE_TOKEN', - 'GITHUB_ENTERPRISE_TOKEN', - ]) { - delete routedEnvironment[name] - } - for (const name of Object.keys(routedEnvironment)) { - if ( - /^GIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+)$/.test(name) || - name.startsWith('ECHO_UI_LOOP_REAL_') || - [ - 'ECHO_UI_LOOP_GITHUB_ROLE', - 'ECHO_UI_LOOP_IDENTITY_GATE', - 'ECHO_UI_LOOP_NODE', - 'ECHO_UI_LOOP_ALLOWED_PUSH_BRANCH', - 'ECHO_UI_LOOP_EXPECTED_REPOSITORY', - ].includes(name) - ) { - delete routedEnvironment[name] - } + const routedEnvironment = { + ...safeBaseEnvironment(channel, environment), + GH_CONFIG_DIR: configDirectory, } Object.assign(routedEnvironment, { + GH_PAGER: 'cat', + GH_PROMPT_DISABLED: '1', GIT_CONFIG_GLOBAL: devNull, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_COUNT: '2', @@ -85,6 +98,9 @@ export function resolveGitHubRoleEnvironment({ channel, role, environment = proc GIT_CONFIG_VALUE_1: `!${shellQuote(process.execPath)} ${shellQuote( commandGatePath, )} credential`, + GIT_PAGER: 'cat', + GIT_TERMINAL_PROMPT: '0', + PAGER: 'cat', }) return { configDirectory, expectedLogin, routedEnvironment } } @@ -118,12 +134,16 @@ function gitSubcommand(args) { return { index, name: args[index] ?? null } } -export function assertGitCommandPolicy(role, args, { allowedPushBranch = null } = {}) { +function authorizedPushBranches(authorization) { + return new Set([authorization?.issue?.branch, authorization?.evolve?.branch].filter(Boolean)) +} + +export function assertGitCommandPolicy(role, args, { authorization = null } = {}) { const subcommand = gitSubcommand(args) if (subcommand.name === 'push') { if (role === 'reviewer') throw new Error('reviewer identity cannot run git push') const branch = args.at(-1) - const isLoopBranch = /^codex\/issue-\d+$/.test(branch) && branch === allowedPushBranch + const isLoopBranch = authorizedPushBranches(authorization).has(branch) const isAllowedShape = subcommand.index === 0 && isLoopBranch && @@ -138,6 +158,19 @@ export function assertGitCommandPolicy(role, args, { allowedPushBranch = null } return } + if ( + args.some( + (argument) => + ['--ext-diff', '--textconv', '--exec-path'].includes(argument) || + ['--ext-diff=', '--textconv=', '--exec-path=', '--output='].some((prefix) => + argument.startsWith(prefix), + ) || + argument === '--output', + ) + ) { + throw new Error(`git command is outside the authenticated ${role} command tree`) + } + const readOnly = new Set([ 'rev-parse', 'status', @@ -237,6 +270,7 @@ function githubApiRequest(apiArguments) { let endpoint = null let hasRequestBody = false let valid = true + const fields = [] for (let index = 0; index < apiArguments.length; index += 1) { const argument = apiArguments[index] @@ -247,7 +281,10 @@ function githubApiRequest(apiArguments) { break } if (argument === '--method' || argument === '-X') explicitMethod = value - if (bodyOptions.has(argument)) hasRequestBody = true + if (bodyOptions.has(argument)) { + hasRequestBody = true + fields.push(value) + } index += 1 continue } @@ -261,7 +298,10 @@ function githubApiRequest(apiArguments) { break } if (longOption === '--method') explicitMethod = value - if (bodyOptions.has(longOption)) hasRequestBody = true + if (bodyOptions.has(longOption)) { + hasRequestBody = true + fields.push(value) + } continue } if (/^-X[A-Za-z]+$/.test(argument)) { @@ -270,6 +310,7 @@ function githubApiRequest(apiArguments) { } if (/^-[fF].+/.test(argument)) { hasRequestBody = true + fields.push(argument.slice(2)) continue } if ( @@ -291,66 +332,232 @@ function githubApiRequest(apiArguments) { method, mutating: !valid || method !== 'GET', valid, + fields, } } -function reviewerCommentReviewAllowed(args, commandIndex) { - let hasComment = false - let targetCount = 0 - const optionsWithValue = new Set(['--body', '-b', '--body-file', '-F', '--repo', '-R']) +function parseOptions(args, startIndex, { valueOptions = {}, booleanOptions = {} } = {}) { + const values = new Map() + const booleans = new Map() + const positional = [] + const valueAliases = new Map(Object.entries(valueOptions)) + const booleanAliases = new Map(Object.entries(booleanOptions)) + let valid = true - for (let index = commandIndex + 1; index < args.length; index += 1) { + for (let index = startIndex; index < args.length; index += 1) { const argument = args[index] - if ( - ['--approve', '--request-changes', '-a', '-r'].some( - (name) => argument === name || argument.startsWith(`${name}=`), - ) - ) { - return false + if (valueAliases.has(argument)) { + const value = args[index + 1] + if (value === undefined) { + valid = false + break + } + const name = valueAliases.get(argument) + values.set(name, [...(values.get(name) ?? []), value]) + index += 1 + continue } - if (argument === '--comment' || argument === '-c' || argument === '--comment=true') { - hasComment = true + const longValueOption = [...valueAliases].find( + ([option]) => option.startsWith('--') && argument.startsWith(`${option}=`), + ) + if (longValueOption) { + const value = argument.slice(longValueOption[0].length + 1) + if (!value) { + valid = false + break + } + const name = longValueOption[1] + values.set(name, [...(values.get(name) ?? []), value]) continue } - if (argument.startsWith('--comment=')) return false - if (optionsWithValue.has(argument)) { - if (args[index + 1] === undefined) return false - index += 1 + if (booleanAliases.has(argument)) { + booleans.set(booleanAliases.get(argument), true) continue } - const longOption = [...optionsWithValue].find( - (name) => name.startsWith('--') && argument.startsWith(`${name}=`), + const longBooleanOption = [...booleanAliases].find( + ([option]) => option.startsWith('--') && argument.startsWith(`${option}=`), ) - if (longOption) { - if (!argument.slice(longOption.length + 1)) return false + if (longBooleanOption) { + const value = argument.slice(longBooleanOption[0].length + 1) + if (!['true', 'false'].includes(value)) { + valid = false + break + } + booleans.set(longBooleanOption[1], value === 'true') continue } - if (argument.startsWith('-')) return false - targetCount += 1 - if (targetCount > 1) return false + if (argument.startsWith('-')) { + valid = false + break + } + positional.push(argument) } - return hasComment + return { booleans, positional, valid, values } } -function automationApiMutationAllowed({ endpoint, method }) { - if (!endpoint) return false +function exactlyOne(values, name) { + const candidates = values.get(name) ?? [] + return candidates.length === 1 ? candidates[0] : null +} + +function pullRequestTargetMatches(target, authorization, expectedRepository) { + const expectedNumber = authorization?.issue?.prNumber + if (!Number.isInteger(expectedNumber)) return false + if (/^\d+$/.test(target)) return Number(target) === expectedNumber + const parsed = parseGitHubTarget(target) + return ( + parsed?.kind === 'pull' && + parsed.number === expectedNumber && + repositoryInScope(`${parsed.owner}/${parsed.repo}`, expectedRepository) + ) +} + +const repositoryValueOptions = { + '--repo': 'repository', + '-R': 'repository', +} + +function reviewerCommentReviewAllowed(args, commandIndex, authorization, expectedRepository) { + const parsed = parseOptions(args, commandIndex + 1, { + valueOptions: { + ...repositoryValueOptions, + '--body': 'body', + '-b': 'body', + '--body-file': 'bodyFile', + '-F': 'bodyFile', + }, + booleanOptions: { '--comment': 'comment', '-c': 'comment' }, + }) + return ( + parsed.valid && + parsed.booleans.get('comment') === true && + parsed.positional.length === 1 && + pullRequestTargetMatches(parsed.positional[0], authorization, expectedRepository) && + Number(Boolean(exactlyOne(parsed.values, 'body'))) + + Number(Boolean(exactlyOne(parsed.values, 'bodyFile'))) === + 1 + ) +} + +function pullRequestCreateAllowed(args, commandIndex, authorization) { + const parsed = parseOptions(args, commandIndex + 1, { + valueOptions: { + ...repositoryValueOptions, + '--base': 'base', + '-B': 'base', + '--head': 'head', + '-H': 'head', + '--title': 'title', + '-t': 'title', + '--body': 'body', + '-b': 'body', + '--body-file': 'bodyFile', + '-F': 'bodyFile', + }, + booleanOptions: { '--draft': 'draft', '-d': 'draft' }, + }) if ( - /^repos\/[^/]+\/[^/]+\/issues\/\d+\/labels(?:\/[^/]+)?$/.test(endpoint) && - ['POST', 'DELETE'].includes(method) + !parsed.valid || + parsed.positional.length !== 0 || + parsed.booleans.get('draft') !== true || + exactlyOne(parsed.values, 'base') !== 'dev' || + !exactlyOne(parsed.values, 'title') || + Number(Boolean(exactlyOne(parsed.values, 'body'))) + + Number(Boolean(exactlyOne(parsed.values, 'bodyFile'))) !== + 1 ) { - return true + return false } - if (/^repos\/[^/]+\/[^/]+\/issues\/\d+\/comments$/.test(endpoint) && method === 'POST') { + const head = exactlyOne(parsed.values, 'head') + if (head === authorization?.issue?.branch && authorization.issue.prNumber === null) { return true } + if (head !== authorization?.evolve?.branch) return false + const body = exactlyOne(parsed.values, 'body') + return body?.includes(`<!-- issue-dev-loop:evolve-request:${authorization.evolve.requestId} -->`) +} + +function pullRequestMutationAllowed(kind, args, commandIndex, authorization, expectedRepository) { + const valueOptions = + kind === 'edit' + ? { + ...repositoryValueOptions, + '--title': 'title', + '-t': 'title', + '--body': 'body', + '-b': 'body', + '--body-file': 'bodyFile', + '-F': 'bodyFile', + '--add-assignee': 'addAssignee', + '--remove-assignee': 'removeAssignee', + '--add-label': 'addLabel', + '--remove-label': 'removeLabel', + '--add-reviewer': 'addReviewer', + '--milestone': 'milestone', + '--remove-milestone': 'removeMilestone', + } + : kind === 'comment' + ? { + ...repositoryValueOptions, + '--body': 'body', + '-b': 'body', + '--body-file': 'bodyFile', + '-F': 'bodyFile', + } + : repositoryValueOptions + const parsed = parseOptions(args, commandIndex + 1, { valueOptions }) if ( - /^repos\/[^/]+\/[^/]+\/(?:issues|pulls)\/comments\/\d+$/.test(endpoint) && - method === 'PATCH' + !parsed.valid || + parsed.positional.length !== 1 || + !pullRequestTargetMatches(parsed.positional[0], authorization, expectedRepository) ) { - return true + return false + } + if (kind === 'ready') return parsed.values.size <= 1 + if (kind === 'comment') { + return ( + Number(Boolean(exactlyOne(parsed.values, 'body'))) + + Number(Boolean(exactlyOne(parsed.values, 'bodyFile'))) === + 1 + ) } + const reviewers = (parsed.values.get('addReviewer') ?? []).flatMap((value) => + value.split(',').map((login) => login.trim()), + ) + if (reviewers.some((login) => !sameGitHubLogin(login, authorization?.ownerGitHubLogin))) { + return false + } + const editedFields = [...parsed.values.keys()].filter((name) => name !== 'repository') + return editedFields.length > 0 +} + +function automationApiMutationAllowed({ endpoint, method, fields }, authorization) { + if (!endpoint) return false + const labels = endpoint.match(/^repos\/[^/]+\/[^/]+\/issues\/(\d+)\/labels(?:\/([^/]+))?$/) + if (labels && Number(labels[1]) === authorization?.issue?.issueNumber) { + if (method === 'POST') { + return labels[2] === undefined && sameArguments(fields, ['labels[]=loop:claimed']) + } + return method === 'DELETE' && labels[2] === 'loop%3Aclaimed' && fields.length === 0 + } + const issueComment = endpoint.match(/^repos\/[^/]+\/[^/]+\/issues\/(\d+)\/comments$/) + const commentTargets = new Set( + [ + authorization?.issue?.issueNumber, + authorization?.issue?.prNumber, + authorization?.stateIssueNumber, + ].filter(Number.isInteger), + ) + if (issueComment && method === 'POST' && commentTargets.has(Number(issueComment[1]))) { + return fields.length === 1 && fields[0].startsWith('body=') + } + const reply = endpoint.match(/^repos\/[^/]+\/[^/]+\/pulls\/(\d+)\/comments\/\d+\/replies$/) return ( - /^repos\/[^/]+\/[^/]+\/pulls\/\d+\/comments\/\d+\/replies$/.test(endpoint) && method === 'POST' + reply !== null && + method === 'POST' && + Number(reply[1]) === authorization?.issue?.prNumber && + fields.length === 1 && + fields[0].startsWith('body=') ) } @@ -387,7 +594,11 @@ function repositoryInScope(actual, expected) { ) } -export function assertGitHubCliPolicy(role, args, { expectedRepository = null } = {}) { +export function assertGitHubCliPolicy( + role, + args, + { expectedRepository = null, authorization = null } = {}, +) { const reject = () => { throw new Error(`GitHub action is prohibited for the ${role} role`) } @@ -402,10 +613,13 @@ export function assertGitHubCliPolicy(role, args, { expectedRepository = null } const group = githubGroup(args) if (!group.name) reject() const subcommand = commandAfterGroup(args, group.index) + const readOnlyIdentityRequest = (request) => + request.valid && !request.mutating && request.endpoint === 'user' && request.fields.length === 0 if (role === 'reviewer') { if (group.name === 'api') { const request = githubApiRequest(args.slice(group.index + 1)) + if (readOnlyIdentityRequest(request)) return if ( !request.valid || request.mutating || @@ -420,24 +634,40 @@ export function assertGitHubCliPolicy(role, args, { expectedRepository = null } if (group.name === 'run' && subcommand.name === 'view') return if (group.name !== 'pr') reject() if (['view', 'diff', 'checks'].includes(subcommand.name)) return - if (subcommand.name !== 'review' || !reviewerCommentReviewAllowed(args, subcommand.index)) { + if ( + subcommand.name !== 'review' || + !reviewerCommentReviewAllowed(args, subcommand.index, authorization, expectedRepository) + ) { reject() } return } if (group.name === 'issue') { - if (!['list', 'view', 'comment', 'edit'].includes(subcommand.name)) reject() + if (!['list', 'view'].includes(subcommand.name)) reject() return } if (group.name === 'pr') { + if (['list', 'view', 'checks', 'diff'].includes(subcommand.name)) return if ( - !['list', 'view', 'create', 'edit', 'comment', 'ready', 'checks', 'diff'].includes( + subcommand.name === 'create' && + pullRequestCreateAllowed(args, subcommand.index, authorization) + ) { + return + } + if ( + ['edit', 'comment', 'ready'].includes(subcommand.name) && + pullRequestMutationAllowed( subcommand.name, + args, + subcommand.index, + authorization, + expectedRepository, ) ) { - reject() + return } + reject() return } if (group.name === 'run') { @@ -446,6 +676,7 @@ export function assertGitHubCliPolicy(role, args, { expectedRepository = null } } if (group.name !== 'api') reject() const request = githubApiRequest(args.slice(group.index + 1)) + if (readOnlyIdentityRequest(request)) return if ( !request.valid || request.endpoint === 'graphql' || @@ -455,41 +686,34 @@ export function assertGitHubCliPolicy(role, args, { expectedRepository = null } reject() } if (!request.mutating) return - if (!automationApiMutationAllowed(request)) reject() + if (!automationApiMutationAllowed(request, authorization)) reject() } -export function assertDescendantCommandPolicy({ - role, - tool, - args, - allowedPushBranch = null, - expectedRepository = null, -}) { +export function assertDescendantCommandPolicy({ role, tool, args, authorization = null }) { if (tool === 'git') { - assertGitCommandPolicy(role, args, { allowedPushBranch }) + assertGitCommandPolicy(role, args, { authorization }) return } if (tool === 'gh') { - assertGitHubCliPolicy(role, args, { expectedRepository }) + assertGitHubCliPolicy(role, args, { + authorization, + expectedRepository: authorization?.expectedRepository ?? null, + }) return } throw new Error(`unsupported authenticated tool: ${tool}`) } -function assertRootCommandPolicy({ - role, - tool, - args, - loopRoot, - allowedPushBranch, - expectedRepository, -}) { +function assertRootCommandPolicy({ role, tool, args, loopRoot, authorization }) { if (tool === 'git') { - assertGitCommandPolicy(role, args, { allowedPushBranch }) + assertGitCommandPolicy(role, args, { authorization }) return } if (tool === 'gh') { - assertGitHubCliPolicy(role, args, { expectedRepository }) + assertGitHubCliPolicy(role, args, { + authorization, + expectedRepository: authorization?.expectedRepository ?? null, + }) return } if (role === 'automation' && tool === 'node') { @@ -563,25 +787,109 @@ export async function assertPushTargetsRepository({ expectedRepository, realGit, } } -async function readActivePushBranch(loopRoot) { +async function readOptionalJson(filePath) { + try { + return await readJson(filePath) + } catch (error) { + if (error?.code === 'ENOENT') return null + throw error + } +} + +async function readAuthorizationContext(loopRoot, channel) { const runsRoot = path.join(loopRoot, 'logs', 'runs') let entries try { entries = await readdir(runsRoot, { withFileTypes: true }) } catch (error) { - if (error?.code === 'ENOENT') return null - throw error + if (error?.code === 'ENOENT') entries = [] + else throw error } const active = [] for (const entry of entries) { if (!entry.isDirectory()) continue const run = await readJson(path.join(runsRoot, entry.name, 'run.json')) - if (['running', 'waiting_for_owner', 'awaiting_owner_review'].includes(run.status)) { - active.push(run.branch) + if ( + run.finishedAt === null && + ['running', 'waiting_for_owner', 'awaiting_owner_review'].includes(run.status) + ) { + active.push(run) } } - if (active.length > 1) throw new Error('multiple active runs cannot authorize a Git push') - return active[0] ?? null + if (active.length > 1) { + throw new Error('multiple active runs cannot authorize GitHub mutations') + } + const run = active[0] ?? null + const pullTarget = run?.prUrl ? parseGitHubTarget(run.prUrl) : null + const issue = run + ? { + branch: assertNonEmpty(run.branch, 'run.branch'), + issueNumber: run.issueNumber, + prNumber: pullTarget?.kind === 'pull' ? pullTarget.number : null, + } + : null + + const metrics = await readOptionalJson(path.join(loopRoot, 'evolve', 'metrics.json')) + let evolve = null + if (metrics?.evolveDue) { + const requestId = assertNonEmpty(metrics.pendingRequestId, 'metrics.pendingRequestId') + if (!/^[A-Z0-9-]+$/.test(requestId)) { + throw new Error('pending evolve request ID cannot authorize a branch') + } + const request = await readJson(path.join(loopRoot, 'evolve', 'requests', `${requestId}.json`)) + if (request.requestId !== requestId || request.status !== 'pending') { + throw new Error('pending evolve authorization does not match its request') + } + evolve = { requestId, branch: `codex/evolve-${requestId}` } + } + if (issue && evolve) { + throw new Error('issue and evolve work cannot share one mutation authorization') + } + + return { + expectedRepository: assertNonEmpty(channel.repository, 'channel.repository'), + ownerGitHubLogin: assertNonEmpty(channel.ownerGitHubLogin, 'channel.ownerGitHubLogin'), + stateIssueNumber: channel.stateIssueNumber, + issue, + evolve, + } +} + +function argumentAfter(args, name) { + const index = args.indexOf(name) + return index === -1 ? null : (args[index + 1] ?? null) +} + +function withRootCommandIntent(authorization, { tool, args, loopRoot }) { + const script = args[0] ? path.resolve(args[0]) : null + if ( + tool !== 'node' || + script !== path.resolve(loopRoot, 'scripts', 'loopctl.mjs') || + args[1] !== 'start' || + authorization.issue !== null + ) { + return authorization + } + const issueNumber = Number(argumentAfter(args, '--issue')) + const issueUrl = argumentAfter(args, '--url') + const target = parseGitHubTarget(issueUrl) + if ( + !Number.isInteger(issueNumber) || + issueNumber < 1 || + target?.kind !== 'issues' || + target.number !== issueNumber || + !repositoryInScope(`${target.owner}/${target.repo}`, authorization.expectedRepository) + ) { + throw new Error('loopctl start intent must identify one issue in the configured repository') + } + return { + ...authorization, + issue: { + branch: `codex/issue-${issueNumber}`, + issueNumber, + prNumber: null, + }, + } } export async function assertGitHubRoleIdentity({ @@ -623,14 +931,17 @@ export async function runWithGitHubRole({ ]) const executable = await resolveRequestedExecutable(requestedCommand, resolved.routedEnvironment) const tool = await authenticatedToolForExecutable(executable, { realGit, realGh }) - const allowedPushBranch = await readActivePushBranch(loopRoot) + const authorization = withRootCommandIntent(await readAuthorizationContext(loopRoot, channel), { + tool, + args, + loopRoot, + }) assertRootCommandPolicy({ role, tool, args, loopRoot, - allowedPushBranch, - expectedRepository: channel.repository, + authorization, }) if (tool === 'git' && gitSubcommand(args).name === 'push') { await assertPushTargetsRepository({ @@ -645,8 +956,7 @@ export async function runWithGitHubRole({ ECHO_UI_LOOP_GITHUB_ROLE: role, ECHO_UI_LOOP_IDENTITY_GATE: commandGatePath, ECHO_UI_LOOP_NODE: process.execPath, - ECHO_UI_LOOP_ALLOWED_PUSH_BRANCH: allowedPushBranch ?? '', - ECHO_UI_LOOP_EXPECTED_REPOSITORY: channel.repository, + ECHO_UI_LOOP_AUTHORIZATION: JSON.stringify(authorization), } const child = spawnCommand(executable, args, { env: childEnvironment, diff --git a/loops/issue-dev-loop/scripts/lib/validation.mjs b/loops/issue-dev-loop/scripts/lib/validation.mjs index c6bd97d7..490aa615 100644 --- a/loops/issue-dev-loop/scripts/lib/validation.mjs +++ b/loops/issue-dev-loop/scripts/lib/validation.mjs @@ -60,6 +60,7 @@ export async function validateLoop({ 'scripts/lib/run-store.mjs', 'scripts/lib/validation.mjs', 'scripts/with-github-identity.mjs', + 'scripts/with-github-identity', 'logs/index.jsonl', 'logs/triggers.jsonl', 'screen-shots/.gitignore', diff --git a/loops/issue-dev-loop/scripts/with-github-identity b/loops/issue-dev-loop/scripts/with-github-identity new file mode 100755 index 00000000..33b55a54 --- /dev/null +++ b/loops/issue-dev-loop/scripts/with-github-identity @@ -0,0 +1,7 @@ +#!/bin/sh + +unset NODE_OPTIONS +unset NODE_PATH + +script_directory=${0%/*} +exec node "$script_directory/with-github-identity.mjs" "$@" diff --git a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs index 3d071ae7..28e0870b 100644 --- a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs +++ b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' import { execFile } from 'node:child_process' -import { chmod, mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { chmod, mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import test from 'node:test' @@ -10,10 +10,11 @@ import { fileURLToPath } from 'node:url' const execFileAsync = promisify(execFile) const testDirectory = path.dirname(fileURLToPath(import.meta.url)) const routerPath = path.resolve(testDirectory, '..', 'scripts', 'with-github-identity.mjs') +const routerLauncherPath = path.resolve(testDirectory, '..', 'scripts', 'with-github-identity') const commandGatePath = path.resolve(testDirectory, '..', 'scripts', 'github-command-gate.mjs') const credentialHelper = `!'${process.execPath}' '${commandGatePath}' credential` -async function createFixture() { +async function createFixture({ activeRun = true, recordedPr = true } = {}) { const parent = await mkdtemp(path.join(os.tmpdir(), 'echo-ui-identity-routing-')) const loopRoot = path.join(parent, 'issue-dev-loop') const channelRoot = path.join(parent, '_shared', 'owner-channel') @@ -23,7 +24,8 @@ async function createFixture() { await Promise.all([ mkdir(channelRoot, { recursive: true }), mkdir(path.join(loopRoot, 'scripts'), { recursive: true }), - mkdir(path.join(loopRoot, 'logs', 'runs', 'fixture-run'), { recursive: true }), + mkdir(path.join(loopRoot, 'logs', 'runs'), { recursive: true }), + mkdir(path.join(loopRoot, 'evolve', 'requests'), { recursive: true }), mkdir(binRoot, { recursive: true }), mkdir(automationProfile, { recursive: true }), mkdir(reviewerProfile, { recursive: true }), @@ -36,18 +38,34 @@ async function createFixture() { reviewerGitHubLogin: 'reviewer-user', automationGitHubConfigEnvironmentVariable: 'ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR', reviewerGitHubConfigEnvironmentVariable: 'ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR', + stateIssueNumber: 999, repository: 'example/repo', })}\n`, 'utf8', ) await writeFile(path.join(automationProfile, 'identity'), 'executor-user\n', 'utf8') await writeFile(path.join(reviewerProfile, 'identity'), 'reviewer-user\n', 'utf8') + if (activeRun) { + await mkdir(path.join(loopRoot, 'logs', 'runs', 'fixture-run'), { recursive: true }) + await writeFile( + path.join(loopRoot, 'logs', 'runs', 'fixture-run', 'run.json'), + `${JSON.stringify({ + runId: 'fixture-run', + issueNumber: 123, + status: 'running', + finishedAt: null, + branch: 'codex/issue-123', + prUrl: recordedPr ? 'https://github.com/example/repo/pull/106' : null, + })}\n`, + 'utf8', + ) + } await writeFile( - path.join(loopRoot, 'logs', 'runs', 'fixture-run', 'run.json'), + path.join(loopRoot, 'evolve', 'metrics.json'), `${JSON.stringify({ - runId: 'fixture-run', - status: 'running', - branch: 'codex/issue-123', + schemaVersion: 1, + evolveDue: false, + pendingRequestId: null, })}\n`, 'utf8', ) @@ -62,6 +80,19 @@ if (process.argv[2] === 'spawn') { stdio: 'inherit', }) process.exitCode = result.status ?? 1 +} else if (process.argv[2] === 'start') { + const issueIndex = process.argv.indexOf('--issue') + const issue = process.argv[issueIndex + 1] + for (const command of [ + ['api', 'user'], + ['api', \`repos/example/repo/issues/\${issue}/labels\`, '--method', 'POST', '-f', 'labels[]=loop:claimed'] + ]) { + const result = spawnSync('gh', command, { env: process.env, stdio: 'inherit' }) + if (result.status !== 0) { + process.exitCode = result.status ?? 1 + break + } + } } else { process.stdout.write(JSON.stringify({ config: process.env.GH_CONFIG_DIR, @@ -81,6 +112,15 @@ if (process.argv[2] === 'spawn') { exposesRealTools: Boolean( process.env.ECHO_UI_LOOP_REAL_GIT || process.env.ECHO_UI_LOOP_REAL_GH + ), + hasExecutionHooks: Boolean( + process.env.NODE_OPTIONS || + process.env.NODE_PATH || + process.env.GIT_EXTERNAL_DIFF || + process.env.GIT_EXEC_PATH || + process.env.GIT_SSH_COMMAND || + process.env.GH_BROWSER || + process.env.BROWSER ) })) } @@ -93,12 +133,16 @@ if (process.argv[2] === 'spawn') { fakeGh, `#!/bin/sh if [ "$1 $2 $3 $4" != "api user --jq .login" ]; then + if [ "$1 $2" = "api user" ]; then + sed -n '1p' "$GH_CONFIG_DIR/identity" + exit 0 + fi if [ "$1 $2 $4" = "pr review --comment" ]; then echo "comment review published" exit 0 fi - echo "unexpected gh arguments" >&2 - exit 90 + node -e 'process.stdout.write(JSON.stringify({args: process.argv.slice(1)}))' -- "$@" + exit 0 fi sed -n '1p' "$GH_CONFIG_DIR/identity" `, @@ -138,6 +182,7 @@ node -e 'process.stdout.write(JSON.stringify({args: process.argv.slice(1), confi ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR: reviewerProfile, GH_TOKEN: 'must-not-leak', GITHUB_TOKEN: 'must-not-leak', + NODE_OPTIONS: '', }, } } @@ -161,9 +206,38 @@ test('automation role selects its dedicated gh profile without leaking token ove gitIsolation: [os.devNull, '1'], exposesOtherProfiles: false, exposesRealTools: false, + hasExecutionHooks: false, }) }) +test('launcher removes Node preload hooks before the authenticated process starts', async () => { + const fixture = await createFixture() + const markerPath = path.join(path.dirname(fixture.loopRoot), 'preload-marker') + const preloadPath = path.join(path.dirname(fixture.loopRoot), 'preload.cjs') + await writeFile( + preloadPath, + `require('node:fs').writeFileSync(${JSON.stringify(markerPath)}, 'executed')\n`, + 'utf8', + ) + const { stdout } = await execFileAsync( + routerLauncherPath, + ['--loop-root', fixture.loopRoot, 'automation', '--', process.execPath, fixture.loopctlPath], + { + env: { + ...fixture.env, + NODE_OPTIONS: `--require=${preloadPath}`, + GIT_EXTERNAL_DIFF: '/tmp/untrusted-diff', + GIT_EXEC_PATH: '/tmp/untrusted-git-exec', + GIT_SSH_COMMAND: '/tmp/untrusted-ssh', + GH_BROWSER: '/tmp/untrusted-browser', + BROWSER: '/tmp/untrusted-browser', + }, + }, + ) + assert.equal(JSON.parse(stdout).hasExecutionHooks, false) + await assert.rejects(readFile(markerPath, 'utf8'), /ENOENT/) +}) + test('reviewer role refuses a profile authenticated as the wrong account', async () => { const fixture = await createFixture() await writeFile(path.join(fixture.reviewerProfile, 'identity'), 'owner-user\n', 'utf8') @@ -216,6 +290,52 @@ test('automation git command clears global helpers and injects the selected gh c ]) }) +test('routed loopctl may perform its exact read-only identity probe', async () => { + const fixture = await createFixture() + const { stdout } = await execFileAsync( + process.execPath, + [ + routerPath, + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + process.execPath, + fixture.loopctlPath, + 'spawn', + 'gh', + 'api', + 'user', + ], + { env: fixture.env }, + ) + assert.equal(stdout.trim(), 'executor-user') +}) + +test('routed loopctl start may claim only its command-line issue', async () => { + const fixture = await createFixture({ activeRun: false }) + const { stdout } = await execFileAsync( + process.execPath, + [ + routerPath, + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + process.execPath, + fixture.loopctlPath, + 'start', + '--issue', + '123', + '--url', + 'https://github.com/example/repo/issues/123', + ], + { env: fixture.env }, + ) + assert.match(stdout, /executor-user/) + assert.match(stdout, /issues\/123\/labels/) +}) + test('reviewer identity cannot push code', async () => { const fixture = await createFixture() await assert.rejects( @@ -332,6 +452,170 @@ test('reviewer role may publish only a non-approving comment review', async () = assert.equal(stdout.trim(), 'comment review published') }) +test('automation PR creation is bound to the active branch, dev, and Draft', async () => { + const fixture = await createFixture({ recordedPr: false }) + const { stdout } = await execFileAsync( + process.execPath, + [ + routerPath, + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + 'gh', + 'pr', + 'create', + '--repo', + 'example/repo', + '--base', + 'dev', + '--head', + 'codex/issue-123', + '--draft', + '--title', + 'Fixture PR', + '--body', + 'Fixture body', + ], + { env: fixture.env }, + ) + assert.deepEqual(JSON.parse(stdout).args.slice(0, 2), ['pr', 'create']) +}) + +test('PR and issue mutations reject unsafe shapes and targets', async () => { + const fixture = await createFixture() + const forbidden = [ + ['automation', ['pr', 'create', '--base', 'main', '--head', 'codex/issue-123', '--draft']], + ['automation', ['pr', 'create', '--base', 'dev', '--head', 'dev', '--draft']], + ['automation', ['pr', 'create', '--base', 'dev', '--head', 'codex/issue-123']], + ['automation', ['pr', 'edit', '106', '--base', 'main']], + ['automation', ['pr', 'edit', '107', '--title', 'Wrong PR']], + ['automation', ['pr', 'edit', '106', '--add-reviewer', 'attacker']], + ['automation', ['pr', 'ready', '107']], + ['automation', ['pr', 'comment', '107', '--body', 'Wrong PR']], + [ + 'reviewer', + [ + 'pr', + 'review', + 'https://github.com/attacker/other-repo/pull/106', + '--comment', + '--body', + 'Wrong repository', + ], + ], + ['automation', ['issue', 'edit', '123', '--state', 'closed']], + [ + 'automation', + [ + 'api', + 'repos/example/repo/issues/124/labels', + '--method', + 'POST', + '-f', + 'labels[]=loop:claimed', + ], + ], + ] + for (const [role, ghArguments] of forbidden) { + await assert.rejects( + execFileAsync( + process.execPath, + [routerPath, '--loop-root', fixture.loopRoot, role, '--', 'gh', ...ghArguments], + { env: fixture.env }, + ), + /GitHub action is prohibited for the (automation|reviewer) role/, + ) + } +}) + +test('pending evolve request authorizes only its exact push and Draft PR branch', async () => { + const fixture = await createFixture({ activeRun: false }) + const requestId = 'EVL-000010-TEN-FINALIZED-RUNS' + await writeFile( + path.join(fixture.loopRoot, 'evolve', 'metrics.json'), + `${JSON.stringify({ + schemaVersion: 1, + evolveDue: true, + pendingRequestId: requestId, + })}\n`, + 'utf8', + ) + await writeFile( + path.join(fixture.loopRoot, 'evolve', 'requests', `${requestId}.json`), + `${JSON.stringify({ + schemaVersion: 1, + requestId, + status: 'pending', + requestedAt: '2026-07-23T00:00:00.000Z', + })}\n`, + 'utf8', + ) + const branch = `codex/evolve-${requestId}` + await execFileAsync( + process.execPath, + [ + routerPath, + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + 'git', + 'push', + 'origin', + branch, + ], + { env: fixture.env }, + ) + const { stdout } = await execFileAsync( + process.execPath, + [ + routerPath, + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + 'gh', + 'pr', + 'create', + '--repo', + 'example/repo', + '--base', + 'dev', + '--head', + branch, + '--draft', + '--title', + 'Evolve loop', + '--body', + `<!-- issue-dev-loop:evolve-request:${requestId} -->`, + ], + { env: fixture.env }, + ) + assert.deepEqual(JSON.parse(stdout).args.slice(0, 2), ['pr', 'create']) + + for (const rejectedBranch of ['codex/evolve-EVL-000011-TEN-FINALIZED-RUNS', 'codex/issue-123']) { + await assert.rejects( + execFileAsync( + process.execPath, + [ + routerPath, + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + 'git', + 'push', + 'origin', + rejectedBranch, + ], + { env: fixture.env }, + ), + /automation may push only one explicit loop branch/, + ) + } +}) + test('authenticated command trees reject shell, env, arbitrary node, and descendant push bypasses', async () => { const fixture = await createFixture() const forbiddenCommands = [ @@ -367,6 +651,8 @@ test('GitHub role allowlists reject alternate merge syntax and unrelated reviewe ['automation', ['api', '-XPUT', 'repos/example/repo/pulls/106/merge']], ['automation', ['api', '/graphql', '-f', 'query=mutation { test }']], ['automation', ['api', '--method', 'DELETE', 'repos/example/repo/branches/dev/protection']], + ['automation', ['issue', 'edit', '105', '--state', 'closed']], + ['automation', ['pr', 'create', '--base', 'main', '--head', 'dev']], ] for (const [role, ghArguments] of forbidden) { await assert.rejects( @@ -385,6 +671,8 @@ test('authenticated roots reject executable impersonation and mutating remote sy for (const command of [ [fixture.impostorGh, 'pr', 'view', '106'], ['git', 'remote', '-v', 'set-url', 'origin', 'https://example.invalid/repo.git'], + ['git', 'diff', '--ext-diff'], + ['git', 'show', '--textconv', 'HEAD'], ]) { await assert.rejects( execFileAsync( diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index d11aad04..f8343d0d 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -120,8 +120,7 @@ async function createFixture() { ownerGitHubLogin: 'codeacme17', automationGitHubLogin: 'echo-ui-loop[bot]', reviewerGitHubLogin: 'echo-ui-reviewer[bot]', - automationGitHubConfigEnvironmentVariable: - 'ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR', + automationGitHubConfigEnvironmentVariable: 'ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR', reviewerGitHubConfigEnvironmentVariable: 'ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR', stateIssueNumber: 999, repository: 'codeacme17/echo-ui', @@ -2653,6 +2652,108 @@ test('fresh worktrees restore active checkpoints and trigger resumable work', as assert.equal(detected.runId, run.runId) }) +test('later-phase checkpoints restore every digest-bound local artifact', async () => { + const { loopRoot } = await createFixture() + const { run } = await startFixtureRun({ + loopRoot, + issueNumber: 207, + issueTitle: 'Resume verified work', + issueUrl: 'https://github.com/codeacme17/echo-ui/issues/207', + now: new Date('2026-07-22T13:00:00.000Z'), + entropy: 'resume2', + }) + const runRoot = path.join(loopRoot, 'logs', 'runs', run.runId) + const artifacts = [ + { + path: path.join(runRoot, 'implementation-result-1.json'), + relativePath: `logs/runs/${run.runId}/implementation-result-1.json`, + source: '{"agent":"$implement","checks":[{"command":"pnpm verify"}]}\n', + eventType: 'implementation_completed', + payloadKey: 'resultPath', + }, + { + path: path.join(loopRoot, 'evidence', run.runId, 'manifest.json'), + relativePath: `evidence/${run.runId}/manifest.json`, + source: '{"checks":[{"command":"pnpm verify","status":"passed"}]}\n', + eventType: 'verification_completed', + payloadKey: 'manifestPath', + }, + { + path: path.join(runRoot, 'review-result.json'), + relativePath: `logs/runs/${run.runId}/review-result.json`, + source: '{"verdict":"PASS","rounds":[]}\n', + eventType: 'review_completed', + payloadKey: 'resultPath', + }, + { + path: path.join(runRoot, 'finalization-result.json'), + relativePath: `logs/runs/${run.runId}/finalization-result.json`, + source: '{"status":"blocked","failureFingerprint":"fixture"}\n', + eventType: 'finalization_published', + payloadKey: 'resultPath', + }, + ] + for (const artifact of artifacts) { + await mkdir(path.dirname(artifact.path), { recursive: true }) + await writeFile(artifact.path, artifact.source, 'utf8') + } + const existingEvents = (await readFile(path.join(runRoot, 'events.jsonl'), 'utf8')) + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) + const laterEvents = artifacts.map((artifact, index) => ({ + schemaVersion: 1, + runId: run.runId, + type: artifact.eventType, + timestamp: new Date(Date.UTC(2026, 6, 22, 13, index + 1)).toISOString(), + status: 'passed', + payload: artifact.payloadKey + ? { + [artifact.payloadKey]: artifact.relativePath, + [artifact.eventType === 'verification_completed' ? 'manifestDigest' : 'resultDigest']: + createHash('sha256').update(artifact.source).digest('hex'), + } + : {}, + })) + await writeFile( + path.join(runRoot, 'events.jsonl'), + `${[...existingEvents, ...laterEvents].map((event) => JSON.stringify(event)).join('\n')}\n`, + 'utf8', + ) + + const prepared = await prepareActiveCheckpoint({ loopRoot, runId: run.runId }) + assert.deepEqual( + prepared.record.artifacts.map((artifact) => artifact.path).sort(), + artifacts.map((artifact) => artifact.relativePath).sort(), + ) + const tampered = structuredClone(prepared.record) + tampered.artifacts[0].source += 'tampered' + await assert.rejects( + restoreActiveCheckpoint({ + loopRoot, + checkpoint: { record: tampered }, + workspaceValidator: async () => {}, + }), + /invalid artifact/, + ) + + await rm(runRoot, { recursive: true, force: true }) + await rm(path.join(loopRoot, 'handoffs', run.runId), { recursive: true, force: true }) + await rm(path.join(loopRoot, 'evidence', run.runId), { recursive: true, force: true }) + await restoreActiveCheckpoint({ + loopRoot, + checkpoint: { + record: prepared.record, + commentUrl: 'https://github.com/codeacme17/echo-ui/issues/999#issuecomment-9911', + createdAt: '2026-07-22T13:10:00.000Z', + }, + workspaceValidator: async () => {}, + }) + for (const artifact of artifacts) { + assert.equal(await readFile(artifact.path, 'utf8'), artifact.source) + } +}) + test('evolve completion rejects an unrelated historical owner-merged PR', async () => { const { loopRoot } = await createFixture() const requestId = 'EVL-20260722T120000-ABC123' @@ -2720,9 +2821,7 @@ test('repository activation verifies both configured GitHub profiles', async () const observedProfiles = [] const identityCommand = async (_command, _args, options) => { observedProfiles.push(options.env.GH_CONFIG_DIR) - const login = options.env.GH_CONFIG_DIR === automationProfile - ? 'Ethandasw' - : 'Traviinam' + const login = options.env.GH_CONFIG_DIR === automationProfile ? 'Ethandasw' : 'Traviinam' return { stdout: `${login}\n` } } const result = await validateLoop({ @@ -2732,8 +2831,5 @@ test('repository activation verifies both configured GitHub profiles', async () identityCommand, }) assert.equal(result.valid, true) - assert.deepEqual(observedProfiles, [ - automationProfile, - reviewerProfile, - ]) + assert.deepEqual(observedProfiles, [automationProfile, reviewerProfile]) }) diff --git a/loops/issue-dev-loop/triggers/TRIGGER.md b/loops/issue-dev-loop/triggers/TRIGGER.md index 3283d2c6..54cd0177 100644 --- a/loops/issue-dev-loop/triggers/TRIGGER.md +++ b/loops/issue-dev-loop/triggers/TRIGGER.md @@ -3,7 +3,7 @@ Run the deterministic detector before waking Codex: ```bash -node loops/issue-dev-loop/scripts/with-github-identity.mjs automation -- node loops/issue-dev-loop/triggers/detect-work.mjs +loops/issue-dev-loop/scripts/with-github-identity automation -- node loops/issue-dev-loop/triggers/detect-work.mjs ``` Before the issue detector, run `loopctl.mjs evolve-status` through the same automation wrapper. A pending evolve request is real work and must wake the dedicated fresh-context evolver instead of an issue executor. diff --git a/loops/issue-dev-loop/triggers/codex-automation-prompt.md b/loops/issue-dev-loop/triggers/codex-automation-prompt.md index 61491653..4f476542 100644 --- a/loops/issue-dev-loop/triggers/codex-automation-prompt.md +++ b/loops/issue-dev-loop/triggers/codex-automation-prompt.md @@ -1,5 +1,5 @@ Use `$issue-dev-loop` in this repository. -Require `ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR` and `ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR`, then run `node loops/issue-dev-loop/scripts/loopctl.mjs validate --activation`. Run all executor GitHub, remote Git, and GitHub-backed loop commands through `node loops/issue-dev-loop/scripts/with-github-identity.mjs automation -- ...`; never change global authentication. +Require `ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR` and `ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR`, then run `node loops/issue-dev-loop/scripts/loopctl.mjs validate --activation`. Run all executor GitHub, remote Git, and GitHub-backed loop commands through `loops/issue-dev-loop/scripts/with-github-identity automation -- ...`; never invoke its `.mjs` implementation directly and never change global authentication. First run `loopctl.mjs reconcile`, then `loopctl.mjs evolve-status`, both through the automation wrapper. A pending evolve request starts a fresh `echo_ui_loop_evolver` session and an owner-reviewed evolve PR before routine product work. Otherwise run `triggers/detect-work.mjs` through the automation wrapper. If it returns `hasWork: false`, report a quiet no-op and stop. If it returns an issue, execute one bounded issue-dev-loop run for that exact issue. Preserve owner-only review and merge, use `$implement` for product code, use a fresh read-only reviewer after the draft PR, publish review commands through the reviewer wrapper, publish terminal state to the durable GitHub journal, and notify the owner for every blocking event or ready PR. From 178da3976a0da2725738027f8a20534c33c1adce Mon Sep 17 00:00:00 2001 From: leyoonafr <codeacme17@gmail.com> Date: Thu, 23 Jul 2026 17:59:30 +0800 Subject: [PATCH 22/41] fix(loop): harden durable GitHub mutations --- loops/_shared/owner-channel/CHANNEL.md | 2 +- loops/issue-dev-loop/SKILL.md | 4 +- .../references/github-operations.md | 8 +- .../scripts/github-command-gate.mjs | 8 +- .../scripts/lib/github-identity.mjs | 235 +++++++++- .../issue-dev-loop/scripts/lib/run-store.mjs | 2 +- .../tests/github-identity-routing.test.mjs | 420 +++++++++++++++++- loops/issue-dev-loop/tests/runtime.test.mjs | 5 +- .../triggers/codex-automation-prompt.md | 2 +- 9 files changed, 641 insertions(+), 45 deletions(-) diff --git a/loops/_shared/owner-channel/CHANNEL.md b/loops/_shared/owner-channel/CHANNEL.md index 2dd08168..89a458a5 100644 --- a/loops/_shared/owner-channel/CHANNEL.md +++ b/loops/_shared/owner-channel/CHANNEL.md @@ -13,7 +13,7 @@ Each blocking GitHub notification prints a unique resume instruction. To continu ## Runtime setup 1. Authenticate the unattended executor and fresh reviewer with distinct GitHub identities. Their exact logins and the names of their profile-path environment variables live in `channel.json`. For the current configuration, set `ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR` to the `Ethandasw` `gh` profile directory and `ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR` to the `Traviinam` profile directory in the scheduler environment. The directory names themselves are local details and do not need to match the roles. -2. Run `node loops/issue-dev-loop/scripts/loopctl.mjs validate --activation` before scheduling. It independently queries both profiles and rejects missing, overlapping, owner-authenticated, or incorrectly routed identities. +2. Run `loops/issue-dev-loop/scripts/with-github-identity automation -- node loops/issue-dev-loop/scripts/loopctl.mjs validate --activation` before scheduling. The launcher independently queries both profiles, then starts structural validation in its sanitized child environment; it rejects missing, overlapping, owner-authenticated, or incorrectly routed identities. 3. Run every executor GitHub command, remote Git command, and GitHub-backed `loopctl` command through the executable shell launcher `loops/issue-dev-loop/scripts/with-github-identity automation -- ...`. Run reviewer publication commands through the same launcher with `reviewer`. The launcher removes Node preload hooks; the router clears token overrides and process hooks, verifies `gh api user`, and gives Git a one-command credential helper without changing global Git or `gh` configuration. A PATH gate applies the role and current-run policy to descendant `git` and `gh` processes too; arbitrary shell, environment, or Node command trees are rejected. 4. Create one dedicated repository issue for the append-only loop state journal and set its number as `stateIssueNumber`. It stores active checkpoints and terminal records. Restrict journal entries to the automation identity; humans may read but should not edit or delete them. 5. Enable GitHub notifications for mentions and review requests for `codeacme17`. diff --git a/loops/issue-dev-loop/SKILL.md b/loops/issue-dev-loop/SKILL.md index 9de0f705..5b91465b 100644 --- a/loops/issue-dev-loop/SKILL.md +++ b/loops/issue-dev-loop/SKILL.md @@ -10,7 +10,7 @@ Run exactly one bounded issue cycle. Treat [`LOOP.md`](./LOOP.md) as the constit ## Start safely 1. Read `LOOP.md`, `state.md`, and `dependencies.md` completely. -2. Run `node loops/issue-dev-loop/scripts/loopctl.mjs validate`. Scheduled activation additionally requires `validate --activation`, which probes the separately configured executor and reviewer `gh` profiles. +2. Run structural validation with `node loops/issue-dev-loop/scripts/loopctl.mjs validate`. Scheduled activation must run `loops/issue-dev-loop/scripts/with-github-identity automation -- node loops/issue-dev-loop/scripts/loopctl.mjs validate --activation`; the launcher probes both configured profiles before starting the sanitized structural validator. 3. Read [`references/github-operations.md`](./references/github-operations.md). Run every executor GitHub command, remote Git command, and GitHub-backed `loopctl` command through `loops/issue-dev-loop/scripts/with-github-identity automation -- ...`. Never invoke the `.mjs` router directly and never alter global `gh` or Git credential configuration. 4. Run `loopctl.mjs reconcile` through the automation wrapper to rebuild terminal history and discover active runs from the append-only GitHub state journal. For returned `workType: resume`, fetch the recorded branch through the wrapper, create a clean isolated worktree at the returned exact head, and run `restore-checkpoint --run-id <id>` inside that worktree. The restore command rejects the wrong branch, a dirty checkout, or any head other than the durable head. Resume it before selecting a new issue. 5. Run `loopctl.mjs evolve-status`. If `evolveDue` is true, start `echo_ui_loop_evolver` with fresh context; do not silently replace it with product work. @@ -46,7 +46,7 @@ Push only the issue branch and create a **draft** PR targeting `dev` using `temp After the draft PR exists, spawn the project agent `echo_ui_pr_reviewer` with a fresh context. Give it only the issue snapshot, acceptance criteria, repository instructions, base SHA, head SHA, diff, CI results, and evidence manifest. Do not give it executor conversation history or rationale. -Publish every round through `with-github-identity reviewer -- ...`; the executor identity may not author it. Post findings verbatim as one review plus inline comments. Each finding needs a stable ID, severity, confidence, evidence, and expected resolution. Record the GitHub review URL for each round. Accepted repairs must start after that round was submitted, and executor replies must be posted through the automation wrapper after the corresponding `$implement` invocation finishes. Follow `review/REVIEW.md` and `review/response-policy.md`. +Publish every round through `with-github-identity reviewer -- ...`; the executor identity may not author it. Every PR write must include `--repo codeacme17/echo-ui` (or use the full configured PR URL). Post findings verbatim as one review plus inline comments. Each finding needs a stable ID, severity, confidence, evidence, and expected resolution. Record the GitHub review URL for each round. Accepted repairs must start after that round was submitted, and executor replies must be posted through the automation wrapper after the corresponding `$implement` invocation finishes. Follow `review/REVIEW.md` and `review/response-policy.md`. The executor must classify every finding as `accepted`, `rejected`, `needs-human`, `stale`, or `already-fixed`: diff --git a/loops/issue-dev-loop/references/github-operations.md b/loops/issue-dev-loop/references/github-operations.md index 3ad2a7d6..9df95d54 100644 --- a/loops/issue-dev-loop/references/github-operations.md +++ b/loops/issue-dev-loop/references/github-operations.md @@ -13,10 +13,10 @@ Run every reviewer publication command through the same wrapper with role `revie Publish reviewer output only as a non-approving comment review: ```text -loops/issue-dev-loop/scripts/with-github-identity reviewer -- gh pr review <number> --comment --body <review-body> +loops/issue-dev-loop/scripts/with-github-identity reviewer -- gh pr review <number> --repo codeacme17/echo-ui --comment --body <review-body> ``` -The shell launcher removes Node preload hooks before starting the router. The router then builds a strict child environment, rejects reviewer pushes and every reviewer mutation except a comment-only review on the recorded PR, and rejects approvals, change requests, merges, executor-authored reviews, GraphQL, and administration APIs. Automation API mutations are limited to the current issue's exact claim label, the current issue/PR or journal comments, and current-PR review-comment replies. PR creation requires the authorized issue or pending-evolve branch, `--base dev`, and `--draft`; later PR mutations require the recorded PR. Automation pushes use only the exact active issue branch or exact pending `codex/evolve-<request-id>` branch in `git push origin <branch>` (or the equivalent `-u`/`--set-upstream` form); every other push shape is rejected. +The shell launcher removes Node preload hooks before starting the router. The router then builds a strict child environment, rejects reviewer pushes and every reviewer mutation except a comment-only review on the recorded PR, and rejects approvals, change requests, merges, executor-authored reviews, GraphQL, and administration APIs. Authenticated Git also disables worktree hooks, fsmonitor, external diff, textconv, and the `ext` transport. Automation API mutations are limited to the current issue's exact claim label, the current issue/PR or journal comments, and current-PR review-comment replies. PR creation requires the authorized issue or pending-evolve branch, explicit `--repo codeacme17/echo-ui`, `--base dev`, and `--draft`; later PR writes require the same explicit repository (or full configured PR URL), the exact durable checkpoint, the recorded live PR branch/base/head, and the phase-specific Draft/evidence/review gates. `pr edit` is limited to title/body updates and requesting `codeacme17`. Automation pushes use only the exact active issue branch or exact pending `codex/evolve-<request-id>` branch in `git push origin <branch>` (or the equivalent `-u`/`--set-upstream` form); every other push shape is rejected. ## Selection and claim @@ -30,10 +30,10 @@ The shell launcher removes Node preload hooks before starting the router. The ro - Refresh `origin/dev` before creating `codex/issue-<number>`. - Push only the issue branch. -- Create a draft PR with `--base dev`. +- Create a draft PR with `--repo codeacme17/echo-ui --base dev`. - Run `loopctl record-pr` immediately so later evidence, review, notification, and merge observations are bound to that exact PR/head. - Run `prepare-checkpoint`, publish its exact body on the state-journal issue, and run `record-checkpoint` immediately after binding or rebinding the PR. -- Create the body from `templates/pr-body.md`. `record-pr` rejects a body missing the run marker, issue closure, full base/head SHAs, or owner-only merge statement. +- Create the body from `templates/pr-body.md`. Initial `record-pr` rejects a body missing the run marker, issue closure, full base/head SHAs, or owner-only merge statement. After a repair push, rebind the new live head first, checkpoint it, then update the body through the exact-head router; the owner-ready gate still requires the visible final head SHA. - Include `Closes #<number>` only when the PR fully satisfies the issue. - Request `codeacme17` only after automated review and verification pass. - Bind every review to immutable base and head SHAs. diff --git a/loops/issue-dev-loop/scripts/github-command-gate.mjs b/loops/issue-dev-loop/scripts/github-command-gate.mjs index 1f7cadc3..8907796c 100755 --- a/loops/issue-dev-loop/scripts/github-command-gate.mjs +++ b/loops/issue-dev-loop/scripts/github-command-gate.mjs @@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url' import { assertDescendantCommandPolicy, assertPushTargetsRepository, + hardenedGitArguments, resolveExecutable, } from './lib/github-identity.mjs' @@ -33,7 +34,12 @@ async function main() { const executable = await resolveExecutable(executableName, process.env, { skipDirectories: [path.join(identityBinDirectory, 'identity-bin')], }) - const executableArgs = tool === 'credential' ? ['auth', 'git-credential'] : args + const executableArgs = + tool === 'credential' + ? ['auth', 'git-credential'] + : tool === 'git' + ? hardenedGitArguments(args) + : args if (tool !== 'credential') { assertDescendantCommandPolicy({ role, diff --git a/loops/issue-dev-loop/scripts/lib/github-identity.mjs b/loops/issue-dev-loop/scripts/lib/github-identity.mjs index 51d55e79..718cc8f1 100644 --- a/loops/issue-dev-loop/scripts/lib/github-identity.mjs +++ b/loops/issue-dev-loop/scripts/lib/github-identity.mjs @@ -7,6 +7,8 @@ import { promisify } from 'node:util' import { fileURLToPath } from 'node:url' import { assertNonEmpty, parseGitHubTarget, readJson, sameGitHubLogin } from './common.mjs' +import { verifyLatestDurableCheckpoint } from './checkpoint-proof.mjs' +import { readEvents } from './run-store.mjs' const execFileAsync = promisify(execFile) const moduleDirectory = path.dirname(fileURLToPath(import.meta.url)) @@ -91,13 +93,19 @@ export function resolveGitHubRoleEnvironment({ channel, role, environment = proc GH_PROMPT_DISABLED: '1', GIT_CONFIG_GLOBAL: devNull, GIT_CONFIG_NOSYSTEM: '1', - GIT_CONFIG_COUNT: '2', + GIT_CONFIG_COUNT: '5', GIT_CONFIG_KEY_0: 'credential.helper', GIT_CONFIG_VALUE_0: '', GIT_CONFIG_KEY_1: 'credential.helper', GIT_CONFIG_VALUE_1: `!${shellQuote(process.execPath)} ${shellQuote( commandGatePath, )} credential`, + GIT_CONFIG_KEY_2: 'core.hooksPath', + GIT_CONFIG_VALUE_2: devNull, + GIT_CONFIG_KEY_3: 'core.fsmonitor', + GIT_CONFIG_VALUE_3: 'false', + GIT_CONFIG_KEY_4: 'protocol.ext.allow', + GIT_CONFIG_VALUE_4: 'never', GIT_PAGER: 'cat', GIT_TERMINAL_PROMPT: '0', PAGER: 'cat', @@ -105,6 +113,14 @@ export function resolveGitHubRoleEnvironment({ channel, role, environment = proc return { configDirectory, expectedLogin, routedEnvironment } } +export function hardenedGitArguments(args) { + const subcommand = gitSubcommand(args) + if (!['diff', 'show', 'log'].includes(subcommand.name)) return [...args] + const hardened = [...args] + hardened.splice(subcommand.index + 1, 0, '--no-ext-diff', '--no-textconv') + return hardened +} + function sameArguments(actual, expected) { return ( actual.length === expected.length && @@ -400,15 +416,21 @@ function exactlyOne(values, name) { return candidates.length === 1 ? candidates[0] : null } -function pullRequestTargetMatches(target, authorization, expectedRepository) { +function pullRequestTargetMatches(target, authorization, expectedRepository, repositoryOption) { const expectedNumber = authorization?.issue?.prNumber if (!Number.isInteger(expectedNumber)) return false - if (/^\d+$/.test(target)) return Number(target) === expectedNumber + if (/^\d+$/.test(target)) { + return ( + Number(target) === expectedNumber && + repositoryInScope(repositoryOption, expectedRepository) + ) + } const parsed = parseGitHubTarget(target) return ( parsed?.kind === 'pull' && parsed.number === expectedNumber && - repositoryInScope(`${parsed.owner}/${parsed.repo}`, expectedRepository) + repositoryInScope(`${parsed.owner}/${parsed.repo}`, expectedRepository) && + (repositoryOption === null || repositoryInScope(repositoryOption, expectedRepository)) ) } @@ -432,14 +454,19 @@ function reviewerCommentReviewAllowed(args, commandIndex, authorization, expecte parsed.valid && parsed.booleans.get('comment') === true && parsed.positional.length === 1 && - pullRequestTargetMatches(parsed.positional[0], authorization, expectedRepository) && + pullRequestTargetMatches( + parsed.positional[0], + authorization, + expectedRepository, + exactlyOne(parsed.values, 'repository'), + ) && Number(Boolean(exactlyOne(parsed.values, 'body'))) + Number(Boolean(exactlyOne(parsed.values, 'bodyFile'))) === 1 ) } -function pullRequestCreateAllowed(args, commandIndex, authorization) { +function pullRequestCreateAllowed(args, commandIndex, authorization, expectedRepository) { const parsed = parseOptions(args, commandIndex + 1, { valueOptions: { ...repositoryValueOptions, @@ -460,6 +487,7 @@ function pullRequestCreateAllowed(args, commandIndex, authorization) { !parsed.valid || parsed.positional.length !== 0 || parsed.booleans.get('draft') !== true || + !repositoryInScope(exactlyOne(parsed.values, 'repository'), expectedRepository) || exactlyOne(parsed.values, 'base') !== 'dev' || !exactlyOne(parsed.values, 'title') || Number(Boolean(exactlyOne(parsed.values, 'body'))) + @@ -488,13 +516,7 @@ function pullRequestMutationAllowed(kind, args, commandIndex, authorization, exp '-b': 'body', '--body-file': 'bodyFile', '-F': 'bodyFile', - '--add-assignee': 'addAssignee', - '--remove-assignee': 'removeAssignee', - '--add-label': 'addLabel', - '--remove-label': 'removeLabel', '--add-reviewer': 'addReviewer', - '--milestone': 'milestone', - '--remove-milestone': 'removeMilestone', } : kind === 'comment' ? { @@ -509,7 +531,12 @@ function pullRequestMutationAllowed(kind, args, commandIndex, authorization, exp if ( !parsed.valid || parsed.positional.length !== 1 || - !pullRequestTargetMatches(parsed.positional[0], authorization, expectedRepository) + !pullRequestTargetMatches( + parsed.positional[0], + authorization, + expectedRepository, + exactlyOne(parsed.values, 'repository'), + ) ) { return false } @@ -651,7 +678,7 @@ export function assertGitHubCliPolicy( if (['list', 'view', 'checks', 'diff'].includes(subcommand.name)) return if ( subcommand.name === 'create' && - pullRequestCreateAllowed(args, subcommand.index, authorization) + pullRequestCreateAllowed(args, subcommand.index, authorization, expectedRepository) ) { return } @@ -826,6 +853,10 @@ async function readAuthorizationContext(loopRoot, channel) { branch: assertNonEmpty(run.branch, 'run.branch'), issueNumber: run.issueNumber, prNumber: pullTarget?.kind === 'pull' ? pullTarget.number : null, + runId: assertNonEmpty(run.runId, 'run.runId'), + status: run.status, + headSha: run.headSha, + implementationCommit: run.implementationCommit, } : null @@ -888,7 +919,153 @@ function withRootCommandIntent(authorization, { tool, args, loopRoot }) { branch: `codex/issue-${issueNumber}`, issueNumber, prNumber: null, + runId: null, + status: 'starting', + headSha: null, + implementationCommit: null, + }, + } +} + +function activationValidationRequested({ role, tool, args, loopRoot }) { + return ( + role === 'automation' && + tool === 'node' && + args.length === 3 && + path.resolve(args[0]) === path.resolve(loopRoot, 'scripts', 'loopctl.mjs') && + args[1] === 'validate' && + args[2] === '--activation' + ) +} + +function pullRequestWriteIntent(role, args) { + const group = githubGroup(args) + if (group.name !== 'pr') return null + const command = commandAfterGroup(args, group.index) + if (role === 'reviewer' && command.name === 'review') { + return { kind: 'review', commandIndex: command.index } + } + if (role === 'automation' && ['create', 'edit', 'comment', 'ready'].includes(command.name)) { + return { kind: command.name, commandIndex: command.index } + } + return null +} + +function editRequestsOwnerReview(args, commandIndex) { + const parsed = parseOptions(args, commandIndex + 1, { + valueOptions: { + ...repositoryValueOptions, + '--title': 'title', + '-t': 'title', + '--body': 'body', + '-b': 'body', + '--body-file': 'bodyFile', + '-F': 'bodyFile', + '--add-reviewer': 'addReviewer', }, + }) + return parsed.values.has('addReviewer') +} + +function hasExactHeadGate(events, eventType, headSha) { + return events.some( + (event) => + event.type === eventType && + event.status === 'passed' && + event.payload?.headSha === headSha, + ) +} + +async function preflightPullRequestWrite({ + role, + args, + authorization, + channel, + loopRoot, + realGh, + environment, +}) { + const intent = pullRequestWriteIntent(role, args) + if (!intent || authorization.evolve) return + const runId = authorization.issue?.runId + if (!runId) throw new Error('pull request write requires an active durable run') + const events = await readEvents(loopRoot, runId) + const githubApi = async (endpoint) => { + const { stdout } = await execFileAsync(realGh, ['api', endpoint], { + env: environment, + maxBuffer: 1024 * 1024, + }) + return JSON.parse(stdout) + } + const durable = await verifyLatestDurableCheckpoint({ + loopRoot, + runId, + events, + operation: `GitHub PR ${intent.kind}`, + githubApi, + }) + const run = durable.record.run + if (run.status !== 'running' || run.finishedAt !== null) { + throw new Error('pull request writes require a running durable run') + } + if (intent.kind === 'create') { + const implementation = events.findLast( + (event) => + event.type === 'implementation_completed' && + event.status === 'passed' && + event.payload?.commitSha === run.implementationCommit, + ) + if (run.prUrl !== null || run.headSha !== null || !implementation) { + throw new Error('draft PR creation requires a durably recorded implementation without a PR') + } + return + } + + if (!Number.isInteger(authorization.issue?.prNumber) || !run.prUrl || !run.headSha) { + throw new Error('pull request write requires a durably recorded PR and head') + } + const [owner, repo] = authorization.expectedRepository.split('/') + const livePullRequest = await githubApi( + `repos/${owner}/${repo}/pulls/${authorization.issue.prNumber}`, + ) + const liveMatchesRecordedState = + livePullRequest.state === 'open' && + livePullRequest.base?.ref === 'dev' && + livePullRequest.base?.repo?.full_name?.toLowerCase() === + authorization.expectedRepository.toLowerCase() && + livePullRequest.head?.ref === run.branch && + livePullRequest.head?.repo?.full_name?.toLowerCase() === + authorization.expectedRepository.toLowerCase() && + livePullRequest.head?.sha === run.headSha && + sameGitHubLogin(livePullRequest.user?.login, channel.automationGitHubLogin) + if (!liveMatchesRecordedState) { + throw new Error('live pull request does not match the durable recorded head, branch, and base') + } + + if (intent.kind === 'review') { + if (livePullRequest.draft !== true) { + throw new Error('independent review publication requires the recorded Draft PR') + } + return + } + if (intent.kind === 'ready') { + if ( + livePullRequest.draft !== true || + !hasExactHeadGate(events, 'verification_completed', run.headSha) || + !hasExactHeadGate(events, 'review_completed', run.headSha) + ) { + throw new Error('PR ready requires exact-head evidence and review in the durable checkpoint') + } + return + } + if (intent.kind === 'edit' && editRequestsOwnerReview(args, intent.commandIndex)) { + if ( + livePullRequest.draft !== false || + !hasExactHeadGate(events, 'verification_completed', run.headSha) || + !hasExactHeadGate(events, 'review_completed', run.headSha) + ) { + throw new Error('owner review request requires a ready PR with exact-head evidence and review') + } } } @@ -943,6 +1120,32 @@ export async function runWithGitHubRole({ loopRoot, authorization, }) + const activationValidation = activationValidationRequested({ + role, + tool, + args, + loopRoot, + }) + if (activationValidation) { + await assertGitHubRoleIdentity({ + channel, + role: 'reviewer', + environment, + identityCommand: (_command, identityArgs, options) => + execFileAsync(realGh, identityArgs, options), + }) + } + if (tool === 'gh') { + await preflightPullRequestWrite({ + role, + args, + authorization, + channel, + loopRoot, + realGh, + environment: resolved.routedEnvironment, + }) + } if (tool === 'git' && gitSubcommand(args).name === 'push') { await assertPushTargetsRepository({ expectedRepository: channel.repository, @@ -958,7 +1161,9 @@ export async function runWithGitHubRole({ ECHO_UI_LOOP_NODE: process.execPath, ECHO_UI_LOOP_AUTHORIZATION: JSON.stringify(authorization), } - const child = spawnCommand(executable, args, { + let executionArgs = tool === 'git' ? hardenedGitArguments(args) : [...args] + if (activationValidation) executionArgs = [args[0], 'validate'] + const child = spawnCommand(executable, executionArgs, { env: childEnvironment, stdio: 'inherit', shell: false, diff --git a/loops/issue-dev-loop/scripts/lib/run-store.mjs b/loops/issue-dev-loop/scripts/lib/run-store.mjs index eed4801a..f47c0dd4 100644 --- a/loops/issue-dev-loop/scripts/lib/run-store.mjs +++ b/loops/issue-dev-loop/scripts/lib/run-store.mjs @@ -587,9 +587,9 @@ export async function recordPullRequest({ `<!-- issue-dev-loop:run:${normalizedRunId} -->`, `Run ID: \`${normalizedRunId}\``, `Base SHA: \`${run.baseSha}\``, - `Head SHA: \`${headSha}\``, 'This PR must be reviewed and merged by `@codeacme17`', ] + if (isInitialBinding) requiredBodyFragments.push(`Head SHA: \`${headSha}\``) if (requiredBodyFragments.some((fragment) => !livePullRequest.body?.includes(fragment))) { throw new Error('draft PR body is missing immutable loop metadata or owner-only merge language') } diff --git a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs index 28e0870b..332fae11 100644 --- a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs +++ b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs @@ -7,6 +7,9 @@ import test from 'node:test' import { promisify } from 'node:util' import { fileURLToPath } from 'node:url' +import { checkpointPublicationBody } from '../scripts/lib/checkpoint-proof.mjs' +import { resolveExecutable } from '../scripts/lib/github-identity.mjs' + const execFileAsync = promisify(execFile) const testDirectory = path.dirname(fileURLToPath(import.meta.url)) const routerPath = path.resolve(testDirectory, '..', 'scripts', 'with-github-identity.mjs') @@ -14,7 +17,13 @@ const routerLauncherPath = path.resolve(testDirectory, '..', 'scripts', 'with-gi const commandGatePath = path.resolve(testDirectory, '..', 'scripts', 'github-command-gate.mjs') const credentialHelper = `!'${process.execPath}' '${commandGatePath}' credential` -async function createFixture({ activeRun = true, recordedPr = true } = {}) { +async function createFixture({ + activeRun = true, + recordedPr = true, + readyToMark = false, + realGit = false, + liveDraft = true, +} = {}) { const parent = await mkdtemp(path.join(os.tmpdir(), 'echo-ui-identity-routing-')) const loopRoot = path.join(parent, 'issue-dev-loop') const channelRoot = path.join(parent, '_shared', 'owner-channel') @@ -25,6 +34,7 @@ async function createFixture({ activeRun = true, recordedPr = true } = {}) { mkdir(channelRoot, { recursive: true }), mkdir(path.join(loopRoot, 'scripts'), { recursive: true }), mkdir(path.join(loopRoot, 'logs', 'runs'), { recursive: true }), + mkdir(path.join(loopRoot, 'handoffs'), { recursive: true }), mkdir(path.join(loopRoot, 'evolve', 'requests'), { recursive: true }), mkdir(binRoot, { recursive: true }), mkdir(automationProfile, { recursive: true }), @@ -46,16 +56,154 @@ async function createFixture({ activeRun = true, recordedPr = true } = {}) { await writeFile(path.join(automationProfile, 'identity'), 'executor-user\n', 'utf8') await writeFile(path.join(reviewerProfile, 'identity'), 'reviewer-user\n', 'utf8') if (activeRun) { - await mkdir(path.join(loopRoot, 'logs', 'runs', 'fixture-run'), { recursive: true }) - await writeFile( - path.join(loopRoot, 'logs', 'runs', 'fixture-run', 'run.json'), - `${JSON.stringify({ + const runRoot = path.join(loopRoot, 'logs', 'runs', 'fixture-run') + const briefRoot = path.join(loopRoot, 'handoffs', 'fixture-run') + const baseSha = 'a'.repeat(40) + const headSha = recordedPr ? 'b'.repeat(40) : null + const implementationCommit = 'c'.repeat(40) + const startedAt = '2026-07-23T00:00:00.000Z' + const briefSource = 'fixture implementation brief\n' + const run = { + schemaVersion: 1, + runId: 'fixture-run', + issueNumber: 123, + issueTitle: 'Fixture issue', + issueUrl: 'https://github.com/example/repo/issues/123', + baseBranch: 'dev', + baseSha, + status: 'running', + startedAt, + finishedAt: null, + branch: 'codex/issue-123', + prUrl: recordedPr ? 'https://github.com/example/repo/pull/106' : null, + headSha, + mergeSha: null, + issueSnapshot: { + title: 'Fixture issue', + body: 'Fixture body', + labels: ['codex-ready'], + url: 'https://github.com/example/repo/issues/123', + capturedAt: startedAt, + }, + briefDigest: null, + uiEvidenceRequired: false, + implementationCommit, + } + const events = [ + { + schemaVersion: 1, runId: 'fixture-run', - issueNumber: 123, + type: 'loop_started', + timestamp: startedAt, status: 'running', - finishedAt: null, - branch: 'codex/issue-123', - prUrl: recordedPr ? 'https://github.com/example/repo/pull/106' : null, + payload: { issueNumber: 123, branch: 'codex/issue-123' }, + }, + { + schemaVersion: 1, + runId: 'fixture-run', + type: 'implementation_completed', + timestamp: '2026-07-23T00:01:00.000Z', + status: 'passed', + payload: { agent: '$implement', commitSha: implementationCommit }, + }, + ] + if (recordedPr) { + events.push({ + schemaVersion: 1, + runId: 'fixture-run', + type: 'pr_published', + timestamp: '2026-07-23T00:02:00.000Z', + status: 'draft', + payload: { + prUrl: run.prUrl, + headSha, + baseBranch: 'dev', + branch: run.branch, + }, + }) + } + if (readyToMark) { + events.push( + { + schemaVersion: 1, + runId: 'fixture-run', + type: 'verification_completed', + timestamp: '2026-07-23T00:03:00.000Z', + status: 'passed', + payload: { headSha, verdict: 'passed' }, + }, + { + schemaVersion: 1, + runId: 'fixture-run', + type: 'review_completed', + timestamp: '2026-07-23T00:04:00.000Z', + status: 'passed', + payload: { headSha, verdict: 'PASS' }, + }, + ) + } + const record = { + schemaVersion: 1, + kind: 'active-checkpoint', + run, + briefSource, + events: [...events], + artifacts: [], + updatedAt: events.at(-1).timestamp, + } + const publication = checkpointPublicationBody(record) + events.push({ + schemaVersion: 1, + runId: 'fixture-run', + type: 'checkpoint_published', + timestamp: '2026-07-23T00:05:00.000Z', + status: 'published', + payload: { + commentUrl: 'https://github.com/example/repo/issues/999#issuecomment-1', + digest: publication.digest, + checkpointUpdatedAt: record.updatedAt, + }, + }) + await Promise.all([ + mkdir(runRoot, { recursive: true }), + mkdir(briefRoot, { recursive: true }), + ]) + await writeFile( + path.join(runRoot, 'run.json'), + `${JSON.stringify(run)}\n`, + 'utf8', + ) + await writeFile( + path.join(runRoot, 'events.jsonl'), + `${events.map((event) => JSON.stringify(event)).join('\n')}\n`, + 'utf8', + ) + await writeFile( + path.join(runRoot, 'checkpoint-result.json'), + `${JSON.stringify(record)}\n`, + 'utf8', + ) + await writeFile(path.join(briefRoot, 'implementation-brief.md'), briefSource, 'utf8') + await writeFile( + path.join(parent, 'checkpoint-comment.json'), + `${JSON.stringify({ + user: { login: 'executor-user' }, + body: publication.body, + })}\n`, + 'utf8', + ) + await writeFile( + path.join(parent, 'live-pr.json'), + `${JSON.stringify({ + state: 'open', + draft: liveDraft, + user: { login: 'executor-user' }, + base: { ref: 'dev', repo: { full_name: 'example/repo' } }, + head: { + ref: 'codex/issue-123', + sha: headSha, + repo: { full_name: 'example/repo' }, + }, })}\n`, 'utf8', ) @@ -132,18 +280,29 @@ if (process.argv[2] === 'spawn') { await writeFile( fakeGh, `#!/bin/sh +parent_dir=$(dirname "$GH_CONFIG_DIR") if [ "$1 $2 $3 $4" != "api user --jq .login" ]; then if [ "$1 $2" = "api user" ]; then + printf 'probe\\n' >> "$GH_CONFIG_DIR/probes" sed -n '1p' "$GH_CONFIG_DIR/identity" exit 0 fi - if [ "$1 $2 $4" = "pr review --comment" ]; then + if [ "$1" = "api" ] && [ "$2" = "repos/example/repo/issues/comments/1" ]; then + sed -n '1p' "$parent_dir/checkpoint-comment.json" + exit 0 + fi + if [ "$1" = "api" ] && [ "$2" = "repos/example/repo/pulls/106" ]; then + sed -n '1p' "$parent_dir/live-pr.json" + exit 0 + fi + if [ "$1 $2" = "pr review" ]; then echo "comment review published" exit 0 fi node -e 'process.stdout.write(JSON.stringify({args: process.argv.slice(1)}))' -- "$@" exit 0 fi +printf 'probe\\n' >> "$GH_CONFIG_DIR/probes" sed -n '1p' "$GH_CONFIG_DIR/identity" `, 'utf8', @@ -151,18 +310,20 @@ sed -n '1p' "$GH_CONFIG_DIR/identity" await chmod(fakeGh, 0o755) const fakeGit = path.join(binRoot, 'git') - await writeFile( - fakeGit, - `#!/bin/sh + if (!realGit) { + await writeFile( + fakeGit, + `#!/bin/sh if [ "$1 $2 $3" = "remote get-url origin" ]; then echo "https://github.com/example/repo.git" exit 0 fi -node -e 'process.stdout.write(JSON.stringify({args: process.argv.slice(1), config: process.env.GH_CONFIG_DIR, hasGhToken: Boolean(process.env.GH_TOKEN || process.env.GITHUB_TOKEN), gitConfig: [process.env.GIT_CONFIG_COUNT, process.env.GIT_CONFIG_KEY_0, process.env.GIT_CONFIG_VALUE_0, process.env.GIT_CONFIG_KEY_1, process.env.GIT_CONFIG_VALUE_1]}))' -- "$@" +node -e 'process.stdout.write(JSON.stringify({args: process.argv.slice(1), config: process.env.GH_CONFIG_DIR, hasGhToken: Boolean(process.env.GH_TOKEN || process.env.GITHUB_TOKEN), gitConfig: Array.from({length: Number(process.env.GIT_CONFIG_COUNT)}, (_, index) => [process.env[\`GIT_CONFIG_KEY_\${index}\`], process.env[\`GIT_CONFIG_VALUE_\${index}\`]]).flat()}))' -- "$@" `, - 'utf8', - ) - await chmod(fakeGit, 0o755) + 'utf8', + ) + await chmod(fakeGit, 0o755) + } const impostorGh = path.join(parent, 'gh') await writeFile(impostorGh, '#!/bin/sh\nexit 0\n', 'utf8') await chmod(impostorGh, 0o755) @@ -202,7 +363,7 @@ test('automation role selects its dedicated gh profile without leaking token ove assert.deepEqual(JSON.parse(stdout), { config: fixture.automationProfile, hasGhToken: false, - gitConfig: ['2', 'credential.helper', '', 'credential.helper', credentialHelper], + gitConfig: ['5', 'credential.helper', '', 'credential.helper', credentialHelper], gitIsolation: [os.devNull, '1'], exposesOtherProfiles: false, exposesRealTools: false, @@ -238,6 +399,27 @@ test('launcher removes Node preload hooks before the authenticated process start await assert.rejects(readFile(markerPath, 'utf8'), /ENOENT/) }) +test('wrapped activation validates both profiles without exposing their paths to loopctl', async () => { + const fixture = await createFixture() + const { stdout } = await execFileAsync( + routerLauncherPath, + [ + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + process.execPath, + fixture.loopctlPath, + 'validate', + '--activation', + ], + { env: fixture.env }, + ) + assert.equal(JSON.parse(stdout).exposesOtherProfiles, false) + assert.match(await readFile(path.join(fixture.automationProfile, 'probes'), 'utf8'), /probe/) + assert.match(await readFile(path.join(fixture.reviewerProfile, 'probes'), 'utf8'), /probe/) +}) + test('reviewer role refuses a profile authenticated as the wrong account', async () => { const fixture = await createFixture() await writeFile(path.join(fixture.reviewerProfile, 'identity'), 'owner-user\n', 'utf8') @@ -282,11 +464,16 @@ test('automation git command clears global helpers and injects the selected gh c assert.equal(observed.hasGhToken, false) assert.deepEqual(observed.args, ['push', 'origin', 'codex/issue-123']) assert.deepEqual(observed.gitConfig, [ - '2', 'credential.helper', '', 'credential.helper', credentialHelper, + 'core.hooksPath', + os.devNull, + 'core.fsmonitor', + 'false', + 'protocol.ext.allow', + 'never', ]) }) @@ -443,6 +630,8 @@ test('reviewer role may publish only a non-approving comment review', async () = 'pr', 'review', '106', + '--repo', + 'example/repo', '--comment', '--body', 'PASS', @@ -485,14 +674,36 @@ test('automation PR creation is bound to the active branch, dev, and Draft', asy test('PR and issue mutations reject unsafe shapes and targets', async () => { const fixture = await createFixture() const forbidden = [ + [ + 'automation', + [ + 'pr', + 'create', + '--base', + 'dev', + '--head', + 'codex/issue-123', + '--draft', + '--title', + 'Missing repository', + '--body', + 'Unsafe', + ], + ], ['automation', ['pr', 'create', '--base', 'main', '--head', 'codex/issue-123', '--draft']], ['automation', ['pr', 'create', '--base', 'dev', '--head', 'dev', '--draft']], ['automation', ['pr', 'create', '--base', 'dev', '--head', 'codex/issue-123']], ['automation', ['pr', 'edit', '106', '--base', 'main']], ['automation', ['pr', 'edit', '107', '--title', 'Wrong PR']], ['automation', ['pr', 'edit', '106', '--add-reviewer', 'attacker']], + ['automation', ['pr', 'edit', '106', '--repo', 'example/repo', '--add-label', 'arbitrary']], + ['automation', ['pr', 'edit', '106', '--repo', 'example/repo', '--add-assignee', 'someone']], + ['automation', ['pr', 'edit', '106', '--repo', 'example/repo', '--milestone', 'later']], + ['automation', ['pr', 'edit', '106', '--title', 'Missing repository']], ['automation', ['pr', 'ready', '107']], ['automation', ['pr', 'comment', '107', '--body', 'Wrong PR']], + ['automation', ['pr', 'comment', '106', '--body', 'Missing repository']], + ['reviewer', ['pr', 'review', '106', '--comment', '--body', 'Missing repository']], [ 'reviewer', [ @@ -529,6 +740,112 @@ test('PR and issue mutations reject unsafe shapes and targets', async () => { } }) +test('PR ready is permitted only after exact-head evidence and review are durably checkpointed', async () => { + const premature = await createFixture() + await assert.rejects( + execFileAsync( + process.execPath, + [ + routerPath, + '--loop-root', + premature.loopRoot, + 'automation', + '--', + 'gh', + 'pr', + 'ready', + '106', + '--repo', + 'example/repo', + ], + { env: premature.env }, + ), + /exact-head evidence and review/, + ) + + const fixture = await createFixture({ readyToMark: true }) + const { stdout } = await execFileAsync( + process.execPath, + [ + routerPath, + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + 'gh', + 'pr', + 'ready', + '106', + '--repo', + 'example/repo', + ], + { env: fixture.env }, + ) + assert.deepEqual(JSON.parse(stdout).args.slice(0, 2), ['pr', 'ready']) +}) + +test('PR writes reject forged local authorization and live PR drift', async () => { + const forged = await createFixture() + const runPath = path.join(forged.loopRoot, 'logs', 'runs', 'fixture-run', 'run.json') + const run = JSON.parse(await readFile(runPath, 'utf8')) + await writeFile(runPath, `${JSON.stringify({ ...run, headSha: 'd'.repeat(40) })}\n`, 'utf8') + await assert.rejects( + execFileAsync( + process.execPath, + [ + routerPath, + '--loop-root', + forged.loopRoot, + 'reviewer', + '--', + 'gh', + 'pr', + 'review', + '106', + '--repo', + 'example/repo', + '--comment', + '--body', + 'Forged', + ], + { env: forged.env }, + ), + /durable|checkpoint/i, + ) + + const drifted = await createFixture() + const livePath = path.join(path.dirname(drifted.loopRoot), 'live-pr.json') + const live = JSON.parse(await readFile(livePath, 'utf8')) + await writeFile( + livePath, + `${JSON.stringify({ ...live, head: { ...live.head, sha: 'e'.repeat(40) } })}\n`, + 'utf8', + ) + await assert.rejects( + execFileAsync( + process.execPath, + [ + routerPath, + '--loop-root', + drifted.loopRoot, + 'reviewer', + '--', + 'gh', + 'pr', + 'review', + '106', + '--repo', + 'example/repo', + '--comment', + '--body', + 'Stale', + ], + { env: drifted.env }, + ), + /live pull request|recorded head/i, + ) +}) + test('pending evolve request authorizes only its exact push and Draft PR branch', async () => { const fixture = await createFixture({ activeRun: false }) const requestId = 'EVL-000010-TEN-FINALIZED-RUNS' @@ -693,6 +1010,71 @@ test('authenticated roots reject executable impersonation and mutating remote sy } }) +test('authenticated real Git ignores local execution hooks and configured diff helpers', async () => { + const fixture = await createFixture({ realGit: true }) + const realGit = await resolveExecutable('git', process.env) + const repository = path.join(path.dirname(fixture.loopRoot), 'repository') + const hookRoot = path.join(repository, 'hooks') + const hookMarker = path.join(repository, 'hook-marker') + const diffMarker = path.join(repository, 'diff-marker') + const textconvMarker = path.join(repository, 'textconv-marker') + const fsmonitorMarker = path.join(repository, 'fsmonitor-marker') + await Promise.all([mkdir(repository, { recursive: true }), mkdir(hookRoot, { recursive: true })]) + await execFileAsync(realGit, ['init', '-q'], { cwd: repository }) + await execFileAsync(realGit, ['config', 'user.name', 'Fixture'], { cwd: repository }) + await execFileAsync(realGit, ['config', 'user.email', 'fixture@example.com'], { cwd: repository }) + await writeFile(path.join(repository, 'sample.probe'), 'before\n', 'utf8') + await writeFile(path.join(repository, '.gitattributes'), '*.probe diff=probe\n', 'utf8') + await execFileAsync(realGit, ['add', '.'], { cwd: repository }) + await execFileAsync(realGit, ['commit', '-qm', 'fixture'], { cwd: repository }) + + const helper = async (target, marker) => { + await writeFile(target, `#!/bin/sh\nprintf invoked > ${JSON.stringify(marker)}\n`, 'utf8') + await chmod(target, 0o755) + } + await helper(path.join(hookRoot, 'pre-push'), hookMarker) + await helper(path.join(repository, 'external-diff'), diffMarker) + await helper(path.join(repository, 'textconv'), textconvMarker) + await helper(path.join(repository, 'fsmonitor'), fsmonitorMarker) + await execFileAsync(realGit, ['config', 'core.hooksPath', hookRoot], { cwd: repository }) + await execFileAsync(realGit, ['config', 'diff.external', path.join(repository, 'external-diff')], { + cwd: repository, + }) + await execFileAsync( + realGit, + ['config', 'diff.probe.textconv', path.join(repository, 'textconv')], + { cwd: repository }, + ) + await execFileAsync(realGit, ['config', 'core.fsmonitor', path.join(repository, 'fsmonitor')], { + cwd: repository, + }) + await writeFile(path.join(repository, 'sample.probe'), 'after\n', 'utf8') + + const routed = (gitArguments) => + execFileAsync( + process.execPath, + [ + routerPath, + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + realGit, + ...gitArguments, + ], + { cwd: repository, env: fixture.env }, + ) + const { stdout: hooksPath } = await routed(['config', '--get', 'core.hooksPath']) + assert.equal(hooksPath.trim(), os.devNull) + await routed(['diff']) + await execFileAsync(realGit, ['config', '--unset', 'diff.external'], { cwd: repository }) + await routed(['diff']) + await routed(['status', '--porcelain']) + for (const marker of [hookMarker, diffMarker, textconvMarker, fsmonitorMarker]) { + await assert.rejects(readFile(marker, 'utf8'), /ENOENT/) + } +}) + test('automation push verifies that origin is the configured repository', async () => { const fixture = await createFixture() await writeFile( diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index f8343d0d..5b8dfe9e 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -863,7 +863,10 @@ test('frozen brief and $implement invocation history cannot be rewritten or reus githubApi: async (endpoint) => endpoint.includes('/compare/') ? { status: 'ahead', base_commit: { sha: secondCommit } } - : pullRequestFixture(run, updatedHead, { draft: false }), + : { + ...pullRequestFixture(run, updatedHead, { draft: false }), + body: pullRequestFixture(run, firstHead, { draft: false }).body, + }, trailingPathValidator: async (range) => { checkedTrailingRange = range }, diff --git a/loops/issue-dev-loop/triggers/codex-automation-prompt.md b/loops/issue-dev-loop/triggers/codex-automation-prompt.md index 4f476542..c8b4cd87 100644 --- a/loops/issue-dev-loop/triggers/codex-automation-prompt.md +++ b/loops/issue-dev-loop/triggers/codex-automation-prompt.md @@ -1,5 +1,5 @@ Use `$issue-dev-loop` in this repository. -Require `ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR` and `ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR`, then run `node loops/issue-dev-loop/scripts/loopctl.mjs validate --activation`. Run all executor GitHub, remote Git, and GitHub-backed loop commands through `loops/issue-dev-loop/scripts/with-github-identity automation -- ...`; never invoke its `.mjs` implementation directly and never change global authentication. +Require `ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR` and `ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR`, then run `loops/issue-dev-loop/scripts/with-github-identity automation -- node loops/issue-dev-loop/scripts/loopctl.mjs validate --activation`. Run all executor GitHub, remote Git, and GitHub-backed loop commands through that executable launcher; never invoke its `.mjs` implementation directly and never change global authentication. First run `loopctl.mjs reconcile`, then `loopctl.mjs evolve-status`, both through the automation wrapper. A pending evolve request starts a fresh `echo_ui_loop_evolver` session and an owner-reviewed evolve PR before routine product work. Otherwise run `triggers/detect-work.mjs` through the automation wrapper. If it returns `hasWork: false`, report a quiet no-op and stop. If it returns an issue, execute one bounded issue-dev-loop run for that exact issue. Preserve owner-only review and merge, use `$implement` for product code, use a fresh read-only reviewer after the draft PR, publish review commands through the reviewer wrapper, publish terminal state to the durable GitHub journal, and notify the owner for every blocking event or ready PR. From ca095c93fafe99a9f28069a44c295d6ff7756144 Mon Sep 17 00:00:00 2001 From: leyoonafr <codeacme17@gmail.com> Date: Thu, 23 Jul 2026 18:29:59 +0800 Subject: [PATCH 23/41] fix(loop): close review workflow escapes --- .codex/agents/echo-ui-pr-reviewer.toml | 7 +- loops/issue-dev-loop/LOOP.md | 2 +- loops/issue-dev-loop/SKILL.md | 4 +- .../references/github-operations.md | 2 +- loops/issue-dev-loop/review/REVIEW.md | 2 +- .../issue-dev-loop/review/reviewer-prompt.md | 2 +- .../scripts/github-command-gate.mjs | 6 +- .../scripts/lib/github-identity.mjs | 251 ++++++++++++++++-- .../tests/github-identity-routing.test.mjs | 197 +++++++++++++- 9 files changed, 432 insertions(+), 41 deletions(-) diff --git a/.codex/agents/echo-ui-pr-reviewer.toml b/.codex/agents/echo-ui-pr-reviewer.toml index b69e9b35..6680f1b8 100644 --- a/.codex/agents/echo-ui-pr-reviewer.toml +++ b/.codex/agents/echo-ui-pr-reviewer.toml @@ -14,8 +14,11 @@ actionable findings exist. Publish GitHub reviews only through the executable `loops/issue-dev-loop/scripts/with-github-identity reviewer -- ...` launcher; the wrapper must verify the configured reviewerGitHubLogin before publication. Never invoke the `.mjs` implementation directly, run raw `gh`, alter global authentication, -or let the executor identity publish on your behalf. Use `gh pr review --comment`; mutating `gh api`, approval, change -requests, and merge commands are prohibited. Every round must include its +or let the executor identity publish on your behalf. Use `gh pr review --comment` +for a body-only review. When a finding has a file and line, use the launcher's +strictly gated `POST repos/<configured-repo>/pulls/<recorded-pr>/reviews` API shape +with `event=COMMENT`, the durable head SHA, and matching inline comments. All other +mutating `gh api`, approval, change requests, and merge commands are prohibited. Every round must include its run/round/head marker and return its unique review URL. For the final PASS, include the cycle-result digest marker and all prior finding IDs and return its review URL. """ diff --git a/loops/issue-dev-loop/LOOP.md b/loops/issue-dev-loop/LOOP.md index 8d88f4af..f17c6200 100644 --- a/loops/issue-dev-loop/LOOP.md +++ b/loops/issue-dev-loop/LOOP.md @@ -98,7 +98,7 @@ After exact-head CI and independent review pass, download the CI manifest and re ### 9. Owner feedback -When the owner requests changes, verify the owner-authored GitHub comment or `CHANGES_REQUESTED` review with `record-owner-response`; ordinary comments must include the notification's exact `RESUME <run-id>` token. Only after that gate may the run return to `running`. Snapshot the comments, create a supplemental handoff, invoke `$implement`, record its new result, rebind the PR at its new head, reverify, rerun fresh review, reply with commit and evidence, and notify the owner that the PR is ready again. +When the owner requests changes, verify the owner-authored GitHub comment or `CHANGES_REQUESTED` review with `record-owner-response`; ordinary comments must include the notification's exact `RESUME <run-id>` token. Only after that gate may the run return to `running`. Snapshot the comments, create a supplemental handoff, invoke `$implement`, record its new result, rebind the PR at its new head, and publish a checkpoint. Then return that exact non-Draft PR to Draft with the phase-gated `gh pr ready --undo`, update its body, reverify, rerun fresh review, reply with commit and evidence, and notify the owner that the PR is ready again. ### 10. Complete diff --git a/loops/issue-dev-loop/SKILL.md b/loops/issue-dev-loop/SKILL.md index 5b91465b..703a6a5c 100644 --- a/loops/issue-dev-loop/SKILL.md +++ b/loops/issue-dev-loop/SKILL.md @@ -46,7 +46,7 @@ Push only the issue branch and create a **draft** PR targeting `dev` using `temp After the draft PR exists, spawn the project agent `echo_ui_pr_reviewer` with a fresh context. Give it only the issue snapshot, acceptance criteria, repository instructions, base SHA, head SHA, diff, CI results, and evidence manifest. Do not give it executor conversation history or rationale. -Publish every round through `with-github-identity reviewer -- ...`; the executor identity may not author it. Every PR write must include `--repo codeacme17/echo-ui` (or use the full configured PR URL). Post findings verbatim as one review plus inline comments. Each finding needs a stable ID, severity, confidence, evidence, and expected resolution. Record the GitHub review URL for each round. Accepted repairs must start after that round was submitted, and executor replies must be posted through the automation wrapper after the corresponding `$implement` invocation finishes. Follow `review/REVIEW.md` and `review/response-policy.md`. +Publish every round through `with-github-identity reviewer -- ...`; the executor identity may not author it. Every PR write must include `--repo codeacme17/echo-ui` (or use the full configured PR URL). Use `gh pr review --comment` for a body-only review. When file/line findings require inline comments, use only the reviewer's gated `POST repos/codeacme17/echo-ui/pulls/<recorded-pr>/reviews` API shape with `event=COMMENT`, the exact durable head, and validated inline fields. Post findings verbatim as one review plus inline comments. Each finding needs a stable ID, severity, confidence, evidence, and expected resolution. Record the GitHub review URL for each round. Accepted repairs must start after that round was submitted, and executor replies must be posted through the automation wrapper after the corresponding `$implement` invocation finishes. Follow `review/REVIEW.md` and `review/response-policy.md`. The executor must classify every finding as `accepted`, `rejected`, `needs-human`, `stale`, or `already-fixed`: @@ -61,7 +61,7 @@ Run verification appropriate to the change and require `pnpm verify` before the The owner is the only actor allowed to approve or merge. Never call `gh pr merge`, enable auto-merge, push to `main`, push to `dev`, dismiss owner feedback, or bypass branch protections. Before any terminal transition, run `prepare-finalization`, publish its exact body to the configured state-journal issue, and validate the returned comment URL with `record-finalization`. A completed run passes the same result and comment URL to `observe-owner-merge`, which queries GitHub and requires both `codeacme17`'s approval and merge at the reviewed head SHA. Future workspaces run `reconcile` to rebuild local history and evolve metrics from those automation-authored journal comments. -For any pause, do not resume from silence or an arbitrary comment. First require a successfully delivered blocking notification. Then verify the owner's GitHub decision with `loopctl.mjs record-owner-response --run-id <id> --response-url <comment-or-review-url>`. A normal comment must include the exact `RESUME <run-id>` token printed in the notification; a `CHANGES_REQUESTED` review is itself an explicit decision. Only then may `loopctl.mjs transition --run-id <id> --status running` continue product work. +For any pause, do not resume from silence or an arbitrary comment. First require a successfully delivered blocking notification. Then verify the owner's GitHub decision with `loopctl.mjs record-owner-response --run-id <id> --response-url <comment-or-review-url>`. A normal comment must include the exact `RESUME <run-id>` token printed in the notification; a `CHANGES_REQUESTED` review is itself an explicit decision. Only then may `loopctl.mjs transition --run-id <id> --status running` continue product work. After an owner-feedback repair is rebound and checkpointed at its new head, run the exact recorded PR through `gh pr ready --undo --repo codeacme17/echo-ui` before the mandatory fresh review; the router permits this only when the durable owner response and live PR state match. ## Stop conditions diff --git a/loops/issue-dev-loop/references/github-operations.md b/loops/issue-dev-loop/references/github-operations.md index 9df95d54..1ed3f032 100644 --- a/loops/issue-dev-loop/references/github-operations.md +++ b/loops/issue-dev-loop/references/github-operations.md @@ -16,7 +16,7 @@ Publish reviewer output only as a non-approving comment review: loops/issue-dev-loop/scripts/with-github-identity reviewer -- gh pr review <number> --repo codeacme17/echo-ui --comment --body <review-body> ``` -The shell launcher removes Node preload hooks before starting the router. The router then builds a strict child environment, rejects reviewer pushes and every reviewer mutation except a comment-only review on the recorded PR, and rejects approvals, change requests, merges, executor-authored reviews, GraphQL, and administration APIs. Authenticated Git also disables worktree hooks, fsmonitor, external diff, textconv, and the `ext` transport. Automation API mutations are limited to the current issue's exact claim label, the current issue/PR or journal comments, and current-PR review-comment replies. PR creation requires the authorized issue or pending-evolve branch, explicit `--repo codeacme17/echo-ui`, `--base dev`, and `--draft`; later PR writes require the same explicit repository (or full configured PR URL), the exact durable checkpoint, the recorded live PR branch/base/head, and the phase-specific Draft/evidence/review gates. `pr edit` is limited to title/body updates and requesting `codeacme17`. Automation pushes use only the exact active issue branch or exact pending `codex/evolve-<request-id>` branch in `git push origin <branch>` (or the equivalent `-u`/`--set-upstream` form); every other push shape is rejected. +The shell launcher removes Node preload hooks before starting the router. The router then builds a strict child environment, rejects reviewer pushes and every reviewer mutation except a comment-only review on the recorded PR, and rejects approvals, change requests, merges, executor-authored reviews, GraphQL, and administration APIs. A reviewer API write is permitted only for one exact-head `event=COMMENT` review with validated inline fields. Authenticated Git disables worktree hooks, fsmonitor, external diff, textconv, and the `ext` transport; remote Git is restricted to exact origin push/fetch/issue-branch discovery shapes and executes against a canonical configured-repository HTTPS URL, never an arbitrary local remote/helper. Automation API mutations are limited to the current issue's exact claim label, the current issue/PR or journal comments, and current-PR review-comment replies. PR creation requires the authorized issue or pending-evolve branch, explicit `--repo codeacme17/echo-ui`, `--base dev`, and `--draft`; later PR writes require the same explicit repository (or full configured PR URL), the exact durable checkpoint, the recorded live PR branch/base/head, and the phase-specific Draft/evidence/review gates. `pr edit` is limited to title/body updates and requesting `codeacme17`. Automation pushes use only the exact active issue branch or exact pending `codex/evolve-<request-id>` branch in `git push origin <branch>`; every other push shape is rejected. ## Selection and claim diff --git a/loops/issue-dev-loop/review/REVIEW.md b/loops/issue-dev-loop/review/REVIEW.md index bfe8ce62..5779d71c 100644 --- a/loops/issue-dev-loop/review/REVIEW.md +++ b/loops/issue-dev-loop/review/REVIEW.md @@ -23,7 +23,7 @@ Do not emit formatter noise, personal style preferences, speculative concerns, u ## Publication -The fresh reviewer publishes each round verbatim through `reviewerGitHubLogin` as one non-approving GitHub review, adding `<!-- issue-dev-loop:<run-id>:<finding-id> -->` for deduplication and `<!-- issue-dev-loop:<run-id>:review-round:<round>:head:<sha> -->` to the round body. Findings with a concrete file and line must also be posted as matching inline comments; cross-cutting findings remain in the review body. The reviewer identity must differ from the executor and owner. The final review body must also include `<!-- issue-dev-loop:<run-id>:review-result-sha256:<digest> -->`. It must not downgrade severity or omit findings. +The fresh reviewer publishes each round verbatim through `reviewerGitHubLogin` as one non-approving GitHub review, adding `<!-- issue-dev-loop:<run-id>:<finding-id> -->` for deduplication and `<!-- issue-dev-loop:<run-id>:review-round:<round>:head:<sha> -->` to the round body. A body-only round uses the gated `gh pr review --comment` command. Findings with a concrete file and line must be posted in one gated `POST repos/<configured-repo>/pulls/<recorded-pr>/reviews` call whose event is exactly `COMMENT`, commit ID is the durable recorded head, and inline path/line/side/body fields carry the finding markers; arbitrary input files and every other mutating API shape are rejected. Cross-cutting findings remain in the review body. The reviewer identity must differ from the executor and owner. The final review body must also include `<!-- issue-dev-loop:<run-id>:review-result-sha256:<digest> -->`. It must not downgrade severity or omit findings. After all replies are posted, create one cycle result matching `result.schema.json`. It contains every round's review URL, every finding, its final classification, response URL, and evidence. Executor replies include both the readable evidence (and accepted fix SHA) plus `<!-- issue-dev-loop:<run-id>:<finding-id>:<classification> -->`. Rejected P0/P1 findings additionally require an independent or owner comment containing `<!-- issue-dev-loop:<run-id>:<finding-id>:adjudication:<verdict> -->`. Run `record-review` with the final GitHub review URL; it queries every recorded review, replies, identities, timestamps, ancestry, adjudications, and markers and binds them to the correct round and current head. Generic events cannot forge this reserved gate. diff --git a/loops/issue-dev-loop/review/reviewer-prompt.md b/loops/issue-dev-loop/review/reviewer-prompt.md index 6dde0f55..18ddf773 100644 --- a/loops/issue-dev-loop/review/reviewer-prompt.md +++ b/loops/issue-dev-loop/review/reviewer-prompt.md @@ -1 +1 @@ -Review the Echo UI PR diff between `<base-sha>` and `<head-sha>` as an independent, fresh-context reviewer. Use `$code-review`. Read only the supplied issue snapshot, acceptance criteria, repository instructions, diff, CI results, and evidence manifest. Follow `loops/issue-dev-loop/review/REVIEW.md`. Do not edit files or rely on executor conversation history. Publish the non-approving GitHub review only through `loops/issue-dev-loop/scripts/with-github-identity reviewer -- gh pr review <number> --comment --body <review-body>`; never invoke the `.mjs` router directly, run raw `gh`, call mutating `gh api`, approve/request changes/merge, or alter global authentication. Return structured findings or `PASS` and the unique review URL. +Review the Echo UI PR diff between `<base-sha>` and `<head-sha>` as an independent, fresh-context reviewer. Use `$code-review`. Read only the supplied issue snapshot, acceptance criteria, repository instructions, diff, CI results, and evidence manifest. Follow `loops/issue-dev-loop/review/REVIEW.md`. Do not edit files or rely on executor conversation history. Publish a body-only non-approving review through `loops/issue-dev-loop/scripts/with-github-identity reviewer -- gh pr review <number> --repo codeacme17/echo-ui --comment --body <review-body>`. If file/line findings require inline comments, use the same launcher with only the strict `gh api repos/codeacme17/echo-ui/pulls/<number>/reviews --method POST` COMMENT-review field shape documented by `REVIEW.md`. Never invoke the `.mjs` router directly, run raw `gh`, use `--input`, call any other mutating API, approve/request changes/merge, or alter global authentication. Return structured findings or `PASS` and the unique review URL. diff --git a/loops/issue-dev-loop/scripts/github-command-gate.mjs b/loops/issue-dev-loop/scripts/github-command-gate.mjs index 8907796c..f5b4fb68 100755 --- a/loops/issue-dev-loop/scripts/github-command-gate.mjs +++ b/loops/issue-dev-loop/scripts/github-command-gate.mjs @@ -38,7 +38,9 @@ async function main() { tool === 'credential' ? ['auth', 'git-credential'] : tool === 'git' - ? hardenedGitArguments(args) + ? hardenedGitArguments(args, { + expectedRepository: authorization?.expectedRepository, + }) : args if (tool !== 'credential') { assertDescendantCommandPolicy({ @@ -47,7 +49,7 @@ async function main() { args, authorization, }) - if (tool === 'git' && args[0] === 'push') { + if (tool === 'git' && ['push', 'fetch', 'ls-remote'].includes(args[0])) { await assertPushTargetsRepository({ expectedRepository: authorization.expectedRepository, realGit: executable, diff --git a/loops/issue-dev-loop/scripts/lib/github-identity.mjs b/loops/issue-dev-loop/scripts/lib/github-identity.mjs index 718cc8f1..34af9403 100644 --- a/loops/issue-dev-loop/scripts/lib/github-identity.mjs +++ b/loops/issue-dev-loop/scripts/lib/github-identity.mjs @@ -84,6 +84,9 @@ export function resolveGitHubRoleEnvironment({ channel, role, environment = proc throw new Error(`${variableName} must contain an absolute directory path`) } + const repositoryUrl = canonicalRepositoryUrl( + assertNonEmpty(channel.repository, 'channel.repository'), + ) const routedEnvironment = { ...safeBaseEnvironment(channel, environment), GH_CONFIG_DIR: configDirectory, @@ -93,7 +96,7 @@ export function resolveGitHubRoleEnvironment({ channel, role, environment = proc GH_PROMPT_DISABLED: '1', GIT_CONFIG_GLOBAL: devNull, GIT_CONFIG_NOSYSTEM: '1', - GIT_CONFIG_COUNT: '5', + GIT_CONFIG_COUNT: '7', GIT_CONFIG_KEY_0: 'credential.helper', GIT_CONFIG_VALUE_0: '', GIT_CONFIG_KEY_1: 'credential.helper', @@ -106,6 +109,10 @@ export function resolveGitHubRoleEnvironment({ channel, role, environment = proc GIT_CONFIG_VALUE_3: 'false', GIT_CONFIG_KEY_4: 'protocol.ext.allow', GIT_CONFIG_VALUE_4: 'never', + GIT_CONFIG_KEY_5: `url.${repositoryUrl}.insteadOf`, + GIT_CONFIG_VALUE_5: repositoryUrl, + GIT_CONFIG_KEY_6: `url.${repositoryUrl}.pushInsteadOf`, + GIT_CONFIG_VALUE_6: repositoryUrl, GIT_PAGER: 'cat', GIT_TERMINAL_PROMPT: '0', PAGER: 'cat', @@ -113,11 +120,36 @@ export function resolveGitHubRoleEnvironment({ channel, role, environment = proc return { configDirectory, expectedLogin, routedEnvironment } } -export function hardenedGitArguments(args) { +function canonicalRepositoryUrl(repository) { + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) { + throw new Error('configured repository must be an owner/name pair') + } + return `https://github.com/${repository}.git` +} + +export function hardenedGitArguments(args, { expectedRepository = null } = {}) { const subcommand = gitSubcommand(args) - if (!['diff', 'show', 'log'].includes(subcommand.name)) return [...args] const hardened = [...args] - hardened.splice(subcommand.index + 1, 0, '--no-ext-diff', '--no-textconv') + if (['diff', 'show', 'log'].includes(subcommand.name)) { + hardened.splice(subcommand.index + 1, 0, '--no-ext-diff', '--no-textconv') + } + if (!expectedRepository) return hardened + const repositoryUrl = canonicalRepositoryUrl(expectedRepository) + if (subcommand.name === 'push') { + const branch = args.at(-1) + return ['push', repositoryUrl, `refs/heads/${branch}:refs/heads/${branch}`] + } + if (subcommand.name === 'fetch') { + const branch = args.at(-1) + return [ + 'fetch', + repositoryUrl, + `refs/heads/${branch}:refs/remotes/origin/${branch}`, + ] + } + if (subcommand.name === 'ls-remote') { + return ['ls-remote', '--heads', repositoryUrl, 'refs/heads/codex/issue-*'] + } return hardened } @@ -165,8 +197,6 @@ export function assertGitCommandPolicy(role, args, { authorization = null } = {} isLoopBranch && [ ['push', 'origin', branch], - ['push', '-u', 'origin', branch], - ['push', '--set-upstream', 'origin', branch], ].some((expected) => sameArguments(args, expected)) if (!isAllowedShape) { throw new Error('GitHub automation may push only one explicit loop branch') @@ -194,7 +224,6 @@ export function assertGitCommandPolicy(role, args, { authorization = null } = {} 'show', 'diff', 'log', - 'ls-remote', ]) if (readOnly.has(subcommand.name)) return if ( @@ -219,7 +248,34 @@ export function assertGitCommandPolicy(role, args, { authorization = null } = {} ) { return } - if (role === 'automation' && subcommand.name === 'fetch') return + if ( + role === 'automation' && + subcommand.index === 0 && + sameArguments(args.slice(subcommand.index), [ + 'ls-remote', + '--heads', + 'origin', + 'refs/heads/codex/issue-*', + ]) + ) { + return + } + if ( + role === 'automation' && + subcommand.index === 0 && + subcommand.name === 'fetch' && + authorizedPushBranches(authorization).has(args.at(-1)) && + sameArguments(args.slice(subcommand.index), ['fetch', 'origin', args.at(-1)]) + ) { + return + } + if ( + role === 'automation' && + subcommand.index === 0 && + sameArguments(args.slice(subcommand.index), ['fetch', 'origin', 'dev']) + ) { + return + } throw new Error(`git command is outside the authenticated ${role} command tree`) } @@ -285,6 +341,7 @@ function githubApiRequest(apiArguments) { let explicitMethod = null let endpoint = null let hasRequestBody = false + let usesInput = false let valid = true const fields = [] @@ -297,6 +354,7 @@ function githubApiRequest(apiArguments) { break } if (argument === '--method' || argument === '-X') explicitMethod = value + if (argument === '--input') usesInput = true if (bodyOptions.has(argument)) { hasRequestBody = true fields.push(value) @@ -314,6 +372,7 @@ function githubApiRequest(apiArguments) { break } if (longOption === '--method') explicitMethod = value + if (longOption === '--input') usesInput = true if (bodyOptions.has(longOption)) { hasRequestBody = true fields.push(value) @@ -349,6 +408,7 @@ function githubApiRequest(apiArguments) { mutating: !valid || method !== 'GET', valid, fields, + usesInput, } } @@ -527,7 +587,10 @@ function pullRequestMutationAllowed(kind, args, commandIndex, authorization, exp '-F': 'bodyFile', } : repositoryValueOptions - const parsed = parseOptions(args, commandIndex + 1, { valueOptions }) + const parsed = parseOptions(args, commandIndex + 1, { + valueOptions, + booleanOptions: kind === 'ready' ? { '--undo': 'undo' } : {}, + }) if ( !parsed.valid || parsed.positional.length !== 1 || @@ -540,7 +603,7 @@ function pullRequestMutationAllowed(kind, args, commandIndex, authorization, exp ) { return false } - if (kind === 'ready') return parsed.values.size <= 1 + if (kind === 'ready') return parsed.values.size <= 1 && parsed.booleans.size <= 1 if (kind === 'comment') { return ( Number(Boolean(exactlyOne(parsed.values, 'body'))) + @@ -588,6 +651,80 @@ function automationApiMutationAllowed({ endpoint, method, fields }, authorizatio ) } +function apiField(field) { + const separator = field.indexOf('=') + return separator === -1 + ? { name: field, value: '' } + : { name: field.slice(0, separator), value: field.slice(separator + 1) } +} + +function reviewerInlineReviewAllowed(request, authorization, expectedRepository) { + const match = request.endpoint?.match(/^repos\/([^/]+\/[^/]+)\/pulls\/(\d+)\/reviews$/) + if ( + request.method !== 'POST' || + request.usesInput || + !match || + !repositoryInScope(match[1], expectedRepository) || + Number(match[2]) !== authorization?.issue?.prNumber || + !authorization?.issue?.runId || + !/^[0-9a-f]{40}$/i.test(authorization.issue.headSha ?? '') + ) { + return false + } + const fields = request.fields.map(apiField) + const values = (name) => fields.filter((field) => field.name === name).map((field) => field.value) + const body = values('body') + const commitIds = values('commit_id') + const events = values('event') + if ( + body.length !== 1 || + commitIds.length !== 1 || + events.length !== 1 || + commitIds[0] !== authorization.issue.headSha || + events[0] !== 'COMMENT' || + !body[0].includes( + `<!-- issue-dev-loop:${authorization.issue.runId}:review-round:`, + ) || + !body[0].includes(`:head:${authorization.issue.headSha} -->`) + ) { + return false + } + const permittedNames = new Set([ + 'body', + 'commit_id', + 'event', + 'comments[][path]', + 'comments[][line]', + 'comments[][side]', + 'comments[][body]', + ]) + if (fields.some((field) => !permittedNames.has(field.name))) return false + const paths = values('comments[][path]') + const lines = values('comments[][line]') + const sides = values('comments[][side]') + const comments = values('comments[][body]') + if ( + paths.length < 1 || + paths.length > 50 || + lines.length !== paths.length || + sides.length !== paths.length || + comments.length !== paths.length + ) { + return false + } + return paths.every( + (filePath, index) => + filePath.length > 0 && + !path.isAbsolute(filePath) && + !filePath.split(/[\\/]/).includes('..') && + /^[1-9][0-9]*$/.test(lines[index]) && + ['LEFT', 'RIGHT'].includes(sides[index]) && + comments[index].includes( + `<!-- issue-dev-loop:${authorization.issue.runId}:RVW-`, + ), + ) +} + function githubRepositoryFlags(args) { const repositories = [] for (let index = 0; index < args.length; index += 1) { @@ -608,6 +745,23 @@ function githubRepositoryFlags(args) { return repositories } +function githubHostnameFlags(args) { + const hostnames = [] + for (let index = 0; index < args.length; index += 1) { + const argument = args[index] + if (argument === '--hostname') { + const hostname = args[index + 1] + if (hostname) hostnames.push(hostname) + index += 1 + continue + } + if (argument.startsWith('--hostname=')) { + hostnames.push(argument.slice('--hostname='.length)) + } + } + return hostnames +} + function endpointRepository(endpoint) { const match = endpoint?.match(/^repos\/([^/]+\/[^/]+)(?:\/|$)/) return match?.[1] ?? null @@ -637,6 +791,9 @@ export function assertGitHubCliPolicy( ) { reject() } + if (githubHostnameFlags(args).some((hostname) => hostname.toLowerCase() !== 'github.com')) { + reject() + } const group = githubGroup(args) if (!group.name) reject() const subcommand = commandAfterGroup(args, group.index) @@ -647,6 +804,7 @@ export function assertGitHubCliPolicy( if (group.name === 'api') { const request = githubApiRequest(args.slice(group.index + 1)) if (readOnlyIdentityRequest(request)) return + if (reviewerInlineReviewAllowed(request, authorization, expectedRepository)) return if ( !request.valid || request.mutating || @@ -792,25 +950,30 @@ async function authenticatedToolForExecutable(executable, { realGit, realGh }) { function normalizedRepositoryFromRemote(remoteUrl) { const value = remoteUrl.trim().replace(/\.git$/, '') - for (const pattern of [ - /^https:\/\/github\.com\/([^/]+\/[^/]+)$/i, - /^git@github\.com:([^/]+\/[^/]+)$/i, - /^ssh:\/\/git@github\.com\/([^/]+\/[^/]+)$/i, - ]) { - const match = value.match(pattern) - if (match) return match[1].toLowerCase() - } - return null + const match = value.match(/^https:\/\/github\.com\/([^/]+\/[^/]+)$/i) + return match?.[1]?.toLowerCase() ?? null } export async function assertPushTargetsRepository({ expectedRepository, realGit, environment }) { const expected = assertNonEmpty(expectedRepository, 'expectedRepository').toLowerCase() - const { stdout } = await execFileAsync(realGit, ['remote', 'get-url', 'origin'], { - env: environment, - maxBuffer: 1024 * 1024, - }) - if (normalizedRepositoryFromRemote(stdout) !== expected) { - throw new Error(`origin must target the configured repository ${expectedRepository}`) + const [fetchRemote, pushRemote] = await Promise.all( + [ + ['remote', 'get-url', 'origin'], + ['remote', 'get-url', '--push', 'origin'], + ].map((args) => + execFileAsync(realGit, args, { + env: environment, + maxBuffer: 1024 * 1024, + }), + ), + ) + if ( + normalizedRepositoryFromRemote(fetchRemote.stdout) !== expected || + normalizedRepositoryFromRemote(pushRemote.stdout) !== expected + ) { + throw new Error( + `origin fetch and push URLs must use HTTPS for the configured repository ${expectedRepository}`, + ) } } @@ -940,6 +1103,12 @@ function activationValidationRequested({ role, tool, args, loopRoot }) { function pullRequestWriteIntent(role, args) { const group = githubGroup(args) + if (role === 'reviewer' && group.name === 'api') { + const request = githubApiRequest(args.slice(group.index + 1)) + if (request.mutating && /\/pulls\/\d+\/reviews$/.test(request.endpoint ?? '')) { + return { kind: 'inline-review', commandIndex: -1 } + } + } if (group.name !== 'pr') return null const command = commandAfterGroup(args, group.index) if (role === 'reviewer' && command.name === 'review') { @@ -967,6 +1136,14 @@ function editRequestsOwnerReview(args, commandIndex) { return parsed.values.has('addReviewer') } +function readyReturnsToDraft(args, commandIndex) { + const parsed = parseOptions(args, commandIndex + 1, { + valueOptions: repositoryValueOptions, + booleanOptions: { '--undo': 'undo' }, + }) + return parsed.booleans.get('undo') === true +} + function hasExactHeadGate(events, eventType, headSha) { return events.some( (event) => @@ -1042,13 +1219,25 @@ async function preflightPullRequestWrite({ throw new Error('live pull request does not match the durable recorded head, branch, and base') } - if (intent.kind === 'review') { + if (['review', 'inline-review'].includes(intent.kind)) { if (livePullRequest.draft !== true) { throw new Error('independent review publication requires the recorded Draft PR') } return } if (intent.kind === 'ready') { + if (readyReturnsToDraft(args, intent.commandIndex)) { + const ownerResponse = events.findLast( + (event) => + event.type === 'owner_response_observed' && + event.status === 'observed' && + sameGitHubLogin(event.payload?.actor, channel.ownerGitHubLogin), + ) + if (livePullRequest.draft !== false || !ownerResponse) { + throw new Error('returning a PR to Draft requires a durable verified owner response') + } + return + } if ( livePullRequest.draft !== true || !hasExactHeadGate(events, 'verification_completed', run.headSha) || @@ -1146,7 +1335,10 @@ export async function runWithGitHubRole({ environment: resolved.routedEnvironment, }) } - if (tool === 'git' && gitSubcommand(args).name === 'push') { + if ( + tool === 'git' && + ['push', 'fetch', 'ls-remote'].includes(gitSubcommand(args).name) + ) { await assertPushTargetsRepository({ expectedRepository: channel.repository, realGit, @@ -1161,7 +1353,10 @@ export async function runWithGitHubRole({ ECHO_UI_LOOP_NODE: process.execPath, ECHO_UI_LOOP_AUTHORIZATION: JSON.stringify(authorization), } - let executionArgs = tool === 'git' ? hardenedGitArguments(args) : [...args] + let executionArgs = + tool === 'git' + ? hardenedGitArguments(args, { expectedRepository: channel.repository }) + : [...args] if (activationValidation) executionArgs = [args[0], 'validate'] const child = spawnCommand(executable, executionArgs, { env: childEnvironment, diff --git a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs index 332fae11..92908685 100644 --- a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs +++ b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs @@ -23,6 +23,7 @@ async function createFixture({ readyToMark = false, realGit = false, liveDraft = true, + ownerFeedback = false, } = {}) { const parent = await mkdtemp(path.join(os.tmpdir(), 'echo-ui-identity-routing-')) const loopRoot = path.join(parent, 'issue-dev-loop') @@ -142,6 +143,16 @@ async function createFixture({ }, ) } + if (ownerFeedback) { + events.push({ + schemaVersion: 1, + runId: 'fixture-run', + type: 'owner_response_observed', + timestamp: '2026-07-23T00:04:30.000Z', + status: 'observed', + payload: { actor: 'owner-user' }, + }) + } const record = { schemaVersion: 1, kind: 'active-checkpoint', @@ -318,6 +329,10 @@ if [ "$1 $2 $3" = "remote get-url origin" ]; then echo "https://github.com/example/repo.git" exit 0 fi +if [ "$1 $2 $3 $4" = "remote get-url --push origin" ]; then + echo "https://github.com/example/repo.git" + exit 0 +fi node -e 'process.stdout.write(JSON.stringify({args: process.argv.slice(1), config: process.env.GH_CONFIG_DIR, hasGhToken: Boolean(process.env.GH_TOKEN || process.env.GITHUB_TOKEN), gitConfig: Array.from({length: Number(process.env.GIT_CONFIG_COUNT)}, (_, index) => [process.env[\`GIT_CONFIG_KEY_\${index}\`], process.env[\`GIT_CONFIG_VALUE_\${index}\`]]).flat()}))' -- "$@" `, 'utf8', @@ -363,7 +378,7 @@ test('automation role selects its dedicated gh profile without leaking token ove assert.deepEqual(JSON.parse(stdout), { config: fixture.automationProfile, hasGhToken: false, - gitConfig: ['5', 'credential.helper', '', 'credential.helper', credentialHelper], + gitConfig: ['7', 'credential.helper', '', 'credential.helper', credentialHelper], gitIsolation: [os.devNull, '1'], exposesOtherProfiles: false, exposesRealTools: false, @@ -462,7 +477,11 @@ test('automation git command clears global helpers and injects the selected gh c const observed = JSON.parse(stdout) assert.equal(observed.config, fixture.automationProfile) assert.equal(observed.hasGhToken, false) - assert.deepEqual(observed.args, ['push', 'origin', 'codex/issue-123']) + assert.deepEqual(observed.args, [ + 'push', + 'https://github.com/example/repo.git', + 'refs/heads/codex/issue-123:refs/heads/codex/issue-123', + ]) assert.deepEqual(observed.gitConfig, [ 'credential.helper', '', @@ -474,6 +493,10 @@ test('automation git command clears global helpers and injects the selected gh c 'false', 'protocol.ext.allow', 'never', + 'url.https://github.com/example/repo.git.insteadOf', + 'https://github.com/example/repo.git', + 'url.https://github.com/example/repo.git.pushInsteadOf', + 'https://github.com/example/repo.git', ]) }) @@ -641,6 +664,93 @@ test('reviewer role may publish only a non-approving comment review', async () = assert.equal(stdout.trim(), 'comment review published') }) +test('reviewer may publish exact-head inline comments only as a COMMENT review API request', async () => { + const fixture = await createFixture() + const { stdout } = await execFileAsync( + process.execPath, + [ + routerPath, + '--loop-root', + fixture.loopRoot, + 'reviewer', + '--', + 'gh', + 'api', + 'repos/example/repo/pulls/106/reviews', + '--method', + 'POST', + '-f', + `commit_id=${'b'.repeat(40)}`, + '-f', + 'event=COMMENT', + '-f', + `body=<!-- issue-dev-loop:fixture-run:review-round:1:head:${'b'.repeat(40)} -->`, + '-F', + 'comments[][path]=src/fixture.ts', + '-F', + 'comments[][line]=10', + '-F', + 'comments[][side]=RIGHT', + '-f', + 'comments[][body]=<!-- issue-dev-loop:fixture-run:RVW-1-1 --> Finding', + ], + { env: fixture.env }, + ) + assert.match(stdout, /pulls\/106\/reviews/) + + for (const unsafe of [ + ['-f', `commit_id=${'b'.repeat(40)}`, '-f', 'event=APPROVE', '-f', 'body=unsafe'], + [ + '-f', + `commit_id=${'d'.repeat(40)}`, + '-f', + 'event=COMMENT', + '-f', + 'body=wrong head', + ], + ['--input', '/tmp/unvalidated-review.json'], + [ + '--hostname', + 'example.invalid', + '-f', + `commit_id=${'b'.repeat(40)}`, + '-f', + 'event=COMMENT', + '-f', + `body=<!-- issue-dev-loop:fixture-run:review-round:1:head:${'b'.repeat(40)} -->`, + '-F', + 'comments[][path]=src/fixture.ts', + '-F', + 'comments[][line]=10', + '-F', + 'comments[][side]=RIGHT', + '-f', + 'comments[][body]=<!-- issue-dev-loop:fixture-run:RVW-1-1 --> Finding', + ], + ]) { + await assert.rejects( + execFileAsync( + process.execPath, + [ + routerPath, + '--loop-root', + fixture.loopRoot, + 'reviewer', + '--', + 'gh', + 'api', + 'repos/example/repo/pulls/106/reviews', + '--method', + 'POST', + ...unsafe, + ], + { env: fixture.env }, + ), + /GitHub action is prohibited for the reviewer role/, + ) + } +}) + test('automation PR creation is bound to the active branch, dev, and Draft', async () => { const fixture = await createFixture({ recordedPr: false }) const { stdout } = await execFileAsync( @@ -784,6 +894,52 @@ test('PR ready is permitted only after exact-head evidence and review are durabl assert.deepEqual(JSON.parse(stdout).args.slice(0, 2), ['pr', 'ready']) }) +test('owner feedback durably authorizes returning only the exact recorded PR to Draft', async () => { + const missingFeedback = await createFixture({ liveDraft: false }) + await assert.rejects( + execFileAsync( + process.execPath, + [ + routerPath, + '--loop-root', + missingFeedback.loopRoot, + 'automation', + '--', + 'gh', + 'pr', + 'ready', + '106', + '--repo', + 'example/repo', + '--undo', + ], + { env: missingFeedback.env }, + ), + /owner response/, + ) + + const fixture = await createFixture({ liveDraft: false, ownerFeedback: true }) + const { stdout } = await execFileAsync( + process.execPath, + [ + routerPath, + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + 'gh', + 'pr', + 'ready', + '106', + '--repo', + 'example/repo', + '--undo', + ], + { env: fixture.env }, + ) + assert.deepEqual(JSON.parse(stdout).args.slice(0, 2), ['pr', 'ready']) +}) + test('PR writes reject forged local authorization and live PR drift', async () => { const forged = await createFixture() const runPath = path.join(forged.loopRoot, 'logs', 'runs', 'fixture-run', 'run.json') @@ -990,6 +1146,10 @@ test('authenticated roots reject executable impersonation and mutating remote sy ['git', 'remote', '-v', 'set-url', 'origin', 'https://example.invalid/repo.git'], ['git', 'diff', '--ext-diff'], ['git', 'show', '--textconv', 'HEAD'], + ['git', 'ls-remote', '--upload-pack=/bin/sh', 'origin'], + ['git', 'ls-remote', 'https://github.com/example/repo.git'], + ['git', 'fetch', 'attacker', 'dev'], + ['git', 'fetch', 'origin', 'feature/unrelated'], ]) { await assert.rejects( execFileAsync( @@ -1010,6 +1170,33 @@ test('authenticated roots reject executable impersonation and mutating remote sy } }) +test('authenticated remote Git accepts only exact origin and authorized ref shapes', async () => { + const fixture = await createFixture() + const allowed = [ + ['ls-remote', '--heads', 'origin', 'refs/heads/codex/issue-*'], + ['fetch', 'origin', 'dev'], + ['fetch', 'origin', 'codex/issue-123'], + ] + for (const gitArguments of allowed) { + const { stdout } = await execFileAsync( + process.execPath, + [ + routerPath, + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + 'git', + ...gitArguments, + ], + { env: fixture.env }, + ) + const executed = JSON.parse(stdout) + assert.match(executed.args.join(' '), /https:\/\/github\.com\/example\/repo\.git/) + assert.doesNotMatch(executed.args.join(' '), /--upload-pack|attacker/) + } +}) + test('authenticated real Git ignores local execution hooks and configured diff helpers', async () => { const fixture = await createFixture({ realGit: true }) const realGit = await resolveExecutable('git', process.env) @@ -1084,6 +1271,10 @@ if [ "$1 $2 $3" = "remote get-url origin" ]; then echo "https://github.com/attacker/other-repo.git" exit 0 fi +if [ "$1 $2 $3 $4" = "remote get-url --push origin" ]; then + echo "https://github.com/attacker/other-repo.git" + exit 0 +fi exit 0 `, 'utf8', @@ -1106,6 +1297,6 @@ exit 0 ], { env: fixture.env }, ), - /origin must target the configured repository example\/repo/, + /origin fetch and push URLs must use HTTPS for the configured repository example\/repo/, ) }) From a8ddc34538de1ab04eac9e78253cbe83bb137ba1 Mon Sep 17 00:00:00 2001 From: leyoonafr <codeacme17@gmail.com> Date: Thu, 23 Jul 2026 19:06:52 +0800 Subject: [PATCH 24/41] fix(loop): bind review and transport state --- .codex/agents/echo-ui-pr-reviewer.toml | 8 +- loops/issue-dev-loop/LOOP.md | 2 +- loops/issue-dev-loop/SKILL.md | 4 +- loops/issue-dev-loop/evolve/EVOLVE.md | 2 +- .../references/github-operations.md | 4 +- loops/issue-dev-loop/review/REVIEW.md | 6 +- .../issue-dev-loop/review/comment.schema.json | 2 +- .../issue-dev-loop/review/result.schema.json | 7 +- .../issue-dev-loop/review/reviewer-prompt.md | 2 +- .../scripts/github-command-gate.mjs | 10 +- loops/issue-dev-loop/scripts/lib/evidence.mjs | 95 +++++- loops/issue-dev-loop/scripts/lib/evolve.mjs | 139 +++++++++ .../scripts/lib/github-identity.mjs | 291 ++++++++++++++++-- .../issue-dev-loop/scripts/lib/run-store.mjs | 22 +- loops/issue-dev-loop/scripts/loopctl.mjs | 19 +- loops/issue-dev-loop/scripts/runtime.mjs | 8 +- .../tests/github-identity-routing.test.mjs | 216 ++++++++++++- loops/issue-dev-loop/tests/runtime.test.mjs | 279 ++++++++++++++--- 18 files changed, 1015 insertions(+), 101 deletions(-) diff --git a/.codex/agents/echo-ui-pr-reviewer.toml b/.codex/agents/echo-ui-pr-reviewer.toml index 6680f1b8..36b20556 100644 --- a/.codex/agents/echo-ui-pr-reviewer.toml +++ b/.codex/agents/echo-ui-pr-reviewer.toml @@ -8,17 +8,17 @@ originating issue acceptance criteria. Prioritize correctness, public API and package compatibility, Web Audio lifecycle behavior, React behavior, accessibility, security, regressions, and missing tests. Ignore executor conversation history and do not modify files. Avoid style-only or speculative findings. Return a structured -review with stable finding IDs, severity P0-P3, confidence, file and line, concrete +review with run-wide `RVW-<cycle>-<round>-<sequence>` IDs, severity P0-P3, confidence, file and line, concrete evidence, reproduction when possible, and expected resolution. Return PASS when no actionable findings exist. Publish GitHub reviews only through the executable `loops/issue-dev-loop/scripts/with-github-identity reviewer -- ...` launcher; the wrapper must verify the configured reviewerGitHubLogin before publication. Never invoke the `.mjs` implementation directly, run raw `gh`, alter global authentication, -or let the executor identity publish on your behalf. Use `gh pr review --comment` -for a body-only review. When a finding has a file and line, use the launcher's +or let the executor identity publish on your behalf. Use `gh pr review --comment --body` +for a body-only review; body files are prohibited. When a finding has a file and line, use the launcher's strictly gated `POST repos/<configured-repo>/pulls/<recorded-pr>/reviews` API shape with `event=COMMENT`, the durable head SHA, and matching inline comments. All other mutating `gh api`, approval, change requests, and merge commands are prohibited. Every round must include its -run/round/head marker and return its unique review URL. For the final PASS, include +run/cycle/round/head marker and return its unique review URL. For the final PASS, include the cycle-result digest marker and all prior finding IDs and return its review URL. """ diff --git a/loops/issue-dev-loop/LOOP.md b/loops/issue-dev-loop/LOOP.md index f17c6200..0befc5ed 100644 --- a/loops/issue-dev-loop/LOOP.md +++ b/loops/issue-dev-loop/LOOP.md @@ -98,7 +98,7 @@ After exact-head CI and independent review pass, download the CI manifest and re ### 9. Owner feedback -When the owner requests changes, verify the owner-authored GitHub comment or `CHANGES_REQUESTED` review with `record-owner-response`; ordinary comments must include the notification's exact `RESUME <run-id>` token. Only after that gate may the run return to `running`. Snapshot the comments, create a supplemental handoff, invoke `$implement`, record its new result, rebind the PR at its new head, and publish a checkpoint. Then return that exact non-Draft PR to Draft with the phase-gated `gh pr ready --undo`, update its body, reverify, rerun fresh review, reply with commit and evidence, and notify the owner that the PR is ready again. +When the owner requests changes, verify the owner-authored GitHub comment or `CHANGES_REQUESTED` review with `record-owner-response`; ordinary comments must include the notification's exact `RESUME <run-id>` token. Only after that gate may the run return to `running`. Publish the transition checkpoint, immediately return the unchanged exact PR head to Draft with the phase-gated `gh pr ready --undo`, observe that remote Draft state with `record-pr`, and publish another checkpoint. Only then snapshot comments, create a supplemental handoff, invoke `$implement`, record its new result, push and rebind the still-Draft PR at the new head. Update its body, reverify, rerun a new numbered fresh-review cycle, reply with commit and evidence, and notify the owner that the PR is ready again. ### 10. Complete diff --git a/loops/issue-dev-loop/SKILL.md b/loops/issue-dev-loop/SKILL.md index 703a6a5c..a17c1fbf 100644 --- a/loops/issue-dev-loop/SKILL.md +++ b/loops/issue-dev-loop/SKILL.md @@ -46,7 +46,7 @@ Push only the issue branch and create a **draft** PR targeting `dev` using `temp After the draft PR exists, spawn the project agent `echo_ui_pr_reviewer` with a fresh context. Give it only the issue snapshot, acceptance criteria, repository instructions, base SHA, head SHA, diff, CI results, and evidence manifest. Do not give it executor conversation history or rationale. -Publish every round through `with-github-identity reviewer -- ...`; the executor identity may not author it. Every PR write must include `--repo codeacme17/echo-ui` (or use the full configured PR URL). Use `gh pr review --comment` for a body-only review. When file/line findings require inline comments, use only the reviewer's gated `POST repos/codeacme17/echo-ui/pulls/<recorded-pr>/reviews` API shape with `event=COMMENT`, the exact durable head, and validated inline fields. Post findings verbatim as one review plus inline comments. Each finding needs a stable ID, severity, confidence, evidence, and expected resolution. Record the GitHub review URL for each round. Accepted repairs must start after that round was submitted, and executor replies must be posted through the automation wrapper after the corresponding `$implement` invocation finishes. Follow `review/REVIEW.md` and `review/response-policy.md`. +Publish every round through `with-github-identity reviewer -- ...`; the executor identity may not author it. Every PR write must include `--repo codeacme17/echo-ui` (or use the full configured PR URL). Use `gh pr review --comment --body <body>` for a body-only review and include the exact run/cycle/round/head marker; the gate rejects body files, skipped rounds, duplicates, and publications outside the next durable cycle. When file/line findings require inline comments, use only the reviewer's gated `POST repos/codeacme17/echo-ui/pulls/<recorded-pr>/reviews` API shape with `event=COMMENT`, the exact durable head, and validated inline fields. Post findings verbatim as one review plus inline comments. Each finding needs a run-wide stable ID, severity, confidence, evidence, and expected resolution. Record the GitHub review URL for each round. Accepted repairs must start after that round was submitted, and executor replies must be posted through the automation wrapper after the corresponding `$implement` invocation finishes. Follow `review/REVIEW.md` and `review/response-policy.md`. The executor must classify every finding as `accepted`, `rejected`, `needs-human`, `stale`, or `already-fixed`: @@ -61,7 +61,7 @@ Run verification appropriate to the change and require `pnpm verify` before the The owner is the only actor allowed to approve or merge. Never call `gh pr merge`, enable auto-merge, push to `main`, push to `dev`, dismiss owner feedback, or bypass branch protections. Before any terminal transition, run `prepare-finalization`, publish its exact body to the configured state-journal issue, and validate the returned comment URL with `record-finalization`. A completed run passes the same result and comment URL to `observe-owner-merge`, which queries GitHub and requires both `codeacme17`'s approval and merge at the reviewed head SHA. Future workspaces run `reconcile` to rebuild local history and evolve metrics from those automation-authored journal comments. -For any pause, do not resume from silence or an arbitrary comment. First require a successfully delivered blocking notification. Then verify the owner's GitHub decision with `loopctl.mjs record-owner-response --run-id <id> --response-url <comment-or-review-url>`. A normal comment must include the exact `RESUME <run-id>` token printed in the notification; a `CHANGES_REQUESTED` review is itself an explicit decision. Only then may `loopctl.mjs transition --run-id <id> --status running` continue product work. After an owner-feedback repair is rebound and checkpointed at its new head, run the exact recorded PR through `gh pr ready --undo --repo codeacme17/echo-ui` before the mandatory fresh review; the router permits this only when the durable owner response and live PR state match. +For any pause, do not resume from silence or an arbitrary comment. First require a successfully delivered blocking notification. Then verify the owner's GitHub decision with `loopctl.mjs record-owner-response --run-id <id> --response-url <comment-or-review-url>`. A normal comment must include the exact `RESUME <run-id>` token printed in the notification; a `CHANGES_REQUESTED` review is itself an explicit decision. Only then may `loopctl.mjs transition --run-id <id> --status running` continue. Before any repair work or new push, publish that transition's checkpoint, run the unchanged exact PR through `gh pr ready --undo --repo codeacme17/echo-ui`, observe the same head as Draft with `record-pr`, and publish another checkpoint. `$implement` repair attestations and later PR rebinds are rejected unless this durable redraft happened after the current owner response. ## Stop conditions diff --git a/loops/issue-dev-loop/evolve/EVOLVE.md b/loops/issue-dev-loop/evolve/EVOLVE.md index ca3e2c88..d6710b80 100644 --- a/loops/issue-dev-loop/evolve/EVOLVE.md +++ b/loops/issue-dev-loop/evolve/EVOLVE.md @@ -9,7 +9,7 @@ The evolve session may propose: - improve non-authority-changing scripts and templates - update dashboards or metrics derived from append-only logs -Every evolve change requires a dedicated `codex/evolve-<request-id>` draft PR targeting `dev`, containing `<!-- issue-dev-loop:evolve-request:<request-id> -->`, and reviewed and merged by the owner. The PR must be created after the request. Push and create it only through `loops/issue-dev-loop/scripts/with-github-identity automation -- ...`; the router reads the pending metrics and request file and authorizes only their exact branch, `--base dev`, and `--draft`. The owner decides when to mark this dedicated evolve PR ready and whether to merge it. In particular, never change the following without explicit owner confirmation: +Every evolve change requires a dedicated `codex/evolve-<request-id>` draft PR targeting `dev`, containing `<!-- issue-dev-loop:evolve-request:<request-id> -->`, and reviewed and merged by the owner. Before creating the branch, run `loopctl prepare-evolve-request --request-id <id>`, publish its exact body to the configured state-journal issue through the automation identity, then bind the returned comment with `loopctl record-evolve-request --request-id <id> --comment-url <url>`. The router re-fetches that automation-authored digest-bound publication before both push and PR creation; local metrics or request files alone never grant mutation authority. Push and create the PR only through `loops/issue-dev-loop/scripts/with-github-identity automation -- ...`; the router authorizes only the published request's exact branch, `--base dev`, and `--draft`. The owner decides when to mark this dedicated evolve PR ready and whether to merge it. In particular, never change the following without explicit owner confirmation: - goals, completion criteria, authority, or stop conditions - merge, release, security, privacy, or dependency policy diff --git a/loops/issue-dev-loop/references/github-operations.md b/loops/issue-dev-loop/references/github-operations.md index 1ed3f032..2ffdd448 100644 --- a/loops/issue-dev-loop/references/github-operations.md +++ b/loops/issue-dev-loop/references/github-operations.md @@ -16,7 +16,7 @@ Publish reviewer output only as a non-approving comment review: loops/issue-dev-loop/scripts/with-github-identity reviewer -- gh pr review <number> --repo codeacme17/echo-ui --comment --body <review-body> ``` -The shell launcher removes Node preload hooks before starting the router. The router then builds a strict child environment, rejects reviewer pushes and every reviewer mutation except a comment-only review on the recorded PR, and rejects approvals, change requests, merges, executor-authored reviews, GraphQL, and administration APIs. A reviewer API write is permitted only for one exact-head `event=COMMENT` review with validated inline fields. Authenticated Git disables worktree hooks, fsmonitor, external diff, textconv, and the `ext` transport; remote Git is restricted to exact origin push/fetch/issue-branch discovery shapes and executes against a canonical configured-repository HTTPS URL, never an arbitrary local remote/helper. Automation API mutations are limited to the current issue's exact claim label, the current issue/PR or journal comments, and current-PR review-comment replies. PR creation requires the authorized issue or pending-evolve branch, explicit `--repo codeacme17/echo-ui`, `--base dev`, and `--draft`; later PR writes require the same explicit repository (or full configured PR URL), the exact durable checkpoint, the recorded live PR branch/base/head, and the phase-specific Draft/evidence/review gates. `pr edit` is limited to title/body updates and requesting `codeacme17`. Automation pushes use only the exact active issue branch or exact pending `codex/evolve-<request-id>` branch in `git push origin <branch>`; every other push shape is rejected. +The shell launcher removes Node preload hooks before starting the router. The router then builds a strict child environment, rejects reviewer pushes and every reviewer mutation except a comment-only review on the recorded PR, and rejects approvals, change requests, merges, executor-authored reviews, GraphQL, and administration APIs. Reviewer publication requires the next run/cycle/round marker; the gate paginates existing reviews and rejects duplicates, skipped rounds, body files, or an omitted reviewer publication. A reviewer API write is permitted only for one exact-head `event=COMMENT` review with validated inline fields. Authenticated Git disables worktree hooks, fsmonitor, external diff, textconv, proxy environment variables, repository-local HTTP/proxy/cookie/header/SSL/URL rewrites, and the `ext` transport. Remote Git is restricted to exact origin push/fetch/issue-branch discovery shapes and executes against a canonical configured-repository HTTPS URL. Automation API mutations are limited to the current issue's exact claim label, the current issue/PR or journal comments, and current-PR review-comment replies. PR creation requires the durably authorized issue or published evolve request branch, explicit `--repo codeacme17/echo-ui`, `--base dev`, and `--draft`; later PR writes require the same explicit repository (or full configured PR URL), the exact durable checkpoint, the recorded live PR branch/base/head, and the phase-specific Draft/evidence/review gates. `pr edit` is limited to title/body updates and requesting `codeacme17`. Issue pushes derive `codex/issue-<number>` from the durable checkpoint and reject local branch overrides; evolve pushes require the automation-authored request publication. Every other push shape is rejected. ## Selection and claim @@ -31,7 +31,7 @@ The shell launcher removes Node preload hooks before starting the router. The ro - Refresh `origin/dev` before creating `codex/issue-<number>`. - Push only the issue branch. - Create a draft PR with `--repo codeacme17/echo-ui --base dev`. -- Run `loopctl record-pr` immediately so later evidence, review, notification, and merge observations are bound to that exact PR/head. +- Run `loopctl record-pr` immediately so later evidence, review, notification, and merge observations are bound to that exact Draft PR/head. After owner feedback, return the unchanged head to Draft and record/checkpoint that observation before any repair. - Run `prepare-checkpoint`, publish its exact body on the state-journal issue, and run `record-checkpoint` immediately after binding or rebinding the PR. - Create the body from `templates/pr-body.md`. Initial `record-pr` rejects a body missing the run marker, issue closure, full base/head SHAs, or owner-only merge statement. After a repair push, rebind the new live head first, checkpoint it, then update the body through the exact-head router; the owner-ready gate still requires the visible final head SHA. - Include `Closes #<number>` only when the PR fully satisfies the issue. diff --git a/loops/issue-dev-loop/review/REVIEW.md b/loops/issue-dev-loop/review/REVIEW.md index 5779d71c..ec4de1b7 100644 --- a/loops/issue-dev-loop/review/REVIEW.md +++ b/loops/issue-dev-loop/review/REVIEW.md @@ -10,7 +10,7 @@ Spawn `echo_ui_pr_reviewer` without executor conversation history. Give it the i Return `PASS` or actionable findings. Each finding must include: -- stable ID `RVW-<round>-<sequence>` +- stable run-wide ID `RVW-<cycle>-<round>-<sequence>` - severity `P0`, `P1`, `P2`, or `P3` - confidence `high`, `medium`, or `low` - file and line when applicable @@ -23,9 +23,9 @@ Do not emit formatter noise, personal style preferences, speculative concerns, u ## Publication -The fresh reviewer publishes each round verbatim through `reviewerGitHubLogin` as one non-approving GitHub review, adding `<!-- issue-dev-loop:<run-id>:<finding-id> -->` for deduplication and `<!-- issue-dev-loop:<run-id>:review-round:<round>:head:<sha> -->` to the round body. A body-only round uses the gated `gh pr review --comment` command. Findings with a concrete file and line must be posted in one gated `POST repos/<configured-repo>/pulls/<recorded-pr>/reviews` call whose event is exactly `COMMENT`, commit ID is the durable recorded head, and inline path/line/side/body fields carry the finding markers; arbitrary input files and every other mutating API shape are rejected. Cross-cutting findings remain in the review body. The reviewer identity must differ from the executor and owner. The final review body must also include `<!-- issue-dev-loop:<run-id>:review-result-sha256:<digest> -->`. It must not downgrade severity or omit findings. +The fresh reviewer publishes each round verbatim through `reviewerGitHubLogin` as one non-approving GitHub review, adding `<!-- issue-dev-loop:<run-id>:<finding-id> -->` for deduplication and `<!-- issue-dev-loop:<run-id>:review-cycle:<cycle>:round:<round>:head:<sha> -->` to the round body. A body-only round uses the gated `gh pr review --comment --body <body>` command; body files are rejected so the identity gate can validate the marker before publication. Findings with a concrete file and line must be posted in one gated `POST repos/<configured-repo>/pulls/<recorded-pr>/reviews` call whose event is exactly `COMMENT`, commit ID is the durable recorded head, and inline path/line/side/body fields carry the finding markers; arbitrary input files and every other mutating API shape are rejected. Cross-cutting findings remain in the review body. The reviewer identity must differ from the executor and owner. The final review body must also include `<!-- issue-dev-loop:<run-id>:review-result-sha256:<digest> -->`. It must not downgrade severity or omit findings. -After all replies are posted, create one cycle result matching `result.schema.json`. It contains every round's review URL, every finding, its final classification, response URL, and evidence. Executor replies include both the readable evidence (and accepted fix SHA) plus `<!-- issue-dev-loop:<run-id>:<finding-id>:<classification> -->`. Rejected P0/P1 findings additionally require an independent or owner comment containing `<!-- issue-dev-loop:<run-id>:<finding-id>:adjudication:<verdict> -->`. Run `record-review` with the final GitHub review URL; it queries every recorded review, replies, identities, timestamps, ancestry, adjudications, and markers and binds them to the correct round and current head. Generic events cannot forge this reserved gate. +After all replies are posted, create one cycle result matching `result.schema.json`. Its `cycle` is the next durable review cycle number, and it contains every round's review URL, every finding, its final classification, response URL, and evidence. Executor replies include both the readable evidence (and accepted fix SHA) plus `<!-- issue-dev-loop:<run-id>:<finding-id>:<classification> -->`. Rejected P0/P1 findings additionally require an independent or owner comment containing `<!-- issue-dev-loop:<run-id>:<finding-id>:adjudication:<verdict> -->`. Run `record-review` with the final GitHub review URL; it paginates every reviewer-authored review on the recorded PR and requires one-to-one membership for the current run/cycle before it verifies replies, identities, timestamps, ancestry, adjudications, and markers. The publication gate rejects skipped or duplicate cycle rounds. Generic events cannot forge this reserved gate. ## Completion diff --git a/loops/issue-dev-loop/review/comment.schema.json b/loops/issue-dev-loop/review/comment.schema.json index ee220fcd..b847a69f 100644 --- a/loops/issue-dev-loop/review/comment.schema.json +++ b/loops/issue-dev-loop/review/comment.schema.json @@ -13,7 +13,7 @@ ], "additionalProperties": false, "properties": { - "findingId": { "type": "string", "pattern": "^RVW-[0-9]+-[0-9]+$" }, + "findingId": { "type": "string", "pattern": "^RVW-[0-9]+-[0-9]+-[0-9]+$" }, "severity": { "enum": ["P0", "P1", "P2", "P3"] }, "confidence": { "enum": ["high", "medium", "low"] }, "headSha": { "type": "string", "pattern": "^[0-9a-fA-F]{40}$" }, diff --git a/loops/issue-dev-loop/review/result.schema.json b/loops/issue-dev-loop/review/result.schema.json index c41c2bae..707aefe3 100644 --- a/loops/issue-dev-loop/review/result.schema.json +++ b/loops/issue-dev-loop/review/result.schema.json @@ -5,6 +5,7 @@ "required": [ "schemaVersion", "runId", + "cycle", "reviewerAgent", "freshContext", "headSha", @@ -15,6 +16,7 @@ "properties": { "schemaVersion": { "const": 1 }, "runId": { "type": "string", "minLength": 1 }, + "cycle": { "type": "integer", "minimum": 1 }, "reviewerAgent": { "const": "echo_ui_pr_reviewer" }, "freshContext": { "const": true }, "headSha": { "type": "string", "pattern": "^[0-9a-fA-F]{40}$" }, @@ -48,7 +50,10 @@ ], "additionalProperties": false, "properties": { - "findingId": { "type": "string", "pattern": "^RVW-[0-9]+-[0-9]+$" }, + "findingId": { + "type": "string", + "pattern": "^RVW-[0-9]+-[0-9]+-[0-9]+$" + }, "severity": { "enum": ["P0", "P1", "P2", "P3"] }, "confidence": { "enum": ["high", "medium", "low"] }, "headSha": { "type": "string", "pattern": "^[0-9a-fA-F]{40}$" }, diff --git a/loops/issue-dev-loop/review/reviewer-prompt.md b/loops/issue-dev-loop/review/reviewer-prompt.md index 18ddf773..b79cba2a 100644 --- a/loops/issue-dev-loop/review/reviewer-prompt.md +++ b/loops/issue-dev-loop/review/reviewer-prompt.md @@ -1 +1 @@ -Review the Echo UI PR diff between `<base-sha>` and `<head-sha>` as an independent, fresh-context reviewer. Use `$code-review`. Read only the supplied issue snapshot, acceptance criteria, repository instructions, diff, CI results, and evidence manifest. Follow `loops/issue-dev-loop/review/REVIEW.md`. Do not edit files or rely on executor conversation history. Publish a body-only non-approving review through `loops/issue-dev-loop/scripts/with-github-identity reviewer -- gh pr review <number> --repo codeacme17/echo-ui --comment --body <review-body>`. If file/line findings require inline comments, use the same launcher with only the strict `gh api repos/codeacme17/echo-ui/pulls/<number>/reviews --method POST` COMMENT-review field shape documented by `REVIEW.md`. Never invoke the `.mjs` router directly, run raw `gh`, use `--input`, call any other mutating API, approve/request changes/merge, or alter global authentication. Return structured findings or `PASS` and the unique review URL. +Review the Echo UI PR diff between `<base-sha>` and `<head-sha>` as an independent, fresh-context reviewer for review cycle `<cycle>`. Use `$code-review`. Read only the supplied issue snapshot, acceptance criteria, repository instructions, diff, CI results, and evidence manifest. Follow `loops/issue-dev-loop/review/REVIEW.md`. Do not edit files or rely on executor conversation history. Publish a body-only non-approving review through `loops/issue-dev-loop/scripts/with-github-identity reviewer -- gh pr review <number> --repo codeacme17/echo-ui --comment --body <review-body>` and include the exact run/cycle/round/head marker. If file/line findings require inline comments, use the same launcher with only the strict `gh api repos/codeacme17/echo-ui/pulls/<number>/reviews --method POST` COMMENT-review field shape documented by `REVIEW.md`. Never invoke the `.mjs` router directly, run raw `gh`, use `--body-file`, use `--input`, call any other mutating API, approve/request changes/merge, or alter global authentication. Return structured findings or `PASS` and the unique review URL. diff --git a/loops/issue-dev-loop/scripts/github-command-gate.mjs b/loops/issue-dev-loop/scripts/github-command-gate.mjs index f5b4fb68..8d5a8410 100755 --- a/loops/issue-dev-loop/scripts/github-command-gate.mjs +++ b/loops/issue-dev-loop/scripts/github-command-gate.mjs @@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url' import { assertDescendantCommandPolicy, assertPushTargetsRepository, + assertSafeRemoteGitConfiguration, hardenedGitArguments, resolveExecutable, } from './lib/github-identity.mjs' @@ -43,13 +44,20 @@ async function main() { }) : args if (tool !== 'credential') { + if (tool === 'git' && args[0] === 'push') { + throw new Error('authenticated descendant processes cannot push') + } assertDescendantCommandPolicy({ role, tool, args, authorization, }) - if (tool === 'git' && ['push', 'fetch', 'ls-remote'].includes(args[0])) { + if (tool === 'git' && ['fetch', 'ls-remote'].includes(args[0])) { + await assertSafeRemoteGitConfiguration({ + realGit: executable, + environment: process.env, + }) await assertPushTargetsRepository({ expectedRepository: authorization.expectedRepository, realGit: executable, diff --git a/loops/issue-dev-loop/scripts/lib/evidence.mjs b/loops/issue-dev-loop/scripts/lib/evidence.mjs index 3db553d5..4e2b33ee 100644 --- a/loops/issue-dev-loop/scripts/lib/evidence.mjs +++ b/loops/issue-dev-loop/scripts/lib/evidence.mjs @@ -64,6 +64,9 @@ function validateReviewEvidence(review, headSha) { if (review.headSha !== headSha || review.verdict !== 'PASS') { throw new Error('review PASS must be bound to the evidence headSha') } + if (!Number.isInteger(review.cycle) || review.cycle < 1) { + throw new Error('review.cycle must be a positive integer') + } const rounds = assertArray(review.rounds, 'review.rounds') if (rounds.length < 1 || rounds.length > 2) { @@ -86,6 +89,9 @@ function validateReviewEvidence(review, headSha) { if (round.round !== roundIndex + 1 || !['PASS', 'CHANGES_REQUESTED'].includes(round.verdict)) { throw new Error('review rounds must be ordered and have a supported verdict') } + if (roundIndex < rounds.length - 1 && round.verdict !== 'CHANGES_REQUESTED') { + throw new Error('only the final review round may PASS') + } const roundReviewUrl = assertHttpUrl(round.reviewUrl, `review.rounds[${roundIndex}].reviewUrl`) if (reviewUrls.has(roundReviewUrl)) throw new Error('each review round requires a unique URL') reviewUrls.add(roundReviewUrl) @@ -99,7 +105,10 @@ function validateReviewEvidence(review, headSha) { for (const finding of findings) { findingCount += 1 const findingId = assertNonEmpty(finding.findingId, 'finding.findingId') - if (!new RegExp(`^RVW-${round.round}-[0-9]+$`).test(findingId) || findingIds.has(findingId)) { + if ( + !new RegExp(`^RVW-${review.cycle}-${round.round}-[0-9]+$`).test(findingId) || + findingIds.has(findingId) + ) { throw new Error(`invalid or duplicate finding ID: ${findingId}`) } findingIds.add(findingId) @@ -138,7 +147,36 @@ function validateReviewEvidence(review, headSha) { } roundDetails.push(round) } - return { findingCount, rounds: rounds.length, roundDetails } + return { cycle: review.cycle, findingCount, rounds: rounds.length, roundDetails } +} + +function reviewCycleMarker(body, runId) { + const matches = [ + ...(body ?? '').matchAll( + new RegExp( + `<!-- issue-dev-loop:${runId}:review-cycle:([1-9][0-9]*):round:([12]):head:([0-9a-f]{40}) -->`, + 'gi', + ), + ), + ] + if (matches.length !== 1) return null + return { + cycle: Number(matches[0][1]), + round: Number(matches[0][2]), + headSha: matches[0][3].toLowerCase(), + } +} + +async function paginateGitHubApi(githubApi, endpoint) { + const separator = endpoint.includes('?') ? '&' : '?' + const items = [] + for (let page = 1; page <= 100; page += 1) { + const batch = await githubApi(`${endpoint}${separator}per_page=100&page=${page}`) + if (!Array.isArray(batch)) throw new Error('GitHub paginated review response must be an array') + items.push(...batch) + if (batch.length < 100) return items + } + throw new Error('GitHub review pagination exceeded the safety limit') } export async function recordEvidence({ @@ -400,6 +438,48 @@ export async function recordReview({ ) { throw new Error('reviewerGitHubLogin must be independent from executor and owner identities') } + const runEvents = await readEvents(loopRoot, normalizedRunId) + const expectedCycle = + runEvents.filter( + (event) => event.type === 'review_completed' && event.status === 'passed', + ).length + 1 + if (reviewSummary.cycle !== expectedCycle) { + throw new Error(`review cycle must be the next durable cycle: ${expectedCycle}`) + } + const publishedReviews = await paginateGitHubApi( + githubApi, + `repos/${reviewTarget.owner}/${reviewTarget.repo}/pulls/${reviewTarget.number}/reviews`, + ) + const cycleReviews = publishedReviews + .filter( + (review) => + review.state === 'COMMENTED' && + sameGitHubLogin(review.user?.login, reviewerLogin) && + reviewCycleMarker(review.body, normalizedRunId)?.cycle === reviewSummary.cycle, + ) + .map((review) => ({ + id: String(review.id), + marker: reviewCycleMarker(review.body, normalizedRunId), + })) + const declaredRounds = new Map( + reviewSummary.roundDetails.map((round) => [ + String(parseReviewUrl(round.reviewUrl)?.reviewId), + round, + ]), + ) + if ( + cycleReviews.length !== declaredRounds.size || + cycleReviews.some(({ id, marker }) => { + const declared = declaredRounds.get(id) + return ( + !declared || + marker.round !== declared.round || + marker.headSha !== declared.headSha.toLowerCase() + ) + }) + ) { + throw new Error('review result must include every reviewer publication in this run cycle') + } const digestMarker = `<!-- issue-dev-loop:${normalizedRunId}:review-result-sha256:${resultDigest} -->` const publications = new Map() const priorFindingIds = new Set() @@ -422,7 +502,7 @@ export async function recordReview({ publishedRound.body ?? '', ...roundComments.map((comment) => comment.body ?? ''), ] - const roundMarker = `<!-- issue-dev-loop:${normalizedRunId}:review-round:${round.round}:head:${round.headSha} -->` + const roundMarker = `<!-- issue-dev-loop:${normalizedRunId}:review-cycle:${reviewSummary.cycle}:round:${round.round}:head:${round.headSha} -->` const submittedAt = Date.parse(publishedRound.submitted_at) if ( publishedRound.commit_id !== round.headSha || @@ -437,7 +517,7 @@ export async function recordReview({ } const expectedFindingIds = new Set(round.findings.map((finding) => finding.findingId)) const publishedFindingIds = new Set( - bodies.flatMap((body) => body.match(/\bRVW-[0-9]+-[0-9]+\b/g) ?? []), + bodies.flatMap((body) => body.match(/\bRVW-[0-9]+-[0-9]+-[0-9]+\b/g) ?? []), ) if ( [...expectedFindingIds].some((findingId) => !publishedFindingIds.has(findingId)) || @@ -450,7 +530,9 @@ export async function recordReview({ throw new Error(`published GitHub review round ${round.round} has unrecorded findings`) } for (const comment of roundComments) { - const commentFindingIds = new Set(comment.body?.match(/\bRVW-[0-9]+-[0-9]+\b/g) ?? []) + const commentFindingIds = new Set( + comment.body?.match(/\bRVW-[0-9]+-[0-9]+-[0-9]+\b/g) ?? [], + ) if ( !sameGitHubLogin(comment.user?.login, reviewerLogin) || commentFindingIds.size === 0 || @@ -510,7 +592,6 @@ export async function recordReview({ ) { throw new Error('published review is not bound to the recorded live PR head') } - const runEvents = await readEvents(loopRoot, normalizedRunId) for (const round of reviewSummary.roundDetails) { const publication = publications.get(round.round) const reviewSubmittedAt = Date.parse(publication.submittedAt) @@ -627,6 +708,7 @@ export async function recordReview({ reviewUrl: publishedReviewUrl, resultPath: relativeResultPath, resultDigest, + reviewCycle: reviewSummary.cycle, findingCount: reviewSummary.findingCount, reviewRounds: reviewSummary.rounds, unresolvedHighSeverityFindings: 0, @@ -637,6 +719,7 @@ export async function recordReview({ headSha, reviewUrl: publishedReviewUrl, resultDigest, + cycle: reviewSummary.cycle, findingCount: reviewSummary.findingCount, rounds: reviewSummary.rounds, } diff --git a/loops/issue-dev-loop/scripts/lib/evolve.mjs b/loops/issue-dev-loop/scripts/lib/evolve.mjs index 4bbfbf1c..33776f85 100644 --- a/loops/issue-dev-loop/scripts/lib/evolve.mjs +++ b/loops/issue-dev-loop/scripts/lib/evolve.mjs @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto' import { readFile } from 'node:fs/promises' import path from 'node:path' @@ -6,11 +7,149 @@ import { assertHttpUrl, assertNonEmpty, defaultGitHubApi, + parsePullCommentUrl, readJson, + sameGitHubLogin, + sameRepository, writeJson, } from './common.mjs' import { observeOwnerApprovedMerge } from './owner-gate.mjs' +function canonicalPendingRequest(request) { + const normalized = { + schemaVersion: request.schemaVersion, + requestId: request.requestId, + status: request.status, + reason: request.reason, + requestedAt: request.requestedAt, + finalizedRunCount: request.finalizedRunCount, + } + if ( + normalized.schemaVersion !== 1 || + normalized.status !== 'pending' || + !/^[A-Z0-9-]+$/.test(normalized.requestId ?? '') || + !assertNonEmpty(normalized.reason, 'evolve.reason') || + Number.isNaN(Date.parse(normalized.requestedAt)) || + !Number.isInteger(normalized.finalizedRunCount) || + normalized.finalizedRunCount < 1 + ) { + throw new Error('invalid pending evolve request') + } + return JSON.stringify(normalized) +} + +function pendingRequestDigest(request) { + return createHash('sha256').update(canonicalPendingRequest(request)).digest('hex') +} + +export async function prepareEvolveRequestPublication({ + loopRoot = DEFAULT_LOOP_ROOT, + requestId, +} = {}) { + const normalizedRequestId = assertNonEmpty(requestId, 'requestId') + const metrics = await readJson(path.join(loopRoot, 'evolve', 'metrics.json')) + if (!metrics.evolveDue || metrics.pendingRequestId !== normalizedRequestId) { + throw new Error(`not the pending evolve request: ${normalizedRequestId}`) + } + const request = await readJson( + path.join(loopRoot, 'evolve', 'requests', `${normalizedRequestId}.json`), + ) + const serialized = canonicalPendingRequest(request) + const digest = pendingRequestDigest(request) + const channel = await readJson( + path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'), + ) + return { + request, + digest, + body: [ + `<!-- issue-dev-loop:evolve-request:${normalizedRequestId}:sha256:${digest} -->`, + '```json', + serialized, + '```', + ].join('\n'), + journalIssueUrl: `https://github.com/${channel.repository}/issues/${channel.stateIssueNumber}`, + } +} + +export async function verifyPublishedEvolveRequest({ + loopRoot = DEFAULT_LOOP_ROOT, + requestId, + githubApi = defaultGitHubApi, +} = {}) { + const normalizedRequestId = assertNonEmpty(requestId, 'requestId') + const requestPath = path.join( + loopRoot, + 'evolve', + 'requests', + `${normalizedRequestId}.json`, + ) + const request = await readJson(requestPath) + const publicationUrl = assertHttpUrl(request.publicationUrl, 'evolve.publicationUrl') + const target = parsePullCommentUrl(publicationUrl) + const channel = await readJson( + path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'), + ) + const repositoryTarget = { + owner: channel.repository.split('/')[0], + repo: channel.repository.split('/')[1], + } + if ( + !target || + target.kind !== 'issue_comment' || + target.number !== channel.stateIssueNumber || + !sameRepository(target, repositoryTarget) + ) { + throw new Error('evolve request publication must be on the configured state journal') + } + const comment = await githubApi( + `repos/${target.owner}/${target.repo}/issues/comments/${target.commentId}`, + ) + const digest = pendingRequestDigest(request) + const marker = `<!-- issue-dev-loop:evolve-request:${normalizedRequestId}:sha256:${digest} -->` + if ( + !sameGitHubLogin(comment.user?.login, channel.automationGitHubLogin) || + !comment.body?.includes(marker) || + !comment.body?.includes(canonicalPendingRequest(request)) || + request.publicationDigest !== digest + ) { + throw new Error('evolve request lacks an exact automation-authored durable publication') + } + return { request, digest, publicationUrl } +} + +export async function recordEvolveRequestPublication({ + loopRoot = DEFAULT_LOOP_ROOT, + requestId, + commentUrl, + githubApi = defaultGitHubApi, +} = {}) { + const normalizedRequestId = assertNonEmpty(requestId, 'requestId') + const requestPath = path.join( + loopRoot, + 'evolve', + 'requests', + `${normalizedRequestId}.json`, + ) + const request = await readJson(requestPath) + const digest = pendingRequestDigest(request) + await writeJson(requestPath, { + ...request, + publicationUrl: assertHttpUrl(commentUrl, 'commentUrl'), + publicationDigest: digest, + }) + try { + return await verifyPublishedEvolveRequest({ + loopRoot, + requestId: normalizedRequestId, + githubApi, + }) + } catch (error) { + await writeJson(requestPath, request) + throw error + } +} + export async function updateEvolveMetrics({ loopRoot, now }) { const metricsPath = path.join(loopRoot, 'evolve', 'metrics.json') const metrics = await readJson(metricsPath) diff --git a/loops/issue-dev-loop/scripts/lib/github-identity.mjs b/loops/issue-dev-loop/scripts/lib/github-identity.mjs index 34af9403..40e40046 100644 --- a/loops/issue-dev-loop/scripts/lib/github-identity.mjs +++ b/loops/issue-dev-loop/scripts/lib/github-identity.mjs @@ -6,8 +6,15 @@ import path from 'node:path' import { promisify } from 'node:util' import { fileURLToPath } from 'node:url' -import { assertNonEmpty, parseGitHubTarget, readJson, sameGitHubLogin } from './common.mjs' +import { + assertIssueNumber, + assertNonEmpty, + parseGitHubTarget, + readJson, + sameGitHubLogin, +} from './common.mjs' import { verifyLatestDurableCheckpoint } from './checkpoint-proof.mjs' +import { verifyPublishedEvolveRequest } from './evolve.mjs' import { readEvents } from './run-store.mjs' const execFileAsync = promisify(execFile) @@ -30,25 +37,17 @@ const inheritedEnvironmentNames = new Set([ 'COLORTERM', 'FORCE_COLOR', 'HOME', - 'HTTPS_PROXY', - 'HTTP_PROXY', 'LANG', 'LOGNAME', 'NO_COLOR', - 'NO_PROXY', 'PATH', 'SHELL', - 'SSL_CERT_DIR', - 'SSL_CERT_FILE', 'TEMP', 'TERM', 'TMP', 'TMPDIR', 'USER', 'XDG_RUNTIME_DIR', - 'https_proxy', - 'http_proxy', - 'no_proxy', ]) function safeBaseEnvironment(channel, environment) { @@ -96,7 +95,7 @@ export function resolveGitHubRoleEnvironment({ channel, role, environment = proc GH_PROMPT_DISABLED: '1', GIT_CONFIG_GLOBAL: devNull, GIT_CONFIG_NOSYSTEM: '1', - GIT_CONFIG_COUNT: '7', + GIT_CONFIG_COUNT: '15', GIT_CONFIG_KEY_0: 'credential.helper', GIT_CONFIG_VALUE_0: '', GIT_CONFIG_KEY_1: 'credential.helper', @@ -113,6 +112,22 @@ export function resolveGitHubRoleEnvironment({ channel, role, environment = proc GIT_CONFIG_VALUE_5: repositoryUrl, GIT_CONFIG_KEY_6: `url.${repositoryUrl}.pushInsteadOf`, GIT_CONFIG_VALUE_6: repositoryUrl, + GIT_CONFIG_KEY_7: 'http.proxy', + GIT_CONFIG_VALUE_7: '', + GIT_CONFIG_KEY_8: 'http.extraHeader', + GIT_CONFIG_VALUE_8: '', + GIT_CONFIG_KEY_9: 'http.cookieFile', + GIT_CONFIG_VALUE_9: devNull, + GIT_CONFIG_KEY_10: 'http.saveCookies', + GIT_CONFIG_VALUE_10: 'false', + GIT_CONFIG_KEY_11: 'http.sslVerify', + GIT_CONFIG_VALUE_11: 'true', + GIT_CONFIG_KEY_12: 'http.curloptResolve', + GIT_CONFIG_VALUE_12: '', + GIT_CONFIG_KEY_13: 'remote.origin.proxy', + GIT_CONFIG_VALUE_13: '', + GIT_CONFIG_KEY_14: 'http.followRedirects', + GIT_CONFIG_VALUE_14: 'initial', GIT_PAGER: 'cat', GIT_TERMINAL_PROMPT: '0', PAGER: 'cat', @@ -499,17 +514,47 @@ const repositoryValueOptions = { '-R': 'repository', } -function reviewerCommentReviewAllowed(args, commandIndex, authorization, expectedRepository) { +function parseReviewPublication(body, authorization, { requireCurrentHead = true } = {}) { + if (typeof body !== 'string') return null + const matches = [ + ...body.matchAll( + /<!-- issue-dev-loop:([^:]+):review-cycle:([1-9][0-9]*):round:([12]):head:([0-9a-f]{40}) -->/gi, + ), + ] + if ( + matches.length !== 1 || + matches[0][1] !== authorization?.issue?.runId || + (requireCurrentHead && + matches[0][4].toLowerCase() !== authorization?.issue?.headSha?.toLowerCase()) + ) { + return null + } + return { + cycle: Number(matches[0][2]), + round: Number(matches[0][3]), + headSha: matches[0][4].toLowerCase(), + } +} + +function reviewerCommentReview(args, commandIndex, authorization) { const parsed = parseOptions(args, commandIndex + 1, { valueOptions: { ...repositoryValueOptions, '--body': 'body', '-b': 'body', - '--body-file': 'bodyFile', - '-F': 'bodyFile', }, booleanOptions: { '--comment': 'comment', '-c': 'comment' }, }) + const body = exactlyOne(parsed.values, 'body') + return { parsed, body, publication: parseReviewPublication(body, authorization) } +} + +function reviewerCommentReviewAllowed(args, commandIndex, authorization, expectedRepository) { + const { parsed, body, publication } = reviewerCommentReview( + args, + commandIndex, + authorization, + ) return ( parsed.valid && parsed.booleans.get('comment') === true && @@ -520,9 +565,8 @@ function reviewerCommentReviewAllowed(args, commandIndex, authorization, expecte expectedRepository, exactlyOne(parsed.values, 'repository'), ) && - Number(Boolean(exactlyOne(parsed.values, 'body'))) + - Number(Boolean(exactlyOne(parsed.values, 'bodyFile'))) === - 1 + Boolean(body) && + Boolean(publication) ) } @@ -676,16 +720,14 @@ function reviewerInlineReviewAllowed(request, authorization, expectedRepository) const body = values('body') const commitIds = values('commit_id') const events = values('event') + const publication = parseReviewPublication(body[0], authorization) if ( body.length !== 1 || commitIds.length !== 1 || events.length !== 1 || commitIds[0] !== authorization.issue.headSha || events[0] !== 'COMMENT' || - !body[0].includes( - `<!-- issue-dev-loop:${authorization.issue.runId}:review-round:`, - ) || - !body[0].includes(`:head:${authorization.issue.headSha} -->`) + !publication ) { return false } @@ -720,7 +762,7 @@ function reviewerInlineReviewAllowed(request, authorization, expectedRepository) /^[1-9][0-9]*$/.test(lines[index]) && ['LEFT', 'RIGHT'].includes(sides[index]) && comments[index].includes( - `<!-- issue-dev-loop:${authorization.issue.runId}:RVW-`, + `<!-- issue-dev-loop:${authorization.issue.runId}:RVW-${publication.cycle}-${publication.round}-`, ), ) } @@ -977,6 +1019,33 @@ export async function assertPushTargetsRepository({ expectedRepository, realGit, } } +export async function assertSafeRemoteGitConfiguration({ realGit, environment }) { + let stdout = '' + try { + const result = await execFileAsync( + realGit, + [ + 'config', + '--local', + '--get-regexp', + '^(http\\..*(proxy|extraheader|cookiefile|savecookies|curloptresolve|sslverify|followredirects)|remote\\..*\\.(proxy|proxyauthmethod|uploadpack|receivepack)|url\\..*\\.(insteadof|pushinsteadof))$', + ], + { + env: environment, + maxBuffer: 1024 * 1024, + }, + ) + stdout = result.stdout + } catch (error) { + if (error?.code !== 1) throw error + } + if (stdout.trim()) { + throw new Error( + 'authenticated remote Git rejects repository-local HTTP, proxy, helper, and URL rewrite configuration', + ) + } +} + async function readOptionalJson(filePath) { try { return await readJson(filePath) @@ -1003,18 +1072,30 @@ async function readAuthorizationContext(loopRoot, channel) { run.finishedAt === null && ['running', 'waiting_for_owner', 'awaiting_owner_review'].includes(run.status) ) { - active.push(run) + active.push({ directoryName: entry.name, run }) } } if (active.length > 1) { throw new Error('multiple active runs cannot authorize GitHub mutations') } - const run = active[0] ?? null + const activeEntry = active[0] ?? null + const run = activeEntry?.run ?? null + if (run && (run.runId !== activeEntry.directoryName || run.runId !== path.basename(run.runId))) { + throw new Error('active run ID cannot authorize GitHub mutations') + } + const issueNumber = run ? assertIssueNumber(run.issueNumber) : null + const expectedIssueBranch = issueNumber === null ? null : `codex/issue-${issueNumber}` + if ( + run && + run.branch !== expectedIssueBranch + ) { + throw new Error('active run branch must be derived from its durable issue number') + } const pullTarget = run?.prUrl ? parseGitHubTarget(run.prUrl) : null const issue = run ? { - branch: assertNonEmpty(run.branch, 'run.branch'), - issueNumber: run.issueNumber, + branch: expectedIssueBranch, + issueNumber, prNumber: pullTarget?.kind === 'pull' ? pullTarget.number : null, runId: assertNonEmpty(run.runId, 'run.runId'), status: run.status, @@ -1101,18 +1182,27 @@ function activationValidationRequested({ role, tool, args, loopRoot }) { ) } -function pullRequestWriteIntent(role, args) { +function pullRequestWriteIntent(role, args, authorization) { const group = githubGroup(args) if (role === 'reviewer' && group.name === 'api') { const request = githubApiRequest(args.slice(group.index + 1)) if (request.mutating && /\/pulls\/\d+\/reviews$/.test(request.endpoint ?? '')) { - return { kind: 'inline-review', commandIndex: -1 } + const body = request.fields.map(apiField).find((field) => field.name === 'body')?.value + return { + kind: 'inline-review', + commandIndex: -1, + publication: parseReviewPublication(body, authorization), + } } } if (group.name !== 'pr') return null const command = commandAfterGroup(args, group.index) if (role === 'reviewer' && command.name === 'review') { - return { kind: 'review', commandIndex: command.index } + return { + kind: 'review', + commandIndex: command.index, + publication: reviewerCommentReview(args, command.index, authorization).publication, + } } if (role === 'automation' && ['create', 'edit', 'comment', 'ready'].includes(command.name)) { return { kind: command.name, commandIndex: command.index } @@ -1162,7 +1252,7 @@ async function preflightPullRequestWrite({ realGh, environment, }) { - const intent = pullRequestWriteIntent(role, args) + const intent = pullRequestWriteIntent(role, args, authorization) if (!intent || authorization.evolve) return const runId = authorization.issue?.runId if (!runId) throw new Error('pull request write requires an active durable run') @@ -1174,6 +1264,19 @@ async function preflightPullRequestWrite({ }) return JSON.parse(stdout) } + const githubPaginatedApi = async (endpoint) => { + const separator = endpoint.includes('?') ? '&' : '?' + const items = [] + for (let page = 1; page <= 100; page += 1) { + const batch = await githubApi(`${endpoint}${separator}per_page=100&page=${page}`) + if (!Array.isArray(batch)) { + throw new Error('GitHub paginated response must be an array') + } + items.push(...batch) + if (batch.length < 100) return items + } + throw new Error('GitHub pagination exceeded the safety limit') + } const durable = await verifyLatestDurableCheckpoint({ loopRoot, runId, @@ -1220,9 +1323,38 @@ async function preflightPullRequestWrite({ } if (['review', 'inline-review'].includes(intent.kind)) { - if (livePullRequest.draft !== true) { + const expectedCycle = + events.filter( + (event) => event.type === 'review_completed' && event.status === 'passed', + ).length + 1 + if ( + livePullRequest.draft !== true || + !intent.publication || + intent.publication.cycle !== expectedCycle + ) { throw new Error('independent review publication requires the recorded Draft PR') } + const publishedReviews = await githubPaginatedApi( + `repos/${owner}/${repo}/pulls/${authorization.issue.prNumber}/reviews`, + ) + const existingRounds = publishedReviews + .filter( + (review) => + review.state === 'COMMENTED' && + sameGitHubLogin(review.user?.login, channel.reviewerGitHubLogin), + ) + .map((review) => + parseReviewPublication(review.body, authorization, { requireCurrentHead: false }), + ) + .filter((publication) => publication?.cycle === expectedCycle) + .map((publication) => publication.round) + .sort((left, right) => left - right) + if ( + existingRounds.length !== intent.publication.round - 1 || + existingRounds.some((round, index) => round !== index + 1) + ) { + throw new Error('independent review publication must be the next unique cycle round') + } return } if (intent.kind === 'ready') { @@ -1258,6 +1390,85 @@ async function preflightPullRequestWrite({ } } +async function preflightIssueBranchPush({ + args, + authorization, + loopRoot, + realGh, + environment, +}) { + if (gitSubcommand(args).name !== 'push' || !authorization.issue?.runId) return + const runId = authorization.issue.runId + const events = await readEvents(loopRoot, runId) + const githubApi = async (endpoint) => { + const { stdout } = await execFileAsync(realGh, ['api', endpoint], { + env: environment, + maxBuffer: 1024 * 1024, + }) + return JSON.parse(stdout) + } + const durable = await verifyLatestDurableCheckpoint({ + loopRoot, + runId, + events, + operation: 'Git push', + githubApi, + }) + const durableRun = durable.record.run + if ( + durableRun.runId !== runId || + durableRun.issueNumber !== authorization.issue.issueNumber || + durableRun.branch !== `codex/issue-${authorization.issue.issueNumber}` || + durableRun.branch !== authorization.issue.branch + ) { + throw new Error('Git push requires the exact durable issue branch authorization') + } + const latestOwnerResponse = events.findLast( + (event) => event.type === 'owner_response_observed' && event.status === 'observed', + ) + if ( + latestOwnerResponse && + !events.some( + (event) => + event.type === 'pr_published' && + event.status === 'draft' && + event.payload?.prUrl === durableRun.prUrl && + event.payload?.headSha === durableRun.headSha && + Date.parse(event.timestamp) >= Date.parse(latestOwnerResponse.timestamp), + ) + ) { + throw new Error('owner-feedback Git push requires the unchanged PR to be redrafted first') + } +} + +async function preflightEvolveMutation({ + role, + tool, + args, + authorization, + loopRoot, + realGh, + environment, +}) { + if (role !== 'automation' || !authorization.evolve?.requestId) return + const gitPush = tool === 'git' && gitSubcommand(args).name === 'push' + const group = tool === 'gh' ? githubGroup(args) : { name: null, index: -1 } + const command = group.name === 'pr' ? commandAfterGroup(args, group.index) : { name: null } + if (!gitPush && command.name !== 'create') return + const githubApi = async (endpoint) => { + const { stdout } = await execFileAsync(realGh, ['api', endpoint], { + env: environment, + maxBuffer: 1024 * 1024, + }) + return JSON.parse(stdout) + } + await verifyPublishedEvolveRequest({ + loopRoot, + requestId: authorization.evolve.requestId, + githubApi, + }) +} + export async function assertGitHubRoleIdentity({ channel, role, @@ -1324,6 +1535,15 @@ export async function runWithGitHubRole({ execFileAsync(realGh, identityArgs, options), }) } + await preflightEvolveMutation({ + role, + tool, + args, + authorization, + loopRoot, + realGh, + environment: resolved.routedEnvironment, + }) if (tool === 'gh') { await preflightPullRequestWrite({ role, @@ -1339,6 +1559,17 @@ export async function runWithGitHubRole({ tool === 'git' && ['push', 'fetch', 'ls-remote'].includes(gitSubcommand(args).name) ) { + await preflightIssueBranchPush({ + args, + authorization, + loopRoot, + realGh, + environment: resolved.routedEnvironment, + }) + await assertSafeRemoteGitConfiguration({ + realGit, + environment: resolved.routedEnvironment, + }) await assertPushTargetsRepository({ expectedRepository: channel.repository, realGit, diff --git a/loops/issue-dev-loop/scripts/lib/run-store.mjs b/loops/issue-dev-loop/scripts/lib/run-store.mjs index f47c0dd4..a35d9bda 100644 --- a/loops/issue-dev-loop/scripts/lib/run-store.mjs +++ b/loops/issue-dev-loop/scripts/lib/run-store.mjs @@ -496,6 +496,24 @@ export async function recordImplementation({ ) { throw new Error('$implement invocation IDs and result paths must be unique within a run') } + const latestOwnerResponse = events.findLast( + (event) => event.type === 'owner_response_observed' && event.status === 'observed', + ) + if (latestOwnerResponse) { + const durableRedraft = events.findLast( + (event) => + event.type === 'pr_published' && + event.status === 'draft' && + event.payload?.prUrl === run.prUrl && + event.payload?.headSha === run.headSha && + Date.parse(event.timestamp) >= Date.parse(latestOwnerResponse.timestamp), + ) + if (!durableRedraft || startedAt < Date.parse(durableRedraft.timestamp)) { + throw new Error( + 'owner-feedback $implement work requires the unchanged PR to be durably redrafted first', + ) + } + } await commitRangeValidator({ loopRoot, ancestor: previousCommit, @@ -568,7 +586,7 @@ export async function recordPullRequest({ if ( livePullRequest.state !== 'open' || !sameGitHubLogin(livePullRequest.user?.login, channel.automationGitHubLogin) || - (isInitialBinding && livePullRequest.draft !== true) || + livePullRequest.draft !== true || livePullRequest.base?.ref !== 'dev' || livePullRequest.head?.ref !== run.branch || livePullRequest.head?.repo?.full_name?.toLowerCase() !== @@ -576,7 +594,7 @@ export async function recordPullRequest({ livePullRequest.head?.sha !== headSha ) { throw new Error( - 'record-pr requires a live PR to dev at the exact run branch and headSha; first binding must be draft', + 'record-pr requires a live Draft PR to dev at the exact run branch and headSha', ) } if (run.prUrl !== null && run.prUrl !== prUrl) { diff --git a/loops/issue-dev-loop/scripts/loopctl.mjs b/loops/issue-dev-loop/scripts/loopctl.mjs index 31683977..ee0766fa 100644 --- a/loops/issue-dev-loop/scripts/loopctl.mjs +++ b/loops/issue-dev-loop/scripts/loopctl.mjs @@ -11,10 +11,12 @@ import { observeOwnerMerge, parseArguments, prepareActiveCheckpoint, + prepareEvolveRequestPublication, prepareFinalizationRecord, reconcileLoopJournal, recordActiveCheckpointPublication, recordEvidence, + recordEvolveRequestPublication, recordFinalizationPublication, recordImplementation, recordOwnerResponse, @@ -210,6 +212,21 @@ async function main() { case 'evolve-status': output(await getEvolveStatus()) break + case 'prepare-evolve-request': + output( + await prepareEvolveRequestPublication({ + requestId: args['request-id'], + }), + ) + break + case 'record-evolve-request': + output( + await recordEvolveRequestPublication({ + requestId: args['request-id'], + commentUrl: args['comment-url'], + }), + ) + break case 'evolve-complete': output( await completeEvolve({ @@ -221,7 +238,7 @@ async function main() { break default: throw new Error( - 'usage: loopctl.mjs <start|freeze-brief|record-implementation|event|record-pr|record-owner-response|record-evidence|record-review|prepare-checkpoint|record-checkpoint|prepare-finalization|record-finalization|reconcile|restore-checkpoint|transition|finalize|observe-owner-merge|notify|detect-work|validate|evolve-status|evolve-complete> [options]', + 'usage: loopctl.mjs <start|freeze-brief|record-implementation|event|record-pr|record-owner-response|record-evidence|record-review|prepare-checkpoint|record-checkpoint|prepare-finalization|record-finalization|reconcile|restore-checkpoint|transition|finalize|observe-owner-merge|notify|detect-work|validate|evolve-status|prepare-evolve-request|record-evolve-request|evolve-complete> [options]', ) } } diff --git a/loops/issue-dev-loop/scripts/runtime.mjs b/loops/issue-dev-loop/scripts/runtime.mjs index c9297da1..0b6365ad 100644 --- a/loops/issue-dev-loop/scripts/runtime.mjs +++ b/loops/issue-dev-loop/scripts/runtime.mjs @@ -1,5 +1,11 @@ export { assertAutomationIdentity, DEFAULT_LOOP_ROOT, parseArguments } from './lib/common.mjs' -export { completeEvolve, getEvolveStatus } from './lib/evolve.mjs' +export { + completeEvolve, + getEvolveStatus, + prepareEvolveRequestPublication, + recordEvolveRequestPublication, + verifyPublishedEvolveRequest, +} from './lib/evolve.mjs' export { recordEvidence, recordReview } from './lib/evidence.mjs' export { canonicalCheckpoint, diff --git a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs index 92908685..bc43e283 100644 --- a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs +++ b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs @@ -8,6 +8,10 @@ import { promisify } from 'node:util' import { fileURLToPath } from 'node:url' import { checkpointPublicationBody } from '../scripts/lib/checkpoint-proof.mjs' +import { + prepareEvolveRequestPublication, + recordEvolveRequestPublication, +} from '../scripts/lib/evolve.mjs' import { resolveExecutable } from '../scripts/lib/github-identity.mjs' const execFileAsync = promisify(execFile) @@ -219,6 +223,7 @@ async function createFixture({ 'utf8', ) } + await writeFile(path.join(parent, 'reviews.json'), '[]\n', 'utf8') await writeFile( path.join(loopRoot, 'evolve', 'metrics.json'), `${JSON.stringify({ @@ -280,6 +285,14 @@ if (process.argv[2] === 'spawn') { process.env.GIT_SSH_COMMAND || process.env.GH_BROWSER || process.env.BROWSER + ), + hasProxyEnvironment: Boolean( + process.env.HTTP_PROXY || + process.env.HTTPS_PROXY || + process.env.NO_PROXY || + process.env.http_proxy || + process.env.https_proxy || + process.env.no_proxy ) })) } @@ -306,6 +319,14 @@ if [ "$1 $2 $3 $4" != "api user --jq .login" ]; then sed -n '1p' "$parent_dir/live-pr.json" exit 0 fi + if [ "$1" = "api" ] && [ "$2" = "repos/example/repo/pulls/106/reviews?per_page=100&page=1" ]; then + sed -n '1p' "$parent_dir/reviews.json" + exit 0 + fi + if [ "$1" = "api" ] && [ "$2" = "repos/example/repo/issues/comments/2" ]; then + sed -n '1p' "$parent_dir/evolve-comment.json" + exit 0 + fi if [ "$1 $2" = "pr review" ]; then echo "comment review published" exit 0 @@ -333,6 +354,9 @@ if [ "$1 $2 $3 $4" = "remote get-url --push origin" ]; then echo "https://github.com/example/repo.git" exit 0 fi +if [ "$1 $2 $3" = "config --local --get-regexp" ]; then + exit 1 +fi node -e 'process.stdout.write(JSON.stringify({args: process.argv.slice(1), config: process.env.GH_CONFIG_DIR, hasGhToken: Boolean(process.env.GH_TOKEN || process.env.GITHUB_TOKEN), gitConfig: Array.from({length: Number(process.env.GIT_CONFIG_COUNT)}, (_, index) => [process.env[\`GIT_CONFIG_KEY_\${index}\`], process.env[\`GIT_CONFIG_VALUE_\${index}\`]]).flat()}))' -- "$@" `, 'utf8', @@ -378,11 +402,12 @@ test('automation role selects its dedicated gh profile without leaking token ove assert.deepEqual(JSON.parse(stdout), { config: fixture.automationProfile, hasGhToken: false, - gitConfig: ['7', 'credential.helper', '', 'credential.helper', credentialHelper], + gitConfig: ['15', 'credential.helper', '', 'credential.helper', credentialHelper], gitIsolation: [os.devNull, '1'], exposesOtherProfiles: false, exposesRealTools: false, hasExecutionHooks: false, + hasProxyEnvironment: false, }) }) @@ -497,6 +522,22 @@ test('automation git command clears global helpers and injects the selected gh c 'https://github.com/example/repo.git', 'url.https://github.com/example/repo.git.pushInsteadOf', 'https://github.com/example/repo.git', + 'http.proxy', + '', + 'http.extraHeader', + '', + 'http.cookieFile', + os.devNull, + 'http.saveCookies', + 'false', + 'http.sslVerify', + 'true', + 'http.curloptResolve', + '', + 'remote.origin.proxy', + '', + 'http.followRedirects', + 'initial', ]) }) @@ -657,11 +698,75 @@ test('reviewer role may publish only a non-approving comment review', async () = 'example/repo', '--comment', '--body', - 'PASS', + `PASS\n<!-- issue-dev-loop:fixture-run:review-cycle:1:round:1:head:${'b'.repeat(40)} -->`, ], { env: fixture.env }, ) assert.equal(stdout.trim(), 'comment review published') + + await assert.rejects( + execFileAsync( + process.execPath, + [ + routerPath, + '--loop-root', + fixture.loopRoot, + 'reviewer', + '--', + 'gh', + 'pr', + 'review', + '106', + '--repo', + 'example/repo', + '--comment', + '--body-file', + '/tmp/uninspected-review.md', + ], + { env: fixture.env }, + ), + /GitHub action is prohibited for the reviewer role/, + ) +}) + +test('review publication rejects duplicate or skipped cycle rounds', async () => { + const fixture = await createFixture() + await writeFile( + path.join(path.dirname(fixture.loopRoot), 'reviews.json'), + `${JSON.stringify([ + { + id: 400, + state: 'COMMENTED', + user: { login: 'reviewer-user' }, + body: `<!-- issue-dev-loop:fixture-run:review-cycle:1:round:1:head:${'a'.repeat(40)} -->`, + }, + ])}\n`, + 'utf8', + ) + const publish = (round) => + execFileAsync( + process.execPath, + [ + routerPath, + '--loop-root', + fixture.loopRoot, + 'reviewer', + '--', + 'gh', + 'pr', + 'review', + '106', + '--repo', + 'example/repo', + '--comment', + '--body', + `PASS\n<!-- issue-dev-loop:fixture-run:review-cycle:1:round:${round}:head:${'b'.repeat(40)} -->`, + ], + { env: fixture.env }, + ) + await assert.rejects(publish(1), /next unique cycle round/) + const { stdout } = await publish(2) + assert.equal(stdout.trim(), 'comment review published') }) test('reviewer may publish exact-head inline comments only as a COMMENT review API request', async () => { @@ -684,7 +789,7 @@ test('reviewer may publish exact-head inline comments only as a COMMENT review A '-f', 'event=COMMENT', '-f', - `body=<!-- issue-dev-loop:fixture-run:review-round:1:head:${'b'.repeat(40)} -->`, + `body=<!-- issue-dev-loop:fixture-run:review-cycle:1:round:1:head:${'b'.repeat(40)} -->`, '-F', 'comments[][path]=src/fixture.ts', '-F', @@ -692,7 +797,7 @@ test('reviewer may publish exact-head inline comments only as a COMMENT review A '-F', 'comments[][side]=RIGHT', '-f', - 'comments[][body]=<!-- issue-dev-loop:fixture-run:RVW-1-1 --> Finding', + 'comments[][body]=<!-- issue-dev-loop:fixture-run:RVW-1-1-1 --> Finding', ], { env: fixture.env }, ) @@ -717,7 +822,7 @@ test('reviewer may publish exact-head inline comments only as a COMMENT review A '-f', 'event=COMMENT', '-f', - `body=<!-- issue-dev-loop:fixture-run:review-round:1:head:${'b'.repeat(40)} -->`, + `body=<!-- issue-dev-loop:fixture-run:review-cycle:1:round:1:head:${'b'.repeat(40)} -->`, '-F', 'comments[][path]=src/fixture.ts', '-F', @@ -725,7 +830,7 @@ test('reviewer may publish exact-head inline comments only as a COMMENT review A '-F', 'comments[][side]=RIGHT', '-f', - 'comments[][body]=<!-- issue-dev-loop:fixture-run:RVW-1-1 --> Finding', + 'comments[][body]=<!-- issue-dev-loop:fixture-run:RVW-1-1-1 --> Finding', ], ]) { await assert.rejects( @@ -940,6 +1045,28 @@ test('owner feedback durably authorizes returning only the exact recorded PR to assert.deepEqual(JSON.parse(stdout).args.slice(0, 2), ['pr', 'ready']) }) +test('owner feedback blocks every new push until the unchanged PR is durably redrafted', async () => { + const fixture = await createFixture({ liveDraft: false, ownerFeedback: true }) + await assert.rejects( + execFileAsync( + process.execPath, + [ + routerPath, + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + 'git', + 'push', + 'origin', + 'codex/issue-123', + ], + { env: fixture.env }, + ), + /redrafted first/, + ) +}) + test('PR writes reject forged local authorization and live PR drift', async () => { const forged = await createFixture() const runPath = path.join(forged.loopRoot, 'logs', 'runs', 'fixture-run', 'run.json') @@ -962,7 +1089,7 @@ test('PR writes reject forged local authorization and live PR drift', async () = 'example/repo', '--comment', '--body', - 'Forged', + `Forged\n<!-- issue-dev-loop:fixture-run:review-cycle:1:round:1:head:${'d'.repeat(40)} -->`, ], { env: forged.env }, ), @@ -994,7 +1121,7 @@ test('PR writes reject forged local authorization and live PR drift', async () = 'example/repo', '--comment', '--body', - 'Stale', + `Stale\n<!-- issue-dev-loop:fixture-run:review-cycle:1:round:1:head:${'b'.repeat(40)} -->`, ], { env: drifted.env }, ), @@ -1020,11 +1147,50 @@ test('pending evolve request authorizes only its exact push and Draft PR branch' schemaVersion: 1, requestId, status: 'pending', + reason: 'ten_finalized_runs', requestedAt: '2026-07-23T00:00:00.000Z', + finalizedRunCount: 10, })}\n`, 'utf8', ) const branch = `codex/evolve-${requestId}` + await assert.rejects( + execFileAsync( + process.execPath, + [ + routerPath, + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + 'git', + 'push', + 'origin', + branch, + ], + { env: fixture.env }, + ), + /publicationUrl|durable publication/, + ) + const prepared = await prepareEvolveRequestPublication({ + loopRoot: fixture.loopRoot, + requestId, + }) + const evolveComment = { + user: { login: 'executor-user' }, + body: prepared.body, + } + await writeFile( + path.join(path.dirname(fixture.loopRoot), 'evolve-comment.json'), + `${JSON.stringify(evolveComment)}\n`, + 'utf8', + ) + await recordEvolveRequestPublication({ + loopRoot: fixture.loopRoot, + requestId, + commentUrl: 'https://github.com/example/repo/issues/999#issuecomment-2', + githubApi: async () => evolveComment, + }) await execFileAsync( process.execPath, [ @@ -1108,7 +1274,7 @@ test('authenticated command trees reject shell, env, arbitrary node, and descend [routerPath, '--loop-root', fixture.loopRoot, role, '--', command[0], ...command.slice(1)], { env: fixture.env }, ), - /(outside the authenticated|reviewer identity cannot run git push|automation may push only)/, + /(outside the authenticated|reviewer identity cannot run git push|automation may push only|descendant processes cannot push)/, ) } }) @@ -1260,6 +1426,38 @@ test('authenticated real Git ignores local execution hooks and configured diff h for (const marker of [hookMarker, diffMarker, textconvMarker, fsmonitorMarker]) { await assert.rejects(readFile(marker, 'utf8'), /ENOENT/) } + await execFileAsync(realGit, ['config', 'http.proxy', 'http://127.0.0.1:9'], { + cwd: repository, + }) + await assert.rejects( + routed(['fetch', 'origin', 'dev']), + /rejects repository-local HTTP, proxy, helper, and URL rewrite configuration/, + ) +}) + +test('local run branch forgery cannot authorize a protected branch push', async () => { + const fixture = await createFixture() + const runPath = path.join(fixture.loopRoot, 'logs', 'runs', 'fixture-run', 'run.json') + const run = JSON.parse(await readFile(runPath, 'utf8')) + await writeFile(runPath, `${JSON.stringify({ ...run, branch: 'dev' })}\n`, 'utf8') + await assert.rejects( + execFileAsync( + process.execPath, + [ + routerPath, + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + 'git', + 'push', + 'origin', + 'dev', + ], + { env: fixture.env }, + ), + /branch must be derived from its durable issue number/, + ) }) test('automation push verifies that origin is the configured repository', async () => { diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index 5b8dfe9e..29a9855d 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -358,6 +358,7 @@ async function writePassingReview({ loopRoot, run, headSha, prNumber = 200, revi `${JSON.stringify({ schemaVersion: 1, runId: run.runId, + cycle: 1, reviewerAgent: 'echo_ui_pr_reviewer', freshContext: true, headSha, @@ -377,6 +378,16 @@ async function writePassingReview({ loopRoot, run, headSha, prNumber = 200, revi return resultPath } +function publishedReviewFixture({ id, runId, cycle = 1, round, headSha }) { + return { + id, + commit_id: headSha, + state: 'COMMENTED', + user: { login: 'echo-ui-reviewer[bot]' }, + body: `<!-- issue-dev-loop:${runId}:review-cycle:${cycle}:round:${round}:head:${headSha} -->`, + } +} + async function writeFixtureFinalization({ loopRoot, runId, @@ -864,8 +875,8 @@ test('frozen brief and $implement invocation history cannot be rewritten or reus endpoint.includes('/compare/') ? { status: 'ahead', base_commit: { sha: secondCommit } } : { - ...pullRequestFixture(run, updatedHead, { draft: false }), - body: pullRequestFixture(run, firstHead, { draft: false }).body, + ...pullRequestFixture(run, updatedHead), + body: pullRequestFixture(run, firstHead).body, }, trailingPathValidator: async (range) => { checkedTrailingRange = range @@ -885,7 +896,7 @@ test('frozen brief and $implement invocation history cannot be rewritten or reus githubApi: async (endpoint) => endpoint.includes('/compare/') ? { status: 'ahead', base_commit: { sha: secondCommit } } - : pullRequestFixture(run, '6'.repeat(40), { draft: false }), + : pullRequestFixture(run, '6'.repeat(40)), trailingPathValidator: async () => { throw new Error('product changes after the recorded $implement commit are forbidden') }, @@ -905,12 +916,111 @@ test('frozen brief and $implement invocation history cannot be rewritten or reus runId: run.runId, prUrl, headSha: '6'.repeat(40), - githubApi: async () => pullRequestFixture(run, '6'.repeat(40), { draft: false }), + githubApi: async () => pullRequestFixture(run, '6'.repeat(40)), }), /brief changed after freeze-brief/, ) }) +test('owner-feedback repair cannot start until the unchanged PR is durably redrafted', async () => { + const { loopRoot } = await createFixture() + const { run } = await startFixtureRun({ + loopRoot, + issueNumber: 151, + issueTitle: 'Redraft before owner repair', + issueUrl: 'https://github.com/codeacme17/echo-ui/issues/151', + entropy: 'redraft151', + }) + const headSha = '3'.repeat(40) + const prUrl = await recordFixturePr({ loopRoot, run, headSha, number: 351 }) + await createNotification({ + loopRoot, + runId: run.runId, + type: 'clarification_required', + summary: 'Owner requested a repair', + requestedAction: 'Confirm the requested repair', + targetUrl: prUrl, + blocking: true, + now: new Date('2030-01-01T00:00:00.000Z'), + githubComment: async () => ({ + html_url: `${prUrl}#issuecomment-600`, + }), + }) + await recordOwnerResponse({ + loopRoot, + runId: run.runId, + responseUrl: `${prUrl}#issuecomment-601`, + now: new Date('2030-01-01T00:01:30.000Z'), + githubApi: async () => ({ + user: { login: 'codeacme17' }, + body: `Please repair this. RESUME ${run.runId}`, + created_at: '2030-01-01T00:01:00.000Z', + }), + }) + await transitionRun({ + loopRoot, + runId: run.runId, + status: 'running', + now: new Date('2030-01-01T00:02:00.000Z'), + }) + const currentRun = JSON.parse( + await readFile(path.join(loopRoot, 'logs', 'runs', run.runId, 'run.json'), 'utf8'), + ) + const resultPath = path.join( + loopRoot, + 'logs', + 'runs', + run.runId, + 'implementation-result-owner-repair.json', + ) + await writeFile( + resultPath, + `${JSON.stringify({ + schemaVersion: 1, + runId: run.runId, + agent: '$implement', + invocationId: 'impl-owner-repair', + startedAt: '2030-01-01T00:04:00.000Z', + finishedAt: '2030-01-01T00:05:00.000Z', + briefDigest: currentRun.briefDigest, + commitSha: '4'.repeat(40), + checks: [ + { command: 'pnpm test -- keyboard', status: 'passed' }, + { command: 'pnpm verify', status: 'passed' }, + ], + })}\n`, + 'utf8', + ) + await assert.rejects( + recordImplementation({ + loopRoot, + runId: run.runId, + resultPath, + commitRangeValidator: async () => {}, + }), + /durably redrafted first/, + ) + await recordPullRequest({ + loopRoot, + runId: run.runId, + prUrl, + headSha, + now: new Date('2030-01-01T00:03:00.000Z'), + githubApi: async (endpoint) => + endpoint.includes('/compare/') + ? { status: 'ahead', base_commit: { sha: '1'.repeat(40) } } + : pullRequestFixture(run, headSha), + trailingPathValidator: async () => {}, + }) + const repaired = await recordImplementation({ + loopRoot, + runId: run.runId, + resultPath, + commitRangeValidator: async () => {}, + }) + assert.equal(repaired.implementationCommit, '4'.repeat(40)) +}) + test('recordEvidence rejects failed workflow runs and mismatched artifact manifests', async () => { const { loopRoot } = await createFixture() const { run } = await startFixtureRun({ @@ -1006,8 +1116,8 @@ test('recordPullRequest rejects empty review sections even when metadata markers const originalHead = '3'.repeat(40) const prUrl = await recordFixturePr({ loopRoot, run, headSha: originalHead, number: 349 }) const nextHead = '4'.repeat(40) - const incomplete = pullRequestFixture(run, nextHead, { draft: false }) - const ownerAuthored = pullRequestFixture(run, nextHead, { draft: false }) + const incomplete = pullRequestFixture(run, nextHead) + const ownerAuthored = pullRequestFixture(run, nextHead) ownerAuthored.user = { login: 'codeacme17' } await assert.rejects( recordPullRequest({ @@ -1021,7 +1131,7 @@ test('recordPullRequest rejects empty review sections even when metadata markers : ownerAuthored, trailingPathValidator: async () => {}, }), - /record-pr requires a live PR/, + /record-pr requires a live Draft PR/, ) incomplete.body = incomplete.body.replace( '## Evidence\nExact-head workflow evidence is attached or pending for this draft.', @@ -1183,6 +1293,16 @@ test('owner-ready transition requires verification and review but remains resuma resultPath: reviewPath, reviewUrl: `${prUrl}#pullrequestreview-300`, githubApi: async (endpoint) => { + if (endpoint.endsWith('/pulls/200/reviews?per_page=100&page=1')) { + return [ + publishedReviewFixture({ + id: 300, + runId: run.runId, + round: 1, + headSha, + }), + ] + } if (endpoint.includes('/comments?')) return [] if (endpoint.endsWith('/pulls/200')) return pullRequestFixture(run, headSha) return { @@ -1192,7 +1312,7 @@ test('owner-ready transition requires verification and review but remains resuma user: { login: 'echo-ui-reviewer[bot]' }, body: [ 'PASS', - `<!-- issue-dev-loop:${run.runId}:review-round:1:head:${headSha} -->`, + `<!-- issue-dev-loop:${run.runId}:review-cycle:1:round:1:head:${headSha} -->`, `<!-- issue-dev-loop:${run.runId}:review-result-sha256:${reviewDigest} -->`, ].join('\n'), } @@ -1738,6 +1858,7 @@ test('review gate verifies published findings and classified replies', async () `${JSON.stringify({ schemaVersion: 1, runId: run.runId, + cycle: 1, reviewerAgent: 'echo_ui_pr_reviewer', freshContext: true, headSha, @@ -1750,7 +1871,7 @@ test('review gate verifies published findings and classified replies', async () verdict: 'CHANGES_REQUESTED', findings: [ { - findingId: 'RVW-1-1', + findingId: 'RVW-1-1-1', severity: 'P2', confidence: 'high', headSha: 'e'.repeat(40), @@ -1789,7 +1910,7 @@ test('review gate verifies published findings and classified replies', async () 'review-result-wrong-round.json', ) const wrongRoundResult = JSON.parse(await readFile(resultPath, 'utf8')) - wrongRoundResult.rounds[0].findings[0].findingId = 'RVW-2-1' + wrongRoundResult.rounds[0].findings[0].findingId = 'RVW-1-2-1' await writeFile(wrongRoundResultPath, `${JSON.stringify(wrongRoundResult)}\n`, 'utf8') await assert.rejects( recordReview({ @@ -1801,12 +1922,28 @@ test('review gate verifies published findings and classified replies', async () throw new Error('GitHub must not be queried for an invalid round ID') }, }), - /invalid or duplicate finding ID: RVW-2-1/, + /invalid or duplicate finding ID: RVW-1-2-1/, ) const reviewGithubApi = ({ includePriorFinding = true } = {}) => async (endpoint) => { + if (endpoint.endsWith('/pulls/300/reviews?per_page=100&page=1')) { + return [ + publishedReviewFixture({ + id: 499, + runId: run.runId, + round: 1, + headSha: 'e'.repeat(40), + }), + publishedReviewFixture({ + id: 500, + runId: run.runId, + round: 2, + headSha, + }), + ] + } if (endpoint.endsWith('/reviews/499/comments?per_page=100')) { return [ { @@ -1814,13 +1951,13 @@ test('review gate verifies published findings and classified replies', async () path: 'src/keyboard.ts', line: 12, body: [ - 'RVW-1-1', + 'RVW-1-1-1', 'P2', 'high', 'Incorrect assertion', 'The runtime check already guarantees this invariant.', 'Prove or fix the assertion.', - `<!-- issue-dev-loop:${run.runId}:RVW-1-1 -->`, + `<!-- issue-dev-loop:${run.runId}:RVW-1-1-1 -->`, ].join('\n'), }, ] @@ -1830,7 +1967,7 @@ test('review gate verifies published findings and classified replies', async () return { user: { login: 'echo-ui-loop[bot]' }, created_at: '2026-07-22T17:00:00.000Z', - body: `Rejected with proof. Reproduction command exits successfully.\n<!-- issue-dev-loop:${run.runId}:RVW-1-1:rejected -->`, + body: `Rejected with proof. Reproduction command exits successfully.\n<!-- issue-dev-loop:${run.runId}:RVW-1-1-1:rejected -->`, } } if (endpoint.endsWith('/pulls/300')) return pullRequestFixture(run, headSha) @@ -1842,21 +1979,21 @@ test('review gate verifies published findings and classified replies', async () user: { login: 'echo-ui-reviewer[bot]' }, body: firstRound ? [ - 'RVW-1-1', + 'RVW-1-1-1', 'P2', 'high', 'Incorrect assertion', 'The runtime check already guarantees this invariant.', 'Prove or fix the assertion.', - `<!-- issue-dev-loop:${run.runId}:RVW-1-1 -->`, - `<!-- issue-dev-loop:${run.runId}:review-round:1:head:${'e'.repeat(40)} -->`, + `<!-- issue-dev-loop:${run.runId}:RVW-1-1-1 -->`, + `<!-- issue-dev-loop:${run.runId}:review-cycle:1:round:1:head:${'e'.repeat(40)} -->`, ].join('\n') : [ 'PASS', ...(includePriorFinding - ? ['Resolved RVW-1-1 with the published executor response.'] + ? ['Resolved RVW-1-1-1 with the published executor response.'] : []), - `<!-- issue-dev-loop:${run.runId}:review-round:2:head:${headSha} -->`, + `<!-- issue-dev-loop:${run.runId}:review-cycle:1:round:2:head:${headSha} -->`, `<!-- issue-dev-loop:${run.runId}:review-result-sha256:${digest} -->`, ].join('\n'), } @@ -1911,6 +2048,44 @@ test('review gate rejects GitHub findings omitted from the durable result', asyn resultPath, reviewUrl: `${prUrl}#pullrequestreview-500`, githubApi: async (endpoint) => { + if (endpoint.endsWith('/pulls/350/reviews?per_page=100&page=1')) { + return [ + publishedReviewFixture({ + id: 499, + runId: run.runId, + round: 1, + headSha: 'e'.repeat(40), + }), + publishedReviewFixture({ + id: 500, + runId: run.runId, + round: 1, + headSha, + }), + ] + } + throw new Error(`unexpected endpoint after exhaustive membership failure: ${endpoint}`) + }, + }), + /include every reviewer publication/, + ) + await assert.rejects( + recordReview({ + loopRoot, + runId: run.runId, + resultPath, + reviewUrl: `${prUrl}#pullrequestreview-500`, + githubApi: async (endpoint) => { + if (endpoint.endsWith('/pulls/350/reviews?per_page=100&page=1')) { + return [ + publishedReviewFixture({ + id: 500, + runId: run.runId, + round: 1, + headSha, + }), + ] + } if (endpoint.endsWith('/comments?per_page=100')) { return [ { @@ -1929,7 +2104,7 @@ test('review gate rejects GitHub findings omitted from the durable result', asyn user: { login: 'echo-ui-reviewer[bot]' }, body: [ 'PASS', - `<!-- issue-dev-loop:${run.runId}:review-round:1:head:${headSha} -->`, + `<!-- issue-dev-loop:${run.runId}:review-cycle:1:round:1:head:${headSha} -->`, `<!-- issue-dev-loop:${run.runId}:review-result-sha256:${digest} -->`, ].join('\n'), } @@ -1991,6 +2166,7 @@ test('accepted review fix must be after the finding head and inside the final he const result = { schemaVersion: 1, runId: run.runId, + cycle: 1, reviewerAgent: 'echo_ui_pr_reviewer', freshContext: true, headSha, @@ -2003,7 +2179,7 @@ test('accepted review fix must be after the finding head and inside the final he verdict: 'CHANGES_REQUESTED', findings: [ { - findingId: 'RVW-1-1', + findingId: 'RVW-1-1-1', severity: 'P2', confidence: 'high', headSha: findingHead, @@ -2038,12 +2214,28 @@ test('accepted review fix must be after the finding head and inside the final he resultPath, reviewUrl: 'https://github.com/codeacme17/echo-ui/pull/304#pullrequestreview-510', githubApi: async (endpoint) => { + if (endpoint.endsWith('/pulls/304/reviews?per_page=100&page=1')) { + return [ + publishedReviewFixture({ + id: 509, + runId: run.runId, + round: 1, + headSha: findingHead, + }), + publishedReviewFixture({ + id: 510, + runId: run.runId, + round: 2, + headSha, + }), + ] + } if (endpoint.endsWith('/comments?per_page=100')) return [] if (endpoint.includes('/issues/comments/410')) { return { user: { login: 'echo-ui-loop[bot]' }, created_at: '2026-07-22T17:40:00.000Z', - body: `pnpm verify passes after the guard. ${fixCommit}\n<!-- issue-dev-loop:${run.runId}:RVW-1-1:accepted -->`, + body: `pnpm verify passes after the guard. ${fixCommit}\n<!-- issue-dev-loop:${run.runId}:RVW-1-1-1:accepted -->`, } } if (endpoint.endsWith(`/compare/${findingHead}...${fixCommit}`)) { @@ -2061,19 +2253,19 @@ test('accepted review fix must be after the finding head and inside the final he user: { login: 'echo-ui-reviewer[bot]' }, body: firstRound ? [ - 'RVW-1-1', + 'RVW-1-1-1', 'P2', 'high', 'Missing guard', 'The failure is reproducible.', 'Add the guard.', - `<!-- issue-dev-loop:${run.runId}:RVW-1-1 -->`, - `<!-- issue-dev-loop:${run.runId}:review-round:1:head:${findingHead} -->`, + `<!-- issue-dev-loop:${run.runId}:RVW-1-1-1 -->`, + `<!-- issue-dev-loop:${run.runId}:review-cycle:1:round:1:head:${findingHead} -->`, ].join('\n') : [ 'PASS', - 'Resolved RVW-1-1 with the published executor response.', - `<!-- issue-dev-loop:${run.runId}:review-round:2:head:${headSha} -->`, + 'Resolved RVW-1-1-1 with the published executor response.', + `<!-- issue-dev-loop:${run.runId}:review-cycle:1:round:2:head:${headSha} -->`, `<!-- issue-dev-loop:${run.runId}:review-result-sha256:${digest} -->`, ].join('\n'), } @@ -2099,6 +2291,7 @@ test('review gate binds high-severity adjudication verdict to the correct identi `${JSON.stringify({ schemaVersion: 1, runId: run.runId, + cycle: 1, reviewerAgent: 'echo_ui_pr_reviewer', freshContext: true, headSha, @@ -2111,7 +2304,7 @@ test('review gate binds high-severity adjudication verdict to the correct identi verdict: 'CHANGES_REQUESTED', findings: [ { - findingId: 'RVW-1-1', + findingId: 'RVW-1-1-1', severity: 'P1', confidence: 'high', headSha, @@ -2149,19 +2342,35 @@ test('review gate binds high-severity adjudication verdict to the correct identi resultPath, reviewUrl: 'https://github.com/codeacme17/echo-ui/pull/302#pullrequestreview-501', githubApi: async (endpoint) => { + if (endpoint.endsWith('/pulls/302/reviews?per_page=100&page=1')) { + return [ + publishedReviewFixture({ + id: 500, + runId: run.runId, + round: 1, + headSha, + }), + publishedReviewFixture({ + id: 501, + runId: run.runId, + round: 2, + headSha, + }), + ] + } if (endpoint.endsWith('/comments?per_page=100')) return [] if (endpoint.includes('/issues/comments/401')) { return { user: { login: 'echo-ui-loop[bot]' }, created_at: '2026-07-22T17:00:00.000Z', - body: `Executor disagrees.\n<!-- issue-dev-loop:${run.runId}:RVW-1-1:rejected -->`, + body: `Executor disagrees.\n<!-- issue-dev-loop:${run.runId}:RVW-1-1-1:rejected -->`, } } if (endpoint.includes('/issues/comments/402')) { return { user: { login: 'echo-ui-reviewer[bot]' }, created_at: '2026-07-22T17:10:00.000Z', - body: `<!-- issue-dev-loop:${run.runId}:RVW-1-1:adjudication:OWNER_REJECTED_FINDING -->`, + body: `<!-- issue-dev-loop:${run.runId}:RVW-1-1-1:adjudication:OWNER_REJECTED_FINDING -->`, } } if (endpoint.endsWith('/pulls/302')) return pullRequestFixture(run, headSha) @@ -2173,19 +2382,19 @@ test('review gate binds high-severity adjudication verdict to the correct identi user: { login: 'echo-ui-reviewer[bot]' }, body: firstRound ? [ - 'RVW-1-1', + 'RVW-1-1-1', 'P1', 'high', 'Potential public API break', 'The export changed.', 'Restore compatibility or adjudicate.', - `<!-- issue-dev-loop:${run.runId}:RVW-1-1 -->`, - `<!-- issue-dev-loop:${run.runId}:review-round:1:head:${headSha} -->`, + `<!-- issue-dev-loop:${run.runId}:RVW-1-1-1 -->`, + `<!-- issue-dev-loop:${run.runId}:review-cycle:1:round:1:head:${headSha} -->`, ].join('\n') : [ 'PASS', - 'Resolved RVW-1-1 through the recorded adjudication.', - `<!-- issue-dev-loop:${run.runId}:review-round:2:head:${headSha} -->`, + 'Resolved RVW-1-1-1 through the recorded adjudication.', + `<!-- issue-dev-loop:${run.runId}:review-cycle:1:round:2:head:${headSha} -->`, `<!-- issue-dev-loop:${run.runId}:review-result-sha256:${digest} -->`, ].join('\n'), } From fbbb6a1efdb3773e741829611f962785e345e18c Mon Sep 17 00:00:00 2001 From: leyoonafr <codeacme17@gmail.com> Date: Thu, 23 Jul 2026 19:10:27 +0800 Subject: [PATCH 25/41] fix(loop): verify every dev pull request --- .github/workflows/issue-dev-loop-evidence.yml | 7 +------ loops/issue-dev-loop/scripts/lib/validation.mjs | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/.github/workflows/issue-dev-loop-evidence.yml b/.github/workflows/issue-dev-loop-evidence.yml index e88dbcf7..74599d6a 100644 --- a/.github/workflows/issue-dev-loop-evidence.yml +++ b/.github/workflows/issue-dev-loop-evidence.yml @@ -28,7 +28,6 @@ jobs: run: node loops/issue-dev-loop/scripts/resolve-run.mjs --branch "${{ github.head_ref }}" - name: Assert immutable head - if: steps.run.outputs.has_run == 'true' run: test "$(git rev-parse HEAD)" = "${{ github.event.pull_request.head.sha }}" - name: Protect append-only loop history @@ -36,24 +35,20 @@ jobs: run: node loops/issue-dev-loop/scripts/validate-history.mjs --base-ref origin/dev - name: Set up pnpm - if: steps.run.outputs.has_run == 'true' uses: pnpm/action-setup@v6 with: version: 10 - name: Set up Node - if: steps.run.outputs.has_run == 'true' uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - name: Install dependencies - if: steps.run.outputs.has_run == 'true' run: pnpm install --frozen-lockfile - name: Run authoritative verification - if: steps.run.outputs.has_run == 'true' id: verify shell: bash run: | @@ -93,5 +88,5 @@ jobs: run: echo "Evidence artifact — ${{ steps.artifact.outputs.artifact-url }}" >> "${GITHUB_STEP_SUMMARY}" - name: Enforce verification result - if: steps.run.outputs.has_run == 'true' && steps.verify.outputs.exit_code != '0' + if: steps.verify.outputs.exit_code != '0' run: exit 1 diff --git a/loops/issue-dev-loop/scripts/lib/validation.mjs b/loops/issue-dev-loop/scripts/lib/validation.mjs index 490aa615..089bc64f 100644 --- a/loops/issue-dev-loop/scripts/lib/validation.mjs +++ b/loops/issue-dev-loop/scripts/lib/validation.mjs @@ -149,6 +149,21 @@ export async function validateLoop({ if (!(await pathExists(evidenceWorkflow))) { throw new Error('missing .github/workflows/issue-dev-loop-evidence.yml') } + const evidenceWorkflowSource = await readFile(evidenceWorkflow, 'utf8') + const verificationStep = evidenceWorkflowSource.match( + / - name: Run authoritative verification\n([\s\S]*?)(?=\n - name:)/, + )?.[1] + const enforcementStep = evidenceWorkflowSource.match( + / - name: Enforce verification result\n([\s\S]*?)(?=\n - name:|$)/, + )?.[1] + if ( + !verificationStep?.includes('pnpm verify') || + verificationStep.includes('if:') || + !enforcementStep || + enforcementStep.includes("steps.run.outputs.has_run == 'true'") + ) { + throw new Error('evidence workflow must run and enforce pnpm verify for every PR') + } const codexConfig = await readFile( path.resolve(loopRoot, '..', '..', '.codex', 'config.toml'), 'utf8', From 7b125b202abaaccbaa9e2f3f3db4404fa4f915fb Mon Sep 17 00:00:00 2001 From: leyoonafr <codeacme17@gmail.com> Date: Thu, 23 Jul 2026 19:12:53 +0800 Subject: [PATCH 26/41] fix(ci): use repository pnpm version --- .github/workflows/issue-dev-loop-evidence.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/issue-dev-loop-evidence.yml b/.github/workflows/issue-dev-loop-evidence.yml index 74599d6a..450bb836 100644 --- a/.github/workflows/issue-dev-loop-evidence.yml +++ b/.github/workflows/issue-dev-loop-evidence.yml @@ -36,8 +36,6 @@ jobs: - name: Set up pnpm uses: pnpm/action-setup@v6 - with: - version: 10 - name: Set up Node uses: actions/setup-node@v6 From e731cfc9d68a32d963935006bd9b9fe9637a44f1 Mon Sep 17 00:00:00 2001 From: leyoonafr <codeacme17@gmail.com> Date: Thu, 23 Jul 2026 19:16:11 +0800 Subject: [PATCH 27/41] fix(ci): isolate evidence fixture environment --- loops/issue-dev-loop/tests/runtime.test.mjs | 43 +++++++++++++-------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index 29a9855d..dbdc7a8c 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -1219,23 +1219,32 @@ test('CI helpers resolve a run and generate exact-head screenshot evidence', asy assert.equal(JSON.parse(resolveResult.stdout).runId, run.runId) const output = path.join(loopRoot, 'evidence', run.runId, 'manifest.json') - await execFileAsync(process.execPath, [ - path.join(repositoryLoopRoot, 'scripts', 'generate-evidence.mjs'), - '--loop-root', - loopRoot, - '--run-id', - run.runId, - '--head-sha', - headSha, - '--status', - 'passed', - '--started-at', - '2026-07-22T16:00:00Z', - '--finished-at', - '2026-07-22T16:10:00Z', - '--output', - output, - ]) + await execFileAsync( + process.execPath, + [ + path.join(repositoryLoopRoot, 'scripts', 'generate-evidence.mjs'), + '--loop-root', + loopRoot, + '--run-id', + run.runId, + '--head-sha', + headSha, + '--status', + 'passed', + '--started-at', + '2026-07-22T16:00:00Z', + '--finished-at', + '2026-07-22T16:10:00Z', + '--output', + output, + ], + { + env: { + ...process.env, + GITHUB_ACTIONS: 'false', + }, + }, + ) const evidence = JSON.parse(await readFile(output, 'utf8')) assert.equal(evidence.headSha, headSha) assert.equal(evidence.screenshots[1].path, screenshotRelativePath) From fca53c3d7b1a87aa277db985dbbf129d5254f902 Mon Sep 17 00:00:00 2001 From: leyoonafr <codeacme17@gmail.com> Date: Thu, 23 Jul 2026 19:25:12 +0800 Subject: [PATCH 28/41] fix(test): tolerate platform font wrapping --- scripts/verify-docs-ui.mjs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/verify-docs-ui.mjs b/scripts/verify-docs-ui.mjs index 2344f3d7..b44f9ff0 100644 --- a/scripts/verify-docs-ui.mjs +++ b/scripts/verify-docs-ui.mjs @@ -21,6 +21,11 @@ const within = (actual, expected, tolerance = 1) => Math.abs(actual - expected) <= tolerance, `Expected ${actual} to be within ${tolerance}px of ${expected}`, ) +const withinOneOf = (actual, expectedValues, tolerance = 1) => + assert.ok( + expectedValues.some((expected) => Math.abs(actual - expected) <= tolerance), + `Expected ${actual} to be within ${tolerance}px of one of ${expectedValues.join(', ')}`, + ) const profiles = [ { colorScheme: 'light', desktop: true, viewport: { height: 900, width: 1440 } }, @@ -412,7 +417,8 @@ try { within(homeContract.heroActions?.x ?? 0, 26) within(homeContract.heroActions?.y ?? 0, 550) within(homeContract.firstFeature?.width ?? 0, 374) - within(homeContract.firstFeature?.height ?? 0, 198) + // Chromium's fallback CJK font may fit the first description on one line on Linux. + withinOneOf(homeContract.firstFeature?.height ?? 0, [174, 198]) within(homeContract.firstFeature?.x ?? 0, 8) within(homeContract.firstFeature?.y ?? 0, 680) } From e050a0e7d8e202f907a84c01c8c4b17424c3d0e1 Mon Sep 17 00:00:00 2001 From: leyoonafr <codeacme17@gmail.com> Date: Thu, 23 Jul 2026 19:57:14 +0800 Subject: [PATCH 29/41] ci: limit Vercel deployments to main and dev --- vercel.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/vercel.json b/vercel.json index 91c7a848..fdb49147 100644 --- a/vercel.json +++ b/vercel.json @@ -1,5 +1,12 @@ { "buildCommand": "pnpm build:docs", "installCommand": "pnpm install --frozen-lockfile", - "outputDirectory": "docs/out" + "outputDirectory": "docs/out", + "git": { + "deploymentEnabled": { + "*": false, + "main": true, + "dev": true + } + } } From 3a00917526fe9a690f34e65db73461019251074b Mon Sep 17 00:00:00 2001 From: Ethandasw <Ethandasw@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:45:20 +0800 Subject: [PATCH 30/41] fix: harden issue development loop trust boundaries --- .github/workflows/issue-dev-loop-evidence.yml | 134 ++++++++- loops/README.md | 1 + loops/_shared/owner-channel/CHANNEL.md | 4 +- loops/issue-dev-loop/LOOP.md | 10 +- loops/issue-dev-loop/SKILL.md | 14 +- loops/issue-dev-loop/dependencies.md | 15 +- loops/issue-dev-loop/evolve/EVOLVE.md | 2 +- .../references/evidence-policy.md | 5 +- .../references/github-operations.md | 20 +- loops/issue-dev-loop/review/REVIEW.md | 4 +- .../issue-dev-loop/review/response-policy.md | 2 +- .../issue-dev-loop/review/reviewer-prompt.md | 2 +- .../schemas/evidence.schema.json | 4 + .../scripts/generate-evidence.mjs | 22 ++ .../scripts/github-command-gate.mjs | 10 +- .../scripts/install-trusted-control-plane.mjs | 221 ++++++++++++++ loops/issue-dev-loop/scripts/lib/evidence.mjs | 87 +++++- .../scripts/lib/github-identity.mjs | 176 ++++++----- loops/issue-dev-loop/scripts/lib/github.mjs | 70 +++-- .../scripts/lib/trusted-control-plane.mjs | 87 ++++++ .../issue-dev-loop/scripts/lib/validation.mjs | 40 ++- loops/issue-dev-loop/scripts/loopctl.mjs | 52 +++- loops/issue-dev-loop/scripts/resolve-run.mjs | 7 +- loops/issue-dev-loop/scripts/runtime.mjs | 3 +- .../validate-candidate-control-plane.mjs | 112 +++++++ .../scripts/verifier.Dockerfile | 9 + .../scripts/with-github-identity.mjs | 6 +- .../tests/github-identity-routing.test.mjs | 274 +++++++++++++----- loops/issue-dev-loop/tests/runtime.test.mjs | 220 ++++++++++++-- loops/issue-dev-loop/triggers/TRIGGER.md | 7 +- .../triggers/codex-automation-prompt.md | 2 +- loops/issue-dev-loop/triggers/detect-work.mjs | 1 + scripts/verify-nextra-output.mjs | 7 + 33 files changed, 1356 insertions(+), 274 deletions(-) create mode 100644 loops/issue-dev-loop/scripts/install-trusted-control-plane.mjs create mode 100644 loops/issue-dev-loop/scripts/lib/trusted-control-plane.mjs create mode 100644 loops/issue-dev-loop/scripts/validate-candidate-control-plane.mjs create mode 100644 loops/issue-dev-loop/scripts/verifier.Dockerfile diff --git a/.github/workflows/issue-dev-loop-evidence.yml b/.github/workflows/issue-dev-loop-evidence.yml index 450bb836..c79bbd02 100644 --- a/.github/workflows/issue-dev-loop-evidence.yml +++ b/.github/workflows/issue-dev-loop-evidence.yml @@ -13,27 +13,21 @@ concurrency: cancel-in-progress: true jobs: - evidence: + bootstrap-evidence: + if: github.event_name == 'pull_request' && github.head_ref == 'codex/issue-dev-loop' runs-on: ubuntu-latest timeout-minutes: 45 steps: - - name: Check out exact PR head + - name: Check out bootstrap PR head uses: actions/checkout@v6 with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 + persist-credentials: false - - name: Resolve loop run - id: run - run: node loops/issue-dev-loop/scripts/resolve-run.mjs --branch "${{ github.head_ref }}" - - - name: Assert immutable head + - name: Assert immutable bootstrap head run: test "$(git rev-parse HEAD)" = "${{ github.event.pull_request.head.sha }}" - - name: Protect append-only loop history - if: steps.run.outputs.has_run == 'true' - run: node loops/issue-dev-loop/scripts/validate-history.mjs --base-ref origin/dev - - name: Set up pnpm uses: pnpm/action-setup@v6 @@ -46,27 +40,135 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Run bootstrap verification + run: pnpm verify + + evidence: + if: github.event_name == 'pull_request' && startsWith(github.event.pull_request.head.ref, 'codex/issue-') && github.event.pull_request.head.ref != 'codex/issue-dev-loop' + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Check out owner-merged baseline + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.base.sha }} + fetch-depth: 1 + path: trusted + persist-credentials: false + + - name: Check out exact PR head + uses: actions/checkout@v6 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + path: candidate + persist-credentials: false + + - name: Set up trusted Node + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Resolve loop run + id: run + env: + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} + run: >- + node trusted/loops/issue-dev-loop/scripts/resolve-run.mjs --loop-root candidate/loops/issue-dev-loop --branch "$PR_HEAD_REF" + + - name: Assert immutable head + run: test "$(git -C candidate rev-parse HEAD)" = "${{ github.event.pull_request.head.sha }}" + + - name: Protect trusted control plane + if: steps.run.outputs.has_run == 'true' + run: >- + node trusted/loops/issue-dev-loop/scripts/validate-candidate-control-plane.mjs --loop-root candidate/loops/issue-dev-loop --run-id "${{ steps.run.outputs.run_id }}" --base-sha "${{ github.event.pull_request.base.sha }}" --head-sha "${{ github.event.pull_request.head.sha }}" + + - name: Protect append-only loop history + if: steps.run.outputs.has_run == 'true' + run: >- + node trusted/loops/issue-dev-loop/scripts/validate-history.mjs --loop-root candidate/loops/issue-dev-loop --base-ref "${{ github.event.pull_request.base.sha }}" + + - name: Build trusted verifier image + run: >- + docker build --tag echo-ui-loop-verifier:${{ github.run_id }}-${{ github.run_attempt }} --file trusted/loops/issue-dev-loop/scripts/verifier.Dockerfile trusted/loops/issue-dev-loop/scripts + + - name: Prepare isolated candidate and baseline volumes + shell: bash + run: | + candidate_volume="issue-dev-loop-candidate-${{ github.run_id }}-${{ github.run_attempt }}" + baseline_volume="issue-dev-loop-baseline-${{ github.run_id }}-${{ github.run_attempt }}" + image="echo-ui-loop-verifier:${{ github.run_id }}-${{ github.run_attempt }}" + docker volume create "${candidate_volume}" + docker volume create "${baseline_volume}" + docker run --rm \ + --mount "type=volume,src=${candidate_volume},dst=/work" \ + --mount "type=bind,src=${GITHUB_WORKSPACE}/candidate,dst=/source,readonly" \ + "${image}" \ + sh -ceu 'cp -a /source/. /work/; cd /work; pnpm install --frozen-lockfile --ignore-scripts' + docker run --rm \ + --mount "type=volume,src=${candidate_volume},dst=/candidate,readonly" \ + --mount "type=volume,src=${baseline_volume},dst=/work" \ + --mount "type=bind,src=${GITHUB_WORKSPACE}/trusted/tests,dst=/owner-tests,readonly" \ + "${image}" \ + sh -ceu 'cp -a /candidate/. /work/; rm -rf /work/tests; cp -a /owner-tests /work/tests' + - name: Run authoritative verification id: verify shell: bash run: | mkdir -p "${RUNNER_TEMP}/issue-dev-evidence" started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + candidate_volume="issue-dev-loop-candidate-${{ github.run_id }}-${{ github.run_attempt }}" + baseline_volume="issue-dev-loop-baseline-${{ github.run_id }}-${{ github.run_attempt }}" + image="echo-ui-loop-verifier:${{ github.run_id }}-${{ github.run_attempt }}" set +e - pnpm verify 2>&1 | tee "${RUNNER_TEMP}/issue-dev-evidence/pnpm-verify.log" - exit_code="${PIPESTATUS[0]}" + docker run --rm --network none \ + --mount "type=volume,src=${candidate_volume},dst=/work" \ + "${image}" \ + pnpm verify 2>&1 | tee "${RUNNER_TEMP}/issue-dev-evidence/pnpm-verify.log" + candidate_exit_code="${PIPESTATUS[0]}" set -e + baseline_status=blocked + baseline_exit_code=1 + if [[ "${candidate_exit_code}" == "0" ]]; then + set +e + docker run --rm --network none \ + --mount "type=volume,src=${baseline_volume},dst=/work" \ + "${image}" \ + pnpm test 2>&1 | tee "${RUNNER_TEMP}/issue-dev-evidence/owner-merged-baseline-tests.log" + baseline_exit_code="${PIPESTATUS[0]}" + set -e + if [[ "${baseline_exit_code}" == "0" ]]; then + baseline_status=passed + else + baseline_status=failed + fi + fi + if [[ "${candidate_exit_code}" == "0" && "${baseline_exit_code}" == "0" ]]; then + exit_code=0 + else + exit_code=1 + fi finished_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" if [[ "${exit_code}" == "0" ]]; then verdict=passed; else verdict=failed; fi echo "started_at=${started_at}" >> "${GITHUB_OUTPUT}" echo "finished_at=${finished_at}" >> "${GITHUB_OUTPUT}" echo "exit_code=${exit_code}" >> "${GITHUB_OUTPUT}" echo "verdict=${verdict}" >> "${GITHUB_OUTPUT}" + echo "baseline_status=${baseline_status}" >> "${GITHUB_OUTPUT}" + + - name: Remove isolated verifier volumes + if: always() + run: | + docker volume rm --force "issue-dev-loop-candidate-${{ github.run_id }}-${{ github.run_attempt }}" + docker volume rm --force "issue-dev-loop-baseline-${{ github.run_id }}-${{ github.run_attempt }}" - name: Generate exact-head manifest if: steps.run.outputs.has_run == 'true' && always() run: >- - node loops/issue-dev-loop/scripts/generate-evidence.mjs --run-id "${{ steps.run.outputs.run_id }}" --head-sha "${{ github.event.pull_request.head.sha }}" --status "${{ steps.verify.outputs.verdict || 'blocked' }}" --started-at "${{ steps.verify.outputs.started_at || github.event.pull_request.updated_at }}" --finished-at "${{ steps.verify.outputs.finished_at || github.event.pull_request.updated_at }}" --output "${RUNNER_TEMP}/issue-dev-evidence/manifest.json" + node trusted/loops/issue-dev-loop/scripts/generate-evidence.mjs --loop-root candidate/loops/issue-dev-loop --run-id "${{ steps.run.outputs.run_id }}" --head-sha "${{ github.event.pull_request.head.sha }}" --trusted-workflow-sha "${{ github.event.pull_request.base.sha }}" --workflow-run-sha "${{ github.event.pull_request.head.sha }}" --status "${{ steps.verify.outputs.verdict || 'blocked' }}" --baseline-status "${{ steps.verify.outputs.baseline_status || 'blocked' }}" --started-at "${{ steps.verify.outputs.started_at || github.event.pull_request.updated_at }}" --finished-at "${{ steps.verify.outputs.finished_at || github.event.pull_request.updated_at }}" --output "${RUNNER_TEMP}/issue-dev-evidence/manifest.json" - name: Upload review evidence if: steps.run.outputs.has_run == 'true' && always() @@ -76,8 +178,8 @@ jobs: name: issue-dev-loop-${{ steps.run.outputs.run_id }}-${{ github.event.pull_request.head.sha }} path: | ${{ runner.temp }}/issue-dev-evidence - loops/issue-dev-loop/screen-shots/${{ steps.run.outputs.run_id }} - loops/issue-dev-loop/logs/runs/${{ steps.run.outputs.run_id }} + candidate/loops/issue-dev-loop/screen-shots/${{ steps.run.outputs.run_id }} + candidate/loops/issue-dev-loop/logs/runs/${{ steps.run.outputs.run_id }} if-no-files-found: error retention-days: 30 diff --git a/loops/README.md b/loops/README.md index 9c4ab379..ac508f04 100644 --- a/loops/README.md +++ b/loops/README.md @@ -11,5 +11,6 @@ This directory contains durable, auditable workflows that Codex can run against - Treat each loop directory as the canonical source for its own workflow. - Put repo-discoverable adapters in `.agents/skills`; adapters must point back to the canonical loop rather than duplicate its contract. - Persist compact state, sanitized event history, and issue-relevant screenshots in the issue PR. Publish exact-head evidence manifests and verification logs as CI artifacts; keep raw local output and large recordings out of Git. +- Treat repository loop code as reviewable source, not a credential boundary. Install a versioned control plane outside the repository only from clean owner-merged `dev`, and route all credential-bearing operations through that hash-verified installation. - Never grant a loop authority to approve, auto-merge, or merge a PR. - Use `pnpm loop:issue-dev:validate` before changing loop infrastructure. diff --git a/loops/_shared/owner-channel/CHANNEL.md b/loops/_shared/owner-channel/CHANNEL.md index 89a458a5..cb03ff97 100644 --- a/loops/_shared/owner-channel/CHANNEL.md +++ b/loops/_shared/owner-channel/CHANNEL.md @@ -13,8 +13,8 @@ Each blocking GitHub notification prints a unique resume instruction. To continu ## Runtime setup 1. Authenticate the unattended executor and fresh reviewer with distinct GitHub identities. Their exact logins and the names of their profile-path environment variables live in `channel.json`. For the current configuration, set `ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR` to the `Ethandasw` `gh` profile directory and `ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR` to the `Traviinam` profile directory in the scheduler environment. The directory names themselves are local details and do not need to match the roles. -2. Run `loops/issue-dev-loop/scripts/with-github-identity automation -- node loops/issue-dev-loop/scripts/loopctl.mjs validate --activation` before scheduling. The launcher independently queries both profiles, then starts structural validation in its sanitized child environment; it rejects missing, overlapping, owner-authenticated, or incorrectly routed identities. -3. Run every executor GitHub command, remote Git command, and GitHub-backed `loopctl` command through the executable shell launcher `loops/issue-dev-loop/scripts/with-github-identity automation -- ...`. Run reviewer publication commands through the same launcher with `reviewer`. The launcher removes Node preload hooks; the router clears token overrides and process hooks, verifies `gh api user`, and gives Git a one-command credential helper without changing global Git or `gh` configuration. A PATH gate applies the role and current-run policy to descendant `git` and `gh` processes too; arbitrary shell, environment, or Node command trees are rejected. +2. From a clean owner-merged `dev` checkout at exact `origin/dev`, install a new versioned control plane outside every repository worktree with `install-trusted-control-plane.mjs`. Set `ECHO_UI_LOOP_CONTROL_PLANE` to its `issue-dev-loop` directory and `ECHO_UI_LOOP_TARGET_ROOT` to the scheduled worktree's loop directory. Run `"$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" --loop-root "$ECHO_UI_LOOP_TARGET_ROOT" automation -- node "$ECHO_UI_LOOP_CONTROL_PLANE/scripts/loopctl.mjs" validate --activation --loop-root "$ECHO_UI_LOOP_TARGET_ROOT"` before scheduling. +3. Run every operational loop command, executor GitHub command, remote Git command, trigger, and reviewer publication through that installed launcher with the explicit target root. The repository launcher intentionally refuses credentials. The installed launcher hash-verifies its files, pins absolute executables, compares trusted channel fields, removes Node preload hooks, clears token overrides and process hooks, verifies `gh api user`, and gives Git a one-command credential helper without changing global Git or `gh` configuration. A PATH gate applies the role and current-run policy to descendant `git` and `gh` processes too; issue-worktree routers, PATH shims, arbitrary shell/environment commands, and arbitrary Node scripts are rejected. 4. Create one dedicated repository issue for the append-only loop state journal and set its number as `stateIssueNumber`. It stores active checkpoints and terminal records. Restrict journal entries to the automation identity; humans may read but should not edit or delete them. 5. Enable GitHub notifications for mentions and review requests for `codeacme17`. 6. Optionally set `ECHO_UI_LOOP_OWNER_WEBHOOK_URL` to an endpoint that accepts the notification JSON with `Content-Type: application/json`. diff --git a/loops/issue-dev-loop/LOOP.md b/loops/issue-dev-loop/LOOP.md index 0befc5ed..ada7cb5c 100644 --- a/loops/issue-dev-loop/LOOP.md +++ b/loops/issue-dev-loop/LOOP.md @@ -78,11 +78,11 @@ Complete the generated handoff with acceptance criteria, scope, TDD seams, requi Explicitly invoke `$implement`. The orchestrator does not write product code. `$implement` owns TDD at agreed seams, implementation, regular typechecking and targeted tests, the final full suite, `$code-review`, and a local commit. It must not push, create a PR, or merge. Every invocation writes a unique schema-validated result with its invocation ID, timestamps, frozen brief digest, passed checks, and a new commit descending from the prior implementation commit (or the frozen base SHA for the first invocation); record it before PR publication or update. -The recorded implementation commit is the product-code boundary. Later commits may contain only the current run's handoff, sanitized logs, screenshots, and evidence. `record-pr` diffs the implementation commit against the proposed head and rejects every other path, so the orchestrator cannot append unrecorded product changes. +The recorded implementation commit is the product-code boundary. Later commits may contain only the current run's handoff, sanitized logs, screenshots, and evidence. `record-pr` diffs the implementation commit against the proposed head and rejects every other path, so the orchestrator cannot append unrecorded product changes. Issue runs may not modify the loop runtime, owner channel, workflow, package manifests, verification scripts, or verification configuration. Such control-plane work belongs to a dedicated owner-reviewed evolve/bootstrap PR and becomes executable only after installation from a clean owner-merged `dev`. ### 5. Verify before publication -Run relevant checks and `pnpm verify`. For UI behavior, capture before/after screenshots under `screen-shots/<run-id>` at meaningful desktop and mobile viewports and include interaction or accessibility evidence where applicable. Bind `before` to the frozen base and `after` to the latest `$implement` commit; never put the containing commit's not-yet-known hash inside its own files. Commit sanitized screenshots and run metadata to the issue branch. The evidence workflow then checks out the exact PR event head, adds that SHA to the generated manifest, reruns `pnpm verify`, and uploads a reviewable artifact for that SHA. +Run relevant checks and `pnpm verify`. For UI behavior, capture before/after screenshots under `screen-shots/<run-id>` at meaningful desktop and mobile viewports and include interaction or accessibility evidence where applicable. Bind `before` to the frozen base and `after` to the latest `$implement` commit; never put the containing commit's not-yet-known hash inside its own files. Commit sanitized screenshots and run metadata to the issue branch. The low-privilege `pull_request` evidence workflow checks out owner-merged base code and the exact candidate head without persisted credentials, installs protected dependencies with lifecycle scripts disabled, and copies the candidate into Docker volumes. Candidate `pnpm verify` and owner-merged baseline `pnpm test` run in separate no-network containers that receive no GitHub token and cannot mount either host checkout. Before accepting the artifact, the installed control plane independently rejects any exact-head change to the workflow or trusted control/verification plane. The manifest binds candidate head, workflow-run SHA, and owner-merged base SHA. ### 6. Create the draft PR @@ -90,7 +90,7 @@ Push the issue branch and create a draft PR targeting `dev`. Immediately bind it ### 7. Independent review -Spawn `echo_ui_pr_reviewer` with fresh context and read-only filesystem access. Provide only durable specifications and review artifacts. Post every round's findings verbatim and record that round's GitHub review URL. The runtime independently verifies each review's author, immutable head SHA, marker, submission time, and contents. The executor then classifies and responds to every comment. Accepted findings go back through a `$implement` invocation that starts after the finding review; its evidence-backed reply must be posted after that invocation finishes. Rejected findings require concrete evidence posted after the review. Use `echo_ui_review_adjudicator` or the owner for disputed P0/P1 findings. Allow at most two automated repair/review rounds. +Spawn `echo_ui_pr_reviewer` with fresh context and read-only filesystem access. Provide only durable specifications and review artifacts. Post every round's findings verbatim and record that round's GitHub review URL. For the final round, compute the canonical publication digest before posting; the digest replaces GitHub-assigned review URLs with a fixed placeholder, so the returned URL can be inserted afterward without changing the marker, while the final result file still receives its own full-content digest. The runtime independently verifies each review's author, immutable head SHA, marker, submission time, contents, and exhaustive paginated membership. The executor then classifies and responds to every comment. Accepted findings go back through a `$implement` invocation that starts after the finding review; its evidence-backed reply must be posted after that invocation finishes. Rejected findings require concrete evidence posted after the review. Use `echo_ui_review_adjudicator` or the owner for disputed P0/P1 findings. Allow at most two automated repair/review rounds. ### 8. Owner gate @@ -98,7 +98,7 @@ After exact-head CI and independent review pass, download the CI manifest and re ### 9. Owner feedback -When the owner requests changes, verify the owner-authored GitHub comment or `CHANGES_REQUESTED` review with `record-owner-response`; ordinary comments must include the notification's exact `RESUME <run-id>` token. Only after that gate may the run return to `running`. Publish the transition checkpoint, immediately return the unchanged exact PR head to Draft with the phase-gated `gh pr ready --undo`, observe that remote Draft state with `record-pr`, and publish another checkpoint. Only then snapshot comments, create a supplemental handoff, invoke `$implement`, record its new result, push and rebind the still-Draft PR at the new head. Update its body, reverify, rerun a new numbered fresh-review cycle, reply with commit and evidence, and notify the owner that the PR is ready again. +When the owner requests changes, verify the owner-authored GitHub comment or `CHANGES_REQUESTED` review with `record-owner-response`; ordinary comments must include the notification's exact `RESUME <run-id>` token, and a review must target the run's current exact head. Only after that gate may the run return to `running`. Publish the transition checkpoint, immediately return the unchanged exact PR head to Draft with the phase-gated `gh pr ready --undo`, observe that remote Draft state with `record-pr`, and publish another checkpoint. Only then snapshot comments, create a supplemental handoff, invoke `$implement`, record its new result, push and rebind the still-Draft PR at the new head. Update its body, reverify, rerun a new numbered fresh-review cycle, reply with commit and evidence, and notify the owner that the PR is ready again. ### 10. Complete @@ -125,6 +125,8 @@ Never log secrets, full environment dumps, cookies, auth headers, private user d GitHub issue/PR comments are the canonical communication record. The shared owner channel may mirror notifications to a webhook. The notification runtime automatically transitions blocking events to `waiting_for_owner`; delivery failure is itself a blocker. `pr_ready_for_review` moves from that pause to `awaiting_owner_review` only after its SHA-bound evidence gates pass. +All credential-bearing commands run from an installed, hash-verified control plane outside the issue worktree. The installed launcher pins absolute Node, Git, and GitHub CLI executables; treats the worktree loop root only as data; compares its security-critical channel fields with the installed owner channel; and refuses tampering or PATH impersonation before loading either GitHub profile. The repository launcher never receives credentials. + A paused run resumes only after successful canonical GitHub delivery and a new, run-bound owner decision. The notification tells the owner to include `RESUME <run-id>` in a normal reply; a GitHub request-changes review is accepted without that token. An unrelated, stale, wrong-author, wrong-target, or pre-delivery comment never unlocks the run. Notify immediately for `approval_required`, `clarification_required`, `blocked`, `review_dispute`, `pr_ready_for_review`, `pr_updated_for_review`, and `loop_failed`. Routine no-work checks belong in a digest, not an interruption. diff --git a/loops/issue-dev-loop/SKILL.md b/loops/issue-dev-loop/SKILL.md index a17c1fbf..6df46570 100644 --- a/loops/issue-dev-loop/SKILL.md +++ b/loops/issue-dev-loop/SKILL.md @@ -10,11 +10,11 @@ Run exactly one bounded issue cycle. Treat [`LOOP.md`](./LOOP.md) as the constit ## Start safely 1. Read `LOOP.md`, `state.md`, and `dependencies.md` completely. -2. Run structural validation with `node loops/issue-dev-loop/scripts/loopctl.mjs validate`. Scheduled activation must run `loops/issue-dev-loop/scripts/with-github-identity automation -- node loops/issue-dev-loop/scripts/loopctl.mjs validate --activation`; the launcher probes both configured profiles before starting the sanitized structural validator. -3. Read [`references/github-operations.md`](./references/github-operations.md). Run every executor GitHub command, remote Git command, and GitHub-backed `loopctl` command through `loops/issue-dev-loop/scripts/with-github-identity automation -- ...`. Never invoke the `.mjs` router directly and never alter global `gh` or Git credential configuration. +2. Require absolute `ECHO_UI_LOOP_CONTROL_PLANE` and `ECHO_UI_LOOP_TARGET_ROOT` values from the scheduler. Run activation through `"$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" --loop-root "$ECHO_UI_LOOP_TARGET_ROOT" automation -- node "$ECHO_UI_LOOP_CONTROL_PLANE/scripts/loopctl.mjs" validate --activation --loop-root "$ECHO_UI_LOOP_TARGET_ROOT"`. The installed launcher verifies its hash manifest and probes both configured profiles before starting validation. +3. Read [`references/github-operations.md`](./references/github-operations.md). Run every operational `loopctl`, executor GitHub command, remote Git command, trigger, and reviewer publication through the installed control plane with the explicit target root. Never use the credential-refusing repository launcher, invoke the `.mjs` router directly, install control code from an issue branch, or alter global `gh` or Git credential configuration. 4. Run `loopctl.mjs reconcile` through the automation wrapper to rebuild terminal history and discover active runs from the append-only GitHub state journal. For returned `workType: resume`, fetch the recorded branch through the wrapper, create a clean isolated worktree at the returned exact head, and run `restore-checkpoint --run-id <id>` inside that worktree. The restore command rejects the wrong branch, a dirty checkout, or any head other than the durable head. Resume it before selecting a new issue. 5. Run `loopctl.mjs evolve-status`. If `evolveDue` is true, start `echo_ui_loop_evolver` with fresh context; do not silently replace it with product work. -6. Run the cheap trigger in `triggers/detect-work.mjs` through the automation wrapper. Exit without invoking an implementation agent when it reports `hasWork: false`. +6. Run the installed `triggers/detect-work.mjs` through the automation wrapper with `--loop-root "$ECHO_UI_LOOP_TARGET_ROOT"`. The detector paginates every open issue and PR page. Exit without invoking an implementation agent when it reports `hasWork: false`. 7. Refuse to start when another active run or PR already claims the issue. 8. Create a `codex/issue-<number>` branch and an isolated worktree from `dev`. 9. Start the run with `loopctl.mjs start --issue <number> --title <title> --url <issue-url> --base-sha <full-origin-dev-sha>`. @@ -46,7 +46,7 @@ Push only the issue branch and create a **draft** PR targeting `dev` using `temp After the draft PR exists, spawn the project agent `echo_ui_pr_reviewer` with a fresh context. Give it only the issue snapshot, acceptance criteria, repository instructions, base SHA, head SHA, diff, CI results, and evidence manifest. Do not give it executor conversation history or rationale. -Publish every round through `with-github-identity reviewer -- ...`; the executor identity may not author it. Every PR write must include `--repo codeacme17/echo-ui` (or use the full configured PR URL). Use `gh pr review --comment --body <body>` for a body-only review and include the exact run/cycle/round/head marker; the gate rejects body files, skipped rounds, duplicates, and publications outside the next durable cycle. When file/line findings require inline comments, use only the reviewer's gated `POST repos/codeacme17/echo-ui/pulls/<recorded-pr>/reviews` API shape with `event=COMMENT`, the exact durable head, and validated inline fields. Post findings verbatim as one review plus inline comments. Each finding needs a run-wide stable ID, severity, confidence, evidence, and expected resolution. Record the GitHub review URL for each round. Accepted repairs must start after that round was submitted, and executor replies must be posted through the automation wrapper after the corresponding `$implement` invocation finishes. Follow `review/REVIEW.md` and `review/response-policy.md`. +Publish every round through the installed wrapper with role `reviewer`; the executor identity may not author it. Every PR write must include `--repo codeacme17/echo-ui` (or use the full configured PR URL). Use `gh pr review --comment --body <body>` for a body-only review and include the exact run/cycle/round/head marker; the gate rejects body files, skipped rounds, duplicates, and publications outside the next durable cycle. When file/line findings require inline comments, use only the reviewer's gated `POST repos/codeacme17/echo-ui/pulls/<recorded-pr>/reviews` API shape with `event=COMMENT`, the exact durable head, and validated inline fields. Post findings verbatim as one review plus inline comments. Each finding needs a run-wide stable ID, severity, confidence, evidence, and expected resolution. Record the GitHub review URL for each round. Accepted repairs must start after that round was submitted, and executor replies must be posted through the automation wrapper after the corresponding `$implement` invocation finishes. Follow `review/REVIEW.md` and `review/response-policy.md`. The executor must classify every finding as `accepted`, `rejected`, `needs-human`, `stale`, or `already-fixed`: @@ -57,11 +57,13 @@ The executor must classify every finding as `accepted`, `rejected`, `needs-human ## Verify and notify the owner -Run verification appropriate to the change and require `pnpm verify` before the PR is ready for owner review. Commit sanitized run metadata and relevant `screen-shots` to the issue branch. Wait for the exact-head `Issue dev loop evidence` workflow, download its artifact, and run `loopctl.mjs record-evidence --run-id <id> --manifest <absolute-path> --publication-url <artifact-url>`. After the fresh reviewer and all comment responses are posted, run `loopctl.mjs record-review --run-id <id> --result <absolute-path> --review-url <github-review-url>`. Both gates must name the current PR head. Emit a blocking `pr_ready_for_review` notification, then transition from `waiting_for_owner` to `awaiting_owner_review` with the PR URL and exact head SHA. +Run verification appropriate to the change and require `pnpm verify` before the PR is ready for owner review. Commit sanitized run metadata and relevant `screen-shots` to the issue branch. Wait for the low-privilege `pull_request` evidence workflow: it prepares protected dependencies with lifecycle scripts disabled and runs candidate `pnpm verify` plus owner-merged baseline `pnpm test` in separate no-network Docker volumes with no GitHub token or host-checkout mount. The base-checkout generator binds candidate head, workflow-run SHA, and owner-merged base SHA. Download its artifact and run `loopctl.mjs record-evidence --run-id <id> --manifest <absolute-path> --publication-url <artifact-url>` from the exact artifact-head worktree; the installed control plane revalidates the protected diff locally before querying GitHub. + +Before publishing the final review round, write the complete cycle result with an unassigned final `reviewUrl`, then run `loopctl.mjs review-digest --result <absolute-path>`. Add the returned marker to the final review body, publish the non-approving review, replace the unassigned URL with GitHub's actual review URL, and confirm `review-digest` is unchanged. After all responses are posted, run `loopctl.mjs record-review --run-id <id> --result <absolute-path> --review-url <github-review-url>`. The publication digest deliberately canonicalizes GitHub-assigned review URLs while the stored full-file digest still protects the final artifact. Both evidence and review gates must name the current PR head. Emit a blocking `pr_ready_for_review` notification, then transition from `waiting_for_owner` to `awaiting_owner_review` with the PR URL and exact head SHA. The owner is the only actor allowed to approve or merge. Never call `gh pr merge`, enable auto-merge, push to `main`, push to `dev`, dismiss owner feedback, or bypass branch protections. Before any terminal transition, run `prepare-finalization`, publish its exact body to the configured state-journal issue, and validate the returned comment URL with `record-finalization`. A completed run passes the same result and comment URL to `observe-owner-merge`, which queries GitHub and requires both `codeacme17`'s approval and merge at the reviewed head SHA. Future workspaces run `reconcile` to rebuild local history and evolve metrics from those automation-authored journal comments. -For any pause, do not resume from silence or an arbitrary comment. First require a successfully delivered blocking notification. Then verify the owner's GitHub decision with `loopctl.mjs record-owner-response --run-id <id> --response-url <comment-or-review-url>`. A normal comment must include the exact `RESUME <run-id>` token printed in the notification; a `CHANGES_REQUESTED` review is itself an explicit decision. Only then may `loopctl.mjs transition --run-id <id> --status running` continue. Before any repair work or new push, publish that transition's checkpoint, run the unchanged exact PR through `gh pr ready --undo --repo codeacme17/echo-ui`, observe the same head as Draft with `record-pr`, and publish another checkpoint. `$implement` repair attestations and later PR rebinds are rejected unless this durable redraft happened after the current owner response. +For any pause, do not resume from silence or an arbitrary comment. First require a successfully delivered blocking notification. Then verify the owner's GitHub decision with `loopctl.mjs record-owner-response --run-id <id> --response-url <comment-or-review-url>`. A normal comment must include the exact `RESUME <run-id>` token printed in the notification; a `CHANGES_REQUESTED` review is itself an explicit decision and must be submitted against the run's current exact head SHA. Only then may `loopctl.mjs transition --run-id <id> --status running` continue. Before any repair work or new push, publish that transition's checkpoint, run the unchanged exact PR through `gh pr ready --undo --repo codeacme17/echo-ui`, observe the same head as Draft with `record-pr`, and publish another checkpoint. `$implement` repair attestations and later PR rebinds are rejected unless this durable redraft happened after the current owner response. ## Stop conditions diff --git a/loops/issue-dev-loop/dependencies.md b/loops/issue-dev-loop/dependencies.md index fc995c51..94f9ad24 100644 --- a/loops/issue-dev-loop/dependencies.md +++ b/loops/issue-dev-loop/dependencies.md @@ -12,6 +12,8 @@ - GitHub CLI (`gh`) authenticated for issue, Actions artifact download, branch, PR, review, and comment work - `ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR` pointing to the executor's dedicated `gh` profile - `ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR` pointing to the reviewer's dedicated `gh` profile +- `ECHO_UI_LOOP_CONTROL_PLANE` pointing to the installed `issue-dev-loop` control-plane directory outside every repository worktree +- `ECHO_UI_LOOP_TARGET_ROOT` pointing to the active worktree's `loops/issue-dev-loop` directory - Repository trust enabled so project `.codex` agents can load - A dedicated GitHub issue configured as `stateIssueNumber` for append-only active checkpoints and finalization records @@ -24,4 +26,15 @@ Use `Ethandasw` for executor-created branches, PRs, replies, and durable journal entries, and `Traviinam` for fresh-context review publication. Neither may be `codeacme17`; neither may have branch-protection bypass or merge authority. The executor needs repository write access. The reviewer needs no repository write access. -Never run `gh auth setup-git` for this loop. Route commands through the executable shell launcher `scripts/with-github-identity`; it removes Node preload hooks before selecting an identity, scopes `GH_CONFIG_DIR` and the Git credential helper to one allowlisted child tree, gates descendant `git`/`gh` calls, and leaves the user's default `gh` account and global Git credential configuration unchanged. +After this loop is owner-reviewed and merged, update a clean `dev` checkout to the exact `origin/dev` commit and install a new versioned control plane: + +```bash +node loops/issue-dev-loop/scripts/install-trusted-control-plane.mjs \ + --target "/absolute/path/outside-the-repository/echo-ui-loop-<dev-sha>" +export ECHO_UI_LOOP_CONTROL_PLANE="/absolute/path/outside-the-repository/echo-ui-loop-<dev-sha>/issue-dev-loop" +export ECHO_UI_LOOP_TARGET_ROOT="$PWD/loops/issue-dev-loop" +``` + +The installer refuses a dirty checkout, a branch other than `dev`, a commit other than `origin/dev`, an in-repository target, or an existing target. It pins absolute Node/Git/GitHub CLI executables, copies the owner channel and runtime, hashes every installed file, and makes the bundle read-only. Install updates only after an owner-merged loop-control change; never install control code from an issue branch. + +Never run `gh auth setup-git` for this loop. Route commands through `"$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" --loop-root "$ECHO_UI_LOOP_TARGET_ROOT" <role> -- ...`. The repository copy is installer source and intentionally refuses credential use. The installed launcher verifies its manifest before selecting an identity, removes Node preload hooks, scopes `GH_CONFIG_DIR` and the Git credential helper to one allowlisted child tree, gates descendant `git`/`gh` calls, and leaves the user's default `gh` account and global Git credential configuration unchanged. diff --git a/loops/issue-dev-loop/evolve/EVOLVE.md b/loops/issue-dev-loop/evolve/EVOLVE.md index d6710b80..160ff719 100644 --- a/loops/issue-dev-loop/evolve/EVOLVE.md +++ b/loops/issue-dev-loop/evolve/EVOLVE.md @@ -9,7 +9,7 @@ The evolve session may propose: - improve non-authority-changing scripts and templates - update dashboards or metrics derived from append-only logs -Every evolve change requires a dedicated `codex/evolve-<request-id>` draft PR targeting `dev`, containing `<!-- issue-dev-loop:evolve-request:<request-id> -->`, and reviewed and merged by the owner. Before creating the branch, run `loopctl prepare-evolve-request --request-id <id>`, publish its exact body to the configured state-journal issue through the automation identity, then bind the returned comment with `loopctl record-evolve-request --request-id <id> --comment-url <url>`. The router re-fetches that automation-authored digest-bound publication before both push and PR creation; local metrics or request files alone never grant mutation authority. Push and create the PR only through `loops/issue-dev-loop/scripts/with-github-identity automation -- ...`; the router authorizes only the published request's exact branch, `--base dev`, and `--draft`. The owner decides when to mark this dedicated evolve PR ready and whether to merge it. In particular, never change the following without explicit owner confirmation: +Every evolve change requires a dedicated `codex/evolve-<request-id>` draft PR targeting `dev`, containing `<!-- issue-dev-loop:evolve-request:<request-id> -->`, and reviewed and merged by the owner. Before creating the branch, run `loopctl prepare-evolve-request --request-id <id>`, publish its exact body to the configured state-journal issue through the automation identity, then bind the returned comment with `loopctl record-evolve-request --request-id <id> --comment-url <url>`. The installed router re-fetches that automation-authored digest-bound publication before both push and PR creation; local metrics or request files alone never grant mutation authority. Push and create the PR only through the currently installed trusted launcher with explicit `--loop-root "$ECHO_UI_LOOP_TARGET_ROOT"`; the router authorizes only the published request's exact branch, `--base dev`, and `--draft`. The owner decides when to mark this dedicated evolve PR ready and whether to merge it. After an evolve PR is owner-merged, install a fresh versioned control plane from a clean `dev` checkout at exact `origin/dev`; issue work must continue using the previous installation until then. In particular, never change the following without explicit owner confirmation: - goals, completion criteria, authority, or stop conditions - merge, release, security, privacy, or dependency policy diff --git a/loops/issue-dev-loop/references/evidence-policy.md b/loops/issue-dev-loop/references/evidence-policy.md index d7df7c9e..d57bb18b 100644 --- a/loops/issue-dev-loop/references/evidence-policy.md +++ b/loops/issue-dev-loop/references/evidence-policy.md @@ -8,6 +8,7 @@ Evidence proves the acceptance criteria against the exact PR head SHA. - commands executed, exit codes, and UTC timestamps - targeted test results - final `pnpm verify` result +- owner-merged baseline test result from the isolated low-privilege workflow - independent review verdict and finding IDs in the combined PR evidence (the separately published, exact-head review gate is authoritative) - known limitations or checks that could not run @@ -24,6 +25,6 @@ Use descriptive names such as `02-after-player-mobile-375.webp` under `screen-sh ## Storage -Commit issue-relevant PNG/WebP screenshots and their metadata under `screen-shots/<run-id>` before owner review. The `issue-dev-loop-evidence.yml` workflow checks out the exact PR head, runs `pnpm verify`, generates `evidence/<run-id>/manifest.json` in the runner, and uploads the manifest, sanitized run log, and screenshots as a GitHub Actions artifact. Download the artifact, then pass its URL to `record-evidence`; never accept an artifact from a different head SHA. +Commit issue-relevant PNG/WebP screenshots and their metadata under `screen-shots/<run-id>` before owner review. The low-privilege `issue-dev-loop-evidence.yml` workflow runs on `pull_request`, checks out owner-merged base code and the exact PR head separately with persisted credentials disabled, prepares protected dependencies with lifecycle scripts disabled, and snapshots two independent Docker volumes. Candidate `pnpm verify` and owner-merged baseline `pnpm test` run in no-network containers without GitHub tokens or host-checkout mounts. Only after those containers exit does the host-side base-checkout script generate and upload the manifest, sanitized run log, test logs, and screenshots. Download the artifact, then pass its URL to `record-evidence` from the exact artifact-head worktree; never accept an artifact from a different candidate head, workflow-run SHA, or owner-merged base SHA. -`record-evidence` queries the GitHub artifact and workflow-run APIs, requires the named evidence workflow to conclude successfully for the recorded PR/branch/head, downloads the artifact through `gh run download`, and byte-compares its manifest with the supplied file. Failed CI artifacts and locally fabricated manifests are rejected. The independent reviewer posts findings and responses to GitHub after the draft PR exists. Save every round's unique review URL in the structured cycle result and use the final one with `record-review --review-url <github-review-url>`; the runtime binds each round to its author, submission time, exact head, comments, repairs, and replies. Keep raw local command output, videos, traces, and bulky reports in ignored runtime directories. Never upload secrets, cookies, auth state, user data, or unredacted environment dumps. +`record-evidence` first executes the installed candidate-control validator against the exact local Git commit, so an artifact cannot be accepted when the PR changed the workflow, loop runtime, package scripts, lockfile, package hooks, or verification configuration. It then queries the GitHub artifact and workflow-run APIs, requires the named low-privilege workflow to conclude successfully for the recorded PR/base/head and workflow-run SHA, downloads the artifact through `gh run download`, and byte-compares its manifest with the supplied file. Failed CI artifacts and locally fabricated manifests are rejected. The independent reviewer posts findings and responses to GitHub after the draft PR exists. Save every round's unique review URL in the structured cycle result and use the final one with `record-review --review-url <github-review-url>`; the runtime binds each round to its author, submission time, exact head, comments, repairs, and replies. The final review marker uses `review-digest`, whose canonical digest excludes only GitHub-assigned review URLs; insert the returned final URL after publication and verify the digest is unchanged. Keep raw local command output, videos, traces, and bulky reports in ignored runtime directories. Never upload secrets, cookies, auth state, user data, or unredacted environment dumps. diff --git a/loops/issue-dev-loop/references/github-operations.md b/loops/issue-dev-loop/references/github-operations.md index 2ffdd448..85df60fa 100644 --- a/loops/issue-dev-loop/references/github-operations.md +++ b/loops/issue-dev-loop/references/github-operations.md @@ -5,15 +5,17 @@ Read this before mutating issues or pull requests. Run every executor GitHub command through: ```text -loops/issue-dev-loop/scripts/with-github-identity automation -- <command> [args...] +"$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" --loop-root "$ECHO_UI_LOOP_TARGET_ROOT" automation -- <command> [args...] ``` -Run every reviewer publication command through the same wrapper with role `reviewer`. The wrapper selects the configured `gh` profile, removes token environment overrides, runs `gh api user`, and refuses an unexpected or owner identity. For Git, it clears global credential helpers and injects `gh auth git-credential` for the entire trusted child tree. Descendant `git` and `gh` processes pass through a role gate; arbitrary `sh`, `env`, and Node command trees are not authenticated. Never use owner credentials for executor or reviewer actions, never run raw remote `gh`/`git push` commands, and never call `gh auth setup-git`. +`ECHO_UI_LOOP_CONTROL_PLANE` must name the versioned installation created from a clean owner-merged `dev`; `ECHO_UI_LOOP_TARGET_ROOT` names the active worktree's `loops/issue-dev-loop`. Operational `loopctl` and trigger commands must use the scripts inside the installed root and pass `--loop-root "$ECHO_UI_LOOP_TARGET_ROOT"`. The repository launcher intentionally refuses credentials. + +Run every reviewer publication command through the installed wrapper with role `reviewer`. Before reading either profile, it verifies every installed file, pins absolute Node/Git/`gh` executables, compares the target's security-critical owner-channel values to its trusted copy, removes token environment overrides, runs `gh api user`, and refuses an unexpected or owner identity. For Git, it clears global credential helpers and injects `gh auth git-credential` for the entire trusted child tree. Descendant `git` and `gh` processes pass through a role gate; arbitrary `sh`, `env`, Node scripts, caller PATH shims, and issue-worktree router changes are not authenticated. Never use owner credentials for executor or reviewer actions, never run raw remote `gh`/`git push` commands, and never call `gh auth setup-git`. Publish reviewer output only as a non-approving comment review: ```text -loops/issue-dev-loop/scripts/with-github-identity reviewer -- gh pr review <number> --repo codeacme17/echo-ui --comment --body <review-body> +"$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" --loop-root "$ECHO_UI_LOOP_TARGET_ROOT" reviewer -- gh pr review <number> --repo codeacme17/echo-ui --comment --body <review-body> ``` The shell launcher removes Node preload hooks before starting the router. The router then builds a strict child environment, rejects reviewer pushes and every reviewer mutation except a comment-only review on the recorded PR, and rejects approvals, change requests, merges, executor-authored reviews, GraphQL, and administration APIs. Reviewer publication requires the next run/cycle/round marker; the gate paginates existing reviews and rejects duplicates, skipped rounds, body files, or an omitted reviewer publication. A reviewer API write is permitted only for one exact-head `event=COMMENT` review with validated inline fields. Authenticated Git disables worktree hooks, fsmonitor, external diff, textconv, proxy environment variables, repository-local HTTP/proxy/cookie/header/SSL/URL rewrites, and the `ext` transport. Remote Git is restricted to exact origin push/fetch/issue-branch discovery shapes and executes against a canonical configured-repository HTTPS URL. Automation API mutations are limited to the current issue's exact claim label, the current issue/PR or journal comments, and current-PR review-comment replies. PR creation requires the durably authorized issue or published evolve request branch, explicit `--repo codeacme17/echo-ui`, `--base dev`, and `--draft`; later PR writes require the same explicit repository (or full configured PR URL), the exact durable checkpoint, the recorded live PR branch/base/head, and the phase-specific Draft/evidence/review gates. `pr edit` is limited to title/body updates and requesting `codeacme17`. Issue pushes derive `codex/issue-<number>` from the durable checkpoint and reject local branch overrides; evolve pushes require the automation-authored request publication. Every other push shape is rejected. @@ -40,16 +42,16 @@ The shell launcher removes Node preload hooks before starting the router. The ro ## Evidence artifact -The PR workflow `Issue dev loop evidence` runs only when the branch contains one active loop run. Wait for its exact-head run to complete, then locate and download the artifact: +The low-privilege `pull_request` workflow `Issue dev loop evidence` runs only when the branch contains one active issue run. Both checkouts disable persisted credentials. A base-checkout container-preparation step installs the protected lockfile with lifecycle scripts disabled and creates two independent Docker volumes before candidate code runs. Candidate `pnpm verify` and owner-merged baseline `pnpm test` run with `--network none`, no inherited GitHub token, and no mount of either host checkout. The base-checkout generator remains outside those containers and binds the manifest to candidate head, workflow-run SHA, and owner-merged base SHA. The installed control plane later rejects the artifact unless its own exact-head diff check proves that the PR did not change the loop plane, owner channel, workflow, verification scripts, package manifests, lock/workspace files, package hooks, or verification configuration. Wait for that exact-head run to complete, then locate and download the artifact: ```text -loops/issue-dev-loop/scripts/with-github-identity automation -- gh run list --workflow issue-dev-loop-evidence.yml --branch codex/issue-<number> -loops/issue-dev-loop/scripts/with-github-identity automation -- gh run download <run-database-id> --name issue-dev-loop-<run-id>-<head-sha> --dir loops/issue-dev-loop/evidence/<run-id> +"$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" --loop-root "$ECHO_UI_LOOP_TARGET_ROOT" automation -- gh run list --workflow issue-dev-loop-evidence.yml --branch codex/issue-<number> +"$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" --loop-root "$ECHO_UI_LOOP_TARGET_ROOT" automation -- gh run download <run-database-id> --name issue-dev-loop-<run-id>-<head-sha> --dir "$ECHO_UI_LOOP_TARGET_ROOT/evidence/<run-id>" ``` -Push only through `with-github-identity automation -- git push ...`. The wrapper rejects reviewer pushes, force pushes, and explicit pushes to `dev` or `main`. +Push only through the installed `with-github-identity ... automation -- git push ...` route. The wrapper rejects reviewer pushes, force pushes, and explicit pushes to `dev` or `main`. -Use the artifact URL emitted by `actions/upload-artifact` as `record-evidence --publication-url` and the downloaded artifact manifest as `--manifest`. The runtime downloads it independently, requires the workflow conclusion to be `success`, and byte-compares the manifest. Reject a workflow run whose PR, branch, workflow path, or `headSha` differs from the recorded run. +Use the artifact URL emitted by `actions/upload-artifact` as `record-evidence --publication-url` and the downloaded artifact manifest as `--manifest`. From a worktree whose `HEAD` is the artifact's exact candidate head, the installed runtime first validates the protected diff, then independently downloads the artifact, requires a successful low-privilege `pull_request` run of the named unchanged workflow, validates workflow-run SHA and owner-merged base SHA, and byte-compares the manifest. Reject a run whose PR, base, branch, candidate head, workflow path, or manifest digests differ from the recorded run. ## Prohibited commands @@ -69,7 +71,7 @@ Do not enable auto-merge, dismiss owner reviews, resolve disputed owner comments Use the originating issue for pre-PR questions and the PR for post-publication questions. Mention `@codeacme17`, include the notification ID and run ID, and link the exact evidence or decision needed. Only decisions from the configured owner identity satisfy an owner gate. -After a blocking notification, verify the reply URL with `record-owner-response`. Normal comments must include `RESUME <run-id>`; a `CHANGES_REQUESTED` review is an explicit response. The runtime requires that the notification was successfully delivered to the same run target before it accepts the reply. +After a blocking notification, verify the reply URL with `record-owner-response`. Normal comments must include `RESUME <run-id>`; a `CHANGES_REQUESTED` review is an explicit response only when its `commit_id` is the run's current head. The runtime requires that the notification was successfully delivered to the same run target before it accepts the reply. ## Durable state journal diff --git a/loops/issue-dev-loop/review/REVIEW.md b/loops/issue-dev-loop/review/REVIEW.md index ec4de1b7..16fe8901 100644 --- a/loops/issue-dev-loop/review/REVIEW.md +++ b/loops/issue-dev-loop/review/REVIEW.md @@ -23,7 +23,9 @@ Do not emit formatter noise, personal style preferences, speculative concerns, u ## Publication -The fresh reviewer publishes each round verbatim through `reviewerGitHubLogin` as one non-approving GitHub review, adding `<!-- issue-dev-loop:<run-id>:<finding-id> -->` for deduplication and `<!-- issue-dev-loop:<run-id>:review-cycle:<cycle>:round:<round>:head:<sha> -->` to the round body. A body-only round uses the gated `gh pr review --comment --body <body>` command; body files are rejected so the identity gate can validate the marker before publication. Findings with a concrete file and line must be posted in one gated `POST repos/<configured-repo>/pulls/<recorded-pr>/reviews` call whose event is exactly `COMMENT`, commit ID is the durable recorded head, and inline path/line/side/body fields carry the finding markers; arbitrary input files and every other mutating API shape are rejected. Cross-cutting findings remain in the review body. The reviewer identity must differ from the executor and owner. The final review body must also include `<!-- issue-dev-loop:<run-id>:review-result-sha256:<digest> -->`. It must not downgrade severity or omit findings. +The fresh reviewer publishes each round verbatim through `reviewerGitHubLogin` as one non-approving GitHub review, adding `<!-- issue-dev-loop:<run-id>:<finding-id> -->` for deduplication and `<!-- issue-dev-loop:<run-id>:review-cycle:<cycle>:round:<round>:head:<sha> -->` to the round body. A body-only round uses the gated `gh pr review --comment --body <body>` command; body files are rejected so the identity gate can validate the marker before publication. Findings with a concrete file and line must be posted in one gated `POST repos/<configured-repo>/pulls/<recorded-pr>/reviews` call whose event is exactly `COMMENT`, commit ID is the durable recorded head, and inline path/line/side/body fields carry the finding markers; arbitrary input files and every other mutating API shape are rejected. Cross-cutting findings remain in the review body. The reviewer identity must differ from the executor and owner. + +Before the final round is published, write the complete cycle result with a temporary/unassigned final `reviewUrl` and run the installed `loopctl review-digest --result <path>`. Put its returned `<!-- issue-dev-loop:<run-id>:review-result-sha256:<digest> -->` marker in the final body. After GitHub assigns the review URL, replace the temporary value and rerun `review-digest`; it must be unchanged. This canonical publication digest replaces only review URLs with a fixed placeholder and therefore avoids a URL↔digest cycle. The later recorded full-file digest still protects the final URLs and every other byte. The reviewer must not downgrade severity or omit findings. After all replies are posted, create one cycle result matching `result.schema.json`. Its `cycle` is the next durable review cycle number, and it contains every round's review URL, every finding, its final classification, response URL, and evidence. Executor replies include both the readable evidence (and accepted fix SHA) plus `<!-- issue-dev-loop:<run-id>:<finding-id>:<classification> -->`. Rejected P0/P1 findings additionally require an independent or owner comment containing `<!-- issue-dev-loop:<run-id>:<finding-id>:adjudication:<verdict> -->`. Run `record-review` with the final GitHub review URL; it paginates every reviewer-authored review on the recorded PR and requires one-to-one membership for the current run/cycle before it verifies replies, identities, timestamps, ancestry, adjudications, and markers. The publication gate rejects skipped or duplicate cycle rounds. Generic events cannot forge this reserved gate. diff --git a/loops/issue-dev-loop/review/response-policy.md b/loops/issue-dev-loop/review/response-policy.md index 86ac735c..7dd1a531 100644 --- a/loops/issue-dev-loop/review/response-policy.md +++ b/loops/issue-dev-loop/review/response-policy.md @@ -14,4 +14,4 @@ Rejected findings require a precise counterclaim and reproducible evidence in th Do not delete review comments. Log classification, response time, response URL, commit, and evidence reference under the run ID. -The final cycle file must match `result.schema.json` and include a unique GitHub review URL for every round. `record-review` accepts only chronologically ordered reviews published by the distinct configured reviewer identity, with every round bound to its own immutable head and marker. The last round must match the recorded PR head and carry the result digest. Earlier findings require verified executor-authored response URLs, classification markers, visible evidence, and review→repair→reply ordering; accepted fix commits must be `$implement`-attested ancestors of the reviewed head. +The final cycle file must match `result.schema.json` and include a unique GitHub review URL for every round. Compute the final publication marker with `review-digest` before GitHub assigns the final URL, insert the returned URL afterward, and verify that the canonical digest is unchanged. `record-review` accepts only chronologically ordered reviews published by the distinct configured reviewer identity, with every round bound to its own immutable head and marker. The last round must match the recorded PR head and carry that canonical publication digest; the recorded result artifact separately keeps a full-content digest. Earlier findings require verified executor-authored response URLs, classification markers, visible evidence, and review→repair→reply ordering; accepted fix commits must be `$implement`-attested ancestors of the reviewed head. diff --git a/loops/issue-dev-loop/review/reviewer-prompt.md b/loops/issue-dev-loop/review/reviewer-prompt.md index b79cba2a..57d8704d 100644 --- a/loops/issue-dev-loop/review/reviewer-prompt.md +++ b/loops/issue-dev-loop/review/reviewer-prompt.md @@ -1 +1 @@ -Review the Echo UI PR diff between `<base-sha>` and `<head-sha>` as an independent, fresh-context reviewer for review cycle `<cycle>`. Use `$code-review`. Read only the supplied issue snapshot, acceptance criteria, repository instructions, diff, CI results, and evidence manifest. Follow `loops/issue-dev-loop/review/REVIEW.md`. Do not edit files or rely on executor conversation history. Publish a body-only non-approving review through `loops/issue-dev-loop/scripts/with-github-identity reviewer -- gh pr review <number> --repo codeacme17/echo-ui --comment --body <review-body>` and include the exact run/cycle/round/head marker. If file/line findings require inline comments, use the same launcher with only the strict `gh api repos/codeacme17/echo-ui/pulls/<number>/reviews --method POST` COMMENT-review field shape documented by `REVIEW.md`. Never invoke the `.mjs` router directly, run raw `gh`, use `--body-file`, use `--input`, call any other mutating API, approve/request changes/merge, or alter global authentication. Return structured findings or `PASS` and the unique review URL. +Review the Echo UI PR diff between `<base-sha>` and `<head-sha>` as an independent, fresh-context reviewer for review cycle `<cycle>`. Use `$code-review`. Read only the supplied issue snapshot, acceptance criteria, repository instructions, diff, CI results, and evidence manifest. Follow `loops/issue-dev-loop/review/REVIEW.md`. Do not edit files or rely on executor conversation history. Publish a body-only non-approving review through `"$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" --loop-root "$ECHO_UI_LOOP_TARGET_ROOT" reviewer -- gh pr review <number> --repo codeacme17/echo-ui --comment --body <review-body>` and include the exact run/cycle/round/head marker. Before a final round, use the installed `loopctl review-digest` procedure so the marker is computed before GitHub assigns the final review URL. If file/line findings require inline comments, use the same installed launcher with only the strict `gh api repos/codeacme17/echo-ui/pulls/<number>/reviews --method POST` COMMENT-review field shape documented by `REVIEW.md`. Never use the repository launcher for credentials, invoke the `.mjs` router directly, run raw `gh`, use `--body-file`, use `--input`, call any other mutating API, approve/request changes/merge, or alter global authentication. Return structured findings or `PASS` and the unique review URL. diff --git a/loops/issue-dev-loop/schemas/evidence.schema.json b/loops/issue-dev-loop/schemas/evidence.schema.json index eda4f7a4..93941286 100644 --- a/loops/issue-dev-loop/schemas/evidence.schema.json +++ b/loops/issue-dev-loop/schemas/evidence.schema.json @@ -8,6 +8,8 @@ "issueNumber", "baseSha", "headSha", + "trustedWorkflowSha", + "workflowRunSha", "verdict", "checks", "screenshots", @@ -20,6 +22,8 @@ "issueNumber": { "type": "integer", "minimum": 1 }, "baseSha": { "type": "string", "pattern": "^[0-9a-fA-F]{40}$" }, "headSha": { "type": "string", "pattern": "^[0-9a-fA-F]{40}$" }, + "trustedWorkflowSha": { "type": "string", "pattern": "^[0-9a-fA-F]{40}$" }, + "workflowRunSha": { "type": "string", "pattern": "^[0-9a-fA-F]{40}$" }, "verdict": { "enum": ["passed", "failed", "blocked"] }, "checks": { "type": "array", diff --git a/loops/issue-dev-loop/scripts/generate-evidence.mjs b/loops/issue-dev-loop/scripts/generate-evidence.mjs index 485e50d8..d9fb32ce 100644 --- a/loops/issue-dev-loop/scripts/generate-evidence.mjs +++ b/loops/issue-dev-loop/scripts/generate-evidence.mjs @@ -11,8 +11,14 @@ const args = parseArguments(process.argv.slice(2)) const loopRoot = args['loop-root'] ? path.resolve(args['loop-root']) : DEFAULT_LOOP_ROOT const runId = assertNonEmpty(args['run-id'], '--run-id') const headSha = assertNonEmpty(args['head-sha'], '--head-sha') +const trustedWorkflowSha = assertNonEmpty(args['trusted-workflow-sha'], '--trusted-workflow-sha') +const workflowRunSha = assertNonEmpty(args['workflow-run-sha'], '--workflow-run-sha') const status = assertNonEmpty(args.status, '--status') +const baselineStatus = assertNonEmpty(args['baseline-status'], '--baseline-status') if (!['passed', 'failed', 'blocked'].includes(status)) throw new Error('unsupported --status') +if (!['passed', 'failed', 'blocked'].includes(baselineStatus)) { + throw new Error('unsupported --baseline-status') +} const run = JSON.parse( await readFile(path.join(loopRoot, 'logs', 'runs', runId, 'run.json'), 'utf8'), @@ -21,6 +27,12 @@ if (!run.briefDigest || !run.implementationCommit) { throw new Error('evidence generation requires the frozen brief and implementation gates') } if (!/^[0-9a-f]{40}$/i.test(headSha)) throw new Error('evidence headSha must be a full Git SHA') +if (!/^[0-9a-f]{40}$/i.test(trustedWorkflowSha)) { + throw new Error('evidence trustedWorkflowSha must be a full Git SHA') +} +if (!/^[0-9a-f]{40}$/i.test(workflowRunSha)) { + throw new Error('evidence workflowRunSha must be a full Git SHA') +} if (process.env.GITHUB_ACTIONS === 'true') { const repositoryRoot = path.resolve(loopRoot, '..', '..') const currentHead = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repositoryRoot }) @@ -197,6 +209,8 @@ const manifest = { issueNumber: run.issueNumber, baseSha: run.baseSha, headSha, + trustedWorkflowSha, + workflowRunSha, verdict: status, checks: [ ...targetedChecks, @@ -208,6 +222,14 @@ const manifest = { finishedAt: assertNonEmpty(args['finished-at'], '--finished-at'), artifactUrl: null, }, + { + command: 'pnpm test (owner-merged baseline tests)', + status: baselineStatus, + exitCode: baselineStatus === 'passed' ? 0 : 1, + startedAt: assertNonEmpty(args['started-at'], '--started-at'), + finishedAt: assertNonEmpty(args['finished-at'], '--finished-at'), + artifactUrl: null, + }, ], screenshots, limitations: [], diff --git a/loops/issue-dev-loop/scripts/github-command-gate.mjs b/loops/issue-dev-loop/scripts/github-command-gate.mjs index 8d5a8410..79bffefd 100755 --- a/loops/issue-dev-loop/scripts/github-command-gate.mjs +++ b/loops/issue-dev-loop/scripts/github-command-gate.mjs @@ -1,16 +1,14 @@ #!/usr/bin/env node import { spawn } from 'node:child_process' -import path from 'node:path' -import { fileURLToPath } from 'node:url' import { assertDescendantCommandPolicy, assertPushTargetsRepository, assertSafeRemoteGitConfiguration, hardenedGitArguments, - resolveExecutable, } from './lib/github-identity.mjs' +import { loadTrustedControlPlane } from './lib/trusted-control-plane.mjs' async function main() { const [tool, ...args] = process.argv.slice(2) @@ -27,14 +25,12 @@ async function main() { if (!authorization?.expectedRepository) { throw new Error('authenticated command gate has invalid authorization context') } - const identityBinDirectory = path.dirname(fileURLToPath(import.meta.url)) const executableName = tool === 'credential' ? 'gh' : tool if (!['git', 'gh'].includes(executableName)) { throw new Error(`unsupported authenticated tool: ${tool}`) } - const executable = await resolveExecutable(executableName, process.env, { - skipDirectories: [path.join(identityBinDirectory, 'identity-bin')], - }) + const trustedControlPlane = await loadTrustedControlPlane() + const executable = trustedControlPlane.executables[executableName] const executableArgs = tool === 'credential' ? ['auth', 'git-credential'] diff --git a/loops/issue-dev-loop/scripts/install-trusted-control-plane.mjs b/loops/issue-dev-loop/scripts/install-trusted-control-plane.mjs new file mode 100644 index 00000000..0aff3222 --- /dev/null +++ b/loops/issue-dev-loop/scripts/install-trusted-control-plane.mjs @@ -0,0 +1,221 @@ +#!/usr/bin/env node + +import { execFile } from 'node:child_process' +import { createHash, randomBytes } from 'node:crypto' +import { constants } from 'node:fs' +import { + access, + chmod, + cp, + lstat, + mkdir, + readFile, + readdir, + realpath, + rename, + rm, + writeFile, +} from 'node:fs/promises' +import path from 'node:path' +import { promisify } from 'node:util' +import { fileURLToPath } from 'node:url' + +const execFileAsync = promisify(execFile) +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)) +const sourceLoopRoot = path.resolve(scriptDirectory, '..') +const repositoryRoot = path.resolve(sourceLoopRoot, '..', '..') + +function parseTarget(argv) { + const index = argv.indexOf('--target') + const value = index === -1 ? null : argv[index + 1] + if (!value || !path.isAbsolute(value)) { + throw new Error('usage: install-trusted-control-plane.mjs --target <absolute-empty-directory>') + } + return path.resolve(value) +} + +async function resolveExecutable(name) { + for (const directory of (process.env.PATH ?? '').split(path.delimiter)) { + if (!directory) continue + const candidate = path.join(directory, name) + try { + await access(candidate, constants.X_OK) + return realpath(candidate) + } catch (error) { + if (error?.code !== 'ENOENT' && error?.code !== 'EACCES') throw error + } + } + throw new Error(`required executable is unavailable during trusted installation: ${name}`) +} + +async function regularFiles(root) { + const files = [] + const visit = async (directory) => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const target = path.join(directory, entry.name) + if (entry.isDirectory()) await visit(target) + else if (entry.isFile() && !entry.isSymbolicLink()) files.push(target) + else throw new Error(`trusted control plane refuses non-regular source: ${target}`) + } + } + await visit(root) + return files +} + +async function directories(root, output = []) { + output.push(root) + for (const entry of await readdir(root, { withFileTypes: true })) { + if (entry.isDirectory()) await directories(path.join(root, entry.name), output) + } + return output +} + +function shellQuote(value) { + return `'${value.replaceAll("'", "'\\''")}'` +} + +async function main() { + const requestedTarget = parseTarget(process.argv.slice(2)) + await mkdir(path.dirname(requestedTarget), { recursive: true }) + const [canonicalTargetParent, canonicalRepositoryRoot] = await Promise.all([ + realpath(path.dirname(requestedTarget)), + realpath(repositoryRoot), + ]) + const targetBundleRoot = path.join(canonicalTargetParent, path.basename(requestedTarget)) + if ( + targetBundleRoot === canonicalRepositoryRoot || + targetBundleRoot.startsWith(`${canonicalRepositoryRoot}${path.sep}`) + ) { + throw new Error('trusted control plane must be installed outside the repository') + } + try { + await lstat(targetBundleRoot) + throw new Error('trusted control plane target already exists; choose a new versioned directory') + } catch (error) { + if (error?.code !== 'ENOENT') throw error + } + const temporaryBundleRoot = `${targetBundleRoot}.tmp-${process.pid}-${randomBytes(4).toString('hex')}` + const targetLoopRoot = path.join(temporaryBundleRoot, 'issue-dev-loop') + try { + await mkdir(targetLoopRoot, { recursive: true }) + await cp(path.join(sourceLoopRoot, 'scripts'), path.join(targetLoopRoot, 'scripts'), { + recursive: true, + force: false, + errorOnExist: true, + }) + await mkdir(path.join(targetLoopRoot, 'triggers'), { recursive: true }) + await cp( + path.join(sourceLoopRoot, 'triggers', 'detect-work.mjs'), + path.join(targetLoopRoot, 'triggers', 'detect-work.mjs'), + ) + const channelTarget = path.join(temporaryBundleRoot, '_shared', 'owner-channel', 'channel.json') + await mkdir(path.dirname(channelTarget), { recursive: true }) + await cp( + path.resolve(sourceLoopRoot, '..', '_shared', 'owner-channel', 'channel.json'), + channelTarget, + ) + const [gitExecutable, ghExecutable] = await Promise.all([ + resolveExecutable('git'), + resolveExecutable('gh'), + ]) + const [sourceCommitResult, sourceBranchResult, mergedDevResult, worktreeStatusResult] = + await Promise.all([ + execFileAsync(gitExecutable, ['rev-parse', 'HEAD'], { cwd: repositoryRoot }), + execFileAsync(gitExecutable, ['branch', '--show-current'], { cwd: repositoryRoot }), + execFileAsync(gitExecutable, ['rev-parse', 'refs/remotes/origin/dev'], { + cwd: repositoryRoot, + }), + execFileAsync(gitExecutable, ['status', '--porcelain'], { cwd: repositoryRoot }), + ]) + if ( + sourceBranchResult.stdout.trim() !== 'dev' || + sourceCommitResult.stdout.trim() !== mergedDevResult.stdout.trim() || + worktreeStatusResult.stdout.trim() + ) { + throw new Error( + 'trusted control plane installation requires a clean dev checkout at owner-merged origin/dev', + ) + } + const installedNode = await realpath(process.execPath) + const installedLauncher = path.join(targetLoopRoot, 'scripts', 'with-github-identity') + await writeFile( + installedLauncher, + [ + '#!/bin/sh', + '', + 'unset NODE_OPTIONS', + 'unset NODE_PATH', + '', + `exec ${shellQuote(installedNode)} ${shellQuote( + path.join(targetBundleRoot, 'issue-dev-loop', 'scripts', 'with-github-identity.mjs'), + )} "$@"`, + '', + ].join('\n'), + { encoding: 'utf8', mode: 0o555 }, + ) + const installedFiles = (await regularFiles(temporaryBundleRoot)).filter( + (target) => path.basename(target) !== 'trusted-control-plane.json', + ) + const files = [] + for (const target of installedFiles.sort()) { + files.push({ + path: path.relative(temporaryBundleRoot, target), + sha256: createHash('sha256') + .update(await readFile(target)) + .digest('hex'), + }) + } + const manifest = { + schemaVersion: 1, + sourceRepository: 'codeacme17/echo-ui', + sourceCommit: sourceCommitResult.stdout.trim(), + installedAt: new Date().toISOString(), + bundleRoot: targetBundleRoot, + controlPlaneRoot: path.join(targetBundleRoot, 'issue-dev-loop'), + executables: { + node: installedNode, + git: await realpath(gitExecutable), + gh: await realpath(ghExecutable), + }, + files, + } + await writeFile( + path.join(targetLoopRoot, 'trusted-control-plane.json'), + `${JSON.stringify(manifest, null, 2)}\n`, + 'utf8', + ) + for (const target of installedFiles) { + await chmod( + target, + target === installedLauncher || target.includes('/identity-bin/') ? 0o555 : 0o444, + ) + } + await chmod(path.join(targetLoopRoot, 'trusted-control-plane.json'), 0o444) + for (const directory of (await directories(temporaryBundleRoot)).reverse()) { + await chmod(directory, 0o555) + } + await rename(temporaryBundleRoot, targetBundleRoot) + process.stdout.write( + `${JSON.stringify({ + bundleRoot: targetBundleRoot, + controlPlaneRoot: path.join(targetBundleRoot, 'issue-dev-loop'), + sourceCommit: manifest.sourceCommit, + })}\n`, + ) + } catch (error) { + try { + for (const directory of await directories(temporaryBundleRoot)) { + await chmod(directory, 0o755) + } + } catch { + // Best-effort permission restoration for an interrupted pre-rename install. + } + await rm(temporaryBundleRoot, { recursive: true, force: true }) + throw error + } +} + +main().catch((error) => { + process.stderr.write(`${error.message}\n`) + process.exitCode = 1 +}) diff --git a/loops/issue-dev-loop/scripts/lib/evidence.mjs b/loops/issue-dev-loop/scripts/lib/evidence.mjs index 4e2b33ee..9bc78f7f 100644 --- a/loops/issue-dev-loop/scripts/lib/evidence.mjs +++ b/loops/issue-dev-loop/scripts/lib/evidence.mjs @@ -2,6 +2,7 @@ import { createHash } from 'node:crypto' import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' +import { fileURLToPath } from 'node:url' import { DEFAULT_LOOP_ROOT, @@ -24,6 +25,20 @@ import { appendValidatedEvent, readEvents, readRun } from './run-store.mjs' import { verifyLatestDurableCheckpoint } from './checkpoint-proof.mjs' const REVIEW_CLASSIFICATIONS = new Set(['accepted', 'rejected', 'stale', 'already-fixed']) +const ASSIGNED_REVIEW_URL = 'https://github.com/assigned-after-publication' +const moduleDirectory = path.dirname(fileURLToPath(import.meta.url)) + +export function reviewPublicationDigest(review) { + if (!review || typeof review !== 'object' || Array.isArray(review)) { + throw new Error('review publication digest requires an object') + } + const canonical = structuredClone(review) + if (!Array.isArray(canonical.rounds)) { + throw new Error('review publication digest requires rounds[]') + } + for (const round of canonical.rounds) round.reviewUrl = ASSIGNED_REVIEW_URL + return createHash('sha256').update(JSON.stringify(canonical)).digest('hex') +} async function defaultArtifactManifestLoader({ owner, repo, runId, artifactName }) { const temporary = await mkdtemp(path.join(tmpdir(), 'echo-ui-loop-artifact-')) @@ -54,6 +69,40 @@ async function defaultArtifactManifestLoader({ owner, repo, runId, artifactName } } +async function defaultCandidateControlPlaneVerifier({ loopRoot, runId, baseSha, headSha }) { + await execFileAsync( + process.execPath, + [ + path.resolve(moduleDirectory, '..', 'validate-candidate-control-plane.mjs'), + '--loop-root', + loopRoot, + '--run-id', + runId, + '--base-sha', + baseSha, + '--head-sha', + headSha, + ], + { maxBuffer: 4 * 1024 * 1024 }, + ) +} + +function workflowRepositoryMatches(repository, target) { + if ( + repository?.full_name?.toLowerCase() === `${target.owner}/${target.repo}`.toLowerCase() + ) { + return true + } + try { + return ( + new URL(repository?.url).pathname.toLowerCase() === + `/repos/${target.owner}/${target.repo}`.toLowerCase() + ) + } catch { + return false + } +} + function validateReviewEvidence(review, headSha) { if (!review || typeof review !== 'object' || Array.isArray(review)) { throw new Error('evidence review must be an object') @@ -187,6 +236,7 @@ export async function recordEvidence({ now = new Date(), githubApi = defaultGitHubApi, artifactManifestLoader = defaultArtifactManifestLoader, + candidateControlPlaneVerifier = defaultCandidateControlPlaneVerifier, checkpointVerifier = verifyLatestDurableCheckpoint, } = {}) { const normalizedRunId = assertRunId(runId) @@ -213,7 +263,9 @@ export async function recordEvidence({ manifest.schemaVersion !== 1 || manifest.runId !== normalizedRunId || manifest.issueNumber !== run.issueNumber || - manifest.baseSha !== run.baseSha + manifest.baseSha !== run.baseSha || + manifest.trustedWorkflowSha !== run.baseSha || + !/^[0-9a-f]{40}$/i.test(manifest.workflowRunSha ?? '') ) { throw new Error('evidence manifest does not match the run') } @@ -221,6 +273,15 @@ export async function recordEvidence({ if (!/^[0-9a-f]{40}$/i.test(headSha)) { throw new Error('manifest.headSha must be a full Git SHA') } + if (manifest.workflowRunSha !== headSha) { + throw new Error('manifest.workflowRunSha must equal the exact candidate head') + } + await candidateControlPlaneVerifier({ + loopRoot, + runId: normalizedRunId, + baseSha: run.baseSha, + headSha, + }) if (manifest.verdict !== 'passed') throw new Error('evidence manifest must have passed verdict') const publishedEvidenceUrl = assertHttpUrl(publicationUrl, 'publicationUrl') const artifactTarget = parseArtifactUrl(publishedEvidenceUrl) @@ -238,21 +299,20 @@ export async function recordEvidence({ String(artifact.id) !== artifactTarget.artifactId || artifact.expired === true || String(artifact.workflow_run?.id) !== artifactTarget.runId || - artifact.workflow_run?.head_sha !== headSha || artifact.name !== `issue-dev-loop-${normalizedRunId}-${headSha}` || workflowRun.id !== Number(artifactTarget.runId) || workflowRun.status !== 'completed' || workflowRun.conclusion !== 'success' || workflowRun.event !== 'pull_request' || - workflowRun.head_sha !== headSha || + workflowRun.head_sha !== manifest.workflowRunSha || workflowRun.head_branch !== run.branch || workflowRun.path?.split('@')[0] !== '.github/workflows/issue-dev-loop-evidence.yml' || !workflowRun.pull_requests?.some( (pullRequest) => pullRequest.number === pullTarget.number && pullRequest.base?.ref === 'dev' && - pullRequest.base?.repo?.full_name?.toLowerCase() === - `${pullTarget.owner}/${pullTarget.repo}`.toLowerCase(), + pullRequest.base?.sha === manifest.trustedWorkflowSha && + workflowRepositoryMatches(pullRequest.base?.repo, pullTarget), ) ) { throw new Error('evidence artifact metadata does not match the run and exact headSha') @@ -278,6 +338,9 @@ export async function recordEvidence({ if (!checks.some((check) => /^pnpm verify(?:\s|$)/.test(check.command))) { throw new Error('evidence checks must include pnpm verify') } + if (!checks.some((check) => check.command === 'pnpm test (owner-merged baseline tests)')) { + throw new Error('evidence checks must include the owner-merged baseline tests') + } const latestImplementation = runEvents.findLast( (event) => event.type === 'implementation_completed' && @@ -424,6 +487,7 @@ export async function recordReview({ throw new Error('reviewUrl must be a GitHub review on the recorded run PR') } const resultDigest = createHash('sha256').update(source).digest('hex') + const publicationDigest = reviewPublicationDigest(result) const channel = await readJson( path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'), ) @@ -440,9 +504,8 @@ export async function recordReview({ } const runEvents = await readEvents(loopRoot, normalizedRunId) const expectedCycle = - runEvents.filter( - (event) => event.type === 'review_completed' && event.status === 'passed', - ).length + 1 + runEvents.filter((event) => event.type === 'review_completed' && event.status === 'passed') + .length + 1 if (reviewSummary.cycle !== expectedCycle) { throw new Error(`review cycle must be the next durable cycle: ${expectedCycle}`) } @@ -480,7 +543,7 @@ export async function recordReview({ ) { throw new Error('review result must include every reviewer publication in this run cycle') } - const digestMarker = `<!-- issue-dev-loop:${normalizedRunId}:review-result-sha256:${resultDigest} -->` + const digestMarker = `<!-- issue-dev-loop:${normalizedRunId}:review-result-sha256:${publicationDigest} -->` const publications = new Map() const priorFindingIds = new Set() let previousSubmittedAt = -Infinity @@ -530,9 +593,7 @@ export async function recordReview({ throw new Error(`published GitHub review round ${round.round} has unrecorded findings`) } for (const comment of roundComments) { - const commentFindingIds = new Set( - comment.body?.match(/\bRVW-[0-9]+-[0-9]+-[0-9]+\b/g) ?? [], - ) + const commentFindingIds = new Set(comment.body?.match(/\bRVW-[0-9]+-[0-9]+-[0-9]+\b/g) ?? []) if ( !sameGitHubLogin(comment.user?.login, reviewerLogin) || commentFindingIds.size === 0 || @@ -708,6 +769,7 @@ export async function recordReview({ reviewUrl: publishedReviewUrl, resultPath: relativeResultPath, resultDigest, + publicationDigest, reviewCycle: reviewSummary.cycle, findingCount: reviewSummary.findingCount, reviewRounds: reviewSummary.rounds, @@ -719,6 +781,7 @@ export async function recordReview({ headSha, reviewUrl: publishedReviewUrl, resultDigest, + publicationDigest, cycle: reviewSummary.cycle, findingCount: reviewSummary.findingCount, rounds: reviewSummary.rounds, diff --git a/loops/issue-dev-loop/scripts/lib/github-identity.mjs b/loops/issue-dev-loop/scripts/lib/github-identity.mjs index 40e40046..c3c60285 100644 --- a/loops/issue-dev-loop/scripts/lib/github-identity.mjs +++ b/loops/issue-dev-loop/scripts/lib/github-identity.mjs @@ -156,11 +156,7 @@ export function hardenedGitArguments(args, { expectedRepository = null } = {}) { } if (subcommand.name === 'fetch') { const branch = args.at(-1) - return [ - 'fetch', - repositoryUrl, - `refs/heads/${branch}:refs/remotes/origin/${branch}`, - ] + return ['fetch', repositoryUrl, `refs/heads/${branch}:refs/remotes/origin/${branch}`] } if (subcommand.name === 'ls-remote') { return ['ls-remote', '--heads', repositoryUrl, 'refs/heads/codex/issue-*'] @@ -210,9 +206,7 @@ export function assertGitCommandPolicy(role, args, { authorization = null } = {} const isAllowedShape = subcommand.index === 0 && isLoopBranch && - [ - ['push', 'origin', branch], - ].some((expected) => sameArguments(args, expected)) + [['push', 'origin', branch]].some((expected) => sameArguments(args, expected)) if (!isAllowedShape) { throw new Error('GitHub automation may push only one explicit loop branch') } @@ -232,14 +226,7 @@ export function assertGitCommandPolicy(role, args, { authorization = null } = {} throw new Error(`git command is outside the authenticated ${role} command tree`) } - const readOnly = new Set([ - 'rev-parse', - 'status', - 'merge-base', - 'show', - 'diff', - 'log', - ]) + const readOnly = new Set(['rev-parse', 'status', 'merge-base', 'show', 'diff', 'log']) if (readOnly.has(subcommand.name)) return if ( subcommand.name === 'branch' && @@ -496,8 +483,7 @@ function pullRequestTargetMatches(target, authorization, expectedRepository, rep if (!Number.isInteger(expectedNumber)) return false if (/^\d+$/.test(target)) { return ( - Number(target) === expectedNumber && - repositoryInScope(repositoryOption, expectedRepository) + Number(target) === expectedNumber && repositoryInScope(repositoryOption, expectedRepository) ) } const parsed = parseGitHubTarget(target) @@ -550,11 +536,7 @@ function reviewerCommentReview(args, commandIndex, authorization) { } function reviewerCommentReviewAllowed(args, commandIndex, authorization, expectedRepository) { - const { parsed, body, publication } = reviewerCommentReview( - args, - commandIndex, - authorization, - ) + const { parsed, body, publication } = reviewerCommentReview(args, commandIndex, authorization) return ( parsed.valid && parsed.booleans.get('comment') === true && @@ -931,7 +913,7 @@ export function assertDescendantCommandPolicy({ role, tool, args, authorization throw new Error(`unsupported authenticated tool: ${tool}`) } -function assertRootCommandPolicy({ role, tool, args, loopRoot, authorization }) { +function assertRootCommandPolicy({ role, tool, args, loopRoot, trustedLoopRoot, authorization }) { if (tool === 'git') { assertGitCommandPolicy(role, args, { authorization }) return @@ -945,11 +927,19 @@ function assertRootCommandPolicy({ role, tool, args, loopRoot, authorization }) } if (role === 'automation' && tool === 'node') { const script = args[0] ? path.resolve(args[0]) : null + const targetLoopRoot = argumentAfter(args, '--loop-root') const allowedScripts = new Set([ - path.resolve(loopRoot, 'scripts', 'loopctl.mjs'), - path.resolve(loopRoot, 'triggers', 'detect-work.mjs'), + path.resolve(trustedLoopRoot, 'scripts', 'loopctl.mjs'), + path.resolve(trustedLoopRoot, 'triggers', 'detect-work.mjs'), ]) - if (script && allowedScripts.has(script)) return + if ( + script && + allowedScripts.has(script) && + targetLoopRoot && + path.resolve(targetLoopRoot) === path.resolve(loopRoot) + ) { + return + } } throw new Error(`command is outside the authenticated ${role} command tree`) } @@ -975,15 +965,29 @@ function shellQuote(value) { return `'${value.replaceAll("'", "'\\''")}'` } -async function resolveRequestedExecutable(command, environment) { - if (!command.includes(path.sep)) return resolveExecutable(command, environment) +async function resolveRequestedExecutable(command, trustedExecutables) { + const named = { + git: trustedExecutables.git, + gh: trustedExecutables.gh, + node: trustedExecutables.node, + } + if (named[command]) return named[command] + if (!path.isAbsolute(command)) { + throw new Error('requested executable is not pinned by the trusted control plane') + } const candidate = path.resolve(command) await access(candidate, constants.X_OK) - return realpath(candidate) + const resolved = await realpath(candidate) + if (!Object.values(named).includes(resolved)) { + throw new Error('requested executable is not pinned by the trusted control plane') + } + return resolved } -async function authenticatedToolForExecutable(executable, { realGit, realGh }) { - const realNode = await realpath(process.execPath) +async function authenticatedToolForExecutable( + executable, + { realGit, realGh, realNode = process.execPath }, +) { if (executable === realGit) return 'git' if (executable === realGh) return 'gh' if (executable === realNode) return 'node' @@ -1085,10 +1089,7 @@ async function readAuthorizationContext(loopRoot, channel) { } const issueNumber = run ? assertIssueNumber(run.issueNumber) : null const expectedIssueBranch = issueNumber === null ? null : `codex/issue-${issueNumber}` - if ( - run && - run.branch !== expectedIssueBranch - ) { + if (run && run.branch !== expectedIssueBranch) { throw new Error('active run branch must be derived from its durable issue number') } const pullTarget = run?.prUrl ? parseGitHubTarget(run.prUrl) : null @@ -1135,11 +1136,11 @@ function argumentAfter(args, name) { return index === -1 ? null : (args[index + 1] ?? null) } -function withRootCommandIntent(authorization, { tool, args, loopRoot }) { +function withRootCommandIntent(authorization, { tool, args, trustedLoopRoot }) { const script = args[0] ? path.resolve(args[0]) : null if ( tool !== 'node' || - script !== path.resolve(loopRoot, 'scripts', 'loopctl.mjs') || + script !== path.resolve(trustedLoopRoot, 'scripts', 'loopctl.mjs') || args[1] !== 'start' || authorization.issue !== null ) { @@ -1171,14 +1172,14 @@ function withRootCommandIntent(authorization, { tool, args, loopRoot }) { } } -function activationValidationRequested({ role, tool, args, loopRoot }) { +function activationValidationRequested({ role, tool, args, loopRoot, trustedLoopRoot }) { return ( role === 'automation' && tool === 'node' && - args.length === 3 && - path.resolve(args[0]) === path.resolve(loopRoot, 'scripts', 'loopctl.mjs') && + path.resolve(args[0]) === path.resolve(trustedLoopRoot, 'scripts', 'loopctl.mjs') && args[1] === 'validate' && - args[2] === '--activation' + args.includes('--activation') && + path.resolve(argumentAfter(args, '--loop-root') ?? '') === path.resolve(loopRoot) ) } @@ -1237,9 +1238,7 @@ function readyReturnsToDraft(args, commandIndex) { function hasExactHeadGate(events, eventType, headSha) { return events.some( (event) => - event.type === eventType && - event.status === 'passed' && - event.payload?.headSha === headSha, + event.type === eventType && event.status === 'passed' && event.payload?.headSha === headSha, ) } @@ -1324,9 +1323,8 @@ async function preflightPullRequestWrite({ if (['review', 'inline-review'].includes(intent.kind)) { const expectedCycle = - events.filter( - (event) => event.type === 'review_completed' && event.status === 'passed', - ).length + 1 + events.filter((event) => event.type === 'review_completed' && event.status === 'passed') + .length + 1 if ( livePullRequest.draft !== true || !intent.publication || @@ -1385,18 +1383,14 @@ async function preflightPullRequestWrite({ !hasExactHeadGate(events, 'verification_completed', run.headSha) || !hasExactHeadGate(events, 'review_completed', run.headSha) ) { - throw new Error('owner review request requires a ready PR with exact-head evidence and review') + throw new Error( + 'owner review request requires a ready PR with exact-head evidence and review', + ) } } } -async function preflightIssueBranchPush({ - args, - authorization, - loopRoot, - realGh, - environment, -}) { +async function preflightIssueBranchPush({ args, authorization, loopRoot, realGh, environment }) { if (gitSubcommand(args).name !== 'push' || !authorization.issue?.runId) return const runId = authorization.issue.runId const events = await readEvents(loopRoot, runId) @@ -1474,9 +1468,10 @@ export async function assertGitHubRoleIdentity({ role, environment = process.env, identityCommand = execFileAsync, + ghExecutable = 'gh', }) { const resolved = resolveGitHubRoleEnvironment({ channel, role, environment }) - const { stdout } = await identityCommand('gh', ['api', 'user', '--jq', '.login'], { + const { stdout } = await identityCommand(ghExecutable, ['api', 'user', '--jq', '.login'], { env: resolved.routedEnvironment, maxBuffer: 1024 * 1024, }) @@ -1491,9 +1486,31 @@ export async function assertGitHubRoleIdentity({ return { ...resolved, authenticatedLogin } } +async function assertTargetChannelMatchesTrusted(loopRoot, trustedChannel) { + const targetChannel = await readOwnerChannel(loopRoot) + for (const field of [ + 'schemaVersion', + 'ownerGitHubLogin', + 'automationGitHubLogin', + 'reviewerGitHubLogin', + 'automationGitHubConfigEnvironmentVariable', + 'reviewerGitHubConfigEnvironmentVariable', + 'stateIssueNumber', + 'repository', + 'canonicalChannel', + 'webhookEnvironmentVariable', + 'immediateTypes', + ]) { + if (JSON.stringify(targetChannel[field]) !== JSON.stringify(trustedChannel[field])) { + throw new Error(`target loop changes trusted owner-channel field: ${field}`) + } + } +} + export async function runWithGitHubRole({ loopRoot, channel, + trustedControlPlane, role, command, args = [], @@ -1501,23 +1518,37 @@ export async function runWithGitHubRole({ spawnCommand = spawn, }) { const requestedCommand = assertNonEmpty(command, 'command') - const resolved = await assertGitHubRoleIdentity({ channel, role, environment }) - const [realGit, realGh] = await Promise.all([ - resolveExecutable('git', resolved.routedEnvironment), - resolveExecutable('gh', resolved.routedEnvironment), - ]) - const executable = await resolveRequestedExecutable(requestedCommand, resolved.routedEnvironment) - const tool = await authenticatedToolForExecutable(executable, { realGit, realGh }) + if (!trustedControlPlane?.loopRoot || !trustedControlPlane?.executables) { + throw new Error('authenticated GitHub routing requires an installed trusted control plane') + } + await assertTargetChannelMatchesTrusted(loopRoot, channel) + const { git: realGit, gh: realGh, node: realNode } = trustedControlPlane.executables + const resolved = await assertGitHubRoleIdentity({ + channel, + role, + environment, + ghExecutable: realGh, + }) + const executable = await resolveRequestedExecutable( + requestedCommand, + trustedControlPlane.executables, + ) + const tool = await authenticatedToolForExecutable(executable, { + realGit, + realGh, + realNode, + }) const authorization = withRootCommandIntent(await readAuthorizationContext(loopRoot, channel), { tool, args, - loopRoot, + trustedLoopRoot: trustedControlPlane.loopRoot, }) assertRootCommandPolicy({ role, tool, args, loopRoot, + trustedLoopRoot: trustedControlPlane.loopRoot, authorization, }) const activationValidation = activationValidationRequested({ @@ -1525,12 +1556,14 @@ export async function runWithGitHubRole({ tool, args, loopRoot, + trustedLoopRoot: trustedControlPlane.loopRoot, }) if (activationValidation) { await assertGitHubRoleIdentity({ channel, role: 'reviewer', environment, + ghExecutable: realGh, identityCommand: (_command, identityArgs, options) => execFileAsync(realGh, identityArgs, options), }) @@ -1555,10 +1588,7 @@ export async function runWithGitHubRole({ environment: resolved.routedEnvironment, }) } - if ( - tool === 'git' && - ['push', 'fetch', 'ls-remote'].includes(gitSubcommand(args).name) - ) { + if (tool === 'git' && ['push', 'fetch', 'ls-remote'].includes(gitSubcommand(args).name)) { await preflightIssueBranchPush({ args, authorization, @@ -1578,17 +1608,19 @@ export async function runWithGitHubRole({ } const childEnvironment = { ...resolved.routedEnvironment, - PATH: `${identityBinDirectory}${path.delimiter}${resolved.routedEnvironment.PATH ?? ''}`, + PATH: identityBinDirectory, ECHO_UI_LOOP_GITHUB_ROLE: role, ECHO_UI_LOOP_IDENTITY_GATE: commandGatePath, - ECHO_UI_LOOP_NODE: process.execPath, + ECHO_UI_LOOP_NODE: realNode, ECHO_UI_LOOP_AUTHORIZATION: JSON.stringify(authorization), } let executionArgs = tool === 'git' ? hardenedGitArguments(args, { expectedRepository: channel.repository }) : [...args] - if (activationValidation) executionArgs = [args[0], 'validate'] + if (activationValidation) { + executionArgs = [args[0], 'validate', '--loop-root', path.resolve(loopRoot)] + } const child = spawnCommand(executable, executionArgs, { env: childEnvironment, stdio: 'inherit', diff --git a/loops/issue-dev-loop/scripts/lib/github.mjs b/loops/issue-dev-loop/scripts/lib/github.mjs index 8d47e00e..8c210fd9 100644 --- a/loops/issue-dev-loop/scripts/lib/github.mjs +++ b/loops/issue-dev-loop/scripts/lib/github.mjs @@ -82,6 +82,20 @@ async function loadJsonFile(target) { return JSON.parse(await readFile(path.resolve(target), 'utf8')) } +export async function loadPaginatedGitHubCollection( + endpoint, + { execute = execFileAsync } = {}, +) { + const result = await execute('gh', ['api', '--paginate', '--slurp', endpoint], { + maxBuffer: 16 * 1024 * 1024, + }) + const pages = JSON.parse(result.stdout) + if (!Array.isArray(pages) || pages.some((page) => !Array.isArray(page))) { + throw new Error('GitHub pagination did not return an array of pages') + } + return pages.flat() +} + export async function detectWork({ loopRoot = DEFAULT_LOOP_ROOT, issuesFile, @@ -135,41 +149,40 @@ export async function detectWork({ let issues let pullRequests let branchNames = [] + const configuredRepository = + repo ?? + (await readJson(path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'))) + .repository if (issuesFile) { issues = await loadJsonFile(issuesFile) } else { - const argumentsList = [ - 'issue', - 'list', - '--state', - 'open', - '--label', - 'codex-ready', - '--limit', - '100', - '--json', - 'number,title,url,labels,createdAt', - ] - if (repo) argumentsList.push('--repo', repo) - const result = await execFileAsync('gh', argumentsList, { maxBuffer: 1024 * 1024 }) - issues = JSON.parse(result.stdout) + const candidates = await loadPaginatedGitHubCollection( + `repos/${configuredRepository}/issues?state=open&labels=codex-ready&per_page=100`, + ) + issues = candidates + .filter((issue) => !issue.pull_request) + .map((issue) => ({ + number: issue.number, + title: issue.title, + url: issue.html_url, + labels: issue.labels, + createdAt: issue.created_at, + })) } if (pullRequestsFile) { pullRequests = await loadJsonFile(pullRequestsFile) } else { - const argumentsList = [ - 'pr', - 'list', - '--state', - 'open', - '--limit', - '100', - '--json', - 'number,title,url,headRefName,body', - ] - if (repo) argumentsList.push('--repo', repo) - const result = await execFileAsync('gh', argumentsList, { maxBuffer: 1024 * 1024 }) - pullRequests = JSON.parse(result.stdout) + pullRequests = ( + await loadPaginatedGitHubCollection( + `repos/${configuredRepository}/pulls?state=open&per_page=100`, + ) + ).map((pullRequest) => ({ + number: pullRequest.number, + title: pullRequest.title, + url: pullRequest.html_url, + headRefName: pullRequest.head?.ref, + body: pullRequest.body, + })) } if (!issuesFile && !pullRequestsFile) { const result = await execFileAsync( @@ -350,6 +363,7 @@ export async function recordOwnerResponse({ !response.body?.trim() || Number.isNaN(responseTime) || (!reviewRequestsChanges && !response.body.includes(explicitResumeToken)) || + (reviewTarget && response.commit_id !== run.headSha) || (pauseEvent && responseTime < Date.parse(pauseEvent.timestamp)) || responseTime < Date.parse(deliveredNotification.timestamp) ) { diff --git a/loops/issue-dev-loop/scripts/lib/trusted-control-plane.mjs b/loops/issue-dev-loop/scripts/lib/trusted-control-plane.mjs new file mode 100644 index 00000000..ea241dfa --- /dev/null +++ b/loops/issue-dev-loop/scripts/lib/trusted-control-plane.mjs @@ -0,0 +1,87 @@ +import { createHash } from 'node:crypto' +import { constants } from 'node:fs' +import { access, lstat, readFile, realpath } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const moduleDirectory = path.dirname(fileURLToPath(import.meta.url)) +const loopRoot = path.resolve(moduleDirectory, '..', '..') +const bundleRoot = path.dirname(loopRoot) + +async function verifiedExecutable(target, name) { + if (!path.isAbsolute(target ?? '')) { + throw new Error(`trusted control plane ${name} executable must be absolute`) + } + await access(target, constants.X_OK) + const resolved = await realpath(target) + const stats = await lstat(resolved) + if (!stats.isFile() || stats.isSymbolicLink()) { + throw new Error(`trusted control plane ${name} executable must be a regular file`) + } + return resolved +} + +export async function loadTrustedControlPlane() { + const manifestPath = path.join(loopRoot, 'trusted-control-plane.json') + let manifest + try { + manifest = JSON.parse(await readFile(manifestPath, 'utf8')) + } catch (error) { + if (error?.code === 'ENOENT') { + throw new Error( + 'GitHub identities require an installed trusted control plane; run install-trusted-control-plane.mjs from owner-merged dev', + ) + } + throw error + } + const manifestLoopRoot = path.resolve(manifest.controlPlaneRoot ?? '') + const manifestBundleRoot = path.resolve(manifest.bundleRoot ?? '') + const [installedLoopRoot, installedBundleRoot, declaredLoopRoot, declaredBundleRoot] = + await Promise.all([ + realpath(loopRoot), + realpath(bundleRoot), + realpath(manifestLoopRoot).catch(() => null), + realpath(manifestBundleRoot).catch(() => null), + ]) + if ( + manifest.schemaVersion !== 1 || + declaredLoopRoot !== installedLoopRoot || + declaredBundleRoot !== installedBundleRoot || + !Array.isArray(manifest.files) || + manifest.files.length === 0 + ) { + throw new Error('trusted control plane manifest does not match this installation') + } + for (const entry of manifest.files) { + const relativePath = entry?.path + const target = path.resolve(bundleRoot, relativePath ?? '') + if ( + typeof relativePath !== 'string' || + path.isAbsolute(relativePath) || + !target.startsWith(`${bundleRoot}${path.sep}`) || + !/^[0-9a-f]{64}$/i.test(entry.sha256 ?? '') + ) { + throw new Error('trusted control plane manifest contains an unsafe file entry') + } + const stats = await lstat(target) + if (!stats.isFile() || stats.isSymbolicLink()) { + throw new Error(`trusted control plane file is not regular: ${relativePath}`) + } + const digest = createHash('sha256') + .update(await readFile(target)) + .digest('hex') + if (digest !== entry.sha256) { + throw new Error(`trusted control plane integrity check failed: ${relativePath}`) + } + } + return { + bundleRoot: manifestBundleRoot, + loopRoot: manifestLoopRoot, + sourceCommit: manifest.sourceCommit, + executables: { + node: await verifiedExecutable(manifest.executables?.node, 'node'), + git: await verifiedExecutable(manifest.executables?.git, 'git'), + gh: await verifiedExecutable(manifest.executables?.gh, 'gh'), + }, + } +} diff --git a/loops/issue-dev-loop/scripts/lib/validation.mjs b/loops/issue-dev-loop/scripts/lib/validation.mjs index 089bc64f..d8c85187 100644 --- a/loops/issue-dev-loop/scripts/lib/validation.mjs +++ b/loops/issue-dev-loop/scripts/lib/validation.mjs @@ -42,8 +42,11 @@ export async function validateLoop({ 'schemas/checkpoint-record.schema.json', 'schemas/implementation-result.schema.json', 'scripts/generate-evidence.mjs', + 'scripts/install-trusted-control-plane.mjs', + 'scripts/verifier.Dockerfile', 'scripts/resolve-run.mjs', 'scripts/validate-history.mjs', + 'scripts/validate-candidate-control-plane.mjs', 'scripts/lib/common.mjs', 'scripts/lib/evidence.mjs', 'scripts/lib/evolve.mjs', @@ -51,6 +54,7 @@ export async function validateLoop({ 'scripts/lib/active-journal.mjs', 'scripts/lib/github.mjs', 'scripts/lib/github-identity.mjs', + 'scripts/lib/trusted-control-plane.mjs', 'scripts/github-command-gate.mjs', 'scripts/identity-bin/gh', 'scripts/identity-bin/git', @@ -61,6 +65,9 @@ export async function validateLoop({ 'scripts/lib/validation.mjs', 'scripts/with-github-identity.mjs', 'scripts/with-github-identity', + 'scripts/loopctl.mjs', + 'scripts/runtime.mjs', + 'triggers/detect-work.mjs', 'logs/index.jsonl', 'logs/triggers.jsonl', 'screen-shots/.gitignore', @@ -160,9 +167,38 @@ export async function validateLoop({ !verificationStep?.includes('pnpm verify') || verificationStep.includes('if:') || !enforcementStep || - enforcementStep.includes("steps.run.outputs.has_run == 'true'") + enforcementStep.includes("steps.run.outputs.has_run == 'true'") || + !evidenceWorkflowSource.includes('pull_request:') || + evidenceWorkflowSource.includes('pull_request_target:') || + !evidenceWorkflowSource.includes('permissions:\n contents: read') || + !evidenceWorkflowSource.includes( + "github.event.pull_request.head.ref != 'codex/issue-dev-loop'", + ) || + !evidenceWorkflowSource.includes('Check out owner-merged baseline') || + !evidenceWorkflowSource.includes('ref: ${{ github.event.pull_request.base.sha }}') || + !evidenceWorkflowSource.includes('path: trusted') || + (evidenceWorkflowSource.match(/persist-credentials: false/g)?.length ?? 0) < 3 || + !evidenceWorkflowSource.includes( + 'trusted/loops/issue-dev-loop/scripts/validate-candidate-control-plane.mjs', + ) || + !evidenceWorkflowSource.includes('verifier.Dockerfile') || + !evidenceWorkflowSource.includes('pnpm install --frozen-lockfile --ignore-scripts') || + (evidenceWorkflowSource.match(/docker run --rm --network none/g)?.length ?? 0) < 2 || + !evidenceWorkflowSource.includes('src=${GITHUB_WORKSPACE}/trusted/tests') || + !evidenceWorkflowSource.includes('pnpm test') || + !evidenceWorkflowSource.includes( + '--trusted-workflow-sha "${{ github.event.pull_request.base.sha }}"', + ) || + !evidenceWorkflowSource.includes( + '--workflow-run-sha "${{ github.event.pull_request.head.sha }}"', + ) || + !evidenceWorkflowSource.includes('PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}') || + !evidenceWorkflowSource.includes('--branch "$PR_HEAD_REF"') || + !evidenceWorkflowSource.includes('--baseline-status') ) { - throw new Error('evidence workflow must run and enforce pnpm verify for every PR') + throw new Error( + 'evidence workflow must use a low-privilege isolated PR run plus owner-merged controls and baseline tests', + ) } const codexConfig = await readFile( path.resolve(loopRoot, '..', '..', '.codex', 'config.toml'), diff --git a/loops/issue-dev-loop/scripts/loopctl.mjs b/loops/issue-dev-loop/scripts/loopctl.mjs index ee0766fa..ff582069 100644 --- a/loops/issue-dev-loop/scripts/loopctl.mjs +++ b/loops/issue-dev-loop/scripts/loopctl.mjs @@ -1,5 +1,8 @@ #!/usr/bin/env node +import { readFile } from 'node:fs/promises' +import path from 'node:path' + import { appendEvent, completeEvolve, @@ -22,6 +25,7 @@ import { recordOwnerResponse, recordPullRequest, recordReview, + reviewPublicationDigest, restoreActiveCheckpoint, startRun, transitionRun, @@ -55,6 +59,7 @@ function runTransitionOptions(args) { async function main() { const [command, ...rest] = process.argv.slice(2) const args = parseArguments(rest) + const loopRoot = args['loop-root'] ? path.resolve(args['loop-root']) : undefined switch (command) { case 'start': @@ -65,17 +70,19 @@ async function main() { issueUrl: args.url, baseSha: args['base-sha'], now: args.now ? new Date(args.now) : undefined, + loopRoot, }), ) break case 'freeze-brief': - output(await freezeBrief({ runId: args['run-id'] })) + output(await freezeBrief({ loopRoot, runId: args['run-id'] })) break case 'record-implementation': output( await recordImplementation({ runId: args['run-id'], resultPath: args.result, + loopRoot, }), ) break @@ -86,14 +93,15 @@ async function main() { type: args.type, status: args.status ?? null, payload: parsePayload(args.payload), + loopRoot, }), ) break case 'transition': - output(await transitionRun(runTransitionOptions(args))) + output(await transitionRun({ ...runTransitionOptions(args), loopRoot })) break case 'finalize': - output(await finalizeRun(runTransitionOptions(args))) + output(await finalizeRun({ ...runTransitionOptions(args), loopRoot })) break case 'record-evidence': output( @@ -101,6 +109,7 @@ async function main() { runId: args['run-id'], manifestPath: args.manifest, publicationUrl: args['publication-url'], + loopRoot, }), ) break @@ -110,6 +119,7 @@ async function main() { runId: args['run-id'], prUrl: args['pr-url'], headSha: args['head-sha'], + loopRoot, }), ) break @@ -118,15 +128,28 @@ async function main() { await recordOwnerResponse({ runId: args['run-id'], responseUrl: args['response-url'], + loopRoot, }), ) break + case 'review-digest': { + const resultPath = path.resolve(args.result) + const result = JSON.parse(await readFile(resultPath, 'utf8')) + const publicationDigest = reviewPublicationDigest(result) + output({ + runId: result.runId, + publicationDigest, + marker: `<!-- issue-dev-loop:${result.runId}:review-result-sha256:${publicationDigest} -->`, + }) + break + } case 'record-review': output( await recordReview({ runId: args['run-id'], resultPath: args.result, reviewUrl: args['review-url'], + loopRoot, }), ) break @@ -136,11 +159,12 @@ async function main() { runId: args['run-id'], resultPath: args.result, commentUrl: args['comment-url'], + loopRoot, }), ) break case 'prepare-checkpoint': - output(await prepareActiveCheckpoint({ runId: args['run-id'] })) + output(await prepareActiveCheckpoint({ loopRoot, runId: args['run-id'] })) break case 'record-checkpoint': output( @@ -148,6 +172,7 @@ async function main() { runId: args['run-id'], resultPath: args.result, commentUrl: args['comment-url'], + loopRoot, }), ) break @@ -159,19 +184,20 @@ async function main() { mergeSha: args['merge-sha'] ?? null, failureFingerprint: args['failure-fingerprint'] ?? null, finishedAt: args['finished-at'] ? new Date(args['finished-at']) : undefined, + loopRoot, }), ) break case 'reconcile': - output(await reconcileLoopJournal()) + output(await reconcileLoopJournal({ loopRoot })) break case 'restore-checkpoint': { - const reconciled = await reconcileLoopJournal() + const reconciled = await reconcileLoopJournal({ loopRoot }) const checkpoint = reconciled.activeCheckpoints.find( (entry) => entry.record.run.runId === args['run-id'], ) if (!checkpoint) throw new Error(`no durable active checkpoint for ${args['run-id']}`) - output(await restoreActiveCheckpoint({ checkpoint })) + output(await restoreActiveCheckpoint({ loopRoot, checkpoint })) break } case 'observe-owner-merge': @@ -180,6 +206,7 @@ async function main() { runId: args['run-id'], finalizationResultPath: args.result, finalizationCommentUrl: args['comment-url'], + loopRoot, }), ) break @@ -194,6 +221,7 @@ async function main() { evidenceUrl: args['evidence-url'] ?? null, blocking: Boolean(args.blocking), dryRun: Boolean(args['dry-run']), + loopRoot, }), ) break @@ -203,19 +231,21 @@ async function main() { issuesFile: args['issues-file'], pullRequestsFile: args['prs-file'], repo: args.repo, + loopRoot, }), ) break case 'validate': - output(await validateLoop({ activation: Boolean(args.activation) })) + output(await validateLoop({ loopRoot, activation: Boolean(args.activation) })) break case 'evolve-status': - output(await getEvolveStatus()) + output(await getEvolveStatus({ loopRoot })) break case 'prepare-evolve-request': output( await prepareEvolveRequestPublication({ requestId: args['request-id'], + loopRoot, }), ) break @@ -224,6 +254,7 @@ async function main() { await recordEvolveRequestPublication({ requestId: args['request-id'], commentUrl: args['comment-url'], + loopRoot, }), ) break @@ -233,12 +264,13 @@ async function main() { requestId: args['request-id'], summary: args.summary, prUrl: args['pr-url'], + loopRoot, }), ) break default: throw new Error( - 'usage: loopctl.mjs <start|freeze-brief|record-implementation|event|record-pr|record-owner-response|record-evidence|record-review|prepare-checkpoint|record-checkpoint|prepare-finalization|record-finalization|reconcile|restore-checkpoint|transition|finalize|observe-owner-merge|notify|detect-work|validate|evolve-status|prepare-evolve-request|record-evolve-request|evolve-complete> [options]', + 'usage: loopctl.mjs <start|freeze-brief|record-implementation|event|record-pr|record-owner-response|record-evidence|review-digest|record-review|prepare-checkpoint|record-checkpoint|prepare-finalization|record-finalization|reconcile|restore-checkpoint|transition|finalize|observe-owner-merge|notify|detect-work|validate|evolve-status|prepare-evolve-request|record-evolve-request|evolve-complete> [options]', ) } } diff --git a/loops/issue-dev-loop/scripts/resolve-run.mjs b/loops/issue-dev-loop/scripts/resolve-run.mjs index 42c377f1..d5217cec 100644 --- a/loops/issue-dev-loop/scripts/resolve-run.mjs +++ b/loops/issue-dev-loop/scripts/resolve-run.mjs @@ -3,12 +3,15 @@ import { appendFile, readFile, readdir } from 'node:fs/promises' import path from 'node:path' +import { assertRunId } from './lib/common.mjs' import { DEFAULT_LOOP_ROOT, parseArguments } from './runtime.mjs' const args = parseArguments(process.argv.slice(2)) const loopRoot = args['loop-root'] ? path.resolve(args['loop-root']) : DEFAULT_LOOP_ROOT const branch = args.branch -if (typeof branch !== 'string' || branch.trim() === '') throw new Error('--branch is required') +if (typeof branch !== 'string' || !/^codex\/issue-[1-9][0-9]*$/.test(branch)) { + throw new Error('--branch must be codex/issue-<number>') +} const runsRoot = path.join(loopRoot, 'logs', 'runs') const matches = [] @@ -16,6 +19,8 @@ for (const entry of await readdir(runsRoot, { withFileTypes: true })) { if (!entry.isDirectory()) continue try { const run = JSON.parse(await readFile(path.join(runsRoot, entry.name, 'run.json'), 'utf8')) + const runId = assertRunId(run.runId) + if (runId !== entry.name) throw new Error('run ID must match its directory') if (run.branch === branch && run.finishedAt === null) matches.push(run) } catch (error) { if (error?.code !== 'ENOENT') throw error diff --git a/loops/issue-dev-loop/scripts/runtime.mjs b/loops/issue-dev-loop/scripts/runtime.mjs index 0b6365ad..6a0c241f 100644 --- a/loops/issue-dev-loop/scripts/runtime.mjs +++ b/loops/issue-dev-loop/scripts/runtime.mjs @@ -6,7 +6,7 @@ export { recordEvolveRequestPublication, verifyPublishedEvolveRequest, } from './lib/evolve.mjs' -export { recordEvidence, recordReview } from './lib/evidence.mjs' +export { recordEvidence, recordReview, reviewPublicationDigest } from './lib/evidence.mjs' export { canonicalCheckpoint, checkpointDigest, @@ -24,6 +24,7 @@ export { } from './lib/finalization-journal.mjs' export { detectWork, + loadPaginatedGitHubCollection, observeOwnerMerge, reconcileLoopJournal, recordOwnerResponse, diff --git a/loops/issue-dev-loop/scripts/validate-candidate-control-plane.mjs b/loops/issue-dev-loop/scripts/validate-candidate-control-plane.mjs new file mode 100644 index 00000000..52fd3273 --- /dev/null +++ b/loops/issue-dev-loop/scripts/validate-candidate-control-plane.mjs @@ -0,0 +1,112 @@ +#!/usr/bin/env node + +import { lstat } from 'node:fs/promises' +import path from 'node:path' + +import { assertNonEmpty, execFileAsync, parseArguments, readJson } from './lib/common.mjs' + +const args = parseArguments(process.argv.slice(2)) +const loopRoot = path.resolve(assertNonEmpty(args['loop-root'], '--loop-root')) +const repositoryRoot = path.resolve(loopRoot, '..', '..') +const runId = assertNonEmpty(args['run-id'], '--run-id') +const baseSha = assertNonEmpty(args['base-sha'], '--base-sha') +const headSha = assertNonEmpty(args['head-sha'], '--head-sha') + +for (const [name, sha] of [ + ['baseSha', baseSha], + ['headSha', headSha], +]) { + if (!/^[0-9a-f]{40}$/i.test(sha)) throw new Error(`${name} must be a full Git SHA`) +} + +const [checkedOutHead, mergeBase] = await Promise.all([ + execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repositoryRoot }), + execFileAsync('git', ['merge-base', baseSha, headSha], { cwd: repositoryRoot }), +]) +if (checkedOutHead.stdout.trim() !== headSha || mergeBase.stdout.trim() !== baseSha) { + throw new Error('candidate control-plane validation requires the exact descendant PR head') +} + +const runPath = path.join(loopRoot, 'logs', 'runs', runId, 'run.json') +const runStats = await lstat(runPath) +if (!runStats.isFile() || runStats.isSymbolicLink()) { + throw new Error('candidate run metadata must be a regular file') +} +const run = await readJson(runPath) +if ( + run.runId !== runId || + run.baseSha !== baseSha || + run.branch !== `codex/issue-${run.issueNumber}` || + run.finishedAt !== null || + !/^[0-9a-f]{40}$/i.test(run.implementationCommit ?? '') || + (run.headSha !== null && !/^[0-9a-f]{40}$/i.test(run.headSha ?? '')) +) { + throw new Error('candidate run metadata does not match the protected diff') +} +await execFileAsync('git', ['merge-base', '--is-ancestor', run.implementationCommit, headSha], { + cwd: repositoryRoot, +}) +if (run.headSha) { + await execFileAsync('git', ['merge-base', '--is-ancestor', run.headSha, headSha], { + cwd: repositoryRoot, + }) +} + +const changed = await execFileAsync( + 'git', + ['diff', '--name-only', '--diff-filter=ACDMRTUXB', `${baseSha}...${headSha}`], + { cwd: repositoryRoot, maxBuffer: 4 * 1024 * 1024 }, +) +const permittedRunRoots = [ + `loops/issue-dev-loop/handoffs/${runId}/`, + `loops/issue-dev-loop/logs/runs/${runId}/`, + `loops/issue-dev-loop/evidence/${runId}/`, + `loops/issue-dev-loop/screen-shots/${runId}/`, +] +const protectedRootFiles = new Set([ + '.npmrc', + 'pnpm-lock.yaml', + 'pnpm-workspace.yaml', +]) +const changedFiles = changed.stdout + .split('\n') + .filter(Boolean) +const violations = changedFiles.filter((file) => { + if (file.startsWith('loops/issue-dev-loop/')) { + return !permittedRunRoots.some((root) => file.startsWith(root)) + } + const basename = path.basename(file) + return ( + file.startsWith('loops/_shared/') || + file.startsWith('.github/') || + file.startsWith('scripts/') || + file.startsWith('patches/') || + file.split('/').includes('node_modules') || + basename === 'package.json' || + /^\.?pnpmfile\.[^.]+$/.test(basename) || + protectedRootFiles.has(file) || + /^(?:eslint|vite|vitest|playwright|next|postcss|tailwind|webpack|rollup|babel|jest|stylelint)\.config\.[^.]+$/.test( + basename, + ) || + /^tsconfig(?:\.[^.]+)*\.json$/.test(basename) + ) + }) +for (const file of changedFiles) { + if (!permittedRunRoots.some((root) => file.startsWith(root))) continue + try { + const stats = await lstat(path.join(repositoryRoot, file)) + if (!stats.isFile() || stats.isSymbolicLink()) violations.push(file) + } catch (error) { + if (error?.code === 'ENOENT') violations.push(file) + else throw error + } +} +if (violations.length > 0) { + throw new Error( + `issue branches cannot modify the trusted control or verification plane:\n${violations.join('\n')}`, + ) +} + +process.stdout.write( + `${JSON.stringify({ valid: true, runId, baseSha, headSha, changedFiles: changedFiles.length })}\n`, +) diff --git a/loops/issue-dev-loop/scripts/verifier.Dockerfile b/loops/issue-dev-loop/scripts/verifier.Dockerfile new file mode 100644 index 00000000..ddd64d64 --- /dev/null +++ b/loops/issue-dev-loop/scripts/verifier.Dockerfile @@ -0,0 +1,9 @@ +FROM node:24-bookworm-slim + +RUN apt-get update \ + && apt-get install --yes --no-install-recommends ca-certificates git \ + && rm -rf /var/lib/apt/lists/* + +RUN npm install --global pnpm@10.22.0 + +WORKDIR /work diff --git a/loops/issue-dev-loop/scripts/with-github-identity.mjs b/loops/issue-dev-loop/scripts/with-github-identity.mjs index b8dd89d4..7e28c3eb 100755 --- a/loops/issue-dev-loop/scripts/with-github-identity.mjs +++ b/loops/issue-dev-loop/scripts/with-github-identity.mjs @@ -2,6 +2,7 @@ import { DEFAULT_LOOP_ROOT } from './lib/common.mjs' import { readOwnerChannel, runWithGitHubRole } from './lib/github-identity.mjs' +import { loadTrustedControlPlane } from './lib/trusted-control-plane.mjs' function parseCommandLine(argv) { const values = [...argv] @@ -24,8 +25,9 @@ function parseCommandLine(argv) { async function main() { const options = parseCommandLine(process.argv.slice(2)) - const channel = await readOwnerChannel(options.loopRoot) - process.exitCode = await runWithGitHubRole({ channel, ...options }) + const trustedControlPlane = await loadTrustedControlPlane() + const channel = await readOwnerChannel(trustedControlPlane.loopRoot) + process.exitCode = await runWithGitHubRole({ channel, trustedControlPlane, ...options }) } main().catch((error) => { diff --git a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs index bc43e283..81a0219e 100644 --- a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs +++ b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict' import { execFile } from 'node:child_process' -import { chmod, mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import { chmod, cp, mkdir, mkdtemp, readFile, readdir, realpath, writeFile } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import test from 'node:test' @@ -16,10 +17,30 @@ import { resolveExecutable } from '../scripts/lib/github-identity.mjs' const execFileAsync = promisify(execFile) const testDirectory = path.dirname(fileURLToPath(import.meta.url)) -const routerPath = path.resolve(testDirectory, '..', 'scripts', 'with-github-identity.mjs') -const routerLauncherPath = path.resolve(testDirectory, '..', 'scripts', 'with-github-identity') -const commandGatePath = path.resolve(testDirectory, '..', 'scripts', 'github-command-gate.mjs') -const credentialHelper = `!'${process.execPath}' '${commandGatePath}' credential` +const repositoryScriptsRoot = path.resolve(testDirectory, '..', 'scripts') +let routerPath +let routerLauncherPath +let credentialHelper + +async function fixtureManifestFiles(bundleRoot) { + const files = [] + const visit = async (directory) => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const target = path.join(directory, entry.name) + if (entry.isDirectory()) await visit(target) + else if (entry.isFile() && entry.name !== 'trusted-control-plane.json') files.push(target) + } + } + await visit(bundleRoot) + return Promise.all( + files.sort().map(async (target) => ({ + path: path.relative(bundleRoot, target), + sha256: createHash('sha256') + .update(await readFile(target)) + .digest('hex'), + })), + ) +} async function createFixture({ activeRun = true, @@ -32,11 +53,15 @@ async function createFixture({ const parent = await mkdtemp(path.join(os.tmpdir(), 'echo-ui-identity-routing-')) const loopRoot = path.join(parent, 'issue-dev-loop') const channelRoot = path.join(parent, '_shared', 'owner-channel') + const trustedBundleRoot = path.join(parent, 'trusted-bundle') + const trustedLoopRoot = path.join(trustedBundleRoot, 'issue-dev-loop') + const trustedChannelRoot = path.join(trustedBundleRoot, '_shared', 'owner-channel') const binRoot = path.join(parent, 'bin') const automationProfile = path.join(parent, 'automation-profile') const reviewerProfile = path.join(parent, 'reviewer-profile') await Promise.all([ mkdir(channelRoot, { recursive: true }), + mkdir(trustedChannelRoot, { recursive: true }), mkdir(path.join(loopRoot, 'scripts'), { recursive: true }), mkdir(path.join(loopRoot, 'logs', 'runs'), { recursive: true }), mkdir(path.join(loopRoot, 'handoffs'), { recursive: true }), @@ -44,20 +69,25 @@ async function createFixture({ mkdir(binRoot, { recursive: true }), mkdir(automationProfile, { recursive: true }), mkdir(reviewerProfile, { recursive: true }), + cp(repositoryScriptsRoot, path.join(trustedLoopRoot, 'scripts'), { recursive: true }), + ]) + const channel = { + ownerGitHubLogin: 'owner-user', + automationGitHubLogin: 'executor-user', + reviewerGitHubLogin: 'reviewer-user', + automationGitHubConfigEnvironmentVariable: 'ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR', + reviewerGitHubConfigEnvironmentVariable: 'ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR', + stateIssueNumber: 999, + repository: 'example/repo', + } + await Promise.all([ + writeFile(path.join(channelRoot, 'channel.json'), `${JSON.stringify(channel)}\n`, 'utf8'), + writeFile( + path.join(trustedChannelRoot, 'channel.json'), + `${JSON.stringify(channel)}\n`, + 'utf8', + ), ]) - await writeFile( - path.join(channelRoot, 'channel.json'), - `${JSON.stringify({ - ownerGitHubLogin: 'owner-user', - automationGitHubLogin: 'executor-user', - reviewerGitHubLogin: 'reviewer-user', - automationGitHubConfigEnvironmentVariable: 'ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR', - reviewerGitHubConfigEnvironmentVariable: 'ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR', - stateIssueNumber: 999, - repository: 'example/repo', - })}\n`, - 'utf8', - ) await writeFile(path.join(automationProfile, 'identity'), 'executor-user\n', 'utf8') await writeFile(path.join(reviewerProfile, 'identity'), 'reviewer-user\n', 'utf8') if (activeRun) { @@ -179,15 +209,8 @@ async function createFixture({ checkpointUpdatedAt: record.updatedAt, }, }) - await Promise.all([ - mkdir(runRoot, { recursive: true }), - mkdir(briefRoot, { recursive: true }), - ]) - await writeFile( - path.join(runRoot, 'run.json'), - `${JSON.stringify(run)}\n`, - 'utf8', - ) + await Promise.all([mkdir(runRoot, { recursive: true }), mkdir(briefRoot, { recursive: true })]) + await writeFile(path.join(runRoot, 'run.json'), `${JSON.stringify(run)}\n`, 'utf8') await writeFile( path.join(runRoot, 'events.jsonl'), `${events.map((event) => JSON.stringify(event)).join('\n')}\n`, @@ -233,20 +256,27 @@ async function createFixture({ })}\n`, 'utf8', ) - const loopctlPath = path.join(loopRoot, 'scripts', 'loopctl.mjs') + const loopctlPath = path.join(trustedLoopRoot, 'scripts', 'loopctl.mjs') await writeFile( loopctlPath, `import { spawnSync } from 'node:child_process' -if (process.argv[2] === 'spawn') { - const result = spawnSync(process.argv[3], process.argv.slice(4), { +const commandArguments = process.argv.slice(2) +const loopRootIndex = commandArguments.indexOf('--loop-root') +if (loopRootIndex !== -1) commandArguments.splice(loopRootIndex, 2) + +if (commandArguments[0] === 'spawn') { + if (!['git', 'gh'].includes(commandArguments[1])) { + throw new Error('descendant processes cannot run untrusted executables') + } + const result = spawnSync(commandArguments[1], commandArguments.slice(2), { env: process.env, stdio: 'inherit', }) process.exitCode = result.status ?? 1 -} else if (process.argv[2] === 'start') { - const issueIndex = process.argv.indexOf('--issue') - const issue = process.argv[issueIndex + 1] +} else if (commandArguments[0] === 'start') { + const issueIndex = commandArguments.indexOf('--issue') + const issue = commandArguments[issueIndex + 1] for (const command of [ ['api', 'user'], ['api', \`repos/example/repo/issues/\${issue}/labels\`, '--method', 'POST', '-f', 'labels[]=loop:claimed'] @@ -304,38 +334,42 @@ if (process.argv[2] === 'spawn') { await writeFile( fakeGh, `#!/bin/sh -parent_dir=$(dirname "$GH_CONFIG_DIR") +parent_dir=\${GH_CONFIG_DIR%/*} +first_line() { + IFS= read -r line < "$1" + printf '%s\\n' "$line" +} if [ "$1 $2 $3 $4" != "api user --jq .login" ]; then if [ "$1 $2" = "api user" ]; then printf 'probe\\n' >> "$GH_CONFIG_DIR/probes" - sed -n '1p' "$GH_CONFIG_DIR/identity" + first_line "$GH_CONFIG_DIR/identity" exit 0 fi if [ "$1" = "api" ] && [ "$2" = "repos/example/repo/issues/comments/1" ]; then - sed -n '1p' "$parent_dir/checkpoint-comment.json" + first_line "$parent_dir/checkpoint-comment.json" exit 0 fi if [ "$1" = "api" ] && [ "$2" = "repos/example/repo/pulls/106" ]; then - sed -n '1p' "$parent_dir/live-pr.json" + first_line "$parent_dir/live-pr.json" exit 0 fi if [ "$1" = "api" ] && [ "$2" = "repos/example/repo/pulls/106/reviews?per_page=100&page=1" ]; then - sed -n '1p' "$parent_dir/reviews.json" + first_line "$parent_dir/reviews.json" exit 0 fi if [ "$1" = "api" ] && [ "$2" = "repos/example/repo/issues/comments/2" ]; then - sed -n '1p' "$parent_dir/evolve-comment.json" + first_line "$parent_dir/evolve-comment.json" exit 0 fi if [ "$1 $2" = "pr review" ]; then echo "comment review published" exit 0 fi - node -e 'process.stdout.write(JSON.stringify({args: process.argv.slice(1)}))' -- "$@" + ${JSON.stringify(process.execPath)} -e 'process.stdout.write(JSON.stringify({args: process.argv.slice(1)}))' -- "$@" exit 0 fi printf 'probe\\n' >> "$GH_CONFIG_DIR/probes" -sed -n '1p' "$GH_CONFIG_DIR/identity" +first_line "$GH_CONFIG_DIR/identity" `, 'utf8', ) @@ -357,7 +391,7 @@ fi if [ "$1 $2 $3" = "config --local --get-regexp" ]; then exit 1 fi -node -e 'process.stdout.write(JSON.stringify({args: process.argv.slice(1), config: process.env.GH_CONFIG_DIR, hasGhToken: Boolean(process.env.GH_TOKEN || process.env.GITHUB_TOKEN), gitConfig: Array.from({length: Number(process.env.GIT_CONFIG_COUNT)}, (_, index) => [process.env[\`GIT_CONFIG_KEY_\${index}\`], process.env[\`GIT_CONFIG_VALUE_\${index}\`]]).flat()}))' -- "$@" +${JSON.stringify(process.execPath)} -e 'process.stdout.write(JSON.stringify({args: process.argv.slice(1), config: process.env.GH_CONFIG_DIR, hasGhToken: Boolean(process.env.GH_TOKEN || process.env.GITHUB_TOKEN), gitConfig: Array.from({length: Number(process.env.GIT_CONFIG_COUNT)}, (_, index) => [process.env[\`GIT_CONFIG_KEY_\${index}\`], process.env[\`GIT_CONFIG_VALUE_\${index}\`]]).flat()}))' -- "$@" `, 'utf8', ) @@ -367,9 +401,40 @@ node -e 'process.stdout.write(JSON.stringify({args: process.argv.slice(1), confi await writeFile(impostorGh, '#!/bin/sh\nexit 0\n', 'utf8') await chmod(impostorGh, 0o755) + routerPath = path.join(trustedLoopRoot, 'scripts', 'with-github-identity.mjs') + routerLauncherPath = path.join(trustedLoopRoot, 'scripts', 'with-github-identity') + const commandGatePath = await realpath( + path.join(trustedLoopRoot, 'scripts', 'github-command-gate.mjs'), + ) + credentialHelper = `!'${process.execPath}' '${commandGatePath}' credential` + const gitExecutable = realGit ? await resolveExecutable('git', process.env) : fakeGit + const manifest = { + schemaVersion: 1, + sourceRepository: 'example/repo', + sourceCommit: 'a'.repeat(40), + installedAt: '2026-07-23T00:00:00.000Z', + bundleRoot: trustedBundleRoot, + controlPlaneRoot: trustedLoopRoot, + executables: { + node: await realpath(process.execPath), + git: await realpath(gitExecutable), + gh: await realpath(fakeGh), + }, + files: await fixtureManifestFiles(trustedBundleRoot), + } + await writeFile( + path.join(trustedLoopRoot, 'trusted-control-plane.json'), + `${JSON.stringify(manifest)}\n`, + 'utf8', + ) + return { loopRoot, loopctlPath, + routerPath, + routerLauncherPath, + commandGatePath, + credentialHelper, fakeGh, fakeGit, impostorGh, @@ -397,6 +462,8 @@ test('automation role selects its dedicated gh profile without leaking token ove '--', process.execPath, fixture.loopctlPath, + '--loop-root', + fixture.loopRoot, ] const { stdout } = await execFileAsync(process.execPath, command, { env: fixture.env }) assert.deepEqual(JSON.parse(stdout), { @@ -422,7 +489,16 @@ test('launcher removes Node preload hooks before the authenticated process start ) const { stdout } = await execFileAsync( routerLauncherPath, - ['--loop-root', fixture.loopRoot, 'automation', '--', process.execPath, fixture.loopctlPath], + [ + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + process.execPath, + fixture.loopctlPath, + '--loop-root', + fixture.loopRoot, + ], { env: { ...fixture.env, @@ -439,6 +515,56 @@ test('launcher removes Node preload hooks before the authenticated process start await assert.rejects(readFile(markerPath, 'utf8'), /ENOENT/) }) +test('authenticated routing ignores caller PATH shims and uses manifest-pinned tools', async () => { + const fixture = await createFixture() + const { stdout } = await execFileAsync( + process.execPath, + [ + fixture.routerPath, + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + 'gh', + 'api', + 'user', + '--jq', + '.login', + ], + { + env: { + ...fixture.env, + PATH: path.dirname(fixture.impostorGh), + }, + }, + ) + assert.equal(stdout.trim(), 'executor-user') +}) + +test('authenticated routing refuses a tampered installed control-plane file', async () => { + const fixture = await createFixture() + await writeFile(fixture.commandGatePath, 'tampered\n', 'utf8') + await assert.rejects( + execFileAsync( + process.execPath, + [ + fixture.routerPath, + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + 'gh', + 'api', + 'user', + '--jq', + '.login', + ], + { env: fixture.env }, + ), + /trusted control plane integrity check failed/, + ) +}) + test('wrapped activation validates both profiles without exposing their paths to loopctl', async () => { const fixture = await createFixture() const { stdout } = await execFileAsync( @@ -452,6 +578,8 @@ test('wrapped activation validates both profiles without exposing their paths to fixture.loopctlPath, 'validate', '--activation', + '--loop-root', + fixture.loopRoot, ], { env: fixture.env }, ) @@ -557,6 +685,8 @@ test('routed loopctl may perform its exact read-only identity probe', async () = 'gh', 'api', 'user', + '--loop-root', + fixture.loopRoot, ], { env: fixture.env }, ) @@ -580,6 +710,8 @@ test('routed loopctl start may claim only its command-line issue', async () => { '123', '--url', 'https://github.com/example/repo/issues/123', + '--loop-root', + fixture.loopRoot, ], { env: fixture.env }, ) @@ -805,14 +937,7 @@ test('reviewer may publish exact-head inline comments only as a COMMENT review A for (const unsafe of [ ['-f', `commit_id=${'b'.repeat(40)}`, '-f', 'event=APPROVE', '-f', 'body=unsafe'], - [ - '-f', - `commit_id=${'d'.repeat(40)}`, - '-f', - 'event=COMMENT', - '-f', - 'body=wrong head', - ], + ['-f', `commit_id=${'d'.repeat(40)}`, '-f', 'event=COMMENT', '-f', 'body=wrong head'], ['--input', '/tmp/unvalidated-review.json'], [ '--hostname', @@ -1263,7 +1388,18 @@ test('authenticated command trees reject shell, env, arbitrary node, and descend ['automation', [process.execPath, '-e', 'process.exit(0)']], [ 'automation', - [process.execPath, fixture.loopctlPath, 'spawn', 'env', 'git', 'push', 'origin', 'main'], + [ + process.execPath, + fixture.loopctlPath, + 'spawn', + 'env', + 'git', + 'push', + 'origin', + 'main', + '--loop-root', + fixture.loopRoot, + ], ], ['reviewer', ['env', 'git', 'push', 'origin', 'main']], ] @@ -1274,7 +1410,7 @@ test('authenticated command trees reject shell, env, arbitrary node, and descend [routerPath, '--loop-root', fixture.loopRoot, role, '--', command[0], ...command.slice(1)], { env: fixture.env }, ), - /(outside the authenticated|reviewer identity cannot run git push|automation may push only|descendant processes cannot push)/, + /(outside the authenticated|not pinned by the trusted control plane|reviewer identity cannot run git push|automation may push only|descendant processes cannot (push|run untrusted executables))/, ) } }) @@ -1331,7 +1467,7 @@ test('authenticated roots reject executable impersonation and mutating remote sy ], { env: fixture.env }, ), - /(outside the authenticated|git command is outside)/, + /(outside the authenticated|git command is outside|not pinned by the trusted control plane)/, ) } }) @@ -1346,15 +1482,7 @@ test('authenticated remote Git accepts only exact origin and authorized ref shap for (const gitArguments of allowed) { const { stdout } = await execFileAsync( process.execPath, - [ - routerPath, - '--loop-root', - fixture.loopRoot, - 'automation', - '--', - 'git', - ...gitArguments, - ], + [routerPath, '--loop-root', fixture.loopRoot, 'automation', '--', 'git', ...gitArguments], { env: fixture.env }, ) const executed = JSON.parse(stdout) @@ -1390,9 +1518,13 @@ test('authenticated real Git ignores local execution hooks and configured diff h await helper(path.join(repository, 'textconv'), textconvMarker) await helper(path.join(repository, 'fsmonitor'), fsmonitorMarker) await execFileAsync(realGit, ['config', 'core.hooksPath', hookRoot], { cwd: repository }) - await execFileAsync(realGit, ['config', 'diff.external', path.join(repository, 'external-diff')], { - cwd: repository, - }) + await execFileAsync( + realGit, + ['config', 'diff.external', path.join(repository, 'external-diff')], + { + cwd: repository, + }, + ) await execFileAsync( realGit, ['config', 'diff.probe.textconv', path.join(repository, 'textconv')], @@ -1406,15 +1538,7 @@ test('authenticated real Git ignores local execution hooks and configured diff h const routed = (gitArguments) => execFileAsync( process.execPath, - [ - routerPath, - '--loop-root', - fixture.loopRoot, - 'automation', - '--', - realGit, - ...gitArguments, - ], + [routerPath, '--loop-root', fixture.loopRoot, 'automation', '--', realGit, ...gitArguments], { cwd: repository, env: fixture.env }, ) const { stdout: hooksPath } = await routed(['config', '--get', 'core.hooksPath']) diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index dbdc7a8c..c87e7c0f 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -21,6 +21,7 @@ import { finalizeRun, freezeBrief as runtimeFreezeBrief, getEvolveStatus, + loadPaginatedGitHubCollection, observeOwnerMerge, prepareActiveCheckpoint, prepareFinalizationRecord as runtimePrepareFinalizationRecord, @@ -34,6 +35,7 @@ import { recordOwnerResponse as runtimeRecordOwnerResponse, recordPullRequest as runtimeRecordPullRequest, recordReview as runtimeRecordReview, + reviewPublicationDigest, restoreActiveCheckpoint, selectIssue, startRun, @@ -52,7 +54,11 @@ const prepareFinalizationRecord = (options) => checkpointVerifier: bypassCheckpointVerifier, }) const recordEvidence = (options) => - runtimeRecordEvidence({ ...options, checkpointVerifier: bypassCheckpointVerifier }) + runtimeRecordEvidence({ + ...options, + candidateControlPlaneVerifier: options.candidateControlPlaneVerifier ?? (async () => {}), + checkpointVerifier: bypassCheckpointVerifier, + }) const recordImplementation = (options) => runtimeRecordImplementation({ ...options, checkpointVerifier: bypassCheckpointVerifier }) const recordOwnerResponse = (options) => @@ -200,6 +206,7 @@ function pullRequestFixture(run, headSha, { draft = true, merged = false } = {}) '## Verification', '- `pnpm test -- keyboard`: passed (exit code 0)', '- `pnpm verify`: passed (exit code 0)', + '- `pnpm test (owner-merged baseline tests)`: passed (exit code 0)', '## Evidence', 'Exact-head workflow evidence is attached or pending for this draft.', '## Independent review', @@ -307,7 +314,15 @@ function successfulWorkflowRun(run, headSha, prNumber, runId) { head_branch: run.branch, path: '.github/workflows/issue-dev-loop-evidence.yml', pull_requests: [ - { number: prNumber, base: { ref: 'dev', repo: { full_name: 'codeacme17/echo-ui' } } }, + { + number: prNumber, + base: { + ref: 'dev', + sha: run.baseSha, + repo: { url: 'https://api.github.com/repos/codeacme17/echo-ui' }, + }, + head: { ref: run.branch, sha: headSha }, + }, ], } } @@ -324,6 +339,8 @@ async function writePassingEvidence({ loopRoot, run, headSha }) { issueNumber: run.issueNumber, baseSha: run.baseSha, headSha, + trustedWorkflowSha: run.baseSha, + workflowRunSha: headSha, verdict: 'passed', checks: [ { @@ -342,6 +359,14 @@ async function writePassingEvidence({ loopRoot, run, headSha }) { finishedAt: timestamp, artifactUrl: null, }, + { + command: 'pnpm test (owner-merged baseline tests)', + status: 'passed', + exitCode: 0, + startedAt: timestamp, + finishedAt: timestamp, + artifactUrl: null, + }, ], screenshots: [], limitations: [], @@ -533,6 +558,141 @@ test('detectWork records a durable no-work trigger check without waking an execu assert.match(triggerLog, /"hasWork":false/) }) +test('GitHub collection loading consumes every page beyond the first 100 records', async () => { + const firstPage = Array.from({ length: 100 }, (_, index) => ({ number: index + 1 })) + const secondPage = [{ number: 101 }, { number: 102 }] + let observedCommand + const collection = await loadPaginatedGitHubCollection( + 'repos/codeacme17/echo-ui/issues?state=open&per_page=100', + { + execute: async (command, args) => { + observedCommand = [command, ...args] + return { stdout: JSON.stringify([firstPage, secondPage]) } + }, + }, + ) + assert.deepEqual(observedCommand, [ + 'gh', + 'api', + '--paginate', + '--slurp', + 'repos/codeacme17/echo-ui/issues?state=open&per_page=100', + ]) + assert.equal(collection.length, 102) + assert.equal(collection.at(-1).number, 102) +}) + +test('candidate control-plane validation permits run evidence but rejects verifier changes', async () => { + const parent = await mkdtemp(path.join(os.tmpdir(), 'echo-ui-control-plane-test-')) + const repository = path.join(parent, 'repository') + const loopRoot = path.join(repository, 'loops', 'issue-dev-loop') + const runId = 'run-issue-123' + await mkdir(path.join(loopRoot, 'logs', 'runs', runId), { recursive: true }) + await mkdir(path.join(repository, 'src'), { recursive: true }) + const git = async (...args) => execFileAsync('git', args, { cwd: repository }) + await git('init', '--initial-branch=dev') + await git('config', 'user.name', 'Loop Test') + await git('config', 'user.email', 'loop-test@example.invalid') + await writeFile(path.join(repository, 'src', 'feature.js'), 'export const value = 1\n', 'utf8') + await writeFile(path.join(repository, 'package.json'), '{"scripts":{"verify":"true"}}\n', 'utf8') + await git('add', '.') + await git('commit', '-m', 'base') + const baseSha = (await git('rev-parse', 'HEAD')).stdout.trim() + + await writeFile(path.join(repository, 'src', 'feature.js'), 'export const value = 2\n', 'utf8') + await git('add', 'src/feature.js') + await git('commit', '-m', 'implementation') + const implementationCommit = (await git('rev-parse', 'HEAD')).stdout.trim() + const run = { + runId, + issueNumber: 123, + baseSha, + headSha: implementationCommit, + implementationCommit, + branch: 'codex/issue-123', + finishedAt: null, + } + await writeFile( + path.join(loopRoot, 'logs', 'runs', runId, 'run.json'), + `${JSON.stringify(run)}\n`, + 'utf8', + ) + await git('add', '.') + await git('commit', '-m', 'run evidence') + const permittedHead = (await git('rev-parse', 'HEAD')).stdout.trim() + const validator = path.join( + repositoryLoopRoot, + 'scripts', + 'validate-candidate-control-plane.mjs', + ) + const permitted = await execFileAsync(process.execPath, [ + validator, + '--loop-root', + loopRoot, + '--run-id', + runId, + '--base-sha', + baseSha, + '--head-sha', + permittedHead, + ]) + assert.equal(JSON.parse(permitted.stdout).valid, true) + + await writeFile( + path.join(repository, 'package.json'), + '{"scripts":{"verify":"node attacker.js"}}\n', + 'utf8', + ) + await git('add', 'package.json') + await git('commit', '-m', 'weaken verifier') + const violatingHead = (await git('rev-parse', 'HEAD')).stdout.trim() + await assert.rejects( + execFileAsync(process.execPath, [ + validator, + '--loop-root', + loopRoot, + '--run-id', + runId, + '--base-sha', + baseSha, + '--head-sha', + violatingHead, + ]), + /issue branches cannot modify the trusted control or verification plane:[\s\S]*package\.json/, + ) +}) + +test('review publication digest excludes assigned review URLs but binds review content', () => { + const review = { + schemaVersion: 1, + runId: 'run-1', + cycle: 1, + reviewerAgent: 'echo_ui_pr_reviewer', + freshContext: true, + headSha: 'a'.repeat(40), + verdict: 'PASS', + rounds: [ + { + round: 1, + headSha: 'a'.repeat(40), + reviewUrl: 'https://github.com/codeacme17/echo-ui/pull/1#pullrequestreview-1', + verdict: 'PASS', + findings: [], + }, + ], + } + const digest = reviewPublicationDigest(review) + const assignedByGitHub = structuredClone(review) + assignedByGitHub.rounds[0].reviewUrl = + 'https://github.com/codeacme17/echo-ui/pull/1#pullrequestreview-987654' + assert.equal(reviewPublicationDigest(assignedByGitHub), digest) + + const changedReview = structuredClone(assignedByGitHub) + changedReview.headSha = 'b'.repeat(40) + changedReview.rounds[0].headSha = changedReview.headSha + assert.notEqual(reviewPublicationDigest(changedReview), digest) +}) + test('authoritative claim rejects any paginated open PR that references the issue', async () => { let labelAdded = false await assert.rejects( @@ -946,6 +1106,21 @@ test('owner-feedback repair cannot start until the unchanged PR is durably redra html_url: `${prUrl}#issuecomment-600`, }), }) + await assert.rejects( + recordOwnerResponse({ + loopRoot, + runId: run.runId, + responseUrl: `${prUrl}#pullrequestreview-700`, + githubApi: async () => ({ + user: { login: 'codeacme17' }, + body: 'Please repair this older revision.', + state: 'CHANGES_REQUESTED', + commit_id: '2'.repeat(40), + submitted_at: '2030-01-01T00:01:00.000Z', + }), + }), + /current, run-bound decision/, + ) await recordOwnerResponse({ loopRoot, runId: run.runId, @@ -1045,6 +1220,21 @@ test('recordEvidence rejects failed workflow runs and mismatched artifact manife ? artifact : { ...successfulWorkflowRun(run, headSha, 301, 800), conclusion: 'failure' } + await assert.rejects( + recordEvidence({ + loopRoot, + runId: run.runId, + manifestPath, + publicationUrl: 'https://github.com/codeacme17/echo-ui/actions/runs/800/artifacts/900', + candidateControlPlaneVerifier: async () => { + throw new Error('candidate changed protected workflow') + }, + githubApi: async () => { + throw new Error('GitHub must not be queried before local control-plane verification') + }, + }), + /candidate changed protected workflow/, + ) await assert.rejects( recordEvidence({ loopRoot, @@ -1229,8 +1419,14 @@ test('CI helpers resolve a run and generate exact-head screenshot evidence', asy run.runId, '--head-sha', headSha, + '--trusted-workflow-sha', + run.baseSha, + '--workflow-run-sha', + headSha, '--status', 'passed', + '--baseline-status', + 'passed', '--started-at', '2026-07-22T16:00:00Z', '--finished-at', @@ -1293,9 +1489,7 @@ test('owner-ready transition requires verification and review but remains resuma run, headSha, }) - const reviewDigest = createHash('sha256') - .update(await readFile(reviewPath, 'utf8')) - .digest('hex') + const reviewDigest = reviewPublicationDigest(JSON.parse(await readFile(reviewPath, 'utf8'))) await recordReview({ loopRoot, runId: run.runId, @@ -1908,9 +2102,7 @@ test('review gate verifies published findings and classified replies', async () })}\n`, 'utf8', ) - const digest = createHash('sha256') - .update(await readFile(resultPath, 'utf8')) - .digest('hex') + const digest = reviewPublicationDigest(JSON.parse(await readFile(resultPath, 'utf8'))) const wrongRoundResultPath = path.join( loopRoot, 'logs', @@ -2047,9 +2239,7 @@ test('review gate rejects GitHub findings omitted from the durable result', asyn prNumber: 350, reviewId: 500, }) - const digest = createHash('sha256') - .update(await readFile(resultPath, 'utf8')) - .digest('hex') + const digest = reviewPublicationDigest(JSON.parse(await readFile(resultPath, 'utf8'))) await assert.rejects( recordReview({ loopRoot, @@ -2214,9 +2404,7 @@ test('accepted review fix must be after the finding head and inside the final he ], } await writeFile(resultPath, `${JSON.stringify(result)}\n`, 'utf8') - const digest = createHash('sha256') - .update(await readFile(resultPath, 'utf8')) - .digest('hex') + const digest = reviewPublicationDigest(JSON.parse(await readFile(resultPath, 'utf8'))) const recorded = await recordReview({ loopRoot, runId: run.runId, @@ -2341,9 +2529,7 @@ test('review gate binds high-severity adjudication verdict to the correct identi })}\n`, 'utf8', ) - const digest = createHash('sha256') - .update(await readFile(resultPath, 'utf8')) - .digest('hex') + const digest = reviewPublicationDigest(JSON.parse(await readFile(resultPath, 'utf8'))) await assert.rejects( recordReview({ loopRoot, diff --git a/loops/issue-dev-loop/triggers/TRIGGER.md b/loops/issue-dev-loop/triggers/TRIGGER.md index 54cd0177..05949d14 100644 --- a/loops/issue-dev-loop/triggers/TRIGGER.md +++ b/loops/issue-dev-loop/triggers/TRIGGER.md @@ -3,12 +3,15 @@ Run the deterministic detector before waking Codex: ```bash -loops/issue-dev-loop/scripts/with-github-identity automation -- node loops/issue-dev-loop/triggers/detect-work.mjs +"$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" \ + --loop-root "$ECHO_UI_LOOP_TARGET_ROOT" automation -- \ + node "$ECHO_UI_LOOP_CONTROL_PLANE/triggers/detect-work.mjs" \ + --loop-root "$ECHO_UI_LOOP_TARGET_ROOT" ``` Before the issue detector, run `loopctl.mjs evolve-status` through the same automation wrapper. A pending evolve request is real work and must wake the dedicated fresh-context evolver instead of an issue executor. -The command prints one JSON object. Start a Codex turn only when `hasWork` is `true`; pass the returned issue number and URL to `$issue-dev-loop`. +The command prints one JSON object after paginating all matching issues and open PRs. Start a Codex turn only when `hasWork` is `true`; pass the returned issue number and URL to `$issue-dev-loop`. For a scheduled Codex automation, use [`codex-automation-prompt.md`](./codex-automation-prompt.md). The machine and repository credentials must remain available for local scheduled runs. Use a dedicated worktree for every issue. diff --git a/loops/issue-dev-loop/triggers/codex-automation-prompt.md b/loops/issue-dev-loop/triggers/codex-automation-prompt.md index c8b4cd87..e2e886d3 100644 --- a/loops/issue-dev-loop/triggers/codex-automation-prompt.md +++ b/loops/issue-dev-loop/triggers/codex-automation-prompt.md @@ -1,5 +1,5 @@ Use `$issue-dev-loop` in this repository. -Require `ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR` and `ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR`, then run `loops/issue-dev-loop/scripts/with-github-identity automation -- node loops/issue-dev-loop/scripts/loopctl.mjs validate --activation`. Run all executor GitHub, remote Git, and GitHub-backed loop commands through that executable launcher; never invoke its `.mjs` implementation directly and never change global authentication. +Require `ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR`, `ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR`, `ECHO_UI_LOOP_CONTROL_PLANE`, and `ECHO_UI_LOOP_TARGET_ROOT`. The control plane must be a versioned, hash-verified installation made from clean owner-merged `dev`, outside the repository. Run `"$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" --loop-root "$ECHO_UI_LOOP_TARGET_ROOT" automation -- node "$ECHO_UI_LOOP_CONTROL_PLANE/scripts/loopctl.mjs" validate --activation --loop-root "$ECHO_UI_LOOP_TARGET_ROOT"`. Run every operational loop command, executor GitHub command, remote Git command, trigger, and reviewer publication through that installed launcher with the explicit target root. Never use the repository launcher for credentials, invoke its `.mjs` implementation directly, install control code from an issue branch, or change global authentication. First run `loopctl.mjs reconcile`, then `loopctl.mjs evolve-status`, both through the automation wrapper. A pending evolve request starts a fresh `echo_ui_loop_evolver` session and an owner-reviewed evolve PR before routine product work. Otherwise run `triggers/detect-work.mjs` through the automation wrapper. If it returns `hasWork: false`, report a quiet no-op and stop. If it returns an issue, execute one bounded issue-dev-loop run for that exact issue. Preserve owner-only review and merge, use `$implement` for product code, use a fresh read-only reviewer after the draft PR, publish review commands through the reviewer wrapper, publish terminal state to the durable GitHub journal, and notify the owner for every blocking event or ready PR. diff --git a/loops/issue-dev-loop/triggers/detect-work.mjs b/loops/issue-dev-loop/triggers/detect-work.mjs index da645194..663fc64e 100644 --- a/loops/issue-dev-loop/triggers/detect-work.mjs +++ b/loops/issue-dev-loop/triggers/detect-work.mjs @@ -5,6 +5,7 @@ import { detectWork, parseArguments } from '../scripts/runtime.mjs' const args = parseArguments(process.argv.slice(2)) detectWork({ + loopRoot: args['loop-root'], issuesFile: args['issues-file'], pullRequestsFile: args['prs-file'], repo: args.repo, diff --git a/scripts/verify-nextra-output.mjs b/scripts/verify-nextra-output.mjs index b8a7f033..ec3c36b7 100644 --- a/scripts/verify-nextra-output.mjs +++ b/scripts/verify-nextra-output.mjs @@ -61,6 +61,13 @@ assert.equal( ) assert.deepEqual(hosting, { buildCommand: 'pnpm build:docs', + git: { + deploymentEnabled: { + '*': false, + dev: true, + main: true, + }, + }, installCommand: 'pnpm install --frozen-lockfile', outputDirectory: 'docs/out', }) From 5ebc30eb1ef64fa3c001e362ac9469622df7b641 Mon Sep 17 00:00:00 2001 From: Ethandasw <Ethandasw@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:19:09 +0800 Subject: [PATCH 31/41] fix: enforce loop trust and owner boundaries --- .agents/skills/issue-dev-loop/SKILL.md | 6 - .../skills/issue-dev-loop/agents/openai.yaml | 6 - .github/workflows/issue-dev-loop-evidence.yml | 49 +++-- loops/README.md | 2 +- loops/_shared/owner-channel/CHANNEL.md | 6 +- loops/issue-dev-loop/LOOP.md | 11 +- loops/issue-dev-loop/SKILL.md | 6 +- loops/issue-dev-loop/dependencies.md | 4 +- .../references/evidence-policy.md | 4 +- .../references/github-operations.md | 6 +- loops/issue-dev-loop/review/REVIEW.md | 2 +- .../schemas/evidence.schema.json | 2 + .../scripts/generate-evidence.mjs | 5 + .../scripts/install-trusted-control-plane.mjs | 6 + loops/issue-dev-loop/scripts/lib/evidence.mjs | 3 +- .../scripts/lib/github-identity.mjs | 151 +++++++++++---- .../scripts/lib/notifications.mjs | 62 ++++-- .../issue-dev-loop/scripts/lib/owner-gate.mjs | 16 +- .../issue-dev-loop/scripts/lib/run-store.mjs | 8 +- .../scripts/lib/trusted-control-plane.mjs | 29 ++- .../issue-dev-loop/scripts/lib/validation.mjs | 11 +- loops/issue-dev-loop/scripts/resolve-run.mjs | 9 +- loops/issue-dev-loop/templates/pr-body.md | 2 +- .../tests/github-identity-routing.test.mjs | 180 ++++++++++++++---- loops/issue-dev-loop/tests/runtime.test.mjs | 119 +++++++++++- 25 files changed, 545 insertions(+), 160 deletions(-) delete mode 100644 .agents/skills/issue-dev-loop/SKILL.md delete mode 100644 .agents/skills/issue-dev-loop/agents/openai.yaml diff --git a/.agents/skills/issue-dev-loop/SKILL.md b/.agents/skills/issue-dev-loop/SKILL.md deleted file mode 100644 index f6b26a10..00000000 --- a/.agents/skills/issue-dev-loop/SKILL.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -name: issue-dev-loop -description: Run the repository's auditable Echo UI issue development loop for one `codex-ready` issue, including `$implement`, independent PR review, evidence, owner notification, and owner-only merge. Use only when explicitly invoked or by the configured maintenance automation. ---- - -Read `loops/issue-dev-loop/SKILL.md` completely, then follow it. Treat the loop directory as canonical; this file exists only for repository skill discovery. diff --git a/.agents/skills/issue-dev-loop/agents/openai.yaml b/.agents/skills/issue-dev-loop/agents/openai.yaml deleted file mode 100644 index ed759af9..00000000 --- a/.agents/skills/issue-dev-loop/agents/openai.yaml +++ /dev/null @@ -1,6 +0,0 @@ -interface: - display_name: 'Echo UI Issue Dev Loop' - short_description: 'Run audited Echo UI issue development loops' - default_prompt: 'Use $issue-dev-loop to select and develop one eligible Echo UI issue with independent review and owner-only merge.' -policy: - allow_implicit_invocation: false diff --git a/.github/workflows/issue-dev-loop-evidence.yml b/.github/workflows/issue-dev-loop-evidence.yml index c79bbd02..cdb51587 100644 --- a/.github/workflows/issue-dev-loop-evidence.yml +++ b/.github/workflows/issue-dev-loop-evidence.yml @@ -14,7 +14,11 @@ concurrency: jobs: bootstrap-evidence: - if: github.event_name == 'pull_request' && github.head_ref == 'codex/issue-dev-loop' + if: >- + github.event_name == 'pull_request' && + github.head_ref == 'codex/issue-dev-loop' && + github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.user.login == 'codeacme17' runs-on: ubuntu-latest timeout-minutes: 45 steps: @@ -38,7 +42,7 @@ jobs: cache: pnpm - name: Install dependencies - run: pnpm install --frozen-lockfile + run: pnpm install --frozen-lockfile --ignore-scripts - name: Run bootstrap verification run: pnpm verify @@ -48,12 +52,12 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 45 steps: - - name: Check out owner-merged baseline + - name: Check out owner-merged control plane uses: actions/checkout@v6 with: ref: ${{ github.event.pull_request.base.sha }} - fetch-depth: 1 - path: trusted + fetch-depth: 0 + path: control persist-credentials: false - name: Check out exact PR head @@ -75,20 +79,38 @@ jobs: env: PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} run: >- - node trusted/loops/issue-dev-loop/scripts/resolve-run.mjs --loop-root candidate/loops/issue-dev-loop --branch "$PR_HEAD_REF" + node control/loops/issue-dev-loop/scripts/resolve-run.mjs --loop-root candidate/loops/issue-dev-loop --branch "$PR_HEAD_REF" - - name: Assert immutable head - run: test "$(git -C candidate rev-parse HEAD)" = "${{ github.event.pull_request.head.sha }}" + - name: Require one active run + run: test "${{ steps.run.outputs.has_run }}" = "true" + + - name: Check out frozen owner-merged baseline + uses: actions/checkout@v6 + with: + ref: ${{ steps.run.outputs.base_sha }} + fetch-depth: 1 + path: trusted + persist-credentials: false + + - name: Assert immutable head and trusted baseline + env: + FROZEN_BASE_SHA: ${{ steps.run.outputs.base_sha }} + LIVE_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + test "$(git -C candidate rev-parse HEAD)" = "${PR_HEAD_SHA}" + test "$(git -C trusted rev-parse HEAD)" = "${FROZEN_BASE_SHA}" + git -C control merge-base --is-ancestor "${FROZEN_BASE_SHA}" "${LIVE_BASE_SHA}" - name: Protect trusted control plane if: steps.run.outputs.has_run == 'true' run: >- - node trusted/loops/issue-dev-loop/scripts/validate-candidate-control-plane.mjs --loop-root candidate/loops/issue-dev-loop --run-id "${{ steps.run.outputs.run_id }}" --base-sha "${{ github.event.pull_request.base.sha }}" --head-sha "${{ github.event.pull_request.head.sha }}" + node trusted/loops/issue-dev-loop/scripts/validate-candidate-control-plane.mjs --loop-root candidate/loops/issue-dev-loop --run-id "${{ steps.run.outputs.run_id }}" --base-sha "${{ steps.run.outputs.base_sha }}" --head-sha "${{ github.event.pull_request.head.sha }}" - name: Protect append-only loop history if: steps.run.outputs.has_run == 'true' run: >- - node trusted/loops/issue-dev-loop/scripts/validate-history.mjs --loop-root candidate/loops/issue-dev-loop --base-ref "${{ github.event.pull_request.base.sha }}" + node trusted/loops/issue-dev-loop/scripts/validate-history.mjs --loop-root candidate/loops/issue-dev-loop --base-ref "${{ steps.run.outputs.base_sha }}" - name: Build trusted verifier image run: >- @@ -108,11 +130,10 @@ jobs: "${image}" \ sh -ceu 'cp -a /source/. /work/; cd /work; pnpm install --frozen-lockfile --ignore-scripts' docker run --rm \ - --mount "type=volume,src=${candidate_volume},dst=/candidate,readonly" \ --mount "type=volume,src=${baseline_volume},dst=/work" \ - --mount "type=bind,src=${GITHUB_WORKSPACE}/trusted/tests,dst=/owner-tests,readonly" \ + --mount "type=bind,src=${GITHUB_WORKSPACE}/trusted,dst=/source,readonly" \ "${image}" \ - sh -ceu 'cp -a /candidate/. /work/; rm -rf /work/tests; cp -a /owner-tests /work/tests' + sh -ceu 'cp -a /source/. /work/; cd /work; pnpm install --frozen-lockfile --ignore-scripts' - name: Run authoritative verification id: verify @@ -168,7 +189,7 @@ jobs: - name: Generate exact-head manifest if: steps.run.outputs.has_run == 'true' && always() run: >- - node trusted/loops/issue-dev-loop/scripts/generate-evidence.mjs --loop-root candidate/loops/issue-dev-loop --run-id "${{ steps.run.outputs.run_id }}" --head-sha "${{ github.event.pull_request.head.sha }}" --trusted-workflow-sha "${{ github.event.pull_request.base.sha }}" --workflow-run-sha "${{ github.event.pull_request.head.sha }}" --status "${{ steps.verify.outputs.verdict || 'blocked' }}" --baseline-status "${{ steps.verify.outputs.baseline_status || 'blocked' }}" --started-at "${{ steps.verify.outputs.started_at || github.event.pull_request.updated_at }}" --finished-at "${{ steps.verify.outputs.finished_at || github.event.pull_request.updated_at }}" --output "${RUNNER_TEMP}/issue-dev-evidence/manifest.json" + node trusted/loops/issue-dev-loop/scripts/generate-evidence.mjs --loop-root candidate/loops/issue-dev-loop --run-id "${{ steps.run.outputs.run_id }}" --head-sha "${{ github.event.pull_request.head.sha }}" --trusted-workflow-sha "${{ steps.run.outputs.base_sha }}" --workflow-base-sha "${{ github.event.pull_request.base.sha }}" --workflow-run-sha "${{ github.event.pull_request.head.sha }}" --status "${{ steps.verify.outputs.verdict || 'blocked' }}" --baseline-status "${{ steps.verify.outputs.baseline_status || 'blocked' }}" --started-at "${{ steps.verify.outputs.started_at || github.event.pull_request.updated_at }}" --finished-at "${{ steps.verify.outputs.finished_at || github.event.pull_request.updated_at }}" --output "${RUNNER_TEMP}/issue-dev-evidence/manifest.json" - name: Upload review evidence if: steps.run.outputs.has_run == 'true' && always() diff --git a/loops/README.md b/loops/README.md index ac508f04..58a294d1 100644 --- a/loops/README.md +++ b/loops/README.md @@ -9,7 +9,7 @@ This directory contains durable, auditable workflows that Codex can run against ## Repository rules - Treat each loop directory as the canonical source for its own workflow. -- Put repo-discoverable adapters in `.agents/skills`; adapters must point back to the canonical loop rather than duplicate its contract. +- Keep each loop's agent instructions inside its loop directory. Do not expose a loop as a top-level `.agents/skills` entry; scheduled automation invokes the loop contract and project agents directly. - Persist compact state, sanitized event history, and issue-relevant screenshots in the issue PR. Publish exact-head evidence manifests and verification logs as CI artifacts; keep raw local output and large recordings out of Git. - Treat repository loop code as reviewable source, not a credential boundary. Install a versioned control plane outside the repository only from clean owner-merged `dev`, and route all credential-bearing operations through that hash-verified installation. - Never grant a loop authority to approve, auto-merge, or merge a PR. diff --git a/loops/_shared/owner-channel/CHANNEL.md b/loops/_shared/owner-channel/CHANNEL.md index cb03ff97..89d0dfd9 100644 --- a/loops/_shared/owner-channel/CHANNEL.md +++ b/loops/_shared/owner-channel/CHANNEL.md @@ -4,7 +4,7 @@ GitHub issue and PR comments are the canonical, auditable channel. The runtime m ## Blocking events -`approval_required`, `clarification_required`, `blocked`, `review_dispute`, `pr_ready_for_review`, `pr_updated_for_review`, and `loop_failed` require immediate delivery and must be sent with `blocking: true`. The runtime enters `waiting_for_owner` even when delivery fails, and it must not choose a default answer after a timeout. A ready PR advances to `awaiting_owner_review` only after SHA-bound evidence validation. +`approval_required`, `clarification_required`, `blocked`, `review_dispute`, `pr_ready_for_review`, `pr_updated_for_review`, and `loop_failed` require immediate delivery and must be sent with `blocking: true`. The runtime enters `waiting_for_owner` even when delivery fails, and it must not choose a default answer after a timeout. A still-Draft PR advances to `awaiting_owner_review` only after SHA-bound evidence validation; the notification asks `codeacme17` to mark it Ready in GitHub, review it, and merge or request changes. `--dry-run` stages a simulated payload only. It never records `owner_notified`, never pauses the run, and never satisfies a blocking delivery gate. @@ -13,8 +13,8 @@ Each blocking GitHub notification prints a unique resume instruction. To continu ## Runtime setup 1. Authenticate the unattended executor and fresh reviewer with distinct GitHub identities. Their exact logins and the names of their profile-path environment variables live in `channel.json`. For the current configuration, set `ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR` to the `Ethandasw` `gh` profile directory and `ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR` to the `Traviinam` profile directory in the scheduler environment. The directory names themselves are local details and do not need to match the roles. -2. From a clean owner-merged `dev` checkout at exact `origin/dev`, install a new versioned control plane outside every repository worktree with `install-trusted-control-plane.mjs`. Set `ECHO_UI_LOOP_CONTROL_PLANE` to its `issue-dev-loop` directory and `ECHO_UI_LOOP_TARGET_ROOT` to the scheduled worktree's loop directory. Run `"$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" --loop-root "$ECHO_UI_LOOP_TARGET_ROOT" automation -- node "$ECHO_UI_LOOP_CONTROL_PLANE/scripts/loopctl.mjs" validate --activation --loop-root "$ECHO_UI_LOOP_TARGET_ROOT"` before scheduling. -3. Run every operational loop command, executor GitHub command, remote Git command, trigger, and reviewer publication through that installed launcher with the explicit target root. The repository launcher intentionally refuses credentials. The installed launcher hash-verifies its files, pins absolute executables, compares trusted channel fields, removes Node preload hooks, clears token overrides and process hooks, verifies `gh api user`, and gives Git a one-command credential helper without changing global Git or `gh` configuration. A PATH gate applies the role and current-run policy to descendant `git` and `gh` processes too; issue-worktree routers, PATH shims, arbitrary shell/environment commands, and arbitrary Node scripts are rejected. +2. From a clean owner-merged `dev` checkout at exact `origin/dev`, install a new versioned control plane outside every repository worktree and every scheduler-writable root with `install-trusted-control-plane.mjs`. Expose it read/execute-only to the unattended process through the scheduler sandbox or separate ownership/ACLs; the automation identity must not be able to rewrite it, its parent, the pinned executables, modes, ACLs, or sandbox policy. Set `ECHO_UI_LOOP_CONTROL_PLANE` to its `issue-dev-loop` directory and `ECHO_UI_LOOP_TARGET_ROOT` to the scheduled worktree's loop directory. Run `"$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" --loop-root "$ECHO_UI_LOOP_TARGET_ROOT" automation -- node "$ECHO_UI_LOOP_CONTROL_PLANE/scripts/loopctl.mjs" validate --activation --loop-root "$ECHO_UI_LOOP_TARGET_ROOT"` before scheduling. +3. Run every operational loop command, executor GitHub command, remote Git command, trigger, and reviewer publication through that installed launcher with the explicit target root. The repository launcher intentionally refuses credentials. The installed launcher hash-verifies its files and absolute executables, compares trusted channel fields, removes Node preload hooks, clears token overrides and process hooks, verifies `gh api user`, and gives Git a one-command credential helper without changing global Git or `gh` configuration. A PATH gate applies the role and current-run policy to descendant `git` and `gh` processes too; issue-worktree routers, PATH shims, arbitrary shell/environment commands, and arbitrary Node scripts are rejected. 4. Create one dedicated repository issue for the append-only loop state journal and set its number as `stateIssueNumber`. It stores active checkpoints and terminal records. Restrict journal entries to the automation identity; humans may read but should not edit or delete them. 5. Enable GitHub notifications for mentions and review requests for `codeacme17`. 6. Optionally set `ECHO_UI_LOOP_OWNER_WEBHOOK_URL` to an endpoint that accepts the notification JSON with `Content-Type: application/json`. diff --git a/loops/issue-dev-loop/LOOP.md b/loops/issue-dev-loop/LOOP.md index ada7cb5c..bdcf93e2 100644 --- a/loops/issue-dev-loop/LOOP.md +++ b/loops/issue-dev-loop/LOOP.md @@ -32,7 +32,7 @@ The loop may: - create and update a draft PR targeting `dev` - request independent review and post its findings - respond to review comments with evidence -- mark the PR ready and request the owner's review +- notify the owner when the Draft PR has passed every automated gate - notify the owner and observe owner actions The separate evolve workflow in [`evolve/EVOLVE.md`](./evolve/EVOLVE.md) may push only the exact `codex/evolve-<pending-request-id>` branch and create its Draft PR to `dev`. That authorization exists only while the matching request file is pending. @@ -48,6 +48,7 @@ The loop must obtain owner confirmation before: The loop must never: - approve, auto-merge, or merge any PR +- mark any Draft PR ready for review - call `gh pr merge` or enable a merge queue for its PR - push directly to `dev` or `main` - target `main` from an issue branch @@ -82,7 +83,7 @@ The recorded implementation commit is the product-code boundary. Later commits m ### 5. Verify before publication -Run relevant checks and `pnpm verify`. For UI behavior, capture before/after screenshots under `screen-shots/<run-id>` at meaningful desktop and mobile viewports and include interaction or accessibility evidence where applicable. Bind `before` to the frozen base and `after` to the latest `$implement` commit; never put the containing commit's not-yet-known hash inside its own files. Commit sanitized screenshots and run metadata to the issue branch. The low-privilege `pull_request` evidence workflow checks out owner-merged base code and the exact candidate head without persisted credentials, installs protected dependencies with lifecycle scripts disabled, and copies the candidate into Docker volumes. Candidate `pnpm verify` and owner-merged baseline `pnpm test` run in separate no-network containers that receive no GitHub token and cannot mount either host checkout. Before accepting the artifact, the installed control plane independently rejects any exact-head change to the workflow or trusted control/verification plane. The manifest binds candidate head, workflow-run SHA, and owner-merged base SHA. +Run relevant checks and `pnpm verify`. For UI behavior, capture before/after screenshots under `screen-shots/<run-id>` at meaningful desktop and mobile viewports and include interaction or accessibility evidence where applicable. Bind `before` to the frozen base and `after` to the latest `$implement` commit; never put the containing commit's not-yet-known hash inside its own files. Commit sanitized screenshots and run metadata to the issue branch. The low-privilege `pull_request` evidence workflow checks out the exact candidate head, resolves its frozen run base, proves that base remains an ancestor of live `dev`, and separately checks out the immutable owner-merged base without persisted credentials. It installs candidate and baseline dependencies with lifecycle scripts disabled into distinct Docker volumes. Candidate `pnpm verify` and the actual frozen owner-merged baseline `pnpm test` run in separate no-network containers that receive no GitHub token and cannot mount any host checkout. Before accepting the artifact, the installed control plane independently rejects any exact-head change to the workflow or trusted control/verification plane. The manifest binds candidate head, workflow-run SHA, frozen owner-merged base SHA, and the live PR base SHA that selected the workflow. ### 6. Create the draft PR @@ -94,7 +95,7 @@ Spawn `echo_ui_pr_reviewer` with fresh context and read-only filesystem access. ### 8. Owner gate -After exact-head CI and independent review pass, download the CI manifest and record its artifact URL with `record-evidence`; the command downloads the artifact again, byte-compares its manifest, and requires a successful run of the named workflow for the recorded PR/head. Record the fresh review cycle and its GitHub URL separately with `record-review`. Mark the PR ready, request review from `codeacme17`, send a blocking GitHub notification (which pauses the run), and transition to `awaiting_owner_review`. The transition requires that delivered notification and queries GitHub for an open, non-draft PR targeting `dev` whose live branch and head SHA match the run. Do not infer approval from timeouts or silence. +After exact-head CI and independent review pass, download the CI manifest and record its artifact URL with `record-evidence`; the command downloads the artifact again, byte-compares its manifest, and requires a successful run of the named workflow for the recorded PR/head. Record the fresh review cycle and its GitHub URL separately with `record-review`. Keep the PR in Draft, send `codeacme17` a blocking GitHub notification asking the owner to mark it ready, review, and either merge or request changes, and transition to `awaiting_owner_review`. The transition requires that delivered notification and queries GitHub for an open Draft PR targeting `dev` whose live branch and head SHA match the run. Only the owner may change the Draft to Ready. Do not infer readiness, approval, or consent from timeouts or silence. ### 9. Owner feedback @@ -123,9 +124,9 @@ Never log secrets, full environment dumps, cookies, auth headers, private user d ## Notifications -GitHub issue/PR comments are the canonical communication record. The shared owner channel may mirror notifications to a webhook. The notification runtime automatically transitions blocking events to `waiting_for_owner`; delivery failure is itself a blocker. `pr_ready_for_review` moves from that pause to `awaiting_owner_review` only after its SHA-bound evidence gates pass. +GitHub issue/PR comments are the canonical communication record. The shared owner channel may mirror notifications to a webhook. The notification runtime automatically transitions blocking events to `waiting_for_owner`; delivery failure is itself a blocker. `pr_ready_for_review` means the still-Draft PR has passed the loop's automated gates; it moves from that pause to `awaiting_owner_review` only after its SHA-bound evidence gates pass, and asks the owner to perform the GitHub Ready transition. -All credential-bearing commands run from an installed, hash-verified control plane outside the issue worktree. The installed launcher pins absolute Node, Git, and GitHub CLI executables; treats the worktree loop root only as data; compares its security-critical channel fields with the installed owner channel; and refuses tampering or PATH impersonation before loading either GitHub profile. The repository launcher never receives credentials. +All credential-bearing commands run from an installed, hash-verified control plane outside the issue worktree and outside every unattended-writable root. The scheduler/OS must expose that bundle read/execute-only; mode bits and a co-located manifest are not themselves a trust boundary against the same OS principal. The installed launcher pins and hashes absolute Node, Git, and GitHub CLI executables; treats the worktree loop root only as data; compares its security-critical channel fields with the installed owner channel; and refuses detected tampering or PATH impersonation before loading either GitHub profile. The repository launcher never receives credentials. A paused run resumes only after successful canonical GitHub delivery and a new, run-bound owner decision. The notification tells the owner to include `RESUME <run-id>` in a normal reply; a GitHub request-changes review is accepted without that token. An unrelated, stale, wrong-author, wrong-target, or pre-delivery comment never unlocks the run. diff --git a/loops/issue-dev-loop/SKILL.md b/loops/issue-dev-loop/SKILL.md index 6df46570..4920734b 100644 --- a/loops/issue-dev-loop/SKILL.md +++ b/loops/issue-dev-loop/SKILL.md @@ -57,11 +57,11 @@ The executor must classify every finding as `accepted`, `rejected`, `needs-human ## Verify and notify the owner -Run verification appropriate to the change and require `pnpm verify` before the PR is ready for owner review. Commit sanitized run metadata and relevant `screen-shots` to the issue branch. Wait for the low-privilege `pull_request` evidence workflow: it prepares protected dependencies with lifecycle scripts disabled and runs candidate `pnpm verify` plus owner-merged baseline `pnpm test` in separate no-network Docker volumes with no GitHub token or host-checkout mount. The base-checkout generator binds candidate head, workflow-run SHA, and owner-merged base SHA. Download its artifact and run `loopctl.mjs record-evidence --run-id <id> --manifest <absolute-path> --publication-url <artifact-url>` from the exact artifact-head worktree; the installed control plane revalidates the protected diff locally before querying GitHub. +Run verification appropriate to the change and require `pnpm verify` before the PR is ready for owner review. Commit sanitized run metadata and relevant `screen-shots` to the issue branch. Wait for the low-privilege `pull_request` evidence workflow: it prepares candidate and frozen-baseline dependencies with lifecycle scripts disabled and runs candidate `pnpm verify` plus the actual frozen owner-merged `pnpm test` in separate no-network Docker volumes with no GitHub token or host-checkout mount. The frozen-base generator binds candidate head, workflow-run SHA, frozen owner-merged base SHA, and live workflow base SHA. Download its artifact and run `loopctl.mjs record-evidence --run-id <id> --manifest <absolute-path> --publication-url <artifact-url>` from the exact artifact-head worktree; the installed control plane revalidates the protected diff locally before querying GitHub. -Before publishing the final review round, write the complete cycle result with an unassigned final `reviewUrl`, then run `loopctl.mjs review-digest --result <absolute-path>`. Add the returned marker to the final review body, publish the non-approving review, replace the unassigned URL with GitHub's actual review URL, and confirm `review-digest` is unchanged. After all responses are posted, run `loopctl.mjs record-review --run-id <id> --result <absolute-path> --review-url <github-review-url>`. The publication digest deliberately canonicalizes GitHub-assigned review URLs while the stored full-file digest still protects the final artifact. Both evidence and review gates must name the current PR head. Emit a blocking `pr_ready_for_review` notification, then transition from `waiting_for_owner` to `awaiting_owner_review` with the PR URL and exact head SHA. +Before publishing the final review round, write the complete cycle result with an unassigned final `reviewUrl`, then run `loopctl.mjs review-digest --result <absolute-path>`. Add the returned marker to the final review body, publish the non-approving review, replace the unassigned URL with GitHub's actual review URL, and confirm `review-digest` is unchanged. After all responses are posted, run `loopctl.mjs record-review --run-id <id> --result <absolute-path> --review-url <github-review-url>`. The publication digest deliberately canonicalizes GitHub-assigned review URLs while the stored full-file digest still protects the final artifact. Both evidence and review gates must name the current PR head. Keep the PR Draft, emit a blocking `pr_ready_for_review` notification asking `codeacme17` to mark it Ready and review it, then transition from `waiting_for_owner` to `awaiting_owner_review` with the PR URL and exact head SHA. -The owner is the only actor allowed to approve or merge. Never call `gh pr merge`, enable auto-merge, push to `main`, push to `dev`, dismiss owner feedback, or bypass branch protections. Before any terminal transition, run `prepare-finalization`, publish its exact body to the configured state-journal issue, and validate the returned comment URL with `record-finalization`. A completed run passes the same result and comment URL to `observe-owner-merge`, which queries GitHub and requires both `codeacme17`'s approval and merge at the reviewed head SHA. Future workspaces run `reconcile` to rebuild local history and evolve metrics from those automation-authored journal comments. +The owner is the only actor allowed to mark a Draft PR Ready, approve it, or merge it. Never call non-`--undo` `gh pr ready`, `gh pr merge`, enable auto-merge, push to `main`, push to `dev`, dismiss owner feedback, or bypass branch protections. Before any terminal transition, run `prepare-finalization`, publish its exact body to the configured state-journal issue, and validate the returned comment URL with `record-finalization`. A completed run passes the same result and comment URL to `observe-owner-merge`, which queries GitHub and requires both `codeacme17`'s approval and merge at the reviewed head SHA. Future workspaces run `reconcile` to rebuild local history and evolve metrics from those automation-authored journal comments. For any pause, do not resume from silence or an arbitrary comment. First require a successfully delivered blocking notification. Then verify the owner's GitHub decision with `loopctl.mjs record-owner-response --run-id <id> --response-url <comment-or-review-url>`. A normal comment must include the exact `RESUME <run-id>` token printed in the notification; a `CHANGES_REQUESTED` review is itself an explicit decision and must be submitted against the run's current exact head SHA. Only then may `loopctl.mjs transition --run-id <id> --status running` continue. Before any repair work or new push, publish that transition's checkpoint, run the unchanged exact PR through `gh pr ready --undo --repo codeacme17/echo-ui`, observe the same head as Draft with `record-pr`, and publish another checkpoint. `$implement` repair attestations and later PR rebinds are rejected unless this durable redraft happened after the current owner response. diff --git a/loops/issue-dev-loop/dependencies.md b/loops/issue-dev-loop/dependencies.md index 94f9ad24..2f8672e7 100644 --- a/loops/issue-dev-loop/dependencies.md +++ b/loops/issue-dev-loop/dependencies.md @@ -35,6 +35,8 @@ export ECHO_UI_LOOP_CONTROL_PLANE="/absolute/path/outside-the-repository/echo-ui export ECHO_UI_LOOP_TARGET_ROOT="$PWD/loops/issue-dev-loop" ``` -The installer refuses a dirty checkout, a branch other than `dev`, a commit other than `origin/dev`, an in-repository target, or an existing target. It pins absolute Node/Git/GitHub CLI executables, copies the owner channel and runtime, hashes every installed file, and makes the bundle read-only. Install updates only after an owner-merged loop-control change; never install control code from an issue branch. +The installer refuses a dirty checkout, a branch other than `dev`, a commit other than `origin/dev`, an in-repository target, or an existing target. It pins and hashes the absolute Node/Git/GitHub CLI executables, copies the owner channel and runtime, hashes every installed file, and makes the bundle mode-only read-only. Install updates only after an owner-merged loop-control change; never install control code from an issue branch. + +The trust root is the scheduler/OS boundary, not Unix mode bits or a self-hashed manifest. Put the installed bundle outside every filesystem root writable by the unattended Codex process, and expose it read/execute-only through the scheduler sandbox or separate ownership/ACLs. The automation identity must not have permission to change the bundle, its parent, the pinned executables, file modes, ACLs, or the sandbox policy. A same-OS-principal process allowed to rewrite both code and its manifest cannot establish a cryptographic trust root in user space; do not activate the loop under that permission model. Never run `gh auth setup-git` for this loop. Route commands through `"$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" --loop-root "$ECHO_UI_LOOP_TARGET_ROOT" <role> -- ...`. The repository copy is installer source and intentionally refuses credential use. The installed launcher verifies its manifest before selecting an identity, removes Node preload hooks, scopes `GH_CONFIG_DIR` and the Git credential helper to one allowlisted child tree, gates descendant `git`/`gh` calls, and leaves the user's default `gh` account and global Git credential configuration unchanged. diff --git a/loops/issue-dev-loop/references/evidence-policy.md b/loops/issue-dev-loop/references/evidence-policy.md index d57bb18b..38386342 100644 --- a/loops/issue-dev-loop/references/evidence-policy.md +++ b/loops/issue-dev-loop/references/evidence-policy.md @@ -4,7 +4,7 @@ Evidence proves the acceptance criteria against the exact PR head SHA. ## Always include -- run ID, issue number, base SHA, and head SHA +- run ID, issue number, frozen base SHA, live workflow base SHA, and head SHA - commands executed, exit codes, and UTC timestamps - targeted test results - final `pnpm verify` result @@ -25,6 +25,6 @@ Use descriptive names such as `02-after-player-mobile-375.webp` under `screen-sh ## Storage -Commit issue-relevant PNG/WebP screenshots and their metadata under `screen-shots/<run-id>` before owner review. The low-privilege `issue-dev-loop-evidence.yml` workflow runs on `pull_request`, checks out owner-merged base code and the exact PR head separately with persisted credentials disabled, prepares protected dependencies with lifecycle scripts disabled, and snapshots two independent Docker volumes. Candidate `pnpm verify` and owner-merged baseline `pnpm test` run in no-network containers without GitHub tokens or host-checkout mounts. Only after those containers exit does the host-side base-checkout script generate and upload the manifest, sanitized run log, test logs, and screenshots. Download the artifact, then pass its URL to `record-evidence` from the exact artifact-head worktree; never accept an artifact from a different candidate head, workflow-run SHA, or owner-merged base SHA. +Commit issue-relevant PNG/WebP screenshots and their metadata under `screen-shots/<run-id>` before owner review. The low-privilege `issue-dev-loop-evidence.yml` workflow runs on `pull_request`, checks out the current owner-merged control commit, the frozen owner-merged run base, and the exact PR head separately with persisted credentials disabled, prepares candidate and frozen-baseline dependencies with lifecycle scripts disabled, and snapshots two independent Docker volumes. Candidate `pnpm verify` and the actual frozen owner-merged baseline `pnpm test` run in no-network containers without GitHub tokens or host-checkout mounts. Only after those containers exit does the host-side frozen-base script generate and upload the manifest, sanitized run log, test logs, and screenshots. Download the artifact, then pass its URL to `record-evidence` from the exact artifact-head worktree; never accept an artifact from a different candidate head, workflow-run SHA, frozen owner-merged base SHA, or live workflow base SHA. `record-evidence` first executes the installed candidate-control validator against the exact local Git commit, so an artifact cannot be accepted when the PR changed the workflow, loop runtime, package scripts, lockfile, package hooks, or verification configuration. It then queries the GitHub artifact and workflow-run APIs, requires the named low-privilege workflow to conclude successfully for the recorded PR/base/head and workflow-run SHA, downloads the artifact through `gh run download`, and byte-compares its manifest with the supplied file. Failed CI artifacts and locally fabricated manifests are rejected. The independent reviewer posts findings and responses to GitHub after the draft PR exists. Save every round's unique review URL in the structured cycle result and use the final one with `record-review --review-url <github-review-url>`; the runtime binds each round to its author, submission time, exact head, comments, repairs, and replies. The final review marker uses `review-digest`, whose canonical digest excludes only GitHub-assigned review URLs; insert the returned final URL after publication and verify the digest is unchanged. Keep raw local command output, videos, traces, and bulky reports in ignored runtime directories. Never upload secrets, cookies, auth state, user data, or unredacted environment dumps. diff --git a/loops/issue-dev-loop/references/github-operations.md b/loops/issue-dev-loop/references/github-operations.md index 85df60fa..d1e22195 100644 --- a/loops/issue-dev-loop/references/github-operations.md +++ b/loops/issue-dev-loop/references/github-operations.md @@ -18,7 +18,7 @@ Publish reviewer output only as a non-approving comment review: "$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" --loop-root "$ECHO_UI_LOOP_TARGET_ROOT" reviewer -- gh pr review <number> --repo codeacme17/echo-ui --comment --body <review-body> ``` -The shell launcher removes Node preload hooks before starting the router. The router then builds a strict child environment, rejects reviewer pushes and every reviewer mutation except a comment-only review on the recorded PR, and rejects approvals, change requests, merges, executor-authored reviews, GraphQL, and administration APIs. Reviewer publication requires the next run/cycle/round marker; the gate paginates existing reviews and rejects duplicates, skipped rounds, body files, or an omitted reviewer publication. A reviewer API write is permitted only for one exact-head `event=COMMENT` review with validated inline fields. Authenticated Git disables worktree hooks, fsmonitor, external diff, textconv, proxy environment variables, repository-local HTTP/proxy/cookie/header/SSL/URL rewrites, and the `ext` transport. Remote Git is restricted to exact origin push/fetch/issue-branch discovery shapes and executes against a canonical configured-repository HTTPS URL. Automation API mutations are limited to the current issue's exact claim label, the current issue/PR or journal comments, and current-PR review-comment replies. PR creation requires the durably authorized issue or published evolve request branch, explicit `--repo codeacme17/echo-ui`, `--base dev`, and `--draft`; later PR writes require the same explicit repository (or full configured PR URL), the exact durable checkpoint, the recorded live PR branch/base/head, and the phase-specific Draft/evidence/review gates. `pr edit` is limited to title/body updates and requesting `codeacme17`. Issue pushes derive `codex/issue-<number>` from the durable checkpoint and reject local branch overrides; evolve pushes require the automation-authored request publication. Every other push shape is rejected. +The shell launcher removes Node preload hooks before starting the router. The router then builds a strict child environment, rejects reviewer pushes and every reviewer mutation except a comment-only review on the recorded PR, and rejects approvals, change requests, merges, executor-authored reviews, GraphQL, and administration APIs. Reviewer publication requires the next run/cycle/round marker; the gate paginates existing reviews and rejects duplicates, skipped rounds, body files, or an omitted reviewer publication. A reviewer API write is permitted only for one exact-head `event=COMMENT` review with validated inline fields. Authenticated Git disables worktree hooks, fsmonitor, external diff, textconv, proxy environment variables, repository-local HTTP/proxy/cookie/header/SSL/URL rewrites, and the `ext` transport. Remote Git is restricted to exact origin push/fetch/issue-branch discovery shapes and executes against a canonical configured-repository HTTPS URL. Automation API mutations are limited to the current issue's exact claim label, the current issue/PR or journal comments, and current-PR review-comment replies. PR bodies and comments must be inline; body files, API input files, and typed `@file` expansion are rejected. PR creation requires the durably authorized issue or published evolve request branch, explicit `--repo codeacme17/echo-ui`, `--base dev`, and `--draft`; later PR writes require the same explicit repository (or full configured PR URL), the exact durable checkpoint, the recorded live PR branch/base/head, and the phase-specific Draft/evidence/review gates. `pr edit` is limited to title/body updates and requesting `codeacme17`; non-`--undo` `pr ready` is rejected because only the owner may mark a Draft Ready. Issue pushes derive `codex/issue-<number>` from the durable checkpoint, require a clean exact local issue branch whose post-`$implement` commits contain only the current run's evidence paths, and reject local branch overrides; evolve pushes require the automation-authored request publication. Every other push shape is rejected. ## Selection and claim @@ -37,12 +37,12 @@ The shell launcher removes Node preload hooks before starting the router. The ro - Run `prepare-checkpoint`, publish its exact body on the state-journal issue, and run `record-checkpoint` immediately after binding or rebinding the PR. - Create the body from `templates/pr-body.md`. Initial `record-pr` rejects a body missing the run marker, issue closure, full base/head SHAs, or owner-only merge statement. After a repair push, rebind the new live head first, checkpoint it, then update the body through the exact-head router; the owner-ready gate still requires the visible final head SHA. - Include `Closes #<number>` only when the PR fully satisfies the issue. -- Request `codeacme17` only after automated review and verification pass. +- Notify `codeacme17` only after automated review and verification pass, keep the PR Draft, and ask the owner to mark it Ready before reviewing. - Bind every review to immutable base and head SHAs. ## Evidence artifact -The low-privilege `pull_request` workflow `Issue dev loop evidence` runs only when the branch contains one active issue run. Both checkouts disable persisted credentials. A base-checkout container-preparation step installs the protected lockfile with lifecycle scripts disabled and creates two independent Docker volumes before candidate code runs. Candidate `pnpm verify` and owner-merged baseline `pnpm test` run with `--network none`, no inherited GitHub token, and no mount of either host checkout. The base-checkout generator remains outside those containers and binds the manifest to candidate head, workflow-run SHA, and owner-merged base SHA. The installed control plane later rejects the artifact unless its own exact-head diff check proves that the PR did not change the loop plane, owner channel, workflow, verification scripts, package manifests, lock/workspace files, package hooks, or verification configuration. Wait for that exact-head run to complete, then locate and download the artifact: +The low-privilege `pull_request` workflow `Issue dev loop evidence` runs only when the branch contains one active issue run. All checkouts disable persisted credentials. It resolves the run's frozen base from the exact candidate, proves that base remains an ancestor of live `dev`, and checks out that immutable owner-merged commit separately from the current control checkout. Container preparation installs the protected candidate and frozen-baseline lockfiles with lifecycle scripts disabled into independent Docker volumes before candidate code runs. Candidate `pnpm verify` and the actual frozen owner-merged baseline `pnpm test` run with `--network none`, no inherited GitHub token, and no mount of any host checkout. The frozen-base generator remains outside those containers and binds the manifest to candidate head, workflow-run SHA, frozen owner-merged base SHA, and the live PR base SHA. The installed control plane later rejects the artifact unless its own exact-head diff check proves that the PR did not change the loop plane, owner channel, workflow, verification scripts, package manifests, lock/workspace files, package hooks, or verification configuration. Wait for that exact-head run to complete, then locate and download the artifact: ```text "$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" --loop-root "$ECHO_UI_LOOP_TARGET_ROOT" automation -- gh run list --workflow issue-dev-loop-evidence.yml --branch codex/issue-<number> diff --git a/loops/issue-dev-loop/review/REVIEW.md b/loops/issue-dev-loop/review/REVIEW.md index 16fe8901..b9759522 100644 --- a/loops/issue-dev-loop/review/REVIEW.md +++ b/loops/issue-dev-loop/review/REVIEW.md @@ -31,4 +31,4 @@ After all replies are posted, create one cycle result matching `result.schema.js ## Completion -Bind every round to a head SHA. A new push invalidates the previous PASS because `awaiting_owner_review` requires a recorded review for its exact head. Allow at most two automated rounds. Any `needs-human` classification prevents a PASS result; unresolved P0/P1 findings block owner-ready status. +Bind every round to a head SHA. A new push invalidates the previous PASS because `awaiting_owner_review` requires a recorded review for its exact head. Allow at most two automated rounds. Any `needs-human` classification prevents a PASS result; unresolved P0/P1 findings block notification that the Draft is ready for the owner's action. diff --git a/loops/issue-dev-loop/schemas/evidence.schema.json b/loops/issue-dev-loop/schemas/evidence.schema.json index 93941286..ff034dca 100644 --- a/loops/issue-dev-loop/schemas/evidence.schema.json +++ b/loops/issue-dev-loop/schemas/evidence.schema.json @@ -9,6 +9,7 @@ "baseSha", "headSha", "trustedWorkflowSha", + "workflowBaseSha", "workflowRunSha", "verdict", "checks", @@ -23,6 +24,7 @@ "baseSha": { "type": "string", "pattern": "^[0-9a-fA-F]{40}$" }, "headSha": { "type": "string", "pattern": "^[0-9a-fA-F]{40}$" }, "trustedWorkflowSha": { "type": "string", "pattern": "^[0-9a-fA-F]{40}$" }, + "workflowBaseSha": { "type": "string", "pattern": "^[0-9a-fA-F]{40}$" }, "workflowRunSha": { "type": "string", "pattern": "^[0-9a-fA-F]{40}$" }, "verdict": { "enum": ["passed", "failed", "blocked"] }, "checks": { diff --git a/loops/issue-dev-loop/scripts/generate-evidence.mjs b/loops/issue-dev-loop/scripts/generate-evidence.mjs index d9fb32ce..ca60f452 100644 --- a/loops/issue-dev-loop/scripts/generate-evidence.mjs +++ b/loops/issue-dev-loop/scripts/generate-evidence.mjs @@ -12,6 +12,7 @@ const loopRoot = args['loop-root'] ? path.resolve(args['loop-root']) : DEFAULT_L const runId = assertNonEmpty(args['run-id'], '--run-id') const headSha = assertNonEmpty(args['head-sha'], '--head-sha') const trustedWorkflowSha = assertNonEmpty(args['trusted-workflow-sha'], '--trusted-workflow-sha') +const workflowBaseSha = assertNonEmpty(args['workflow-base-sha'], '--workflow-base-sha') const workflowRunSha = assertNonEmpty(args['workflow-run-sha'], '--workflow-run-sha') const status = assertNonEmpty(args.status, '--status') const baselineStatus = assertNonEmpty(args['baseline-status'], '--baseline-status') @@ -30,6 +31,9 @@ if (!/^[0-9a-f]{40}$/i.test(headSha)) throw new Error('evidence headSha must be if (!/^[0-9a-f]{40}$/i.test(trustedWorkflowSha)) { throw new Error('evidence trustedWorkflowSha must be a full Git SHA') } +if (!/^[0-9a-f]{40}$/i.test(workflowBaseSha)) { + throw new Error('evidence workflowBaseSha must be a full Git SHA') +} if (!/^[0-9a-f]{40}$/i.test(workflowRunSha)) { throw new Error('evidence workflowRunSha must be a full Git SHA') } @@ -210,6 +214,7 @@ const manifest = { baseSha: run.baseSha, headSha, trustedWorkflowSha, + workflowBaseSha, workflowRunSha, verdict: status, checks: [ diff --git a/loops/issue-dev-loop/scripts/install-trusted-control-plane.mjs b/loops/issue-dev-loop/scripts/install-trusted-control-plane.mjs index 0aff3222..a27221a1 100644 --- a/loops/issue-dev-loop/scripts/install-trusted-control-plane.mjs +++ b/loops/issue-dev-loop/scripts/install-trusted-control-plane.mjs @@ -137,6 +137,11 @@ async function main() { ) } const installedNode = await realpath(process.execPath) + const executableDigests = { + node: createHash('sha256').update(await readFile(installedNode)).digest('hex'), + git: createHash('sha256').update(await readFile(gitExecutable)).digest('hex'), + gh: createHash('sha256').update(await readFile(ghExecutable)).digest('hex'), + } const installedLauncher = path.join(targetLoopRoot, 'scripts', 'with-github-identity') await writeFile( installedLauncher, @@ -177,6 +182,7 @@ async function main() { git: await realpath(gitExecutable), gh: await realpath(ghExecutable), }, + executableDigests, files, } await writeFile( diff --git a/loops/issue-dev-loop/scripts/lib/evidence.mjs b/loops/issue-dev-loop/scripts/lib/evidence.mjs index 9bc78f7f..c422e1e3 100644 --- a/loops/issue-dev-loop/scripts/lib/evidence.mjs +++ b/loops/issue-dev-loop/scripts/lib/evidence.mjs @@ -265,6 +265,7 @@ export async function recordEvidence({ manifest.issueNumber !== run.issueNumber || manifest.baseSha !== run.baseSha || manifest.trustedWorkflowSha !== run.baseSha || + !/^[0-9a-f]{40}$/i.test(manifest.workflowBaseSha ?? '') || !/^[0-9a-f]{40}$/i.test(manifest.workflowRunSha ?? '') ) { throw new Error('evidence manifest does not match the run') @@ -311,7 +312,7 @@ export async function recordEvidence({ (pullRequest) => pullRequest.number === pullTarget.number && pullRequest.base?.ref === 'dev' && - pullRequest.base?.sha === manifest.trustedWorkflowSha && + pullRequest.base?.sha === manifest.workflowBaseSha && workflowRepositoryMatches(pullRequest.base?.repo, pullTarget), ) ) { diff --git a/loops/issue-dev-loop/scripts/lib/github-identity.mjs b/loops/issue-dev-loop/scripts/lib/github-identity.mjs index c3c60285..7567bd6c 100644 --- a/loops/issue-dev-loop/scripts/lib/github-identity.mjs +++ b/loops/issue-dev-loop/scripts/lib/github-identity.mjs @@ -343,6 +343,7 @@ function githubApiRequest(apiArguments) { let explicitMethod = null let endpoint = null let hasRequestBody = false + let usesFileExpansion = false let usesInput = false let valid = true const fields = [] @@ -360,6 +361,12 @@ function githubApiRequest(apiArguments) { if (bodyOptions.has(argument)) { hasRequestBody = true fields.push(value) + if ( + ['-F', '--field'].includes(argument) && + (value.startsWith('@') || value.slice(value.indexOf('=') + 1).startsWith('@')) + ) { + usesFileExpansion = true + } } index += 1 continue @@ -378,6 +385,12 @@ function githubApiRequest(apiArguments) { if (bodyOptions.has(longOption)) { hasRequestBody = true fields.push(value) + if ( + longOption === '--field' && + (value.startsWith('@') || value.slice(value.indexOf('=') + 1).startsWith('@')) + ) { + usesFileExpansion = true + } } continue } @@ -387,7 +400,14 @@ function githubApiRequest(apiArguments) { } if (/^-[fF].+/.test(argument)) { hasRequestBody = true - fields.push(argument.slice(2)) + const value = argument.slice(2) + fields.push(value) + if ( + argument.startsWith('-F') && + (value.startsWith('@') || value.slice(value.indexOf('=') + 1).startsWith('@')) + ) { + usesFileExpansion = true + } continue } if ( @@ -410,6 +430,7 @@ function githubApiRequest(apiArguments) { mutating: !valid || method !== 'GET', valid, fields, + usesFileExpansion, usesInput, } } @@ -564,8 +585,6 @@ function pullRequestCreateAllowed(args, commandIndex, authorization, expectedRep '-t': 'title', '--body': 'body', '-b': 'body', - '--body-file': 'bodyFile', - '-F': 'bodyFile', }, booleanOptions: { '--draft': 'draft', '-d': 'draft' }, }) @@ -576,9 +595,7 @@ function pullRequestCreateAllowed(args, commandIndex, authorization, expectedRep !repositoryInScope(exactlyOne(parsed.values, 'repository'), expectedRepository) || exactlyOne(parsed.values, 'base') !== 'dev' || !exactlyOne(parsed.values, 'title') || - Number(Boolean(exactlyOne(parsed.values, 'body'))) + - Number(Boolean(exactlyOne(parsed.values, 'bodyFile'))) !== - 1 + !exactlyOne(parsed.values, 'body') ) { return false } @@ -600,8 +617,6 @@ function pullRequestMutationAllowed(kind, args, commandIndex, authorization, exp '-t': 'title', '--body': 'body', '-b': 'body', - '--body-file': 'bodyFile', - '-F': 'bodyFile', '--add-reviewer': 'addReviewer', } : kind === 'comment' @@ -609,8 +624,6 @@ function pullRequestMutationAllowed(kind, args, commandIndex, authorization, exp ...repositoryValueOptions, '--body': 'body', '-b': 'body', - '--body-file': 'bodyFile', - '-F': 'bodyFile', } : repositoryValueOptions const parsed = parseOptions(args, commandIndex + 1, { @@ -629,14 +642,16 @@ function pullRequestMutationAllowed(kind, args, commandIndex, authorization, exp ) { return false } - if (kind === 'ready') return parsed.values.size <= 1 && parsed.booleans.size <= 1 - if (kind === 'comment') { + if (kind === 'ready') { return ( - Number(Boolean(exactlyOne(parsed.values, 'body'))) + - Number(Boolean(exactlyOne(parsed.values, 'bodyFile'))) === - 1 + parsed.values.size <= 1 && + parsed.booleans.size === 1 && + parsed.booleans.get('undo') === true ) } + if (kind === 'comment') { + return Boolean(exactlyOne(parsed.values, 'body')) + } const reviewers = (parsed.values.get('addReviewer') ?? []).flatMap((value) => value.split(',').map((login) => login.trim()), ) @@ -647,8 +662,11 @@ function pullRequestMutationAllowed(kind, args, commandIndex, authorization, exp return editedFields.length > 0 } -function automationApiMutationAllowed({ endpoint, method, fields }, authorization) { - if (!endpoint) return false +function automationApiMutationAllowed( + { endpoint, method, fields, usesFileExpansion, usesInput }, + authorization, +) { + if (!endpoint || usesInput || usesFileExpansion) return false const labels = endpoint.match(/^repos\/[^/]+\/[^/]+\/issues\/(\d+)\/labels(?:\/([^/]+))?$/) if (labels && Number(labels[1]) === authorization?.issue?.issueNumber) { if (method === 'POST') { @@ -689,6 +707,7 @@ function reviewerInlineReviewAllowed(request, authorization, expectedRepository) if ( request.method !== 'POST' || request.usesInput || + request.usesFileExpansion || !match || !repositoryInScope(match[1], expectedRepository) || Number(match[2]) !== authorization?.issue?.prNumber || @@ -1219,8 +1238,6 @@ function editRequestsOwnerReview(args, commandIndex) { '-t': 'title', '--body': 'body', '-b': 'body', - '--body-file': 'bodyFile', - '-F': 'bodyFile', '--add-reviewer': 'addReviewer', }, }) @@ -1356,24 +1373,17 @@ async function preflightPullRequestWrite({ return } if (intent.kind === 'ready') { - if (readyReturnsToDraft(args, intent.commandIndex)) { - const ownerResponse = events.findLast( - (event) => - event.type === 'owner_response_observed' && - event.status === 'observed' && - sameGitHubLogin(event.payload?.actor, channel.ownerGitHubLogin), - ) - if (livePullRequest.draft !== false || !ownerResponse) { - throw new Error('returning a PR to Draft requires a durable verified owner response') - } - return + if (!readyReturnsToDraft(args, intent.commandIndex)) { + throw new Error('only the configured owner may mark a Draft PR ready for review') } - if ( - livePullRequest.draft !== true || - !hasExactHeadGate(events, 'verification_completed', run.headSha) || - !hasExactHeadGate(events, 'review_completed', run.headSha) - ) { - throw new Error('PR ready requires exact-head evidence and review in the durable checkpoint') + const ownerResponse = events.findLast( + (event) => + event.type === 'owner_response_observed' && + event.status === 'observed' && + sameGitHubLogin(event.payload?.actor, channel.ownerGitHubLogin), + ) + if (livePullRequest.draft !== false || !ownerResponse) { + throw new Error('returning a PR to Draft requires a durable verified owner response') } return } @@ -1390,7 +1400,14 @@ async function preflightPullRequestWrite({ } } -async function preflightIssueBranchPush({ args, authorization, loopRoot, realGh, environment }) { +async function preflightIssueBranchPush({ + args, + authorization, + loopRoot, + realGit, + realGh, + environment, +}) { if (gitSubcommand(args).name !== 'push' || !authorization.issue?.runId) return const runId = authorization.issue.runId const events = await readEvents(loopRoot, runId) @@ -1417,6 +1434,65 @@ async function preflightIssueBranchPush({ args, authorization, loopRoot, realGh, ) { throw new Error('Git push requires the exact durable issue branch authorization') } + const repositoryRoot = path.resolve(loopRoot, '..', '..') + const [localBranch, localHead, localStatus] = await Promise.all([ + execFileAsync(realGit, ['branch', '--show-current'], { + cwd: repositoryRoot, + env: environment, + }), + execFileAsync(realGit, ['rev-parse', 'HEAD'], { + cwd: repositoryRoot, + env: environment, + }), + execFileAsync(realGit, ['status', '--porcelain'], { + cwd: repositoryRoot, + env: environment, + maxBuffer: 1024 * 1024, + }), + ]) + const headSha = localHead.stdout.trim() + if ( + localBranch.stdout.trim() !== durableRun.branch || + !/^[0-9a-f]{40}$/i.test(headSha) || + localStatus.stdout.trim() + ) { + throw new Error('Git push requires a clean checkout of the exact durable issue branch') + } + if (!/^[0-9a-f]{40}$/i.test(durableRun.implementationCommit ?? '')) { + throw new Error('Git push requires a recorded $implement commit') + } + await execFileAsync( + realGit, + ['merge-base', '--is-ancestor', durableRun.implementationCommit, headSha], + { cwd: repositoryRoot, env: environment }, + ) + const trailing = await execFileAsync( + realGit, + ['diff', '--name-status', `${durableRun.implementationCommit}..${headSha}`], + { cwd: repositoryRoot, env: environment, maxBuffer: 1024 * 1024 }, + ) + const permittedPrefixes = [ + `loops/issue-dev-loop/logs/runs/${runId}/`, + `loops/issue-dev-loop/handoffs/${runId}/`, + `loops/issue-dev-loop/screen-shots/${runId}/`, + `loops/issue-dev-loop/evidence/${runId}/`, + ] + const unexpected = trailing.stdout + .split('\n') + .filter(Boolean) + .filter((line) => { + const [status, ...files] = line.split('\t') + return ( + !['A', 'M'].includes(status) || + files.length !== 1 || + !permittedPrefixes.some((prefix) => files[0].startsWith(prefix)) + ) + }) + if (unexpected.length > 0) { + throw new Error( + `Git push contains unrecorded or unsafe post-$implement changes: ${unexpected.join(', ')}`, + ) + } const latestOwnerResponse = events.findLast( (event) => event.type === 'owner_response_observed' && event.status === 'observed', ) @@ -1593,6 +1669,7 @@ export async function runWithGitHubRole({ args, authorization, loopRoot, + realGit, realGh, environment: resolved.routedEnvironment, }) diff --git a/loops/issue-dev-loop/scripts/lib/notifications.mjs b/loops/issue-dev-loop/scripts/lib/notifications.mjs index 8cc72a33..e82423a8 100644 --- a/loops/issue-dev-loop/scripts/lib/notifications.mjs +++ b/loops/issue-dev-loop/scripts/lib/notifications.mjs @@ -62,6 +62,7 @@ export async function createNotification({ dryRun = false, environment = process.env, fetchImplementation = globalThis.fetch, + webhookTimeoutMs = 5000, githubComment = defaultGitHubComment, verifyAutomationIdentity = assertAutomationIdentity, githubApi, @@ -165,23 +166,9 @@ export async function createNotification({ notification.delivery.github = 'failed: target is not a GitHub issue or pull request URL' } - const webhookUrl = environment[channel.webhookEnvironmentVariable] - if (webhookUrl) { - try { - const response = await fetchImplementation(webhookUrl, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(notification), - }) - notification.delivery.webhook = response.ok - ? 'delivered' - : `failed: HTTP ${response.status}` - } catch (error) { - notification.delivery.webhook = `failed: ${error.message}` - } - } } + // Persist canonical GitHub delivery before attempting the optional webhook mirror. await writeJson(outboxFile, notification) const delivered = notification.delivery.github === 'delivered' await appendValidatedEvent({ @@ -212,6 +199,51 @@ export async function createNotification({ }) } } + const webhookUrl = environment[channel.webhookEnvironmentVariable] + if (!dryRun && webhookUrl) { + if (!Number.isInteger(webhookTimeoutMs) || webhookTimeoutMs < 1) { + throw new Error('webhookTimeoutMs must be a positive integer') + } + const controller = new AbortController() + let timeout + try { + const timeoutPromise = new Promise((_, reject) => { + timeout = setTimeout(() => { + controller.abort() + reject(new Error(`timed out after ${webhookTimeoutMs}ms`)) + }, webhookTimeoutMs) + }) + const response = await Promise.race([ + fetchImplementation(webhookUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(notification), + signal: controller.signal, + }), + timeoutPromise, + ]) + notification.delivery.webhook = response.ok + ? 'delivered' + : `failed: HTTP ${response.status}` + } catch (error) { + notification.delivery.webhook = `failed: ${error.message}` + } finally { + clearTimeout(timeout) + } + await writeJson(outboxFile, notification) + await appendValidatedEvent({ + loopRoot, + runId: normalizedRunId, + type: 'notification_webhook_finished', + status: notification.delivery.webhook === 'delivered' ? 'delivered' : 'failed', + payload: { + notificationId, + notificationType, + webhook: notification.delivery.webhook, + }, + now: new Date(), + }) + } if (blocking && !delivered && !dryRun) { throw new Error(`blocking notification was not delivered: ${notificationId}`) } diff --git a/loops/issue-dev-loop/scripts/lib/owner-gate.mjs b/loops/issue-dev-loop/scripts/lib/owner-gate.mjs index 5c31cdc6..6a2e31c8 100644 --- a/loops/issue-dev-loop/scripts/lib/owner-gate.mjs +++ b/loops/issue-dev-loop/scripts/lib/owner-gate.mjs @@ -2,6 +2,17 @@ import path from 'node:path' import { defaultGitHubApi, parseGitHubTarget, readJson, sameGitHubLogin } from './common.mjs' +async function paginateGitHubApi(githubApi, endpoint) { + const records = [] + for (let page = 1; ; page += 1) { + const separator = endpoint.includes('?') ? '&' : '?' + const batch = await githubApi(`${endpoint}${separator}per_page=100&page=${page}`) + if (!Array.isArray(batch)) throw new Error('GitHub paginated review response must be an array') + records.push(...batch) + if (batch.length < 100) return records + } +} + export async function observeOwnerApprovedMerge({ loopRoot, prUrl, @@ -20,7 +31,10 @@ export async function observeOwnerApprovedMerge({ ) const [pullRequest, reviews] = await Promise.all([ githubApi(`repos/${target.owner}/${target.repo}/pulls/${target.number}`), - githubApi(`repos/${target.owner}/${target.repo}/pulls/${target.number}/reviews?per_page=100`), + paginateGitHubApi( + githubApi, + `repos/${target.owner}/${target.repo}/pulls/${target.number}/reviews`, + ), ]) const headSha = pullRequest.head?.sha const configuredRepository = expectedRepository ?? channel.repository diff --git a/loops/issue-dev-loop/scripts/lib/run-store.mjs b/loops/issue-dev-loop/scripts/lib/run-store.mjs index a35d9bda..9b2f2eb3 100644 --- a/loops/issue-dev-loop/scripts/lib/run-store.mjs +++ b/loops/issue-dev-loop/scripts/lib/run-store.mjs @@ -605,7 +605,7 @@ export async function recordPullRequest({ `<!-- issue-dev-loop:run:${normalizedRunId} -->`, `Run ID: \`${normalizedRunId}\``, `Base SHA: \`${run.baseSha}\``, - 'This PR must be reviewed and merged by `@codeacme17`', + 'This PR must be marked Ready, reviewed, and merged by `@codeacme17`', ] if (isInitialBinding) requiredBodyFragments.push(`Head SHA: \`${headSha}\``) if (requiredBodyFragments.some((fragment) => !livePullRequest.body?.includes(fragment))) { @@ -999,7 +999,7 @@ export async function transitionRun({ `Run ID: \`${normalizedRunId}\``, `Base SHA: \`${run.baseSha}\``, `Head SHA: \`${headSha}\``, - 'This PR must be reviewed and merged by `@codeacme17`', + 'This PR must be marked Ready, reviewed, and merged by `@codeacme17`', ] const requiredSections = [ 'Changes', @@ -1035,7 +1035,7 @@ export async function transitionRun({ ))) if ( livePullRequest.state !== 'open' || - livePullRequest.draft !== false || + livePullRequest.draft !== true || !sameGitHubLogin(livePullRequest.user?.login, channel.automationGitHubLogin) || livePullRequest.base?.ref !== 'dev' || livePullRequest.head?.ref !== run.branch || @@ -1045,7 +1045,7 @@ export async function transitionRun({ !bodyHasExactProof ) { throw new Error( - 'awaiting_owner_review requires an automation-authored live PR with exact-head evidence and review links', + 'awaiting_owner_review requires an automation-authored live Draft PR with exact-head evidence and review links', ) } } diff --git a/loops/issue-dev-loop/scripts/lib/trusted-control-plane.mjs b/loops/issue-dev-loop/scripts/lib/trusted-control-plane.mjs index ea241dfa..5c2c8fa2 100644 --- a/loops/issue-dev-loop/scripts/lib/trusted-control-plane.mjs +++ b/loops/issue-dev-loop/scripts/lib/trusted-control-plane.mjs @@ -8,7 +8,7 @@ const moduleDirectory = path.dirname(fileURLToPath(import.meta.url)) const loopRoot = path.resolve(moduleDirectory, '..', '..') const bundleRoot = path.dirname(loopRoot) -async function verifiedExecutable(target, name) { +async function verifiedExecutable(target, name, expectedDigest) { if (!path.isAbsolute(target ?? '')) { throw new Error(`trusted control plane ${name} executable must be absolute`) } @@ -18,6 +18,15 @@ async function verifiedExecutable(target, name) { if (!stats.isFile() || stats.isSymbolicLink()) { throw new Error(`trusted control plane ${name} executable must be a regular file`) } + if (!/^[0-9a-f]{64}$/i.test(expectedDigest ?? '')) { + throw new Error(`trusted control plane ${name} executable digest is invalid`) + } + const actualDigest = createHash('sha256') + .update(await readFile(resolved)) + .digest('hex') + if (actualDigest !== expectedDigest) { + throw new Error(`trusted control plane ${name} executable integrity check failed`) + } return resolved } @@ -79,9 +88,21 @@ export async function loadTrustedControlPlane() { loopRoot: manifestLoopRoot, sourceCommit: manifest.sourceCommit, executables: { - node: await verifiedExecutable(manifest.executables?.node, 'node'), - git: await verifiedExecutable(manifest.executables?.git, 'git'), - gh: await verifiedExecutable(manifest.executables?.gh, 'gh'), + node: await verifiedExecutable( + manifest.executables?.node, + 'node', + manifest.executableDigests?.node, + ), + git: await verifiedExecutable( + manifest.executables?.git, + 'git', + manifest.executableDigests?.git, + ), + gh: await verifiedExecutable( + manifest.executables?.gh, + 'gh', + manifest.executableDigests?.gh, + ), }, } } diff --git a/loops/issue-dev-loop/scripts/lib/validation.mjs b/loops/issue-dev-loop/scripts/lib/validation.mjs index d8c85187..f3294414 100644 --- a/loops/issue-dev-loop/scripts/lib/validation.mjs +++ b/loops/issue-dev-loop/scripts/lib/validation.mjs @@ -174,8 +174,10 @@ export async function validateLoop({ !evidenceWorkflowSource.includes( "github.event.pull_request.head.ref != 'codex/issue-dev-loop'", ) || - !evidenceWorkflowSource.includes('Check out owner-merged baseline') || + !evidenceWorkflowSource.includes('Check out owner-merged control plane') || + !evidenceWorkflowSource.includes('Check out frozen owner-merged baseline') || !evidenceWorkflowSource.includes('ref: ${{ github.event.pull_request.base.sha }}') || + !evidenceWorkflowSource.includes('ref: ${{ steps.run.outputs.base_sha }}') || !evidenceWorkflowSource.includes('path: trusted') || (evidenceWorkflowSource.match(/persist-credentials: false/g)?.length ?? 0) < 3 || !evidenceWorkflowSource.includes( @@ -184,10 +186,13 @@ export async function validateLoop({ !evidenceWorkflowSource.includes('verifier.Dockerfile') || !evidenceWorkflowSource.includes('pnpm install --frozen-lockfile --ignore-scripts') || (evidenceWorkflowSource.match(/docker run --rm --network none/g)?.length ?? 0) < 2 || - !evidenceWorkflowSource.includes('src=${GITHUB_WORKSPACE}/trusted/tests') || + !evidenceWorkflowSource.includes('src=${GITHUB_WORKSPACE}/trusted,dst=/source,readonly') || !evidenceWorkflowSource.includes('pnpm test') || !evidenceWorkflowSource.includes( - '--trusted-workflow-sha "${{ github.event.pull_request.base.sha }}"', + '--trusted-workflow-sha "${{ steps.run.outputs.base_sha }}"', + ) || + !evidenceWorkflowSource.includes( + '--workflow-base-sha "${{ github.event.pull_request.base.sha }}"', ) || !evidenceWorkflowSource.includes( '--workflow-run-sha "${{ github.event.pull_request.head.sha }}"', diff --git a/loops/issue-dev-loop/scripts/resolve-run.mjs b/loops/issue-dev-loop/scripts/resolve-run.mjs index d5217cec..51c7953c 100644 --- a/loops/issue-dev-loop/scripts/resolve-run.mjs +++ b/loops/issue-dev-loop/scripts/resolve-run.mjs @@ -28,13 +28,16 @@ for (const entry of await readdir(runsRoot, { withFileTypes: true })) { } if (matches.length > 1) throw new Error(`multiple active runs found for ${branch}`) +if (matches[0] && !/^[0-9a-f]{40}$/i.test(matches[0].baseSha ?? '')) { + throw new Error('active run baseSha must be a full Git SHA') +} const result = matches[0] - ? { hasRun: true, runId: matches[0].runId } - : { hasRun: false, runId: null } + ? { hasRun: true, runId: matches[0].runId, baseSha: matches[0].baseSha } + : { hasRun: false, runId: null, baseSha: null } if (process.env.GITHUB_OUTPUT) { await appendFile( process.env.GITHUB_OUTPUT, - `has_run=${result.hasRun}\nrun_id=${result.runId ?? ''}\n`, + `has_run=${result.hasRun}\nrun_id=${result.runId ?? ''}\nbase_sha=${result.baseSha ?? ''}\n`, 'utf8', ) } diff --git a/loops/issue-dev-loop/templates/pr-body.md b/loops/issue-dev-loop/templates/pr-body.md index bf4b2cc0..8eb039be 100644 --- a/loops/issue-dev-loop/templates/pr-body.md +++ b/loops/issue-dev-loop/templates/pr-body.md @@ -23,4 +23,4 @@ Closes #{{ISSUE_NUMBER}} ## Known limitations -> This PR must be reviewed and merged by `@codeacme17`. The loop has no approval or merge authority. +> This PR must be marked Ready, reviewed, and merged by `@codeacme17`. The loop has no Ready, approval, or merge authority. diff --git a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs index 81a0219e..0b187d12 100644 --- a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs +++ b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs @@ -391,6 +391,29 @@ fi if [ "$1 $2 $3" = "config --local --get-regexp" ]; then exit 1 fi +if [ "$1 $2" = "branch --show-current" ]; then + echo "codex/issue-123" + exit 0 +fi +if [ "$1 $2" = "rev-parse HEAD" ]; then + echo "${'b'.repeat(40)}" + exit 0 +fi +if [ "$1 $2" = "status --porcelain" ]; then + if [ -f ${JSON.stringify(path.join(parent, 'dirty-git'))} ]; then + echo " M src/unsafe.ts" + fi + exit 0 +fi +if [ "$1" = "merge-base" ]; then + exit 0 +fi +if [ "$1 $2" = "diff --name-status" ]; then + if [ -f ${JSON.stringify(path.join(parent, 'unsafe-trailing-diff'))} ]; then + printf "M\\tsrc/unsafe.ts\\n" + fi + exit 0 +fi ${JSON.stringify(process.execPath)} -e 'process.stdout.write(JSON.stringify({args: process.argv.slice(1), config: process.env.GH_CONFIG_DIR, hasGhToken: Boolean(process.env.GH_TOKEN || process.env.GITHUB_TOKEN), gitConfig: Array.from({length: Number(process.env.GIT_CONFIG_COUNT)}, (_, index) => [process.env[\`GIT_CONFIG_KEY_\${index}\`], process.env[\`GIT_CONFIG_VALUE_\${index}\`]]).flat()}))' -- "$@" `, 'utf8', @@ -420,6 +443,11 @@ ${JSON.stringify(process.execPath)} -e 'process.stdout.write(JSON.stringify({arg git: await realpath(gitExecutable), gh: await realpath(fakeGh), }, + executableDigests: { + node: createHash('sha256').update(await readFile(process.execPath)).digest('hex'), + git: createHash('sha256').update(await readFile(gitExecutable)).digest('hex'), + gh: createHash('sha256').update(await readFile(fakeGh)).digest('hex'), + }, files: await fixtureManifestFiles(trustedBundleRoot), } await writeFile( @@ -565,6 +593,31 @@ test('authenticated routing refuses a tampered installed control-plane file', as ) }) +test('authenticated routing refuses a replaced pinned executable', async () => { + const fixture = await createFixture() + await writeFile(fixture.fakeGh, '#!/bin/sh\nexit 0\n', 'utf8') + await chmod(fixture.fakeGh, 0o755) + await assert.rejects( + execFileAsync( + process.execPath, + [ + fixture.routerPath, + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + 'gh', + 'api', + 'user', + '--jq', + '.login', + ], + { env: fixture.env }, + ), + /trusted control plane gh executable integrity check failed/, + ) +}) + test('wrapped activation validates both profiles without exposing their paths to loopctl', async () => { const fixture = await createFixture() const { stdout } = await execFileAsync( @@ -669,6 +722,31 @@ test('automation git command clears global helpers and injects the selected gh c ]) }) +test('automation push rejects dirty or unrecorded post-implement content', async () => { + for (const marker of ['dirty-git', 'unsafe-trailing-diff']) { + const fixture = await createFixture() + await writeFile(path.join(path.dirname(fixture.loopRoot), marker), '1\n', 'utf8') + await assert.rejects( + execFileAsync( + process.execPath, + [ + routerPath, + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + 'git', + 'push', + 'origin', + 'codex/issue-123', + ], + { env: fixture.env }, + ), + /(clean checkout|unrecorded or unsafe post-\$implement changes)/, + ) + } +}) + test('routed loopctl may perform its exact read-only identity probe', async () => { const fixture = await createFixture() const { stdout } = await execFileAsync( @@ -1043,6 +1121,11 @@ test('PR and issue mutations reject unsafe shapes and targets', async () => { ['automation', ['pr', 'ready', '107']], ['automation', ['pr', 'comment', '107', '--body', 'Wrong PR']], ['automation', ['pr', 'comment', '106', '--body', 'Missing repository']], + [ + 'automation', + ['pr', 'comment', '106', '--repo', 'example/repo', '--body-file', '/tmp/secret'], + ], + ['automation', ['pr', 'edit', '106', '--repo', 'example/repo', '-F', '/tmp/secret']], ['reviewer', ['pr', 'review', '106', '--comment', '--body', 'Missing repository']], [ 'reviewer', @@ -1067,6 +1150,17 @@ test('PR and issue mutations reject unsafe shapes and targets', async () => { 'labels[]=loop:claimed', ], ], + [ + 'automation', + [ + 'api', + 'repos/example/repo/issues/123/comments', + '--method', + 'POST', + '-F', + 'body=@/tmp/secret', + ], + ], ] for (const [role, ghArguments] of forbidden) { await assert.rejects( @@ -1080,48 +1174,30 @@ test('PR and issue mutations reject unsafe shapes and targets', async () => { } }) -test('PR ready is permitted only after exact-head evidence and review are durably checkpointed', async () => { - const premature = await createFixture() - await assert.rejects( - execFileAsync( - process.execPath, - [ - routerPath, - '--loop-root', - premature.loopRoot, - 'automation', - '--', - 'gh', - 'pr', - 'ready', - '106', - '--repo', - 'example/repo', - ], - { env: premature.env }, - ), - /exact-head evidence and review/, - ) - - const fixture = await createFixture({ readyToMark: true }) - const { stdout } = await execFileAsync( - process.execPath, - [ - routerPath, - '--loop-root', - fixture.loopRoot, - 'automation', - '--', - 'gh', - 'pr', - 'ready', - '106', - '--repo', - 'example/repo', - ], - { env: fixture.env }, - ) - assert.deepEqual(JSON.parse(stdout).args.slice(0, 2), ['pr', 'ready']) +test('only the owner may mark a Draft PR ready', async () => { + for (const readyToMark of [false, true]) { + const fixture = await createFixture({ readyToMark }) + await assert.rejects( + execFileAsync( + process.execPath, + [ + routerPath, + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + 'gh', + 'pr', + 'ready', + '106', + '--repo', + 'example/repo', + ], + { env: fixture.env }, + ), + /GitHub action is prohibited for the automation role/, + ) + } }) test('owner feedback durably authorizes returning only the exact recorded PR to Draft', async () => { @@ -1589,6 +1665,17 @@ test('automation push verifies that origin is the configured repository', async await writeFile( fixture.fakeGit, `#!/bin/sh +if [ "$1 $2" = "branch --show-current" ]; then + echo "codex/issue-123" + exit 0 +fi +if [ "$1 $2" = "rev-parse HEAD" ]; then + echo "${'b'.repeat(40)}" + exit 0 +fi +if [ "$1 $2" = "status --porcelain" ] || [ "$1" = "merge-base" ] || [ "$1 $2" = "diff --name-status" ]; then + exit 0 +fi if [ "$1 $2 $3" = "remote get-url origin" ]; then echo "https://github.com/attacker/other-repo.git" exit 0 @@ -1602,6 +1689,15 @@ exit 0 'utf8', ) await chmod(fixture.fakeGit, 0o755) + const manifestPath = path.join( + path.dirname(path.dirname(fixture.routerPath)), + 'trusted-control-plane.json', + ) + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) + manifest.executableDigests.git = createHash('sha256') + .update(await readFile(fixture.fakeGit)) + .digest('hex') + await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`, 'utf8') await assert.rejects( execFileAsync( diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index c87e7c0f..fb42833b 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -42,6 +42,7 @@ import { transitionRun as runtimeTransitionRun, validateLoop, } from '../scripts/runtime.mjs' +import { observeOwnerApprovedMerge } from '../scripts/lib/owner-gate.mjs' const bypassCheckpointVerifier = async () => {} const createNotification = (options) => @@ -74,6 +75,50 @@ const testDirectory = path.dirname(fileURLToPath(import.meta.url)) const repositoryLoopRoot = path.resolve(testDirectory, '..') const execFileAsync = promisify(execFile) +test('owner merge observation paginates beyond one hundred reviews', async () => { + const { loopRoot } = await createFixture() + const headSha = 'a'.repeat(40) + const mergeSha = 'b'.repeat(40) + const result = await observeOwnerApprovedMerge({ + loopRoot, + prUrl: 'https://github.com/codeacme17/echo-ui/pull/700', + expectedHeadSha: headSha, + expectedHeadBranch: 'codex/issue-700', + githubApi: async (endpoint) => { + if (endpoint.endsWith('/reviews?per_page=100&page=1')) { + return Array.from({ length: 100 }, (_, index) => ({ + id: index + 1, + user: { login: 'someone-else' }, + state: 'COMMENTED', + commit_id: headSha, + })) + } + if (endpoint.endsWith('/reviews?per_page=100&page=2')) { + return [ + { + id: 101, + user: { login: 'codeacme17' }, + state: 'APPROVED', + commit_id: headSha, + }, + ] + } + return { + merged: true, + merged_by: { login: 'codeacme17' }, + merge_commit_sha: mergeSha, + base: { ref: 'dev', repo: { full_name: 'codeacme17/echo-ui' } }, + head: { + ref: 'codex/issue-700', + sha: headSha, + repo: { full_name: 'codeacme17/echo-ui' }, + }, + } + }, + }) + assert.equal(result.mergeSha, mergeSha) +}) + async function createFixture() { const parent = await mkdtemp(path.join(os.tmpdir(), 'echo-ui-loop-test-')) const loopRoot = path.join(parent, 'issue-dev-loop') @@ -213,7 +258,7 @@ function pullRequestFixture(run, headSha, { draft = true, merged = false } = {}) 'Fresh-context review is attached or pending for this draft.', '## Known limitations', 'None known.', - 'This PR must be reviewed and merged by `@codeacme17`', + 'This PR must be marked Ready, reviewed, and merged by `@codeacme17`', ].join('\n'), } } @@ -340,6 +385,7 @@ async function writePassingEvidence({ loopRoot, run, headSha }) { baseSha: run.baseSha, headSha, trustedWorkflowSha: run.baseSha, + workflowBaseSha: run.baseSha, workflowRunSha: headSha, verdict: 'passed', checks: [ @@ -1260,6 +1306,23 @@ test('recordEvidence rejects failed workflow runs and mismatched artifact manife }), /does not match the published artifact manifest/, ) + const advancedDevRun = successfulWorkflowRun(run, headSha, 301, 800) + advancedDevRun.pull_requests[0].base.sha = 'f'.repeat(40) + const advancedManifest = JSON.parse(manifestSource) + advancedManifest.workflowBaseSha = 'f'.repeat(40) + const advancedManifestSource = `${JSON.stringify(advancedManifest, null, 2)}\n` + await writeFile(manifestPath, advancedManifestSource, 'utf8') + const recorded = await recordEvidence({ + loopRoot, + runId: run.runId, + manifestPath, + publicationUrl: 'https://github.com/codeacme17/echo-ui/actions/runs/800/artifacts/900', + githubApi: async (endpoint) => + endpoint.includes('/actions/artifacts/') ? artifact : advancedDevRun, + artifactManifestLoader: async () => advancedManifestSource, + }) + assert.equal(recorded.headSha, headSha) + await writeFile(manifestPath, manifestSource, 'utf8') const implementationEvent = ( await readFile(path.join(loopRoot, 'logs', 'runs', run.runId, 'events.jsonl'), 'utf8') @@ -1407,6 +1470,7 @@ test('CI helpers resolve a run and generate exact-head screenshot evidence', asy run.branch, ]) assert.equal(JSON.parse(resolveResult.stdout).runId, run.runId) + assert.equal(JSON.parse(resolveResult.stdout).baseSha, run.baseSha) const output = path.join(loopRoot, 'evidence', run.runId, 'manifest.json') await execFileAsync( @@ -1421,6 +1485,8 @@ test('CI helpers resolve a run and generate exact-head screenshot evidence', asy headSha, '--trusted-workflow-sha', run.baseSha, + '--workflow-base-sha', + run.baseSha, '--workflow-run-sha', headSha, '--status', @@ -1449,7 +1515,7 @@ test('CI helpers resolve a run and generate exact-head screenshot evidence', asy assert.match(evidence.screenshots[1].sha256, /^[0-9a-f]{64}$/) }) -test('owner-ready transition requires verification and review but remains resumable', async () => { +test('owner-review waiting transition keeps the exact verified PR Draft and remains resumable', async () => { const { loopRoot } = await createFixture() const { run } = await startFixtureRun({ loopRoot, @@ -1523,7 +1589,7 @@ test('owner-ready transition requires verification and review but remains resuma }) await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) - const ownerReadyPullRequest = pullRequestFixture(run, headSha, { draft: false }) + const ownerReadyPullRequest = pullRequestFixture(run, headSha, { draft: true }) ownerReadyPullRequest.body = ownerReadyPullRequest.body .replace( 'Exact-head workflow evidence is attached or pending for this draft.', @@ -1581,7 +1647,7 @@ test('owner-ready transition requires verification and review but remains resuma base: { ref: 'main', repo: { full_name: 'codeacme17/echo-ui' } }, }), }), - /automation-authored live PR/, + /automation-authored live Draft PR/, ) const failedCommandPullRequest = structuredClone(ownerReadyPullRequest) @@ -2641,6 +2707,51 @@ test('notification dry-run is auditable but never counts as owner delivery', asy assert.doesNotMatch(events, /"type":"owner_notified"/) }) +test('canonical GitHub notification persists before a bounded webhook mirror', async () => { + const { loopRoot, channelRoot } = await createFixture() + const { run } = await startFixtureRun({ + loopRoot, + issueNumber: 132, + issueTitle: 'Bound webhook delivery', + issueUrl: 'https://github.com/codeacme17/echo-ui/issues/132', + entropy: 'web001', + }) + await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) + const notification = await createNotification({ + loopRoot, + runId: run.runId, + type: 'clarification_required', + summary: 'A decision is required', + requestedAction: 'Choose the expected behavior', + targetUrl: run.issueUrl, + blocking: true, + environment: { TEST_LOOP_WEBHOOK_URL: 'https://example.invalid/webhook' }, + githubComment: async () => ({ + html_url: `${run.issueUrl}#issuecomment-800`, + }), + fetchImplementation: async () => new Promise(() => {}), + webhookTimeoutMs: 5, + }) + assert.equal(notification.delivery.github, 'delivered') + assert.match(notification.delivery.webhook, /^failed: timed out after 5ms$/) + const persisted = JSON.parse( + await readFile(path.join(channelRoot, 'outbox', `${notification.notificationId}.json`), 'utf8'), + ) + assert.equal(persisted.delivery.github, 'delivered') + assert.match(persisted.delivery.webhook, /^failed: timed out/) + const events = (await readFile( + path.join(loopRoot, 'logs', 'runs', run.runId, 'events.jsonl'), + 'utf8', + )) + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + assert.ok( + events.findIndex((event) => event.type === 'owner_notified') < + events.findIndex((event) => event.type === 'notification_webhook_finished'), + ) +}) + test('failed blocking delivery still pauses for the owner', async () => { const { loopRoot } = await createFixture() const { run } = await startFixtureRun({ From aa2b6c28d12c7590925390b96d423a16be9b39cb Mon Sep 17 00:00:00 2001 From: leyoonafr <codeacme17@gmail.com> Date: Thu, 23 Jul 2026 21:56:49 +0800 Subject: [PATCH 32/41] fix: close loop owner and credential boundaries --- loops/_shared/owner-channel/CHANNEL.md | 6 +- loops/_shared/owner-channel/channel.json | 4 + loops/issue-dev-loop/LOOP.md | 4 +- loops/issue-dev-loop/SKILL.md | 2 +- loops/issue-dev-loop/dependencies.md | 4 + .../scripts/lib/github-identity.mjs | 89 ++++- loops/issue-dev-loop/scripts/lib/github.mjs | 39 ++ .../scripts/lib/notifications.mjs | 3 + .../issue-dev-loop/scripts/lib/owner-gate.mjs | 24 +- .../issue-dev-loop/scripts/lib/validation.mjs | 5 + .../validate-candidate-control-plane.mjs | 23 ++ loops/issue-dev-loop/state.md | 2 +- .../tests/github-identity-routing.test.mjs | 10 + loops/issue-dev-loop/tests/runtime.test.mjs | 337 +++++++++++++++--- .../triggers/codex-automation-prompt.md | 2 +- 15 files changed, 498 insertions(+), 56 deletions(-) diff --git a/loops/_shared/owner-channel/CHANNEL.md b/loops/_shared/owner-channel/CHANNEL.md index 89d0dfd9..0ed659e0 100644 --- a/loops/_shared/owner-channel/CHANNEL.md +++ b/loops/_shared/owner-channel/CHANNEL.md @@ -8,12 +8,14 @@ GitHub issue and PR comments are the canonical, auditable channel. The runtime m `--dry-run` stages a simulated payload only. It never records `owner_notified`, never pauses the run, and never satisfies a blocking delivery gate. +`pr_completed` is immediate but informational and must remain non-blocking. After remotely observing the owner-authored Ready transition, exact-head owner approval, and owner merge, the runtime durably posts this GitHub notification and performs the same bounded optional webhook attempt before it records the local merge/final state. + Each blocking GitHub notification prints a unique resume instruction. To continue after answering, include `RESUME <run-id>` in a normal issue/PR comment; submitting a GitHub `CHANGES_REQUESTED` review is also an explicit response. The runtime verifies author, target, timestamp, successful delivery, and response URL before resuming. Silence and unrelated comments never count. ## Runtime setup -1. Authenticate the unattended executor and fresh reviewer with distinct GitHub identities. Their exact logins and the names of their profile-path environment variables live in `channel.json`. For the current configuration, set `ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR` to the `Ethandasw` `gh` profile directory and `ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR` to the `Traviinam` profile directory in the scheduler environment. The directory names themselves are local details and do not need to match the roles. -2. From a clean owner-merged `dev` checkout at exact `origin/dev`, install a new versioned control plane outside every repository worktree and every scheduler-writable root with `install-trusted-control-plane.mjs`. Expose it read/execute-only to the unattended process through the scheduler sandbox or separate ownership/ACLs; the automation identity must not be able to rewrite it, its parent, the pinned executables, modes, ACLs, or sandbox policy. Set `ECHO_UI_LOOP_CONTROL_PLANE` to its `issue-dev-loop` directory and `ECHO_UI_LOOP_TARGET_ROOT` to the scheduled worktree's loop directory. Run `"$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" --loop-root "$ECHO_UI_LOOP_TARGET_ROOT" automation -- node "$ECHO_UI_LOOP_CONTROL_PLANE/scripts/loopctl.mjs" validate --activation --loop-root "$ECHO_UI_LOOP_TARGET_ROOT"` before scheduling. +1. Authenticate the unattended executor and fresh reviewer with distinct GitHub identities. Their exact logins and the names of their profile-path environment variables live in `channel.json`. For the current configuration, set `ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR` to the `Ethandasw` `gh` profile directory and `ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR` to the `Traviinam` profile directory in the trusted router environment. The directory names themselves are local details and do not need to match the roles. Both directories must be private (`0700`, with no group/other access on any descendant) and outside every untrusted agent-visible root. +2. From a clean owner-merged `dev` checkout at exact `origin/dev`, install a new versioned control plane outside every repository worktree and every scheduler-writable root with `install-trusted-control-plane.mjs`. Expose it read/execute-only to the unattended process through the scheduler sandbox or separate ownership/ACLs; the automation identity must not be able to rewrite it, its parent, the pinned executables, modes, ACLs, or sandbox policy. Set `ECHO_UI_LOOP_CONTROL_PLANE` to its `issue-dev-loop` directory, `ECHO_UI_LOOP_TARGET_ROOT` to the scheduled worktree's loop directory, and `ECHO_UI_LOOP_UNTRUSTED_ROOTS` to a JSON array covering the repository plus every root mounted into `$implement`, reviewer, or test sandboxes. Those sandboxes must not inherit either profile-path variable or `GH_CONFIG_DIR` and must be unable to read the profile directories. Run `"$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" --loop-root "$ECHO_UI_LOOP_TARGET_ROOT" automation -- node "$ECHO_UI_LOOP_CONTROL_PLANE/scripts/loopctl.mjs" validate --activation --loop-root "$ECHO_UI_LOOP_TARGET_ROOT"` before scheduling. 3. Run every operational loop command, executor GitHub command, remote Git command, trigger, and reviewer publication through that installed launcher with the explicit target root. The repository launcher intentionally refuses credentials. The installed launcher hash-verifies its files and absolute executables, compares trusted channel fields, removes Node preload hooks, clears token overrides and process hooks, verifies `gh api user`, and gives Git a one-command credential helper without changing global Git or `gh` configuration. A PATH gate applies the role and current-run policy to descendant `git` and `gh` processes too; issue-worktree routers, PATH shims, arbitrary shell/environment commands, and arbitrary Node scripts are rejected. 4. Create one dedicated repository issue for the append-only loop state journal and set its number as `stateIssueNumber`. It stores active checkpoints and terminal records. Restrict journal entries to the automation identity; humans may read but should not edit or delete them. 5. Enable GitHub notifications for mentions and review requests for `codeacme17`. diff --git a/loops/_shared/owner-channel/channel.json b/loops/_shared/owner-channel/channel.json index 8678b081..059bac46 100644 --- a/loops/_shared/owner-channel/channel.json +++ b/loops/_shared/owner-channel/channel.json @@ -9,6 +9,10 @@ "repository": "codeacme17/echo-ui", "canonicalChannel": "github", "webhookEnvironmentVariable": "ECHO_UI_LOOP_OWNER_WEBHOOK_URL", + "untrustedRootsEnvironmentVariable": "ECHO_UI_LOOP_UNTRUSTED_ROOTS", + "informationalImmediateTypes": [ + "pr_completed" + ], "immediateTypes": [ "approval_required", "clarification_required", diff --git a/loops/issue-dev-loop/LOOP.md b/loops/issue-dev-loop/LOOP.md index bdcf93e2..b4076655 100644 --- a/loops/issue-dev-loop/LOOP.md +++ b/loops/issue-dev-loop/LOOP.md @@ -126,11 +126,11 @@ Never log secrets, full environment dumps, cookies, auth headers, private user d GitHub issue/PR comments are the canonical communication record. The shared owner channel may mirror notifications to a webhook. The notification runtime automatically transitions blocking events to `waiting_for_owner`; delivery failure is itself a blocker. `pr_ready_for_review` means the still-Draft PR has passed the loop's automated gates; it moves from that pause to `awaiting_owner_review` only after its SHA-bound evidence gates pass, and asks the owner to perform the GitHub Ready transition. -All credential-bearing commands run from an installed, hash-verified control plane outside the issue worktree and outside every unattended-writable root. The scheduler/OS must expose that bundle read/execute-only; mode bits and a co-located manifest are not themselves a trust boundary against the same OS principal. The installed launcher pins and hashes absolute Node, Git, and GitHub CLI executables; treats the worktree loop root only as data; compares its security-critical channel fields with the installed owner channel; and refuses detected tampering or PATH impersonation before loading either GitHub profile. The repository launcher never receives credentials. +All credential-bearing commands run from an installed, hash-verified control plane outside the issue worktree and outside every unattended-writable root. The scheduler/OS must expose that bundle read/execute-only; mode bits and a co-located manifest are not themselves a trust boundary against the same OS principal. The installed launcher pins and hashes absolute Node, Git, and GitHub CLI executables; treats the worktree loop root only as data; compares its security-critical channel fields with the installed owner channel; and refuses detected tampering or PATH impersonation before loading either GitHub profile. The repository launcher never receives credentials. Activation also requires private credential profiles outside every declared untrusted agent root. `$implement`, reviewers, product tests, and verifier containers receive no profile variables or `GH_CONFIG_DIR`, and their OS sandbox must not be able to read those directories. A paused run resumes only after successful canonical GitHub delivery and a new, run-bound owner decision. The notification tells the owner to include `RESUME <run-id>` in a normal reply; a GitHub request-changes review is accepted without that token. An unrelated, stale, wrong-author, wrong-target, or pre-delivery comment never unlocks the run. -Notify immediately for `approval_required`, `clarification_required`, `blocked`, `review_dispute`, `pr_ready_for_review`, `pr_updated_for_review`, and `loop_failed`. Routine no-work checks belong in a digest, not an interruption. +Notify immediately for `approval_required`, `clarification_required`, `blocked`, `review_dispute`, `pr_ready_for_review`, `pr_updated_for_review`, and `loop_failed`. After the owner marks the PR Ready, approves the exact head, and merges it, emit the non-blocking `pr_completed` GitHub notification (plus the bounded webhook mirror) before recording terminal completion. Routine no-work checks belong in a digest, not an interruption. ## Anti-busywork diff --git a/loops/issue-dev-loop/SKILL.md b/loops/issue-dev-loop/SKILL.md index 4920734b..58339893 100644 --- a/loops/issue-dev-loop/SKILL.md +++ b/loops/issue-dev-loop/SKILL.md @@ -61,7 +61,7 @@ Run verification appropriate to the change and require `pnpm verify` before the Before publishing the final review round, write the complete cycle result with an unassigned final `reviewUrl`, then run `loopctl.mjs review-digest --result <absolute-path>`. Add the returned marker to the final review body, publish the non-approving review, replace the unassigned URL with GitHub's actual review URL, and confirm `review-digest` is unchanged. After all responses are posted, run `loopctl.mjs record-review --run-id <id> --result <absolute-path> --review-url <github-review-url>`. The publication digest deliberately canonicalizes GitHub-assigned review URLs while the stored full-file digest still protects the final artifact. Both evidence and review gates must name the current PR head. Keep the PR Draft, emit a blocking `pr_ready_for_review` notification asking `codeacme17` to mark it Ready and review it, then transition from `waiting_for_owner` to `awaiting_owner_review` with the PR URL and exact head SHA. -The owner is the only actor allowed to mark a Draft PR Ready, approve it, or merge it. Never call non-`--undo` `gh pr ready`, `gh pr merge`, enable auto-merge, push to `main`, push to `dev`, dismiss owner feedback, or bypass branch protections. Before any terminal transition, run `prepare-finalization`, publish its exact body to the configured state-journal issue, and validate the returned comment URL with `record-finalization`. A completed run passes the same result and comment URL to `observe-owner-merge`, which queries GitHub and requires both `codeacme17`'s approval and merge at the reviewed head SHA. Future workspaces run `reconcile` to rebuild local history and evolve metrics from those automation-authored journal comments. +The owner is the only actor allowed to mark a Draft PR Ready, approve it, or merge it. Never call non-`--undo` `gh pr ready`, `gh pr merge`, enable auto-merge, push to `main`, push to `dev`, dismiss owner feedback, or bypass branch protections. Before any terminal transition, run `prepare-finalization`, publish its exact body to the configured state-journal issue, and validate the returned comment URL with `record-finalization`. A completed run passes the same result and comment URL to `observe-owner-merge`, which paginates the PR timeline and reviews, requires a post-notification Ready transition authored by `codeacme17` with no later redraft, and requires the owner's exact-head approval and merge. It then delivers the informational `pr_completed` GitHub/webhook notification before recording the merge and terminal state. Future workspaces run `reconcile` to rebuild local history and evolve metrics from those automation-authored journal comments. For any pause, do not resume from silence or an arbitrary comment. First require a successfully delivered blocking notification. Then verify the owner's GitHub decision with `loopctl.mjs record-owner-response --run-id <id> --response-url <comment-or-review-url>`. A normal comment must include the exact `RESUME <run-id>` token printed in the notification; a `CHANGES_REQUESTED` review is itself an explicit decision and must be submitted against the run's current exact head SHA. Only then may `loopctl.mjs transition --run-id <id> --status running` continue. Before any repair work or new push, publish that transition's checkpoint, run the unchanged exact PR through `gh pr ready --undo --repo codeacme17/echo-ui`, observe the same head as Draft with `record-pr`, and publish another checkpoint. `$implement` repair attestations and later PR rebinds are rejected unless this durable redraft happened after the current owner response. diff --git a/loops/issue-dev-loop/dependencies.md b/loops/issue-dev-loop/dependencies.md index 2f8672e7..3050fce5 100644 --- a/loops/issue-dev-loop/dependencies.md +++ b/loops/issue-dev-loop/dependencies.md @@ -12,6 +12,7 @@ - GitHub CLI (`gh`) authenticated for issue, Actions artifact download, branch, PR, review, and comment work - `ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR` pointing to the executor's dedicated `gh` profile - `ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR` pointing to the reviewer's dedicated `gh` profile +- `ECHO_UI_LOOP_UNTRUSTED_ROOTS` containing a JSON array of every absolute filesystem root exposed read/write to `$implement`, candidate tests, or another untrusted agent sandbox - `ECHO_UI_LOOP_CONTROL_PLANE` pointing to the installed `issue-dev-loop` control-plane directory outside every repository worktree - `ECHO_UI_LOOP_TARGET_ROOT` pointing to the active worktree's `loops/issue-dev-loop` directory - Repository trust enabled so project `.codex` agents can load @@ -33,10 +34,13 @@ node loops/issue-dev-loop/scripts/install-trusted-control-plane.mjs \ --target "/absolute/path/outside-the-repository/echo-ui-loop-<dev-sha>" export ECHO_UI_LOOP_CONTROL_PLANE="/absolute/path/outside-the-repository/echo-ui-loop-<dev-sha>/issue-dev-loop" export ECHO_UI_LOOP_TARGET_ROOT="$PWD/loops/issue-dev-loop" +export ECHO_UI_LOOP_UNTRUSTED_ROOTS='["/absolute/path/to/the/scheduled/repository","/absolute/path/to/each/untrusted-worktree-root"]' ``` The installer refuses a dirty checkout, a branch other than `dev`, a commit other than `origin/dev`, an in-repository target, or an existing target. It pins and hashes the absolute Node/Git/GitHub CLI executables, copies the owner channel and runtime, hashes every installed file, and makes the bundle mode-only read-only. Install updates only after an owner-merged loop-control change; never install control code from an issue branch. The trust root is the scheduler/OS boundary, not Unix mode bits or a self-hashed manifest. Put the installed bundle outside every filesystem root writable by the unattended Codex process, and expose it read/execute-only through the scheduler sandbox or separate ownership/ACLs. The automation identity must not have permission to change the bundle, its parent, the pinned executables, file modes, ACLs, or the sandbox policy. A same-OS-principal process allowed to rewrite both code and its manifest cannot establish a cryptographic trust root in user space; do not activate the loop under that permission model. +Credential profiles are a separate secret boundary. Put both profile directories outside every root listed by `ECHO_UI_LOOP_UNTRUSTED_ROOTS`, set each directory to mode `0700` and every entry below it to deny group/other access, and expose them only to the trusted orchestration/identity-router process. The activation check canonicalizes these paths, rejects symlinks, broad permissions, wrong ownership, missing repository coverage, profiles inside an untrusted root, and overlapping agent-visible roots. `$implement`, fresh reviewers, candidate scripts, local product tests, and verifier containers must receive neither profile-path variables nor `GH_CONFIG_DIR`, and their sandbox must not be able to read the profile directories. If the scheduler cannot enforce that read boundary, do not activate the loop. + Never run `gh auth setup-git` for this loop. Route commands through `"$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" --loop-root "$ECHO_UI_LOOP_TARGET_ROOT" <role> -- ...`. The repository copy is installer source and intentionally refuses credential use. The installed launcher verifies its manifest before selecting an identity, removes Node preload hooks, scopes `GH_CONFIG_DIR` and the Git credential helper to one allowlisted child tree, gates descendant `git`/`gh` calls, and leaves the user's default `gh` account and global Git credential configuration unchanged. diff --git a/loops/issue-dev-loop/scripts/lib/github-identity.mjs b/loops/issue-dev-loop/scripts/lib/github-identity.mjs index 7567bd6c..38d025d1 100644 --- a/loops/issue-dev-loop/scripts/lib/github-identity.mjs +++ b/loops/issue-dev-loop/scripts/lib/github-identity.mjs @@ -1,6 +1,6 @@ import { execFile, spawn } from 'node:child_process' import { constants } from 'node:fs' -import { access, readdir, realpath } from 'node:fs/promises' +import { access, lstat, readdir, realpath } from 'node:fs/promises' import { devNull } from 'node:os' import path from 'node:path' import { promisify } from 'node:util' @@ -135,6 +135,75 @@ export function resolveGitHubRoleEnvironment({ channel, role, environment = proc return { configDirectory, expectedLogin, routedEnvironment } } +function containsPath(root, target) { + return target === root || target.startsWith(`${root}${path.sep}`) +} + +function repositoryRootForLoop(loopRoot) { + const loopsRoot = path.dirname(loopRoot) + return path.basename(loopsRoot) === 'loops' ? path.dirname(loopsRoot) : path.resolve(loopRoot) +} + +async function assertPrivateCredentialTree(root) { + const expectedUid = typeof process.getuid === 'function' ? process.getuid() : null + const visit = async (target) => { + const stats = await lstat(target) + if (stats.isSymbolicLink() || (!stats.isDirectory() && !stats.isFile())) { + throw new Error(`GitHub credential profile contains an unsafe entry: ${target}`) + } + if ((stats.mode & 0o077) !== 0) { + throw new Error(`GitHub credential profile entries must deny group/other access: ${target}`) + } + if (expectedUid !== null && stats.uid !== expectedUid) { + throw new Error(`GitHub credential profile must be owned by the scheduler user: ${target}`) + } + if (stats.isDirectory()) { + for (const entry of await readdir(target)) await visit(path.join(target, entry)) + } + } + await visit(root) +} + +export async function assertCredentialProfileIsolation({ + channel, + configDirectory, + environment = process.env, + requiredUntrustedRoots = [], +}) { + const variableName = assertNonEmpty( + channel.untrustedRootsEnvironmentVariable, + 'channel.untrustedRootsEnvironmentVariable', + ) + let configuredRoots + try { + configuredRoots = JSON.parse(assertNonEmpty(environment[variableName], variableName)) + } catch { + throw new Error(`${variableName} must contain a JSON array of absolute untrusted roots`) + } + if ( + !Array.isArray(configuredRoots) || + configuredRoots.length === 0 || + configuredRoots.some((root) => typeof root !== 'string' || !path.isAbsolute(root)) + ) { + throw new Error(`${variableName} must contain a non-empty JSON array of absolute paths`) + } + const [canonicalConfigDirectory, canonicalRoots, canonicalRequiredRoots] = await Promise.all([ + realpath(configDirectory), + Promise.all(configuredRoots.map((root) => realpath(root))), + Promise.all(requiredUntrustedRoots.map((root) => realpath(root))), + ]) + for (const requiredRoot of canonicalRequiredRoots) { + if (!canonicalRoots.some((root) => containsPath(root, requiredRoot))) { + throw new Error(`${variableName} must cover the repository and every untrusted agent root`) + } + } + if (canonicalRoots.some((root) => containsPath(root, canonicalConfigDirectory))) { + throw new Error('GitHub credential profiles must be outside every untrusted agent root') + } + await assertPrivateCredentialTree(canonicalConfigDirectory) + return canonicalConfigDirectory +} + function canonicalRepositoryUrl(repository) { if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) { throw new Error('configured repository must be an owner/name pair') @@ -1545,8 +1614,18 @@ export async function assertGitHubRoleIdentity({ environment = process.env, identityCommand = execFileAsync, ghExecutable = 'gh', + enforceCredentialIsolation = false, + requiredUntrustedRoots = [], }) { - const resolved = resolveGitHubRoleEnvironment({ channel, role, environment }) + let resolved = resolveGitHubRoleEnvironment({ channel, role, environment }) + if (enforceCredentialIsolation) { + await assertCredentialProfileIsolation({ + channel, + configDirectory: resolved.configDirectory, + environment, + requiredUntrustedRoots, + }) + } const { stdout } = await identityCommand(ghExecutable, ['api', 'user', '--jq', '.login'], { env: resolved.routedEnvironment, maxBuffer: 1024 * 1024, @@ -1575,6 +1654,8 @@ async function assertTargetChannelMatchesTrusted(loopRoot, trustedChannel) { 'repository', 'canonicalChannel', 'webhookEnvironmentVariable', + 'untrustedRootsEnvironmentVariable', + 'informationalImmediateTypes', 'immediateTypes', ]) { if (JSON.stringify(targetChannel[field]) !== JSON.stringify(trustedChannel[field])) { @@ -1604,6 +1685,8 @@ export async function runWithGitHubRole({ role, environment, ghExecutable: realGh, + enforceCredentialIsolation: true, + requiredUntrustedRoots: [repositoryRootForLoop(loopRoot)], }) const executable = await resolveRequestedExecutable( requestedCommand, @@ -1642,6 +1725,8 @@ export async function runWithGitHubRole({ ghExecutable: realGh, identityCommand: (_command, identityArgs, options) => execFileAsync(realGh, identityArgs, options), + enforceCredentialIsolation: true, + requiredUntrustedRoots: [repositoryRootForLoop(loopRoot)], }) } await preflightEvolveMutation({ diff --git a/loops/issue-dev-loop/scripts/lib/github.mjs b/loops/issue-dev-loop/scripts/lib/github.mjs index 8c210fd9..f4c9bb29 100644 --- a/loops/issue-dev-loop/scripts/lib/github.mjs +++ b/loops/issue-dev-loop/scripts/lib/github.mjs @@ -23,6 +23,7 @@ import { } from './finalization-journal.mjs' import { reconcileActiveJournal } from './active-journal.mjs' import { observeOwnerApprovedMerge } from './owner-gate.mjs' +import { createNotification } from './notifications.mjs' import { defaultReleaseIssueClaim } from './issue-claim.mjs' import { appendValidatedEvent, finalizeRun, readEvents, readRun } from './run-store.mjs' import { verifyLatestDurableCheckpoint } from './checkpoint-proof.mjs' @@ -211,19 +212,57 @@ export async function observeOwnerMerge({ finalizationResultPath, finalizationCommentUrl, recordFinalization = recordFinalizationPublication, + notifyOwner = createNotification, } = {}) { const normalizedRunId = assertRunId(runId) const run = await readRun(loopRoot, normalizedRunId) if (run.status !== 'awaiting_owner_review' || !run.prUrl || !run.headSha) { throw new Error('owner merge observation requires an awaiting_owner_review run') } + const events = await readEvents(loopRoot, normalizedRunId) + const readyNotification = events.findLast( + (event) => + event.type === 'owner_notified' && + event.status === 'delivered' && + ['pr_ready_for_review', 'pr_updated_for_review'].includes( + event.payload?.notificationType, + ) && + event.payload?.targetUrl === run.prUrl && + event.payload?.headSha === run.headSha, + ) + if (!readyNotification) { + throw new Error('owner merge observation requires the delivered exact-head Ready notification') + } const merge = await observeOwnerApprovedMerge({ loopRoot, prUrl: run.prUrl, expectedHeadSha: run.headSha, expectedHeadBranch: run.branch, + readyAfter: readyNotification.timestamp, githubApi, }) + const completionNotification = events.findLast( + (event) => + event.type === 'owner_notified' && + event.status === 'delivered' && + event.payload?.notificationType === 'pr_completed' && + event.payload?.targetUrl === run.prUrl && + event.payload?.headSha === run.headSha, + ) + if (!completionNotification) { + await notifyOwner({ + loopRoot, + runId: normalizedRunId, + type: 'pr_completed', + summary: `PR merged by the owner at ${merge.mergeSha}.`, + requestedAction: 'No action is required; the completed run is being finalized.', + targetUrl: run.prUrl, + evidenceUrl: run.prUrl, + blocking: false, + now, + githubApi, + }) + } await appendValidatedEvent({ loopRoot, runId: normalizedRunId, diff --git a/loops/issue-dev-loop/scripts/lib/notifications.mjs b/loops/issue-dev-loop/scripts/lib/notifications.mjs index e82423a8..3ade7067 100644 --- a/loops/issue-dev-loop/scripts/lib/notifications.mjs +++ b/loops/issue-dev-loop/scripts/lib/notifications.mjs @@ -83,6 +83,9 @@ export async function createNotification({ if (channel.immediateTypes.includes(notificationType) && !blocking) { throw new Error(`${notificationType} must be sent as a blocking notification`) } + if (channel.informationalImmediateTypes?.includes(notificationType) && blocking) { + throw new Error(`${notificationType} must be sent as a non-blocking notification`) + } const ownerReadyType = ['pr_ready_for_review', 'pr_updated_for_review'].includes(notificationType) if (ownerReadyType && (!run.prUrl || !run.headSha || targetUrl !== run.prUrl || !evidenceUrl)) { throw new Error( diff --git a/loops/issue-dev-loop/scripts/lib/owner-gate.mjs b/loops/issue-dev-loop/scripts/lib/owner-gate.mjs index 6a2e31c8..986454f0 100644 --- a/loops/issue-dev-loop/scripts/lib/owner-gate.mjs +++ b/loops/issue-dev-loop/scripts/lib/owner-gate.mjs @@ -7,7 +7,7 @@ async function paginateGitHubApi(githubApi, endpoint) { for (let page = 1; ; page += 1) { const separator = endpoint.includes('?') ? '&' : '?' const batch = await githubApi(`${endpoint}${separator}per_page=100&page=${page}`) - if (!Array.isArray(batch)) throw new Error('GitHub paginated review response must be an array') + if (!Array.isArray(batch)) throw new Error('GitHub paginated response must be an array') records.push(...batch) if (batch.length < 100) return records } @@ -22,6 +22,7 @@ export async function observeOwnerApprovedMerge({ expectedBaseBranch = 'dev', requiredBodyMarker = null, createdAfter = null, + readyAfter = null, githubApi = defaultGitHubApi, }) { const target = parseGitHubTarget(prUrl) @@ -29,12 +30,16 @@ export async function observeOwnerApprovedMerge({ const channel = await readJson( path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'), ) - const [pullRequest, reviews] = await Promise.all([ + const [pullRequest, reviews, timeline] = await Promise.all([ githubApi(`repos/${target.owner}/${target.repo}/pulls/${target.number}`), paginateGitHubApi( githubApi, `repos/${target.owner}/${target.repo}/pulls/${target.number}/reviews`, ), + paginateGitHubApi( + githubApi, + `repos/${target.owner}/${target.repo}/issues/${target.number}/timeline`, + ), ]) const headSha = pullRequest.head?.sha const configuredRepository = expectedRepository ?? channel.repository @@ -44,6 +49,16 @@ export async function observeOwnerApprovedMerge({ review.state === 'APPROVED' && review.commit_id === headSha, ) + const readinessTransitions = timeline.filter((event) => + ['ready_for_review', 'convert_to_draft'].includes(event.event), + ) + const latestReadinessTransition = readinessTransitions.at(-1) + const ownerReady = + latestReadinessTransition?.event === 'ready_for_review' && + sameGitHubLogin(latestReadinessTransition.actor?.login, channel.ownerGitHubLogin) && + !Number.isNaN(Date.parse(latestReadinessTransition.created_at)) && + (!readyAfter || + Date.parse(latestReadinessTransition.created_at) >= Date.parse(readyAfter)) if ( pullRequest.merged !== true || `${target.owner}/${target.repo}`.toLowerCase() !== configuredRepository.toLowerCase() || @@ -55,10 +70,13 @@ export async function observeOwnerApprovedMerge({ (createdAfter && Date.parse(pullRequest.created_at) < Date.parse(createdAfter)) || !sameGitHubLogin(pullRequest.merged_by?.login, channel.ownerGitHubLogin) || !/^[0-9a-f]{40}$/i.test(pullRequest.merge_commit_sha ?? '') || + !ownerReady || !ownerApproval || (expectedHeadSha !== null && headSha !== expectedHeadSha) ) { - throw new Error('PR is not approved and merged by the configured owner at the expected headSha') + throw new Error( + 'PR is not approved and merged by the configured owner at the expected headSha with an owner-authored Ready transition', + ) } return { owner: channel.ownerGitHubLogin, diff --git a/loops/issue-dev-loop/scripts/lib/validation.mjs b/loops/issue-dev-loop/scripts/lib/validation.mjs index f3294414..bcd07d86 100644 --- a/loops/issue-dev-loop/scripts/lib/validation.mjs +++ b/loops/issue-dev-loop/scripts/lib/validation.mjs @@ -111,8 +111,11 @@ export async function validateLoop({ !Object.hasOwn(channel, 'reviewerGitHubLogin') || typeof channel.automationGitHubConfigEnvironmentVariable !== 'string' || typeof channel.reviewerGitHubConfigEnvironmentVariable !== 'string' || + typeof channel.untrustedRootsEnvironmentVariable !== 'string' || !Object.hasOwn(channel, 'stateIssueNumber') || channel.repository !== 'codeacme17/echo-ui' || + !Array.isArray(channel.informationalImmediateTypes) || + !channel.informationalImmediateTypes.includes('pr_completed') || !Array.isArray(channel.immediateTypes) ) { throw new Error('owner channel is missing identity or immediate notification configuration') @@ -141,6 +144,8 @@ export async function validateLoop({ channel, role, environment, + enforceCredentialIsolation: true, + requiredUntrustedRoots: [path.resolve(loopRoot, '..', '..')], ...(identityCommand ? { identityCommand } : {}), }) } diff --git a/loops/issue-dev-loop/scripts/validate-candidate-control-plane.mjs b/loops/issue-dev-loop/scripts/validate-candidate-control-plane.mjs index 52fd3273..9e2aab74 100644 --- a/loops/issue-dev-loop/scripts/validate-candidate-control-plane.mjs +++ b/loops/issue-dev-loop/scripts/validate-candidate-control-plane.mjs @@ -64,10 +64,24 @@ const permittedRunRoots = [ `loops/issue-dev-loop/screen-shots/${runId}/`, ] const protectedRootFiles = new Set([ + '.cursorrules', '.npmrc', + 'AGENTS.md', + 'CLAUDE.md', + 'CODEX.md', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', ]) +const protectedDeploymentFiles = new Set([ + 'amplify.yml', + 'firebase.json', + 'fly.toml', + 'netlify.toml', + 'now.json', + 'railway.json', + 'render.yaml', + 'vercel.json', +]) const changedFiles = changed.stdout .split('\n') .filter(Boolean) @@ -78,12 +92,21 @@ const violations = changedFiles.filter((file) => { const basename = path.basename(file) return ( file.startsWith('loops/_shared/') || + file.startsWith('.agents/') || + file.startsWith('.claude/') || + file.startsWith('.codex/') || + file.startsWith('.cursor/') || file.startsWith('.github/') || + file.startsWith('.netlify/') || + file.startsWith('.openai/') || + file.startsWith('.vercel/') || file.startsWith('scripts/') || file.startsWith('patches/') || file.split('/').includes('node_modules') || basename === 'package.json' || /^\.?pnpmfile\.[^.]+$/.test(basename) || + /^(?:wrangler)(?:\.[^.]+)*\.(?:json|jsonc|toml)$/.test(basename) || + protectedDeploymentFiles.has(basename) || protectedRootFiles.has(file) || /^(?:eslint|vite|vitest|playwright|next|postcss|tailwind|webpack|rollup|babel|jest|stylelint)\.config\.[^.]+$/.test( basename, diff --git a/loops/issue-dev-loop/state.md b/loops/issue-dev-loop/state.md index 4c67b44a..59bae403 100644 --- a/loops/issue-dev-loop/state.md +++ b/loops/issue-dev-loop/state.md @@ -25,7 +25,7 @@ None. ## Blockers -- Add the two configured `GH_CONFIG_DIR` path variables to the recurring automation environment before activation. +- Add the two configured `GH_CONFIG_DIR` path variables and JSON `ECHO_UI_LOOP_UNTRUSTED_ROOTS` declaration to the trusted router environment; make both profiles private and exclude their variables/directories from every `$implement`, reviewer, and product-test sandbox before activation. - Merge this infrastructure into `dev` before enabling its recurring automation; the PR evidence workflow must exist on the base branch. - Choose the recurring Codex automation cadence after the infrastructure PR is merged. - Optionally configure `ECHO_UI_LOOP_OWNER_WEBHOOK_URL` for a push-channel mirror; GitHub mentions remain the canonical baseline channel. diff --git a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs index 0b187d12..453af17a 100644 --- a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs +++ b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs @@ -77,6 +77,8 @@ async function createFixture({ reviewerGitHubLogin: 'reviewer-user', automationGitHubConfigEnvironmentVariable: 'ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR', reviewerGitHubConfigEnvironmentVariable: 'ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR', + untrustedRootsEnvironmentVariable: 'ECHO_UI_LOOP_UNTRUSTED_ROOTS', + informationalImmediateTypes: ['pr_completed'], stateIssueNumber: 999, repository: 'example/repo', } @@ -90,6 +92,12 @@ async function createFixture({ ]) await writeFile(path.join(automationProfile, 'identity'), 'executor-user\n', 'utf8') await writeFile(path.join(reviewerProfile, 'identity'), 'reviewer-user\n', 'utf8') + await Promise.all([ + chmod(automationProfile, 0o700), + chmod(reviewerProfile, 0o700), + chmod(path.join(automationProfile, 'identity'), 0o600), + chmod(path.join(reviewerProfile, 'identity'), 0o600), + ]) if (activeRun) { const runRoot = path.join(loopRoot, 'logs', 'runs', 'fixture-run') const briefRoot = path.join(loopRoot, 'handoffs', 'fixture-run') @@ -334,6 +342,7 @@ if (commandArguments[0] === 'spawn') { await writeFile( fakeGh, `#!/bin/sh +umask 077 parent_dir=\${GH_CONFIG_DIR%/*} first_line() { IFS= read -r line < "$1" @@ -473,6 +482,7 @@ ${JSON.stringify(process.execPath)} -e 'process.stdout.write(JSON.stringify({arg PATH: `${binRoot}${path.delimiter}${process.env.PATH}`, ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR: automationProfile, ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR: reviewerProfile, + ECHO_UI_LOOP_UNTRUSTED_ROOTS: JSON.stringify([loopRoot]), GH_TOKEN: 'must-not-leak', GITHUB_TOKEN: 'must-not-leak', NODE_OPTIONS: '', diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index fb42833b..ae1f7941 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict' import { execFile } from 'node:child_process' import { createHash } from 'node:crypto' -import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { chmod, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import test from 'node:test' @@ -43,6 +43,7 @@ import { validateLoop, } from '../scripts/runtime.mjs' import { observeOwnerApprovedMerge } from '../scripts/lib/owner-gate.mjs' +import { assertCredentialProfileIsolation } from '../scripts/lib/github-identity.mjs' const bypassCheckpointVerifier = async () => {} const createNotification = (options) => @@ -103,6 +104,22 @@ test('owner merge observation paginates beyond one hundred reviews', async () => }, ] } + if (endpoint.endsWith('/timeline?per_page=100&page=1')) { + return Array.from({ length: 100 }, (_, index) => ({ + id: index + 1, + event: 'commented', + })) + } + if (endpoint.endsWith('/timeline?per_page=100&page=2')) { + return [ + { + id: 101, + event: 'ready_for_review', + actor: { login: 'codeacme17' }, + created_at: '2026-07-23T08:00:00.000Z', + }, + ] + } return { merged: true, merged_by: { login: 'codeacme17' }, @@ -119,6 +136,72 @@ test('owner merge observation paginates beyond one hundred reviews', async () => assert.equal(result.mergeSha, mergeSha) }) +test('owner merge observation requires the owner Ready transition after notification', async () => { + const { loopRoot } = await createFixture() + const headSha = 'a'.repeat(40) + const pullRequest = { + merged: true, + merged_by: { login: 'codeacme17' }, + merge_commit_sha: 'b'.repeat(40), + base: { ref: 'dev', repo: { full_name: 'codeacme17/echo-ui' } }, + head: { + ref: 'codex/issue-701', + sha: headSha, + repo: { full_name: 'codeacme17/echo-ui' }, + }, + } + const verify = (timeline) => + observeOwnerApprovedMerge({ + loopRoot, + prUrl: 'https://github.com/codeacme17/echo-ui/pull/701', + expectedHeadSha: headSha, + expectedHeadBranch: 'codex/issue-701', + readyAfter: '2026-07-23T08:00:00.000Z', + githubApi: async (endpoint) => { + if (endpoint.includes('/reviews')) { + return [{ user: { login: 'codeacme17' }, state: 'APPROVED', commit_id: headSha }] + } + if (endpoint.includes('/timeline')) return timeline + return pullRequest + }, + }) + await assert.rejects( + verify([ + { + event: 'ready_for_review', + actor: { login: 'another-collaborator' }, + created_at: '2026-07-23T08:30:00.000Z', + }, + ]), + /owner-authored Ready transition/, + ) + await assert.rejects( + verify([ + { + event: 'ready_for_review', + actor: { login: 'codeacme17' }, + created_at: '2026-07-23T08:30:00.000Z', + }, + { + event: 'convert_to_draft', + actor: { login: 'codeacme17' }, + created_at: '2026-07-23T08:40:00.000Z', + }, + ]), + /owner-authored Ready transition/, + ) + await assert.rejects( + verify([ + { + event: 'ready_for_review', + actor: { login: 'codeacme17' }, + created_at: '2026-07-23T07:30:00.000Z', + }, + ]), + /owner-authored Ready transition/, + ) +}) + async function createFixture() { const parent = await mkdtemp(path.join(os.tmpdir(), 'echo-ui-loop-test-')) const loopRoot = path.join(parent, 'issue-dev-loop') @@ -173,9 +256,11 @@ async function createFixture() { reviewerGitHubLogin: 'echo-ui-reviewer[bot]', automationGitHubConfigEnvironmentVariable: 'ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR', reviewerGitHubConfigEnvironmentVariable: 'ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR', + untrustedRootsEnvironmentVariable: 'ECHO_UI_LOOP_UNTRUSTED_ROOTS', stateIssueNumber: 999, repository: 'codeacme17/echo-ui', webhookEnvironmentVariable: 'TEST_LOOP_WEBHOOK_URL', + informationalImmediateTypes: ['pr_completed'], immediateTypes: [ 'approval_required', 'clarification_required', @@ -493,6 +578,15 @@ async function writeFixtureFinalization({ if (endpoint.includes('/reviews')) { return [{ user: { login: 'codeacme17' }, state: 'APPROVED', commit_id: record.headSha }] } + if (endpoint.includes('/timeline')) { + return [ + { + event: 'ready_for_review', + actor: { login: 'codeacme17' }, + created_at: new Date(Date.now() + 60_000).toISOString(), + }, + ] + } if (endpoint.includes('/pulls/')) { return { merged: true, @@ -706,6 +800,45 @@ test('candidate control-plane validation permits run evidence but rejects verifi ]), /issue branches cannot modify the trusted control or verification plane:[\s\S]*package\.json/, ) + + for (const [relativePath, source, expected] of [ + [ + '.codex/agents/echo_ui_pr_reviewer.toml', + 'sandbox_mode = "danger-full-access"\n', + /\.codex\/agents\/echo_ui_pr_reviewer\.toml/, + ], + [ + '.agents/skills/issue-dev-loop/SKILL.md', + '# Candidate-controlled adapter\n', + /\.agents\/skills\/issue-dev-loop\/SKILL\.md/, + ], + [ + 'vercel.json', + '{"git":{"deploymentEnabled":{"codex/issue-*":true}}}\n', + /vercel\.json/, + ], + ]) { + const target = path.join(repository, relativePath) + await mkdir(path.dirname(target), { recursive: true }) + await writeFile(target, source, 'utf8') + await git('add', relativePath) + await git('commit', '-m', `change protected ${relativePath}`) + const headSha = (await git('rev-parse', 'HEAD')).stdout.trim() + await assert.rejects( + execFileAsync(process.execPath, [ + validator, + '--loop-root', + loopRoot, + '--run-id', + runId, + '--base-sha', + baseSha, + '--head-sha', + headSha, + ]), + expected, + ) + } }) test('review publication digest excludes assigned review URLs but binds review content', () => { @@ -1802,22 +1935,33 @@ test('owner-review waiting transition keeps the exact verified PR Draft and rema observeOwnerMerge({ loopRoot, runId: run.runId, - githubApi: async (endpoint) => - endpoint.includes('/reviews') - ? [ + githubApi: async (endpoint) => { + if (endpoint.includes('/reviews')) { + return [ { user: { login: 'codeacme17' }, state: 'APPROVED', commit_id: headSha, }, ] - : { - merged: true, - merged_by: { login: 'someone-else' }, - ...pullRequestFixture(run, headSha, { draft: false, merged: true }), - merged_by: { login: 'someone-else' }, - merge_commit_sha: '9'.repeat(40), + } + if (endpoint.includes('/timeline')) { + return [ + { + event: 'ready_for_review', + actor: { login: 'codeacme17' }, + created_at: '2026-07-23T08:30:00.000Z', }, + ] + } + return { + merged: true, + merged_by: { login: 'someone-else' }, + ...pullRequestFixture(run, headSha, { draft: false, merged: true }), + merged_by: { login: 'someone-else' }, + merge_commit_sha: '9'.repeat(40), + } + }, }), /not approved and merged by the configured owner/, ) @@ -1826,13 +1970,24 @@ test('owner-review waiting transition keeps the exact verified PR Draft and rema observeOwnerMerge({ loopRoot, runId: run.runId, - githubApi: async (endpoint) => - endpoint.includes('/reviews') - ? [{ user: { login: 'codeacme17' }, state: 'APPROVED', commit_id: headSha }] - : { - ...pullRequestFixture(run, headSha, { draft: false, merged: true }), - base: { ref: 'main', repo: { full_name: 'codeacme17/echo-ui' } }, + githubApi: async (endpoint) => { + if (endpoint.includes('/reviews')) { + return [{ user: { login: 'codeacme17' }, state: 'APPROVED', commit_id: headSha }] + } + if (endpoint.includes('/timeline')) { + return [ + { + event: 'ready_for_review', + actor: { login: 'codeacme17' }, + created_at: '2026-07-23T08:30:00.000Z', }, + ] + } + return { + ...pullRequestFixture(run, headSha, { draft: false, merged: true }), + base: { ref: 'main', repo: { full_name: 'codeacme17/echo-ui' } }, + } + }, }), /not approved and merged by the configured owner/, ) @@ -1854,10 +2009,27 @@ test('owner-review waiting transition keeps the exact verified PR Draft and rema finalizationCommentUrl: completionJournal.commentUrl, recordFinalization: (options) => recordFinalizationPublication({ ...options, githubApi: completionJournal.githubApi }), + notifyOwner: (notification) => + createNotification({ + ...notification, + githubComment: async (_target, body) => { + assert.match(body, /\*\*pr_completed\*\*/) + return { html_url: `${prUrl}#issuecomment-9901` } + }, + }), }) assert.equal(finalized.status, 'completed') assert.equal(finalized.mergeSha, '9'.repeat(40)) assert.equal(finalized.finishedAt, '2026-07-23T09:00:00.000Z') + const completedEvents = await readFile( + path.join(loopRoot, 'logs', 'runs', run.runId, 'events.jsonl'), + 'utf8', + ) + assert.match(completedEvents, /"notificationType":"pr_completed"/) + assert.ok( + completedEvents.indexOf('"notificationType":"pr_completed"') < + completedEvents.indexOf('"type":"pr_merged"'), + ) }) test('completed finalization cannot bypass the owner-ready gate', async () => { @@ -1944,15 +2116,26 @@ test('forged local owner events cannot bypass the remote completion gate', async runId: run.runId, status: 'completed', mergeSha, - githubApi: async (endpoint) => - endpoint.includes('/reviews') - ? [{ user: { login: 'codeacme17' }, state: 'APPROVED', commit_id: headSha }] - : { - ...pullRequestFixture(run, headSha, { draft: false }), - merged: false, - merged_by: null, - merge_commit_sha: null, + githubApi: async (endpoint) => { + if (endpoint.includes('/reviews')) { + return [{ user: { login: 'codeacme17' }, state: 'APPROVED', commit_id: headSha }] + } + if (endpoint.includes('/timeline')) { + return [ + { + event: 'ready_for_review', + actor: { login: 'codeacme17' }, + created_at: '2026-07-23T08:30:00.000Z', }, + ] + } + return { + ...pullRequestFixture(run, headSha, { draft: false }), + merged: false, + merged_by: null, + merge_commit_sha: null, + } + }, releaseIssueClaim: async () => { released = true }, @@ -1998,15 +2181,26 @@ test('forged local owner events cannot bypass the remote completion gate', async runId: run.runId, status: 'completed', mergeSha, - githubApi: async (endpoint) => - endpoint.includes('/reviews') - ? [{ user: { login: 'codeacme17' }, state: 'APPROVED', commit_id: headSha }] - : { - ...pullRequestFixture(run, headSha, { draft: false }), - merged: false, - merged_by: null, - merge_commit_sha: null, + githubApi: async (endpoint) => { + if (endpoint.includes('/reviews')) { + return [{ user: { login: 'codeacme17' }, state: 'APPROVED', commit_id: headSha }] + } + if (endpoint.includes('/timeline')) { + return [ + { + event: 'ready_for_review', + actor: { login: 'codeacme17' }, + created_at: '2026-07-23T08:30:00.000Z', }, + ] + } + return { + ...pullRequestFixture(run, headSha, { draft: false }), + merged: false, + merged_by: null, + merge_commit_sha: null, + } + }, releaseIssueClaim: async () => { released = true }, @@ -3300,24 +3494,35 @@ test('evolve completion rejects an unrelated historical owner-merged PR', async requestId, summary: 'Improve trigger batching', prUrl: 'https://github.com/codeacme17/echo-ui/pull/99', - githubApi: async (endpoint) => - endpoint.includes('/reviews') - ? [ + githubApi: async (endpoint) => { + if (endpoint.includes('/reviews')) { + return [ { user: { login: 'codeacme17' }, state: 'APPROVED', commit_id: 'a'.repeat(40), }, ] - : { - merged: true, - merged_by: { login: 'codeacme17' }, - merge_commit_sha: '9'.repeat(40), - created_at: '2026-07-20T00:00:00.000Z', - body: 'Unrelated change', - base: { ref: 'dev', repo: { full_name: 'codeacme17/echo-ui' } }, - head: { ref: 'feature/unrelated', sha: 'a'.repeat(40) }, + } + if (endpoint.includes('/timeline')) { + return [ + { + event: 'ready_for_review', + actor: { login: 'codeacme17' }, + created_at: '2026-07-20T01:00:00.000Z', }, + ] + } + return { + merged: true, + merged_by: { login: 'codeacme17' }, + merge_commit_sha: '9'.repeat(40), + created_at: '2026-07-20T00:00:00.000Z', + body: 'Unrelated change', + base: { ref: 'dev', repo: { full_name: 'codeacme17/echo-ui' } }, + head: { ref: 'feature/unrelated', sha: 'a'.repeat(40) }, + } + }, }), /not approved and merged by the configured owner/, ) @@ -3329,12 +3534,20 @@ test('repository loop package satisfies its structural invariants', async () => }) test('repository activation verifies both configured GitHub profiles', async () => { - const profileRoot = path.join(os.tmpdir(), 'echo-ui-activation-profiles') + const profileRoot = await mkdtemp(path.join(os.tmpdir(), 'echo-ui-activation-profiles-')) const automationProfile = path.join(profileRoot, 'automation') const reviewerProfile = path.join(profileRoot, 'reviewer') + await Promise.all([ + mkdir(automationProfile), + mkdir(reviewerProfile), + ]) + await Promise.all([chmod(automationProfile, 0o700), chmod(reviewerProfile, 0o700)]) const environment = { ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR: automationProfile, ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR: reviewerProfile, + ECHO_UI_LOOP_UNTRUSTED_ROOTS: JSON.stringify([ + path.resolve(repositoryLoopRoot, '..', '..'), + ]), } const observedProfiles = [] const identityCommand = async (_command, _args, options) => { @@ -3351,3 +3564,39 @@ test('repository activation verifies both configured GitHub profiles', async () assert.equal(result.valid, true) assert.deepEqual(observedProfiles, [automationProfile, reviewerProfile]) }) + +test('credential isolation rejects profiles inside untrusted roots or with broad permissions', async () => { + const parent = await mkdtemp(path.join(os.tmpdir(), 'echo-ui-credential-boundary-')) + const untrustedRoot = path.join(parent, 'repository') + const embeddedProfile = path.join(untrustedRoot, 'credentials') + const externalProfile = path.join(parent, 'private-credentials') + await Promise.all([ + mkdir(embeddedProfile, { recursive: true }), + mkdir(externalProfile, { recursive: true }), + ]) + await Promise.all([chmod(embeddedProfile, 0o700), chmod(externalProfile, 0o755)]) + const channel = { + untrustedRootsEnvironmentVariable: 'ECHO_UI_LOOP_UNTRUSTED_ROOTS', + } + const environment = { + ECHO_UI_LOOP_UNTRUSTED_ROOTS: JSON.stringify([untrustedRoot]), + } + await assert.rejects( + assertCredentialProfileIsolation({ + channel, + configDirectory: embeddedProfile, + environment, + requiredUntrustedRoots: [untrustedRoot], + }), + /outside every untrusted agent root/, + ) + await assert.rejects( + assertCredentialProfileIsolation({ + channel, + configDirectory: externalProfile, + environment, + requiredUntrustedRoots: [untrustedRoot], + }), + /deny group\/other access/, + ) +}) diff --git a/loops/issue-dev-loop/triggers/codex-automation-prompt.md b/loops/issue-dev-loop/triggers/codex-automation-prompt.md index e2e886d3..7d27b016 100644 --- a/loops/issue-dev-loop/triggers/codex-automation-prompt.md +++ b/loops/issue-dev-loop/triggers/codex-automation-prompt.md @@ -1,5 +1,5 @@ Use `$issue-dev-loop` in this repository. -Require `ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR`, `ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR`, `ECHO_UI_LOOP_CONTROL_PLANE`, and `ECHO_UI_LOOP_TARGET_ROOT`. The control plane must be a versioned, hash-verified installation made from clean owner-merged `dev`, outside the repository. Run `"$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" --loop-root "$ECHO_UI_LOOP_TARGET_ROOT" automation -- node "$ECHO_UI_LOOP_CONTROL_PLANE/scripts/loopctl.mjs" validate --activation --loop-root "$ECHO_UI_LOOP_TARGET_ROOT"`. Run every operational loop command, executor GitHub command, remote Git command, trigger, and reviewer publication through that installed launcher with the explicit target root. Never use the repository launcher for credentials, invoke its `.mjs` implementation directly, install control code from an issue branch, or change global authentication. +Require `ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR`, `ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR`, `ECHO_UI_LOOP_UNTRUSTED_ROOTS`, `ECHO_UI_LOOP_CONTROL_PLANE`, and `ECHO_UI_LOOP_TARGET_ROOT`. The control plane must be a versioned, hash-verified installation made from clean owner-merged `dev`, outside the repository. The JSON-array untrusted roots must cover every repository/worktree/test mount visible to `$implement` or another untrusted agent; both private GitHub profiles must be outside them. Do not pass either profile variable or `GH_CONFIG_DIR` into `$implement`, reviewer, product-test, or verifier environments, and require their sandbox to deny profile-directory reads. Run `"$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" --loop-root "$ECHO_UI_LOOP_TARGET_ROOT" automation -- node "$ECHO_UI_LOOP_CONTROL_PLANE/scripts/loopctl.mjs" validate --activation --loop-root "$ECHO_UI_LOOP_TARGET_ROOT"`. Run every operational loop command, executor GitHub command, remote Git command, trigger, and reviewer publication through that installed launcher with the explicit target root. Never use the repository launcher for credentials, invoke its `.mjs` implementation directly, install control code from an issue branch, or change global authentication. First run `loopctl.mjs reconcile`, then `loopctl.mjs evolve-status`, both through the automation wrapper. A pending evolve request starts a fresh `echo_ui_loop_evolver` session and an owner-reviewed evolve PR before routine product work. Otherwise run `triggers/detect-work.mjs` through the automation wrapper. If it returns `hasWork: false`, report a quiet no-op and stop. If it returns an issue, execute one bounded issue-dev-loop run for that exact issue. Preserve owner-only review and merge, use `$implement` for product code, use a fresh read-only reviewer after the draft PR, publish review commands through the reviewer wrapper, publish terminal state to the durable GitHub journal, and notify the owner for every blocking event or ready PR. From 2416f7f803dc5ad23533bc7737dab101da5a840d Mon Sep 17 00:00:00 2001 From: leyoonafr <codeacme17@gmail.com> Date: Thu, 23 Jul 2026 22:36:19 +0800 Subject: [PATCH 33/41] fix: close final loop review boundaries --- .codex/agents/echo-ui-pr-reviewer.toml | 7 +- .codex/agents/echo-ui-review-adjudicator.toml | 7 +- loops/_shared/owner-channel/CHANNEL.md | 2 +- loops/issue-dev-loop/LOOP.md | 2 +- loops/issue-dev-loop/SKILL.md | 2 +- loops/issue-dev-loop/dependencies.md | 2 +- .../references/github-operations.md | 4 +- .../schemas/finalization-record.schema.json | 12 +- .../scripts/lib/finalization-journal.mjs | 99 +++++- .../scripts/lib/finalization-proof.mjs | 98 +++++- .../scripts/lib/github-identity.mjs | 11 +- loops/issue-dev-loop/scripts/lib/github.mjs | 86 +++--- .../scripts/lib/notifications.mjs | 63 ++-- .../issue-dev-loop/scripts/lib/owner-gate.mjs | 1 + .../issue-dev-loop/scripts/lib/run-store.mjs | 23 ++ .../issue-dev-loop/scripts/lib/validation.mjs | 17 + .../validate-candidate-control-plane.mjs | 22 +- .../tests/github-identity-routing.test.mjs | 12 +- loops/issue-dev-loop/tests/runtime.test.mjs | 292 +++++++++++++----- 19 files changed, 580 insertions(+), 182 deletions(-) diff --git a/.codex/agents/echo-ui-pr-reviewer.toml b/.codex/agents/echo-ui-pr-reviewer.toml index 36b20556..a0944951 100644 --- a/.codex/agents/echo-ui-pr-reviewer.toml +++ b/.codex/agents/echo-ui-pr-reviewer.toml @@ -10,9 +10,10 @@ security, regressions, and missing tests. Ignore executor conversation history a do not modify files. Avoid style-only or speculative findings. Return a structured review with run-wide `RVW-<cycle>-<round>-<sequence>` IDs, severity P0-P3, confidence, file and line, concrete evidence, reproduction when possible, and expected resolution. Return PASS when no -actionable findings exist. Publish GitHub reviews only through the executable -`loops/issue-dev-loop/scripts/with-github-identity reviewer -- ...` launcher; the -wrapper must verify the configured reviewerGitHubLogin before publication. Never +actionable findings exist. Publish GitHub reviews only through the installed executable +`"$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" --loop-root +"$ECHO_UI_LOOP_TARGET_ROOT" reviewer -- ...` launcher; the wrapper must verify the +configured reviewerGitHubLogin before publication. Never use the repository launcher, invoke the `.mjs` implementation directly, run raw `gh`, alter global authentication, or let the executor identity publish on your behalf. Use `gh pr review --comment --body` for a body-only review; body files are prohibited. When a finding has a file and line, use the launcher's diff --git a/.codex/agents/echo-ui-review-adjudicator.toml b/.codex/agents/echo-ui-review-adjudicator.toml index 39c6d96c..0af55175 100644 --- a/.codex/agents/echo-ui-review-adjudicator.toml +++ b/.codex/agents/echo-ui-review-adjudicator.toml @@ -9,8 +9,9 @@ from private conversation history. Return ACCEPT_FINDING, REJECT_FINDING, or NEEDS_OWNER, followed by concise evidence. Prefer NEEDS_OWNER when product intent, public API policy, security, or release policy is ambiguous. For REJECT_FINDING, publish the required adjudication marker only through the -executable `loops/issue-dev-loop/scripts/with-github-identity reviewer -- ...` -launcher. Use a non-approving `gh pr review --comment`. Do not invoke the `.mjs` -implementation directly or run raw or mutating `gh api`, +installed executable `"$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" +--loop-root "$ECHO_UI_LOOP_TARGET_ROOT" reviewer -- ...` launcher. Use a non-approving +`gh pr review --comment`. Do not use the repository launcher, invoke the `.mjs` +implementation directly, or run raw or mutating `gh api`, alter global authentication, or post through the executor identity. """ diff --git a/loops/_shared/owner-channel/CHANNEL.md b/loops/_shared/owner-channel/CHANNEL.md index 0ed659e0..be2741a5 100644 --- a/loops/_shared/owner-channel/CHANNEL.md +++ b/loops/_shared/owner-channel/CHANNEL.md @@ -8,7 +8,7 @@ GitHub issue and PR comments are the canonical, auditable channel. The runtime m `--dry-run` stages a simulated payload only. It never records `owner_notified`, never pauses the run, and never satisfies a blocking delivery gate. -`pr_completed` is immediate but informational and must remain non-blocking. After remotely observing the owner-authored Ready transition, exact-head owner approval, and owner merge, the runtime durably posts this GitHub notification and performs the same bounded optional webhook attempt before it records the local merge/final state. +`pr_completed` is informational and does not ask the owner for another decision, but successful canonical GitHub delivery is a hard prerequisite for completion. `prepare-finalization` posts it only after remotely observing the owner-authored Ready transition, exact-head owner approval, and owner merge; it waits for the bounded optional webhook attempt and binds both notification outcomes before any terminal journal publication. GitHub delivery failure leaves the run active and retryable. Each blocking GitHub notification prints a unique resume instruction. To continue after answering, include `RESUME <run-id>` in a normal issue/PR comment; submitting a GitHub `CHANGES_REQUESTED` review is also an explicit response. The runtime verifies author, target, timestamp, successful delivery, and response URL before resuming. Silence and unrelated comments never count. diff --git a/loops/issue-dev-loop/LOOP.md b/loops/issue-dev-loop/LOOP.md index b4076655..36144282 100644 --- a/loops/issue-dev-loop/LOOP.md +++ b/loops/issue-dev-loop/LOOP.md @@ -103,7 +103,7 @@ When the owner requests changes, verify the owner-authored GitHub comment or `CH ### 10. Complete -Only the remote owner-merge gate permits `completed`. Both finalization publication and the terminal transition independently query GitHub and must observe the recorded issue branch still targeting `dev`, an `APPROVED` review by `codeacme17` for the exact reviewed head SHA, and a merge performed by `codeacme17` for that same head. Mutable local events are audit records, never authorization. Before any terminal transition, generate a canonical finalization record, publish it through the automation identity to the configured GitHub state-journal issue, and validate its comment URL and digest. Reconciliation revalidates completed records remotely before accepting them or suppressing an active checkpoint. Record merge SHA and timestamp, remove `loop:claimed`, update state, append the run summary, and retain links to published evidence. A closed unmerged PR is `cancelled`, not completed. +Only the remote owner-merge gate permits `completed`. Before a completion record exists, `prepare-finalization` remotely verifies the automation-authored Ready notification, an owner `ready_for_review` timeline event after that comment with no later redraft, an `APPROVED` review for the exact reviewed head SHA, and a merge performed by `codeacme17`. It then requires successful canonical `pr_completed` GitHub delivery and waits for the optional webhook attempt to settle within its bound. The finalization record binds the Ready and completion comment URLs/timestamps plus webhook outcome. Only after those prerequisites may the automation identity publish the record to the GitHub state journal. Publication, `observe-owner-merge`, terminal transition, and future reconciliation re-fetch that proof; a failed delivery or crash before publication leaves the active checkpoint resumable instead of suppressing it. Mutable local events are audit records, never authorization. Record merge SHA and timestamp, remove `loop:claimed`, update state, append the run summary, and retain links to published evidence. A closed unmerged PR is `cancelled`, not completed. ## State and history diff --git a/loops/issue-dev-loop/SKILL.md b/loops/issue-dev-loop/SKILL.md index 58339893..1541dea6 100644 --- a/loops/issue-dev-loop/SKILL.md +++ b/loops/issue-dev-loop/SKILL.md @@ -61,7 +61,7 @@ Run verification appropriate to the change and require `pnpm verify` before the Before publishing the final review round, write the complete cycle result with an unassigned final `reviewUrl`, then run `loopctl.mjs review-digest --result <absolute-path>`. Add the returned marker to the final review body, publish the non-approving review, replace the unassigned URL with GitHub's actual review URL, and confirm `review-digest` is unchanged. After all responses are posted, run `loopctl.mjs record-review --run-id <id> --result <absolute-path> --review-url <github-review-url>`. The publication digest deliberately canonicalizes GitHub-assigned review URLs while the stored full-file digest still protects the final artifact. Both evidence and review gates must name the current PR head. Keep the PR Draft, emit a blocking `pr_ready_for_review` notification asking `codeacme17` to mark it Ready and review it, then transition from `waiting_for_owner` to `awaiting_owner_review` with the PR URL and exact head SHA. -The owner is the only actor allowed to mark a Draft PR Ready, approve it, or merge it. Never call non-`--undo` `gh pr ready`, `gh pr merge`, enable auto-merge, push to `main`, push to `dev`, dismiss owner feedback, or bypass branch protections. Before any terminal transition, run `prepare-finalization`, publish its exact body to the configured state-journal issue, and validate the returned comment URL with `record-finalization`. A completed run passes the same result and comment URL to `observe-owner-merge`, which paginates the PR timeline and reviews, requires a post-notification Ready transition authored by `codeacme17` with no later redraft, and requires the owner's exact-head approval and merge. It then delivers the informational `pr_completed` GitHub/webhook notification before recording the merge and terminal state. Future workspaces run `reconcile` to rebuild local history and evolve metrics from those automation-authored journal comments. +The owner is the only actor allowed to mark a Draft PR Ready, approve it, or merge it. Never call non-`--undo` `gh pr ready`, `gh pr merge`, enable auto-merge, push to `main`, push to `dev`, dismiss owner feedback, or bypass branch protections. Before any terminal transition, run `prepare-finalization`. For completion, that command paginates the PR timeline and reviews, requires a Ready transition authored by `codeacme17` after the remotely verified Ready-notification comment with no later redraft, requires the owner's exact-head approval and merge, delivers the informational `pr_completed` GitHub notification, waits for the bounded webhook attempt to settle, and binds both notification URLs/timestamps into the record. If canonical GitHub delivery fails, no terminal record is created. Only then publish the exact body to the configured state-journal issue and pass its result/comment URL to `observe-owner-merge`; that command revalidates the remote record, appends the local notification/owner audit events, and finalizes. Future workspaces run `reconcile` to revalidate the same notification, Ready, approval, and merge proof before rebuilding local history or suppressing an active checkpoint. For any pause, do not resume from silence or an arbitrary comment. First require a successfully delivered blocking notification. Then verify the owner's GitHub decision with `loopctl.mjs record-owner-response --run-id <id> --response-url <comment-or-review-url>`. A normal comment must include the exact `RESUME <run-id>` token printed in the notification; a `CHANGES_REQUESTED` review is itself an explicit decision and must be submitted against the run's current exact head SHA. Only then may `loopctl.mjs transition --run-id <id> --status running` continue. Before any repair work or new push, publish that transition's checkpoint, run the unchanged exact PR through `gh pr ready --undo --repo codeacme17/echo-ui`, observe the same head as Draft with `record-pr`, and publish another checkpoint. `$implement` repair attestations and later PR rebinds are rejected unless this durable redraft happened after the current owner response. diff --git a/loops/issue-dev-loop/dependencies.md b/loops/issue-dev-loop/dependencies.md index 3050fce5..67c8c174 100644 --- a/loops/issue-dev-loop/dependencies.md +++ b/loops/issue-dev-loop/dependencies.md @@ -41,6 +41,6 @@ The installer refuses a dirty checkout, a branch other than `dev`, a commit othe The trust root is the scheduler/OS boundary, not Unix mode bits or a self-hashed manifest. Put the installed bundle outside every filesystem root writable by the unattended Codex process, and expose it read/execute-only through the scheduler sandbox or separate ownership/ACLs. The automation identity must not have permission to change the bundle, its parent, the pinned executables, file modes, ACLs, or the sandbox policy. A same-OS-principal process allowed to rewrite both code and its manifest cannot establish a cryptographic trust root in user space; do not activate the loop under that permission model. -Credential profiles are a separate secret boundary. Put both profile directories outside every root listed by `ECHO_UI_LOOP_UNTRUSTED_ROOTS`, set each directory to mode `0700` and every entry below it to deny group/other access, and expose them only to the trusted orchestration/identity-router process. The activation check canonicalizes these paths, rejects symlinks, broad permissions, wrong ownership, missing repository coverage, profiles inside an untrusted root, and overlapping agent-visible roots. `$implement`, fresh reviewers, candidate scripts, local product tests, and verifier containers must receive neither profile-path variables nor `GH_CONFIG_DIR`, and their sandbox must not be able to read the profile directories. If the scheduler cannot enforce that read boundary, do not activate the loop. +Credential profiles are a separate secret boundary. Put both profile directories outside every root listed by `ECHO_UI_LOOP_UNTRUSTED_ROOTS`, set each directory to mode `0700` and every entry below it to deny group/other access, and expose them only to the trusted orchestration/identity-router process. The activation check rejects the originally configured path when it is a symlink, then canonicalizes the real directory, checks descendants, permissions, ownership, repository coverage, and overlap with agent-visible roots, and routes `GH_CONFIG_DIR` through that validated canonical path. `$implement`, fresh reviewers, candidate scripts, local product tests, and verifier containers must receive neither profile-path variables nor `GH_CONFIG_DIR`, and their sandbox must not be able to read the profile directories. If the scheduler cannot enforce that read boundary, do not activate the loop. Never run `gh auth setup-git` for this loop. Route commands through `"$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" --loop-root "$ECHO_UI_LOOP_TARGET_ROOT" <role> -- ...`. The repository copy is installer source and intentionally refuses credential use. The installed launcher verifies its manifest before selecting an identity, removes Node preload hooks, scopes `GH_CONFIG_DIR` and the Git credential helper to one allowlisted child tree, gates descendant `git`/`gh` calls, and leaves the user's default `gh` account and global Git credential configuration unchanged. diff --git a/loops/issue-dev-loop/references/github-operations.md b/loops/issue-dev-loop/references/github-operations.md index d1e22195..f1fae1e5 100644 --- a/loops/issue-dev-loop/references/github-operations.md +++ b/loops/issue-dev-loop/references/github-operations.md @@ -42,7 +42,7 @@ The shell launcher removes Node preload hooks before starting the router. The ro ## Evidence artifact -The low-privilege `pull_request` workflow `Issue dev loop evidence` runs only when the branch contains one active issue run. All checkouts disable persisted credentials. It resolves the run's frozen base from the exact candidate, proves that base remains an ancestor of live `dev`, and checks out that immutable owner-merged commit separately from the current control checkout. Container preparation installs the protected candidate and frozen-baseline lockfiles with lifecycle scripts disabled into independent Docker volumes before candidate code runs. Candidate `pnpm verify` and the actual frozen owner-merged baseline `pnpm test` run with `--network none`, no inherited GitHub token, and no mount of any host checkout. The frozen-base generator remains outside those containers and binds the manifest to candidate head, workflow-run SHA, frozen owner-merged base SHA, and the live PR base SHA. The installed control plane later rejects the artifact unless its own exact-head diff check proves that the PR did not change the loop plane, owner channel, workflow, verification scripts, package manifests, lock/workspace files, package hooks, or verification configuration. Wait for that exact-head run to complete, then locate and download the artifact: +The low-privilege `pull_request` workflow `Issue dev loop evidence` runs only when the branch contains one active issue run. All checkouts disable persisted credentials. It resolves the run's frozen base from the exact candidate, proves that base remains an ancestor of live `dev`, and checks out that immutable owner-merged commit separately from the current control checkout. Container preparation installs the protected candidate and frozen-baseline lockfiles with lifecycle scripts disabled into independent Docker volumes before candidate code runs. Candidate `pnpm verify` and the actual frozen owner-merged baseline `pnpm test` run with `--network none`, no inherited GitHub token, and no mount of any host checkout. The frozen-base generator remains outside those containers and binds the manifest to candidate head, workflow-run SHA, frozen owner-merged base SHA, and the live PR base SHA. The installed control plane later rejects the artifact unless its own NUL-safe, rename-disabled exact-head diff check proves that the PR did not change either side of a loop/control rename, the owner channel, workflow, agent/prompt configuration, deployment-provider configuration, verification scripts, package manifests, lock/workspace files, package hooks, or verification configuration. Exact protected root entries and symlinks are rejected as well as descendants. Wait for that exact-head run to complete, then locate and download the artifact: ```text "$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" --loop-root "$ECHO_UI_LOOP_TARGET_ROOT" automation -- gh run list --workflow issue-dev-loop-evidence.yml --branch codex/issue-<number> @@ -77,4 +77,4 @@ After a blocking notification, verify the reply URL with `record-owner-response` For active work, run `loopctl prepare-checkpoint --run-id <id>`, post its exact `body` to the configured `stateIssueNumber` using the automation identity, then validate it with `loopctl record-checkpoint --run-id <id> --result <path> --comment-url <url>`. A checkpoint is SHA-256 bound to the active run, frozen brief, ordered validated events, and the small local result/manifest artifacts required by later gates. Restore verifies every embedded artifact digest before recreating it. Publish one after every state-changing phase; later checkpoints supersede earlier ones. -Before `completed`, `failed`, `blocked`, or `cancelled`, run `loopctl prepare-finalization` with the terminal status and any merge SHA/failure fingerprint. Failed/blocked records bind the delivered automation-authored owner-notification URL; completion binds and remotely rechecks owner approval and merge; cancellation requires the recorded PR to be closed without merge. Post the exact `body` to the configured `stateIssueNumber` using the automation identity, then run `loopctl record-finalization --run-id <id> --result <path> --comment-url <url>`. For completion, pass that result and URL to `observe-owner-merge`. Terminal transitions reject missing, edited, wrong-author, wrong-issue, digest-mismatched, or externally unproven journal entries. Every scheduled wake begins with `loopctl reconcile`, which restores missing local history and recomputes evolve metrics from the append-only journal. +Before `completed`, `failed`, `blocked`, or `cancelled`, run `loopctl prepare-finalization` with the terminal status and any merge SHA/failure fingerprint. Failed/blocked records bind the delivered automation-authored owner-notification URL; cancellation requires the recorded PR to be closed without merge. Completion preparation first re-fetches the Ready-notification comment, requires the owner's later Ready event, exact-head approval, and merge, delivers `pr_completed`, and waits for the bounded webhook attempt. It refuses to create a terminal record if canonical GitHub delivery fails. The record binds both notification URLs/timestamps and the webhook outcome so reconciliation cannot silently skip completion delivery. Post the resulting exact `body` to the configured `stateIssueNumber` using the automation identity, then pass that result and comment URL to `observe-owner-merge`; it revalidates the journal entry before recording local audit events and finalizing. Terminal transitions reject missing, edited, wrong-author, wrong-issue, digest-mismatched, or externally unproven journal entries. Every scheduled wake begins with `loopctl reconcile`, which restores missing local history and recomputes evolve metrics from the append-only journal. diff --git a/loops/issue-dev-loop/schemas/finalization-record.schema.json b/loops/issue-dev-loop/schemas/finalization-record.schema.json index 3c791bd2..a3c62a35 100644 --- a/loops/issue-dev-loop/schemas/finalization-record.schema.json +++ b/loops/issue-dev-loop/schemas/finalization-record.schema.json @@ -13,7 +13,11 @@ "headSha", "mergeSha", "failureFingerprint", - "notificationUrl" + "notificationUrl", + "readyNotificationUrl", + "readyNotifiedAt", + "completionNotifiedAt", + "notificationWebhookStatus" ], "additionalProperties": false, "properties": { @@ -33,6 +37,10 @@ "pattern": "^[0-9a-fA-F]{40}$" }, "failureFingerprint": { "type": ["string", "null"] }, - "notificationUrl": { "type": ["string", "null"], "format": "uri" } + "notificationUrl": { "type": ["string", "null"], "format": "uri" }, + "readyNotificationUrl": { "type": ["string", "null"], "format": "uri" }, + "readyNotifiedAt": { "type": ["string", "null"], "format": "date-time" }, + "completionNotifiedAt": { "type": ["string", "null"], "format": "date-time" }, + "notificationWebhookStatus": { "type": ["string", "null"] } } } diff --git a/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs b/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs index b599634d..71bcaa58 100644 --- a/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs +++ b/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs @@ -21,11 +21,14 @@ import { finalizationJournalConfiguration, finalizationRecordDigest, validateFinalizationRecord, + verifyPullNotificationComment, verifyPublishedFinalization, verifyTerminalExternalProof, } from './finalization-proof.mjs' import { verifyLatestDurableCheckpoint } from './checkpoint-proof.mjs' import { appendValidatedEvent, readEvents, readRun } from './run-store.mjs' +import { createNotification } from './notifications.mjs' +import { observeOwnerApprovedMerge } from './owner-gate.mjs' const TERMINAL_STATUSES = new Set(['completed', 'failed', 'blocked', 'cancelled']) @@ -41,6 +44,7 @@ export async function prepareFinalizationRecord({ finishedAt = new Date(), githubApi = defaultGitHubApi, checkpointVerifier = verifyLatestDurableCheckpoint, + notifyOwner = createNotification, } = {}) { const normalizedRunId = assertRunId(runId) const run = await readRun(loopRoot, normalizedRunId) @@ -89,6 +93,93 @@ export async function prepareFinalizationRecord({ journalIssueUrl: `https://github.com/${owner}/${repo}/issues/${channel.stateIssueNumber}`, } } + let completionProof = { + notificationUrl, + readyNotificationUrl: null, + readyNotifiedAt: null, + completionNotifiedAt: null, + notificationWebhookStatus: null, + } + let recordFinishedAt = finishedAt + if (status === 'completed') { + const channel = await readJson( + path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'), + ) + const readyEvent = events.findLast( + (event) => + event.type === 'owner_notified' && + event.status === 'delivered' && + ['pr_ready_for_review', 'pr_updated_for_review'].includes( + event.payload?.notificationType, + ) && + event.payload?.targetUrl === run.prUrl && + event.payload?.headSha === run.headSha && + event.payload?.deliveryUrl, + ) + if (!readyEvent) { + throw new Error('completed finalization requires the delivered exact-head Ready notification') + } + const readyNotification = await verifyPullNotificationComment({ + url: readyEvent.payload.deliveryUrl, + allowedTypes: ['pr_ready_for_review', 'pr_updated_for_review'], + runId: normalizedRunId, + prUrl: run.prUrl, + channel, + githubApi, + }) + const merge = await observeOwnerApprovedMerge({ + loopRoot, + prUrl: run.prUrl, + expectedHeadSha: run.headSha, + expectedHeadBranch: run.branch, + readyAfter: readyNotification.comment.created_at, + githubApi, + }) + if (merge.mergeSha !== mergeSha) { + throw new Error('completed mergeSha does not match the remote owner merge') + } + const completionNotification = await notifyOwner({ + loopRoot, + runId: normalizedRunId, + type: 'pr_completed', + summary: `PR merged by the owner at ${merge.mergeSha}.`, + requestedAction: 'No action is required; the completed run is being finalized.', + targetUrl: run.prUrl, + evidenceUrl: run.prUrl, + blocking: false, + now: finishedAt, + githubApi, + checkpointVerifier, + recordEvent: false, + }) + if ( + completionNotification.delivery.github !== 'delivered' || + !completionNotification.delivery.githubUrl || + ['pending', 'dry_run'].includes(completionNotification.delivery.webhook) + ) { + throw new Error( + 'completed finalization requires durable GitHub delivery and a settled webhook attempt', + ) + } + const publishedCompletion = await verifyPullNotificationComment({ + url: completionNotification.delivery.githubUrl, + allowedTypes: ['pr_completed'], + runId: normalizedRunId, + prUrl: run.prUrl, + channel, + githubApi, + }) + completionProof = { + notificationUrl: completionNotification.delivery.githubUrl, + readyNotificationUrl: readyEvent.payload.deliveryUrl, + readyNotifiedAt: readyNotification.comment.created_at, + completionNotifiedAt: publishedCompletion.comment.created_at, + notificationWebhookStatus: completionNotification.delivery.webhook, + } + recordFinishedAt = new Date( + Math.max(finishedAt.getTime(), Date.parse(publishedCompletion.comment.created_at)), + ) + } const record = validateFinalizationRecord( { schemaVersion: 1, @@ -96,12 +187,12 @@ export async function prepareFinalizationRecord({ issueNumber: run.issueNumber, status, startedAt: run.startedAt, - finishedAt: finishedAt.toISOString(), + finishedAt: recordFinishedAt.toISOString(), prUrl: run.prUrl, headSha: run.headSha, mergeSha, failureFingerprint, - notificationUrl, + ...completionProof, }, run, ) @@ -163,6 +254,10 @@ export async function recordFinalizationPublication({ mergeSha: record.mergeSha, failureFingerprint: record.failureFingerprint, notificationUrl: record.notificationUrl, + readyNotificationUrl: record.readyNotificationUrl, + readyNotifiedAt: record.readyNotifiedAt, + completionNotifiedAt: record.completionNotifiedAt, + notificationWebhookStatus: record.notificationWebhookStatus, }, now, }) diff --git a/loops/issue-dev-loop/scripts/lib/finalization-proof.mjs b/loops/issue-dev-loop/scripts/lib/finalization-proof.mjs index ad88a673..03501933 100644 --- a/loops/issue-dev-loop/scripts/lib/finalization-proof.mjs +++ b/loops/issue-dev-loop/scripts/lib/finalization-proof.mjs @@ -27,6 +27,10 @@ export function canonicalFinalizationRecord(record) { mergeSha: record.mergeSha ?? null, failureFingerprint: record.failureFingerprint ?? null, notificationUrl: record.notificationUrl ?? null, + readyNotificationUrl: record.readyNotificationUrl ?? null, + readyNotifiedAt: record.readyNotifiedAt ?? null, + completionNotifiedAt: record.completionNotifiedAt ?? null, + notificationWebhookStatus: record.notificationWebhookStatus ?? null, }) } @@ -43,15 +47,34 @@ export function validateFinalizationRecord(record, run = null) { Number.isNaN(Date.parse(record.finishedAt)) || Date.parse(record.finishedAt) < Date.parse(record.startedAt) || (record.headSha !== null && !/^[0-9a-f]{40}$/i.test(record.headSha)) || - (record.mergeSha !== null && !/^[0-9a-f]{40}$/i.test(record.mergeSha)) + (record.mergeSha !== null && !/^[0-9a-f]{40}$/i.test(record.mergeSha)) || + ![ + 'readyNotificationUrl', + 'readyNotifiedAt', + 'completionNotifiedAt', + 'notificationWebhookStatus', + ].every((field) => Object.hasOwn(record, field)) ) { throw new Error('invalid finalization journal record') } if ( record.status === 'completed' && - (!record.prUrl || !record.headSha || !record.mergeSha || record.failureFingerprint !== null) + (!record.prUrl || + !record.headSha || + !record.mergeSha || + record.failureFingerprint !== null || + !record.notificationUrl || + !record.readyNotificationUrl || + Number.isNaN(Date.parse(record.readyNotifiedAt)) || + Number.isNaN(Date.parse(record.completionNotifiedAt)) || + Date.parse(record.readyNotifiedAt) > Date.parse(record.completionNotifiedAt) || + Date.parse(record.completionNotifiedAt) > Date.parse(record.finishedAt) || + !record.notificationWebhookStatus || + ['pending', 'dry_run'].includes(record.notificationWebhookStatus)) ) { - throw new Error('completed finalization record requires PR, head, and merge proof') + throw new Error( + 'completed finalization record requires PR, head, merge, Ready, and completion-notification proof', + ) } if (['failed', 'blocked'].includes(record.status)) { assertNonEmpty(record.failureFingerprint, 'failureFingerprint') @@ -62,6 +85,17 @@ export function validateFinalizationRecord(record, run = null) { if (record.status === 'cancelled' && (!record.prUrl || !record.headSha)) { throw new Error('cancelled finalization requires a published PR') } + if ( + record.status !== 'completed' && + [ + record.readyNotificationUrl, + record.readyNotifiedAt, + record.completionNotifiedAt, + record.notificationWebhookStatus, + ].some((value) => value !== null) + ) { + throw new Error('non-completed finalization cannot contain completion-notification proof') + } if ( run && (record.runId !== run.runId || @@ -94,6 +128,41 @@ export async function finalizationJournalConfiguration(loopRoot) { return { channel, owner, repo } } +export async function verifyPullNotificationComment({ + url, + allowedTypes, + runId, + prUrl, + channel, + githubApi, +}) { + const target = parsePullCommentUrl(url) + const pullTarget = parseGitHubTarget(prUrl) + if ( + !target || + target.kind !== 'issue_comment' || + target.surface !== 'pull' || + !pullTarget || + !sameRepository(target, pullTarget) || + target.number !== pullTarget.number + ) { + throw new Error('completion proof notification must be a comment on the recorded PR') + } + const comment = await githubApi( + `repos/${target.owner}/${target.repo}/issues/comments/${target.commentId}`, + ) + const notificationType = allowedTypes.find((type) => comment.body?.includes(`**${type}**`)) + if ( + !notificationType || + !sameGitHubLogin(comment.user?.login, channel.automationGitHubLogin) || + !comment.body?.includes(`Run: \`${runId}\``) || + Number.isNaN(Date.parse(comment.created_at)) + ) { + throw new Error('completion proof notification is not durable automation-authored evidence') + } + return { comment, notificationType } +} + export async function verifyTerminalExternalProof({ loopRoot, record, @@ -104,16 +173,39 @@ export async function verifyTerminalExternalProof({ const { channel, owner, repo } = await finalizationJournalConfiguration(loopRoot) const configuredTarget = { owner, repo } if (validated.status === 'completed') { + const readyNotification = await verifyPullNotificationComment({ + url: validated.readyNotificationUrl, + allowedTypes: ['pr_ready_for_review', 'pr_updated_for_review'], + runId: validated.runId, + prUrl: validated.prUrl, + channel, + githubApi, + }) + if (readyNotification.comment.created_at !== validated.readyNotifiedAt) { + throw new Error('completed finalization Ready notification timestamp changed') + } const merge = await observeOwnerApprovedMerge({ loopRoot, prUrl: validated.prUrl, expectedHeadSha: validated.headSha, expectedHeadBranch, + readyAfter: validated.readyNotifiedAt, githubApi, }) if (merge.mergeSha !== validated.mergeSha) { throw new Error('completed finalization does not match the remote owner merge') } + const completionNotification = await verifyPullNotificationComment({ + url: validated.notificationUrl, + allowedTypes: ['pr_completed'], + runId: validated.runId, + prUrl: validated.prUrl, + channel, + githubApi, + }) + if (completionNotification.comment.created_at !== validated.completionNotifiedAt) { + throw new Error('completed finalization completion-notification timestamp changed') + } } if (['failed', 'blocked'].includes(validated.status)) { const target = parsePullCommentUrl(validated.notificationUrl) diff --git a/loops/issue-dev-loop/scripts/lib/github-identity.mjs b/loops/issue-dev-loop/scripts/lib/github-identity.mjs index 38d025d1..04429384 100644 --- a/loops/issue-dev-loop/scripts/lib/github-identity.mjs +++ b/loops/issue-dev-loop/scripts/lib/github-identity.mjs @@ -187,6 +187,10 @@ export async function assertCredentialProfileIsolation({ ) { throw new Error(`${variableName} must contain a non-empty JSON array of absolute paths`) } + const configuredProfileStats = await lstat(configDirectory) + if (configuredProfileStats.isSymbolicLink() || !configuredProfileStats.isDirectory()) { + throw new Error('GitHub credential profile path must be a real directory, not a symlink') + } const [canonicalConfigDirectory, canonicalRoots, canonicalRequiredRoots] = await Promise.all([ realpath(configDirectory), Promise.all(configuredRoots.map((root) => realpath(root))), @@ -1619,12 +1623,17 @@ export async function assertGitHubRoleIdentity({ }) { let resolved = resolveGitHubRoleEnvironment({ channel, role, environment }) if (enforceCredentialIsolation) { - await assertCredentialProfileIsolation({ + const configDirectory = await assertCredentialProfileIsolation({ channel, configDirectory: resolved.configDirectory, environment, requiredUntrustedRoots, }) + resolved = { + ...resolved, + configDirectory, + routedEnvironment: { ...resolved.routedEnvironment, GH_CONFIG_DIR: configDirectory }, + } } const { stdout } = await identityCommand(ghExecutable, ['api', 'user', '--jq', '.login'], { env: resolved.routedEnvironment, diff --git a/loops/issue-dev-loop/scripts/lib/github.mjs b/loops/issue-dev-loop/scripts/lib/github.mjs index f4c9bb29..11b08b11 100644 --- a/loops/issue-dev-loop/scripts/lib/github.mjs +++ b/loops/issue-dev-loop/scripts/lib/github.mjs @@ -22,8 +22,6 @@ import { recordFinalizationPublication, } from './finalization-journal.mjs' import { reconcileActiveJournal } from './active-journal.mjs' -import { observeOwnerApprovedMerge } from './owner-gate.mjs' -import { createNotification } from './notifications.mjs' import { defaultReleaseIssueClaim } from './issue-claim.mjs' import { appendValidatedEvent, finalizeRun, readEvents, readRun } from './run-store.mjs' import { verifyLatestDurableCheckpoint } from './checkpoint-proof.mjs' @@ -212,55 +210,55 @@ export async function observeOwnerMerge({ finalizationResultPath, finalizationCommentUrl, recordFinalization = recordFinalizationPublication, - notifyOwner = createNotification, } = {}) { const normalizedRunId = assertRunId(runId) const run = await readRun(loopRoot, normalizedRunId) if (run.status !== 'awaiting_owner_review' || !run.prUrl || !run.headSha) { throw new Error('owner merge observation requires an awaiting_owner_review run') } - const events = await readEvents(loopRoot, normalizedRunId) - const readyNotification = events.findLast( - (event) => - event.type === 'owner_notified' && - event.status === 'delivered' && - ['pr_ready_for_review', 'pr_updated_for_review'].includes( - event.payload?.notificationType, - ) && - event.payload?.targetUrl === run.prUrl && - event.payload?.headSha === run.headSha, - ) - if (!readyNotification) { - throw new Error('owner merge observation requires the delivered exact-head Ready notification') - } - const merge = await observeOwnerApprovedMerge({ + const publication = await recordFinalization({ loopRoot, - prUrl: run.prUrl, - expectedHeadSha: run.headSha, - expectedHeadBranch: run.branch, - readyAfter: readyNotification.timestamp, + runId: normalizedRunId, + resultPath: finalizationResultPath, + commentUrl: finalizationCommentUrl, + now, githubApi, }) - const completionNotification = events.findLast( - (event) => - event.type === 'owner_notified' && - event.status === 'delivered' && - event.payload?.notificationType === 'pr_completed' && - event.payload?.targetUrl === run.prUrl && - event.payload?.headSha === run.headSha, + const merge = publication.record + if (merge.status !== 'completed' || merge.headSha !== run.headSha || !merge.notificationUrl) { + throw new Error('owner merge observation requires completed durable finalization proof') + } + const channel = await readJson( + path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'), ) - if (!completionNotification) { - await notifyOwner({ + const events = await readEvents(loopRoot, normalizedRunId) + if ( + !events.some( + (event) => + event.type === 'owner_notified' && + event.status === 'delivered' && + event.payload?.notificationType === 'pr_completed' && + event.payload?.deliveryUrl === merge.notificationUrl, + ) + ) { + await appendValidatedEvent({ loopRoot, runId: normalizedRunId, - type: 'pr_completed', - summary: `PR merged by the owner at ${merge.mergeSha}.`, - requestedAction: 'No action is required; the completed run is being finalized.', - targetUrl: run.prUrl, - evidenceUrl: run.prUrl, - blocking: false, + type: 'owner_notified', + status: 'delivered', + payload: { + notificationType: 'pr_completed', + delivery: { + github: 'delivered', + githubUrl: merge.notificationUrl, + webhook: merge.notificationWebhookStatus, + }, + deliveryUrl: merge.notificationUrl, + targetUrl: run.prUrl, + evidenceUrl: run.prUrl, + headSha: run.headSha, + }, now, - githubApi, }) } await appendValidatedEvent({ @@ -268,7 +266,7 @@ export async function observeOwnerMerge({ runId: normalizedRunId, type: 'owner_review_approved', status: 'observed', - payload: { actor: merge.owner, headSha: run.headSha, prUrl: run.prUrl }, + payload: { actor: channel.ownerGitHubLogin, headSha: run.headSha, prUrl: run.prUrl }, now, }) await appendValidatedEvent({ @@ -277,21 +275,13 @@ export async function observeOwnerMerge({ type: 'pr_merged', status: 'observed', payload: { - actor: merge.owner, + actor: channel.ownerGitHubLogin, headSha: run.headSha, mergeSha: merge.mergeSha, prUrl: run.prUrl, }, now, }) - await recordFinalization({ - loopRoot, - runId: normalizedRunId, - resultPath: finalizationResultPath, - commentUrl: finalizationCommentUrl, - now, - githubApi, - }) return finalizeRun({ loopRoot, runId: normalizedRunId, diff --git a/loops/issue-dev-loop/scripts/lib/notifications.mjs b/loops/issue-dev-loop/scripts/lib/notifications.mjs index 3ade7067..135f937f 100644 --- a/loops/issue-dev-loop/scripts/lib/notifications.mjs +++ b/loops/issue-dev-loop/scripts/lib/notifications.mjs @@ -67,6 +67,7 @@ export async function createNotification({ verifyAutomationIdentity = assertAutomationIdentity, githubApi, checkpointVerifier = verifyLatestDurableCheckpoint, + recordEvent = true, } = {}) { const normalizedRunId = assertRunId(runId) const run = await readRun(loopRoot, normalizedRunId) @@ -174,24 +175,26 @@ export async function createNotification({ // Persist canonical GitHub delivery before attempting the optional webhook mirror. await writeJson(outboxFile, notification) const delivered = notification.delivery.github === 'delivered' - await appendValidatedEvent({ - loopRoot, - runId: normalizedRunId, - type: dryRun ? 'notification_dry_run' : delivered ? 'owner_notified' : 'notification_failed', - status: dryRun ? 'simulated' : delivered ? 'delivered' : 'failed', - payload: { - notificationId, - notificationType, - delivery: notification.delivery, - deliveryUrl: notification.delivery.githubUrl ?? null, - targetUrl, - evidenceUrl, - headSha: run.headSha, - }, - now, - }) + if (recordEvent) { + await appendValidatedEvent({ + loopRoot, + runId: normalizedRunId, + type: dryRun ? 'notification_dry_run' : delivered ? 'owner_notified' : 'notification_failed', + status: dryRun ? 'simulated' : delivered ? 'delivered' : 'failed', + payload: { + notificationId, + notificationType, + delivery: notification.delivery, + deliveryUrl: notification.delivery.githubUrl ?? null, + targetUrl, + evidenceUrl, + headSha: run.headSha, + }, + now, + }) + } - if (blocking && !dryRun) { + if (recordEvent && blocking && !dryRun) { if (run.finishedAt === null && run.status !== 'waiting_for_owner') { await transitionRun({ loopRoot, @@ -234,18 +237,20 @@ export async function createNotification({ clearTimeout(timeout) } await writeJson(outboxFile, notification) - await appendValidatedEvent({ - loopRoot, - runId: normalizedRunId, - type: 'notification_webhook_finished', - status: notification.delivery.webhook === 'delivered' ? 'delivered' : 'failed', - payload: { - notificationId, - notificationType, - webhook: notification.delivery.webhook, - }, - now: new Date(), - }) + if (recordEvent) { + await appendValidatedEvent({ + loopRoot, + runId: normalizedRunId, + type: 'notification_webhook_finished', + status: notification.delivery.webhook === 'delivered' ? 'delivered' : 'failed', + payload: { + notificationId, + notificationType, + webhook: notification.delivery.webhook, + }, + now: new Date(), + }) + } } if (blocking && !delivered && !dryRun) { throw new Error(`blocking notification was not delivered: ${notificationId}`) diff --git a/loops/issue-dev-loop/scripts/lib/owner-gate.mjs b/loops/issue-dev-loop/scripts/lib/owner-gate.mjs index 986454f0..fa5ad207 100644 --- a/loops/issue-dev-loop/scripts/lib/owner-gate.mjs +++ b/loops/issue-dev-loop/scripts/lib/owner-gate.mjs @@ -82,6 +82,7 @@ export async function observeOwnerApprovedMerge({ owner: channel.ownerGitHubLogin, headSha, mergeSha: pullRequest.merge_commit_sha, + mergeAt: pullRequest.merged_at ?? null, prUrl, } } diff --git a/loops/issue-dev-loop/scripts/lib/run-store.mjs b/loops/issue-dev-loop/scripts/lib/run-store.mjs index 9b2f2eb3..2d0c0f19 100644 --- a/loops/issue-dev-loop/scripts/lib/run-store.mjs +++ b/loops/issue-dev-loop/scripts/lib/run-store.mjs @@ -1059,11 +1059,34 @@ export async function transitionRun({ ) { throw new Error('completed requires an owner-ready PR and mergeSha') } + const readyNotification = events.findLast( + (event) => + event.type === 'owner_notified' && + event.status === 'delivered' && + ['pr_ready_for_review', 'pr_updated_for_review'].includes( + event.payload?.notificationType, + ) && + event.payload?.targetUrl === run.prUrl && + event.payload?.headSha === run.headSha, + ) + const completionNotification = events.findLast( + (event) => + event.type === 'owner_notified' && + event.status === 'delivered' && + event.payload?.notificationType === 'pr_completed' && + event.payload?.targetUrl === run.prUrl && + event.payload?.headSha === run.headSha && + event.payload?.delivery?.github === 'delivered', + ) + if (!readyNotification || !completionNotification) { + throw new Error('completed requires durable Ready and completion notifications') + } const remoteMerge = await observeOwnerApprovedMerge({ loopRoot, prUrl: run.prUrl, expectedHeadSha: run.headSha, expectedHeadBranch: run.branch, + readyAfter: readyNotification.timestamp, githubApi, }) if (remoteMerge.mergeSha !== mergeSha) { diff --git a/loops/issue-dev-loop/scripts/lib/validation.mjs b/loops/issue-dev-loop/scripts/lib/validation.mjs index bcd07d86..65930eef 100644 --- a/loops/issue-dev-loop/scripts/lib/validation.mjs +++ b/loops/issue-dev-loop/scripts/lib/validation.mjs @@ -223,6 +223,23 @@ export async function validateLoop({ throw new Error(`Codex role is not registered through config_file: ${role}`) } } + for (const roleFile of [ + 'echo-ui-pr-reviewer.toml', + 'echo-ui-review-adjudicator.toml', + ]) { + const roleSource = await readFile( + path.resolve(loopRoot, '..', '..', '.codex', 'agents', roleFile), + 'utf8', + ) + if ( + !roleSource.includes('$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity') || + !roleSource.includes('--loop-root') || + !roleSource.includes('$ECHO_UI_LOOP_TARGET_ROOT') || + !roleSource.includes('repository launcher') + ) { + throw new Error(`${roleFile} must publish only through the installed identity launcher`) + } + } const contract = await readFile(path.join(loopRoot, 'LOOP.md'), 'utf8') const skill = await readFile(path.join(loopRoot, 'SKILL.md'), 'utf8') diff --git a/loops/issue-dev-loop/scripts/validate-candidate-control-plane.mjs b/loops/issue-dev-loop/scripts/validate-candidate-control-plane.mjs index 9e2aab74..ed1e8586 100644 --- a/loops/issue-dev-loop/scripts/validate-candidate-control-plane.mjs +++ b/loops/issue-dev-loop/scripts/validate-candidate-control-plane.mjs @@ -54,7 +54,14 @@ if (run.headSha) { const changed = await execFileAsync( 'git', - ['diff', '--name-only', '--diff-filter=ACDMRTUXB', `${baseSha}...${headSha}`], + [ + 'diff', + '--name-only', + '-z', + '--no-renames', + '--diff-filter=ACDMRTUXB', + `${baseSha}...${headSha}`, + ], { cwd: repositoryRoot, maxBuffer: 4 * 1024 * 1024 }, ) const permittedRunRoots = [ @@ -83,7 +90,7 @@ const protectedDeploymentFiles = new Set([ 'vercel.json', ]) const changedFiles = changed.stdout - .split('\n') + .split('\0') .filter(Boolean) const violations = changedFiles.filter((file) => { if (file.startsWith('loops/issue-dev-loop/')) { @@ -91,16 +98,27 @@ const violations = changedFiles.filter((file) => { } const basename = path.basename(file) return ( + file === 'loops/_shared' || file.startsWith('loops/_shared/') || + file === '.agents' || file.startsWith('.agents/') || + file === '.claude' || file.startsWith('.claude/') || + file === '.codex' || file.startsWith('.codex/') || + file === '.cursor' || file.startsWith('.cursor/') || + file === '.github' || file.startsWith('.github/') || + file === '.netlify' || file.startsWith('.netlify/') || + file === '.openai' || file.startsWith('.openai/') || + file === '.vercel' || file.startsWith('.vercel/') || + file === 'scripts' || file.startsWith('scripts/') || + file === 'patches' || file.startsWith('patches/') || file.split('/').includes('node_modules') || basename === 'package.json' || diff --git a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs index 453af17a..4d44e405 100644 --- a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs +++ b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs @@ -464,6 +464,10 @@ ${JSON.stringify(process.execPath)} -e 'process.stdout.write(JSON.stringify({arg `${JSON.stringify(manifest)}\n`, 'utf8', ) + const [canonicalAutomationProfile, canonicalReviewerProfile] = await Promise.all([ + realpath(automationProfile), + realpath(reviewerProfile), + ]) return { loopRoot, @@ -475,13 +479,13 @@ ${JSON.stringify(process.execPath)} -e 'process.stdout.write(JSON.stringify({arg fakeGh, fakeGit, impostorGh, - automationProfile, - reviewerProfile, + automationProfile: canonicalAutomationProfile, + reviewerProfile: canonicalReviewerProfile, env: { ...process.env, PATH: `${binRoot}${path.delimiter}${process.env.PATH}`, - ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR: automationProfile, - ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR: reviewerProfile, + ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR: canonicalAutomationProfile, + ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR: canonicalReviewerProfile, ECHO_UI_LOOP_UNTRUSTED_ROOTS: JSON.stringify([loopRoot]), GH_TOKEN: 'must-not-leak', GITHUB_TOKEN: 'must-not-leak', diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index ae1f7941..abe08e9d 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -1,7 +1,16 @@ import assert from 'node:assert/strict' import { execFile } from 'node:child_process' import { createHash } from 'node:crypto' -import { chmod, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { + chmod, + mkdtemp, + mkdir, + readFile, + realpath, + rm, + symlink, + writeFile, +} from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import test from 'node:test' @@ -44,6 +53,7 @@ import { } from '../scripts/runtime.mjs' import { observeOwnerApprovedMerge } from '../scripts/lib/owner-gate.mjs' import { assertCredentialProfileIsolation } from '../scripts/lib/github-identity.mjs' +import { verifyTerminalExternalProof } from '../scripts/lib/finalization-proof.mjs' const bypassCheckpointVerifier = async () => {} const createNotification = (options) => @@ -555,6 +565,13 @@ async function writeFixtureFinalization({ const run = JSON.parse( await readFile(path.join(loopRoot, 'logs', 'runs', runId, 'run.json'), 'utf8'), ) + const completed = status === 'completed' + const readyNotifiedAt = completed + ? new Date(Date.parse(finishedAt) - 20 * 60_000).toISOString() + : null + const completionNotifiedAt = completed + ? new Date(Date.parse(finishedAt) - 5 * 60_000).toISOString() + : null const record = { schemaVersion: 1, runId, @@ -566,9 +583,15 @@ async function writeFixtureFinalization({ headSha: run.headSha, mergeSha, failureFingerprint, - notificationUrl: ['failed', 'blocked'].includes(status) - ? `${run.issueUrl}#issuecomment-8800` - : null, + notificationUrl: completed + ? `${run.prUrl}#issuecomment-8803` + : ['failed', 'blocked'].includes(status) + ? `${run.issueUrl}#issuecomment-8800` + : null, + readyNotificationUrl: completed ? `${run.prUrl}#issuecomment-8802` : null, + readyNotifiedAt, + completionNotifiedAt, + notificationWebhookStatus: completed ? 'not_configured' : null, } const resultPath = path.join(loopRoot, 'logs', 'runs', runId, 'finalization-result.json') await writeFile(resultPath, `${canonicalRecord(record)}\n`, 'utf8') @@ -607,6 +630,20 @@ async function writeFixtureFinalization({ body: `@codeacme17 **${notificationType}**\n\nRun: \`${runId}\``, } } + if (endpoint.endsWith('/issues/comments/8802')) { + return { + user: { login: 'echo-ui-loop[bot]' }, + created_at: readyNotifiedAt, + body: `@codeacme17 **pr_ready_for_review**\n\nRun: \`${runId}\``, + } + } + if (endpoint.endsWith('/issues/comments/8803')) { + return { + user: { login: 'echo-ui-loop[bot]' }, + created_at: completionNotifiedAt, + body: `@codeacme17 **pr_completed**\n\nRun: \`${runId}\``, + } + } return { user: { login: 'echo-ui-loop[bot]' }, body: [ @@ -729,12 +766,18 @@ test('candidate control-plane validation permits run evidence but rejects verifi const runId = 'run-issue-123' await mkdir(path.join(loopRoot, 'logs', 'runs', runId), { recursive: true }) await mkdir(path.join(repository, 'src'), { recursive: true }) + await mkdir(path.join(repository, '.codex', 'agents'), { recursive: true }) const git = async (...args) => execFileAsync('git', args, { cwd: repository }) await git('init', '--initial-branch=dev') await git('config', 'user.name', 'Loop Test') await git('config', 'user.email', 'loop-test@example.invalid') await writeFile(path.join(repository, 'src', 'feature.js'), 'export const value = 1\n', 'utf8') await writeFile(path.join(repository, 'package.json'), '{"scripts":{"verify":"true"}}\n', 'utf8') + await writeFile( + path.join(repository, '.codex', 'agents', 'reviewer.toml'), + 'sandbox_mode = "read-only"\n', + 'utf8', + ) await git('add', '.') await git('commit', '-m', 'base') const baseSha = (await git('rev-parse', 'HEAD')).stdout.trim() @@ -778,6 +821,48 @@ test('candidate control-plane validation permits run evidence but rejects verifi ]) assert.equal(JSON.parse(permitted.stdout).valid, true) + await git('mv', '.codex/agents/reviewer.toml', 'src/reviewer.toml') + await git('commit', '-m', 'move protected reviewer configuration') + const renamedHead = (await git('rev-parse', 'HEAD')).stdout.trim() + await assert.rejects( + execFileAsync(process.execPath, [ + validator, + '--loop-root', + loopRoot, + '--run-id', + runId, + '--base-sha', + baseSha, + '--head-sha', + renamedHead, + ]), + /\.codex\/agents\/reviewer\.toml/, + ) + await git('mv', 'src/reviewer.toml', '.codex/agents/reviewer.toml') + await git('commit', '-m', 'restore protected reviewer configuration') + + await symlink('src', path.join(repository, '.agents')) + await git('add', '.agents') + await git('commit', '-m', 'add root agent adapter symlink') + const symlinkHead = (await git('rev-parse', 'HEAD')).stdout.trim() + await assert.rejects( + execFileAsync(process.execPath, [ + validator, + '--loop-root', + loopRoot, + '--run-id', + runId, + '--base-sha', + baseSha, + '--head-sha', + symlinkHead, + ]), + /(?:^|\n)\.agents(?:\n|$)/, + ) + await rm(path.join(repository, '.agents')) + await git('add', '-A') + await git('commit', '-m', 'remove root agent adapter symlink') + await writeFile( path.join(repository, 'package.json'), '{"scripts":{"verify":"node attacker.js"}}\n', @@ -1752,7 +1837,9 @@ test('owner-review waiting transition keeps the exact verified PR Draft and rema targetUrl: prUrl, evidenceUrl: 'https://github.com/codeacme17/echo-ui/actions/runs/101/artifacts/201', blocking: true, - githubComment: async () => {}, + githubComment: async () => ({ + html_url: `${prUrl}#issuecomment-8802`, + }), }) await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) @@ -1931,92 +2018,115 @@ test('owner-review waiting transition keeps the exact verified PR Draft and rema /reserved/, ) + const completionCommentUrl = `${prUrl}#issuecomment-8803` + const finalizationCommentUrl = + 'https://github.com/codeacme17/echo-ui/issues/999#issuecomment-9900' + let preparedCompletion + const completionGithubApi = async (endpoint) => { + if (endpoint.includes('/reviews')) { + return [{ user: { login: 'codeacme17' }, state: 'APPROVED', commit_id: headSha }] + } + if (endpoint.includes('/timeline')) { + return [ + { + event: 'ready_for_review', + actor: { login: 'codeacme17' }, + created_at: new Date(Date.now() + 60_000).toISOString(), + }, + ] + } + if (endpoint.endsWith('/issues/comments/8802')) { + return { + user: { login: 'echo-ui-loop[bot]' }, + created_at: '2026-07-23T08:30:00.000Z', + body: `@codeacme17 **pr_ready_for_review**\n\nRun: \`${run.runId}\``, + } + } + if (endpoint.endsWith('/issues/comments/8803')) { + return { + user: { login: 'echo-ui-loop[bot]' }, + created_at: '2026-07-23T08:50:00.000Z', + body: `@codeacme17 **pr_completed**\n\nRun: \`${run.runId}\``, + } + } + if (endpoint.endsWith('/issues/comments/9900')) { + return { + user: { login: 'echo-ui-loop[bot]' }, + body: preparedCompletion.body, + } + } + return { + ...pullRequestFixture(run, headSha, { draft: false, merged: true }), + merged_at: '2026-07-23T08:45:00.000Z', + } + } await assert.rejects( - observeOwnerMerge({ + prepareFinalizationRecord({ loopRoot, runId: run.runId, - githubApi: async (endpoint) => { - if (endpoint.includes('/reviews')) { - return [ - { - user: { login: 'codeacme17' }, - state: 'APPROVED', - commit_id: headSha, - }, - ] - } - if (endpoint.includes('/timeline')) { - return [ - { - event: 'ready_for_review', - actor: { login: 'codeacme17' }, - created_at: '2026-07-23T08:30:00.000Z', - }, - ] - } - return { - merged: true, - merged_by: { login: 'someone-else' }, - ...pullRequestFixture(run, headSha, { draft: false, merged: true }), - merged_by: { login: 'someone-else' }, - merge_commit_sha: '9'.repeat(40), - } - }, + status: 'completed', + finishedAt: new Date('2026-07-23T09:00:00.000Z'), + mergeSha: '9'.repeat(40), + githubApi: completionGithubApi, + notifyOwner: async () => ({ + delivery: { + github: 'failed: unavailable', + webhook: 'not_configured', + }, + }), }), - /not approved and merged by the configured owner/, + /durable GitHub delivery and a settled webhook attempt/, ) - + preparedCompletion = await prepareFinalizationRecord({ + loopRoot, + runId: run.runId, + status: 'completed', + finishedAt: new Date('2026-07-23T09:00:00.000Z'), + mergeSha: '9'.repeat(40), + githubApi: completionGithubApi, + checkpointVerifier: bypassCheckpointVerifier, + notifyOwner: async (notification) => { + assert.equal(notification.recordEvent, false) + assert.equal(notification.type, 'pr_completed') + return { + delivery: { + github: 'delivered', + githubUrl: completionCommentUrl, + webhook: 'failed: timed out after 5ms', + }, + } + }, + }) await assert.rejects( - observeOwnerMerge({ + verifyTerminalExternalProof({ loopRoot, - runId: run.runId, + record: { + ...preparedCompletion.record, + readyNotifiedAt: '2099-01-01T00:00:00.000Z', + completionNotifiedAt: '2099-01-02T00:00:00.000Z', + finishedAt: '2099-01-03T00:00:00.000Z', + }, githubApi: async (endpoint) => { - if (endpoint.includes('/reviews')) { - return [{ user: { login: 'codeacme17' }, state: 'APPROVED', commit_id: headSha }] - } - if (endpoint.includes('/timeline')) { - return [ - { - event: 'ready_for_review', - actor: { login: 'codeacme17' }, - created_at: '2026-07-23T08:30:00.000Z', - }, - ] - } - return { - ...pullRequestFixture(run, headSha, { draft: false, merged: true }), - base: { ref: 'main', repo: { full_name: 'codeacme17/echo-ui' } }, + if (endpoint.endsWith('/issues/comments/8802')) { + return { + user: { login: 'echo-ui-loop[bot]' }, + created_at: '2099-01-01T00:00:00.000Z', + body: `@codeacme17 **pr_ready_for_review**\n\nRun: \`${run.runId}\``, + } } + return completionGithubApi(endpoint) }, }), - /not approved and merged by the configured owner/, + /owner-authored Ready transition/, ) - - const completionJournal = await writeFixtureFinalization({ - loopRoot, - runId: run.runId, - status: 'completed', - finishedAt: '2026-07-23T09:00:00.000Z', - mergeSha: '9'.repeat(40), - }) const finalized = await observeOwnerMerge({ loopRoot, runId: run.runId, now: new Date('2026-07-23T09:00:00Z'), - githubApi: completionJournal.githubApi, + githubApi: completionGithubApi, releaseIssueClaim: async () => {}, - finalizationResultPath: completionJournal.resultPath, - finalizationCommentUrl: completionJournal.commentUrl, - recordFinalization: (options) => - recordFinalizationPublication({ ...options, githubApi: completionJournal.githubApi }), - notifyOwner: (notification) => - createNotification({ - ...notification, - githubComment: async (_target, body) => { - assert.match(body, /\*\*pr_completed\*\*/) - return { html_url: `${prUrl}#issuecomment-9901` } - }, - }), + finalizationResultPath: preparedCompletion.resultPath, + finalizationCommentUrl, }) assert.equal(finalized.status, 'completed') assert.equal(finalized.mergeSha, '9'.repeat(40)) @@ -2140,7 +2250,7 @@ test('forged local owner events cannot bypass the remote completion gate', async released = true }, }), - /not approved and merged by the configured owner/, + /durable Ready and completion notifications|not approved and merged by the configured owner/, ) assert.equal(released, false) @@ -2234,6 +2344,10 @@ test('forged local blocked finalization cannot release an issue claim', async () mergeSha: null, failureFingerprint, notificationUrl: `${run.issueUrl}#issuecomment-8800`, + readyNotificationUrl: null, + readyNotifiedAt: null, + completionNotifiedAt: null, + notificationWebhookStatus: null, } await writeFile( path.join(runPath, 'run.json'), @@ -3222,6 +3336,10 @@ test('fresh worktrees rebuild finalization history and evolve metrics from GitHu mergeSha: null, failureFingerprint: 'persistent-browser-failure', notificationUrl: 'https://github.com/codeacme17/echo-ui/issues/205#issuecomment-8802', + readyNotificationUrl: null, + readyNotifiedAt: null, + completionNotifiedAt: null, + notificationWebhookStatus: null, } const digest = recordDigest(record) const foreignRecord = { @@ -3542,9 +3660,13 @@ test('repository activation verifies both configured GitHub profiles', async () mkdir(reviewerProfile), ]) await Promise.all([chmod(automationProfile, 0o700), chmod(reviewerProfile, 0o700)]) + const [canonicalAutomationProfile, canonicalReviewerProfile] = await Promise.all([ + realpath(automationProfile), + realpath(reviewerProfile), + ]) const environment = { - ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR: automationProfile, - ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR: reviewerProfile, + ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR: canonicalAutomationProfile, + ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR: canonicalReviewerProfile, ECHO_UI_LOOP_UNTRUSTED_ROOTS: JSON.stringify([ path.resolve(repositoryLoopRoot, '..', '..'), ]), @@ -3552,7 +3674,8 @@ test('repository activation verifies both configured GitHub profiles', async () const observedProfiles = [] const identityCommand = async (_command, _args, options) => { observedProfiles.push(options.env.GH_CONFIG_DIR) - const login = options.env.GH_CONFIG_DIR === automationProfile ? 'Ethandasw' : 'Traviinam' + const login = + options.env.GH_CONFIG_DIR === canonicalAutomationProfile ? 'Ethandasw' : 'Traviinam' return { stdout: `${login}\n` } } const result = await validateLoop({ @@ -3562,7 +3685,7 @@ test('repository activation verifies both configured GitHub profiles', async () identityCommand, }) assert.equal(result.valid, true) - assert.deepEqual(observedProfiles, [automationProfile, reviewerProfile]) + assert.deepEqual(observedProfiles, [canonicalAutomationProfile, canonicalReviewerProfile]) }) test('credential isolation rejects profiles inside untrusted roots or with broad permissions', async () => { @@ -3570,17 +3693,28 @@ test('credential isolation rejects profiles inside untrusted roots or with broad const untrustedRoot = path.join(parent, 'repository') const embeddedProfile = path.join(untrustedRoot, 'credentials') const externalProfile = path.join(parent, 'private-credentials') + const symlinkedProfile = path.join(parent, 'credential-link') await Promise.all([ mkdir(embeddedProfile, { recursive: true }), mkdir(externalProfile, { recursive: true }), ]) await Promise.all([chmod(embeddedProfile, 0o700), chmod(externalProfile, 0o755)]) + await symlink(externalProfile, symlinkedProfile) const channel = { untrustedRootsEnvironmentVariable: 'ECHO_UI_LOOP_UNTRUSTED_ROOTS', } const environment = { ECHO_UI_LOOP_UNTRUSTED_ROOTS: JSON.stringify([untrustedRoot]), } + await assert.rejects( + assertCredentialProfileIsolation({ + channel, + configDirectory: symlinkedProfile, + environment, + requiredUntrustedRoots: [untrustedRoot], + }), + /real directory, not a symlink/, + ) await assert.rejects( assertCredentialProfileIsolation({ channel, From 21d0f73518d82267fb4b21803550d5779b223fa6 Mon Sep 17 00:00:00 2001 From: leyoonafr <codeacme17@gmail.com> Date: Thu, 23 Jul 2026 23:07:16 +0800 Subject: [PATCH 34/41] fix: persist loop completion proofs --- loops/_shared/owner-channel/CHANNEL.md | 2 +- loops/issue-dev-loop/LOOP.md | 4 +- loops/issue-dev-loop/SKILL.md | 4 +- loops/issue-dev-loop/dependencies.md | 2 +- loops/issue-dev-loop/evolve/EVOLVE.md | 2 +- .../references/github-operations.md | 2 +- loops/issue-dev-loop/scripts/lib/evolve.mjs | 376 ++++++++++++++++-- .../scripts/lib/finalization-journal.mjs | 23 ++ .../scripts/lib/finalization-proof.mjs | 10 +- .../scripts/lib/github-identity.mjs | 39 +- loops/issue-dev-loop/scripts/lib/github.mjs | 4 +- .../issue-dev-loop/scripts/lib/owner-gate.mjs | 2 +- .../issue-dev-loop/scripts/lib/run-store.mjs | 12 + loops/issue-dev-loop/scripts/runtime.mjs | 2 + .../tests/github-identity-routing.test.mjs | 23 ++ loops/issue-dev-loop/tests/runtime.test.mjs | 271 ++++++++++++- 16 files changed, 730 insertions(+), 48 deletions(-) diff --git a/loops/_shared/owner-channel/CHANNEL.md b/loops/_shared/owner-channel/CHANNEL.md index be2741a5..c87f8867 100644 --- a/loops/_shared/owner-channel/CHANNEL.md +++ b/loops/_shared/owner-channel/CHANNEL.md @@ -8,7 +8,7 @@ GitHub issue and PR comments are the canonical, auditable channel. The runtime m `--dry-run` stages a simulated payload only. It never records `owner_notified`, never pauses the run, and never satisfies a blocking delivery gate. -`pr_completed` is informational and does not ask the owner for another decision, but successful canonical GitHub delivery is a hard prerequisite for completion. `prepare-finalization` posts it only after remotely observing the owner-authored Ready transition, exact-head owner approval, and owner merge; it waits for the bounded optional webhook attempt and binds both notification outcomes before any terminal journal publication. GitHub delivery failure leaves the run active and retryable. +`pr_completed` is informational and does not ask the owner for another decision, but successful canonical GitHub delivery is a hard prerequisite for completion. `prepare-finalization` posts this reserved marker only after remotely observing the strictly post-notification owner Ready transition, exact-head owner approval, and owner merge. Its body binds the merge SHA, its remote timestamp must be at or after the merge, and it must use a comment URL distinct from the Ready notification. The command waits for the bounded optional webhook attempt and binds both notification outcomes before any terminal journal publication. GitHub delivery failure leaves the run active and retryable. Each blocking GitHub notification prints a unique resume instruction. To continue after answering, include `RESUME <run-id>` in a normal issue/PR comment; submitting a GitHub `CHANGES_REQUESTED` review is also an explicit response. The runtime verifies author, target, timestamp, successful delivery, and response URL before resuming. Silence and unrelated comments never count. diff --git a/loops/issue-dev-loop/LOOP.md b/loops/issue-dev-loop/LOOP.md index 36144282..b0faa1f9 100644 --- a/loops/issue-dev-loop/LOOP.md +++ b/loops/issue-dev-loop/LOOP.md @@ -103,13 +103,13 @@ When the owner requests changes, verify the owner-authored GitHub comment or `CH ### 10. Complete -Only the remote owner-merge gate permits `completed`. Before a completion record exists, `prepare-finalization` remotely verifies the automation-authored Ready notification, an owner `ready_for_review` timeline event after that comment with no later redraft, an `APPROVED` review for the exact reviewed head SHA, and a merge performed by `codeacme17`. It then requires successful canonical `pr_completed` GitHub delivery and waits for the optional webhook attempt to settle within its bound. The finalization record binds the Ready and completion comment URLs/timestamps plus webhook outcome. Only after those prerequisites may the automation identity publish the record to the GitHub state journal. Publication, `observe-owner-merge`, terminal transition, and future reconciliation re-fetch that proof; a failed delivery or crash before publication leaves the active checkpoint resumable instead of suppressing it. Mutable local events are audit records, never authorization. Record merge SHA and timestamp, remove `loop:claimed`, update state, append the run summary, and retain links to published evidence. A closed unmerged PR is `cancelled`, not completed. +Only the remote owner-merge gate permits `completed`. Before a completion record exists, `prepare-finalization` remotely verifies the automation-authored Ready notification, a strictly later owner `ready_for_review` timeline event with no later redraft, an `APPROVED` review for the exact reviewed head SHA, and a merge performed by `codeacme17`. It then requires successful canonical `pr_completed` GitHub delivery whose body binds the merge SHA and whose remote timestamp is at or after the merge, and waits for the optional webhook attempt to settle within its bound. The identity router reserves `pr_completed` for the trusted finalization command. The finalization record binds distinct Ready and completion comment URLs/timestamps plus webhook outcome. Only after those prerequisites may the automation identity publish the record to the GitHub state journal. Publication, `observe-owner-merge`, terminal transition, and future reconciliation re-fetch that proof; a failed delivery or crash before publication leaves the active checkpoint resumable instead of suppressing it. Mutable local events are audit records, never authorization. Record merge SHA and timestamp, remove `loop:claimed`, update state, append the run summary, and retain links to published evidence. A closed unmerged PR is `cancelled`, not completed. ## State and history Keep `state.md` small and deliberate. It may be rewritten and contains only active runs, open PRs, blockers, follow-ups, current hypotheses, and learned constraints. -`logs/index.jsonl` is append-only and contains one compact summary per finalized run; `logs/triggers.jsonl` is the append-only record of cheap trigger decisions, including successful no-ops and resumes. The dedicated GitHub state-journal issue is the durable cross-worktree source for both active checkpoints and terminal records. After every durable run phase, publish the exact `prepare-checkpoint` body and validate it with `record-checkpoint`; the next phase re-fetches that automation-authored comment and refuses stale, absent, edited, or locally forged proof. Each compact checkpoint also embeds the digest-bound `$implement` result, evidence manifest, review result, and finalization record referenced by its event chain, when present, so later gates remain resumable. Terminal comments supersede active checkpoints only after finalization reconciliation validates them, including remote owner proof for completion. `loopctl reconcile` discovers resumable state and recomputes evolve metrics; it does not write run state into an arbitrary checkout. The orchestrator creates a clean worktree at the recorded exact branch/head, then `restore-checkpoint` verifies both before restoring run files and required small artifacts. A resumable run is returned as `workType: resume` before new issue selection. Commit sanitized `run.json`, pre-publication `events.jsonl`, summaries, and relevant screenshots to the issue branch so they are reviewable in its PR. The exact-head CI manifest and full proof travel in an Actions artifact. Keep raw local command output and large recordings in ignored `raw/` or `test-results/` directories. GitHub journal comments, PR reviews, workflow artifacts, and merge metadata are authoritative; local indexes and metrics are reconstructable caches. +`logs/index.jsonl` is append-only and contains compact finalized-run summaries plus reconciliation tombstones for rows that lack a durable counterpart; metrics ignore tombstoned rows. `logs/triggers.jsonl` is the append-only record of cheap trigger decisions, including successful no-ops and resumes. The dedicated GitHub state-journal issue is the durable cross-worktree source for active checkpoints, terminal records, and pending/completed evolve sessions. After every durable run phase, publish the exact `prepare-checkpoint` body and validate it with `record-checkpoint`; the next phase re-fetches that automation-authored comment and refuses stale, absent, edited, or locally forged proof. Each compact checkpoint also embeds the digest-bound `$implement` result, evidence manifest, review result, and finalization record referenced by its event chain, when present, so later gates remain resumable. Terminal comments supersede active checkpoints only after finalization reconciliation validates them, including remote owner proof for completion. `loopctl reconcile` discovers resumable state, excludes local terminal rows without verified remote records, rebuilds evolve request/completion state, and recomputes metrics from verified journal history; it does not write run state into an arbitrary checkout. The orchestrator creates a clean worktree at the recorded exact branch/head, then `restore-checkpoint` verifies both before restoring run files and required small artifacts. A resumable run is returned as `workType: resume` before new issue selection. Commit sanitized `run.json`, pre-publication `events.jsonl`, summaries, and relevant screenshots to the issue branch so they are reviewable in its PR. The exact-head CI manifest and full proof travel in an Actions artifact. Keep raw local command output and large recordings in ignored `raw/` or `test-results/` directories. GitHub journal comments, PR reviews, workflow artifacts, and merge metadata are authoritative; local indexes and metrics are reconstructable caches. Never log secrets, full environment dumps, cookies, auth headers, private user data, or raw prompts containing sensitive information. diff --git a/loops/issue-dev-loop/SKILL.md b/loops/issue-dev-loop/SKILL.md index 1541dea6..2d83b4e6 100644 --- a/loops/issue-dev-loop/SKILL.md +++ b/loops/issue-dev-loop/SKILL.md @@ -12,7 +12,7 @@ Run exactly one bounded issue cycle. Treat [`LOOP.md`](./LOOP.md) as the constit 1. Read `LOOP.md`, `state.md`, and `dependencies.md` completely. 2. Require absolute `ECHO_UI_LOOP_CONTROL_PLANE` and `ECHO_UI_LOOP_TARGET_ROOT` values from the scheduler. Run activation through `"$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" --loop-root "$ECHO_UI_LOOP_TARGET_ROOT" automation -- node "$ECHO_UI_LOOP_CONTROL_PLANE/scripts/loopctl.mjs" validate --activation --loop-root "$ECHO_UI_LOOP_TARGET_ROOT"`. The installed launcher verifies its hash manifest and probes both configured profiles before starting validation. 3. Read [`references/github-operations.md`](./references/github-operations.md). Run every operational `loopctl`, executor GitHub command, remote Git command, trigger, and reviewer publication through the installed control plane with the explicit target root. Never use the credential-refusing repository launcher, invoke the `.mjs` router directly, install control code from an issue branch, or alter global `gh` or Git credential configuration. -4. Run `loopctl.mjs reconcile` through the automation wrapper to rebuild terminal history and discover active runs from the append-only GitHub state journal. For returned `workType: resume`, fetch the recorded branch through the wrapper, create a clean isolated worktree at the returned exact head, and run `restore-checkpoint --run-id <id>` inside that worktree. The restore command rejects the wrong branch, a dirty checkout, or any head other than the durable head. Resume it before selecting a new issue. +4. Run `loopctl.mjs reconcile` through the automation wrapper to rebuild verified terminal history, pending/completed evolve state, and active runs from the append-only GitHub state journal. It tombstones local terminal-cache rows with no durable counterpart before recomputing metrics. For returned `workType: resume`, fetch the recorded branch through the wrapper, create a clean isolated worktree at the returned exact head, and run `restore-checkpoint --run-id <id>` inside that worktree. The restore command rejects the wrong branch, a dirty checkout, or any head other than the durable head. Resume it before selecting a new issue. 5. Run `loopctl.mjs evolve-status`. If `evolveDue` is true, start `echo_ui_loop_evolver` with fresh context; do not silently replace it with product work. 6. Run the installed `triggers/detect-work.mjs` through the automation wrapper with `--loop-root "$ECHO_UI_LOOP_TARGET_ROOT"`. The detector paginates every open issue and PR page. Exit without invoking an implementation agent when it reports `hasWork: false`. 7. Refuse to start when another active run or PR already claims the issue. @@ -61,7 +61,7 @@ Run verification appropriate to the change and require `pnpm verify` before the Before publishing the final review round, write the complete cycle result with an unassigned final `reviewUrl`, then run `loopctl.mjs review-digest --result <absolute-path>`. Add the returned marker to the final review body, publish the non-approving review, replace the unassigned URL with GitHub's actual review URL, and confirm `review-digest` is unchanged. After all responses are posted, run `loopctl.mjs record-review --run-id <id> --result <absolute-path> --review-url <github-review-url>`. The publication digest deliberately canonicalizes GitHub-assigned review URLs while the stored full-file digest still protects the final artifact. Both evidence and review gates must name the current PR head. Keep the PR Draft, emit a blocking `pr_ready_for_review` notification asking `codeacme17` to mark it Ready and review it, then transition from `waiting_for_owner` to `awaiting_owner_review` with the PR URL and exact head SHA. -The owner is the only actor allowed to mark a Draft PR Ready, approve it, or merge it. Never call non-`--undo` `gh pr ready`, `gh pr merge`, enable auto-merge, push to `main`, push to `dev`, dismiss owner feedback, or bypass branch protections. Before any terminal transition, run `prepare-finalization`. For completion, that command paginates the PR timeline and reviews, requires a Ready transition authored by `codeacme17` after the remotely verified Ready-notification comment with no later redraft, requires the owner's exact-head approval and merge, delivers the informational `pr_completed` GitHub notification, waits for the bounded webhook attempt to settle, and binds both notification URLs/timestamps into the record. If canonical GitHub delivery fails, no terminal record is created. Only then publish the exact body to the configured state-journal issue and pass its result/comment URL to `observe-owner-merge`; that command revalidates the remote record, appends the local notification/owner audit events, and finalizes. Future workspaces run `reconcile` to revalidate the same notification, Ready, approval, and merge proof before rebuilding local history or suppressing an active checkpoint. +The owner is the only actor allowed to mark a Draft PR Ready, approve it, or merge it. Never call non-`--undo` `gh pr ready`, `gh pr merge`, enable auto-merge, push to `main`, push to `dev`, dismiss owner feedback, or bypass branch protections. Before any terminal transition, run `prepare-finalization`. For completion, that command paginates the PR timeline and reviews, requires a Ready transition authored by `codeacme17` strictly after the remotely verified Ready-notification comment with no later redraft, requires the owner's exact-head approval and merge, delivers the informational `pr_completed` GitHub notification with the merge SHA after the remote merge timestamp, waits for the bounded webhook attempt to settle, and binds distinct notification URLs/timestamps into the record. Raw executor comments cannot use the reserved `pr_completed` marker. If canonical GitHub delivery fails, no terminal record is created. Only then publish the exact body to the configured state-journal issue and pass its result/comment URL to `observe-owner-merge`; that command revalidates the remote record, appends the local notification/owner audit events, and finalizes. Future workspaces run `reconcile` to revalidate the same notification, Ready, approval, and merge proof before rebuilding local history or suppressing an active checkpoint. For any pause, do not resume from silence or an arbitrary comment. First require a successfully delivered blocking notification. Then verify the owner's GitHub decision with `loopctl.mjs record-owner-response --run-id <id> --response-url <comment-or-review-url>`. A normal comment must include the exact `RESUME <run-id>` token printed in the notification; a `CHANGES_REQUESTED` review is itself an explicit decision and must be submitted against the run's current exact head SHA. Only then may `loopctl.mjs transition --run-id <id> --status running` continue. Before any repair work or new push, publish that transition's checkpoint, run the unchanged exact PR through `gh pr ready --undo --repo codeacme17/echo-ui`, observe the same head as Draft with `record-pr`, and publish another checkpoint. `$implement` repair attestations and later PR rebinds are rejected unless this durable redraft happened after the current owner response. diff --git a/loops/issue-dev-loop/dependencies.md b/loops/issue-dev-loop/dependencies.md index 67c8c174..506a71da 100644 --- a/loops/issue-dev-loop/dependencies.md +++ b/loops/issue-dev-loop/dependencies.md @@ -41,6 +41,6 @@ The installer refuses a dirty checkout, a branch other than `dev`, a commit othe The trust root is the scheduler/OS boundary, not Unix mode bits or a self-hashed manifest. Put the installed bundle outside every filesystem root writable by the unattended Codex process, and expose it read/execute-only through the scheduler sandbox or separate ownership/ACLs. The automation identity must not have permission to change the bundle, its parent, the pinned executables, file modes, ACLs, or the sandbox policy. A same-OS-principal process allowed to rewrite both code and its manifest cannot establish a cryptographic trust root in user space; do not activate the loop under that permission model. -Credential profiles are a separate secret boundary. Put both profile directories outside every root listed by `ECHO_UI_LOOP_UNTRUSTED_ROOTS`, set each directory to mode `0700` and every entry below it to deny group/other access, and expose them only to the trusted orchestration/identity-router process. The activation check rejects the originally configured path when it is a symlink, then canonicalizes the real directory, checks descendants, permissions, ownership, repository coverage, and overlap with agent-visible roots, and routes `GH_CONFIG_DIR` through that validated canonical path. `$implement`, fresh reviewers, candidate scripts, local product tests, and verifier containers must receive neither profile-path variables nor `GH_CONFIG_DIR`, and their sandbox must not be able to read the profile directories. If the scheduler cannot enforce that read boundary, do not activate the loop. +Credential profiles are a separate secret boundary. Put both profile directories outside every root listed by `ECHO_UI_LOOP_UNTRUSTED_ROOTS`, set each directory to mode `0700` and every entry below it to deny group/other access, and expose them only to the trusted orchestration/identity-router process. The activation check first lexically resolves trailing separators and `/.`, rejects that originally named top-level entry when it is a symlink, then canonicalizes the real directory, checks descendants, permissions, ownership, repository coverage, and overlap with agent-visible roots, and routes `GH_CONFIG_DIR` through that validated canonical path. `$implement`, fresh reviewers, candidate scripts, local product tests, and verifier containers must receive neither profile-path variables nor `GH_CONFIG_DIR`, and their sandbox must not be able to read the profile directories. If the scheduler cannot enforce that read boundary, do not activate the loop. Never run `gh auth setup-git` for this loop. Route commands through `"$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" --loop-root "$ECHO_UI_LOOP_TARGET_ROOT" <role> -- ...`. The repository copy is installer source and intentionally refuses credential use. The installed launcher verifies its manifest before selecting an identity, removes Node preload hooks, scopes `GH_CONFIG_DIR` and the Git credential helper to one allowlisted child tree, gates descendant `git`/`gh` calls, and leaves the user's default `gh` account and global Git credential configuration unchanged. diff --git a/loops/issue-dev-loop/evolve/EVOLVE.md b/loops/issue-dev-loop/evolve/EVOLVE.md index 160ff719..5f1ba4a8 100644 --- a/loops/issue-dev-loop/evolve/EVOLVE.md +++ b/loops/issue-dev-loop/evolve/EVOLVE.md @@ -18,4 +18,4 @@ Every evolve change requires a dedicated `codex/evolve-<request-id>` draft PR ta Never optimize by weakening evidence, increasing autonomous authority, deleting unfavorable history, or hiding no-work and failure runs. -After the owner merges the evolve PR, run `loopctl.mjs evolve-complete --request-id <id> --summary <summary> --pr-url <url>`. A pending request is not cleared by silence or an unmerged PR. +After the owner merges the evolve PR, run `loopctl.mjs evolve-complete --request-id <id> --summary <summary> --pr-url <url>` through the installed automation wrapper. The trusted command re-fetches the digest-bound request, owner Ready/approval/merge proof, posts a digest-bound `evolve-completion` record to the state journal, and only then clears the pending request. The identity router reserves that marker for this command. Every scheduled `reconcile` rebuilds pending and completed evolve requests, `lastEvolvedRunCount`, and completion counters from those automation-authored journal records before deciding whether another evolve session is due. A local file, silence, an unmerged PR, or a completion comment created before the recorded merge never clears a request. diff --git a/loops/issue-dev-loop/references/github-operations.md b/loops/issue-dev-loop/references/github-operations.md index f1fae1e5..8f463e9a 100644 --- a/loops/issue-dev-loop/references/github-operations.md +++ b/loops/issue-dev-loop/references/github-operations.md @@ -77,4 +77,4 @@ After a blocking notification, verify the reply URL with `record-owner-response` For active work, run `loopctl prepare-checkpoint --run-id <id>`, post its exact `body` to the configured `stateIssueNumber` using the automation identity, then validate it with `loopctl record-checkpoint --run-id <id> --result <path> --comment-url <url>`. A checkpoint is SHA-256 bound to the active run, frozen brief, ordered validated events, and the small local result/manifest artifacts required by later gates. Restore verifies every embedded artifact digest before recreating it. Publish one after every state-changing phase; later checkpoints supersede earlier ones. -Before `completed`, `failed`, `blocked`, or `cancelled`, run `loopctl prepare-finalization` with the terminal status and any merge SHA/failure fingerprint. Failed/blocked records bind the delivered automation-authored owner-notification URL; cancellation requires the recorded PR to be closed without merge. Completion preparation first re-fetches the Ready-notification comment, requires the owner's later Ready event, exact-head approval, and merge, delivers `pr_completed`, and waits for the bounded webhook attempt. It refuses to create a terminal record if canonical GitHub delivery fails. The record binds both notification URLs/timestamps and the webhook outcome so reconciliation cannot silently skip completion delivery. Post the resulting exact `body` to the configured `stateIssueNumber` using the automation identity, then pass that result and comment URL to `observe-owner-merge`; it revalidates the journal entry before recording local audit events and finalizing. Terminal transitions reject missing, edited, wrong-author, wrong-issue, digest-mismatched, or externally unproven journal entries. Every scheduled wake begins with `loopctl reconcile`, which restores missing local history and recomputes evolve metrics from the append-only journal. +Before `completed`, `failed`, `blocked`, or `cancelled`, run `loopctl prepare-finalization` with the terminal status and any merge SHA/failure fingerprint. Failed/blocked records bind the delivered automation-authored owner-notification URL; cancellation requires the recorded PR to be closed without merge. Completion preparation first re-fetches the Ready-notification comment, requires the owner's strictly later Ready event, exact-head approval, and merge, delivers the reserved `pr_completed` notification with the merge SHA at or after the remote merge time, and waits for the bounded webhook attempt. It refuses to create a terminal record if canonical GitHub delivery fails. The record binds distinct notification URLs/timestamps and the webhook outcome so reconciliation cannot silently skip completion delivery. Post the resulting exact `body` to the configured `stateIssueNumber` using the automation identity, then pass that result and comment URL to `observe-owner-merge`; it revalidates the journal entry before recording local audit events and finalizing. Terminal transitions reject missing, edited, wrong-author, wrong-issue, digest-mismatched, or externally unproven journal entries. Every scheduled wake begins with `loopctl reconcile`, which restores verified local history, appends tombstones for unverified local terminal rows, rebuilds pending/completed evolve state, and recomputes metrics from the append-only journal. diff --git a/loops/issue-dev-loop/scripts/lib/evolve.mjs b/loops/issue-dev-loop/scripts/lib/evolve.mjs index 33776f85..4ca597db 100644 --- a/loops/issue-dev-loop/scripts/lib/evolve.mjs +++ b/loops/issue-dev-loop/scripts/lib/evolve.mjs @@ -4,9 +4,12 @@ import path from 'node:path' import { DEFAULT_LOOP_ROOT, + assertAutomationIdentity, assertHttpUrl, assertNonEmpty, defaultGitHubApi, + defaultGitHubPaginatedApi, + execFileAsync, parsePullCommentUrl, readJson, sameGitHubLogin, @@ -42,6 +45,92 @@ function pendingRequestDigest(request) { return createHash('sha256').update(canonicalPendingRequest(request)).digest('hex') } +function canonicalCompletedEvolve(record) { + const normalized = { + schemaVersion: record.schemaVersion, + requestId: record.requestId, + status: record.status, + reason: record.reason, + requestedAt: record.requestedAt, + finalizedRunCount: record.finalizedRunCount, + requestPublicationUrl: record.requestPublicationUrl, + requestPublicationDigest: record.requestPublicationDigest, + summary: record.summary, + prUrl: record.prUrl, + headSha: record.headSha, + mergeSha: record.mergeSha, + mergeAt: record.mergeAt, + } + if ( + normalized.schemaVersion !== 1 || + normalized.status !== 'completed' || + !/^[A-Z0-9-]+$/.test(normalized.requestId ?? '') || + !assertNonEmpty(normalized.reason, 'evolve.reason') || + Number.isNaN(Date.parse(normalized.requestedAt)) || + !Number.isInteger(normalized.finalizedRunCount) || + normalized.finalizedRunCount < 1 || + !assertHttpUrl(normalized.requestPublicationUrl, 'evolve.requestPublicationUrl') || + !/^[0-9a-f]{64}$/.test(normalized.requestPublicationDigest ?? '') || + !assertNonEmpty(normalized.summary, 'evolve.summary') || + !assertHttpUrl(normalized.prUrl, 'evolve.prUrl') || + !/^[0-9a-f]{40}$/i.test(normalized.headSha ?? '') || + !/^[0-9a-f]{40}$/i.test(normalized.mergeSha ?? '') || + Number.isNaN(Date.parse(normalized.mergeAt)) + ) { + throw new Error('invalid completed evolve record') + } + return JSON.stringify(normalized) +} + +function completedEvolveDigest(record) { + return createHash('sha256').update(canonicalCompletedEvolve(record)).digest('hex') +} + +function stateJournalTarget(channel) { + const [owner, repo] = channel.repository.split('/') + return { owner, repo, number: channel.stateIssueNumber } +} + +function isStateJournalComment(url, channel) { + const target = parsePullCommentUrl(url) + const journal = stateJournalTarget(channel) + return ( + target?.kind === 'issue_comment' && + target.surface === 'issues' && + target.number === journal.number && + sameRepository(target, journal) + ) +} + +async function defaultGitHubComment(target, body) { + const result = await execFileAsync( + 'gh', + [ + 'api', + `repos/${target.owner}/${target.repo}/issues/${target.number}/comments`, + '--method', + 'POST', + '-f', + `body=${body}`, + ], + { maxBuffer: 1024 * 1024 }, + ) + return JSON.parse(result.stdout) +} + +async function verifyPendingRequestComment({ request, comment, channel }) { + const digest = pendingRequestDigest(request) + const marker = `<!-- issue-dev-loop:evolve-request:${request.requestId}:sha256:${digest} -->` + if ( + !sameGitHubLogin(comment.user?.login, channel.automationGitHubLogin) || + !comment.body?.includes(marker) || + !comment.body?.includes(canonicalPendingRequest(request)) + ) { + throw new Error('evolve request lacks an exact automation-authored durable publication') + } + return digest +} + export async function prepareEvolveRequestPublication({ loopRoot = DEFAULT_LOOP_ROOT, requestId, @@ -90,27 +179,15 @@ export async function verifyPublishedEvolveRequest({ const channel = await readJson( path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'), ) - const repositoryTarget = { - owner: channel.repository.split('/')[0], - repo: channel.repository.split('/')[1], - } - if ( - !target || - target.kind !== 'issue_comment' || - target.number !== channel.stateIssueNumber || - !sameRepository(target, repositoryTarget) - ) { + if (!isStateJournalComment(publicationUrl, channel)) { throw new Error('evolve request publication must be on the configured state journal') } const comment = await githubApi( `repos/${target.owner}/${target.repo}/issues/comments/${target.commentId}`, ) const digest = pendingRequestDigest(request) - const marker = `<!-- issue-dev-loop:evolve-request:${normalizedRequestId}:sha256:${digest} -->` if ( - !sameGitHubLogin(comment.user?.login, channel.automationGitHubLogin) || - !comment.body?.includes(marker) || - !comment.body?.includes(canonicalPendingRequest(request)) || + (await verifyPendingRequestComment({ request, comment, channel })) !== digest || request.publicationDigest !== digest ) { throw new Error('evolve request lacks an exact automation-authored durable publication') @@ -153,11 +230,16 @@ export async function recordEvolveRequestPublication({ export async function updateEvolveMetrics({ loopRoot, now }) { const metricsPath = path.join(loopRoot, 'evolve', 'metrics.json') const metrics = await readJson(metricsPath) - const history = (await readFile(path.join(loopRoot, 'logs', 'index.jsonl'), 'utf8')) + const indexEntries = (await readFile(path.join(loopRoot, 'logs', 'index.jsonl'), 'utf8')) .split('\n') .filter(Boolean) .map((line) => JSON.parse(line)) - .filter((entry) => entry.event === 'run_finalized') + const finalizations = new Map() + for (const entry of indexEntries) { + if (entry.event === 'run_finalized') finalizations.set(entry.runId, entry) + if (entry.event === 'run_finalization_unverified') finalizations.delete(entry.runId) + } + const history = [...finalizations.values()] .sort((left, right) => Date.parse(left.finishedAt) - Date.parse(right.finishedAt)) metrics.finalizedRuns = history.length metrics.successfulRuns = history.filter((entry) => entry.status === 'completed').length @@ -171,12 +253,18 @@ export async function updateEvolveMetrics({ loopRoot, now }) { let dueReason = null if (metrics.finalizedRuns - (metrics.lastEvolvedRunCount ?? 0) >= 10) { dueReason = 'ten_finalized_runs' - } else if ( - metrics.recentFailureFingerprints.length === 3 && - metrics.recentFailureFingerprints[0] !== null && - new Set(metrics.recentFailureFingerprints).size === 1 - ) { - dueReason = 'repeated_failure_pattern' + } else { + const sinceLastEvolve = history.slice(metrics.lastEvolvedRunCount ?? 0) + const recentSinceLastEvolve = sinceLastEvolve + .slice(-3) + .map((entry) => entry.failureFingerprint || null) + if ( + recentSinceLastEvolve.length === 3 && + recentSinceLastEvolve[0] !== null && + new Set(recentSinceLastEvolve).size === 1 + ) { + dueReason = 'repeated_failure_pattern' + } } if (dueReason && !metrics.evolveDue) { @@ -209,6 +297,8 @@ export async function completeEvolve({ prUrl, now = new Date(), githubApi = defaultGitHubApi, + githubComment = defaultGitHubComment, + verifyAutomationIdentity = assertAutomationIdentity, } = {}) { const normalizedRequestId = assertNonEmpty(requestId, 'requestId') const metricsPath = path.join(loopRoot, 'evolve', 'metrics.json') @@ -218,6 +308,11 @@ export async function completeEvolve({ } const requestPath = path.join(loopRoot, 'evolve', 'requests', `${normalizedRequestId}.json`) const request = await readJson(requestPath) + const publishedRequest = await verifyPublishedEvolveRequest({ + loopRoot, + requestId: normalizedRequestId, + githubApi, + }) const publishedPrUrl = assertHttpUrl(prUrl, 'prUrl') const merge = await observeOwnerApprovedMerge({ loopRoot, @@ -230,22 +325,247 @@ export async function completeEvolve({ createdAfter: request.requestedAt, githubApi, }) - - const completed = { - ...request, + if (Number.isNaN(Date.parse(merge.mergeAt))) { + throw new Error('owner-merged evolve PR must expose a durable merge timestamp') + } + const record = { + schemaVersion: 1, + requestId: normalizedRequestId, status: 'completed', - completedAt: now.toISOString(), + reason: request.reason, + requestedAt: request.requestedAt, + finalizedRunCount: request.finalizedRunCount, + requestPublicationUrl: publishedRequest.publicationUrl, + requestPublicationDigest: publishedRequest.digest, summary: assertNonEmpty(summary, 'summary'), prUrl: publishedPrUrl, headSha: merge.headSha, mergeSha: merge.mergeSha, + mergeAt: merge.mergeAt, + } + const digest = completedEvolveDigest(record) + const body = [ + `<!-- issue-dev-loop:evolve-completion:${normalizedRequestId}:sha256:${digest} -->`, + '```json', + canonicalCompletedEvolve(record), + '```', + ].join('\n') + const channel = await readJson( + path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'), + ) + if (githubComment === defaultGitHubComment) await verifyAutomationIdentity({ loopRoot }) + const comment = await githubComment(stateJournalTarget(channel), body) + const commentUrl = assertHttpUrl(comment?.html_url, 'evolve.completionPublicationUrl') + const verified = await verifyPublishedEvolveCompletion({ + loopRoot, + record, + commentUrl, + githubApi, + }) + const completed = { + ...request, + status: 'completed', + completedAt: record.mergeAt, + journaledAt: verified.journaledAt, + summary: record.summary, + prUrl: record.prUrl, + headSha: record.headSha, + mergeSha: record.mergeSha, + mergeAt: record.mergeAt, + completionPublicationUrl: commentUrl, + completionPublicationDigest: digest, } await writeJson(requestPath, completed) metrics.evolveDue = false metrics.pendingRequestId = null - metrics.lastEvolvedAt = now.toISOString() - metrics.lastEvolvedRunCount = metrics.finalizedRuns + metrics.lastEvolvedAt = record.mergeAt + metrics.lastEvolvedRunCount = record.finalizedRunCount metrics.completedEvolveSessions = (metrics.completedEvolveSessions ?? 0) + 1 await writeJson(metricsPath, metrics) + await updateEvolveMetrics({ loopRoot, now }) return completed } + +export async function verifyPublishedEvolveCompletion({ + loopRoot = DEFAULT_LOOP_ROOT, + record, + commentUrl, + githubApi = defaultGitHubApi, +} = {}) { + const serialized = canonicalCompletedEvolve(record) + const digest = completedEvolveDigest(record) + const channel = await readJson( + path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'), + ) + if (!isStateJournalComment(commentUrl, channel)) { + throw new Error('evolve completion publication must be on the configured state journal') + } + if (!isStateJournalComment(record.requestPublicationUrl, channel)) { + throw new Error('evolve completion is not bound to its durable request') + } + const requestTarget = parsePullCommentUrl(record.requestPublicationUrl) + const requestComment = await githubApi( + `repos/${requestTarget.owner}/${requestTarget.repo}/issues/comments/${requestTarget.commentId}`, + ) + const pendingRequest = { + schemaVersion: 1, + requestId: record.requestId, + status: 'pending', + reason: record.reason, + requestedAt: record.requestedAt, + finalizedRunCount: record.finalizedRunCount, + } + if ( + (await verifyPendingRequestComment({ + request: pendingRequest, + comment: requestComment, + channel, + })) !== record.requestPublicationDigest + ) { + throw new Error('evolve completion is not bound to its durable request') + } + const merge = await observeOwnerApprovedMerge({ + loopRoot, + prUrl: record.prUrl, + expectedHeadSha: record.headSha, + expectedHeadBranch: `codex/evolve-${record.requestId}`, + expectedRepository: channel.repository, + requiredBodyMarker: `<!-- issue-dev-loop:evolve-request:${record.requestId} -->`, + createdAfter: record.requestedAt, + githubApi, + }) + if (merge.mergeSha !== record.mergeSha || merge.mergeAt !== record.mergeAt) { + throw new Error('evolve completion does not match the remote owner merge') + } + const target = parsePullCommentUrl(commentUrl) + const comment = await githubApi( + `repos/${target.owner}/${target.repo}/issues/comments/${target.commentId}`, + ) + const marker = `<!-- issue-dev-loop:evolve-completion:${record.requestId}:sha256:${digest} -->` + if ( + !sameGitHubLogin(comment.user?.login, channel.automationGitHubLogin) || + !comment.body?.includes(marker) || + !comment.body?.includes(serialized) || + Number.isNaN(Date.parse(comment.created_at)) || + Date.parse(comment.created_at) < Date.parse(record.mergeAt) + ) { + throw new Error('evolve completion lacks post-merge automation-authored durable proof') + } + return { record, digest, commentUrl, journaledAt: comment.created_at } +} + +export async function reconcileEvolveJournal({ + loopRoot = DEFAULT_LOOP_ROOT, + githubPaginatedApi = defaultGitHubPaginatedApi, + githubApi = defaultGitHubApi, +} = {}) { + const channel = await readJson( + path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'), + ) + const journal = stateJournalTarget(channel) + const comments = await githubPaginatedApi( + `repos/${journal.owner}/${journal.repo}/issues/${journal.number}/comments?per_page=100`, + ) + const requests = new Map() + for (const comment of comments) { + if (!sameGitHubLogin(comment.user?.login, channel.automationGitHubLogin)) continue + const marker = comment.body?.match( + /<!-- issue-dev-loop:evolve-request:([^:]+):sha256:([0-9a-f]{64}) -->/, + ) + const serialized = comment.body?.match(/```json\s*([^\n]+)\s*```/)?.[1] + if (!marker || !serialized) continue + const request = JSON.parse(serialized) + const digest = pendingRequestDigest(request) + if ( + request.requestId !== marker[1] || + digest !== marker[2] || + (await verifyPendingRequestComment({ request, comment, channel })) !== digest + ) { + throw new Error(`invalid durable evolve request: ${marker[1]}`) + } + if (requests.has(request.requestId)) { + throw new Error(`duplicate durable evolve request: ${request.requestId}`) + } + requests.set(request.requestId, { + ...request, + publicationUrl: comment.html_url, + publicationDigest: digest, + }) + } + + const completions = new Map() + for (const comment of comments) { + if (!sameGitHubLogin(comment.user?.login, channel.automationGitHubLogin)) continue + const marker = comment.body?.match( + /<!-- issue-dev-loop:evolve-completion:([^:]+):sha256:([0-9a-f]{64}) -->/, + ) + const serialized = comment.body?.match(/```json\s*([^\n]+)\s*```/)?.[1] + if (!marker || !serialized) continue + const record = JSON.parse(serialized) + const request = requests.get(marker[1]) + if ( + !request || + record.requestId !== marker[1] || + completedEvolveDigest(record) !== marker[2] || + record.requestPublicationUrl !== request.publicationUrl || + record.requestPublicationDigest !== request.publicationDigest + ) { + throw new Error(`invalid durable evolve completion: ${marker[1]}`) + } + if (completions.has(record.requestId)) { + throw new Error(`duplicate durable evolve completion: ${record.requestId}`) + } + const verified = await verifyPublishedEvolveCompletion({ + loopRoot, + record, + commentUrl: comment.html_url, + githubApi, + }) + completions.set(record.requestId, verified) + } + + const pending = [...requests.values()].filter( + (request) => !completions.has(request.requestId), + ) + if (pending.length > 1) { + throw new Error('multiple durable pending evolve requests') + } + for (const request of requests.values()) { + const verified = completions.get(request.requestId) + await writeJson( + path.join(loopRoot, 'evolve', 'requests', `${request.requestId}.json`), + verified + ? { + ...request, + status: 'completed', + completedAt: verified.record.mergeAt, + journaledAt: verified.journaledAt, + summary: verified.record.summary, + prUrl: verified.record.prUrl, + headSha: verified.record.headSha, + mergeSha: verified.record.mergeSha, + mergeAt: verified.record.mergeAt, + completionPublicationUrl: verified.commentUrl, + completionPublicationDigest: verified.digest, + } + : request, + ) + } + const metricsPath = path.join(loopRoot, 'evolve', 'metrics.json') + const metrics = await readJson(metricsPath) + const orderedCompletions = [...completions.values()].sort( + (left, right) => Date.parse(left.record.mergeAt) - Date.parse(right.record.mergeAt), + ) + const latest = orderedCompletions.at(-1) + metrics.evolveDue = pending.length === 1 + metrics.pendingRequestId = pending[0]?.requestId ?? null + metrics.lastEvolvedAt = latest?.record.mergeAt ?? null + metrics.lastEvolvedRunCount = latest?.record.finalizedRunCount ?? 0 + metrics.completedEvolveSessions = orderedCompletions.length + await writeJson(metricsPath, metrics) + return { + durableEvolveRequestIds: [...requests.keys()], + durableCompletedEvolveRequestIds: [...completions.keys()], + pendingEvolveRequestId: pending[0]?.requestId ?? null, + } +} diff --git a/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs b/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs index 71bcaa58..fab87719 100644 --- a/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs +++ b/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs @@ -306,17 +306,40 @@ export async function reconcileFinalizationJournal({ .filter((entry) => entry.event === 'run_finalized') .map((entry) => [entry.runId, entry]), ) + const latestLocalState = new Map() + for (const entry of existing) { + if (['run_finalized', 'run_finalization_unverified'].includes(entry.event)) { + latestLocalState.set(entry.runId, entry.event) + } + } for (const record of records) { const prior = byRunId.get(record.runId) if (prior) { if (canonicalRecord(prior) !== canonicalRecord(record)) { throw new Error(`local finalization conflicts with durable journal: ${record.runId}`) } + if (latestLocalState.get(record.runId) === 'run_finalization_unverified') { + await appendJsonLine(indexPath, { event: 'run_finalized', ...record }) + } continue } await appendJsonLine(indexPath, { event: 'run_finalized', ...record }) byRunId.set(record.runId, record) } + const durableRunIds = new Set(records.map((record) => record.runId)) + for (const runId of byRunId.keys()) { + if ( + !durableRunIds.has(runId) && + latestLocalState.get(runId) !== 'run_finalization_unverified' + ) { + await appendJsonLine(indexPath, { + schemaVersion: 1, + event: 'run_finalization_unverified', + runId, + timestamp: now.toISOString(), + }) + } + } await updateEvolveMetrics({ loopRoot, now }) return { reconciled: records.length, durableRunIds: records.map((record) => record.runId) } } diff --git a/loops/issue-dev-loop/scripts/lib/finalization-proof.mjs b/loops/issue-dev-loop/scripts/lib/finalization-proof.mjs index 03501933..b79d937c 100644 --- a/loops/issue-dev-loop/scripts/lib/finalization-proof.mjs +++ b/loops/issue-dev-loop/scripts/lib/finalization-proof.mjs @@ -65,6 +65,7 @@ export function validateFinalizationRecord(record, run = null) { record.failureFingerprint !== null || !record.notificationUrl || !record.readyNotificationUrl || + record.notificationUrl === record.readyNotificationUrl || Number.isNaN(Date.parse(record.readyNotifiedAt)) || Number.isNaN(Date.parse(record.completionNotifiedAt)) || Date.parse(record.readyNotifiedAt) > Date.parse(record.completionNotifiedAt) || @@ -135,6 +136,7 @@ export async function verifyPullNotificationComment({ prUrl, channel, githubApi, + requiredBodyFragments = [], }) { const target = parsePullCommentUrl(url) const pullTarget = parseGitHubTarget(prUrl) @@ -156,6 +158,7 @@ export async function verifyPullNotificationComment({ !notificationType || !sameGitHubLogin(comment.user?.login, channel.automationGitHubLogin) || !comment.body?.includes(`Run: \`${runId}\``) || + requiredBodyFragments.some((fragment) => !comment.body?.includes(fragment)) || Number.isNaN(Date.parse(comment.created_at)) ) { throw new Error('completion proof notification is not durable automation-authored evidence') @@ -202,8 +205,13 @@ export async function verifyTerminalExternalProof({ prUrl: validated.prUrl, channel, githubApi, + requiredBodyFragments: [validated.mergeSha], }) - if (completionNotification.comment.created_at !== validated.completionNotifiedAt) { + if ( + completionNotification.comment.created_at !== validated.completionNotifiedAt || + Number.isNaN(Date.parse(merge.mergeAt)) || + Date.parse(validated.completionNotifiedAt) < Date.parse(merge.mergeAt) + ) { throw new Error('completed finalization completion-notification timestamp changed') } } diff --git a/loops/issue-dev-loop/scripts/lib/github-identity.mjs b/loops/issue-dev-loop/scripts/lib/github-identity.mjs index 04429384..c0cb03b9 100644 --- a/loops/issue-dev-loop/scripts/lib/github-identity.mjs +++ b/loops/issue-dev-loop/scripts/lib/github-identity.mjs @@ -187,12 +187,13 @@ export async function assertCredentialProfileIsolation({ ) { throw new Error(`${variableName} must contain a non-empty JSON array of absolute paths`) } - const configuredProfileStats = await lstat(configDirectory) + const lexicalConfigDirectory = path.resolve(configDirectory) + const configuredProfileStats = await lstat(lexicalConfigDirectory) if (configuredProfileStats.isSymbolicLink() || !configuredProfileStats.isDirectory()) { throw new Error('GitHub credential profile path must be a real directory, not a symlink') } const [canonicalConfigDirectory, canonicalRoots, canonicalRequiredRoots] = await Promise.all([ - realpath(configDirectory), + realpath(lexicalConfigDirectory), Promise.all(configuredRoots.map((root) => realpath(root))), Promise.all(requiredUntrustedRoots.map((root) => realpath(root))), ]) @@ -723,7 +724,8 @@ function pullRequestMutationAllowed(kind, args, commandIndex, authorization, exp ) } if (kind === 'comment') { - return Boolean(exactlyOne(parsed.values, 'body')) + const body = exactlyOne(parsed.values, 'body') + return Boolean(body) && !reservedAutomationComment(body) } const reviewers = (parsed.values.get('addReviewer') ?? []).flatMap((value) => value.split(',').map((login) => login.trim()), @@ -735,6 +737,21 @@ function pullRequestMutationAllowed(kind, args, commandIndex, authorization, exp return editedFields.length > 0 } +function reservedAutomationComment(body) { + return ( + body.includes('**pr_completed**') || + body.includes('<!-- issue-dev-loop:evolve-completion:') + ) +} + +function automationCommentBodyAllowed(body, authorization) { + if (!reservedAutomationComment(body)) return true + if (body.includes('**pr_completed**')) { + return authorization?.rootIntent === 'prepare-finalization' + } + return authorization?.rootIntent === 'evolve-complete' +} + function automationApiMutationAllowed( { endpoint, method, fields, usesFileExpansion, usesInput }, authorization, @@ -756,7 +773,8 @@ function automationApiMutationAllowed( ].filter(Number.isInteger), ) if (issueComment && method === 'POST' && commentTargets.has(Number(issueComment[1]))) { - return fields.length === 1 && fields[0].startsWith('body=') + const body = fields.length === 1 && fields[0].startsWith('body=') ? fields[0].slice(5) : null + return Boolean(body) && automationCommentBodyAllowed(body, authorization) } const reply = endpoint.match(/^repos\/[^/]+\/[^/]+\/pulls\/(\d+)\/comments\/\d+\/replies$/) return ( @@ -1230,13 +1248,18 @@ function argumentAfter(args, name) { function withRootCommandIntent(authorization, { tool, args, trustedLoopRoot }) { const script = args[0] ? path.resolve(args[0]) : null + const isTrustedLoopctl = + tool === 'node' && + script === path.resolve(trustedLoopRoot, 'scripts', 'loopctl.mjs') + const withIntent = isTrustedLoopctl + ? { ...authorization, rootIntent: args[1] ?? null } + : authorization if ( - tool !== 'node' || - script !== path.resolve(trustedLoopRoot, 'scripts', 'loopctl.mjs') || + !isTrustedLoopctl || args[1] !== 'start' || authorization.issue !== null ) { - return authorization + return withIntent } const issueNumber = Number(argumentAfter(args, '--issue')) const issueUrl = argumentAfter(args, '--url') @@ -1251,7 +1274,7 @@ function withRootCommandIntent(authorization, { tool, args, trustedLoopRoot }) { throw new Error('loopctl start intent must identify one issue in the configured repository') } return { - ...authorization, + ...withIntent, issue: { branch: `codex/issue-${issueNumber}`, issueNumber, diff --git a/loops/issue-dev-loop/scripts/lib/github.mjs b/loops/issue-dev-loop/scripts/lib/github.mjs index 11b08b11..4352266e 100644 --- a/loops/issue-dev-loop/scripts/lib/github.mjs +++ b/loops/issue-dev-loop/scripts/lib/github.mjs @@ -22,6 +22,7 @@ import { recordFinalizationPublication, } from './finalization-journal.mjs' import { reconcileActiveJournal } from './active-journal.mjs' +import { reconcileEvolveJournal } from './evolve.mjs' import { defaultReleaseIssueClaim } from './issue-claim.mjs' import { appendValidatedEvent, finalizeRun, readEvents, readRun } from './run-store.mjs' import { verifyLatestDurableCheckpoint } from './checkpoint-proof.mjs' @@ -69,12 +70,13 @@ export async function reconcileLoopJournal({ loopRoot = DEFAULT_LOOP_ROOT, now = new Date(), } = {}) { + const evolve = await reconcileEvolveJournal({ loopRoot }) const finalization = await reconcileFinalizationJournal({ loopRoot, now }) const active = await reconcileActiveJournal({ loopRoot, terminalRunIds: finalization.durableRunIds, }) - return { ...finalization, ...active } + return { ...evolve, ...finalization, ...active } } async function loadJsonFile(target) { diff --git a/loops/issue-dev-loop/scripts/lib/owner-gate.mjs b/loops/issue-dev-loop/scripts/lib/owner-gate.mjs index fa5ad207..b2cccec0 100644 --- a/loops/issue-dev-loop/scripts/lib/owner-gate.mjs +++ b/loops/issue-dev-loop/scripts/lib/owner-gate.mjs @@ -58,7 +58,7 @@ export async function observeOwnerApprovedMerge({ sameGitHubLogin(latestReadinessTransition.actor?.login, channel.ownerGitHubLogin) && !Number.isNaN(Date.parse(latestReadinessTransition.created_at)) && (!readyAfter || - Date.parse(latestReadinessTransition.created_at) >= Date.parse(readyAfter)) + Date.parse(latestReadinessTransition.created_at) > Date.parse(readyAfter)) if ( pullRequest.merged !== true || `${target.owner}/${target.repo}`.toLowerCase() !== configuredRepository.toLowerCase() || diff --git a/loops/issue-dev-loop/scripts/lib/run-store.mjs b/loops/issue-dev-loop/scripts/lib/run-store.mjs index 2d0c0f19..9e2efc65 100644 --- a/loops/issue-dev-loop/scripts/lib/run-store.mjs +++ b/loops/issue-dev-loop/scripts/lib/run-store.mjs @@ -763,6 +763,18 @@ async function ensureFinalizationArtifacts({ notificationUrl: events.findLast((event) => event.type === 'finalization_published')?.payload ?.notificationUrl ?? null, + readyNotificationUrl: + events.findLast((event) => event.type === 'finalization_published')?.payload + ?.readyNotificationUrl ?? null, + readyNotifiedAt: + events.findLast((event) => event.type === 'finalization_published')?.payload + ?.readyNotifiedAt ?? null, + completionNotifiedAt: + events.findLast((event) => event.type === 'finalization_published')?.payload + ?.completionNotifiedAt ?? null, + notificationWebhookStatus: + events.findLast((event) => event.type === 'finalization_published')?.payload + ?.notificationWebhookStatus ?? null, }) } await updateEvolveMetrics({ loopRoot, now }) diff --git a/loops/issue-dev-loop/scripts/runtime.mjs b/loops/issue-dev-loop/scripts/runtime.mjs index 6a0c241f..38096b6e 100644 --- a/loops/issue-dev-loop/scripts/runtime.mjs +++ b/loops/issue-dev-loop/scripts/runtime.mjs @@ -3,7 +3,9 @@ export { completeEvolve, getEvolveStatus, prepareEvolveRequestPublication, + reconcileEvolveJournal, recordEvolveRequestPublication, + verifyPublishedEvolveCompletion, verifyPublishedEvolveRequest, } from './lib/evolve.mjs' export { recordEvidence, recordReview, reviewPublicationDigest } from './lib/evidence.mjs' diff --git a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs index 4d44e405..11773dcd 100644 --- a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs +++ b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs @@ -1135,6 +1135,18 @@ test('PR and issue mutations reject unsafe shapes and targets', async () => { ['automation', ['pr', 'ready', '107']], ['automation', ['pr', 'comment', '107', '--body', 'Wrong PR']], ['automation', ['pr', 'comment', '106', '--body', 'Missing repository']], + [ + 'automation', + [ + 'pr', + 'comment', + '106', + '--repo', + 'example/repo', + '--body', + '@owner **pr_completed**', + ], + ], [ 'automation', ['pr', 'comment', '106', '--repo', 'example/repo', '--body-file', '/tmp/secret'], @@ -1175,6 +1187,17 @@ test('PR and issue mutations reject unsafe shapes and targets', async () => { 'body=@/tmp/secret', ], ], + [ + 'automation', + [ + 'api', + 'repos/example/repo/issues/106/comments', + '--method', + 'POST', + '-f', + 'body=@owner **pr_completed**', + ], + ], ] for (const [role, ghArguments] of forbidden) { await assert.rejects( diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index abe08e9d..df72d1c9 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -33,10 +33,13 @@ import { loadPaginatedGitHubCollection, observeOwnerMerge, prepareActiveCheckpoint, + prepareEvolveRequestPublication, prepareFinalizationRecord as runtimePrepareFinalizationRecord, reconcileActiveJournal, + reconcileEvolveJournal, reconcileFinalizationJournal, recordEvidence as runtimeRecordEvidence, + recordEvolveRequestPublication, recordDigest, recordFinalizationPublication, recordActiveCheckpointPublication, @@ -210,6 +213,16 @@ test('owner merge observation requires the owner Ready transition after notifica ]), /owner-authored Ready transition/, ) + await assert.rejects( + verify([ + { + event: 'ready_for_review', + actor: { login: 'codeacme17' }, + created_at: '2026-07-23T08:00:00.000Z', + }, + ]), + /owner-authored Ready transition/, + ) }) async function createFixture() { @@ -2046,7 +2059,7 @@ test('owner-review waiting transition keeps the exact verified PR Draft and rema return { user: { login: 'echo-ui-loop[bot]' }, created_at: '2026-07-23T08:50:00.000Z', - body: `@codeacme17 **pr_completed**\n\nRun: \`${run.runId}\``, + body: `@codeacme17 **pr_completed**\n\nRun: \`${run.runId}\`\n\nMerge: \`${'9'.repeat(40)}\``, } } if (endpoint.endsWith('/issues/comments/9900')) { @@ -2097,6 +2110,26 @@ test('owner-review waiting transition keeps the exact verified PR Draft and rema } }, }) + await assert.rejects( + verifyTerminalExternalProof({ + loopRoot, + record: { + ...preparedCompletion.record, + completionNotifiedAt: '2026-07-23T08:40:00.000Z', + }, + githubApi: async (endpoint) => { + if (endpoint.endsWith('/issues/comments/8803')) { + return { + user: { login: 'echo-ui-loop[bot]' }, + created_at: '2026-07-23T08:40:00.000Z', + body: `@codeacme17 **pr_completed**\n\nRun: \`${run.runId}\`\n\nMerge: \`${'9'.repeat(40)}\``, + } + } + return completionGithubApi(endpoint) + }, + }), + /completion-notification timestamp/, + ) await assert.rejects( verifyTerminalExternalProof({ loopRoot, @@ -2140,6 +2173,19 @@ test('owner-review waiting transition keeps the exact verified PR Draft and rema completedEvents.indexOf('"notificationType":"pr_completed"') < completedEvents.indexOf('"type":"pr_merged"'), ) + const reconciledFinalization = await reconcileFinalizationJournal({ + loopRoot, + now: new Date('2026-07-23T09:01:00.000Z'), + githubPaginatedApi: async () => [ + { + user: { login: 'echo-ui-loop[bot]' }, + html_url: finalizationCommentUrl, + body: preparedCompletion.body, + }, + ], + githubApi: completionGithubApi, + }) + assert.deepEqual(reconciledFinalization.durableRunIds, [run.runId]) }) test('completed finalization cannot bypass the owner-ready gate', async () => { @@ -3404,6 +3450,49 @@ test('fresh worktrees rebuild finalization history and evolve metrics from GitHu assert.equal(metrics.failedRuns, 1) }) +test('reconciliation excludes local finalization rows without a durable journal record', async () => { + const { loopRoot } = await createFixture() + const indexPath = path.join(loopRoot, 'logs', 'index.jsonl') + const forgedRows = Array.from({ length: 3 }, (_, index) => ({ + schemaVersion: 1, + event: 'run_finalized', + runId: `forged-${index}`, + issueNumber: 800 + index, + status: 'blocked', + startedAt: '2026-07-22T12:00:00.000Z', + finishedAt: `2026-07-22T12:0${index + 1}:00.000Z`, + prUrl: null, + headSha: null, + mergeSha: null, + failureFingerprint: 'forged-local-row', + notificationUrl: `https://github.com/codeacme17/echo-ui/issues/${800 + index}#issuecomment-${9000 + index}`, + readyNotificationUrl: null, + readyNotifiedAt: null, + completionNotifiedAt: null, + notificationWebhookStatus: null, + })) + await writeFile( + indexPath, + `${[ + { schemaVersion: 1, event: 'loop_initialized' }, + ...forgedRows, + ] + .map((entry) => JSON.stringify(entry)) + .join('\n')}\n`, + 'utf8', + ) + await reconcileFinalizationJournal({ + loopRoot, + githubPaginatedApi: async () => [], + }) + const metrics = await getEvolveStatus({ loopRoot }) + assert.equal(metrics.finalizedRuns, 0) + assert.equal(metrics.failedRuns, 0) + assert.equal(metrics.evolveDue, false) + const reconciledIndex = await readFile(indexPath, 'utf8') + assert.match(reconciledIndex, /run_finalization_unverified/) +}) + test('fresh worktrees restore active checkpoints and trigger resumable work', async () => { const { loopRoot } = await createFixture() const { run } = await startFixtureRun({ @@ -3606,6 +3695,17 @@ test('evolve completion rejects an unrelated historical owner-merged PR', async })}\n`, 'utf8', ) + const preparedRequest = await prepareEvolveRequestPublication({ loopRoot, requestId }) + await recordEvolveRequestPublication({ + loopRoot, + requestId, + commentUrl: + 'https://github.com/codeacme17/echo-ui/issues/999#issuecomment-7000', + githubApi: async () => ({ + user: { login: 'echo-ui-loop[bot]' }, + body: preparedRequest.body, + }), + }) await assert.rejects( completeEvolve({ loopRoot, @@ -3613,6 +3713,12 @@ test('evolve completion rejects an unrelated historical owner-merged PR', async summary: 'Improve trigger batching', prUrl: 'https://github.com/codeacme17/echo-ui/pull/99', githubApi: async (endpoint) => { + if (endpoint.endsWith('/issues/comments/7000')) { + return { + user: { login: 'echo-ui-loop[bot]' }, + body: preparedRequest.body, + } + } if (endpoint.includes('/reviews')) { return [ { @@ -3646,6 +3752,158 @@ test('evolve completion rejects an unrelated historical owner-merged PR', async ) }) +test('fresh worktrees rebuild pending and completed evolve state from the durable journal', async () => { + const { loopRoot } = await createFixture() + const requestId = 'EVL-000010-TEN-FINALIZED-RUNS' + const requestUrl = + 'https://github.com/codeacme17/echo-ui/issues/999#issuecomment-7100' + const completionUrl = + 'https://github.com/codeacme17/echo-ui/issues/999#issuecomment-7101' + const prUrl = 'https://github.com/codeacme17/echo-ui/pull/710' + const headSha = '7'.repeat(40) + const mergeSha = '8'.repeat(40) + const mergeAt = '2026-07-23T13:00:00.000Z' + const metricsPath = path.join(loopRoot, 'evolve', 'metrics.json') + const initialMetrics = JSON.parse(await readFile(metricsPath, 'utf8')) + await writeFile( + metricsPath, + `${JSON.stringify({ + ...initialMetrics, + finalizedRuns: 10, + evolveDue: true, + pendingRequestId: requestId, + })}\n`, + 'utf8', + ) + const requestPath = path.join(loopRoot, 'evolve', 'requests', `${requestId}.json`) + await writeFile( + requestPath, + `${JSON.stringify({ + schemaVersion: 1, + requestId, + status: 'pending', + reason: 'ten_finalized_runs', + requestedAt: '2026-07-23T12:00:00.000Z', + finalizedRunCount: 10, + })}\n`, + 'utf8', + ) + const preparedRequest = await prepareEvolveRequestPublication({ loopRoot, requestId }) + await recordEvolveRequestPublication({ + loopRoot, + requestId, + commentUrl: requestUrl, + githubApi: async () => ({ + user: { login: 'echo-ui-loop[bot]' }, + body: preparedRequest.body, + }), + }) + + let completionBody = null + const githubApi = async (endpoint) => { + if (endpoint.endsWith('/issues/comments/7100')) { + return { + user: { login: 'echo-ui-loop[bot]' }, + body: preparedRequest.body, + } + } + if (endpoint.endsWith('/issues/comments/7101')) { + return { + user: { login: 'echo-ui-loop[bot]' }, + body: completionBody, + created_at: '2026-07-23T13:01:00.000Z', + } + } + if (endpoint.includes('/reviews')) { + return [ + { + user: { login: 'codeacme17' }, + state: 'APPROVED', + commit_id: headSha, + }, + ] + } + if (endpoint.includes('/timeline')) { + return [ + { + event: 'ready_for_review', + actor: { login: 'codeacme17' }, + created_at: '2026-07-23T12:30:00.000Z', + }, + ] + } + return { + merged: true, + merged_at: mergeAt, + merged_by: { login: 'codeacme17' }, + merge_commit_sha: mergeSha, + created_at: '2026-07-23T12:10:00.000Z', + body: `<!-- issue-dev-loop:evolve-request:${requestId} -->`, + base: { ref: 'dev', repo: { full_name: 'codeacme17/echo-ui' } }, + head: { + ref: `codex/evolve-${requestId}`, + sha: headSha, + repo: { full_name: 'codeacme17/echo-ui' }, + }, + } + } + const completed = await completeEvolve({ + loopRoot, + requestId, + summary: 'Batch empty trigger checks before waking an executor.', + prUrl, + now: new Date('2026-07-23T13:02:00.000Z'), + githubApi, + githubComment: async (_target, body) => { + completionBody = body + return { html_url: completionUrl } + }, + verifyAutomationIdentity: async () => {}, + }) + assert.equal(completed.completionPublicationUrl, completionUrl) + let metrics = await getEvolveStatus({ loopRoot }) + assert.equal(metrics.evolveDue, false) + assert.equal(metrics.lastEvolvedRunCount, 10) + assert.equal(metrics.completedEvolveSessions, 1) + + await writeFile(metricsPath, `${JSON.stringify(initialMetrics)}\n`, 'utf8') + await rm(requestPath) + const reconciled = await reconcileEvolveJournal({ + loopRoot, + githubPaginatedApi: async () => [ + { + user: { login: 'echo-ui-loop[bot]' }, + html_url: requestUrl, + body: preparedRequest.body, + }, + { + user: { login: 'echo-ui-loop[bot]' }, + html_url: completionUrl, + body: completionBody, + created_at: '2026-07-23T13:01:00.000Z', + }, + ], + githubApi, + }) + assert.deepEqual(reconciled.durableCompletedEvolveRequestIds, [requestId]) + metrics = await getEvolveStatus({ loopRoot }) + assert.equal(metrics.evolveDue, false) + assert.equal(metrics.pendingRequestId, null) + assert.equal(metrics.lastEvolvedRunCount, 10) + assert.equal(metrics.completedEvolveSessions, 1) + const restoredRequest = JSON.parse(await readFile(requestPath, 'utf8')) + assert.equal(restoredRequest.status, 'completed') + assert.equal(restoredRequest.mergeSha, mergeSha) + + await reconcileFinalizationJournal({ + loopRoot, + githubPaginatedApi: async () => [], + }) + metrics = await getEvolveStatus({ loopRoot }) + assert.equal(metrics.evolveDue, false) + assert.equal(metrics.completedEvolveSessions, 1) +}) + test('repository loop package satisfies its structural invariants', async () => { const result = await validateLoop({ loopRoot: repositoryLoopRoot }) assert.equal(result.valid, true) @@ -3715,6 +3973,17 @@ test('credential isolation rejects profiles inside untrusted roots or with broad }), /real directory, not a symlink/, ) + for (const disguisedSymlink of [`${symlinkedProfile}/`, `${symlinkedProfile}/.`]) { + await assert.rejects( + assertCredentialProfileIsolation({ + channel, + configDirectory: disguisedSymlink, + environment, + requiredUntrustedRoots: [untrustedRoot], + }), + /real directory, not a symlink/, + ) + } await assert.rejects( assertCredentialProfileIsolation({ channel, From 9e1da732c0986a8a97572abc3a2f7531b38cb40e Mon Sep 17 00:00:00 2001 From: leyoonafr <codeacme17@gmail.com> Date: Fri, 24 Jul 2026 00:12:14 +0800 Subject: [PATCH 35/41] fix: close durable loop reconciliation gaps --- .codex/agents/echo-ui-review-adjudicator.toml | 4 + loops/issue-dev-loop/LOOP.md | 4 +- loops/issue-dev-loop/SKILL.md | 2 +- loops/issue-dev-loop/evolve/EVOLVE.md | 2 +- loops/issue-dev-loop/review/REVIEW.md | 2 +- .../issue-dev-loop/review/response-policy.md | 2 +- .../schemas/finalization-record.schema.json | 15 +- .../scripts/lib/active-journal.mjs | 41 +- loops/issue-dev-loop/scripts/lib/evidence.mjs | 46 +- loops/issue-dev-loop/scripts/lib/evolve.mjs | 143 ++- .../scripts/lib/finalization-journal.mjs | 105 ++- .../scripts/lib/finalization-proof.mjs | 200 +++- .../scripts/lib/github-identity.mjs | 104 ++- loops/issue-dev-loop/scripts/lib/github.mjs | 23 +- .../issue-dev-loop/scripts/lib/owner-gate.mjs | 28 +- .../issue-dev-loop/scripts/lib/validation.mjs | 38 +- .../tests/github-identity-routing.test.mjs | 70 ++ loops/issue-dev-loop/tests/runtime.test.mjs | 876 +++++++++++++++--- 18 files changed, 1478 insertions(+), 227 deletions(-) diff --git a/.codex/agents/echo-ui-review-adjudicator.toml b/.codex/agents/echo-ui-review-adjudicator.toml index 0af55175..ce0f319e 100644 --- a/.codex/agents/echo-ui-review-adjudicator.toml +++ b/.codex/agents/echo-ui-review-adjudicator.toml @@ -14,4 +14,8 @@ installed executable `"$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" `gh pr review --comment`. Do not use the repository launcher, invoke the `.mjs` implementation directly, or run raw or mutating `gh api`, alter global authentication, or post through the executor identity. +The body must include exactly one +`<!-- issue-dev-loop:<run-id>:<finding-id>:adjudication:REJECT_FINDING:head:<sha> -->` +marker for the existing current-head finding. This adjudication review is separate +from, and must not include, a review-cycle marker. """ diff --git a/loops/issue-dev-loop/LOOP.md b/loops/issue-dev-loop/LOOP.md index b0faa1f9..aff13d23 100644 --- a/loops/issue-dev-loop/LOOP.md +++ b/loops/issue-dev-loop/LOOP.md @@ -103,13 +103,13 @@ When the owner requests changes, verify the owner-authored GitHub comment or `CH ### 10. Complete -Only the remote owner-merge gate permits `completed`. Before a completion record exists, `prepare-finalization` remotely verifies the automation-authored Ready notification, a strictly later owner `ready_for_review` timeline event with no later redraft, an `APPROVED` review for the exact reviewed head SHA, and a merge performed by `codeacme17`. It then requires successful canonical `pr_completed` GitHub delivery whose body binds the merge SHA and whose remote timestamp is at or after the merge, and waits for the optional webhook attempt to settle within its bound. The identity router reserves `pr_completed` for the trusted finalization command. The finalization record binds distinct Ready and completion comment URLs/timestamps plus webhook outcome. Only after those prerequisites may the automation identity publish the record to the GitHub state journal. Publication, `observe-owner-merge`, terminal transition, and future reconciliation re-fetch that proof; a failed delivery or crash before publication leaves the active checkpoint resumable instead of suppressing it. Mutable local events are audit records, never authorization. Record merge SHA and timestamp, remove `loop:claimed`, update state, append the run summary, and retain links to published evidence. A closed unmerged PR is `cancelled`, not completed. +Only the remote owner-merge gate permits `completed`. Before a completion record exists, `prepare-finalization` remotely verifies the automation-authored Ready notification, a strictly later owner `ready_for_review` timeline event with no later redraft, and the latest owner review for the exact reviewed head SHA; that latest review must be `APPROVED`, must be strictly after Ready, and must not be superseded by requested changes or dismissal. It also requires a merge performed by `codeacme17`. The command then requires successful canonical `pr_completed` GitHub delivery whose body binds the merge SHA and whose remote timestamp is at or after the merge, and waits for the optional webhook attempt to settle within its bound. The identity router reserves `pr_completed` for the trusted finalization command. The finalization record binds distinct Ready and completion comment URLs/timestamps plus webhook outcome. Failed and blocked records instead bind the latest durable `waiting_for_owner` checkpoint, its digest, the current pause timestamp, and the matching notification's remote timestamp; stale notifications from earlier pauses cannot terminate or suppress the active checkpoint. Only after those prerequisites may the automation identity publish the record to the GitHub state journal. Publication, `observe-owner-merge`, terminal transition, and future reconciliation re-fetch that proof; a failed delivery or crash before publication leaves the active checkpoint resumable instead of suppressing it. Mutable local events are audit records, never authorization. Record merge SHA and timestamp, remove `loop:claimed`, update state, append the run summary, and retain links to published evidence. A closed unmerged PR is `cancelled`, not completed. ## State and history Keep `state.md` small and deliberate. It may be rewritten and contains only active runs, open PRs, blockers, follow-ups, current hypotheses, and learned constraints. -`logs/index.jsonl` is append-only and contains compact finalized-run summaries plus reconciliation tombstones for rows that lack a durable counterpart; metrics ignore tombstoned rows. `logs/triggers.jsonl` is the append-only record of cheap trigger decisions, including successful no-ops and resumes. The dedicated GitHub state-journal issue is the durable cross-worktree source for active checkpoints, terminal records, and pending/completed evolve sessions. After every durable run phase, publish the exact `prepare-checkpoint` body and validate it with `record-checkpoint`; the next phase re-fetches that automation-authored comment and refuses stale, absent, edited, or locally forged proof. Each compact checkpoint also embeds the digest-bound `$implement` result, evidence manifest, review result, and finalization record referenced by its event chain, when present, so later gates remain resumable. Terminal comments supersede active checkpoints only after finalization reconciliation validates them, including remote owner proof for completion. `loopctl reconcile` discovers resumable state, excludes local terminal rows without verified remote records, rebuilds evolve request/completion state, and recomputes metrics from verified journal history; it does not write run state into an arbitrary checkout. The orchestrator creates a clean worktree at the recorded exact branch/head, then `restore-checkpoint` verifies both before restoring run files and required small artifacts. A resumable run is returned as `workType: resume` before new issue selection. Commit sanitized `run.json`, pre-publication `events.jsonl`, summaries, and relevant screenshots to the issue branch so they are reviewable in its PR. The exact-head CI manifest and full proof travel in an Actions artifact. Keep raw local command output and large recordings in ignored `raw/` or `test-results/` directories. GitHub journal comments, PR reviews, workflow artifacts, and merge metadata are authoritative; local indexes and metrics are reconstructable caches. +`logs/index.jsonl` is append-only and contains compact finalized-run summaries plus reconciliation tombstones for rows that lack a durable counterpart; metrics ignore tombstoned rows. A durable record may be restored after its tombstone, but duplicate active finalizations, consecutive tombstones, and conflicting restorations are invalid. `logs/triggers.jsonl` is the append-only record of cheap trigger decisions, including successful no-ops and resumes. The dedicated GitHub state-journal issue is the durable cross-worktree source for active checkpoints, terminal records, and pending/completed evolve sessions. After every durable run phase, publish the exact `prepare-checkpoint` body and validate it with `record-checkpoint`; the next phase re-fetches that automation-authored comment and refuses stale, absent, edited, or locally forged proof. Each compact checkpoint also embeds the digest-bound `$implement` result, evidence manifest, review result, and finalization record referenced by its event chain, when present, so later gates remain resumable. Terminal comments supersede active checkpoints only after finalization reconciliation validates them, including remote owner proof for completion and current-pause proof for failed/blocked runs. `loopctl reconcile` discovers resumable state, excludes local terminal rows without verified remote records, rebuilds evolve request/completion state, and recomputes metrics from verified journal history; it does not write run state into an arbitrary checkout. The orchestrator creates a clean worktree at the recorded exact branch/head, then `restore-checkpoint` verifies both before restoring run files and required small artifacts. A resumable run is returned as `workType: resume` before new issue selection. Commit sanitized `run.json`, pre-publication `events.jsonl`, summaries, and relevant screenshots to the issue branch so they are reviewable in its PR. The exact-head CI manifest and full proof travel in an Actions artifact. Keep raw local command output and large recordings in ignored `raw/` or `test-results/` directories. GitHub journal comments, PR reviews, workflow artifacts, and merge metadata are authoritative; local indexes and metrics are reconstructable caches. Never log secrets, full environment dumps, cookies, auth headers, private user data, or raw prompts containing sensitive information. diff --git a/loops/issue-dev-loop/SKILL.md b/loops/issue-dev-loop/SKILL.md index 2d83b4e6..2721d778 100644 --- a/loops/issue-dev-loop/SKILL.md +++ b/loops/issue-dev-loop/SKILL.md @@ -61,7 +61,7 @@ Run verification appropriate to the change and require `pnpm verify` before the Before publishing the final review round, write the complete cycle result with an unassigned final `reviewUrl`, then run `loopctl.mjs review-digest --result <absolute-path>`. Add the returned marker to the final review body, publish the non-approving review, replace the unassigned URL with GitHub's actual review URL, and confirm `review-digest` is unchanged. After all responses are posted, run `loopctl.mjs record-review --run-id <id> --result <absolute-path> --review-url <github-review-url>`. The publication digest deliberately canonicalizes GitHub-assigned review URLs while the stored full-file digest still protects the final artifact. Both evidence and review gates must name the current PR head. Keep the PR Draft, emit a blocking `pr_ready_for_review` notification asking `codeacme17` to mark it Ready and review it, then transition from `waiting_for_owner` to `awaiting_owner_review` with the PR URL and exact head SHA. -The owner is the only actor allowed to mark a Draft PR Ready, approve it, or merge it. Never call non-`--undo` `gh pr ready`, `gh pr merge`, enable auto-merge, push to `main`, push to `dev`, dismiss owner feedback, or bypass branch protections. Before any terminal transition, run `prepare-finalization`. For completion, that command paginates the PR timeline and reviews, requires a Ready transition authored by `codeacme17` strictly after the remotely verified Ready-notification comment with no later redraft, requires the owner's exact-head approval and merge, delivers the informational `pr_completed` GitHub notification with the merge SHA after the remote merge timestamp, waits for the bounded webhook attempt to settle, and binds distinct notification URLs/timestamps into the record. Raw executor comments cannot use the reserved `pr_completed` marker. If canonical GitHub delivery fails, no terminal record is created. Only then publish the exact body to the configured state-journal issue and pass its result/comment URL to `observe-owner-merge`; that command revalidates the remote record, appends the local notification/owner audit events, and finalizes. Future workspaces run `reconcile` to revalidate the same notification, Ready, approval, and merge proof before rebuilding local history or suppressing an active checkpoint. +The owner is the only actor allowed to mark a Draft PR Ready, approve it, or merge it. Never call non-`--undo` `gh pr ready`, `gh pr merge`, enable auto-merge, push to `main`, push to `dev`, dismiss owner feedback, or bypass branch protections. Before any terminal transition, run `prepare-finalization`. For completion, that command paginates the PR timeline and reviews, requires a Ready transition authored by `codeacme17` strictly after the remotely verified Ready-notification comment with no later redraft, and requires the latest exact-head owner review to be a strictly later `APPROVED` decision before the owner merge. It delivers the informational `pr_completed` GitHub notification with the merge SHA after the remote merge timestamp, waits for the bounded webhook attempt to settle, and binds distinct notification URLs/timestamps into the record. For `failed` or `blocked`, publish a checkpoint after the blocking notification and pause; the terminal record must bind that exact `waiting_for_owner` checkpoint, its digest, current pause, and the matching notification timestamp. Raw executor comments cannot use the reserved `pr_completed` marker. If canonical GitHub delivery fails, no terminal record is created. Only then publish the exact body to the configured state-journal issue and pass its result/comment URL to `observe-owner-merge`; that command revalidates the remote record, appends the local notification/owner audit events, and finalizes. Future workspaces run `reconcile` to revalidate the same notification, pause or Ready, approval, and merge proof before rebuilding local history or suppressing an active checkpoint. For any pause, do not resume from silence or an arbitrary comment. First require a successfully delivered blocking notification. Then verify the owner's GitHub decision with `loopctl.mjs record-owner-response --run-id <id> --response-url <comment-or-review-url>`. A normal comment must include the exact `RESUME <run-id>` token printed in the notification; a `CHANGES_REQUESTED` review is itself an explicit decision and must be submitted against the run's current exact head SHA. Only then may `loopctl.mjs transition --run-id <id> --status running` continue. Before any repair work or new push, publish that transition's checkpoint, run the unchanged exact PR through `gh pr ready --undo --repo codeacme17/echo-ui`, observe the same head as Draft with `record-pr`, and publish another checkpoint. `$implement` repair attestations and later PR rebinds are rejected unless this durable redraft happened after the current owner response. diff --git a/loops/issue-dev-loop/evolve/EVOLVE.md b/loops/issue-dev-loop/evolve/EVOLVE.md index 5f1ba4a8..8951160d 100644 --- a/loops/issue-dev-loop/evolve/EVOLVE.md +++ b/loops/issue-dev-loop/evolve/EVOLVE.md @@ -18,4 +18,4 @@ Every evolve change requires a dedicated `codex/evolve-<request-id>` draft PR ta Never optimize by weakening evidence, increasing autonomous authority, deleting unfavorable history, or hiding no-work and failure runs. -After the owner merges the evolve PR, run `loopctl.mjs evolve-complete --request-id <id> --summary <summary> --pr-url <url>` through the installed automation wrapper. The trusted command re-fetches the digest-bound request, owner Ready/approval/merge proof, posts a digest-bound `evolve-completion` record to the state journal, and only then clears the pending request. The identity router reserves that marker for this command. Every scheduled `reconcile` rebuilds pending and completed evolve requests, `lastEvolvedRunCount`, and completion counters from those automation-authored journal records before deciding whether another evolve session is due. A local file, silence, an unmerged PR, or a completion comment created before the recorded merge never clears a request. +After the owner merges the evolve PR, run `loopctl.mjs evolve-complete --request-id <id> --summary <summary> --pr-url <url>` through the installed automation wrapper. The trusted command re-fetches the digest-bound request, owner Ready/approval/merge proof, reuses an already-valid matching completion after a crash or ambiguous retry, otherwise posts a digest-bound `evolve-completion` record to the state journal, and only then clears the pending request. The identity router reserves that marker for this command. Every scheduled `reconcile` coalesces identical same-digest request or completion publications while rejecting conflicting ones, then rebuilds pending and completed evolve requests, `lastEvolvedRunCount`, and completion counters before deciding whether another evolve session is due. A local file, silence, an unmerged PR, or a completion comment created before the recorded merge never clears a request. diff --git a/loops/issue-dev-loop/review/REVIEW.md b/loops/issue-dev-loop/review/REVIEW.md index b9759522..6a7095a4 100644 --- a/loops/issue-dev-loop/review/REVIEW.md +++ b/loops/issue-dev-loop/review/REVIEW.md @@ -27,7 +27,7 @@ The fresh reviewer publishes each round verbatim through `reviewerGitHubLogin` a Before the final round is published, write the complete cycle result with a temporary/unassigned final `reviewUrl` and run the installed `loopctl review-digest --result <path>`. Put its returned `<!-- issue-dev-loop:<run-id>:review-result-sha256:<digest> -->` marker in the final body. After GitHub assigns the review URL, replace the temporary value and rerun `review-digest`; it must be unchanged. This canonical publication digest replaces only review URLs with a fixed placeholder and therefore avoids a URL↔digest cycle. The later recorded full-file digest still protects the final URLs and every other byte. The reviewer must not downgrade severity or omit findings. -After all replies are posted, create one cycle result matching `result.schema.json`. Its `cycle` is the next durable review cycle number, and it contains every round's review URL, every finding, its final classification, response URL, and evidence. Executor replies include both the readable evidence (and accepted fix SHA) plus `<!-- issue-dev-loop:<run-id>:<finding-id>:<classification> -->`. Rejected P0/P1 findings additionally require an independent or owner comment containing `<!-- issue-dev-loop:<run-id>:<finding-id>:adjudication:<verdict> -->`. Run `record-review` with the final GitHub review URL; it paginates every reviewer-authored review on the recorded PR and requires one-to-one membership for the current run/cycle before it verifies replies, identities, timestamps, ancestry, adjudications, and markers. The publication gate rejects skipped or duplicate cycle rounds. Generic events cannot forge this reserved gate. +After all replies are posted, create one cycle result matching `result.schema.json`. Its `cycle` is the next durable review cycle number, and it contains every round's review URL, every finding, its final classification, response URL, and evidence. Executor replies include both the readable evidence (and accepted fix SHA) plus `<!-- issue-dev-loop:<run-id>:<finding-id>:<classification> -->`. Rejected P0/P1 findings additionally require an independent or owner COMMENT publication containing `<!-- issue-dev-loop:<run-id>:<finding-id>:adjudication:<verdict>:head:<sha> -->`. Independent `REJECT_FINDING` adjudication is a separate body-only, non-approving GitHub review published through the reviewer identity; it is bound to an existing current-head reviewer finding, cannot be duplicated, and does not consume a review cycle round. Run `record-review` with the final GitHub review URL; it paginates every reviewer-authored review on the recorded PR and requires one-to-one membership for the current run/cycle before it verifies replies, identities, timestamps, ancestry, adjudications, and markers. The publication gate rejects skipped or duplicate cycle rounds. Generic events cannot forge this reserved gate. ## Completion diff --git a/loops/issue-dev-loop/review/response-policy.md b/loops/issue-dev-loop/review/response-policy.md index 7dd1a531..6501c64c 100644 --- a/loops/issue-dev-loop/review/response-policy.md +++ b/loops/issue-dev-loop/review/response-policy.md @@ -10,7 +10,7 @@ Classify every automated finding as one of: Accepted findings return to a new `$implement` invocation that starts after the review was submitted. Reply only after that invocation finishes and the fix is pushed; include commit SHA, commands, results, and evidence links. -Rejected findings require a precise counterclaim and reproducible evidence in the GitHub reply. Do not reply with bare disagreement. An executor cannot unilaterally close a disputed P0 or P1; publish `REJECT_FINDING` from the independent reviewer identity or `OWNER_REJECTED_FINDING` from the owner, include its adjudication marker/URL in the cycle result, and escalate `NEEDS_OWNER` outcomes. +Rejected findings require a precise counterclaim and reproducible evidence in the GitHub reply. Do not reply with bare disagreement. An executor cannot unilaterally close a disputed P0 or P1; publish `REJECT_FINDING` from the independent reviewer identity as a separate body-only COMMENT review, or `OWNER_REJECTED_FINDING` from the owner. The publication must carry `<!-- issue-dev-loop:<run-id>:<finding-id>:adjudication:<verdict>:head:<sha> -->`; include its review/comment URL in the cycle result, and escalate `NEEDS_OWNER` outcomes. Do not delete review comments. Log classification, response time, response URL, commit, and evidence reference under the run ID. diff --git a/loops/issue-dev-loop/schemas/finalization-record.schema.json b/loops/issue-dev-loop/schemas/finalization-record.schema.json index a3c62a35..9c4e2860 100644 --- a/loops/issue-dev-loop/schemas/finalization-record.schema.json +++ b/loops/issue-dev-loop/schemas/finalization-record.schema.json @@ -17,7 +17,11 @@ "readyNotificationUrl", "readyNotifiedAt", "completionNotifiedAt", - "notificationWebhookStatus" + "notificationWebhookStatus", + "predecessorCheckpointUrl", + "predecessorCheckpointDigest", + "pauseStartedAt", + "notificationNotifiedAt" ], "additionalProperties": false, "properties": { @@ -41,6 +45,13 @@ "readyNotificationUrl": { "type": ["string", "null"], "format": "uri" }, "readyNotifiedAt": { "type": ["string", "null"], "format": "date-time" }, "completionNotifiedAt": { "type": ["string", "null"], "format": "date-time" }, - "notificationWebhookStatus": { "type": ["string", "null"] } + "notificationWebhookStatus": { "type": ["string", "null"] }, + "predecessorCheckpointUrl": { "type": ["string", "null"], "format": "uri" }, + "predecessorCheckpointDigest": { + "type": ["string", "null"], + "pattern": "^[0-9a-f]{64}$" + }, + "pauseStartedAt": { "type": ["string", "null"], "format": "date-time" }, + "notificationNotifiedAt": { "type": ["string", "null"], "format": "date-time" } } } diff --git a/loops/issue-dev-loop/scripts/lib/active-journal.mjs b/loops/issue-dev-loop/scripts/lib/active-journal.mjs index b18f3311..92dcca9f 100644 --- a/loops/issue-dev-loop/scripts/lib/active-journal.mjs +++ b/loops/issue-dev-loop/scripts/lib/active-journal.mjs @@ -30,6 +30,42 @@ const ACTIVE_STATUSES = new Set(['running', 'waiting_for_owner', 'awaiting_owner export const canonicalCheckpoint = canonicalCheckpointRecord export const checkpointDigest = checkpointRecordDigest +function durableCommentId(comment) { + const rawId = + comment.id ?? + comment.html_url?.match(/#issuecomment-([1-9][0-9]*)$/)?.[1] ?? + null + return /^[1-9][0-9]*$/.test(String(rawId ?? '')) ? BigInt(rawId) : null +} + +function compareDurableCheckpoints(candidate, existing) { + const updatedDifference = + Date.parse(candidate.record.updatedAt) - Date.parse(existing.record.updatedAt) + if (updatedDifference !== 0) return Math.sign(updatedDifference) + if (checkpointRecordDigest(candidate.record) === checkpointRecordDigest(existing.record)) { + return 0 + } + + const candidateCreatedAt = Date.parse(candidate.comment.created_at) + const existingCreatedAt = Date.parse(existing.comment.created_at) + if ( + !Number.isNaN(candidateCreatedAt) && + !Number.isNaN(existingCreatedAt) && + candidateCreatedAt !== existingCreatedAt + ) { + return Math.sign(candidateCreatedAt - existingCreatedAt) + } + + const candidateId = durableCommentId(candidate.comment) + const existingId = durableCommentId(existing.comment) + if (candidateId !== null && existingId !== null && candidateId !== existingId) { + return candidateId > existingId ? 1 : -1 + } + throw new Error( + `ambiguous durable active checkpoints for ${candidate.record.run.runId} at ${candidate.record.updatedAt}`, + ) +} + export async function prepareActiveCheckpoint({ loopRoot = DEFAULT_LOOP_ROOT, runId } = {}) { const normalizedRunId = assertRunId(runId) const run = await readRun(loopRoot, normalizedRunId) @@ -150,9 +186,10 @@ export async function reconcileActiveJournal({ if (record.run.runId !== marker[1] || checkpointRecordDigest(record) !== marker[2]) { throw new Error(`invalid durable active checkpoint for ${marker[1]}`) } + const candidate = { record, comment } const existing = latestByRunId.get(record.run.runId) - if (!existing || Date.parse(record.updatedAt) > Date.parse(existing.record.updatedAt)) { - latestByRunId.set(record.run.runId, { record, comment }) + if (!existing || compareDurableCheckpoints(candidate, existing) > 0) { + latestByRunId.set(record.run.runId, candidate) } } diff --git a/loops/issue-dev-loop/scripts/lib/evidence.mjs b/loops/issue-dev-loop/scripts/lib/evidence.mjs index c422e1e3..093ba818 100644 --- a/loops/issue-dev-loop/scripts/lib/evidence.mjs +++ b/loops/issue-dev-loop/scripts/lib/evidence.mjs @@ -726,33 +726,63 @@ export async function recordReview({ ['P0', 'P1'].includes(finding.severity) && finding.resolution.classification === 'rejected' ) { - const adjudicationTarget = parsePullCommentUrl(finding.resolution.adjudicationUrl) + const adjudicationReviewTarget = parseReviewUrl( + finding.resolution.adjudicationUrl, + ) + const adjudicationCommentTarget = parsePullCommentUrl( + finding.resolution.adjudicationUrl, + ) + const adjudicationTarget = adjudicationReviewTarget ?? adjudicationCommentTarget if ( !adjudicationTarget || - adjudicationTarget.surface !== 'pull' || !sameRepository(reviewTarget, adjudicationTarget) || - adjudicationTarget.number !== reviewTarget.number + adjudicationTarget.number !== reviewTarget.number || + (adjudicationCommentTarget && adjudicationCommentTarget.surface !== 'pull') ) { throw new Error(`${finding.findingId} adjudication is not on the reviewed PR`) } - const adjudicationEndpoint = - adjudicationTarget.kind === 'review_comment' + const adjudicationEndpoint = adjudicationReviewTarget + ? `repos/${adjudicationTarget.owner}/${adjudicationTarget.repo}/pulls/${adjudicationTarget.number}/reviews/${adjudicationTarget.reviewId}` + : adjudicationTarget.kind === 'review_comment' ? `repos/${adjudicationTarget.owner}/${adjudicationTarget.repo}/pulls/comments/${adjudicationTarget.commentId}` : `repos/${adjudicationTarget.owner}/${adjudicationTarget.repo}/issues/comments/${adjudicationTarget.commentId}` const adjudication = await githubApi(adjudicationEndpoint) - const adjudicationAt = Date.parse(adjudication.created_at ?? adjudication.updated_at) + const adjudicationAt = Date.parse( + adjudicationReviewTarget + ? adjudication.submitted_at + : (adjudication.created_at ?? adjudication.updated_at), + ) const expectedVerdict = finding.resolution.adjudicationVerdict - const expectedMarker = `<!-- issue-dev-loop:${normalizedRunId}:${finding.findingId}:adjudication:${expectedVerdict} -->` + const expectedMarker = `<!-- issue-dev-loop:${normalizedRunId}:${finding.findingId}:adjudication:${expectedVerdict}:head:${finding.headSha} -->` + const reusesCycleReview = reviewSummary.roundDetails.some((candidateRound) => { + const candidateTarget = parseReviewUrl(candidateRound.reviewUrl) + return ( + adjudicationReviewTarget && + candidateTarget && + candidateTarget.reviewId === adjudicationReviewTarget.reviewId && + sameRepository(candidateTarget, adjudicationReviewTarget) && + candidateTarget.number === adjudicationReviewTarget.number + ) + }) + const carriesCycleMarker = adjudication.body?.includes( + `<!-- issue-dev-loop:${normalizedRunId}:review-cycle:`, + ) const permittedAdjudicator = (expectedVerdict === 'REJECT_FINDING' && + Boolean(adjudicationReviewTarget) && sameGitHubLogin(adjudication.user?.login, reviewerLogin)) || (expectedVerdict === 'OWNER_REJECTED_FINDING' && sameGitHubLogin(adjudication.user?.login, channel.ownerGitHubLogin)) if ( !permittedAdjudicator || + reusesCycleReview || + carriesCycleMarker || !adjudication.body?.includes(expectedMarker) || + (adjudicationReviewTarget && + (adjudication.state !== 'COMMENTED' || + adjudication.commit_id !== finding.headSha)) || Number.isNaN(adjudicationAt) || - adjudicationAt < reviewSubmittedAt + adjudicationAt <= reviewSubmittedAt ) { throw new Error(`${finding.findingId} lacks independent published adjudication`) } diff --git a/loops/issue-dev-loop/scripts/lib/evolve.mjs b/loops/issue-dev-loop/scripts/lib/evolve.mjs index 4ca597db..f9467d62 100644 --- a/loops/issue-dev-loop/scripts/lib/evolve.mjs +++ b/loops/issue-dev-loop/scripts/lib/evolve.mjs @@ -86,6 +86,20 @@ function completedEvolveDigest(record) { return createHash('sha256').update(canonicalCompletedEvolve(record)).digest('hex') } +function parseCompletedEvolveComment(comment, { requestId = null } = {}) { + const marker = comment.body?.match( + /<!-- issue-dev-loop:evolve-completion:([^:]+):sha256:([0-9a-f]{64}) -->/, + ) + const serialized = comment.body?.match(/```json\s*([^\n]+)\s*```/)?.[1] + if (!marker || !serialized || (requestId && marker[1] !== requestId)) return null + const record = JSON.parse(serialized) + const digest = completedEvolveDigest(record) + if (record.requestId !== marker[1] || digest !== marker[2]) { + throw new Error(`invalid durable evolve completion: ${marker[1]}`) + } + return { record, requestId: marker[1], digest, serialized } +} + function stateJournalTarget(channel) { const [owner, repo] = channel.repository.split('/') return { owner, repo, number: channel.stateIssueNumber } @@ -297,6 +311,7 @@ export async function completeEvolve({ prUrl, now = new Date(), githubApi = defaultGitHubApi, + githubPaginatedApi = defaultGitHubPaginatedApi, githubComment = defaultGitHubComment, verifyAutomationIdentity = assertAutomationIdentity, } = {}) { @@ -353,15 +368,43 @@ export async function completeEvolve({ const channel = await readJson( path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'), ) - if (githubComment === defaultGitHubComment) await verifyAutomationIdentity({ loopRoot }) - const comment = await githubComment(stateJournalTarget(channel), body) - const commentUrl = assertHttpUrl(comment?.html_url, 'evolve.completionPublicationUrl') - const verified = await verifyPublishedEvolveCompletion({ - loopRoot, - record, - commentUrl, - githubApi, - }) + const journal = stateJournalTarget(channel) + const comments = await githubPaginatedApi( + `repos/${journal.owner}/${journal.repo}/issues/${journal.number}/comments?per_page=100`, + ) + let verified = null + for (const comment of comments) { + if (!sameGitHubLogin(comment.user?.login, channel.automationGitHubLogin)) continue + const publication = parseCompletedEvolveComment(comment, { + requestId: normalizedRequestId, + }) + if (!publication) continue + if ( + publication.digest !== digest || + publication.serialized !== canonicalCompletedEvolve(record) + ) { + throw new Error(`conflicting durable evolve completion: ${normalizedRequestId}`) + } + const candidate = await verifyPublishedEvolveCompletion({ + loopRoot, + record, + commentUrl: comment.html_url, + githubApi, + }) + verified ??= candidate + } + if (!verified) { + if (githubComment === defaultGitHubComment) await verifyAutomationIdentity({ loopRoot }) + const comment = await githubComment(journal, body) + const commentUrl = assertHttpUrl(comment?.html_url, 'evolve.completionPublicationUrl') + verified = await verifyPublishedEvolveCompletion({ + loopRoot, + record, + commentUrl, + githubApi, + }) + } + const commentUrl = verified.commentUrl const completed = { ...request, status: 'completed', @@ -483,37 +526,58 @@ export async function reconcileEvolveJournal({ ) { throw new Error(`invalid durable evolve request: ${marker[1]}`) } - if (requests.has(request.requestId)) { - throw new Error(`duplicate durable evolve request: ${request.requestId}`) + const existing = requests.get(request.requestId) + if (existing) { + if ( + existing.digest !== digest || + canonicalPendingRequest(existing.request) !== canonicalPendingRequest(request) + ) { + throw new Error(`conflicting durable evolve request: ${request.requestId}`) + } + existing.publicationUrls.add(comment.html_url) + continue } requests.set(request.requestId, { - ...request, - publicationUrl: comment.html_url, - publicationDigest: digest, + request: { + ...request, + publicationUrl: comment.html_url, + publicationDigest: digest, + }, + digest, + publicationUrls: new Set([comment.html_url]), }) } const completions = new Map() for (const comment of comments) { if (!sameGitHubLogin(comment.user?.login, channel.automationGitHubLogin)) continue - const marker = comment.body?.match( - /<!-- issue-dev-loop:evolve-completion:([^:]+):sha256:([0-9a-f]{64}) -->/, - ) - const serialized = comment.body?.match(/```json\s*([^\n]+)\s*```/)?.[1] - if (!marker || !serialized) continue - const record = JSON.parse(serialized) - const request = requests.get(marker[1]) + const publication = parseCompletedEvolveComment(comment) + if (!publication) continue + const { record } = publication + const requestEntry = requests.get(publication.requestId) if ( - !request || - record.requestId !== marker[1] || - completedEvolveDigest(record) !== marker[2] || - record.requestPublicationUrl !== request.publicationUrl || - record.requestPublicationDigest !== request.publicationDigest + !requestEntry || + !requestEntry.publicationUrls.has(record.requestPublicationUrl) || + record.requestPublicationDigest !== requestEntry.digest ) { - throw new Error(`invalid durable evolve completion: ${marker[1]}`) + throw new Error(`invalid durable evolve completion: ${publication.requestId}`) } - if (completions.has(record.requestId)) { - throw new Error(`duplicate durable evolve completion: ${record.requestId}`) + const existing = completions.get(record.requestId) + if (existing) { + if ( + existing.digest !== publication.digest || + canonicalCompletedEvolve(existing.verified.record) !== + canonicalCompletedEvolve(record) + ) { + throw new Error(`conflicting durable evolve completion: ${record.requestId}`) + } + await verifyPublishedEvolveCompletion({ + loopRoot, + record, + commentUrl: comment.html_url, + githubApi, + }) + continue } const verified = await verifyPublishedEvolveCompletion({ loopRoot, @@ -521,17 +585,18 @@ export async function reconcileEvolveJournal({ commentUrl: comment.html_url, githubApi, }) - completions.set(record.requestId, verified) + completions.set(record.requestId, { verified, digest: publication.digest }) } const pending = [...requests.values()].filter( - (request) => !completions.has(request.requestId), + (entry) => !completions.has(entry.request.requestId), ) if (pending.length > 1) { throw new Error('multiple durable pending evolve requests') } - for (const request of requests.values()) { - const verified = completions.get(request.requestId) + for (const entry of requests.values()) { + const request = entry.request + const verified = completions.get(request.requestId)?.verified await writeJson( path.join(loopRoot, 'evolve', 'requests', `${request.requestId}.json`), verified @@ -553,12 +618,14 @@ export async function reconcileEvolveJournal({ } const metricsPath = path.join(loopRoot, 'evolve', 'metrics.json') const metrics = await readJson(metricsPath) - const orderedCompletions = [...completions.values()].sort( - (left, right) => Date.parse(left.record.mergeAt) - Date.parse(right.record.mergeAt), - ) + const orderedCompletions = [...completions.values()] + .map((entry) => entry.verified) + .sort( + (left, right) => Date.parse(left.record.mergeAt) - Date.parse(right.record.mergeAt), + ) const latest = orderedCompletions.at(-1) metrics.evolveDue = pending.length === 1 - metrics.pendingRequestId = pending[0]?.requestId ?? null + metrics.pendingRequestId = pending[0]?.request.requestId ?? null metrics.lastEvolvedAt = latest?.record.mergeAt ?? null metrics.lastEvolvedRunCount = latest?.record.finalizedRunCount ?? 0 metrics.completedEvolveSessions = orderedCompletions.length @@ -566,6 +633,6 @@ export async function reconcileEvolveJournal({ return { durableEvolveRequestIds: [...requests.keys()], durableCompletedEvolveRequestIds: [...completions.keys()], - pendingEvolveRequestId: pending[0]?.requestId ?? null, + pendingEvolveRequestId: pending[0]?.request.requestId ?? null, } } diff --git a/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs b/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs index fab87719..646ab7e9 100644 --- a/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs +++ b/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs @@ -21,11 +21,16 @@ import { finalizationJournalConfiguration, finalizationRecordDigest, validateFinalizationRecord, + validateTerminalPauseCheckpoint, + verifyFailedOrBlockedNotification, verifyPullNotificationComment, verifyPublishedFinalization, verifyTerminalExternalProof, } from './finalization-proof.mjs' -import { verifyLatestDurableCheckpoint } from './checkpoint-proof.mjs' +import { + checkpointRecordDigest, + verifyLatestDurableCheckpoint, +} from './checkpoint-proof.mjs' import { appendValidatedEvent, readEvents, readRun } from './run-store.mjs' import { createNotification } from './notifications.mjs' import { observeOwnerApprovedMerge } from './owner-gate.mjs' @@ -50,7 +55,7 @@ export async function prepareFinalizationRecord({ const run = await readRun(loopRoot, normalizedRunId) if (run.finishedAt !== null) throw new Error('cannot prepare finalization for a finished run') const events = await readEvents(loopRoot, normalizedRunId) - await checkpointVerifier({ + const predecessorCheckpoint = await checkpointVerifier({ loopRoot, runId: normalizedRunId, events, @@ -67,13 +72,58 @@ export async function prepareFinalizationRecord({ event.payload?.notificationType === notificationType, )?.payload?.deliveryUrl ?? null) : null + let pauseProof = { + predecessorCheckpointUrl: null, + predecessorCheckpointDigest: null, + pauseStartedAt: null, + notificationNotifiedAt: null, + } + if (notificationType) { + if ( + !predecessorCheckpoint?.record || + !predecessorCheckpoint?.commentUrl || + !predecessorCheckpoint?.digest + ) { + throw new Error( + 'failed or blocked finalization requires the current durable predecessor checkpoint', + ) + } + const pause = validateTerminalPauseCheckpoint({ + checkpoint: predecessorCheckpoint.record, + status, + runId: normalizedRunId, + issueNumber: run.issueNumber, + prUrl: run.prUrl, + headSha: run.headSha, + notificationUrl, + }) + const notification = await verifyFailedOrBlockedNotification({ + loopRoot, + status, + runId: normalizedRunId, + issueNumber: run.issueNumber, + prUrl: run.prUrl, + notificationUrl, + githubApi, + }) + pauseProof = { + predecessorCheckpointUrl: predecessorCheckpoint.commentUrl, + predecessorCheckpointDigest: predecessorCheckpoint.digest, + pauseStartedAt: pause.pauseStartedAt, + notificationNotifiedAt: notification.created_at, + } + } const resultPath = path.join(runDirectory(loopRoot, normalizedRunId), 'finalization-result.json') if (await pathExists(resultPath)) { const existing = validateFinalizationRecord(await readJson(resultPath), run) if ( existing.status !== status || existing.mergeSha !== mergeSha || - existing.failureFingerprint !== failureFingerprint + existing.failureFingerprint !== failureFingerprint || + existing.predecessorCheckpointUrl !== pauseProof.predecessorCheckpointUrl || + existing.predecessorCheckpointDigest !== pauseProof.predecessorCheckpointDigest || + existing.pauseStartedAt !== pauseProof.pauseStartedAt || + existing.notificationNotifiedAt !== pauseProof.notificationNotifiedAt ) { throw new Error('a different finalization record is already prepared for this run') } @@ -99,6 +149,7 @@ export async function prepareFinalizationRecord({ readyNotifiedAt: null, completionNotifiedAt: null, notificationWebhookStatus: null, + ...pauseProof, } let recordFinishedAt = finishedAt if (status === 'completed') { @@ -175,6 +226,7 @@ export async function prepareFinalizationRecord({ readyNotifiedAt: readyNotification.comment.created_at, completionNotifiedAt: publishedCompletion.comment.created_at, notificationWebhookStatus: completionNotification.delivery.webhook, + ...pauseProof, } recordFinishedAt = new Date( Math.max(finishedAt.getTime(), Date.parse(publishedCompletion.comment.created_at)), @@ -258,6 +310,10 @@ export async function recordFinalizationPublication({ readyNotifiedAt: record.readyNotifiedAt, completionNotifiedAt: record.completionNotifiedAt, notificationWebhookStatus: record.notificationWebhookStatus, + predecessorCheckpointUrl: record.predecessorCheckpointUrl, + predecessorCheckpointDigest: record.predecessorCheckpointDigest, + pauseStartedAt: record.pauseStartedAt, + notificationNotifiedAt: record.notificationNotifiedAt, }, now, }) @@ -269,6 +325,7 @@ export async function reconcileFinalizationJournal({ now = new Date(), githubPaginatedApi = defaultGitHubPaginatedApi, githubApi = defaultGitHubApi, + latestActiveCheckpoints = null, } = {}) { const { channel, owner, repo } = await finalizationJournalConfiguration(loopRoot) const comments = await githubPaginatedApi( @@ -294,7 +351,35 @@ export async function reconcileFinalizationJournal({ }) records.push(record) } - records.sort((left, right) => Date.parse(left.finishedAt) - Date.parse(right.finishedAt)) + const recordsByRunId = new Map() + for (const record of records) { + const existing = recordsByRunId.get(record.runId) + if (existing && canonicalRecord(existing) !== canonicalRecord(record)) { + throw new Error(`conflicting durable finalization records for ${record.runId}`) + } + if (!existing) recordsByRunId.set(record.runId, record) + } + const latestActiveByRunId = Array.isArray(latestActiveCheckpoints) + ? new Map( + latestActiveCheckpoints.map((checkpoint) => [ + checkpoint.record.run.runId, + checkpoint, + ]), + ) + : null + const effectiveRecords = [...recordsByRunId.values()].filter((record) => { + if (!latestActiveByRunId || !['failed', 'blocked'].includes(record.status)) { + return true + } + const latest = latestActiveByRunId.get(record.runId) + return ( + latest && + checkpointRecordDigest(latest.record) === record.predecessorCheckpointDigest + ) + }) + effectiveRecords.sort( + (left, right) => Date.parse(left.finishedAt) - Date.parse(right.finishedAt), + ) const indexPath = path.join(loopRoot, 'logs', 'index.jsonl') const existing = (await readFile(indexPath, 'utf8')) @@ -312,7 +397,7 @@ export async function reconcileFinalizationJournal({ latestLocalState.set(entry.runId, entry.event) } } - for (const record of records) { + for (const record of effectiveRecords) { const prior = byRunId.get(record.runId) if (prior) { if (canonicalRecord(prior) !== canonicalRecord(record)) { @@ -320,13 +405,15 @@ export async function reconcileFinalizationJournal({ } if (latestLocalState.get(record.runId) === 'run_finalization_unverified') { await appendJsonLine(indexPath, { event: 'run_finalized', ...record }) + latestLocalState.set(record.runId, 'run_finalized') } continue } await appendJsonLine(indexPath, { event: 'run_finalized', ...record }) byRunId.set(record.runId, record) + latestLocalState.set(record.runId, 'run_finalized') } - const durableRunIds = new Set(records.map((record) => record.runId)) + const durableRunIds = new Set(effectiveRecords.map((record) => record.runId)) for (const runId of byRunId.keys()) { if ( !durableRunIds.has(runId) && @@ -341,5 +428,9 @@ export async function reconcileFinalizationJournal({ } } await updateEvolveMetrics({ loopRoot, now }) - return { reconciled: records.length, durableRunIds: records.map((record) => record.runId) } + return { + reconciled: effectiveRecords.length, + durableRunIds: effectiveRecords.map((record) => record.runId), + durableRecords: effectiveRecords, + } } diff --git a/loops/issue-dev-loop/scripts/lib/finalization-proof.mjs b/loops/issue-dev-loop/scripts/lib/finalization-proof.mjs index b79d937c..04f08fe1 100644 --- a/loops/issue-dev-loop/scripts/lib/finalization-proof.mjs +++ b/loops/issue-dev-loop/scripts/lib/finalization-proof.mjs @@ -10,6 +10,10 @@ import { sameGitHubLogin, sameRepository, } from './common.mjs' +import { + parseCheckpointRecord, + verifyPublishedCheckpoint, +} from './checkpoint-proof.mjs' import { observeOwnerApprovedMerge } from './owner-gate.mjs' const TERMINAL_STATUSES = new Set(['completed', 'failed', 'blocked', 'cancelled']) @@ -31,6 +35,10 @@ export function canonicalFinalizationRecord(record) { readyNotifiedAt: record.readyNotifiedAt ?? null, completionNotifiedAt: record.completionNotifiedAt ?? null, notificationWebhookStatus: record.notificationWebhookStatus ?? null, + predecessorCheckpointUrl: record.predecessorCheckpointUrl ?? null, + predecessorCheckpointDigest: record.predecessorCheckpointDigest ?? null, + pauseStartedAt: record.pauseStartedAt ?? null, + notificationNotifiedAt: record.notificationNotifiedAt ?? null, }) } @@ -53,6 +61,10 @@ export function validateFinalizationRecord(record, run = null) { 'readyNotifiedAt', 'completionNotifiedAt', 'notificationWebhookStatus', + 'predecessorCheckpointUrl', + 'predecessorCheckpointDigest', + 'pauseStartedAt', + 'notificationNotifiedAt', ].every((field) => Object.hasOwn(record, field)) ) { throw new Error('invalid finalization journal record') @@ -79,8 +91,18 @@ export function validateFinalizationRecord(record, run = null) { } if (['failed', 'blocked'].includes(record.status)) { assertNonEmpty(record.failureFingerprint, 'failureFingerprint') - if (!record.notificationUrl) { - throw new Error('failed or blocked finalization requires a notification URL') + if ( + !record.notificationUrl || + !record.predecessorCheckpointUrl || + !/^[0-9a-f]{64}$/.test(record.predecessorCheckpointDigest ?? '') || + Number.isNaN(Date.parse(record.pauseStartedAt)) || + Number.isNaN(Date.parse(record.notificationNotifiedAt)) || + Date.parse(record.notificationNotifiedAt) < Date.parse(record.pauseStartedAt) || + Date.parse(record.notificationNotifiedAt) > Date.parse(record.finishedAt) + ) { + throw new Error( + 'failed or blocked finalization requires notification and current-pause checkpoint proof', + ) } } if (record.status === 'cancelled' && (!record.prUrl || !record.headSha)) { @@ -97,6 +119,17 @@ export function validateFinalizationRecord(record, run = null) { ) { throw new Error('non-completed finalization cannot contain completion-notification proof') } + if ( + !['failed', 'blocked'].includes(record.status) && + [ + record.predecessorCheckpointUrl, + record.predecessorCheckpointDigest, + record.pauseStartedAt, + record.notificationNotifiedAt, + ].some((value) => value !== null) + ) { + throw new Error('non-failure finalization cannot contain pause checkpoint proof') + } if ( run && (record.runId !== run.runId || @@ -114,6 +147,103 @@ export function validateFinalizationRecord(record, run = null) { return record } +export function validateTerminalPauseCheckpoint({ + checkpoint, + status, + runId, + issueNumber, + prUrl, + headSha, + notificationUrl, +}) { + const expectedType = status === 'failed' ? 'loop_failed' : 'blocked' + const run = checkpoint?.run + const events = checkpoint?.events + if ( + !['failed', 'blocked'].includes(status) || + run?.runId !== runId || + run?.issueNumber !== issueNumber || + run?.prUrl !== prUrl || + run?.headSha !== headSha || + run?.status !== 'waiting_for_owner' || + run?.finishedAt !== null || + !Array.isArray(events) + ) { + throw new Error('terminal predecessor checkpoint is not the current waiting run') + } + const statusIndexes = events + .map((event, index) => ({ event, index })) + .filter(({ event }) => event.type === 'run_status_changed') + const latestStatus = statusIndexes.at(-1) + if (latestStatus?.event.status !== 'waiting_for_owner') { + throw new Error('terminal predecessor checkpoint does not contain the current pause') + } + const pauseStartedAt = latestStatus.event.timestamp + const notification = events + .map((event, index) => ({ event, index })) + .findLast( + ({ event, index }) => + event.type === 'owner_notified' && + event.status === 'delivered' && + event.payload?.notificationType === expectedType && + event.payload?.delivery?.github === 'delivered' && + event.payload?.deliveryUrl === notificationUrl && + [run.issueUrl, run.prUrl].filter(Boolean).includes(event.payload?.targetUrl) && + Date.parse(event.timestamp) >= Date.parse(pauseStartedAt) && + (index > latestStatus.index || + (index === latestStatus.index - 1 && event.timestamp === pauseStartedAt)), + ) + if (!notification) { + throw new Error( + 'terminal predecessor checkpoint lacks the delivered notification for its current pause', + ) + } + return { pauseStartedAt, notificationEvent: notification.event } +} + +export async function verifyFailedOrBlockedNotification({ + loopRoot, + status, + runId, + issueNumber, + prUrl, + notificationUrl, + githubApi = defaultGitHubApi, +}) { + const { channel, owner, repo } = await finalizationJournalConfiguration(loopRoot) + const configuredTarget = { owner, repo } + const target = parsePullCommentUrl(notificationUrl) + const issueTarget = parseGitHubTarget( + `https://github.com/${owner}/${repo}/issues/${issueNumber}`, + ) + const pullTarget = parseGitHubTarget(prUrl) + if ( + !target || + target.kind !== 'issue_comment' || + !sameRepository(target, configuredTarget) || + !['pull', 'issues'].includes(target.surface) || + (target.surface === 'issues' && + (!sameRepository(target, issueTarget) || target.number !== issueNumber)) || + (target.surface === 'pull' && + (!pullTarget || !sameRepository(target, pullTarget) || target.number !== pullTarget.number)) + ) { + throw new Error('terminal notification URL is not bound to the configured run issue or PR') + } + const comment = await githubApi( + `repos/${target.owner}/${target.repo}/issues/comments/${target.commentId}`, + ) + const expectedType = status === 'failed' ? 'loop_failed' : 'blocked' + if ( + !sameGitHubLogin(comment.user?.login, channel.automationGitHubLogin) || + !comment.body?.includes(`**${expectedType}**`) || + !comment.body?.includes(`Run: \`${runId}\``) || + Number.isNaN(Date.parse(comment.created_at)) + ) { + throw new Error('terminal notification lacks durable automation-authored proof') + } + return comment +} + export async function finalizationJournalConfiguration(loopRoot) { const channel = await readJson( path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'), @@ -216,33 +346,57 @@ export async function verifyTerminalExternalProof({ } } if (['failed', 'blocked'].includes(validated.status)) { - const target = parsePullCommentUrl(validated.notificationUrl) - const issueTarget = parseGitHubTarget( - `https://github.com/${owner}/${repo}/issues/${validated.issueNumber}`, - ) - const pullTarget = parseGitHubTarget(validated.prUrl) + const comment = await verifyFailedOrBlockedNotification({ + loopRoot, + status: validated.status, + runId: validated.runId, + issueNumber: validated.issueNumber, + prUrl: validated.prUrl, + notificationUrl: validated.notificationUrl, + githubApi, + }) + const checkpointTarget = parsePullCommentUrl(validated.predecessorCheckpointUrl) if ( - !target || - target.kind !== 'issue_comment' || - !sameRepository(target, configuredTarget) || - !['pull', 'issues'].includes(target.surface) || - (target.surface === 'issues' && - (!sameRepository(target, issueTarget) || target.number !== validated.issueNumber)) || - (target.surface === 'pull' && - (!pullTarget || !sameRepository(target, pullTarget) || target.number !== pullTarget.number)) + !checkpointTarget || + checkpointTarget.kind !== 'issue_comment' || + checkpointTarget.surface !== 'issues' || + !sameRepository(checkpointTarget, configuredTarget) || + checkpointTarget.number !== channel.stateIssueNumber ) { - throw new Error('terminal notification URL is not bound to the configured run issue or PR') + throw new Error('terminal predecessor checkpoint is not on the state journal') } - const comment = await githubApi( - `repos/${target.owner}/${target.repo}/issues/comments/${target.commentId}`, + const checkpointComment = await githubApi( + `repos/${checkpointTarget.owner}/${checkpointTarget.repo}/issues/comments/${checkpointTarget.commentId}`, ) - const expectedType = validated.status === 'failed' ? 'loop_failed' : 'blocked' + const checkpointRecord = parseCheckpointRecord(checkpointComment.body) + const durableCheckpoint = await verifyPublishedCheckpoint({ + loopRoot, + record: checkpointRecord, + commentUrl: validated.predecessorCheckpointUrl, + githubApi, + }) + if ( + durableCheckpoint.digest !== validated.predecessorCheckpointDigest + ) { + throw new Error('terminal predecessor checkpoint digest changed') + } + const pause = validateTerminalPauseCheckpoint({ + checkpoint: durableCheckpoint.record, + status: validated.status, + runId: validated.runId, + issueNumber: validated.issueNumber, + prUrl: validated.prUrl, + headSha: validated.headSha, + notificationUrl: validated.notificationUrl, + }) + if (pause.pauseStartedAt !== validated.pauseStartedAt) { + throw new Error('terminal predecessor checkpoint pause changed') + } if ( - !sameGitHubLogin(comment.user?.login, channel.automationGitHubLogin) || - !comment.body?.includes(`**${expectedType}**`) || - !comment.body?.includes(`Run: \`${validated.runId}\``) + comment.created_at !== validated.notificationNotifiedAt || + Date.parse(comment.created_at) < Date.parse(validated.pauseStartedAt) ) { - throw new Error('terminal notification lacks durable automation-authored proof') + throw new Error('terminal notification is not bound to the current pause') } } if (validated.status === 'cancelled') { diff --git a/loops/issue-dev-loop/scripts/lib/github-identity.mjs b/loops/issue-dev-loop/scripts/lib/github-identity.mjs index c0cb03b9..e44c27cd 100644 --- a/loops/issue-dev-loop/scripts/lib/github-identity.mjs +++ b/loops/issue-dev-loop/scripts/lib/github-identity.mjs @@ -611,12 +611,36 @@ function parseReviewPublication(body, authorization, { requireCurrentHead = true return null } return { + kind: 'cycle', cycle: Number(matches[0][2]), round: Number(matches[0][3]), headSha: matches[0][4].toLowerCase(), } } +function parseAdjudicationPublication(body, authorization) { + if (typeof body !== 'string') return null + const matches = [ + ...body.matchAll( + /<!-- issue-dev-loop:([^:]+):(RVW-[1-9][0-9]*-[12]-[1-9][0-9]*):adjudication:(REJECT_FINDING):head:([0-9a-f]{40}) -->/gi, + ), + ] + if ( + matches.length !== 1 || + matches[0][1] !== authorization?.issue?.runId || + matches[0][4].toLowerCase() !== authorization?.issue?.headSha?.toLowerCase() + ) { + return null + } + return { + kind: 'adjudication', + findingId: matches[0][2], + verdict: matches[0][3], + headSha: matches[0][4].toLowerCase(), + marker: matches[0][0], + } +} + function reviewerCommentReview(args, commandIndex, authorization) { const parsed = parseOptions(args, commandIndex + 1, { valueOptions: { @@ -627,7 +651,16 @@ function reviewerCommentReview(args, commandIndex, authorization) { booleanOptions: { '--comment': 'comment', '-c': 'comment' }, }) const body = exactlyOne(parsed.values, 'body') - return { parsed, body, publication: parseReviewPublication(body, authorization) } + const cyclePublication = parseReviewPublication(body, authorization) + const adjudicationPublication = parseAdjudicationPublication(body, authorization) + return { + parsed, + body, + publication: + Boolean(cyclePublication) === Boolean(adjudicationPublication) + ? null + : (cyclePublication ?? adjudicationPublication), + } } function reviewerCommentReviewAllowed(args, commandIndex, authorization, expectedRepository) { @@ -1435,19 +1468,78 @@ async function preflightPullRequestWrite({ } if (['review', 'inline-review'].includes(intent.kind)) { - const expectedCycle = - events.filter((event) => event.type === 'review_completed' && event.status === 'passed') - .length + 1 if ( livePullRequest.draft !== true || - !intent.publication || - intent.publication.cycle !== expectedCycle + !intent.publication ) { throw new Error('independent review publication requires the recorded Draft PR') } const publishedReviews = await githubPaginatedApi( `repos/${owner}/${repo}/pulls/${authorization.issue.prNumber}/reviews`, ) + if (intent.publication.kind === 'adjudication') { + if (intent.kind !== 'review') { + throw new Error('adjudication must be a body-only COMMENT review') + } + if ( + publishedReviews.some( + (review) => + review.state === 'COMMENTED' && + sameGitHubLogin(review.user?.login, channel.reviewerGitHubLogin) && + review.body?.includes(intent.publication.marker), + ) + ) { + throw new Error( + `adjudication for ${intent.publication.findingId} is already published`, + ) + } + const findingMarker = `<!-- issue-dev-loop:${runId}:${intent.publication.findingId} -->` + let findingPublished = false + for (const review of publishedReviews) { + const reviewPublication = parseReviewPublication(review.body, authorization, { + requireCurrentHead: false, + }) + if ( + review.state !== 'COMMENTED' || + !sameGitHubLogin(review.user?.login, channel.reviewerGitHubLogin) || + review.commit_id !== run.headSha || + Number.isNaN(Date.parse(review.submitted_at)) || + reviewPublication?.headSha !== run.headSha.toLowerCase() + ) { + continue + } + if (review.body?.includes(findingMarker)) { + findingPublished = true + break + } + if (!Number.isInteger(review.id)) continue + const inlineComments = await githubPaginatedApi( + `repos/${owner}/${repo}/pulls/${authorization.issue.prNumber}/reviews/${review.id}/comments`, + ) + if ( + inlineComments.some( + (comment) => + sameGitHubLogin(comment.user?.login, channel.reviewerGitHubLogin) && + comment.body?.includes(findingMarker), + ) + ) { + findingPublished = true + break + } + } + if (!findingPublished) { + throw new Error( + `adjudication requires an existing reviewer finding at the current head: ${intent.publication.findingId}`, + ) + } + return + } + const expectedCycle = + events.filter((event) => event.type === 'review_completed' && event.status === 'passed') + .length + 1 + if (intent.publication.cycle !== expectedCycle) { + throw new Error('independent review publication requires the next review cycle') + } const existingRounds = publishedReviews .filter( (review) => diff --git a/loops/issue-dev-loop/scripts/lib/github.mjs b/loops/issue-dev-loop/scripts/lib/github.mjs index 4352266e..2dadd921 100644 --- a/loops/issue-dev-loop/scripts/lib/github.mjs +++ b/loops/issue-dev-loop/scripts/lib/github.mjs @@ -7,6 +7,7 @@ import { assertNonEmpty, assertRunId, defaultGitHubApi, + defaultGitHubPaginatedApi, execFileAsync, labelNames, parseGitHubTarget, @@ -69,13 +70,27 @@ export function selectIssue({ issues = [], pullRequests = [], branchNames = [] } export async function reconcileLoopJournal({ loopRoot = DEFAULT_LOOP_ROOT, now = new Date(), + githubPaginatedApi = defaultGitHubPaginatedApi, + githubApi = defaultGitHubApi, } = {}) { - const evolve = await reconcileEvolveJournal({ loopRoot }) - const finalization = await reconcileFinalizationJournal({ loopRoot, now }) - const active = await reconcileActiveJournal({ + const evolve = await reconcileEvolveJournal({ loopRoot, githubPaginatedApi, githubApi }) + const allActive = await reconcileActiveJournal({ + loopRoot, + githubPaginatedApi, + }) + const finalization = await reconcileFinalizationJournal({ loopRoot, - terminalRunIds: finalization.durableRunIds, + now, + githubPaginatedApi, + githubApi, + latestActiveCheckpoints: allActive.activeCheckpoints, }) + const terminalRunIds = new Set(finalization.durableRunIds) + const active = { + activeCheckpoints: allActive.activeCheckpoints.filter( + (checkpoint) => !terminalRunIds.has(checkpoint.record.run.runId), + ), + } return { ...evolve, ...finalization, ...active } } diff --git a/loops/issue-dev-loop/scripts/lib/owner-gate.mjs b/loops/issue-dev-loop/scripts/lib/owner-gate.mjs index b2cccec0..7f712297 100644 --- a/loops/issue-dev-loop/scripts/lib/owner-gate.mjs +++ b/loops/issue-dev-loop/scripts/lib/owner-gate.mjs @@ -43,22 +43,30 @@ export async function observeOwnerApprovedMerge({ ]) const headSha = pullRequest.head?.sha const configuredRepository = expectedRepository ?? channel.repository - const ownerApproval = reviews.some( - (review) => - sameGitHubLogin(review.user?.login, channel.ownerGitHubLogin) && - review.state === 'APPROVED' && - review.commit_id === headSha, - ) const readinessTransitions = timeline.filter((event) => ['ready_for_review', 'convert_to_draft'].includes(event.event), ) const latestReadinessTransition = readinessTransitions.at(-1) + const latestReadyAt = Date.parse(latestReadinessTransition?.created_at) const ownerReady = latestReadinessTransition?.event === 'ready_for_review' && sameGitHubLogin(latestReadinessTransition.actor?.login, channel.ownerGitHubLogin) && - !Number.isNaN(Date.parse(latestReadinessTransition.created_at)) && - (!readyAfter || - Date.parse(latestReadinessTransition.created_at) > Date.parse(readyAfter)) + !Number.isNaN(latestReadyAt) && + (!readyAfter || latestReadyAt > Date.parse(readyAfter)) + const latestOwnerReview = reviews + .filter( + (review) => + sameGitHubLogin(review.user?.login, channel.ownerGitHubLogin) && + review.commit_id === headSha && + !Number.isNaN(Date.parse(review.submitted_at)), + ) + .sort( + (left, right) => Date.parse(left.submitted_at) - Date.parse(right.submitted_at), + ) + .at(-1) + const ownerApproval = + latestOwnerReview?.state === 'APPROVED' && + Date.parse(latestOwnerReview.submitted_at) > latestReadyAt if ( pullRequest.merged !== true || `${target.owner}/${target.repo}`.toLowerCase() !== configuredRepository.toLowerCase() || @@ -75,7 +83,7 @@ export async function observeOwnerApprovedMerge({ (expectedHeadSha !== null && headSha !== expectedHeadSha) ) { throw new Error( - 'PR is not approved and merged by the configured owner at the expected headSha with an owner-authored Ready transition', + 'PR is not merged by the configured owner at the expected headSha with an owner-authored Ready transition and a later latest owner review of APPROVED', ) } return { diff --git a/loops/issue-dev-loop/scripts/lib/validation.mjs b/loops/issue-dev-loop/scripts/lib/validation.mjs index 65930eef..a4429d6e 100644 --- a/loops/issue-dev-loop/scripts/lib/validation.mjs +++ b/loops/issue-dev-loop/scripts/lib/validation.mjs @@ -15,6 +15,37 @@ async function collectFiles(root, output = []) { return output } +export function validateFinalizationHistory(historyLines) { + const stateByRunId = new Map() + for (const entry of historyLines) { + if (!['run_finalized', 'run_finalization_unverified'].includes(entry.event)) continue + if (typeof entry.runId !== 'string' || !entry.runId) { + throw new Error('logs/index.jsonl finalization entry is missing a run ID') + } + const prior = stateByRunId.get(entry.runId) + if (entry.event === 'run_finalization_unverified') { + if (prior?.state !== 'finalized') { + throw new Error( + `logs/index.jsonl run is not currently finalized: ${entry.runId}`, + ) + } + stateByRunId.set(entry.runId, { ...prior, state: 'unverified' }) + continue + } + const { event: _event, ...record } = entry + const canonical = JSON.stringify(record) + if (prior?.state === 'finalized') { + throw new Error(`logs/index.jsonl run is already finalized: ${entry.runId}`) + } + if (prior && prior.canonical !== canonical) { + throw new Error( + `logs/index.jsonl restored finalization conflicts with its prior record: ${entry.runId}`, + ) + } + stateByRunId.set(entry.runId, { state: 'finalized', canonical }) + } +} + export async function validateLoop({ loopRoot = DEFAULT_LOOP_ROOT, activation = false, @@ -91,12 +122,7 @@ export async function validateLoop({ if (historyLines[0]?.event !== 'loop_initialized') { throw new Error('logs/index.jsonl must start with loop_initialized') } - const finalizedRunIds = historyLines - .filter((entry) => entry.event === 'run_finalized') - .map((entry) => entry.runId) - if (new Set(finalizedRunIds).size !== finalizedRunIds.length) { - throw new Error('logs/index.jsonl contains duplicate finalized run IDs') - } + validateFinalizationHistory(historyLines) const triggerLines = (await readFile(path.join(loopRoot, 'logs', 'triggers.jsonl'), 'utf8')) .split('\n') .filter(Boolean) diff --git a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs index 11773dcd..52c93b27 100644 --- a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs +++ b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs @@ -366,6 +366,10 @@ if [ "$1 $2 $3 $4" != "api user --jq .login" ]; then first_line "$parent_dir/reviews.json" exit 0 fi + if [ "$1" = "api" ] && [ "$2" = "repos/example/repo/pulls/106/reviews/400/comments?per_page=100&page=1" ]; then + printf '[]\\n' + exit 0 + fi if [ "$1" = "api" ] && [ "$2" = "repos/example/repo/issues/comments/2" ]; then first_line "$parent_dir/evolve-comment.json" exit 0 @@ -993,6 +997,72 @@ test('review publication rejects duplicate or skipped cycle rounds', async () => assert.equal(stdout.trim(), 'comment review published') }) +test('reviewer adjudication COMMENT is exact-head, finding-bound, and non-cyclic', async () => { + const fixture = await createFixture() + const headSha = 'b'.repeat(40) + const findingId = 'RVW-1-1-1' + const originalReview = { + id: 400, + commit_id: headSha, + submitted_at: '2026-07-23T00:06:00.000Z', + state: 'COMMENTED', + user: { login: 'reviewer-user' }, + body: [ + `<!-- issue-dev-loop:fixture-run:${findingId} -->`, + `<!-- issue-dev-loop:fixture-run:review-cycle:1:round:1:head:${headSha} -->`, + ].join('\n'), + } + const reviewsPath = path.join(path.dirname(fixture.loopRoot), 'reviews.json') + await writeFile(reviewsPath, `${JSON.stringify([originalReview])}\n`, 'utf8') + const marker = `<!-- issue-dev-loop:fixture-run:${findingId}:adjudication:REJECT_FINDING:head:${headSha} -->` + const publish = (body = marker) => + execFileAsync( + process.execPath, + [ + routerPath, + '--loop-root', + fixture.loopRoot, + 'reviewer', + '--', + 'gh', + 'pr', + 'review', + '106', + '--repo', + 'example/repo', + '--comment', + '--body', + body, + ], + { env: fixture.env }, + ) + const { stdout } = await publish() + assert.equal(stdout.trim(), 'comment review published') + + await assert.rejects( + publish( + `<!-- issue-dev-loop:fixture-run:RVW-1-1-2:adjudication:REJECT_FINDING:head:${headSha} -->`, + ), + /existing reviewer finding/, + ) + await writeFile( + reviewsPath, + `${JSON.stringify([ + originalReview, + { + id: 401, + commit_id: headSha, + submitted_at: '2026-07-23T00:07:00.000Z', + state: 'COMMENTED', + user: { login: 'reviewer-user' }, + body: marker, + }, + ])}\n`, + 'utf8', + ) + await assert.rejects(publish(), /already published/) +}) + test('reviewer may publish exact-head inline comments only as a COMMENT review API request', async () => { const fixture = await createFixture() const { stdout } = await execFileAsync( diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index df72d1c9..a77f53b6 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -57,6 +57,7 @@ import { import { observeOwnerApprovedMerge } from '../scripts/lib/owner-gate.mjs' import { assertCredentialProfileIsolation } from '../scripts/lib/github-identity.mjs' import { verifyTerminalExternalProof } from '../scripts/lib/finalization-proof.mjs' +import { validateFinalizationHistory } from '../scripts/lib/validation.mjs' const bypassCheckpointVerifier = async () => {} const createNotification = (options) => @@ -66,7 +67,7 @@ const freezeBrief = (options) => const prepareFinalizationRecord = (options) => runtimePrepareFinalizationRecord({ ...options, - checkpointVerifier: bypassCheckpointVerifier, + checkpointVerifier: options.checkpointVerifier ?? bypassCheckpointVerifier, }) const recordEvidence = (options) => runtimeRecordEvidence({ @@ -114,6 +115,7 @@ test('owner merge observation paginates beyond one hundred reviews', async () => user: { login: 'codeacme17' }, state: 'APPROVED', commit_id: headSha, + submitted_at: '2026-07-23T08:01:00.000Z', }, ] } @@ -163,7 +165,14 @@ test('owner merge observation requires the owner Ready transition after notifica repo: { full_name: 'codeacme17/echo-ui' }, }, } - const verify = (timeline) => + const verify = (timeline, reviews = [ + { + user: { login: 'codeacme17' }, + state: 'APPROVED', + commit_id: headSha, + submitted_at: '2026-07-23T08:31:00.000Z', + }, + ]) => observeOwnerApprovedMerge({ loopRoot, prUrl: 'https://github.com/codeacme17/echo-ui/pull/701', @@ -171,9 +180,7 @@ test('owner merge observation requires the owner Ready transition after notifica expectedHeadBranch: 'codex/issue-701', readyAfter: '2026-07-23T08:00:00.000Z', githubApi: async (endpoint) => { - if (endpoint.includes('/reviews')) { - return [{ user: { login: 'codeacme17' }, state: 'APPROVED', commit_id: headSha }] - } + if (endpoint.includes('/reviews')) return reviews if (endpoint.includes('/timeline')) return timeline return pullRequest }, @@ -223,6 +230,52 @@ test('owner merge observation requires the owner Ready transition after notifica ]), /owner-authored Ready transition/, ) + await assert.rejects( + verify( + [ + { + event: 'ready_for_review', + actor: { login: 'codeacme17' }, + created_at: '2026-07-23T08:30:00.000Z', + }, + ], + [ + { + user: { login: 'codeacme17' }, + state: 'APPROVED', + commit_id: headSha, + submitted_at: '2026-07-23T08:31:00.000Z', + }, + { + user: { login: 'codeacme17' }, + state: 'CHANGES_REQUESTED', + commit_id: headSha, + submitted_at: '2026-07-23T08:32:00.000Z', + }, + ], + ), + /latest owner review/, + ) + await assert.rejects( + verify( + [ + { + event: 'ready_for_review', + actor: { login: 'codeacme17' }, + created_at: '2026-07-23T08:30:00.000Z', + }, + ], + [ + { + user: { login: 'codeacme17' }, + state: 'APPROVED', + commit_id: headSha, + submitted_at: '2026-07-23T08:29:00.000Z', + }, + ], + ), + /latest owner review/, + ) }) async function createFixture() { @@ -332,7 +385,7 @@ async function publishFixtureCheckpoint({ loopRoot, runId }) { body: prepared.body, }), }) - return prepared + return { ...prepared, commentUrl } } function pullRequestFixture(run, headSha, { draft = true, merged = false } = {}) { @@ -585,6 +638,31 @@ async function writeFixtureFinalization({ const completionNotifiedAt = completed ? new Date(Date.parse(finishedAt) - 5 * 60_000).toISOString() : null + const failureStatus = ['failed', 'blocked'].includes(status) + const checkpointRecord = failureStatus + ? JSON.parse( + await readFile( + path.join(loopRoot, 'logs', 'runs', runId, 'checkpoint-result.json'), + 'utf8', + ), + ) + : null + const checkpointEvent = failureStatus + ? (await readFile( + path.join(loopRoot, 'logs', 'runs', runId, 'events.jsonl'), + 'utf8', + )) + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) + .findLast((event) => event.type === 'checkpoint_published') + : null + const pauseStartedAt = failureStatus + ? checkpointRecord.events.findLast( + (event) => + event.type === 'run_status_changed' && event.status === 'waiting_for_owner', + )?.timestamp + : null const record = { schemaVersion: 1, runId, @@ -605,6 +683,10 @@ async function writeFixtureFinalization({ readyNotifiedAt, completionNotifiedAt, notificationWebhookStatus: completed ? 'not_configured' : null, + predecessorCheckpointUrl: checkpointEvent?.payload?.commentUrl ?? null, + predecessorCheckpointDigest: checkpointEvent?.payload?.digest ?? null, + pauseStartedAt, + notificationNotifiedAt: failureStatus ? pauseStartedAt : null, } const resultPath = path.join(loopRoot, 'logs', 'runs', runId, 'finalization-result.json') await writeFile(resultPath, `${canonicalRecord(record)}\n`, 'utf8') @@ -612,7 +694,14 @@ async function writeFixtureFinalization({ const commentUrl = 'https://github.com/codeacme17/echo-ui/issues/999#issuecomment-9900' const githubApi = async (endpoint) => { if (endpoint.includes('/reviews')) { - return [{ user: { login: 'codeacme17' }, state: 'APPROVED', commit_id: record.headSha }] + return [ + { + user: { login: 'codeacme17' }, + state: 'APPROVED', + commit_id: record.headSha, + submitted_at: new Date(Date.now() + 120_000).toISOString(), + }, + ] } if (endpoint.includes('/timeline')) { return [ @@ -640,9 +729,26 @@ async function writeFixtureFinalization({ const notificationType = record.status === 'failed' ? 'loop_failed' : 'blocked' return { user: { login: 'echo-ui-loop[bot]' }, + created_at: record.notificationNotifiedAt, body: `@codeacme17 **${notificationType}**\n\nRun: \`${runId}\``, } } + if ( + checkpointEvent && + endpoint.endsWith( + `/issues/comments/${record.predecessorCheckpointUrl.split('#issuecomment-')[1]}`, + ) + ) { + return { + user: { login: 'echo-ui-loop[bot]' }, + body: [ + `<!-- issue-dev-loop:checkpoint:${runId}:sha256:${record.predecessorCheckpointDigest} -->`, + '```json', + canonicalCheckpoint(checkpointRecord), + '```', + ].join('\n'), + } + } if (endpoint.endsWith('/issues/comments/8802')) { return { user: { login: 'echo-ui-loop[bot]' }, @@ -1850,6 +1956,7 @@ test('owner-review waiting transition keeps the exact verified PR Draft and rema targetUrl: prUrl, evidenceUrl: 'https://github.com/codeacme17/echo-ui/actions/runs/101/artifacts/201', blocking: true, + now: new Date('2030-07-23T08:30:00.000Z'), githubComment: async () => ({ html_url: `${prUrl}#issuecomment-8802`, }), @@ -2014,7 +2121,7 @@ test('owner-review waiting transition keeps the exact verified PR Draft and rema status: 'awaiting_owner_review', prUrl, headSha, - now: new Date('2026-07-22T17:00:00Z'), + now: new Date('2030-07-23T08:42:00.000Z'), githubApi: async () => ownerReadyPullRequest, }) assert.equal(paused.status, 'awaiting_owner_review') @@ -2037,28 +2144,35 @@ test('owner-review waiting transition keeps the exact verified PR Draft and rema let preparedCompletion const completionGithubApi = async (endpoint) => { if (endpoint.includes('/reviews')) { - return [{ user: { login: 'codeacme17' }, state: 'APPROVED', commit_id: headSha }] + return [ + { + user: { login: 'codeacme17' }, + state: 'APPROVED', + commit_id: headSha, + submitted_at: '2030-07-23T08:41:00.000Z', + }, + ] } if (endpoint.includes('/timeline')) { return [ { event: 'ready_for_review', actor: { login: 'codeacme17' }, - created_at: new Date(Date.now() + 60_000).toISOString(), + created_at: '2030-07-23T08:40:00.000Z', }, ] } if (endpoint.endsWith('/issues/comments/8802')) { return { user: { login: 'echo-ui-loop[bot]' }, - created_at: '2026-07-23T08:30:00.000Z', + created_at: '2030-07-23T08:30:00.000Z', body: `@codeacme17 **pr_ready_for_review**\n\nRun: \`${run.runId}\``, } } if (endpoint.endsWith('/issues/comments/8803')) { return { user: { login: 'echo-ui-loop[bot]' }, - created_at: '2026-07-23T08:50:00.000Z', + created_at: '2030-07-23T08:50:00.000Z', body: `@codeacme17 **pr_completed**\n\nRun: \`${run.runId}\`\n\nMerge: \`${'9'.repeat(40)}\``, } } @@ -2070,7 +2184,7 @@ test('owner-review waiting transition keeps the exact verified PR Draft and rema } return { ...pullRequestFixture(run, headSha, { draft: false, merged: true }), - merged_at: '2026-07-23T08:45:00.000Z', + merged_at: '2030-07-23T08:45:00.000Z', } } await assert.rejects( @@ -2078,7 +2192,7 @@ test('owner-review waiting transition keeps the exact verified PR Draft and rema loopRoot, runId: run.runId, status: 'completed', - finishedAt: new Date('2026-07-23T09:00:00.000Z'), + finishedAt: new Date('2030-07-23T09:00:00.000Z'), mergeSha: '9'.repeat(40), githubApi: completionGithubApi, notifyOwner: async () => ({ @@ -2094,7 +2208,7 @@ test('owner-review waiting transition keeps the exact verified PR Draft and rema loopRoot, runId: run.runId, status: 'completed', - finishedAt: new Date('2026-07-23T09:00:00.000Z'), + finishedAt: new Date('2030-07-23T09:00:00.000Z'), mergeSha: '9'.repeat(40), githubApi: completionGithubApi, checkpointVerifier: bypassCheckpointVerifier, @@ -2115,13 +2229,13 @@ test('owner-review waiting transition keeps the exact verified PR Draft and rema loopRoot, record: { ...preparedCompletion.record, - completionNotifiedAt: '2026-07-23T08:40:00.000Z', + completionNotifiedAt: '2030-07-23T08:40:00.000Z', }, githubApi: async (endpoint) => { if (endpoint.endsWith('/issues/comments/8803')) { return { user: { login: 'echo-ui-loop[bot]' }, - created_at: '2026-07-23T08:40:00.000Z', + created_at: '2030-07-23T08:40:00.000Z', body: `@codeacme17 **pr_completed**\n\nRun: \`${run.runId}\`\n\nMerge: \`${'9'.repeat(40)}\``, } } @@ -2155,7 +2269,7 @@ test('owner-review waiting transition keeps the exact verified PR Draft and rema const finalized = await observeOwnerMerge({ loopRoot, runId: run.runId, - now: new Date('2026-07-23T09:00:00Z'), + now: new Date('2030-07-23T09:00:00Z'), githubApi: completionGithubApi, releaseIssueClaim: async () => {}, finalizationResultPath: preparedCompletion.resultPath, @@ -2163,7 +2277,7 @@ test('owner-review waiting transition keeps the exact verified PR Draft and rema }) assert.equal(finalized.status, 'completed') assert.equal(finalized.mergeSha, '9'.repeat(40)) - assert.equal(finalized.finishedAt, '2026-07-23T09:00:00.000Z') + assert.equal(finalized.finishedAt, '2030-07-23T09:00:00.000Z') const completedEvents = await readFile( path.join(loopRoot, 'logs', 'runs', run.runId, 'events.jsonl'), 'utf8', @@ -2175,7 +2289,7 @@ test('owner-review waiting transition keeps the exact verified PR Draft and rema ) const reconciledFinalization = await reconcileFinalizationJournal({ loopRoot, - now: new Date('2026-07-23T09:01:00.000Z'), + now: new Date('2030-07-23T09:01:00.000Z'), githubPaginatedApi: async () => [ { user: { login: 'echo-ui-loop[bot]' }, @@ -2394,6 +2508,11 @@ test('forged local blocked finalization cannot release an issue claim', async () readyNotifiedAt: null, completionNotifiedAt: null, notificationWebhookStatus: null, + predecessorCheckpointUrl: + 'https://github.com/codeacme17/echo-ui/issues/999#issuecomment-8801', + predecessorCheckpointDigest: 'a'.repeat(64), + pauseStartedAt: '2030-01-01T00:00:00.000Z', + notificationNotifiedAt: '2030-01-01T00:01:00.000Z', } await writeFile( path.join(runPath, 'run.json'), @@ -2452,6 +2571,7 @@ test('forged local blocked finalization cannot release an issue claim', async () endpoint.endsWith('/issues/comments/8800') ? { user: { login: 'echo-ui-loop[bot]' }, + created_at: '2030-01-01T00:01:00.000Z', body: `@codeacme17 **blocked**\n\nRun: \`${run.runId}\``, } : { user: { login: 'echo-ui-loop[bot]' }, body: 'forged journal publication' }, @@ -2459,7 +2579,7 @@ test('forged local blocked finalization cannot release an issue claim', async () released = true }, }), - /does not attest the exact record/, + /checkpoint|does not attest the exact record/, ) assert.equal(released, false) }) @@ -2932,8 +3052,9 @@ test('review gate binds high-severity adjudication verdict to the correct identi classification: 'rejected', responseUrl: 'https://github.com/codeacme17/echo-ui/pull/302#issuecomment-401', evidence: 'Executor disagrees.', - adjudicationUrl: 'https://github.com/codeacme17/echo-ui/pull/302#issuecomment-402', - adjudicationVerdict: 'OWNER_REJECTED_FINDING', + adjudicationUrl: + 'https://github.com/codeacme17/echo-ui/pull/302#pullrequestreview-502', + adjudicationVerdict: 'REJECT_FINDING', }, }, ], @@ -2949,7 +3070,92 @@ test('review gate binds high-severity adjudication verdict to the correct identi })}\n`, 'utf8', ) - const digest = reviewPublicationDigest(JSON.parse(await readFile(resultPath, 'utf8'))) + let digest = reviewPublicationDigest(JSON.parse(await readFile(resultPath, 'utf8'))) + const adjudicationGithubApi = (adjudicatorLogin) => async (endpoint) => { + if (endpoint.endsWith('/pulls/302/reviews?per_page=100&page=1')) { + return [ + publishedReviewFixture({ + id: 500, + runId: run.runId, + round: 1, + headSha, + }), + publishedReviewFixture({ + id: 501, + runId: run.runId, + round: 2, + headSha, + }), + { + id: 502, + commit_id: headSha, + state: 'COMMENTED', + submitted_at: '2026-07-22T17:10:00.000Z', + user: { login: adjudicatorLogin }, + body: `<!-- issue-dev-loop:${run.runId}:RVW-1-1-1:adjudication:REJECT_FINDING:head:${headSha} -->`, + }, + ] + } + if (endpoint.endsWith('/comments?per_page=100')) return [] + if (endpoint.includes('/issues/comments/401')) { + return { + user: { login: 'echo-ui-loop[bot]' }, + created_at: '2026-07-22T17:00:00.000Z', + body: `Executor disagrees.\n<!-- issue-dev-loop:${run.runId}:RVW-1-1-1:rejected -->`, + } + } + if (endpoint.endsWith('/pulls/302/reviews/502')) { + return { + commit_id: headSha, + state: 'COMMENTED', + submitted_at: '2026-07-22T17:10:00.000Z', + user: { login: adjudicatorLogin }, + body: `<!-- issue-dev-loop:${run.runId}:RVW-1-1-1:adjudication:REJECT_FINDING:head:${headSha} -->`, + } + } + if (endpoint.endsWith('/pulls/302')) return pullRequestFixture(run, headSha) + const firstRound = endpoint.includes('/reviews/500') + return { + commit_id: headSha, + state: 'COMMENTED', + submitted_at: firstRound ? '2026-07-22T16:00:00.000Z' : '2026-07-22T18:00:00.000Z', + user: { login: 'echo-ui-reviewer[bot]' }, + body: firstRound + ? [ + 'RVW-1-1-1', + 'P1', + 'high', + 'Potential public API break', + 'The export changed.', + 'Restore compatibility or adjudicate.', + `<!-- issue-dev-loop:${run.runId}:RVW-1-1-1 -->`, + `<!-- issue-dev-loop:${run.runId}:review-cycle:1:round:1:head:${headSha} -->`, + ].join('\n') + : [ + 'PASS', + 'Resolved RVW-1-1-1 through the recorded adjudication.', + `<!-- issue-dev-loop:${run.runId}:review-cycle:1:round:2:head:${headSha} -->`, + `<!-- issue-dev-loop:${run.runId}:review-result-sha256:${digest} -->`, + ].join('\n'), + } + } + await assert.rejects( + recordReview({ + loopRoot, + runId: run.runId, + resultPath, + reviewUrl: 'https://github.com/codeacme17/echo-ui/pull/302#pullrequestreview-501', + githubApi: adjudicationGithubApi('codeacme17'), + }), + /lacks independent published adjudication/, + ) + const originalResult = JSON.parse(await readFile(resultPath, 'utf8')) + const reusedCycleResult = structuredClone(originalResult) + reusedCycleResult.rounds[0].findings[0].resolution.adjudicationUrl = + 'https://github.com/codeacme17/echo-ui/pull/302#pullrequestreview-500' + await writeFile(resultPath, `${JSON.stringify(reusedCycleResult)}\n`, 'utf8') + digest = reviewPublicationDigest(reusedCycleResult) + const reusedCycleBaseApi = adjudicationGithubApi('echo-ui-reviewer[bot]') await assert.rejects( recordReview({ loopRoot, @@ -2957,66 +3163,31 @@ test('review gate binds high-severity adjudication verdict to the correct identi resultPath, reviewUrl: 'https://github.com/codeacme17/echo-ui/pull/302#pullrequestreview-501', githubApi: async (endpoint) => { - if (endpoint.endsWith('/pulls/302/reviews?per_page=100&page=1')) { - return [ - publishedReviewFixture({ - id: 500, - runId: run.runId, - round: 1, - headSha, - }), - publishedReviewFixture({ - id: 501, - runId: run.runId, - round: 2, - headSha, - }), - ] - } - if (endpoint.endsWith('/comments?per_page=100')) return [] - if (endpoint.includes('/issues/comments/401')) { - return { - user: { login: 'echo-ui-loop[bot]' }, - created_at: '2026-07-22T17:00:00.000Z', - body: `Executor disagrees.\n<!-- issue-dev-loop:${run.runId}:RVW-1-1-1:rejected -->`, - } - } - if (endpoint.includes('/issues/comments/402')) { + const response = await reusedCycleBaseApi(endpoint) + if (endpoint.endsWith('/pulls/302/reviews/500')) { return { - user: { login: 'echo-ui-reviewer[bot]' }, - created_at: '2026-07-22T17:10:00.000Z', - body: `<!-- issue-dev-loop:${run.runId}:RVW-1-1-1:adjudication:OWNER_REJECTED_FINDING -->`, + ...response, + body: [ + response.body, + `<!-- issue-dev-loop:${run.runId}:RVW-1-1-1:adjudication:REJECT_FINDING:head:${headSha} -->`, + ].join('\n'), } } - if (endpoint.endsWith('/pulls/302')) return pullRequestFixture(run, headSha) - const firstRound = endpoint.includes('/reviews/500') - return { - commit_id: headSha, - state: 'COMMENTED', - submitted_at: firstRound ? '2026-07-22T16:00:00.000Z' : '2026-07-22T18:00:00.000Z', - user: { login: 'echo-ui-reviewer[bot]' }, - body: firstRound - ? [ - 'RVW-1-1-1', - 'P1', - 'high', - 'Potential public API break', - 'The export changed.', - 'Restore compatibility or adjudicate.', - `<!-- issue-dev-loop:${run.runId}:RVW-1-1-1 -->`, - `<!-- issue-dev-loop:${run.runId}:review-cycle:1:round:1:head:${headSha} -->`, - ].join('\n') - : [ - 'PASS', - 'Resolved RVW-1-1-1 through the recorded adjudication.', - `<!-- issue-dev-loop:${run.runId}:review-cycle:1:round:2:head:${headSha} -->`, - `<!-- issue-dev-loop:${run.runId}:review-result-sha256:${digest} -->`, - ].join('\n'), - } + return response }, }), /lacks independent published adjudication/, ) + await writeFile(resultPath, `${JSON.stringify(originalResult)}\n`, 'utf8') + digest = reviewPublicationDigest(originalResult) + const recorded = await recordReview({ + loopRoot, + runId: run.runId, + resultPath, + reviewUrl: 'https://github.com/codeacme17/echo-ui/pull/302#pullrequestreview-501', + githubApi: adjudicationGithubApi('echo-ui-reviewer[bot]'), + }) + assert.equal(recorded.findingCount, 1) }) test('notification dry-run is auditable but never counts as owner delivery', async () => { @@ -3252,6 +3423,82 @@ test('a new blocker moves an owner-review run back to the decision pause', async assert.equal(paused.status, 'waiting_for_owner') }) +test('failed or blocked finalization rejects a stale notification from an earlier pause', async () => { + const { loopRoot } = await createFixture() + const { run } = await startFixtureRun({ + loopRoot, + issueNumber: 152, + issueTitle: 'Reject stale terminal notification', + issueUrl: 'https://github.com/codeacme17/echo-ui/issues/152', + now: new Date('2030-01-01T10:00:00.000Z'), + entropy: 'stale152', + }) + await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) + await createNotification({ + loopRoot, + runId: run.runId, + type: 'blocked', + summary: 'First pause blocker', + requestedAction: 'Acknowledge the first pause', + targetUrl: run.issueUrl, + blocking: true, + now: new Date('2030-01-01T10:01:00.000Z'), + githubComment: async () => ({ + html_url: `${run.issueUrl}#issuecomment-8810`, + }), + }) + await recordOwnerResponse({ + loopRoot, + runId: run.runId, + responseUrl: `${run.issueUrl}#issuecomment-8811`, + now: new Date('2030-01-01T10:02:00.000Z'), + githubApi: async () => ({ + user: { login: 'codeacme17' }, + body: `Continue. RESUME ${run.runId}`, + created_at: '2030-01-01T10:02:00.000Z', + }), + }) + await transitionRun({ + loopRoot, + runId: run.runId, + status: 'running', + now: new Date('2030-01-01T10:03:00.000Z'), + }) + await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) + await createNotification({ + loopRoot, + runId: run.runId, + type: 'clarification_required', + summary: 'Second pause needs a different owner decision', + requestedAction: 'Clarify the second pause', + targetUrl: run.issueUrl, + blocking: true, + now: new Date('2030-01-01T10:04:00.000Z'), + githubComment: async () => ({ + html_url: `${run.issueUrl}#issuecomment-8812`, + }), + }) + const predecessor = await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) + await assert.rejects( + prepareFinalizationRecord({ + loopRoot, + runId: run.runId, + status: 'blocked', + failureFingerprint: 'stale-first-pause', + finishedAt: new Date('2030-01-01T10:05:00.000Z'), + checkpointVerifier: async () => ({ + record: predecessor.record, + digest: predecessor.digest, + commentUrl: predecessor.commentUrl, + }), + githubApi: async () => { + throw new Error('stale notification must fail before remote publication lookup') + }, + }), + /current pause/, + ) +}) + test('preparing the same terminal journal record is idempotent', async () => { const { loopRoot } = await createFixture() const { run } = await startFixtureRun({ @@ -3274,7 +3521,28 @@ test('preparing the same terminal journal record is idempotent', async () => { html_url: `${run.issueUrl}#issuecomment-8801`, }), }) - await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) + const predecessor = await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) + const pauseStartedAt = predecessor.record.events.findLast( + (event) => event.type === 'run_status_changed' && event.status === 'waiting_for_owner', + ).timestamp + const githubApi = async (endpoint) => { + if (endpoint.endsWith('/issues/comments/8801')) { + return { + user: { login: 'echo-ui-loop[bot]' }, + created_at: pauseStartedAt, + body: `@codeacme17 **blocked**\n\nRun: \`${run.runId}\``, + } + } + return { + user: { login: 'echo-ui-loop[bot]' }, + body: predecessor.body, + } + } + const checkpointVerifier = async () => ({ + record: predecessor.record, + digest: predecessor.digest, + commentUrl: predecessor.commentUrl, + }) const firstFinishedAt = new Date(Date.parse(run.startedAt) + 60_000) const retryFinishedAt = new Date(Date.parse(run.startedAt) + 120_000) const first = await prepareFinalizationRecord({ @@ -3283,10 +3551,8 @@ test('preparing the same terminal journal record is idempotent', async () => { status: 'blocked', failureFingerprint: 'same-terminal-cause', finishedAt: firstFinishedAt, - githubApi: async () => ({ - user: { login: 'echo-ui-loop[bot]' }, - body: `@codeacme17 **blocked**\n\nRun: \`${run.runId}\``, - }), + githubApi, + checkpointVerifier, }) const retried = await prepareFinalizationRecord({ loopRoot, @@ -3294,10 +3560,8 @@ test('preparing the same terminal journal record is idempotent', async () => { status: 'blocked', failureFingerprint: 'same-terminal-cause', finishedAt: retryFinishedAt, - githubApi: async () => ({ - user: { login: 'echo-ui-loop[bot]' }, - body: `@codeacme17 **blocked**\n\nRun: \`${run.runId}\``, - }), + githubApi, + checkpointVerifier, }) assert.equal(retried.digest, first.digest) assert.equal(retried.record.finishedAt, firstFinishedAt.toISOString()) @@ -3322,8 +3586,11 @@ test('three matching failures make a fresh evolve session due', async () => { requestedAction: 'Restore the verification environment', targetUrl: run.issueUrl, blocking: true, - githubComment: async () => {}, + githubComment: async () => ({ + html_url: `${run.issueUrl}#issuecomment-8800`, + }), }) + await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) const finalizationOptions = { loopRoot, runId: run.runId, @@ -3370,9 +3637,86 @@ test('three matching failures make a fresh evolve session due', async () => { test('fresh worktrees rebuild finalization history and evolve metrics from GitHub journal', async () => { const { loopRoot } = await createFixture() + const durableRunId = '20260722T120000Z-issue-205-journal' + const notificationUrl = + 'https://github.com/codeacme17/echo-ui/issues/205#issuecomment-8802' + const predecessorCheckpointUrl = + 'https://github.com/codeacme17/echo-ui/issues/999#issuecomment-8801' + const pauseStartedAt = '2026-07-22T12:30:00.000Z' + const checkpointRecord = { + schemaVersion: 1, + kind: 'active-checkpoint', + run: { + schemaVersion: 1, + runId: durableRunId, + issueNumber: 205, + issueTitle: 'Durable blocked run', + issueUrl: 'https://github.com/codeacme17/echo-ui/issues/205', + baseBranch: 'dev', + baseSha: '0'.repeat(40), + branch: 'codex/issue-205', + status: 'waiting_for_owner', + startedAt: '2026-07-22T12:00:00.000Z', + finishedAt: null, + prUrl: null, + headSha: null, + mergeSha: null, + issueSnapshot: { + title: 'Durable blocked run', + body: '', + labels: ['codex-ready'], + url: 'https://github.com/codeacme17/echo-ui/issues/205', + capturedAt: '2026-07-22T12:00:00.000Z', + }, + briefDigest: null, + uiEvidenceRequired: null, + implementationCommit: null, + }, + briefSource: '', + events: [ + { + schemaVersion: 1, + runId: durableRunId, + type: 'loop_started', + timestamp: '2026-07-22T12:00:00.000Z', + status: 'running', + payload: { issueNumber: 205, branch: 'codex/issue-205' }, + }, + { + schemaVersion: 1, + runId: durableRunId, + type: 'owner_notified', + timestamp: pauseStartedAt, + status: 'delivered', + payload: { + notificationType: 'blocked', + delivery: { github: 'delivered' }, + deliveryUrl: notificationUrl, + targetUrl: 'https://github.com/codeacme17/echo-ui/issues/205', + }, + }, + { + schemaVersion: 1, + runId: durableRunId, + type: 'run_status_changed', + timestamp: pauseStartedAt, + status: 'waiting_for_owner', + payload: { previousStatus: 'running' }, + }, + ], + artifacts: [], + updatedAt: pauseStartedAt, + } + const predecessorCheckpointDigest = checkpointDigest(checkpointRecord) + const checkpointBody = [ + `<!-- issue-dev-loop:checkpoint:${durableRunId}:sha256:${predecessorCheckpointDigest} -->`, + '```json', + canonicalCheckpoint(checkpointRecord), + '```', + ].join('\n') const record = { schemaVersion: 1, - runId: '20260722T120000Z-issue-205-journal', + runId: durableRunId, issueNumber: 205, status: 'blocked', startedAt: '2026-07-22T12:00:00.000Z', @@ -3381,11 +3725,15 @@ test('fresh worktrees rebuild finalization history and evolve metrics from GitHu headSha: null, mergeSha: null, failureFingerprint: 'persistent-browser-failure', - notificationUrl: 'https://github.com/codeacme17/echo-ui/issues/205#issuecomment-8802', + notificationUrl, readyNotificationUrl: null, readyNotifiedAt: null, completionNotifiedAt: null, notificationWebhookStatus: null, + predecessorCheckpointUrl, + predecessorCheckpointDigest, + pauseStartedAt, + notificationNotifiedAt: pauseStartedAt, } const digest = recordDigest(record) const foreignRecord = { @@ -3393,6 +3741,34 @@ test('fresh worktrees rebuild finalization history and evolve metrics from GitHu notificationUrl: 'https://github.com/another/repository/issues/205#issuecomment-8802', } const foreignDigest = recordDigest(foreignRecord) + const finalizationComments = [ + { + user: { login: 'echo-ui-loop[bot]' }, + html_url: 'https://github.com/codeacme17/echo-ui/issues/999#issuecomment-9901', + body: [ + `<!-- issue-dev-loop:finalization:${record.runId}:sha256:${digest} -->`, + '```json', + canonicalRecord(record), + '```', + ].join('\n'), + }, + ] + const reconciliationGithubApi = async (endpoint) => { + if (endpoint.endsWith('/issues/comments/8801')) { + return { user: { login: 'echo-ui-loop[bot]' }, body: checkpointBody } + } + if (endpoint.endsWith('/issues/comments/8802')) { + return { + user: { login: 'echo-ui-loop[bot]' }, + created_at: pauseStartedAt, + body: `@codeacme17 **blocked**\n\nRun: \`${record.runId}\``, + } + } + return { + user: { login: 'echo-ui-loop[bot]' }, + body: finalizationComments[0].body, + } + } await assert.rejects( reconcileFinalizationJournal({ loopRoot, @@ -3414,33 +3790,15 @@ test('fresh worktrees rebuild finalization history and evolve metrics from GitHu const result = await reconcileFinalizationJournal({ loopRoot, now: new Date('2026-07-22T14:00:00.000Z'), - githubPaginatedApi: async () => [ + githubPaginatedApi: async () => finalizationComments, + githubApi: reconciliationGithubApi, + latestActiveCheckpoints: [ { - user: { login: 'echo-ui-loop[bot]' }, - html_url: 'https://github.com/codeacme17/echo-ui/issues/999#issuecomment-9901', - body: [ - `<!-- issue-dev-loop:finalization:${record.runId}:sha256:${digest} -->`, - '```json', - canonicalRecord(record), - '```', - ].join('\n'), + record: checkpointRecord, + commentUrl: predecessorCheckpointUrl, + createdAt: pauseStartedAt, }, ], - githubApi: async (endpoint) => - endpoint.endsWith('/issues/comments/8802') - ? { - user: { login: 'echo-ui-loop[bot]' }, - body: `@codeacme17 **blocked**\n\nRun: \`${record.runId}\``, - } - : { - user: { login: 'echo-ui-loop[bot]' }, - body: [ - `<!-- issue-dev-loop:finalization:${record.runId}:sha256:${digest} -->`, - '```json', - canonicalRecord(record), - '```', - ].join('\n'), - }, }) assert.deepEqual(result.durableRunIds, [record.runId]) const history = await readFile(path.join(loopRoot, 'logs', 'index.jsonl'), 'utf8') @@ -3448,6 +3806,127 @@ test('fresh worktrees rebuild finalization history and evolve metrics from GitHu const metrics = await getEvolveStatus({ loopRoot }) assert.equal(metrics.finalizedRuns, 1) assert.equal(metrics.failedRuns, 1) + + const latestCheckpointRecord = structuredClone(checkpointRecord) + latestCheckpointRecord.events.push( + { + schemaVersion: 1, + runId: durableRunId, + type: 'owner_response_observed', + timestamp: '2026-07-22T13:10:00.000Z', + status: 'observed', + payload: { actor: 'codeacme17' }, + }, + { + schemaVersion: 1, + runId: durableRunId, + type: 'run_status_changed', + timestamp: '2026-07-22T13:11:00.000Z', + status: 'running', + payload: { previousStatus: 'waiting_for_owner' }, + }, + { + schemaVersion: 1, + runId: durableRunId, + type: 'owner_notified', + timestamp: '2026-07-22T13:20:00.000Z', + status: 'delivered', + payload: { + notificationType: 'clarification_required', + delivery: { github: 'delivered' }, + deliveryUrl: + 'https://github.com/codeacme17/echo-ui/issues/205#issuecomment-8811', + targetUrl: 'https://github.com/codeacme17/echo-ui/issues/205', + }, + }, + { + schemaVersion: 1, + runId: durableRunId, + type: 'run_status_changed', + timestamp: '2026-07-22T13:20:00.000Z', + status: 'waiting_for_owner', + payload: { previousStatus: 'running' }, + }, + ) + latestCheckpointRecord.updatedAt = '2026-07-22T13:20:00.000Z' + const latestCheckpointDigest = checkpointDigest(latestCheckpointRecord) + const latestCheckpointUrl = + 'https://github.com/codeacme17/echo-ui/issues/999#issuecomment-8810' + const latestCheckpointBody = [ + `<!-- issue-dev-loop:checkpoint:${durableRunId}:sha256:${latestCheckpointDigest} -->`, + '```json', + canonicalCheckpoint(latestCheckpointRecord), + '```', + ].join('\n') + const allActive = await reconcileActiveJournal({ + loopRoot, + githubPaginatedApi: async () => [ + { + user: { login: 'echo-ui-loop[bot]' }, + html_url: predecessorCheckpointUrl, + body: checkpointBody, + created_at: pauseStartedAt, + }, + { + user: { login: 'echo-ui-loop[bot]' }, + html_url: latestCheckpointUrl, + body: latestCheckpointBody, + created_at: latestCheckpointRecord.updatedAt, + }, + ], + }) + assert.equal(allActive.activeCheckpoints[0].commentUrl, latestCheckpointUrl) + const superseded = await reconcileFinalizationJournal({ + loopRoot, + now: new Date('2026-07-22T14:01:00.000Z'), + githubPaginatedApi: async () => finalizationComments, + githubApi: reconciliationGithubApi, + latestActiveCheckpoints: allActive.activeCheckpoints, + }) + assert.deepEqual(superseded.durableRunIds, []) + const supersededHistory = await readFile( + path.join(loopRoot, 'logs', 'index.jsonl'), + 'utf8', + ) + assert.match(supersededHistory, /run_finalization_unverified/) + const supersededMetrics = await getEvolveStatus({ loopRoot }) + assert.equal(supersededMetrics.finalizedRuns, 0) + + const restored = await reconcileFinalizationJournal({ + loopRoot, + now: new Date('2026-07-22T14:02:00.000Z'), + githubPaginatedApi: async () => [ + finalizationComments[0], + { + ...finalizationComments[0], + html_url: 'https://github.com/codeacme17/echo-ui/issues/999#issuecomment-9902', + }, + ], + githubApi: reconciliationGithubApi, + latestActiveCheckpoints: [ + { + record: checkpointRecord, + commentUrl: predecessorCheckpointUrl, + createdAt: pauseStartedAt, + }, + ], + }) + assert.equal(restored.reconciled, 1) + assert.deepEqual(restored.durableRunIds, [record.runId]) + const restoredHistory = (await readFile( + path.join(loopRoot, 'logs', 'index.jsonl'), + 'utf8', + )) + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) + assert.doesNotThrow(() => validateFinalizationHistory(restoredHistory)) + assert.equal( + restoredHistory.filter( + (entry) => entry.event === 'run_finalized' && entry.runId === record.runId, + ).length, + 2, + ) }) test('reconciliation excludes local finalization rows without a durable journal record', async () => { @@ -3493,6 +3972,48 @@ test('reconciliation excludes local finalization rows without a durable journal assert.match(reconciledIndex, /run_finalization_unverified/) }) +test('finalization history permits durable restoration only after a tombstone', () => { + const finalized = { + schemaVersion: 1, + event: 'run_finalized', + runId: 'restored-run', + status: 'blocked', + } + assert.doesNotThrow(() => + validateFinalizationHistory([ + { schemaVersion: 1, event: 'loop_initialized' }, + finalized, + { + schemaVersion: 1, + event: 'run_finalization_unverified', + runId: 'restored-run', + }, + finalized, + ]), + ) + assert.throws( + () => + validateFinalizationHistory([ + { schemaVersion: 1, event: 'loop_initialized' }, + finalized, + finalized, + ]), + /already finalized/, + ) + assert.throws( + () => + validateFinalizationHistory([ + { schemaVersion: 1, event: 'loop_initialized' }, + { + schemaVersion: 1, + event: 'run_finalization_unverified', + runId: 'restored-run', + }, + ]), + /not currently finalized/, + ) +}) + test('fresh worktrees restore active checkpoints and trigger resumable work', async () => { const { loopRoot } = await createFixture() const { run } = await startFixtureRun({ @@ -3532,6 +4053,57 @@ test('fresh worktrees restore active checkpoints and trigger resumable work', as githubPaginatedApi: async () => [durableComment], }) assert.equal(reconciled.activeCheckpoints[0].record.run.runId, run.runId) + + const tiedRecord = structuredClone(prepared.record) + tiedRecord.events.push({ + schemaVersion: 1, + runId: run.runId, + type: 'owner_response_observed', + timestamp: tiedRecord.updatedAt, + status: 'observed', + payload: { actor: 'codeacme17' }, + }) + const tiedDigest = checkpointDigest(tiedRecord) + const tiedBody = [ + `<!-- issue-dev-loop:checkpoint:${run.runId}:sha256:${tiedDigest} -->`, + '```json', + canonicalCheckpoint(tiedRecord), + '```', + ].join('\n') + const tied = await reconcileActiveJournal({ + loopRoot, + githubPaginatedApi: async () => [ + { + user: { login: 'echo-ui-loop[bot]' }, + id: 9911, + body: tiedBody, + created_at: durableComment.created_at, + }, + { + ...durableComment, + id: 9910, + }, + ], + }) + assert.equal(checkpointDigest(tied.activeCheckpoints[0].record), tiedDigest) + await assert.rejects( + reconcileActiveJournal({ + loopRoot, + githubPaginatedApi: async () => [ + { + user: { login: 'echo-ui-loop[bot]' }, + body: tiedBody, + created_at: durableComment.created_at, + }, + { + user: { login: 'echo-ui-loop[bot]' }, + body: prepared.body, + created_at: durableComment.created_at, + }, + ], + }), + /ambiguous durable active checkpoints/, + ) await restoreActiveCheckpoint({ loopRoot, checkpoint: reconciled.activeCheckpoints[0], @@ -3748,7 +4320,7 @@ test('evolve completion rejects an unrelated historical owner-merged PR', async } }, }), - /not approved and merged by the configured owner/, + /configured owner/, ) }) @@ -3798,6 +4370,7 @@ test('fresh worktrees rebuild pending and completed evolve state from the durabl body: preparedRequest.body, }), }) + const publishedPendingRequest = JSON.parse(await readFile(requestPath, 'utf8')) let completionBody = null const githubApi = async (endpoint) => { @@ -3820,6 +4393,7 @@ test('fresh worktrees rebuild pending and completed evolve state from the durabl user: { login: 'codeacme17' }, state: 'APPROVED', commit_id: headSha, + submitted_at: '2026-07-23T12:31:00.000Z', }, ] } @@ -3854,6 +4428,7 @@ test('fresh worktrees rebuild pending and completed evolve state from the durabl prUrl, now: new Date('2026-07-23T13:02:00.000Z'), githubApi, + githubPaginatedApi: async () => [], githubComment: async (_target, body) => { completionBody = body return { html_url: completionUrl } @@ -3866,8 +4441,53 @@ test('fresh worktrees rebuild pending and completed evolve state from the durabl assert.equal(metrics.lastEvolvedRunCount, 10) assert.equal(metrics.completedEvolveSessions, 1) + await writeFile(requestPath, `${JSON.stringify(publishedPendingRequest)}\n`, 'utf8') + await writeFile( + metricsPath, + `${JSON.stringify({ + ...initialMetrics, + finalizedRuns: 10, + evolveDue: true, + pendingRequestId: requestId, + })}\n`, + 'utf8', + ) + let duplicatePublicationAttempted = false + const retried = await completeEvolve({ + loopRoot, + requestId, + summary: 'Batch empty trigger checks before waking an executor.', + prUrl, + now: new Date('2026-07-23T13:03:00.000Z'), + githubApi, + githubPaginatedApi: async () => [ + { + user: { login: 'echo-ui-loop[bot]' }, + html_url: requestUrl, + body: preparedRequest.body, + }, + { + user: { login: 'echo-ui-loop[bot]' }, + html_url: completionUrl, + body: completionBody, + created_at: '2026-07-23T13:01:00.000Z', + }, + ], + githubComment: async () => { + duplicatePublicationAttempted = true + throw new Error('completion must be reused') + }, + verifyAutomationIdentity: async () => {}, + }) + assert.equal(retried.completionPublicationUrl, completionUrl) + assert.equal(duplicatePublicationAttempted, false) + await writeFile(metricsPath, `${JSON.stringify(initialMetrics)}\n`, 'utf8') await rm(requestPath) + const duplicateRequestUrl = + 'https://github.com/codeacme17/echo-ui/issues/999#issuecomment-7200' + const duplicateCompletionUrl = + 'https://github.com/codeacme17/echo-ui/issues/999#issuecomment-7201' const reconciled = await reconcileEvolveJournal({ loopRoot, githubPaginatedApi: async () => [ @@ -3882,8 +4502,34 @@ test('fresh worktrees rebuild pending and completed evolve state from the durabl body: completionBody, created_at: '2026-07-23T13:01:00.000Z', }, + { + user: { login: 'echo-ui-loop[bot]' }, + html_url: duplicateRequestUrl, + body: preparedRequest.body, + }, + { + user: { login: 'echo-ui-loop[bot]' }, + html_url: duplicateCompletionUrl, + body: completionBody, + created_at: '2026-07-23T13:01:01.000Z', + }, ], - githubApi, + githubApi: async (endpoint) => { + if (endpoint.endsWith('/issues/comments/7200')) { + return { + user: { login: 'echo-ui-loop[bot]' }, + body: preparedRequest.body, + } + } + if (endpoint.endsWith('/issues/comments/7201')) { + return { + user: { login: 'echo-ui-loop[bot]' }, + body: completionBody, + created_at: '2026-07-23T13:01:01.000Z', + } + } + return githubApi(endpoint) + }, }) assert.deepEqual(reconciled.durableCompletedEvolveRequestIds, [requestId]) metrics = await getEvolveStatus({ loopRoot }) From a52c807d6789ce642d5555a59ca1b59e08832f23 Mon Sep 17 00:00:00 2001 From: leyoonafr <codeacme17@gmail.com> Date: Fri, 24 Jul 2026 00:26:33 +0800 Subject: [PATCH 36/41] fix: validate restored finalization state --- .../scripts/lib/finalization-journal.mjs | 2 + .../issue-dev-loop/scripts/lib/run-store.mjs | 26 +++++------- loops/issue-dev-loop/tests/runtime.test.mjs | 41 ++++++++++++++++++- 3 files changed, 53 insertions(+), 16 deletions(-) diff --git a/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs b/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs index 646ab7e9..8ea6421b 100644 --- a/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs +++ b/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs @@ -34,6 +34,7 @@ import { import { appendValidatedEvent, readEvents, readRun } from './run-store.mjs' import { createNotification } from './notifications.mjs' import { observeOwnerApprovedMerge } from './owner-gate.mjs' +import { validateFinalizationHistory } from './validation.mjs' const TERMINAL_STATUSES = new Set(['completed', 'failed', 'blocked', 'cancelled']) @@ -386,6 +387,7 @@ export async function reconcileFinalizationJournal({ .split('\n') .filter(Boolean) .map((line) => JSON.parse(line)) + validateFinalizationHistory(existing) const byRunId = new Map( existing .filter((entry) => entry.event === 'run_finalized') diff --git a/loops/issue-dev-loop/scripts/lib/run-store.mjs b/loops/issue-dev-loop/scripts/lib/run-store.mjs index 9e2efc65..fb88bfa3 100644 --- a/loops/issue-dev-loop/scripts/lib/run-store.mjs +++ b/loops/issue-dev-loop/scripts/lib/run-store.mjs @@ -748,6 +748,7 @@ async function ensureFinalizationArtifacts({ .filter(Boolean) .map((line) => JSON.parse(line)) if (!indexEntries.some((entry) => entry.event === 'run_finalized' && entry.runId === run.runId)) { + const publication = events.findLast((event) => event.type === 'finalization_published') await appendJsonLine(indexPath, { schemaVersion: 1, event: 'run_finalized', @@ -760,21 +761,16 @@ async function ensureFinalizationArtifacts({ headSha: run.headSha, mergeSha: run.mergeSha, failureFingerprint, - notificationUrl: - events.findLast((event) => event.type === 'finalization_published')?.payload - ?.notificationUrl ?? null, - readyNotificationUrl: - events.findLast((event) => event.type === 'finalization_published')?.payload - ?.readyNotificationUrl ?? null, - readyNotifiedAt: - events.findLast((event) => event.type === 'finalization_published')?.payload - ?.readyNotifiedAt ?? null, - completionNotifiedAt: - events.findLast((event) => event.type === 'finalization_published')?.payload - ?.completionNotifiedAt ?? null, - notificationWebhookStatus: - events.findLast((event) => event.type === 'finalization_published')?.payload - ?.notificationWebhookStatus ?? null, + notificationUrl: publication?.payload?.notificationUrl ?? null, + readyNotificationUrl: publication?.payload?.readyNotificationUrl ?? null, + readyNotifiedAt: publication?.payload?.readyNotifiedAt ?? null, + completionNotifiedAt: publication?.payload?.completionNotifiedAt ?? null, + notificationWebhookStatus: publication?.payload?.notificationWebhookStatus ?? null, + predecessorCheckpointUrl: publication?.payload?.predecessorCheckpointUrl ?? null, + predecessorCheckpointDigest: + publication?.payload?.predecessorCheckpointDigest ?? null, + pauseStartedAt: publication?.payload?.pauseStartedAt ?? null, + notificationNotifiedAt: publication?.payload?.notificationNotifiedAt ?? null, }) } await updateEvolveMetrics({ loopRoot, now }) diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index a77f53b6..49d1e98f 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -3613,7 +3613,22 @@ test('three matching failures make a fresh evolve session due', async () => { }) finalizationOptions.githubApi = durableFinalization.githubApi await finalizeRun(finalizationOptions) - if (issueNumber === 201) await finalizeRun(finalizationOptions) + if (issueNumber === 201) { + const durableComment = await durableFinalization.githubApi('finalization-comment') + const reconciled = await reconcileFinalizationJournal({ + loopRoot, + githubPaginatedApi: async () => [ + { + user: { login: 'echo-ui-loop[bot]' }, + html_url: durableFinalization.commentUrl, + body: durableComment.body, + }, + ], + githubApi: durableFinalization.githubApi, + }) + assert.deepEqual(reconciled.durableRunIds, [run.runId]) + await finalizeRun(finalizationOptions) + } } const metrics = await getEvolveStatus({ loopRoot }) assert.equal(metrics.evolveDue, true) @@ -3972,6 +3987,30 @@ test('reconciliation excludes local finalization rows without a durable journal assert.match(reconciledIndex, /run_finalization_unverified/) }) +test('reconciliation rejects malformed local finalization history before mutation', async () => { + const { loopRoot } = await createFixture() + const indexPath = path.join(loopRoot, 'logs', 'index.jsonl') + const malformed = [ + { schemaVersion: 1, event: 'loop_initialized' }, + { + schemaVersion: 1, + event: 'run_finalization_unverified', + runId: 'never-finalized', + timestamp: '2026-07-22T12:00:00.000Z', + }, + ] + const source = `${malformed.map((entry) => JSON.stringify(entry)).join('\n')}\n` + await writeFile(indexPath, source, 'utf8') + await assert.rejects( + reconcileFinalizationJournal({ + loopRoot, + githubPaginatedApi: async () => [], + }), + /not currently finalized/, + ) + assert.equal(await readFile(indexPath, 'utf8'), source) +}) + test('finalization history permits durable restoration only after a tombstone', () => { const finalized = { schemaVersion: 1, From 0366d287e8129ed056a6f4880b713d2e1f0bcced Mon Sep 17 00:00:00 2001 From: leyoonafr <codeacme17@gmail.com> Date: Fri, 24 Jul 2026 00:42:27 +0800 Subject: [PATCH 37/41] fix: enforce loop trust boundaries --- loops/issue-dev-loop/SKILL.md | 7 +- loops/issue-dev-loop/agents/openai.yaml | 2 +- .../scripts/lib/checkpoint-proof.mjs | 65 ++++++- loops/issue-dev-loop/scripts/lib/common.mjs | 21 +++ loops/issue-dev-loop/scripts/lib/evidence.mjs | 13 +- .../scripts/lib/github-identity.mjs | 57 +++++-- loops/issue-dev-loop/scripts/lib/github.mjs | 6 + .../issue-dev-loop/scripts/lib/owner-gate.mjs | 27 ++- .../issue-dev-loop/scripts/lib/run-store.mjs | 63 ++++--- .../tests/github-identity-routing.test.mjs | 134 ++++++++++++++- loops/issue-dev-loop/tests/runtime.test.mjs | 161 ++++++++++++++++-- loops/issue-dev-loop/triggers/TRIGGER.md | 2 +- .../triggers/codex-automation-prompt.md | 2 +- 13 files changed, 471 insertions(+), 89 deletions(-) diff --git a/loops/issue-dev-loop/SKILL.md b/loops/issue-dev-loop/SKILL.md index 2721d778..21ee7b81 100644 --- a/loops/issue-dev-loop/SKILL.md +++ b/loops/issue-dev-loop/SKILL.md @@ -1,10 +1,7 @@ ---- -name: issue-dev-loop -description: Run one auditable Echo UI issue-development cycle from `codex-ready` issue selection through implementation, independent PR review, verification, owner notification, and owner-only merge. Use when Codex is scheduled to maintain Echo UI, explicitly asked to run the issue loop, or resuming an active loop-created PR. Do not use for releases, direct changes to main, or work without an eligible GitHub issue. ---- - # Echo UI issue development loop +This is the loop package's internal agent runbook, retained alongside the contract. It is not a registered top-level Codex skill and must be read by repository path. + Run exactly one bounded issue cycle. Treat [`LOOP.md`](./LOOP.md) as the constitution and [`state.md`](./state.md) as the current durable state. ## Start safely diff --git a/loops/issue-dev-loop/agents/openai.yaml b/loops/issue-dev-loop/agents/openai.yaml index ed759af9..61b611a8 100644 --- a/loops/issue-dev-loop/agents/openai.yaml +++ b/loops/issue-dev-loop/agents/openai.yaml @@ -1,6 +1,6 @@ interface: display_name: 'Echo UI Issue Dev Loop' short_description: 'Run audited Echo UI issue development loops' - default_prompt: 'Use $issue-dev-loop to select and develop one eligible Echo UI issue with independent review and owner-only merge.' + default_prompt: 'Execute loops/issue-dev-loop by reading LOOP.md and the internal SKILL.md runbook, then develop one eligible Echo UI issue with independent review and owner-only merge.' policy: allow_implicit_invocation: false diff --git a/loops/issue-dev-loop/scripts/lib/checkpoint-proof.mjs b/loops/issue-dev-loop/scripts/lib/checkpoint-proof.mjs index 0c800182..49346a6c 100644 --- a/loops/issue-dev-loop/scripts/lib/checkpoint-proof.mjs +++ b/loops/issue-dev-loop/scripts/lib/checkpoint-proof.mjs @@ -145,6 +145,63 @@ export function checkpointRecordDigest(record) { return createHash('sha256').update(canonicalCheckpointRecord(record)).digest('hex') } +function validateImplementationBoundary(record) { + const { run, events, artifacts } = record + if (run.implementationCommit === null) return + if (!/^[0-9a-f]{40}$/i.test(run.implementationCommit ?? '') || !run.briefDigest) { + throw new Error('active checkpoint has an invalid $implement boundary') + } + const matches = events.filter( + (event) => + event.type === 'implementation_completed' && + event.status === 'passed' && + event.payload?.agent === '$implement' && + event.payload?.commitSha === run.implementationCommit && + event.payload?.briefDigest === run.briefDigest, + ) + if (matches.length !== 1) { + throw new Error('active checkpoint $implement commit lacks one matching durable event') + } + const event = matches[0] + const resultPath = event.payload?.resultPath + const resultDigest = event.payload?.resultDigest + const artifact = artifacts.find((candidate) => candidate.path === resultPath) + if ( + typeof resultPath !== 'string' || + !/^[0-9a-f]{64}$/.test(resultDigest ?? '') || + artifact?.sha256 !== resultDigest + ) { + throw new Error('active checkpoint $implement event lacks its digest-bound result') + } + let result + try { + result = JSON.parse(artifact.source) + } catch { + throw new Error('active checkpoint contains an invalid $implement result') + } + const startedAt = Date.parse(result?.startedAt) + const finishedAt = Date.parse(result?.finishedAt) + if ( + result?.schemaVersion !== 1 || + result.runId !== run.runId || + result.agent !== '$implement' || + result.invocationId !== event.payload?.invocationId || + result.startedAt !== event.payload?.startedAt || + result.finishedAt !== event.payload?.finishedAt || + result.briefDigest !== run.briefDigest || + result.commitSha !== run.implementationCommit || + Number.isNaN(startedAt) || + Number.isNaN(finishedAt) || + finishedAt < startedAt || + !Array.isArray(result.checks) || + result.checks.length === 0 || + result.checks.some((check) => check?.status !== 'passed') || + !result.checks.some((check) => /^pnpm verify(?:\s|$)/.test(check.command ?? '')) + ) { + throw new Error('active checkpoint $implement result does not attest the recorded boundary') + } +} + export function validateCheckpointRecord(record) { const run = record?.run const events = record?.events @@ -207,17 +264,19 @@ export function validateCheckpointRecord(record) { ) { throw new Error('active checkpoint brief does not match the frozen digest') } + validateImplementationBoundary(record) return record } export function checkpointPublicationBody(record) { - const digest = checkpointRecordDigest(record) + const validated = validateCheckpointRecord(record) + const digest = checkpointRecordDigest(validated) const result = { digest, body: [ - `<!-- issue-dev-loop:checkpoint:${record.run.runId}:sha256:${digest} -->`, + `<!-- issue-dev-loop:checkpoint:${validated.run.runId}:sha256:${digest} -->`, '```json', - canonicalCheckpointRecord(record), + canonicalCheckpointRecord(validated), '```', ].join('\n'), } diff --git a/loops/issue-dev-loop/scripts/lib/common.mjs b/loops/issue-dev-loop/scripts/lib/common.mjs index 9956a4d6..6d3f1519 100644 --- a/loops/issue-dev-loop/scripts/lib/common.mjs +++ b/loops/issue-dev-loop/scripts/lib/common.mjs @@ -208,6 +208,27 @@ export async function defaultGitHubApi(endpoint) { return JSON.parse(result.stdout) } +export async function paginateGitHubApi( + githubApi, + endpoint, + { maxPages = 100 } = {}, +) { + if (!Number.isInteger(maxPages) || maxPages < 1) { + throw new Error('GitHub pagination requires a positive page limit') + } + const separator = endpoint.includes('?') ? '&' : '?' + const records = [] + for (let page = 1; page <= maxPages; page += 1) { + const batch = await githubApi(`${endpoint}${separator}per_page=100&page=${page}`) + if (!Array.isArray(batch)) { + throw new Error('GitHub paginated response must be an array') + } + records.push(...batch) + if (batch.length < 100) return records + } + throw new Error(`GitHub pagination exceeded the ${maxPages}-page safety limit`) +} + export async function defaultGitHubPaginatedApi(endpoint) { const result = await execFileAsync('gh', ['api', '--paginate', '--slurp', endpoint], { maxBuffer: 8 * 1024 * 1024, diff --git a/loops/issue-dev-loop/scripts/lib/evidence.mjs b/loops/issue-dev-loop/scripts/lib/evidence.mjs index 093ba818..6a1ce604 100644 --- a/loops/issue-dev-loop/scripts/lib/evidence.mjs +++ b/loops/issue-dev-loop/scripts/lib/evidence.mjs @@ -16,6 +16,7 @@ import { parseGitHubTarget, parsePullCommentUrl, parseReviewUrl, + paginateGitHubApi, readJson, runDirectory, sameGitHubLogin, @@ -216,18 +217,6 @@ function reviewCycleMarker(body, runId) { } } -async function paginateGitHubApi(githubApi, endpoint) { - const separator = endpoint.includes('?') ? '&' : '?' - const items = [] - for (let page = 1; page <= 100; page += 1) { - const batch = await githubApi(`${endpoint}${separator}per_page=100&page=${page}`) - if (!Array.isArray(batch)) throw new Error('GitHub paginated review response must be an array') - items.push(...batch) - if (batch.length < 100) return items - } - throw new Error('GitHub review pagination exceeded the safety limit') -} - export async function recordEvidence({ loopRoot = DEFAULT_LOOP_ROOT, runId, diff --git a/loops/issue-dev-loop/scripts/lib/github-identity.mjs b/loops/issue-dev-loop/scripts/lib/github-identity.mjs index e44c27cd..0d13f42a 100644 --- a/loops/issue-dev-loop/scripts/lib/github-identity.mjs +++ b/loops/issue-dev-loop/scripts/lib/github-identity.mjs @@ -9,11 +9,17 @@ import { fileURLToPath } from 'node:url' import { assertIssueNumber, assertNonEmpty, + paginateGitHubApi, parseGitHubTarget, readJson, sameGitHubLogin, } from './common.mjs' -import { verifyLatestDurableCheckpoint } from './checkpoint-proof.mjs' +import { + checkpointRecordDigest, + parseCheckpointRecord, + validateCheckpointRecord, + verifyLatestDurableCheckpoint, +} from './checkpoint-proof.mjs' import { verifyPublishedEvolveRequest } from './evolve.mjs' import { readEvents } from './run-store.mjs' @@ -773,12 +779,38 @@ function pullRequestMutationAllowed(kind, args, commandIndex, authorization, exp function reservedAutomationComment(body) { return ( body.includes('**pr_completed**') || - body.includes('<!-- issue-dev-loop:evolve-completion:') + body.includes('<!-- issue-dev-loop:evolve-completion:') || + body.includes('<!-- issue-dev-loop:checkpoint:') ) } +function checkpointPublicationAllowed(body, authorization) { + const markers = [ + ...body.matchAll( + /<!-- issue-dev-loop:checkpoint:([^:]+):sha256:([0-9a-f]{64}) -->/g, + ), + ] + if (markers.length !== 1 || markers[0][1] !== authorization?.issue?.runId) { + return false + } + try { + const record = validateCheckpointRecord(parseCheckpointRecord(body)) + return ( + record.run.runId === authorization.issue.runId && + record.run.issueNumber === authorization.issue.issueNumber && + record.run.branch === authorization.issue.branch && + checkpointRecordDigest(record) === markers[0][2] + ) + } catch { + return false + } +} + function automationCommentBodyAllowed(body, authorization) { if (!reservedAutomationComment(body)) return true + if (body.includes('<!-- issue-dev-loop:checkpoint:')) { + return checkpointPublicationAllowed(body, authorization) + } if (body.includes('**pr_completed**')) { return authorization?.rootIntent === 'prepare-finalization' } @@ -807,7 +839,12 @@ function automationApiMutationAllowed( ) if (issueComment && method === 'POST' && commentTargets.has(Number(issueComment[1]))) { const body = fields.length === 1 && fields[0].startsWith('body=') ? fields[0].slice(5) : null - return Boolean(body) && automationCommentBodyAllowed(body, authorization) + const checkpointBody = body?.includes('<!-- issue-dev-loop:checkpoint:') + return ( + Boolean(body) && + (!checkpointBody || Number(issueComment[1]) === authorization?.stateIssueNumber) && + automationCommentBodyAllowed(body, authorization) + ) } const reply = endpoint.match(/^repos\/[^/]+\/[^/]+\/pulls\/(\d+)\/comments\/\d+\/replies$/) return ( @@ -1409,19 +1446,7 @@ async function preflightPullRequestWrite({ }) return JSON.parse(stdout) } - const githubPaginatedApi = async (endpoint) => { - const separator = endpoint.includes('?') ? '&' : '?' - const items = [] - for (let page = 1; page <= 100; page += 1) { - const batch = await githubApi(`${endpoint}${separator}per_page=100&page=${page}`) - if (!Array.isArray(batch)) { - throw new Error('GitHub paginated response must be an array') - } - items.push(...batch) - if (batch.length < 100) return items - } - throw new Error('GitHub pagination exceeded the safety limit') - } + const githubPaginatedApi = (endpoint) => paginateGitHubApi(githubApi, endpoint) const durable = await verifyLatestDurableCheckpoint({ loopRoot, runId, diff --git a/loops/issue-dev-loop/scripts/lib/github.mjs b/loops/issue-dev-loop/scripts/lib/github.mjs index 2dadd921..f856d25b 100644 --- a/loops/issue-dev-loop/scripts/lib/github.mjs +++ b/loops/issue-dev-loop/scripts/lib/github.mjs @@ -27,6 +27,7 @@ import { reconcileEvolveJournal } from './evolve.mjs' import { defaultReleaseIssueClaim } from './issue-claim.mjs' import { appendValidatedEvent, finalizeRun, readEvents, readRun } from './run-store.mjs' import { verifyLatestDurableCheckpoint } from './checkpoint-proof.mjs' +import { validateFinalizationHistory } from './validation.mjs' const PRIORITY = new Map([ ['priority:critical', 0], @@ -73,6 +74,11 @@ export async function reconcileLoopJournal({ githubPaginatedApi = defaultGitHubPaginatedApi, githubApi = defaultGitHubApi, } = {}) { + const history = (await readFile(path.join(loopRoot, 'logs', 'index.jsonl'), 'utf8')) + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) + validateFinalizationHistory(history) const evolve = await reconcileEvolveJournal({ loopRoot, githubPaginatedApi, githubApi }) const allActive = await reconcileActiveJournal({ loopRoot, diff --git a/loops/issue-dev-loop/scripts/lib/owner-gate.mjs b/loops/issue-dev-loop/scripts/lib/owner-gate.mjs index 7f712297..0515cf20 100644 --- a/loops/issue-dev-loop/scripts/lib/owner-gate.mjs +++ b/loops/issue-dev-loop/scripts/lib/owner-gate.mjs @@ -1,17 +1,12 @@ import path from 'node:path' -import { defaultGitHubApi, parseGitHubTarget, readJson, sameGitHubLogin } from './common.mjs' - -async function paginateGitHubApi(githubApi, endpoint) { - const records = [] - for (let page = 1; ; page += 1) { - const separator = endpoint.includes('?') ? '&' : '?' - const batch = await githubApi(`${endpoint}${separator}per_page=100&page=${page}`) - if (!Array.isArray(batch)) throw new Error('GitHub paginated response must be an array') - records.push(...batch) - if (batch.length < 100) return records - } -} +import { + defaultGitHubApi, + paginateGitHubApi, + parseGitHubTarget, + readJson, + sameGitHubLogin, +} from './common.mjs' export async function observeOwnerApprovedMerge({ loopRoot, @@ -64,11 +59,15 @@ export async function observeOwnerApprovedMerge({ (left, right) => Date.parse(left.submitted_at) - Date.parse(right.submitted_at), ) .at(-1) + const mergedAt = Date.parse(pullRequest.merged_at) + const ownerApprovalAt = Date.parse(latestOwnerReview?.submitted_at) const ownerApproval = latestOwnerReview?.state === 'APPROVED' && - Date.parse(latestOwnerReview.submitted_at) > latestReadyAt + ownerApprovalAt > latestReadyAt && + ownerApprovalAt < mergedAt if ( pullRequest.merged !== true || + Number.isNaN(mergedAt) || `${target.owner}/${target.repo}`.toLowerCase() !== configuredRepository.toLowerCase() || pullRequest.base?.ref !== expectedBaseBranch || pullRequest.base?.repo?.full_name?.toLowerCase() !== configuredRepository.toLowerCase() || @@ -83,7 +82,7 @@ export async function observeOwnerApprovedMerge({ (expectedHeadSha !== null && headSha !== expectedHeadSha) ) { throw new Error( - 'PR is not merged by the configured owner at the expected headSha with an owner-authored Ready transition and a later latest owner review of APPROVED', + 'PR is not merged by the configured owner at the expected headSha with an owner-authored Ready transition and a later latest owner review of APPROVED under strict owner Ready, APPROVED, then merge ordering', ) } return { diff --git a/loops/issue-dev-loop/scripts/lib/run-store.mjs b/loops/issue-dev-loop/scripts/lib/run-store.mjs index fb88bfa3..ae5d3c16 100644 --- a/loops/issue-dev-loop/scripts/lib/run-store.mjs +++ b/loops/issue-dev-loop/scripts/lib/run-store.mjs @@ -25,6 +25,7 @@ import { defaultClaimIssue, defaultReleaseIssueClaim } from './issue-claim.mjs' import { updateEvolveMetrics } from './evolve.mjs' import { verifyLatestDurableCheckpoint } from './checkpoint-proof.mjs' import { + canonicalFinalizationRecord, finalizationRecordDigest, validateFinalizationRecord, verifyPublishedFinalization, @@ -747,30 +748,48 @@ async function ensureFinalizationArtifacts({ .split('\n') .filter(Boolean) .map((line) => JSON.parse(line)) - if (!indexEntries.some((entry) => entry.event === 'run_finalized' && entry.runId === run.runId)) { - const publication = events.findLast((event) => event.type === 'finalization_published') + const publication = events.findLast((event) => event.type === 'finalization_published') + const finalizationRecord = { + schemaVersion: 1, + runId: run.runId, + issueNumber: run.issueNumber, + status: run.status, + startedAt: run.startedAt, + finishedAt: run.finishedAt, + prUrl: run.prUrl, + headSha: run.headSha, + mergeSha: run.mergeSha, + failureFingerprint, + notificationUrl: publication?.payload?.notificationUrl ?? null, + readyNotificationUrl: publication?.payload?.readyNotificationUrl ?? null, + readyNotifiedAt: publication?.payload?.readyNotifiedAt ?? null, + completionNotifiedAt: publication?.payload?.completionNotifiedAt ?? null, + notificationWebhookStatus: publication?.payload?.notificationWebhookStatus ?? null, + predecessorCheckpointUrl: publication?.payload?.predecessorCheckpointUrl ?? null, + predecessorCheckpointDigest: + publication?.payload?.predecessorCheckpointDigest ?? null, + pauseStartedAt: publication?.payload?.pauseStartedAt ?? null, + notificationNotifiedAt: publication?.payload?.notificationNotifiedAt ?? null, + } + const runHistory = indexEntries.filter( + (entry) => + entry.runId === run.runId && + ['run_finalized', 'run_finalization_unverified'].includes(entry.event), + ) + const previousFinalization = runHistory.findLast( + (entry) => entry.event === 'run_finalized', + ) + if ( + previousFinalization && + canonicalFinalizationRecord(previousFinalization) !== + canonicalFinalizationRecord(finalizationRecord) + ) { + throw new Error(`local finalization conflicts with durable journal: ${run.runId}`) + } + if (runHistory.at(-1)?.event !== 'run_finalized') { await appendJsonLine(indexPath, { - schemaVersion: 1, event: 'run_finalized', - runId: run.runId, - issueNumber: run.issueNumber, - status: run.status, - startedAt: run.startedAt, - finishedAt: run.finishedAt, - prUrl: run.prUrl, - headSha: run.headSha, - mergeSha: run.mergeSha, - failureFingerprint, - notificationUrl: publication?.payload?.notificationUrl ?? null, - readyNotificationUrl: publication?.payload?.readyNotificationUrl ?? null, - readyNotifiedAt: publication?.payload?.readyNotifiedAt ?? null, - completionNotifiedAt: publication?.payload?.completionNotifiedAt ?? null, - notificationWebhookStatus: publication?.payload?.notificationWebhookStatus ?? null, - predecessorCheckpointUrl: publication?.payload?.predecessorCheckpointUrl ?? null, - predecessorCheckpointDigest: - publication?.payload?.predecessorCheckpointDigest ?? null, - pauseStartedAt: publication?.payload?.pauseStartedAt ?? null, - notificationNotifiedAt: publication?.payload?.notificationNotifiedAt ?? null, + ...finalizationRecord, }) } await updateEvolveMetrics({ loopRoot, now }) diff --git a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs index 52c93b27..6a020aa6 100644 --- a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs +++ b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs @@ -8,7 +8,12 @@ import test from 'node:test' import { promisify } from 'node:util' import { fileURLToPath } from 'node:url' -import { checkpointPublicationBody } from '../scripts/lib/checkpoint-proof.mjs' +import { + canonicalCheckpointRecord, + checkpointPublicationBody, + checkpointRecordDigest, + parseCheckpointRecord, +} from '../scripts/lib/checkpoint-proof.mjs' import { prepareEvolveRequestPublication, recordEvolveRequestPublication, @@ -106,6 +111,24 @@ async function createFixture({ const implementationCommit = 'c'.repeat(40) const startedAt = '2026-07-23T00:00:00.000Z' const briefSource = 'fixture implementation brief\n' + const briefDigest = createHash('sha256').update(briefSource).digest('hex') + const implementationResult = { + schemaVersion: 1, + runId: 'fixture-run', + agent: '$implement', + invocationId: 'fixture-implementation', + startedAt: '2026-07-23T00:00:10.000Z', + finishedAt: '2026-07-23T00:00:50.000Z', + briefDigest, + commitSha: implementationCommit, + checks: [{ command: 'pnpm verify', status: 'passed' }], + } + const implementationSource = `${JSON.stringify(implementationResult)}\n` + const implementationResultDigest = createHash('sha256') + .update(implementationSource) + .digest('hex') + const implementationResultPath = + 'logs/runs/fixture-run/implementation-result.json' const run = { schemaVersion: 1, runId: 'fixture-run', @@ -128,7 +151,7 @@ async function createFixture({ url: 'https://github.com/example/repo/issues/123', capturedAt: startedAt, }, - briefDigest: null, + briefDigest, uiEvidenceRequired: false, implementationCommit, } @@ -147,7 +170,16 @@ async function createFixture({ type: 'implementation_completed', timestamp: '2026-07-23T00:01:00.000Z', status: 'passed', - payload: { agent: '$implement', commitSha: implementationCommit }, + payload: { + agent: '$implement', + invocationId: implementationResult.invocationId, + startedAt: implementationResult.startedAt, + finishedAt: implementationResult.finishedAt, + briefDigest, + commitSha: implementationCommit, + resultPath: implementationResultPath, + resultDigest: implementationResultDigest, + }, }, ] if (recordedPr) { @@ -201,7 +233,13 @@ async function createFixture({ run, briefSource, events: [...events], - artifacts: [], + artifacts: [ + { + path: implementationResultPath, + sha256: implementationResultDigest, + source: implementationSource, + }, + ], updatedAt: events.at(-1).timestamp, } const publication = checkpointPublicationBody(record) @@ -219,6 +257,11 @@ async function createFixture({ }) await Promise.all([mkdir(runRoot, { recursive: true }), mkdir(briefRoot, { recursive: true })]) await writeFile(path.join(runRoot, 'run.json'), `${JSON.stringify(run)}\n`, 'utf8') + await writeFile( + path.join(runRoot, 'implementation-result.json'), + implementationSource, + 'utf8', + ) await writeFile( path.join(runRoot, 'events.jsonl'), `${events.map((event) => JSON.stringify(event)).join('\n')}\n`, @@ -765,6 +808,89 @@ test('automation push rejects dirty or unrecorded post-implement content', async } }) +test('checkpoint publication is reserved to a semantically attested current run', async () => { + const fixture = await createFixture() + const checkpointPath = path.join(path.dirname(fixture.loopRoot), 'checkpoint-comment.json') + const checkpointComment = JSON.parse(await readFile(checkpointPath, 'utf8')) + const publishArguments = [ + 'api', + 'repos/example/repo/issues/999/comments', + '--method', + 'POST', + '-f', + `body=${checkpointComment.body}`, + ] + const allowed = await execFileAsync( + process.execPath, + [ + routerPath, + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + 'gh', + ...publishArguments, + ], + { env: fixture.env }, + ) + assert.match(allowed.stdout, /issues\/999\/comments/) + + const forged = parseCheckpointRecord(checkpointComment.body) + forged.events = forged.events.filter((event) => event.type !== 'implementation_completed') + forged.artifacts = [] + forged.updatedAt = forged.events.at(-1).timestamp + const forgedDigest = checkpointRecordDigest(forged) + const forgedBody = [ + `<!-- issue-dev-loop:checkpoint:${forged.run.runId}:sha256:${forgedDigest} -->`, + '```json', + canonicalCheckpointRecord(forged), + '```', + ].join('\n') + await assert.rejects( + execFileAsync( + process.execPath, + [ + routerPath, + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + 'gh', + ...publishArguments.slice(0, -1), + `body=${forgedBody}`, + ], + { env: fixture.env }, + ), + /GitHub action is prohibited for the automation role/, + ) + await writeFile( + checkpointPath, + `${JSON.stringify({ + user: { login: 'executor-user' }, + body: forgedBody, + })}\n`, + 'utf8', + ) + await assert.rejects( + execFileAsync( + process.execPath, + [ + routerPath, + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + 'git', + 'push', + 'origin', + 'codex/issue-123', + ], + { env: fixture.env }, + ), + /published checkpoint comment does not attest the exact active state|\$implement commit lacks one matching durable event/, + ) +}) + test('routed loopctl may perform its exact read-only identity probe', async () => { const fixture = await createFixture() const { stdout } = await execFileAsync( diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index 49d1e98f..4baed912 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -38,6 +38,7 @@ import { reconcileActiveJournal, reconcileEvolveJournal, reconcileFinalizationJournal, + reconcileLoopJournal, recordEvidence as runtimeRecordEvidence, recordEvolveRequestPublication, recordDigest, @@ -58,6 +59,7 @@ import { observeOwnerApprovedMerge } from '../scripts/lib/owner-gate.mjs' import { assertCredentialProfileIsolation } from '../scripts/lib/github-identity.mjs' import { verifyTerminalExternalProof } from '../scripts/lib/finalization-proof.mjs' import { validateFinalizationHistory } from '../scripts/lib/validation.mjs' +import { checkpointPublicationBody } from '../scripts/lib/checkpoint-proof.mjs' const bypassCheckpointVerifier = async () => {} const createNotification = (options) => @@ -137,6 +139,7 @@ test('owner merge observation paginates beyond one hundred reviews', async () => } return { merged: true, + merged_at: '2026-07-23T08:02:00.000Z', merged_by: { login: 'codeacme17' }, merge_commit_sha: mergeSha, base: { ref: 'dev', repo: { full_name: 'codeacme17/echo-ui' } }, @@ -156,6 +159,7 @@ test('owner merge observation requires the owner Ready transition after notifica const headSha = 'a'.repeat(40) const pullRequest = { merged: true, + merged_at: '2026-07-23T08:33:00.000Z', merged_by: { login: 'codeacme17' }, merge_commit_sha: 'b'.repeat(40), base: { ref: 'dev', repo: { full_name: 'codeacme17/echo-ui' } }, @@ -165,14 +169,18 @@ test('owner merge observation requires the owner Ready transition after notifica repo: { full_name: 'codeacme17/echo-ui' }, }, } - const verify = (timeline, reviews = [ - { - user: { login: 'codeacme17' }, - state: 'APPROVED', - commit_id: headSha, - submitted_at: '2026-07-23T08:31:00.000Z', - }, - ]) => + const verify = ( + timeline, + reviews = [ + { + user: { login: 'codeacme17' }, + state: 'APPROVED', + commit_id: headSha, + submitted_at: '2026-07-23T08:31:00.000Z', + }, + ], + pull = pullRequest, + ) => observeOwnerApprovedMerge({ loopRoot, prUrl: 'https://github.com/codeacme17/echo-ui/pull/701', @@ -182,7 +190,7 @@ test('owner merge observation requires the owner Ready transition after notifica githubApi: async (endpoint) => { if (endpoint.includes('/reviews')) return reviews if (endpoint.includes('/timeline')) return timeline - return pullRequest + return pull }, }) await assert.rejects( @@ -276,6 +284,69 @@ test('owner merge observation requires the owner Ready transition after notifica ), /latest owner review/, ) + const readyTimeline = [ + { + event: 'ready_for_review', + actor: { login: 'codeacme17' }, + created_at: '2026-07-23T08:30:00.000Z', + }, + ] + const afterMergeApproval = [ + { + user: { login: 'codeacme17' }, + state: 'APPROVED', + commit_id: headSha, + submitted_at: '2026-07-23T08:34:00.000Z', + }, + ] + await assert.rejects(verify(readyTimeline, afterMergeApproval), /strict owner Ready/) + await assert.rejects( + verify(readyTimeline, [ + { + ...afterMergeApproval[0], + submitted_at: pullRequest.merged_at, + }, + ]), + /strict owner Ready/, + ) + await assert.rejects( + verify(readyTimeline, undefined, { ...pullRequest, merged_at: null }), + /strict owner Ready/, + ) + assert.equal((await verify(readyTimeline)).mergeSha, pullRequest.merge_commit_sha) +}) + +test('owner merge observation bounds repeated full pagination pages', async () => { + const { loopRoot } = await createFixture() + let reviewPages = 0 + await assert.rejects( + observeOwnerApprovedMerge({ + loopRoot, + prUrl: 'https://github.com/codeacme17/echo-ui/pull/702', + expectedHeadBranch: 'codex/issue-702', + githubApi: async (endpoint) => { + if (endpoint.includes('/reviews')) { + reviewPages += 1 + return Array.from({ length: 100 }, () => ({ state: 'COMMENTED' })) + } + if (endpoint.includes('/timeline')) return [] + return { + merged: true, + merged_at: '2026-07-23T08:33:00.000Z', + merged_by: { login: 'codeacme17' }, + merge_commit_sha: 'b'.repeat(40), + base: { ref: 'dev', repo: { full_name: 'codeacme17/echo-ui' } }, + head: { + ref: 'codex/issue-702', + sha: 'a'.repeat(40), + repo: { full_name: 'codeacme17/echo-ui' }, + }, + } + }, + }), + /100-page safety limit/, + ) + assert.equal(reviewPages, 100) }) async function createFixture() { @@ -715,6 +786,7 @@ async function writeFixtureFinalization({ if (endpoint.includes('/pulls/')) { return { merged: true, + merged_at: new Date(Date.now() + 180_000).toISOString(), merged_by: { login: 'codeacme17' }, merge_commit_sha: record.mergeSha, base: { ref: 'dev', repo: { full_name: 'codeacme17/echo-ui' } }, @@ -3627,7 +3699,24 @@ test('three matching failures make a fresh evolve session due', async () => { githubApi: durableFinalization.githubApi, }) assert.deepEqual(reconciled.durableRunIds, [run.runId]) + const tombstoned = await reconcileFinalizationJournal({ + loopRoot, + githubPaginatedApi: async () => [], + }) + assert.deepEqual(tombstoned.durableRunIds, []) await finalizeRun(finalizationOptions) + const restoredHistory = (await readFile( + path.join(loopRoot, 'logs', 'index.jsonl'), + 'utf8', + )) + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) + assert.doesNotThrow(() => validateFinalizationHistory(restoredHistory)) + assert.equal( + restoredHistory.findLast((entry) => entry.runId === run.runId)?.event, + 'run_finalized', + ) } } const metrics = await getEvolveStatus({ loopRoot }) @@ -3636,7 +3725,7 @@ test('three matching failures make a fresh evolve session due', async () => { const history = (await readFile(path.join(loopRoot, 'logs', 'index.jsonl'), 'utf8')) .split('\n') .filter((line) => line.includes('run_finalized')) - assert.equal(history.length, 3) + assert.equal(history.length, 4) assert.match(metrics.pendingRequestId, /^EVL-/) await assert.rejects( startFixtureRun({ @@ -4011,6 +4100,40 @@ test('reconciliation rejects malformed local finalization history before mutatio assert.equal(await readFile(indexPath, 'utf8'), source) }) +test('top-level reconciliation validates finalization history before any cache writer', async () => { + const { loopRoot } = await createFixture() + const indexPath = path.join(loopRoot, 'logs', 'index.jsonl') + const metricsPath = path.join(loopRoot, 'evolve', 'metrics.json') + const malformed = [ + { schemaVersion: 1, event: 'loop_initialized' }, + { + schemaVersion: 1, + event: 'run_finalization_unverified', + runId: 'never-finalized', + timestamp: '2026-07-22T12:00:00.000Z', + }, + ] + await writeFile( + indexPath, + `${malformed.map((entry) => JSON.stringify(entry)).join('\n')}\n`, + 'utf8', + ) + const metricsBefore = await readFile(metricsPath, 'utf8') + let queriedGitHub = false + await assert.rejects( + reconcileLoopJournal({ + loopRoot, + githubPaginatedApi: async () => { + queriedGitHub = true + return [] + }, + }), + /not currently finalized/, + ) + assert.equal(queriedGitHub, false) + assert.equal(await readFile(metricsPath, 'utf8'), metricsBefore) +}) + test('finalization history permits durable restoration only after a tombstone', () => { const finalized = { schemaVersion: 1, @@ -4182,6 +4305,24 @@ test('fresh worktrees restore active checkpoints and trigger resumable work', as assert.equal(detected.runId, run.runId) }) +test('checkpoint publication rejects an unattested implementation boundary', async () => { + const { loopRoot } = await createFixture() + const { run } = await startFixtureRun({ + loopRoot, + issueNumber: 208, + issueTitle: 'Reject forged implementation checkpoint', + issueUrl: 'https://github.com/codeacme17/echo-ui/issues/208', + entropy: 'forged208', + }) + const prepared = await prepareActiveCheckpoint({ loopRoot, runId: run.runId }) + const forged = structuredClone(prepared.record) + forged.run.implementationCommit = 'c'.repeat(40) + assert.throws( + () => checkpointPublicationBody(forged), + /invalid \$implement boundary|lacks one matching durable event/, + ) +}) + test('later-phase checkpoints restore every digest-bound local artifact', async () => { const { loopRoot } = await createFixture() const { run } = await startFixtureRun({ diff --git a/loops/issue-dev-loop/triggers/TRIGGER.md b/loops/issue-dev-loop/triggers/TRIGGER.md index 05949d14..ac4ebb52 100644 --- a/loops/issue-dev-loop/triggers/TRIGGER.md +++ b/loops/issue-dev-loop/triggers/TRIGGER.md @@ -11,7 +11,7 @@ Run the deterministic detector before waking Codex: Before the issue detector, run `loopctl.mjs evolve-status` through the same automation wrapper. A pending evolve request is real work and must wake the dedicated fresh-context evolver instead of an issue executor. -The command prints one JSON object after paginating all matching issues and open PRs. Start a Codex turn only when `hasWork` is `true`; pass the returned issue number and URL to `$issue-dev-loop`. +The command prints one JSON object after paginating all matching issues and open PRs. Start a Codex turn only when `hasWork` is `true`; pass the returned issue number and URL to a fresh Codex task instructed to read `loops/issue-dev-loop/LOOP.md` and the internal `SKILL.md` runbook by path. For a scheduled Codex automation, use [`codex-automation-prompt.md`](./codex-automation-prompt.md). The machine and repository credentials must remain available for local scheduled runs. Use a dedicated worktree for every issue. diff --git a/loops/issue-dev-loop/triggers/codex-automation-prompt.md b/loops/issue-dev-loop/triggers/codex-automation-prompt.md index 7d27b016..1e2c2d7e 100644 --- a/loops/issue-dev-loop/triggers/codex-automation-prompt.md +++ b/loops/issue-dev-loop/triggers/codex-automation-prompt.md @@ -1,4 +1,4 @@ -Use `$issue-dev-loop` in this repository. +Execute the repository loop at `loops/issue-dev-loop`. Read its `LOOP.md` contract and internal `SKILL.md` runbook by path; do not invoke or register the loop as a top-level skill. Require `ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR`, `ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR`, `ECHO_UI_LOOP_UNTRUSTED_ROOTS`, `ECHO_UI_LOOP_CONTROL_PLANE`, and `ECHO_UI_LOOP_TARGET_ROOT`. The control plane must be a versioned, hash-verified installation made from clean owner-merged `dev`, outside the repository. The JSON-array untrusted roots must cover every repository/worktree/test mount visible to `$implement` or another untrusted agent; both private GitHub profiles must be outside them. Do not pass either profile variable or `GH_CONFIG_DIR` into `$implement`, reviewer, product-test, or verifier environments, and require their sandbox to deny profile-directory reads. Run `"$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" --loop-root "$ECHO_UI_LOOP_TARGET_ROOT" automation -- node "$ECHO_UI_LOOP_CONTROL_PLANE/scripts/loopctl.mjs" validate --activation --loop-root "$ECHO_UI_LOOP_TARGET_ROOT"`. Run every operational loop command, executor GitHub command, remote Git command, trigger, and reviewer publication through that installed launcher with the explicit target root. Never use the repository launcher for credentials, invoke its `.mjs` implementation directly, install control code from an issue branch, or change global authentication. From 28d318831b1e3cb15b88d1026f889299ef6ae266 Mon Sep 17 00:00:00 2001 From: leyoonafr <codeacme17@gmail.com> Date: Fri, 24 Jul 2026 00:48:41 +0800 Subject: [PATCH 38/41] fix: colocate loop agent policies --- .codex/config.toml | 6 +++--- .../agents/echo-ui-loop-evolver.toml | 0 .../agents/echo-ui-pr-reviewer.toml | 0 .../agents/echo-ui-review-adjudicator.toml | 0 .../issue-dev-loop/scripts/lib/validation.mjs | 19 ++++++++++++------- 5 files changed, 15 insertions(+), 10 deletions(-) rename {.codex => loops/issue-dev-loop}/agents/echo-ui-loop-evolver.toml (100%) rename {.codex => loops/issue-dev-loop}/agents/echo-ui-pr-reviewer.toml (100%) rename {.codex => loops/issue-dev-loop}/agents/echo-ui-review-adjudicator.toml (100%) diff --git a/.codex/config.toml b/.codex/config.toml index edff1271..282eb8ff 100644 --- a/.codex/config.toml +++ b/.codex/config.toml @@ -4,12 +4,12 @@ max_depth = 1 [agents.echo_ui_pr_reviewer] description = "Fresh-context, read-only reviewer for Echo UI issue pull requests." -config_file = "agents/echo-ui-pr-reviewer.toml" +config_file = "../loops/issue-dev-loop/agents/echo-ui-pr-reviewer.toml" [agents.echo_ui_review_adjudicator] description = "Read-only adjudicator for disputed high-severity Echo UI review findings." -config_file = "agents/echo-ui-review-adjudicator.toml" +config_file = "../loops/issue-dev-loop/agents/echo-ui-review-adjudicator.toml" [agents.echo_ui_loop_evolver] description = "Fresh-context evaluator that improves Echo UI loops from measured history." -config_file = "agents/echo-ui-loop-evolver.toml" +config_file = "../loops/issue-dev-loop/agents/echo-ui-loop-evolver.toml" diff --git a/.codex/agents/echo-ui-loop-evolver.toml b/loops/issue-dev-loop/agents/echo-ui-loop-evolver.toml similarity index 100% rename from .codex/agents/echo-ui-loop-evolver.toml rename to loops/issue-dev-loop/agents/echo-ui-loop-evolver.toml diff --git a/.codex/agents/echo-ui-pr-reviewer.toml b/loops/issue-dev-loop/agents/echo-ui-pr-reviewer.toml similarity index 100% rename from .codex/agents/echo-ui-pr-reviewer.toml rename to loops/issue-dev-loop/agents/echo-ui-pr-reviewer.toml diff --git a/.codex/agents/echo-ui-review-adjudicator.toml b/loops/issue-dev-loop/agents/echo-ui-review-adjudicator.toml similarity index 100% rename from .codex/agents/echo-ui-review-adjudicator.toml rename to loops/issue-dev-loop/agents/echo-ui-review-adjudicator.toml diff --git a/loops/issue-dev-loop/scripts/lib/validation.mjs b/loops/issue-dev-loop/scripts/lib/validation.mjs index a4429d6e..64ab20d0 100644 --- a/loops/issue-dev-loop/scripts/lib/validation.mjs +++ b/loops/issue-dev-loop/scripts/lib/validation.mjs @@ -58,6 +58,9 @@ export async function validateLoop({ 'state.md', 'dependencies.md', 'agents/openai.yaml', + 'agents/echo-ui-pr-reviewer.toml', + 'agents/echo-ui-review-adjudicator.toml', + 'agents/echo-ui-loop-evolver.toml', 'review/REVIEW.md', 'review/response-policy.md', 'review/result.schema.json', @@ -240,12 +243,14 @@ export async function validateLoop({ path.resolve(loopRoot, '..', '..', '.codex', 'config.toml'), 'utf8', ) - for (const role of [ - 'echo_ui_pr_reviewer', - 'echo_ui_review_adjudicator', - 'echo_ui_loop_evolver', - ]) { - if (!codexConfig.includes(`[agents.${role}]`) || !codexConfig.includes('config_file =')) { + const roleRegistrations = { + echo_ui_pr_reviewer: 'echo-ui-pr-reviewer.toml', + echo_ui_review_adjudicator: 'echo-ui-review-adjudicator.toml', + echo_ui_loop_evolver: 'echo-ui-loop-evolver.toml', + } + for (const [role, roleFile] of Object.entries(roleRegistrations)) { + const registration = `config_file = "../loops/issue-dev-loop/agents/${roleFile}"` + if (!codexConfig.includes(`[agents.${role}]`) || !codexConfig.includes(registration)) { throw new Error(`Codex role is not registered through config_file: ${role}`) } } @@ -254,7 +259,7 @@ export async function validateLoop({ 'echo-ui-review-adjudicator.toml', ]) { const roleSource = await readFile( - path.resolve(loopRoot, '..', '..', '.codex', 'agents', roleFile), + path.resolve(loopRoot, 'agents', roleFile), 'utf8', ) if ( From 1ef5ef6b57fa6fd1d91a8d2cf078d131f60a99b3 Mon Sep 17 00:00:00 2001 From: leyoonafr <codeacme17@gmail.com> Date: Fri, 24 Jul 2026 01:00:04 +0800 Subject: [PATCH 39/41] fix: serialize issue claims and paginate reviews --- loops/issue-dev-loop/LOOP.md | 2 +- loops/issue-dev-loop/scripts/lib/evidence.mjs | 2 +- .../scripts/lib/github-identity.mjs | 14 ++++ .../scripts/lib/issue-claim.mjs | 28 +++++++- .../issue-dev-loop/scripts/lib/run-store.mjs | 1 + .../tests/github-identity-routing.test.mjs | 49 ++++++++++++++ loops/issue-dev-loop/tests/runtime.test.mjs | 64 ++++++++++++++++--- 7 files changed, 149 insertions(+), 11 deletions(-) diff --git a/loops/issue-dev-loop/LOOP.md b/loops/issue-dev-loop/LOOP.md index aff13d23..a810f63b 100644 --- a/loops/issue-dev-loop/LOOP.md +++ b/loops/issue-dev-loop/LOOP.md @@ -65,7 +65,7 @@ Use a combo trigger. Run `triggers/detect-work.mjs` before starting a Codex impl ### 1. Claim and snapshot -Recheck the issue immediately before mutation. `loopctl start` atomically reserves the issue locally, rechecks GitHub, applies `loop:claimed`, rejects another active run or open issue branch PR, and then creates the run. Capture the issue title/body/labels/URL, base SHA, and acceptance criteria. Use one run ID across logs, handoffs, `screen-shots`, evidence, review comments, notifications, branch metadata, and the PR body. +Recheck the issue immediately before mutation. `loopctl start` atomically creates the remote `codex/issue-N` branch at the verified base SHA as the cross-worktree reservation, then applies `loop:claimed` and creates the local run. GitHub permits only one creator for a new ref, so a concurrent loser exits before any checkpoint is published. Also reject another active run or open issue branch PR. Capture the issue title/body/labels/URL, base SHA, and acceptance criteria. Use one run ID across logs, handoffs, `screen-shots`, evidence, review comments, notifications, branch metadata, and the PR body. ### 2. Isolate diff --git a/loops/issue-dev-loop/scripts/lib/evidence.mjs b/loops/issue-dev-loop/scripts/lib/evidence.mjs index 6a1ce604..5b161240 100644 --- a/loops/issue-dev-loop/scripts/lib/evidence.mjs +++ b/loops/issue-dev-loop/scripts/lib/evidence.mjs @@ -549,7 +549,7 @@ export async function recordReview({ const roundEndpoint = `repos/${roundTarget.owner}/${roundTarget.repo}/pulls/${roundTarget.number}/reviews/${roundTarget.reviewId}` const [publishedRound, roundComments] = await Promise.all([ githubApi(roundEndpoint), - githubApi(`${roundEndpoint}/comments?per_page=100`), + paginateGitHubApi(githubApi, `${roundEndpoint}/comments`), ]) const bodies = [ publishedRound.body ?? '', diff --git a/loops/issue-dev-loop/scripts/lib/github-identity.mjs b/loops/issue-dev-loop/scripts/lib/github-identity.mjs index 0d13f42a..077443cc 100644 --- a/loops/issue-dev-loop/scripts/lib/github-identity.mjs +++ b/loops/issue-dev-loop/scripts/lib/github-identity.mjs @@ -822,6 +822,17 @@ function automationApiMutationAllowed( authorization, ) { if (!endpoint || usesInput || usesFileExpansion) return false + if ( + endpoint.match(/^repos\/[^/]+\/[^/]+\/git\/refs$/) && + method === 'POST' && + authorization?.rootIntent === 'start' && + authorization?.issue?.status === 'starting' + ) { + return sameArguments(fields, [ + `ref=refs/heads/${authorization.issue.branch}`, + `sha=${authorization.issue.baseSha}`, + ]) + } const labels = endpoint.match(/^repos\/[^/]+\/[^/]+\/issues\/(\d+)\/labels(?:\/([^/]+))?$/) if (labels && Number(labels[1]) === authorization?.issue?.issueNumber) { if (method === 'POST') { @@ -1333,10 +1344,12 @@ function withRootCommandIntent(authorization, { tool, args, trustedLoopRoot }) { } const issueNumber = Number(argumentAfter(args, '--issue')) const issueUrl = argumentAfter(args, '--url') + const baseSha = argumentAfter(args, '--base-sha') const target = parseGitHubTarget(issueUrl) if ( !Number.isInteger(issueNumber) || issueNumber < 1 || + !/^[0-9a-f]{40}$/i.test(baseSha ?? '') || target?.kind !== 'issues' || target.number !== issueNumber || !repositoryInScope(`${target.owner}/${target.repo}`, authorization.expectedRepository) @@ -1351,6 +1364,7 @@ function withRootCommandIntent(authorization, { tool, args, trustedLoopRoot }) { prNumber: null, runId: null, status: 'starting', + baseSha: baseSha.toLowerCase(), headSha: null, implementationCommit: null, }, diff --git a/loops/issue-dev-loop/scripts/lib/issue-claim.mjs b/loops/issue-dev-loop/scripts/lib/issue-claim.mjs index fc93fd0b..c006add9 100644 --- a/loops/issue-dev-loop/scripts/lib/issue-claim.mjs +++ b/loops/issue-dev-loop/scripts/lib/issue-claim.mjs @@ -24,6 +24,23 @@ async function defaultAddLabel({ target, issueNumber }) { ) } +async function defaultReserveRemoteBranch({ target, issueNumber, baseSha }) { + await execFileAsync( + 'gh', + [ + 'api', + `repos/${target.owner}/${target.repo}/git/refs`, + '--method', + 'POST', + '-f', + `ref=refs/heads/codex/issue-${issueNumber}`, + '-f', + `sha=${baseSha}`, + ], + { maxBuffer: 1024 * 1024 }, + ) +} + async function defaultRemoteBranchExists({ target, issueNumber }) { try { await execFileAsync( @@ -55,15 +72,20 @@ export async function defaultClaimIssue({ loopRoot = DEFAULT_LOOP_ROOT, issueUrl, issueNumber, + baseSha, githubApi = defaultGitHubApi, githubPaginatedApi = defaultGitHubPaginatedApi, addLabel = defaultAddLabel, + reserveRemoteBranch = defaultReserveRemoteBranch, remoteBranchExists = defaultRemoteBranchExists, }) { const target = parseGitHubTarget(issueUrl) if (!target || target.kind !== 'issues' || target.number !== issueNumber) { throw new Error('issueUrl must identify the issue being claimed') } + if (!/^[0-9a-f]{40}$/i.test(baseSha ?? '')) { + throw new Error('issue claim requires a full base SHA') + } const issue = await githubApi(`repos/${target.owner}/${target.repo}/issues/${issueNumber}`) const labels = labelNames(issue) if (issue.state !== 'open' || !labels.has('codex-ready') || labels.has('loop:claimed')) { @@ -78,9 +100,13 @@ export async function defaultClaimIssue({ if (pulls.some((pullRequest) => pullRequestClaimsIssue(pullRequest, issueNumber))) { throw new Error(`an open pull request already claims issue ${issueNumber}`) } - if (addLabel === defaultAddLabel) { + if ( + addLabel === defaultAddLabel || + reserveRemoteBranch === defaultReserveRemoteBranch + ) { await assertAutomationIdentity({ loopRoot, githubApi }) } + await reserveRemoteBranch({ target, issueNumber, baseSha }) await addLabel({ target, issueNumber }) return issue } diff --git a/loops/issue-dev-loop/scripts/lib/run-store.mjs b/loops/issue-dev-loop/scripts/lib/run-store.mjs index ae5d3c16..02a2275c 100644 --- a/loops/issue-dev-loop/scripts/lib/run-store.mjs +++ b/loops/issue-dev-loop/scripts/lib/run-store.mjs @@ -166,6 +166,7 @@ export async function startRun({ issueUrl: url, issueNumber: issue, branch: `codex/issue-${issue}`, + baseSha: normalizedBaseSha, githubApi, }) remoteClaimCreated = true diff --git a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs index 6a020aa6..1ff1a1cc 100644 --- a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs +++ b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs @@ -328,8 +328,23 @@ if (commandArguments[0] === 'spawn') { } else if (commandArguments[0] === 'start') { const issueIndex = commandArguments.indexOf('--issue') const issue = commandArguments[issueIndex + 1] + const baseShaIndex = commandArguments.indexOf('--base-sha') + const baseSha = commandArguments[baseShaIndex + 1] + const reserveIssueIndex = commandArguments.indexOf('--reserve-issue') + const reserveIssue = + reserveIssueIndex === -1 ? issue : commandArguments[reserveIssueIndex + 1] for (const command of [ ['api', 'user'], + [ + 'api', + 'repos/example/repo/git/refs', + '--method', + 'POST', + '-f', + \`ref=refs/heads/codex/issue-\${reserveIssue}\`, + '-f', + \`sha=\${baseSha}\`, + ], ['api', \`repos/example/repo/issues/\${issue}/labels\`, '--method', 'POST', '-f', 'labels[]=loop:claimed'] ]) { const result = spawnSync('gh', command, { env: process.env, stdio: 'inherit' }) @@ -932,15 +947,49 @@ test('routed loopctl start may claim only its command-line issue', async () => { '123', '--url', 'https://github.com/example/repo/issues/123', + '--base-sha', + '0'.repeat(40), '--loop-root', fixture.loopRoot, ], { env: fixture.env }, ) assert.match(stdout, /executor-user/) + assert.match(stdout, /git\/refs/) assert.match(stdout, /issues\/123\/labels/) }) +test('routed loopctl start rejects a remote reservation for another issue', async () => { + const fixture = await createFixture({ activeRun: false }) + await assert.rejects( + execFileAsync( + process.execPath, + [ + routerPath, + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + process.execPath, + fixture.loopctlPath, + 'start', + '--issue', + '123', + '--url', + 'https://github.com/example/repo/issues/123', + '--base-sha', + '0'.repeat(40), + '--reserve-issue', + '124', + '--loop-root', + fixture.loopRoot, + ], + { env: fixture.env }, + ), + /GitHub action is prohibited for the automation role/, + ) +}) + test('reviewer identity cannot push code', async () => { const fixture = await createFixture() await assert.rejects( diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index 4baed912..1b24cd8e 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -1154,6 +1154,7 @@ test('authoritative claim rejects any paginated open PR that references the issu defaultClaimIssue({ issueUrl: 'https://github.com/codeacme17/echo-ui/issues/128', issueNumber: 128, + baseSha: '0'.repeat(40), githubApi: async () => ({ number: 128, title: 'Issue', @@ -1176,6 +1177,7 @@ test('authoritative claim rejects any paginated open PR that references the issu defaultClaimIssue({ issueUrl: 'https://github.com/codeacme17/echo-ui/issues/128', issueNumber: 128, + baseSha: '0'.repeat(40), githubApi: async () => ({ number: 128, title: 'Issue', @@ -1193,6 +1195,41 @@ test('authoritative claim rejects any paginated open PR that references the issu assert.equal(labelAdded, false) }) +test('authoritative claim atomically reserves one remote issue branch across starters', async () => { + let reservationCreated = false + let labelCount = 0 + const claim = () => + defaultClaimIssue({ + issueUrl: 'https://github.com/codeacme17/echo-ui/issues/128', + issueNumber: 128, + baseSha: '0'.repeat(40), + githubApi: async () => ({ + number: 128, + title: 'Issue', + state: 'open', + labels: [{ name: 'codex-ready' }], + }), + githubPaginatedApi: async () => [], + remoteBranchExists: async () => false, + reserveRemoteBranch: async () => { + if (reservationCreated) throw new Error('remote ref already exists') + reservationCreated = true + }, + addLabel: async () => { + labelCount += 1 + }, + }) + + const outcomes = await Promise.allSettled([claim(), claim()]) + assert.equal(outcomes.filter(({ status }) => status === 'fulfilled').length, 1) + assert.equal(outcomes.filter(({ status }) => status === 'rejected').length, 1) + assert.match( + outcomes.find(({ status }) => status === 'rejected').reason.message, + /remote ref already exists/, + ) + assert.equal(labelCount, 1) +}) + test('startRun creates one correlated run, handoff, and evidence directories', async () => { const { loopRoot } = await createFixture() let workspaceValidation @@ -2757,7 +2794,7 @@ test('review gate verifies published findings and classified replies', async () }), ] } - if (endpoint.endsWith('/reviews/499/comments?per_page=100')) { + if (endpoint.endsWith('/reviews/499/comments?per_page=100&page=1')) { return [ { user: { login: 'echo-ui-reviewer[bot]' }, @@ -2775,7 +2812,7 @@ test('review gate verifies published findings and classified replies', async () }, ] } - if (endpoint.endsWith('/comments?per_page=100')) return [] + if (endpoint.endsWith('/comments?per_page=100&page=1')) return [] if (endpoint.includes('/issues/comments/400')) { return { user: { login: 'echo-ui-loop[bot]' }, @@ -2880,6 +2917,7 @@ test('review gate rejects GitHub findings omitted from the durable result', asyn }), /include every reviewer publication/, ) + let secondCommentPageFetched = false await assert.rejects( recordReview({ loopRoot, @@ -2897,13 +2935,22 @@ test('review gate rejects GitHub findings omitted from the durable result', asyn }), ] } - if (endpoint.endsWith('/comments?per_page=100')) { + if (endpoint.endsWith('/comments?per_page=100&page=1')) { + return Array.from({ length: 100 }, (_, index) => ({ + user: { login: 'echo-ui-reviewer[bot]' }, + path: 'src/context.ts', + line: index + 1, + body: `Reviewer context ${index + 1}`, + })) + } + if (endpoint.endsWith('/comments?per_page=100&page=2')) { + secondCommentPageFetched = true return [ { user: { login: 'echo-ui-reviewer[bot]' }, path: 'src/untracked.ts', - line: 3, - body: 'This inline finding was omitted and has no stable ID.', + line: 101, + body: 'RVW-1-1-1: omitted finding beyond the first page.', }, ] } @@ -2921,8 +2968,9 @@ test('review gate rejects GitHub findings omitted from the durable result', asyn } }, }), - /unrecorded reviewer inline comment/, + /unrecorded findings/, ) + assert.equal(secondCommentPageFetched, true) }) test('accepted review fix must be after the finding head and inside the final head', async () => { @@ -3039,7 +3087,7 @@ test('accepted review fix must be after the finding head and inside the final he }), ] } - if (endpoint.endsWith('/comments?per_page=100')) return [] + if (endpoint.endsWith('/comments?per_page=100&page=1')) return [] if (endpoint.includes('/issues/comments/410')) { return { user: { login: 'echo-ui-loop[bot]' }, @@ -3168,7 +3216,7 @@ test('review gate binds high-severity adjudication verdict to the correct identi }, ] } - if (endpoint.endsWith('/comments?per_page=100')) return [] + if (endpoint.endsWith('/comments?per_page=100&page=1')) return [] if (endpoint.includes('/issues/comments/401')) { return { user: { login: 'echo-ui-loop[bot]' }, From 792dc80f2e760364b5a65d260d30976af55d9d2f Mon Sep 17 00:00:00 2001 From: leyoonafr <codeacme17@gmail.com> Date: Fri, 24 Jul 2026 16:13:01 +0800 Subject: [PATCH 40/41] fix: recover claims and bind review comments --- loops/issue-dev-loop/LOOP.md | 2 +- loops/issue-dev-loop/SKILL.md | 2 +- .../references/github-operations.md | 4 +- loops/issue-dev-loop/scripts/lib/common.mjs | 16 +++ loops/issue-dev-loop/scripts/lib/evidence.mjs | 31 ++++- loops/issue-dev-loop/scripts/lib/evolve.mjs | 22 +--- .../scripts/lib/finalization-journal.mjs | 3 +- .../scripts/lib/finalization-proof.mjs | 3 +- .../scripts/lib/github-identity.mjs | 10 ++ loops/issue-dev-loop/scripts/lib/github.mjs | 20 ++++ .../scripts/lib/issue-claim.mjs | 38 +++++- .../scripts/lib/lifecycle-status.mjs | 6 + .../scripts/lib/notifications.mjs | 22 +--- .../issue-dev-loop/scripts/lib/run-store.mjs | 2 +- .../issue-dev-loop/scripts/lib/validation.mjs | 1 + .../tests/github-identity-routing.test.mjs | 52 +++++++- loops/issue-dev-loop/tests/runtime.test.mjs | 113 +++++++++++++----- .../triggers/codex-automation-prompt.md | 2 +- 18 files changed, 264 insertions(+), 85 deletions(-) create mode 100644 loops/issue-dev-loop/scripts/lib/lifecycle-status.mjs diff --git a/loops/issue-dev-loop/LOOP.md b/loops/issue-dev-loop/LOOP.md index a810f63b..3abf02dc 100644 --- a/loops/issue-dev-loop/LOOP.md +++ b/loops/issue-dev-loop/LOOP.md @@ -65,7 +65,7 @@ Use a combo trigger. Run `triggers/detect-work.mjs` before starting a Codex impl ### 1. Claim and snapshot -Recheck the issue immediately before mutation. `loopctl start` atomically creates the remote `codex/issue-N` branch at the verified base SHA as the cross-worktree reservation, then applies `loop:claimed` and creates the local run. GitHub permits only one creator for a new ref, so a concurrent loser exits before any checkpoint is published. Also reject another active run or open issue branch PR. Capture the issue title/body/labels/URL, base SHA, and acceptance criteria. Use one run ID across logs, handoffs, `screen-shots`, evidence, review comments, notifications, branch metadata, and the PR body. +Recheck the issue immediately before mutation. `loopctl start` atomically creates the remote `codex/issue-N` branch at the verified base SHA as the cross-worktree reservation, then applies `loop:claimed` and creates the local run. GitHub permits only one creator for a new ref, so a concurrent loser exits before any checkpoint is published. If labeling fails, release the reservation only after verifying it still points to the exact base SHA. If a process crash nevertheless leaves a branch without a resumable checkpoint or open PR, the next trigger returns `claim_recovery`, wakes the loop, and immediately escalates the issue and branch to the owner instead of silently excluding it; never delete or reuse that reservation without explicit owner confirmation. Also reject another active run or open issue branch PR. Capture the issue title/body/labels/URL, base SHA, and acceptance criteria. Use one run ID across logs, handoffs, `screen-shots`, evidence, review comments, notifications, branch metadata, and the PR body. ### 2. Isolate diff --git a/loops/issue-dev-loop/SKILL.md b/loops/issue-dev-loop/SKILL.md index 21ee7b81..38366227 100644 --- a/loops/issue-dev-loop/SKILL.md +++ b/loops/issue-dev-loop/SKILL.md @@ -11,7 +11,7 @@ Run exactly one bounded issue cycle. Treat [`LOOP.md`](./LOOP.md) as the constit 3. Read [`references/github-operations.md`](./references/github-operations.md). Run every operational `loopctl`, executor GitHub command, remote Git command, trigger, and reviewer publication through the installed control plane with the explicit target root. Never use the credential-refusing repository launcher, invoke the `.mjs` router directly, install control code from an issue branch, or alter global `gh` or Git credential configuration. 4. Run `loopctl.mjs reconcile` through the automation wrapper to rebuild verified terminal history, pending/completed evolve state, and active runs from the append-only GitHub state journal. It tombstones local terminal-cache rows with no durable counterpart before recomputing metrics. For returned `workType: resume`, fetch the recorded branch through the wrapper, create a clean isolated worktree at the returned exact head, and run `restore-checkpoint --run-id <id>` inside that worktree. The restore command rejects the wrong branch, a dirty checkout, or any head other than the durable head. Resume it before selecting a new issue. 5. Run `loopctl.mjs evolve-status`. If `evolveDue` is true, start `echo_ui_loop_evolver` with fresh context; do not silently replace it with product work. -6. Run the installed `triggers/detect-work.mjs` through the automation wrapper with `--loop-root "$ECHO_UI_LOOP_TARGET_ROOT"`. The detector paginates every open issue and PR page. Exit without invoking an implementation agent when it reports `hasWork: false`. +6. Run the installed `triggers/detect-work.mjs` through the automation wrapper with `--loop-root "$ECHO_UI_LOOP_TARGET_ROOT"`. The detector paginates every open issue and PR page. Exit without invoking an implementation agent when it reports `hasWork: false`. If it returns `workType: claim_recovery`, a remote claim branch exists without a resumable checkpoint or open PR: immediately notify the owner through the scheduled task and canonical owner channel, include the issue and branch, do not invoke `$implement`, and do not delete or reuse the branch without explicit owner confirmation. 7. Refuse to start when another active run or PR already claims the issue. 8. Create a `codex/issue-<number>` branch and an isolated worktree from `dev`. 9. Start the run with `loopctl.mjs start --issue <number> --title <title> --url <issue-url> --base-sha <full-origin-dev-sha>`. diff --git a/loops/issue-dev-loop/references/github-operations.md b/loops/issue-dev-loop/references/github-operations.md index 8f463e9a..8734a3f1 100644 --- a/loops/issue-dev-loop/references/github-operations.md +++ b/loops/issue-dev-loop/references/github-operations.md @@ -23,8 +23,8 @@ The shell launcher removes Node preload hooks before starting the router. The ro ## Selection and claim 1. Select only open issues labeled `codex-ready`. -2. Exclude `loop:claimed`, an existing `codex/issue-<number>` branch, and any open PR that references or closes the issue. -3. Let `loopctl start --base-sha <full-origin-dev-sha>` acquire the local claim, recheck every page of open PRs, apply `loop:claimed`, and capture the authoritative issue; do not add the label manually first. +2. Exclude `loop:claimed` and any open PR that references or closes the issue. An existing `codex/issue-<number>` branch without a resumable checkpoint or open PR is `workType: claim_recovery`, not a quiet no-op: notify the owner and require confirmation before cleanup or reuse. +3. Let `loopctl start --base-sha <full-origin-dev-sha>` acquire the local claim, recheck every page of open PRs, atomically create `codex/issue-<number>` at that base SHA, apply `loop:claimed`, and capture the authoritative issue; do not add the label or remote branch manually first. If labeling fails, the trusted start command verifies that the ref is still at the reservation SHA and releases it. A crash that leaves only the remote ref is detected on the next wake and escalated instead of being silently skipped. 4. Record the issue snapshot and claim timestamp before implementation. A second active local run for the issue is rejected. 5. Immediately publish and validate an active checkpoint after the claim. Repeat after each durable phase; the next phase re-fetches the exact state-journal comment and refuses to advance on local-only proof. On a fresh wake, `reconcile` returns `workType: resume`, branch, and expected head before considering a new issue. Fetch that branch, create a clean isolated worktree at the exact head, then run `restore-checkpoint`; restoration blocks on the wrong branch, head, or dirty state. diff --git a/loops/issue-dev-loop/scripts/lib/common.mjs b/loops/issue-dev-loop/scripts/lib/common.mjs index 6d3f1519..7e991a54 100644 --- a/loops/issue-dev-loop/scripts/lib/common.mjs +++ b/loops/issue-dev-loop/scripts/lib/common.mjs @@ -208,6 +208,22 @@ export async function defaultGitHubApi(endpoint) { return JSON.parse(result.stdout) } +export async function postGitHubIssueComment(target, body) { + const result = await execFileAsync( + 'gh', + [ + 'api', + `repos/${target.owner}/${target.repo}/issues/${target.number}/comments`, + '--method', + 'POST', + '-f', + `body=${body}`, + ], + { maxBuffer: 1024 * 1024 }, + ) + return JSON.parse(result.stdout) +} + export async function paginateGitHubApi( githubApi, endpoint, diff --git a/loops/issue-dev-loop/scripts/lib/evidence.mjs b/loops/issue-dev-loop/scripts/lib/evidence.mjs index 5b161240..ba4fc7d9 100644 --- a/loops/issue-dev-loop/scripts/lib/evidence.mjs +++ b/loops/issue-dev-loop/scripts/lib/evidence.mjs @@ -536,6 +536,7 @@ export async function recordReview({ const digestMarker = `<!-- issue-dev-loop:${normalizedRunId}:review-result-sha256:${publicationDigest} -->` const publications = new Map() const priorFindingIds = new Set() + const publishedInlineCommentIds = new Set() let previousSubmittedAt = -Infinity for (const round of reviewSummary.roundDetails) { const roundTarget = parseReviewUrl(round.reviewUrl) @@ -568,7 +569,10 @@ export async function recordReview({ ) { throw new Error(`published GitHub review round ${round.round} is not bound to its exact head`) } - const expectedFindingIds = new Set(round.findings.map((finding) => finding.findingId)) + const expectedFindings = new Map( + round.findings.map((finding) => [finding.findingId, finding]), + ) + const expectedFindingIds = new Set(expectedFindings.keys()) const publishedFindingIds = new Set( bodies.flatMap((body) => body.match(/\bRVW-[0-9]+-[0-9]+-[0-9]+\b/g) ?? []), ) @@ -582,17 +586,38 @@ export async function recordReview({ ) { throw new Error(`published GitHub review round ${round.round} has unrecorded findings`) } + const inlineFindingIds = new Set() for (const comment of roundComments) { + const commentId = String(comment.id ?? '') const commentFindingIds = new Set(comment.body?.match(/\bRVW-[0-9]+-[0-9]+-[0-9]+\b/g) ?? []) + const [commentFindingId] = commentFindingIds + const finding = expectedFindings.get(commentFindingId) if ( + !/^[1-9][0-9]*$/.test(commentId) || + publishedInlineCommentIds.has(commentId) || !sameGitHubLogin(comment.user?.login, reviewerLogin) || - commentFindingIds.size === 0 || - [...commentFindingIds].some((findingId) => !expectedFindingIds.has(findingId)) + commentFindingIds.size !== 1 || + !finding || + inlineFindingIds.has(commentFindingId) || + finding.path !== comment.path || + !Number.isInteger(finding.line) || + ![comment.line, comment.original_line].includes(finding.line) || + ![ + `<!-- issue-dev-loop:${normalizedRunId}:${commentFindingId} -->`, + commentFindingId, + finding.severity, + finding.confidence, + finding.problem, + finding.evidence, + finding.expectedResolution, + ].every((fragment) => comment.body?.includes(fragment)) ) { throw new Error( `published GitHub review round ${round.round} has an unrecorded reviewer inline comment`, ) } + publishedInlineCommentIds.add(commentId) + inlineFindingIds.add(commentFindingId) } for (const finding of round.findings) { const findingMarker = `<!-- issue-dev-loop:${normalizedRunId}:${finding.findingId} -->` diff --git a/loops/issue-dev-loop/scripts/lib/evolve.mjs b/loops/issue-dev-loop/scripts/lib/evolve.mjs index f9467d62..2354aca0 100644 --- a/loops/issue-dev-loop/scripts/lib/evolve.mjs +++ b/loops/issue-dev-loop/scripts/lib/evolve.mjs @@ -9,8 +9,8 @@ import { assertNonEmpty, defaultGitHubApi, defaultGitHubPaginatedApi, - execFileAsync, parsePullCommentUrl, + postGitHubIssueComment, readJson, sameGitHubLogin, sameRepository, @@ -116,22 +116,6 @@ function isStateJournalComment(url, channel) { ) } -async function defaultGitHubComment(target, body) { - const result = await execFileAsync( - 'gh', - [ - 'api', - `repos/${target.owner}/${target.repo}/issues/${target.number}/comments`, - '--method', - 'POST', - '-f', - `body=${body}`, - ], - { maxBuffer: 1024 * 1024 }, - ) - return JSON.parse(result.stdout) -} - async function verifyPendingRequestComment({ request, comment, channel }) { const digest = pendingRequestDigest(request) const marker = `<!-- issue-dev-loop:evolve-request:${request.requestId}:sha256:${digest} -->` @@ -312,7 +296,7 @@ export async function completeEvolve({ now = new Date(), githubApi = defaultGitHubApi, githubPaginatedApi = defaultGitHubPaginatedApi, - githubComment = defaultGitHubComment, + githubComment = postGitHubIssueComment, verifyAutomationIdentity = assertAutomationIdentity, } = {}) { const normalizedRequestId = assertNonEmpty(requestId, 'requestId') @@ -394,7 +378,7 @@ export async function completeEvolve({ verified ??= candidate } if (!verified) { - if (githubComment === defaultGitHubComment) await verifyAutomationIdentity({ loopRoot }) + if (githubComment === postGitHubIssueComment) await verifyAutomationIdentity({ loopRoot }) const comment = await githubComment(journal, body) const commentUrl = assertHttpUrl(comment?.html_url, 'evolve.completionPublicationUrl') verified = await verifyPublishedEvolveCompletion({ diff --git a/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs b/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs index 8ea6421b..ede358ff 100644 --- a/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs +++ b/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs @@ -34,10 +34,9 @@ import { import { appendValidatedEvent, readEvents, readRun } from './run-store.mjs' import { createNotification } from './notifications.mjs' import { observeOwnerApprovedMerge } from './owner-gate.mjs' +import { TERMINAL_STATUSES } from './lifecycle-status.mjs' import { validateFinalizationHistory } from './validation.mjs' -const TERMINAL_STATUSES = new Set(['completed', 'failed', 'blocked', 'cancelled']) - export const canonicalRecord = canonicalFinalizationRecord export const recordDigest = finalizationRecordDigest diff --git a/loops/issue-dev-loop/scripts/lib/finalization-proof.mjs b/loops/issue-dev-loop/scripts/lib/finalization-proof.mjs index 04f08fe1..d355d3e5 100644 --- a/loops/issue-dev-loop/scripts/lib/finalization-proof.mjs +++ b/loops/issue-dev-loop/scripts/lib/finalization-proof.mjs @@ -14,10 +14,9 @@ import { parseCheckpointRecord, verifyPublishedCheckpoint, } from './checkpoint-proof.mjs' +import { TERMINAL_STATUSES } from './lifecycle-status.mjs' import { observeOwnerApprovedMerge } from './owner-gate.mjs' -const TERMINAL_STATUSES = new Set(['completed', 'failed', 'blocked', 'cancelled']) - export function canonicalFinalizationRecord(record) { return JSON.stringify({ schemaVersion: 1, diff --git a/loops/issue-dev-loop/scripts/lib/github-identity.mjs b/loops/issue-dev-loop/scripts/lib/github-identity.mjs index 077443cc..dc2d3d1a 100644 --- a/loops/issue-dev-loop/scripts/lib/github-identity.mjs +++ b/loops/issue-dev-loop/scripts/lib/github-identity.mjs @@ -833,6 +833,16 @@ function automationApiMutationAllowed( `sha=${authorization.issue.baseSha}`, ]) } + if ( + endpoint === + `repos/${authorization?.expectedRepository}/git/refs/heads/${authorization?.issue?.branch}` && + method === 'DELETE' && + fields.length === 0 && + authorization?.rootIntent === 'start' && + authorization?.issue?.status === 'starting' + ) { + return true + } const labels = endpoint.match(/^repos\/[^/]+\/[^/]+\/issues\/(\d+)\/labels(?:\/([^/]+))?$/) if (labels && Number(labels[1]) === authorization?.issue?.issueNumber) { if (method === 'POST') { diff --git a/loops/issue-dev-loop/scripts/lib/github.mjs b/loops/issue-dev-loop/scripts/lib/github.mjs index f856d25b..bc2de3a0 100644 --- a/loops/issue-dev-loop/scripts/lib/github.mjs +++ b/loops/issue-dev-loop/scripts/lib/github.mjs @@ -47,6 +47,25 @@ function issuePriority(issue) { export function selectIssue({ issues = [], pullRequests = [], branchNames = [] } = {}) { const branches = new Set(branchNames) + const orphanedClaims = issues.filter( + (issue) => + labelNames(issue).has('codex-ready') && + branches.has(`codex/issue-${issue.number}`) && + !pullRequests.some((pullRequest) => pullRequestClaimsIssue(pullRequest, issue.number)), + ) + orphanedClaims.sort((left, right) => { + const priorityDifference = issuePriority(left) - issuePriority(right) + if (priorityDifference !== 0) return priorityDifference + return left.number - right.number + }) + if (orphanedClaims[0]) { + return { + hasWork: true, + workType: 'claim_recovery', + issue: orphanedClaims[0], + branch: `codex/issue-${orphanedClaims[0].number}`, + } + } const eligible = issues.filter((issue) => { const labels = labelNames(issue) return ( @@ -136,6 +155,7 @@ export async function detectWork({ runId: result.runId ?? null, issueNumber: result.issue?.number ?? null, requestId: result.requestId ?? null, + branch: result.branch ?? null, }) return result } diff --git a/loops/issue-dev-loop/scripts/lib/issue-claim.mjs b/loops/issue-dev-loop/scripts/lib/issue-claim.mjs index c006add9..2b131242 100644 --- a/loops/issue-dev-loop/scripts/lib/issue-claim.mjs +++ b/loops/issue-dev-loop/scripts/lib/issue-claim.mjs @@ -41,6 +41,29 @@ async function defaultReserveRemoteBranch({ target, issueNumber, baseSha }) { ) } +async function defaultReleaseRemoteBranch({ + target, + issueNumber, + baseSha, + githubApi, +}) { + const endpoint = `repos/${target.owner}/${target.repo}/git/ref/heads/codex/issue-${issueNumber}` + const remoteRef = await githubApi(endpoint) + if (remoteRef.object?.sha !== baseSha) { + throw new Error('refusing to release a claim branch that advanced beyond its reservation SHA') + } + await execFileAsync( + 'gh', + [ + 'api', + `repos/${target.owner}/${target.repo}/git/refs/heads/codex/issue-${issueNumber}`, + '--method', + 'DELETE', + ], + { maxBuffer: 1024 * 1024 }, + ) +} + async function defaultRemoteBranchExists({ target, issueNumber }) { try { await execFileAsync( @@ -77,6 +100,7 @@ export async function defaultClaimIssue({ githubPaginatedApi = defaultGitHubPaginatedApi, addLabel = defaultAddLabel, reserveRemoteBranch = defaultReserveRemoteBranch, + releaseRemoteBranch = defaultReleaseRemoteBranch, remoteBranchExists = defaultRemoteBranchExists, }) { const target = parseGitHubTarget(issueUrl) @@ -107,7 +131,19 @@ export async function defaultClaimIssue({ await assertAutomationIdentity({ loopRoot, githubApi }) } await reserveRemoteBranch({ target, issueNumber, baseSha }) - await addLabel({ target, issueNumber }) + try { + await addLabel({ target, issueNumber }) + } catch (error) { + try { + await releaseRemoteBranch({ target, issueNumber, baseSha, githubApi }) + } catch (releaseError) { + throw new AggregateError( + [error, releaseError], + 'claim labeling failed and the exact remote reservation could not be released', + ) + } + throw error + } return issue } diff --git a/loops/issue-dev-loop/scripts/lib/lifecycle-status.mjs b/loops/issue-dev-loop/scripts/lib/lifecycle-status.mjs new file mode 100644 index 00000000..e3fac1c5 --- /dev/null +++ b/loops/issue-dev-loop/scripts/lib/lifecycle-status.mjs @@ -0,0 +1,6 @@ +export const TERMINAL_STATUSES = new Set([ + 'completed', + 'failed', + 'blocked', + 'cancelled', +]) diff --git a/loops/issue-dev-loop/scripts/lib/notifications.mjs b/loops/issue-dev-loop/scripts/lib/notifications.mjs index 135f937f..e0968f23 100644 --- a/loops/issue-dev-loop/scripts/lib/notifications.mjs +++ b/loops/issue-dev-loop/scripts/lib/notifications.mjs @@ -6,8 +6,8 @@ import { assertAutomationIdentity, assertNonEmpty, assertRunId, - execFileAsync, parseGitHubTarget, + postGitHubIssueComment, readJson, sameRepository, timestampToken, @@ -32,22 +32,6 @@ function notificationBody(notification, owner) { ].join('\n') } -async function defaultGitHubComment(target, body) { - const result = await execFileAsync( - 'gh', - [ - 'api', - `repos/${target.owner}/${target.repo}/issues/${target.number}/comments`, - '--method', - 'POST', - '-f', - `body=${body}`, - ], - { maxBuffer: 1024 * 1024 }, - ) - return JSON.parse(result.stdout) -} - export async function createNotification({ loopRoot = DEFAULT_LOOP_ROOT, runId, @@ -63,7 +47,7 @@ export async function createNotification({ environment = process.env, fetchImplementation = globalThis.fetch, webhookTimeoutMs = 5000, - githubComment = defaultGitHubComment, + githubComment = postGitHubIssueComment, verifyAutomationIdentity = assertAutomationIdentity, githubApi, checkpointVerifier = verifyLatestDurableCheckpoint, @@ -152,7 +136,7 @@ export async function createNotification({ ? 'dry_run' : 'not_configured' } else { - if (githubComment === defaultGitHubComment) { + if (githubComment === postGitHubIssueComment) { await verifyAutomationIdentity({ loopRoot }) } if (target) { diff --git a/loops/issue-dev-loop/scripts/lib/run-store.mjs b/loops/issue-dev-loop/scripts/lib/run-store.mjs index 02a2275c..87050fb7 100644 --- a/loops/issue-dev-loop/scripts/lib/run-store.mjs +++ b/loops/issue-dev-loop/scripts/lib/run-store.mjs @@ -22,6 +22,7 @@ import { writeJson, } from './common.mjs' import { defaultClaimIssue, defaultReleaseIssueClaim } from './issue-claim.mjs' +import { TERMINAL_STATUSES } from './lifecycle-status.mjs' import { updateEvolveMetrics } from './evolve.mjs' import { verifyLatestDurableCheckpoint } from './checkpoint-proof.mjs' import { @@ -33,7 +34,6 @@ import { import { observeOwnerApprovedMerge } from './owner-gate.mjs' export const PAUSED_STATUSES = new Set(['awaiting_owner_review', 'waiting_for_owner']) -const TERMINAL_STATUSES = new Set(['completed', 'failed', 'blocked', 'cancelled']) const RUN_STATUSES = new Set(['running', ...PAUSED_STATUSES, ...TERMINAL_STATUSES]) const RESERVED_EVENT_TYPES = new Set([ 'loop_started', diff --git a/loops/issue-dev-loop/scripts/lib/validation.mjs b/loops/issue-dev-loop/scripts/lib/validation.mjs index 64ab20d0..588cb585 100644 --- a/loops/issue-dev-loop/scripts/lib/validation.mjs +++ b/loops/issue-dev-loop/scripts/lib/validation.mjs @@ -93,6 +93,7 @@ export async function validateLoop({ 'scripts/identity-bin/gh', 'scripts/identity-bin/git', 'scripts/lib/issue-claim.mjs', + 'scripts/lib/lifecycle-status.mjs', 'scripts/lib/notifications.mjs', 'scripts/lib/owner-gate.mjs', 'scripts/lib/run-store.mjs', diff --git a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs index 1ff1a1cc..a739c35c 100644 --- a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs +++ b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs @@ -333,7 +333,7 @@ if (commandArguments[0] === 'spawn') { const reserveIssueIndex = commandArguments.indexOf('--reserve-issue') const reserveIssue = reserveIssueIndex === -1 ? issue : commandArguments[reserveIssueIndex + 1] - for (const command of [ + const commands = [ ['api', 'user'], [ 'api', @@ -345,8 +345,25 @@ if (commandArguments[0] === 'spawn') { '-f', \`sha=\${baseSha}\`, ], - ['api', \`repos/example/repo/issues/\${issue}/labels\`, '--method', 'POST', '-f', 'labels[]=loop:claimed'] - ]) { + ] + if (commandArguments.includes('--rollback-claim')) { + commands.push([ + 'api', + \`repos/example/repo/git/refs/heads/codex/issue-\${reserveIssue}\`, + '--method', + 'DELETE', + ]) + } else { + commands.push([ + 'api', + \`repos/example/repo/issues/\${issue}/labels\`, + '--method', + 'POST', + '-f', + 'labels[]=loop:claimed', + ]) + } + for (const command of commands) { const result = spawnSync('gh', command, { env: process.env, stdio: 'inherit' }) if (result.status !== 0) { process.exitCode = result.status ?? 1 @@ -959,6 +976,35 @@ test('routed loopctl start may claim only its command-line issue', async () => { assert.match(stdout, /issues\/123\/labels/) }) +test('routed loopctl start may release only its exact failed reservation', async () => { + const fixture = await createFixture({ activeRun: false }) + const { stdout } = await execFileAsync( + process.execPath, + [ + routerPath, + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + process.execPath, + fixture.loopctlPath, + 'start', + '--issue', + '123', + '--url', + 'https://github.com/example/repo/issues/123', + '--base-sha', + '0'.repeat(40), + '--rollback-claim', + '--loop-root', + fixture.loopRoot, + ], + { env: fixture.env }, + ) + assert.match(stdout, /git\/refs\/heads\/codex\/issue-123/) + assert.doesNotMatch(stdout, /issues\/123\/labels/) +}) + test('routed loopctl start rejects a remote reservation for another issue', async () => { const fixture = await createFixture({ activeRun: false }) await assert.rejects( diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index 1b24cd8e..6de54bf5 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -894,19 +894,19 @@ test('selectIssue chooses the highest-priority eligible unclaimed issue', () => assert.equal(result.hasWork, true) assert.equal(result.issue.number, 13) - assert.equal( - selectIssue({ - issues: [ - { - number: 13, - title: 'Reserved branch', - labels: [{ name: 'codex-ready' }], - }, - ], - branchNames: ['codex/issue-13'], - }).hasWork, - false, - ) + const orphanedClaim = selectIssue({ + issues: [ + { + number: 13, + title: 'Reserved branch', + labels: [{ name: 'codex-ready' }], + }, + ], + branchNames: ['codex/issue-13'], + }) + assert.equal(orphanedClaim.hasWork, true) + assert.equal(orphanedClaim.workType, 'claim_recovery') + assert.equal(orphanedClaim.branch, 'codex/issue-13') }) test('detectWork records a durable no-work trigger check without waking an executor', async () => { @@ -1230,6 +1230,39 @@ test('authoritative claim atomically reserves one remote issue branch across sta assert.equal(labelCount, 1) }) +test('authoritative claim releases its exact reservation when labeling fails', async () => { + let reservationCreated = false + let reservationReleased = false + await assert.rejects( + defaultClaimIssue({ + issueUrl: 'https://github.com/codeacme17/echo-ui/issues/128', + issueNumber: 128, + baseSha: '0'.repeat(40), + githubApi: async () => ({ + number: 128, + title: 'Issue', + state: 'open', + labels: [{ name: 'codex-ready' }], + }), + githubPaginatedApi: async () => [], + remoteBranchExists: async () => false, + reserveRemoteBranch: async () => { + reservationCreated = true + }, + addLabel: async () => { + throw new Error('label unavailable') + }, + releaseRemoteBranch: async ({ baseSha }) => { + assert.equal(baseSha, '0'.repeat(40)) + reservationReleased = true + }, + }), + /label unavailable/, + ) + assert.equal(reservationCreated, true) + assert.equal(reservationReleased, true) +}) + test('startRun creates one correlated run, handoff, and evidence directories', async () => { const { loopRoot } = await createFixture() let workspaceValidation @@ -2776,7 +2809,7 @@ test('review gate verifies published findings and classified replies', async () ) const reviewGithubApi = - ({ includePriorFinding = true } = {}) => + ({ includePriorFinding = true, duplicateInlineFinding = false } = {}) => async (endpoint) => { if (endpoint.endsWith('/pulls/300/reviews?per_page=100&page=1')) { return [ @@ -2795,22 +2828,30 @@ test('review gate verifies published findings and classified replies', async () ] } if (endpoint.endsWith('/reviews/499/comments?per_page=100&page=1')) { - return [ - { - user: { login: 'echo-ui-reviewer[bot]' }, - path: 'src/keyboard.ts', - line: 12, - body: [ - 'RVW-1-1-1', - 'P2', - 'high', - 'Incorrect assertion', - 'The runtime check already guarantees this invariant.', - 'Prove or fix the assertion.', - `<!-- issue-dev-loop:${run.runId}:RVW-1-1-1 -->`, - ].join('\n'), - }, - ] + const inlineFinding = { + id: 9001, + user: { login: 'echo-ui-reviewer[bot]' }, + path: 'src/keyboard.ts', + line: 12, + body: [ + 'RVW-1-1-1', + 'P2', + 'high', + 'Incorrect assertion', + 'The runtime check already guarantees this invariant.', + 'Prove or fix the assertion.', + `<!-- issue-dev-loop:${run.runId}:RVW-1-1-1 -->`, + ].join('\n'), + } + return duplicateInlineFinding + ? [ + inlineFinding, + { + ...inlineFinding, + id: 9002, + }, + ] + : [inlineFinding] } if (endpoint.endsWith('/comments?per_page=100&page=1')) return [] if (endpoint.includes('/issues/comments/400')) { @@ -2849,6 +2890,16 @@ test('review gate verifies published findings and classified replies', async () } } + await assert.rejects( + recordReview({ + loopRoot, + runId: run.runId, + resultPath, + reviewUrl: 'https://github.com/codeacme17/echo-ui/pull/300#pullrequestreview-500', + githubApi: reviewGithubApi({ duplicateInlineFinding: true }), + }), + /unrecorded reviewer inline comment/, + ) await assert.rejects( recordReview({ loopRoot, @@ -2937,6 +2988,7 @@ test('review gate rejects GitHub findings omitted from the durable result', asyn } if (endpoint.endsWith('/comments?per_page=100&page=1')) { return Array.from({ length: 100 }, (_, index) => ({ + id: index + 1, user: { login: 'echo-ui-reviewer[bot]' }, path: 'src/context.ts', line: index + 1, @@ -2947,6 +2999,7 @@ test('review gate rejects GitHub findings omitted from the durable result', asyn secondCommentPageFetched = true return [ { + id: 101, user: { login: 'echo-ui-reviewer[bot]' }, path: 'src/untracked.ts', line: 101, diff --git a/loops/issue-dev-loop/triggers/codex-automation-prompt.md b/loops/issue-dev-loop/triggers/codex-automation-prompt.md index 1e2c2d7e..4cce3743 100644 --- a/loops/issue-dev-loop/triggers/codex-automation-prompt.md +++ b/loops/issue-dev-loop/triggers/codex-automation-prompt.md @@ -2,4 +2,4 @@ Execute the repository loop at `loops/issue-dev-loop`. Read its `LOOP.md` contra Require `ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR`, `ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR`, `ECHO_UI_LOOP_UNTRUSTED_ROOTS`, `ECHO_UI_LOOP_CONTROL_PLANE`, and `ECHO_UI_LOOP_TARGET_ROOT`. The control plane must be a versioned, hash-verified installation made from clean owner-merged `dev`, outside the repository. The JSON-array untrusted roots must cover every repository/worktree/test mount visible to `$implement` or another untrusted agent; both private GitHub profiles must be outside them. Do not pass either profile variable or `GH_CONFIG_DIR` into `$implement`, reviewer, product-test, or verifier environments, and require their sandbox to deny profile-directory reads. Run `"$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" --loop-root "$ECHO_UI_LOOP_TARGET_ROOT" automation -- node "$ECHO_UI_LOOP_CONTROL_PLANE/scripts/loopctl.mjs" validate --activation --loop-root "$ECHO_UI_LOOP_TARGET_ROOT"`. Run every operational loop command, executor GitHub command, remote Git command, trigger, and reviewer publication through that installed launcher with the explicit target root. Never use the repository launcher for credentials, invoke its `.mjs` implementation directly, install control code from an issue branch, or change global authentication. -First run `loopctl.mjs reconcile`, then `loopctl.mjs evolve-status`, both through the automation wrapper. A pending evolve request starts a fresh `echo_ui_loop_evolver` session and an owner-reviewed evolve PR before routine product work. Otherwise run `triggers/detect-work.mjs` through the automation wrapper. If it returns `hasWork: false`, report a quiet no-op and stop. If it returns an issue, execute one bounded issue-dev-loop run for that exact issue. Preserve owner-only review and merge, use `$implement` for product code, use a fresh read-only reviewer after the draft PR, publish review commands through the reviewer wrapper, publish terminal state to the durable GitHub journal, and notify the owner for every blocking event or ready PR. +First run `loopctl.mjs reconcile`, then `loopctl.mjs evolve-status`, both through the automation wrapper. A pending evolve request starts a fresh `echo_ui_loop_evolver` session and an owner-reviewed evolve PR before routine product work. Otherwise run `triggers/detect-work.mjs` through the automation wrapper. If it returns `hasWork: false`, report a quiet no-op and stop. If it returns `workType: claim_recovery`, immediately notify the owner through this scheduled task and the canonical owner channel with the exact issue and branch, do not invoke `$implement`, and do not delete or reuse the orphaned reservation without explicit owner confirmation. If it returns an issue, execute one bounded issue-dev-loop run for that exact issue. Preserve owner-only review and merge, use `$implement` for product code, use a fresh read-only reviewer after the draft PR, publish review commands through the reviewer wrapper, publish terminal state to the durable GitHub journal, and notify the owner for every blocking event or ready PR. From e25b21285b920583f4361ea236092328acbf8acb Mon Sep 17 00:00:00 2001 From: leyoonafr <codeacme17@gmail.com> Date: Fri, 24 Jul 2026 16:24:43 +0800 Subject: [PATCH 41/41] fix: make review and claim proof atomic --- loops/issue-dev-loop/LOOP.md | 2 +- loops/issue-dev-loop/SKILL.md | 2 +- .../agents/echo-ui-pr-reviewer.toml | 3 +- .../references/github-operations.md | 4 +- loops/issue-dev-loop/review/REVIEW.md | 3 +- .../issue-dev-loop/review/result.schema.json | 2 + .../scripts/github-command-gate.mjs | 18 ++++- loops/issue-dev-loop/scripts/lib/evidence.mjs | 23 ++++++ .../scripts/lib/finalization-proof.mjs | 18 +---- .../scripts/lib/github-identity.mjs | 32 ++++++--- .../scripts/lib/issue-claim.mjs | 28 ++++---- .../tests/github-identity-routing.test.mjs | 53 ++++++++------ loops/issue-dev-loop/tests/runtime.test.mjs | 72 +++++++++++++++++-- 13 files changed, 186 insertions(+), 74 deletions(-) diff --git a/loops/issue-dev-loop/LOOP.md b/loops/issue-dev-loop/LOOP.md index 3abf02dc..e2449717 100644 --- a/loops/issue-dev-loop/LOOP.md +++ b/loops/issue-dev-loop/LOOP.md @@ -65,7 +65,7 @@ Use a combo trigger. Run `triggers/detect-work.mjs` before starting a Codex impl ### 1. Claim and snapshot -Recheck the issue immediately before mutation. `loopctl start` atomically creates the remote `codex/issue-N` branch at the verified base SHA as the cross-worktree reservation, then applies `loop:claimed` and creates the local run. GitHub permits only one creator for a new ref, so a concurrent loser exits before any checkpoint is published. If labeling fails, release the reservation only after verifying it still points to the exact base SHA. If a process crash nevertheless leaves a branch without a resumable checkpoint or open PR, the next trigger returns `claim_recovery`, wakes the loop, and immediately escalates the issue and branch to the owner instead of silently excluding it; never delete or reuse that reservation without explicit owner confirmation. Also reject another active run or open issue branch PR. Capture the issue title/body/labels/URL, base SHA, and acceptance criteria. Use one run ID across logs, handoffs, `screen-shots`, evidence, review comments, notifications, branch metadata, and the PR body. +Recheck the issue immediately before mutation. `loopctl start` atomically creates the remote `codex/issue-N` branch at the verified base SHA as the cross-worktree reservation, then applies `loop:claimed` and creates the local run. GitHub permits only one creator for a new ref, so a concurrent loser exits before any checkpoint is published. If labeling fails, release the reservation with a single atomic `--force-with-lease=<exact-base-sha>` ref deletion; an advanced branch fails the lease and is never deleted. If a process crash nevertheless leaves a branch without a resumable checkpoint or open PR, the next trigger returns `claim_recovery`, wakes the loop, and immediately escalates the issue and branch to the owner instead of silently excluding it; never delete or reuse that reservation without explicit owner confirmation. Also reject another active run or open issue branch PR. Capture the issue title/body/labels/URL, base SHA, and acceptance criteria. Use one run ID across logs, handoffs, `screen-shots`, evidence, review comments, notifications, branch metadata, and the PR body. ### 2. Isolate diff --git a/loops/issue-dev-loop/SKILL.md b/loops/issue-dev-loop/SKILL.md index 38366227..b96d4dc6 100644 --- a/loops/issue-dev-loop/SKILL.md +++ b/loops/issue-dev-loop/SKILL.md @@ -43,7 +43,7 @@ Push only the issue branch and create a **draft** PR targeting `dev` using `temp After the draft PR exists, spawn the project agent `echo_ui_pr_reviewer` with a fresh context. Give it only the issue snapshot, acceptance criteria, repository instructions, base SHA, head SHA, diff, CI results, and evidence manifest. Do not give it executor conversation history or rationale. -Publish every round through the installed wrapper with role `reviewer`; the executor identity may not author it. Every PR write must include `--repo codeacme17/echo-ui` (or use the full configured PR URL). Use `gh pr review --comment --body <body>` for a body-only review and include the exact run/cycle/round/head marker; the gate rejects body files, skipped rounds, duplicates, and publications outside the next durable cycle. When file/line findings require inline comments, use only the reviewer's gated `POST repos/codeacme17/echo-ui/pulls/<recorded-pr>/reviews` API shape with `event=COMMENT`, the exact durable head, and validated inline fields. Post findings verbatim as one review plus inline comments. Each finding needs a run-wide stable ID, severity, confidence, evidence, and expected resolution. Record the GitHub review URL for each round. Accepted repairs must start after that round was submitted, and executor replies must be posted through the automation wrapper after the corresponding `$implement` invocation finishes. Follow `review/REVIEW.md` and `review/response-policy.md`. +Publish every round through the installed wrapper with role `reviewer`; the executor identity may not author it. Every PR write must include `--repo codeacme17/echo-ui` (or use the full configured PR URL). Use `gh pr review --comment --body <body>` for a body-only review and include the exact run/cycle/round/head marker; the gate rejects body files, skipped rounds, duplicates, and publications outside the next durable cycle. When file/line findings require inline comments, use only the reviewer's gated `POST repos/codeacme17/echo-ui/pulls/<recorded-pr>/reviews` API shape with `event=COMMENT`, the exact durable head, and validated inline fields. Post findings verbatim as one review plus inline comments. Each finding needs a run-wide stable ID, severity, confidence, evidence, expected resolution, and the published GitHub inline comment ID or `null` for a body-only finding. Record the GitHub review URL for each round. Accepted repairs must start after that round was submitted, and executor replies must be posted through the automation wrapper after the corresponding `$implement` invocation finishes. One executor response comment may resolve only one finding. Follow `review/REVIEW.md` and `review/response-policy.md`. The executor must classify every finding as `accepted`, `rejected`, `needs-human`, `stale`, or `already-fixed`: diff --git a/loops/issue-dev-loop/agents/echo-ui-pr-reviewer.toml b/loops/issue-dev-loop/agents/echo-ui-pr-reviewer.toml index a0944951..134036e6 100644 --- a/loops/issue-dev-loop/agents/echo-ui-pr-reviewer.toml +++ b/loops/issue-dev-loop/agents/echo-ui-pr-reviewer.toml @@ -9,7 +9,8 @@ package compatibility, Web Audio lifecycle behavior, React behavior, accessibili security, regressions, and missing tests. Ignore executor conversation history and do not modify files. Avoid style-only or speculative findings. Return a structured review with run-wide `RVW-<cycle>-<round>-<sequence>` IDs, severity P0-P3, confidence, file and line, concrete -evidence, reproduction when possible, and expected resolution. Return PASS when no +evidence, reproduction when possible, expected resolution, and the GitHub inline comment ID after +publication (`null` for body-only findings). Return PASS when no actionable findings exist. Publish GitHub reviews only through the installed executable `"$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" --loop-root "$ECHO_UI_LOOP_TARGET_ROOT" reviewer -- ...` launcher; the wrapper must verify the diff --git a/loops/issue-dev-loop/references/github-operations.md b/loops/issue-dev-loop/references/github-operations.md index 8734a3f1..21691c24 100644 --- a/loops/issue-dev-loop/references/github-operations.md +++ b/loops/issue-dev-loop/references/github-operations.md @@ -24,7 +24,7 @@ The shell launcher removes Node preload hooks before starting the router. The ro 1. Select only open issues labeled `codex-ready`. 2. Exclude `loop:claimed` and any open PR that references or closes the issue. An existing `codex/issue-<number>` branch without a resumable checkpoint or open PR is `workType: claim_recovery`, not a quiet no-op: notify the owner and require confirmation before cleanup or reuse. -3. Let `loopctl start --base-sha <full-origin-dev-sha>` acquire the local claim, recheck every page of open PRs, atomically create `codex/issue-<number>` at that base SHA, apply `loop:claimed`, and capture the authoritative issue; do not add the label or remote branch manually first. If labeling fails, the trusted start command verifies that the ref is still at the reservation SHA and releases it. A crash that leaves only the remote ref is detected on the next wake and escalated instead of being silently skipped. +3. Let `loopctl start --base-sha <full-origin-dev-sha>` acquire the local claim, recheck every page of open PRs, atomically create `codex/issue-<number>` at that base SHA, apply `loop:claimed`, and capture the authoritative issue; do not add the label or remote branch manually first. If labeling fails, the trusted start command uses one exact `--force-with-lease` deletion, so an advanced ref fails atomically instead of being removed. A crash that leaves only the remote ref is detected on the next wake and escalated instead of being silently skipped. 4. Record the issue snapshot and claim timestamp before implementation. A second active local run for the issue is rejected. 5. Immediately publish and validate an active checkpoint after the claim. Repeat after each durable phase; the next phase re-fetches the exact state-journal comment and refuses to advance on local-only proof. On a fresh wake, `reconcile` returns `workType: resume`, branch, and expected head before considering a new issue. Fetch that branch, create a clean isolated worktree at the exact head, then run `restore-checkpoint`; restoration blocks on the wrong branch, head, or dirty state. @@ -49,7 +49,7 @@ The low-privilege `pull_request` workflow `Issue dev loop evidence` runs only wh "$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" --loop-root "$ECHO_UI_LOOP_TARGET_ROOT" automation -- gh run download <run-database-id> --name issue-dev-loop-<run-id>-<head-sha> --dir "$ECHO_UI_LOOP_TARGET_ROOT/evidence/<run-id>" ``` -Push only through the installed `with-github-identity ... automation -- git push ...` route. The wrapper rejects reviewer pushes, force pushes, and explicit pushes to `dev` or `main`. +Push only through the installed `with-github-identity ... automation -- git push ...` route. The wrapper rejects reviewer pushes, force pushes, and explicit pushes to `dev` or `main`; the only lease-bearing exception is the trusted `start` command's exact-base atomic deletion of its just-created claim reservation after labeling fails. Use the artifact URL emitted by `actions/upload-artifact` as `record-evidence --publication-url` and the downloaded artifact manifest as `--manifest`. From a worktree whose `HEAD` is the artifact's exact candidate head, the installed runtime first validates the protected diff, then independently downloads the artifact, requires a successful low-privilege `pull_request` run of the named unchanged workflow, validates workflow-run SHA and owner-merged base SHA, and byte-compares the manifest. Reject a run whose PR, base, branch, candidate head, workflow path, or manifest digests differ from the recorded run. diff --git a/loops/issue-dev-loop/review/REVIEW.md b/loops/issue-dev-loop/review/REVIEW.md index 6a7095a4..33fa9355 100644 --- a/loops/issue-dev-loop/review/REVIEW.md +++ b/loops/issue-dev-loop/review/REVIEW.md @@ -14,6 +14,7 @@ Return `PASS` or actionable findings. Each finding must include: - severity `P0`, `P1`, `P2`, or `P3` - confidence `high`, `medium`, or `low` - file and line when applicable +- GitHub inline comment ID after publication, or `null` for a body-only finding - concrete impact and evidence - reproduction or failing test when practical - expected resolution @@ -27,7 +28,7 @@ The fresh reviewer publishes each round verbatim through `reviewerGitHubLogin` a Before the final round is published, write the complete cycle result with a temporary/unassigned final `reviewUrl` and run the installed `loopctl review-digest --result <path>`. Put its returned `<!-- issue-dev-loop:<run-id>:review-result-sha256:<digest> -->` marker in the final body. After GitHub assigns the review URL, replace the temporary value and rerun `review-digest`; it must be unchanged. This canonical publication digest replaces only review URLs with a fixed placeholder and therefore avoids a URL↔digest cycle. The later recorded full-file digest still protects the final URLs and every other byte. The reviewer must not downgrade severity or omit findings. -After all replies are posted, create one cycle result matching `result.schema.json`. Its `cycle` is the next durable review cycle number, and it contains every round's review URL, every finding, its final classification, response URL, and evidence. Executor replies include both the readable evidence (and accepted fix SHA) plus `<!-- issue-dev-loop:<run-id>:<finding-id>:<classification> -->`. Rejected P0/P1 findings additionally require an independent or owner COMMENT publication containing `<!-- issue-dev-loop:<run-id>:<finding-id>:adjudication:<verdict>:head:<sha> -->`. Independent `REJECT_FINDING` adjudication is a separate body-only, non-approving GitHub review published through the reviewer identity; it is bound to an existing current-head reviewer finding, cannot be duplicated, and does not consume a review cycle round. Run `record-review` with the final GitHub review URL; it paginates every reviewer-authored review on the recorded PR and requires one-to-one membership for the current run/cycle before it verifies replies, identities, timestamps, ancestry, adjudications, and markers. The publication gate rejects skipped or duplicate cycle rounds. Generic events cannot forge this reserved gate. +After all replies are posted, create one cycle result matching `result.schema.json`. Its `cycle` is the next durable review cycle number, and it contains every round's review URL, every finding, its GitHub `inlineCommentId` (or `null` for a body-only finding), final classification, unique response URL, and evidence. Each fetched inline comment ID/path/line/body must bind exactly one durable finding, and one executor response comment may classify only one finding. Executor replies include both the readable evidence (and accepted fix SHA) plus `<!-- issue-dev-loop:<run-id>:<finding-id>:<classification> -->`. Rejected P0/P1 findings additionally require an independent or owner COMMENT publication containing `<!-- issue-dev-loop:<run-id>:<finding-id>:adjudication:<verdict>:head:<sha> -->`. Independent `REJECT_FINDING` adjudication is a separate body-only, non-approving GitHub review published through the reviewer identity; it is bound to an existing current-head reviewer finding, cannot be duplicated, and does not consume a review cycle round. Run `record-review` with the final GitHub review URL; it paginates every reviewer-authored review on the recorded PR and requires one-to-one membership for the current run/cycle before it verifies replies, identities, timestamps, ancestry, adjudications, and markers. The publication gate rejects skipped or duplicate cycle rounds. Generic events cannot forge this reserved gate. ## Completion diff --git a/loops/issue-dev-loop/review/result.schema.json b/loops/issue-dev-loop/review/result.schema.json index 707aefe3..4aa9dc5f 100644 --- a/loops/issue-dev-loop/review/result.schema.json +++ b/loops/issue-dev-loop/review/result.schema.json @@ -43,6 +43,7 @@ "severity", "confidence", "headSha", + "inlineCommentId", "problem", "evidence", "expectedResolution", @@ -57,6 +58,7 @@ "severity": { "enum": ["P0", "P1", "P2", "P3"] }, "confidence": { "enum": ["high", "medium", "low"] }, "headSha": { "type": "string", "pattern": "^[0-9a-fA-F]{40}$" }, + "inlineCommentId": { "type": ["integer", "null"], "minimum": 1 }, "path": { "type": ["string", "null"] }, "line": { "type": ["integer", "null"], "minimum": 1 }, "problem": { "type": "string", "minLength": 1 }, diff --git a/loops/issue-dev-loop/scripts/github-command-gate.mjs b/loops/issue-dev-loop/scripts/github-command-gate.mjs index 79bffefd..c3e49a3e 100755 --- a/loops/issue-dev-loop/scripts/github-command-gate.mjs +++ b/loops/issue-dev-loop/scripts/github-command-gate.mjs @@ -41,7 +41,21 @@ async function main() { : args if (tool !== 'credential') { if (tool === 'git' && args[0] === 'push') { - throw new Error('authenticated descendant processes cannot push') + const branch = authorization?.issue?.branch + const expectedRollback = [ + 'push', + `--force-with-lease=refs/heads/${branch}:${authorization?.issue?.baseSha}`, + 'origin', + `:refs/heads/${branch}`, + ] + if ( + authorization?.rootIntent !== 'start' || + authorization?.issue?.status !== 'starting' || + args.length !== expectedRollback.length || + args.some((argument, index) => argument !== expectedRollback[index]) + ) { + throw new Error('authenticated descendant processes cannot push') + } } assertDescendantCommandPolicy({ role, @@ -49,7 +63,7 @@ async function main() { args, authorization, }) - if (tool === 'git' && ['fetch', 'ls-remote'].includes(args[0])) { + if (tool === 'git' && ['push', 'fetch', 'ls-remote'].includes(args[0])) { await assertSafeRemoteGitConfiguration({ realGit: executable, environment: process.env, diff --git a/loops/issue-dev-loop/scripts/lib/evidence.mjs b/loops/issue-dev-loop/scripts/lib/evidence.mjs index ba4fc7d9..ab950f90 100644 --- a/loops/issue-dev-loop/scripts/lib/evidence.mjs +++ b/loops/issue-dev-loop/scripts/lib/evidence.mjs @@ -168,6 +168,22 @@ function validateReviewEvidence(review, headSha) { if (!['high', 'medium', 'low'].includes(finding.confidence)) { throw new Error(`${findingId}.confidence must be high, medium, or low`) } + const hasInlineLocation = + typeof finding.path === 'string' && + finding.path.length > 0 && + Number.isInteger(finding.line) && + finding.line > 0 + const hasInlineCommentId = + Number.isInteger(finding.inlineCommentId) && finding.inlineCommentId > 0 + if ( + (finding.path != null || finding.line != null) !== hasInlineLocation || + (finding.inlineCommentId !== null && !hasInlineCommentId) || + hasInlineLocation !== hasInlineCommentId + ) { + throw new Error( + `${findingId}.inlineCommentId must bind exactly one inline path and line, or be null`, + ) + } assertNonEmpty(finding.problem, `${findingId}.problem`) assertNonEmpty(finding.evidence, `${findingId}.evidence`) assertNonEmpty(finding.expectedResolution, `${findingId}.expectedResolution`) @@ -599,6 +615,7 @@ export async function recordReview({ commentFindingIds.size !== 1 || !finding || inlineFindingIds.has(commentFindingId) || + finding.inlineCommentId !== Number(commentId) || finding.path !== comment.path || !Number.isInteger(finding.line) || ![comment.line, comment.original_line].includes(finding.line) || @@ -668,6 +685,7 @@ export async function recordReview({ ) { throw new Error('published review is not bound to the recorded live PR head') } + const responseCommentIds = new Set() for (const round of reviewSummary.roundDetails) { const publication = publications.get(round.round) const reviewSubmittedAt = Date.parse(publication.submittedAt) @@ -681,6 +699,11 @@ export async function recordReview({ ) { throw new Error(`${finding.findingId} response is not on the reviewed PR`) } + const responseCommentId = `${responseTarget.kind}:${responseTarget.commentId}` + if (responseCommentIds.has(responseCommentId)) { + throw new Error('one executor response comment cannot adjudicate multiple findings') + } + responseCommentIds.add(responseCommentId) const responseEndpoint = responseTarget.kind === 'review_comment' ? `repos/${responseTarget.owner}/${responseTarget.repo}/pulls/comments/${responseTarget.commentId}` diff --git a/loops/issue-dev-loop/scripts/lib/finalization-proof.mjs b/loops/issue-dev-loop/scripts/lib/finalization-proof.mjs index d355d3e5..292b0761 100644 --- a/loops/issue-dev-loop/scripts/lib/finalization-proof.mjs +++ b/loops/issue-dev-loop/scripts/lib/finalization-proof.mjs @@ -1,16 +1,15 @@ import { createHash } from 'node:crypto' -import path from 'node:path' import { assertNonEmpty, defaultGitHubApi, parseGitHubTarget, parsePullCommentUrl, - readJson, sameGitHubLogin, sameRepository, } from './common.mjs' import { + checkpointJournalConfiguration, parseCheckpointRecord, verifyPublishedCheckpoint, } from './checkpoint-proof.mjs' @@ -243,20 +242,7 @@ export async function verifyFailedOrBlockedNotification({ return comment } -export async function finalizationJournalConfiguration(loopRoot) { - const channel = await readJson( - path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'), - ) - if ( - !Number.isInteger(channel.stateIssueNumber) || - channel.stateIssueNumber < 1 || - !channel.automationGitHubLogin - ) { - throw new Error('owner channel must configure stateIssueNumber and automationGitHubLogin') - } - const [owner, repo] = channel.repository.split('/') - return { channel, owner, repo } -} +export const finalizationJournalConfiguration = checkpointJournalConfiguration export async function verifyPullNotificationComment({ url, diff --git a/loops/issue-dev-loop/scripts/lib/github-identity.mjs b/loops/issue-dev-loop/scripts/lib/github-identity.mjs index dc2d3d1a..df4ba88d 100644 --- a/loops/issue-dev-loop/scripts/lib/github-identity.mjs +++ b/loops/issue-dev-loop/scripts/lib/github-identity.mjs @@ -231,6 +231,17 @@ export function hardenedGitArguments(args, { expectedRepository = null } = {}) { if (!expectedRepository) return hardened const repositoryUrl = canonicalRepositoryUrl(expectedRepository) if (subcommand.name === 'push') { + const lease = args[subcommand.index + 1] + const deleteRef = args.at(-1) + const rollback = lease?.match( + /^--force-with-lease=(refs\/heads\/codex\/issue-[1-9][0-9]*):([0-9a-f]{40})$/, + ) + if ( + rollback && + sameArguments(args, ['push', lease, 'origin', `:${rollback[1]}`]) + ) { + return ['push', lease, repositoryUrl, deleteRef] + } const branch = args.at(-1) return ['push', repositoryUrl, `refs/heads/${branch}:refs/heads/${branch}`] } @@ -281,6 +292,17 @@ export function assertGitCommandPolicy(role, args, { authorization = null } = {} const subcommand = gitSubcommand(args) if (subcommand.name === 'push') { if (role === 'reviewer') throw new Error('reviewer identity cannot run git push') + const claimBranch = authorization?.issue?.branch + const claimRollback = + authorization?.rootIntent === 'start' && + authorization?.issue?.status === 'starting' && + sameArguments(args, [ + 'push', + `--force-with-lease=refs/heads/${claimBranch}:${authorization?.issue?.baseSha}`, + 'origin', + `:refs/heads/${claimBranch}`, + ]) + if (claimRollback) return const branch = args.at(-1) const isLoopBranch = authorizedPushBranches(authorization).has(branch) const isAllowedShape = @@ -833,16 +855,6 @@ function automationApiMutationAllowed( `sha=${authorization.issue.baseSha}`, ]) } - if ( - endpoint === - `repos/${authorization?.expectedRepository}/git/refs/heads/${authorization?.issue?.branch}` && - method === 'DELETE' && - fields.length === 0 && - authorization?.rootIntent === 'start' && - authorization?.issue?.status === 'starting' - ) { - return true - } const labels = endpoint.match(/^repos\/[^/]+\/[^/]+\/issues\/(\d+)\/labels(?:\/([^/]+))?$/) if (labels && Number(labels[1]) === authorization?.issue?.issueNumber) { if (method === 'POST') { diff --git a/loops/issue-dev-loop/scripts/lib/issue-claim.mjs b/loops/issue-dev-loop/scripts/lib/issue-claim.mjs index 2b131242..51d1b695 100644 --- a/loops/issue-dev-loop/scripts/lib/issue-claim.mjs +++ b/loops/issue-dev-loop/scripts/lib/issue-claim.mjs @@ -1,3 +1,5 @@ +import path from 'node:path' + import { assertAutomationIdentity, DEFAULT_LOOP_ROOT, @@ -42,25 +44,23 @@ async function defaultReserveRemoteBranch({ target, issueNumber, baseSha }) { } async function defaultReleaseRemoteBranch({ - target, + loopRoot, issueNumber, baseSha, - githubApi, }) { - const endpoint = `repos/${target.owner}/${target.repo}/git/ref/heads/codex/issue-${issueNumber}` - const remoteRef = await githubApi(endpoint) - if (remoteRef.object?.sha !== baseSha) { - throw new Error('refusing to release a claim branch that advanced beyond its reservation SHA') - } + const branch = `codex/issue-${issueNumber}` await execFileAsync( - 'gh', + 'git', [ - 'api', - `repos/${target.owner}/${target.repo}/git/refs/heads/codex/issue-${issueNumber}`, - '--method', - 'DELETE', + 'push', + `--force-with-lease=refs/heads/${branch}:${baseSha}`, + 'origin', + `:refs/heads/${branch}`, ], - { maxBuffer: 1024 * 1024 }, + { + cwd: path.resolve(loopRoot, '..', '..'), + maxBuffer: 1024 * 1024, + }, ) } @@ -135,7 +135,7 @@ export async function defaultClaimIssue({ await addLabel({ target, issueNumber }) } catch (error) { try { - await releaseRemoteBranch({ target, issueNumber, baseSha, githubApi }) + await releaseRemoteBranch({ loopRoot, issueNumber, baseSha }) } catch (releaseError) { throw new AggregateError( [error, releaseError], diff --git a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs index a739c35c..e6074e02 100644 --- a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs +++ b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs @@ -334,37 +334,46 @@ if (commandArguments[0] === 'spawn') { const reserveIssue = reserveIssueIndex === -1 ? issue : commandArguments[reserveIssueIndex + 1] const commands = [ - ['api', 'user'], + ['gh', ['api', 'user']], [ - 'api', - 'repos/example/repo/git/refs', - '--method', - 'POST', - '-f', - \`ref=refs/heads/codex/issue-\${reserveIssue}\`, - '-f', - \`sha=\${baseSha}\`, + 'gh', + [ + 'api', + 'repos/example/repo/git/refs', + '--method', + 'POST', + '-f', + \`ref=refs/heads/codex/issue-\${reserveIssue}\`, + '-f', + \`sha=\${baseSha}\`, + ], ], ] if (commandArguments.includes('--rollback-claim')) { commands.push([ - 'api', - \`repos/example/repo/git/refs/heads/codex/issue-\${reserveIssue}\`, - '--method', - 'DELETE', + 'git', + [ + 'push', + \`--force-with-lease=refs/heads/codex/issue-\${reserveIssue}:\${baseSha}\`, + 'origin', + \`:refs/heads/codex/issue-\${reserveIssue}\`, + ], ]) } else { commands.push([ - 'api', - \`repos/example/repo/issues/\${issue}/labels\`, - '--method', - 'POST', - '-f', - 'labels[]=loop:claimed', + 'gh', + [ + 'api', + \`repos/example/repo/issues/\${issue}/labels\`, + '--method', + 'POST', + '-f', + 'labels[]=loop:claimed', + ], ]) } - for (const command of commands) { - const result = spawnSync('gh', command, { env: process.env, stdio: 'inherit' }) + for (const [tool, command] of commands) { + const result = spawnSync(tool, command, { env: process.env, stdio: 'inherit' }) if (result.status !== 0) { process.exitCode = result.status ?? 1 break @@ -1001,7 +1010,7 @@ test('routed loopctl start may release only its exact failed reservation', async ], { env: fixture.env }, ) - assert.match(stdout, /git\/refs\/heads\/codex\/issue-123/) + assert.match(stdout, /force-with-lease=refs\/heads\/codex\/issue-123/) assert.doesNotMatch(stdout, /issues\/123\/labels/) }) diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index 6de54bf5..5b5acd23 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -2760,6 +2760,7 @@ test('review gate verifies published findings and classified replies', async () severity: 'P2', confidence: 'high', headSha: 'e'.repeat(40), + inlineCommentId: 9001, path: 'src/keyboard.ts', line: 12, problem: 'Incorrect assertion', @@ -2771,6 +2772,21 @@ test('review gate verifies published findings and classified replies', async () evidence: 'Reproduction command exits successfully.', }, }, + { + findingId: 'RVW-1-1-2', + severity: 'P3', + confidence: 'high', + headSha: 'e'.repeat(40), + inlineCommentId: null, + problem: 'Duplicated branch', + evidence: 'The duplicate branch is visible in the reviewed diff.', + expectedResolution: 'Remove or justify the duplicate branch.', + resolution: { + classification: 'rejected', + responseUrl: 'https://github.com/codeacme17/echo-ui/pull/300#issuecomment-401', + evidence: 'The branches cover distinct state transitions.', + }, + }, ], }, { @@ -2807,9 +2823,29 @@ test('review gate verifies published findings and classified replies', async () }), /invalid or duplicate finding ID: RVW-1-2-1/, ) + const duplicateResponseResultPath = path.join( + loopRoot, + 'logs', + 'runs', + run.runId, + 'review-result-duplicate-response.json', + ) + const duplicateResponseResult = JSON.parse(await readFile(resultPath, 'utf8')) + duplicateResponseResult.rounds[0].findings[1].resolution.responseUrl = + duplicateResponseResult.rounds[0].findings[0].resolution.responseUrl + await writeFile( + duplicateResponseResultPath, + `${JSON.stringify(duplicateResponseResult)}\n`, + 'utf8', + ) + const duplicateResponseDigest = reviewPublicationDigest(duplicateResponseResult) const reviewGithubApi = - ({ includePriorFinding = true, duplicateInlineFinding = false } = {}) => + ({ + includePriorFinding = true, + duplicateInlineFinding = false, + publicationDigest = digest, + } = {}) => async (endpoint) => { if (endpoint.endsWith('/pulls/300/reviews?per_page=100&page=1')) { return [ @@ -2861,6 +2897,13 @@ test('review gate verifies published findings and classified replies', async () body: `Rejected with proof. Reproduction command exits successfully.\n<!-- issue-dev-loop:${run.runId}:RVW-1-1-1:rejected -->`, } } + if (endpoint.includes('/issues/comments/401')) { + return { + user: { login: 'echo-ui-loop[bot]' }, + created_at: '2026-07-22T17:01:00.000Z', + body: `The branches cover distinct state transitions.\n<!-- issue-dev-loop:${run.runId}:RVW-1-1-2:rejected -->`, + } + } if (endpoint.endsWith('/pulls/300')) return pullRequestFixture(run, headSha) const firstRound = endpoint.includes('/reviews/499') return { @@ -2877,15 +2920,24 @@ test('review gate verifies published findings and classified replies', async () 'The runtime check already guarantees this invariant.', 'Prove or fix the assertion.', `<!-- issue-dev-loop:${run.runId}:RVW-1-1-1 -->`, + 'RVW-1-1-2', + 'P3', + 'high', + 'Duplicated branch', + 'The duplicate branch is visible in the reviewed diff.', + 'Remove or justify the duplicate branch.', + `<!-- issue-dev-loop:${run.runId}:RVW-1-1-2 -->`, `<!-- issue-dev-loop:${run.runId}:review-cycle:1:round:1:head:${'e'.repeat(40)} -->`, ].join('\n') : [ 'PASS', ...(includePriorFinding - ? ['Resolved RVW-1-1-1 with the published executor response.'] + ? [ + 'Resolved RVW-1-1-1 and RVW-1-1-2 with published executor responses.', + ] : []), `<!-- issue-dev-loop:${run.runId}:review-cycle:1:round:2:head:${headSha} -->`, - `<!-- issue-dev-loop:${run.runId}:review-result-sha256:${digest} -->`, + `<!-- issue-dev-loop:${run.runId}:review-result-sha256:${publicationDigest} -->`, ].join('\n'), } } @@ -2900,6 +2952,16 @@ test('review gate verifies published findings and classified replies', async () }), /unrecorded reviewer inline comment/, ) + await assert.rejects( + recordReview({ + loopRoot, + runId: run.runId, + resultPath: duplicateResponseResultPath, + reviewUrl: 'https://github.com/codeacme17/echo-ui/pull/300#pullrequestreview-500', + githubApi: reviewGithubApi({ publicationDigest: duplicateResponseDigest }), + }), + /response comment cannot adjudicate multiple findings/, + ) await assert.rejects( recordReview({ loopRoot, @@ -2917,7 +2979,7 @@ test('review gate verifies published findings and classified replies', async () reviewUrl: 'https://github.com/codeacme17/echo-ui/pull/300#pullrequestreview-500', githubApi: reviewGithubApi(), }) - assert.equal(recorded.findingCount, 1) + assert.equal(recorded.findingCount, 2) assert.equal(recorded.rounds, 2) }) @@ -3095,6 +3157,7 @@ test('accepted review fix must be after the finding head and inside the final he severity: 'P2', confidence: 'high', headSha: findingHead, + inlineCommentId: null, problem: 'Missing guard', evidence: 'The failure is reproducible.', expectedResolution: 'Add the guard.', @@ -3218,6 +3281,7 @@ test('review gate binds high-severity adjudication verdict to the correct identi severity: 'P1', confidence: 'high', headSha, + inlineCommentId: null, problem: 'Potential public API break', evidence: 'The export changed.', expectedResolution: 'Restore compatibility or adjudicate.',