Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 127 additions & 4 deletions extensions/iterator.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,13 @@ import {
bundleExists,
featuresDirEntries,
composeAmbientContext,
completeFeatureWaveAbort,
extractPathsFromBash,
footerText,
mergePayload,
nextAutoAction,
nextFeatureWaveAction,
pauseFeatureWave,
projectRoot,
roleModelSpec,
runJson,
Expand Down Expand Up @@ -436,6 +439,10 @@ export default function iteratorExtension(pi) {
if (input.action === "open-settings") {
await openSettings();
} else if (input.action === "pause") {
// A wave's active item was removed from its fixed queue when dispatched.
// Put it back before aborting so Continue retries it instead of treating
// the interrupted turn as a failed result and moving on.
if (featureWave) featureWave = pauseFeatureWave(featureWave);
await writeState({ paused: true });
// Stop the in-flight stream too — state is saved after each step,
// so Continue simply picks the flow back up.
Expand All @@ -453,8 +460,10 @@ export default function iteratorExtension(pi) {
await writeState({ paused: false, escalation: null });
if (lastCtx?.hasUI) lastCtx.ui.notify("iterator: continuing", "info");
await pushStatus(cwd);
resumeAuto(cwd); // no-op unless auto mode has work to pick up
if (featureWave) void advanceFeatureWave(cwd);
else resumeAuto(cwd); // no-op unless auto mode has work to pick up
} else if (input.action === "abort") {
featureWave = null;
// One-click recovery to a clean state: kill the in-flight stream,
// reset the runtime flow state, and re-render the hub — works even
// when a dispatch stalled, because /control never needs a model turn.
Expand Down Expand Up @@ -493,6 +502,7 @@ export default function iteratorExtension(pi) {

const AUTO_MAX_STEPS = 60; // per-session circuit breaker
let autoSteps = 0;
let featureWave = null; // fixed ready-feature snapshot; review stays manual
let preAutoModel = null; // the user's model before the first role switch

const notifyUi = (msg, level = "info") => {
Expand Down Expand Up @@ -559,6 +569,100 @@ export default function iteratorExtension(pi) {
}
};

/** Advance the fixed dependency-ready implementation wave by one agent turn. */
const advanceFeatureWave = async (cwd) => {
if (!featureWave || !bundleExists(cwd)) return;
try {
const { hub, settings, state } = await gatherSession(cwd);
// Pause is persisted before the active agent is aborted. Its agent_end
// callback may still arrive, but must not consume or dispatch the queue.
if (state?.paused) return;
const previousResults = featureWave.results.length;
const decision = nextFeatureWaveAction(featureWave, hub.features || []);
if (!decision) return;
featureWave = decision.wave;
if (decision.waiting) return;
for (const result of featureWave.results.slice(previousResults)) {
notifyUi(
`wave: ${result.feature} ${result.status}`,
result.status === "implemented" ? "info" : "warning",
);
}
if (decision.done) {
const results = featureWave.results;
const implemented = results.filter(
(result) => result.status === "implemented",
).length;
const failed = results.length - implemented;
featureWave = null;
await writeState({
mode: "manual",
paused: false,
phase: "idle",
active_feature: null,
});
await restoreModel();
session?.clearWorking?.();
notifyUi(
`ready wave finished: ${implemented} implemented${failed ? `, ${failed} failed or skipped` : ""} — review remains explicit`,
failed ? "warning" : "info",
);
await refreshHub(cwd);
return;
}

const action = decision.action;
await writeState({
mode: "manual",
paused: false,
phase: "implementing",
active_feature: action.feature,
});
await applyRole(action.role, settings);
attribution = { step: action.step, feature: action.feature };
session?.showWorking({
text: `Wave: implementing ${action.feature} (${featureWave.results.length}/${featureWave.results.length + featureWave.queue.length + 1} finished)…`,
step: action.step,
feature: action.feature,
});
await pushStatus(cwd);
await dispatch(action.cmd);
} catch (error) {
featureWave = null;
await writeState({
mode: "manual",
paused: false,
phase: "idle",
active_feature: null,
});
await restoreModel();
session?.clearWorking?.();
notifyUi(`ready wave stopped: ${error.message}`, "error");
await refreshHub(cwd);
}
};

/** Snapshot the server-derived ready set, then implement only that wave. */
const startFeatureWave = async (cwd) => {
try {
invalidateSession();
const { implement } = await gatherSession(cwd);
const queue = Array.isArray(implement?.ready) ? [...implement.ready] : [];
if (!queue.length) {
notifyUi("no dependency-ready features to implement", "warning");
await refreshHub(cwd);
return;
}
featureWave = { queue, active: null, results: [] };
session?.showWorking?.(`Ready wave: ${queue.length} feature(s)…`);
await advanceFeatureWave(cwd);
} catch (error) {
featureWave = null;
session?.clearWorking?.();
notifyUi(`ready wave did not start: ${error.message}`, "error");
}
};

/** One driver step: decide → bookkeep → dispatch (or finish/escalate). */
const kickAuto = async (cwd) => {
if (!bundleExists(cwd)) return;
Expand Down Expand Up @@ -852,6 +956,13 @@ export default function iteratorExtension(pi) {
void refreshHub(ctxCwd());
return;
}
// Implement only the features that are dependency-ready right now;
// acceptance remains a separate user-controlled review step.
if (result?.type === "action" && result.action === "implement-wave") {
session.showWorking("Ready wave: taking a dependency snapshot…");
void startFeatureWave(ctxCwd());
return;
}
// Hub "Implement all (auto)" button: flip into auto mode for
// this run without touching the global auto_mode setting.
if (result?.type === "action" && result.action === "auto-implement") {
Expand Down Expand Up @@ -1451,9 +1562,21 @@ export default function iteratorExtension(pi) {
await flushUsage(ctx.cwd);
await refreshHub(ctx.cwd);
await refreshStatus(ctx);
// The auto-mode loop advances exactly here: one decision per finished
// agent loop (no-op unless state.mode === 'auto' and not paused).
await kickAuto(ctx.cwd);
// Keep abortPending set until this stale agent_end reaches its final
// decision. Continue sees the flag and waits; once we clear it, exactly one
// side owns resumption: this callback when already unpaused, or a later
// Continue click when still paused.
if (featureWave?.abortPending) {
const { state } = await gatherSession(ctx.cwd);
featureWave = completeFeatureWaveAbort(featureWave);
if (!state?.paused) await advanceFeatureWave(ctx.cwd);
} else if (featureWave) {
// A ready-wave snapshot advances before auto mode. Wave implementation
// intentionally stops at implemented; review remains a separate action.
await advanceFeatureWave(ctx.cwd);
} else {
await kickAuto(ctx.cwd);
}
});

// ---------------------------------------------------------------------
Expand Down
65 changes: 58 additions & 7 deletions lib/gather.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,8 @@ export function gather(startDir) {
stage: planStage(null, [], b.settings),
progress: { done: 0, total: 0 },
features: [],
readyWave: [],
reviewWave: [],
// Tracked files for the goal box's @-mention suggestions (capped so
// the embedded payload stays small).
files: git(["ls-files"], b.root)
Expand Down Expand Up @@ -416,6 +418,16 @@ export function gather(startDir) {
stage: planStage(b.plan, b.features, b.settings),
progress: progress(b.features),
features,
// Snapshot candidates for the Work surface's "Implement next wave" action.
// Readiness is server-derived above; the browser only renders this list.
readyWave: features
.filter((feature) => feature.status === "pending" && feature.ready)
.map((feature) => feature.name),
reviewWave: features
.filter(
(feature) => feature.status === "implemented" && feature.hasCommits,
)
.map((feature) => feature.name),
knowledgeInitialized: knowledgeReady(b.memDir),
settings: b.settings,
state: b.state,
Expand Down Expand Up @@ -1118,9 +1130,7 @@ export function hydrateMemoryCards(data, startDir) {
if (!needy.length) return data;
const b = loadBundle(startDir);
for (const m of needy) {
const [area, slug] = m.id
? String(m.id).split("/")
: [m.area, m.slug];
const [area, slug] = m.id ? String(m.id).split("/") : [m.area, m.slug];
if (!SLUG.test(area || "") || !SLUG.test(slug || "")) continue;
const file = join(b.memDir, area, `${slug}.md`);
if (existsSync(file))
Expand Down Expand Up @@ -1380,6 +1390,50 @@ export function resolveFeatureCommits(root, c) {

export function gatherReview(startDir, opts = {}) {
const b = loadBundle(startDir);

// Consolidated review is an explicit scope, never the old unscoped
// working-tree fallback. Reuse the focused commit-backed gather once per
// implemented feature, then combine the already-attributed payloads. This
// keeps overlapping files, incidental changes, stats, and pitfalls attached
// to the feature whose commits introduced them.
if (opts.feature === "all") {
const selected = b.features.filter(
(c) =>
c.fm.status === "implemented" &&
resolveFeatureCommits(b.root, c).length > 0,
);
const rounds = selected.map((c) =>
gatherReview(b.root, { feature: c.slug }),
);
const features = rounds.flatMap((round) => round.features);
return {
step: "review",
multiReview: true,
reviewScope: selected.map((c) => c.slug),
diffTruncated: rounds.some((round) => round.diffTruncated),
diffOmittedFiles: [
...new Set(rounds.flatMap((round) => round.diffOmittedFiles || [])),
],
branch: b.branch,
root: b.root,
commit: `${rounds.length} implemented feature${rounds.length === 1 ? "" : "s"}`,
plan: b.plan?.fm.title || "",
progress: progress(b.features),
hasFeaturesFile: b.features.length > 0,
hasChanges: features.some((feature) => feature.files.length > 0),
source: "commits",
uncommittedOverlap: [
...new Set(rounds.flatMap((round) => round.uncommittedOverlap || [])),
],
features,
activeFeature: selected[0]?.slug || null,
defaulted: [],
uncategorized: [],
pitfalls: [],
designFile: b.design ? join(b.memDir, "design.md") : null,
};
}

const hasHead = git(["rev-parse", "--verify", "HEAD"], b.root) !== "";

const selected = opts.feature
Expand Down Expand Up @@ -1605,10 +1659,7 @@ export function gatherReview(startDir, opts = {}) {
const diffOmittedFiles = [];
{
let budget = MAX_DIFF;
const allFiles = [
...features.flatMap((c) => c.files),
...uncategorized,
];
const allFiles = [...features.flatMap((c) => c.files), ...uncategorized];
for (const f of allFiles) {
const size = JSON.stringify(f.hunks || []).length;
if (size <= budget) {
Expand Down
Loading
Loading