Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
17 changes: 16 additions & 1 deletion .claude/hooks/test-hooks.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -538,7 +538,22 @@ const WAKE_HOOK = "require-wake-source.mjs"
const { readWakeSources, runStatePath } = await import("../../tools/lib/run-state.mjs")
const stopPayload = { session_id: "orbit-hooks-gate-session", stop_hook_active: false }
const priorState = existsSync(runStatePath()) ? readFileSync(runStatePath(), "utf8") : null
const liveWakeSources = readWakeSources().length
/**
* LIVE, not merely registered. `readWakeSources` returns every registration file; the hook then
* proves each pid with `process.kill(pid, 0)` before honouring it. Counting registrations made this
* gate take the "a live wake source allows the stop" arm whenever an old overnight run had left a
* file behind for a process that has since died, and then fail because the hook correctly blocked.
* Measured on this checkout 2026-08-10: red on main and on the branch alike, for stale state alone.
*/
const isAlive = (pid) => {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
const liveWakeSources = readWakeSources().filter((source) => isAlive(source.pid)).length
try {
writeFileSync(runStatePath(), JSON.stringify({ sessionId: stopPayload.session_id, sleep: true, remaining: ["ORB-2"] }))
// A live wake source is a legitimate reason NOT to block, so assert the blocking case only when
Expand Down
18 changes: 18 additions & 0 deletions .claude/skills/merge-prs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ Immediately after each confirmed merge:
GitHub confirms MERGED. Run
`node tools/complete-ticket.mjs --issue "<actual-ticket-reference>"` for the Done and close
transition. Use only repository ticket tools and never issue a raw ticket mutation.

That tool now posts the ticket's **manual steps** as a comment before it closes the issue, and
returns them on its `manualSteps` field. **Collect every one of them across the merged set.** A
ticket with no such step returns null and is silent, which is the normal case.
2. Run the repository's canonical teardown tool for that ticket.
3. If a server-side update left the local worktree at the approved head, fetch
`refs/pull/<pr>/head`, fast-forward that exact local branch, and rerun teardown.
Expand All @@ -140,3 +144,17 @@ Do not finish until the ledger proves:

Return the merge order, PR/ticket/commit mapping, cleanup result, exclusions preserved, and any lane
that required a human conflict handoff.

## Still outstanding: the manual steps this merge did NOT do

End the report with one explicit **"Still outstanding"** list: every manual step collected above, per
merged ticket, expanded and numbered, or the single line `No manual steps outstanding.`

This is not a courtesy summary. orbit-tickets#81 merged on 2026-08-08 with the body line "Rollout:
merge, deploy to Render, then set `PostHog:ApiKey` in the Render env. The code path is inert until
the key exists." Review was clean, CI was green, the ticket closed Done, and nothing in this skill
ever mentioned the key. It happened to be set already, so nothing was lost, which is exactly why it
is worth writing down: the merge path could not tell the difference. A merge is not a deploy and a
deploy is not a rollout. Say what is left, at the moment Thomas is reading, or it is not said at all.

**Never do the step yourself.** These land in a vendor console under Thomas's account. Print them.
57 changes: 55 additions & 2 deletions .claude/skills/orchestrate/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ run it always was.
generated artifact together; split only at real behavior/deployment boundaries
2b QUESTION GATE BOTH MODES. Every question the queue raises, asked in ONE batch, before
the first worktree. Answers that remove a ticket remove it now.
CONVERSATION-FIRST ticket: --sleep -> already deferred NEEDS_CONVERSATION.
Attended -> converse ONE TOPIC AT A TIME, then write the decisions to the
ticket with comment-ticket.mjs BEFORE compose-prompt. No worker until answered.
THEN write .git/orbit-orchestrate-run.json: session, sleep, remaining[]
---- per ticket, in wave order ----
3 Worktree orca worktree create; git switch -c feature/<ticket-slug>-<slug>
Expand Down Expand Up @@ -96,6 +99,7 @@ run it always was.
into branch when behind · rerun invalidated receipts · synchronize the ticket
READY only when every receipt names the same current head and base SHA
14 Hand over PR URL, advisory diff size, receipt and READY verdict.
PRINT any manual step the ticket carries (complete-ticket.mjs --preflight)
READY -> In Review.
UPDATE remaining[] in .git/orbit-orchestrate-run.json
no --sleep -> STOP and wait for `continue` · --sleep -> next ticket
Expand All @@ -111,7 +115,9 @@ run it always was.
These interfaces are fixed. Do not invent flags or variants.

```
node tools/plan-queue.mjs (--tickets ORB-1,ORB-2 | --board) [--format markdown]
node tools/plan-queue.mjs (--tickets ORB-1,ORB-2 | --board) [--format markdown] [--sleep]
node tools/comment-ticket.mjs --issue "<ticket-ref>" --body-file <path|->
node tools/complete-ticket.mjs --issue "<ticket-ref>" [--preflight]
node tools/compose-prompt.mjs --issue "<ticket-ref>" --repo <key> --out <file> [--worktree <p>] [--branch <b>] [--base <ref>]
node tools/launch-worker.mjs --issue "<ticket-ref>" --worktree <p> --prompt <f> [--codex-only]
node tools/launch-worker.mjs --issue "<ticket-ref>" --review --repo <key> --prompt <f> [--codex-only]
Expand Down Expand Up @@ -320,6 +326,7 @@ The ticket is the prompt (D2): quoted verbatim into the worker prompt, never par
| `NOT_REPRODUCED` | the body says NOT REPRODUCED, asks for a device or emulator repro, or makes obtaining one the first Scope item. ORB-128 and ORB-208 are both Android runtime bugs whose competing hypotheses only a device can tell apart |
| `NOT_CODE_WORK` | the body says no code in any repo, Ops-only, or HUMAN-ONLY. ORB-27, ORB-28, ORB-83 |
| `MULTI_PR` | the body scopes itself to several pull requests, which breaks D4 before the harness sees it. ORB-25, ORB-26 |
| `NEEDS_CONVERSATION` | **`--sleep` only.** The ticket can be executed headlessly, but not CORRECTLY without asking first: a human grant in its acceptance criteria, a body that contradicts itself about which tool is current, a choice left to the implementer, or a product/brand/copy/price call the repository cannot supply. Thomas is asleep, so it defers WITH its open questions printed. ORB-30 (#36) |

The last four are the executability pass, added after the Onda 1 queue admitted 71 tickets and
deferred none while eleven of them could not be executed by a headless agent at all. Two things it is
Expand Down Expand Up @@ -370,6 +377,37 @@ Ask all of them in ONE `AskUserQuestion` batch, or one message if there are more
answer removes a ticket, remove it from the queue before the run starts** rather than spawning a
worker that will fail.

### The conversation-first exception: some tickets need a talk, not a batch

`plan-queue.mjs` classifies each ticket for the four signals above and returns
`conversation: {source, signals, questions}` on the admitted entry, plus a `CONVERSATION FIRST`
warning. Pass `--sleep` to the planner when the run has it, and those tickets defer as
`NEEDS_CONVERSATION` with their questions printed instead.

The label `needs:conversation` forces it on and `needs:no-conversation` forces it off; either
overrides the body. Use `needs:no-conversation` once the questions are already answered in a comment,
so the ticket runs headless the next night.

**Under `--sleep`, never attempt one.** A conversation cannot happen while Thomas is asleep. The
ticket defers before any worker spawns and its open questions go in the step 15 report, so he wakes
to a decision list rather than a confidently wrong pull request.

**Attended, converse before you compose.** The one-batch rule above is right for "should this ticket
run at all" and wrong for "design this with me". So for a conversation-first ticket:

1. Ask **one topic at a time**. Show the contradiction or the grant, state the options, recommend one,
and wait. Rule 7 of `.claude/rules/core.md` still holds: assert the obvious option and ask for
confirmation rather than presenting a menu.
2. **Never spawn the worker until the open questions are answered.** A product, brand, copy, price or
design call is not something to proceed on under an assumption.
3. **Write the decisions back to the ticket as a comment, before composing the worker prompt.** This
needs no new machinery and is the whole trick: a comment is already part of the work order and the
LATER comment already wins, and `compose-prompt.mjs` already passes comments through to the
worker. So the answers reach the implementer over a path that already exists, and they are durable
and auditable instead of living in this session's scrollback. Post it with
`node tools/comment-ticket.mjs --issue "<ticket-ref>" --body-file <scratchpad-file>`, never a raw
`gh issue comment`.

**What this gate cannot do, stated plainly rather than implied.** It asks only what is derivable from
the tickets UP FRONT. It cannot predict what a worker hits mid-run: a dependency that turns out to be
missing, a test that was already broken, an API whose real response contradicts the ticket. Those
Expand Down Expand Up @@ -882,6 +920,18 @@ Print:
`BOT REVIEW ABSENT`.
- Every follow-up ticket filed, by identifier.
- `DEGRADED: same-vendor review` when `--codex-only` was passed.
- **Any manual step the ticket carries**, expanded. Run
`node tools/complete-ticket.mjs --issue "<ticket-ref>" --preflight` and print its `manualSteps`
field verbatim; it writes nothing. Silence is the normal answer and prints nothing at all.

**Why a manual step is printed here and not only at merge.** orbit-tickets#81 said "merge, deploy to
Render, then set `PostHog:ApiKey` in the Render env. The code path is inert until the key exists." The
pull request was perfect, review was clean, CI was green, the ticket closed Done on 2026-08-08, and
nothing anywhere in that path ever mentioned the key. The key turned out to be set already (verified
live 2026-08-10: `posthog-dotnet` events since 2026-07-25, nothing lost), so this is a near miss
rather than an incident. The missing thing is not the key, it is any mechanism that knew. Every gate
in this harness measures the PULL REQUEST; that step is not in one, so it has to be carried to the
human at the moments a human is reading. 13 of the 166 open tickets carry a step of the same shape.

**Then, without `--sleep`: STOP and wait for Thomas to type `continue`.** Nothing polls and nothing
watches; zero tokens burn while it waits. **With `--sleep`: go straight to the next ticket.**
Expand All @@ -893,7 +943,10 @@ Once the queue is exhausted, print one summary and stop:
- Every pull request opened, with repository, number, current base/head SHAs, advisory diff size,
behind count and receipt verdict.
- **The stack layout**, so the merge order is stated rather than worked out at 08:00.
- Every ticket skipped, with its reason: a deferral from step 1 or a genuine delivery blocker.
- Every ticket skipped, with its reason: a deferral from step 1 or a genuine delivery blocker. For a
`NEEDS_CONVERSATION` deferral, print its open questions too, so the night ends in a decision list.
- **Every manual step across the whole queue, in one "still outstanding" list.** These are Thomas's
clicks, not the harness's, and they are the only work the merge does not finish.
- **The single command that merges the lot**, ready for Thomas to approve.

Append one JSON line per ticket outcome to `<scratchpad>/queue-run.jsonl` as the queue runs, not at
Expand Down
54 changes: 54 additions & 0 deletions tools/__tests__/comment-ticket.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { existsSync, readFileSync } from "node:fs"

import { T, check, orcaEnv, stage } from "./_harness.mjs"

const TOOL = "comment-ticket.mjs"

const issue = () => JSON.stringify({
blockedBy: { nodes: [], totalCount: 0 },
blocking: { nodes: [], totalCount: 0 },
body: "Ticket body",
labels: [{ name: "repo:ui" }],
number: 221,
state: "OPEN",
stateReason: null,
title: "Ticket title",
url: "https://github.com/thomasluizon/orbit-tickets/issues/221",
})

const project = () => JSON.stringify({
items: [{ content: { number: 221, repository: "thomasluizon/orbit-tickets", type: "Issue" }, id: "PVTI_comment", status: "Todo" }],
totalCount: 1,
})

const plan = (label) => {
const bodyCapture = stage(`comment-ticket/${label}-body.txt`, "unwritten")
const statusMarker = stage(`comment-ticket/${label}-status`, "pending")
return {
bodyCapture,
statusMarker,
entries: [
{ match: "issue view 221 --repo thomasluizon/orbit-tickets", stdout: issue() },
{ match: "project item-list 2 --owner thomasluizon", stdout: project() },
{ match: "issue comment 221 --repo thomasluizon/orbit-tickets", stdout: "", ignoreTicketShape: true, stdinFile: bodyCapture },
{ match: "project item-edit 2 --owner thomasluizon", stdout: "", ignoreTicketShape: true, removePath: statusMarker },
],
}
}

export const cases = () => {
check(TOOL, "refuses a missing issue reference", ["--body-file", "-"], { status: 2, stderr: /--issue is required/ })
check(TOOL, "refuses a missing body file", ["--issue", "#221"], { status: 2, stderr: /--body-file is required/ })
check(TOOL, "refuses an unknown migrated identifier", ["--issue", "ORB-999999", "--body-file", "-"], { status: 2, stderr: /Unknown migrated ticket ORB-999999/ })

const empty = stage("comment-ticket/empty.md", " \n")
check(TOOL, "refuses an empty comment body", ["--issue", "#221", "--body-file", empty], { status: 2, stderr: /the comment body is empty/ })
check(TOOL, "refuses an unreadable body file", ["--issue", "#221", "--body-file", `${empty}.absent`], { status: 2, stderr: /cannot read/ })

const posted = plan("posted")
const body = stage("comment-ticket/decisions.md", "Decisions from step 2b\n\n- Claude Design, not Pencil.\n")
check(TOOL, "posts the comment body verbatim", ["--issue", "#221", "--body-file", body], { status: 0, stdout: /"number": 221/ }, { env: orcaEnv(posted.entries) })
T(`${TOOL}: the comment carried the file's exact bytes`, readFileSync(posted.bodyCapture, "utf8") === readFileSync(body, "utf8"), readFileSync(posted.bodyCapture, "utf8"))
/** A decisions comment is not a lifecycle transition. Moving Status here would be a second, unasked write. */
T(`${TOOL}: commenting never touches board Status`, existsSync(posted.statusMarker))
}
58 changes: 49 additions & 9 deletions tools/__tests__/complete-ticket.mjs
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { existsSync } from "node:fs"
import { existsSync, readFileSync } from "node:fs"

import { T, check, orcaEnv, stage } from "./_harness.mjs"

const TOOL = "complete-ticket.mjs"
const issue = (state = "OPEN") => JSON.stringify({

/** orbit-tickets#81's rollout line, the step that closed with the ticket and was never surfaced. */
const ROLLOUT_BODY = "## Rollout / kill switch\n\n* Rollout: merge, deploy to Render, then set `PostHog:ApiKey` in the Render env.\n"

const issue = (state = "OPEN", body = "Ticket body", repoLabel = "repo:ui") => JSON.stringify({
blockedBy: { nodes: [], totalCount: 0 },
blocking: { nodes: [], totalCount: 0 },
body: "Ticket body",
labels: [{ name: "repo:ui" }],
body,
labels: [{ name: repoLabel }],
number: 221,
state,
stateReason: state === "OPEN" ? null : "COMPLETED",
Expand All @@ -23,12 +27,22 @@ const project = (present = true) => JSON.stringify({
totalCount: present ? 1 : 0,
})

const plan = ({ present = true, state = "OPEN", includeWrites = true } = {}) => {
const statusMarker = stage(`complete-ticket/${present}-${state}-status`, "pending")
const closeMarker = stage(`complete-ticket/${present}-${state}-close`, "pending")
const plan = ({ present = true, state = "OPEN", includeWrites = true, body = "Ticket body", repoLabel = "repo:ui", label = "", commentExit = 0 } = {}) => {
const scope = `${present}-${state}-${label}`
const statusMarker = stage(`complete-ticket/${scope}-status`, "pending")
const closeMarker = stage(`complete-ticket/${scope}-close`, "pending")
const commentCapture = stage(`complete-ticket/${scope}-comment.txt`, "unwritten")
const entries = [
{ match: "issue view 221 --repo thomasluizon/orbit-tickets", stdout: issue(state) },
{ match: "issue view 221 --repo thomasluizon/orbit-tickets", stdout: issue(state, body, repoLabel) },
{ match: "project item-list 2 --owner thomasluizon", stdout: project(present) },
{
match: "issue comment 221 --repo thomasluizon/orbit-tickets",
stdout: "",
stderr: commentExit === 0 ? "" : "comment write refused",
exit: commentExit,
ignoreTicketShape: true,
stdinFile: commentCapture,
},
]
if (includeWrites) {
entries.push(
Expand All @@ -46,7 +60,7 @@ const plan = ({ present = true, state = "OPEN", includeWrites = true } = {}) =>
},
)
}
return { entries, statusMarker, closeMarker }
return { entries, statusMarker, closeMarker, commentCapture }
}

export const cases = async () => {
Expand All @@ -63,4 +77,30 @@ export const cases = async () => {
const completion = plan()
check(TOOL, "the post-merge path accepts a plain number and completes the ticket", ["--issue", "221"], { status: 0, stdout: /"number": 221/ }, { env: orcaEnv(completion.entries) })
T(`${TOOL}: completion sets Done and closes with the completed reason`, !existsSync(completion.statusMarker) && !existsSync(completion.closeMarker))
/** Silence is the correct output for the common case: an ordinary ticket gets no comment at all. */
T(`${TOOL}: a ticket with no rollout section is closed without a comment`, readFileSync(completion.commentCapture, "utf8") === "unwritten")

/**
* The gap this whole path exists for. orbit-tickets#81 closed Done on 2026-08-08 carrying "set
* PostHog:ApiKey in the Render env" and nothing in the merge path mentioned it. The key was in
* fact already set, so this is a near miss; the instruction still has to reach the ticket,
* because the ticket outlives the terminal.
*/
const rollout = plan({ body: ROLLOUT_BODY, repoLabel: "repo:api", label: "rollout" })
check(TOOL, "a rollout step is posted to the ticket and returned", ["--issue", "221"], { status: 0, stdout: /PostHog__ApiKey/ }, { env: orcaEnv(rollout.entries) })
const posted = readFileSync(rollout.commentCapture, "utf8")
T(`${TOOL}: the comment names the env var that actually binds the key`, /Key: `PostHog__ApiKey`/.test(posted), posted)
T(`${TOOL}: the comment reached the ticket AND the ticket still completed`, !existsSync(rollout.statusMarker) && !existsSync(rollout.closeMarker))

const preflightRollout = plan({ body: ROLLOUT_BODY, repoLabel: "repo:api", label: "preflight-rollout" })
check(TOOL, "preflight PRINTS the manual step and posts nothing", ["--issue", "221", "--preflight"], { status: 0, stdout: /PostHog__ApiKey/ }, { env: orcaEnv(preflightRollout.entries) })
T(`${TOOL}: preflight wrote no comment and no completion`, readFileSync(preflightRollout.commentCapture, "utf8") === "unwritten" && existsSync(preflightRollout.statusMarker))

/**
* Comment BEFORE close, proven by the failure path: a refused comment must abort the completion.
* Closing first and commenting second would reintroduce the exact defect on any comment failure.
*/
const refused = plan({ body: ROLLOUT_BODY, repoLabel: "repo:api", label: "refused", commentExit: 1 })
check(TOOL, "a refused comment aborts the completion instead of closing silently", ["--issue", "221"], { status: 1, stderr: /complete-ticket:/ }, { env: orcaEnv(refused.entries) })
T(`${TOOL}: the ticket was neither set Done nor closed when its step could not be recorded`, existsSync(refused.statusMarker) && existsSync(refused.closeMarker))
}
Loading
Loading