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 or poll for green. The run bookkeeping a retired CI job used to do — labels, spec and intake checks — is now one script call per stage (§2).

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 ran the unit tier that stopped being true: CI runs the identical suite on every ready head that touches code, 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 (advisory) check run like every other gate.

The E2E tier (apps/e2e) is blocked the same way, in every runner form — playwright test bare or behind npx / pnpm exec, plus the script forms pnpm e2e, pnpm --filter @sustentus/e2e e2e and turbo run e2e. No CI job runs it any more (Preview smoke was retired): the operator runs pnpm e2e against the web preview at Ready-to-merge. The npx playwright test spelling used to slip past the hook; closing that gap was the decision, rather than carving an exception for the E2E workspace.

One door, on request. An agent runs the real walk only when the operator asks for one in that session, and only through .icm/scripts/e2e.sh walk <base-url> [-g <persona>] — never as a routine check or a gate. The hook reads the command the agent types, so the script is the door and the runner forms stay blocked. Before any browser opens it checks the target’s Clerk keys by name (a development sk_test_ key only) and that the pinned Chromium is installed, and it ends in one RESULT: PASSED | FAILED (exit n) | BLOCKED line with the walk’s exit code. Every walk returns each seat’s video, trace and screenshots and a plain-text summary (apps/e2e/artifacts/walk.md), all on the machine that ran it — no model call, no Slack post.

The one activity that is not a check: looking. An agent authoring or repairing the walk needs to see what a preview actually renders, which no check run can tell it. The browser-smoke skill drives Chromium through Playwright’s library API from a throwaway script for exactly that: it opens the preview as each persona and screenshots the pages, producing a description rather than a pass/fail. It runs as plain node, matches nothing the hook blocks, and needs no exemption — because it is a different activity, not a bypass. It is never the verdict: the repair it produces goes out on a chore lane and is proven by a real walk of that PR’s preview — the operator’s pnpm e2e, or an agent’s .icm/scripts/e2e.sh walk at the operator’s request.

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.


1a. Session start — dependencies and the Husky hook

Hook: .claude/hooks/session-start.sh (registered in .claude/settings.json as SessionStart)

The formatting half of §1 rests on Husky’s pre-commit (lint-staged → prettier) running on every commit — and Husky is a git hook that only exists after pnpm install has run the root prepare script. A Claude Code cloud session starts from a fresh clone with no node_modules, so nothing formatted on commit there and the first thing CI said about a markdown-only PR was “unformatted”.

The SessionStart hook closes that gap. It is idempotent and cheap: when node_modules/.modules.yaml (pnpm’s install record) exists and is not older than pnpm-lock.yaml, it exits in milliseconds with no output. Otherwise it announces async mode to the harness ({"async": true, "asyncTimeout": 600000}) so the first prompt is never delayed, and runs pnpm install --frozen-lockfile in the background — no --ignore-scripts, because the root prepare script (husky) is the point. Everything pnpm prints goes to session-start-pnpm-install.log in the session’s scratch directory, and the log’s last lines record whether git config core.hooksPath ended up at .husky/_. The hook never fails the session: a failed install is logged, the session carries on, and the next commit simply lands unformatted for CI to catch.

One thing it refuses to do quietly: husky honours HUSKY=0 by skipping the hook install with exit 0, and the runtime hook skips too. If that variable is set in the session’s environment the hook prints a single line saying pre-commit formatting is off, and still installs — the operator sees it at session start instead of discovering it from a red check.

Setup: none. HUSKY must not be set to 0 in the cloud environment’s variables.


2. Run bookkeeping — moved out of CI

Until 2026-09-24 a .github/workflows/pipeline.yaml job (check run Project run labels) projected run labels and ran four advisory checks on every PR that touched the pipeline’s files. D43 — the estate’s CI cost floor — retired it: GitHub bills each job by the rounded-up minute, and the job spent a runner on seconds of work. .github/workflows/ now holds daily-digest, db-audit, db-migrate, mongodb-cleanup, quality and release, and none of them does run bookkeeping. Each former step is a script the stage that owns the change runs itself:

Former pipeline.yaml stepWhere it runs now
Label projection (stage:*, persona:*, complexity:*)The session, through .icm/scripts/project-labels.sh: new-run.sh projects the first set at Define, Build runs --stage auto after the push that carries notes.md, Release runs --stage release as its first step. A lane’s type:bug|tweak|chore label is set once by new-run.sh --lane.
Spec structural check.icm/scripts/validate-spec.sh <slug> → RESULT: OK in Define (step 4) and in revise, before the Spec approved gate.
Intake bookkeeping — sequence: n of m, depends-on, ## Build order.icm/scripts/validate-intake.sh <scope-slug> → RESULT: OK when Scope cuts a batch and when triage batch cuts an epic.
Release completeness — docs or changelog for a releasing runNo automated check. Release’s own contract lands the affected apps/docs/** pages and the changelog page in the PR, or records no impact / announce: none.
Knowledge-map dead paths.icm/scripts/validate-knowledge-map.sh → RESULT: OK in the knowledge lane (§6), before it opens its docs-only PR.

The three checks are pure bash/awk, need no network and end in one RESULT: line, so a session runs them in seconds; project-labels.sh writes through the GitHub API with the session’s token (§10). What none of them replaces is judgement: whether a stub is independently shippable, whether a page a run changed still says what the code does.

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 → Rules → Rulesets → the main ruleset (“Quality Protection”) → Require status checks to pass → Vercel – web — the one status it requires since 2026-09-24 (D43); Quality (advisory) is deliberately not on it.

With that on, GitHub itself also rejects a merge whose web preview did not build; without it the script’s STOP is still the contract. Immediately after a successful merge the agent repoints the PR body’s spec link — the announcement then runs itself and the archive the branch carried is verified (§7). No deploy-status check or gate of any kind.

No smoke check is required. The script can require one conditionally — on a ready head whose web preview really built — but only when .icm/project.json declares smoke_check, and this repo does not: Preview smoke was retired (D43, 2026-09-24), and the six-persona browser walk is the operator’s, run by hand at Ready to merge. See the persona walk.


5. PR review — deliberately not offloaded

There is no CI review workflow. One existed (claude-code-review.yaml, gated on an ENABLE_CLAUDE_REVIEW repository variable) and was never switched on: every PR reported it as skipped, and the Release contract carried a dead branch for it. It was removed in 2026-09 — the decision is to keep the review in-session. Release runs /code-review at the spec’s complexity (trivial → low, standard → medium, complex → high) and triages every finding by its rule.


6. Request auto-routing

Hook: .claude/hooks/route-request.sh (registered in .claude/settings.json)

A UserPromptSubmit hook (same design as the block-local-checks hook: stdin JSON → jq → file lookups and grep, sub-100 ms, no LLM) that lets the operator drop the /pipeline prefix. It injects one additionalContext line of two kinds:

  • [pipeline-router] Route: /pipeline <sub> [<slug>] (<why>) — authoritative. Emitted from checked facts only: a bare stage verb plus a slug (build vendor-funnel-graph, new smoke-visual-verdict, bug <triage-stub>) resolved against .icm/runs/<slug>/run.md, the archive under .icm/archive/pipeline-runs/, or an intake stub filename (triage/ counts only for the lanes); bare new (next stub in the batch); scope <anything>; triage report|batch|prune; knowledge add|edit|remove "<what>" (the one way to change a business/** or technical/** page outside a Release — one page, routed through the knowledge map, on a docs-only PR); and a work-shaped prompt that names nothing existing — new content, which routes to Scope. The /pipeline router runs the command unless the user’s own words override it.
  • [pipeline-router] Suggest: … — advisory. The bug / tweak / chore classifiers are grep heuristics; the router announces the suggested lane and the user can redirect. A prompt that mentions an existing run or stub by slug gets a Suggest: naming it instead of a trip to Scope.

Guarantees: it never blocks (always exits 0, fail-open on malformed input), and pure questions/discussion pass through untouched — interrogative openers, analysis verbs and question-shaped prompts with no work verb emit nothing at all. Slash-commands (the explicit /pipeline <sub> override), webhook payloads and short conversational prompts are skipped too.

Setup: none. Re-verify after editing the classifier with .claude/hooks/route-request.test.sh — plain bash, a fixture tree, no framework; it is not a repo check and CI does not run it.


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 one job (Announce and verify — two halves that used to be separate jobs, collapsed in the deployment-economics batch; each half’s failure posts its own #alerts message, and either failing fails the job):

  • 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.
  • verify — confirms the merge actually archived the run: .icm/runs/<slug>/ gone from main, <runs_archive>/<slug>/ present. The archive path is read from main’s .icm/project.json → runs_archive — the same key, with the same .icm/runs/_done default, that close-out.sh moves the run by — so moving the archive needs no edit here beyond the workflow’s literal paths: trigger. A run found under the retired docs-app archive (apps/docs/archive/pipeline-runs/) is reported as exactly that: its branch ran the close-out without merging main first, and the fix is to move it with a chore lane. The #alerts post names the cause verify found for each run. The archiving itself happens on the run’s own branch — Release runs .icm/scripts/close-out.sh before the merge, and the squash is what publishes it — so this workflow needs read access only.

Both halves learn what the merge brought in from one read of the PR’s file list (GET /pulls/{n}/files), which GitHub stops at 3,000 files without saying so. When fewer entries come back than the PR’s changed_files, the list was cut off. Both halves then fall back to the run slug in the PR body, the same slug line resolve-run.sh matches. Announce looks for apps/help/app/changelog/????-??-??-<slug>/page.mdx on the merged tree, and finding none means there is nothing to announce. Verify adds the slug to the runs it checks. A cut-off list with no slug in the body fails both halves to #alerts. So does an empty list, or a short one under 3,000, because that is a failed read, not the cap.

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 half 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 half, a fixed summary, sweeping a legacy run): Actions → Release → Run workflow with the slug — verify 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 and the nightly database-audit report, weighs findings, and parks at most one of them.

The Daily Codebase Audit Routine. Schedule 30 7 * * 1-5 (07:30 UTC, weekdays), a fresh cloud session per firing on the sustentus/sustentus environment, prompt /codebase-audit plus one line telling the session to execute the skill’s handback. What one firing does, in order:

  1. The skill forks into the read-only auditor agent, which sweeps the eleven lenses (ten code lenses plus the db:audit data report), verifies, dedupes against .icm/intake/triage/ (and _done/), open PRs and the intake epics, and ranks. It writes nothing.
  2. The fork returns one of two reports: all clear, or one stub — the complete triage stub (lane, found-by: codebase-audit · <date>, Problem with file:line evidence, Proposed change) and the steps to park it.
  3. On a stub, the calling session writes that one file to .icm/intake/triage/<name>.md, commits it on claude/audit-<date> from origin/main, pushes, and opens one ready PR against main whose body names the lane pickup (/pipeline <lane> <name>) and, over the folder’s 60-stub cap, the triage/ holds N active stubs (cap 60) — run triage report notice. A one-file .icm/** diff is in Quality’s paths-ignore, so no Quality job runs and nothing waits; a human merges the PR in the GitHub UI. On an all-clear the session stops — nothing is written or posted.

The branch name is the idempotency key: a second firing on the same day finds claude/audit-<date> on origin and stops. Nothing goes to Slack; the PR is the notification.

What the Routine needs. Push access to sustentus/sustentus for claude/audit-* branches (the Routine’s repository outcome — the session pushes with the account’s GitHub connection) and a route to open the PR: the GitHub MCP connector on the Routine, or GH_TOKEN in the environment so .icm/scripts/lib/gh.sh’s gh_api POST /repos/sustentus/sustentus/pulls works (§10). No Slack connector, no other connectors. The account identity that pushes must not be on main’s ruleset bypass list for any other reason — the audit never pushes main and never merges.

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 job needs contents: write and pull-requests: write so the session can push claude/audit-<date> and open the PR — nothing else moves, because the skill posts nowhere.


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.

The E2E carve-out request that never happened is worth recording, because it is the shape every future one will take. Authoring the persona walk genuinely needs a browser, and the obvious ask was an exception for the E2E workspace while the authoring skill is active. It was refused: an exception that exists is an exception that gets used, and “while the skill is active” is not a condition a PreToolUse hook can check. What the skill got instead is the library API — the same Chromium, driven to produce screenshots rather than a verdict — which was never blocked and never needed to be. The rule that survives: if the thing you want to run returns a pass/fail, the factory runs it; if it returns something you have to read and judge, it was never a check.

Why Quality path-filters again

Quality once carried a paths-ignore for .icm/** and **/*.md, then dropped it for an in-job dorny/paths-filter classification so that every PR reported a Quality status and the check could be required in branch protection — a required check that a path filter skips never reports and hangs the PR. While Quality Project was required (until 2026-09-24), that was the right trade.

D43 made the job advisory (Quality (advisory)), and the ruleset on main now requires the Vercel – web status instead. Nothing waits on an advisory job, so it is free to skip, and the trigger-level filter came back: paths-ignore for .icm/**, **/*.md, **/*.mdx and .github/**, a job-level draft guard, and the Scope step that skips a bookkeeping-only push on a code PR (CI/CD → the Quality workflow). The markdown backstop moved with it: unformatted markdown is caught by the Husky pre-commit hook, which the session-start hook (§1a) installs when a cloud session’s dependencies are stale, and by .icm/scripts/format.sh over the changed files where the hook is off — not by CI.


10. Runs anywhere — GitHub credentials per environment

Helper: .icm/scripts/lib/gh.sh, sourced by every pipeline script that talks to GitHub (new-run.sh, resolve-run.sh, project-body.sh, project-labels.sh, ci-status.sh, close-out.sh)

The team runs the pipeline from four places — a local machine, a Claude Code cloud session, OpenCode, and GitHub Actions — and the scripts have to behave identically in all of them. new-run.sh is the only way a pipeline PR is opened and a run branch is named (on main or a detached HEAD it creates claude/<slug>; on any other branch it uses the current one, so a harness-named branch is accepted and recorded in run.md), so it in particular must work wherever it is invoked.

Every GitHub call goes through one transport with one fallback order:

  1. curl + token — GITHUB_TOKEN, else GH_TOKEN, against GITHUB_API_URL (default https://api.github.com). This is the route the scripts always used.
  2. the gh CLI — on a missing token, a network failure or a non-2xx answer, the same request is retried through gh api if gh is installed and logged in on its own account (gh auth status, checked with the token variables unset, so a token that just failed is not handed to a second client).
  3. one die — when neither route produces an HTTP answer the script exits 1 with a single message naming what this environment is missing and the fix for it. The RESULT: contract of every script is unchanged; a credential failure is stderr + exit 1, never a verdict.

A non-2xx that is a real answer (a 404 probe, a 422 “already exists”) is retried through gh once and then handed back unchanged — the caller judges the code exactly as before.

EnvironmentMust be setgh fallback available
Local machineGITHUB_TOKEN or GH_TOKEN exported in the shell — or gh auth login once. Either route alone is enough.yes
Claude Code cloud sessionGH_TOKEN — injected by the session from the account’s GitHub connection. If it is absent, add GITHUB_TOKEN to the environment’s variables (claude.ai/code → the environment → Environment variables).no — gh is not installed there
OpenCodeGITHUB_TOKEN or GH_TOKEN in the shell OpenCode was started from — or a gh auth login on that machine.yes
GitHub ActionsGH_TOKEN: ${{ github.token }} (or GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}) in the step’s env, and the job’s permissions: block granting contents: write, pull-requests: write, issues: write.yes — preinstalled, reads GH_TOKEN

GITHUB_REPO (default sustentus/sustentus) and GITHUB_API_URL (default https://api.github.com, GHES only) are optional everywhere.

Minimum token scopes. A fine-grained token for sustentus/sustentus needs Contents, Pull requests and Issues — all read and write (labels are written through the issues API, so issues: write is not optional). A classic token needs repo. In Actions the built-in github.token covers all three once the job’s permissions: block grants them.

Verify in ten seconds:

.icm/scripts/resolve-run.sh <any-live-slug> # → RESULT: READY

The PR search inside it proves the credential whenever the run is not already in the working tree (a run that is checked out resolves offline, without touching GitHub). To force the call regardless — say, right after setting a variable — use the helper’s own check, which also prints the route that answered:

.icm/scripts/lib/gh.sh --check # → GET /repos/sustentus/sustentus → 200 via curl (GH_TOKEN) # RESULT: OK

With nothing set and no gh login the same command prints the one message the scripts would die with — for a cloud session, for example: “no GitHub route works in this environment (Claude Code cloud session) … GITHUB_TOKEN and GH_TOKEN are unset; the gh CLI is not installed. Fix: add GH_TOKEN (or GITHUB_TOKEN) to the cloud environment’s variables …”.

Setup: per environment, the one row above that applies.


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
pnpm install by hand so Husky exists in a fresh cloneSessionStart hook installs in the background when stale (§1a)
project-labels.sh --stage buildstill the session’s: Build runs --stage auto once after its notes.md push; the CI label job was retired (§2)
eyeball spec structure on later editsvalidate-spec.sh in Define and revise (§2)
re-check the cut’s sequence / depends-on / build ordervalidate-intake.sh in Scope and triage batch (§2)
remember whether a releasing run needs docs or a changelogRelease’s contract; no automated check since the CI job was retired (§2)
hand-audit the knowledge map against the docs treevalidate-knowledge-map.sh in the knowledge lane, which also repairs it (§2)
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 mergerun it on the branch as Release’s last commit; CI release.yaml only verifies the archive landed (§7)
listen to PR webhook events (Vercel churn)pipeline PRs are never subscribed; ci-status.sh is the read
pnpm e2e / playwright test to see the walk passblocked; the operator runs the walk, an agent only on request via .icm/scripts/e2e.sh walk — browser-smoke only looks (§1)
Last updated on