feat(harness): /night-run skill for unattended overnight queue-drain - #444
Conversation
Adds /night-run: a skill plus a Node driver that drains a queue of issues (or a backlog file) overnight, spawning one fresh `claude -p` per task so each starts with clean context instead of one rotting session. Each task ends at a draft PR on its own branch; nothing merges, main is never touched. - run.mjs: the driver. Strips CLAUDECODE to avoid the nested-startup hang (anthropics/claude-code#26190), spawns claude with shell:false (no quoting footgun, verified), per-task --max-budget-usd plus a total-budget cap and a consecutive-failure circuit breaker, resets repos to base between tasks, STOP-flag graceful halt. - SKILL.md: setup -> fit-gate -> human gate -> detached launch, plus status and stop modes. The fit-gate excludes campaign/multi-phase issues. - config.example.json and README.md (layered safety model). - .gitignore: ignore the runtime dir .claude/night-run/. The child runs without --bare, so it inherits the project PreToolUse hooks; git-guardrails blocks any push to main from inside each task. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
|
There was a problem hiding this comment.
Code Review: PR #444 — feat(harness): /night-run skill for unattended overnight queue-drain
Scope: PR #444 in thomasluizon/orbit-ui-mobile (feature/night-run-skill → main)
Recommendation: APPROVE
Summary
This PR adds a new harness skill (.claude/skills/night-run/: run.mjs driver, SKILL.md
runbook, README.md, config.example.json) plus a one-line .gitignore addition. It is
pure tooling — no apps/*, packages/*, or orbit-api code is touched, so none of the
cross-platform, i18n, contract, or UI dimensions apply. The driver's design is sound: no
shell-injection surface (spawnSync with array args, shell: false), it correctly strips
the CLAUDECODE/CLAUDE_CODE_ENTRYPOINT taint vars before every child spawn, and it
layers real safety mechanisms (inherited git-guardrails hook, branch-per-task +
draft-PR-only, per-task and total budget caps, a circuit breaker, a STOP flag). No
Critical or High finding survived verification. Three concrete Medium-level gaps are
worth a follow-up but don't block merge.
Findings
Critical
None.
High
None.
Medium
[MEDIUM] queue.json's per-task `repo` field is documented but never consumed
· dimension: 2 (Dead/stale code) / 1 (Correctness)
· location: orbit-ui-mobile/.claude/skills/night-run/SKILL.md:76 (queue.json schema:
`{ "id", "label", "repo" }`) vs. orbit-ui-mobile/.claude/skills/night-run/run.mjs
(queue loop in `main()`, ~line 220, and `runTask()`)
· issue: SKILL.md Phase 2 documents each queue entry as capturing a target `repo`, but
`run.mjs` never reads `task.repo` anywhere — not for the child's `cwd` (`runTask` always
spawns in `resolve(config.repos[0].path)`, i.e. the first configured repo, regardless of
which repo a task targets), not in the per-task prompt template (which never mentions
`task.repo`), and not in the STATUS.md/SUMMARY.md tables. Confirmed by grepping run.mjs
for `.repo` — zero matches outside `config.repos`.
· risk: an operator queuing a mixed-repo run (some tasks for orbit-ui-mobile, some for
orbit-api) can reasonably expect the driver to route each task by its declared `repo`;
instead every task's `claude -p` child always starts cwd'd in `config.repos[0]` and must
`cd` itself via Bash into any other repo, entirely undocumented in the prompt template.
The field gives a false impression of per-task repo dispatch that doesn't exist.
· fix: either wire `task.repo` into `runTask` (pick `cwd` from `config.repos.find(r =>
r.label === task.repo)` or similar) and reference it in the Phase 1 prompt template, or
drop the field from the documented schema and clarify in README/SKILL.md that repo
routing for a given task is the child's own responsibility via `cd` + `--add-dir`.
· reference: CLAUDE.md rule 2 (delete unused code / don't ship dead surface)
[MEDIUM] git failures in resetReposToBase are silently swallowed
· dimension: 12 (Security/error-handling) / 1 (Correctness)
· location: orbit-ui-mobile/.claude/skills/night-run/run.mjs, `resetReposToBase()`
(`git(repoPath, ["stash", "push", ...])`, `git(repoPath, ["checkout", repo.base])`,
`git(repoPath, ["pull", "--ff-only"])`)
· issue: none of these three `git()` calls check `result.status` or log `result.stderr`.
A failed `git pull --ff-only` (diverged history, no network, no upstream configured) or
a failed `checkout` (e.g. a stash conflicting with an untracked file on the target
branch) is silently ignored — the driver proceeds to the next task as if the repo were
correctly reset.
· risk: the next task's branch gets cut from a stale or wrong base commit with no signal
in `run.log`/`STATUS.md` that anything went wrong, which surfaces later as a confusing
PR built on the wrong base rather than as an actionable error at the point of failure.
· fix: check `.status !== 0` after each call in `resetReposToBase` and `log()` a warning
(at minimum) so a bad reset is visible in `run.log`/`STATUS.md` instead of silent.
· reference: CLAUDE.md rule 8 (never swallow errors silently)
[MEDIUM] total budget cap does not account for spend from a timed-out task
· dimension: 12 (Security — safety-net gap)
· location: orbit-ui-mobile/.claude/skills/night-run/run.mjs, `runTask()` — the
`run.error` branch returns `{ status: "failed", cost: 0, ... }` unconditionally
· issue: when `spawnSync` hits `perTaskTimeoutMs` and kills the child (`ETIMEDOUT`), the
function always reports `cost: 0`, even though the child may have already made billed
API calls before hanging. That $0 is what gets added to the `spent` accumulator that
gates `totalBudgetUsd`.
· risk: real spend from timed-out tasks is invisible to the cap the README describes as
a hard safety layer ("`totalBudgetUsd` accumulator halts the run"). Impact is bounded
by `perTaskBudgetUsd` per timeout and by the `maxConsecutiveFailures` circuit breaker
(default: 2 timeouts ≈ up to $8 unaccounted against a $20 default cap), so this isn't
catastrophic, but it's a real gap in a documented guarantee.
· fix: note in README that the total-budget cap is best-effort under `--output-format
json` (which only reports cost on a clean exit) rather than an absolute ceiling, or
switch to `--output-format stream-json` to get incremental cost reporting that survives
a kill.
· reference: README.md "Safety model" item 3 (Budget caps)
Low / Info
None posted (signal gate — style/nit-level observations dropped).
Subagents
| Agent | Verdict |
|---|---|
| parity-checker | N/A — no apps/web/** or apps/mobile/** file changed |
| i18n-syncer | N/A — no user-facing strings or packages/shared/src/i18n/* changed |
| contract-aligner | N/A — no packages/shared/src/types/* / endpoints.ts / orbit-api DTO changed |
| security-reviewer | N/A — no orbit-api code changed |
| design-reviewer | N/A — no apps/* / orbit-landing-page UI file changed |
Validation
| Check | Result |
|---|---|
| Lint | N/A — no apps/*/packages/* file changed; .claude/skills/** is outside all workspace ESLint configs (confirmed: no root eslint.config.*, only apps/mobile, apps/web, packages/shared have one) |
| Type check | N/A — plain .mjs, no TS project covers .claude/skills/** |
| Tests | N/A — no Vitest/xUnit surface touched |
| Build (api) | N/A — orbit-api untouched |
CI adaptation: per this repo's claude-review.yml, Phase 7 (Validate) is skipped in CI
(Build/Unit Tests/SonarCloud run as separate required checks). A manual read-through of
the full run.mjs diff found it syntactically well-formed (balanced braces/parens
throughout); the PR body's own claim of a clean node --check is consistent with that.
Deferred — N/A dimensions & files not verdicted
- Dimension 8 (DESIGN.md/AI-slop), 9 (Parity), 10 (i18n), 11 (Contract drift), 13 (Backend
hard rules), 14 (FEATURES.md parity): all N/A — the diff touches only
.claude/skills/night-run/**and.gitignore, none of which are in those dimensions'
gated surfaces (noapps/*, noorbit-api, nopackages/shared/src/typesor
src/i18n, no user-facing feature surface). - All 5 changed files got a verdict:
README.md,SKILL.md,config.example.json,
run.mjs(the driver, reviewed line-by-line),.gitignore. Nothing deferred within the
diff's own surface. - One technical claim I initially suspected as a possible High/Critical finding —
whether thedisallowedToolspatterns inconfig.example.json
("Bash(rm -rf *)","Bash(git reset --hard*)","Bash(git push --force*)", no
colon before the trailing wildcard) actually match anything, versus this repo's other
precedent (.github/workflows/claude-review.yml) which uses a colon-prefixed
Bash(cmd:*)form — was checked against this environment's own installedclaude --help, which documents--disallowedTools/--allowedToolsas accepting a "comma or
space-separated list" and gives"Bash(git *) Edit"(bare wildcard, no colon) as its
own canonical example. That directly supports the PR's syntax as valid, so I did not
carry this forward as a finding. I also could not get a fully authoritative answer on
whether--permission-mode bypassPermissionsfully or partially suppresses
--disallowedToolsenforcement (a separate, narrower--dangerously-skip-permissions
flag exists in the same CLI, suggesting the two are distinct anddisallowedToolsstill
applies underbypassPermissions, but I could not verify this against real CLI
behavior in this sandbox). Flagging this residual uncertainty rather than asserting it
either way — worth a quick manual smoke test (queue a task whose prompt asks the child
to run a denylisted command, confirm it's blocked) before relying on the denylist as a
hard guarantee in a real overnight run.
What's good
- Correct threat model for the actual danger in this feature: an unattended,
bypassPermissionsagent running arbitrary bash overnight. The README's own "Residual
risk" paragraph is honest about what's left uncovered, rather than overclaiming safety. spawnSync(..., { shell: false })with array args is the right call for the
Windows quoting footgun the PR body describes, and genuinely closes the injection
surface (no user input flows into a shell string anywhere inrun.mjs).- Stripping
CLAUDECODE/CLAUDE_CODE_ENTRYPOINTbefore every spawn is a precise,
verifiable fix for the cited nested-startup hang (anthropics/claude-code#26190). - "Prepare, not merge" is enforced structurally (draft PR only, branch-per-task, reset to
base between tasks, stash-not-discard for leftovers) rather than left to prompt-level
trust. - The fit-gate in
SKILL.md(excluding campaign/multi-phase issues from the queue) shows
real judgment about this tool's failure mode — it would rather refuse a task than take
an unattended stab at something open-ended.
Recommendation
Approve as-is. The three Medium findings (dead task.repo field, silent git-failure
swallowing in resetReposToBase, and the timeout/budget-accounting gap) are all real and
worth a quick follow-up, but none block merge — they're pattern/defense-in-depth gaps in
a new, self-contained tool with no runtime blast radius on apps/* or orbit-api, not
correctness breaks in shipped product code. Before the first real overnight run, do the
manual smoke test noted in the Deferred section: queue a throwaway task that tries a
denylisted command and confirm it's actually blocked end-to-end under
permissionMode: bypassPermissions.
Landed as one commit because A1 and A4b both edit guards.yml, test-tools.mjs
and tools/README.md, and a per-commit reviewer reading half of either would see
a workflow wired to a tool that does not exist yet. Every file here is
consistent with every other file here.
A1. tools/check-required-gates.mjs reads the workflow files, reads
GET /repos/{owner}/{repo}/branches/{branch}/protection, and exits non-zero
naming each job defined in an enforced workflow with no required context and
each required context nobody produces. The enforced set, the App and CodeQL
contexts with no workflow file, and every exemption with its reason live in
tools/required-gates.json, because inferring the set from "every job on a
pull_request workflow" would name the architecture map, the mutation report,
the perf budget, the visual gate and the review workflow, none of which are
meant to block.
Measured against the real API tonight: orbit-ui-mobile exits 1 naming all 11
guards.yml jobs, orbit-api exits 0 with 10 of 10 accounted for. The recorded
protection payload is committed as a fixture so the harness is hermetic, and a
case asserts the fixture still carries the fields only the real endpoint
returns.
--head reads check runs and honours only the LATEST run per context. On the
first commit of this pull request the endpoint returned 49 raw runs that dedupe
to 31; a scanner reading the whole rollup reports failures GitHub does not
honour, which produced a false red on orbit-api #444 earlier tonight.
Wired report-only into the Harness Execution job. It needs a token with
repository Administration read: workflow permissions has no administration key,
verified against the workflow-syntax reference, so GITHUB_TOKEN cannot
substitute and the step says so rather than reporting a clean diff it never
made.
Also in guards.yml: labeled and unlabeled join the trigger types, without which
every label escape hatch is decorative because edited covers title, body and
base but not labels; and the calibration step stops piping into tee, which
reported tee's status and swallowed a crash in the tool.
A2. Both merge sweeps require, as the LAST API read before the merge call, at
least one APPROVED review whose commit.oid equals the expected head. Refuses on
more than one page rather than paginating, and fails closed on a lookup failure
or an unparseable answer. No author filter: the reviewer login is claude in
GraphQL and claude[bot] in REST, and a rule keyed on either spelling would go
silently wrong. Every refusal case runs with the stub reporting reviewDecision
APPROVED, because that is exactly the signal PR #654 merged on while its newest
approval named a commit that was no longer the head.
A4b. A coverage ratchet, because silent coverage loss is this ticket's root
defect: on PR1 a bare return disabled about 60 assertions while the suite still
printed PASS lines and exited 0. The count is taken by EXECUTION and tallied per
tool by the single reporter, never by regex over the source, which cannot see an
unreachable return. tools/harness-coverage-baseline.json holds 810 assertions
across 37 tools. A drop fails naming the tool and both numbers; growth is free;
shrinking needs the coverage:reseed label, which the workflow turns into the
reseed flag. The table prints every run so a drop stays visible even when
allowed.
J3. merge-sweep-cov.sh's coverage-only Sonar path prints ADMIN-MERGE-REQUIRED
and stops instead of merging with --admin, and squash_merge loses its variadic
passthrough, so the tool cannot reach the override at all rather than merely not
using it at one call site. A case asserts neither sweep passes the flag.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bojq5cLFZ87iBD8zhnoSWH
* ORB-163 J3: forbid the admin merge where agents read the rules (places 2 and 3)
Place 1, the worker contract injected by tools/launch-worker.mjs, landed in PR1.
This adds the same wording to the two docs an agent actually reads before it
touches git: AGENTS.md's "Guardrails you must not trip" for Codex, and
CLAUDE.md's git conventions line for a Claude session.
Both name the REST and GraphQL merge calls alongside the CLI flag. Forbidding
only `--admin` leaves `PUT /repos/{owner}/{repo}/pulls/{number}/merge` and the
GraphQL `mergePullRequest` mutation open, and those are the bypass paths the
identity control would have closed before it was declined.
CLAUDE.md is counted by tools/context-budget.json on a shrink-only baseline, so
the baseline is reseeded here and the pull request carries context:reseed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bojq5cLFZ87iBD8zhnoSWH
* ORB-165: exempt a linked worktree from the raw repo-tool surfacing scan
The declared repository roots are the hook's own root plus orchestrator.json's
repos map, all three of which are the ROOT checkouts. An Orca worktree lives
under none of them, so isRepoArtifact returned false for a worker editing a real
repository file, artifactVerdict then scanned the written content as an
arbitrary payload, and any legitimate repo-tool string in it blocked the write.
tools/README.md is full of those by design.
That is root cause 3 committed by the gate whose own contract forbids it:
"match the real invocation, not a substring of an arbitrary payload, and exempt
writes whose target is outside every repository root". The exemption existed; it
could not see a worktree.
Resolved from the filesystem, with no subprocess in a PreToolUse hook and
nothing machine-specific: walk up for `.git`, a directory IS the root, a file is
read and its gitdir line split on the worktrees segment to recover the main
checkout. The `.git` file shape was read off the real one in this worktree
rather than assumed. Fails CLOSED: unreadable or unrecognised falls through to
scanning.
Proved red before the change and green after, on the same fixture and payload:
status=2 at HEAD, status=0 here. Five cases pin both directions, including that
`git init` in a scratch directory cannot buy the exemption.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bojq5cLFZ87iBD8zhnoSWH
* ORB-163 A3d: refuse raw engine spend and the admin merge, on both shells
Two rules in one PreToolUse guard, registered on the PowerShell tool as well as
Bash. The PowerShell tool fires no hook by default, so every existing command
guard in this repository was open to anyone who reached for the other shell;
git-guardrails and the Linear guard get the same registration here.
The engine rule keys on WHO is calling, never on the subcommand. After the
headless flip `codex exec` is how every worker runs, so refusing the flag would
refuse the launcher. Permitted from a process carrying ORBIT_LAUNCH_WORKER, and
from inside a launcher-created worktree, which is discriminated from the main
checkout by the filesystem: a linked worktree's root carries a `.git` FILE whose
gitdir line points into the main checkout's worktrees directory. No hardcoded
path, no subprocess in a PreToolUse hook.
Matching is command-position only, after heredoc bodies are stripped, so
`.claude/skills/...` never reads as the claude binary and a commit message naming
a banned command stays data. That is root cause 3 and this gate must not commit
it. The second-opinion helper is therefore not refusable by construction: it
invokes node, and a path-based exemption on top would be unreachable code.
The admin-merge rule takes no context at all: neither the launcher marker nor a
worker's worktree buys it, and it names the REST and GraphQL calls alongside the
CLI flag. J3a's prohibition is absolute for every agent.
Three documented bypasses are stated in the module header rather than implied.
This is cost-raising defence in depth and never the control.
repo-roots.mjs carries the worktree resolution ORB-165 added, now shared by both
hooks that need it, each keeping its own policy on top.
Harness: 438 assertions pass, exit 0 read from a file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bojq5cLFZ87iBD8zhnoSWH
* ORB-163 A4: assert the contract clauses against what the launcher actually injects
Every clause was pattern.test(launcherSource) over the whole launch-worker.mjs
file as a string, so nothing they claimed was ever driven. The demonstrated hole:
clause 3 could be deleted from the injected contract entirely, parked in a dead
comment, and all of them still passed.
The cases now run a real launch, read back the contract the launcher appended to
the prompt file, and match each pattern INSIDE its own numbered clause block. A
dry run returns before the append, so the fixture runs for real and fails at
worktree create, which the launcher reaches only after appending.
Two gate proofs ship with it, because an assertion that cannot be shown to fail
is not a gate: gutting clause 3 must name its clauses, and a clause satisfied
only by a phrase from a later clause must be refused while whole-text matching
still accepts it.
Correction to the specification, measured rather than assumed: the terminator
phrase does occur twice, but clause 5's copy is line-wrapped, so the spanning
hazard is LATENT today rather than live. The second proof plants an unwrapped
copy so the gate exercises it anyway.
Three clauses gained assertions that had none: the no-draft rule, the unattended
decisions heading, and clause 7's admin-merge prohibition from J3.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bojq5cLFZ87iBD8zhnoSWH
* ORB-163 A1, A2, A4b and J3: make the deterministic gates able to block
Landed as one commit because A1 and A4b both edit guards.yml, test-tools.mjs
and tools/README.md, and a per-commit reviewer reading half of either would see
a workflow wired to a tool that does not exist yet. Every file here is
consistent with every other file here.
A1. tools/check-required-gates.mjs reads the workflow files, reads
GET /repos/{owner}/{repo}/branches/{branch}/protection, and exits non-zero
naming each job defined in an enforced workflow with no required context and
each required context nobody produces. The enforced set, the App and CodeQL
contexts with no workflow file, and every exemption with its reason live in
tools/required-gates.json, because inferring the set from "every job on a
pull_request workflow" would name the architecture map, the mutation report,
the perf budget, the visual gate and the review workflow, none of which are
meant to block.
Measured against the real API tonight: orbit-ui-mobile exits 1 naming all 11
guards.yml jobs, orbit-api exits 0 with 10 of 10 accounted for. The recorded
protection payload is committed as a fixture so the harness is hermetic, and a
case asserts the fixture still carries the fields only the real endpoint
returns.
--head reads check runs and honours only the LATEST run per context. On the
first commit of this pull request the endpoint returned 49 raw runs that dedupe
to 31; a scanner reading the whole rollup reports failures GitHub does not
honour, which produced a false red on orbit-api #444 earlier tonight.
Wired report-only into the Harness Execution job. It needs a token with
repository Administration read: workflow permissions has no administration key,
verified against the workflow-syntax reference, so GITHUB_TOKEN cannot
substitute and the step says so rather than reporting a clean diff it never
made.
Also in guards.yml: labeled and unlabeled join the trigger types, without which
every label escape hatch is decorative because edited covers title, body and
base but not labels; and the calibration step stops piping into tee, which
reported tee's status and swallowed a crash in the tool.
A2. Both merge sweeps require, as the LAST API read before the merge call, at
least one APPROVED review whose commit.oid equals the expected head. Refuses on
more than one page rather than paginating, and fails closed on a lookup failure
or an unparseable answer. No author filter: the reviewer login is claude in
GraphQL and claude[bot] in REST, and a rule keyed on either spelling would go
silently wrong. Every refusal case runs with the stub reporting reviewDecision
APPROVED, because that is exactly the signal PR #654 merged on while its newest
approval named a commit that was no longer the head.
A4b. A coverage ratchet, because silent coverage loss is this ticket's root
defect: on PR1 a bare return disabled about 60 assertions while the suite still
printed PASS lines and exited 0. The count is taken by EXECUTION and tallied per
tool by the single reporter, never by regex over the source, which cannot see an
unreachable return. tools/harness-coverage-baseline.json holds 810 assertions
across 37 tools. A drop fails naming the tool and both numbers; growth is free;
shrinking needs the coverage:reseed label, which the workflow turns into the
reseed flag. The table prints every run so a drop stays visible even when
allowed.
J3. merge-sweep-cov.sh's coverage-only Sonar path prints ADMIN-MERGE-REQUIRED
and stops instead of merging with --admin, and squash_merge loses its variadic
passthrough, so the tool cannot reach the override at all rather than merely not
using it at one call site. A case asserts neither sweep passes the flag.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bojq5cLFZ87iBD8zhnoSWH
* ORB-163 A2: pin that APPROVED plus CLEAN is insufficient on its own
"An approval on an older commit refuses" understates the gate. The pair a human
or an agent actually reads before typing a merge command is reviewDecision
APPROVED with mergeStateStatus CLEAN, and that pair was TRUE for PR #654, which
merged anyway.
It was true again at 05:20Z on this very pull request: reviewDecision APPROVED,
head 6f8d8a1, both approving reviews naming 0d3df5f, because
dismiss_stale_reviews is false on main. Verified independently through the same
GraphQL read the sweep now performs.
The new case asserts the sweep reached its final approval read, which is only
reachable past the failed-check, DIRTY, review-staleness, pending-check, thread
and Linear gates, and still refused without calling merge.
This also settles that the branch-protection flip is not a substitute for the
code check. merge-sweep runs unattended; it must fail closed on evidence it
reads itself, not on a branch setting it never queries.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bojq5cLFZ87iBD8zhnoSWH
* ORB-163 A1: refuse a workflows directory that is not the repository being checked
The two halves of this diff came from different places and nothing tied them
together: the required contexts from the API for --repo, the job names from
whatever directory the process happened to sit in. A run from this worktree
asking for orbit-api's protection produced a confident, fully formed, entirely
wrong verdict: 17 problems claiming orbit-api defines Cross-Platform Parity and
Expo SDK Pin, and that its own OpenAPI Breaking-Change Gate, Dependency Scan,
Guard Conventions and Guard Migrations are required but defined by nobody. It
read as a discovery rather than a misconfiguration, which is the worst failure
mode a gate can have, and it was one recorded finding away from becoming a false
record.
That is state read from a source nothing keeps current, committed by the tool
written to catch exactly that.
The checkout's own identity now comes from its origin remote and must MATCH
--repo. An identity that cannot be resolved at all is refused too, rather than
assumed benign. A caller who genuinely wants a mismatched or non-checkout source
says so with --unverified-workflows-source, which prints a loud banner into the
output so the verdict is never read as ordinary.
Verified against the run that produced the wrong verdict: it now exits 2 naming
both repositories, while orbit-api pointed at orbit-api's own workflows still
exits 0 and this repository still exits 1 naming all 11 guards.yml jobs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bojq5cLFZ87iBD8zhnoSWH
* ORB-163: A2 refuses a stale approval rather than requiring a fresh one, plus three review fixes
A2, semantics. Verified: every pull request in this repository is authored by
thomasluizon with is_bot false, the ONLY account that has ever posted an
APPROVED review is the bot driven by claude-review.yml, GitHub forbids an author
approving their own pull request, and required_approving_review_count is 1 in
both repositories. PR4 deletes claude-review.yml. So "require an APPROVED review
naming the head" would have refused EVERY unattended merge from that point on,
forever, which is the specification's own J3c failure mode arriving in the two
repositories that matter.
The rule is now: if any review is APPROVED, at least one must name the expected
head, or refuse. If nothing is approved at all, this gate imposes nothing and
the other gates carry the merge. That is exactly as strong against #654, which
was a STALE approval carrying a merge, and against the race reproduced on this
pull request earlier tonight. Renamed to approval_not_stale so the name states
the semantics, and the refusal reads APPROVAL-STALE.
The third case is the one that matters and is new: a pull request with NO
approving review is not refused BY THIS GATE. Without it the semantics are
untested and the next agent restores the stricter form.
Review fix, High. The PUT matcher required a separator, so curl's concatenated
-XPUT slipped through. It now uses [=\s]* like its sibling in rules-linear.mjs,
accepts quoted values, and covers --request as well as --method. Eight cases,
one per shape an agent would actually type, because a gate fixed for exactly the
example it was shown is the defect this ticket exists to remove.
Review fix, Medium. The inline ORBIT_LAUNCH_WORKER=1 exemption is DELETED, not
softened. The launcher passes the marker through the spawn environment and never
shells out with an assignment, so the text check exempted nothing legitimate and
everything an agent chose to type. A case asserts a typed marker with no such
environment is still refused.
The disclosed KNOWN BYPASSES list gains that lesson plus a fourth entry nobody
had flagged: the cwd exemption lets an orchestrating session change directory
into a worktree. The specification accepts that, so it is recorded rather than
closed. An incomplete disclosed-bypass list is worse than none, because it is
read as exhaustive.
Review fix, Medium. The required-gates CI step now passes --head, confirmed as
github.event.pull_request.head.sha against the live pull object and a delivered
workflow run, so the latest-per-context correction executes in production rather
than only in its own fixtures.
Coverage baseline reseeded to 816 assertions across 37 tools.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bojq5cLFZ87iBD8zhnoSWH
* ORB-163 A1: announce a missing admin token loudly instead of passing silently
ADMIN_READ_TOKEN does not exist. The repository's secrets were listed and it is
absent, so the required-gates step took its early exit on every run and reported
success having checked nothing. That is root cause 1 inside the gate A1 exists
to build: wired, green, never executed.
It cannot be avoided by permissions. Reading branch protection needs the
Administration permission and the Actions permissions block has no
administration key, verified against the workflow-syntax reference, so
GITHUB_TOKEN can never satisfy it and a PAT is structurally required.
The step now emits a ::warning:: annotation on the run itself, not only a
summary line nobody reads, and says branch protection was NOT read and NO
alignment was checked. It still never fails: making it fail would turn every
pull request red until the secret exists, this one included.
Three cases pin the wiring rather than the prose, since the step is YAML and no
tool call can reach it: it runs report-only against the pull request head, its
no-token branch warns and cannot read as a verdict, and it captures its status
instead of re-raising it.
The token itself is deliberately not created here. A fine-grained PAT with
Administration read on the three repositories is Thomas's call to make awake;
reusing his account-scoped session token as a repository secret readable by
every workflow is a security downgrade nobody asked for.
Coverage baseline reseeded to 819 assertions across 37 tools.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bojq5cLFZ87iBD8zhnoSWH
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>



What
Adds
/night-run: a skill plus a Node driver that drains a queue of GitHub issues (or abacklog.txt) overnight, spawning one freshclaude -pper task so each starts with clean context instead of one long session that rots. Each task ends at a draft PR on its own branch. Nothing merges;mainis never touched. You wake up to PRs to review.This automates the split-session campaign discipline (one stage per fresh context, state in files, human gate between phases) as a detached driver.
Why this shape
claude -pper task defeats context rot (the reason/goaland/loop, which stay in one growing session, degrade over long unattended runs).CLAUDECODE/CLAUDE_CODE_ENTRYPOINTso children do not hit the ~50% nested-startup hang (Nested claude -p instances hang when CLAUDECODE env var is inherited anthropics/claude-code#26190), and holds no task context of its own.spawnSync(claude, args, { shell: false })passes args with no shell quoting (verified on Windows), sidestepping the PowerShell/.cmdfootgun; the prompt goes over stdin.Safety model (layered)
--bare, so it inherits the projectPreToolUsehooks.git-guardrailsblocks any push or force-push tomain(and the hook-bypass flags) from inside each task.--max-budget-usdplus atotalBudgetUsdcap plus a consecutive-failure circuit breaker.disallowedToolsdenylist,bypassPermissionsso it never stalls at 3am, and aSTOPflag for graceful halt.Files
.claude/skills/night-run/run.mjs: the driver engine.claude/skills/night-run/SKILL.md: the runbook (setup, fit-gate, human gate, detached launch; plusstatusandstopmodes).claude/skills/night-run/config.example.json: the knobs.claude/skills/night-run/README.md: the safety model.gitignore: ignore the runtime dir.claude/night-run/The fit-gate in the skill excludes campaign/multi-phase issues (for example #243's "drive the whole codebase to zero plus loop /prod-readiness") and tells you to decompose them into slices or run them interactively, since night-run's unit is one bounded slice to one reviewable PR.
Validation
node --checkclean; happy-path dry-run passes preflight and lists tasks; dirty-tree preflight correctly blocks; the status-line parser andCLAUDECODE-stripping are unit-checked.claude -pproducing a branch and draft PR) is not yet exercised end to end; the first real run is the final validation.Generated with Claude Code