Skip to Content
TechnicalDevelopmentAutomation offload

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 presentLabel projected
spec.mdstage:define
03_build/output/notes.mdstage:build
a ## Release section in that notes.mdstage:release
retired 04_verify/output/verify.md / 05_ship/output/*stage:verify / stage:ship (historical runs)
older 04_build / 05_verify / 06_shipstage:build / verify / ship
legacy 02_build / 03_releasestage: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.mdVerify) asks for four things that are pure bookkeeping:

  • sequence: n of m unique and contiguous over the whole batch
  • m matching the stub count
  • every depends-on naming an in-batch stub sequenced first
  • ## Build order agreeing 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 mainRequire 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 its title + summary + live URL to #product-update via the Slack bot (SLACK_BOT_TOKEN — the token the digest and #alerts steps already use; links render properly, which the old email-ingest path never managed). audience: internal entries 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) to apps/docs/archive/** and pushes to main as github-actions[bot]. If branch protection rejects the push, the job opens the small chore/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) and prettier --check over just the changed markdown files, ~40 s. .prettierignore still 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 actionNow
pnpm build / turbo run build / tsc to check compilationblocked by the hook; Vercel preview builds the PR
pnpm lint / pnpm typecheck locallyblocked; CI quality.yaml runs them
pnpm format locallyblocked; Husky pre-commit + CI
project-labels.sh --stage build/releaseCI pipeline.yaml labels job (stage from output files)
eyeball spec structure on later editsCI spec-check advisory job
re-check the cut’s sequence / depends-on / build orderCI intake-check advisory job (validate-intake.sh, §3a)
remember whether a releasing run needs docs or a changelogCI release-check advisory nudge (§3b)
loop get_check_runs waiting for green before mergingone blocking ci-status.sh call; merge attempted once (§4)
block on poll-deploy.sh before sending the investor emailremoved — no deploy-status gate
write + send a separate ship note after the mergeone changelog page; CI release.yaml announces on merge (§7)
run close-out.sh after the mergeCI 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 mergeCI claude-code-review.yaml when enabled (§5)
Last updated on