Imported from thomasholknielsen/claude-tweaks (
.claude/skills/gh-api-module-pattern/SKILL.md). Install upstream withnpx skills add thomasholknielsen/claude-tweaks --skill gh-api-module-pattern. Copyright stays with the author.
gh-api module pattern
How this repo writes Node modules that shell to gh. Two shipped instances define the pattern: plugin/bin/lib/issues/capabilities-probe.js (reads) and plugin/bin/lib/issues/link.js (writes). Two shipped bugs in one session (#626, #610) came from getting the flag rules wrong — read that table before writing any gh api call.
The injectable runner
- The module takes
runner(args), invoked as ifgh ${args.join(' ')}, returning stdout; a throw is a failed call. ExportdefaultRunner=execFileSync('gh', args, { encoding: 'utf8' })— always an argv array, never a shell string (no injection surface, no quoting bugs). - Tests never touch real
gh: fake runners branch on theargsshape (isGraphQL,isPost-style lazy helpers) andthrow new Error('unexpected ' + args.join(' '))on anything unhandled — a wrong endpoint fails loudly instead of passing silently. Record values inside the runner, assert after it returns (anAssertionErrorthrown inside a runner can be swallowed by the module's own try/catch). - A module may carry two runner seams, and they have opposite failure contracts.
plugin/bin/lib/claim-targets/claim-targets.jstakes both:deps.ghApi— theplugin/bin/lib/issues/claim-store.jscontract, which never throws and returns{stdout, failure, status}so a 404/409/422 arrives as data — for contents-API reads and writes, anddeps.gh— the throwing runner above — for everything else (repo view,label list/create,issue edit,issue comment). Decide which seam a call belongs to before writing it: a never-throwing runner wrapped intry/catchreads as success on every rejection, and a throwing one used bare escapes uncaught. - An argv array stops shell injection, not flag injection. A caller-supplied value that begins with
-is still read as an option by the command itself, so any such value needs an end-of-options separator ahead of it. Forgitthe separator is--end-of-options, not--:--switchesrev-parse --verifyandmerge-baseinto pathspec mode and silently changes what they resolve — verified empirically before the fix landed, after--was tried first (#888 review lens 3b).plugin/bin/lib/blast-radius-cli.jsis the shipped instance, on both of its revision-resolving calls. Pin the separator with a fake-runner test that asserts the exact argv array for a value like-evil(tests/bin-lib/blast-radius-cli.test.js), not just that the call succeeded. - The same seam serves non-
ghcommands.plugin/bin/lib/blast-radius-cli.jstakesdeps.gitanddeps.readFileon exactly the contract above and cites this skill for it. Nothing in the injectable-runner rules isgh-specific — the argv-array rule, the loud-throw fake, and record-inside/assert-after all carry over unchanged; only thegh apiflag table below isgh-only. - Bound every remote-contacting call on the seam; leave local-only calls unbounded.
execFileSynchas no default timeout, so a black-holed remote hangs the caller indefinitely. Everyghcall and every networkgitcall (fetch,remote prune,ls-remote) passes an explicittimeout—GH_TIMEOUT_MS = 5000(plugin/bin/lib/issues/claim-store.js,plugin/bin/claim-targets.js),FETCH_TIMEOUT_MS = 5000(plugin/bin/lib/reconcile/classify.js,pr-state.js,red-tip.js), orplugin/bin/lib/hooks/git-exec.js'sDEFAULT_TIMEOUT_MS = 10000for anything routed throughrunGit. A batched call earns a wider bound than the 5s single-call convention —plugin/bin/fetch-sub-issues.js's runner passestimeout: 30000for a 50-alias GraphQL batch and states the reason in a comment beside it; widen deliberately with a stated reason rather than carrying 5000 over by copy. Local-only reads (rev-parse,git config,branch -r --merged) take none. ThedefaultRunnershape above is the argv-array rule, not a complete options object — aghrunner copied from it still owes atimeout. On a seam that is otherwise all-local, widen it with a per-call options argument rather than bounding every call:plugin/bin/residue.js'srunner(argv, opts)takes exactly such a per-callexecFileSyncoptions object (#663), and the seam's existing catch-all already maps a timeout kill onto its normal failure value. That seam has no bounded call site today — thegit remote pruneit was added for was since removed, andplugin/bin/lib/residue/probes/branches.jsstates why in a comment beside the gap ("Nogit remote prunehere: this probe's findings are read-only") — so keep the options argument and pass atimeoutthe moment a probe contacts a remote, rather than reading the absent call as evidence the seam is unused. Omitting the bound is defensible only with a stated reason —plugin/bin/lib/reconcile/shared-fetch.jsskipstimeoutMssogit-exec.js'sCT_HOOKS_GIT_TIMEOUT_MSescape hatch still applies, and says so in a comment. - The same probe copied across N separate modules is the cross-module instance of the sibling-drift bullet below — consolidate into one shared, injectable helper rather than leaving each copy to drift on its own options object.
plugin/bin/lib/repo-resolve.js'sghAvailable(deps = {})(#2017) replaced seven near-verbatimexecFileSync('gh', ['--version'], …)probes scattered acrossfile-feedback.js,link-records.js,materialize.js,release-claim.js,compose-context/resolve-conditions.js,wrap-up/engine-verify.js, andrepo-resolve.jsitself — each had quietly drifted on its options object (stdio: 'ignore'vsstdio: 'pipe', some with notimeoutat all) before the consolidation. A grep-backed test (tests/bin-lib/repo-resolve.test.js) now pins'--version'to the single production site, so the count can't silently regress back to N copies. A caller whose own seam shape differs (engine-verify.js'sdeps.gh(args, cwd)takes acwdthe shared helper has no slot for) wraps the shared call in a one-line adapter rather than reintroducing a local copy. - Sibling default runners in one module drift on their options object — build both from one factory, once you've decided they should share it. A module shelling to both
gitandghon this seam ends up with adefaultGitand adefaultGhdiffering only in the command name, so a rule applied to one silently leaves the other behind.plugin/bin/lib/wrap-up/engine-verify.jsshipped the pair unbounded, then gainedtimeout: 5000ondefaultGhalone when the bullet above was applied to its remote-contacting call (7569d4ce8, refs #900), and needed a separate whole-branch review later the same day to noticedefaultGithad been left behind (#1230,76fd10487) — one options object, edited twice, hours apart, because nothing but eyeballing the two copies could show they disagreed. It now builds both frommakeDefaultRunner(cmd)— one closure over one options object — so the two can no longer diverge by copy (#1222). Weigh what the factory costs before reaching for it: it forces symmetry, and the bullet above says symmetry is not automatically correct (that module'sgitcalls are all local and owe no bound at all — a uniform 5s is merely harmless there, not required). Factor when the siblings genuinely should share their options; otherwise keep them separate and state beside each why it differs. Same remedy as the sync/async twin drift under Async fan-out below, on a different axis: factor at the point of duplication rather than diffing copies later. - A seam whose calls are probes runs quiet, and carries its own error class so the CLI can pick the exit code.
plugin/bin/lib/verify/changed-files.js(#1922) shells togiton exactly the seam above, and every real call passesstdio: ['ignore', 'pipe', 'pipe']— the postureblast-radius-cli.js's own defaultgit()already uses — because base resolution asks questions it expects to fail:merge-base --is-ancestoron a stale stamp anchor,rev-parse --verifyon a ref that may not exist. Without the quiet posture a benign miss leaks git's ownfatal:line to the stderr of a process that is about to exit 0, which reads to a caller as a real error. The message is not thrown away: the module raises a namedChangedFilesErrorcarrying it, and the CLI — not the module — decides what an unresolvable base means per mode, since the same module-level failure is a different class of problem depending on who asked.verify.js --scopemaps it to the usage-class exit 2 (an unresolvable base is a malformed invocation, not a check failure),verify.js --changed-filesto its own documented exit 1, and both re-throwanything that is not aChangedFilesErrorrather than flattening an unrelated bug into a tidy exit code. A barethrow new Erroron the seam would force the CLI to string-match its own module to tell the two apart.
The three gh api value mechanisms (the bug source)
| You are passing… | Flag | Why |
|---|---|---|
gh's literal {owner}/{repo}/{branch} placeholder in a field value |
-F |
Only -F substitutes; -f sends the braces as a static string (#626) |
An already-resolved string bound to a GraphQL String! variable |
-f |
-F type-coerces an all-numeric name (2048) to an Int and GraphQL rejects it (#610) |
A numeric REST body field (sub_issue_id, issue_id) |
-F |
So it lands as an integer, not a string |
URL path placeholders (repos/{owner}/{repo}/…) are a fourth thing: always substituted, no flag involved. Never collapse these into "always -F for owner/repo" — that generalization is exactly what shipped #610's bug, with a plan-authored test pinning the wrong flag.
A caller-overridable REST path has no bound-variable escape hatch. The {owner}/{repo} path placeholders above always resolve from gh's own ambient repo context (the cwd's git remote) — there is no -f owner=…/-F owner=… equivalent that rebinds a REST path segment the way -f owner=… rebinds a GraphQL variable (row 2 above). A CLI that must honor its own --repo owner/name override for a REST call (rather than gh's ambient context) therefore has to build the path string itself — plugin/bin/fetch-sub-issues.js's --resolve-retries retry loop does exactly this (`repos/${owner}/${repo}/issues/${n}/sub_issues`, #1153 review finding). That loses the placeholder mechanism's implicit safety: the interpolated owner/repo values need their own validation before they reach the path, since a lax upstream parser (this repo's shared parseRepo accepts any non-/ segment, including ./..) can otherwise redirect the call. fetch-sub-issues.js added a narrow owner !== '.' && owner !== '..' guard at its own call site rather than tightening the shared parseRepo (9 callers, most never resolve a caller-supplied --repo at all) — a reminder to validate at the interpolation site when you can't fix the shared resolver's blast radius in the same change.
gh api graphql is where two of these rows collide: a String! variable needs -f, and -f never expands {owner}/{repo}, so the placeholder mechanism is simply unavailable to a GraphQL variable. Resolve the slug locally instead — plugin/bin/lib/hooks/git-exec.js's repoSlugOf parses it out of remote get-url origin and is shared by reconcile/pr-state.js and reconcile/release-merged.js (#1082) — and pass it as -f owner=…/-f name=…. claim-targets.js's gh repo view --json nameWithOwner form is for a seam holding a gh runner but no repo root; hooks/teardown-run.js's inline copy of the same regex predates the shared helper and is not license for a fourth.
Batching and failure posture
- Resolve everything up front in one call where the API allows it (aliased GraphQL: one
i{N}: issue(number:{N}){ databaseId }per distinct number), and chunk the alias list rather than letting one query grow without bound —plugin/bin/fetch-sub-issues.jsslices its input at 50 numbers per call and merges each chunk's result into one envelope. - The alias-query builder is a pure function in
plugin/bin/lib/issues/record.js, separate from whatever runs it.buildNativeDependencyQuery,buildNativeSubIssuesQuery, andbuildNativeParentQueryeach take anumbersarray, return the query string, and returnnullfor an empty or non-array input — an emptyrepository{}selection set is invalid GraphQL, so the caller must branch onnullrather than send it.record.jsrequires nothing but./facet-shapeand shells out nowhere, which is what letstests/bin-lib/issues/record.test.jspin each builder with plain regex assertions and no fake runner at all; the transport, the chunking, and the partial-result posture below all belong to the caller (plugin/bin/lib/issues/native-dependencies.js,plugin/bin/resolve-blockers.js, or a skill's ownnode -eline), never to the builder. Add a fourth builder the same way — and factor the sharedquery($owner:String!,$repo:String!){ repository(owner:$owner,name:$repo){ … } }envelope out when you do: the three above repeat it verbatim today (#1194 hindsight). - Two sanctioned partial-result postures; choose by whether the caller can recover per item. Throw on a partial result when partial data is unusable —
plugin/bin/lib/issues/native-dependencies.js'sfetchNativeDependenciesthrows naming every missingi{N}alias (#723), because a caller that silently drops a dependency edge ships a wrong graph. Return a retry envelope when the caller has a per-item fallback — the same file'sfetchNativeSubIssuesreturns{byParent, retry}(#1097), routing a single missing or null alias ontoretryfor the caller's per-parent REST loop instead of failing the whole batch; such a parent never lands inbyParentas[], because an empty array already means "fetched, has no sub-issues". A whole-response failure (data.repositorynull or missing) throws under both postures — an envelope degrades per item, never per batch. - Routing an existing caller onto a shared module inherits that module's posture — re-check the caller's blast radius, not just its output shape. The two postures above are chosen when a module is written; the hazard lands at the caller migration, where a call site that degraded per item silently starts degrading per batch.
plugin/skills/dispatch/queue-pull-script.md'swork-links: nativebranch (#1309) moved from a hand-rolledgh api graphqlresponse fed torecord.js'spartitionByOpenNativeBlockers— which reads a missingi{N}alias as an empty blocker list and so fails open for that one candidate — ontoplugin/bin/resolve-blockers.js, whosefetchNativeDependenciesthrows on any single missing alias (the throw on a partial result posture above), exits 3, and disables native blocker filtering for every candidate in the run. The per-record output shape was identical and the suite stayed green; only the failure fan-out changed, so no shape-level assertion could catch it. When such a migration is the point of the change, state the old and new degrade granularity side by side in the record, and give the caller an exit-code branch that separates a partial result from a total outage instead of one catch-allelse. - A bulk alias query is chunked, all-or-nothing, and never authorizes a destructive act by itself.
plugin/bin/lib/reconcile/pr-state.js'sresolvePrStatesBulk(#1082) resolves N branches' governing PRs inceil(N/50)calls ofb{i}: ref(qualifiedName:"refs/heads/…"){ associatedPullRequests }aliases — the chunk size is probe-measured, not guessed: 50 aliases cost 1 GraphQL rate-limit point. It returns a completeMapor a failure sentinel, never a partial map: a transport throw, an HTTP-200 body carryingerrors[], and an alias key missing fromdata.repositoryeach fail the whole call, because a dropped chunk's branches are otherwise indistinguishable from branches with no PR. Bulk evidence is also weaker than a per-branch read —ref()is null for a branch deleted after its PR merged — so a caller screens with it to skip cheaply and re-confirms viaresolvePrStatebefore anything destructive (prune-remote.js,archive-branches.js). - Per-write calls are each independently try/caught into
{ok: [...], failed: [{…, error}]}— one failed edge never aborts the batch. An "already exists" 422 is a re-run, not a failure: it lands inokwithalready: true(live-confirmed wording: GitHub answersValidation failed: Target issue has already been taken). - Error text: join
err.message/err.stderr/err.stdout, with aString(err)fallback so a non-Error throw never yields an emptyfailed[].error. - Enumerate an operation's full documented error-status set in one sitting. Adding one status per bug report ships the same misclassification serially:
plugin/bin/lib/issues/claim-store.js's Contents-API PUT got its 422 create-race branch in75c8b3b6, then the symmetric 409 sha-mismatch branch in4ee0fbcc— one defect class, found twice, the second time by a review lens. Read the endpoint's documented statuses first (Contents PUT: 404 read-miss, 409 sha-mismatch, 422 create-race) and branch on all of them in the same commit. - Rate-limit recognition and burst pacing. Classify a
gh apirate-limit failure perplugin/skills/_shared/github-rate-limit.md's taxonomy before deciding whether to retry — a plain 403 under that file's rules is not transient and must not be retried. When a module issues a scripted sequence of mutative calls, follow that file's burst-shape rules. - A transient-failure retry on a non-idempotent mutating call needs a dedup-safe recheck, not the same blind retry as an idempotent read. A 5xx/timeout/reset signature is ambiguous about where the failure happened: the request may have reached GitHub and succeeded, with only the response lost. Retrying
gh issue list/issue viewon that signature is free — a second read changes nothing. Retryinggh issue create(or any other non-idempotent write) the same way risks filing a duplicate.plugin/bin/lib/feedback/file-feedback.js'screateWithDedupSafeRetryis the shipped instance (#834/#835 review): before each retry of the create call, it re-runs the module's own dedup search for a marker unique to this call — a hit means the "failed" attempt actually succeeded, so that result is reused instead of creating a second one. A single generic retry wrapper applied uniformly across a module's read and write calls is exactly the shape this hazard hides in. - A machine-filed issue carries only the labels its filing context can actually justify — and the module's header says so. The two shipped
gh issue createsites split on whether an LLM is in the loop.plugin/bin/lib/feedback/file-feedback.js'sfileDrafttakeslabelsfrom its caller, because the/feedbackskill orchestrating it can judge them.plugin/bin/lib/reconcile/escalate-residue.jsfiles--label bugand nothing else, from the no-LLM contexts named in its header (session-start.jsin-process,bin/hooks.js reconcile) — deliberately, not as a gap (#1216):risk:*/size:*are content judgments no mechanical caller can score, a fixed always-low default is wrong on its own, andreadyasserts a spec-shaped body that a terse auto-report is not. Leave the enrichment to the downstream path that demonstrably picks the issue up — a plain open issue is already a backlog-stage record that/specifyshapes and/backloggrants route onward — and record the posture in the module's header comment, since the alternative is a later reader re-opening the thin label set as a missing-facets bug (which is what #1216 was). - Widen an existing call's
-qprojection before adding a second API call. When a later step needs one more field off data you already fetched, extend thejqfilter rather than issuing a fresh request —plugin/bin/lib/issues/claim-store.js'slistClaimEntrieswidened-q .[].nameto-q '[.[] | {name, sha}]'to get each claim blob'sshafrom the same single Contents-API directory listing (#820 D6), instead of a second lookup per entry. - Cross-check a documented failure-status word against
append.js'sSTATUSESenum before wiring a batch CLI'scatchto log nothing on failure.plugin/bin/lib/log-decision/append.js'sSTATUSES(AUTO/STAGED/KEPT-PROMPT/SCANNED/REFUSED/SKIP) has noFAILEDmember. A consuming skill's prose can still document aFAILED {time} — …decisions.mdtemplate its own closing-summary tally counts on —apply-refine-labels.js(#844) shipped exactly this gap: itscatchblock only appends to the stdout JSON'sfailed[]array, never todecisions.md, becauseFAILEDisn't a loggable status. Check the enum, not just the prose, before assuming a documented status word is writable viaappendEntry. - On a path another process may prune, read and catch — never
existsSync-then-read. These modules read.claude-tweaks/pipelines/run-dirs that this project's own reconcile/archive passes prune concurrently, so anexistsSyncguard ahead ofreadFileSync/readdirSyncis a window, not a check: the path can vanish between the two calls and the read throws exactly where the guard promised it could not. Read straight off and wrap intry/catch— an absent path and a mid-read prune then arrive as the same degraded value, with one code path instead of two.plugin/bin/lib/wrap-up/engine-verify.jsshipped the guard-then-read pair three times (resolvedIssueNumbers,memory-updates's index read, and its per-check map); two independent review agents reproduced it and997e571cfixed all three. A bareexistsSyncused as a verdict, with no read behind it (resolveArchivedRunDir, therun-dir-archivedcheck), is not this hazard — the pair is. - One unit's throw never aborts the report, and the verdict a throw maps to is a decision. The per-write
{ok, failed}rule above generalizes past runner calls: any module mapping a registry of independent units to results wraps each unit in its owntry/catch, because one uncaught throw prints no output at all and exits through Node's default path, silently colliding with the CLI's documented exit-code contract.engine-verify.js'srunVerifymaps itsCHECKSarray that way and maps a throw tofail, notunknown: for a gate whose whole purpose is to block on doubt, a unit that could not determine its own state is evidence to block rather than to shrug. Pick the direction the module errs and say so beside thecatch— the two are not interchangeable. isIndeterminateis the wrong failure filter when a probe's negative answer lives in stdout.plugin/bin/lib/hooks/git-exec.jsexportsisIndeterminate(timeout/spawn/no-git) for failures where "the question went unanswered, rather than answered in the negative" — but that split only holds for a call whose negative answer is the non-zero exit (merge-base --is-ancestor,rev-parse --verify). For a probe that answers in stdout —git ls-files -- <dir>prints nothing for an untracked path and still exits 0 — a definitiveFAILURE.GIT_ERROR(corrupt index, unreadable object store) is exactly as unanswered as a timeout, so filtering onisIndeterminateand lettinggit-errorfall through to the falsy branch reads "the probe broke" as "the answer is no".plugin/bin/lib/reconcile/archive-merged.js'shasTrackedContent(#2227) shipped that in751f22c92— the whole-branch review explicitly waved thegit-errorcase through as "an answer" — and a later review lens caught it against the module's own sibling guard,archiveRunDir'sls-files-failedrefusal, which already fails closed on any failure;67af77ee7made every failure kind assume tracked. When a probe result selects a route rather than filling a report field, branch onfailureas one condition, say which direction is safe in a comment beside it, and check the sibling probe in the same module before adopting a narrower filter than it uses.
Async fan-out
execFileSync blocks the event loop regardless of how the calling code is structured, so a concurrency pool built over sync runners buys nothing — the async runner below is what actually makes fan-out non-blocking. plugin/bin/lib/reconcile/gh-pool.js establishes the async sibling to the sync seam above:
- Export an async runner via
promisify(execFile), returning the same{stdout, failure, status}shape the syncdeps.ghApi-style seam already returns (release-merged.js'sghApiAsync,console-execute.js'sexecFileAsync) — same contract, non-blocking transport. - Reuse a single injectable
ghApioption for both sync and async call sites rather than adding a second override parameter: a sync test fake gets wrapped in an already-resolved promise (release-merged.js'sreleaseMergedopts) so callers and tests don't need to know which transport a given call uses. gh-pool.js'srunWithConcurrencyfans calls out order-preserving and concurrency-capped, with each item's own try/catch storing theErrorat its index — the async form of "one failed edge never aborts the batch" above. Clamp the concurrency cap to>= 1: an unclampedMath.min(cap, len)withcap <= 0/NaNspawns zero workers and silently resolves an all-undefinedarray instead of throwing or doing the work.- A new async runner needs a non-blocking proof, and it must be structural, not wall-clock.
promisify(execFile)andexecFileSyncunder anasyncwrapper are indistinguishable by signature, so the property this whole section depends on has to be tested explicitly — but on this shared machine any assertion over elapsed time flakes under sibling-session load wherever the margin sits:tests/bin-lib/reconcile/pr-state.test.js's event-loop test burned a fixed< 400msbound (#1127), then a concurrent-vs-sequential ratio (#1404), before landing on an existence check — each spawnedghwrapper drops a marker file for its own lifetime, samples how many coexist, and the test asserts the observed max is>= 2. That holds at any machine speed and is impossible for a blocking implementation, whose single-threaded spawns can never overlap.console-execute.jsandrelease-merged.jscarry the same seam with no such test; add one on this shape when you touch them. promisify(execFile)(orexecFileSync) must be called at the site the runner fires, not hoisted to module scope. A module whose sync half already resolvescp.execFileSync(...)at call time (rather than a require-time destructure) exists specifically sot.mock.method(cp, 'execFileSync', ...)/a plaincp.execFileSync = stubreassignment reaches it — butconst execFileAsync = promisify(cp.execFile)at the top of the file snapshots the originalcp.execFilereference throughpromisify, silently defeating that same mockability for the async sibling alone (plugin/bin/lib/hooks/git-exec.js'srunGitAsync, #872: shipped once, broke a timeout test's ability to mock a hung child, fixed by callingpromisify(cp.execFile)inside the function body instead). To stub a hung/slow async call deterministically without this, give the stub a[require('util').promisify.custom]property — the same hook Node's ownchild_process.execFilecarries — since a plain callback-shaped stub only returns a bare value under genericpromisify, not the{stdout, stderr}shapeexecFile's real custom implementation provides.- A real async timeout race against a fast child is not equivalent to
execFileSync's timeout at the sametimeoutMs.execFileSync's timeout enforcement reliably beats even a trivial, sub-millisecond child on ordinary hardware;execFile's async timeout is a genuinesetTimeout-vs-real-child-completion race that a sufficiently fast/cache-warm host (a fresh CI runner in particular) can occasionally win on the child's side even attimeoutMs: 1— the #872 incident above (a test green 20/20 on one machine, red on GitHub Actions). Test an async timeout's classification branch with a mocked hung child (per the bullet above), never by racing a real short-lived process against a millisecond-scale deadline. - Once a sync/async twin pair's two catch blocks are independently hand-written, they drift — extract a shared
runClassified/runClassifiedAsynctry/execute/catch scaffold instead.git-exec.js'srunGit/runGitAsyncandpreflight.js'sghHealthCheck/ghHealthCheckAsynceach retyped the sametry { … } catch (err) { return shape(err); }body once per twin — a whole-branch pre-release review (pre-v6.110.0) caughtrunGit'sstderrfield added without the mirroring update landing onrunGitAsync, despite a header comment asserting "identical return shape" (#1652). The fix is two tiers:plugin/bin/lib/shared-primitives.js'srunClassified(fn, mapError)/runClassifiedAsync(fn, mapError)are the shared try/execute/catch shell (sync executesfn()directly, asyncawaits it; both callmapError(err)on a throw and never otherwise) — and each pair additionally defines its ownbuildSuccess/buildFailureonce, called from both twins, so a shape fix (a new field, a renamed key) lands in one function instead of two. Do this only where a pair's own classification/shaping logic is actually duplicated — a twin that's a pure delegator with notry/catchof its own (shared-fetch.js'ssharedFetch/sharedFetchAsync, which just callsrunGit/runGitAsync) has nothing to extract and inherits the fix transitively through the primitive it already calls.
The CLI wrapper contract
-
Logic lives in an exported
run(argv, deps); every side effect (runner,ghAvailable,remoteUrl,stdout,stderr) goes throughdepsso tests inject fakes. Everydepscall that can throw (e.g.remoteUrloutside a git repo) is try/caught into the documented exit-code contract — an un-wrapped deps call is where #610's one post-review high landed. -
require.main === moduleguard setsprocess.exitCode = run(...)(neverprocess.exit, which can truncate piped stdout).--helpshort-circuits before any availability probe. This one is pinned mechanically, not by grep —tests/bin-lib/exit-code-conformance.test.js(#1903) walks everyplugin/bin/**/*.js, extracts the text eachrequire.main === moduleguard governs, and fails on a directprocess.exit(...)inside it. Three prior records (#1176, #1313, #1535) each closed one violation of this rule found by a manual sweep that missed another, which is why the sweep was replaced. A new guard needs no registration; a deliberate exception goes in that test'sALLOWLISTwith its reason stated beside it (today:bin/hooks.js, whose stdout is a synchronousfs.writeSync(1, …)with nothing pending to flush, andbin/lib/statusline-wrapper-source.js, which deliberately hard-stops on a child process's own exit code). -
The guard rule above only sees a file's own entry-point guard — it does not follow the call graph into a library function that guard invokes.
plugin/bin/lib/health-core/*.jsare shared library files invoked from deep inside all four health-suite CLIs' (code-health.js,harness-health.js,journey-health.js,docs-health.js) own correctly-guardedmain()— outside any guard #1903's scan can see, so aprocess.exit()there was invisible to it (#2053:churn-report.js,mark.js,retry-cli.js'supdateeach did this after a pending stdout/stderr write). The fix mirrors the guard rule's own shape one level down rather than making the mechanical scan call-graph-aware (disproportionate for a bug class with exactly three known instances): each library function returns a numeric code (orundefined) instead of exiting, and each CLI'smain()dispatch line captures that return value intoprocess.exitCode, e.g.if (cmd === 'churn-report') { const code = cmdChurnReport(args); if (code) process.exitCode = code; return; }— the sameprocess.exitCode = N; return;shape every other branch in the samemain()already uses.tests/bin-lib/exit-code-conformance.test.jsadds a second, narrower rule for this: a flat per-file sweep ofplugin/bin/lib/health-core/*.js(no guard extraction, no call-graph walk) failing on any directprocess.exit(anywhere in the file. Never widen this scope to all ofplugin/bin/lib/**—bin/lib/statusline-wrapper-source.js's ownprocess.exit()calls are a different case (a guard-less whole-script file, not a library function invoked from another file's guard) already covered by the guard-scoped test's ownALLOWLISTabove. -
A multi-verb dispatcher needs one table-driven guard, not N per-verb ones.
plugin/bin/hooks.jsisn't a single-purpose CLI in the sense above — it's onemain(argv)dispatching 14 documented subcommands (plus 6 harness-invoked EVENTS names). Before #1143, each subcommand branch treated a stray--help/-has an ordinary argument, ran for real, and on the--run-omitted verbs could fall through toresolveRunArg's implicit "newest non-terminal run" guess — silently stamping a sibling session'srun-state.json(observed 2026-08-20). The fix generalizes the single-CLI--helpshort-circuit above into oneUSAGElookup table (verb → usage string) checked once, ahead of every branch inmain(), before any lib call orresolveRunArgscan — not a per-verb early-return copy-pasted 14 times. Regression risk for this shape specifically: the guard's own coverage can silently drift from the dispatch table it guards (a verb added tomain()'scmd === '...'chain with no matchingUSAGEentry gets no guard at all) —tests/hooks-help-guard.test.jspins this with a structural test scanningmain()'s source for everycmd === '...'branch and asserting each has aUSAGEentry, not just the reverse (iteratingUSAGE's own keys, which proves nothing about branches missing from the table). -
Exit codes are a documented contract, and this repo ships three base vocabularies — pick the one the CLI's sibling and its consuming prose already use, then spell it out in the file's header comment and
USAGE:- Combined-2 (
plugin/bin/link-records.js): 0 success or partial-with-failed, 1 upstream resolution failure, 2 malformed invocation or missing dependency. - Split-1/2 (
plugin/bin/resolve-blockers.js,plugin/bin/fetch-sub-issues.js): 0 success, 1 malformed invocation, 2 missing dependency or unresolvable owner/repo, 3 the remote call itself failed. - Sanctioned-writer (
plugin/bin/log-decision.js,plugin/bin/stage-item.js,plugin/bin/set-config.js): 0 written (the written path echoed to stdout), 2 malformed invocation, 3 the run dir is missing or not anchored under the main checkout, or the target file is unwritable — no 1 at all. These are the run-directory writers a worktree-isolated session reaches for when Edit/Write refuses the run dir (decisions.md,staged/,config.ymlrespectively), and the skill prose that invokes them branches on 3 as "wrong run dir — re-resolve$RUN_ROOT". Copying Split-1/2 into a fourth writer would make its malformed case land on a code its siblings use for an anchoring failure.
Never mix them inside one family: a caller that branches on a sibling's codes reads the wrong branch silently.
- Combined-2 (
-
Every malformed-input class must actually reach its malformed code —
Number('') === 0passesNumber.isInteger, so validate positivity and pair-structure explicitly. -
Codes above the base vocabulary are per-CLI domain outcomes and are not portable.
plugin/bin/claim-targets.jsships 3 (contested — holder JSON on stdout) and 4 (transientghfailure), branched on byplugin/skills/flow/claim-targets.mdStep 2.8;plugin/bin/fetch-sub-issues.jsships 4 for "the capability probe says the GraphQL field is unavailable — take the documented fallback". Spell each one out in that CLI's ownUSAGEand in the prose that branches on it; never assume a number carries its meaning across CLIs. -
A read-only status verb is status-as-data: it exits 0 in every state, and the JSON on stdout is the answer. The vocabularies above key the outcome of doing something; a verb that only reports the state of an artifact the CLI itself wrote has no such outcome to key.
plugin/bin/verify.js --stamp-status(#1921) is the shipped instance — its header states the rule outright ("Status is data, never a failure — exit 0 in every case, including 'no checkout at all'") — and it prints{present, match, …}whether the pass stamp is absent, present-but-mismatched, or unreadable because there is no git dir to read from. Spending a non-zero code on "absent" would force every consuming skill to reimplement the state machine in shell, and a sibling reading that code as "not verified" is exactly the misread it prevents. Two constraints keep the exemption honest: malformed invocation of the status verb still throws into the vocabulary's usage code (--stamp-statusrejects--cmd,--scope,--base,--integration-branch), and the exemption is declared in the CLI's ownUSAGEalongside its other modes so a reader sees which verbs it covers. -
A guard added to an existing
run(argv, deps)reaches the environment throughdepstoo. The seam above is stated as "every side effect", so an argument-validation read —process.cwd(), a git-root probe, a stat — reads as exempt and gets called directly. It isn't.plugin/bin/materialize.js's--run-diranchoring guard (#790) shipped callingplugin/bin/lib/hooks/worktree-detect.js'sisAnchoredUnderRootdirectly while thecwd/mainRootcalls beside it were already injected; only a review lens caught the one-of-three gap. A validation guard bolted onto an already-seamed CLI is exactly where the seam gets holed, because it doesn't feel like a side effect — inject it (deps.cwd,deps.mainRoot,deps.isAnchored) as you write it, and add the fake to every consuming test's deps object in the same commit. -
A run-directory argument gets one of four boundary guards; pick before writing the check.
plugin/skills/_shared/pipeline-run-dir.md's CLI-argument-boundary section is canonical and enumerates all four itself ("Four shapes live at this boundary"); this bullet is thebin/-side summary, never a second registry. (1) Pipeline-owned binaries (hooks.js,wrap-up-engine.js,materialize.js,apply-refine-labels.js) refuse any value not anchored under the main checkout and exit 2. (2) The two resolver CLIs (plugin/bin/resolve-profile.js,plugin/bin/resolve-policy.js, #1065) callworktree-detect.js'scheckRunDirAnchoredOrOutside— a resolved path inside any checkout must be anchored, a path outside every checkout is accepted as-is — and exit 1, their own documented invocation-failure code. (3) The Sanctioned-writer family above (log-decision.js,stage-item.js,set-config.js) applies the same strict anchoring, but throughplugin/bin/lib/stage-item/write.js's exportedresolveTargetrather thanworktree-detect.jsdirectly, and refuses with exit 3, not 2. Import thatresolveTargetwhen you add a fourth writer instead of re-deriving the predicate —set-config.js(#1376) does, on its record's own instruction — and pin the refusal with a fixture carrying both a real main-checkout run dir and a worktree-local shadow of it, asserting the shadow's file is byte-unchanged after the exit 3. (4) The composer CLI (plugin/bin/compose-context.js, #1988) is a run-directory writer ({run}/context/{step}.md) that nonetheless takes the resolver family's anchored-or-outside rule — throughplugin/bin/lib/run-dir-guard.js'sanchoredOrOutsideMessage, the shared message renderer the two resolvers already call — and rejects with exit 2, its malformed-invocation code, not 3. Strict is the default; anchored-or-outside needs a documented legitimate outside-repo use, and shape 4's is narrow enough to be non-precedential: its callers never branch on a run-dir code (a skill step's documented fallback on any non-zero exit is to read the named source files directly), and its tests spawn the real binary against tmp-root fixtures outside any checkout, the same documented use the resolvers have. A fifth writer inherits shape 4's reasoning only if its own callers share it; otherwise shape 3 is the default. The deps-injection rule above is scoped to binaries that already have a path-handling seam: the two resolvers aremain(argv)CLIs with none, so their guard callsworktree-detectdirectly and is tested by spawning the real binary againsttests/helpers/git-fixtures.jsrepos rather than by a fake —compose-context.jsdoes have the seam and injects every path-handling call through it (deps.cwd,deps.mainRoot,deps.isDirectory,deps.anchoredOrOutsideMessage), which is the shape a new run-dir-taking CLI should copy. -
A sanctioned writer that read-modify-writes a shared file guards it; a create-only writer doesn't. The Sanctioned-writer family above splits on write shape, not on file type.
plugin/bin/lib/log-decision/append.js'sappendEntryread-modify-writes one shareddecisions.mdthat concurrentbin/log-decision.jsprocesses contend for, so it runs the whole read-modify-write-rename underplugin/bin/lib/file-lock.js'swithLock(mkdir-based, best-effort/fail-open — a lock it can't acquire inLOCK_WAIT_MSproceeds unlocked rather than hanging the caller) and renames a per-processdecisions.md.tmp-${process.pid}over the target, so a concurrent reader never sees a torn file even when the lock was missed (#816).plugin/bin/lib/stage-item/write.jsneeds neither: it createsstaged/<id><ext>at a per-item path, so there is no read-modify-write to lose. The family's third member is guarded by neither, and the bullet above does not classify it:plugin/bin/lib/set-config/write.js'ssetConfigLeverread-modify-writes the sharedconfig.yml(replace the key's line, drop later duplicate lines, append when absent) through a barefs.writeFileSync— nowithLock, no tmp+rename. That is tolerable only becauseconfig.ymlhas no concurrent multi-process writer the waydecisions.mddoes: the Manifesto writes it once per run, and the ceremony escape hatch downgrades a single lever in place later in that same run. #1580's--setbatch form narrows the margin rather than widening it — it runs the whole read-modify-write once per lever in a sequential loop (13 rewrites for a full Manifesto), so a downstreambin/resolve-policy.js --runread racing that loop can land between two rewrites, andwriteFileSync's truncate-then-write opens a short-read window on each one. Before adding a second concurrent writer ofconfig.yml, or widening the batch further, givesetConfigLeverthe samewithLock+ tmp+rename pairingappendEntryalready has. The pairing is the repo's established shape for a contended bookkeeping file —plugin/bin/lib/hooks/context.js(run-state.json),plugin/bin/lib/flow/manifest.js(manifest.yml, tmp+rename only),plugin/bin/lib/json-store.js, andplugin/bin/lib/declined-learning/store.jsall ship some combination of it — but it is documented only infile-lock.js's own header, so a new writer copied from a sibling silently inherits whichever half that sibling happened to have. Decide the shape before writing the file, and reusewithLockrather than hand-rolling a second mutex. -
When an endpoint has no GitHub MCP equivalent, say so and name the real fallback; never invent an MCP row in
plugin/skills/_shared/github-write-transport.md. -
Hoist a warning-emitting check ahead of the destructive call it warns about, not after.
plugin/bin/repair-claim.js(commit387b53f4e, spec #1608 Finding 3) andplugin/bin/release-claim.js(spec #1710) both shipped the same bug independently:resolveTarget— the run-dir anchoring check whose stderr warning tells an operatordecisions.mdwill not be written — ran after the CLI's own destructive write (repair/release.releaseClaim), so the warning arrived only once the irreversible part was already done. Both fixes were pure reorderings — computeresolveTarget(and emit its warning) before the destructive call, leaveformatEntry/appendEntryafter it since they need the outcome. Two independent CLIs shipping the identical ordering mistake is a pattern, not a coincidence: when a CLI on this seam pairs a destructive write with a warning about why its own audit trail might not land, write the warning-emitting check first.