diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 00000000..282eb8ff --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,15 @@ +[agents] +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 = "../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 = "../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 = "../loops/issue-dev-loop/agents/echo-ui-loop-evolver.toml" diff --git a/.github/workflows/issue-dev-loop-evidence.yml b/.github/workflows/issue-dev-loop-evidence.yml new file mode 100644 index 00000000..cdb51587 --- /dev/null +++ b/.github/workflows/issue-dev-loop-evidence.yml @@ -0,0 +1,213 @@ +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: + bootstrap-evidence: + 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: + - 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: Assert immutable bootstrap head + run: test "$(git rev-parse HEAD)" = "${{ github.event.pull_request.head.sha }}" + + - name: Set up pnpm + uses: pnpm/action-setup@v6 + + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + + - 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 control plane + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.base.sha }} + fetch-depth: 0 + path: control + 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 control/loops/issue-dev-loop/scripts/resolve-run.mjs --loop-root candidate/loops/issue-dev-loop --branch "$PR_HEAD_REF" + + - 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 "${{ 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 "${{ steps.run.outputs.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=${baseline_volume},dst=/work" \ + --mount "type=bind,src=${GITHUB_WORKSPACE}/trusted,dst=/source,readonly" \ + "${image}" \ + sh -ceu 'cp -a /source/. /work/; cd /work; pnpm install --frozen-lockfile --ignore-scripts' + + - 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 + 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 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() + 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 + 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 + + - 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.verify.outputs.exit_code != '0' + run: exit 1 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/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/README.md b/loops/README.md new file mode 100644 index 00000000..58a294d1 --- /dev/null +++ b/loops/README.md @@ -0,0 +1,16 @@ +# 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. +- 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. +- 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..c87f8867 --- /dev/null +++ b/loops/_shared/owner-channel/CHANNEL.md @@ -0,0 +1,25 @@ +# 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 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. + +`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 ` 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 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`. +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 new file mode 100644 index 00000000..059bac46 --- /dev/null +++ b/loops/_shared/owner-channel/channel.json @@ -0,0 +1,25 @@ +{ + "schemaVersion": 1, + "ownerGitHubLogin": "codeacme17", + "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", + "webhookEnvironmentVariable": "ECHO_UI_LOOP_OWNER_WEBHOOK_URL", + "untrustedRootsEnvironmentVariable": "ECHO_UI_LOOP_UNTRUSTED_ROOTS", + "informationalImmediateTypes": [ + "pr_completed" + ], + "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..e2449717 --- /dev/null +++ b/loops/issue-dev-loop/LOOP.md @@ -0,0 +1,137 @@ +# 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 +- 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-` 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 +- 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 +- 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 +- 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. 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 + +### 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 with a single atomic `--force-with-lease=` 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 + +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, 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. 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. 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/` 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 + +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 `- \`\`: passed (exit code 0)`; a summary-level pass statement is not sufficient. + +### 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. 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 + +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 + +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, 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 + +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. 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. + +## 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. 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. 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 ` 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`. 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 + +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..b96d4dc6 --- /dev/null +++ b/loops/issue-dev-loop/SKILL.md @@ -0,0 +1,77 @@ +# 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 + +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 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 ` 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`. 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-` branch and an isolated worktree from `dev`. +9. Start the run with `loopctl.mjs start --issue --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. + +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 + +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 + +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 + +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, 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`: + +- 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. 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. 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, 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. + +## 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/echo-ui-loop-evolver.toml b/loops/issue-dev-loop/agents/echo-ui-loop-evolver.toml new file mode 100644 index 00000000..78e0cbdb --- /dev/null +++ b/loops/issue-dev-loop/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/loops/issue-dev-loop/agents/echo-ui-pr-reviewer.toml b/loops/issue-dev-loop/agents/echo-ui-pr-reviewer.toml new file mode 100644 index 00000000..134036e6 --- /dev/null +++ b/loops/issue-dev-loop/agents/echo-ui-pr-reviewer.toml @@ -0,0 +1,26 @@ +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 run-wide `RVW-<cycle>-<round>-<sequence>` IDs, severity P0-P3, confidence, file and line, concrete +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 +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 +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/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/agents/echo-ui-review-adjudicator.toml b/loops/issue-dev-loop/agents/echo-ui-review-adjudicator.toml new file mode 100644 index 00000000..ce0f319e --- /dev/null +++ b/loops/issue-dev-loop/agents/echo-ui-review-adjudicator.toml @@ -0,0 +1,21 @@ +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. +For REJECT_FINDING, publish the required adjudication marker only through the +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. +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/agents/openai.yaml b/loops/issue-dev-loop/agents/openai.yaml new file mode 100644 index 00000000..61b611a8 --- /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: '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/dependencies.md b/loops/issue-dev-loop/dependencies.md new file mode 100644 index 00000000..506a71da --- /dev/null +++ b/loops/issue-dev-loop/dependencies.md @@ -0,0 +1,46 @@ +# Runtime dependencies + +## 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 +- 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 +- `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 +- A dedicated GitHub issue configured as `stateIssueNumber` for append-only active checkpoints and finalization records + +## Optional + +- `ECHO_UI_LOOP_OWNER_WEBHOOK_URL` for an immediate JSON webhook mirror +- Browser access for UI verification and screenshot collection + +## Identity + +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. + +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" +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 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/evidence/.gitignore b/loops/issue-dev-loop/evidence/.gitignore new file mode 100644 index 00000000..c9489c04 --- /dev/null +++ b/loops/issue-dev-loop/evidence/.gitignore @@ -0,0 +1,4 @@ +**/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 new file mode 100644 index 00000000..8951160d --- /dev/null +++ b/loops/issue-dev-loop/evolve/EVOLVE.md @@ -0,0 +1,21 @@ +# Evolve session contract + +`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 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 + +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 +- 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. + +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/evolve/metrics.json b/loops/issue-dev-loop/evolve/metrics.json new file mode 100644 index 00000000..26ca0713 --- /dev/null +++ b/loops/issue-dev-loop/evolve/metrics.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": 1, + "finalizedRuns": 0, + "successfulRuns": 0, + "failedRuns": 0, + "noWorkChecks": 0, + "ownerRequestedChanges": 0, + "revertedPullRequests": 0, + "recentFailureFingerprints": [], + "evolveDue": false, + "pendingRequestId": null, + "lastEvolvedAt": null, + "lastEvolvedRunCount": 0, + "completedEvolveSessions": 0, + "reviewFindings": { + "accepted": 0, + "rejected": 0, + "needsHuman": 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 new file mode 100644 index 00000000..e258f9a9 --- /dev/null +++ b/loops/issue-dev-loop/handoffs/.gitignore @@ -0,0 +1 @@ +**/private/ 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/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 new file mode 100644 index 00000000..38386342 --- /dev/null +++ b/loops/issue-dev-loop/references/evidence-policy.md @@ -0,0 +1,30 @@ +# Evidence policy + +Evidence proves the acceptance criteria against the exact PR head SHA. + +## Always include + +- 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 +- 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 + +## 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` 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 + +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 new file mode 100644 index 00000000..21691c24 --- /dev/null +++ b/loops/issue-dev-loop/references/github-operations.md @@ -0,0 +1,80 @@ +# GitHub operation policy + +Read this before mutating issues or pull requests. + +Run every executor GitHub command through: + +```text +"$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" --loop-root "$ECHO_UI_LOOP_TARGET_ROOT" automation -- <command> [args...] +``` + +`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 +"$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 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 + +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 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. + +## Branch and PR + +- 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 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. +- 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. 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> +"$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`; 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. + +## 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. + +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 + +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 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/review/REVIEW.md b/loops/issue-dev-loop/review/REVIEW.md new file mode 100644 index 00000000..33fa9355 --- /dev/null +++ b/loops/issue-dev-loop/review/REVIEW.md @@ -0,0 +1,35 @@ +# 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 run-wide ID `RVW-<cycle>-<round>-<sequence>` +- 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 +- 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 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 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 + +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/review/comment.schema.json b/loops/issue-dev-loop/review/comment.schema.json new file mode 100644 index 00000000..b847a69f --- /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]+-[0-9]+$" }, + "severity": { "enum": ["P0", "P1", "P2", "P3"] }, + "confidence": { "enum": ["high", "medium", "low"] }, + "headSha": { "type": "string", "pattern": "^[0-9a-fA-F]{40}$" }, + "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..6501c64c --- /dev/null +++ b/loops/issue-dev-loop/review/response-policy.md @@ -0,0 +1,17 @@ +# 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 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 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. + +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/result.schema.json b/loops/issue-dev-loop/review/result.schema.json new file mode 100644 index 00000000..4aa9dc5f --- /dev/null +++ b/loops/issue-dev-loop/review/result.schema.json @@ -0,0 +1,95 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Independent review cycle result", + "type": "object", + "required": [ + "schemaVersion", + "runId", + "cycle", + "reviewerAgent", + "freshContext", + "headSha", + "verdict", + "rounds" + ], + "additionalProperties": false, + "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}$" }, + "verdict": { "const": "PASS" }, + "rounds": { + "type": "array", + "minItems": 1, + "maxItems": 2, + "items": { + "type": "object", + "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", + "items": { + "type": "object", + "required": [ + "findingId", + "severity", + "confidence", + "headSha", + "inlineCommentId", + "problem", + "evidence", + "expectedResolution", + "resolution" + ], + "additionalProperties": false, + "properties": { + "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}$" }, + "inlineCommentId": { "type": ["integer", "null"], "minimum": 1 }, + "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"], + "pattern": "^[0-9a-fA-F]{40}$" + }, + "adjudicationUrl": { "type": ["string", "null"], "format": "uri" }, + "adjudicationVerdict": { + "type": ["string", "null"], + "enum": ["REJECT_FINDING", "OWNER_REJECTED_FINDING", null] + } + } + } + } + } + } + } + } + } + } +} 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..57d8704d --- /dev/null +++ b/loops/issue-dev-loop/review/reviewer-prompt.md @@ -0,0 +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 `"$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/checkpoint-record.schema.json b/loops/issue-dev-loop/schemas/checkpoint-record.schema.json new file mode 100644 index 00000000..150df03b --- /dev/null +++ b/loops/issue-dev-loop/schemas/checkpoint-record.schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Issue development loop active checkpoint", + "type": "object", + "required": ["schemaVersion", "kind", "run", "briefSource", "events", "artifacts", "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" } + }, + "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/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..ff034dca --- /dev/null +++ b/loops/issue-dev-loop/schemas/evidence.schema.json @@ -0,0 +1,84 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Issue development evidence manifest", + "type": "object", + "required": [ + "schemaVersion", + "runId", + "issueNumber", + "baseSha", + "headSha", + "trustedWorkflowSha", + "workflowBaseSha", + "workflowRunSha", + "verdict", + "checks", + "screenshots", + "limitations" + ], + "additionalProperties": false, + "properties": { + "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}$" }, + "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": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "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" } + } + } + }, + "screenshots": { + "type": "array", + "items": { + "type": "object", + "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 }, + "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}$" } + } + } + }, + "limitations": { "type": "array", "items": { "type": "string" } } + } +} 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..9c4e2860 --- /dev/null +++ b/loops/issue-dev-loop/schemas/finalization-record.schema.json @@ -0,0 +1,57 @@ +{ + "$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", + "notificationUrl", + "readyNotificationUrl", + "readyNotifiedAt", + "completionNotifiedAt", + "notificationWebhookStatus", + "predecessorCheckpointUrl", + "predecessorCheckpointDigest", + "pauseStartedAt", + "notificationNotifiedAt" + ], + "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"] }, + "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"] }, + "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/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 new file mode 100644 index 00000000..b2876f9a --- /dev/null +++ b/loops/issue-dev-loop/schemas/run.schema.json @@ -0,0 +1,66 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Issue development loop run", + "type": "object", + "required": [ + "schemaVersion", + "runId", + "issueNumber", + "issueTitle", + "issueUrl", + "baseBranch", + "baseSha", + "branch", + "status", + "startedAt", + "issueSnapshot", + "briefDigest", + "uiEvidenceRequired", + "implementationCommit" + ], + "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" }, + "baseSha": { "type": "string", "pattern": "^[0-9a-fA-F]{40}$" }, + "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"], "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/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/scripts/generate-evidence.mjs b/loops/issue-dev-loop/scripts/generate-evidence.mjs new file mode 100644 index 00000000..ca60f452 --- /dev/null +++ b/loops/issue-dev-loop/scripts/generate-evidence.mjs @@ -0,0 +1,245 @@ +#!/usr/bin/env node + +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' +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 +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') +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'), +) +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(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') +} +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', +) +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)) + 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 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 : run.implementationCommit + if ( + path.isAbsolute(screenshot.path) || + !normalizedScreenshotPath.startsWith(expectedPathPrefix) || + screenshot.sourceSha !== expectedSourceSha || + !/^[0-9a-f]{40}$/i.test(screenshot.sourceSha) || + Number.isNaN(Date.parse(screenshot.capturedAt)) + ) { + throw new Error('screenshot metadata must match its source commit 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.headSha = headSha + 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') + } +} + +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 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) => ({ + 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, + trustedWorkflowSha, + workflowBaseSha, + workflowRunSha, + 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, + }, + { + 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: [], +} + +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/github-command-gate.mjs b/loops/issue-dev-loop/scripts/github-command-gate.mjs new file mode 100755 index 00000000..c3e49a3e --- /dev/null +++ b/loops/issue-dev-loop/scripts/github-command-gate.mjs @@ -0,0 +1,99 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process' + +import { + assertDescendantCommandPolicy, + assertPushTargetsRepository, + assertSafeRemoteGitConfiguration, + hardenedGitArguments, +} from './lib/github-identity.mjs' +import { loadTrustedControlPlane } from './lib/trusted-control-plane.mjs' + +async function main() { + const [tool, ...args] = process.argv.slice(2) + const role = process.env.ECHO_UI_LOOP_GITHUB_ROLE + 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 executableName = tool === 'credential' ? 'gh' : tool + if (!['git', 'gh'].includes(executableName)) { + throw new Error(`unsupported authenticated tool: ${tool}`) + } + const trustedControlPlane = await loadTrustedControlPlane() + const executable = trustedControlPlane.executables[executableName] + const executableArgs = + tool === 'credential' + ? ['auth', 'git-credential'] + : tool === 'git' + ? hardenedGitArguments(args, { + expectedRepository: authorization?.expectedRepository, + }) + : args + if (tool !== 'credential') { + if (tool === 'git' && args[0] === '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, + tool, + args, + authorization, + }) + if (tool === 'git' && ['push', 'fetch', 'ls-remote'].includes(args[0])) { + await assertSafeRemoteGitConfiguration({ + realGit: executable, + environment: process.env, + }) + await assertPushTargetsRepository({ + expectedRepository: authorization.expectedRepository, + realGit: executable, + environment: process.env, + }) + } + } + const child = spawn(executable, executableArgs, { + 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/install-trusted-control-plane.mjs b/loops/issue-dev-loop/scripts/install-trusted-control-plane.mjs new file mode 100644 index 00000000..a27221a1 --- /dev/null +++ b/loops/issue-dev-loop/scripts/install-trusted-control-plane.mjs @@ -0,0 +1,227 @@ +#!/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 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, + [ + '#!/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), + }, + executableDigests, + 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/active-journal.mjs b/loops/issue-dev-loop/scripts/lib/active-journal.mjs new file mode 100644 index 00000000..92dcca9f --- /dev/null +++ b/loops/issue-dev-loop/scripts/lib/active-journal.mjs @@ -0,0 +1,291 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' + +import { + DEFAULT_LOOP_ROOT, + assertNonEmpty, + assertRunId, + defaultGitHubApi, + defaultGitHubPaginatedApi, + execFileAsync, + readJson, + runDirectory, + sameGitHubLogin, + writeJson, +} from './common.mjs' +import { + canonicalCheckpointRecord, + checkpointArtifactsForEvents, + 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']) + +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) + 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 = validateCheckpointRecord({ + schemaVersion: 1, + kind: 'active-checkpoint', + 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') + await writeJson(resultPath, record) + const { channel, owner, repo } = await checkpointJournalConfiguration(loopRoot) + const { digest, body } = checkpointPublicationBody(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 = 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') + const briefSource = await readFile( + path.join(loopRoot, 'handoffs', normalizedRunId, 'implementation-brief.md'), + 'utf8', + ) + const currentRecord = { + ...record, + run, + briefSource, + events: currentEvents, + artifacts: await checkpointArtifactsForEvents({ + loopRoot, + runId: normalizedRunId, + events: currentEvents, + }), + updatedAt: currentEvents.at(-1)?.timestamp, + } + if (canonicalCheckpointRecord(currentRecord) !== canonicalCheckpointRecord(record)) { + throw new Error('checkpoint result no longer matches the active run') + } + const { digest } = await verifyPublishedCheckpoint({ + loopRoot, + record, + commentUrl: assertNonEmpty(commentUrl, 'commentUrl'), + githubApi, + }) + 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 } +} + +export async function reconcileActiveJournal({ + loopRoot = DEFAULT_LOOP_ROOT, + githubPaginatedApi = defaultGitHubPaginatedApi, + terminalRunIds = [], +} = {}) { + const { channel, owner, repo } = await checkpointJournalConfiguration(loopRoot) + const comments = await githubPaginatedApi( + `repos/${owner}/${repo}/issues/${channel.stateIssueNumber}/comments?per_page=100`, + ) + const terminalIds = new Set(terminalRunIds) + const latestByRunId = new Map() + for (const comment of comments) { + if (!sameGitHubLogin(comment.user?.login, channel.automationGitHubLogin)) continue + const marker = comment.body?.match( + /<!-- issue-dev-loop:checkpoint:([^:]+):sha256:([0-9a-f]{64}) -->/, + ) + if (!marker) continue + 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 candidate = { record, comment } + const existing = latestByRunId.get(record.run.runId) + if (!existing || compareDurableCheckpoints(candidate, existing) > 0) { + latestByRunId.set(record.run.runId, candidate) + } + } + + const activeCheckpoints = [] + for (const [runId, durable] of latestByRunId) { + 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.record.updatedAt) - Date.parse(right.record.updatedAt), + ) + return { activeCheckpoints } +} + +async function defaultWorkspaceValidator({ loopRoot, record }) { + const repositoryRoot = path.resolve(loopRoot, '..', '..') + 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, + }), + 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 + 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({ + loopRoot = DEFAULT_LOOP_ROOT, + checkpoint, + workspaceValidator = defaultWorkspaceValidator, +} = {}) { + const record = validateCheckpointRecord(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: checkpointRecordDigest(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', + ) + 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 new file mode 100644 index 00000000..49346a6c --- /dev/null +++ b/loops/issue-dev-loop/scripts/lib/checkpoint-proof.mjs @@ -0,0 +1,378 @@ +import { createHash } from 'node:crypto' +import { readFile } from 'node:fs/promises' +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, + } +} + +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, + kind: 'active-checkpoint', + 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, + }) +} + +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 + const artifacts = record?.artifacts + 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 || + !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) + 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') + } + validateImplementationBoundary(record) + return record +} + +export function checkpointPublicationBody(record) { + const validated = validateCheckpointRecord(record) + const digest = checkpointRecordDigest(validated) + const result = { + digest, + body: [ + `<!-- issue-dev-loop:checkpoint:${validated.run.runId}:sha256:${digest} -->`, + '```json', + canonicalCheckpointRecord(validated), + '```', + ].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 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'), + artifacts: await checkpointArtifactsForEvents({ + loopRoot, + runId: normalizedRunId, + 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`) + } + return verifyPublishedCheckpoint({ + loopRoot, + record, + commentUrl: checkpoint.payload?.commentUrl, + githubApi, + }) +} 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..7e991a54 --- /dev/null +++ b/loops/issue-dev-loop/scripts/lib/common.mjs @@ -0,0 +1,254 @@ +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 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 + 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) + 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 function parsePullCommentUrl(value) { + const parsed = new URL(value) + 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], + surface: match[3], + number: Number(match[4]), + 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) +} + +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, + { 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, + }) + 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 new file mode 100644 index 00000000..ab950f90 --- /dev/null +++ b/loops/issue-dev-loop/scripts/lib/evidence.mjs @@ -0,0 +1,857 @@ +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, + assertArray, + assertHttpUrl, + assertNonEmpty, + assertRunId, + defaultGitHubApi, + execFileAsync, + parseArtifactUrl, + parseGitHubTarget, + parsePullCommentUrl, + parseReviewUrl, + paginateGitHubApi, + readJson, + runDirectory, + sameGitHubLogin, + sameRepository, +} from './common.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']) +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-')) + 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 }) + } +} + +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') + } + 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') + } + 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) { + 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() + 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)) { + 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') + } + 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) + 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 ( + !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) + 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`) + } + 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`) + 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') { + 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`) + } + } + } + roundDetails.push(round) + } + 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(), + } +} + +export async function recordEvidence({ + loopRoot = DEFAULT_LOOP_ROOT, + runId, + manifestPath, + publicationUrl, + now = new Date(), + githubApi = defaultGitHubApi, + artifactManifestLoader = defaultArtifactManifestLoader, + candidateControlPlaneVerifier = defaultCandidateControlPlaneVerifier, + 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) + await checkpointVerifier({ + loopRoot, + runId: normalizedRunId, + events: runEvents, + operation: 'record-evidence', + githubApi, + }) + + 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 || + 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') + } + const headSha = assertNonEmpty(manifest.headSha, 'manifest.headSha') + 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) + 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}`, + ) + 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.name !== `issue-dev-loop-${normalizedRunId}-${headSha}` || + workflowRun.id !== Number(artifactTarget.runId) || + workflowRun.status !== 'completed' || + workflowRun.conclusion !== 'success' || + workflowRun.event !== 'pull_request' || + 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?.sha === manifest.workflowBaseSha && + workflowRepositoryMatches(pullRequest.base?.repo, pullTarget), + ) + ) { + 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')) { + 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') + } + 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' && + 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 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') + } + 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`) + } + if (check.artifactUrl !== null && check.artifactUrl !== undefined) { + assertHttpUrl(check.artifactUrl, `checks[${index}].artifactUrl`) + } + } + for (const [index, screenshot] of assertArray( + manifest.screenshots, + 'manifest.screenshots', + ).entries()) { + for (const field of [ + 'name', + 'scenario', + 'route', + 'viewport', + 'path', + 'capturedAt', + 'sourceSha', + ]) { + assertNonEmpty(screenshot[field], `screenshots[${index}].${field}`) + } + const expectedSourceSha = screenshot.phase === 'before' ? run.baseSha : run.implementationCommit + 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) + 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(), + 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}`) + 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}`)) { + 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') + } + 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') + 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 ( + !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 publicationDigest = reviewPublicationDigest(result) + const channel = await readJson( + path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'), + ) + const automationLogin = assertNonEmpty( + 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 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:${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) + 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), + paginateGitHubApi(githubApi, `${roundEndpoint}/comments`), + ]) + const bodies = [ + publishedRound.body ?? '', + ...roundComments.map((comment) => comment.body ?? ''), + ] + 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 || + 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`) + } + 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) ?? []), + ) + if ( + [...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`) + } + 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 !== 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) || + ![ + `<!-- 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} -->` + 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`) + } + 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, + }) + for (const findingId of expectedFindingIds) priorFindingIds.add(findingId) + previousSubmittedAt = submittedAt + } + const livePullRequest = await githubApi( + `repos/${reviewTarget.owner}/${reviewTarget.repo}/pulls/${reviewTarget.number}`, + ) + 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') + } + const responseCommentIds = new Set() + 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 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}` + : `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 ( + !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} 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 || + 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 ( + ['P0', 'P1'].includes(finding.severity) && + finding.resolution.classification === 'rejected' + ) { + const adjudicationReviewTarget = parseReviewUrl( + finding.resolution.adjudicationUrl, + ) + const adjudicationCommentTarget = parsePullCommentUrl( + finding.resolution.adjudicationUrl, + ) + const adjudicationTarget = adjudicationReviewTarget ?? adjudicationCommentTarget + if ( + !adjudicationTarget || + !sameRepository(reviewTarget, adjudicationTarget) || + adjudicationTarget.number !== reviewTarget.number || + (adjudicationCommentTarget && adjudicationCommentTarget.surface !== 'pull') + ) { + throw new Error(`${finding.findingId} adjudication is not on the reviewed PR`) + } + 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( + 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}: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 + ) { + throw new Error(`${finding.findingId} lacks independent published adjudication`) + } + } + } + } + await appendValidatedEvent({ + loopRoot, + runId: normalizedRunId, + type: 'review_completed', + status: 'passed', + payload: { + verdict: 'PASS', + headSha, + reviewUrl: publishedReviewUrl, + resultPath: relativeResultPath, + resultDigest, + publicationDigest, + reviewCycle: reviewSummary.cycle, + findingCount: reviewSummary.findingCount, + reviewRounds: reviewSummary.rounds, + unresolvedHighSeverityFindings: 0, + }, + now, + }) + return { + headSha, + reviewUrl: publishedReviewUrl, + resultDigest, + publicationDigest, + 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 new file mode 100644 index 00000000..2354aca0 --- /dev/null +++ b/loops/issue-dev-loop/scripts/lib/evolve.mjs @@ -0,0 +1,622 @@ +import { createHash } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import path from 'node:path' + +import { + DEFAULT_LOOP_ROOT, + assertAutomationIdentity, + assertHttpUrl, + assertNonEmpty, + defaultGitHubApi, + defaultGitHubPaginatedApi, + parsePullCommentUrl, + postGitHubIssueComment, + 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') +} + +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 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 } +} + +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 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, +} = {}) { + 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'), + ) + 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) + if ( + (await verifyPendingRequestComment({ request, comment, channel })) !== digest || + 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) + const indexEntries = (await readFile(path.join(loopRoot, 'logs', 'index.jsonl'), 'utf8')) + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) + 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 + 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) { + dueReason = 'ten_finalized_runs' + } 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) { + const requestId = `EVL-${String(metrics.finalizedRuns).padStart(6, '0')}-${dueReason + .replaceAll('_', '-') + .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, + githubPaginatedApi = defaultGitHubPaginatedApi, + githubComment = postGitHubIssueComment, + verifyAutomationIdentity = assertAutomationIdentity, +} = {}) { + 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 publishedRequest = await verifyPublishedEvolveRequest({ + loopRoot, + requestId: normalizedRequestId, + githubApi, + }) + const publishedPrUrl = assertHttpUrl(prUrl, 'prUrl') + const merge = await observeOwnerApprovedMerge({ + loopRoot, + prUrl: publishedPrUrl, + expectedHeadBranch: `codex/evolve-${normalizedRequestId}`, + expectedRepository: ( + await readJson(path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json')) + ).repository, + requiredBodyMarker: `<!-- issue-dev-loop:evolve-request:${normalizedRequestId} -->`, + createdAfter: request.requestedAt, + githubApi, + }) + 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', + 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'), + ) + 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 === postGitHubIssueComment) 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', + 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 = 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]}`) + } + 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: { + ...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 publication = parseCompletedEvolveComment(comment) + if (!publication) continue + const { record } = publication + const requestEntry = requests.get(publication.requestId) + if ( + !requestEntry || + !requestEntry.publicationUrls.has(record.requestPublicationUrl) || + record.requestPublicationDigest !== requestEntry.digest + ) { + throw new Error(`invalid durable evolve completion: ${publication.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, + record, + commentUrl: comment.html_url, + githubApi, + }) + completions.set(record.requestId, { verified, digest: publication.digest }) + } + + const pending = [...requests.values()].filter( + (entry) => !completions.has(entry.request.requestId), + ) + if (pending.length > 1) { + throw new Error('multiple durable pending evolve requests') + } + 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 + ? { + ...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()] + .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]?.request.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]?.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 new file mode 100644 index 00000000..ede358ff --- /dev/null +++ b/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs @@ -0,0 +1,437 @@ +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, + pathExists, + readJson, + runDirectory, + sameGitHubLogin, + writeJson, +} from './common.mjs' +import { updateEvolveMetrics } from './evolve.mjs' +import { + canonicalFinalizationRecord, + finalizationJournalConfiguration, + finalizationRecordDigest, + validateFinalizationRecord, + validateTerminalPauseCheckpoint, + verifyFailedOrBlockedNotification, + verifyPullNotificationComment, + verifyPublishedFinalization, + verifyTerminalExternalProof, +} from './finalization-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' +import { TERMINAL_STATUSES } from './lifecycle-status.mjs' +import { validateFinalizationHistory } from './validation.mjs' + +export const canonicalRecord = canonicalFinalizationRecord +export const recordDigest = finalizationRecordDigest + +export async function prepareFinalizationRecord({ + loopRoot = DEFAULT_LOOP_ROOT, + runId, + status, + mergeSha = null, + failureFingerprint = null, + finishedAt = new Date(), + githubApi = defaultGitHubApi, + checkpointVerifier = verifyLatestDurableCheckpoint, + notifyOwner = createNotification, +} = {}) { + 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) + const predecessorCheckpoint = await checkpointVerifier({ + loopRoot, + runId: normalizedRunId, + events, + operation: 'prepare-finalization', + githubApi, + }) + 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 + 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.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') + } + await verifyTerminalExternalProof({ loopRoot, record: existing, githubApi }) + const { channel, owner, repo } = await finalizationJournalConfiguration(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}`, + } + } + let completionProof = { + notificationUrl, + readyNotificationUrl: null, + readyNotifiedAt: null, + completionNotifiedAt: null, + notificationWebhookStatus: null, + ...pauseProof, + } + 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, + ...pauseProof, + } + recordFinishedAt = new Date( + Math.max(finishedAt.getTime(), Date.parse(publishedCompletion.comment.created_at)), + ) + } + const record = validateFinalizationRecord( + { + schemaVersion: 1, + runId: normalizedRunId, + issueNumber: run.issueNumber, + status, + startedAt: run.startedAt, + finishedAt: recordFinishedAt.toISOString(), + prUrl: run.prUrl, + headSha: run.headSha, + mergeSha, + failureFingerprint, + ...completionProof, + }, + run, + ) + await verifyTerminalExternalProof({ loopRoot, record, githubApi }) + const { channel, owner, repo } = await finalizationJournalConfiguration(loopRoot) + const digest = recordDigest(record) + const body = [ + `<!-- issue-dev-loop:finalization:${normalizedRunId}:sha256:${digest} -->`, + '```json', + canonicalRecord(record), + '```', + ].join('\n') + 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 resultSource = await readFile(resolvedResultPath, 'utf8') + const record = validateFinalizationRecord(JSON.parse(resultSource), run) + const { digest } = await verifyPublishedFinalization({ + loopRoot, + record, + commentUrl: assertNonEmpty(commentUrl, 'commentUrl'), + expectedHeadBranch: run.branch, + githubApi, + }) + await appendValidatedEvent({ + loopRoot, + runId: normalizedRunId, + type: 'finalization_published', + status: record.status, + payload: { + commentUrl, + digest, + resultPath: path.relative(loopRoot, resolvedResultPath), + resultDigest: createHash('sha256').update(resultSource).digest('hex'), + finishedAt: record.finishedAt, + mergeSha: record.mergeSha, + failureFingerprint: record.failureFingerprint, + notificationUrl: record.notificationUrl, + readyNotificationUrl: record.readyNotificationUrl, + readyNotifiedAt: record.readyNotifiedAt, + completionNotifiedAt: record.completionNotifiedAt, + notificationWebhookStatus: record.notificationWebhookStatus, + predecessorCheckpointUrl: record.predecessorCheckpointUrl, + predecessorCheckpointDigest: record.predecessorCheckpointDigest, + pauseStartedAt: record.pauseStartedAt, + notificationNotifiedAt: record.notificationNotifiedAt, + }, + now, + }) + return { record, digest, commentUrl } +} + +export async function reconcileFinalizationJournal({ + loopRoot = DEFAULT_LOOP_ROOT, + now = new Date(), + githubPaginatedApi = defaultGitHubPaginatedApi, + githubApi = defaultGitHubApi, + latestActiveCheckpoints = null, +} = {}) { + const { channel, owner, repo } = await finalizationJournalConfiguration(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 = validateFinalizationRecord(JSON.parse(serialized)) + if (record.runId !== marker[1] || recordDigest(record) !== marker[2]) { + throw new Error(`invalid durable finalization record for ${marker[1]}`) + } + await verifyPublishedFinalization({ + loopRoot, + record, + commentUrl: comment.html_url, + githubApi, + }) + records.push(record) + } + 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')) + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) + validateFinalizationHistory(existing) + const byRunId = new Map( + existing + .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 effectiveRecords) { + 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 }) + 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(effectiveRecords.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: 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 new file mode 100644 index 00000000..292b0761 --- /dev/null +++ b/loops/issue-dev-loop/scripts/lib/finalization-proof.mjs @@ -0,0 +1,438 @@ +import { createHash } from 'node:crypto' + +import { + assertNonEmpty, + defaultGitHubApi, + parseGitHubTarget, + parsePullCommentUrl, + sameGitHubLogin, + sameRepository, +} from './common.mjs' +import { + checkpointJournalConfiguration, + parseCheckpointRecord, + verifyPublishedCheckpoint, +} from './checkpoint-proof.mjs' +import { TERMINAL_STATUSES } from './lifecycle-status.mjs' +import { observeOwnerApprovedMerge } from './owner-gate.mjs' + +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, + readyNotificationUrl: record.readyNotificationUrl ?? null, + 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, + }) +} + +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)) || + ![ + 'readyNotificationUrl', + 'readyNotifiedAt', + 'completionNotifiedAt', + 'notificationWebhookStatus', + 'predecessorCheckpointUrl', + 'predecessorCheckpointDigest', + 'pauseStartedAt', + 'notificationNotifiedAt', + ].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.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) || + 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, merge, Ready, and completion-notification proof', + ) + } + if (['failed', 'blocked'].includes(record.status)) { + assertNonEmpty(record.failureFingerprint, 'failureFingerprint') + 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)) { + 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 ( + !['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 || + 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 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 const finalizationJournalConfiguration = checkpointJournalConfiguration + +export async function verifyPullNotificationComment({ + url, + allowedTypes, + runId, + prUrl, + channel, + githubApi, + requiredBodyFragments = [], +}) { + 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}\``) || + 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') + } + return { comment, notificationType } +} + +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 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, + requiredBodyFragments: [validated.mergeSha], + }) + 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') + } + } + if (['failed', 'blocked'].includes(validated.status)) { + 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 ( + !checkpointTarget || + checkpointTarget.kind !== 'issue_comment' || + checkpointTarget.surface !== 'issues' || + !sameRepository(checkpointTarget, configuredTarget) || + checkpointTarget.number !== channel.stateIssueNumber + ) { + throw new Error('terminal predecessor checkpoint is not on the state journal') + } + const checkpointComment = await githubApi( + `repos/${checkpointTarget.owner}/${checkpointTarget.repo}/issues/comments/${checkpointTarget.commentId}`, + ) + 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 ( + comment.created_at !== validated.notificationNotifiedAt || + Date.parse(comment.created_at) < Date.parse(validated.pauseStartedAt) + ) { + throw new Error('terminal notification is not bound to the current pause') + } + } + 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-identity.mjs b/loops/issue-dev-loop/scripts/lib/github-identity.mjs new file mode 100644 index 00000000..df4ba88d --- /dev/null +++ b/loops/issue-dev-loop/scripts/lib/github-identity.mjs @@ -0,0 +1,1990 @@ +import { execFile, spawn } from 'node:child_process' +import { constants } from 'node:fs' +import { access, lstat, readdir, realpath } 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 { + assertIssueNumber, + assertNonEmpty, + paginateGitHubApi, + parseGitHubTarget, + readJson, + sameGitHubLogin, +} from './common.mjs' +import { + checkpointRecordDigest, + parseCheckpointRecord, + validateCheckpointRecord, + verifyLatestDurableCheckpoint, +} from './checkpoint-proof.mjs' +import { verifyPublishedEvolveRequest } from './evolve.mjs' +import { readEvents } from './run-store.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', + environmentVariable: 'automationGitHubConfigEnvironmentVariable', + }, + reviewer: { + login: 'reviewerGitHubLogin', + environmentVariable: 'reviewerGitHubConfigEnvironmentVariable', + }, +} + +const inheritedEnvironmentNames = new Set([ + 'CI', + 'COLORTERM', + 'FORCE_COLOR', + 'HOME', + 'LANG', + 'LOGNAME', + 'NO_COLOR', + 'PATH', + 'SHELL', + 'TEMP', + 'TERM', + 'TMP', + 'TMPDIR', + 'USER', + 'XDG_RUNTIME_DIR', +]) + +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') + + 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 repositoryUrl = canonicalRepositoryUrl( + assertNonEmpty(channel.repository, 'channel.repository'), + ) + 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: '15', + 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_CONFIG_KEY_5: `url.${repositoryUrl}.insteadOf`, + 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', + }) + 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 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(lexicalConfigDirectory), + 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') + } + return `https://github.com/${repository}.git` +} + +export function hardenedGitArguments(args, { expectedRepository = null } = {}) { + const subcommand = gitSubcommand(args) + const hardened = [...args] + 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 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}`] + } + 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 +} + +function sameArguments(actual, expected) { + return ( + actual.length === expected.length && + actual.every((argument, index) => argument === expected[index]) + ) +} + +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 } +} + +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 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 = + subcommand.index === 0 && + isLoopBranch && + [['push', 'origin', branch]].some((expected) => sameArguments(args, expected)) + if (!isAllowedShape) { + throw new Error('GitHub automation may push only one explicit loop branch') + } + 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', 'merge-base', 'show', 'diff', 'log']) + 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 ( + sameArguments(args.slice(subcommand.index), ['remote', '-v']) || + sameArguments(args.slice(subcommand.index), ['remote', 'get-url', 'origin']) + ) { + 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`) +} + +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('-')) { + return { index: -1, name: null } + } + return { index, name: argument } + } + return { index: -1, name: null } +} + +function githubGroup(args) { + const groups = new Set(['api', 'issue', 'pr', 'run', 'repo']) + 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 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 usesFileExpansion = false + let usesInput = false + let valid = true + const fields = [] + + 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 (argument === '--input') usesInput = true + 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 + } + 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 (longOption === '--input') usesInput = true + if (bodyOptions.has(longOption)) { + hasRequestBody = true + fields.push(value) + if ( + longOption === '--field' && + (value.startsWith('@') || value.slice(value.indexOf('=') + 1).startsWith('@')) + ) { + usesFileExpansion = true + } + } + continue + } + if (/^-X[A-Za-z]+$/.test(argument)) { + explicitMethod = argument.slice(2) + continue + } + if (/^-[fF].+/.test(argument)) { + hasRequestBody = true + const value = argument.slice(2) + fields.push(value) + if ( + argument.startsWith('-F') && + (value.startsWith('@') || value.slice(value.indexOf('=') + 1).startsWith('@')) + ) { + usesFileExpansion = 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() + return { + endpoint: valid ? (endpoint?.replace(/^\//, '').split('?')[0] ?? null) : null, + method, + mutating: !valid || method !== 'GET', + valid, + fields, + usesFileExpansion, + usesInput, + } +} + +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 = startIndex; index < args.length; index += 1) { + const argument = args[index] + 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 + } + 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 (booleanAliases.has(argument)) { + booleans.set(booleanAliases.get(argument), true) + continue + } + const longBooleanOption = [...booleanAliases].find( + ([option]) => option.startsWith('--') && argument.startsWith(`${option}=`), + ) + 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('-')) { + valid = false + break + } + positional.push(argument) + } + return { booleans, positional, valid, values } +} + +function exactlyOne(values, name) { + const candidates = values.get(name) ?? [] + return candidates.length === 1 ? candidates[0] : null +} + +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 && repositoryInScope(repositoryOption, expectedRepository) + ) + } + const parsed = parseGitHubTarget(target) + return ( + parsed?.kind === 'pull' && + parsed.number === expectedNumber && + repositoryInScope(`${parsed.owner}/${parsed.repo}`, expectedRepository) && + (repositoryOption === null || repositoryInScope(repositoryOption, expectedRepository)) + ) +} + +const repositoryValueOptions = { + '--repo': 'repository', + '-R': 'repository', +} + +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 { + 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: { + ...repositoryValueOptions, + '--body': 'body', + '-b': 'body', + }, + booleanOptions: { '--comment': 'comment', '-c': 'comment' }, + }) + const body = exactlyOne(parsed.values, 'body') + 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) { + const { parsed, body, publication } = reviewerCommentReview(args, commandIndex, authorization) + return ( + parsed.valid && + parsed.booleans.get('comment') === true && + parsed.positional.length === 1 && + pullRequestTargetMatches( + parsed.positional[0], + authorization, + expectedRepository, + exactlyOne(parsed.values, 'repository'), + ) && + Boolean(body) && + Boolean(publication) + ) +} + +function pullRequestCreateAllowed(args, commandIndex, authorization, expectedRepository) { + const parsed = parseOptions(args, commandIndex + 1, { + valueOptions: { + ...repositoryValueOptions, + '--base': 'base', + '-B': 'base', + '--head': 'head', + '-H': 'head', + '--title': 'title', + '-t': 'title', + '--body': 'body', + '-b': 'body', + }, + booleanOptions: { '--draft': 'draft', '-d': 'draft' }, + }) + if ( + !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') || + !exactlyOne(parsed.values, 'body') + ) { + return false + } + 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', + '--add-reviewer': 'addReviewer', + } + : kind === 'comment' + ? { + ...repositoryValueOptions, + '--body': 'body', + '-b': 'body', + } + : repositoryValueOptions + const parsed = parseOptions(args, commandIndex + 1, { + valueOptions, + booleanOptions: kind === 'ready' ? { '--undo': 'undo' } : {}, + }) + if ( + !parsed.valid || + parsed.positional.length !== 1 || + !pullRequestTargetMatches( + parsed.positional[0], + authorization, + expectedRepository, + exactlyOne(parsed.values, 'repository'), + ) + ) { + return false + } + if (kind === 'ready') { + return ( + parsed.values.size <= 1 && + parsed.booleans.size === 1 && + parsed.booleans.get('undo') === true + ) + } + if (kind === 'comment') { + 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()), + ) + if (reviewers.some((login) => !sameGitHubLogin(login, authorization?.ownerGitHubLogin))) { + return false + } + const editedFields = [...parsed.values.keys()].filter((name) => name !== 'repository') + return editedFields.length > 0 +} + +function reservedAutomationComment(body) { + return ( + body.includes('**pr_completed**') || + 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' + } + return authorization?.rootIntent === 'evolve-complete' +} + +function automationApiMutationAllowed( + { endpoint, method, fields, usesFileExpansion, usesInput }, + 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') { + 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]))) { + const body = fields.length === 1 && fields[0].startsWith('body=') ? fields[0].slice(5) : null + 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 ( + reply !== null && + method === 'POST' && + Number(reply[1]) === authorization?.issue?.prNumber && + fields.length === 1 && + fields[0].startsWith('body=') + ) +} + +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 || + request.usesFileExpansion || + !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') + const publication = parseReviewPublication(body[0], authorization) + if ( + body.length !== 1 || + commitIds.length !== 1 || + events.length !== 1 || + commitIds[0] !== authorization.issue.headSha || + events[0] !== 'COMMENT' || + !publication + ) { + 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-${publication.cycle}-${publication.round}-`, + ), + ) +} + +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 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 +} + +function repositoryInScope(actual, expected) { + return ( + typeof actual === 'string' && + typeof expected === 'string' && + actual.toLowerCase() === expected.toLowerCase() + ) +} + +export function assertGitHubCliPolicy( + role, + args, + { expectedRepository = null, authorization = null } = {}, +) { + const reject = () => { + throw new Error(`GitHub action is prohibited for the ${role} role`) + } + if ( + expectedRepository && + githubRepositoryFlags(args).some( + (repository) => !repositoryInScope(repository, expectedRepository), + ) + ) { + 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) + 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 (reviewerInlineReviewAllowed(request, authorization, expectedRepository)) return + if ( + !request.valid || + request.mutating || + request.endpoint === 'graphql' || + (expectedRepository && + !repositoryInScope(endpointRepository(request.endpoint), expectedRepository)) + ) { + reject() + } + return + } + 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, authorization, expectedRepository) + ) { + reject() + } + return + } + + if (group.name === 'issue') { + if (!['list', 'view'].includes(subcommand.name)) reject() + return + } + if (group.name === 'pr') { + if (['list', 'view', 'checks', 'diff'].includes(subcommand.name)) return + if ( + subcommand.name === 'create' && + pullRequestCreateAllowed(args, subcommand.index, authorization, expectedRepository) + ) { + return + } + if ( + ['edit', 'comment', 'ready'].includes(subcommand.name) && + pullRequestMutationAllowed( + subcommand.name, + args, + subcommand.index, + authorization, + expectedRepository, + ) + ) { + return + } + reject() + return + } + if (group.name === 'run') { + if (!['list', 'view', 'download'].includes(subcommand.name)) reject() + return + } + if (group.name !== 'api') reject() + const request = githubApiRequest(args.slice(group.index + 1)) + if (readOnlyIdentityRequest(request)) return + if ( + !request.valid || + request.endpoint === 'graphql' || + (expectedRepository && + !repositoryInScope(endpointRepository(request.endpoint), expectedRepository)) + ) { + reject() + } + if (!request.mutating) return + if (!automationApiMutationAllowed(request, authorization)) reject() +} + +export function assertDescendantCommandPolicy({ role, tool, args, authorization = null }) { + if (tool === 'git') { + assertGitCommandPolicy(role, args, { authorization }) + return + } + if (tool === 'gh') { + assertGitHubCliPolicy(role, args, { + authorization, + expectedRepository: authorization?.expectedRepository ?? null, + }) + return + } + throw new Error(`unsupported authenticated tool: ${tool}`) +} + +function assertRootCommandPolicy({ role, tool, args, loopRoot, trustedLoopRoot, authorization }) { + if (tool === 'git') { + assertGitCommandPolicy(role, args, { authorization }) + return + } + if (tool === 'gh') { + assertGitHubCliPolicy(role, args, { + authorization, + expectedRepository: authorization?.expectedRepository ?? null, + }) + return + } + 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(trustedLoopRoot, 'scripts', 'loopctl.mjs'), + path.resolve(trustedLoopRoot, 'triggers', 'detect-work.mjs'), + ]) + if ( + script && + allowedScripts.has(script) && + targetLoopRoot && + path.resolve(targetLoopRoot) === path.resolve(loopRoot) + ) { + return + } + } + throw new Error(`command is outside the authenticated ${role} command tree`) +} + +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 absoluteDirectory = path.resolve(directory) + if (skipped.has(absoluteDirectory)) continue + const candidate = path.join(absoluteDirectory, name) + try { + await access(candidate, constants.X_OK) + return await realpath(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 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) + 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, realNode = 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$/, '') + 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 [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}`, + ) + } +} + +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) + } 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') 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 ( + run.finishedAt === null && + ['running', 'waiting_for_owner', 'awaiting_owner_review'].includes(run.status) + ) { + active.push({ directoryName: entry.name, run }) + } + } + if (active.length > 1) { + throw new Error('multiple active runs cannot authorize GitHub mutations') + } + 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: expectedIssueBranch, + issueNumber, + prNumber: pullTarget?.kind === 'pull' ? pullTarget.number : null, + runId: assertNonEmpty(run.runId, 'run.runId'), + status: run.status, + headSha: run.headSha, + implementationCommit: run.implementationCommit, + } + : 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, 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 ( + !isTrustedLoopctl || + args[1] !== 'start' || + authorization.issue !== null + ) { + return withIntent + } + 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) + ) { + throw new Error('loopctl start intent must identify one issue in the configured repository') + } + return { + ...withIntent, + issue: { + branch: `codex/issue-${issueNumber}`, + issueNumber, + prNumber: null, + runId: null, + status: 'starting', + baseSha: baseSha.toLowerCase(), + headSha: null, + implementationCommit: null, + }, + } +} + +function activationValidationRequested({ role, tool, args, loopRoot, trustedLoopRoot }) { + return ( + role === 'automation' && + tool === 'node' && + path.resolve(args[0]) === path.resolve(trustedLoopRoot, 'scripts', 'loopctl.mjs') && + args[1] === 'validate' && + args.includes('--activation') && + path.resolve(argumentAfter(args, '--loop-root') ?? '') === path.resolve(loopRoot) + ) +} + +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 ?? '')) { + 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, + publication: reviewerCommentReview(args, command.index, authorization).publication, + } + } + 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', + '--add-reviewer': 'addReviewer', + }, + }) + 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) => + 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, authorization) + 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 githubPaginatedApi = (endpoint) => paginateGitHubApi(githubApi, endpoint) + 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 (['review', 'inline-review'].includes(intent.kind)) { + if ( + livePullRequest.draft !== true || + !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) => + 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') { + if (!readyReturnsToDraft(args, intent.commandIndex)) { + throw new Error('only the configured owner may mark a Draft PR ready for review') + } + 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 (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', + ) + } + } +} + +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) + 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 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', + ) + 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, + environment = process.env, + identityCommand = execFileAsync, + ghExecutable = 'gh', + enforceCredentialIsolation = false, + requiredUntrustedRoots = [], +}) { + let resolved = resolveGitHubRoleEnvironment({ channel, role, environment }) + if (enforceCredentialIsolation) { + 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, + 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 } +} + +async function assertTargetChannelMatchesTrusted(loopRoot, trustedChannel) { + const targetChannel = await readOwnerChannel(loopRoot) + for (const field of [ + 'schemaVersion', + 'ownerGitHubLogin', + 'automationGitHubLogin', + 'reviewerGitHubLogin', + 'automationGitHubConfigEnvironmentVariable', + 'reviewerGitHubConfigEnvironmentVariable', + 'stateIssueNumber', + 'repository', + 'canonicalChannel', + 'webhookEnvironmentVariable', + 'untrustedRootsEnvironmentVariable', + 'informationalImmediateTypes', + '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 = [], + environment = process.env, + spawnCommand = spawn, +}) { + const requestedCommand = assertNonEmpty(command, 'command') + 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, + enforceCredentialIsolation: true, + requiredUntrustedRoots: [repositoryRootForLoop(loopRoot)], + }) + const executable = await resolveRequestedExecutable( + requestedCommand, + trustedControlPlane.executables, + ) + const tool = await authenticatedToolForExecutable(executable, { + realGit, + realGh, + realNode, + }) + const authorization = withRootCommandIntent(await readAuthorizationContext(loopRoot, channel), { + tool, + args, + trustedLoopRoot: trustedControlPlane.loopRoot, + }) + assertRootCommandPolicy({ + role, + tool, + args, + loopRoot, + trustedLoopRoot: trustedControlPlane.loopRoot, + authorization, + }) + const activationValidation = activationValidationRequested({ + role, + tool, + args, + loopRoot, + trustedLoopRoot: trustedControlPlane.loopRoot, + }) + if (activationValidation) { + await assertGitHubRoleIdentity({ + channel, + role: 'reviewer', + environment, + ghExecutable: realGh, + identityCommand: (_command, identityArgs, options) => + execFileAsync(realGh, identityArgs, options), + enforceCredentialIsolation: true, + requiredUntrustedRoots: [repositoryRootForLoop(loopRoot)], + }) + } + await preflightEvolveMutation({ + role, + tool, + args, + authorization, + loopRoot, + realGh, + environment: resolved.routedEnvironment, + }) + if (tool === 'gh') { + await preflightPullRequestWrite({ + role, + args, + authorization, + channel, + loopRoot, + realGh, + environment: resolved.routedEnvironment, + }) + } + if (tool === 'git' && ['push', 'fetch', 'ls-remote'].includes(gitSubcommand(args).name)) { + await preflightIssueBranchPush({ + args, + authorization, + loopRoot, + realGit, + realGh, + environment: resolved.routedEnvironment, + }) + await assertSafeRemoteGitConfiguration({ + realGit, + environment: resolved.routedEnvironment, + }) + await assertPushTargetsRepository({ + expectedRepository: channel.repository, + realGit, + environment: resolved.routedEnvironment, + }) + } + const childEnvironment = { + ...resolved.routedEnvironment, + PATH: identityBinDirectory, + ECHO_UI_LOOP_GITHUB_ROLE: role, + ECHO_UI_LOOP_IDENTITY_GATE: commandGatePath, + 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', '--loop-root', path.resolve(loopRoot)] + } + const child = spawnCommand(executable, executionArgs, { + env: childEnvironment, + 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/github.mjs b/loops/issue-dev-loop/scripts/lib/github.mjs new file mode 100644 index 00000000..bc2de3a0 --- /dev/null +++ b/loops/issue-dev-loop/scripts/lib/github.mjs @@ -0,0 +1,460 @@ +import { readFile } from 'node:fs/promises' +import path from 'node:path' + +import { + DEFAULT_LOOP_ROOT, + appendJsonLine, + assertNonEmpty, + assertRunId, + defaultGitHubApi, + defaultGitHubPaginatedApi, + execFileAsync, + labelNames, + parseGitHubTarget, + parsePullCommentUrl, + parseReviewUrl, + pullRequestClaimsIssue, + readJson, + sameGitHubLogin, + sameRepository, +} from './common.mjs' +import { + reconcileFinalizationJournal, + 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' +import { validateFinalizationHistory } from './validation.mjs' + +const PRIORITY = new Map([ + ['priority:critical', 0], + ['priority:high', 1], + ['priority:medium', 2], + ['priority:low', 3], +]) + +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 +} + +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 ( + labels.has('codex-ready') && + !labels.has('loop:claimed') && + !branches.has(`codex/issue-${issue.number}`) && + !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 } +} + +export async function reconcileLoopJournal({ + loopRoot = DEFAULT_LOOP_ROOT, + now = new Date(), + 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, + githubPaginatedApi, + }) + const finalization = await reconcileFinalizationJournal({ + loopRoot, + 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 } +} + +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, + pullRequestsFile, + repo, + now = new Date(), + reconcileJournal = reconcileLoopJournal, +} = {}) { + 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, + runId: result.runId ?? null, + issueNumber: result.issue?.number ?? null, + requestId: result.requestId ?? null, + branch: result.branch ?? null, + }) + return result + } + const reconciliation = + !issuesFile && !pullRequestsFile ? await reconcileJournal({ loopRoot, now }) : null + const resumable = reconciliation?.activeCheckpoints?.[0] + if (resumable) { + return recordTriggerCheck({ + hasWork: true, + workType: 'resume', + 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.record.run.issueNumber, + title: resumable.record.run.issueTitle, + url: resumable.record.run.issueUrl, + }, + }) + } + const evolve = await readJson(path.join(loopRoot, 'evolve', 'metrics.json')) + if (evolve.evolveDue) { + return recordTriggerCheck({ + hasWork: true, + workType: 'evolve', + requestId: evolve.pendingRequestId, + issue: null, + }) + } + 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 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 { + 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( + '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({ + loopRoot = DEFAULT_LOOP_ROOT, + runId, + now = new Date(), + githubApi = defaultGitHubApi, + releaseIssueClaim = defaultReleaseIssueClaim, + finalizationResultPath, + finalizationCommentUrl, + recordFinalization = recordFinalizationPublication, +} = {}) { + 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 publication = await recordFinalization({ + loopRoot, + runId: normalizedRunId, + resultPath: finalizationResultPath, + commentUrl: finalizationCommentUrl, + now, + githubApi, + }) + 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'), + ) + 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: '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, + }) + } + 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: merge.mergeSha, + prUrl: run.prUrl, + }, + now, + }) + return finalizeRun({ + loopRoot, + runId: normalizedRunId, + status: 'completed', + mergeSha: merge.mergeSha, + now, + githubApi, + releaseIssueClaim, + }) +} + +export async function recordOwnerResponse({ + loopRoot = DEFAULT_LOOP_ROOT, + runId, + 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) + 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 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)) || + (reviewTarget && response.commit_id !== run.headSha) || + (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 new file mode 100644 index 00000000..51d1b695 --- /dev/null +++ b/loops/issue-dev-loop/scripts/lib/issue-claim.mjs @@ -0,0 +1,167 @@ +import path from 'node:path' + +import { + assertAutomationIdentity, + DEFAULT_LOOP_ROOT, + defaultGitHubApi, + defaultGitHubPaginatedApi, + execFileAsync, + labelNames, + parseGitHubTarget, + pullRequestClaimsIssue, +} from './common.mjs' + +async function defaultAddLabel({ target, issueNumber }) { + await execFileAsync( + 'gh', + [ + 'api', + `repos/${target.owner}/${target.repo}/issues/${issueNumber}/labels`, + '--method', + 'POST', + '-f', + 'labels[]=loop:claimed', + ], + { maxBuffer: 1024 * 1024 }, + ) +} + +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 defaultReleaseRemoteBranch({ + loopRoot, + issueNumber, + baseSha, +}) { + const branch = `codex/issue-${issueNumber}` + await execFileAsync( + 'git', + [ + 'push', + `--force-with-lease=refs/heads/${branch}:${baseSha}`, + 'origin', + `:refs/heads/${branch}`, + ], + { + cwd: path.resolve(loopRoot, '..', '..'), + maxBuffer: 1024 * 1024, + }, + ) +} + +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, + baseSha, + githubApi = defaultGitHubApi, + githubPaginatedApi = defaultGitHubPaginatedApi, + addLabel = defaultAddLabel, + reserveRemoteBranch = defaultReserveRemoteBranch, + releaseRemoteBranch = defaultReleaseRemoteBranch, + 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')) { + 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 || + reserveRemoteBranch === defaultReserveRemoteBranch + ) { + await assertAutomationIdentity({ loopRoot, githubApi }) + } + await reserveRemoteBranch({ target, issueNumber, baseSha }) + try { + await addLabel({ target, issueNumber }) + } catch (error) { + try { + await releaseRemoteBranch({ loopRoot, issueNumber, baseSha }) + } catch (releaseError) { + throw new AggregateError( + [error, releaseError], + 'claim labeling failed and the exact remote reservation could not be released', + ) + } + throw error + } + 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) { + 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 + if (removeLabel === defaultRemoveLabel) { + await assertAutomationIdentity({ loopRoot, githubApi }) + } + await removeLabel({ target, issueNumber }) +} 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 new file mode 100644 index 00000000..e0968f23 --- /dev/null +++ b/loops/issue-dev-loop/scripts/lib/notifications.mjs @@ -0,0 +1,243 @@ +import { randomBytes } from 'node:crypto' +import path from 'node:path' + +import { + DEFAULT_LOOP_ROOT, + assertAutomationIdentity, + assertNonEmpty, + assertRunId, + parseGitHubTarget, + postGitHubIssueComment, + readJson, + sameRepository, + timestampToken, + writeJson, +} from './common.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}` : '' + 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}**`, + '', + notification.summary, + '', + `Requested action: ${notification.requestedAction}`, + '', + `Notification: \`${notification.notificationId}\` · Run: \`${notification.runId}\`${evidence}${resume}`, + ].join('\n') +} + +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, + webhookTimeoutMs = 5000, + githubComment = postGitHubIssueComment, + verifyAutomationIdentity = assertAutomationIdentity, + githubApi, + checkpointVerifier = verifyLatestDurableCheckpoint, + recordEvent = true, +} = {}) { + const normalizedRunId = assertRunId(runId) + const run = await readRun(loopRoot, normalizedRunId) + 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') + 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( + `${notificationType} must target the recorded PR and include exact-head evidence`, + ) + } + 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), + ) + ) { + 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 || (!isRunIssue && !isRunPull))) { + 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}` + 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 (githubComment === postGitHubIssueComment) { + await verifyAutomationIdentity({ loopRoot }) + } + if (target) { + try { + 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}` + } + } else if (targetUrl) { + notification.delivery.github = 'failed: target is not a GitHub issue or pull request URL' + } + + } + + // Persist canonical GitHub delivery before attempting the optional webhook mirror. + await writeJson(outboxFile, notification) + const delivered = notification.delivery.github === 'delivered' + 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 (recordEvent && blocking && !dryRun) { + if (run.finishedAt === null && run.status !== 'waiting_for_owner') { + await transitionRun({ + loopRoot, + runId: normalizedRunId, + status: 'waiting_for_owner', + now, + skipCheckpointGate: true, + }) + } + } + 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) + 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}`) + } + 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..0515cf20 --- /dev/null +++ b/loops/issue-dev-loop/scripts/lib/owner-gate.mjs @@ -0,0 +1,95 @@ +import path from 'node:path' + +import { + defaultGitHubApi, + paginateGitHubApi, + parseGitHubTarget, + readJson, + sameGitHubLogin, +} from './common.mjs' + +export async function observeOwnerApprovedMerge({ + loopRoot, + prUrl, + expectedHeadSha = null, + expectedHeadBranch, + expectedRepository, + expectedBaseBranch = 'dev', + requiredBodyMarker = null, + createdAfter = null, + readyAfter = 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, 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 + 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(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 mergedAt = Date.parse(pullRequest.merged_at) + const ownerApprovalAt = Date.parse(latestOwnerReview?.submitted_at) + const ownerApproval = + latestOwnerReview?.state === 'APPROVED' && + 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() || + (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) || + !/^[0-9a-f]{40}$/i.test(pullRequest.merge_commit_sha ?? '') || + !ownerReady || + !ownerApproval || + (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 under strict owner Ready, APPROVED, then merge ordering', + ) + } + return { + 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 new file mode 100644 index 00000000..87050fb7 --- /dev/null +++ b/loops/issue-dev-loop/scripts/lib/run-store.mjs @@ -0,0 +1,1237 @@ +import { createHash, randomBytes } from 'node:crypto' +import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import path from 'node:path' + +import { + DEFAULT_LOOP_ROOT, + appendJsonLine, + assertAutomationIdentity, + assertIssueNumber, + assertNonEmpty, + assertRunId, + defaultGitHubApi, + execFileAsync, + parseGitHubTarget, + pathExists, + readJson, + replaceTemplate, + runDirectory, + sameGitHubLogin, + sameRepository, + timestampToken, + 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 { + canonicalFinalizationRecord, + 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']) +const RUN_STATUSES = new Set(['running', ...PAUSED_STATUSES, ...TERMINAL_STATUSES]) +const RESERVED_EVENT_TYPES = new Set([ + 'loop_started', + 'verification_completed', + 'review_completed', + 'pr_published', + 'owner_notified', + 'notification_failed', + 'notification_dry_run', + 'owner_response_observed', + 'brief_frozen', + 'implementation_completed', + 'finalization_published', + 'owner_review_approved', + 'pr_merged', + 'run_status_changed', + 'run_finalization_authorized', + 'run_finalized', + 'checkpoint_published', + 'issue_claim_released', +]) + +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', 'waiting_for_owner', 'completed', 'cancelled'])], +]) + +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')) +} + +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, + issueTitle, + issueUrl, + baseSha, + now = new Date(), + entropy, + githubApi = defaultGitHubApi, + claimIssue = defaultClaimIssue, + releaseIssueClaim = defaultReleaseIssueClaim, + verifyAutomationIdentity = assertAutomationIdentity, + workspaceValidator = defaultStartWorkspaceValidator, +} = {}) { + 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') + } + 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}`) + + 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 + } + + let issueSnapshot + let remoteClaimCreated = false + try { + if (claimIssue === defaultClaimIssue) { + await verifyAutomationIdentity({ loopRoot, githubApi }) + } + const snapshot = await claimIssue({ + loopRoot, + issueUrl: url, + issueNumber: issue, + branch: `codex/issue-${issue}`, + baseSha: normalizedBaseSha, + 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({ loopRoot, 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 + } + + 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: issueSnapshot.title, + issueUrl: url, + baseBranch: 'dev', + baseSha: normalizedBaseSha, + branch: `codex/issue-${issue}`, + status: 'running', + startedAt: now.toISOString(), + finishedAt: null, + prUrl: null, + headSha: null, + mergeSha: null, + issueSnapshot, + briefDigest: null, + uiEvidenceRequired: null, + implementationCommit: 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: 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 +} + +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 withoutHtmlComments(source) { + 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 +} + +function visibleMarkdownLines(source) { + return withoutHtmlComments(source) + .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)]), + ) + 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') + } + 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(), + 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') + } + 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) + 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') + } + 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, + }) +} + +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, + resultPath, + now = new Date(), + commitRangeValidator = defaultCommitRangeValidator, + 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.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 resultSource = await readFile(resolvedResultPath, 'utf8') + const result = JSON.parse(resultSource) + 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 : [] + 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)) || + requiredChecks.some( + (requiredCommand) => !checks.some((check) => check.command === requiredCommand), + ) + ) { + 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) + await checkpointVerifier({ + loopRoot, + runId: normalizedRunId, + events, + operation: 'record-implementation', + githubApi, + }) + const relativeResultPath = path.relative(loopRoot, resolvedResultPath) + const resultDigest = createHash('sha256').update(resultSource).digest('hex') + 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') + } + 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, + 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, + resultDigest, + }, + now, + }) + return updated +} + +export async function recordPullRequest({ + loopRoot = DEFAULT_LOOP_ROOT, + runId, + prUrl, + headSha, + now = new Date(), + githubApi = defaultGitHubApi, + trailingPathValidator = defaultTrailingPathValidator, + checkpointVerifier = verifyLatestDurableCheckpoint, +} = {}) { + 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') + } + if (!run.briefDigest || !run.implementationCommit) { + throw new Error('record-pr requires a frozen brief and recorded $implement result') + } + await assertFrozenBriefUnchanged(loopRoot, run) + 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') + } + 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}`, + ) + 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) || + 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', + ) + } + 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}`, + `<!-- issue-dev-loop:run:${normalizedRunId} -->`, + `Run ID: \`${normalizedRunId}\``, + `Base SHA: \`${run.baseSha}\``, + '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))) { + 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}`, + ) + 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') + } + await trailingPathValidator({ + loopRoot, + runId: normalizedRunId, + ancestor: run.implementationCommit, + descendant: 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, + 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)) +} + +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)) + 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, { + event: 'run_finalized', + ...finalizationRecord, + }) + } + 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({ + loopRoot, + 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, + }) + 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, + status, + prUrl = null, + headSha = null, + mergeSha = null, + failureFingerprint = null, + now = new Date(), + githubApi = defaultGitHubApi, + releaseIssueClaim = defaultReleaseIssueClaim, + skipCheckpointGate = false, + checkpointVerifier = verifyLatestDurableCheckpoint, +} = {}) { + 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) { + 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}`) + } + await verifyRunFinalizationPublication({ + loopRoot, + run, + events: existingEvents, + status, + mergeSha: run.mergeSha, + failureFingerprint: authorization.payload.failureFingerprint ?? null, + githubApi, + }) + 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) + if (!skipCheckpointGate && !TERMINAL_STATUSES.has(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}`) + } + 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) { + throw new Error('awaiting_owner_review requires the recorded PR URL 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') + } + 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') + } + 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( + (event) => + event.type === 'owner_notified' && + event.status === 'delivered' && + ['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, + ) + 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}`, + ) + const channel = await readJson( + path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'), + ) + const verifiedManifestSource = await readFile( + path.resolve(loopRoot, verificationEvent.payload.manifestPath), + 'utf8', + ) + 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 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}`, + `Run ID: \`${normalizedRunId}\``, + `Base SHA: \`${run.baseSha}\``, + `Head SHA: \`${headSha}\``, + 'This PR must be marked Ready, reviewed, and merged by `@codeacme17`', + ] + const requiredSections = [ + 'Changes', + 'Acceptance criteria', + 'Verification', + 'Evidence', + 'Independent review', + 'Known limitations', + ] + 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 = + 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 && + !/\bpending\b/i.test(`${verificationSection}\n${evidenceSection}\n${reviewSection}`) && + (!run.uiEvidenceRequired || + (screenshotPaths.length > 0 && + screenshotPaths.every((screenshotPath) => + visiblePullRequestBody.includes(screenshotPath), + ))) + if ( + livePullRequest.state !== 'open' || + livePullRequest.draft !== true || + !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 || + !bodyHasExactProof + ) { + throw new Error( + 'awaiting_owner_review requires an automation-authored live Draft PR with exact-head evidence and review links', + ) + } + } + + if (status === 'completed') { + 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 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) { + throw new Error('completed mergeSha does not match the remote owner merge') + } + 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 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' && + 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 for this pause`, + ) + } + } + const finalizationProof = TERMINAL_STATUSES.has(status) + ? await verifyRunFinalizationPublication({ + loopRoot, + run, + events, + status, + mergeSha, + failureFingerprint, + githubApi, + }) + : null + + const transitioned = { + ...run, + status, + finishedAt: TERMINAL_STATUSES.has(status) ? finalizationProof.record.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) + if (!TERMINAL_STATUSES.has(status)) { + await appendValidatedEvent({ + loopRoot, + runId: normalizedRunId, + type: 'run_status_changed', + status, + payload: { previousStatus: run.status }, + now, + }) + return transitioned + } + 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 = {}) { + 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/trusted-control-plane.mjs b/loops/issue-dev-loop/scripts/lib/trusted-control-plane.mjs new file mode 100644 index 00000000..5c2c8fa2 --- /dev/null +++ b/loops/issue-dev-loop/scripts/lib/trusted-control-plane.mjs @@ -0,0 +1,108 @@ +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, expectedDigest) { + 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`) + } + 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 +} + +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', + 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 new file mode 100644 index 00000000..588cb585 --- /dev/null +++ b/loops/issue-dev-loop/scripts/lib/validation.mjs @@ -0,0 +1,311 @@ +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 }) + 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 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, + environment = process.env, + identityCommand, +} = {}) { + const required = [ + 'SKILL.md', + 'LOOP.md', + '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', + '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', + 'schemas/finalization-record.schema.json', + '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', + 'scripts/lib/finalization-journal.mjs', + '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', + 'scripts/lib/issue-claim.mjs', + 'scripts/lib/lifecycle-status.mjs', + 'scripts/lib/notifications.mjs', + 'scripts/lib/owner-gate.mjs', + 'scripts/lib/run-store.mjs', + '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', + ] + 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 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') + } + validateFinalizationHistory(historyLines) + 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') || + 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') + } + 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') + } + if (activation) { + for (const role of ['automation', 'reviewer']) { + await assertGitHubRoleIdentity({ + channel, + role, + environment, + enforceCredentialIsolation: true, + requiredUntrustedRoots: [path.resolve(loopRoot, '..', '..')], + ...(identityCommand ? { identityCommand } : {}), + }) + } + } + 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 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'") || + !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 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( + '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,dst=/source,readonly') || + !evidenceWorkflowSource.includes('pnpm test') || + !evidenceWorkflowSource.includes( + '--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 }}"', + ) || + !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 use a low-privilege isolated PR run plus owner-merged controls and baseline tests', + ) + } + const codexConfig = await readFile( + path.resolve(loopRoot, '..', '..', '.codex', 'config.toml'), + 'utf8', + ) + 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}`) + } + } + for (const roleFile of [ + 'echo-ui-pr-reviewer.toml', + 'echo-ui-review-adjudicator.toml', + ]) { + const roleSource = await readFile( + path.resolve(loopRoot, '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') + for (const phrase of [ + 'draft PR targeting `dev`', + 'approve, auto-merge, or merge any PR', + 'Only the remote owner-merge gate', + '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-pr', + '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 new file mode 100644 index 00000000..ff582069 --- /dev/null +++ b/loops/issue-dev-loop/scripts/loopctl.mjs @@ -0,0 +1,281 @@ +#!/usr/bin/env node + +import { readFile } from 'node:fs/promises' +import path from 'node:path' + +import { + appendEvent, + completeEvolve, + createNotification, + detectWork, + finalizeRun, + freezeBrief, + getEvolveStatus, + observeOwnerMerge, + parseArguments, + prepareActiveCheckpoint, + prepareEvolveRequestPublication, + prepareFinalizationRecord, + reconcileLoopJournal, + recordActiveCheckpointPublication, + recordEvidence, + recordEvolveRequestPublication, + recordFinalizationPublication, + recordImplementation, + recordOwnerResponse, + recordPullRequest, + recordReview, + reviewPublicationDigest, + restoreActiveCheckpoint, + 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 +} + +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) + const loopRoot = args['loop-root'] ? path.resolve(args['loop-root']) : undefined + + switch (command) { + case 'start': + output( + await startRun({ + issueNumber: args.issue, + issueTitle: args.title, + issueUrl: args.url, + baseSha: args['base-sha'], + now: args.now ? new Date(args.now) : undefined, + loopRoot, + }), + ) + break + case 'freeze-brief': + output(await freezeBrief({ loopRoot, runId: args['run-id'] })) + break + case 'record-implementation': + output( + await recordImplementation({ + runId: args['run-id'], + resultPath: args.result, + loopRoot, + }), + ) + break + case 'event': + output( + await appendEvent({ + runId: args['run-id'], + type: args.type, + status: args.status ?? null, + payload: parsePayload(args.payload), + loopRoot, + }), + ) + break + case 'transition': + output(await transitionRun({ ...runTransitionOptions(args), loopRoot })) + break + case 'finalize': + output(await finalizeRun({ ...runTransitionOptions(args), loopRoot })) + break + case 'record-evidence': + output( + await recordEvidence({ + runId: args['run-id'], + manifestPath: args.manifest, + publicationUrl: args['publication-url'], + loopRoot, + }), + ) + break + case 'record-pr': + output( + await recordPullRequest({ + runId: args['run-id'], + prUrl: args['pr-url'], + headSha: args['head-sha'], + loopRoot, + }), + ) + break + case 'record-owner-response': + output( + 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 + case 'record-finalization': + output( + await recordFinalizationPublication({ + runId: args['run-id'], + resultPath: args.result, + commentUrl: args['comment-url'], + loopRoot, + }), + ) + break + case 'prepare-checkpoint': + output(await prepareActiveCheckpoint({ loopRoot, runId: args['run-id'] })) + break + case 'record-checkpoint': + output( + await recordActiveCheckpointPublication({ + runId: args['run-id'], + resultPath: args.result, + commentUrl: args['comment-url'], + loopRoot, + }), + ) + 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, + loopRoot, + }), + ) + break + case 'reconcile': + output(await reconcileLoopJournal({ loopRoot })) + break + case 'restore-checkpoint': { + 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({ loopRoot, checkpoint })) + break + } + case 'observe-owner-merge': + output( + await observeOwnerMerge({ + runId: args['run-id'], + finalizationResultPath: args.result, + finalizationCommentUrl: args['comment-url'], + loopRoot, + }), + ) + 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']), + loopRoot, + }), + ) + break + case 'detect-work': + output( + await detectWork({ + issuesFile: args['issues-file'], + pullRequestsFile: args['prs-file'], + repo: args.repo, + loopRoot, + }), + ) + break + case 'validate': + output(await validateLoop({ loopRoot, activation: Boolean(args.activation) })) + break + case 'evolve-status': + output(await getEvolveStatus({ loopRoot })) + break + case 'prepare-evolve-request': + output( + await prepareEvolveRequestPublication({ + requestId: args['request-id'], + loopRoot, + }), + ) + break + case 'record-evolve-request': + output( + await recordEvolveRequestPublication({ + requestId: args['request-id'], + commentUrl: args['comment-url'], + loopRoot, + }), + ) + break + case 'evolve-complete': + output( + await completeEvolve({ + 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|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]', + ) + } +} + +main().catch((error) => { + process.stderr.write(`${error.message}\n`) + process.exitCode = 1 +}) 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..51c7953c --- /dev/null +++ b/loops/issue-dev-loop/scripts/resolve-run.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node + +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' || !/^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 = [] +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 + } +} +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, 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 ?? ''}\nbase_sha=${result.baseSha ?? ''}\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 new file mode 100644 index 00000000..38096b6e --- /dev/null +++ b/loops/issue-dev-loop/scripts/runtime.mjs @@ -0,0 +1,47 @@ +export { assertAutomationIdentity, DEFAULT_LOOP_ROOT, parseArguments } from './lib/common.mjs' +export { + completeEvolve, + getEvolveStatus, + prepareEvolveRequestPublication, + reconcileEvolveJournal, + recordEvolveRequestPublication, + verifyPublishedEvolveCompletion, + verifyPublishedEvolveRequest, +} from './lib/evolve.mjs' +export { recordEvidence, recordReview, reviewPublicationDigest } from './lib/evidence.mjs' +export { + canonicalCheckpoint, + checkpointDigest, + prepareActiveCheckpoint, + reconcileActiveJournal, + recordActiveCheckpointPublication, + restoreActiveCheckpoint, +} from './lib/active-journal.mjs' +export { + canonicalRecord, + prepareFinalizationRecord, + reconcileFinalizationJournal, + recordDigest, + recordFinalizationPublication, +} from './lib/finalization-journal.mjs' +export { + detectWork, + loadPaginatedGitHubCollection, + observeOwnerMerge, + reconcileLoopJournal, + 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, +} from './lib/run-store.mjs' +export { validateLoop } from './lib/validation.mjs' 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..ed1e8586 --- /dev/null +++ b/loops/issue-dev-loop/scripts/validate-candidate-control-plane.mjs @@ -0,0 +1,153 @@ +#!/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', + '-z', + '--no-renames', + '--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([ + '.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('\0') + .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 === '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' || + /^\.?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, + ) || + /^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/validate-history.mjs b/loops/issue-dev-loop/scripts/validate-history.mjs new file mode 100644 index 00000000..78f66c63 --- /dev/null +++ b/loops/issue-dev-loop/scripts/validate-history.mjs @@ -0,0 +1,24 @@ +#!/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 baseRef = args['base-ref'] ?? 'origin/dev' +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/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 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/scripts/with-github-identity.mjs b/loops/issue-dev-loop/scripts/with-github-identity.mjs new file mode 100755 index 00000000..7e28c3eb --- /dev/null +++ b/loops/issue-dev-loop/scripts/with-github-identity.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node + +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] + 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 trustedControlPlane = await loadTrustedControlPlane() + const channel = await readOwnerChannel(trustedControlPlane.loopRoot) + process.exitCode = await runWithGitHubRole({ channel, trustedControlPlane, ...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 new file mode 100644 index 00000000..59bae403 --- /dev/null +++ b/loops/issue-dev-loop/state.md @@ -0,0 +1,42 @@ +# Issue development loop state + +Updated: 2026-07-23 + +## 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 +- Durable state journal: issue #105 +- Executor GitHub identity: `Ethandasw` +- Independent reviewer GitHub identity: `Traviinam` + +## Active runs + +None. + +## Open loop PRs + +None. + +## Blockers + +- 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. + +## 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..60589745 --- /dev/null +++ b/loops/issue-dev-loop/templates/implementation-brief.md @@ -0,0 +1,33 @@ +# Implementation brief + +- Run ID: `{{RUN_ID}}` +- 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 + +<!-- Freeze concrete, testable criteria before invoking $implement. --> + +## 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..8eb039be --- /dev/null +++ b/loops/issue-dev-loop/templates/pr-body.md @@ -0,0 +1,26 @@ +Closes #{{ISSUE_NUMBER}} + +<!-- issue-dev-loop:run:{{RUN_ID}} --> + +## Loop metadata + +- Run ID: `{{RUN_ID}}` +- Base SHA: `{{BASE_SHA}}` +- Head SHA: `{{HEAD_SHA}}` +- Risk: {{RISK}} + +## Changes + +## Acceptance criteria + +## Verification + +<!-- One line per manifest check: - `<exact command>`: passed (exit code 0) --> + +## Evidence + +## Independent review + +## Known limitations + +> 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/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/github-identity-routing.test.mjs b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs new file mode 100644 index 00000000..e6074e02 --- /dev/null +++ b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs @@ -0,0 +1,2057 @@ +import assert from 'node:assert/strict' +import { execFile } from 'node:child_process' +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' +import { promisify } from 'node:util' +import { fileURLToPath } from 'node:url' + +import { + canonicalCheckpointRecord, + checkpointPublicationBody, + checkpointRecordDigest, + parseCheckpointRecord, +} 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) +const testDirectory = path.dirname(fileURLToPath(import.meta.url)) +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, + recordedPr = true, + 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') + 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 }), + mkdir(path.join(loopRoot, 'evolve', 'requests'), { recursive: true }), + 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', + untrustedRootsEnvironmentVariable: 'ECHO_UI_LOOP_UNTRUSTED_ROOTS', + informationalImmediateTypes: ['pr_completed'], + 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(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') + 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 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', + 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, + uiEvidenceRequired: false, + implementationCommit, + } + const events = [ + { + schemaVersion: 1, + runId: 'fixture-run', + type: 'loop_started', + timestamp: startedAt, + status: 'running', + 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', + invocationId: implementationResult.invocationId, + startedAt: implementationResult.startedAt, + finishedAt: implementationResult.finishedAt, + briefDigest, + commitSha: implementationCommit, + resultPath: implementationResultPath, + resultDigest: implementationResultDigest, + }, + }, + ] + 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' }, + }, + ) + } + 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', + run, + briefSource, + events: [...events], + artifacts: [ + { + path: implementationResultPath, + sha256: implementationResultDigest, + source: implementationSource, + }, + ], + 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, 'implementation-result.json'), + implementationSource, + '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', + ) + } + await writeFile(path.join(parent, 'reviews.json'), '[]\n', 'utf8') + await writeFile( + path.join(loopRoot, 'evolve', 'metrics.json'), + `${JSON.stringify({ + schemaVersion: 1, + evolveDue: false, + pendingRequestId: null, + })}\n`, + 'utf8', + ) + const loopctlPath = path.join(trustedLoopRoot, 'scripts', 'loopctl.mjs') + await writeFile( + loopctlPath, + `import { spawnSync } from 'node:child_process' + +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 (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] + const commands = [ + ['gh', ['api', 'user']], + [ + '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([ + 'git', + [ + 'push', + \`--force-with-lease=refs/heads/codex/issue-\${reserveIssue}:\${baseSha}\`, + 'origin', + \`:refs/heads/codex/issue-\${reserveIssue}\`, + ], + ]) + } else { + commands.push([ + 'gh', + [ + 'api', + \`repos/example/repo/issues/\${issue}/labels\`, + '--method', + 'POST', + '-f', + 'labels[]=loop:claimed', + ], + ]) + } + 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 + } + } +} 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], + 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 + ), + 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 + ), + 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 + ) + })) +} +`, + 'utf8', + ) + + const fakeGh = path.join(binRoot, 'gh') + await writeFile( + fakeGh, + `#!/bin/sh +umask 077 +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" + first_line "$GH_CONFIG_DIR/identity" + exit 0 + fi + if [ "$1" = "api" ] && [ "$2" = "repos/example/repo/issues/comments/1" ]; then + first_line "$parent_dir/checkpoint-comment.json" + exit 0 + fi + if [ "$1" = "api" ] && [ "$2" = "repos/example/repo/pulls/106" ]; then + 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 + 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 + fi + if [ "$1 $2" = "pr review" ]; then + echo "comment review published" + exit 0 + fi + ${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" +first_line "$GH_CONFIG_DIR/identity" +`, + 'utf8', + ) + await chmod(fakeGh, 0o755) + + const fakeGit = path.join(binRoot, 'git') + 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 +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 +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', + ) + await chmod(fakeGit, 0o755) + } + const impostorGh = path.join(parent, 'gh') + 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), + }, + 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( + path.join(trustedLoopRoot, 'trusted-control-plane.json'), + `${JSON.stringify(manifest)}\n`, + 'utf8', + ) + const [canonicalAutomationProfile, canonicalReviewerProfile] = await Promise.all([ + realpath(automationProfile), + realpath(reviewerProfile), + ]) + + return { + loopRoot, + loopctlPath, + routerPath, + routerLauncherPath, + commandGatePath, + credentialHelper, + fakeGh, + fakeGit, + impostorGh, + automationProfile: canonicalAutomationProfile, + reviewerProfile: canonicalReviewerProfile, + env: { + ...process.env, + PATH: `${binRoot}${path.delimiter}${process.env.PATH}`, + 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', + NODE_OPTIONS: '', + }, + } +} + +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, + fixture.loopctlPath, + '--loop-root', + fixture.loopRoot, + ] + const { stdout } = await execFileAsync(process.execPath, command, { env: fixture.env }) + assert.deepEqual(JSON.parse(stdout), { + config: fixture.automationProfile, + hasGhToken: false, + gitConfig: ['15', 'credential.helper', '', 'credential.helper', credentialHelper], + gitIsolation: [os.devNull, '1'], + exposesOtherProfiles: false, + exposesRealTools: false, + hasExecutionHooks: false, + hasProxyEnvironment: 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, + '--loop-root', + fixture.loopRoot, + ], + { + 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('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('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( + routerLauncherPath, + [ + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + process.execPath, + fixture.loopctlPath, + 'validate', + '--activation', + '--loop-root', + fixture.loopRoot, + ], + { 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') + 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, [ + 'push', + 'https://github.com/example/repo.git', + 'refs/heads/codex/issue-123:refs/heads/codex/issue-123', + ]) + assert.deepEqual(observed.gitConfig, [ + 'credential.helper', + '', + 'credential.helper', + credentialHelper, + 'core.hooksPath', + os.devNull, + 'core.fsmonitor', + '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', + 'http.proxy', + '', + 'http.extraHeader', + '', + 'http.cookieFile', + os.devNull, + 'http.saveCookies', + 'false', + 'http.sslVerify', + 'true', + 'http.curloptResolve', + '', + 'remote.origin.proxy', + '', + 'http.followRedirects', + 'initial', + ]) +}) + +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('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( + process.execPath, + [ + routerPath, + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + process.execPath, + fixture.loopctlPath, + 'spawn', + 'gh', + 'api', + 'user', + '--loop-root', + fixture.loopRoot, + ], + { 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', + '--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 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, /force-with-lease=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( + 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( + 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 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', 'codex/issue-124'], + ['push', 'origin', 'dev'], + ['push', 'origin', 'HEAD:main'], + ['-C', '.', 'push', 'origin', 'codex/issue-123'], + ]) { + await assert.rejects( + execFileAsync( + process.execPath, + [routerPath, '--loop-root', fixture.loopRoot, 'automation', '--', 'git', ...gitArguments], + { env: fixture.env }, + ), + /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']], + [ + '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']], + ['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']], + [ + '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], + { 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', + '--repo', + 'example/repo', + '--comment', + '--body', + `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 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( + 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-cycle:1: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-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-cycle:1: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-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( + 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', + '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']], + [ + 'automation', + [ + 'pr', + 'comment', + '106', + '--repo', + 'example/repo', + '--body', + '@owner **pr_completed**', + ], + ], + [ + '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', + [ + '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', + ], + ], + [ + 'automation', + [ + 'api', + 'repos/example/repo/issues/123/comments', + '--method', + 'POST', + '-F', + '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( + execFileAsync( + process.execPath, + [routerPath, '--loop-root', fixture.loopRoot, role, '--', 'gh', ...ghArguments], + { env: fixture.env }, + ), + /GitHub action is prohibited for the (automation|reviewer) role/, + ) + } +}) + +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 () => { + 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('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') + 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\n<!-- issue-dev-loop:fixture-run:review-cycle:1:round:1:head:${'d'.repeat(40)} -->`, + ], + { 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\n<!-- issue-dev-loop:fixture-run:review-cycle:1:round:1:head:${'b'.repeat(40)} -->`, + ], + { 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' + 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', + 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, + [ + 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 = [ + ['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', + '--loop-root', + fixture.loopRoot, + ], + ], + ['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|not pinned by the trusted control plane|reviewer identity cannot run git push|automation may push only|descendant processes cannot (push|run untrusted executables))/, + ) + } +}) + +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']], + ['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', ['issue', 'edit', '105', '--state', 'closed']], + ['automation', ['pr', 'create', '--base', 'main', '--head', 'dev']], + ] + 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'], + ['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( + process.execPath, + [ + routerPath, + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + command[0], + ...command.slice(1), + ], + { env: fixture.env }, + ), + /(outside the authenticated|git command is outside|not pinned by the trusted control plane)/, + ) + } +}) + +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) + 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/) + } + 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 () => { + const fixture = await createFixture() + 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 +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', + ) + 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( + process.execPath, + [ + routerPath, + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + 'git', + 'push', + 'origin', + 'codex/issue-123', + ], + { env: fixture.env }, + ), + /origin fetch and push URLs must use HTTPS for the configured repository example\/repo/, + ) +}) 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..5b5acd23 --- /dev/null +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -0,0 +1,4996 @@ +import assert from 'node:assert/strict' +import { execFile } from 'node:child_process' +import { createHash } from 'node:crypto' +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' +import { promisify } from 'node:util' +import { fileURLToPath } from 'node:url' + +import { + appendEvent, + assertAutomationIdentity, + canonicalCheckpoint, + canonicalRecord, + checkpointDigest, + completeEvolve, + createNotification as runtimeCreateNotification, + defaultClaimIssue, + detectWork, + finalizeRun, + freezeBrief as runtimeFreezeBrief, + getEvolveStatus, + loadPaginatedGitHubCollection, + observeOwnerMerge, + prepareActiveCheckpoint, + prepareEvolveRequestPublication, + prepareFinalizationRecord as runtimePrepareFinalizationRecord, + reconcileActiveJournal, + reconcileEvolveJournal, + reconcileFinalizationJournal, + reconcileLoopJournal, + recordEvidence as runtimeRecordEvidence, + recordEvolveRequestPublication, + recordDigest, + recordFinalizationPublication, + recordActiveCheckpointPublication, + recordImplementation as runtimeRecordImplementation, + recordOwnerResponse as runtimeRecordOwnerResponse, + recordPullRequest as runtimeRecordPullRequest, + recordReview as runtimeRecordReview, + reviewPublicationDigest, + restoreActiveCheckpoint, + selectIssue, + startRun, + transitionRun as runtimeTransitionRun, + validateLoop, +} 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' +import { validateFinalizationHistory } from '../scripts/lib/validation.mjs' +import { checkpointPublicationBody } from '../scripts/lib/checkpoint-proof.mjs' + +const bypassCheckpointVerifier = async () => {} +const createNotification = (options) => + runtimeCreateNotification({ ...options, checkpointVerifier: bypassCheckpointVerifier }) +const freezeBrief = (options) => + runtimeFreezeBrief({ ...options, checkpointVerifier: bypassCheckpointVerifier }) +const prepareFinalizationRecord = (options) => + runtimePrepareFinalizationRecord({ + ...options, + checkpointVerifier: options.checkpointVerifier ?? bypassCheckpointVerifier, + }) +const recordEvidence = (options) => + runtimeRecordEvidence({ + ...options, + candidateControlPlaneVerifier: options.candidateControlPlaneVerifier ?? (async () => {}), + 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) + +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, + submitted_at: '2026-07-23T08:01:00.000Z', + }, + ] + } + 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_at: '2026-07-23T08:02:00.000Z', + 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) +}) + +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_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-701', + sha: headSha, + 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', + }, + ], + pull = pullRequest, + ) => + 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 reviews + if (endpoint.includes('/timeline')) return timeline + return pull + }, + }) + 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/, + ) + await assert.rejects( + verify([ + { + event: 'ready_for_review', + actor: { login: 'codeacme17' }, + created_at: '2026-07-23T08:00: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', + }, + ], + [ + { + 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/, + ) + 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() { + 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(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']) { + 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(loopRoot, 'logs', 'triggers.jsonl'), + '{"schemaVersion":1,"event":"trigger_log_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', + 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', + 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', + 'blocked', + 'review_dispute', + 'pr_ready_for_review', + 'pr_updated_for_review', + 'loop_failed', + ], + })}\n`, + 'utf8', + ) + return { loopRoot, channelRoot } +} + +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, + body: 'Authoritative issue body and acceptance context.', + html_url: options.issueUrl, + labels: [{ name: 'codex-ready' }], + }), + }) +} + +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, commentUrl } +} + +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 ? '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: [ + `Closes #${run.issueNumber}`, + `<!-- issue-dev-loop:run:${run.runId} -->`, + `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', + '- `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', + 'Fresh-context review is attached or pending for this draft.', + '## Known limitations', + 'None known.', + 'This PR must be marked Ready, reviewed, and merged by `@codeacme17`', + ].join('\n'), + } +} + +async function recordFixturePr({ + loopRoot, + run, + headSha, + number = 200, + 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, + 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.', + ) + .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( + 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 test -- keyboard', status: 'passed' }, + { command: 'pnpm verify', status: 'passed' }, + ], + })}\n`, + 'utf8', + ) + await recordImplementation({ + loopRoot, + runId: run.runId, + resultPath, + commitRangeValidator: async () => {}, + }) + await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) + const prUrl = `https://github.com/codeacme17/echo-ui/pull/${number}` + await recordPullRequest({ + loopRoot, + runId: run.runId, + prUrl, + headSha, + githubApi: async (endpoint) => + endpoint.includes('/compare/') + ? { status: 'ahead', base_commit: { sha: implementationCommit } } + : pullRequestFixture(run, headSha), + trailingPathValidator: async () => {}, + }) + await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) + 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', + sha: run.baseSha, + repo: { url: 'https://api.github.com/repos/codeacme17/echo-ui' }, + }, + head: { ref: run.branch, sha: headSha }, + }, + ], + } +} + +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, + baseSha: run.baseSha, + headSha, + trustedWorkflowSha: run.baseSha, + workflowBaseSha: run.baseSha, + workflowRunSha: 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, + }, + { + command: 'pnpm test (owner-merged baseline tests)', + status: 'passed', + exitCode: 0, + startedAt: timestamp, + finishedAt: timestamp, + artifactUrl: null, + }, + ], + screenshots: [], + limitations: [], + })}\n`, + 'utf8', + ) + return manifestPath +} + +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, + `${JSON.stringify({ + schemaVersion: 1, + runId: run.runId, + cycle: 1, + reviewerAgent: 'echo_ui_pr_reviewer', + freshContext: true, + headSha, + verdict: 'PASS', + rounds: [ + { + round: 1, + headSha, + reviewUrl: `https://github.com/codeacme17/echo-ui/pull/${prNumber}#pullrequestreview-${reviewId}`, + verdict: 'PASS', + findings: [], + }, + ], + })}\n`, + 'utf8', + ) + 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, + status, + finishedAt, + mergeSha = null, + failureFingerprint = null, +}) { + 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 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, + issueNumber: run.issueNumber, + status, + startedAt: run.startedAt, + finishedAt, + prUrl: run.prUrl, + headSha: run.headSha, + mergeSha, + failureFingerprint, + 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, + 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') + const digest = recordDigest(record) + 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, + submitted_at: new Date(Date.now() + 120_000).toISOString(), + }, + ] + } + 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, + 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' } }, + 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]' }, + 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]' }, + 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: [ + `<!-- 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: [ + { + 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) + 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 () => { + 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('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 }) + 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() + + 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 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', + '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/, + ) + + 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', () => { + 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( + 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 () => [ + { head: { ref: 'feature/other' }, title: 'Existing fix', body: 'Closes #128' }, + ], + remoteBranchExists: async () => false, + addLabel: async () => { + labelAdded = true + }, + }), + /already claims issue 128/, + ) + assert.equal(labelAdded, 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 () => true, + addLabel: async () => { + labelAdded = true + }, + }), + /remote branch codex\/issue-128 already exists/, + ) + 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('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 + const result = await startFixtureRun({ + 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', + 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/) + const events = await readFile(path.join(result.runPath, 'events.jsonl'), 'utf8') + 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('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( + 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('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({ + 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/, + ) + 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 () => { + 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 + 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', + workspaceValidator: async () => {}, + 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 test -- keyboard', status: 'passed' }, + { 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 + }, + }) + await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) + 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 updatedHead = '5'.repeat(40) + let checkedTrailingRange + 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), + body: pullRequestFixture(run, firstHead).body, + }, + trailingPathValidator: async (range) => { + checkedTrailingRange = range + }, + }) + 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({ + 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)), + 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( + briefPath, + `${await readFile(briefPath, 'utf8')}\nMutated after freeze.\n`, + 'utf8', + ) + await assert.rejects( + recordPullRequest({ + loopRoot, + runId: run.runId, + prUrl, + headSha: '6'.repeat(40), + 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 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, + 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({ + loopRoot, + issueNumber: 134, + issueTitle: 'Artifact attestation', + issueUrl: 'https://github.com/codeacme17/echo-ui/issues/134', + entropy: 'art134', + }) + 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') + 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', + 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, + 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/, + ) + 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') + ) + .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 () => { + 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) + const ownerAuthored = pullRequestFixture(run, nextHead) + 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 Draft PR/, + ) + 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({ + loopRoot, + issueNumber: 128, + issueTitle: 'Capture Player UI', + issueUrl: 'https://github.com/codeacme17/echo-ui/issues/128', + entropy: 'c1e2e3', + }) + 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, + sourceSha: run.baseSha, + }, + { + name: 'Player after', + phase: 'after', + scenario: 'Keyboard focus', + route: '/player', + viewport: '1280x720', + path: screenshotRelativePath, + capturedAt, + sourceSha: '1'.repeat(40), + }, + ], + })}\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) + assert.equal(JSON.parse(resolveResult.stdout).baseSha, run.baseSha) + + 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, + '--trusted-workflow-sha', + run.baseSha, + '--workflow-base-sha', + run.baseSha, + '--workflow-run-sha', + headSha, + '--status', + 'passed', + '--baseline-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) + 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-review waiting transition keeps the exact verified PR Draft and remains resumable', async () => { + const { loopRoot } = await createFixture() + const { run } = await startFixtureRun({ + 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', + }) + const headSha = 'c'.repeat(40) + const prUrl = await recordFixturePr({ loopRoot, run, headSha, number: 200 }) + const manifestPath = await writePassingEvidence({ + loopRoot, + run, + 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 (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, + }) + await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) + const reviewPath = await writePassingReview({ + loopRoot, + run, + headSha, + }) + const reviewDigest = reviewPublicationDigest(JSON.parse(await readFile(reviewPath, 'utf8'))) + await recordReview({ + loopRoot, + runId: run.runId, + 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 { + commit_id: headSha, + state: 'COMMENTED', + submitted_at: '2026-07-22T17:00:00.000Z', + user: { login: 'echo-ui-reviewer[bot]' }, + body: [ + 'PASS', + `<!-- issue-dev-loop:${run.runId}:review-cycle:1:round:1:head:${headSha} -->`, + `<!-- issue-dev-loop:${run.runId}:review-result-sha256:${reviewDigest} -->`, + ].join('\n'), + } + }, + }) + await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) + + const ownerReadyPullRequest = pullRequestFixture(run, headSha, { draft: true }) + 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, + runId: run.runId, + status: 'awaiting_owner_review', + 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, + now: new Date('2030-07-23T08:30:00.000Z'), + githubComment: async () => ({ + html_url: `${prUrl}#issuecomment-8802`, + }), + }) + 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, + runId: run.runId, + status: 'awaiting_owner_review', + prUrl, + headSha, + githubApi: async () => ({ + ...ownerReadyPullRequest, + base: { ref: 'main', repo: { full_name: 'codeacme17/echo-ui' } }, + }), + }), + /automation-authored live Draft 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 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 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 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)', + '- `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', + 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, + status: 'awaiting_owner_review', + prUrl, + headSha, + now: new Date('2030-07-23T08:42:00.000Z'), + githubApi: async () => ownerReadyPullRequest, + }) + assert.equal(paused.status, 'awaiting_owner_review') + assert.equal(paused.finishedAt, null) + + await assert.rejects( + appendEvent({ + loopRoot, + runId: run.runId, + type: 'pr_merged', + status: 'observed', + payload: { actor: 'codeacme17', mergeSha: '9'.repeat(40) }, + }), + /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, + submitted_at: '2030-07-23T08:41:00.000Z', + }, + ] + } + if (endpoint.includes('/timeline')) { + return [ + { + event: 'ready_for_review', + actor: { login: 'codeacme17' }, + created_at: '2030-07-23T08:40:00.000Z', + }, + ] + } + if (endpoint.endsWith('/issues/comments/8802')) { + return { + user: { login: 'echo-ui-loop[bot]' }, + 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: '2030-07-23T08:50:00.000Z', + body: `@codeacme17 **pr_completed**\n\nRun: \`${run.runId}\`\n\nMerge: \`${'9'.repeat(40)}\``, + } + } + 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: '2030-07-23T08:45:00.000Z', + } + } + await assert.rejects( + prepareFinalizationRecord({ + loopRoot, + runId: run.runId, + status: 'completed', + finishedAt: new Date('2030-07-23T09:00:00.000Z'), + mergeSha: '9'.repeat(40), + githubApi: completionGithubApi, + notifyOwner: async () => ({ + delivery: { + github: 'failed: unavailable', + webhook: 'not_configured', + }, + }), + }), + /durable GitHub delivery and a settled webhook attempt/, + ) + preparedCompletion = await prepareFinalizationRecord({ + loopRoot, + runId: run.runId, + status: 'completed', + finishedAt: new Date('2030-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( + verifyTerminalExternalProof({ + loopRoot, + record: { + ...preparedCompletion.record, + 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: '2030-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, + 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.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) + }, + }), + /owner-authored Ready transition/, + ) + const finalized = await observeOwnerMerge({ + loopRoot, + runId: run.runId, + now: new Date('2030-07-23T09:00:00Z'), + githubApi: completionGithubApi, + releaseIssueClaim: async () => {}, + finalizationResultPath: preparedCompletion.resultPath, + finalizationCommentUrl, + }) + assert.equal(finalized.status, 'completed') + assert.equal(finalized.mergeSha, '9'.repeat(40)) + assert.equal(finalized.finishedAt, '2030-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"'), + ) + const reconciledFinalization = await reconcileFinalizationJournal({ + loopRoot, + now: new Date('2030-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 () => { + const { loopRoot } = await createFixture() + const { run } = await startFixtureRun({ + 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: '9'.repeat(40), + }), + /invalid run status transition/, + ) +}) + +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) => { + 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 + }, + }), + /durable Ready and completion notifications|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) => { + 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 + }, + }), + /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`, + readyNotificationUrl: null, + 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'), + `${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]' }, + 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' }, + releaseIssueClaim: async () => { + released = true + }, + }), + /checkpoint|does not attest the exact record/, + ) + assert.equal(released, false) +}) + +test('review gate verifies published findings and classified replies', async () => { + const { loopRoot } = await createFixture() + const { run } = await startFixtureRun({ + loopRoot, + issueNumber: 133, + issueTitle: 'Review response proof', + issueUrl: 'https://github.com/codeacme17/echo-ui/issues/133', + entropy: 'rev133', + }) + 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( + resultPath, + `${JSON.stringify({ + schemaVersion: 1, + runId: run.runId, + cycle: 1, + reviewerAgent: 'echo_ui_pr_reviewer', + freshContext: true, + headSha, + verdict: 'PASS', + rounds: [ + { + round: 1, + headSha: 'e'.repeat(40), + reviewUrl: 'https://github.com/codeacme17/echo-ui/pull/300#pullrequestreview-499', + verdict: 'CHANGES_REQUESTED', + findings: [ + { + findingId: 'RVW-1-1-1', + severity: 'P2', + confidence: 'high', + headSha: 'e'.repeat(40), + inlineCommentId: 9001, + path: 'src/keyboard.ts', + line: 12, + 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.', + }, + }, + { + 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.', + }, + }, + ], + }, + { + round: 2, + headSha, + reviewUrl: 'https://github.com/codeacme17/echo-ui/pull/300#pullrequestreview-500', + verdict: 'PASS', + findings: [], + }, + ], + })}\n`, + 'utf8', + ) + const digest = reviewPublicationDigest(JSON.parse(await readFile(resultPath, 'utf8'))) + const wrongRoundResultPath = path.join( + loopRoot, + 'logs', + 'runs', + run.runId, + 'review-result-wrong-round.json', + ) + const wrongRoundResult = JSON.parse(await readFile(resultPath, 'utf8')) + wrongRoundResult.rounds[0].findings[0].findingId = 'RVW-1-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-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, + publicationDigest = digest, + } = {}) => + 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&page=1')) { + 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')) { + 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-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 { + 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: firstRound + ? [ + '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 -->`, + '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 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:${publicationDigest} -->`, + ].join('\n'), + } + } + + 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, + 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, + 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, 2) + 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 = reviewPublicationDigest(JSON.parse(await readFile(resultPath, 'utf8'))) + 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: 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/, + ) + let secondCommentPageFetched = false + 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&page=1')) { + return Array.from({ length: 100 }, (_, index) => ({ + id: index + 1, + 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 [ + { + id: 101, + user: { login: 'echo-ui-reviewer[bot]' }, + path: 'src/untracked.ts', + line: 101, + body: 'RVW-1-1-1: omitted finding beyond the first page.', + }, + ] + } + 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', + `<!-- issue-dev-loop:${run.runId}:review-cycle:1:round:1:head:${headSha} -->`, + `<!-- issue-dev-loop:${run.runId}:review-result-sha256:${digest} -->`, + ].join('\n'), + } + }, + }), + /unrecorded findings/, + ) + assert.equal(secondCommentPageFetched, true) +}) + +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 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 test -- keyboard', status: 'passed' }, + { command: 'pnpm verify', status: 'passed' }, + ], + })}\n`, + 'utf8', + ) + await recordImplementation({ + loopRoot, + runId: run.runId, + 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, + runId: run.runId, + cycle: 1, + reviewerAgent: 'echo_ui_pr_reviewer', + freshContext: true, + headSha, + verdict: 'PASS', + rounds: [ + { + round: 1, + headSha: findingHead, + reviewUrl: 'https://github.com/codeacme17/echo-ui/pull/304#pullrequestreview-509', + verdict: 'CHANGES_REQUESTED', + findings: [ + { + findingId: 'RVW-1-1-1', + severity: 'P2', + confidence: 'high', + headSha: findingHead, + inlineCommentId: null, + 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, + reviewUrl: 'https://github.com/codeacme17/echo-ui/pull/304#pullrequestreview-510', + verdict: 'PASS', + findings: [], + }, + ], + } + await writeFile(resultPath, `${JSON.stringify(result)}\n`, 'utf8') + const digest = reviewPublicationDigest(JSON.parse(await readFile(resultPath, 'utf8'))) + 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('/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&page=1')) 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-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) + const firstRound = endpoint.includes('/reviews/509') + return { + 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: firstRound + ? [ + 'RVW-1-1-1', + 'P2', + 'high', + 'Missing guard', + 'The failure is reproducible.', + 'Add the guard.', + `<!-- 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-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'), + } + }, + }) + 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, + issueNumber: 135, + issueTitle: 'High severity review dispute', + issueUrl: 'https://github.com/codeacme17/echo-ui/issues/135', + entropy: 'rev135', + }) + 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( + resultPath, + `${JSON.stringify({ + schemaVersion: 1, + runId: run.runId, + cycle: 1, + reviewerAgent: 'echo_ui_pr_reviewer', + freshContext: true, + headSha, + verdict: 'PASS', + rounds: [ + { + round: 1, + headSha, + reviewUrl: 'https://github.com/codeacme17/echo-ui/pull/302#pullrequestreview-500', + verdict: 'CHANGES_REQUESTED', + findings: [ + { + findingId: 'RVW-1-1-1', + severity: 'P1', + confidence: 'high', + headSha, + inlineCommentId: null, + 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.', + adjudicationUrl: + 'https://github.com/codeacme17/echo-ui/pull/302#pullrequestreview-502', + adjudicationVerdict: 'REJECT_FINDING', + }, + }, + ], + }, + { + round: 2, + headSha, + reviewUrl: 'https://github.com/codeacme17/echo-ui/pull/302#pullrequestreview-501', + verdict: 'PASS', + findings: [], + }, + ], + })}\n`, + '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&page=1')) 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, + runId: run.runId, + resultPath, + reviewUrl: 'https://github.com/codeacme17/echo-ui/pull/302#pullrequestreview-501', + githubApi: async (endpoint) => { + const response = await reusedCycleBaseApi(endpoint) + if (endpoint.endsWith('/pulls/302/reviews/500')) { + return { + ...response, + body: [ + response.body, + `<!-- issue-dev-loop:${run.runId}:RVW-1-1-1:adjudication:REJECT_FINDING:head:${headSha} -->`, + ].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 () => { + const { loopRoot, channelRoot } = await createFixture() + const { run } = await startFixtureRun({ + loopRoot, + issueNumber: 131, + issueTitle: 'Notify owner', + 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, + 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'), + 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) + const paused = JSON.parse( + await readFile(path.join(loopRoot, 'logs', 'runs', run.runId, 'run.json'), 'utf8'), + ) + 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('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({ + loopRoot, + issueNumber: 132, + issueTitle: 'Needs clarification', + issueUrl: 'https://github.com/codeacme17/echo-ui/issues/132', + entropy: 'badbee', + }) + await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) + 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') + await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) + 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( + runtimeRecordOwnerResponse({ + loopRoot, + runId: run.runId, + responseUrl, + }), + /requires a durable checkpoint/, + ) + await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) + 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', + }), + 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'), + }) + 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 publishFixtureCheckpoint({ loopRoot, runId: run.runId }) + 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('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({ + loopRoot, + issueNumber: 145, + issueTitle: 'Retry finalization prepare', + 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`, + }), + }) + 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({ + loopRoot, + runId: run.runId, + status: 'blocked', + failureFingerprint: 'same-terminal-cause', + finishedAt: firstFinishedAt, + githubApi, + checkpointVerifier, + }) + const retried = await prepareFinalizationRecord({ + loopRoot, + runId: run.runId, + status: 'blocked', + failureFingerprint: 'same-terminal-cause', + finishedAt: retryFinishedAt, + githubApi, + checkpointVerifier, + }) + assert.equal(retried.digest, first.digest) + assert.equal(retried.record.finishedAt, firstFinishedAt.toISOString()) +}) + +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 startFixtureRun({ + loopRoot, + issueNumber, + issueTitle: `Failure ${issueNumber}`, + issueUrl: `https://github.com/codeacme17/echo-ui/issues/${issueNumber}`, + entropy: `fail${issueNumber}`, + }) + await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) + 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 () => ({ + html_url: `${run.issueUrl}#issuecomment-8800`, + }), + }) + await publishFixtureCheckpoint({ loopRoot, runId: run.runId }) + const finalizationOptions = { + loopRoot, + runId: run.runId, + status: 'blocked', + failureFingerprint: 'browser-environment-unavailable', + 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) + }, + } + 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) { + 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]) + 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 }) + 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, 4) + 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('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: durableRunId, + 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', + notificationUrl, + readyNotificationUrl: null, + readyNotifiedAt: null, + completionNotifiedAt: null, + notificationWebhookStatus: null, + predecessorCheckpointUrl, + predecessorCheckpointDigest, + pauseStartedAt, + notificationNotifiedAt: pauseStartedAt, + } + const digest = recordDigest(record) + const foreignRecord = { + ...record, + 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, + 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 () => finalizationComments, + githubApi: reconciliationGithubApi, + latestActiveCheckpoints: [ + { + record: checkpointRecord, + commentUrl: predecessorCheckpointUrl, + createdAt: pauseStartedAt, + }, + ], + }) + 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) + + 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 () => { + 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('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('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, + 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({ + 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].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], + workspaceValidator: async () => {}, + }) + const restored = JSON.parse( + 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, + now: new Date('2026-07-22T12:02:00.000Z'), + reconcileJournal: async () => ({ + activeCheckpoints: [ + { + record: prepared.record, + commentUrl, + createdAt: '2026-07-22T12:01:00.000Z', + }, + ], + }), + }) + assert.equal(detected.hasWork, true) + assert.equal(detected.workType, 'resume') + 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({ + 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' + 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', + ) + 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, + requestId, + 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 [ + { + user: { login: 'codeacme17' }, + state: 'APPROVED', + commit_id: '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) }, + } + }, + }), + /configured owner/, + ) +}) + +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, + }), + }) + const publishedPendingRequest = JSON.parse(await readFile(requestPath, 'utf8')) + + 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, + submitted_at: '2026-07-23T12:31:00.000Z', + }, + ] + } + 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, + githubPaginatedApi: async () => [], + 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(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 () => [ + { + 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', + }, + { + 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: 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 }) + 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) +}) + +test('repository activation verifies both configured GitHub profiles', async () => { + 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 [canonicalAutomationProfile, canonicalReviewerProfile] = await Promise.all([ + realpath(automationProfile), + realpath(reviewerProfile), + ]) + const environment = { + 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, '..', '..'), + ]), + } + const observedProfiles = [] + const identityCommand = async (_command, _args, options) => { + observedProfiles.push(options.env.GH_CONFIG_DIR) + const login = + options.env.GH_CONFIG_DIR === canonicalAutomationProfile ? 'Ethandasw' : 'Traviinam' + return { stdout: `${login}\n` } + } + const result = await validateLoop({ + loopRoot: repositoryLoopRoot, + activation: true, + environment, + identityCommand, + }) + assert.equal(result.valid, true) + assert.deepEqual(observedProfiles, [canonicalAutomationProfile, canonicalReviewerProfile]) +}) + +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') + 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/, + ) + 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, + 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/TRIGGER.md b/loops/issue-dev-loop/triggers/TRIGGER.md new file mode 100644 index 00000000..ac4ebb52 --- /dev/null +++ b/loops/issue-dev-loop/triggers/TRIGGER.md @@ -0,0 +1,18 @@ +# Combo trigger + +Run the deterministic detector before waking Codex: + +```bash +"$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 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. + +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..4cce3743 --- /dev/null +++ b/loops/issue-dev-loop/triggers/codex-automation-prompt.md @@ -0,0 +1,5 @@ +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. + +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. 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..663fc64e --- /dev/null +++ b/loops/issue-dev-loop/triggers/detect-work.mjs @@ -0,0 +1,17 @@ +#!/usr/bin/env node + +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, +}) + .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..0b0648d8 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,10 @@ "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", - "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", + "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 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": { 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) } 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', }) 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 + } + } }