diff --git a/.github/workflows/authority-installer-retention.yml b/.github/workflows/authority-installer-retention.yml new file mode 100644 index 00000000..9979aa8c --- /dev/null +++ b/.github/workflows/authority-installer-retention.yml @@ -0,0 +1,31 @@ +name: Authority installer retention + +on: + pull_request: + paths: + - 'authority-host/windows/install-release.ps1' + - 'tests/windows/authority-release-retention.ps1' + - '.github/workflows/authority-installer-retention.yml' + +permissions: + contents: read + +jobs: + retention: + name: Prune obsolete Authority versions + runs-on: windows-latest + timeout-minutes: 20 + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + global-json-file: global.json + + - name: Run Authority installer retention regression + shell: pwsh + run: ./tests/windows/authority-release-retention.ps1 diff --git a/authority-host/windows/install-release.ps1 b/authority-host/windows/install-release.ps1 index 4f9ab174..84044295 100644 --- a/authority-host/windows/install-release.ps1 +++ b/authority-host/windows/install-release.ps1 @@ -123,6 +123,19 @@ try { [IO.File]::WriteAllText($recordTemp, $recordJson, $utf8NoBom) Move-Item -Force $recordTemp $recordPath + # Version directories are release-owned and self-contained. Once the new + # version is active, remove obsolete release versions so updates do not + # accumulate full runtimes. Root state and unknown app content are preserved. + $targetName = 'v' + $ExpectedVersion + $obsoleteVersionDirs = @( + Get-ChildItem -LiteralPath $appRoot -Directory -Force | Where-Object { + $_.Name -match '^v\d+\.\d+\.\d+$' -and $_.Name -ne $targetName + } + ) + foreach ($obsoleteVersionDir in $obsoleteVersionDirs) { + Remove-Item -LiteralPath $obsoleteVersionDir.FullName -Recurse -Force + } + # Remove only the obsolete root-level launcher from the legacy layout. State # (`authority.db`, trust-store.json) and unknown user files are deliberately preserved. $legacyExe = Join-Path $InstallDir 'GitHubDeliveryAuthority.exe' diff --git a/references/create-pr-for-issue.md b/references/create-pr-for-issue.md index 86d3814e..52609ccc 100644 --- a/references/create-pr-for-issue.md +++ b/references/create-pr-for-issue.md @@ -18,7 +18,9 @@ Policy modules: ## Goal -Open only the requested PRs, normally one, on the issue's canonical repository. Fix the verified issue, preserve unrelated work, pass the required gates, link/assign/notify as permitted, and stop before merge. +Open only requested PRs on the issue's canonical repository. Fix the verified issue, preserve unrelated work, pass required gates, link/assign/notify as permitted, and stop before merge. + +For explicit **open the PR and stop** requests, run through E, then `OPEN_PR -> DONE` only after live identity verification and both locked publication receipts; skip F/G. ## Runtime contract @@ -27,110 +29,116 @@ Open only the requested PRs, normally one, on the issue's canonical repository. - `create_pr` intent is controller-owned/operation-bound. Never repair via manual `--workflow-intent`, checkpoint edits, or `explicitInstruction`; changed payloads need fresh intent. - `github-mutate.mjs` owns authority; `off` skips only Hello/Authority, protected modes retain it. - Local work is not publication; broker remote writes. +- Issue publication uses `scripts/create-pr-publication-plan.mjs`; no hand-built mutation schemas or direct `git push` / `gh pr create`. - **Do not merge.** ## A. Need-to-fix preflight -Capture one current issue/development snapshot and answer before coding: +Before coding, capture the current issue/development snapshot: 1. Is the issue still needed on the latest development/base tip? 2. Was it already fixed there? If yes, identify the SHA/PR. 3. Is an open PR already covering it? -4. Is it an obvious duplicate of another issue? +4. Is it an obvious duplicate? ### Full issue thread intake -Read the issue body, **every comment** with pagination, labels, linked PRs, and timeline scope changes. Extract the Agent Brief, maintainer clarifications, `[GD]` research notes, repro updates, acceptance criteria, screenshots, and explicit non-goals. Carry that contract into implementation, PR description, and Spec review. +Read the issue body, **every comment** with pagination, labels, linked PRs, and timeline scope changes. Extract the Agent Brief, clarifications, `[GD]` research, repro updates, acceptance criteria, screenshots, and non-goals. Carry that contract into implementation, PR description, and Spec review. ### Screenshot gate -Review author-provided screenshots/images before implementation. If required screenshots cannot be reviewed, stop instead of opening a speculative PR. +Review author-provided screenshots/images before implementation. If required screenshots cannot be reviewed, stop. ### Preflight outcome -- **Already fixed/shipped:** do not create a PR. -- **Covering PR exists:** do not create a duplicate; report/use that PR. -- **Duplicate issue:** do not create a PR for the duplicate; use the canonical issue. +- **Already fixed/shipped:** no PR. +- **Covering PR exists:** reuse/report it; no duplicate. +- **Duplicate issue:** use the canonical issue; no duplicate PR. - **Still needs fix:** continue. -- **Evidence incomplete:** restore the missing evidence; do not guess. +- **Evidence incomplete:** restore it; do not guess. -If `research-issue.md` just produced the same verdict on the same development tip and unchanged issue conversation, reuse it. Do not restart broad research merely because implementation reveals another call site, adapter, UI surface, test, or documentation consumer. Re-enter preflight only when a new fact invalidates the original decision. +If `research-issue.md` produced the same verdict on the same development tip and unchanged issue conversation, reuse it. Do not restart broad research merely because implementation reveals another call site, adapter, UI surface, test, or documentation consumer. Re-enter preflight only when a new fact invalidates that decision. ## B. Confirm scope -Default to one cohesive PR. Split only when independently shippable concerns need separate validation/review boundaries or acceptance criteria conflict. Batches over three issues use fan-out. +Default to one cohesive PR. Split only for independently shippable concerns with separate validation/review boundaries or conflicting acceptance criteria. Batches over three issues use fan-out. ## C. Implement locally -1. Start from the exact base/development tip captured in A. Use a task branch/worktree that preserves unrelated local work. Apply `references/git-workflow.md`; repository rules and `GD-GIT-*` remain authoritative. -2. For non-trivial implementation, apply `references/minimal-solution.md`, then make the smallest complete change that satisfies the issue contract without weakening required safeguards. -3. Follow required consumers as dependencies appear; for broad migrations or deterministic sweeps apply `references/change-execution.md`. -4. Run focused validation appropriate to the changed code: tests, typecheck, build, repro, and repository-local checks as available. -5. Require a non-empty base-to-head candidate diff. If no change is needed, return to the matching preflight outcome; do not open an empty PR. -6. Hygiene: resolve passes independently. Run `references/no-comments.md` unless its pass is opted out, then `references/simplify-pr.md` unless its pass is opted out. The no-comments and simplify opt-outs are independent. A failed no-comments pass blocks publication. +1. Start from the exact base/development tip from A. Use a task branch/worktree that preserves unrelated work. Apply `references/git-workflow.md`; repository rules and `GD-GIT-*` remain authoritative. +2. For non-trivial implementation, apply `references/minimal-solution.md`; make the smallest complete change without weakening safeguards. +3. Follow required consumers; for broad migrations or deterministic sweeps apply `references/change-execution.md`. +4. Run focused tests/typecheck/build/repro and repository-local checks as applicable. +5. Require a non-empty base-to-head diff containing only intended work. If no change is needed, return to the matching preflight outcome. +6. Hygiene: use canonical helpers for both passes. Caller text cannot opt out or supply trusted skip provenance. Until controller/host-owned user intent can prove a skip, run `references/no-comments.md` and `references/simplify-pr.md`; skipped results fail closed. ## D. Pre-open bug + security gate -After implementation and before any push/open action, run: +After implementation and before push/open: ```text node /scripts/pre-open-gate.mjs OWNER/REPO --checkpoint ``` - `ready`: continue. -- `blocked` with `workflow:implementation_missing`: return to C and implement. -- other `blocked`: run required branch-diff bug/security passes, load `bug-review.md` / `security-review.md` only for triggered lenses/surfaces, fix Confirmed High/Critical findings, record `done` or honest `n/a (why)` evidence, then re-evaluate. -- `unknown`: stop and restore complete branch evidence before publication. +- `blocked` with `workflow:implementation_missing`: return to C. +- other `blocked`: run every required bug/security pass. Load `bug-review.md` / `security-review.md` only for triggered lenses/surfaces, fix Confirmed High/Critical findings, and record one head-bound row per required lens/surface with status, bounded method, and required file coverage. Candidate-wide `bug: clean` / `security: clean` is non-authoritative. +- `unknown`: stop and restore complete branch evidence. -Carry completed gate/review evidence into the PR validation notes. +Carry completed gate/review evidence into PR validation notes. ## E. Publish the canonical PR -1. Resolve repository identity from the **issue**, not whichever remote is convenient, and resolve the correct base branch. -2. Publish the exact commit using broker action `push_code`. Bind the observed remote generation; use force-with-lease only when the selected Git workflow permits rewriting that branch. -3. Build the PR description from `references/pr-description.md`, the final candidate diff, issue acceptance criteria, thread clarifications, and completed validation. Do not narrate planned work as completed work. -4. After push, re-check exact publication identity: canonical repository + pushed head identity + intended base. One exact open PR → reuse it; multiple → fail closed and report them; none → create. This is narrower than A's semantic covering-PR check, and `create_pr` preflight repeats it immediately before execution. -5. Create with broker action `create_pr`, a stable idempotency key, and exact base/head/title/body. `create_pr_existing` names the publication result; do not bypass it or retry under another title. -6. Confirm the created/reused PR has the canonical issue repository and intended base/head. Wrong topology is a hard stop. +1. Resolve canonical repository identity from the **issue** and the correct base branch. +2. Build the PR description from `references/pr-description.md`, final diff, issue contract, and completed validation; never claim planned work as done. +3. Resolve exact tips/identity. Run `node scripts/create-pr-publication-plan.mjs --input --output ` to lock broker action `push_code` plus draft broker action `create_pr` to the checkpoint. +4. Execute that plan unchanged via `node scripts/github-mutate.mjs --request --execute --checkpoint `. No direct `git push` / `gh pr create`; force-with-lease is planner-owned. +5. Re-check canonical repo + head + base. Reuse one exact open PR; multiple or none after successful publication fail closed. `create_pr_existing` names reuse. +6. Require canonical repo/base/head and successful receipts for both locked operations. +7. Explicit open-only request: `OPEN_PR -> DONE`; otherwise continue to F/G. ## F. Link, assign, notify -GitHub's closing-keyword behavior depends on the PR base: +GitHub closing keywords depend on the PR base: -- **Default branch:** put `Fixes #N` or `Closes #N` on its own line and verify `closingIssuesReferences` includes the issue. -- **Non-default branch:** GitHub ignores closing keywords for linking/auto-close. Do **not** loop on empty `closingIssuesReferences`; keep an explicit issue reference such as `Refs #N` and verify visible linkage. The issue comment below provides the durable issue-side pointer. +- **Default branch:** put `Fixes #N` or `Closes #N` on its own line and verify `closingIssuesReferences`. +- **Non-default branch:** GitHub ignores closing keywords for linking/auto-close. Keep `Refs #N`, do not loop on empty `closingIssuesReferences`; the issue comment below provides the durable pointer. Then: -1. Assign yourself on the issue with broker action `assign_issue` when permissions permit. If denied, report once and continue. -2. Post exactly one idempotent issue comment with broker action `post_issue_comment`: `[GD] Opened PR # to address this.` Reuse the canonical PR number whether newly created or found by the exact publication check. -3. Spot-check that issue/PR references point at the canonical PR, not a fork-only or superseded PR. +1. Assign yourself on the issue with broker action `assign_issue` when permitted. If denied, report once and continue. +2. Post exactly one idempotent brokered issue comment: `[GD] Opened PR # to address this.` Reuse the canonical PR number. +3. Spot-check issue/PR references point to the canonical PR. ## G. Make merge-ready -Work on the current PR head until the authoritative merge-ready bar is satisfied: +Work on the current PR head until the authoritative merge-ready bar passes: 1. Keep branch current with base; resolve conflicts safely. Remote updates use `push_code`. 2. Process current human/bot feedback; fix required findings or decline with verified rationale. -3. Require green CI on current SHA; helpers diagnose but never override `ship-gate.mjs`. +3. Require green CI on current SHA; helpers never override `ship-gate.mjs`. 4. Complete own bug, security, Spec + Standards, semantic propagation, proactive contract verification, CODEOWNERS, and applicable `runtime-verification`; load detail only for active axes. 5. Reconcile the PR description with final-head scope, validation, limitations, and linkage via broker action `update_pr_body`; preserve protected media absent explicit removal authority. -6. Run the settle window; re-read reviews/checks/rules/base/head; run the authoritative final ship gate. Head changes invalidate head-bound evidence. +6. Run the settle window; re-read reviews/checks/rules/base/head; run the final ship gate. Head changes invalidate head-bound evidence. 7. Publish merge-ready PR and linked-issue notifications through brokered actions. **Do not merge.** ## H. Completion report -Before final reporting, apply `references/completion-claims.md` to current authoritative evidence; re-measure material counts and preserve unknown, blocked, not-run, and partial states. +Apply `references/completion-claims.md` to current authoritative evidence. Re-measure material counts and preserve unknown, blocked, not-run, and partial states. ## Done when +For normal merge-ready delivery: - Only requested PRs; canonical issue repository and intended base/head. -- Full issue thread and screenshot gate complete; preflight has evidence-backed outcome. -- Non-empty implementation diff existed before the pre-open gate; bug/security publication requirements cleared. +- Full issue thread and Screenshot gate complete; preflight is evidence-backed. +- Non-empty implementation diff existed before pre-open; every required bug/security row is head/file-bound, not aggregate clean evidence. +- Hygiene evidence came from canonical passes; caller text did not bypass them. - Exact-head/base publication was reused instead of duplicated. -- Network writes used `github-mutate.mjs` with required authority. +- Network writes used the canonical planner plus `github-mutate.mjs` with required authority. - PR description matches final head/issue contract; linkage and protected-media rules are satisfied. - Self-assignment when possible; one opened-PR issue comment; no duplicates. - Reviews, feedback, required CI, freshness, applicable runtime verification, and final ship gate pass on final head. - Final report satisfies `references/completion-claims.md`. - Merge-ready was published and **the PR was not merged**. + +Open-only delivery ends at E only after both locked receipts and live PR identity verification; no F/G. diff --git a/references/create-pr-from-local-work.md b/references/create-pr-from-local-work.md index 3512c2f9..50d7a27c 100644 --- a/references/create-pr-from-local-work.md +++ b/references/create-pr-from-local-work.md @@ -48,13 +48,13 @@ If the active higher-priority instruction stack genuinely requires a GitHub writ 4. **Validate the candidate.** Run focused tests/typecheck/build/repro appropriate to the changed code plus `git diff --check` when available. Confirm the base-to-head diff is non-empty and contains only intended work. Before publication, use the Git-workflow change summary to identify the logical change, intentionally untouched related surfaces, material concerns, and checks actually run. 5. **Produce hygiene evidence through the orchestrator.** Resolve `references/no-comments.md` and `references/simplify-pr.md` independently; do not mint checkpoint receipts directly and do not rediscover helper JSON shapes from source. - When no-comments runs, execute `node scripts/create-pr-hygiene.mjs prepare --root --base --head --scope --snapshot `. Give Comment Inspector exactly the generated `scope.json`. It may read nearby context but may classify only that immutable diff-added-line scope. Save only its final structured `github-delivery/comment-review-result` as ``. - - Run `references/simplify-pr.md` unless opted out and record one `` pass object: successful review uses `{ "outcome": "clean", "method": "simplify-pass", "validationPassed": true }`; an explicit opt-out uses `{ "outcome": "skipped", "method": "opt-out", "reason": "" }`. + - Run `references/simplify-pr.md` and record one `` pass object such as `{ "outcome": "clean", "method": "simplify-pass", "validationPassed": true }` after the pass succeeds. - Finalize with `node scripts/create-pr-hygiene.mjs finalize --root --head --scope --snapshot --result --simplify --output `. The helper verifies unchanged reviewer bytes, discards the verified snapshot, validates the final result against the exact scope, and refuses to turn DELETE/root-cause findings into a clean receipt. If it reports `comment_review_guard_changed_restore_required`, restore through `comment-review-guard.mjs` before doing anything else. If it reports pending no-comments changes, the parent applies accepted in-scope fixes, revalidates/commits the new head, then restarts hygiene on that new head. - - When no-comments is explicitly opted out, do not prepare/spawn it. Run `node scripts/create-pr-hygiene.mjs skip-no-comments --head --reason "" --simplify --output `. -6. **Run compact pre-open and assemble review evidence without per-row boilerplate.** Run `node scripts/pre-open-gate.mjs OWNER/REPO --compact --checkpoint --output `. The top-level `decision` plus exit code is authoritative. If it is already `ready`, continue. When blocked only on review evidence, use `remaining` and `evidenceRequirements` as the exact worklist. - - Perform one structured **bug-axis** review that explicitly covers every ID in `remaining.lenses` and every file required by `evidenceRequirements.lenses`; perform one structured **security-axis** review with the same rule for `remaining.surfaces` / `evidenceRequirements.surfaces`. Preserve every deterministic probe as its own canonical structured probe-evidence record. - - Write one `` with `schemaVersion: 1`, `kind: "github-delivery/pre-open-review-result"`, the exact `headSha`, and `bug` / `security` objects containing `status: "clean"`, a bounded `method`, `coveredIds`, and the union of actually `reviewedFiles`; put the canonical required probe records under `probes`. - - Run `node scripts/pre-open-review-evidence.mjs --summary --review --output `. This helper does **not** reduce coverage: it emits every existing schema-v2 lens/surface row only when its semantic ID and required files were covered by the corresponding axis review. + - **Do not use caller-authored skip text as publication evidence.** A `skipped` hygiene result requires trusted user-intent provenance that is not currently supplied by the public hygiene CLI. Until such controller/host-owned provenance exists, an attempted no-comments or simplify skip fails closed and the actual pass must run before publication. +6. **Run compact pre-open and assemble explicit review evidence.** Run `node scripts/pre-open-gate.mjs OWNER/REPO --compact --checkpoint --output `. The top-level `decision` plus exit code is authoritative. If it is already `ready`, continue. When blocked only on review evidence, use `remaining` and `evidenceRequirements` as the exact worklist. + - Review every ID in `remaining.lenses` and `remaining.surfaces` against the exact files required for that ID. Preserve every deterministic probe as its own canonical structured probe-evidence record. One candidate-wide `bug: clean` or `security: clean` assertion is not authoritative and cannot stand in for the required rows. + - Write one `` with `schemaVersion: 2`, `kind: "github-delivery/pre-open-review-result"`, the exact `headSha`, and explicit `lenses` / `surfaces` maps. Every required ID gets its own structured row containing `status: "done"` or honest `n/a `, the exact `headSha`, a bounded `method`, and `reviewedFiles` covering every file required by `evidenceRequirements` for that ID. Put the canonical required probe records under `probes`. + - Run `node scripts/pre-open-review-evidence.mjs --summary --review --output `. The helper validates and assembles the explicit rows; legacy schema-v1 aggregate bug/security declarations fail as `pre_open_review_aggregate_not_authoritative` rather than being expanded into many completions. - Rerun `node scripts/pre-open-gate.mjs OWNER/REPO --compact --checkpoint --evidence-file --hygiene-file `. Continue only on top-level `decision=ready` and exit `0`. Full gate output is diagnostic-only when compact output or evidence validation itself fails. 7. **Check exact-head publication identity and optional branch issue candidate.** Before planning `create_pr`, prove whether an open PR already exists for the exact target repository + head identity + intended base. This is an identity check, not fuzzy title/body similarity. The `create_pr` lifecycle preflight independently repeats this live check immediately before execution. At this same read-only stage, apply the verified branch-derived closing-link rule above when the branch conventionally encodes one issue candidate; perform no issue-side mutation. - exactly one PR match → **reuse/report that PR**; do not create another; @@ -74,15 +74,16 @@ If the active higher-priority instruction stack genuinely requires a GitHub writ - Never treat a direct-write instruction conflict as permission to experiment with multiple write paths. Fail closed once and report the conflict. - Do not invent issue linkage from arbitrary numbers. A branch-derived `Closes #N` is allowed only after the same-repository open-issue and scope-match verification above; do not add issue-side effects merely because another create-PR workflow supports them. - Do not defeat exact-head duplicate prevention by changing the title/body, inventing another local branch name for the same remote head, or weakening repository identity. -- Do not reduce a large compact evidence worklist by omitting required IDs. Aggregation removes repetitive record construction, not required review coverage. +- Do not reduce a compact evidence worklist by replacing required IDs with a candidate-wide aggregate declaration. Every required lens/surface remains independently represented and head/file bound. +- Do not manufacture hygiene opt-out evidence from a caller-controlled reason string. ## Done when - exactly one PR exists for the intended local head/base publication; - an already-existing exact-head/base PR was reused rather than duplicated when present; - the PR contains no unrelated files or commits; -- current-head hygiene evidence came from the deterministic orchestration boundary; -- the candidate diff passed the compact pre-open gate with top-level `decision=ready` using complete semantic-ID/file coverage and required probes; +- current-head hygiene evidence came from the deterministic orchestration boundary and no caller-authored skip bypassed a required pass; +- the candidate diff passed the compact pre-open gate with top-level `decision=ready` using explicit complete semantic-ID/file coverage and required probes; - any verified branch-derived issue candidate produced exactly one closing reference in the PR body, while an unverified/mismatched candidate produced none and no issue-side effects were invented; - any remote branch/PR publication was performed from the canonical generated plan through the authorized mutation boundary; - the resulting PR repository/base/head/draft state were verified; and diff --git a/scripts/delivery-controller.mjs b/scripts/delivery-controller.mjs index 8c3475ec..a73157ba 100644 --- a/scripts/delivery-controller.mjs +++ b/scripts/delivery-controller.mjs @@ -23,7 +23,6 @@ const USAGE = `Usage: node scripts/delivery-controller.mjs retry CHECKPOINT node scripts/delivery-controller.mjs evidence-action CHECKPOINT node scripts/delivery-controller.mjs usage CHECKPOINT --workflow-tokens N --phase-tokens N - node scripts/delivery-controller.mjs refs CHECKPOINT [--base SHA] [--head SHA] node scripts/delivery-controller.mjs authorize-mutation CHECKPOINT --request FILE [--workflow-intent] [--exact-text-confirmed] node scripts/delivery-controller.mjs blocker-add CHECKPOINT BLOCKER node scripts/delivery-controller.mjs blocker-remove CHECKPOINT BLOCKER @@ -176,7 +175,9 @@ try { const checkpoint = argv.shift(); if (!checkpoint) throw new Error(USAGE); assertEmpty(argv); - print(readDeliveryWorkflowCheckpoint(resolve(checkpoint))); + print(load(checkpoint).controller.snapshot()); + } else if (command === "refs") { + throw new Error("controller_refs_are_internal_only"); } else { const checkpointValue = argv.shift(); if (!checkpointValue) throw new Error(USAGE); @@ -211,14 +212,6 @@ try { workflowTokens: workflowTokens === null ? undefined : nonNegativeInteger(workflowTokens, "--workflow-tokens"), phaseTokens: phaseTokens === null ? undefined : nonNegativeInteger(phaseTokens, "--phase-tokens"), }); - } else if (command === "refs") { - const baseSha = takeOption(argv, "--base"); - const headSha = takeOption(argv, "--head"); - assertEmpty(argv); - result = loaded.controller.updateRefs({ - ...(baseSha !== null ? { baseSha } : {}), - ...(headSha !== null ? { headSha } : {}), - }); } else if (command === "authorize-mutation") { const requestPath = takeOption(argv, "--request"); const trustedWorkflowIntent = takeFlag(argv, "--workflow-intent"); diff --git a/scripts/lib/agent-debug-trace.mjs b/scripts/lib/agent-debug-trace.mjs index 7c2a3c31..a63f5025 100644 --- a/scripts/lib/agent-debug-trace.mjs +++ b/scripts/lib/agent-debug-trace.mjs @@ -12,6 +12,7 @@ import { homedir } from "node:os"; import { join, resolve } from "node:path"; const DEFAULT_MAX_BYTES = 2 * 1024 * 1024; +const DEFAULT_REASONING_COALESCE_BYTES = 16 * 1024; const TRACE_KIND = "github-delivery/agent-debug-trace-event"; const PROVIDERS = new Set(["codex", "grok", "cursor"]); const ALLOWED_EVENT_TYPES = new Set([ @@ -21,6 +22,8 @@ const ALLOWED_EVENT_TYPES = new Set([ "turn_started", "turn_completed", ]); +const ALLOWED_OUTCOMES = new Set(["succeeded", "failed", "cancelled"]); +const ERROR_KIND_RE = /^[a-z0-9][a-z0-9_.-]{0,63}$/i; function cleanString(value) { return typeof value === "string" && value.length > 0 ? value : null; @@ -32,6 +35,20 @@ function checkedProvider(value) { return provider; } +function safeDuration(value) { + return Number.isFinite(value) && value >= 0 ? Math.round(value) : null; +} + +function safeOutcome(value) { + const outcome = String(value || "").trim().toLowerCase(); + return ALLOWED_OUTCOMES.has(outcome) ? outcome : null; +} + +function safeErrorKind(value) { + const kind = String(value || "").trim(); + return ERROR_KIND_RE.test(kind) ? kind : null; +} + function sanitizeEvent(event, provider, traceKind = TRACE_KIND, timestamp = new Date()) { if (!event || typeof event !== "object") return null; const type = cleanString(event.type); @@ -54,6 +71,15 @@ function sanitizeEvent(event, provider, traceKind = TRACE_KIND, timestamp = new sanitized.text = typeof event.text === "string" ? event.text : ""; } + if (type === "item_completed") { + const outcome = safeOutcome(event.outcome); + const durationMs = safeDuration(event.durationMs); + const errorKind = safeErrorKind(event.errorKind); + if (outcome) sanitized.outcome = outcome; + if (durationMs !== null) sanitized.durationMs = durationMs; + if (errorKind) sanitized.errorKind = errorKind; + } + const decision = cleanString(event.watchdogDecision); if (decision) sanitized.watchdogDecision = decision; if (typeof event.interrupted === "boolean") sanitized.interrupted = event.interrupted; @@ -118,6 +144,10 @@ function byteLimit(value) { return Number.isInteger(value) && value > 0 ? value : DEFAULT_MAX_BYTES; } +function reasoningIdentity(event) { + return [event.threadId || "", event.turnId || "", event.itemId || ""].join("\0"); +} + function disabledRecorder() { return { enabled: false, @@ -140,11 +170,13 @@ export function createAgentDebugTraceRecorder({ pid = process.pid, maxBytes = DEFAULT_MAX_BYTES, traceKind = TRACE_KIND, + reasoningCoalesceBytes = DEFAULT_REASONING_COALESCE_BYTES, } = {}) { if (!debugTraceEnabled(env)) return disabledRecorder(); const normalizedProvider = checkedProvider(provider); const limit = byteLimit(maxBytes); + const coalesceLimit = byteLimit(reasoningCoalesceBytes); const root = traceRoot(env, stateDir); const directory = join(root, "debug-traces"); ensurePrivateDirectory(root, "debug trace state directory"); @@ -155,11 +187,10 @@ export function createAgentDebugTraceRecorder({ let fd = opened.fd; let bytesWritten = 0; + let pendingReasoning = null; - function record(event) { + function writeSanitized(sanitized) { if (fd === null) return false; - const sanitized = sanitizeEvent(event, normalizedProvider, traceKind, now()); - if (!sanitized) return false; const line = `${JSON.stringify(sanitized)}\n`; const bytes = Buffer.byteLength(line); if (bytesWritten + bytes > limit) return false; @@ -168,8 +199,40 @@ export function createAgentDebugTraceRecorder({ return true; } + function flushReasoning() { + if (!pendingReasoning) return true; + const pending = pendingReasoning; + pendingReasoning = null; + return writeSanitized(pending); + } + + function record(event) { + if (fd === null) return false; + const sanitized = sanitizeEvent(event, normalizedProvider, traceKind, now()); + if (!sanitized) return false; + + if (sanitized.type === "reasoning_summary_delta") { + const identity = reasoningIdentity(sanitized); + if (pendingReasoning && reasoningIdentity(pendingReasoning) === identity) { + const combined = `${pendingReasoning.text}${sanitized.text}`; + if (Buffer.byteLength(combined, "utf8") <= coalesceLimit) { + pendingReasoning.text = combined; + pendingReasoning.deltaCount += 1; + return true; + } + } + flushReasoning(); + pendingReasoning = { ...sanitized, deltaCount: 1 }; + return true; + } + + flushReasoning(); + return writeSanitized(sanitized); + } + function close() { if (fd === null) return; + flushReasoning(); closeSync(fd); fd = null; } diff --git a/scripts/lib/codex-app-server-watchdog-proxy.mjs b/scripts/lib/codex-app-server-watchdog-proxy.mjs index 9b49d91e..1e4f8e2d 100644 --- a/scripts/lib/codex-app-server-watchdog-proxy.mjs +++ b/scripts/lib/codex-app-server-watchdog-proxy.mjs @@ -17,6 +17,27 @@ function messageThreadId(message) { return message?.params?.threadId || null; } +function completionDiagnostics(message) { + const item = message?.params?.item || {}; + const rawStatus = String(item?.status || message?.params?.status || "completed").toLowerCase(); + const outcome = ["failed", "error"].includes(rawStatus) + ? "failed" + : ["cancelled", "canceled"].includes(rawStatus) + ? "cancelled" + : "succeeded"; + const rawDuration = Number.isFinite(item?.durationMs) + ? item.durationMs + : Number.isFinite(message?.params?.durationMs) + ? message.params.durationMs + : item?.duration_ms; + const durationMs = Number.isFinite(rawDuration) && rawDuration >= 0 ? Math.round(rawDuration) : null; + return { + outcome, + ...(durationMs !== null ? { durationMs } : {}), + ...(outcome === "failed" ? { errorKind: "tool_failed" } : {}), + }; +} + function emitTelemetry(options, message, outcome = null) { if (typeof options.onTelemetry !== "function" || !message?.method) return; const event = { @@ -61,6 +82,7 @@ function debugTraceEvent(message, outcome = null) { type: method === "item/started" ? "item_started" : "item_completed", itemId: message?.params?.item?.id || message?.params?.itemId || null, itemType: message?.params?.item?.type || null, + ...(method === "item/completed" ? completionDiagnostics(message) : {}), }; } diff --git a/scripts/lib/create-pr-publication-state.mjs b/scripts/lib/create-pr-publication-state.mjs index ed9cbaab..2a89eb4e 100644 --- a/scripts/lib/create-pr-publication-state.mjs +++ b/scripts/lib/create-pr-publication-state.mjs @@ -3,7 +3,10 @@ import { mutationReceiptCompleted, } from "./mutation-document-execution.mjs"; -const LOCAL_WORKFLOW = "create-pr-from-local-work"; +const CREATE_PR_PUBLICATION_WORKFLOWS = new Set([ + "create-pr-for-issue", + "create-pr-from-local-work", +]); const ACTIONS = Object.freeze(["push_code", "create_pr"]); function plainObject(value) { @@ -83,7 +86,7 @@ export function createPrPublicationPlanLock(plan, { headSha = null, now = Date.n } export function assertCreatePrPublicationRequest(snapshot, request) { - if (String(snapshot?.workflow || "") !== LOCAL_WORKFLOW) return null; + if (!CREATE_PR_PUBLICATION_WORKFLOWS.has(String(snapshot?.workflow || ""))) return null; const action = String(request?.action || ""); if (!ACTIONS.includes(action)) return null; const lock = normalizeCreatePrPublicationPlanLock(snapshot?.publicationPlan); @@ -99,7 +102,7 @@ export function assertCreatePrPublicationRequest(snapshot, request) { } export function reconcileCreatePrPublicationReceipts(snapshot, output, { now = Date.now } = {}) { - if (String(snapshot?.workflow || "") !== LOCAL_WORKFLOW) { + if (!CREATE_PR_PUBLICATION_WORKFLOWS.has(String(snapshot?.workflow || ""))) { return { changed: false, receipts: normalizeCreatePrPublicationReceipts(snapshot?.publicationReceipts) }; } const lock = normalizeCreatePrPublicationPlanLock(snapshot?.publicationPlan); @@ -128,7 +131,7 @@ export function reconcileCreatePrPublicationReceipts(snapshot, output, { now = D } export function assertCreatePrPublicationComplete(snapshot) { - if (String(snapshot?.workflow || "") !== LOCAL_WORKFLOW) return null; + if (!CREATE_PR_PUBLICATION_WORKFLOWS.has(String(snapshot?.workflow || ""))) return null; const lock = normalizeCreatePrPublicationPlanLock(snapshot?.publicationPlan); if (!lock) throw new Error("create_pr_publication_plan_missing"); const receipts = normalizeCreatePrPublicationReceipts(snapshot?.publicationReceipts); diff --git a/scripts/lib/cursor-debug-trace.mjs b/scripts/lib/cursor-debug-trace.mjs index 6057b46e..91e0927a 100644 --- a/scripts/lib/cursor-debug-trace.mjs +++ b/scripts/lib/cursor-debug-trace.mjs @@ -2,6 +2,21 @@ function text(value) { return typeof value === "string" && value.length > 0 ? value : null; } +function durationMs(event) { + const value = Number.isFinite(event?.durationMs) ? event.durationMs : event?.duration_ms; + return Number.isFinite(value) && value >= 0 ? Math.round(value) : null; +} + +function terminalDiagnostics(event, status) { + const outcome = ["completed", "complete"].includes(status) ? "succeeded" : status; + const duration = durationMs(event); + return { + outcome, + ...(duration !== null ? { durationMs: duration } : {}), + ...(status === "failed" ? { errorKind: "tool_failed" } : {}), + }; +} + function commonCursorIds(event) { const threadId = text(event?.conversation_id) || text(event?.session_id); const turnId = text(event?.generation_id); @@ -78,6 +93,7 @@ export function normalizeCursorCliDebugTraceEvent(event) { type: "item_completed", ...ids, ...toolIdentity(event), + ...terminalDiagnostics(event, subtype), }; } @@ -116,11 +132,13 @@ export function normalizeCursorHookDebugTraceEvent(event) { } if (hook === "postToolUse" || hook === "postToolUseFailure") { + const status = hook === "postToolUseFailure" ? "failed" : "completed"; return { provider: "cursor", type: "item_completed", ...ids, ...toolIdentity(event), + ...terminalDiagnostics(event, status), }; } diff --git a/scripts/lib/delivery-workflow-controller.mjs b/scripts/lib/delivery-workflow-controller.mjs index 62e04cdc..294bb29f 100644 --- a/scripts/lib/delivery-workflow-controller.mjs +++ b/scripts/lib/delivery-workflow-controller.mjs @@ -151,6 +151,36 @@ function normalizeHygienePasses(value) { }; } +function normalizePhaseReceipts(value, graph, completedPhases) { + if (!Array.isArray(value)) return []; + const completed = new Set(completedPhases); + const receipts = []; + const seen = new Set(); + for (const entry of value) { + const phase = String(entry?.phase || ""); + if (!phase || seen.has(phase) || !completed.has(phase) || !Object.hasOwn(graph, phase)) continue; + if (entry?.authority !== "controller-transition") continue; + receipts.push({ + phase, + authority: "controller-transition", + stateGeneration: nonNegativeInteger(entry?.stateGeneration), + baseSha: entry?.baseSha ? String(entry.baseSha) : null, + headSha: entry?.headSha ? String(entry.headSha) : null, + issue: entry?.issue ?? null, + pr: entry?.pr ?? null, + completedAt: Number.isFinite(entry?.completedAt) ? entry.completedAt : null, + }); + seen.add(phase); + } + return receipts; +} + +function nextActionForPhase(phase) { + return phase === "DONE" + ? { action: "stop", phase: "DONE", authority: "controller-checkpoint" } + : { action: "execute_phase", phase, authority: "controller-checkpoint" }; +} + function sameIdentity(left, right) { return String(left || "").toLowerCase() === String(right || "").toLowerCase(); } @@ -234,6 +264,7 @@ export function createDeliveryWorkflowController(options = {}) { ); let stateGeneration = nonNegativeInteger(snapshot?.stateGeneration); const completedPhases = [...(snapshot?.completedPhases || [])].map(String); + const phaseReceipts = normalizePhaseReceipts(snapshot?.phaseReceipts, graph, completedPhases); const blockers = new Set((snapshot?.blockers || []).map(String)); const attempts = { workflowSteps: nonNegativeInteger(snapshot?.attempts?.workflowSteps), @@ -269,8 +300,10 @@ export function createDeliveryWorkflowController(options = {}) { publicationPlan: publicationPlan ? structuredClone(publicationPlan) : null, publicationReceipts: structuredClone(publicationReceipts), phase, + nextAction: nextActionForPhase(phase), graph, completedPhases: [...completedPhases], + phaseReceipts: phaseReceipts.map((entry) => structuredClone(entry)), blockers: [...blockers].sort(), stateGeneration, attempts: { ...attempts }, @@ -306,10 +339,22 @@ export function createDeliveryWorkflowController(options = {}) { assertPreOpenHygieneEvidence(snapshotState()); assertPreOpenPublicationEvidence(snapshotState()); } - if (phase === "OPEN_PR" && workflow === "create-pr-from-local-work") { + if (phase === "OPEN_PR" && PRE_OPEN_WORKFLOWS.has(workflow)) { assertCreatePrPublicationComplete(snapshotState()); } - if (!completedPhases.includes(phase)) completedPhases.push(phase); + if (!completedPhases.includes(phase)) { + completedPhases.push(phase); + phaseReceipts.push({ + phase, + authority: "controller-transition", + stateGeneration, + baseSha: baseSha ? String(baseSha) : null, + headSha: headSha ? String(headSha) : null, + issue, + pr, + completedAt: now(), + }); + } phase = target; attempts.noProgressSteps = 0; attempts.phaseRetries = 0; diff --git a/scripts/lib/delivery-workflow-profiles.mjs b/scripts/lib/delivery-workflow-profiles.mjs index d5d3a9b8..45d90af2 100644 --- a/scripts/lib/delivery-workflow-profiles.mjs +++ b/scripts/lib/delivery-workflow-profiles.mjs @@ -38,7 +38,7 @@ const CREATE_PR_GRAPH = Object.freeze({ EXISTING_PR: ["REVIEW_FEEDBACK", "LOCAL_VERIFY"], LOCAL_VERIFY: ["PREOPEN_GATE", "REVIEW_FEEDBACK"], PREOPEN_GATE: ["OPEN_PR", "REVIEW_FEEDBACK"], - OPEN_PR: ["REVIEW_FEEDBACK"], + OPEN_PR: ["REVIEW_FEEDBACK", "DONE"], REVIEW_FEEDBACK: ["CI", "FINAL_GATE"], CI: ["FINAL_GATE"], FINAL_GATE: ["DONE"], diff --git a/scripts/lib/github-retry.mjs b/scripts/lib/github-retry.mjs index f1d4b4a9..c6e312ef 100644 --- a/scripts/lib/github-retry.mjs +++ b/scripts/lib/github-retry.mjs @@ -104,12 +104,15 @@ function fallbackDelayMs(attempt, random) { function githubCommandOptions(command, options) { if (command !== "gh") return options; + const env = { ...(options.env ?? process.env) }; + delete env.CLICOLOR_FORCE; + delete env.FORCE_COLOR; + delete env.GH_FORCE_TTY; + env.CLICOLOR = "0"; + env.NO_COLOR = "1"; return { ...options, - env: { - ...(options.env ?? process.env), - NO_COLOR: "1", - }, + env, }; } diff --git a/scripts/lib/grok-debug-trace.mjs b/scripts/lib/grok-debug-trace.mjs index 84a34bc7..c4ceabe3 100644 --- a/scripts/lib/grok-debug-trace.mjs +++ b/scripts/lib/grok-debug-trace.mjs @@ -2,6 +2,21 @@ function text(value) { return typeof value === "string" && value.length > 0 ? value : null; } +function durationMs(event) { + const value = Number.isFinite(event?.durationMs) ? event.durationMs : event?.duration_ms; + return Number.isFinite(value) && value >= 0 ? Math.round(value) : null; +} + +function terminalDiagnostics(event, status) { + const outcome = status === "completed" ? "succeeded" : status; + const duration = durationMs(event); + return { + outcome, + ...(duration !== null ? { durationMs: duration } : {}), + ...(status === "failed" ? { errorKind: "tool_failed" } : {}), + }; +} + function hasOwnedOutputFormat(args) { return args.some((arg) => arg === "--output-format" || String(arg).startsWith("--output-format=")); } @@ -56,6 +71,7 @@ export function normalizeGrokDebugTraceEvent(event) { type: "item_completed", ...(text(event.toolCallId) ? { itemId: event.toolCallId } : {}), ...(text(event.toolName) ? { itemType: event.toolName } : {}), + ...terminalDiagnostics(event, status), }; } diff --git a/scripts/lib/mutation-checkpoint.mjs b/scripts/lib/mutation-checkpoint.mjs index a9c29e1c..0ba0b181 100644 --- a/scripts/lib/mutation-checkpoint.mjs +++ b/scripts/lib/mutation-checkpoint.mjs @@ -33,6 +33,10 @@ const PRE_OPEN_WORKFLOWS = new Set([ "create-pr-for-issue", "create-pr-from-local-work", ]); +const CREATE_PR_PUBLICATION_PLAN_WORKFLOWS = new Set([ + "create-pr-for-issue", + "create-pr-from-local-work", +]); const PRE_OPEN_PUBLICATION_ACTIONS = new Set(["push_code", "create_pr"]); function routedWorkflowIntentSlot(snapshot, request) { @@ -119,7 +123,7 @@ function samePublicationLock(left, right) { export function lockCreatePrPublicationPlanCheckpoint({ path, plan } = {}) { if (!path) throw new Error("checkpoint path is required"); const snapshot = readDeliveryWorkflowCheckpoint(path); - if (String(snapshot.workflow || "") !== "create-pr-from-local-work") { + if (!CREATE_PR_PUBLICATION_PLAN_WORKFLOWS.has(String(snapshot.workflow || ""))) { throw new Error("create_pr_publication_plan_not_required"); } if (!["PREOPEN_GATE", "OPEN_PR"].includes(String(snapshot.phase || ""))) { diff --git a/scripts/lib/pre-open-evidence.mjs b/scripts/lib/pre-open-evidence.mjs index ba8ccbbb..511110f8 100644 --- a/scripts/lib/pre-open-evidence.mjs +++ b/scripts/lib/pre-open-evidence.mjs @@ -178,55 +178,47 @@ export function validatePreOpenEvidence(input) { }; } -function aggregateAxis(review, axis, expectedHead, requiredIds) { - const value = review?.[axis]; - if (!isRecord(value)) throw new Error(`pre_open_review_${axis}_missing`); - if (value.status !== "clean") throw new Error(`pre_open_review_${axis}_not_clean`); - const method = typeof value.method === "string" ? value.method.trim() : ""; - if (!method || method.length > MAX_METHOD_LENGTH) { - throw new Error(`pre_open_review_${axis}_method_invalid`); +function requiredReviewRow(reviewRows, axis, id, requirement, expectedHead) { + const row = reviewRows?.[id]; + if (!isRecord(row)) throw new Error(`pre_open_review_${axis}_${id}_missing`); + const errors = []; + const normalized = normalizeStructuredReviewEvidence(row, `${axis}:${id}`, errors); + if (!normalized || errors.length) { + throw new Error(`pre_open_review_${axis}_${id}_invalid:${errors.join(";")}`); } - const coveredIds = Array.isArray(value.coveredIds) - ? [...new Set(value.coveredIds.map(String).filter(Boolean))] - : []; - const coveredSet = new Set(coveredIds); - if (requiredIds.some((id) => !coveredSet.has(id))) { - throw new Error(`pre_open_review_${axis}_ids_incomplete`); + if (normalized.headSha !== expectedHead) { + throw new Error(`pre_open_review_${axis}_${id}_head_mismatch`); } - const errors = []; - const reviewedFiles = normalizeReviewedFiles(value.reviewedFiles, `aggregate:${axis}`, errors); - if (errors.length) throw new Error(`pre_open_review_${axis}_files_invalid`); - return { - headSha: expectedHead, - method, - coveredIds, - reviewedFiles, - reviewedSet: new Set(reviewedFiles), - }; -} - -function requirementFiles(requirement, axisReview, code) { - const files = Array.isArray(requirement?.reviewedFiles) + const requiredFiles = Array.isArray(requirement?.reviewedFiles) ? [...new Set(requirement.reviewedFiles.map(String).filter(Boolean))] : []; - const scoped = files.length ? files : axisReview.reviewedFiles; - if (scoped.some((file) => !axisReview.reviewedSet.has(file))) throw new Error(code); - return scoped; + const reviewedSet = new Set(normalized.reviewedFiles); + if (requiredFiles.some((file) => !reviewedSet.has(file))) { + throw new Error(`pre_open_review_${axis}_${id}_scope_mismatch`); + } + return normalized; } /** - * Expand one candidate-wide bug review and one candidate-wide security review - * into the exact schema-v2 rows requested by a compact pre-open summary. + * Assemble exact schema-v2 evidence requested by a compact pre-open summary. * - * This is an evidence-shape reducer, not a coverage reducer: every required row - * remains present, retains its semantic id and scoped files, and can be emitted - * only when the corresponding axis review explicitly covered both. + * Legacy schema-v1 aggregate declarations such as one candidate-wide + * `bug: clean` / `security: clean` assertion are deliberately non-authoritative: + * they cannot mint many semantic completion rows. Every required lens and + * surface must be represented by its own head-bound structured review record. + * The historical function name is retained for API compatibility. */ export function expandAggregatePreOpenEvidence(summary, review) { if (!isRecord(summary) || summary.kind !== "github-delivery/pre-open-gate-summary") { throw new Error("pre_open_review_summary_invalid"); } - if (!isRecord(review) || review.schemaVersion !== 1 || review.kind !== "github-delivery/pre-open-review-result") { + if (!isRecord(review) || review.kind !== "github-delivery/pre-open-review-result") { + throw new Error("pre_open_review_result_invalid"); + } + if (review.schemaVersion !== PRE_OPEN_EVIDENCE_SCHEMA_VERSION) { + if (review.schemaVersion === LEGACY_PRE_OPEN_EVIDENCE_SCHEMA_VERSION) { + throw new Error("pre_open_review_aggregate_not_authoritative"); + } throw new Error("pre_open_review_result_invalid"); } const requirements = summary.evidenceRequirements; @@ -243,25 +235,15 @@ export function expandAggregatePreOpenEvidence(summary, review) { const lensRequirements = isRecord(requirements.lenses) ? requirements.lenses : {}; const surfaceRequirements = isRecord(requirements.surfaces) ? requirements.surfaces : {}; - const bug = aggregateAxis(review, "bug", expectedHead, Object.keys(lensRequirements)); - const security = aggregateAxis(review, "security", expectedHead, Object.keys(surfaceRequirements)); + const reviewLenses = isRecord(review.lenses) ? review.lenses : {}; + const reviewSurfaces = isRecord(review.surfaces) ? review.surfaces : {}; const lenses = {}; for (const [id, requirement] of Object.entries(lensRequirements)) { - lenses[id] = { - status: "done", - headSha: expectedHead, - method: bug.method, - reviewedFiles: requirementFiles(requirement, bug, "pre_open_review_bug_scope_incomplete"), - }; + lenses[id] = requiredReviewRow(reviewLenses, "lens", id, requirement, expectedHead); } const surfaces = {}; for (const [id, requirement] of Object.entries(surfaceRequirements)) { - surfaces[id] = { - status: "done", - headSha: expectedHead, - method: security.method, - reviewedFiles: requirementFiles(requirement, security, "pre_open_review_security_scope_incomplete"), - }; + surfaces[id] = requiredReviewRow(reviewSurfaces, "surface", id, requirement, expectedHead); } const reviewProbes = isRecord(review.probes) ? review.probes : {}; diff --git a/scripts/lib/pre-open-hygiene-evidence.mjs b/scripts/lib/pre-open-hygiene-evidence.mjs index ff739b2a..d07ef87e 100644 --- a/scripts/lib/pre-open-hygiene-evidence.mjs +++ b/scripts/lib/pre-open-hygiene-evidence.mjs @@ -21,6 +21,7 @@ function validateNoComments(entry) { if (method !== "opt-out" || !String(entry?.reason || "").trim()) { throw new Error("pre_open_hygiene_no_comments_skip_invalid"); } + throw new Error("pre_open_hygiene_skip_requires_trusted_user_intent"); } else { if (entry?.scopeKind !== "diff-added-lines") { throw new Error("pre_open_hygiene_no_comments_scope_invalid"); @@ -35,13 +36,9 @@ function validateNoComments(entry) { return { outcome, method, - ...(outcome === "skipped" - ? { reason: String(entry.reason).trim() } - : { - scopeKind: "diff-added-lines", - resultValid: true, - workspaceVerified: true, - }), + scopeKind: "diff-added-lines", + resultValid: true, + workspaceVerified: true, }; } @@ -53,15 +50,15 @@ function validateSimplify(entry) { if (method !== "opt-out" || !String(entry?.reason || "").trim()) { throw new Error("pre_open_hygiene_simplify_skip_invalid"); } - } else if (entry?.validationPassed !== true) { + throw new Error("pre_open_hygiene_skip_requires_trusted_user_intent"); + } + if (entry?.validationPassed !== true) { throw new Error("pre_open_hygiene_simplify_validation_required"); } return { outcome, method, - ...(outcome === "skipped" - ? { reason: String(entry.reason).trim() } - : { validationPassed: true }), + validationPassed: true, }; } @@ -96,6 +93,10 @@ export function validatePreOpenHygieneEvidence(value, { headSha = null } = {}) { * A reviewer DELETE cannot be converted into a clean receipt: the parent must * apply the accepted change, revalidate the candidate, and rerun hygiene on the * new head. This keeps the builder from turning a finding into completion. + * + * Skipped passes are deliberately not accepted at this boundary. Until a host or + * controller can supply provenance for the user's opt-out, caller-authored text + * is not authoritative enough to bypass a publication hygiene pass. */ export function buildPreOpenHygieneEvidence({ scope, diff --git a/scripts/lib/watchdog-progress-classifier.mjs b/scripts/lib/watchdog-progress-classifier.mjs index de8d0b2e..5e3fbc14 100644 --- a/scripts/lib/watchdog-progress-classifier.mjs +++ b/scripts/lib/watchdog-progress-classifier.mjs @@ -28,6 +28,66 @@ function hasOutputRedirection(value) { return />{1,2}/.test(value); } +function isGraphQlWordChar(char) { + if (!char) return false; + const code = char.charCodeAt(0); + return ( + (code >= 48 && code <= 57) || + (code >= 97 && code <= 122) || + char === "_" + ); +} + +function isShellWhitespace(char) { + return ( + char === " " || + char === "\t" || + char === "\r" || + char === "\n" || + char === "\f" || + char === "\v" + ); +} + +function skipShellWhitespace(value, index) { + let cursor = index; + while (cursor < value.length && isShellWhitespace(value[cursor])) cursor += 1; + return cursor; +} + +function hasGraphQlQueryField(value) { + let searchFrom = 0; + while (searchFrom < value.length) { + const index = value.indexOf("query", searchFrom); + if (index < 0) return false; + + const before = index > 0 ? value[index - 1] : ""; + const after = value[index + 5] || ""; + if (isGraphQlWordChar(before) || isGraphQlWordChar(after)) { + searchFrom = index + 5; + continue; + } + + let cursor = skipShellWhitespace(value, index + 5); + if (value[cursor] !== "=") { + searchFrom = index + 5; + continue; + } + + cursor = skipShellWhitespace(value, cursor + 1); + if (value[cursor] === "'" || value[cursor] === '"') cursor += 1; + cursor = skipShellWhitespace(value, cursor); + + if (value.slice(cursor, cursor + 5) === "query") { + const queryAfter = value[cursor + 5] || ""; + if (!isGraphQlWordChar(queryAfter)) return true; + } + + searchFrom = index + 5; + } + return false; +} + function classifyGhApi(value) { if (!/\bgh(?:\.exe)?\s+api\b/i.test(value)) return null; const explicitGet = /(?:--method(?:=|\s+)get\b|-x\s*get\b)/i.test(value); @@ -35,10 +95,11 @@ function classifyGhApi(value) { if (/\bgh(?:\.exe)?\s+api\s+graphql\b/i.test(value)) { if (/\bmutation\b/i.test(value)) return { kind: "state-change" }; - if (explicitMutationMethod && !/\bquery\s*=\s*['"]?\s*query\b/i.test(value)) { + const hasQueryField = hasGraphQlQueryField(value); + if (explicitMutationMethod && !hasQueryField) { return { kind: "neutral" }; } - if (explicitGet || /\bquery\s*=\s*['"]?\s*query\b/i.test(value)) { + if (explicitGet || hasQueryField) { return { kind: "evidence", volatility: "volatile" }; } return { kind: "neutral" }; diff --git a/scripts/lib/workflow-bootstrap.mjs b/scripts/lib/workflow-bootstrap.mjs index 02901aa2..a87d7760 100644 --- a/scripts/lib/workflow-bootstrap.mjs +++ b/scripts/lib/workflow-bootstrap.mjs @@ -6,6 +6,7 @@ import { dirname, join, resolve } from "node:path"; import { createDeliveryWorkflowController, readDeliveryWorkflowCheckpoint, + writeDeliveryWorkflowCheckpoint, } from "./delivery-workflow-controller.mjs"; import { resolveDeliveryWorkflowProfile } from "./delivery-workflow-profiles.mjs"; @@ -94,19 +95,25 @@ export function bootstrapLocalPrWorkflow({ repo, headSha, baseSha = null, stateD reused = true; } - const snapshot = readDeliveryWorkflowCheckpoint(checkpointPath); - assertCheckpointIdentity(snapshot, { + const storedSnapshot = readDeliveryWorkflowCheckpoint(checkpointPath); + assertCheckpointIdentity(storedSnapshot, { repo: normalizedRepo, headSha: normalizedHead, }); if ( baseSha && - snapshot.baseSha && - String(snapshot.baseSha).toLowerCase() !== String(baseSha).toLowerCase() + storedSnapshot.baseSha && + String(storedSnapshot.baseSha).toLowerCase() !== String(baseSha).toLowerCase() ) { throw new Error("workflow_bootstrap_checkpoint_base_mismatch"); } + const snapshot = createDeliveryWorkflowController({ + snapshot: storedSnapshot, + graph: profile.graph, + }).snapshot(); + if (reused) writeDeliveryWorkflowCheckpoint(checkpointPath, snapshot); + return { checkpointPath, reused, diff --git a/scripts/lib/workflow-execution-contract.mjs b/scripts/lib/workflow-execution-contract.mjs index 3379c16b..4ee318d1 100644 --- a/scripts/lib/workflow-execution-contract.mjs +++ b/scripts/lib/workflow-execution-contract.mjs @@ -11,6 +11,14 @@ const NORMAL_HELPERS = Object.freeze({ shipGate: "scripts/ship-gate.mjs", }); +const CONTROLLER_STATE_CONTRACT = Object.freeze({ + source: "controller-checkpoint", + nextAction: "authoritative", + completedPhaseReceipts: "authoritative", + reasoningClaims: "non-authoritative", + externalStateClaims: "structured-evidence-only", +}); + const DECLARED_ACTIONS = Object.freeze({ "create-pr-for-issue": Object.freeze([ "assign_issue", @@ -30,7 +38,30 @@ const DECLARED_ACTIONS = Object.freeze({ ]), }); +const CREATE_PR_PUBLICATION_PLAN = Object.freeze({ + initialCreate: "draft-only", + planner: "scripts/create-pr-publication-plan.mjs", + mutationEntrypoint: "scripts/github-mutate.mjs", + directWriteFallback: "forbidden", + completion: "broker-receipts", +}); + const WORKFLOW_PLANS = Object.freeze({ + "create-pr-for-issue": Object.freeze({ + decisionAuthority: "workflow-packet+controller-checkpoint", + sourceDiscovery: "diagnostic-only-on-helper-failure", + instructionConflict: "fail-closed", + preOpen: Object.freeze({ + decisionField: "decision", + readyValue: "ready", + evidenceAssembler: "scripts/pre-open-review-evidence.mjs", + }), + publication: Object.freeze({ + ...CREATE_PR_PUBLICATION_PLAN, + openOnlyTerminalPhase: "OPEN_PR", + openOnlyCondition: "explicit-user-stop-after-pr-creation", + }), + }), "create-pr-from-local-work": Object.freeze({ decisionAuthority: "workflow-packet+controller-checkpoint", sourceDiscovery: "diagnostic-only-on-helper-failure", @@ -49,12 +80,8 @@ const WORKFLOW_PLANS = Object.freeze({ orchestrator: "scripts/create-pr-hygiene.mjs", }), publication: Object.freeze({ - initialCreate: "draft-only", - planner: "scripts/create-pr-publication-plan.mjs", - mutationEntrypoint: "scripts/github-mutate.mjs", - directWriteFallback: "forbidden", + ...CREATE_PR_PUBLICATION_PLAN, directWriteGuard: "runtime-after-workflow-selection", - completion: "broker-receipts", }), }), }); @@ -65,9 +92,10 @@ export function executionContractForWorkflow(workflow) { helpers: { ...NORMAL_HELPERS }, declaredActions: [...(DECLARED_ACTIONS[workflow] || [])], sourceDiscovery: "diagnostic-only", + controllerState: { ...CONTROLLER_STATE_CONTRACT }, ...(workflowPlan ? { workflowPlan: structuredClone(workflowPlan) } : {}), normalOperation: - "Use this packet and its declared helpers/actions for normal workflow execution. Do not re-decide a locked route, publication path, or initial PR state after packet/controller resolution. Read or grep github-delivery implementation source only after a concrete internal contract/helper failure requires diagnostic escalation. If a higher-priority instruction genuinely conflicts with the locked safe write path, fail closed once; do not fall back to a different GitHub write path.", + "Use this packet and its controller checkpoint for normal workflow execution. Treat checkpoint nextAction and completed phase receipts as authoritative progress state; reasoning prose, remembered SHAs/PRs/checks, and unstamped claims are non-authoritative. Do not re-decide a locked route, publication path, initial PR state, or already completed phase. Read or grep github-delivery implementation source only after a concrete internal contract/helper failure requires diagnostic escalation. If a higher-priority instruction genuinely conflicts with the locked safe write path, fail closed once; do not fall back to a different GitHub write path.", }; } diff --git a/tests/unit/agent-debug-trace-diagnostics.test.mjs b/tests/unit/agent-debug-trace-diagnostics.test.mjs new file mode 100644 index 00000000..36ee9744 --- /dev/null +++ b/tests/unit/agent-debug-trace-diagnostics.test.mjs @@ -0,0 +1,184 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { createAgentDebugTraceRecorder } from "../../scripts/lib/agent-debug-trace.mjs"; +import { createAppServerWatchdogRouter } from "../../scripts/lib/codex-app-server-watchdog-proxy.mjs"; +import { + normalizeGrokDebugTraceEvent, +} from "../../scripts/lib/grok-debug-trace.mjs"; +import { + normalizeCursorCliDebugTraceEvent, + normalizeCursorHookDebugTraceEvent, +} from "../../scripts/lib/cursor-debug-trace.mjs"; + +function traceEvents(path) { + return readFileSync(path, "utf8") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)); +} + +test("stream recorder coalesces adjacent reasoning deltas with the same identity", () => { + const stateDir = mkdtempSync(join(tmpdir(), "gd-trace-coalesce-")); + try { + const recorder = createAgentDebugTraceRecorder({ + provider: "grok", + env: { GITHUB_DELIVERY_DEBUG_TRACE: "1" }, + stateDir, + now: () => new Date("2026-09-09T05:00:00.000Z"), + pid: 437, + }); + recorder.record({ type: "reasoning_summary_delta", threadId: "thread-1", turnId: "turn-1", itemId: "reasoning-1", text: "Inspect " }); + recorder.record({ type: "reasoning_summary_delta", threadId: "thread-1", turnId: "turn-1", itemId: "reasoning-1", text: "the controller " }); + recorder.record({ type: "reasoning_summary_delta", threadId: "thread-1", turnId: "turn-1", itemId: "reasoning-1", text: "once." }); + recorder.record({ type: "item_started", threadId: "thread-1", turnId: "turn-1", itemId: "call-1", itemType: "read_file" }); + recorder.close(); + + const events = traceEvents(recorder.path); + assert.equal(events.length, 2); + assert.equal(events[0].type, "reasoning_summary_delta"); + assert.equal(events[0].text, "Inspect the controller once."); + assert.equal(events[0].deltaCount, 3); + assert.equal(events[1].type, "item_started"); + } finally { + rmSync(stateDir, { recursive: true, force: true }); + } +}); + +test("reasoning coalescing stops at an identity boundary", () => { + const stateDir = mkdtempSync(join(tmpdir(), "gd-trace-boundary-")); + try { + const recorder = createAgentDebugTraceRecorder({ + provider: "cursor", + env: { GITHUB_DELIVERY_DEBUG_TRACE: "1" }, + stateDir, + now: () => new Date("2026-09-09T05:00:00.000Z"), + pid: 438, + }); + recorder.record({ type: "reasoning_summary_delta", turnId: "a", text: "first" }); + recorder.record({ type: "reasoning_summary_delta", turnId: "b", text: "second" }); + recorder.close(); + + const events = traceEvents(recorder.path); + assert.deepEqual(events.map((event) => event.text), ["first", "second"]); + assert.deepEqual(events.map((event) => event.deltaCount), [1, 1]); + } finally { + rmSync(stateDir, { recursive: true, force: true }); + } +}); + +test("Grok terminal tool updates expose only safe outcome duration and error metadata", () => { + const succeeded = normalizeGrokDebugTraceEvent({ + type: "tool_call_update", + toolCallId: "call-1", + toolName: "shell", + status: "completed", + duration_ms: 125, + rawOutput: { secret: "do-not-record" }, + }); + assert.equal(succeeded.outcome, "succeeded"); + assert.equal(succeeded.durationMs, 125); + assert.equal(succeeded.errorKind, undefined); + + const failed = normalizeGrokDebugTraceEvent({ + type: "tool_call_update", + toolCallId: "call-2", + toolName: "shell", + status: "failed", + durationMs: 330, + error: { message: "token=private", stack: "C:/private/path" }, + }); + assert.equal(failed.outcome, "failed"); + assert.equal(failed.durationMs, 330); + assert.equal(failed.errorKind, "tool_failed"); + assert.doesNotMatch(JSON.stringify(failed), /token=private|C:\/private|rawOutput|stack/); +}); + +test("Cursor terminal events expose safe outcome duration and failure class", () => { + const completed = normalizeCursorCliDebugTraceEvent({ + type: "tool_call", + subtype: "completed", + call_id: "call-1", + tool_name: "Shell", + duration_ms: 45, + result: "private output", + }); + assert.equal(completed.outcome, "succeeded"); + assert.equal(completed.durationMs, 45); + assert.doesNotMatch(JSON.stringify(completed), /private output/); + + const failed = normalizeCursorHookDebugTraceEvent({ + hook_event_name: "postToolUseFailure", + tool_call_id: "call-2", + tool_name: "Shell", + duration_ms: 90, + error: "private failure text", + }); + assert.equal(failed.outcome, "failed"); + assert.equal(failed.durationMs, 90); + assert.equal(failed.errorKind, "tool_failed"); + assert.doesNotMatch(JSON.stringify(failed), /private failure text/); +}); + +test("Codex item completion exposes bounded outcome and duration without payloads", () => { + const trace = []; + const router = createAppServerWatchdogRouter({ onDebugTrace: (event) => trace.push(event) }); + router.onServerMessage({ + method: "item/completed", + params: { + threadId: "thread-1", + turnId: "turn-1", + item: { + id: "call-1", + type: "commandExecution", + status: "failed", + durationMs: 77, + error: { message: "private token", stack: "C:/private/path" }, + output: "private output", + }, + }, + }); + + assert.equal(trace.length, 1); + assert.equal(trace[0].type, "item_completed"); + assert.equal(trace[0].outcome, "failed"); + assert.equal(trace[0].durationMs, 77); + assert.equal(trace[0].errorKind, "tool_failed"); + assert.doesNotMatch(JSON.stringify(trace[0]), /private token|private output|C:\/private/); +}); + +test("recorder persists only allowlisted completion diagnostics", () => { + const stateDir = mkdtempSync(join(tmpdir(), "gd-trace-sanitize-")); + try { + const recorder = createAgentDebugTraceRecorder({ + provider: "grok", + env: { GITHUB_DELIVERY_DEBUG_TRACE: "1" }, + stateDir, + now: () => new Date("2026-09-09T05:00:00.000Z"), + pid: 439, + }); + recorder.record({ + type: "item_completed", + itemId: "call-1", + itemType: "shell", + outcome: "failed", + durationMs: 101, + errorKind: "tool_failed", + errorMessage: "secret error body", + rawOutput: "secret output", + }); + recorder.close(); + + const [event] = traceEvents(recorder.path); + assert.equal(event.outcome, "failed"); + assert.equal(event.durationMs, 101); + assert.equal(event.errorKind, "tool_failed"); + assert.doesNotMatch(JSON.stringify(event), /secret error body|secret output|errorMessage|rawOutput/); + } finally { + rmSync(stateDir, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/create-pr-hygiene-cli.test.mjs b/tests/unit/create-pr-hygiene-cli.test.mjs index b6ce5b44..5c44ec7c 100644 --- a/tests/unit/create-pr-hygiene-cli.test.mjs +++ b/tests/unit/create-pr-hygiene-cli.test.mjs @@ -107,7 +107,7 @@ test("create-pr hygiene CLI prepares guarded diff scope and finalizes current-he } }); -test("create-pr hygiene CLI records an explicit no-comments opt-out without a reviewer snapshot", () => { +test("caller-authored no-comments opt-out text cannot mint routed publication evidence", () => { const root = mkdtempSync(join(tmpdir(), "github-delivery-hygiene-skip-")); const simplifyPath = join(root, "simplify.json"); const outputPath = join(root, "hygiene.json"); @@ -118,18 +118,17 @@ test("create-pr hygiene CLI records an explicit no-comments opt-out without a re method: "opt-out", reason: "without simplify", }, null, 2)}\n`, "utf8"); - run(root, process.execPath, [ + const result = spawnSync(process.execPath, [ CLI, "skip-no-comments", "--head", head, "--reason", "keep source comments", "--simplify", simplifyPath, "--output", outputPath, - ]); - const evidence = JSON.parse(readFileSync(outputPath, "utf8")); - assert.equal(evidence.passes["no-comments"].outcome, "skipped"); - assert.equal(evidence.passes["no-comments"].reason, "keep source comments"); - assert.equal(evidence.passes.simplify.outcome, "skipped"); + ], { cwd: root, encoding: "utf8" }); + assert.notEqual(result.status, 0); + assert.match(`${result.stderr}\n${result.stdout}`, /pre_open_hygiene_skip_requires_trusted_user_intent/); + assert.equal(existsSync(outputPath), false); } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/tests/unit/github-retry.test.mjs b/tests/unit/github-retry.test.mjs index ed31c753..142e7e08 100644 --- a/tests/unit/github-retry.test.mjs +++ b/tests/unit/github-retry.test.mjs @@ -28,13 +28,20 @@ test("classifies GitHub retry-after and reset guidance", () => { ); }); -test("machine-readable GitHub commands disable ANSI color", () => { +test("machine-readable GitHub commands disable ANSI color even when the parent forces color", () => { let observedOptions; const result = runGitHubCommandWithRetry( "gh", ["repo", "view", "acme/widgets", "--json", "url,sshUrl"], { - options: { env: { TEST_MARKER: "kept" } }, + options: { + env: { + TEST_MARKER: "kept", + CLICOLOR_FORCE: "1", + FORCE_COLOR: "3", + GH_FORCE_TTY: "120", + }, + }, runner(_command, _args, options) { observedOptions = options; return { status: 0, stdout: "{}", stderr: "" }; @@ -45,6 +52,10 @@ test("machine-readable GitHub commands disable ANSI color", () => { assert.equal(result.status, 0); assert.equal(observedOptions.env.TEST_MARKER, "kept"); assert.equal(observedOptions.env.NO_COLOR, "1"); + assert.equal(observedOptions.env.CLICOLOR, "0"); + assert.equal(Object.hasOwn(observedOptions.env, "CLICOLOR_FORCE"), false); + assert.equal(Object.hasOwn(observedOptions.env, "FORCE_COLOR"), false); + assert.equal(Object.hasOwn(observedOptions.env, "GH_FORCE_TTY"), false); }); test("does not shorten a server-directed wait to the local retry budget", () => { diff --git a/tests/unit/issue-pr-publication-boundary.test.mjs b/tests/unit/issue-pr-publication-boundary.test.mjs new file mode 100644 index 00000000..2d654bd6 --- /dev/null +++ b/tests/unit/issue-pr-publication-boundary.test.mjs @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { buildCreatePrPublicationPlan } from "../../scripts/lib/create-pr-publication-plan.mjs"; +import { createPrPublicationPlanLock } from "../../scripts/lib/create-pr-publication-state.mjs"; +import { + createDeliveryWorkflowController, + readDeliveryWorkflowCheckpoint, + writeDeliveryWorkflowCheckpoint, +} from "../../scripts/lib/delivery-workflow-controller.mjs"; +import { resolveDeliveryWorkflowProfile } from "../../scripts/lib/delivery-workflow-profiles.mjs"; +import { lockCreatePrPublicationPlanCheckpoint } from "../../scripts/lib/mutation-checkpoint.mjs"; +import { executionContractForWorkflow } from "../../scripts/lib/workflow-execution-contract.mjs"; + +const BASE = "a".repeat(40); +const HEAD = "b".repeat(40); + +function plan(path) { + return buildCreatePrPublicationPlan({ + repo: "acme/widgets", + remote: "origin", + branch: "feat/253-widgets", + base: "dev", + expectedRemoteTip: "absent", + originalLocalTip: HEAD, + newTip: HEAD, + title: "Fix widgets", + body: "Refs #253\n", + idempotencyKey: "issue-253-create-pr", + checkpoint: path, + }); +} + +test("issue create-PR workflow can terminate after verified publication when the user requested open-only", () => { + const profile = resolveDeliveryWorkflowProfile("create-pr-for-issue"); + assert.ok(profile.graph.OPEN_PR.includes("DONE")); +}); + +test("issue create-PR workflow exposes the canonical publication planner", () => { + const contract = executionContractForWorkflow("create-pr-for-issue"); + assert.equal(contract.workflowPlan.publication.planner, "scripts/create-pr-publication-plan.mjs"); + assert.equal(contract.workflowPlan.publication.openOnlyTerminalPhase, "OPEN_PR"); + assert.equal(contract.workflowPlan.sourceDiscovery, "diagnostic-only-on-helper-failure"); +}); + +test("issue create-PR checkpoints can lock the same validated publication plan as local work", () => { + const directory = mkdtempSync(join(tmpdir(), "github-delivery-issue-plan-")); + const path = join(directory, "checkpoint.json"); + try { + const profile = resolveDeliveryWorkflowProfile("create-pr-for-issue"); + const controller = createDeliveryWorkflowController({ + workflow: "create-pr-for-issue", + repo: "acme/widgets", + baseSha: BASE, + headSha: HEAD, + graph: profile.graph, + startPhase: "PREOPEN_GATE", + }); + writeDeliveryWorkflowCheckpoint(path, controller.snapshot()); + + const lock = lockCreatePrPublicationPlanCheckpoint({ path, plan: plan(path) }); + assert.equal(lock.headSha, HEAD); + const snapshot = readDeliveryWorkflowCheckpoint(path); + assert.equal(snapshot.publicationPlan.headSha, HEAD); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test("issue open-only completion still requires successful locked publication receipts", () => { + const profile = resolveDeliveryWorkflowProfile("create-pr-for-issue"); + const publicationPlan = createPrPublicationPlanLock(plan("checkpoint.json"), { headSha: HEAD }); + const controller = createDeliveryWorkflowController({ + workflow: "create-pr-for-issue", + repo: "acme/widgets", + baseSha: BASE, + headSha: HEAD, + graph: profile.graph, + startPhase: "OPEN_PR", + publicationPlan, + publicationReceipts: {}, + }); + + assert.throws(() => controller.transition("DONE"), /create_pr_publication_incomplete/); +}); diff --git a/tests/unit/local-pr-complete-execution.test.mjs b/tests/unit/local-pr-complete-execution.test.mjs index b5479e6f..245d84d2 100644 --- a/tests/unit/local-pr-complete-execution.test.mjs +++ b/tests/unit/local-pr-complete-execution.test.mjs @@ -65,7 +65,51 @@ function aggregateReview(overrides = {}) { }; } -test("local PR execution contract exposes the deterministic hygiene and aggregated evidence helpers", () => { +function perRequirementReview(overrides = {}) { + return { + schemaVersion: 2, + kind: "github-delivery/pre-open-review-result", + headSha: HEAD, + lenses: { + edge_cases: { + status: "done", + headSha: HEAD, + method: "focused edge-case review", + reviewedFiles: ["src/ui.ts"], + }, + ui_accessibility: { + status: "done", + headSha: HEAD, + method: "focused accessibility review", + reviewedFiles: ["src/ui.ts", "src/a11y.ts"], + }, + }, + surfaces: { + authn: { + status: "done", + headSha: HEAD, + method: "focused authn review", + reviewedFiles: ["src/session.ts"], + }, + injection: { + status: "done", + headSha: HEAD, + method: "focused injection review", + reviewedFiles: ["src/ui.ts"], + }, + }, + probes: { + "ui-accessibility": { + probeId: "ui-accessibility", + status: "clean", + files: ["src/ui.ts"], + }, + }, + ...overrides, + }; +} + +test("local PR execution contract exposes the deterministic hygiene and review evidence helpers", () => { const contract = executionContractForWorkflow("create-pr-from-local-work"); assert.equal(contract.helpers.hygieneOrchestrator, "scripts/create-pr-hygiene.mjs"); assert.equal(contract.helpers.preOpenEvidenceAssembler, "scripts/pre-open-review-evidence.mjs"); @@ -74,9 +118,16 @@ test("local PR execution contract exposes the deterministic hygiene and aggregat assert.equal(contract.workflowPlan.publication.directWriteGuard, "runtime-after-workflow-selection"); }); -test("one bug review and one security review expand into current schema-v2 evidence", () => { +test("aggregate clean review declarations cannot mint per-requirement pre-open evidence", () => { assert.equal(typeof preOpenEvidence.expandAggregatePreOpenEvidence, "function"); - const output = preOpenEvidence.expandAggregatePreOpenEvidence(compactSummary(), aggregateReview()); + assert.throws( + () => preOpenEvidence.expandAggregatePreOpenEvidence(compactSummary(), aggregateReview()), + /pre_open_review_aggregate_not_authoritative/, + ); +}); + +test("explicit per-requirement review rows assemble into current schema-v2 evidence", () => { + const output = preOpenEvidence.expandAggregatePreOpenEvidence(compactSummary(), perRequirementReview()); assert.equal(output.schemaVersion, 2); assert.deepEqual(Object.keys(output.lenses).sort(), ["edge_cases", "ui_accessibility"]); @@ -84,29 +135,28 @@ test("one bug review and one security review expand into current schema-v2 evide assert.deepEqual(output.lenses.edge_cases, { status: "done", headSha: HEAD, - method: "focused candidate bug review", + method: "focused edge-case review", reviewedFiles: ["src/ui.ts"], }); assert.deepEqual(output.surfaces.authn.reviewedFiles, ["src/session.ts"]); assert.equal(output.probes["ui-accessibility"].status, "clean"); }); -test("aggregated evidence fails when the axis review did not cover a required file", () => { - assert.equal(typeof preOpenEvidence.expandAggregatePreOpenEvidence, "function"); - const review = aggregateReview(); - review.bug = { ...review.bug, reviewedFiles: ["src/ui.ts"] }; +test("per-requirement evidence fails when a required row omits a required file", () => { + const review = perRequirementReview(); + review.lenses.edge_cases.reviewedFiles = ["src/a11y.ts"]; assert.throws( () => preOpenEvidence.expandAggregatePreOpenEvidence(compactSummary(), review), - /pre_open_review_bug_scope_incomplete/, + /pre_open_review_lens_edge_cases_scope_mismatch/, ); }); -test("aggregated evidence fails when the axis review did not cover a required semantic ID", () => { - const review = aggregateReview(); - review.security = { ...review.security, coveredIds: ["authn"] }; +test("per-requirement evidence fails when a required semantic ID is absent", () => { + const review = perRequirementReview(); + delete review.surfaces.injection; assert.throws( () => preOpenEvidence.expandAggregatePreOpenEvidence(compactSummary(), review), - /pre_open_review_security_ids_incomplete/, + /pre_open_review_surface_injection_missing/, ); }); diff --git a/tests/unit/no-comments-contract.test.mjs b/tests/unit/no-comments-contract.test.mjs index ecc1d023..490a80dc 100644 --- a/tests/unit/no-comments-contract.test.mjs +++ b/tests/unit/no-comments-contract.test.mjs @@ -115,12 +115,12 @@ test("apply vs report, encodings, and merge-ready blockers", () => { assert.match(workflow, /Do not encode an alibi/i); }); -test("composed workflows load no-comments then simplify unless opted out", () => { +test("composed workflows load no-comments and simplify with explicit opt-out policy", () => { for (const path of COMPOSED) { const text = read(path); assert.match(text, /references\/no-comments\.md/, path); assert.match(text, /references\/simplify-pr\.md/, path); - assert.match(text, /skip no-comments|without simplify|opt(?:s|ed)? out/i, path); + assert.match(text, /skip no-comments|without simplify|opt(?:s|ed)?[- ]out/i, path); } }); diff --git a/tests/unit/pre-open-publication-binding.test.mjs b/tests/unit/pre-open-publication-binding.test.mjs index 97f6b774..e58448f6 100644 --- a/tests/unit/pre-open-publication-binding.test.mjs +++ b/tests/unit/pre-open-publication-binding.test.mjs @@ -6,6 +6,7 @@ import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import test from "node:test"; +import { buildCreatePrPublicationPlan } from "../../scripts/lib/create-pr-publication-plan.mjs"; import { createDeliveryWorkflowController, readDeliveryWorkflowCheckpoint, @@ -16,7 +17,10 @@ import { executeMutationDocument, mutationOperationKey, } from "../../scripts/lib/mutation-document-execution.mjs"; -import { mutationExecutionContextFromCheckpoint } from "../../scripts/lib/mutation-checkpoint.mjs"; +import { + lockCreatePrPublicationPlanCheckpoint, + mutationExecutionContextFromCheckpoint, +} from "../../scripts/lib/mutation-checkpoint.mjs"; const PRE_OPEN_GATE = fileURLToPath( new URL("../../scripts/pre-open-gate.mjs", import.meta.url), @@ -70,16 +74,24 @@ function completeHygiene(current) { }); } -function pushRequest(newTip = HEAD) { - return { - schemaVersion: 1, - action: "push_code", - mutationMode: "maintainer", - explicitInstruction: false, +function publicationPlan(checkpoint, newTip = HEAD) { + return buildCreatePrPublicationPlan({ repo: "acme/widgets", + remote: "origin", branch: "task", + base: "dev", + expectedRemoteTip: "absent", + originalLocalTip: HEAD, newTip, - }; + title: "Fix task", + body: "Refs #1", + idempotencyKey: "issue-1-create-pr", + checkpoint, + }); +} + +function pushRequest(newTip = HEAD) { + return publicationPlan("checkpoint.json", newTip).requests[0]; } function run(cwd, command, args) { @@ -129,6 +141,10 @@ test("mutation boundary independently rejects missing, changed, or stale pre-ope current.recordPreOpenGate(readyGate()); current.transition("OPEN_PR"); writeDeliveryWorkflowCheckpoint(checkpoint, current.snapshot()); + lockCreatePrPublicationPlanCheckpoint({ + path: checkpoint, + plan: publicationPlan(checkpoint), + }); assert.deepEqual( mutationExecutionContextFromCheckpoint({ path: checkpoint, request: pushRequest() }), { diff --git a/tests/unit/routed-user-intent-handoff.test.mjs b/tests/unit/routed-user-intent-handoff.test.mjs index cdf13820..9efba28d 100644 --- a/tests/unit/routed-user-intent-handoff.test.mjs +++ b/tests/unit/routed-user-intent-handoff.test.mjs @@ -6,11 +6,17 @@ import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import test from "node:test"; +import { buildCreatePrPublicationPlan } from "../../scripts/lib/create-pr-publication-plan.mjs"; +import { createPrPublicationPlanLock } from "../../scripts/lib/create-pr-publication-state.mjs"; import { createDeliveryWorkflowController, + readDeliveryWorkflowCheckpoint, writeDeliveryWorkflowCheckpoint, } from "../../scripts/lib/delivery-workflow-controller.mjs"; -import { mutationExecutionContextFromCheckpoint } from "../../scripts/lib/mutation-checkpoint.mjs"; +import { + lockCreatePrPublicationPlanCheckpoint, + mutationExecutionContextFromCheckpoint, +} from "../../scripts/lib/mutation-checkpoint.mjs"; import { executeMutationDocument } from "../../scripts/lib/mutation-document-execution.mjs"; const DELIVERY_CONTROLLER = fileURLToPath( @@ -24,7 +30,6 @@ function createPrRequest(overrides = {}) { schemaVersion: 1, action: "create_pr", mutationMode: "maintainer", - explicitInstruction: false, repo: "acme/widgets", base: "main", head: "fix/issue-95", @@ -36,6 +41,23 @@ function createPrRequest(overrides = {}) { }; } +function publicationPlan(checkpoint, createOverrides = {}) { + const create = createPrRequest(createOverrides); + return buildCreatePrPublicationPlan({ + repo: create.repo, + remote: "origin", + branch: create.head, + base: create.base, + expectedRemoteTip: "absent", + originalLocalTip: HEAD, + newTip: HEAD, + title: create.title, + body: create.body, + idempotencyKey: create.idempotencyKey, + checkpoint, + }); +} + function createCheckpoint() { const controller = createDeliveryWorkflowController({ workflow: "create-pr-for-issue", @@ -62,6 +84,7 @@ function createCheckpoint() { const directory = mkdtempSync(join(tmpdir(), "github-delivery-routed-intent-")); const checkpoint = join(directory, "controller.json"); writeDeliveryWorkflowCheckpoint(checkpoint, controller.snapshot()); + lockCreatePrPublicationPlanCheckpoint({ path: checkpoint, plan: publicationPlan(checkpoint) }); return { directory, checkpoint }; } @@ -162,13 +185,21 @@ test("Protection Off executes the routed create_pr document without Authority-ho } }); -test("routed create_pr intent cannot be rebound to a changed mutation payload", () => { +test("routed create_pr intent cannot be rebound even if the publication lock is tampered", () => { const { directory, checkpoint } = createCheckpoint(); try { mutationExecutionContextFromCheckpoint({ path: checkpoint, request: createPrRequest(), }); + + const snapshot = readDeliveryWorkflowCheckpoint(checkpoint); + snapshot.publicationPlan = createPrPublicationPlanLock( + publicationPlan(checkpoint, { title: "Different effect" }), + { headSha: HEAD }, + ); + writeDeliveryWorkflowCheckpoint(checkpoint, snapshot); + assert.throws( () => mutationExecutionContextFromCheckpoint({ path: checkpoint, diff --git a/tests/unit/watchdog-classifier-safety.test.mjs b/tests/unit/watchdog-classifier-safety.test.mjs index f619fb3a..a7c4c8a3 100644 --- a/tests/unit/watchdog-classifier-safety.test.mjs +++ b/tests/unit/watchdog-classifier-safety.test.mjs @@ -41,6 +41,18 @@ test("GraphQL reads remain evidence while literal mutations are state changes", ); }); +test("GraphQL query-field detection handles long whitespace without changing semantics", () => { + const padding = " ".repeat(16_384); + assert.equal( + classify(`gh api graphql --method POST -f query${padding}=${padding}'${padding}query { viewer { login } }'`).kind, + "evidence", + ); + assert.equal( + classify(`gh api graphql --method POST -f query${padding}=${padding}'${padding}queryish { viewer { login } }'`).kind, + "neutral", + ); +}); + test("opaque GraphQL input files stay neutral instead of falsely resetting progress", () => { assert.equal(classify("gh api graphql --input request.json").kind, "neutral"); }); diff --git a/tests/unit/workflow-state-grounding.test.mjs b/tests/unit/workflow-state-grounding.test.mjs new file mode 100644 index 00000000..f4c000d6 --- /dev/null +++ b/tests/unit/workflow-state-grounding.test.mjs @@ -0,0 +1,209 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { + createDeliveryWorkflowController, + readDeliveryWorkflowCheckpoint, + writeDeliveryWorkflowCheckpoint, +} from "../../scripts/lib/delivery-workflow-controller.mjs"; +import { + bootstrapLocalPrWorkflow, + localPrWorkflowCheckpointPath, +} from "../../scripts/lib/workflow-bootstrap.mjs"; +import { executionContractForWorkflow } from "../../scripts/lib/workflow-execution-contract.mjs"; + +const DELIVERY_CONTROLLER = fileURLToPath( + new URL("../../scripts/delivery-controller.mjs", import.meta.url), +); +const HEAD = "b".repeat(40); +const BASE = "a".repeat(40); +const GRAPH = { + ROUTE: ["PREFLIGHT"], + PREFLIGHT: ["LOCAL_VERIFY", "DONE"], + LOCAL_VERIFY: ["DONE"], + DONE: [], +}; + +function controller() { + let at = 1_000; + return createDeliveryWorkflowController({ + workflow: "create-pr-from-local-work", + repo: "acme/widgets", + baseSha: BASE, + headSha: HEAD, + graph: GRAPH, + startPhase: "ROUTE", + now: () => ++at, + }); +} + +test("controller exposes one authoritative current action and controller-owned phase receipts", () => { + const current = controller(); + let snapshot = current.snapshot(); + assert.deepEqual(snapshot.nextAction, { + action: "execute_phase", + phase: "ROUTE", + authority: "controller-checkpoint", + }); + assert.deepEqual(snapshot.phaseReceipts, []); + + current.transition("PREFLIGHT"); + snapshot = current.snapshot(); + assert.deepEqual(snapshot.nextAction, { + action: "execute_phase", + phase: "PREFLIGHT", + authority: "controller-checkpoint", + }); + assert.equal(snapshot.phaseReceipts.length, 1); + assert.deepEqual(snapshot.phaseReceipts[0], { + phase: "ROUTE", + authority: "controller-transition", + stateGeneration: 0, + baseSha: BASE, + headSha: HEAD, + issue: null, + pr: null, + completedAt: snapshot.phaseReceipts[0].completedAt, + }); + assert.ok(Number.isFinite(snapshot.phaseReceipts[0].completedAt)); + + current.transition("DONE"); + snapshot = current.snapshot(); + assert.deepEqual(snapshot.nextAction, { + action: "stop", + phase: "DONE", + authority: "controller-checkpoint", + }); + assert.deepEqual(snapshot.phaseReceipts.map((entry) => entry.phase), ["ROUTE", "PREFLIGHT"]); +}); + +test("checkpoint resume preserves receipts and the same singular next action", () => { + const directory = mkdtempSync(join(tmpdir(), "github-delivery-grounding-")); + const checkpoint = join(directory, "checkpoint.json"); + try { + const current = controller(); + current.transition("PREFLIGHT"); + writeDeliveryWorkflowCheckpoint(checkpoint, current.snapshot()); + + const saved = readDeliveryWorkflowCheckpoint(checkpoint); + const resumed = createDeliveryWorkflowController({ snapshot: saved, graph: GRAPH }); + assert.deepEqual(resumed.snapshot().nextAction, saved.nextAction); + assert.deepEqual(resumed.snapshot().phaseReceipts, saved.phaseReceipts); + assert.equal(resumed.snapshot().phase, "PREFLIGHT"); + assert.deepEqual(resumed.snapshot().completedPhases, ["ROUTE"]); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test("show re-derives controller state instead of trusting raw checkpoint guidance", () => { + const directory = mkdtempSync(join(tmpdir(), "github-delivery-show-grounding-")); + const checkpoint = join(directory, "checkpoint.json"); + try { + const snapshot = controller().snapshot(); + snapshot.nextAction = { action: "stop", phase: "DONE", authority: "model-prose" }; + writeDeliveryWorkflowCheckpoint(checkpoint, snapshot); + + const result = spawnSync(process.execPath, [DELIVERY_CONTROLLER, "show", checkpoint], { + encoding: "utf8", + }); + assert.equal(result.status, 0, result.stderr); + const shown = JSON.parse(result.stdout); + assert.deepEqual(shown.nextAction, { + action: "execute_phase", + phase: "ROUTE", + authority: "controller-checkpoint", + }); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test("local workflow bootstrap upgrades reused raw checkpoint guidance", () => { + const stateDir = mkdtempSync(join(tmpdir(), "github-delivery-bootstrap-grounding-")); + try { + const initial = controller(); + initial.transition("PREFLIGHT"); + const legacy = initial.snapshot(); + delete legacy.phaseReceipts; + legacy.nextAction = { action: "stop", phase: "DONE", authority: "model-prose" }; + const checkpointPath = localPrWorkflowCheckpointPath({ + repo: "acme/widgets", + headSha: HEAD, + stateDir, + }); + writeDeliveryWorkflowCheckpoint(checkpointPath, legacy); + + const bootstrapped = bootstrapLocalPrWorkflow({ + repo: "acme/widgets", + headSha: HEAD, + baseSha: BASE, + stateDir, + }); + assert.equal(bootstrapped.reused, true); + assert.deepEqual(bootstrapped.snapshot.nextAction, { + action: "execute_phase", + phase: "PREFLIGHT", + authority: "controller-checkpoint", + }); + assert.deepEqual(bootstrapped.snapshot.phaseReceipts, []); + } finally { + rmSync(stateDir, { recursive: true, force: true }); + } +}); + +test("model reasoning claims cannot change controller identity, phase, or receipts", () => { + const current = controller(); + const before = current.snapshot(); + current.observeCycle({ + narrationChanged: true, + claimedHeadSha: "f".repeat(40), + claimedBaseSha: "e".repeat(40), + claimedPr: 999, + claimedChecks: "green", + claimedPhase: "DONE", + }); + const after = current.snapshot(); + + assert.equal(after.headSha, before.headSha); + assert.equal(after.baseSha, before.baseSha); + assert.equal(after.pr, before.pr); + assert.equal(after.phase, before.phase); + assert.deepEqual(after.phaseReceipts, before.phaseReceipts); + assert.deepEqual(after.nextAction, before.nextAction); + assert.equal(after.attempts.noProgressSteps, 1); +}); + +test("public controller CLI cannot inject model-authored refs", () => { + const directory = mkdtempSync(join(tmpdir(), "github-delivery-ref-grounding-")); + const checkpoint = join(directory, "checkpoint.json"); + try { + writeDeliveryWorkflowCheckpoint(checkpoint, controller().snapshot()); + const result = spawnSync( + process.execPath, + [DELIVERY_CONTROLLER, "refs", checkpoint, "--head", "f".repeat(40)], + { encoding: "utf8" }, + ); + assert.equal(result.status, 2); + assert.match(`${result.stderr}\n${result.stdout}`, /controller_refs_are_internal_only/); + assert.equal(readDeliveryWorkflowCheckpoint(checkpoint).headSha, HEAD); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test("execution contract makes checkpoint state authoritative over reasoning claims", () => { + const contract = executionContractForWorkflow("create-pr-from-local-work"); + assert.deepEqual(contract.controllerState, { + source: "controller-checkpoint", + nextAction: "authoritative", + completedPhaseReceipts: "authoritative", + reasoningClaims: "non-authoritative", + externalStateClaims: "structured-evidence-only", + }); +}); diff --git a/tests/windows/authority-release-retention.ps1 b/tests/windows/authority-release-retention.ps1 new file mode 100644 index 00000000..aff85499 --- /dev/null +++ b/tests/windows/authority-release-retention.ps1 @@ -0,0 +1,75 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +$repoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..')) +$project = Join-Path $repoRoot 'authority-host\windows\GitHubDeliveryAuthority\GitHubDeliveryAuthority.csproj' +$installer = Join-Path $repoRoot 'authority-host\windows\install-release.ps1' +$workspace = Join-Path $env:RUNNER_TEMP ('authority-retention-' + [guid]::NewGuid().ToString('N')) +$publish = Join-Path $workspace 'publish' +$installDir = Join-Path $workspace 'install' +$utf8NoBom = New-Object System.Text.UTF8Encoding($false) + +try { + New-Item -ItemType Directory -Force -Path $workspace | Out-Null + + & dotnet restore $project --locked-mode + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + & dotnet publish $project --configuration Release --runtime win-x64 --self-contained true --no-restore --output $publish + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + $version = [string](Get-Content (Join-Path $repoRoot 'package.json') -Raw | ConvertFrom-Json).version + $sourceCommit = (& git -C $repoRoot rev-parse HEAD | Select-Object -First 1).Trim().ToLowerInvariant() + if ($sourceCommit -notmatch '^[0-9a-f]{40}$') { throw 'Could not resolve source commit for installer retention test.' } + + $versionInfo = [ordered]@{ + schemaVersion = 1 + kind = 'github-delivery/authority-host-version' + version = $version + sourceCommit = $sourceCommit + platform = 'win32' + arch = 'x64' + } + [IO.File]::WriteAllText( + (Join-Path $publish 'authority-host-version.json'), + (($versionInfo | ConvertTo-Json) + [Environment]::NewLine), + $utf8NoBom + ) + + $appRoot = Join-Path $installDir 'app' + $oldA = Join-Path $appRoot 'v0.0.1' + $oldB = Join-Path $appRoot 'v0.0.2' + New-Item -ItemType Directory -Force -Path $oldA | Out-Null + New-Item -ItemType Directory -Force -Path $oldB | Out-Null + [IO.File]::WriteAllText((Join-Path $oldA 'old-a.bin'), 'old-a', $utf8NoBom) + [IO.File]::WriteAllText((Join-Path $oldB 'old-b.bin'), 'old-b', $utf8NoBom) + + New-Item -ItemType Directory -Force -Path $installDir | Out-Null + $dbPath = Join-Path $installDir 'authority.db' + $trustPath = Join-Path $installDir 'trust-store.json' + [IO.File]::WriteAllText($dbPath, 'persistent-db', $utf8NoBom) + [IO.File]::WriteAllText($trustPath, '{"persistent":true}', $utf8NoBom) + + & powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass ` + -File $installer ` + -SourceDir $publish ` + -ExpectedVersion $version ` + -ExpectedSourceCommit $sourceCommit ` + -InstallDir $installDir ` + -SkipStart + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + $targetName = 'v' + $version + $releaseDirs = @( + Get-ChildItem $appRoot -Directory | Where-Object { $_.Name -match '^v\d+\.\d+\.\d+$' } + ) + if ($releaseDirs.Count -ne 1 -or $releaseDirs[0].Name -ne $targetName) { + $found = ($releaseDirs | ForEach-Object Name) -join ', ' + throw "Expected only $targetName under app after update, found: $found" + } + if ((Get-Content $dbPath -Raw) -ne 'persistent-db') { throw 'authority.db was not preserved.' } + if ((Get-Content $trustPath -Raw) -ne '{"persistent":true}') { throw 'trust-store.json was not preserved.' } +} +finally { + Remove-Item $workspace -Recurse -Force -ErrorAction SilentlyContinue +}