@galosandoval/shopfloor
v1.0.0
Published
Harness for a GitHub-issue-driven SDLC agent loop: spawn Claude Code headlessly to implement an issue as a draft PR, with runaway guards, preflight refusal, and verify-comment posting.
Maintainers
Readme
shopfloor
A typed, tested harness for a GitHub-issue-driven SDLC agent loop: spawn the Claude Code CLI headlessly to implement a labeled issue as a draft PR, with runaway guards, preflight refusal, and verify-comment posting built in.
Per the "Agent = Model + Harness" framing — harness meaning instructions,
tools, sandboxes, orchestration logic, guardrails, and observability, not the
model itself — this package is the harness for the loop, not a one-off
script. It ships one phase of that loop today (implement: TDD → quality
gate → draft PR). Future phases — expanding an issue into a spec, and a
review loop — are intended to land as additional modules inside this same
package later, following the harness anatomy the modules are already
organized by (see Module layout).
The consumer-facing surface is one verb, runPhase(rawEvent): it
classifies the webhook payload, re-checks admission, locates or creates the
branch and the draft PR, runs the phase, and moves the issue's labels. What
your CI still owns is the checkout, the exit code, and the setup-free
admission job in front of it. shopfloor init scaffolds both jobs and a prompt
skeleton; the prompt's environment half — your install command, your gate,
your seeded database — is still yours and is never shipped.
Install
npm install @galosandoval/shopfloorRequires Node 20+ and the claude and gh CLIs on PATH — this package
shells out to both rather than wrapping an SDK — plus git and reachable
GitHub at install time, for the bundled plugin below.
No second command, though. The skills the harness expects an agent
to have arrive with the install as the bundled plugin — a git dependency on
galosandoval/skills pinned to a
tag — so there is no second checkout to clone and no path to keep an
environment variable pointed at. An unstated pluginDirs loads it; see
Plugin directories.
What ships in it is procedure — how work gets done, which is the same in
every repository. Coding standards are not procedure: they are per-repository,
so they live in the repository being worked on — in its CLAUDE.md and the
docs that file points at, which the agent reads for itself. This package ships
none of its own, and no longer takes a path to yours: standardsDir was
removed, and a run still configured for it refuses (see
Pre-spawn preconditions).
From an empty repository, one command scaffolds the rest — labels, workflow, and a prompt whose environment block it fills from your lockfile and scripts:
npx shopfloor-initIt is interactive, re-runnable, and never overwrites a file without asking. See Setup init, and Setup doctor for the read-only half that says what is still wrong.
Usage
A phase run needs the webhook payload and an OAuth token. Everything else — the phase, the issue, the branch, the PR — comes off the payload or off the convention this package owns:
import { runPhase, ImplementAgentError } from '@galosandoval/shopfloor'
try {
// With no `payload`, it reads $GITHUB_EVENT_PATH — what the runner already
// wrote to disk. Nothing here names an issue, a branch, or a phase.
const result = await runPhase()
if (!result.ran) {
console.error(`refused (${result.refusal}): ${result.reason}`)
} else {
console.log(
`${result.phase} on #${result.issueNumber}: ${result.pullRequest.url}`
)
}
} catch (error) {
if (error instanceof ImplementAgentError) {
console.error(error.message, error.outputTail)
}
throw error
}What runPhase does, in order. Re-check admission (classification, the
spend gate, the in-flight check, the attempt ceiling) → resolve the phase's
prompt → preflight → transition the issue to started → locate or create
agent/issue-<n> → run the phase → push → locate or create the draft PR →
post the verify comment → transition on the outcome.
Admission is re-checked, not assumed. Run
shopfloor-admit in a job of its own first, with
nothing installed: a spend gate behind the spend it guards is not a gate. This
call re-asks the same question against the same payload, so a run reached by
any other path is judged rather than admitted by assumption.
A retrigger reuses what it finds. The branch already exists and the PR is
already open on the machine edge, so both are located before either is created.
Branch identity is computed in exactly one place —
agentBranchForIssue — and never
re-derived from an issue title in a sed pipeline.
Refusals write nothing, except preflight's — whose refusal is a judgement about the issue, and which labels and comments it before returning. An admission refusal leaves the issue exactly as it found it; the in-flight case depends on that, because the issue belongs to a run this one does not own.
Everything a run accepts is still statable, minus the four values the payload decides (the issue, its title, the branch, the repository):
import { resolveBundledPluginDir } from '@galosandoval/shopfloor'
import * as fs from 'node:fs'
await runPhase({
claudeCodeOAuthToken: process.env.CLAUDE_CODE_OAUTH_TOKEN!,
// Prompts are keyed by phase. Unstated, a phase runs on the shim this
// package ships (see below).
prompts: { implement: fs.readFileSync('agent/implement/prompt.md', 'utf8') },
// The outer loop's ceiling, as `shopfloor-admit --max-attempts` states it.
maxAttempts: 3,
// Claude Code plugins loaded for this session only, one --plugin-dir each,
// so their skills reach the agent without anything landing in your git tree.
// Every entry is validated before a token is spent. Stating this REPLACES
// the bundled plugin — name it alongside yours to keep both.
pluginDirs: [resolveBundledPluginDir(), '/opt/my-plugin'],
// Places pr_description.txt, verify_report.md, transcript.jsonl, and
// failure_reason.txt; each is still individually overridable.
outputDir: '/tmp/out',
screenshotsDir: '.agent/verify/issue-123',
projectsDir: `${process.env.HOME}/.claude/projects`,
runPolicy: {
// Every field is optional and merges over DEFAULT_RUN_POLICY.
maxTurns: 150,
idleMinutes: 15,
// Omitted, a run has no wall-clock ceiling — only the idle guard. This
// bounds the whole run, iterations included, not one spawn.
wallClockMinutes: 45,
// The quality gate the HARNESS runs after each spawn. Omitted, a run is
// single-shot; stated, a failure respawns with the failure fed back in.
gateCommand: 'bun run typecheck && bun run test',
maxIterations: 3,
// The CLI version this policy was validated against. A mismatch on
// major.minor warns by default; 'error' refuses the run, 'off' skips.
cliVersion: '2.1.220',
cliVersionStrictness: 'warn',
// The caller's own app-specific env vars — this package bakes in none.
requiredEnvVars: ['DATABASE_URL', 'OPENAI_API_KEY', 'GH_TOKEN']
}
})Prompts, keyed by phase
One verb discovers the phase from the payload, so prompts are keyed by phase:
prompts: { implement: '...' }, or the single PROMPT_FILE environment
variable, which applies to whichever phase was discovered. A discovered phase
with no prompt refuses at startup naming the phase — before the branch,
before the transition, and before a token is spent.
What ships by default is a shim, not a prompt. DEFAULT_PHASE_PROMPTS
names the phase, names the issue and the branch, says where the run's outputs
go, and defers to the bundled skills plugin for how to carry the work out. It
carries no procedure — that lives in skills, and two copies would have no rule
for which wins — and no environment content: your install command, your gate,
your seeded database are yours, and shopfloor init fills that block from your
own lockfile and scripts. A run on the shim alone works; a run on your own
prompt is the normal case.
Before the spawn, a prompt's {{PLACEHOLDER}} tokens are rendered against the
run's own resolved values. Eight are substituted, and only these eight:
| Token | Rendered to |
| ------------------------- | ----------------------------------------------------------------- |
| {{ISSUE_NUMBER}} | issueNumber, as resolved |
| {{ISSUE_TITLE}} | the issue's own title, read once via gh |
| {{BRANCH}} | agent/issue-<n> — the branch the verb located or created |
| {{PR_DESCRIPTION_FILE}} | absolute path the agent writes its PR description to |
| {{VERIFY_REPORT_FILE}} | absolute path the agent writes its verify report to |
| {{SCREENSHOTS_DIR}} | repo-relative directory the agent commits verify screenshots to |
| {{ATTEMPTS_DIR}} | repo-relative directory holding every previous attempt's handoff |
| {{HANDOFF_CLAIMS_FILE}} | absolute path the agent writes its own account of this attempt to |
The output paths are how a prompt tells the agent where to put the artifacts
this package then reads back — a template that never names them yields a run
with no PR description (prDescription: 'fallback') and nothing to post.
{{ATTEMPTS_DIR}} is a path, not the trail itself: inlining N previous
attempts would cost context linearly in attempt count and put the whole trail
in static context. See The handoff trail.
An unrecognized token refuses the run, before the spawn and before any
probe — a misspelled placeholder, or one that used to exist, like
{{STANDARDS_DIR}}. It used to render as literal text, unchanged and
unreported, which made an unfilled placeholder indistinguishable from prose; a
prompt that carried one now fails immediately, naming it and this table. So does
one still carrying shopfloor init's TODO(shopfloor) sentinel. See Pre-spawn
preconditions.
A missing token is not refused — leaving one out is a choice this package
does not second-guess, and shopfloor-doctor's prompt-tokens check is where
it is reported. Check your template against this table when you upgrade.
What a run returns
runPhase answers with RunPhaseResult — either a refusal or a finished run:
| Field | Type | Meaning |
| --------------------- | -------------------------------- | ----------------------------------------------------------------- |
| ran | boolean | False for a refusal; the fields below are a finished run's |
| refusal / reason | string | On a refusal: admission's own kinds, or preflight |
| phase / edge | Phase / 'human' \| 'machine' | Which phase ran, and which edge started it |
| issueNumber | number | The issue the payload named |
| branch | string | agent/issue-<n>, located or created |
| pullRequest | { number, url, created } | created: false when a retrigger iterated on the PR already open |
| attempt | number | Which attempt this was, against maxAttempts |
| outcome | RunOutcome | Always succeeded — every other outcome leaves by throwing |
| verifyCommentPosted | boolean | Verify is best-effort and never fails a run |
| run | RunImplementAgentResult | What the phase's own run produced, below |
A failed run throws ImplementAgentError — and first pushes whatever it
committed (best-effort, so the work outlives the runner; no PR is opened for
it) and transitions the issue: exhausted when the inner loop spent its ceiling with the gate still
red, failed otherwise. Both terminal rows set ready-for-human, so no way
out of a started run leaves an issue sitting in agent:in-progress.
The phase's own run answers with RunImplementAgentResult:
| Field | Type | Meaning |
| -------------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------- |
| branch | string | The branch committed on, as resolved — stated, inferred, or probed |
| commitsAhead | number | Commits on branch since main, per git rev-list --count |
| prDescription | 'agent' \| 'fallback' | Whether the agent wrote its own PR description, or this run supplied one |
| transcriptCaptured | boolean | Whether the session transcript was found and copied to transcriptFile |
| cliVersion | string \| undefined | The CLI version this run spawned; undefined when that probe failed or was unreadable |
| iterations | number | How many times the run spawned the CLI — 1 without a gateCommand, unless the trajectory sent it round again |
| usage | RunUsage | What the run spent, summed over its iterations — see below |
prDescription: 'fallback' is not a failure — the run committed either
way. It is there so CI glue can say so in the PR rather than presenting
generated prose as the agent's own.
transcriptCaptured used to be the same kind of report, and is not any more:
the closure condition blocks a run whose last attempt
was not captured, so it is always true on a result. It stays on the type
because a caller reading it as "there is a transcript to upload" still gets the
right answer.
What a run spent
The CLI's stream-json output already flows through the harness process — the
idle guard reads it as a heartbeat — and usage is that stream parsed as it
arrives rather than dropped. It exists because the inner loop bounds a run by
attempts, and attempts are not the budget the loop multiplies.
| Field | Type | Meaning |
| -------------------------- | -------------------------- | ----------------------------------------------------------- |
| inputTokens | number | Uncached input tokens |
| outputTokens | number | Output tokens |
| cacheCreationInputTokens | number | Tokens written to the prompt cache |
| cacheReadInputTokens | number | Tokens served from it |
| costUsd | number \| undefined | USD, when the stream reported it |
| source | 'reported' \| 'observed' | Whether these are the CLI's own tally or this package's sum |
source is the field to read first. 'reported' means every spawn reached
its terminal result event and these are the CLI's own numbers. 'observed'
means at least one did not — a run a guard killed, or one whose stream was
unreadable — and the totals are then this package's sum over the assistant
messages it watched go by, each counted at the snapshot taken when its message
started.
An 'observed' total is not a total, and it is not uniformly a floor
either — the buckets degrade in opposite directions:
outputTokensandcacheCreationInputTokensundercount. The snapshot precedes the message's final count, and a run killed mid-message contributes nothing at all. Read them as a lower bound.inputTokensandcacheReadInputTokensovercount, usually by a lot. Every turn re-sends the conversation, so each message restates the prefix its predecessors already reported; summing across N turns counts the same tokens up to N times. On a multi-turn run these can exceed the CLI's own tally by a large multiple. Read them as evidence that work happened, not as a quantity.
A 'observed' total carries no costUsd even where one was seen: a cost is a
whole session's, and pairing a complete price with an incomplete token count is
the misreading source exists to prevent.
The numbers are always present. A run whose stream said nothing about usage
reports zeroes with source: 'observed', so "free" is never confused with
"unmeasured". Nothing about this fails a run: an unreadable diagnostic must not
cause an outage, so a malformed line is skipped and the run continues.
A failed run reports its spend too, on the error rather than on a result it
never produces — ImplementAgentError.usage. A guard kill, a non-zero CLI
exit, an exhausted attempt ceiling, and a run that committed nothing all spent
real tokens, and those are the runs whose cost is least visible. It is
undefined only for a failure that refused before the spawn, where the answer
is genuinely nothing.
That number is also what every failed attempt's handoff states, so the trail a spent ceiling posts says what the loop cost as well as what it tried — see the handoff trail.
Resolution order
Every optional input resolves the same way: explicit input → environment
variable → probe (git, gh) → package default. Probes are lazy — a field
you state, or one the environment already carries, never spawns a subprocess.
| Field | Environment | Probe | Default |
| -------------------------------- | ---------------------------- | ----- | ----------------------------------------------------- |
| claudeCodeOAuthToken | CLAUDE_CODE_OAUTH_TOKEN | — | required |
| prompts | — (path only: PROMPT_FILE) | — | the per-phase shim this package ships |
| maxAttempts | — | — | 3 |
| pluginDirs | PLUGIN_DIRS (comma-sep.) | — | the bundled skills plugin; stating a list replaces it |
| outputDir | OUTPUT_DIR | — | OS tmpdir |
| prDescriptionFile | — | — | pr_description.txt under outputDir |
| verifyReportFile | — | — | verify_report.md under outputDir |
| transcriptFile | — | — | transcript.jsonl under outputDir |
| failureReasonFile | — | — | failure_reason.txt under outputDir |
| screenshotsDir | SCREENSHOTS_DIR | — | .agent/verify/issue-<N> |
| attemptsDir | ATTEMPTS_DIR | — | .agent/attempts |
| handoffClaimsFile | — | — | handoff_claims.md under outputDir |
| projectsDir | PROJECTS_DIR | — | ~/.claude/projects |
| runPolicy.model | MODEL | — | none — the Claude CLI's own default |
| runPolicy.maxTurns | MAX_TURNS | — | 150 |
| runPolicy.idleMinutes | IDLE_MINUTES | — | 15 |
| runPolicy.wallClockMinutes | WALL_CLOCK_MINUTES | — | none — the run has no wall-clock ceiling |
| runPolicy.gateCommand | GATE_COMMAND | — | none — the run is single-shot |
| runPolicy.maxIterations | MAX_ITERATIONS | — | 3 — reachable only with a gate stated |
| runPolicy.cliVersion | CLI_VERSION | — | none — the running version is recorded, not compared |
| runPolicy.cliVersionStrictness | CLI_VERSION_STRICTNESS | — | 'warn' |
| runPolicy.requiredEnvVars | REQUIRED_ENV_VARS | — | [] |
prompts holds raw template contents keyed by phase, not paths.
PROMPT_FILE is the one variable read off disk: runPhase reads that path and
applies it to whichever phase the payload discovered.
The issue, its title, the branch, and the repository are not in the table, and neither are their environment variables: the payload decides all four, and stating one now refuses the run by name. See Removed inputs and what replaced them.
The four output files take no environment variable either: state one to move it,
or leave it and it lands under outputDir. That is where OUTPUT_DIR earns its
place — one variable relocates all four.
screenshotsDir is deliberately not derived from outputDir: those files
get committed, so they stay repo-relative while the rest of the run's outputs
live in a temp dir.
ANTHROPIC_API_KEY is stripped from the child environment unconditionally,
however the OAuth token was resolved — inference never widens the auth surface.
LOCAL_IDLE_MINUTES / LOCAL_WALL_CLOCK_MINUTES override the guard budgets
for a single run without touching the contract.
Runaway guards
Two time-based guards watch a run, because they catch different failures. The
idle guard catches a stalled agent — output goes silent — and is always
armed, at 15 minutes by default. The wall-clock guard catches a looping
agent, one that stays productive-looking and resets the idle timer on every
chunk; it is armed only when runPolicy.wallClockMinutes (WALL_CLOCK_MINUTES)
states a ceiling, since a default ceiling would kill runs no caller asked to
bound.
Either guard terminates the run and throws an ImplementAgentError naming the
budget that tripped, with the transcript still captured. How they kill differs
on purpose: the wall-clock guard sends SIGTERM, waits 30 seconds, then
SIGKILLs, giving a looping agent a chance to flush real uncommitted work,
while the idle guard goes straight to SIGKILL — a stalled agent is usually
wedged somewhere that cannot service a signal handler anyway.
A killed run is a hard failure even if the agent had already committed. It never reached its own verify phase, so those commits are unvetted work-in-progress. That is deliberately stricter than the missing-PR-description case below, where the commits were finished and only the prose was absent. A kill ends the run outright; it never becomes another iteration.
The two guards bound different things, and the difference only shows up on a run that iterates. The idle budget is per spawn — it measures one live process going quiet, and there is no such thing as a process that fell silent between spawns. The wall-clock budget is per run: each spawn is armed with what is left of it, and a run with none left fails rather than starting another. A single-iteration run is armed exactly as it was before the inner loop existed.
The inner loop
State a runPolicy.gateCommand (GATE_COMMAND) and a run stops being
single-shot. After each spawn the harness runs that command in the run's
cwd; a non-zero exit spawns the CLI again with the command and a 4 KB tail of
its output appended to the prompt, up to runPolicy.maxIterations
(MAX_ITERATIONS, default 3). Omit the gate and nothing changes: one spawn,
as before.
Three things about it are worth stating plainly, because each was a decision:
- The harness generates the signal, not the agent. The gate is a command
this package runs and observes the exit status of. An agent's own report that
its gate passed is not a signal — a fluent run that skipped verification is
exactly what the loop exists to catch. The command is yours: this package
ships no build-tool vocabulary, on the same footing as
requiredEnvVars. It runs through a shell, so a chain (&&) works, and it is trusted input from your configuration — never from the issue, the agent, or anything the run read. - Each iteration is a fresh spawn, not a
--resume. Resuming would keep the reasoning that produced the failing work in the context of the turn meant to correct it. The cost is named rather than hidden: every iteration pays for its static context again. - A spent budget fails the run. A gate still red at
maxIterations, or a run with too little wall clock left to be worth another attempt (under a minute), throws anImplementAgentErrornaming the gate and the budget. It does not return unvetted work as a success. Stopping a little above zero is deliberate: a spawn given seconds would be killed by the wall-clock guard, and the run would then report a runaway agent instead of the spent budget it is.
Two details worth knowing before you wire this up:
- The gate's own runtime is charged to the wall clock. A gate is normally
the whole test suite, and time the run spends inside it is time the run spent.
wallClockMinutestherefore bounds spawns and gates together. - The gate runs on the run's own environment, not the CLI's child env. No
OAuth token is injected into it, and
ANTHROPIC_API_KEYis not stripped from it — that pair exists to keep the agent off a metered key, and your test suite is not the agent. YourrequiredEnvVarsare there as usual. transcriptFileholds the last iteration's transcript, and the failed attempts land beside it. Each pass overwritestranscriptFile, so before iterating the attempt that just failed is kept attranscript.iteration-<n>.jsonlin the same directory.runTrajectoryCheckstill gradestranscriptFile— the session that finished the run — and you can point it at an earlier attempt to grade that one instead. A single-shot run writes noiteration-files at all.
evaluateIteration — the pure function that decides iterate / done /
exhausted — is exported, so the rule can be tested or reused without a run.
Pre-spawn preconditions
Five things are settled just before the CLI spawns, so a misconfigured run
costs zero tokens: the runPolicy.requiredEnvVars check, the prompt being
filled in, the plugin directories, the label vocabulary, and the CLI version. A
sixth refuses earlier still, while the configuration resolves, because it needs
nothing from disk to detect: a standards directory.
A standards directory — fails the run. standardsDir is gone, and the
prompt no longer carries a {{STANDARDS_DIR}} placeholder to substitute into;
skills reach the agent through the CLI's own plugin discovery instead. A run
that still states standardsDir, or whose environment still sets a non-empty
STANDARDS_DIR, refuses before spawning and names the replacement. That
refusal is the migration: dropping the field quietly would leave a CI-set
variable meaning nothing at all — no type error, no runtime error, just a run
with less context than its operator believes it has. An empty value from either
source still means "deliberately skip" and does not refuse, exactly as before.
It is one row of the shim table —
Removed inputs and what replaced them
lists the rest, and they refuse the same way.
A prompt template that still contains {{STANDARDS_DIR}} is refused by the next
check rather than rendered — see below.
An unfilled prompt — fails the run. Before any probe runs, the prompt is
checked for the two things that mean it was never finished: shopfloor init's
TODO(shopfloor) sentinel, and a {{TOKEN}} outside the ones this package
substitutes. Either refuses before spawning, naming
every offender — the sentinel by line number, an unknown token beside the table
of real ones.
Both used to be invisible. A sentinel was a TODO in prose, and an unrecognized
token rendered as literal text, so a consumer who skipped filling the
environment block paid for a full run that then failed on a command their
repository does not have. This is a new failure mode for an existing prompt:
a template carrying either now refuses where it previously ran.
npx shopfloor-doctor reports most of this without spending anything, and
evaluatePromptReadiness is exported for tooling that wants the verdict on its
own. The doctor and the run are not the same check, and the run is the
stricter of the two: the doctor looks for the sentinel only inside the
environment fences and reads tokens as upper-case with surrounding spaces
tolerated, while the run refuses on the sentinel anywhere in the prompt and on
any identifier-shaped {{ token }} the renderer would not substitute —
{{ ISSUE_NUMBER }} and {{issue_number}} included, because those reach the
agent as literal text exactly like a misspelling does. Braced prose that is not
identifier-shaped is left alone.
A missing token is deliberately not refused here — see the prompt template.
Plugin directories — fail the run. Each entry in pluginDirs
(PLUGIN_DIRS, comma-separated) is passed to the CLI as --plugin-dir, one
flag occurrence per entry, loading that plugin for the session only — the
CLI's own skill discovery, with nothing written into your git tree. That last
part matters: the agent commits its own work, and files it did not create risk
being swept into a commit. A relative entry resolves against the run's cwd,
which is where the CLI resolves it from. Remote plugin URLs are deliberately
not accepted — fetching unattested code over the network into a
fully-permissioned autonomous run is its own decision, not a free ride on this
one.
Unstated, the list is the bundled plugin. Installing this package brings
galosandoval/skills with it as a git
dependency pinned to a tag, and an unstated pluginDirs loads that. Its
resolved location is on the public surface as resolveBundledPluginDir(),
because a stated list replaces the default rather than adding to it — so
naming both is something you write, not something you guess:
import { resolveBundledPluginDir } from '@galosandoval/shopfloor'
pluginDirs: [resolveBundledPluginDir(), '/opt/my-plugin']Replacement, not addition: a default that always loads is a floor rather than a
default, and a floor is what turns a harness into a framework — your own version
of a bundled skill would load beside it, with the CLI arbitrating a collision
this package created. It also keeps the opt-out free: an explicitly empty list
(pluginDirs: [], PLUGIN_DIRS='') loads no plugins at all, which is why an
unstated list and an empty one are held apart rather than collapsed.
The bundled plugin is validated exactly like a stated one — no exemption. Its likeliest failure is not being on disk at all (a strict package-manager layout, a pruned install), and that refuses the run naming the package, rather than letting it proceed with none of the procedure it was configured to have.
Nothing spawns until every entry passes. A directory entry is refused when:
| Refused when | Why |
| --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| it does not resolve | the failure a bare standards path used to hide — a rotted path and a correct one are indistinguishable once the flag is on the vector |
| it has no readable .claude-plugin/plugin.json | it is not a plugin |
| its manifest declares no skills and it has no skills/ directory | it contributes nothing the run asked for |
| its manifest declares a skill path that is absent on disk | the manifest is stale |
| it ships hooks or MCP servers | see below |
The refusal names every offending entry and what is wrong with it, not just the first.
The check asserts what the manifest asserts about itself, and no more — it does not count skill files or read their frontmatter. Anything deeper would be a second, independent model of how the CLI discovers skills, and it would drift from the real one.
An entry that is an archive — a .zip, the only file form accepted; any
other file is refused — is checked for existence only. That is a deliberately weaker guarantee: inspecting it
would mean unpacking it, which reintroduces exactly the staging work passing a
directory to the CLI avoids. Nothing below applies to an archive — including
the capability refusal.
Hooks and MCP servers — refused. Both are refused whether declared in the
manifest (hooks, mcpServers) or present only as convention (hooks/,
.mcp.json); published plugins commonly declare neither key and rely on the
directory names alone, so both are checked.
The reason is specific to how these runs execute. They already pass
--dangerously-skip-permissions, so a plugin's tool-permission declarations
are moot — nothing is gated either way. What is not moot is code that runs
automatically without the model choosing to invoke it, and tools that fall
outside the command guard, which matches shell commands only
and therefore cannot see a tool contributed by an MCP server. That is what
makes this promise mean something: a stated plugin adds no automatic code
execution and no tools outside the command guard. Plugin content that is only
prose — skills, subagent definitions, slash commands — is deliberately
permitted; a headless run never even invokes a slash command.
The label vocabulary — fails the run. gh label list is read against the
run's repository, and a repository missing any of the six labels this package
owns refuses before spawning,
naming every one that is absent. It is the only precondition that costs a
network round trip, so it runs after every local check has had its chance to
refuse for free.
It verifies; it never creates. Creating labels is a durable write to a
shared human workspace, so it belongs to npx shopfloor-init, at a moment you
asked for it — not to a run that happened to be triggered. Verification is what
makes the failure below impossible; creation never was.
This is a new failure mode for an existing setup: a repository lacking a
label used to have its transition silently skipped, and now fails the run
instead. That is the point. In the live consumer, a workflow step swapping
ready-for-agent for ready-for-human had failed on every successful run
since it was written — the label did not exist, and || true swallowed it. A
transition the pipeline claimed to make had never once happened. Run
npx shopfloor-doctor to see the gap, npx shopfloor-init to close it.
An unreadable label list refuses too, and says so rather than naming
labels — gh unauthenticated and a repository unconfigured are different
things to go fix. That is stricter than the doctor, which reports the same fact
as unknown and passes: a diagnostic tolerates its own blind spots, a gate on
spend does not. Refusing costs one re-run before a token is spent; proceeding
buys a whole run whose closing transition then fails against a repository
nobody checked.
CLI version — warns by default. The running claude --version is read
before the spawn and returned on the run result as cliVersion, so a run's
output always names which CLI produced it. When runPolicy.cliVersion
(CLI_VERSION) states a pin, the two are compared:
| runPolicy.cliVersionStrictness | CLI_VERSION_STRICTNESS | On mismatch |
| -------------------------------- | ------------------------ | -------------------------------- |
| 'warn' (default) | warn | Logs a warning; the run proceeds |
| 'error' | error | Refuses before spawning |
| 'off' | off | No comparison at all |
A mismatch means a differing major.minor — the patch is ignored. The
surfaces this harness depends on are CLI features (the headless flag vector,
the stream-json event shape, the ~/.claude/projects session layout), and
features arrive in minor releases; a patch bump fixes surface that already
exists. Requiring an exact match would fail runs over changes that cannot
affect the harness — the pin churn that trains people to delete the check.
The rule is the same at every strictness; only the consequence differs.
Absent cliVersion, the version is recorded and compared to nothing. A
claude --version that fails or returns something unrecognizable never blocks
a run, whatever the strictness — an unreadable version is the harness's own
uncertainty, and refusing a run over it would turn a missing diagnostic into an
outage. A stated cliVersion that isn't a readable semver doesn't block either,
but it does warn: a check that silently stopped running is the exact rot this
is here to catch.
CLI
A thin bin entrypoint runs whatever phase the event starts, for a drop-in CI step:
CLAUDE_CODE_OAUTH_TOKEN=*** GH_TOKEN=*** PROMPT_FILE=./prompt.md npx shopfloor-run-phaseIt takes no arguments. The issue, the phase, and the actor come off
$GITHUB_EVENT_PATH, and the branch is the harness's own — a step that states
nothing cannot state it wrong. Every environment variable in the table above
still works; the resolution lives in the harness, not in this entrypoint.
Its exit code splits the way shopfloor-admit's does: not-a-trigger exits
zero, because it is the outcome for the large majority of events that reach the
loop and painting the repository red for those is how a check gets deleted.
Every other refusal, and every failed run, exits non-zero. A failed run writes
its reason to failure_reason.txt under OUTPUT_DIR.
shopfloor-implement <issue> was the bin before 1.0.0. It still ships, and it
does one thing: prints what replaced it and exits non-zero. It is kept rather
than deleted because npx answers a bin a package no longer declares by
fetching whatever the registry has published under that name.
Removed inputs and what replaced them
Nothing this package stops accepting is merely deleted. Every removed field, environment variable, result field, export, and bin refuses by name and says what replaced it — because a type removal only reaches a caller who typechecks against this package, while the binding that actually breaks is CI still exporting a variable, where a silent deletion leaves a run doing something its operator did not ask for.
| Removed | Refuses where | What to do instead |
| ------------------------------------------------------------------------------ | ---------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| issueNumber / ISSUE_NUMBER | runPhase, before admission | Nothing — the payload names the issue |
| issueTitle / ISSUE_TITLE | runPhase, before admission | Nothing — read from the issue once, so the prompt and the PR cannot disagree |
| branch / BRANCH | runPhase, before admission | Nothing — the branch is agent/issue-<n>; use agentBranchForIssue if your glue needs the name |
| repo | runPhase, before admission | Nothing — the payload's repository is the run's |
| promptTemplate | runPhase, before admission | prompts: { implement }, or PROMPT_FILE; unstated, the shipped shim runs |
| standardsDir / STANDARDS_DIR | config resolution | pluginDirs / PLUGIN_DIRS; coding standards live in the repository being worked on |
| PermissionProbe.read | evaluateAuthorization | answered — renamed because read is also one of the permission levels being judged |
| permission on an admitted verdict | reading the field | authorizedBy — { via: 'permission', permission } on the human edge, { via: 'continuation' } on the machine edge |
| shopfloor-implement | the bin itself | shopfloor-run-phase, which takes no arguments |
| runImplementAgent, runPreflight, postVerifyComment, runPluginDirsCheck | calling the export | runPhase; each shim names the pure half that still ships |
Four things to know about the shape of these refusals:
- An empty variable never refuses, and an empty field does.
ISSUE_NUMBER=is a line left behind while deleting the variable, and refusing there would punish the fix.{ issueNumber: '' }is a key someone typed, so it refuses — otherwise you meet a generic "no issue number" error instead of the migration. A key carryingundefinedis a spread, not a caller, and never refuses. - A stated field and a set variable each refuse on their own. A stated value is not a way to mask a variable your workflow still exports.
GITHUB_REPOSITORYandGITHUB_REF_NAMEare not refused, even thoughrepoandbranchonce resolved from them: the runner sets both on every job, so refusing on their presence would refuse every run in GitHub Actions.- A value that leaves the process is refused as a value.
permissionis a throwing getter, which reaches a JavaScript caller and stops at the process boundary — soshopfloor-admit's JSON carries the field with a sentence in it instead, and a workflow readingfromJSON(...).permissionsees what replaced it rather thannull. A gate comparing it against'write'still fails closed.
The four verbs above were removed from the public surface in the same sequence.
They are internals runPhase composes; the pure halves they were paired with
(evaluatePreflight, buildVerifyComment, evaluatePluginDirs) all still
ship, and each shim names its own. They throw when called, not when imported.
Trigger classification and admission
Which webhook event starts which phase, on whose behalf, and whether it may
start at all. Pass the raw webhook payload — the contents of
$GITHUB_EVENT_PATH, undestructured — and the answer is a typed verdict rather
than a stack of if: expressions:
import { classifyTrigger } from '@galosandoval/shopfloor'
const classification = classifyTrigger(JSON.parse(eventJson))
// { triggered: true, phase: 'implement', edge: 'human',
// issueNumber: 46, actor: 'octocat', repo: 'you/your-repo' }Two edges are admitted:
| Event | Edge | Keyed on |
| --------------------------------------------------- | --------- | ---------------------------------------------------------------------------------------------------------- |
| issues.labeled with ready-for-agent | human | the added label, not the issue's label set |
| workflow_run.completed with conclusion: failure | machine | a head_branch of agent/issue-<n>, on this repository, from a commit authored by claude-code[bot] |
Everything else — a different label, a green CI run, a branch the harness does
not own, a payload that will not parse — is { triggered: false, reason }. That
is the common case, not an error: every label anyone adds to any issue reaches
this function, and it never throws on one.
The human edge is keyed on the added label deliberately. The harness adds
labels of its own (agent:in-progress when a run starts), and each of those
fires issues.labeled again — a trigger reading the issue's label set instead
of the addition would restart itself. The phase comes from an agent:<phase>
label on the issue, falling back to implement, the only phase that ships.
The machine edge reads the issue number out of the branch, which is the only
place a finished CI run says which issue it belongs to. agentBranchForIssue
and issueNumberFromBranch are exported, because your glue and this package
have to name the same branch and two conventions that agree by eye are the
binding this design exists to eliminate.
The branch name is a pre-filter, not the test. Two more fences decide the machine edge, and both are load-bearing rather than tidy:
head_repository.full_namemust equalrepository.full_name. Aworkflow_runfrom a fork PR carries the fork's ref with your repository inrepository, so a stranger picks the branch name there. A head repository the payload does not state refuses the same way a mismatched one does.- The head commit must be authored by
claude-code[bot](AGENT_COMMIT_AUTHOR, matched against the commit'snameor the local part of itsemail). Your own commit onto the agent's branch is CI red the loop must not answer — you are already at the keyboard. The author is fixed, not configurable, for the reason the label vocabulary is: a consumer naming their own identity here turns every push they make into a retrigger. - The head commit must not be this package's own loop-closing commit. The
strip that ends a successful run is authored as the agent like the work under
it, and it carries a
Shopfloor-Loop: closedtrailer (LOOP_CLOSED_TRAILER, exported) on its own line. The asymmetry is the loop: a failed attempt's handoff commit must retrigger, since that is how the loop iterates, while a finished run's must not — that branch has an open pull request and a human on it, and answering CI red there would spend an attempt on work nobody asked the agent to keep, starting cold, since the strip is what removed the trail.
The admission callable
Classification, authorization, the concurrency check, and the attempt ceiling, composed into one verdict — and shipped as its own bin so a job with nothing installed runs it first:
jobs:
admit:
runs-on: ubuntu-latest
outputs:
verdict: ${{ steps.admit.outputs.verdict }}
steps:
# No checkout, no toolchain, no install. GITHUB_EVENT_PATH comes from
# the runner.
- id: admit
env:
GH_TOKEN: ${{ secrets.AGENT_PAT }}
# The assignment runs first and on its own, so a non-zero refusal fails
# this step under the default `bash -e`. Writing the output inside the
# `echo` instead would make the step's status `echo`'s, swallowing every
# refusal the exit code is there to carry.
run: |
verdict="$(npx -y @galosandoval/shopfloor@<version> shopfloor-admit)"
echo "verdict=$verdict" >> "$GITHUB_OUTPUT"
implement:
needs: admit
if: fromJSON(needs.admit.outputs.verdict).admitted
# … the expensive job: checkout, install, browsers, the run itselfThe verdict is one line of JSON on stdout; the human sentence goes to
stderr, so stdout stays machine-readable with no flag deciding which mode the
command is in. --max-attempts <n> raises the ceiling.
An admitted verdict carries what the run needs — phase, edge,
issueNumber, actor, repo, branch, attempt, maxAttempts, and
authorizedBy, which says which of the two authorities let it through:
| authorizedBy | Edge | Means |
| ---------------------------------------- | --------- | ----------------------------------------------------------------- |
| { via: 'permission', permission: '…' } | human | A person triggered it, and the spend gate probed what they may do |
| { via: 'continuation' } | machine | Nobody triggered it — the loop's own failed run did |
The machine edge is not probed, and that is the correction rather than a
shortcut. There is no login on it worth asking about: triggering_actor is
whatever credential pushed, frequently github-actions[bot], whose collaborator
permission is none — so probing it refused the one edge with no human on it,
every time. What authorizes a continuation is that pushing to agent/issue-<n>
on your repository already requires write access, which is a spending
permission. The fork fence above is what keeps that sentence true, which is why
it refuses on an unstated head repository too.
A refusal carries a reason and one of five kinds:
| refusal | Means |
| --------------- | -------------------------------------------------------------------------------------- |
| not-a-trigger | Nothing happened — not an event the loop runs on |
| not-permitted | The actor may not spend on this repository |
| undetermined | A probe answered nothing usable — refused, not assumed |
| in-flight | The issue is labeled agent:in-progress — a run is already going |
| exhausted | The issue has already had its attempts — see the terminal state |
The exit code splits, and the split is deliberate. not-a-trigger exits
zero: it is by far the most common outcome, and painting the repository red for
events where nothing happened is how a check gets deleted. Every other refusal
exits non-zero, so a caller who ignores stdout is still stopped by a stranger,
a broken token, a run in flight, or a spent ceiling.
It runs the probes in the order that costs least, and skips what it does not need. An event that classifies as nothing spends no subprocess at all; an actor who may not spend never costs a run-list call; and the machine edge never runs the permission probe, because there is no login on it to ask about.
It writes nothing, on any verdict — the same rule the spend gate follows,
and for the same reason: a refusal that labelled or commented would hand any
drive-by triager a way to make the harness write to your repository. The
shopfloor-admit bin makes exactly one write, on the exhausted verdict;
see the terminal state below.
It refuses on uncertainty, like the spend gate it wraps and unlike every other guard here. An unreadable issue is not "probably no attempts" — it is not knowing whether a run is already spending on this issue, or whether the ceiling is already spent.
Both counts are read off the issue, and nothing extra is written to get
them. The started transition already adds agent:in-progress, and GitHub's
issue timeline keeps every labeled event permanently — after the label is
removed, and after any always() clear. So:
- attempts = how many times
agent:in-progresswas ever added (gh api repos/…/issues/<n>/timeline), which counts runs that started, including ones killed before they could clean up; - in flight = whether
agent:in-progressis on the issue now (gh issue view <n> --json labels).
This reverses design §4 and §7, which derived both from gh run list --branch.
That mechanism cannot work: a run triggered by issues.labeled — or by
workflow_run — executes on the default branch, so its head_branch is
main and a list filtered by agent/issue-<n> is empty on every real run.
Derive-don't-store was argued against an alternative that never fires.
The concurrency check is a narrowing, not a lock. §7 rejected the label as
a lock, and that stands: a maintainer clearing a stuck agent:in-progress
silently unlocks concurrent spending, and two events landing together can both
read it absent. The real mutual exclusion is a concurrency: group in your
workflow — the one shopfloor init scaffolds already has it. What this adds is
a cheap check in front of it, on a signal that exists.
For a caller that wants the verdict rather than a process, runAdmission is the
callable and evaluateAdmission is the pure decision underneath, over facts you
gathered yourself:
import { runAdmission } from '@galosandoval/shopfloor'
const verdict = await runAdmission({ maxAttempts: 5 })
if (!verdict.admitted) process.exit(verdict.refusal === 'not-a-trigger' ? 0 : 1)Its job is to gate the expensive one from a job that installed nothing.
runPhase re-asks the same question itself rather than trusting the answer.
shopfloor-authorize is unchanged and still ships: it is the spend gate alone,
for a caller that wants the permission question answered without the rest.
Authorization — the spend gate
On a public repository, anyone who can add a label can start a run that spends your Claude subscription. This is the only guardrail here whose failure mode is financial and adversarial rather than operational, so it ships as its own bin and runs in a job that has installed nothing:
jobs:
authorize:
if: github.event.label.name == 'agent:implement'
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ secrets.AGENT_PAT }}
steps:
# No checkout, no toolchain, no install — the guard runs before the spend
# it guards. A refusal exits non-zero, which fails this job, which skips
# every job that `needs:` it.
- run: npx -y @galosandoval/shopfloor@<version> shopfloor-authorizeGITHUB_ACTOR and GITHUB_REPOSITORY come from the runner. The probe is
gh api repos/{repo}/collaborators/{actor}/permission --jq '.role_name // .permission',
and the run proceeds only for admin, maintain, or write — a triage
collaborator can label an issue and therefore trigger the loop, and labeling is
not spending. It reads role_name rather than the endpoint's legacy
permission field, which reports only admin / write / read / none and
would collapse maintain into write and triage into read; an API old
enough not to send role_name falls back to it rather than refusing. An
organization's custom repository role is a name this guard has never seen,
so it is undetermined rather than allowed.
That set (SPENDING_PERMISSIONS, exported) is fixed rather than
configurable, for the reason the label vocabulary is: a stated set could only
be validated against a role model this package does not own, and the failure
mode here is not a broken diagnostic but a silently reopened spend gate.
It refuses on uncertainty, and it is the only guard here that does.
Everywhere else in this package an unreadable signal proceeds, because a
missing diagnostic should not cause an outage. Here an unreadable signal means
"I do not know whether this person may spend your money." So a gh that is
missing, unauthenticated, rate-limited, or answering with a permission level
the guard does not recognize all refuse, and the verdict says which of two
things happened:
| refusal | Means |
| --------------- | -------------------------------------------------------- |
| not-permitted | The probe answered, and the answer was no |
| undetermined | The probe answered nothing usable — refused, not assumed |
They are kept apart for the reason the doctor keeps unknown apart from
wrong: one is a trespasser and the other is a broken token, and a message
that conflates them files an outage under a security incident. The refusal
names the actor and what would unblock them.
It writes nothing. Unlike preflight refusal, this one neither labels nor comments — a refusal that commented would hand any drive-by triager a way to make the harness write to your repository. The exit code is the whole output.
For a caller that wants the verdict rather than a process:
import { runAuthorization } from '@galosandoval/shopfloor'
const { verdict } = await runAuthorization({
actor: 'octocat',
repo: 'galosandoval/recipe-chat'
})
if (!verdict.authorized) {
console.error(`${verdict.refusal}: ${verdict.reason}`)
}evaluateAuthorization is the pure decision underneath, if you probed the
permission another way, and SPENDING_PERMISSIONS is the set it judges
against. An actor or repository that is not a well-formed GitHub login /
owner/repo is undetermined and never probed: the probe path is built by
interpolation, and a target that could address a different endpoint is not one
whose answer is worth trusting.
Preflight refusal
runPhase refuses a run before it spends any tokens when the issue is a PRD
(it has native sub-issues), a native sub-issue of a parent, or an issue that
already has an open PR targeting it. The refusal comes back as
{ ran: false, refusal: 'preflight', reason }.
Only on the human edge. A retrigger continues a run whose PR is already open, so asking there would refuse every continuation on the evidence that the previous attempt worked.
It is the one refusal that writes: the judgement is about the issue, so it
applies the refused row of the transition table below and posts a comment
explaining why — naming ready-for-agent (exported as ENTRY_LABEL) as the
label to re-add to retry, since that is the one the loop's trigger watches and
the one the refusal just dropped.
Because it applies a transition, it verifies the label vocabulary first and
throws an ImplementAgentError if the repository is missing any of it —
before reading the issue, and whatever the verdict would have been. That is a
different failure from a refused verdict: a verdict says this issue must not be
implemented and is answered by labelling it, and labelling is exactly what an
unconfigured repository cannot be trusted to do. Run npx shopfloor-init to
create what is missing.
evaluatePreflight is the pure decision function underneath, exported for your
own tooling if you already have the sub-issue count, parent number, and linking
PRs gathered another way:
import { evaluatePreflight } from '@galosandoval/shopfloor'
const verdict = evaluatePreflight({
subIssueCount: 0,
parentNumber: null,
linkingPullRequests: []
})Issue state and the label vocabulary
Six labels, fixed and package-owned — the harness's own run state and the process lifecycle either side of it:
| Label | Means |
| ------------------- | ------------------------------------------------------------ |
| ready-for-agent | Spec is ready for an agent to implement |
| ready-for-human | The agent is done — a human owns the next move |
| agent:implement | The implement phase owns this issue |
| agent:in-progress | A run is in flight — do not start a second one on this issue |
| agent:blocked | A run refused or could not proceed — a human must unblock it |
| agent:exhausted | A run hit its ceiling without passing the gate |
They are not configurable, and that is the one place this package names things
in your repository. A name the harness does not own is a binding it cannot
guarantee — it could only ever be validated against a mapping you hold. The
concrete cost of not owning them is in the precondition above. npx
shopfloor-init creates them; LABEL_VOCABULARY carries each one's colour and
description, and REQUIRED_LABELS is just the names.
The state machine is a table, one row per run outcome, exported so your CI glue applies the same transition the harness does rather than a second guess at it:
| Outcome | Leaves the issue carrying |
| ----------- | -------------------------------------- |
| started | agent:implement, agent:in-progress |
| succeeded | ready-for-human |
| exhausted | agent:exhausted, ready-for-human |
| failed | agent:blocked, ready-for-human |
| refused | agent:blocked, ready-for-human |
import { applyLabelTransition } from '@galosandoval/shopfloor'
await applyLabelTransition({
issueNumber: '123',
repo: 'galosandoval/recipe-chat',
outcome: 'succeeded'
})Four properties are worth knowing before you wire it in:
- Each row is a target, not a list of edits. The edits are derived against the issue's real labels, so applying the same outcome twice writes nothing the second time, and any starting state — including one a human left by editing labels mid-run — lands correctly.
- Only these six labels are ever removed. Your own labels are untouched.
ready-for-humanis set by every terminal outcome, whatever the run produced. It marks whose move it is, not whether the work is good; theagent:labels say which kind of attention is wanted.exhaustedandfailedstay distinct for the same reason — the work is harder than specified and something is broken are different human responses.- A failed
ghthrows. It is not swallowed, because swallowing it is the original bug.
evaluateLabelTransition is the pure function underneath, and
TRANSITION_TABLE / RUN_OUTCOMES are the table and its outcomes if you want
to render or exhaustively switch on them. applyLabelTransition reads the
issue's current labels itself unless you pass currentLabels — pass them if
you already have them and save a round trip.
runPhase applies every row itself (shopfloor#47): refused when
preflight refuses, started before the branch exists, and succeeded,
exhausted, or failed on the way out — the failing ones inside the catch,
before the error is rethrown, so no way out of a started run leaves an issue in
agent:in-progress. The table stays exported anyway: your own tooling acting
on a run should apply the transition rather than a second guess at it.
Command guard
Three operations an autonomous run must never perform — pushing a Prisma
schema straight at the database instead of writing a migration, force-pushing,
and amending — are blocked at tool-call time rather than asked for in the
prompt. A phase run arms this automatically: the invocation carries a
--settings payload wiring a PreToolUse hook over Bash at the shipped
hook script, and a matching command exits 2 with the reason and the
sanctioned alternative on stderr, which the CLI feeds back to the agent as a
refusal
