feat(implement-loop): fold the standard-mode preflight into plan-index

A standard iteration paid a dedicated ~37k-real-token dispatch for a
clean-tree check. The check itself stays deterministic in the script:
plan-index now carries clean/head_sha/branch/dirty_paths (run FIRST,
before reading the plan; a dirty tree returns an empty index), and the
script evaluates the folded verdict before any cardinality guard. Mini
mode keeps its dedicated preflight — it has no plan-index to fold into.

refs #29
This commit is contained in:
2026-07-17 15:27:03 +02:00
parent 7a08259db7
commit 8272077e9b
+43 -10
View File
@@ -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(
// ---- 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) {
)
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.