diff --git a/docs/gists/auto-git.md b/docs/gists/auto-git.md index 3cd1ee4..7ae557d 100644 --- a/docs/gists/auto-git.md +++ b/docs/gists/auto-git.md @@ -332,13 +332,21 @@ Controller helpers: auto-git start --cwd "$PWD" --task "fix this" auto-git ledger list --cwd "$PWD" auto-git finish --cwd "$PWD" --run-id "" --complete -auto-git release-preflight --cwd "$PWD" --require-verification +auto-git release-preflight --cwd "$PWD" --run-id "" --require-verification ``` -`auto-git-finish.mjs` blocks coordinated/everything/yolo completion until the -branch is pushed upstream or merged into a pushed base branch, the checkout is -switched back to main/default, a PR handoff or merge is recorded, and the -ledger update succeeds. +`auto-git-finish.mjs` validates the run's `decisionReceipt` before it reports +done. It blocks when the receipt requires evidence that is missing: clean +working tree, commit evidence after changes, branch/worktree evidence, +verification, push/sync, PR/merge/land, release-preflight, release execution or +explicit deferral, follow-up thread handoff, return to main/default, and the +final ledger update. Blockers are short command-class hints and do not include +raw diffs, command output, transcripts, or secret-looking values. + +For release and yolo routes, `auto-git release-preflight` records successful +preflight evidence to the active or requested run using safe metadata only. +If release execution is intentionally deferred, finish requires an explicit +`--defer-release`. Completion from main/default still preserves the completed branch/head in the ledger so later chats can find the exact handoff. diff --git a/gists/auto-git/README.md b/gists/auto-git/README.md index 3cd1ee4..7ae557d 100644 --- a/gists/auto-git/README.md +++ b/gists/auto-git/README.md @@ -332,13 +332,21 @@ Controller helpers: auto-git start --cwd "$PWD" --task "fix this" auto-git ledger list --cwd "$PWD" auto-git finish --cwd "$PWD" --run-id "" --complete -auto-git release-preflight --cwd "$PWD" --require-verification +auto-git release-preflight --cwd "$PWD" --run-id "" --require-verification ``` -`auto-git-finish.mjs` blocks coordinated/everything/yolo completion until the -branch is pushed upstream or merged into a pushed base branch, the checkout is -switched back to main/default, a PR handoff or merge is recorded, and the -ledger update succeeds. +`auto-git-finish.mjs` validates the run's `decisionReceipt` before it reports +done. It blocks when the receipt requires evidence that is missing: clean +working tree, commit evidence after changes, branch/worktree evidence, +verification, push/sync, PR/merge/land, release-preflight, release execution or +explicit deferral, follow-up thread handoff, return to main/default, and the +final ledger update. Blockers are short command-class hints and do not include +raw diffs, command output, transcripts, or secret-looking values. + +For release and yolo routes, `auto-git release-preflight` records successful +preflight evidence to the active or requested run using safe metadata only. +If release execution is intentionally deferred, finish requires an explicit +`--defer-release`. Completion from main/default still preserves the completed branch/head in the ledger so later chats can find the exact handoff. diff --git a/gists/auto-git/auto-git.SKILL.md b/gists/auto-git/auto-git.SKILL.md index c0a9250..6c7a6f3 100644 --- a/gists/auto-git/auto-git.SKILL.md +++ b/gists/auto-git/auto-git.SKILL.md @@ -250,18 +250,26 @@ PATH, use the installed skill's `scripts/*.mjs` helper paths as a fallback. ledger metadata - never deletes ledger entries - `auto-git finish --cwd "$PWD" --run-id "" [--complete]` - - checks dirty state, HEAD/upstream, active run locks, PR readiness, and - verification against current HEAD + - checks dirty state, unresolved index state, HEAD/upstream, active run + locks, PR readiness, and verification against current HEAD + - validates the run's `decisionReceipt` completion gates before reporting + done; missing gate evidence fails closed with a short actionable blocker - blocks completion for coordinated/everything/yolo branch work until the branch is pushed upstream and the checkout is switched back to main/default - checks whether there is a recorded PR handoff or pushed merge evidence, and whether the ledger update actually completed + - blocks release/yolo completion until release-preflight evidence is recorded + and release execution is recorded or explicitly deferred with + `--defer-release` + - blocks follow-up-thread completion until thread handoff evidence exists - preserves the completed branch/head in the ledger even when completion is run from main/default after cleanup - records PR metadata when asked and completes the run only when safe -- `auto-git release-preflight --cwd "$PWD" [--require-verification]` +- `auto-git release-preflight --cwd "$PWD" [--run-id ""] [--require-verification]` - checks package version, changelog/release notes, dirty state, existing local tag conflicts, and optional remote release/tag state before tagging + - records successful release-preflight evidence to the active or requested + Auto Git run using safe metadata only - successful clean release verification may be reused after switching back to main/default when `HEAD` is unchanged, even if the upstream branch context changed the dirty fingerprint @@ -269,7 +277,7 @@ PATH, use the installed skill's `scripts/*.mjs` helper paths as a fallback. ## Global Async State -Auto Git may use global advisory state under `~/.async/auto-git/v1/repos//` to avoid repeating expensive inspection and to coordinate across chats. Live runtime leases use Async-compatible lock records under `~/.async/locks/auto-git/repos//runs/*.lease.json`; completion removes the live lease and writes a receipt under `~/.async/locks/auto-git/history/`. This state is a cache of safe metadata: fingerprints, file path lists, commit ids, command names, exit codes, timestamps, lock classifications, process ids started by Auto Git, execution profiles, generated env override names/values, durations, recovery hints, run ids, task slugs, lifecycle modes, coordinated intents, branch names, worktree paths, base branches, lease expirations, lease paths, verification keys, and PR URLs/statuses. +Auto Git may use global advisory state under `~/.async/auto-git/v1/repos//` to avoid repeating expensive inspection and to coordinate across chats. Live runtime leases use Async-compatible lock records under `~/.async/locks/auto-git/repos//runs/*.lease.json`; completion removes the live lease and writes a receipt under `~/.async/locks/auto-git/history/`. This state is a cache of safe metadata: fingerprints, file path lists, commit ids, command names, exit codes, timestamps, lock classifications, process ids started by Auto Git, execution profiles, generated env override names/values, durations, recovery hints, run ids, task slugs, lifecycle modes, coordinated intents, branch names, worktree paths, base branches, lease expirations, lease paths, verification keys, release-preflight evidence, release deferral state, thread handoff ids/status, and PR URLs/statuses. The ledger is cooperative. Auto Git can reliably detect stale or inactive chats only when those chats used Auto Git and wrote ledger state. A run is active diff --git a/gists/auto-git/auto-git.script-auto-git-finish.mjs b/gists/auto-git/auto-git.script-auto-git-finish.mjs index e585637..78bbd68 100644 --- a/gists/auto-git/auto-git.script-auto-git-finish.mjs +++ b/gists/auto-git/auto-git.script-auto-git-finish.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { spawnSync } from "node:child_process"; import { join, resolve } from "node:path"; @@ -10,7 +10,7 @@ function usage() { return [ "Usage: auto-git-finish.mjs [--cwd ] [--run-id ] [--complete]", " [--record-pr [--pr-number ] [--pr-status ]]", - " [--allow-dirty] [--json]", + " [--defer-release] [--allow-dirty] [--json]", "", "Inspects final Auto Git state, optionally records a PR, and completes the run when safe.", "Coordinated branch completion requires a pushed branch and a checkout returned to base." @@ -25,6 +25,7 @@ function parseArgs(argv) { recordPr: undefined, prNumber: undefined, prStatus: "open", + deferRelease: false, allowDirty: false, json: false }; @@ -59,6 +60,10 @@ function parseArgs(argv) { parsed.prStatus = requireValue(argv, ++index, arg); continue; } + if (arg === "--defer-release") { + parsed.deferRelease = true; + continue; + } if (arg === "--allow-dirty") { parsed.allowDirty = true; continue; @@ -143,6 +148,73 @@ function isCompletionLifecycle(run) { return run?.lifecycle === "everything" || run?.lifecycle === "yolo"; } +function decisionReceipt(run) { + return run?.decisionReceipt; +} + +function decisionGates(run) { + return new Set(decisionReceipt(run)?.completionGates ?? []); +} + +function hasGate(run, gate) { + return decisionGates(run).has(gate); +} + +function decisionIntent(run) { + return decisionReceipt(run)?.normalizedIntentLabel; +} + +function decisionWorkflow(run) { + return decisionReceipt(run)?.selectedWorkflowMode; +} + +function requiresVerification(run) { + return hasGate(run, "verification") || ["sync", "release", "land", "everything", "yolo"].includes(decisionIntent(run)); +} + +function requiresPushEvidence(run) { + return ( + hasGate(run, "branch-pushed") || + hasGate(run, "branch-pushed-before-tag") || + ["sync", "release", "PR", "merge", "land", "everything", "yolo"].includes(decisionIntent(run)) + ); +} + +function requiresBranchOrWorktreeEvidence(run) { + return ( + hasGate(run, "isolated-branch-or-worktree") || + hasGate(run, "isolated-worktrees") || + decisionWorkflow(run) === "coordinated-branch-worktree" + ); +} + +function requiresHandoffEvidence(run) { + return hasGate(run, "pr-handoff-or-merge-evidence") || ["PR", "merge", "land", "everything", "yolo"].includes(decisionIntent(run)); +} + +function requiresReleasePreflight(run) { + return ( + decisionReceipt(run)?.releasePreflightRequired === true || + hasGate(run, "release-preflight") || + hasGate(run, "release-preflight-before-release-action") || + ["release", "yolo"].includes(decisionIntent(run)) || + run?.intent === "release" || + run?.lifecycle === "yolo" + ); +} + +function requiresReleaseCompletion(run) { + return ["release", "yolo"].includes(decisionIntent(run)) || run?.intent === "release" || run?.lifecycle === "yolo"; +} + +function requiresThreadHandoff(run) { + return decisionReceipt(run)?.threadHandoffRequired === true || hasGate(run, "thread-handoff-evidence"); +} + +function requiresReturnToBase(run) { + return hasGate(run, "return-to-base") || hasGate(run, "handoff-or-return-to-base") || requiresBranchOrWorktreeEvidence(run); +} + function git(cwd, args) { return spawnSync("git", args, { cwd, encoding: "utf8" }); } @@ -284,9 +356,7 @@ function mergeCheck(run, cwd, completion) { function handoffCheck(run, merge, completion) { const pr = prHandoff(run); - const required = Boolean( - completion.required && (isCompletionLifecycle(run) || ["merge", "branch"].includes(run?.intent)) - ); + const required = Boolean(requiresHandoffEvidence(run)); return { required, satisfied: !required || pr.handoffRecorded || merge.mergedIntoBase, @@ -295,6 +365,167 @@ function handoffCheck(run, merge, completion) { }; } +function branchPushState(cwd, branch) { + const result = { + branch, + required: Boolean(branch), + upstream: undefined, + aheadOfUpstream: undefined, + behindUpstream: undefined, + pushed: false, + blockers: [] + }; + if (!branch) { + result.blockers.push("missing branch name for push check"); + return result; + } + const upstream = git(cwd, ["rev-parse", "--abbrev-ref", `${branch}@{upstream}`]); + if (upstream.status !== 0 || !upstream.stdout.trim()) { + result.blockers.push(`branch ${branch} has no upstream`); + return result; + } + result.upstream = upstream.stdout.trim(); + const counts = git(cwd, ["rev-list", "--left-right", "--count", `${result.upstream}...${branch}`]); + if (counts.status !== 0) { + result.blockers.push(`could not compare ${branch} with ${result.upstream}`); + return result; + } + const [behind, ahead] = counts.stdout + .trim() + .split(/\s+/) + .map((value) => Number(value)); + result.behindUpstream = Number.isFinite(behind) ? behind : undefined; + result.aheadOfUpstream = Number.isFinite(ahead) ? ahead : undefined; + result.pushed = result.aheadOfUpstream === 0; + if (result.aheadOfUpstream && result.aheadOfUpstream > 0) { + result.blockers.push(`branch ${branch} has ${result.aheadOfUpstream} unpushed commit(s)`); + } + return result; +} + +function pushCheck(snapshot, run, cwd, completion, merge) { + const required = requiresPushEvidence(run); + const branch = completion.branch ?? run?.branch ?? snapshot.topology.branch; + if (!required) return { required, satisfied: true, branch, pushed: true, blockers: [] }; + if (merge?.mergedIntoBase) { + return { + required, + satisfied: merge.basePushed, + branch: merge.baseBranch, + pushed: merge.basePushed, + upstream: merge.baseUpstream, + blockers: merge.basePushed ? [] : merge.blockers + }; + } + if (completion.required) { + return { + required, + satisfied: completion.pushed, + branch: completion.branch, + pushed: completion.pushed, + upstream: completion.upstream, + blockers: completion.blockers.filter((blocker) => !blocker.startsWith("checkout is still on ")) + }; + } + const state = branchPushState(cwd, branch); + return { + required, + satisfied: state.pushed, + ...state + }; +} + +function hasUnresolvedIndex(snapshot) { + return (snapshot.dirty?.statusPorcelain ?? []).some((line) => /^(DD|AU|UD|UA|DU|AA|UU)\s/.test(line)); +} + +function commitEvidence(snapshot, run, cwd) { + const baseBranch = defaultBaseBranch(snapshot, run); + const currentBranch = snapshot.topology.branch || "HEAD"; + const recordedCommits = Array.isArray(run?.commits) ? run.commits : []; + const ahead = baseBranch && currentBranch !== baseBranch ? git(cwd, ["rev-list", "--reverse", `${baseBranch}..${currentBranch}`]) : undefined; + const currentAheadCommits = ahead?.status === 0 ? ahead.stdout.trim().split("\n").filter(Boolean) : []; + const headChanged = Boolean(run?.head && snapshot.topology.head && run.head !== snapshot.topology.head); + const changesMade = recordedCommits.length > 0 || currentAheadCommits.length > 0 || headChanged; + return { + required: hasGate(run, "commit-by-intent") || hasGate(run, "commit-by-intent-per-worktree") || hasGate(run, "release-metadata-commit"), + changesMade, + recorded: recordedCommits.length > 0 || (!changesMade && Boolean(run?.head)), + recordedCommits, + currentAheadCommitCount: currentAheadCommits.length, + headChanged + }; +} + +function branchOrWorktreeCheck(snapshot, run, completion) { + const required = requiresBranchOrWorktreeEvidence(run); + const baseBranch = defaultBaseBranch(snapshot, run); + const branch = run?.branch ?? completion.branch; + const branchIsIsolated = Boolean(branch && branch !== baseBranch); + const worktreeExists = Boolean(run?.worktreePath && existsSync(run.worktreePath)); + return { + required, + satisfied: !required || (branchIsIsolated && worktreeExists), + branch, + baseBranch, + worktreePath: run?.worktreePath, + worktreeExists + }; +} + +function releasePreflightCheck(snapshot, run) { + const required = requiresReleasePreflight(run); + const evidence = run?.releasePreflight; + const headMatches = Boolean( + evidence?.head && (evidence.head === run?.head || evidence.head === snapshot.topology.head) + ); + const cleanEnough = !snapshot.dirty?.isDirty || evidence?.dirtyFingerprint === snapshot.dirty.fingerprint || evidence?.dirtyFingerprint === run?.dirtyFingerprint; + return { + required, + satisfied: !required || Boolean(evidence?.safeToTag === true && headMatches && cleanEnough), + evidence: evidence + ? { + safeToTag: evidence.safeToTag, + version: evidence.version, + tagName: evidence.tagName, + recordedAt: evidence.recordedAt, + head: evidence.head + } + : undefined + }; +} + +function releaseCompletionCheck(run) { + const required = requiresReleaseCompletion(run); + const executed = run?.releaseExecution?.status === "executed"; + const deferred = run?.releaseDeferral?.status === "deferred"; + return { + required, + satisfied: !required || executed || deferred, + executed, + deferred, + deferral: run?.releaseDeferral + }; +} + +function threadHandoffCheck(run) { + const required = requiresThreadHandoff(run); + const recorded = Boolean(run?.threadHandoff?.threadId || run?.threadHandoff?.status === "recorded"); + return { + required, + satisfied: !required || recorded, + handoff: run?.threadHandoff + }; +} + +function manualRoutingCheck(run) { + const required = hasGate(run, "manual-routing-confirmation"); + return { + required, + satisfied: !required + }; +} + function ledgerStatus(snapshot, runId) { const ledgerPath = join(stateRoot(), "repos", snapshot.repo.hash, "ledger.json"); const ledger = readJson(ledgerPath, { runs: [] }); @@ -323,28 +554,49 @@ function completionBlockers(completion, handoff) { return blockers.filter((blocker) => blocker.startsWith("checkout is still on ")); } -function blockers(snapshot, run, options, completion, handoff) { +function blockers(snapshot, run, options, completion, handoff, contract) { const issues = []; if (!run) issues.push("no Auto Git run could be resolved; pass --run-id"); + if (run && !decisionReceipt(run)) issues.push("missing start decision receipt; rerun auto-git start before finish"); if (snapshot.dirty.isDirty && !options.allowDirty) issues.push("worktree has uncommitted changes"); + if (hasUnresolvedIndex(snapshot)) issues.push("unresolved index state; resolve conflicts before finish"); const lockPaths = activeLockPaths(snapshot); if (lockPaths.length > 0) issues.push(`active Async run locks remain: ${lockPaths.join(", ")}`); - if ( - snapshot.workflowMode === "coordinated-branch" && - (["merge", "branch"].includes(run?.intent) || isCompletionLifecycle(run)) - ) { - if (!verificationMatches(snapshot, run)) { - issues.push("coordinated branch run lacks passing verification for its final branch HEAD"); - } + if (contract.manualRouting.required && !contract.manualRouting.satisfied) { + issues.push("missing manual routing confirmation; rerun auto-git start with an explicit lifecycle"); + } + if (contract.commit.required && contract.commit.changesMade && !contract.commit.recorded) { + issues.push("missing commit evidence; record the final run state with auto-git snapshot --write-state before finish"); + } + if (contract.branchOrWorktree.required && !contract.branchOrWorktree.satisfied) { + issues.push("missing branch/worktree evidence; heartbeat the run from its isolated branch or worktree before finish"); + } + if (requiresVerification(run) && !verificationMatches(snapshot, run)) { + issues.push("missing verification evidence; run auto-git gate or record a passing verification for the final HEAD"); + } + if (contract.push.required && !contract.push.satisfied) { + issues.push("missing push/sync evidence; push the required branch or merged base before finish"); } issues.push(...completionBlockers(completion, handoff)); if (handoff?.required && !handoff.satisfied) { - issues.push("coordinated branch has no recorded PR handoff and is not merged into base"); + issues.push("missing PR/merge/land evidence; record a PR handoff or merge and push the base before finish"); } if (handoff?.merge?.mergedIntoBase) { issues.push(...(handoff.merge.blockers ?? [])); } - return issues; + if (requiresReturnToBase(run) && completion.required && !completion.returnedToBase) { + issues.push(`missing return-to-base evidence; switch back to ${completion.baseBranch} before finish`); + } + if (contract.releasePreflight.required && !contract.releasePreflight.satisfied) { + issues.push("missing release-preflight evidence; run auto-git release-preflight --require-verification before finish"); + } + if (contract.releaseCompletion.required && !contract.releaseCompletion.satisfied) { + issues.push("missing release execution or deferral evidence; execute release or pass --defer-release when explicitly deferred"); + } + if (contract.threadHandoff.required && !contract.threadHandoff.satisfied) { + issues.push("missing follow-up thread evidence; record the thread handoff before finish"); + } + return [...new Set(issues)]; } function inspect(cwd, runId) { @@ -370,10 +622,27 @@ function buildReceipt(options) { run = currentRun(snapshot, runId); } + if (options.deferRelease) { + if (!runId) throw new Error("--defer-release requires --run-id when no active run is uniquely resolvable."); + ensureStateWrite(runSnapshot(["--cwd", cwd, "--write-state", "--record-release-deferral", runId]), "record release deferral"); + mutations.push("record-release-deferral"); + snapshot = inspect(cwd, runId); + run = currentRun(snapshot, runId); + } + const completion = branchCompletion(snapshot, run, cwd); const merge = mergeCheck(run, cwd, completion); const handoff = handoffCheck(run, merge, completion); - const issues = blockers(snapshot, run, options, completion, handoff); + const contract = { + manualRouting: manualRoutingCheck(run), + commit: commitEvidence(snapshot, run, cwd), + branchOrWorktree: branchOrWorktreeCheck(snapshot, run, completion), + push: pushCheck(snapshot, run, cwd, completion, merge), + releasePreflight: releasePreflightCheck(snapshot, run), + releaseCompletion: releaseCompletionCheck(run), + threadHandoff: threadHandoffCheck(run) + }; + const issues = blockers(snapshot, run, options, completion, handoff, contract); let completed = false; if (options.complete && issues.length === 0) { if (!runId) throw new Error("--complete requires --run-id when no active run is uniquely resolvable."); @@ -413,6 +682,7 @@ function buildReceipt(options) { activeAsyncRunLocks: activeLockPaths(snapshot) }, verificationMatchesCurrentHead: verificationMatches(snapshot, run), + contract, branchCompletion: completion, handoffCheck: handoff, ledger, diff --git a/gists/auto-git/auto-git.script-auto-git-ledger.mjs b/gists/auto-git/auto-git.script-auto-git-ledger.mjs index 85168c3..8f01a46 100644 --- a/gists/auto-git/auto-git.script-auto-git-ledger.mjs +++ b/gists/auto-git/auto-git.script-auto-git-ledger.mjs @@ -108,6 +108,10 @@ function publicRun(run, statusById) { } : undefined, pr: run.pr, + releasePreflight: run.releasePreflight, + releaseExecution: run.releaseExecution, + releaseDeferral: run.releaseDeferral, + threadHandoff: run.threadHandoff, decisionReceipt: run.decisionReceipt }; } diff --git a/gists/auto-git/auto-git.script-auto-git-release-preflight.mjs b/gists/auto-git/auto-git.script-auto-git-release-preflight.mjs index 2396349..f2ea890 100644 --- a/gists/auto-git/auto-git.script-auto-git-release-preflight.mjs +++ b/gists/auto-git/auto-git.script-auto-git-release-preflight.mjs @@ -10,7 +10,7 @@ const SNAPSHOT_SCRIPT = new URL("./auto-git-snapshot.mjs", import.meta.url); function usage() { return [ "Usage: auto-git-release-preflight.mjs [--cwd ] [--version ]", - " [--tag-prefix ] [--require-verification] [--check-remote] [--json]", + " [--tag-prefix ] [--run-id ] [--require-verification] [--check-remote] [--json]", "", "Checks release metadata before creating or pushing a release tag." ].join("\n"); @@ -21,6 +21,7 @@ function parseArgs(argv) { cwd: process.cwd(), version: undefined, tagPrefix: "v", + runId: undefined, requireVerification: false, checkRemote: false, json: false @@ -43,6 +44,10 @@ function parseArgs(argv) { parsed.tagPrefix = requireValue(argv, ++index, arg); continue; } + if (arg === "--run-id") { + parsed.runId = requireValue(argv, ++index, arg); + continue; + } if (arg === "--require-verification") { parsed.requireVerification = true; continue; @@ -77,6 +82,20 @@ function runSnapshot(cwd) { return payload.snapshot; } +function runSnapshotMutation(args) { + const result = spawnSync(process.execPath, [SNAPSHOT_SCRIPT.pathname, ...args], { + encoding: "utf8", + env: process.env + }); + if (result.status !== 0) return { ok: false, reason: result.stderr || result.stdout || `snapshot exited ${result.status}` }; + try { + const payload = JSON.parse(result.stdout); + return payload.stateWrite ?? { ok: false, reason: "snapshot mutation did not return stateWrite" }; + } catch (error) { + return { ok: false, reason: String(error?.message ?? error) }; + } +} + function git(cwd, args) { return spawnSync("git", args, { cwd, encoding: "utf8" }); } @@ -159,6 +178,34 @@ function githubReleaseStatus(repoRoot, tagName) { return { checked: true, exists: false, warning: gh.stderr.trim() || "gh release view failed" }; } +function currentRun(snapshot, requestedId) { + const runs = [ + ...(snapshot.occupancy?.activeRuns ?? []), + ...(snapshot.occupancy?.staleRuns ?? []), + ...(snapshot.handoffs?.openPrs ?? []) + ]; + if (requestedId) return runs.find((run) => run.id === requestedId); + const active = snapshot.occupancy?.activeRuns ?? []; + if (active.length === 1) return active[0]; + return runs.find((run) => run.branch === snapshot.topology.branch); +} + +function recordPreflightEvidence(repoRoot, snapshot, options, tagName, version, safeToTag) { + if (!safeToTag) return { ok: false, skipped: true, reason: "preflight did not pass" }; + const run = currentRun(snapshot, options.runId); + if (!run?.id) return { ok: false, skipped: true, reason: "no Auto Git run resolved for release-preflight evidence" }; + const args = [ + "--cwd", + repoRoot, + "--write-state", + "--record-release-preflight", + run.id + ]; + if (version) args.push("--release-version", version); + if (tagName) args.push("--release-tag", tagName); + return runSnapshotMutation(args); +} + function buildReceipt(options) { const repoRoot = resolve(options.cwd); const snapshot = runSnapshot(repoRoot); @@ -212,13 +259,20 @@ function buildReceipt(options) { if (githubRelease.exists) blockers.push(`GitHub Release ${tagName} already exists`); if (githubRelease.warning) warnings.push(githubRelease.warning); + const safeToTag = blockers.length === 0; + const evidenceStateWrite = recordPreflightEvidence(repoRoot, snapshot, options, tagName, version, safeToTag); + if (safeToTag && evidenceStateWrite.ok === false && !evidenceStateWrite.skipped) { + warnings.push(`release-preflight evidence was not recorded: ${evidenceStateWrite.reason ?? "unknown error"}`); + } + return { schemaVersion: 1, tool: "auto-git-release-preflight", - ok: blockers.length === 0, - safeToTag: blockers.length === 0, + ok: safeToTag, + safeToTag, blockers, warnings, + evidenceStateWrite, repo: { root: snapshot.repo.root, branch: snapshot.topology.branch, diff --git a/gists/auto-git/auto-git.script-auto-git-snapshot.mjs b/gists/auto-git/auto-git.script-auto-git-snapshot.mjs index 8e99187..76f6716 100644 --- a/gists/auto-git/auto-git.script-auto-git-snapshot.mjs +++ b/gists/auto-git/auto-git.script-auto-git-snapshot.mjs @@ -55,6 +55,8 @@ function usage() { " [--lifecycle ]", " [--heartbeat-run ] [--complete-run ]", " [--record-pr --pr-url [--pr-number ]]", + " [--record-release-preflight ] [--release-version ] [--release-tag ]", + " [--record-release-deferral ]", " [--lease-ttl-ms ]", " [--record-verification --exit-code ]", " [--execution-profile ] [--duration-ms ] [--failure-class ]", @@ -79,6 +81,10 @@ function parseArgs(argv) { prNumber: undefined, prBranch: undefined, prStatus: "open", + recordReleasePreflight: undefined, + releaseVersion: undefined, + releaseTag: undefined, + recordReleaseDeferral: undefined, baseBranch: undefined, leaseTtlMs: DEFAULT_LEASE_TTL_MS, recordVerification: undefined, @@ -146,6 +152,22 @@ function parseArgs(argv) { parsed.prStatus = requireValue(argv, ++index, arg); continue; } + if (arg === "--record-release-preflight") { + parsed.recordReleasePreflight = requireValue(argv, ++index, arg); + continue; + } + if (arg === "--release-version") { + parsed.releaseVersion = requireValue(argv, ++index, arg); + continue; + } + if (arg === "--release-tag") { + parsed.releaseTag = requireValue(argv, ++index, arg); + continue; + } + if (arg === "--record-release-deferral") { + parsed.recordReleaseDeferral = requireValue(argv, ++index, arg); + continue; + } if (arg === "--base-branch") { parsed.baseBranch = requireValue(argv, ++index, arg); continue; @@ -184,7 +206,9 @@ function parseArgs(argv) { parsed.claimRun !== undefined || parsed.heartbeatRun !== undefined || parsed.completeRun !== undefined || - parsed.recordPr !== undefined; + parsed.recordPr !== undefined || + parsed.recordReleasePreflight !== undefined || + parsed.recordReleaseDeferral !== undefined; if (mutatesLedger && !parsed.writeState) { throw new Error("Run ledger updates require --write-state."); } @@ -224,6 +248,10 @@ function parseArgs(argv) { ["--record-pr", parsed.recordPr], ["--pr-url", parsed.prUrl], ["--pr-branch", parsed.prBranch], + ["--record-release-preflight", parsed.recordReleasePreflight], + ["--release-version", parsed.releaseVersion], + ["--release-tag", parsed.releaseTag], + ["--record-release-deferral", parsed.recordReleaseDeferral], ["--base-branch", parsed.baseBranch] ]) { if (typeof value === "string" && looksSecretish(value)) { @@ -1027,6 +1055,10 @@ function normalizeRun(run) { commits: Array.isArray(run.commits) ? run.commits.filter((commit) => typeof commit === "string").slice(0, 200) : [], verification: normalizeVerification(run.verification), pr: normalizePr(run.pr), + releasePreflight: normalizeReleasePreflight(run.releasePreflight), + releaseExecution: normalizeReleaseExecution(run.releaseExecution), + releaseDeferral: normalizeReleaseDeferral(run.releaseDeferral), + threadHandoff: normalizeThreadHandoff(run.threadHandoff), decisionReceipt: normalizeDecisionReceipt(run.decisionReceipt) }; } @@ -1108,6 +1140,45 @@ function normalizePr(pr) { }; } +function normalizeReleasePreflight(preflight) { + if (!preflight || typeof preflight !== "object") return undefined; + return { + safeToTag: preflight.safeToTag === true, + version: typeof preflight.version === "string" && !looksSecretish(preflight.version) ? preflight.version.slice(0, 80) : undefined, + tagName: typeof preflight.tagName === "string" && !looksSecretish(preflight.tagName) ? preflight.tagName.slice(0, 120) : undefined, + recordedAt: typeof preflight.recordedAt === "string" ? preflight.recordedAt : undefined, + head: typeof preflight.head === "string" ? preflight.head : undefined, + dirtyFingerprint: typeof preflight.dirtyFingerprint === "string" ? preflight.dirtyFingerprint : undefined + }; +} + +function normalizeReleaseExecution(execution) { + if (!execution || typeof execution !== "object") return undefined; + return { + status: execution.status === "executed" ? "executed" : undefined, + recordedAt: typeof execution.recordedAt === "string" ? execution.recordedAt : undefined, + head: typeof execution.head === "string" ? execution.head : undefined + }; +} + +function normalizeReleaseDeferral(deferral) { + if (!deferral || typeof deferral !== "object") return undefined; + return { + status: deferral.status === "deferred" ? "deferred" : undefined, + recordedAt: typeof deferral.recordedAt === "string" ? deferral.recordedAt : undefined, + head: typeof deferral.head === "string" ? deferral.head : undefined + }; +} + +function normalizeThreadHandoff(handoff) { + if (!handoff || typeof handoff !== "object") return undefined; + return { + status: typeof handoff.status === "string" && !looksSecretish(handoff.status) ? handoff.status.slice(0, 40) : undefined, + threadId: typeof handoff.threadId === "string" && !looksSecretish(handoff.threadId) ? handoff.threadId.slice(0, 120) : undefined, + recordedAt: typeof handoff.recordedAt === "string" ? handoff.recordedAt : undefined + }; +} + function upsertRun(runs, run) { const index = runs.findIndex((existing) => existing.id === run.id); if (index === -1) return [run, ...runs].slice(0, 100); @@ -1260,6 +1331,41 @@ function mutateLedger(snapshot, ledger, options, updatedAt) { changed = true; } + if (options.recordReleasePreflight) { + const id = sanitizeRunId(options.recordReleasePreflight); + currentRunId = id; + const existing = runs.find((run) => run.id === id); + if (!existing) throw new Error(`Cannot record release preflight for unknown Auto Git run: ${id}`); + runs = upsertRun(runs, { + ...existing, + releasePreflight: { + safeToTag: true, + version: typeof options.releaseVersion === "string" ? options.releaseVersion.slice(0, 80) : undefined, + tagName: typeof options.releaseTag === "string" ? options.releaseTag.slice(0, 120) : undefined, + recordedAt: updatedAt, + head: snapshot.topology.head, + dirtyFingerprint: snapshot.dirty.fingerprint + } + }); + changed = true; + } + + if (options.recordReleaseDeferral) { + const id = sanitizeRunId(options.recordReleaseDeferral); + currentRunId = id; + const existing = runs.find((run) => run.id === id); + if (!existing) throw new Error(`Cannot record release deferral for unknown Auto Git run: ${id}`); + runs = upsertRun(runs, { + ...existing, + releaseDeferral: { + status: "deferred", + recordedAt: updatedAt, + head: snapshot.topology.head + } + }); + changed = true; + } + return { ledger: { schemaVersion: SCHEMA_VERSION, updatedAt: changed ? updatedAt : ledger.updatedAt, runs }, changed, @@ -1380,6 +1486,10 @@ function publicRun(run, state) { commits: run.commits, verification: run.verification, pr: run.pr, + releasePreflight: run.releasePreflight, + releaseExecution: run.releaseExecution, + releaseDeferral: run.releaseDeferral, + threadHandoff: run.threadHandoff, decisionReceipt: run.decisionReceipt }; } diff --git a/skills/auto-git/SKILL.md b/skills/auto-git/SKILL.md index c0a9250..6c7a6f3 100644 --- a/skills/auto-git/SKILL.md +++ b/skills/auto-git/SKILL.md @@ -250,18 +250,26 @@ PATH, use the installed skill's `scripts/*.mjs` helper paths as a fallback. ledger metadata - never deletes ledger entries - `auto-git finish --cwd "$PWD" --run-id "" [--complete]` - - checks dirty state, HEAD/upstream, active run locks, PR readiness, and - verification against current HEAD + - checks dirty state, unresolved index state, HEAD/upstream, active run + locks, PR readiness, and verification against current HEAD + - validates the run's `decisionReceipt` completion gates before reporting + done; missing gate evidence fails closed with a short actionable blocker - blocks completion for coordinated/everything/yolo branch work until the branch is pushed upstream and the checkout is switched back to main/default - checks whether there is a recorded PR handoff or pushed merge evidence, and whether the ledger update actually completed + - blocks release/yolo completion until release-preflight evidence is recorded + and release execution is recorded or explicitly deferred with + `--defer-release` + - blocks follow-up-thread completion until thread handoff evidence exists - preserves the completed branch/head in the ledger even when completion is run from main/default after cleanup - records PR metadata when asked and completes the run only when safe -- `auto-git release-preflight --cwd "$PWD" [--require-verification]` +- `auto-git release-preflight --cwd "$PWD" [--run-id ""] [--require-verification]` - checks package version, changelog/release notes, dirty state, existing local tag conflicts, and optional remote release/tag state before tagging + - records successful release-preflight evidence to the active or requested + Auto Git run using safe metadata only - successful clean release verification may be reused after switching back to main/default when `HEAD` is unchanged, even if the upstream branch context changed the dirty fingerprint @@ -269,7 +277,7 @@ PATH, use the installed skill's `scripts/*.mjs` helper paths as a fallback. ## Global Async State -Auto Git may use global advisory state under `~/.async/auto-git/v1/repos//` to avoid repeating expensive inspection and to coordinate across chats. Live runtime leases use Async-compatible lock records under `~/.async/locks/auto-git/repos//runs/*.lease.json`; completion removes the live lease and writes a receipt under `~/.async/locks/auto-git/history/`. This state is a cache of safe metadata: fingerprints, file path lists, commit ids, command names, exit codes, timestamps, lock classifications, process ids started by Auto Git, execution profiles, generated env override names/values, durations, recovery hints, run ids, task slugs, lifecycle modes, coordinated intents, branch names, worktree paths, base branches, lease expirations, lease paths, verification keys, and PR URLs/statuses. +Auto Git may use global advisory state under `~/.async/auto-git/v1/repos//` to avoid repeating expensive inspection and to coordinate across chats. Live runtime leases use Async-compatible lock records under `~/.async/locks/auto-git/repos//runs/*.lease.json`; completion removes the live lease and writes a receipt under `~/.async/locks/auto-git/history/`. This state is a cache of safe metadata: fingerprints, file path lists, commit ids, command names, exit codes, timestamps, lock classifications, process ids started by Auto Git, execution profiles, generated env override names/values, durations, recovery hints, run ids, task slugs, lifecycle modes, coordinated intents, branch names, worktree paths, base branches, lease expirations, lease paths, verification keys, release-preflight evidence, release deferral state, thread handoff ids/status, and PR URLs/statuses. The ledger is cooperative. Auto Git can reliably detect stale or inactive chats only when those chats used Auto Git and wrote ledger state. A run is active diff --git a/skills/auto-git/scripts/auto-git-finish.mjs b/skills/auto-git/scripts/auto-git-finish.mjs index e585637..78bbd68 100755 --- a/skills/auto-git/scripts/auto-git-finish.mjs +++ b/skills/auto-git/scripts/auto-git-finish.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { spawnSync } from "node:child_process"; import { join, resolve } from "node:path"; @@ -10,7 +10,7 @@ function usage() { return [ "Usage: auto-git-finish.mjs [--cwd ] [--run-id ] [--complete]", " [--record-pr [--pr-number ] [--pr-status ]]", - " [--allow-dirty] [--json]", + " [--defer-release] [--allow-dirty] [--json]", "", "Inspects final Auto Git state, optionally records a PR, and completes the run when safe.", "Coordinated branch completion requires a pushed branch and a checkout returned to base." @@ -25,6 +25,7 @@ function parseArgs(argv) { recordPr: undefined, prNumber: undefined, prStatus: "open", + deferRelease: false, allowDirty: false, json: false }; @@ -59,6 +60,10 @@ function parseArgs(argv) { parsed.prStatus = requireValue(argv, ++index, arg); continue; } + if (arg === "--defer-release") { + parsed.deferRelease = true; + continue; + } if (arg === "--allow-dirty") { parsed.allowDirty = true; continue; @@ -143,6 +148,73 @@ function isCompletionLifecycle(run) { return run?.lifecycle === "everything" || run?.lifecycle === "yolo"; } +function decisionReceipt(run) { + return run?.decisionReceipt; +} + +function decisionGates(run) { + return new Set(decisionReceipt(run)?.completionGates ?? []); +} + +function hasGate(run, gate) { + return decisionGates(run).has(gate); +} + +function decisionIntent(run) { + return decisionReceipt(run)?.normalizedIntentLabel; +} + +function decisionWorkflow(run) { + return decisionReceipt(run)?.selectedWorkflowMode; +} + +function requiresVerification(run) { + return hasGate(run, "verification") || ["sync", "release", "land", "everything", "yolo"].includes(decisionIntent(run)); +} + +function requiresPushEvidence(run) { + return ( + hasGate(run, "branch-pushed") || + hasGate(run, "branch-pushed-before-tag") || + ["sync", "release", "PR", "merge", "land", "everything", "yolo"].includes(decisionIntent(run)) + ); +} + +function requiresBranchOrWorktreeEvidence(run) { + return ( + hasGate(run, "isolated-branch-or-worktree") || + hasGate(run, "isolated-worktrees") || + decisionWorkflow(run) === "coordinated-branch-worktree" + ); +} + +function requiresHandoffEvidence(run) { + return hasGate(run, "pr-handoff-or-merge-evidence") || ["PR", "merge", "land", "everything", "yolo"].includes(decisionIntent(run)); +} + +function requiresReleasePreflight(run) { + return ( + decisionReceipt(run)?.releasePreflightRequired === true || + hasGate(run, "release-preflight") || + hasGate(run, "release-preflight-before-release-action") || + ["release", "yolo"].includes(decisionIntent(run)) || + run?.intent === "release" || + run?.lifecycle === "yolo" + ); +} + +function requiresReleaseCompletion(run) { + return ["release", "yolo"].includes(decisionIntent(run)) || run?.intent === "release" || run?.lifecycle === "yolo"; +} + +function requiresThreadHandoff(run) { + return decisionReceipt(run)?.threadHandoffRequired === true || hasGate(run, "thread-handoff-evidence"); +} + +function requiresReturnToBase(run) { + return hasGate(run, "return-to-base") || hasGate(run, "handoff-or-return-to-base") || requiresBranchOrWorktreeEvidence(run); +} + function git(cwd, args) { return spawnSync("git", args, { cwd, encoding: "utf8" }); } @@ -284,9 +356,7 @@ function mergeCheck(run, cwd, completion) { function handoffCheck(run, merge, completion) { const pr = prHandoff(run); - const required = Boolean( - completion.required && (isCompletionLifecycle(run) || ["merge", "branch"].includes(run?.intent)) - ); + const required = Boolean(requiresHandoffEvidence(run)); return { required, satisfied: !required || pr.handoffRecorded || merge.mergedIntoBase, @@ -295,6 +365,167 @@ function handoffCheck(run, merge, completion) { }; } +function branchPushState(cwd, branch) { + const result = { + branch, + required: Boolean(branch), + upstream: undefined, + aheadOfUpstream: undefined, + behindUpstream: undefined, + pushed: false, + blockers: [] + }; + if (!branch) { + result.blockers.push("missing branch name for push check"); + return result; + } + const upstream = git(cwd, ["rev-parse", "--abbrev-ref", `${branch}@{upstream}`]); + if (upstream.status !== 0 || !upstream.stdout.trim()) { + result.blockers.push(`branch ${branch} has no upstream`); + return result; + } + result.upstream = upstream.stdout.trim(); + const counts = git(cwd, ["rev-list", "--left-right", "--count", `${result.upstream}...${branch}`]); + if (counts.status !== 0) { + result.blockers.push(`could not compare ${branch} with ${result.upstream}`); + return result; + } + const [behind, ahead] = counts.stdout + .trim() + .split(/\s+/) + .map((value) => Number(value)); + result.behindUpstream = Number.isFinite(behind) ? behind : undefined; + result.aheadOfUpstream = Number.isFinite(ahead) ? ahead : undefined; + result.pushed = result.aheadOfUpstream === 0; + if (result.aheadOfUpstream && result.aheadOfUpstream > 0) { + result.blockers.push(`branch ${branch} has ${result.aheadOfUpstream} unpushed commit(s)`); + } + return result; +} + +function pushCheck(snapshot, run, cwd, completion, merge) { + const required = requiresPushEvidence(run); + const branch = completion.branch ?? run?.branch ?? snapshot.topology.branch; + if (!required) return { required, satisfied: true, branch, pushed: true, blockers: [] }; + if (merge?.mergedIntoBase) { + return { + required, + satisfied: merge.basePushed, + branch: merge.baseBranch, + pushed: merge.basePushed, + upstream: merge.baseUpstream, + blockers: merge.basePushed ? [] : merge.blockers + }; + } + if (completion.required) { + return { + required, + satisfied: completion.pushed, + branch: completion.branch, + pushed: completion.pushed, + upstream: completion.upstream, + blockers: completion.blockers.filter((blocker) => !blocker.startsWith("checkout is still on ")) + }; + } + const state = branchPushState(cwd, branch); + return { + required, + satisfied: state.pushed, + ...state + }; +} + +function hasUnresolvedIndex(snapshot) { + return (snapshot.dirty?.statusPorcelain ?? []).some((line) => /^(DD|AU|UD|UA|DU|AA|UU)\s/.test(line)); +} + +function commitEvidence(snapshot, run, cwd) { + const baseBranch = defaultBaseBranch(snapshot, run); + const currentBranch = snapshot.topology.branch || "HEAD"; + const recordedCommits = Array.isArray(run?.commits) ? run.commits : []; + const ahead = baseBranch && currentBranch !== baseBranch ? git(cwd, ["rev-list", "--reverse", `${baseBranch}..${currentBranch}`]) : undefined; + const currentAheadCommits = ahead?.status === 0 ? ahead.stdout.trim().split("\n").filter(Boolean) : []; + const headChanged = Boolean(run?.head && snapshot.topology.head && run.head !== snapshot.topology.head); + const changesMade = recordedCommits.length > 0 || currentAheadCommits.length > 0 || headChanged; + return { + required: hasGate(run, "commit-by-intent") || hasGate(run, "commit-by-intent-per-worktree") || hasGate(run, "release-metadata-commit"), + changesMade, + recorded: recordedCommits.length > 0 || (!changesMade && Boolean(run?.head)), + recordedCommits, + currentAheadCommitCount: currentAheadCommits.length, + headChanged + }; +} + +function branchOrWorktreeCheck(snapshot, run, completion) { + const required = requiresBranchOrWorktreeEvidence(run); + const baseBranch = defaultBaseBranch(snapshot, run); + const branch = run?.branch ?? completion.branch; + const branchIsIsolated = Boolean(branch && branch !== baseBranch); + const worktreeExists = Boolean(run?.worktreePath && existsSync(run.worktreePath)); + return { + required, + satisfied: !required || (branchIsIsolated && worktreeExists), + branch, + baseBranch, + worktreePath: run?.worktreePath, + worktreeExists + }; +} + +function releasePreflightCheck(snapshot, run) { + const required = requiresReleasePreflight(run); + const evidence = run?.releasePreflight; + const headMatches = Boolean( + evidence?.head && (evidence.head === run?.head || evidence.head === snapshot.topology.head) + ); + const cleanEnough = !snapshot.dirty?.isDirty || evidence?.dirtyFingerprint === snapshot.dirty.fingerprint || evidence?.dirtyFingerprint === run?.dirtyFingerprint; + return { + required, + satisfied: !required || Boolean(evidence?.safeToTag === true && headMatches && cleanEnough), + evidence: evidence + ? { + safeToTag: evidence.safeToTag, + version: evidence.version, + tagName: evidence.tagName, + recordedAt: evidence.recordedAt, + head: evidence.head + } + : undefined + }; +} + +function releaseCompletionCheck(run) { + const required = requiresReleaseCompletion(run); + const executed = run?.releaseExecution?.status === "executed"; + const deferred = run?.releaseDeferral?.status === "deferred"; + return { + required, + satisfied: !required || executed || deferred, + executed, + deferred, + deferral: run?.releaseDeferral + }; +} + +function threadHandoffCheck(run) { + const required = requiresThreadHandoff(run); + const recorded = Boolean(run?.threadHandoff?.threadId || run?.threadHandoff?.status === "recorded"); + return { + required, + satisfied: !required || recorded, + handoff: run?.threadHandoff + }; +} + +function manualRoutingCheck(run) { + const required = hasGate(run, "manual-routing-confirmation"); + return { + required, + satisfied: !required + }; +} + function ledgerStatus(snapshot, runId) { const ledgerPath = join(stateRoot(), "repos", snapshot.repo.hash, "ledger.json"); const ledger = readJson(ledgerPath, { runs: [] }); @@ -323,28 +554,49 @@ function completionBlockers(completion, handoff) { return blockers.filter((blocker) => blocker.startsWith("checkout is still on ")); } -function blockers(snapshot, run, options, completion, handoff) { +function blockers(snapshot, run, options, completion, handoff, contract) { const issues = []; if (!run) issues.push("no Auto Git run could be resolved; pass --run-id"); + if (run && !decisionReceipt(run)) issues.push("missing start decision receipt; rerun auto-git start before finish"); if (snapshot.dirty.isDirty && !options.allowDirty) issues.push("worktree has uncommitted changes"); + if (hasUnresolvedIndex(snapshot)) issues.push("unresolved index state; resolve conflicts before finish"); const lockPaths = activeLockPaths(snapshot); if (lockPaths.length > 0) issues.push(`active Async run locks remain: ${lockPaths.join(", ")}`); - if ( - snapshot.workflowMode === "coordinated-branch" && - (["merge", "branch"].includes(run?.intent) || isCompletionLifecycle(run)) - ) { - if (!verificationMatches(snapshot, run)) { - issues.push("coordinated branch run lacks passing verification for its final branch HEAD"); - } + if (contract.manualRouting.required && !contract.manualRouting.satisfied) { + issues.push("missing manual routing confirmation; rerun auto-git start with an explicit lifecycle"); + } + if (contract.commit.required && contract.commit.changesMade && !contract.commit.recorded) { + issues.push("missing commit evidence; record the final run state with auto-git snapshot --write-state before finish"); + } + if (contract.branchOrWorktree.required && !contract.branchOrWorktree.satisfied) { + issues.push("missing branch/worktree evidence; heartbeat the run from its isolated branch or worktree before finish"); + } + if (requiresVerification(run) && !verificationMatches(snapshot, run)) { + issues.push("missing verification evidence; run auto-git gate or record a passing verification for the final HEAD"); + } + if (contract.push.required && !contract.push.satisfied) { + issues.push("missing push/sync evidence; push the required branch or merged base before finish"); } issues.push(...completionBlockers(completion, handoff)); if (handoff?.required && !handoff.satisfied) { - issues.push("coordinated branch has no recorded PR handoff and is not merged into base"); + issues.push("missing PR/merge/land evidence; record a PR handoff or merge and push the base before finish"); } if (handoff?.merge?.mergedIntoBase) { issues.push(...(handoff.merge.blockers ?? [])); } - return issues; + if (requiresReturnToBase(run) && completion.required && !completion.returnedToBase) { + issues.push(`missing return-to-base evidence; switch back to ${completion.baseBranch} before finish`); + } + if (contract.releasePreflight.required && !contract.releasePreflight.satisfied) { + issues.push("missing release-preflight evidence; run auto-git release-preflight --require-verification before finish"); + } + if (contract.releaseCompletion.required && !contract.releaseCompletion.satisfied) { + issues.push("missing release execution or deferral evidence; execute release or pass --defer-release when explicitly deferred"); + } + if (contract.threadHandoff.required && !contract.threadHandoff.satisfied) { + issues.push("missing follow-up thread evidence; record the thread handoff before finish"); + } + return [...new Set(issues)]; } function inspect(cwd, runId) { @@ -370,10 +622,27 @@ function buildReceipt(options) { run = currentRun(snapshot, runId); } + if (options.deferRelease) { + if (!runId) throw new Error("--defer-release requires --run-id when no active run is uniquely resolvable."); + ensureStateWrite(runSnapshot(["--cwd", cwd, "--write-state", "--record-release-deferral", runId]), "record release deferral"); + mutations.push("record-release-deferral"); + snapshot = inspect(cwd, runId); + run = currentRun(snapshot, runId); + } + const completion = branchCompletion(snapshot, run, cwd); const merge = mergeCheck(run, cwd, completion); const handoff = handoffCheck(run, merge, completion); - const issues = blockers(snapshot, run, options, completion, handoff); + const contract = { + manualRouting: manualRoutingCheck(run), + commit: commitEvidence(snapshot, run, cwd), + branchOrWorktree: branchOrWorktreeCheck(snapshot, run, completion), + push: pushCheck(snapshot, run, cwd, completion, merge), + releasePreflight: releasePreflightCheck(snapshot, run), + releaseCompletion: releaseCompletionCheck(run), + threadHandoff: threadHandoffCheck(run) + }; + const issues = blockers(snapshot, run, options, completion, handoff, contract); let completed = false; if (options.complete && issues.length === 0) { if (!runId) throw new Error("--complete requires --run-id when no active run is uniquely resolvable."); @@ -413,6 +682,7 @@ function buildReceipt(options) { activeAsyncRunLocks: activeLockPaths(snapshot) }, verificationMatchesCurrentHead: verificationMatches(snapshot, run), + contract, branchCompletion: completion, handoffCheck: handoff, ledger, diff --git a/skills/auto-git/scripts/auto-git-ledger.mjs b/skills/auto-git/scripts/auto-git-ledger.mjs index 85168c3..8f01a46 100755 --- a/skills/auto-git/scripts/auto-git-ledger.mjs +++ b/skills/auto-git/scripts/auto-git-ledger.mjs @@ -108,6 +108,10 @@ function publicRun(run, statusById) { } : undefined, pr: run.pr, + releasePreflight: run.releasePreflight, + releaseExecution: run.releaseExecution, + releaseDeferral: run.releaseDeferral, + threadHandoff: run.threadHandoff, decisionReceipt: run.decisionReceipt }; } diff --git a/skills/auto-git/scripts/auto-git-release-preflight.mjs b/skills/auto-git/scripts/auto-git-release-preflight.mjs index 2396349..f2ea890 100755 --- a/skills/auto-git/scripts/auto-git-release-preflight.mjs +++ b/skills/auto-git/scripts/auto-git-release-preflight.mjs @@ -10,7 +10,7 @@ const SNAPSHOT_SCRIPT = new URL("./auto-git-snapshot.mjs", import.meta.url); function usage() { return [ "Usage: auto-git-release-preflight.mjs [--cwd ] [--version ]", - " [--tag-prefix ] [--require-verification] [--check-remote] [--json]", + " [--tag-prefix ] [--run-id ] [--require-verification] [--check-remote] [--json]", "", "Checks release metadata before creating or pushing a release tag." ].join("\n"); @@ -21,6 +21,7 @@ function parseArgs(argv) { cwd: process.cwd(), version: undefined, tagPrefix: "v", + runId: undefined, requireVerification: false, checkRemote: false, json: false @@ -43,6 +44,10 @@ function parseArgs(argv) { parsed.tagPrefix = requireValue(argv, ++index, arg); continue; } + if (arg === "--run-id") { + parsed.runId = requireValue(argv, ++index, arg); + continue; + } if (arg === "--require-verification") { parsed.requireVerification = true; continue; @@ -77,6 +82,20 @@ function runSnapshot(cwd) { return payload.snapshot; } +function runSnapshotMutation(args) { + const result = spawnSync(process.execPath, [SNAPSHOT_SCRIPT.pathname, ...args], { + encoding: "utf8", + env: process.env + }); + if (result.status !== 0) return { ok: false, reason: result.stderr || result.stdout || `snapshot exited ${result.status}` }; + try { + const payload = JSON.parse(result.stdout); + return payload.stateWrite ?? { ok: false, reason: "snapshot mutation did not return stateWrite" }; + } catch (error) { + return { ok: false, reason: String(error?.message ?? error) }; + } +} + function git(cwd, args) { return spawnSync("git", args, { cwd, encoding: "utf8" }); } @@ -159,6 +178,34 @@ function githubReleaseStatus(repoRoot, tagName) { return { checked: true, exists: false, warning: gh.stderr.trim() || "gh release view failed" }; } +function currentRun(snapshot, requestedId) { + const runs = [ + ...(snapshot.occupancy?.activeRuns ?? []), + ...(snapshot.occupancy?.staleRuns ?? []), + ...(snapshot.handoffs?.openPrs ?? []) + ]; + if (requestedId) return runs.find((run) => run.id === requestedId); + const active = snapshot.occupancy?.activeRuns ?? []; + if (active.length === 1) return active[0]; + return runs.find((run) => run.branch === snapshot.topology.branch); +} + +function recordPreflightEvidence(repoRoot, snapshot, options, tagName, version, safeToTag) { + if (!safeToTag) return { ok: false, skipped: true, reason: "preflight did not pass" }; + const run = currentRun(snapshot, options.runId); + if (!run?.id) return { ok: false, skipped: true, reason: "no Auto Git run resolved for release-preflight evidence" }; + const args = [ + "--cwd", + repoRoot, + "--write-state", + "--record-release-preflight", + run.id + ]; + if (version) args.push("--release-version", version); + if (tagName) args.push("--release-tag", tagName); + return runSnapshotMutation(args); +} + function buildReceipt(options) { const repoRoot = resolve(options.cwd); const snapshot = runSnapshot(repoRoot); @@ -212,13 +259,20 @@ function buildReceipt(options) { if (githubRelease.exists) blockers.push(`GitHub Release ${tagName} already exists`); if (githubRelease.warning) warnings.push(githubRelease.warning); + const safeToTag = blockers.length === 0; + const evidenceStateWrite = recordPreflightEvidence(repoRoot, snapshot, options, tagName, version, safeToTag); + if (safeToTag && evidenceStateWrite.ok === false && !evidenceStateWrite.skipped) { + warnings.push(`release-preflight evidence was not recorded: ${evidenceStateWrite.reason ?? "unknown error"}`); + } + return { schemaVersion: 1, tool: "auto-git-release-preflight", - ok: blockers.length === 0, - safeToTag: blockers.length === 0, + ok: safeToTag, + safeToTag, blockers, warnings, + evidenceStateWrite, repo: { root: snapshot.repo.root, branch: snapshot.topology.branch, diff --git a/skills/auto-git/scripts/auto-git-snapshot.mjs b/skills/auto-git/scripts/auto-git-snapshot.mjs index 8e99187..76f6716 100755 --- a/skills/auto-git/scripts/auto-git-snapshot.mjs +++ b/skills/auto-git/scripts/auto-git-snapshot.mjs @@ -55,6 +55,8 @@ function usage() { " [--lifecycle ]", " [--heartbeat-run ] [--complete-run ]", " [--record-pr --pr-url [--pr-number ]]", + " [--record-release-preflight ] [--release-version ] [--release-tag ]", + " [--record-release-deferral ]", " [--lease-ttl-ms ]", " [--record-verification --exit-code ]", " [--execution-profile ] [--duration-ms ] [--failure-class ]", @@ -79,6 +81,10 @@ function parseArgs(argv) { prNumber: undefined, prBranch: undefined, prStatus: "open", + recordReleasePreflight: undefined, + releaseVersion: undefined, + releaseTag: undefined, + recordReleaseDeferral: undefined, baseBranch: undefined, leaseTtlMs: DEFAULT_LEASE_TTL_MS, recordVerification: undefined, @@ -146,6 +152,22 @@ function parseArgs(argv) { parsed.prStatus = requireValue(argv, ++index, arg); continue; } + if (arg === "--record-release-preflight") { + parsed.recordReleasePreflight = requireValue(argv, ++index, arg); + continue; + } + if (arg === "--release-version") { + parsed.releaseVersion = requireValue(argv, ++index, arg); + continue; + } + if (arg === "--release-tag") { + parsed.releaseTag = requireValue(argv, ++index, arg); + continue; + } + if (arg === "--record-release-deferral") { + parsed.recordReleaseDeferral = requireValue(argv, ++index, arg); + continue; + } if (arg === "--base-branch") { parsed.baseBranch = requireValue(argv, ++index, arg); continue; @@ -184,7 +206,9 @@ function parseArgs(argv) { parsed.claimRun !== undefined || parsed.heartbeatRun !== undefined || parsed.completeRun !== undefined || - parsed.recordPr !== undefined; + parsed.recordPr !== undefined || + parsed.recordReleasePreflight !== undefined || + parsed.recordReleaseDeferral !== undefined; if (mutatesLedger && !parsed.writeState) { throw new Error("Run ledger updates require --write-state."); } @@ -224,6 +248,10 @@ function parseArgs(argv) { ["--record-pr", parsed.recordPr], ["--pr-url", parsed.prUrl], ["--pr-branch", parsed.prBranch], + ["--record-release-preflight", parsed.recordReleasePreflight], + ["--release-version", parsed.releaseVersion], + ["--release-tag", parsed.releaseTag], + ["--record-release-deferral", parsed.recordReleaseDeferral], ["--base-branch", parsed.baseBranch] ]) { if (typeof value === "string" && looksSecretish(value)) { @@ -1027,6 +1055,10 @@ function normalizeRun(run) { commits: Array.isArray(run.commits) ? run.commits.filter((commit) => typeof commit === "string").slice(0, 200) : [], verification: normalizeVerification(run.verification), pr: normalizePr(run.pr), + releasePreflight: normalizeReleasePreflight(run.releasePreflight), + releaseExecution: normalizeReleaseExecution(run.releaseExecution), + releaseDeferral: normalizeReleaseDeferral(run.releaseDeferral), + threadHandoff: normalizeThreadHandoff(run.threadHandoff), decisionReceipt: normalizeDecisionReceipt(run.decisionReceipt) }; } @@ -1108,6 +1140,45 @@ function normalizePr(pr) { }; } +function normalizeReleasePreflight(preflight) { + if (!preflight || typeof preflight !== "object") return undefined; + return { + safeToTag: preflight.safeToTag === true, + version: typeof preflight.version === "string" && !looksSecretish(preflight.version) ? preflight.version.slice(0, 80) : undefined, + tagName: typeof preflight.tagName === "string" && !looksSecretish(preflight.tagName) ? preflight.tagName.slice(0, 120) : undefined, + recordedAt: typeof preflight.recordedAt === "string" ? preflight.recordedAt : undefined, + head: typeof preflight.head === "string" ? preflight.head : undefined, + dirtyFingerprint: typeof preflight.dirtyFingerprint === "string" ? preflight.dirtyFingerprint : undefined + }; +} + +function normalizeReleaseExecution(execution) { + if (!execution || typeof execution !== "object") return undefined; + return { + status: execution.status === "executed" ? "executed" : undefined, + recordedAt: typeof execution.recordedAt === "string" ? execution.recordedAt : undefined, + head: typeof execution.head === "string" ? execution.head : undefined + }; +} + +function normalizeReleaseDeferral(deferral) { + if (!deferral || typeof deferral !== "object") return undefined; + return { + status: deferral.status === "deferred" ? "deferred" : undefined, + recordedAt: typeof deferral.recordedAt === "string" ? deferral.recordedAt : undefined, + head: typeof deferral.head === "string" ? deferral.head : undefined + }; +} + +function normalizeThreadHandoff(handoff) { + if (!handoff || typeof handoff !== "object") return undefined; + return { + status: typeof handoff.status === "string" && !looksSecretish(handoff.status) ? handoff.status.slice(0, 40) : undefined, + threadId: typeof handoff.threadId === "string" && !looksSecretish(handoff.threadId) ? handoff.threadId.slice(0, 120) : undefined, + recordedAt: typeof handoff.recordedAt === "string" ? handoff.recordedAt : undefined + }; +} + function upsertRun(runs, run) { const index = runs.findIndex((existing) => existing.id === run.id); if (index === -1) return [run, ...runs].slice(0, 100); @@ -1260,6 +1331,41 @@ function mutateLedger(snapshot, ledger, options, updatedAt) { changed = true; } + if (options.recordReleasePreflight) { + const id = sanitizeRunId(options.recordReleasePreflight); + currentRunId = id; + const existing = runs.find((run) => run.id === id); + if (!existing) throw new Error(`Cannot record release preflight for unknown Auto Git run: ${id}`); + runs = upsertRun(runs, { + ...existing, + releasePreflight: { + safeToTag: true, + version: typeof options.releaseVersion === "string" ? options.releaseVersion.slice(0, 80) : undefined, + tagName: typeof options.releaseTag === "string" ? options.releaseTag.slice(0, 120) : undefined, + recordedAt: updatedAt, + head: snapshot.topology.head, + dirtyFingerprint: snapshot.dirty.fingerprint + } + }); + changed = true; + } + + if (options.recordReleaseDeferral) { + const id = sanitizeRunId(options.recordReleaseDeferral); + currentRunId = id; + const existing = runs.find((run) => run.id === id); + if (!existing) throw new Error(`Cannot record release deferral for unknown Auto Git run: ${id}`); + runs = upsertRun(runs, { + ...existing, + releaseDeferral: { + status: "deferred", + recordedAt: updatedAt, + head: snapshot.topology.head + } + }); + changed = true; + } + return { ledger: { schemaVersion: SCHEMA_VERSION, updatedAt: changed ? updatedAt : ledger.updatedAt, runs }, changed, @@ -1380,6 +1486,10 @@ function publicRun(run, state) { commits: run.commits, verification: run.verification, pr: run.pr, + releasePreflight: run.releasePreflight, + releaseExecution: run.releaseExecution, + releaseDeferral: run.releaseDeferral, + threadHandoff: run.threadHandoff, decisionReceipt: run.decisionReceipt }; } diff --git a/tests/skill-suite.test.js b/tests/skill-suite.test.js index b80003d..a461c6c 100644 --- a/tests/skill-suite.test.js +++ b/tests/skill-suite.test.js @@ -614,6 +614,125 @@ test("auto-git start writes sanitized decision receipts", async () => { } }); +test("auto-git finish blocks coordinated routes with only local evidence", async () => { + const repo = await createFixtureRepo("auto-git-finish-local-only-"); + const remote = await mkdtemp(path.join(tmpdir(), "auto-git-finish-local-only-remote-")); + const stateHome = await mkdtemp(path.join(tmpdir(), "auto-git-finish-local-only-state-")); + try { + git(remote, ["init", "--bare"]); + git(repo, ["remote", "add", "origin", remote]); + git(repo, ["push", "-u", "origin", "main"]); + git(repo, ["switch", "-c", "codex/local-only"]); + + snapshot(repo, ["--write-state", "--claim-run", "get this in", "--run-id", "local-only-run"], { + AUTO_GIT_STATE_HOME: stateHome + }); + await writeProjectFile(repo, "src/local-only.js", "export const localOnly = true;\n"); + commit(repo, "feat(auto-git): create local-only branch evidence", "Codex Tester "); + snapshot(repo, ["--write-state", "--heartbeat-run", "local-only-run"], { + AUTO_GIT_STATE_HOME: stateHome + }); + + const result = script("auto-git-finish.mjs", repo, ["--run-id", "local-only-run", "--complete", "--json"], { + AUTO_GIT_STATE_HOME: stateHome + }); + assert.equal(result.status, 1); + const payload = JSON.parse(result.stdout); + assert.equal(payload.status, "blocked"); + assert.ok(payload.blockers.some((blocker) => blocker.includes("missing push/sync evidence"))); + assert.ok(payload.blockers.some((blocker) => blocker.includes("missing verification evidence"))); + assert.ok(payload.blockers.some((blocker) => blocker.includes("missing PR/merge/land evidence"))); + } finally { + await rm(repo, { recursive: true, force: true }); + await rm(remote, { recursive: true, force: true }); + await rm(stateHome, { recursive: true, force: true }); + } +}); + +test("auto-git finish blocks release routes without release-preflight evidence", async () => { + const repo = await createFixtureRepo("auto-git-finish-release-contract-"); + const remote = await mkdtemp(path.join(tmpdir(), "auto-git-finish-release-contract-remote-")); + const stateHome = await mkdtemp(path.join(tmpdir(), "auto-git-finish-release-contract-state-")); + try { + git(remote, ["init", "--bare"]); + git(repo, ["remote", "add", "origin", remote]); + await writeProjectFile( + repo, + "package.json", + JSON.stringify({ name: "fixture", version: "1.2.3", type: "module" }, null, 2) + "\n" + ); + await writeProjectFile(repo, "CHANGELOG.md", "# Changelog\n\n## 1.2.3 - 2026-06-20\n\n- Release metadata.\n"); + commit(repo, "release(fixture): prepare 1.2.3", "Codex Tester "); + git(repo, ["push", "-u", "origin", "main"]); + + snapshot(repo, ["--write-state", "--claim-run", "everything release", "--run-id", "release-contract-run"], { + AUTO_GIT_STATE_HOME: stateHome + }); + snapshot( + repo, + ["--write-state", "--record-verification", "pnpm run verify", "--exit-code", "0", "--run-id", "release-contract-run"], + { AUTO_GIT_STATE_HOME: stateHome } + ); + + const result = script("auto-git-finish.mjs", repo, ["--run-id", "release-contract-run", "--defer-release", "--complete", "--json"], { + AUTO_GIT_STATE_HOME: stateHome + }); + assert.equal(result.status, 1); + const payload = JSON.parse(result.stdout); + assert.equal(payload.status, "blocked"); + assert.ok(payload.blockers.some((blocker) => blocker.includes("missing release-preflight evidence"))); + } finally { + await rm(repo, { recursive: true, force: true }); + await rm(remote, { recursive: true, force: true }); + await rm(stateHome, { recursive: true, force: true }); + } +}); + +test("auto-git finish blocks follow-up routes without thread evidence", async () => { + const repo = await createFixtureRepo("auto-git-finish-follow-up-"); + const stateHome = await mkdtemp(path.join(tmpdir(), "auto-git-finish-follow-up-state-")); + try { + snapshot( + repo, + ["--write-state", "--claim-run", "create a follow-up chat after ADR 2", "--run-id", "follow-up-run"], + { AUTO_GIT_STATE_HOME: stateHome } + ); + + const result = script("auto-git-finish.mjs", repo, ["--run-id", "follow-up-run", "--complete", "--json"], { + AUTO_GIT_STATE_HOME: stateHome + }); + assert.equal(result.status, 1); + const payload = JSON.parse(result.stdout); + assert.equal(payload.status, "blocked"); + assert.ok(payload.blockers.some((blocker) => blocker.includes("missing follow-up thread evidence"))); + } finally { + await rm(repo, { recursive: true, force: true }); + await rm(stateHome, { recursive: true, force: true }); + } +}); + +test("auto-git finish completes a satisfied local-review route", async () => { + const repo = await createFixtureRepo("auto-git-finish-local-review-"); + const stateHome = await mkdtemp(path.join(tmpdir(), "auto-git-finish-local-review-state-")); + try { + snapshot(repo, ["--write-state", "--claim-run", "checkpoint this locally", "--run-id", "local-review-run"], { + AUTO_GIT_STATE_HOME: stateHome + }); + + const result = script("auto-git-finish.mjs", repo, ["--run-id", "local-review-run", "--complete", "--json"], { + AUTO_GIT_STATE_HOME: stateHome + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + const payload = JSON.parse(result.stdout); + assert.equal(payload.status, "completed"); + assert.equal(payload.contract.branchOrWorktree.required, false); + assert.equal(payload.ledger.status, "completed"); + } finally { + await rm(repo, { recursive: true, force: true }); + await rm(stateHome, { recursive: true, force: true }); + } +}); + test("auto-git finish requires pushed branch and return to main for everything runs", async () => { const repo = await createFixtureRepo("auto-git-finish-main-"); const remote = await mkdtemp(path.join(tmpdir(), "auto-git-finish-remote-")); @@ -647,7 +766,7 @@ test("auto-git finish requires pushed branch and return to main for everything r assert.equal(payload.branchCompletion.returnedToBase, false); assert.equal(payload.handoffCheck.satisfied, false); assert.ok(payload.blockers.some((blocker) => blocker.includes("switch back to main"))); - assert.ok(payload.blockers.some((blocker) => blocker.includes("no recorded PR handoff"))); + assert.ok(payload.blockers.some((blocker) => blocker.includes("missing PR/merge/land evidence"))); git(repo, ["switch", "main"]); result = script( @@ -693,6 +812,13 @@ test("auto-git finish accepts pushed yolo merge evidence without PR handoff", as try { git(remote, ["init", "--bare"]); git(repo, ["remote", "add", "origin", remote]); + await writeProjectFile( + repo, + "package.json", + JSON.stringify({ name: "fixture", version: "1.2.3", type: "module" }, null, 2) + "\n" + ); + await writeProjectFile(repo, "CHANGELOG.md", "# Changelog\n\n## 1.2.3 - 2026-06-20\n\n- Release metadata.\n"); + commit(repo, "release(fixture): prepare 1.2.3", "Codex Tester "); git(repo, ["push", "-u", "origin", "main"]); git(repo, ["switch", "-c", "codex/finish-merged"]); await writeProjectFile(repo, "src/merged.js", "export const merged = true;\n"); @@ -706,23 +832,29 @@ test("auto-git finish accepts pushed yolo merge evidence without PR handoff", as snapshot(repo, ["--write-state", "--record-verification", "pnpm run verify", "--exit-code", "0", "--run-id", "merge-run"], { AUTO_GIT_STATE_HOME: stateHome }); + let result = script("auto-git-release-preflight.mjs", repo, ["--run-id", "merge-run", "--json"], { + AUTO_GIT_STATE_HOME: stateHome + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + let payload = JSON.parse(result.stdout); + assert.equal(payload.evidenceStateWrite.ok, true); git(repo, ["switch", "main"]); git(repo, ["merge", "--ff-only", "codex/finish-merged"]); git(repo, ["branch", "-d", "codex/finish-merged"]); - let result = script("auto-git-finish.mjs", repo, ["--run-id", "merge-run", "--complete", "--json"], { + result = script("auto-git-finish.mjs", repo, ["--run-id", "merge-run", "--complete", "--json"], { AUTO_GIT_STATE_HOME: stateHome }); assert.equal(result.status, 1); - let payload = JSON.parse(result.stdout); + payload = JSON.parse(result.stdout); assert.equal(payload.handoffCheck.satisfied, true); assert.equal(payload.handoffCheck.merge.mergedIntoBase, true); assert.equal(payload.branchCompletion.exists, false); assert.ok(payload.blockers.some((blocker) => blocker.includes("base branch main has 1 unpushed commit"))); git(repo, ["push", "origin", "main"]); - result = script("auto-git-finish.mjs", repo, ["--run-id", "merge-run", "--complete", "--json"], { + result = script("auto-git-finish.mjs", repo, ["--run-id", "merge-run", "--defer-release", "--complete", "--json"], { AUTO_GIT_STATE_HOME: stateHome }); assert.equal(result.status, 0, result.stderr || result.stdout);