Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions .claude/skills/orchestrate/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: orchestrate
description: Linear project (or single ticket) in, reviewed PRs out, wave by wave. Computes the merge-gated DAG with tools/wave-plan.mjs, reconciles each ticket against the code (D8), launches one Orca worktree + worker per ticket (engine from .claude/orchestrator.json, claude or codex), babysits CI and review, enforces the evidence gate (D7) and two-strikes (D9). A human merge is the only thing that advances a wave (D3), unless --sleep is passed. Scope is the whole project unless --single bounds it to one ticket. Use after /feature or /bug created the tickets.
description: Linear project (or single ticket) in, reviewed PRs out, wave by wave. Computes the merge-gated DAG with tools/wave-plan.mjs, reconciles each ticket against the code (D8), launches one Orca worktree + worker per ticket (engine from .claude/orchestrator.json, claude or codex), babysits CI and review, enforces the evidence gate (D7) and two-strikes (D9), then tears down each worktree immediately after verified Done. A human merge is the only thing that advances a wave (D3), unless --sleep is passed. Scope is the whole project unless --single bounds it to one ticket. Use after /feature or /bug created the tickets.
argument-hint: <Linear project name or ORB-N> [--single] [--sleep]
effort: high
---
Expand Down Expand Up @@ -266,11 +266,15 @@ somebody remembered.
## 4. Advance

**Project scope, the default.** Thomas merges. On his word (or on observing merges),
fetch, re-run wave-plan, and launch the newly launchable set. Repeat until the
project has no unfinished tickets, then print the final ledger: ticket, PR, merge
SHA, evidence link. This holds whether the run was invoked with a project name or
with a single `ORB-N`, because without `--single` a ticket argument names where to
start, not where to stop.
fetch, verify the Linear issue is Done, then immediately run
`node tools/teardown-worktree.mjs --issue ORB-N` for that ticket. It is evidence-gated:
if it refuses, leave the tree untouched, record the failed check, and do not call the
ticket cleaned up. On confirmed removal, record the removed worktree path in that
ticket's ledger row. Then re-run wave-plan and launch the newly launchable set. Repeat
until the project has no unfinished tickets, then print the final ledger: ticket, PR,
merge SHA, evidence link, removed worktree. This holds whether the run was invoked with
a project name or with a single `ORB-N`, because without `--single` a ticket argument
names where to start, not where to stop.

**`--single` ends here instead.** The run is complete once that one ticket's PR is
open and its issue is In Review with the PR attached (plus the screenshot when it
Expand Down
2 changes: 2 additions & 0 deletions tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
> - POSIX `.sh` is the baseline; add a `.ps1` twin only when it must run in the user's PowerShell shell.
> - Add a new tool with `/make-tool`; add its row to the catalog below in the same change.
> - `test-tools.mjs` EXECUTES every script here (Harness Execution CI job). A new tool with no coverage entry fails it, so coverage lands in the same PR as the tool.
> - `teardown-worktree.mjs` removes a completed ticket's Orca worktree immediately after verified Done, only when its evidence checks pass.

Reusable scripts an agent (or a human) invokes from the CLI. The bar for landing a file here: it has a single clear purpose and you will run it again. One-off commands stay in your shell history or the scratchpad.

Expand Down Expand Up @@ -34,6 +35,7 @@ Read `CONVENTIONS.md` before adding one. Use the `/make-tool` skill to scaffold
| `wave-plan.mjs` | Reads a Linear project's tickets + `blockedBy` relations via the orca CLI and prints the merge-gated wave table. Backs `/orchestrate` (which then launches workers) and `/next` (which stops at the answer). Relation reads run in a bounded pool while the table ordering stays unchanged. | `node tools/wave-plan.mjs --help` |
| `compose-prompt.mjs` | Composes the worker prompt from a Linear issue's verbatim body plus every chronological comment, preserving comment Markdown. Write outside an Orbit repository so the prompt cannot be committed. | `node tools/compose-prompt.mjs --issue ORB-N --output <absolute path>` |
| `launch-worker.mjs` | Launches one ticket's Orca worktree + TUI worker end to end (`/orchestrate` step 2), and is the single place the four measured launch gotchas are handled: `worktree create` needs `--name`, a fresh checkout blocks on the workspace-trust prompt, Orca's `<gituser>/<name>` branch is not the contract branch, and a multi-line prompt through `terminal send` arrives mangled. Reads the engine, model routing and repo paths from `.claude/orchestrator.json`, and refuses any engine that does not declare `interactive: true` (a headless worker cannot be supervised) or whose command or args carry a headless token; prints handle + worktree path + branch as JSON. Exit 0 means the worker ACCEPTED the prompt as a user turn, read back off the TUI after the send, because orca accepting a `terminal send` is not delivery: measured on ORB-88, the composer swallowed the pointer and the launch reported success on a worker sitting idle with no work. Any non-zero exit rolls the worktree and its new branches back out, so a relaunch starts clean. | `node tools/launch-worker.mjs --issue ORB-N --prompt-file <path>` (`--dry-run`, `--help`) |
| `teardown-worktree.mjs` | Removes one completed ticket's Orca worktree, terminals, and local branch immediately after the Linear issue is verified Done. Refuses unless the worktree is clean, its branch tree is present in the target branch, the live issue is Done, and no terminal is repainting. Verifies removal from the filesystem and `git worktree list`, rather than trusting Orca's removal response. | `node tools/teardown-worktree.mjs --issue ORB-N` (or a worktree selector; `--help`) |
| `nudge-worker.mjs` | Delivers a message to a running TUI worker only when it is tui-idle, and exits 1 sending NOTHING while it is mid-turn (a send while busy is queued and cuts the running turn short). Appends the update to the worker's prompt file from stdin and sends a one-line re-read pointer; rejects multi-line `--text`. | `node tools/nudge-worker.mjs --terminal <handle> --prompt-file <path> < update.md` (`--text`, `--help`) |
| `worker-status.mjs` | The mechanical answer to "is the worker actually done", because tui-idle means stopped, not finished. Derives the verdict from artifacts (commits above the freshly fetched `origin/<base>`, a clean worktree, the branch pushed, a PR open against the target, the issue In Review with the PR attached, an image attached when `visible-effect`, D7) and exits 1 listing exactly what is unmet. | `node tools/worker-status.mjs --worktree <path> --issue ORB-N` (`--base`, `--json`, `--help`) |
| `worker-watch.mjs` | The LIVENESS half of babysitting, alongside `worker-status.mjs`'s delivery verdict: for every Orca worktree in the Orbit repos, the ticket, branch, Linear state, BUSY or IDLE classified by repaint delta across two `terminal list` samples, the last meaningful output lines with TUI repaint noise stripped, and the contract verdict. Reports only; deciding what to send a stalled worker stays the orchestrator's judgement. Backs `/watch`. | `node tools/worker-watch.mjs` (`--repo`, `--lines`, `--no-contract`, `--json`, `--help`) |
Expand Down
147 changes: 147 additions & 0 deletions tools/teardown-worktree.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
#!/usr/bin/env node
/**
* Remove one completed Orca worktree only after independently checking that no
* work can be lost. Orca may drop its runtime connection after completing a
* removal, so success is verified from the filesystem and Git, never its reply.
*/

import { execFileSync, spawnSync } from "node:child_process"
import { existsSync, realpathSync } from "node:fs"
import { resolve } from "node:path"

import { isRepainting } from "./lib/tui-repaint.mjs"

const USAGE = `usage: teardown-worktree.mjs (--issue ORB-N | --worktree <path>) [--base <ref>]

--issue ORB-N remove the Orca worktree linked to this Linear issue
--worktree <path> remove this Orca child worktree, using its linked Linear issue
--base <ref> target branch whose tree must contain the worktree branch (default: worktree base or main)
--help, -h print this usage and exit 0

All four checks must pass before anything is removed: the tree is clean, its branch tree is
present in the target branch, the linked Linear issue is Done, and no terminal is mid-turn.
Removal is successful only when the path is gone and git worktree list no longer names it.

exit codes: 0 removed and verified, 1 evidence or removal verification failed, 2 usage error,
3 an orca or git command could not be read`

if (process.argv.includes("--help") || process.argv.includes("-h")) {
console.log(USAGE)
process.exit(0)
}

const ORCA = process.env.ORCA_BIN || "C:\\Users\\thoma\\AppData\\Local\\Programs\\orca\\resources\\bin\\orca"
const GIT = process.env.GIT_BIN || "git"
const fail = (code, message) => {
console.error(message)
process.exit(code)
}
const argOf = (flag) => {
const index = process.argv.indexOf(flag)
if (index === -1) return null
const value = process.argv[index + 1]
return value === undefined || value.startsWith("-") ? undefined : value
}
const KNOWN_FLAGS = new Set(["--issue", "--worktree", "--base", "--help", "-h"])
const unknown = process.argv.slice(2).filter((token) => token.startsWith("-") && !KNOWN_FLAGS.has(token))
if (unknown.length > 0) fail(2, `${USAGE}\n\nunknown option(s): ${unknown.join(" ")}`)

const requestedIssue = argOf("--issue")
const requestedWorktree = argOf("--worktree")
const requestedBase = argOf("--base")
if ([requestedIssue, requestedWorktree, requestedBase].some((value) => value === undefined)) fail(2, `${USAGE}\n\nselector flags require a value`)
if ((requestedIssue && requestedWorktree) || (!requestedIssue && !requestedWorktree)) fail(2, `${USAGE}\n\nprovide exactly one selector`)
if (requestedIssue && !/^[A-Z]+-\d+$/.test(requestedIssue)) fail(2, `${USAGE}\n\n--issue must be a Linear identifier such as ORB-75`)

const orca = (args) => {
let raw
try {
raw = execFileSync(ORCA, [...args, "--json"], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 })
} catch (error) {
const payload = error.stdout?.toString().trim() ?? ""
return fail(3, `orca ${args.join(" ")} failed: ${payload || error.stderr?.toString().trim() || error.message}`)
}
try {
const parsed = JSON.parse(raw)
if (parsed.ok === false) fail(3, `orca ${args.join(" ")} failed: ${parsed.error?.message ?? parsed.message ?? "unknown error"}`)
return parsed.result ?? parsed
} catch (error) {
return fail(3, `orca ${args.join(" ")} returned unparseable output: ${raw.slice(0, 300)}`)
}
}
const git = (path, args, { allowFailure = false } = {}) => {
const result = spawnSync(GIT, ["-C", path, ...args], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 })
if (result.status === 0) return result.stdout.trim()
if (allowFailure) return null
fail(3, `git ${args.join(" ")} failed: ${(result.stderr || result.stdout || "unknown error").trim()}`)
}
const selectorPath = (value) => value?.replace(/^path:/, "")
const normalize = (path) => resolve(selectorPath(path)).replaceAll("\\", "/").replace(/\/+$/, "").toLowerCase()

const worktrees = orca(["worktree", "list"]).worktrees ?? []
const worktree = requestedIssue
? worktrees.find((entry) => !entry.isMainWorktree && !entry.isArchived && entry.linkedLinearIssue === requestedIssue)
: worktrees.find((entry) => normalize(entry.path) === normalize(requestedWorktree))
if (!worktree) fail(1, requestedIssue ? `no active Orca worktree is linked to ${requestedIssue}` : `no active Orca worktree matches ${requestedWorktree}`)
if (worktree.isMainWorktree) fail(1, "refusing to remove a primary checkout")
if (!worktree.linkedLinearIssue || !/^[A-Z]+-\d+$/.test(worktree.linkedLinearIssue)) fail(1, "refusing a worktree without a linked Linear issue")

const path = worktree.path
const selector = `path:${path}`
const issue = worktree.linkedLinearIssue
const branch = (worktree.branch ?? git(path, ["rev-parse", "--abbrev-ref", "HEAD"])).replace(/^refs\/heads\//, "")
const base = requestedBase ?? worktree.baseRef ?? "main"
const dirty = git(path, ["status", "--short"]).split("\n").filter(Boolean)
const terminals = (orca(["terminal", "list"]).terminals ?? []).filter((terminal) => normalize(terminal.worktreePath) === normalize(path))
const busy = terminals.filter((terminal) => isRepainting(orca, terminal.handle))
const detail = orca(["linear", "issue", issue])
const linearIssue = detail.issue ?? detail
const state = linearIssue.state?.name ?? linearIssue.state

git(path, ["fetch", "--quiet", "origin", base], { allowFailure: true })
const baseRef = git(path, ["rev-parse", "--verify", "--quiet", `origin/${base}`], { allowFailure: true }) ? `origin/${base}` : base
const mergeBase = git(path, ["merge-base", baseRef, branch])
const branchPaths = git(path, ["diff", "--name-only", mergeBase, branch]).split("\n").filter(Boolean)
const treePresent = branchPaths.length === 0 || git(path, ["diff", "--quiet", baseRef, branch, "--", ...branchPaths], { allowFailure: true }) !== null
const checks = [
{ name: "worktree-clean", ok: dirty.length === 0, detail: dirty.length ? `uncommitted paths: ${dirty.join(", ")}` : "no uncommitted work" },
{ name: "tree-present-in-target", ok: treePresent, detail: treePresent ? `${branch}'s content is present in ${baseRef}` : `${branch}'s content is not present in ${baseRef}` },
{ name: "linear-done", ok: state === "Done", detail: `issue is ${state ?? "unknown"}, expected Done` },
{ name: "terminals-idle", ok: busy.length === 0, detail: busy.length ? `worker is still working: ${busy.map((terminal) => terminal.handle).join(", ")}` : `${terminals.length} terminal(s) idle` },
]
const unmet = checks.filter((check) => !check.ok)
if (unmet.length > 0) {
for (const check of unmet) console.error(`UNMET ${check.name}: ${check.detail}`)
process.exit(1)
}

const commonDirRaw = git(path, ["rev-parse", "--git-common-dir"])
const commonDir = resolve(path, commonDirRaw)
const gitCommon = (args, { allowFailure = false } = {}) => {
const result = spawnSync(GIT, [`--git-dir=${commonDir}`, ...args], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 })
if (result.status === 0) return result.stdout.trim()
if (allowFailure) return null
fail(3, `git ${args.join(" ")} failed: ${(result.stderr || result.stdout || "unknown error").trim()}`)
}
orca(["terminal", "stop", "--worktree", selector])
try {
execFileSync(ORCA, ["worktree", "rm", "--worktree", selector, "--force", "--json"], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 })
} catch {
// Verification below decides whether a dropped Orca runtime connection was harmless.
}
gitCommon(["worktree", "prune"], { allowFailure: true })
const gitWorktrees = gitCommon(["worktree", "list", "--porcelain"])
const stillListed = gitWorktrees.split("\n").some((line) => line === `worktree ${path}` || normalize(line.replace(/^worktree /, "")) === normalize(path))
const pathGone = !existsSync(path)
if (!pathGone || stillListed) fail(1, `removal verification failed: filesystem=${pathGone ? "gone" : "present"}, git-worktree-list=${stillListed ? "present" : "gone"}`)

const branchExists = gitCommon(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { allowFailure: true }) !== null
if (branchExists) {
const dropped = gitCommon(["branch", "-D", branch], { allowFailure: true })
if (dropped === null) fail(1, `removed worktree but could not delete local branch ${branch}`)
}
const branchRemaining = gitCommon(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { allowFailure: true }) !== null
if (branchRemaining) fail(1, `removed worktree but local branch ${branch} still exists`)
console.log(`REMOVED worktree ${path}`)
console.log(`REMOVED terminals for ${path}`)
console.log(`REMOVED local branch ${branch}`)
Loading
Loading