Source repository for Platform GitHub agentic workflows and the @plainconceptsplatform/workflows CLI.
loops/: standalone copyable workflow source, arranged like a consumer.github/:actions/for composite actions,workflows/for workers, shared imports, and router, andscripts/for compilation.cli/: TypeScript installer and updater.docs/: ownership and consumer guidance.skills/: workflow-author and workflow-consumer skills.runners/: the ephemeral VM Scale Set fleet the agent workers run on, and the app that scales it.
Consumer repositories generate and commit their own *.lock.yml files. This repository does not store generated workflow locks.
The agent workers target runs-on: agents-arc. runners/ builds that fleet,
and docs/self-hosted-runners.md explains what the compiled lock
files do differently from what gh-aw generates, and why. Read the second one before changing
loops/scripts/compile-agent-workflows.mjs.
docs/diagrams.md draws all of it: the router's one-event-one-route
selection, how the routes chain into each other, and one diagram per worker. Those diagrams used
to sit at the bottom of each worker's markdown, which is the prompt, so every run paid for about
240 lines of Mermaid and then read an instruction telling it to ignore them. verify-route-matrix.sh
now fails if one reappears in a prompt.
Every worker is a workflow_call reusable workflow. Nothing triggers itself: work-router.yml
owns all the triggers, classifies the event into exactly one route, and calls one worker. That
is why a worker can be added or removed without touching the others, and why the route matrix
in verify-route-matrix.sh is the thing to run after changing any of them.
| Route | Worker | Starts when | Produces |
|---|---|---|---|
triage |
agent-triage.md |
someone outside the organisation opens an issue | a comment, and either the refine label when it passes, the review label when it needs a person, or a close when it is genuinely rejected |
refine |
agent-refine.md |
the refine label is added |
a refined story wrapped in the repository's own issue template with a Fibonacci estimate, or a temporal draft plus questions for the author, or a split into several right-sized issues |
implement |
agent-implement.md |
the implement label is added |
one branch, one pull request, one issue closed |
merge-gate |
agent-merge-gate.md |
CI reports on a bot pull request | one of auto-merge, human-review, owner-review or blocked, or a fix pushed to the same branch |
apply-review |
agent-apply-review.md |
someone reviews or comments on a bot pull request | the requested changes pushed to that pull request |
audit |
agent-audit.md |
Mondays, or on demand | one issue of findings, labelled refine so it gets sized and split |
release |
agent-release.md |
on demand (operation=release) |
a tagged GitHub Release with AI-generated release notes |
Plus the plumbing, which has no agent in it: work-router.yml (the router itself),
authorize-bot-work.yml (a human's label is checked, then the bot re-labels so the workers see
a trusted actor), and the classify-route / verify-route-matrix composite actions.
Two deterministic jobs exist because the measured problem was never bad decisions, it was silence. Across four consumers, 58 of the 237 issues closed since 1 August were closed by the bot: autonomy between 6% and 50%. The rest stopped somewhere and told nobody.
| Job | Runs | Does |
|---|---|---|
housekeeping |
23 */6 * * * |
retries work that stalled (a crash, a timeout, an empty output) after 6h, up to 3 times; drops pr-pending from issues no open pull request closes; closes a split parent once every child is closed; deletes branches whose pull requests are all finished and bot-authored; rewrites one issue, Needs a human, listing what genuinely needs a person |
audit-close |
43 3 * * * |
closes an audit report once every issue it references is closed, and labels a report nobody actioned stale-audit after 14 days so it stops blocking the next audit |
The rule that shapes the janitor: retry a failure, report a decision. stalled marks a park
the machine caused, and those are worth running again. A triage needs-maintainer, a refine
questions or any merge-gate disposition other than auto-merge is a decision reached on
purpose, and re-running a decision only reproduces it, so those are listed in the digest and
never retried.
The digest also carries the gate's own scoreboard: the share of dispositions that were
auto-merge over the last fortnight, the split across the rest, and how many auto-merged pull
requests were later reverted. That last number is the one that has to stay flat while the first
one climbs.
It runs on the App token, because GitHub starts no workflow run from an event raised with
GITHUB_TOKEN -- with the default token every retry would be a green no-op. Every write goes
through one wrapper, which is the only place dry-run is read, so operation=housekeeping with
dry-run on prints exactly what it would change and writes nothing. verify-route-matrix.sh
asserts all of that, including that it closes only two kinds of issue.
The normal life of a piece of work is refine → implement → CI → merge-gate → merged, with
no human in the loop unless a worker asks for one. Refine decides the size: an estimate of 8 or
more is split into children of 5 or less, and each child walks the same path on its own. Audit
creates work rather than consuming it, and files into refine rather than straight to
implement, so a report of several unrelated findings becomes one properly sized issue per
finding instead of one pull request that has to fix them all.
A merge-gate verdict parks the code it was given on, not the CI run that prompted it. Both
paths that dispatch the gate compare the standing verdict against the newest commit on the
branch: re-run CI as often as you like and the park holds, but push a commit -- which is what
apply-review and a remediated verdict both do -- and the gate decides again, because that is
new code nobody has ruled on. They used to compare against the CI finish time, which made the
park worthless: any later run on the same commits was newer than the verdict, so the belt
re-dispatched a pull request a human already owned and handed it a fresh budget of six ~120-minute
gate runs at the same time. One pull request sat parked for six days while that happened.
Two labels are the controls a person has: review parks anything for a human, and future
holds a refined issue back from implementation until it is removed. A third label, pr-pending,
marks issues whose bot PR is open and awaiting merge-gate — it is informational only and does
not route or block anything.
Two different things carry a timeout and only one of them is ours.
The agent step. timeout-minutes: in a worker's frontmatter compiles to a
timeout-minutes on the Execute OpenCode CLI step: the wall clock the agent has to read,
think and call its safe-output tools. This is the one that matters and the one to change.
The framework's own job timeout. gh-aw puts 45 minutes on the deterministic jobs it
generates around the agent (safe_outputs, validate_output, conclusion). It is not read
from our frontmatter and there is nothing to tune; if you see 45 in a lock file next to a
number you did not write, that is what it is.
| Worker | Agent step | Why |
|---|---|---|
agent-implement.md |
90 | Writes code, runs a build and a test suite, and pushes a branch. |
agent-merge-gate.md |
120 | Reviews the diff for defects, and may fix failed CI and re-push. |
agent-refine.md |
60 | Reads the repository to ground a story, then rewrites one issue body. |
agent-apply-review.md |
45 | Applies review comments to an existing branch. |
agent-audit.md |
45 | Sweeps the repository and writes one findings issue. |
agent-release.md |
30 | Reads the commit log and writes release notes. |
agent-triage.md |
30 | Judges one issue and comments. |
Not like a timeout. The issue gets the worker's ordinary incomplete comment — "ended without an outcome", with the label left in place for a retry — which reads as though the agent had nothing to say. The real cause is one line in the agent job's log:
##[error]The action 'Execute OpenCode CLI' has timed out after 40 minutes.
So when a worker keeps producing no outcome on the same issue, read the agent job's
conclusion before reading its output. failure with validate_output skipped is a killed
step; success with an incomplete job that ran is an agent that finished and produced
something the validator rejected. Those are unrelated problems.
The binding constraint is usually the model's pace rather than the size of the work. Refine sat at 40 minutes and was raised to 60 on the evidence of a run that died having done the job: 36 model turns, most of them returning under 110 output tokens and still taking over a minute each, with turn 32 producing 6,479 tokens — the finished story — and the clock running out in the few short turns that would have submitted it. Forty minutes bought about thirty-six turns whatever it was asked to refine.
The token table at the end of every agent job log is where to look: turn count, output tokens and duration per turn. An agent that is looping shows many turns with small outputs and no large one; an agent that ran out of clock shows the large one near the end.
A workflow run reads its lock when it starts. Raising a timeout and pushing does nothing for runs already in flight — they finish, or die, on the old number. Re-trigger after the push if you want the new one.
agent-release.md generates a tagged GitHub Release with AI-written release notes. Trigger it
manually via workflow_dispatch with operation=release. The agent reads the commit log since
the last tag, categorizes commits by conventional-commit prefix, and writes release notes to
/tmp/gh-aw/agent/release-notes.md. A deterministic conclude job then bumps the version
(auto detects BREAKING CHANGE → major, feat: → minor, else → patch), commits, tags, pushes,
and creates the GitHub Release.
gh workflow run work-router.yml --repo PlainConceptsPlatform/<repo> -f operation=release -f version-bump=autoversion-bump accepts auto, patch, minor, or major. The worker only needs
OPENAI_API_KEY — it uses github.token for git operations and gh release create.
One worker runs on a timer and it is long: audit takes about 45 minutes. Every repository
installs the same router from this package, so unless the schedule is changed at install time,
every consumer fires it at the same minute. The agent fleet is shared by every consumer and
deliberately small, so two repositories auditing together already consume all of it while CI and
merge gates queue behind them.
Give each repository its own slot. The scheme below puts one audit on each day of the week:
| Slot | Repository | Audit |
|---|---|---|
| 0 | first consumer | 17 1 * * 1 Mon |
| 1 | second consumer | 17 1 * * 2 Tue |
| 2 | free | 17 1 * * 3 Wed |
| 3 | free | 17 1 * * 4 Thu |
| 4 | free | 17 1 * * 5 Fri |
| 5 | free | 17 1 * * 6 Sat |
| 6 | free | 17 1 * * 7 Sun |
Seven slots is the ceiling for this shape. Beyond that, either widen the fleet (MAX_VMS in
runners/scaler-app) or accept that the eighth repository shares a day with the first.
A slot is one value: AUDIT_CRON in the env: block at the top of
.github/workflows/work-router.yml. The classifier reads it from there, and workflows update
copies it into the schedule: line below, which has to be a literal because GitHub evaluates no
expression in a cron:. Change it in env: and run workflows update, or edit both together;
verify-route-matrix.sh asserts the two agree and that the cron reaches its route, because a
cron in one place and not the other fails in the direction that hurts, with the run firing and
then classifying to no route at all.
The other value in that block is CI_WORKFLOW_NAME, the name of the CI workflow the merge belt
reads its verdict from. It works the same way, mirrored into the workflow_run trigger, and
getting it wrong is equally quiet: the belt logs no completed CI run and the pull request waits.
The daily and hourly crons (audit-close, cleanup-artifacts, reconcile-bot-pr-runs,
housekeeping, and the optional error report) do not need staggering: they run on
GitHub-hosted runners and never touch the fleet. The reconcile cron runs hourly at minute 17.
The rest of that env: block belongs to the janitor and the audit chain, and each value is a
repository's to change:
| Value | Default | Means |
|---|---|---|
HOUSEKEEPING_RETRY_AFTER_HOURS |
6 |
how long a stalled issue waits before a retry |
HOUSEKEEPING_MAX_RETRIES |
3 |
retries before the issue is left to a person |
HOUSEKEEPING_STALE_PR_DAYS |
3 |
a bot pull request older than this with no gate verdict is reported, never closed |
HOUSEKEEPING_DIGEST_TITLE |
Needs a human |
the one issue listing what needs a person; empty turns the digest off |
AUDIT_STALE_AFTER_DAYS |
14 |
when an unactioned audit report stops blocking the next audit |
MAX_GATE_ATTEMPTS |
6 |
failed merge-gate attempts one pull request head may consume before the belt parks it |
Before installing or compiling these workflows, consumer repositories should install and configure PlainConceptsPlatform/agent-harness. Loop workers invoke the skills and commands it provides. Verify the required skills and commands are available in the consumer repository before compiling.
The primary entrypoint is the interactive TUI. Run it with no arguments:
npx @plainconceptsplatform/workflowsThe TUI lists all routes and templates with install status. Use arrow keys to navigate, space to toggle, and Enter to install selected items. Selecting any route installs the full managed catalog (actions, workflows, router, compile script) plus the mandatory opencode.ci.json and scripts/compile-agent-workflows.mjs. Selecting only templates still installs those two mandatory files.
For automation or scripting, non-interactive commands are available:
npx @plainconceptsplatform/workflows@latest init
npx @plainconceptsplatform/workflows@latest add
npx @plainconceptsplatform/workflows@latest add refine implement
npx @plainconceptsplatform/workflows@latest add triage
npx @plainconceptsplatform/workflows@latest add audit
npx @plainconceptsplatform/workflows@latest add release
npx @plainconceptsplatform/workflows@latest add --template agentics-checks
npx @plainconceptsplatform/workflows@latest add --template agentics-maintenance
npx @plainconceptsplatform/workflows@latest add --template app-ci-dotnet-next
npx @plainconceptsplatform/workflows@latest add --template app-ci-node-monorepo
npx @plainconceptsplatform/workflows@latest add --template bug-report
npx @plainconceptsplatform/workflows@latest add --template feature-request
npx @plainconceptsplatform/workflows@latest remove triage
npx @plainconceptsplatform/workflows@latest update --dry-run
npx @plainconceptsplatform/workflows@latest update
npx @plainconceptsplatform/workflows@latest --versionadd (catalog install) always installs opencode.ci.json and scripts/compile-agent-workflows.mjs alongside managed loop files. They are mandatory.
The router (work-router.yml) is a derived file: its content is a function of which routes are installed. The CLI owns that assembly so the router always references exactly the workers on disk, never more. The classifier is the complete route table everywhere (a route with no job is a no-op run), and the route matrix reads the installed worker files, so neither is rewritten.
add <routes>unions the requested routes with the routes already installed, then regenerates the router from the union. Adding a route later keeps the ones already there instead of dropping them.update(andaddwith no routes) refreshes exactly the installed set. It never adds or removes a route.remove <routes>drops the requested routes from that set, regenerates the router, and deletes each removed worker'sagent-<route>.mdand generatedagent-<route>.lock.yml.- The interactive TUI is desired-state: the checked routes are the target set. Checking a new route adds it; unchecking an installed route removes it. Enter on an unchanged selection is an update.
release is a route like the other six. A repository that never installed it does not receive it on update.
For a project-local development dependency, install @plainconceptsplatform/workflows and run pnpm exec workflows with no arguments to launch the TUI, or pnpm exec workflows <init|add|update> for non-interactive use.
Every installed file carries the package version in its ownership header, stamped at install time:
# Managed by @plainconceptsplatform/workflows@0.7.0. Source: loops/workflows/agent-refine.md. Update with `workflows update --force`; consumer edits may be overwritten.
workflows update replaces every package-managed file with this version's and re-stamps it.
The env: block at the top of a worker is the one part of it that belongs to the repository,
so the merge keeps it:
- Your values stay. A key the package added arrives with its default. A key only you defined stays at the end of the block.
- When the header records the version you installed from, that release is fetched from npm
(
npm pack) and used as the merge baseline: a value you never changed follows the package when its default changes, and a key the package removed disappears if you never changed it. Offline, every one of your values is kept and the result says the baseline was unavailable. - The agent runner pool (
runs-on) and the engine gateway URL are kept too, because GitHub gives them no home inenv:.
Everything else in a package-managed file is the package's, byte for byte: the prompt body, the
jobs, the composite actions, the shared imports, the compile script. If a change is worth
making, make it in loops/ and let every repository get it; if it must differ per repository,
it has to be an env variable. A file whose ownership header was removed is yours and is left
alone unless --force is passed. Templates are yours from installation and are replaced only
with --force.
update --dry-run prints the plan as JSON without writing: per file added, updated (with
the env keys kept and the defaults applied), unchanged or skipped, plus the installed and
package versions.
The triage route gates issues opened from outside the organisation. The gate is organisation
membership, not permission level. The authorize job marks an actor an outside collaborator when they
have Read permission, and also when they have Write or better but their author association is
COLLABORATOR — repository access without being one of the organisation's own. So an org member with
write skips triage; an outside collaborator with write does not.
When such an issue is opened, the triage agent runs 10 checks (template completeness, security risk, change
size, danger level, duplicate detection, clarity, reproducibility, acceptance criteria quality,
cross-cutting impact, product-owner eligibility) and loops up to 3 rounds. The author or any write+ user can
comment to re-trigger triage after a needs-info verdict.
- pass: all checks pass → bot adds
refinelabel → enters the normal pipeline (refine → implement) with no human in the loop. - needs-info: needs clarification → bot posts questions, adds
reviewlabel, keepstriage→ author or write+ user replies → re-triage. - needs-maintainer: legitimate work, wrong door. Outside product-owner scope, so it needs a maintainer-owned technical proposal → the issue stays open, gets
review, and losestriageso later comments do not re-trigger triage. A maintainer takes it on by addingrefine. - block: cannot be done, security risk, too dangerous to automate, or still ambiguous after 3 rounds → bot closes the issue with an explanation.
Only block closes. Out-of-scope work used to close too, which lost it: a reproducible authorization
defect that passed nine of the ten checks was closed as not_planned with every label stripped, and
nothing distinguished it from junk somebody rejected. That is what needs-maintainer exists for.
At round 3, needs-info is no longer valid — the agent must pass, needs-maintainer or block.
Install the triage route alongside other routes:
npx @plainconceptsplatform/workflows@latest add triage
npx @plainconceptsplatform/workflows@latest add auditadd and update install only package-owned loops. They do not install maintenance templates. Install a template explicitly with add --template <name>; use --force only to replace a changed copy.
agentics-checksverifies generated lockfiles and lints agentic workflow source on pull requests.agentics-error-reportlooks at how this package's workflows behaved here in the last day and files what broke upstream, so the package gets fixed instead of every repository working around the same bug. Worth installing everywhere; see the privacy contract below.github-releasepublishes a GitHub Release with generated notes when av*tag is pushed.agentics-maintenanceis thegh awgenerated maintenance workflow. It is supplied for repositories that want to commit the generated workflow before their first compilation.bug-reportinstalls a bug report issue template to.github/ISSUE_TEMPLATE/bug_report.yml. Bugs can be any size.feature-requestinstalls a feature request issue template to.github/ISSUE_TEMPLATE/feature_request.yml. Scoped to small, well-scoped improvements — a Small/Medium dropdown gate steers large work to a planning issue.
Templates are standalone copies placed in .github/workflows/. app-ci-dotnet-next provides .NET, SQL Server integration testing, Next.js, and security checks. app-ci-node-monorepo provides Node monorepo, web, Electron, Capacitor, E2E, and security checks. github-release publishes generated GitHub release notes when a v* tag is pushed. bug-report and feature-request install to .github/ISSUE_TEMPLATE/ instead of .github/workflows/. opencode.ci.json is always installed as a mandatory file during catalog install; the --template opencode.ci.json command is an advanced option for installing it in isolation. Edit their top-level env: defaults or JSON properties after copying.
Every consumer of this package is a private repository, and agentics-error-report is the only
job in the fleet that sends anything out of one. That is its whole design constraint, so it is
worth stating what does and does not cross the boundary.
What is sent. A fixed, enumerable set of facts about workflows this package itself ships:
the workflow's file name, the job and step names, a conclusion, a runner label, an occurrence
count, and the id of a matched entry from a catalogue of sixteen known failure shapes
(jq-error, runner-never-assigned, gh-api-403, model-quota, and so on).
What is never sent. Free text of any kind. No log line, no branch name, no issue or pull request title, no file path, no commit, no URL, no issue number. A consumer's own workflows are counted and never inspected at all, because a workflow name can describe a product, a customer or an environment. And no model runs: a model asked to summarise a failure paraphrases whatever the log happened to contain, which is exactly the thing that must not leave.
How that is enforced, rather than intended:
- Only the finding object is rendered into a report, and its eight fields are declared in one
list.
verify-route-matrix.shcompares that list against the fields the code actually sets, and the job re-checks the shape at run time, so a ninth field fails the build and, failing that, stops the run. - A leak scanner reads the finished text and looks for this repository's name, its owner, a
github.comURL, an email address, an absolute path, a token-shaped or commit-shaped blob, an issue reference and a branch ref. It fails closed: a report it flags is not filed, and the run goes red so the field that carried private text gets fixed rather than leaking again the next morning. - The job holds
contents: readandactions: readhere and nothing else, and its upstream token is minted for the upstream repository alone.
Every one of those guards is mutation-tested: each was deliberately broken and the matrix confirmed red. Four early versions of the assertions passed against a broken guard -- they grepped for a symbol name that survived at its other use sites -- and were rewritten to compare sets and count call sites instead.
To turn the report off in one repository, delete the workflow or clear UPSTREAM_NAME in its
env: block, which computes the report into the job summary and files nothing.