Automation offload
The /pipeline doctrine is that the factory — Husky, GitHub Actions, and Vercel — owns
deterministic work, not the AI agent. The agent writes specs, code, and prose; it does not run
builds, re-run checks, poll for green, or hand-project labels.
This page documents the guardrails and Actions that enforce that split, and the one-time,
GitHub-side setup a couple of them need. The stage contracts that consume it live in
.icm/stages/*/CONTEXT.md; see also CI/CD pipeline for the
checks themselves and the development workflow for the stages.
1. Local build/check guard
Hook: .claude/hooks/block-local-checks.sh (registered in .claude/settings.json)
A PreToolUse(Bash) hook blocks local build / lint / typecheck / format / test
invocations — pnpm …, turbo run …, tsc, next build, and prettier / eslint / vitest
behind npx / pnpm exec. The agent is redirected to push and read results back from the PR’s
check runs.
Tests were originally exempt, on the reasoning that a unit suite is the one deterministic signal
worth a local turn. Once quality.yaml grew its Run tests step that stopped being true: CI runs
the identical pnpm test on every PR, so the local run bought nothing but a cold turbo build of
@sustentus/services inside the agent’s context window. Agents still write and update tests — the
Build stage requires it — they just read the result off the Quality check run like every other
gate.
It only matches runner invocations at a command-segment boundary, so a blocked word inside a commit
message (git commit -m "fix lint") is never caught. The corresponding pnpm build/lint/format and
turbo run entries were removed from the settings allow list — the hook is the enforcement; the
allow-list trim just stops dead auto-approvals.
Setup: none.
2. Label projection
Job: labels in .github/workflows/pipeline.yaml
On any PR that touches .icm/runs/**, CI projects each changed run’s labels from its spec.md
(type:feature, persona:*, complexity:*) and derives stage:* from which run outputs exist:
| Output present | Label projected |
|---|---|
spec.md | stage:define |
03_build/output/notes.md | stage:build |
a ## Release section in that notes.md | stage:release |
retired 04_verify/output/verify.md / 05_ship/output/* | stage:verify / stage:ship (historical runs) |
older 04_build / 05_verify / 06_ship | stage:build / verify / ship |
legacy 02_build / 03_release | stage:build / stage:release |
The projection runs via .icm/scripts/project-labels.sh <slug> --stage auto --pr <n>. Lane runs
have no spec — the job skips them; their type:bug|tweak|chore label is set once by
new-run.sh --lane.
Consequence for the agent: it never calls project-labels.sh to advance stage:* at Build
or Release — pushing the stage’s output is what moves the label. new-run.sh still projects
the initial label set at Define (so labels exist the instant the PR opens); the script remains the
manual fallback for one-offs. Same-repo PRs only — forks get a read-only token.
Setup: none.
3. Spec structural backstop
Job: spec-check in .github/workflows/pipeline.yaml
Runs validate-spec.sh on any changed spec.md and reports to the job summary. Advisory only — it
always exits 0 and never blocks the PR. Define still runs validate-spec.sh before its gate (that
is the real check — issue #548 deliberately kept it out of CI as a hard gate); this only flags drift
introduced by later pushes.
Setup: none.
3a. Intake bookkeeping
Job: intake-check in .github/workflows/pipeline.yaml
The cut’s contract (.icm/intake/CONTEXT.md → Verify) asks for four things that are pure
bookkeeping:
sequence: n of munique and contiguous over the whole batchmmatching the stub count- every
depends-onnaming an in-batch stub sequenced first ## Build orderagreeing with the stubs’sequence:lines
The agent used to re-derive all four by re-reading the batch, and a hand-edit to breakdown.md after
the cut broke them silently — the order /pipeline new walks is exactly what stops meaning anything
when they drift.
.icm/scripts/validate-intake.sh <scope-slug> now owns them (same shape as validate-spec.sh:
pure bash/awk, no network, RESULT: verdict line, exit 2 on problems), and the intake-check job
runs it on every batch touched by a PR. Stubs already spun out into _done/ still count — a
partially consumed batch must still read as a contiguous 1..m. Advisory only: it writes to the
job summary and always exits 0.
What stays with the agent is the judgement the same Verify block asks for: is each stub independently shippable, does it sit on a real product seam, is anything stub-sized actually scope-sized.
Setup: none.
3b. Release completeness
Job: release-check in .github/workflows/pipeline.yaml
Release’s steps 5–6 say the affected apps/docs/** pages and the changelog page land in this
PR, “or record no impact / announce: none”. That’s prose, so nothing enforced it. The job
turns the enforceable half into a presence check: on a PR whose diff includes a notes.md carrying
the ## Release record (legacy in-flight runs: a 05|06_ship/output/release.md), warn to the job
summary when the PR carries neither an apps/help/app/changelog/** page nor an apps/docs/**
change.
The escape is honoured rather than ignored: before warning, the job greps the record for a
no-impact or announce: none line. A deliberately silent run stays quiet; a forgotten one gets a
nudge. Advisory only — never a merge gate.
Setup: none.
4. CI-gated merge — no agent-side polling
The Release agent never model-polls check runs. The verdict comes from one blocking script call —
.icm/scripts/ci-status.sh <slug> — whose waiting happens in its own loop (wall-clock, not model
turns) and which reads both health surfaces (check runs and the Vercel commit statuses) and
prints a single settled GREEN | RED | PENDING. Only GREEN, established after the last push,
authorises merge_pull_request on the ticked Ready to merge box — attempted exactly once.
One-time GitHub setup backstops it:
Settings → Branches → Branch protection rule for
main→ Require status checks to pass before merging → add Quality (and any other required check).
With that on (recommended), GitHub itself also rejects a red merge; without it the script’s STOP is still the contract. Immediately after a successful merge the agent repoints the PR body’s spec link — and the announcement + close-out run themselves (§7). No deploy-status check or gate of any kind.
5. Automated PR review
Workflow: .github/workflows/claude-code-review.yaml
Moves the Release stage’s /code-review pass onto CI (anthropics/claude-code-action@v1) so the diff
is reviewed against CONVENTIONS.md on every code PR without spending an interactive turn.
INERT until you opt in. Set both:
- secret
ANTHROPIC_API_KEY— Settings → Secrets and variables → Actions → Secrets- variable
ENABLE_CLAUDE_REVIEW = true— same page → Variables
The job’s if keys on the variable, so the file does nothing until you enable it. Once on, the Release
agent can note “review runs in CI” and skip the inline /code-review (it still triages the posted
comments before merge). Trades interactive tokens for Action-runner minutes plus API spend on the key.
6. Request auto-routing
Hook: .claude/hooks/route-request.sh (registered in .claude/settings.json)
A UserPromptSubmit hook that classifies each raw user prompt with deterministic grep heuristics
(same design as the block-local-checks hook: stdin JSON → jq → grep, sub-100 ms, no LLM) and, when
the prompt looks like actionable work, injects one advisory additionalContext line suggesting a
lane — bug / tweak / chore, or the full front (/pipeline scope) for feature-shaped requests and
multi-feature dumps. The /pipeline router announces the suggestion and proceeds; the user’s own
words always win.
Guarantees: it never blocks (always exits 0, fail-open on malformed input), and pure questions/discussion pass through untouched — interrogative openers and question-shaped prompts with no work verb emit nothing at all. Slash-commands and short conversational prompts are skipped too.
Setup: none.
7. Release on merge — announce + close-out
Workflow: .github/workflows/release.yaml
The two last acts of a run — announcing it and moving the ticket to done — both used to cost the
agent turns after every merge (and before 2026-08, the announcement was a separate
investor-update.md file emailed via Resend by ship-note.yaml; both file and email path are
retired). The workflow fires on the merge of any PR touching .icm/runs/** and runs two jobs:
- announce — reads the run’s changelog page (the one release artifact,
apps/help/app/changelog/<date>-<slug>/page.mdx) out of the merge commit’s tree and posts itstitle+summary+ live URL to#product-updatevia the Slack bot (SLACK_BOT_TOKEN— the token the digest and#alertssteps already use; links render properly, which the old email-ingest path never managed).audience: internalentries are announced with an[internal]tag; a run with nothing to announce simply has no page, and nothing posts. - close-out — runs
.icm/scripts/close-out.sh <slug>: archives the run folder (and the intake epic, if this was its last unshipped stub) toapps/docs/archive/**and pushes tomainasgithub-actions[bot]. If branch protection rejects the push, the job opens the smallchore/close-out-<slug>fallback PR itself.
Authorisation is unchanged: the Ready to merge tick gates the merge, and the merge is the
trigger — nothing posts or archives that a human didn’t authorise. Failures in either job post to
#alerts, because a release that silently doesn’t announce (or a run left un-archived) looks
exactly like a completed one.
Manual re-run (a failed job, a fixed summary, sweeping a legacy run): Actions → Release → Run
workflow with the slug — close-out only by default; tick announce to also re-post from main.
Setup: the SLACK_BOT_TOKEN Actions secret (already required by the digest); the
#product-update and #alerts channel IDs are inlined in the workflow.
8. Two schedulers — where a new scheduled job belongs
Recurring automation lives in two places, and both are right for what they run.
GitHub Actions runs the deterministic scripted jobs that need repo secrets — the weekday digest
(daily-digest.yaml), the nightly db-audit (db-audit.yaml), and the merge-triggered announce +
close-out (release.yaml, §7).
A claude.ai Routine runs the daily /codebase-audit, which is judgement work: it reads the
monorepo, weighs findings, and posts the top three to Slack #build-log through the connector MCPs
available inside the subscription.
The rule for the next scheduled job: deterministic + secrets → Actions; judgement + connectors → Routine.
A job that can be written as a script with an exit code belongs in a workflow, where it is versioned, reviewable, and gets Actions secrets. A job whose output is an assessment rather than a pass/fail — and which needs Slack, Gmail, Drive or another connector — belongs in the Routine, where those connectors are already authorised.
If the Routine is ever migrated to Actions (claude-code-action supports prompt: /codebase-audit on
a schedule: trigger), the skill’s Slack posting has to move to a bot token in Actions secrets first.
Without one it degrades silently to a written report that nobody reads — the same failure shape §7’s
#alerts step exists to prevent.
9. What is deliberately NOT offloaded
block-local-checks.sh has no escape hatch — no override flag, no env var. That is the design,
not an oversight: the moment a bypass exists it becomes the default, and the agent is back to
compiling the monorepo in its context window. If a check fails, the fix is a commit, not a local
re-run. The cost is one CI round-trip per formatting mistake, which is the intended trade.
What is no longer skipped
Quality used to carry a paths-ignore for .icm/** and **/*.md, which bought cheap pipeline
PRs at the price of two defects — unformatted markdown reaching main and later redding an unrelated
code PR, and an all-markdown PR getting no Quality status at all (which also made Quality
impossible to require in branch protection: a skipped workflow reports nothing, and a required check
that never reports waits forever).
quality.yaml now triggers on every PR and classifies the diff in-job with dorny/paths-filter
(predicate-quantifier: every, so the !.icm/** / !**/*.md negations AND together instead of
being swallowed by the bare **):
- Code changed → full install +
format:check+lint+typecheck, exactly as before. - Markdown-only or pipeline-only diff → root-only install (
pnpm install --filter sustentus) andprettier --checkover just the changed markdown files, ~40 s..prettierignorestill applies to explicitly-passed paths, so a diff made entirely of exempt files (.claude/skills/**,.icm/runs/**,apps/docs/archive/**) legitimately checks nothing.
For push to main and workflow_dispatch the filter step is skipped and its outputs are empty, so
the != 'false' guards fall through to the full checks.
Net effect: markdown debt is caught in the PR that creates it — which Husky cannot do, since a cloud
session without node_modules never runs pre-commit — every PR carries a green/red Quality status,
and Quality is safe to make a required check (§4).
Summary — what the agent stopped doing
| Was an agent action | Now |
|---|---|
pnpm build / turbo run build / tsc to check compilation | blocked by the hook; Vercel preview builds the PR |
pnpm lint / pnpm typecheck locally | blocked; CI quality.yaml runs them |
pnpm format locally | blocked; Husky pre-commit + CI |
project-labels.sh --stage build/release | CI pipeline.yaml labels job (stage from output files) |
| eyeball spec structure on later edits | CI spec-check advisory job |
| re-check the cut’s sequence / depends-on / build order | CI intake-check advisory job (validate-intake.sh, §3a) |
| remember whether a releasing run needs docs or a changelog | CI release-check advisory nudge (§3b) |
loop get_check_runs waiting for green before merging | one blocking ci-status.sh call; merge attempted once (§4) |
block on poll-deploy.sh before sending the investor email | removed — no deploy-status gate |
| write + send a separate ship note after the merge | one changelog page; CI release.yaml announces on merge (§7) |
run close-out.sh after the merge | CI release.yaml close-out job on merge (§7) |
| listen to PR webhook events (Vercel churn) | pipeline PRs are never subscribed; ci-status.sh is the read |
run /code-review inline before merge | CI claude-code-review.yaml when enabled (§5) |