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 step | Where 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 run | No 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
mainruleset (“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); barenew(next stub in the batch);scope <anything>;triage report|batch|prune;knowledge add|edit|remove "<what>"(the one way to change abusiness/**ortechnical/**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/pipelinerouter 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 aSuggest: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 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. - verify — confirms the merge actually archived the run:
.icm/runs/<slug>/gone frommain,<runs_archive>/<slug>/present. The archive path is read frommain’s.icm/project.json→runs_archive— the same key, with the same.icm/runs/_donedefault, thatclose-out.shmoves the run by — so moving the archive needs no edit here beyond the workflow’s literalpaths: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 mergingmainfirst, and the fix is to move it with a chore lane. The#alertspost names the cause verify found for each run. The archiving itself happens on the run’s own branch — Release runs.icm/scripts/close-out.shbefore 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:
- The skill forks into the read-only
auditoragent, which sweeps the eleven lenses (ten code lenses plus thedb:auditdata report), verifies, dedupes against.icm/intake/triage/(and_done/), open PRs and the intake epics, and ranks. It writes nothing. - The fork returns one of two reports: all clear, or one stub — the complete triage stub
(
lane,found-by: codebase-audit · <date>, Problem withfile:lineevidence, Proposed change) and the steps to park it. - On a stub, the calling session writes that one file to
.icm/intake/triage/<name>.md, commits it onclaude/audit-<date>fromorigin/main, pushes, and opens one ready PR againstmainwhose body names the lane pickup (/pipeline <lane> <name>) and, over the folder’s 60-stub cap, thetriage/ holds N active stubs (cap 60) — run triage reportnotice. A one-file.icm/**diff is in Quality’spaths-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:
- curl + token —
GITHUB_TOKEN, elseGH_TOKEN, againstGITHUB_API_URL(defaulthttps://api.github.com). This is the route the scripts always used. - the
ghCLI — on a missing token, a network failure or a non-2xx answer, the same request is retried throughgh apiifghis 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). - 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.
| Environment | Must be set | gh fallback available |
|---|---|---|
| Local machine | GITHUB_TOKEN or GH_TOKEN exported in the shell — or gh auth login once. Either route alone is enough. | yes |
| Claude Code cloud session | GH_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 |
| OpenCode | GITHUB_TOKEN or GH_TOKEN in the shell OpenCode was started from — or a gh auth login on that machine. | yes |
| GitHub Actions | GH_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: READYThe 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: OKWith 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 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 |
pnpm install by hand so Husky exists in a fresh clone | SessionStart hook installs in the background when stale (§1a) |
project-labels.sh --stage build | still 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 edits | validate-spec.sh in Define and revise (§2) |
| re-check the cut’s sequence / depends-on / build order | validate-intake.sh in Scope and triage batch (§2) |
| remember whether a releasing run needs docs or a changelog | Release’s contract; no automated check since the CI job was retired (§2) |
| hand-audit the knowledge map against the docs tree | validate-knowledge-map.sh in the knowledge lane, which also repairs it (§2) |
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 | run 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 pass | blocked; the operator runs the walk, an agent only on request via .icm/scripts/e2e.sh walk — browser-smoke only looks (§1) |