diff --git a/docs/gists/auto-git.md b/docs/gists/auto-git.md index e2bc4a2..3cd1ee4 100644 --- a/docs/gists/auto-git.md +++ b/docs/gists/auto-git.md @@ -49,6 +49,7 @@ Gist file mapping: - Detects repo root, branch, upstream, default branch, dirty state, and worktree topology before staging. - Uses a compact snapshot helper to detect Git index write capability, run locks, package-manager hints, ledger occupancy, PR handoffs, PR readiness, and the recommended verification profile before expensive commands. +- Writes a sanitized start decision receipt on claimed runs, including the normalized route, selected workflow, required gates, branch/worktree context, release-preflight requirement, and follow-up thread handoff requirement without storing raw transcript text. - Supports two workflows: local review for single-chat code review and coordinated branch for multi-chat conflicts, PR handoffs, experiments, and fanouts. - Tracks cooperative run state and PR handoffs under `~/.async/auto-git/v1/repos//ledger.json`, with live leases under `~/.async/locks/auto-git/`, without storing raw diffs, prompts, full command output, environment dumps, or secrets. - Commits by change intent instead of making one vague bulk commit. @@ -323,6 +324,7 @@ auto-git snapshot --cwd "$PWD" --write-state --record-pr "" --pr-url "ht ``` The snapshot emits `occupancy`, `handoffs.openPrs`, `recommendedAction`, and `prReadiness` so future chats can continue, supersede, or merge by explicit instruction. +Claimed runs also carry `decisionReceipt` in the snapshot and ledger. The receipt is the durable routing authority for follow-up helpers: it records a sanitized actionable-turn summary, normalized intent label, selected workflow, required completion gates, active branch/worktree context, and whether release preflight or thread handoff evidence is required. Controller helpers: diff --git a/gists/auto-git/README.md b/gists/auto-git/README.md index e2bc4a2..3cd1ee4 100644 --- a/gists/auto-git/README.md +++ b/gists/auto-git/README.md @@ -49,6 +49,7 @@ Gist file mapping: - Detects repo root, branch, upstream, default branch, dirty state, and worktree topology before staging. - Uses a compact snapshot helper to detect Git index write capability, run locks, package-manager hints, ledger occupancy, PR handoffs, PR readiness, and the recommended verification profile before expensive commands. +- Writes a sanitized start decision receipt on claimed runs, including the normalized route, selected workflow, required gates, branch/worktree context, release-preflight requirement, and follow-up thread handoff requirement without storing raw transcript text. - Supports two workflows: local review for single-chat code review and coordinated branch for multi-chat conflicts, PR handoffs, experiments, and fanouts. - Tracks cooperative run state and PR handoffs under `~/.async/auto-git/v1/repos//ledger.json`, with live leases under `~/.async/locks/auto-git/`, without storing raw diffs, prompts, full command output, environment dumps, or secrets. - Commits by change intent instead of making one vague bulk commit. @@ -323,6 +324,7 @@ auto-git snapshot --cwd "$PWD" --write-state --record-pr "" --pr-url "ht ``` The snapshot emits `occupancy`, `handoffs.openPrs`, `recommendedAction`, and `prReadiness` so future chats can continue, supersede, or merge by explicit instruction. +Claimed runs also carry `decisionReceipt` in the snapshot and ledger. The receipt is the durable routing authority for follow-up helpers: it records a sanitized actionable-turn summary, normalized intent label, selected workflow, required completion gates, active branch/worktree context, and whether release preflight or thread handoff evidence is required. Controller helpers: diff --git a/gists/auto-git/auto-git.SKILL.md b/gists/auto-git/auto-git.SKILL.md index fd7e1cb..c0a9250 100644 --- a/gists/auto-git/auto-git.SKILL.md +++ b/gists/auto-git/auto-git.SKILL.md @@ -45,9 +45,14 @@ an already-established Auto Git mode for that action. must not fail the whole snapshot. Auto Git state must not store raw diffs, file contents, environment values, tokens, npmrc content, or full command output. - -2. Use the snapshot's `workflowMode`, `occupancy`, `recommendedAction`, and - `prReadiness` before mutating: + When a run is claimed, `auto-git start`/`auto-git snapshot` also attach a + small `decisionReceipt` to the ledger run. The receipt records the sanitized + routing summary, normalized intent label, selected workflow, required gates, + branch/worktree context, and release/thread handoff requirements without + storing raw transcript text. + +2. Use the snapshot's `workflowMode`, `occupancy`, `recommendedAction`, + `decisionReceipt`, and `prReadiness` before mutating: - if `occupancy.status` is `occupied`, create or reuse an isolated worktree/branch instead of editing the occupied checkout - if `occupancy.status` is `stale` or `abandoned-candidate`, inspect the @@ -208,8 +213,9 @@ PATH, use the installed skill's `scripts/*.mjs` helper paths as a fallback. - `auto-git start --cwd "$PWD" --task ""` - wraps snapshot and `--claim-run` - - emits `workflowMode`, `recommendedAction`, run id, PR readiness, and the - suggested worktree command for coordinated branch work + - emits `workflowMode`, `recommendedAction`, run id, PR readiness, + `decisionReceipt`, and the suggested worktree command for coordinated + branch work - `auto-git snapshot --cwd "$PWD" --write-state` - snapshots topology, dirty fingerprints, Git index write capability, root and `examples/**/.async/run.lock` state, package-manager hints, and the @@ -221,9 +227,11 @@ PATH, use the installed skill's `scripts/*.mjs` helper paths as a fallback. a run removes the live lease and keeps a completion receipt under `~/.async/locks/auto-git/history/` - supports `--claim-run `, `--intent `, - `--lifecycle `, + `--lifecycle `, `--heartbeat-run `, `--complete-run `, and `--record-pr --pr-url [--pr-number ]` + - stores a sanitized decision receipt on claimed runs so later helpers and + chats can inspect the original route without reading raw prompts - emits `occupancy.status`, `handoffs.openPrs`, `recommendedAction`, and `prReadiness` so later chats can continue, supersede, or hand off work - classifies inaccessible PIDs with optional `ps` metadata; an unrelated @@ -238,7 +246,8 @@ PATH, use the installed skill's `scripts/*.mjs` helper paths as a fallback. process-tree diagnostics - `auto-git ledger list|show|stale|handoffs --cwd "$PWD"` - prints active runs, stale runs, completed runs, PR handoffs, branches, - worktrees, leases, and verification state from safe ledger metadata + worktrees, leases, decision receipts, and verification state from safe + 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 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 df3597b..85168c3 100644 --- a/gists/auto-git/auto-git.script-auto-git-ledger.mjs +++ b/gists/auto-git/auto-git.script-auto-git-ledger.mjs @@ -107,7 +107,8 @@ function publicRun(run, statusById) { recordedAt: run.verification.recordedAt } : undefined, - pr: run.pr + pr: run.pr, + decisionReceipt: run.decisionReceipt }; } @@ -166,7 +167,8 @@ function printText(receipt) { } for (const run of receipt.runs) { const pr = run.pr?.url ? ` pr=${run.pr.url}` : ""; - console.log(`${run.id} status=${run.status} lifecycle=${run.lifecycle} intent=${run.intent} branch=${run.branch ?? "none"}${pr}`); + const decision = run.decisionReceipt?.normalizedIntentLabel ? ` decision=${run.decisionReceipt.normalizedIntentLabel}` : ""; + console.log(`${run.id} status=${run.status} lifecycle=${run.lifecycle} intent=${run.intent}${decision} branch=${run.branch ?? "none"}${pr}`); } } 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 2e41589..8e99187 100644 --- a/gists/auto-git/auto-git.script-auto-git-snapshot.mjs +++ b/gists/auto-git/auto-git.script-auto-git-snapshot.mjs @@ -26,6 +26,27 @@ const DEFAULT_LEASE_TTL_MS = 45 * 60 * 1000; const PACKAGE_MANAGER_HINT_THREAD = "019ebdd4-9cff-76c2-bf71-a3bb38ad1592"; const INTENT_VALUES = ["merge", "branch", "experiment", "checkpoint", "release", "unknown"]; const LIFECYCLE_VALUES = ["checkpoint", "sync", "land", "fanout", "everything", "yolo"]; +const DECISION_INTENT_VALUES = [ + "checkpoint", + "sync", + "branch", + "worktree", + "PR", + "release", + "merge", + "land", + "everything", + "yolo", + "fanout", + "inconclusive" +]; +const DECISION_WORKFLOW_VALUES = [ + "local-review", + "coordinated-branch-worktree", + "follow-up-thread", + "fanout", + "release" +]; function usage() { return [ @@ -329,16 +350,13 @@ function classifyIntent(value, explicitIntent) { if (/\b(testing something|experimenting|experiment|try this|trying this|not sure|unsure|spike|prototype)\b/.test(text)) { return "experiment"; } - if ( - /\b(make|create|start|put)\s+(a\s+)?branch\b|\bbranch this\b|\bput this on a branch\b/.test(text) || - /\b(open|create|prepare)\s+(a\s+)?(?:pr|pull request)\b|\bpr this\b/.test(text) - ) { + if (hasBranchDirective(text) || hasPrDirective(text)) { return "branch"; } if (/\b(save|checkpoint|commit this locally|commit locally|local checkpoint)\b/.test(text)) { return "checkpoint"; } - if (/\b(release this|cut v?\d+\.\d+\.\d+|version bump|bump version|prepare changelog|changelog|release notes?)\b/.test(text)) { + if (hasReleaseDirective(text)) { return "release"; } if (/\b(get this in|ship|finish|land|merge-ready|ready to merge|merge this|merge-ready|ready-pr)\b/.test(text)) { @@ -353,10 +371,10 @@ function classifyLifecycle(value, explicitLifecycle) { if (hasYoloDirective(text)) { return "yolo"; } - if (/\b(multiple agents|separate features|worktrees|do not step on each other|fanout)\b/.test(text)) { + if (hasFanoutDirective(text) || hasWorktreeDirective(text)) { return "fanout"; } - if (/\b(do everything|everything mode|fully manage|manage all git|handle all git|all the git)\b/.test(text)) { + if (hasEverythingDirective(text)) { return "everything"; } if (/\b(finish|land|merge back|merge it|return to main|switch back to main)\b/.test(text)) { @@ -372,6 +390,160 @@ function hasYoloDirective(text) { return /(?:^|\s)(?:\[\$auto-git\]|\$auto-git|auto-git)\s+yolo\b/.test(text); } +function hasFollowUpThreadDirective(text) { + return /\b(follow-up|follow up|next)\s+(chat|thread|codex chat)\b|\bcreate\s+(a\s+)?(chat|thread)\b/.test(text); +} + +function hasReleaseDirective(text) { + return /\b(release this|release\b|cut v?\d+\.\d+\.\d+|version bump|bump version|prepare changelog|changelog|release notes?)\b/.test( + text + ); +} + +function hasEverythingDirective(text) { + return /\b(do everything|everything mode|fully manage|manage all git|handle all git|all the git|everything)\b/.test(text); +} + +function hasSyncDirective(text) { + return /\b(push|sync|sync with main|keep remote latest|publish this branch)\b/.test(text); +} + +function hasLandDirective(text) { + return /\b(finish|land|merge back|merge it|return to main|switch back to main)\b/.test(text); +} + +function hasPrDirective(text) { + return /\b(open|create|prepare)\s+(a\s+)?(?:pr|pull request)\b|\bpr this\b/.test(text); +} + +function hasWorktreeDirective(text) { + return /\bworktree(s)?\b/.test(text); +} + +function hasBranchDirective(text) { + return /\b(make|create|start|put)\s+(a\s+)?branch\b|\bbranch this\b|\bput this on a branch\b/.test(text); +} + +function hasFanoutDirective(text) { + return /\b(multiple agents|separate features|do not step on each other|fanout)\b/.test(text); +} + +function hasCheckpointDirective(text) { + return /\b(save|checkpoint|commit this locally|commit locally|local checkpoint)\b/.test(text); +} + +function normalizedDecisionIntent(value, run) { + const text = String(value ?? "").toLowerCase(); + if (hasYoloDirective(text) || run?.lifecycle === "yolo") return "yolo"; + if (hasReleaseDirective(text) || run?.intent === "release") return "release"; + if (hasEverythingDirective(text) || run?.lifecycle === "everything") return "everything"; + if (hasSyncDirective(text) || run?.lifecycle === "sync") return "sync"; + if (hasLandDirective(text) || run?.lifecycle === "land") return "land"; + if (hasPrDirective(text)) return "PR"; + if (hasWorktreeDirective(text)) return "worktree"; + if (hasBranchDirective(text) || run?.intent === "branch") return "branch"; + if (hasFanoutDirective(text) || run?.lifecycle === "fanout" || hasFollowUpThreadDirective(text)) return "fanout"; + if (run?.intent === "merge") return "merge"; + if (hasCheckpointDirective(text) || run?.intent === "checkpoint") return "checkpoint"; + return "inconclusive"; +} + +function selectedDecisionWorkflow(value, run, normalizedIntent) { + const text = String(value ?? "").toLowerCase(); + if (hasFollowUpThreadDirective(text)) return "follow-up-thread"; + if (normalizedIntent === "release") return "release"; + if (normalizedIntent === "fanout") return "fanout"; + if ( + ["branch", "worktree", "PR", "merge", "land", "everything", "yolo"].includes(normalizedIntent) || + shouldUseCoordinatedWorkflow(run) + ) { + return "coordinated-branch-worktree"; + } + return "local-review"; +} + +function completionGatesForDecision(normalizedIntent, selectedWorkflow) { + if (selectedWorkflow === "follow-up-thread") { + return ["thread-handoff-evidence", "ledger-finish"]; + } + if (selectedWorkflow === "release") { + return ["release-metadata-commit", "verification", "release-preflight", "branch-pushed-before-tag", "ledger-finish"]; + } + if (selectedWorkflow === "fanout") { + return ["isolated-worktrees", "commit-by-intent-per-worktree", "verification", "handoff-or-return-to-base", "ledger-finish"]; + } + if (selectedWorkflow === "coordinated-branch-worktree") { + const gates = ["isolated-branch-or-worktree", "commit-by-intent", "verification", "branch-pushed", "return-to-base", "ledger-finish"]; + if (["branch", "PR", "merge", "land", "everything", "yolo"].includes(normalizedIntent)) { + gates.splice(4, 0, "pr-handoff-or-merge-evidence"); + } + if (normalizedIntent === "yolo") { + gates.splice(gates.length - 1, 0, "release-preflight-before-release-action"); + } + return gates; + } + if (normalizedIntent === "sync") { + return ["commit-by-intent", "verification", "branch-pushed", "ledger-finish"]; + } + if (normalizedIntent === "inconclusive") { + return ["manual-routing-confirmation", "ledger-finish"]; + } + return ["commit-by-intent", "working-tree-clean", "ledger-finish"]; +} + +function decisionReason(normalizedIntent, selectedWorkflow) { + if (normalizedIntent === "yolo") return "Matched an explicit Auto Git YOLO directive."; + if (normalizedIntent === "release") return "Matched release wording; release preflight is required before release completion."; + if (selectedWorkflow === "follow-up-thread") return "Matched follow-up chat or thread handoff wording."; + if (normalizedIntent === "everything") return "Matched everything authority wording."; + if (normalizedIntent === "sync") return "Matched sync or push wording."; + if (normalizedIntent === "land") return "Matched land or return-to-main wording."; + if (["branch", "worktree", "PR"].includes(normalizedIntent)) return "Matched isolated branch, worktree, or PR wording."; + if (normalizedIntent === "fanout") return "Matched fanout or multi-worktree wording."; + if (normalizedIntent === "checkpoint") return "Matched local checkpoint wording."; + return "No explicit Auto Git lifecycle wording matched."; +} + +function actionableTurnSummary(normalizedIntent, selectedWorkflow) { + if (selectedWorkflow === "follow-up-thread") return "follow-up thread request"; + if (normalizedIntent === "PR") return "pull request request"; + if (normalizedIntent === "inconclusive") return "inconclusive request"; + return `${normalizedIntent} request`; +} + +function worktreePathClass(snapshot) { + const worktreeLines = snapshot.worktrees ?? []; + const worktreePaths = worktreeLines.filter((line) => line.startsWith("worktree ")).map((line) => line.slice("worktree ".length)); + const index = worktreePaths.indexOf(snapshot.repo.root); + const base = index === -1 ? "unknown" : index === 0 ? "primary-checkout" : "linked-worktree"; + return snapshot.topology.detached ? `detached-${base}` : base; +} + +function buildDecisionReceipt(snapshot, run, task, generatedAt) { + const normalizedIntentLabel = normalizedDecisionIntent(task, run); + const selectedWorkflowMode = selectedDecisionWorkflow(task, run, normalizedIntentLabel); + return { + schemaVersion: 1, + generatedAt, + actionableTurn: { + source: "task-argument", + summary: actionableTurnSummary(normalizedIntentLabel, selectedWorkflowMode), + fingerprint: sha256(String(task ?? ""), 16) + }, + normalizedIntentLabel, + selectedWorkflowMode, + completionGates: completionGatesForDecision(normalizedIntentLabel, selectedWorkflowMode), + reason: decisionReason(normalizedIntentLabel, selectedWorkflowMode), + context: { + activeBranch: snapshot.topology.branch ?? "detached", + worktreePathClass: worktreePathClass(snapshot), + baseBranch: defaultBaseBranch(snapshot) + }, + releasePreflightRequired: normalizedIntentLabel === "release", + threadHandoffRequired: selectedWorkflowMode === "follow-up-thread" + }; +} + function repoSlug(repoRoot) { return basename(repoRoot).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "repo"; } @@ -854,7 +1026,58 @@ function normalizeRun(run) { stagedFingerprint: typeof run.stagedFingerprint === "string" ? run.stagedFingerprint : undefined, commits: Array.isArray(run.commits) ? run.commits.filter((commit) => typeof commit === "string").slice(0, 200) : [], verification: normalizeVerification(run.verification), - pr: normalizePr(run.pr) + pr: normalizePr(run.pr), + decisionReceipt: normalizeDecisionReceipt(run.decisionReceipt) + }; +} + +function normalizeDecisionReceipt(receipt) { + if (!receipt || typeof receipt !== "object") return undefined; + const normalizedIntentLabel = DECISION_INTENT_VALUES.includes(receipt.normalizedIntentLabel) + ? receipt.normalizedIntentLabel + : "inconclusive"; + const selectedWorkflowMode = DECISION_WORKFLOW_VALUES.includes(receipt.selectedWorkflowMode) + ? receipt.selectedWorkflowMode + : "local-review"; + const completionGates = Array.isArray(receipt.completionGates) + ? receipt.completionGates.filter((gate) => typeof gate === "string" && !looksSecretish(gate)).slice(0, 20) + : completionGatesForDecision(normalizedIntentLabel, selectedWorkflowMode); + const actionableTurn = + receipt.actionableTurn && typeof receipt.actionableTurn === "object" + ? { + source: receipt.actionableTurn.source === "task-argument" ? "task-argument" : "unknown", + summary: + typeof receipt.actionableTurn.summary === "string" && !looksSecretish(receipt.actionableTurn.summary) + ? receipt.actionableTurn.summary.slice(0, 80) + : actionableTurnSummary(normalizedIntentLabel, selectedWorkflowMode), + fingerprint: + typeof receipt.actionableTurn.fingerprint === "string" && /^[a-f0-9]{8,64}$/i.test(receipt.actionableTurn.fingerprint) + ? receipt.actionableTurn.fingerprint + : undefined + } + : undefined; + return { + schemaVersion: 1, + generatedAt: typeof receipt.generatedAt === "string" ? receipt.generatedAt : undefined, + actionableTurn, + normalizedIntentLabel, + selectedWorkflowMode, + completionGates, + reason: + typeof receipt.reason === "string" && !looksSecretish(receipt.reason) + ? receipt.reason.slice(0, 200) + : decisionReason(normalizedIntentLabel, selectedWorkflowMode), + context: + receipt.context && typeof receipt.context === "object" + ? { + activeBranch: typeof receipt.context.activeBranch === "string" ? receipt.context.activeBranch.slice(0, 120) : undefined, + worktreePathClass: + typeof receipt.context.worktreePathClass === "string" ? receipt.context.worktreePathClass.slice(0, 80) : undefined, + baseBranch: typeof receipt.context.baseBranch === "string" ? receipt.context.baseBranch.slice(0, 120) : undefined + } + : undefined, + releasePreflightRequired: Boolean(receipt.releasePreflightRequired), + threadHandoffRequired: Boolean(receipt.threadHandoffRequired) }; } @@ -978,6 +1201,7 @@ function mutateLedger(snapshot, ledger, options, updatedAt) { claimedAt: existing?.claimedAt ?? updatedAt, ...currentRunBasis(snapshot, options, updatedAt, leaseInfo) }; + run.decisionReceipt = buildDecisionReceipt(snapshot, run, options.claimRun, updatedAt); runs = upsertRun(runs, run); changed = true; } @@ -1155,7 +1379,8 @@ function publicRun(run, state) { stagedFingerprint: run.stagedFingerprint, commits: run.commits, verification: run.verification, - pr: run.pr + pr: run.pr, + decisionReceipt: run.decisionReceipt }; } diff --git a/gists/auto-git/auto-git.script-auto-git-start.mjs b/gists/auto-git/auto-git.script-auto-git-start.mjs index b8106f1..20278c1 100644 --- a/gists/auto-git/auto-git.script-auto-git-start.mjs +++ b/gists/auto-git/auto-git.script-auto-git-start.mjs @@ -134,6 +134,7 @@ function buildReceipt(payload, options) { const snapshot = payload.snapshot; const runId = snapshot.ledger?.currentRunId; const run = findRun(snapshot, runId); + const decisionReceipt = run?.decisionReceipt; return { schemaVersion: 1, tool: "auto-git-start", @@ -156,10 +157,10 @@ function buildReceipt(payload, options) { }, recommendedAction: snapshot.recommendedAction, prReadiness: snapshot.prReadiness, + decisionReceipt, worktreeSuggestion: worktreeSuggestion(snapshot, run), nextSteps: nextSteps(snapshot, run), - stateWrite: payload.stateWrite, - task: options.task + stateWrite: payload.stateWrite }; } @@ -170,6 +171,14 @@ function printText(receipt) { console.log(`runId: ${receipt.runId ?? "none"}`); console.log(`occupancy: ${receipt.occupancy.status}`); console.log(`recommendedAction: ${receipt.recommendedAction}`); + if (receipt.decisionReceipt) { + console.log(`decisionIntent: ${receipt.decisionReceipt.normalizedIntentLabel}`); + console.log(`decisionWorkflow: ${receipt.decisionReceipt.selectedWorkflowMode}`); + console.log(`decisionGates: ${receipt.decisionReceipt.completionGates.join(", ")}`); + console.log(`releasePreflightRequired: ${receipt.decisionReceipt.releasePreflightRequired}`); + console.log(`threadHandoffRequired: ${receipt.decisionReceipt.threadHandoffRequired}`); + console.log(`decisionReason: ${receipt.decisionReceipt.reason}`); + } if (receipt.worktreeSuggestion) { console.log(`suggestedBranch: ${receipt.worktreeSuggestion.branch}`); console.log(`suggestedWorktree: ${receipt.worktreeSuggestion.path}`); diff --git a/skills/auto-git/SKILL.md b/skills/auto-git/SKILL.md index fd7e1cb..c0a9250 100644 --- a/skills/auto-git/SKILL.md +++ b/skills/auto-git/SKILL.md @@ -45,9 +45,14 @@ an already-established Auto Git mode for that action. must not fail the whole snapshot. Auto Git state must not store raw diffs, file contents, environment values, tokens, npmrc content, or full command output. - -2. Use the snapshot's `workflowMode`, `occupancy`, `recommendedAction`, and - `prReadiness` before mutating: + When a run is claimed, `auto-git start`/`auto-git snapshot` also attach a + small `decisionReceipt` to the ledger run. The receipt records the sanitized + routing summary, normalized intent label, selected workflow, required gates, + branch/worktree context, and release/thread handoff requirements without + storing raw transcript text. + +2. Use the snapshot's `workflowMode`, `occupancy`, `recommendedAction`, + `decisionReceipt`, and `prReadiness` before mutating: - if `occupancy.status` is `occupied`, create or reuse an isolated worktree/branch instead of editing the occupied checkout - if `occupancy.status` is `stale` or `abandoned-candidate`, inspect the @@ -208,8 +213,9 @@ PATH, use the installed skill's `scripts/*.mjs` helper paths as a fallback. - `auto-git start --cwd "$PWD" --task ""` - wraps snapshot and `--claim-run` - - emits `workflowMode`, `recommendedAction`, run id, PR readiness, and the - suggested worktree command for coordinated branch work + - emits `workflowMode`, `recommendedAction`, run id, PR readiness, + `decisionReceipt`, and the suggested worktree command for coordinated + branch work - `auto-git snapshot --cwd "$PWD" --write-state` - snapshots topology, dirty fingerprints, Git index write capability, root and `examples/**/.async/run.lock` state, package-manager hints, and the @@ -221,9 +227,11 @@ PATH, use the installed skill's `scripts/*.mjs` helper paths as a fallback. a run removes the live lease and keeps a completion receipt under `~/.async/locks/auto-git/history/` - supports `--claim-run `, `--intent `, - `--lifecycle `, + `--lifecycle `, `--heartbeat-run `, `--complete-run `, and `--record-pr --pr-url [--pr-number ]` + - stores a sanitized decision receipt on claimed runs so later helpers and + chats can inspect the original route without reading raw prompts - emits `occupancy.status`, `handoffs.openPrs`, `recommendedAction`, and `prReadiness` so later chats can continue, supersede, or hand off work - classifies inaccessible PIDs with optional `ps` metadata; an unrelated @@ -238,7 +246,8 @@ PATH, use the installed skill's `scripts/*.mjs` helper paths as a fallback. process-tree diagnostics - `auto-git ledger list|show|stale|handoffs --cwd "$PWD"` - prints active runs, stale runs, completed runs, PR handoffs, branches, - worktrees, leases, and verification state from safe ledger metadata + worktrees, leases, decision receipts, and verification state from safe + 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 diff --git a/skills/auto-git/scripts/auto-git-ledger.mjs b/skills/auto-git/scripts/auto-git-ledger.mjs index df3597b..85168c3 100755 --- a/skills/auto-git/scripts/auto-git-ledger.mjs +++ b/skills/auto-git/scripts/auto-git-ledger.mjs @@ -107,7 +107,8 @@ function publicRun(run, statusById) { recordedAt: run.verification.recordedAt } : undefined, - pr: run.pr + pr: run.pr, + decisionReceipt: run.decisionReceipt }; } @@ -166,7 +167,8 @@ function printText(receipt) { } for (const run of receipt.runs) { const pr = run.pr?.url ? ` pr=${run.pr.url}` : ""; - console.log(`${run.id} status=${run.status} lifecycle=${run.lifecycle} intent=${run.intent} branch=${run.branch ?? "none"}${pr}`); + const decision = run.decisionReceipt?.normalizedIntentLabel ? ` decision=${run.decisionReceipt.normalizedIntentLabel}` : ""; + console.log(`${run.id} status=${run.status} lifecycle=${run.lifecycle} intent=${run.intent}${decision} branch=${run.branch ?? "none"}${pr}`); } } diff --git a/skills/auto-git/scripts/auto-git-snapshot.mjs b/skills/auto-git/scripts/auto-git-snapshot.mjs index 2e41589..8e99187 100755 --- a/skills/auto-git/scripts/auto-git-snapshot.mjs +++ b/skills/auto-git/scripts/auto-git-snapshot.mjs @@ -26,6 +26,27 @@ const DEFAULT_LEASE_TTL_MS = 45 * 60 * 1000; const PACKAGE_MANAGER_HINT_THREAD = "019ebdd4-9cff-76c2-bf71-a3bb38ad1592"; const INTENT_VALUES = ["merge", "branch", "experiment", "checkpoint", "release", "unknown"]; const LIFECYCLE_VALUES = ["checkpoint", "sync", "land", "fanout", "everything", "yolo"]; +const DECISION_INTENT_VALUES = [ + "checkpoint", + "sync", + "branch", + "worktree", + "PR", + "release", + "merge", + "land", + "everything", + "yolo", + "fanout", + "inconclusive" +]; +const DECISION_WORKFLOW_VALUES = [ + "local-review", + "coordinated-branch-worktree", + "follow-up-thread", + "fanout", + "release" +]; function usage() { return [ @@ -329,16 +350,13 @@ function classifyIntent(value, explicitIntent) { if (/\b(testing something|experimenting|experiment|try this|trying this|not sure|unsure|spike|prototype)\b/.test(text)) { return "experiment"; } - if ( - /\b(make|create|start|put)\s+(a\s+)?branch\b|\bbranch this\b|\bput this on a branch\b/.test(text) || - /\b(open|create|prepare)\s+(a\s+)?(?:pr|pull request)\b|\bpr this\b/.test(text) - ) { + if (hasBranchDirective(text) || hasPrDirective(text)) { return "branch"; } if (/\b(save|checkpoint|commit this locally|commit locally|local checkpoint)\b/.test(text)) { return "checkpoint"; } - if (/\b(release this|cut v?\d+\.\d+\.\d+|version bump|bump version|prepare changelog|changelog|release notes?)\b/.test(text)) { + if (hasReleaseDirective(text)) { return "release"; } if (/\b(get this in|ship|finish|land|merge-ready|ready to merge|merge this|merge-ready|ready-pr)\b/.test(text)) { @@ -353,10 +371,10 @@ function classifyLifecycle(value, explicitLifecycle) { if (hasYoloDirective(text)) { return "yolo"; } - if (/\b(multiple agents|separate features|worktrees|do not step on each other|fanout)\b/.test(text)) { + if (hasFanoutDirective(text) || hasWorktreeDirective(text)) { return "fanout"; } - if (/\b(do everything|everything mode|fully manage|manage all git|handle all git|all the git)\b/.test(text)) { + if (hasEverythingDirective(text)) { return "everything"; } if (/\b(finish|land|merge back|merge it|return to main|switch back to main)\b/.test(text)) { @@ -372,6 +390,160 @@ function hasYoloDirective(text) { return /(?:^|\s)(?:\[\$auto-git\]|\$auto-git|auto-git)\s+yolo\b/.test(text); } +function hasFollowUpThreadDirective(text) { + return /\b(follow-up|follow up|next)\s+(chat|thread|codex chat)\b|\bcreate\s+(a\s+)?(chat|thread)\b/.test(text); +} + +function hasReleaseDirective(text) { + return /\b(release this|release\b|cut v?\d+\.\d+\.\d+|version bump|bump version|prepare changelog|changelog|release notes?)\b/.test( + text + ); +} + +function hasEverythingDirective(text) { + return /\b(do everything|everything mode|fully manage|manage all git|handle all git|all the git|everything)\b/.test(text); +} + +function hasSyncDirective(text) { + return /\b(push|sync|sync with main|keep remote latest|publish this branch)\b/.test(text); +} + +function hasLandDirective(text) { + return /\b(finish|land|merge back|merge it|return to main|switch back to main)\b/.test(text); +} + +function hasPrDirective(text) { + return /\b(open|create|prepare)\s+(a\s+)?(?:pr|pull request)\b|\bpr this\b/.test(text); +} + +function hasWorktreeDirective(text) { + return /\bworktree(s)?\b/.test(text); +} + +function hasBranchDirective(text) { + return /\b(make|create|start|put)\s+(a\s+)?branch\b|\bbranch this\b|\bput this on a branch\b/.test(text); +} + +function hasFanoutDirective(text) { + return /\b(multiple agents|separate features|do not step on each other|fanout)\b/.test(text); +} + +function hasCheckpointDirective(text) { + return /\b(save|checkpoint|commit this locally|commit locally|local checkpoint)\b/.test(text); +} + +function normalizedDecisionIntent(value, run) { + const text = String(value ?? "").toLowerCase(); + if (hasYoloDirective(text) || run?.lifecycle === "yolo") return "yolo"; + if (hasReleaseDirective(text) || run?.intent === "release") return "release"; + if (hasEverythingDirective(text) || run?.lifecycle === "everything") return "everything"; + if (hasSyncDirective(text) || run?.lifecycle === "sync") return "sync"; + if (hasLandDirective(text) || run?.lifecycle === "land") return "land"; + if (hasPrDirective(text)) return "PR"; + if (hasWorktreeDirective(text)) return "worktree"; + if (hasBranchDirective(text) || run?.intent === "branch") return "branch"; + if (hasFanoutDirective(text) || run?.lifecycle === "fanout" || hasFollowUpThreadDirective(text)) return "fanout"; + if (run?.intent === "merge") return "merge"; + if (hasCheckpointDirective(text) || run?.intent === "checkpoint") return "checkpoint"; + return "inconclusive"; +} + +function selectedDecisionWorkflow(value, run, normalizedIntent) { + const text = String(value ?? "").toLowerCase(); + if (hasFollowUpThreadDirective(text)) return "follow-up-thread"; + if (normalizedIntent === "release") return "release"; + if (normalizedIntent === "fanout") return "fanout"; + if ( + ["branch", "worktree", "PR", "merge", "land", "everything", "yolo"].includes(normalizedIntent) || + shouldUseCoordinatedWorkflow(run) + ) { + return "coordinated-branch-worktree"; + } + return "local-review"; +} + +function completionGatesForDecision(normalizedIntent, selectedWorkflow) { + if (selectedWorkflow === "follow-up-thread") { + return ["thread-handoff-evidence", "ledger-finish"]; + } + if (selectedWorkflow === "release") { + return ["release-metadata-commit", "verification", "release-preflight", "branch-pushed-before-tag", "ledger-finish"]; + } + if (selectedWorkflow === "fanout") { + return ["isolated-worktrees", "commit-by-intent-per-worktree", "verification", "handoff-or-return-to-base", "ledger-finish"]; + } + if (selectedWorkflow === "coordinated-branch-worktree") { + const gates = ["isolated-branch-or-worktree", "commit-by-intent", "verification", "branch-pushed", "return-to-base", "ledger-finish"]; + if (["branch", "PR", "merge", "land", "everything", "yolo"].includes(normalizedIntent)) { + gates.splice(4, 0, "pr-handoff-or-merge-evidence"); + } + if (normalizedIntent === "yolo") { + gates.splice(gates.length - 1, 0, "release-preflight-before-release-action"); + } + return gates; + } + if (normalizedIntent === "sync") { + return ["commit-by-intent", "verification", "branch-pushed", "ledger-finish"]; + } + if (normalizedIntent === "inconclusive") { + return ["manual-routing-confirmation", "ledger-finish"]; + } + return ["commit-by-intent", "working-tree-clean", "ledger-finish"]; +} + +function decisionReason(normalizedIntent, selectedWorkflow) { + if (normalizedIntent === "yolo") return "Matched an explicit Auto Git YOLO directive."; + if (normalizedIntent === "release") return "Matched release wording; release preflight is required before release completion."; + if (selectedWorkflow === "follow-up-thread") return "Matched follow-up chat or thread handoff wording."; + if (normalizedIntent === "everything") return "Matched everything authority wording."; + if (normalizedIntent === "sync") return "Matched sync or push wording."; + if (normalizedIntent === "land") return "Matched land or return-to-main wording."; + if (["branch", "worktree", "PR"].includes(normalizedIntent)) return "Matched isolated branch, worktree, or PR wording."; + if (normalizedIntent === "fanout") return "Matched fanout or multi-worktree wording."; + if (normalizedIntent === "checkpoint") return "Matched local checkpoint wording."; + return "No explicit Auto Git lifecycle wording matched."; +} + +function actionableTurnSummary(normalizedIntent, selectedWorkflow) { + if (selectedWorkflow === "follow-up-thread") return "follow-up thread request"; + if (normalizedIntent === "PR") return "pull request request"; + if (normalizedIntent === "inconclusive") return "inconclusive request"; + return `${normalizedIntent} request`; +} + +function worktreePathClass(snapshot) { + const worktreeLines = snapshot.worktrees ?? []; + const worktreePaths = worktreeLines.filter((line) => line.startsWith("worktree ")).map((line) => line.slice("worktree ".length)); + const index = worktreePaths.indexOf(snapshot.repo.root); + const base = index === -1 ? "unknown" : index === 0 ? "primary-checkout" : "linked-worktree"; + return snapshot.topology.detached ? `detached-${base}` : base; +} + +function buildDecisionReceipt(snapshot, run, task, generatedAt) { + const normalizedIntentLabel = normalizedDecisionIntent(task, run); + const selectedWorkflowMode = selectedDecisionWorkflow(task, run, normalizedIntentLabel); + return { + schemaVersion: 1, + generatedAt, + actionableTurn: { + source: "task-argument", + summary: actionableTurnSummary(normalizedIntentLabel, selectedWorkflowMode), + fingerprint: sha256(String(task ?? ""), 16) + }, + normalizedIntentLabel, + selectedWorkflowMode, + completionGates: completionGatesForDecision(normalizedIntentLabel, selectedWorkflowMode), + reason: decisionReason(normalizedIntentLabel, selectedWorkflowMode), + context: { + activeBranch: snapshot.topology.branch ?? "detached", + worktreePathClass: worktreePathClass(snapshot), + baseBranch: defaultBaseBranch(snapshot) + }, + releasePreflightRequired: normalizedIntentLabel === "release", + threadHandoffRequired: selectedWorkflowMode === "follow-up-thread" + }; +} + function repoSlug(repoRoot) { return basename(repoRoot).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "repo"; } @@ -854,7 +1026,58 @@ function normalizeRun(run) { stagedFingerprint: typeof run.stagedFingerprint === "string" ? run.stagedFingerprint : undefined, commits: Array.isArray(run.commits) ? run.commits.filter((commit) => typeof commit === "string").slice(0, 200) : [], verification: normalizeVerification(run.verification), - pr: normalizePr(run.pr) + pr: normalizePr(run.pr), + decisionReceipt: normalizeDecisionReceipt(run.decisionReceipt) + }; +} + +function normalizeDecisionReceipt(receipt) { + if (!receipt || typeof receipt !== "object") return undefined; + const normalizedIntentLabel = DECISION_INTENT_VALUES.includes(receipt.normalizedIntentLabel) + ? receipt.normalizedIntentLabel + : "inconclusive"; + const selectedWorkflowMode = DECISION_WORKFLOW_VALUES.includes(receipt.selectedWorkflowMode) + ? receipt.selectedWorkflowMode + : "local-review"; + const completionGates = Array.isArray(receipt.completionGates) + ? receipt.completionGates.filter((gate) => typeof gate === "string" && !looksSecretish(gate)).slice(0, 20) + : completionGatesForDecision(normalizedIntentLabel, selectedWorkflowMode); + const actionableTurn = + receipt.actionableTurn && typeof receipt.actionableTurn === "object" + ? { + source: receipt.actionableTurn.source === "task-argument" ? "task-argument" : "unknown", + summary: + typeof receipt.actionableTurn.summary === "string" && !looksSecretish(receipt.actionableTurn.summary) + ? receipt.actionableTurn.summary.slice(0, 80) + : actionableTurnSummary(normalizedIntentLabel, selectedWorkflowMode), + fingerprint: + typeof receipt.actionableTurn.fingerprint === "string" && /^[a-f0-9]{8,64}$/i.test(receipt.actionableTurn.fingerprint) + ? receipt.actionableTurn.fingerprint + : undefined + } + : undefined; + return { + schemaVersion: 1, + generatedAt: typeof receipt.generatedAt === "string" ? receipt.generatedAt : undefined, + actionableTurn, + normalizedIntentLabel, + selectedWorkflowMode, + completionGates, + reason: + typeof receipt.reason === "string" && !looksSecretish(receipt.reason) + ? receipt.reason.slice(0, 200) + : decisionReason(normalizedIntentLabel, selectedWorkflowMode), + context: + receipt.context && typeof receipt.context === "object" + ? { + activeBranch: typeof receipt.context.activeBranch === "string" ? receipt.context.activeBranch.slice(0, 120) : undefined, + worktreePathClass: + typeof receipt.context.worktreePathClass === "string" ? receipt.context.worktreePathClass.slice(0, 80) : undefined, + baseBranch: typeof receipt.context.baseBranch === "string" ? receipt.context.baseBranch.slice(0, 120) : undefined + } + : undefined, + releasePreflightRequired: Boolean(receipt.releasePreflightRequired), + threadHandoffRequired: Boolean(receipt.threadHandoffRequired) }; } @@ -978,6 +1201,7 @@ function mutateLedger(snapshot, ledger, options, updatedAt) { claimedAt: existing?.claimedAt ?? updatedAt, ...currentRunBasis(snapshot, options, updatedAt, leaseInfo) }; + run.decisionReceipt = buildDecisionReceipt(snapshot, run, options.claimRun, updatedAt); runs = upsertRun(runs, run); changed = true; } @@ -1155,7 +1379,8 @@ function publicRun(run, state) { stagedFingerprint: run.stagedFingerprint, commits: run.commits, verification: run.verification, - pr: run.pr + pr: run.pr, + decisionReceipt: run.decisionReceipt }; } diff --git a/skills/auto-git/scripts/auto-git-start.mjs b/skills/auto-git/scripts/auto-git-start.mjs index b8106f1..20278c1 100755 --- a/skills/auto-git/scripts/auto-git-start.mjs +++ b/skills/auto-git/scripts/auto-git-start.mjs @@ -134,6 +134,7 @@ function buildReceipt(payload, options) { const snapshot = payload.snapshot; const runId = snapshot.ledger?.currentRunId; const run = findRun(snapshot, runId); + const decisionReceipt = run?.decisionReceipt; return { schemaVersion: 1, tool: "auto-git-start", @@ -156,10 +157,10 @@ function buildReceipt(payload, options) { }, recommendedAction: snapshot.recommendedAction, prReadiness: snapshot.prReadiness, + decisionReceipt, worktreeSuggestion: worktreeSuggestion(snapshot, run), nextSteps: nextSteps(snapshot, run), - stateWrite: payload.stateWrite, - task: options.task + stateWrite: payload.stateWrite }; } @@ -170,6 +171,14 @@ function printText(receipt) { console.log(`runId: ${receipt.runId ?? "none"}`); console.log(`occupancy: ${receipt.occupancy.status}`); console.log(`recommendedAction: ${receipt.recommendedAction}`); + if (receipt.decisionReceipt) { + console.log(`decisionIntent: ${receipt.decisionReceipt.normalizedIntentLabel}`); + console.log(`decisionWorkflow: ${receipt.decisionReceipt.selectedWorkflowMode}`); + console.log(`decisionGates: ${receipt.decisionReceipt.completionGates.join(", ")}`); + console.log(`releasePreflightRequired: ${receipt.decisionReceipt.releasePreflightRequired}`); + console.log(`threadHandoffRequired: ${receipt.decisionReceipt.threadHandoffRequired}`); + console.log(`decisionReason: ${receipt.decisionReceipt.reason}`); + } if (receipt.worktreeSuggestion) { console.log(`suggestedBranch: ${receipt.worktreeSuggestion.branch}`); console.log(`suggestedWorktree: ${receipt.worktreeSuggestion.path}`); diff --git a/tests/skill-suite.test.js b/tests/skill-suite.test.js index ecb6217..b80003d 100644 --- a/tests/skill-suite.test.js +++ b/tests/skill-suite.test.js @@ -529,6 +529,91 @@ test("auto-git controller scripts start, list, and block unsafe finish", async ( } }); +test("auto-git start writes sanitized decision receipts", async () => { + const repo = await createFixtureRepo("auto-git-start-receipts-"); + const stateHome = await mkdtemp(path.join(tmpdir(), "auto-git-start-receipts-state-")); + try { + const cases = [ + { + task: "auto-git yolo", + runId: "receipt-yolo", + normalizedIntentLabel: "yolo", + selectedWorkflowMode: "coordinated-branch-worktree", + gates: ["verification", "pr-handoff-or-merge-evidence"], + releasePreflightRequired: false, + threadHandoffRequired: false + }, + { + task: "everything release", + runId: "receipt-release", + normalizedIntentLabel: "release", + selectedWorkflowMode: "release", + gates: ["release-preflight", "branch-pushed-before-tag"], + releasePreflightRequired: true, + threadHandoffRequired: false + }, + { + task: "sync with main", + runId: "receipt-sync", + normalizedIntentLabel: "sync", + selectedWorkflowMode: "local-review", + gates: ["branch-pushed"], + releasePreflightRequired: false, + threadHandoffRequired: false + }, + { + task: "create a follow-up chat after ADR 1; do not store this raw transcript", + runId: "receipt-follow-up", + normalizedIntentLabel: "fanout", + selectedWorkflowMode: "follow-up-thread", + gates: ["thread-handoff-evidence"], + releasePreflightRequired: false, + threadHandoffRequired: true, + forbidden: "do not store this raw transcript" + }, + { + task: "please look at this when you have a chance", + runId: "receipt-inconclusive", + normalizedIntentLabel: "inconclusive", + selectedWorkflowMode: "local-review", + gates: ["manual-routing-confirmation"], + releasePreflightRequired: false, + threadHandoffRequired: false + } + ]; + + for (const item of cases) { + const result = script("auto-git-start.mjs", repo, ["--task", item.task, "--run-id", item.runId, "--json"], { + AUTO_GIT_STATE_HOME: stateHome + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + const payload = JSON.parse(result.stdout); + const receipt = payload.decisionReceipt; + assert.equal(receipt.normalizedIntentLabel, item.normalizedIntentLabel); + assert.equal(receipt.selectedWorkflowMode, item.selectedWorkflowMode); + assert.equal(receipt.releasePreflightRequired, item.releasePreflightRequired); + assert.equal(receipt.threadHandoffRequired, item.threadHandoffRequired); + assert.equal(receipt.actionableTurn.source, "task-argument"); + assert.match(receipt.actionableTurn.fingerprint, /^[a-f0-9]{16}$/); + for (const gate of item.gates) assert.ok(receipt.completionGates.includes(gate), `${item.runId} includes ${gate}`); + assert.equal(Object.hasOwn(payload, "task"), false); + if (item.forbidden) assert.doesNotMatch(JSON.stringify(receipt), new RegExp(escapeRegExp(item.forbidden))); + + const ledger = JSON.parse(await readFile(path.join(stateHome, "repos", payload.repo.hash, "ledger.json"), "utf8")); + const ledgerRun = ledger.runs.find((run) => run.id === item.runId); + assert.equal(ledgerRun.decisionReceipt.normalizedIntentLabel, item.normalizedIntentLabel); + } + + const ledgerResult = script("auto-git-ledger.mjs", repo, ["list", "--json"], { AUTO_GIT_STATE_HOME: stateHome }); + assert.equal(ledgerResult.status, 0, ledgerResult.stderr || ledgerResult.stdout); + const ledgerPayload = JSON.parse(ledgerResult.stdout); + assert.ok(ledgerPayload.runs.some((run) => run.decisionReceipt?.selectedWorkflowMode === "follow-up-thread")); + } 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-"));