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
+57 -1
View File
@@ -61,6 +61,8 @@
// red_test_path: (mini) absolute path to the RED test from debug / tdd
// cause_summary: (mini) 12 sentences (debugger cause, or tdd spec_summary)
// constraint: (mini) e.g. 'minimal fix, no surrounding cleanup'
// A non-object carrier, an unknown mode, or a missing required field fails
// fast as an infra BLOCKED before any agent is dispatched (issue #24).
export const meta = {
name: 'implement-loop',
@@ -75,6 +77,33 @@ export const meta = {
],
}
// Carrier guard (issue #24): a named-workflow invocation can deliver `args`
// as a STRING. `args || {}` alone does not catch that — a non-empty string is
// truthy, so every field would destructure to undefined and the failure
// would surface far downstream (a plan-index dispatch against "Read the plan
// at undefined"), mislabelled as a plan-content problem after burning agent
// calls. Normalize, then reject a malformed carrier HERE, before any agent
// is dispatched — a distinct infra verdict, never a substantive one.
let carrier = args
if (typeof carrier === 'string') {
// Tolerate a JSON-SERIALIZED object carrier (a stringifying caller or
// substrate layer still authored the documented object form); free text
// does not parse to an object and falls through to the rejection below.
try {
carrier = JSON.parse(carrier)
} catch {}
}
if ((carrier !== undefined && carrier !== null && typeof carrier !== 'object') || Array.isArray(carrier)) {
return {
status: 'BLOCKED',
iter_id: null,
reason:
`infra: args arrived as ${Array.isArray(carrier) ? 'an array' : `a ${typeof carrier}`}, not the documented ` +
'object carrier — nothing was dispatched; re-invoke with args: { mode, iter_id, ' +
'plan_path | red_test_path + cause_summary, ... } (invocation examples in implement/SKILL.md)',
}
}
const {
mode = 'standard',
iter_id,
@@ -83,7 +112,34 @@ const {
red_test_path,
cause_summary,
constraint = 'minimal change, no surrounding cleanup',
} = args || {}
} = carrier || {}
// Blank-safe echo for the guard verdicts below: never mirror a blank/non-string
// iter_id into the end-report while also naming it missing.
const iterIdEcho = typeof iter_id === 'string' && iter_id.trim() ? iter_id : null
if (mode !== 'standard' && mode !== 'mini') {
return {
status: 'BLOCKED',
iter_id: iterIdEcho,
reason: `infra: unknown mode '${mode}' — expected 'standard' or 'mini'; nothing was dispatched`,
}
}
// The required carrier fields per mode (the header comment above is the
// contract). Missing or blank fields fail fast by name — the loop must never
// glob for a plan or brief an implementer with the literal string "undefined".
const requiredByMode = mode === 'mini' ? { iter_id, red_test_path, cause_summary } : { iter_id, plan_path }
const missingFields = Object.entries(requiredByMode)
.filter(([, v]) => typeof v !== 'string' || !v.trim())
.map(([k]) => k)
if (missingFields.length) {
return {
status: 'BLOCKED',
iter_id: iterIdEcho,
reason:
`infra: missing required carrier field(s) for mode '${mode}': ${missingFields.join(', ')}` +
'nothing was dispatched (carrier contract in the header comment and implement/SKILL.md)',
}
}
const STANDING =
"Standing reading first (the plugin convention): read the project's CLAUDE.md " +