npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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/shopfloor

Requires 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-init

It 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:

  • outputTokens and cacheCreationInputTokens undercount. The snapshot precedes the message's final count, and a run killed mid-message contributes nothing at all. Read them as a lower bound.
  • inputTokens and cacheReadInputTokens overcount, 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 an ImplementAgentError naming 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. wallClockMinutes therefore 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_KEY is not stripped from it — that pair exists to keep the agent off a metered key, and your test suite is not the agent. Your requiredEnvVars are there as usual.
  • transcriptFile holds the last iteration's transcript, and the failed attempts land beside it. Each pass overwrites transcriptFile, so before iterating the attempt that just failed is kept at transcript.iteration-<n>.jsonl in the same directory. runTrajectoryCheck still grades transcriptFile — 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 no iteration- 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-phase

It 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 carrying undefined is 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_REPOSITORY and GITHUB_REF_NAME are not refused, even though repo and branch once 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. permission is a throwing getter, which reaches a JavaScript caller and stops at the process boundary — so shopfloor-admit's JSON carries the field with a sentence in it instead, and a workflow reading fromJSON(...).permission sees what replaced it rather than null. 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_name must equal repository.full_name. A workflow_run from a fork PR carries the fork's ref with your repository in repository, 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's name or the local part of its email). 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: closed trailer (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 itself

The 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-progress was 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-progress is 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-authorize

GITHUB_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-human is set by every terminal outcome, whatever the run produced. It marks whose move it is, not whether the work is good; the agent: labels say which kind of attention is wanted. exhausted and failed stay distinct for the same reason — the work is harder than specified and something is broken are different human responses.
  • A failed gh throws. 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