[#217] feat: tag-driven workflows — opt-in automation on tagged issues - #474
[#217] feat: tag-driven workflows — opt-in automation on tagged issues#474rucka wants to merge 14 commits into
Conversation
- KB schema (dataset + mirror): seventh section of tech/automation.md — `<tag> ⇒ <workflow>` entries + optional `Precedence:`, tag as opaque routing key (D18), untagged ⇒ never, absent section ⇒ no mapping (opt-in, never an error), eligibility before routing, read-time + routing-time HALTs - workflow-mapping.ts: parses/validates the section; unknown-workflow and multi-tag rules deliberately left to routing (they need board + skill set) - policy-sections.ts: section/HALT/label primitives extracted from automation-policy.ts so both readers of the file share one answer - conformance guard extended over dataset + mirror - Task: T1 — Mapping schema in adoption + validation Refs: #217
- dispatch.ts: agnostic routing core. mapping → eligibility → routing, in that order (BR3); untagged/unmapped/ineligible ⇒ reported skip, never a default workflow (AC2); no `## Workflows` ⇒ "no mapping declared", clean exit (AC4); uninstalled workflow and undecidable multi-tag ⇒ HALT - `pair run --card <id> --card-tags <list>`: the entry point a host trigger calls (ADR-021 tier 2). Both values are host DATA, content-checked at parse; --skill/--prompt alongside --card refused (one answer, not two) - a routed card invokes the MAPPED workflow (source: mapping) and scopes the run with --root <card>; a skip spawns nothing - Task: T2 — Dispatcher: trigger → eligibility check → route → run Refs: #217
- dispatch-audit.ts: start/skip/end appended to the `## Audit Location` file; the start record also printed as `DISPATCH-RECORD:` — the line the trigger's host adapter posts on the card, so the driver holds no tracker credentials (AC3) - card-lock.ts: exclusive per-card lock (atomic mkdir under working_path). A trigger burst is skipped and logged, never queued; release is unconditional, so a crash never parks a card - handler: lock -> audit start -> run -> audit end -> release; a dry run writes nothing and locks nothing - ADR-024 (tag-driven dispatch: adoption mapping, agnostic core, host adapter) + ADL on testing OS atomicity against the OS; architecture.md and way-of-working.md updated - Task: T3 — Audit trail (start/end/skip records) Refs: #217
…erator surfaces - KB `## Workflows` gains the catalog a maintainer needs to write a mapping: which shipped skills are workflows (pair-loop, refine-story, plan-tasks), the precedence pair that keeps a just-refined card from HALTing, and why a second tag never means a second intensity of the same workflow (D18) - github-automation.md: the reference trigger adapter — a GH Actions job on `issues: [labeled]` calling `pair run --card/--card-tags`, labels passed as DATA (never re-fetched), the token scoped to the adapter, and the DISPATCH-RECORD line posted by IT, not by the driver. Says plainly that `concurrency:` is a host economy and the per-card lock is the guard - docs site: `run` gains both flags + a Tag-driven dispatch section (the four fail-towards-not-running properties, the two HALTs, the audit split); adoption-files documents the sixth section; the unattended-delivery tutorial gains Option D — push instead of pull, a tag starts the run - conformance guard extended over all of it, dataset + mirror, including that every workflow the catalog names is a skill this repo actually ships - Task: T4 — KB workflow examples + docs site Refs: #217
…it caught - cli.e2e.test.ts: five triggers at ONE real project (routed, unlabelled, eligible-but-unmapped, multi-tagged, mapped-but-ineligible), driven through the command registry with only the engine spawn injected. Real lock, real audit file: each dispatch hands state to the next through the filesystem, which is the only thing the module suites (lock + audit injected) cannot show. Plus the burst — a second trigger re-entering WHILE the first run holds the card — and the no-mapping board - BUG the e2e found: `--card-tags ""` — what every host adapter renders for an UNLABELLED issue — was refused as a malformed flag, so AC2's own case exited 1 instead of skipping cleanly, and the opt-in boundary was unreachable through the entry point. Failing parser test first, then the fix: an empty value is the observation "no labels"; a hole INSIDE a list still HALTs. ADL + way-of-working record the observation-vs-intent rule; CLI/docs/adapter say it where a reader meets the flag - routing-purity.test.ts: the DoD's grep, mechanised — no tier vocabulary, no classification import, no shape inspection and no regex in the routing core; every `tags.*` call allowlisted to membership and reporting (D18) - llms.txt regenerated: ADR-024 and both ADLs were missing from the index - Task: T5 — End-to-end test on a populated board (tagged/untagged/multi-tag) Refs: #217
- `driveDispatchedCard`/`record` each took 5 positionals and tripped eslint `max-params` (max 4). The four the first took describe ONE routed card, so they become a `DispatchedCard` interface; `record`'s trailing `event` + optional `outcome` become the one options object every call site already reads as a pair - No behaviour change: same lock → start → drive → end → release order, same audit records, same stdout line - Task: T2/T3 — lint gate green on the dispatcher and its audit wiring Refs: #217
Verdict
PR: [#474] · Author: Gianluca Carucci · Reviewer: independent reviewer · Date: 2026-08-30 · Story: US-217 · Type: feature Classification matrix — per dimension
Tier = max(assessed) = risk:red, unchanged. Cost = orange, unchanged. Review value confirms refinement; nothing raised. AssessmentsSecurity — Input validationVerdict: green — every value crossing the boundary ( Details
Security — Output handlingVerdict: green — the only outputs are a Security — AuthenticationVerdict: not applicable — the driver deliberately holds no credentials; the token stays in the adapter (ADR-024 §3). Security — AuthorizationVerdict: yellow — the boundary itself is right (untagged ⇒ never, eligibility before routing, no default workflow, all unit-tested), but the documented deployment of it weakens the concurrency half — see Major 3. Details
Security — Introduced vulnerabilitiesVerdict: green — 0 introduced, 0 pre-existing. CostVerdict: Architecture (Coupling)Verdict: not assessed — Bug fix — Red test before fixVerdict: not applicable — not a bug fix. (The DetailsFindings by severityCritical (must fix before merge)
Major (must fix before merge)
Minor (must fix before merge — same bar as Major, just lower impact)
Questions (informational, never blocking)
Positive feedback
Functionality & requirements (AC coverage)
Testing & quality gates
Adoption compliance
Tech debt
Documentation
Performance & deployment
|
…ecord a crash owes Review round 1 on PR #474 — 9 findings, all in place. - Adapter shell: `if [ -n "$record" ]` (a trailing `&&` under `bash -e` reddened the job on every untagged label edit) and `shell: bash` (GitHub's implicit shell has no pipefail, so a HALT behind `| tee` reported green). Executed, not grepped: `github-automation.test.ts` parses the shipped workflow and runs each step under the flags GitHub uses, against stub `pair`/`gh`. - The concurrency comment claimed the per-card lock guards cross-job runs. On ephemeral runners it cannot — fresh checkout, fresh working area. The group IS the guard there; the lock guards one working area (ADR-024). Corrected in the KB, the tutorial, commands.mdx and architecture.md. - A thrown run wrote no `end`: the trail stopped at `start` and read like a run still in flight. Now `outcome=crashed`, then rethrow. - A held lock reports the holder the acquirer named + how long it has held it; `LOCK_DIRECTORY` is exported instead of re-spelled at the call site. Stale locks are silent-forever otherwise; clearing them is in the KB pre-flight. - Confirmed and written down: only `start` is posted on the card, and an uninstalled workflow HALTs the whole board, not one tag. - Tutorial: seven sections / the seventh, line count dropped. Refs: #217
…w declares Round 2, 5 findings. `--root <card>` was rendered at every routed workflow. Two of the three the KB catalog recommends declare only `$story`, and both pick the highest-priority Draft story themselves when unscoped: `auto-refine ⇒ pair-process-refine-story` on card 304 refined a DIFFERENT card while the audit trail and the on-issue `DISPATCH-RECORD:` both said 304. `SKILL_PARAMETERS` now carries each catalogued workflow's own spelling, the corpus test pins it against the skills' `## Arguments` tables, and a mapping naming a workflow the driver cannot scope HALTs before eligibility and routing. `skillAcceptsFilter` became `filterDeliveryFor`: a card-scoped workflow neither takes `--filter` nor reads `## Eligibility`, so the label is dropped instead of printed as a perimeter nothing applies. `acquireCardLock` leaked a raw ENOENT when the holder released between the EEXIST and the stat — the burst it exists for. It retries the exclusive create once; a path that exists for mkdir and for nothing else is named as a broken working area. A crash whose `end` record cannot be written no longer replaces the engine error with the filesystem one: both in the message, the run error as `cause`. The e2e board fixture is now what the assertions read — routes, trail, records and locks all iterate the rows, so an added card is a checked card. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UQJzGMhRqBRRboxMrRqFPP
…e mappable set Review round 3 — 5 findings, all fixed in place. - `--root` is refused alongside `--card`: `--card 217 --root 300` drove the agent over 300 while the trail, the lock and the on-issue record all named 217. The handler reads the dispatched card first too, so a non-parser caller cannot reach it either. - an unwritable audit destination on the START record is reported as a dispatch that never began, not as a crashed run whose trail stops at `event=start` — both of which were false. - `DISPATCHABLE_WORKFLOWS` is its own declaration, asserted EQUAL to the KB catalog; `pair-next` is scopable by hand and no longer mappable. - the `## Workflows` schema bullet carries the catalog restriction; ADR-024 item 1 drops the "no workflow catalog ever lives in code" clause item 7 contradicts. - `github-automation.md` loses its duplicated `## TODO` stub tail (dataset + regenerated mirror), guarded by execution. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UQJzGMhRqBRRboxMrRqFPP
…e runner must be given the binary - `pair-process-refine-story` out of the catalog and `DISPATCHABLE_WORKFLOWS`: its own SKILL.md makes explicit human alignment a prerequisite, so a dispatch either stalls to the timeout holding the card lock, or answers its own gate. Guard reads catalogued skills SKILL.md. - reference adapter installs the CLI it calls (setup-node + npm i -g); guard runs the shipped step on a PATH with no `pair` and asserts 127. - skip-path audit write wrapped like recordCrash: names the card and that nothing was spawned. - a `## Workflows` line pasted with its fence reports the wrapper, not a grammar failure.
Anchor re-reviews; prove provisioned artifacts.
…iven-workflows # Conflicts: # .pair/llms.txt
…ven by a real install - reference GH Actions adapter installed `@foomakers/pair-cli` then invoked `pair run`; the package publishes ONE bin, `pair-cli` (ADL 2026-08-25) — the dispatch step was 127 on any runner. Every representation moved: yaml `run:`, both yaml comments, the runner-does-not-ship paragraph, the `command not found` example, the decision table, the by-hand line, the provisioning bullet, and automation-policy's entry-point invocation. Both mirrors identical. - new smoke scenario github-dispatch-adapter.sh (in CI_TESTS, OFFLINE_SAFE): real package-manual.sh + create-registry-tgz.sh, `npm install -g` into a clean prefix, then the shipped step run under `env -i` with only that prefix on PATH — nothing stubbed at the boundary (ADL 2026-08-31). RED before the fix with `pair: command not found`. - conformance guard stops naming the binary: `invokedCommand()` reads it from the document, every stub is built from that, and a new case pins it to the sole bin key declared by BOTH producers of the published manifest. Round 4 passed only because it stubbed `pair`.
7b55900 to
adb9627
Compare
PR Information
PR Title: [#217] feat: tag-driven workflows — opt-in automation on tagged issues
Story/Epic: #217 (epic #212 — Supervised automation)
Type: Feature
Priority: High
Assignee: @rucka
Labels:
risk:redSummary
What Changed
A team declares
tag ⇒ workflowin.pair/adoption/tech/automation.md(## Workflows), andpair run --card <id> --card-tags <list>becomes the entry point a trigger calls: it routes that card to the workflow its tag names, and does nothing at all on every card nobody tagged.## Workflowsschema + readerworkflow-mapping.ts· schema owned by the KB guideline, mirrored to the datasetdispatch.ts(decideDispatch): pure function of card + observed tags + policy + installed-skill probeparser.ts(--card,--card-tags),handler.tsdispatch-audit.ts,card-lock.tsgithub-automation.mdWhy This Change
Automation must be opt-in per card, not per run:
## Eligibility(#216) answers which cards an unattended run may pick up; nothing answered what runs on a card once a trigger fires on it. R4.4 · Spec G10 · D18, D21.Story Context
User Story: As a team, we want to declare a tag→workflow mapping in adoption, so that an unattended trigger routes each tagged card to the workflow its tag names — and does nothing on every card nobody tagged.
cli.e2e.test.tsBOARDrows 301/304 — real project dir, real lock, real audit fileBOARDrows 302/303/305 + the negative no-start/no-route assertionsDISPATCH-RECORD:stdout line the host adapter posts; start only, never skip/end## Workflows⇒ clean exit, no default workflowruns nothing at all when the project declares no mappingrouting-purity.test.ts— the executable form of the DoD lineChanges Made
Implementation Details
decideDispatchis told the card's labels; it never reads a tracker, holds no credentials, makes no network call. Adding a code host is a new thin adapter.--rootforpair-loop,--storyforpair-process-plan-tasks) and nothing displaces it:--root,--skilland--promptare all refused alongside--card, and the handler reads the dispatched card beforeconfig.scope.root.DISPATCHABLE_WORKFLOWS—pair-loop,pair-process-plan-tasks), a declaration of its own, asserted set-EQUAL to the guideline's catalog table. Two things do not put a skill in it, and each has its own refusal and its own message: knowing how it spells its scope (pair-next— a dispatch would take the card's lock and post a record for a run that only prints a recommendation), and being able to be scoped at all (pair-process-refine-story— see below).--autonomouswith nobody watching, holding the card's lock, on a card already carrying a publicDISPATCH-RECORD:comment. A workflow whose own steps require an explicit human decision has two outcomes there and both are worse than not running: a stall until the per-iteration timeout, or an agent that answers its own gate.pair-process-refine-storyis the concrete exclusion (its phase 0 is the R3.11 alignment gate); it keeps itsSKILL_PARAMETERSrow, because--skill pair-process-refine-story --root <card>is a legitimate hand-driven run. A conformance guard reads each catalogued workflow's own SKILL.md, so re-adding a row fails a test rather than shipping. ADR-024 item 8.## Workflows⇒ "no mapping declared", clean exit. Uninstalled workflow, uncatalogued workflow, unscopable workflow, or a multi-tag card with noPrecedence:⇒ HALT with an adoption-fix message, before eligibility and routing — whole board, deliberately.mkdirlock underworking_path, taken after every refusal and before anything spawns, released in afinally. A burst's second trigger is skipped, never queued, and the skip prints the holder's path and age.appendAuditLinethrows by design, so each of the three writes has a message an operator can act on: a crash writesoutcome=crashed; astartthat cannot be written is reported as a dispatch that never began, not as a crashed run; a skip — the commonest outcome on a board — names the card and states that nothing was spawned, instead of surfacing a bareEACCES.ubuntu-latesthas heard of the CLI, so the shipped job installs it (setup-node+npm i -g @foomakers/pair-cli) before the dispatch step; the engine and its credentials stay the adopter's step, stated in the pre-flight. The step invokespair-cli run—@foomakers/pair-clipublishes exactly one bin,pair-cli, and ADL 2026-08-25 forbids apairalias, so an earlier revision of this PR that installed the package and typedpair runshipped a step that wascommand not found/127 on every runner. That is now proven by a real install, not by a string match — see the Reviewer Guide.Files Changed
workflow-mapping.ts,dispatch.ts,dispatch-audit.ts,card-lock.ts(+ a test file each),routing-purity.test.ts,scripts/smoke-tests/scenarios/github-dispatch-adapter.sh, ADR-024, two ADLsparser.ts,handler.ts,invocation.ts,perimeter.ts,metadata.ts,automation-policy.ts,resolve-skill.ts,policy-sections.ts,cli.e2e.test.ts; KBautomation-policy.md/github-automation.md/README.md(dataset + mirror);architecture.md,way-of-working.md,llms.txt; docsadoption-files.mdx,commands.mdx,unattended-delivery.mdx,guidelines-catalog.mdx; conformanceautomation-eligibility.test.ts,github-automation.test.ts40 files · +4848 / −173.
Services to Release
apps/pair-cli:pair-cli rungains--card/--card-tagsand the whole dispatch path. Every existing invocation is byte-identical — the dispatch branch is entered only when--cardis passed.Testing
Test Coverage
cli.e2e.test.tsdrives five triggers at ONE real project directory through the command registry, with only the engine spawn injected — real lock, real appended audit file. TheBOARDfixture is the assertion set (spawned prompts, audit lines, negative checks,DISPATCH-RECORD:list, released locks all iterate it), plus the burst case, the no-mapping board and the--root-refused case.invocation.test.tsreads the skills' real## Argumentstables and the KB catalog table — the driver cannot spell an argument a skill does not declare, cannot dispatch a workflow the catalog does not name, and the catalog cannot name a workflow whose SKILL.md declares a human-judgment gate.github-automation.test.tsparses the yaml the guideline ships and RUNS eachrun:step under the flags GitHub documents. The stubbed binary's name is now read from the document (invokedCommand()), never hardcoded — a stub named after an assumption is what let a dead step pass — and one case asserts that name equals the sole bin key declared by BOTH producers of the published manifest (apps/pair-cli/package.jsonand thebin: { … }literal inpackage-manual.sh).scripts/smoke-tests/scenarios/github-dispatch-adapter.sh(inCI_TESTS,OFFLINE_SAFE=true) runs the REALpackage-manual.shandcreate-registry-tgz.sh,npm install --global --prefix <clean temp prefix>of the resulting tarball, then executes the dispatch step extracted from the shipped guideline underenv -iwith only that prefix, node and the system bins on PATH — over BOTH the dataset copy and the root mirror. It assertsbin/pair-cliexists,bin/pairdoes not, the exit is not 127, and the installed CLI's ownno mapping declaredline appears (proof the real binary ran, not merely that some name resolved). Required by ADL 2026-08-31 — review baseline and provisioned-artifact contract.Test Results
Pre-merge tiering: disabledinway-of-working.md, so the full suite is the CI-parity set regardless of therisk:redtag.Branch is merged up to
origin/mainate7d375bf(merge commit5460fc48; one.pair/llms.txtdecision-log conflict, resolved as the union of both sides).Re-review scope (please read before starting). Per ADL 2026-08-31, the baseline is
reviewedHead = e4b42cc8(the last completed review). A re-review verifies the previously open findings plusgit diff e4b42cc8...origin/feature/US-217-tag-driven-workflowsand the directly changed producer/consumer boundary — it is not a rescan of the accumulated PR.Deliberately out of scope, and already carded:
apps/website/content/docs/**still writes barepair <cmd>throughout (commands.mdxalone has ~40 lines). That mechanical rename, and thedocs-stalenessINVOCATION_PREFIXwidening that keeps it fixed, are story #449's per ADL 2026-08-25. Renaming part of it here would leave one page internally inconsistent and pre-empt that story; the guideline fixed in this PR is the one that ships an executable, copy-verbatim artifact.Testing Strategy
DISPATCH-RECORD:on stdout.--card-tags ""— data, not a malformed flag), eligible-but-unmapped, mapped-but-ineligible, two mapped tags with/withoutPrecedence:, trigger burst, lock released mid-probe, dangling lock symlink, a## Workflowssection pasted with its markdown fence.outcome=crashedthen rethrow, lock still released; crash + unwritable audit ⇒ one error carrying both messages, engine error ascause; unwritable audit on the START ⇒ "nothing was spawned", no false crash claim; unwritable audit on a SKIP ⇒ the card and "nothing was spawned", not a bare filesystem error.Quality Assurance
Review Areas
dispatch.ts— the order (mapping → eligibility → routing) and the HALTs.assertWorkflowsScopabledeliberately has two messages: an uncatalogued workflow is a mapping to change, an unscopable one is a catalog entry with no argument row — telling the first it needs an argument row sends a maintainer to add one that exists.invocation.ts—SKILL_PARAMETERSvsDISPATCHABLE_WORKFLOWS: two tables answering two different questions ("how does this skill spell its scope" vs "may a tag route a card here").pair-process-refine-storyis in the first and not the second, on purpose.handler.tsdriveDispatchedCard— lock → audit → drive → release ordering, thestartedflag, andrecordSkip/recordCrashkeeping their messages true about what did and did not happen.parser.tsresolveDispatch— what is refused alongside--card, and why--filteris not.automation-policy.md— the schema bullet, the catalog table and the "needs a human in the room" rule must state ONE rule; three conformance guards hold them together, one of them against the catalogued skills' own SKILL.md.Documentation
.pairmirror):automation-policy.md## Workflowsschema, HALT list, the workflow catalog with its "how the dispatched card reaches it" column and the § "A workflow that needs a human in the room is not mappable";github-automation.mdreference adapter (five steps — checkout, setup-node, install, dispatch, record) + pre-flight.concepts/adoption-files,reference/cli/commands,tutorials/unattended-delivery,reference/guidelines-catalog.node:fsdirectly, ADL — an empty--card-tagsis an observation.Risk Assessment
commands.mdxand ADR-024 — surfacing it only on the card carrying that tag would make the failure depend on which trigger fired firstReviewer Guide
Testing the Changes
Key Test Scenarios
pair run --card 217 --card-tags "auto-dev,risk:green" --dry-runagainst a project declaringauto-dev ⇒ pair-loop— prints the route, spawns nothing.--card-tags ""— skipped cleanly, exit 0, skip logged.--card 217 --root 300; a mapping namingpair-nextorpair-process-review; a mapping namingpair-process-refine-story(installed, scopable, still refused — "the KB catalog does not name as mappable"); a## Workflowssection pasted inside its fence. Each refused with the file and the value to fix.Dependencies & Related Work
## Eligibility), pair-cli execution adapter: run a card on any engine (pi, opencode, claude -p) — portable fan-out, amends ADR-017 #451 (pair runexecution adapter), pair run / pair-loop do not forward the $approval signal to composed skills #464 ($approval)github-automation.md, per ADR-024 — the driver ships the agnostic core and the reference adapter, not a provisioned trigger.