diff --git a/implement/workflows/implement-loop.js b/implement/workflows/implement-loop.js index 9618fab..7d1d1aa 100644 --- a/implement/workflows/implement-loop.js +++ b/implement/workflows/implement-loop.js @@ -202,8 +202,17 @@ const PREFLIGHT_SCHEMA = { const TASK_INDEX_SCHEMA = { type: 'object', additionalProperties: false, - required: ['total_tasks', 'task_ids'], + required: ['clean', 'head_sha', 'branch', 'total_tasks', 'task_ids'], properties: { + // Folded clean-tree preflight (standard mode; issue #29): the same checks + // the dedicated preflight dispatch runs in mini mode, carried by the + // plan-index agent so a standard iteration saves one whole primed context. + // The script evaluates `clean` BEFORE any cardinality guard — a dirty tree + // never reaches the per-task loop. + clean: { type: 'boolean', description: '`git status --porcelain` produced no output' }, + head_sha: { type: 'string' }, + branch: { type: 'string' }, + dirty_paths: { type: 'array', items: { type: 'string' } }, total_tasks: { type: 'integer', description: @@ -404,25 +413,32 @@ const FINALIZE_SCHEMA = { }, } -// ---- Phase 0 — clean-tree preflight ------------------------------------ -phase('Preflight') -const pre = await agent( - `${STANDING}\n\nYou are the clean-tree preflight for an implement iteration. ` + - 'Run `git status --porcelain`, `git rev-parse HEAD`, and `git rev-parse --abbrev-ref HEAD`. ' + - 'Report whether the tree is clean (no porcelain output), the HEAD sha, the branch, and any dirty paths. ' + - 'Do NOT modify anything, do NOT commit. The HEAD sha is an informational anchor only — never a reset target.', - { model: 'sonnet', effort: 'medium', label: 'preflight', phase: 'Preflight', schema: PREFLIGHT_SCHEMA }, -) -if (!pre) return { status: 'BLOCKED', iter_id, reason: 'infra: preflight agent did not return' } -if (!pre.clean) { - return { - status: 'BLOCKED', - iter_id, - reason: 'infra: working tree dirty — the orchestrator must clean it before re-invoking', - dirty_paths: pre.dirty_paths || [], +// ---- Phase 0 — clean-tree preflight (mini mode only) -------------------- +// Standard mode folds these checks into the plan-index dispatch (issue #29): +// the check itself stays deterministic in the script (schema fields evaluated +// before anything else), only the dedicated carrier dispatch is saved. Mini +// mode has no plan-index to fold into, so it keeps the dedicated preflight. +let startSha = null +if (mode === 'mini') { + phase('Preflight') + const pre = await agent( + `${STANDING}\n\nYou are the clean-tree preflight for an implement iteration. ` + + 'Run `git status --porcelain`, `git rev-parse HEAD`, and `git rev-parse --abbrev-ref HEAD`. ' + + 'Report whether the tree is clean (no porcelain output), the HEAD sha, the branch, and any dirty paths. ' + + 'Do NOT modify anything, do NOT commit. The HEAD sha is an informational anchor only — never a reset target.', + { model: 'sonnet', effort: 'medium', label: 'preflight', phase: 'Preflight', schema: PREFLIGHT_SCHEMA }, + ) + if (!pre) return { status: 'BLOCKED', iter_id, reason: 'infra: preflight agent did not return' } + if (!pre.clean) { + return { + status: 'BLOCKED', + iter_id, + reason: 'infra: working tree dirty — the orchestrator must clean it before re-invoking', + dirty_paths: pre.dirty_paths || [], + } } + startSha = pre.head_sha } -const startSha = pre.head_sha // ---- Phase 1 — assemble the task list ---------------------------------- let tasks @@ -471,7 +487,11 @@ if (mode === 'mini') { // narrower than the closed across-tasks vector and rests on the planner's // bite-sized-task invariant; it is the one truncation slice left open here. const index = await agent( - `${STANDING}\n\nRead the plan at ${plan_path}. List the task ` + + `${STANDING}\n\nFIRST the folded clean-tree preflight: run \`git status --porcelain\`, \`git rev-parse HEAD\`, ` + + 'and `git rev-parse --abbrev-ref HEAD`; report `clean` (no porcelain output), `head_sha`, `branch`, and any ' + + '`dirty_paths`. The HEAD sha is an informational anchor only — never a reset target. If the tree is DIRTY, ' + + 'stop there: return task_ids: [] and total_tasks: 0 without reading the plan.\n\n' + + `Otherwise read the plan at ${plan_path}. List the task ` + (task_range ? `ids in the range ${task_range[0]}..${task_range[1]} (inclusive, 1-based)` : 'ids of EVERY task') + @@ -480,7 +500,20 @@ if (mode === 'mini') { '`total_tasks`: the count of ALL tasks the entire plan defines, ignoring any range filter. Do not edit anything.', { model: 'sonnet', effort: 'medium', label: 'plan-index', phase: 'Per-task loop', schema: TASK_INDEX_SCHEMA }, ) - if (!index || !index.task_ids || index.task_ids.length === 0) { + if (!index) { + return { status: 'BLOCKED', iter_id, reason: 'infra: plan-index agent did not return' } + } + // Folded preflight verdict — evaluated before every cardinality guard. + if (index.clean === false) { + return { + status: 'BLOCKED', + iter_id, + reason: 'infra: working tree dirty — the orchestrator must clean it before re-invoking', + dirty_paths: index.dirty_paths || [], + } + } + startSha = index.head_sha + if (!index.task_ids || index.task_ids.length === 0) { return { status: 'NEEDS_CONTEXT', iter_id, reason: `no tasks found in ${plan_path}` } } // Dedup the enumerated ids (first title wins); they define the enumerate set.