Skip to content
Merged
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
50d3a30
ORB-262: make orchestrate own final-head readiness
thomasluizon Aug 7, 2026
c7ab13f
ORB-262: bound connector review waits
thomasluizon Aug 7, 2026
74fd402
ORB-262: accept measured connector aliases
thomasluizon Aug 7, 2026
3c3db39
ORB-262: revalidate readiness against GitHub
thomasluizon Aug 7, 2026
f16d005
ORB-262: close canonical review blind spots
thomasluizon Aug 7, 2026
0ad30d5
ORB-262: close frozen readiness blockers
thomasluizon Aug 7, 2026
c61556c
ORB-262: close connector readiness gaps
thomasluizon Aug 7, 2026
599edae
ORB-262: scope thread mutations to target repo
thomasluizon Aug 7, 2026
b2ac5c1
ORB-262: sync required API contract artifact
thomasluizon Aug 7, 2026
ece5bf9
Merge remote-tracking branch 'origin/main' into chore/orchestrate-rea…
thomasluizon Aug 7, 2026
6d9937d
ORB-260: align review parity guidance
thomasluizon Aug 7, 2026
3d5dc3b
Merge remote-tracking branch 'origin/main' into chore/orchestrate-rea…
thomasluizon Aug 7, 2026
09940dd
fix: close final readiness review gaps
thomasluizon Aug 7, 2026
7c51ce9
Merge remote-tracking branch 'origin/main' into chore/orchestrate-rea…
thomasluizon Aug 7, 2026
5ee1c95
Merge remote-tracking branch 'origin/main' into chore/orchestrate-rea…
thomasluizon Aug 7, 2026
c837583
fix: close final connector and review findings
thomasluizon Aug 7, 2026
130be12
Merge remote-tracking branch 'origin/main' into chore/orchestrate-rea…
thomasluizon Aug 7, 2026
d2782e8
Merge branch 'main' into chore/orchestrate-ready-lifecycle
thomasluizon Aug 7, 2026
e5467b9
Merge remote-tracking branch 'origin/main' into chore/orchestrate-rea…
thomasluizon Aug 7, 2026
f0ee2eb
Merge remote-tracking branch 'origin/chore/orchestrate-ready-lifecycl…
thomasluizon Aug 7, 2026
312e765
fix(orchestrate): harden Linear synchronization
thomasluizon Aug 7, 2026
da73811
fix(orchestrate): close final readiness bypasses
thomasluizon Aug 7, 2026
5be4976
fix(orchestrate): block abbreviated pathspec staging
thomasluizon Aug 7, 2026
f09476a
fix(orchestrate): complete thread and staging guards
thomasluizon Aug 7, 2026
d8187b2
fix(orchestrate): bound commit and thread pagination
thomasluizon Aug 7, 2026
a413a82
fix(orchestrate): block abbreviated commit staging
thomasluizon Aug 7, 2026
cef4f90
fix(orchestrate): close final readiness gaps
thomasluizon Aug 7, 2026
9d5c30a
fix(orchestrate): revalidate live readiness
thomasluizon Aug 7, 2026
0ee82f9
fix(orchestrate): close final readiness gaps
thomasluizon Aug 7, 2026
abc4d2e
fix(orchestrate): revalidate aggregation live
thomasluizon Aug 7, 2026
2e3051c
fix(orchestrate): pin salvage branch
thomasluizon Aug 8, 2026
c56770e
Fix launcher body-edit CI invalidation
thomasluizon Aug 8, 2026
2543b28
Close final readiness invalidation gaps
thomasluizon Aug 8, 2026
d9bb423
Bind reviews and body edits to frozen evidence
thomasluizon Aug 8, 2026
acade79
Await body-edit invalidation during CI polling
thomasluizon Aug 8, 2026
c2937f0
Harden final readiness evidence
thomasluizon Aug 8, 2026
a6105a7
Close final volatile readiness gaps
thomasluizon Aug 8, 2026
6627da4
Anchor final review readiness
thomasluizon Aug 8, 2026
7b5b647
Seal immutable review evidence
thomasluizon Aug 8, 2026
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
6 changes: 4 additions & 2 deletions .claude/agents/product-manager.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ Return, as structured text:
2. **Affected surface inventory**: routes (web + mobile pair), endpoints, DTOs, i18n
namespaces. Name the parity consequence explicitly: web + mobile in one ticket
(parity:yes), or the adapter-only exemption with its justification.
3. **The ticket split**: each ticket one repo, one reviewable PR under ~400 lines,
sized 5 points or less. Cross-repo work is an api ticket BLOCKING a ui ticket
3. **The ticket split**: each ticket one repo and one coherent, independently mergeable PR.
Prefer small tickets when behavior or deployment boundaries are genuinely separable; file and
line estimates are advisory and never justify splitting an atomic behavior or its required
generated artifacts. Cross-repo work is an api ticket BLOCKING a ui ticket
(deploy-API-first as a DAG edge). Shared/DTO changes are append-only: add optional
fields, never rename/remove/retype what shipped mobile clients still read.
Never propose: a tests-only ticket, a foundation ticket of unused code, or a
Expand Down
100 changes: 100 additions & 0 deletions .claude/hooks/_lib/rules-orchestrator.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,28 @@ const API_CLIENTS = new Set(["gh", "curl", "wget", "http", "https", "httpie"])
// bypass is not a prohibition, and this is the one everything else rests on.
const HTTPIE_BINARIES = new Set(["http", "https", "httpie"])
const BARE_PUT = /(?<![\w-])PUT(?![\w-])/
const SHELL_WORD = /"[^"]*"|'[^']*'|\S+/g
const BROAD_ADD_FLAGS = new Set(["-A", "--all", "-u", "--update", "--renormalize"])
const COMMIT_VALUE_FLAGS = new Set(["-m", "--message", "-F", "--file", "-C", "--reuse-message", "-c", "--reedit-message", "--author", "--date", "--cleanup", "--trailer", "--fixup", "--squash"])
const BROAD_COMMIT_LONG_FLAGS = ["--all", "--interactive", "--patch", "--pathspec-from-file", "--pathspec-file-nul"]

const hasBroadCommitShortFlag = (argument) => {
if (!/^-[^-]/.test(argument)) return false
for (const flag of argument.slice(1)) {
if (["a", "i", "p"].includes(flag)) return true
// The remainder is the attached value, not more option letters. In particular, -mapi is a
// message containing a/i/p and stages nothing.
if (["m", "F", "C", "c", "S"].includes(flag)) return false
}
return false
}

const isBroadPathspec = (argument, literalGlobally) => {
const literalPrefix = argument.startsWith(":(literal)")
const path = literalPrefix ? argument.slice(10) : argument
if (!path || /^\.\/?$/.test(path)) return true
return !literalGlobally && !literalPrefix && (/[*?[\]]/.test(path) || argument.startsWith(":"))
}

/**
* Everything before the first real word: leading grouping punctuation and any number of
Expand Down Expand Up @@ -134,6 +156,84 @@ export function checkEngineInvocation(command, { env = {}, cwd = "", repoRoots =
return null
}

/** Workers stage only named paths. Broad staging can sweep prompt residue, generated output, or a
* colleague's tracked `.orca/` edit into the commit. The rule applies only to launcher children and
* linked worktrees; ordinary user Git outside a worker remains untouched. */
export function checkBroadStaging(command, { env = {}, cwd = "", repoRoots = [] } = {}) {
if (typeof command !== "string") return null
if (!env[LAUNCHER_MARKER] && !(cwd && insideLinkedWorktree(cwd, repoRoots))) return null
for (const segment of segmentsOf(command)) {
const source = withoutLeadingAssignments(segment)
if (invokedBinary(source) !== "git") continue
const words = (source.match(SHELL_WORD) ?? []).map((word) => word.replace(/^["']|["']$/g, ""))
const commitIndex = words.findIndex((word, index) => index > 0 && word.toLowerCase() === "commit")
if (commitIndex >= 0) {
const literalGlobally = words.slice(1, commitIndex).includes("--literal-pathspecs")
let afterSeparator = false
let skipValue = false
let broadCommit = false
for (const argument of words.slice(commitIndex + 1)) {
if (skipValue) {
skipValue = false
continue
}
if (!afterSeparator && argument === "--") {
afterSeparator = true
continue
}
if (!afterSeparator && argument.startsWith("-")) {
// Git accepts unambiguous long-option abbreviations. Prefix matching the dangerous set
// closes --intera/--patc and the equivalent pathspec/all spellings mechanically.
if (BROAD_COMMIT_LONG_FLAGS.some((flag) => flag.startsWith(argument) || argument.startsWith(`${flag}=`)) || hasBroadCommitShortFlag(argument)) broadCommit = true
if (COMMIT_VALUE_FLAGS.has(argument)) skipValue = true
continue
}
if (isBroadPathspec(argument, literalGlobally)) broadCommit = true
}
if (broadCommit) {
return blocked(
command,
"Worker worktrees may not let `git commit -a/--all` stage every tracked change. Inspect\n" +
"`git status --short`, stage each intended literal path by name, then commit without an\n" +
"automatic staging flag. Tracked `.orca/` changes are source.",
)
}
}
// `git stage` is an exact synonym for `git add`; guarding only the canonical spelling leaves
// every broad pathspec form available through the alias.
const addIndex = words.findIndex((word, index) => index > 0 && ["add", "stage"].includes(word.toLowerCase()))
if (addIndex < 0) continue
Comment thread
thomasluizon marked this conversation as resolved.
const literalGlobally = words.slice(1, addIndex).includes("--literal-pathspecs")
let afterSeparator = false
let namedPaths = 0
let broad = false
for (const argument of words.slice(addIndex + 1)) {
if (!afterSeparator && argument === "--") {
afterSeparator = true
continue
}
if (!afterSeparator && argument.startsWith("-")) {
if (BROAD_ADD_FLAGS.has(argument) || /^-[^-]*[Au][^-]*$/.test(argument)) broad = true
Comment thread
thomasluizon marked this conversation as resolved.
Outdated
// Git accepts unambiguous long-option abbreviations (measured: --pathspec-from-f), so
// matching only the documented full spelling leaves the same indirect staging bypass.
if (argument.startsWith("--pathspec")) broad = true
continue
}
namedPaths += 1
if (isBroadPathspec(argument, literalGlobally)) broad = true
}
if (!broad && !(words.length > addIndex + 1 && namedPaths === 0)) continue
return blocked(
command,
"Worker worktrees may stage only explicitly named literal paths. Bulk update flags, dot,\n" +
"wildcards, and non-literal magic pathspecs can capture unrelated or runtime residue.\n" +
"Inspect `git status --short`, then use `git --literal-pathspecs add` with each intended\n" +
"path by name. Tracked `.orca/` changes are source.",
)
}
return null
}

/**
* Refuse the admin merge in every shape, with NO launcher or worktree exemption: the prohibition
* is absolute for every agent, and forbidding only the CLI flag would leave both raw API paths
Expand Down
34 changes: 23 additions & 11 deletions .claude/hooks/_lib/rules-sleep.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@
// still the run's own responsibility, and the invariant in the skill says to name it.

/**
* @param options `{ state, wakeSources, sessionId, stopHookActive, isAlive }`
* @param options `{ state, wakeSources, sessionId, stopHookActive, isAlive, receiptVerdict }`
* @returns `{ block, message }` when an unattended run is about to go quiet, else null
*/
export function checkSleepStop({ state, wakeSources = [], sessionId = "", stopHookActive = false, isAlive = () => false } = {}) {
export function checkSleepStop({ state, wakeSources = [], sessionId = "", stopHookActive = false, isAlive = () => false, receiptVerdict = () => null } = {}) {
// A blocked stop that blocks again is an infinite loop, and Claude Code sets this flag on the
// second pass for exactly that reason.
if (stopHookActive) return null
Expand All @@ -35,16 +35,28 @@ export function checkSleepStop({ state, wakeSources = [], sessionId = "", stopHo
* re-entered step 7. A queue is not done while one of its pull requests has not been through the
* rest of the algorithm.
*/
const unreviewed = Array.isArray(state.unreviewedPullRequests) ? state.unreviewedPullRequests.filter((entry) => Number.isInteger(entry)) : []
if (remaining.length === 0 && unreviewed.length === 0) return null
const rawPullRequests = [
...(Array.isArray(state.pullRequests) ? state.pullRequests : []),
...(Array.isArray(state.readinessLedger) ? state.readinessLedger : []),
]
const pullRequests = rawPullRequests.filter(
(entry) => typeof entry?.repositoryKey === "string" && entry.repositoryKey !== "" && Number.isInteger(entry?.prNumber) && typeof entry?.receiptPath === "string" && entry.receiptPath !== "",
)
const uniquePullRequests = [...new Map(pullRequests.map((entry) => [`${entry.repositoryKey}#${entry.prNumber}`, entry])).values()]
Comment thread
thomasluizon marked this conversation as resolved.
const pendingPullRequests = uniquePullRequests.filter((entry) => receiptVerdict(entry) !== "READY")
const invalidPullRequestIdentities = rawPullRequests.length - pullRequests.length +
(Array.isArray(state.unreviewedPullRequests) ? state.unreviewedPullRequests.length : 0)
if (remaining.length === 0 && pendingPullRequests.length === 0 && invalidPullRequestIdentities === 0) return null

const live = wakeSources.filter((source) => Number.isInteger(source?.pid) && isAlive(source.pid))
if (live.length > 0) return null

const outstanding =
remaining.length > 0
? `${remaining.length} ticket(s) left (${remaining.join(", ")})`
: `every ticket done but pull request(s) ${unreviewed.map((number) => `#${number}`).join(", ")} carrying no review verdict`
: invalidPullRequestIdentities > 0
? `${invalidPullRequestIdentities} pull request identity record(s) are bare or invalid; repositoryKey, prNumber, and receiptPath are required`
: `every ticket done but pull request(s) ${pendingPullRequests.map((entry) => `${entry.repositoryKey}#${entry.prNumber}`).join(", ")} lack a READY final-head receipt`

return {
block: true,
Expand All @@ -55,11 +67,11 @@ export function checkSleepStop({ state, wakeSources = [], sessionId = "", stopHo
"When every slot is free and work remains, the action is to LAUNCH THE NEXT TICKET, not to end\n" +
"the turn. `node tools/launch-worker.mjs` registers itself as a wake source, so starting the\n" +
"next worker or the next reviewer clears this by construction.\n\n" +
"A pull request listed in unreviewedPullRequests has not been through steps 7, 8 and 12. Run\n" +
"them, then drop it from the list. A salvaged pull request is not an exception: opening it is\n" +
"the middle of salvage, never the end.\n\n" +
"If the queue really is done and every pull request is reviewed, write `remaining: []` and\n" +
"`unreviewedPullRequests: []` into .git/orbit-orchestrate-run.json, then print the step 14\n" +
"report. Never leave the record saying work remains when it does not.",
"A pull request listed in pullRequests has not reached simultaneous final-head readiness. Run\n" +
"the readiness loop, then drop it only after its receipt says READY. A salvaged pull request\n" +
"is not an exception: opening it is the middle of salvage, never the end.\n\n" +
"If the queue really is done, keep its append-only readinessLedger intact. The hook reads\n" +
"each receipt and allows completion only when every one mechanically reports READY.\n" +
"Never clear the ledger to manufacture an exhausted queue.",
}
}
3 changes: 2 additions & 1 deletion .claude/hooks/orchestrator-guardrails.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { fileURLToPath } from "node:url"

import { readStdinJson } from "./_lib/io.mjs"
import { declaredRepoRoots } from "./_lib/repo-roots.mjs"
import { checkAdminMerge, checkEngineInvocation } from "./_lib/rules-orchestrator.mjs"
import { checkAdminMerge, checkBroadStaging, checkEngineInvocation } from "./_lib/rules-orchestrator.mjs"

try {
const input = readStdinJson()
Expand All @@ -23,6 +23,7 @@ try {
const repoRoots = declaredRepoRoots(resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."))
const verdict =
checkAdminMerge(command) ??
checkBroadStaging(command, { env: process.env, cwd: input?.cwd || process.cwd(), repoRoots }) ??
checkEngineInvocation(command, { env: process.env, cwd: input?.cwd || process.cwd(), repoRoots })
if (verdict?.block) {
process.stderr.write(verdict.message)
Expand Down
91 changes: 90 additions & 1 deletion .claude/hooks/require-wake-source.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,19 @@
// re-derived here: launch-worker.mjs writes them with that same module, and two definitions of
// where the files live is how one of them silently stops finding the other.

import { readFileSync } from "node:fs"
import { fileURLToPath } from "node:url"

import { githubEnvironment, repositorySlug } from "../../tools/lib/github-auth.mjs"
import { runBounded } from "../../tools/lib/bounded-process.mjs"
import { readOrchestratorConfig } from "../../tools/lib/orchestrator-config.mjs"
import { readinessCiIsGreen, readinessReceiptMatchesLive, readinessReport } from "../../tools/lib/readiness-receipt.mjs"
import { readRunState, readWakeSources } from "../../tools/lib/run-state.mjs"
import { readStdinJson } from "./_lib/io.mjs"
import { checkSleepStop } from "./_lib/rules-sleep.mjs"

const LIST_BOT_THREADS = fileURLToPath(new URL("../../tools/list-bot-threads.mjs", import.meta.url))

/** Signal 0 tests for existence without delivering anything. EPERM means it exists and is not ours. */
const isAlive = (pid) => {
try {
Expand All @@ -22,14 +31,94 @@ const isAlive = (pid) => {
}
}

const liveReceiptVerdict = async (entry, config) => {
try {
const receipt = JSON.parse(readFileSync(entry.receiptPath, "utf8"))
if (readinessReport(receipt).verdict !== "READY") return null
const repoRoot = config.repos?.[entry.repositoryKey]
if (typeof repoRoot !== "string" || typeof receipt?.issue !== "string") return null
const repository = repositorySlug(repoRoot)
const githubAuth = await githubEnvironment(repoRoot, { timeoutMs: 45000 })
const viewed = await runBounded(
process.env.GH_BIN || "gh",
["pr", "view", String(entry.prNumber), "--repo", repository, "--json", "number,baseRefName,baseRefOid,headRefOid,isDraft,statusCheckRollup"],
{ cwd: repoRoot, env: githubAuth.environment, timeoutMs: 45000 },
Comment thread
thomasluizon marked this conversation as resolved.
)
if (viewed.timedOut || viewed.error || viewed.status !== 0) return null
const pr = JSON.parse(viewed.stdout)
if (pr?.number !== entry.prNumber || typeof pr?.baseRefName !== "string" || typeof pr?.baseRefOid !== "string" || typeof pr?.headRefOid !== "string" || typeof pr?.isDraft !== "boolean" || !Array.isArray(pr?.statusCheckRollup)) return null

const requiredRead = await runBounded(
process.env.GH_BIN || "gh",
["api", `repos/${repository}/branches/${encodeURIComponent(pr.baseRefName)}/protection/required_status_checks`],
{ cwd: repoRoot, env: githubAuth.environment, timeoutMs: 45000 },
)
if (requiredRead.timedOut || requiredRead.error || requiredRead.status !== 0) return null
const requiredContexts = JSON.parse(requiredRead.stdout)?.contexts

const botRead = await runBounded(
process.execPath,
[LIST_BOT_THREADS, "--pr", String(entry.prNumber), "--repo", entry.repositoryKey, "--wait-seconds", "0", "--poll-seconds", "1", "--command-timeout-seconds", "45", "--no-request"],
{ cwd: repoRoot, env: githubAuth.environment, timeoutMs: 60000 },
)
if (botRead.timedOut || botRead.error || botRead.status !== 0) return null
const bot = JSON.parse(botRead.stdout)

const ORCA = process.env.ORCA_BIN || "C:\\Users\\thoma\\AppData\\Local\\Programs\\orca\\resources\\bin\\orca"
const linearRead = await runBounded(ORCA, ["linear", "issue", receipt.issue, "--full", "--json"], { timeoutMs: 45000, maxBuffer: 16 * 1024 * 1024 })
if (linearRead.timedOut || linearRead.error || linearRead.status !== 0) return null
const linear = JSON.parse(linearRead.stdout)?.result?.issue
if (typeof linear?.state?.name !== "string" || !Array.isArray(linear?.labels) || linear.labels.some((label) => typeof label?.name !== "string")) return null

const live = {
repositoryKey: entry.repositoryKey,
prNumber: pr.number,
baseSha: pr.baseRefOid,
headSha: pr.headRefOid,
draft: pr.isDraft,
linearIssue: receipt.issue,
linearStatus: linear.state.name,
linearVisibleEffect: linear.labels.some((label) => label.name === "visible-effect"),
ciGreen: readinessCiIsGreen(pr.statusCheckRollup, requiredContexts),
connectorPassed: bot?.verdict === "REVIEWED" && bot?.reviewedCommit === pr.headRefOid && bot?.baseRefOid === pr.baseRefOid,
threadsComplete: bot?.threadsComplete === true,
unresolvedThreads: bot?.counts?.unresolved,
}
return readinessReceiptMatchesLive(receipt, entry, live) ? "READY" : null
} catch {
return null
}
}

try {
const input = readStdinJson()
const state = readRunState()
const identities = [
...(Array.isArray(state?.pullRequests) ? state.pullRequests : []),
...(Array.isArray(state?.readinessLedger) ? state.readinessLedger : []),
]
const unique = [...new Map(identities.filter((entry) => typeof entry?.repositoryKey === "string" && Number.isInteger(entry?.prNumber) && typeof entry?.receiptPath === "string").map((entry) => [`${entry.repositoryKey}#${entry.prNumber}`, entry])).values()]
const liveVerdicts = new Map()
if (unique.length > 0) {
let config = null
try {
config = readOrchestratorConfig()
} catch {
config = null
}
if (config) {
await Promise.all(unique.map(async (entry) => {
liveVerdicts.set(`${entry.repositoryKey}#${entry.prNumber}`, await liveReceiptVerdict(entry, config))
}))
}
}
const verdict = checkSleepStop({
state: readRunState(),
state,
wakeSources: readWakeSources(),
sessionId: input?.session_id ?? "",
stopHookActive: input?.stop_hook_active === true,
isAlive,
receiptVerdict: (entry) => liveVerdicts.get(`${entry.repositoryKey}#${entry.prNumber}`) ?? null,
})
if (verdict?.block) {
process.stderr.write(verdict.message)
Expand Down
Loading
Loading