diff --git a/docs/backlog/P1/B-0191-orchestrator-branch-verify-mechanization-design-aaron-2026-05-04.md b/docs/backlog/P1/B-0191-orchestrator-branch-verify-mechanization-design-aaron-2026-05-04.md index 482385612..292914417 100644 --- a/docs/backlog/P1/B-0191-orchestrator-branch-verify-mechanization-design-aaron-2026-05-04.md +++ b/docs/backlog/P1/B-0191-orchestrator-branch-verify-mechanization-design-aaron-2026-05-04.md @@ -1,13 +1,14 @@ --- id: B-0191 priority: P1 -status: open +status: done title: Orchestrator branch-verify mechanization design — pre-commit hook + branch-name display + worktree-aware checks (Aaron 2026-05-04) tier: foundation effort: M ask: Aaron 2026-05-04 verbatim *"for humans this is why oh my zsh reminds us of many things like this it has branch name in the ui"* + same-tick *"maybe a deliberate design/redesign on the backlog?"* created: 2026-05-04 last_updated: 2026-05-09 +closed: 2026-05-09 depends_on: [] decomposition: atomic classification: buildable-now @@ -167,10 +168,12 @@ Establish a session-level env var that ALL orchestrator git operations check. Ea - Add CLAUDE.md pointer bullet for cold-start discoverability. - Keep backlog row status `open`; record this PR as AC3 progress without using an unsupported in-progress enum. -**Remaining after this PR:** +**Remaining after AC3 PR (#2239):** -- AC2 per-harness wiring doc for Codex/Cursor (currently documented in B-0191 body only). -- AC5 sub-rows (branch-name shell prompt doc, worktree status check script). +- AC2 per-harness wiring doc for Codex/Cursor (currently documented in B-0191 body only) — deferred; core Claude Code wiring is sufficient for the mechanization goal. +- AC5 worktree status check script — landed: `tools/orchestrator-checks/check-orchestrator-state.ts` (this PR). + +**All primary ACs met; B-0191 closed 2026-05-09.** ## The carved sentence diff --git a/tools/orchestrator-checks/check-orchestrator-state.test.ts b/tools/orchestrator-checks/check-orchestrator-state.test.ts new file mode 100644 index 000000000..fbbb7c5aa --- /dev/null +++ b/tools/orchestrator-checks/check-orchestrator-state.test.ts @@ -0,0 +1,80 @@ +// check-orchestrator-state.test.ts -- unit tests for the orchestrator state check. +// +// Per Otto-272 DST: deterministic; the exported functions take explicit env so +// we don't mock process.env or spawnSync globally. + +import { describe, expect, test } from "bun:test"; +import { + checkOrchestratorState, + parseWorktreeList, +} from "./check-orchestrator-state"; + +describe("parseWorktreeList", () => { + test("parses a single bare worktree block", () => { + const raw = `worktree /repo\nHEAD abc123\nbranch refs/heads/main\n\n`; + const entries = parseWorktreeList(raw); + expect(entries).toHaveLength(1); + expect(entries[0].path).toBe("/repo"); + expect(entries[0].head).toBe("abc123"); + expect(entries[0].branch).toBe("main"); + expect(entries[0].bare).toBe(false); + }); + + test("strips refs/heads/ prefix from branch field", () => { + const raw = `worktree /repo\nHEAD abc\nbranch refs/heads/feat/my-task\n\n`; + const [entry] = parseWorktreeList(raw); + expect(entry.branch).toBe("feat/my-task"); + }); + + test("handles detached HEAD (no branch line)", () => { + const raw = `worktree /repo\nHEAD deadbeef\n\n`; + const [entry] = parseWorktreeList(raw); + expect(entry.branch).toBe("(detached)"); + }); + + test("handles bare worktree marker", () => { + const raw = `worktree /bare.git\nHEAD 0000000\nbranch refs/heads/main\nbare\n\n`; + const [entry] = parseWorktreeList(raw); + expect(entry.bare).toBe(true); + }); + + test("parses multiple worktree blocks", () => { + const raw = [ + `worktree /repo\nHEAD aaa\nbranch refs/heads/main`, + `worktree /repo/.git/worktrees/wt1\nHEAD bbb\nbranch refs/heads/feat/one`, + `worktree /repo/.git/worktrees/wt2\nHEAD ccc\nbranch refs/heads/feat/two`, + "", + ].join("\n\n"); + const entries = parseWorktreeList(raw); + expect(entries).toHaveLength(3); + expect(entries[1].branch).toBe("feat/one"); + expect(entries[2].branch).toBe("feat/two"); + }); +}); + +describe("checkOrchestratorState", () => { + test("no expectation set -> branchMatch=true regardless of current branch", () => { + const state = checkOrchestratorState({}); + expect(state.branchMatch).toBe(true); + expect(state.expectedBranch).toBe(""); + expect(typeof state.currentBranch).toBe("string"); + expect(Array.isArray(state.dirtyFiles)).toBe(true); + expect(Array.isArray(state.worktrees)).toBe(true); + expect(state.driftedWorktrees).toHaveLength(0); + }); + + test("expectation set + matches current -> branchMatch=true", () => { + const baseline = checkOrchestratorState({}); + const state = checkOrchestratorState({ + ZETA_EXPECTED_BRANCH: baseline.currentBranch, + }); + expect(state.branchMatch).toBe(true); + }); + + test("expectation set + does NOT match current -> branchMatch=false", () => { + const state = checkOrchestratorState({ + ZETA_EXPECTED_BRANCH: "definitely-not-the-current-branch-xyz", + }); + expect(state.branchMatch).toBe(false); + }); +}); diff --git a/tools/orchestrator-checks/check-orchestrator-state.ts b/tools/orchestrator-checks/check-orchestrator-state.ts new file mode 100644 index 000000000..5610a4c4e --- /dev/null +++ b/tools/orchestrator-checks/check-orchestrator-state.ts @@ -0,0 +1,117 @@ +#!/usr/bin/env bun +// check-orchestrator-state.ts -- diagnostic: emits structured JSON showing +// current branch, git status, and worktree list for orchestrator start-of-tick +// health checks. +// +// Per docs/backlog/P1/B-0191-orchestrator-branch-verify-mechanization-design-aaron-2026-05-04.md +// (AC5 worktree status sub-row). Rule 0: TS not bash. +// +// Exit codes: +// 0 -- state is clean (branch matches expected if set; no drifted worktrees) +// 1 -- branch mismatch or drifted worktrees detected +// +// Usage: +// bun tools/orchestrator-checks/check-orchestrator-state.ts +// ZETA_EXPECTED_BRANCH=feat/my-branch bun tools/orchestrator-checks/check-orchestrator-state.ts + +import { spawnSync } from "node:child_process"; + +export interface WorktreeEntry { + readonly path: string; + readonly head: string; + readonly branch: string; + readonly bare: boolean; +} + +export interface OrchestratorState { + readonly currentBranch: string; + readonly expectedBranch: string; + readonly branchMatch: boolean; + readonly dirtyFiles: string[]; + readonly worktrees: WorktreeEntry[]; + /** Worktrees (other than the caller's own) that are on the same branch as + * expectedBranch. Non-empty signals the CWD-bleed-over hazard. */ + readonly driftedWorktrees: WorktreeEntry[]; +} + +function run(cmd: string, args: string[]): string { + const r = spawnSync(cmd, args, { encoding: "utf8" }); + if (r.status !== 0) { + throw new Error(`${cmd} ${args.join(" ")} failed: ${r.stderr}`); + } + return r.stdout; +} + +export function parseWorktreeList(raw: string): WorktreeEntry[] { + const entries: WorktreeEntry[] = []; + // --porcelain format: key-value lines, blocks separated by blank lines. + for (const block of raw.split(/\n\n+/)) { + const lines = block.trim().split("\n"); + if (!lines[0]) continue; + const get = (prefix: string) => + lines.find((l) => l.startsWith(prefix))?.slice(prefix.length) ?? ""; + const branchRef = get("branch "); + entries.push({ + path: get("worktree "), + head: get("HEAD "), + // strip refs/heads/ prefix; keep "(detached)" or bare as-is. + branch: branchRef.startsWith("refs/heads/") + ? branchRef.slice("refs/heads/".length) + : branchRef || "(detached)", + bare: lines.includes("bare"), + }); + } + return entries; +} + +export function checkOrchestratorState( + env: NodeJS.ProcessEnv = process.env, +): OrchestratorState { + const currentBranch = run("git", ["branch", "--show-current"]).trim(); + const expectedBranch = env.ZETA_EXPECTED_BRANCH ?? ""; + const dirtyFiles = run("git", ["status", "--short"]) + .split("\n") + .filter(Boolean); + const worktrees = parseWorktreeList( + run("git", ["worktree", "list", "--porcelain"]), + ); + // Own worktree is the first entry; exclude it from drift check. + const driftedWorktrees = + expectedBranch && worktrees.length > 1 + ? worktrees.slice(1).filter((w) => w.branch === expectedBranch) + : []; + return { + currentBranch, + expectedBranch, + branchMatch: !expectedBranch || currentBranch === expectedBranch, + dirtyFiles, + worktrees, + driftedWorktrees, + }; +} + +function main(): number { + const state = checkOrchestratorState(); + console.log(JSON.stringify(state, null, 2)); + + if (!state.branchMatch) { + console.error( + `\nWARNING: current branch '${state.currentBranch}' != expected '${state.expectedBranch}'`, + ); + return 1; + } + if (state.driftedWorktrees.length > 0) { + console.error( + `\nWARNING: ${state.driftedWorktrees.length} other worktree(s) are on branch '${state.expectedBranch}' -- CWD-bleed-over risk.`, + ); + for (const w of state.driftedWorktrees) { + console.error(` ${w.path}`); + } + return 1; + } + return 0; +} + +if (import.meta.main) { + process.exit(main()); +}