fix(workflows): fail fast on a malformed args carrier instead of misreporting it

Both workflow scripts destructured object-form `args` behind `args || {}`
only. A named-workflow invocation can deliver `args` as a string; a
non-empty string is truthy, so every carrier field silently read
`undefined` and the failure surfaced far from the cause: implement-loop
dispatched its plan-index agent against "Read the plan at undefined" and
stopped only via a mislabelled NEEDS_CONTEXT after burning agent calls;
compiler-driven-edit briefed its edit agent with "EDIT: undefined" and
mapped the agent's refusal onto the straddle-rule BOUNCE to specify —
a mechanics failure dressed as a design finding.

Guards now run before any agent dispatch, mirroring the existing
malformed-input pattern (the task_range guard):

- A string carrier is first JSON-parsed: a JSON-serialized object
  carrier (a stringifying caller or substrate layer that still authored
  the documented object form) is accepted; free text does not parse to
  an object and is rejected.
- A carrier that is not an object (string, number, boolean, array) is a
  distinct infra BLOCKED naming the received type (compiler-driven-edit:
  kind bad-carrier) — never a bounce, never NEEDS_CONTEXT.
- Missing or blank required fields fail fast by name, per mode:
  standard iter_id+plan_path, mini iter_id+red_test_path+cause_summary,
  compiler-driven-edit edit_description+def_site. implement-loop also
  rejects an unknown mode, since the required-field set keys off it.

String-form args are NOT a documented interface — the only invocation
examples in the plugin are object-form (implement/SKILL.md); the issue's
free-text-passthrough fix part was dropped on that ground (triage
verification on the issue). implement/SKILL.md now also documents the
infra early-exit end-report shape (minimal {status, iter_id, reason},
no BLOCKED.md, no blocked_detail) in the Iron Law exceptions, Step 2,
and Step 4's no-file special case.

Verified: both scripts pass node --check (async-wrapped, as the Workflow
substrate runs them) and a 17-case stub-agent harness — malformed
carriers block with zero agent dispatches; valid and JSON-serialized
object carriers reach the first agent unchanged.

closes #24
This commit is contained in:
2026-07-09 11:14:03 +02:00
parent f1b3af5f09
commit 22aafe892a
3 changed files with 113 additions and 5 deletions
+13 -3
View File
@@ -74,7 +74,9 @@ Its executor is **observe-then-bounce**, shipped as
would otherwise read as DONE. A clean working tree after the edit
phase is a no-op, not a success → distinct `BLOCKED` (no-op-edit),
never DONE. Positive evidence (tree differs from HEAD) is required,
checked both at the edit phase and independently at verify.
checked both at the edit phase and independently at verify. (A
malformed or missing carrier is likewise a distinct `BLOCKED`
(bad-carrier) before any agent runs — never a bounce.)
4. **Bounce** otherwise — the change was not purely behaviour-preserving
after all, so route up per the straddle rule: a site that forces a
design decision → `specify`; a site that turns out to **encode new
@@ -102,7 +104,7 @@ WITHIN THIS LOOP, MAIN HEAD IS SACROSANCT — NO RESET, NO REVERT; THE LOOP ONLY
PER-TASK PHASES ARE SEPARATE AGENT CALLS IN THE WORKFLOW SCRIPT — implementer, then spec-compliance, then quality. SPEC COMPLIANCE IS GATED BEFORE QUALITY.
TASKS RUN SEQUENTIALLY; A BLOCKED TASK STOPS THE LOOP — NO SKIP-AHEAD (TASK ORDERING DEPENDENCIES ARE UNKNOWN).
NEVER PUSH PAST `BLOCKED` BY HAND.
ON `PARTIAL` OR `BLOCKED`, THE SCRIPT WRITES `BLOCKED.md` AT THE REPO ROOT — UNCOMMITTED BY CONVENTION. ON `DONE`, NO SEPARATE FILE. (ONE EXCEPTION: A NO-OP ITERATION IS `BLOCKED` WITH NO `BLOCKED.md` — ITS TREE IS ALREADY CLEAN, SO THE STATUS + REASON RIDE THE END-REPORT AND THERE IS NOTHING TO CLEAN UP.)
ON `PARTIAL` OR `BLOCKED`, THE SCRIPT WRITES `BLOCKED.md` AT THE REPO ROOT — UNCOMMITTED BY CONVENTION. ON `DONE`, NO SEPARATE FILE. (EXCEPTIONS THAT WRITE NO `BLOCKED.md`: A NO-OP ITERATION — ITS TREE IS ALREADY CLEAN — AND ANY PRE-LOOP EARLY EXIT (MALFORMED CARRIER, DIRTY-TREE PREFLIGHT, PLAN-EXTRACT GUARD): NOTHING RAN AND NOTHING WAS WRITTEN, SO THE STATUS + `infra:`-PREFIXED REASON RIDE THE END-REPORT AND THERE IS NOTHING TO CLEAN UP OR EXPLAIN.)
`DONE` REQUIRES POSITIVE EVIDENCE THAT AN EDIT LANDED — A NO-OP ITERATION (`git status --porcelain` EMPTY, untracked files INCLUDED) IS NEVER `DONE`, IT IS `BLOCKED` (the issue #11 vacuous-green shape, guarded on the main path). THE VERDICT IS THE GIT TREE COUNT, NEVER A SELF-REPORT.
IN MINI MODE THE RED->GREEN IS OBSERVED, NOT ASSERTED: AN INDEPENDENT VERIFY RE-RUNS THE HANDED-OFF RED TEST AND THE SUITE; A STILL-RED TEST OR A REGRESSION IS `BLOCKED` (route back to debug, RED-first).
THE COMPILER-DRIVEN ARM COMMITS ONLY ON CLEAN BUILD + SUITE GREEN UNCHANGED; ELSE IT BOUNCES (specify for a design hole, tdd for discovered new behaviour, debug for a regression).
@@ -197,7 +199,10 @@ per-task summaries, concerns, E2E fixtures, the stats path, and — on
PARTIAL/BLOCKED — the blocked detail. Per-task chatter stayed inside
the workflow's agent contexts and never reached the orchestrator. Read
the end-report; it is the per-task summary you build the commit body
from.
from. An **infra early-exit** (malformed or missing carrier, dirty-tree
preflight, a plan-extract guard) returns a minimal report instead:
`status: BLOCKED` plus an `infra:`-prefixed `reason`, no `blocked_detail`
and none of the per-task fields — nothing ran.
### Step 3 — Orchestrator inspect + commit step (on DONE)
@@ -243,6 +248,11 @@ tree:
already clean. The diagnostic is in the end-report's `blocked_detail`, not
a file. Nothing to clean up or stash; re-plan or re-run from the same
clean tree.
The same no-file shape occurs for an **infra early-exit** (`reason`
prefixed `infra:` — malformed/missing carrier, dirty preflight tree, a
plan-extract guard): no `BLOCKED.md`, no `blocked_detail`, and the tree
exactly as the loop found it. Fix the invocation (or clean the tree) and
re-invoke.
1. Read `BLOCKED.md``## What did not` names the failure mode and the
blocked task's reason. Read `git diff` to see what was attempted.
2. Decide: