Workflow gates: iw-cli owns where they fire, kanon owns what they check #421

Merged
mph merged 9 commits from feat/workflow-gates into main 2026-08-15 09:01:07 +00:00
Owner

What this is

Release 0.9.0. It adds a hook type that lets a workflow plugin decide whether an
iw-cli command may proceed, fires it at two points, gives it a cheap self-check verb,
and fixes a stale-attention bug in phase-start that predates all of it.

The stale-attention fix

phase-start moved status to implementing and left needs_attention and activity
exactly as the previous phase left them. A card that ended a phase with the flag raised
and activity: waiting kept both across the start of the next phase.

That matters because of what reads those fields. PendingAct resolves a card to a lane
by what the writers published, and it treats activity: "working" as "an agent holds the
ball" — that case returns None, i.e. nothing is waiting on the human. With the two
fields stale, the card still resolved to ReviewInWorktree, so the dashboard kept asking
a human to come and resume work an agent had already resumed.

phase-start now states who holds the ball as part of beginning the phase:
activity: working, needs_attention: false.

The gate mechanism

The split is deliberate: iw-cli owns where a gate fires, the plugin owns what it checks.

WorkflowGate is a trait with one method — given a GateContext, return a
List[CheckResult], the same result type the doctor already speaks. iw-cli knows no
workflow rule. It does not know what an analysis is, what a task file is, or what any of
ag/wf/dx require. It only knows that at this point in this command, something may want a say.

The reason for the split is where the definitions live. Workflow rules live in kanon, not
here. If iw-cli owned the checks, every new invariant kanon wants to enforce would need an
iw-cli release, a version bump and a reinstall — for a rule that is entirely a kanon
concept. With the gate returning results rather than answering a fixed question, kanon adds
an invariant by editing a hook file in its own repo, and nothing here moves.

requireArtifact and requireFile are conveniences for the two invariants we expect most
often. They are explicitly not a ceiling: the trait hands back a function, so a gate can
express a check the helpers do not cover without touching iw-cli.

Two design points worth naming:

  • The gate is pure. GateContext carries a fileExists: os.Path => Boolean capability
    rather than a filesystem, the way CiPresence takes listInHead. A gate stays a function
    of its arguments, and a test supplies a fake.
  • A gate that throws warns, it does not block. A gate decorates the command rather than
    being the thing the user asked to run, so third-party hook code that fails to answer costs
    its own answer and nothing more. Otherwise one stale plugin hook stops every command it
    attaches to.

Evaluation reduces the results: one Error anywhere blocks, warnings report without
blocking, Skip is ignored, and Success stays silent unless --verbose asks.

Where the gates fire

  • phase-pr — asked before the push, deliberately. A blocked gate must leave no
    branch on the forge and burn no CI.
  • phase-start — asked before the branch is cut. Note the card phase-start is about
    to write says nothing yet, so a gate here can only speak for invariants phase-start does
    not itself control — never for the fields the line below it writes.

iw gate check <point>

A gate you can only trigger by attempting the real action answers at the worst possible
moment: you find out you owe a task file at the point where you wanted a PR. iw gate check phase-pr asks the same gates that phase-pr runs, while the work can still be done cheaply.

For the two answers to agree they must come from the same gates, which is what the next
section is about.

The iw-run change

Hook files attach by exact command name — *.hook-phase-pr.scala loads for phase-pr.
Left alone, iw gate check phase-pr would have run as the command gate, found none of
phase-pr's hook files, and cheerfully reported all clear on gates it never loaded. A
self-check that always says yes is worse than no self-check.

hook_command_name resolves the name discovery uses: the gate point for gate check <point>, the command's own name otherwise. It is applied at all four discovery sites
(shared, project and plugin hooks of a shared command; project hooks of a plugin command).

The same reasoning drove a fix on the verb side: iw gate check --issue-id TEST-100 used
to exit 0 reporting all clear, because the positional filter dropped flags but kept their
values, so TEST-100 took the place of the point. A run naming no point now prints usage
and fails.

Version

VERSION goes to 0.9.0, and kanon's gate hooks declare // REQUIRES: iw-cli >= 0.9.0 as
their floor — 0.9.0 is the release carrying WorkflowGate and the gate points. An
installation below the floor skips the hook with a warning rather than failing the command.

Tests

./mill core.test — 2317 tests, green. ./mill dashboard.test — green (it shares
ReviewStateJson, which moved into core so a gate could be handed the card its worktree
published). bats test/gate-hooks.bats — 10 tests, green.

  • WorkflowGatesTest — the pure evaluator: blocking, warnings, skips, gate order, a
    throwing gate, and both helpers.
  • WorkflowGateOutputTest — rendering, including verbose.
  • GateCommandHarnessTest — the verb: verdicts, the reported hints, the context it builds,
    and the argument-parsing failures.
  • PhaseStartHarnessTest / PhasePrHarnessTest — that a blocked gate cuts no branch and
    pushes nothing, that a warning lets the command through, and that with no gates installed
    the output is byte-for-byte what it was.
  • test/gate-hooks.bats — drives execute_command with a stub scala-cli that reports the
    command line the launcher built, over all four discovery sites. Reverting any one of them
    fails these tests.

What is NOT covered — please read

Be aware of the honest gaps:

  1. LiveHookOps.workflowGates is untested. It is a one-line
    HookDiscovery.collectValues[WorkflowGate]. HookDiscoveryTest covers
    collectValuesFrom for Check and SessionAction, never for WorkflowGate. Reflection
    over a real WorkflowGate value in a real hook file has not been exercised by a test.

  2. The bats suite proves file selection, not execution. It asserts which hook files
    the launcher puts on the command line. It stubs scala-cli, so it never shows that a
    gate object inside those files is instantiated and asked.

  3. Nothing was verified end to end. No kanon hook shipping a WorkflowGate exists yet,
    so the complete path — a real *.hook-phase-pr.scala → discovery → a real phase-pr
    blocked — has never once been executed. Every gate-point test injects gates through
    FakeHookOps. Points 1 and 2 above are exactly the seam that path crosses, so if this
    is broken in practice, that is where it will be. It wants a smoke test against kanon's
    hooks before we lean on it.

  4. The stale-attention fix was verified at the write, not at the read. Tests assert what
    phase-start writes into review-state.json. I did not observe a running dashboard
    moving the card out of the waiting lane. The PendingAct behaviour it relies on is
    pre-existing and separately tested, so the inference is sound, but it is an inference.

Also note the last commit is mine: the new tests tripped DisableSyntax (var, throw) on
the pre-push hook. I did not bypass the hook; I rewrote the captures to the conventions the
surrounding suites already use (AtomicReference, sys.error), which folded six identical
blocks into one helper.

## What this is Release **0.9.0**. It adds a hook type that lets a workflow plugin decide whether an iw-cli command may proceed, fires it at two points, gives it a cheap self-check verb, and fixes a stale-attention bug in `phase-start` that predates all of it. ## The stale-attention fix `phase-start` moved `status` to `implementing` and left `needs_attention` and `activity` exactly as the previous phase left them. A card that ended a phase with the flag raised and `activity: waiting` kept both across the start of the next phase. That matters because of what reads those fields. `PendingAct` resolves a card to a lane by what the writers published, and it treats `activity: "working"` as "an agent holds the ball" — that case returns `None`, i.e. nothing is waiting on the human. With the two fields stale, the card still resolved to `ReviewInWorktree`, so the dashboard kept asking a human to come and resume work an agent had already resumed. `phase-start` now states who holds the ball as part of beginning the phase: `activity: working`, `needs_attention: false`. ## The gate mechanism The split is deliberate: **iw-cli owns where a gate fires, the plugin owns what it checks.** `WorkflowGate` is a trait with one method — given a `GateContext`, return a `List[CheckResult]`, the same result type the doctor already speaks. iw-cli knows no workflow rule. It does not know what an analysis is, what a task file is, or what any of ag/wf/dx require. It only knows that at this point in this command, something may want a say. The reason for the split is where the definitions live. Workflow rules live in kanon, not here. If iw-cli owned the checks, every new invariant kanon wants to enforce would need an iw-cli release, a version bump and a reinstall — for a rule that is entirely a kanon concept. With the gate returning results rather than answering a fixed question, kanon adds an invariant by editing a hook file in its own repo, and nothing here moves. `requireArtifact` and `requireFile` are conveniences for the two invariants we expect most often. They are explicitly not a ceiling: the trait hands back a function, so a gate can express a check the helpers do not cover without touching iw-cli. Two design points worth naming: - **The gate is pure.** `GateContext` carries a `fileExists: os.Path => Boolean` capability rather than a filesystem, the way `CiPresence` takes `listInHead`. A gate stays a function of its arguments, and a test supplies a fake. - **A gate that throws warns, it does not block.** A gate decorates the command rather than being the thing the user asked to run, so third-party hook code that fails to answer costs its own answer and nothing more. Otherwise one stale plugin hook stops every command it attaches to. Evaluation reduces the results: one `Error` anywhere blocks, warnings report without blocking, `Skip` is ignored, and `Success` stays silent unless `--verbose` asks. ### Where the gates fire - **`phase-pr`** — asked *before the push*, deliberately. A blocked gate must leave no branch on the forge and burn no CI. - **`phase-start`** — asked before the branch is cut. Note the card `phase-start` is about to write says nothing yet, so a gate here can only speak for invariants `phase-start` does not itself control — never for the fields the line below it writes. ## `iw gate check <point>` A gate you can only trigger by attempting the real action answers at the worst possible moment: you find out you owe a task file at the point where you wanted a PR. `iw gate check phase-pr` asks the same gates that `phase-pr` runs, while the work can still be done cheaply. For the two answers to agree they must come from the same gates, which is what the next section is about. ## The `iw-run` change Hook files attach by exact command name — `*.hook-phase-pr.scala` loads for `phase-pr`. Left alone, `iw gate check phase-pr` would have run as the command `gate`, found none of `phase-pr`'s hook files, and cheerfully reported all clear on gates it never loaded. A self-check that always says yes is worse than no self-check. `hook_command_name` resolves the name discovery uses: the gate point for `gate check <point>`, the command's own name otherwise. It is applied at all four discovery sites (shared, project and plugin hooks of a shared command; project hooks of a plugin command). The same reasoning drove a fix on the verb side: `iw gate check --issue-id TEST-100` used to exit 0 reporting all clear, because the positional filter dropped flags but kept their values, so `TEST-100` took the place of the point. A run naming no point now prints usage and fails. ## Version `VERSION` goes to 0.9.0, and kanon's gate hooks declare `// REQUIRES: iw-cli >= 0.9.0` as their floor — 0.9.0 is the release carrying `WorkflowGate` and the gate points. An installation below the floor skips the hook with a warning rather than failing the command. ## Tests `./mill core.test` — 2317 tests, green. `./mill dashboard.test` — green (it shares `ReviewStateJson`, which moved into core so a gate could be handed the card its worktree published). `bats test/gate-hooks.bats` — 10 tests, green. - `WorkflowGatesTest` — the pure evaluator: blocking, warnings, skips, gate order, a throwing gate, and both helpers. - `WorkflowGateOutputTest` — rendering, including verbose. - `GateCommandHarnessTest` — the verb: verdicts, the reported hints, the context it builds, and the argument-parsing failures. - `PhaseStartHarnessTest` / `PhasePrHarnessTest` — that a blocked gate cuts no branch and pushes nothing, that a warning lets the command through, and that with no gates installed the output is byte-for-byte what it was. - `test/gate-hooks.bats` — drives `execute_command` with a stub `scala-cli` that reports the command line the launcher built, over all four discovery sites. Reverting any one of them fails these tests. ## What is NOT covered — please read Be aware of the honest gaps: 1. **`LiveHookOps.workflowGates` is untested.** It is a one-line `HookDiscovery.collectValues[WorkflowGate]`. `HookDiscoveryTest` covers `collectValuesFrom` for `Check` and `SessionAction`, never for `WorkflowGate`. Reflection over a real `WorkflowGate` value in a real hook file has not been exercised by a test. 2. **The bats suite proves file selection, not execution.** It asserts *which* hook files the launcher puts on the command line. It stubs `scala-cli`, so it never shows that a gate object inside those files is instantiated and asked. 3. **Nothing was verified end to end.** No kanon hook shipping a `WorkflowGate` exists yet, so the complete path — a real `*.hook-phase-pr.scala` → discovery → a real `phase-pr` blocked — has never once been executed. Every gate-point test injects gates through `FakeHookOps`. Points 1 and 2 above are exactly the seam that path crosses, so if this is broken in practice, that is where it will be. It wants a smoke test against kanon's hooks before we lean on it. 4. **The stale-attention fix was verified at the write, not at the read.** Tests assert what `phase-start` writes into `review-state.json`. I did not observe a running dashboard moving the card out of the waiting lane. The `PendingAct` behaviour it relies on is pre-existing and separately tested, so the inference is sound, but it is an inference. Also note the last commit is mine: the new tests tripped `DisableSyntax` (`var`, `throw`) on the pre-push hook. I did not bypass the hook; I rewrote the captures to the conventions the surrounding suites already use (`AtomicReference`, `sys.error`), which folded six identical blocks into one helper.
phase-start moved status to implementing but left needs_attention and
activity untouched. A card that ended the previous phase with a raised
flag and activity waiting kept both, so the dashboard resolved it to
ReviewInWorktree and asked the human to resume work an agent had
already resumed.

The start of a phase now states who holds the ball: activity working,
needs_attention false.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Workflow rules live in the plugin repository, but the commands that must
enforce them live here. WorkflowGate lets a plugin answer "may this
proceed" without iw-cli knowing any workflow rule: it hands over a
GateContext and takes back the CheckResult list the doctor already
speaks.

The gate reads a fileExists capability rather than the filesystem, the
way CiPresence takes listInHead, so a gate stays a function of its
arguments and a test supplies a fake.

Evaluation and rendering are pure and separately tested: one Error
blocks, warnings report without blocking, skips are ignored, and passed
checks stay silent unless the caller asks for them.

requireArtifact and requireFile are conveniences for the two common
invariants, never a ceiling — the trait returns a function, so a plugin
can express a check they do not cover without an iw-cli release.

No command fires a gate yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A gate you can only trigger by attempting the real action answers at the
worst moment. `iw gate check <point>` asks the same gates the point runs,
while the work can still be done cheaply.

The launcher selects hook files by exact command name, so `gate` would
have found none of the point's hooks and answered differently from the
real gate. hook_command_name resolves the name hook discovery uses: the
gate point for `gate check <point>`, the command itself otherwise.

Reading a published card moves into core as ReviewStateJson, which the
dashboard now shares, because a gate is given the card its worktree
published and core had no way to read one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
phase-pr asks before the push, deliberately: a blocked gate leaves no
branch on the forge and burns no CI.

phase-start asks before the branch is cut. The card it is about to write
says nothing yet, so a gate there speaks for invariants phase-start does
not control, never for the fields the line below writes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The gate hooks a project ships declare "// REQUIRES: iw-cli >= 0.9.0",
which is the release that carries WorkflowGate and the gate points.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`iw gate check --issue-id TEST-100` exited 0 reporting all clear. The
positional filter dropped the flags but kept their values, so the value
took the place of the point and the verb ran the check against gates the
launcher had never loaded — hook discovery reads the word after `check`
and falls back to the verb's own name when it is a flag.

The verb now reads the point where the launcher reads it, and a run that
names none prints the usage line and fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A gate ran inside a bare flatMap, so an exception from third-party hook
code aborted the command it guards with a stack trace. requireArtifact
supplies a ready trigger: a malformed label pattern throws while
compiling the regular expression.

A gate decorates the command rather than being the thing the user asked
to run, so one that fails to answer now costs its own answer and nothing
more: it is reported as a warning naming the gate and what it threw, and
the remaining gates still decide.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The hook-name wiring was asserted only through the pure hook_command_name
and a find the test itself built, so reverting any discovery site in
execute_command back to the command's own name still passed.

The tests now run execute_command with a stub scala-cli that reports the
command line the launcher built, over all four discovery sites: shared,
project and plugin hooks of a shared command, and the project hooks of a
plugin command. Reverting any of them fails these tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
style(gate-tests): say the gate captures the way the rest of the suite says it
All checks were successful
CI / format (pull_request) Successful in 7s
CI / contract (pull_request) Has been skipped
CI / lint (pull_request) Successful in 7s
CI / compile (pull_request) Successful in 1m27s
CI / dashboard-build (pull_request) Successful in 1m1s
CI / test (pull_request) Successful in 9m19s
915e31d596
The new gate tests carried a `var` capture and a bare `throw`, which the
DisableSyntax rules reject and the surrounding suites never use:
DoctorHarnessTest already captures a hook's context in an AtomicReference
and RmHarnessTest already raises from a fake with sys.error.

The six identical capture blocks in the gate verb's tests become one
recordGate helper, which also answers whether a gate was asked at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mph merged commit 114554a6db into main 2026-08-15 09:01:07 +00:00
mph deleted branch feat/workflow-gates 2026-08-15 09:01:15 +00:00
Sign in to join this conversation.
No description provided.