diff --git a/extensions/iterator.js b/extensions/iterator.js index 472fcb9..ee03834 100644 --- a/extensions/iterator.js +++ b/extensions/iterator.js @@ -57,10 +57,13 @@ import { bundleExists, featuresDirEntries, composeAmbientContext, + completeFeatureWaveAbort, extractPathsFromBash, footerText, mergePayload, nextAutoAction, + nextFeatureWaveAction, + pauseFeatureWave, projectRoot, roleModelSpec, runJson, @@ -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. @@ -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. @@ -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") => { @@ -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; @@ -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") { @@ -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); + } }); // --------------------------------------------------------------------- diff --git a/lib/gather.mjs b/lib/gather.mjs index c79e8e3..6d8350b 100644 --- a/lib/gather.mjs +++ b/lib/gather.mjs @@ -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) @@ -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, @@ -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)) @@ -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 @@ -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) { diff --git a/lib/pi-tools.mjs b/lib/pi-tools.mjs index 5d3601a..0afe97e 100644 --- a/lib/pi-tools.mjs +++ b/lib/pi-tools.mjs @@ -7,16 +7,16 @@ * that keeps the pi path byte-identical to the bash path the skills use, * including their validation and exit-code behavior. */ -import { execFile } from 'node:child_process'; -import { existsSync, readFileSync, readdirSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { isAbsolute, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { frontmatter } from './bundle.mjs'; +import { execFile } from "node:child_process"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { isAbsolute, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { frontmatter } from "./bundle.mjs"; /** Merge the small agent-authored extra fields over a gathered payload. */ export function mergePayload(gathered, extra) { - return { ...gathered, ...(extra && typeof extra === 'object' ? extra : {}) }; + return { ...gathered, ...(extra && typeof extra === "object" ? extra : {}) }; } /** @@ -24,74 +24,87 @@ export function mergePayload(gathered, extra) { * flow, or null for cancel/timeout/close/anything non-actionable. */ export function actionToCommand(result) { - if (!result || result.type !== 'action') return null; - - // Hub (Work tab): step flows, optionally scoped to a feature. A typed plan - // goal from the hero rides along so /iterator-plan can skip its questions. - const STEPS = ['plan', 'feature', 'test', 'implement', 'review', 'design']; - if (STEPS.includes(result.action)) { - const parts = [`/skill:iterator-${result.action}`]; - if (result.feature) parts.push(result.feature); - if (result.prompt) parts.push(`— ${result.prompt}`); - return parts.join(' '); - } - // Hub: retire a finished plan (the /iterator skill owns the flow). - if (result.action === 'retire') return '/skill:iterator retire-plan'; - // Hub: whole-plan review once every feature is implemented/done. - if (result.action === 'review-plan') return '/skill:iterator-review-plan'; - - // Knowledge tab: knowledge skills, and free-form memory actions routed to /iterator-knowledge. - // iterator-init from the hub hero may carry a stashed plan goal: initialize, - // then continue straight into planning with it. - const OKF_SKILLS = ['iterator-init', 'iterator-consolidate', 'iterator-memorize']; - if (OKF_SKILLS.includes(result.action)) { - const cmd = `/skill:${result.action}`; - return result.prompt - ? `${cmd} — when initialization finishes, continue into /skill:iterator-plan — ${result.prompt}` - : cmd; - } - const OKF_ACTIONS = ['draft-memory', 'draft-memory-prompt', 'update-memory', 'refresh-format']; - if (OKF_ACTIONS.includes(result.action)) { - const parts = [`/skill:iterator-knowledge ${result.action}`]; - if (result.target) parts.push(result.target); - if (result.prompt) parts.push(`— ${result.prompt}`); - return parts.join(' '); - } - return null; + if (!result || result.type !== "action") return null; + + // Hub (Work tab): step flows, optionally scoped to a feature. A typed plan + // goal from the hero rides along so /iterator-plan can skip its questions. + const STEPS = ["plan", "feature", "test", "implement", "review", "design"]; + if (STEPS.includes(result.action)) { + const parts = [`/skill:iterator-${result.action}`]; + if (result.feature) parts.push(result.feature); + if (result.prompt) parts.push(`— ${result.prompt}`); + return parts.join(" "); + } + // Hub: one commit-backed review containing every implemented feature. + if (result.action === "review-all") return "/skill:iterator-review --all"; + // Hub: retire a finished plan (the /iterator skill owns the flow). + if (result.action === "retire") return "/skill:iterator retire-plan"; + // Hub: whole-plan review once every feature is implemented/done. + if (result.action === "review-plan") return "/skill:iterator-review-plan"; + + // Knowledge tab: knowledge skills, and free-form memory actions routed to /iterator-knowledge. + // iterator-init from the hub hero may carry a stashed plan goal: initialize, + // then continue straight into planning with it. + const OKF_SKILLS = [ + "iterator-init", + "iterator-consolidate", + "iterator-memorize", + ]; + if (OKF_SKILLS.includes(result.action)) { + const cmd = `/skill:${result.action}`; + return result.prompt + ? `${cmd} — when initialization finishes, continue into /skill:iterator-plan — ${result.prompt}` + : cmd; + } + const OKF_ACTIONS = [ + "draft-memory", + "draft-memory-prompt", + "update-memory", + "refresh-format", + ]; + if (OKF_ACTIONS.includes(result.action)) { + const parts = [`/skill:iterator-knowledge ${result.action}`]; + if (result.target) parts.push(result.target); + if (result.prompt) parts.push(`— ${result.prompt}`); + return parts.join(" "); + } + return null; } /** Resolve the bundle dir for a working directory (mirrors loadBundle). */ export function memoryDir(startDir) { - const memName = process.env.ITERATOR_MEMORY_DIR || 'memory'; - if (isAbsolute(memName)) return memName; - return join(gitRoot(startDir), memName); + const memName = process.env.ITERATOR_MEMORY_DIR || "memory"; + if (isAbsolute(memName)) return memName; + return join(gitRoot(startDir), memName); } /** The git root for a working directory (walked, not spawned — hook-safe). */ export function projectRoot(startDir) { - return gitRoot(startDir); + return gitRoot(startDir); } function gitRoot(startDir) { - let dir = startDir || process.cwd(); - // Walk up to the git root without spawning git (cheap, hook-safe). - while (!existsSync(join(dir, '.git'))) { - const parent = join(dir, '..'); - if (parent === dir) return startDir || process.cwd(); - dir = parent; - } - return dir; + let dir = startDir || process.cwd(); + // Walk up to the git root without spawning git (cheap, hook-safe). + while (!existsSync(join(dir, ".git"))) { + const parent = join(dir, ".."); + if (parent === dir) return startDir || process.cwd(); + dir = parent; + } + return dir; } /** Does a bundle exist (plan or features) for this working directory? */ export function bundleExists(startDir) { - const mem = memoryDir(startDir); - return existsSync(join(mem, 'plan.md')) || existsSync(join(mem, 'features')); + const mem = memoryDir(startDir); + return existsSync(join(mem, "plan.md")) || existsSync(join(mem, "features")); } /** Absolute path of a hub-skill script (gather|write|server). */ export function scriptPath(name) { - return fileURLToPath(new URL(`../skills/iterator/${name}.mjs`, import.meta.url)); + return fileURLToPath( + new URL(`../skills/iterator/${name}.mjs`, import.meta.url), + ); } /** @@ -99,27 +112,33 @@ export function scriptPath(name) { * @returns {Promise<{code, stdout, stderr}>} never rejects on non-zero exit. */ export function runScript(script, args = [], { cwd, stdin } = {}) { - return new Promise((resolve) => { - const child = execFile(process.execPath, [script, ...args], - { cwd, maxBuffer: 64 * 1024 * 1024 }, - (err, stdout, stderr) => resolve({ code: err?.code ?? 0, stdout, stderr })); - if (stdin != null) child.stdin.end(stdin); - else child.stdin.end(); - }); + return new Promise((resolve) => { + const child = execFile( + process.execPath, + [script, ...args], + { cwd, maxBuffer: 64 * 1024 * 1024 }, + (err, stdout, stderr) => + resolve({ code: err?.code ?? 0, stdout, stderr }), + ); + if (stdin != null) child.stdin.end(stdin); + else child.stdin.end(); + }); } /** Run a script that prints one JSON line; throws a readable error if not. */ export async function runJson(script, args, opts) { - const { code, stdout, stderr } = await runScript(script, args, opts); - let parsed = null; - try { parsed = JSON.parse(stdout.trim().split('\n').pop() || ''); } catch {} - if (parsed == null) { - throw new Error((stderr || stdout || `exit ${code}`).trim()); - } - if (code !== 0 || parsed.ok === false) { - throw new Error(parsed.error || (stderr || `exit ${code}`).trim()); - } - return parsed; + const { code, stdout, stderr } = await runScript(script, args, opts); + let parsed = null; + try { + parsed = JSON.parse(stdout.trim().split("\n").pop() || ""); + } catch {} + if (parsed == null) { + throw new Error((stderr || stdout || `exit ${code}`).trim()); + } + if (code !== 0 || parsed.ok === false) { + throw new Error(parsed.error || (stderr || `exit ${code}`).trim()); + } + return parsed; } // --------------------------------------------------------------------------- @@ -127,14 +146,14 @@ export async function runJson(script, args, opts) { /** Best-effort file-path tokens in a bash command (caller checks existence). */ export function extractPathsFromBash(command) { - const out = []; - for (const m of String(command || '').matchAll(/[\w./-]+\.\w{1,8}\b/g)) { - const p = m[0].replace(/^\.\//, ''); - // Skip pure extensions/domains-looking tokens and obvious non-paths. - if (!/[a-z]/i.test(p) || p.startsWith('-')) continue; - out.push(p); - } - return [...new Set(out)]; + const out = []; + for (const m of String(command || "").matchAll(/[\w./-]+\.\w{1,8}\b/g)) { + const p = m[0].replace(/^\.\//, ""); + // Skip pure extensions/domains-looking tokens and obvious non-paths. + if (!/[a-z]/i.test(p) || p.startsWith("-")) continue; + out.push(p); + } + return [...new Set(out)]; } /** @@ -148,24 +167,32 @@ export function extractPathsFromBash(command) { * @param {Array<{id,title,description,ref}>} concepts anchored concepts */ export function composeAmbientContext(hub, implement, concepts = []) { - const lines = []; - if (hub?.plan) { - const p = hub.progress || {}; - const red = (hub.features || []).filter(c => c.testsStatus === 'red').map(c => c.name); - const parts = [ - `Plan "${hub.plan.title}" — ${p.done ?? 0}/${p.total ?? 0} features done`, - `next ready: ${implement?.next?.name || 'none'}`, - ]; - if (red.length) parts.push(`tests red: ${red.join(', ')}`); - lines.push(`iterator: ${parts.join(' · ')}. Route new implementation work through the feature flow (/iterator-implement or the iterator tools), not ad-hoc edits.`); - } - if (concepts.length) { - lines.push('Knowledge anchored to recently touched files (read the concept before editing further):'); - for (const c of concepts) { - lines.push(`- [${c.id}] ${c.title} — ${c.description}${c.ref ? ` (${c.ref})` : ''}`); - } - } - return lines.length ? lines.join('\n') : null; + const lines = []; + if (hub?.plan) { + const p = hub.progress || {}; + const red = (hub.features || []) + .filter((c) => c.testsStatus === "red") + .map((c) => c.name); + const parts = [ + `Plan "${hub.plan.title}" — ${p.done ?? 0}/${p.total ?? 0} features done`, + `next ready: ${implement?.next?.name || "none"}`, + ]; + if (red.length) parts.push(`tests red: ${red.join(", ")}`); + lines.push( + `iterator: ${parts.join(" · ")}. Route new implementation work through the feature flow (/iterator-implement or the iterator tools), not ad-hoc edits.`, + ); + } + if (concepts.length) { + lines.push( + "Knowledge anchored to recently touched files (read the concept before editing further):", + ); + for (const c of concepts) { + lines.push( + `- [${c.id}] ${c.title} — ${c.description}${c.ref ? ` (${c.ref})` : ""}`, + ); + } + } + return lines.length ? lines.join("\n") : null; } // --------------------------------------------------------------------------- @@ -175,12 +202,94 @@ export function composeAmbientContext(hub, implement, concepts = []) { // dispatches what this returns. export const AUTO_PHASE_FOR_STEP = { - test: 'testing', - implement: 'implementing', - review: 'reviewing', - 'plan-review': 'reviewing', + test: "testing", + implement: "implementing", + review: "reviewing", + "plan-review": "reviewing", }; +/** Preserve the active item when a ready wave is paused mid-turn. */ +export function pauseFeatureWave(wave) { + if (!wave || !Array.isArray(wave.queue) || !Array.isArray(wave.results)) + return null; + return { + queue: wave.active ? [wave.active, ...wave.queue] : [...wave.queue], + active: null, + results: [...wave.results], + abortPending: Boolean(wave.active), + }; +} + +/** Mark the interrupted agent turn finished so the requeued item may resume. */ +export function completeFeatureWaveAbort(wave) { + if (!wave) return null; + return { ...wave, abortPending: false }; +} + +/** + * Advance an implementation-wave snapshot after each agent turn. + * + * The queue is fixed when the button is clicked: features that become ready + * later never join the current wave. Bundle status is the result contract — an + * active feature that reached implemented/done succeeded; one still pending + * failed its round. Decision-conflicted entries are reported without dispatch. + */ +export function nextFeatureWaveAction(wave, features = []) { + if (!wave || !Array.isArray(wave.queue) || !Array.isArray(wave.results)) + return null; + if (wave.abortPending) return { wave: { ...wave }, waiting: true }; + const byName = new Map(features.map((feature) => [feature.name, feature])); + const next = { + queue: [...wave.queue], + active: wave.active || null, + results: [...wave.results], + abortPending: false, + }; + + if (next.active) { + const feature = byName.get(next.active); + const success = feature && ["implemented", "done"].includes(feature.status); + next.results.push({ + feature: next.active, + status: success ? "implemented" : "failed", + }); + next.active = null; + } + + while (next.queue.length) { + const featureName = next.queue.shift(); + const feature = byName.get(featureName); + if (!feature) { + next.results.push({ feature: featureName, status: "missing" }); + continue; + } + if (feature.conflicts) { + next.results.push({ feature: featureName, status: "conflict" }); + continue; + } + if (feature.status === "pending") { + next.active = featureName; + return { + wave: next, + action: { + step: "implement", + role: "implementer", + feature: featureName, + cmd: `/skill:iterator-implement ${featureName} --auto`, + }, + }; + } + next.results.push({ + feature: featureName, + status: ["implemented", "done"].includes(feature.status) + ? "implemented" + : "skipped", + }); + } + + return { wave: next, done: true }; +} + /** * The next auto-mode action, or a terminal outcome: * { step, role, feature, cmd, strike? } — dispatch cmd as a role turn; @@ -195,86 +304,118 @@ export const AUTO_PHASE_FOR_STEP = { * text parsing. */ export function nextAutoAction(sessionPayload, settings, state) { - const hub = sessionPayload?.hub; - const imp = sessionPayload?.implement; - if (!hub?.plan || state?.mode !== 'auto' || state?.paused) return null; - const max = settings?.max_review_iterations ?? 3; - const strikes = state.strikes || {}; - - // A review round just came back: done = approved+committed; anything else - // is a needs-work round → strike, then re-implement with the reviewer's - // notes (they live in the feature's # Review section). - if (state.phase === 'reviewing' && state.active_feature) { - const ch = (hub.features || []).find(c => c.name === state.active_feature); - if (ch && ch.status !== 'done') { - const count = (strikes[state.active_feature] || 0) + 1; - if (count >= max) { - return { - escalate: true, - feature: state.active_feature, - reason: `feature '${state.active_feature}' failed agent review ${count} time(s) — human intervention needed`, - }; - } - return { - step: 'implement', - role: 'implementer', - feature: state.active_feature, - strike: state.active_feature, - cmd: `/skill:iterator-implement ${state.active_feature} --auto`, - }; - } - } - - // An implemented feature awaits review — review always directly follows - // implement in auto mode (review_required gates dependents, not the review - // itself). This also means "awaiting review" is never misread as stuck. - const awaiting = (hub.features || []).find(c => c.status === 'implemented'); - if (awaiting) { - return { step: 'review', role: 'reviewer', feature: awaiting.name, cmd: `/skill:iterator-review ${awaiting.name} --agent` }; - } - - const next = imp?.next || null; - const p = hub.progress || {}; - if (!next) { - if (p.total > 0 && p.done === p.total) { - // Every feature landed: run the whole-plan review exactly once (the - // recorded plan_reviewed date is the once-marker), then stop — no fix - // loop around it; the human verifies the report. - if (!hub.plan.planReviewed) { - return { step: 'plan-review', role: 'plan_reviewer', cmd: '/skill:iterator-review-plan --auto' }; - } - return { done: true }; - } - if (imp?.drafts?.length) { - return { escalate: true, reason: 'only draft features exist — accept the feature set first (/iterator-feature)' }; - } - if (imp?.stuck) { - return { escalate: true, reason: 'pending features remain but none is ready — dependency cycle or missing dependency' }; - } - return { done: true }; - } - if ((next.conflicts || []).length) { - return { - escalate: true, - feature: next.name, - reason: `feature '${next.name}' conflicts with recorded decisions (${next.conflicts.map(x => x.decision).join(', ')}) — resolve before implementing`, - }; - } - if ((strikes[next.name] || 0) >= max) { - return { - escalate: true, - feature: next.name, - reason: `feature '${next.name}' already failed review ${strikes[next.name]} time(s)`, - }; - } - - if (settings?.testing_default === 'on' && (next.testsStatus || 'none') === 'none') { - return { step: 'test', role: 'tester', feature: next.name, cmd: `/skill:iterator-test ${next.name} --auto` }; - } - // Review readiness is a status, not a diff heuristic: the implement flow - // flips a feature to `implemented`, which the awaiting-review branch above - // dispatches. Anything still pending gets (re)implemented. - return { step: 'implement', role: 'implementer', feature: next.name, cmd: `/skill:iterator-implement ${next.name} --auto` }; + const hub = sessionPayload?.hub; + const imp = sessionPayload?.implement; + if (!hub?.plan || state?.mode !== "auto" || state?.paused) return null; + const max = settings?.max_review_iterations ?? 3; + const strikes = state.strikes || {}; + + // A review round just came back: done = approved+committed; anything else + // is a needs-work round → strike, then re-implement with the reviewer's + // notes (they live in the feature's # Review section). + if (state.phase === "reviewing" && state.active_feature) { + const ch = (hub.features || []).find( + (c) => c.name === state.active_feature, + ); + if (ch && ch.status !== "done") { + const count = (strikes[state.active_feature] || 0) + 1; + if (count >= max) { + return { + escalate: true, + feature: state.active_feature, + reason: `feature '${state.active_feature}' failed agent review ${count} time(s) — human intervention needed`, + }; + } + return { + step: "implement", + role: "implementer", + feature: state.active_feature, + strike: state.active_feature, + cmd: `/skill:iterator-implement ${state.active_feature} --auto`, + }; + } + } + + // An implemented feature awaits review — review always directly follows + // implement in auto mode (review_required gates dependents, not the review + // itself). This also means "awaiting review" is never misread as stuck. + const awaiting = (hub.features || []).find((c) => c.status === "implemented"); + if (awaiting) { + return { + step: "review", + role: "reviewer", + feature: awaiting.name, + cmd: `/skill:iterator-review ${awaiting.name} --agent`, + }; + } + + const next = imp?.next || null; + const p = hub.progress || {}; + if (!next) { + if (p.total > 0 && p.done === p.total) { + // Every feature landed: run the whole-plan review exactly once (the + // recorded plan_reviewed date is the once-marker), then stop — no fix + // loop around it; the human verifies the report. + if (!hub.plan.planReviewed) { + return { + step: "plan-review", + role: "plan_reviewer", + cmd: "/skill:iterator-review-plan --auto", + }; + } + return { done: true }; + } + if (imp?.drafts?.length) { + return { + escalate: true, + reason: + "only draft features exist — accept the feature set first (/iterator-feature)", + }; + } + if (imp?.stuck) { + return { + escalate: true, + reason: + "pending features remain but none is ready — dependency cycle or missing dependency", + }; + } + return { done: true }; + } + if ((next.conflicts || []).length) { + return { + escalate: true, + feature: next.name, + reason: `feature '${next.name}' conflicts with recorded decisions (${next.conflicts.map((x) => x.decision).join(", ")}) — resolve before implementing`, + }; + } + if ((strikes[next.name] || 0) >= max) { + return { + escalate: true, + feature: next.name, + reason: `feature '${next.name}' already failed review ${strikes[next.name]} time(s)`, + }; + } + + if ( + settings?.testing_default === "on" && + (next.testsStatus || "none") === "none" + ) { + return { + step: "test", + role: "tester", + feature: next.name, + cmd: `/skill:iterator-test ${next.name} --auto`, + }; + } + // Review readiness is a status, not a diff heuristic: the implement flow + // flips a feature to `implemented`, which the awaiting-review branch above + // dispatches. Anything still pending gets (re)implemented. + return { + step: "implement", + role: "implementer", + feature: next.name, + cmd: `/skill:iterator-implement ${next.name} --auto`, + }; } /** @@ -283,31 +424,31 @@ export function nextAutoAction(sessionPayload, settings, state) { * ctx.modelRegistry and applies pi.setModel/pi.setThinkingLevel. */ export function roleModelSpec(settings, role) { - const model = settings?.[`${role}_model`]; - const thinking = settings?.[`${role}_thinking`]; - return { - model: model && model !== 'active' ? String(model) : null, - thinking: thinking && thinking !== 'active' ? String(thinking) : null, - }; + const model = settings?.[`${role}_model`]; + const thinking = settings?.[`${role}_thinking`]; + return { + model: model && model !== "active" ? String(model) : null, + thinking: thinking && thinking !== "active" ? String(thinking) : null, + }; } // --------------------------------------------------------------------------- // Token-usage attribution (pi turn_end capture → usage op rows) const ATTRIBUTION_MAP = { - 'iterator-plan': 'plan', - 'iterator-feature': 'feature', - 'iterator-test': 'test', - 'iterator-implement': 'implement', - 'iterator-review': 'review', - 'iterator-review-plan': 'review', - 'iterator-design': 'design', - 'iterator-next': 'implement', - iterator: 'hub', - 'iterator-knowledge': 'memory', - 'iterator-init': 'memory', - 'iterator-consolidate': 'memory', - 'iterator-memorize': 'memory', + "iterator-plan": "plan", + "iterator-feature": "feature", + "iterator-test": "test", + "iterator-implement": "implement", + "iterator-review": "review", + "iterator-review-plan": "review", + "iterator-design": "design", + "iterator-next": "implement", + iterator: "hub", + "iterator-knowledge": "memory", + "iterator-init": "memory", + "iterator-consolidate": "memory", + "iterator-memorize": "memory", }; /** @@ -317,10 +458,12 @@ const ATTRIBUTION_MAP = { * attribution keeps applying until the flow visibly changes. */ export function attributionFromInput(text) { - const m = String(text || '').trim().match(/^\/(?:skill:)?([a-z-]+)(?:\s+([A-Za-z0-9._/-]+))?/); - if (!m || !ATTRIBUTION_MAP[m[1]]) return null; - const feature = m[2] && /^[a-z0-9][a-z0-9-]*$/.test(m[2]) ? m[2] : null; - return { step: ATTRIBUTION_MAP[m[1]], feature }; + const m = String(text || "") + .trim() + .match(/^\/(?:skill:)?([a-z-]+)(?:\s+([A-Za-z0-9._/-]+))?/); + if (!m || !ATTRIBUTION_MAP[m[1]]) return null; + const feature = m[2] && /^[a-z0-9][a-z0-9-]*$/.test(m[2]) ? m[2] : null; + return { step: ATTRIBUTION_MAP[m[1]], feature }; } /** @@ -328,18 +471,18 @@ export function attributionFromInput(text) { * current flow step; null for non-assistant turns or messages without usage. */ export function usageRowFromMessage(message, attribution) { - if (!message || message.role !== 'assistant' || !message.usage) return null; - const u = message.usage; - return { - step: attribution?.step || 'other', - ...(attribution?.feature ? { feature: attribution.feature } : {}), - provider: String(message.provider || 'unknown'), - model: String(message.model || 'unknown'), - input: u.input || 0, - output: u.output || 0, - cacheRead: u.cacheRead || 0, - cacheWrite: u.cacheWrite || 0, - }; + if (!message || message.role !== "assistant" || !message.usage) return null; + const u = message.usage; + return { + step: attribution?.step || "other", + ...(attribution?.feature ? { feature: attribution.feature } : {}), + provider: String(message.provider || "unknown"), + model: String(message.model || "unknown"), + input: u.input || 0, + output: u.output || 0, + cacheRead: u.cacheRead || 0, + cacheWrite: u.cacheWrite || 0, + }; } // --------------------------------------------------------------------------- @@ -349,23 +492,25 @@ export function usageRowFromMessage(message, attribution) { export const ACTIVITY_TEXT_MAX = 800; const clipActivity = (s, max) => { - if (s.length <= max) return s; - const cut = s.slice(0, max); - const sp = cut.lastIndexOf(' '); - return (sp > 0 && sp > max - 80 ? cut.slice(0, sp) : cut).trimEnd() + '…'; + if (s.length <= max) return s; + const cut = s.slice(0, max); + const sp = cut.lastIndexOf(" "); + return (sp > 0 && sp > max - 80 ? cut.slice(0, sp) : cut).trimEnd() + "…"; }; /** `Running read, edit ×2` — first-seen order, counted, capped at 6 distinct names. */ const toolSummary = (names) => { - const order = []; - const counts = new Map(); - for (const n of names) { - if (!counts.has(n)) order.push(n); - counts.set(n, (counts.get(n) || 0) + 1); - } - const shown = order.slice(0, 6).map(n => (counts.get(n) > 1 ? `${n} ×${counts.get(n)}` : n)); - if (order.length > 6) shown.push('…'); - return `Running ${shown.join(', ')}`; + const order = []; + const counts = new Map(); + for (const n of names) { + if (!counts.has(n)) order.push(n); + counts.set(n, (counts.get(n) || 0) + 1); + } + const shown = order + .slice(0, 6) + .map((n) => (counts.get(n) > 1 ? `${n} ×${counts.get(n)}` : n)); + if (order.length > 6) shown.push("…"); + return `Running ${shown.join(", ")}`; }; /** @@ -380,26 +525,26 @@ const toolSummary = (names) => { * clearing. Thinking blocks are skipped — they carry `.thinking`, not `.text`. */ export function activityTextFromMessage(message, max = ACTIVITY_TEXT_MAX) { - if (!message || message.role !== 'assistant') return null; - if (message.stopReason === 'aborted') return null; - const blocks = Array.isArray(message.content) ? message.content : []; - const text = blocks - .filter(b => b && b.type === 'text' && typeof b.text === 'string') - .map(b => b.text) - .join('\n') - .replace(/\s+/g, ' ') - .trim(); - if (text) return clipActivity(text, max); - const tools = blocks - .filter(b => b && b.type === 'toolCall' && b.name) - .map(b => String(b.name)); - return tools.length ? clipActivity(toolSummary(tools), max) : null; + if (!message || message.role !== "assistant") return null; + if (message.stopReason === "aborted") return null; + const blocks = Array.isArray(message.content) ? message.content : []; + const text = blocks + .filter((b) => b && b.type === "text" && typeof b.text === "string") + .map((b) => b.text) + .join("\n") + .replace(/\s+/g, " ") + .trim(); + if (text) return clipActivity(text, max); + const tools = blocks + .filter((b) => b && b.type === "toolCall" && b.name) + .map((b) => String(b.name)); + return tools.length ? clipActivity(toolSummary(tools), max) : null; } // --------------------------------------------------------------------------- // Footer status + memorize nudge -const PISBX_ENV = join(homedir(), '.pisbx-env'); +const PISBX_ENV = join(homedir(), ".pisbx-env"); const PORT_RE = /^\s*(?:export\s+)?ITERATOR_DISPLAY_PORT=["']?(\d+)["']?/m; /** @@ -415,15 +560,15 @@ const PORT_RE = /^\s*(?:export\s+)?ITERATOR_DISPLAY_PORT=["']?(\d+)["']?/m; * server/env.mjs reads the env var only, so printed URLs still miss it.) */ export function uiPort(env = process.env, file = PISBX_ENV) { - const p = parseInt(env.ITERATOR_DISPLAY_PORT || '', 10); - if (Number.isInteger(p) && p > 0) return p; - try { - const m = PORT_RE.exec(readFileSync(file, 'utf8')); - const fp = m ? parseInt(m[1], 10) : NaN; - return Number.isInteger(fp) && fp > 0 ? fp : null; - } catch { - return null; // no file (not sandboxed) or unreadable — never throw - } + const p = parseInt(env.ITERATOR_DISPLAY_PORT || "", 10); + if (Number.isInteger(p) && p > 0) return p; + try { + const m = PORT_RE.exec(readFileSync(file, "utf8")); + const fp = m ? parseInt(m[1], 10) : NaN; + return Number.isInteger(fp) && fp > 0 ? fp : null; + } catch { + return null; // no file (not sandboxed) or unreadable — never throw + } } /** @@ -435,18 +580,20 @@ export function uiPort(env = process.env, file = PISBX_ENV) { * Returns null when there is nothing to show (clears the segment). */ export function footerText(hub, implement, pendingCount = 0, port = null) { - const segs = []; - if (hub?.plan) { - const p = hub.progress || {}; - segs.push(`⛭ ${p.done ?? 0}/${p.total ?? 0}`); - if (implement?.next?.name) segs.push(`next: ${implement.next.name}`); - const red = (hub.features || []).filter(c => c.testsStatus === 'red').length; - if (red) segs.push(`🔴 ${red} red`); - if (hub.dirty?.count) segs.push(`⚠ ${hub.dirty.count} uncommitted`); - } - if (pendingCount > 0) segs.push(`🧠 ${pendingCount} unmemorized`); - if (port) segs.push(`🌐 ui:${port}`); - return segs.length ? segs.join(' · ') : null; + const segs = []; + if (hub?.plan) { + const p = hub.progress || {}; + segs.push(`⛭ ${p.done ?? 0}/${p.total ?? 0}`); + if (implement?.next?.name) segs.push(`next: ${implement.next.name}`); + const red = (hub.features || []).filter( + (c) => c.testsStatus === "red", + ).length; + if (red) segs.push(`🔴 ${red} red`); + if (hub.dirty?.count) segs.push(`⚠ ${hub.dirty.count} uncommitted`); + } + if (pendingCount > 0) segs.push(`🧠 ${pendingCount} unmemorized`); + if (port) segs.push(`🌐 ui:${port}`); + return segs.length ? segs.join(" · ") : null; } /** @@ -455,20 +602,23 @@ export function footerText(hub, implement, pendingCount = 0, port = null) { * past the last nudge (never per-commit nagging). threshold <= 0 disables. */ export function shouldNudge(pendingCount, lastNudgedAt, threshold) { - if (!Number.isFinite(threshold) || threshold <= 0) return false; - return pendingCount >= threshold && pendingCount >= lastNudgedAt + threshold; + if (!Number.isFinite(threshold) || threshold <= 0) return false; + return pendingCount >= threshold && pendingCount >= lastNudgedAt + threshold; } /** Feature frontmatter entries for the guardrails ({slug, fm} per feature). */ export function featuresDirEntries(startDir) { - const dir = join(memoryDir(startDir), 'features'); - if (!existsSync(dir)) return []; - const out = []; - for (const f of readdirSync(dir)) { - if (!f.endsWith('.md') || f === 'index.md') continue; - try { - out.push({ slug: f.slice(0, -3), fm: frontmatter(readFileSync(join(dir, f), 'utf8')) }); - } catch {} - } - return out; + const dir = join(memoryDir(startDir), "features"); + if (!existsSync(dir)) return []; + const out = []; + for (const f of readdirSync(dir)) { + if (!f.endsWith(".md") || f === "index.md") continue; + try { + out.push({ + slug: f.slice(0, -3), + fm: frontmatter(readFileSync(join(dir, f), "utf8")), + }); + } catch {} + } + return out; } diff --git a/lib/session-server.mjs b/lib/session-server.mjs index 1ccfcbb..023cb8a 100644 --- a/lib/session-server.mjs +++ b/lib/session-server.mjs @@ -446,11 +446,18 @@ export function createSessionServer({ onUnsolicited, onControl, log } = {}) { let body = ""; req.on("data", (c) => (body += c)); req.on("end", () => { - // Defense in depth: while the agent is busy (working state, no round - // pending), an unsolicited dashboard action must not dispatch a second - // concurrent model turn — a stale or JS-disabled tab bypasses the - // client-side read-only guard. - if (!pending && working != null) { + let parsed; + try { + parsed = JSON.parse(body.trim() || "{}"); + } catch { + parsed = {}; + } + // Backlog CRUD is a deterministic filesystem write and does not start a + // model turn, so it remains available while an implementation is running. + // Every other unsolicited action stays blocked to prevent concurrent agent + // flows from a stale or JS-disabled dashboard. + const backlogWrite = parsed.type === "backlog"; + if (!pending && working != null && !backlogWrite) { say("rejected unsolicited /submit while working"); res.writeHead(409, { "Content-Type": "application/json" }); res.end('{"busy":true}'); @@ -458,12 +465,6 @@ export function createSessionServer({ onUnsolicited, onControl, log } = {}) { } res.writeHead(200, { "Content-Type": "application/json" }); res.end('{"ok":true}'); - let parsed; - try { - parsed = JSON.parse(body.trim() || "{}"); - } catch { - parsed = {}; - } if (pending) { working = { text: "Sent to Claude — working…" }; broadcast("working", working); @@ -656,7 +657,10 @@ export function createSessionServer({ onUnsolicited, onControl, log } = {}) { if (working == null || !text) return; const prev = Array.isArray(working.activity) ? working.activity : []; if (prev[0] === text) return; // same line again — no re-render - working = { ...working, activity: [text, ...prev].slice(0, ACTIVITY_MAX) }; + working = { + ...working, + activity: [text, ...prev].slice(0, ACTIVITY_MAX), + }; broadcast("working", working); }, diff --git a/lib/ui.mjs b/lib/ui.mjs index 71bc52b..5b6ac6f 100644 --- a/lib/ui.mjs +++ b/lib/ui.mjs @@ -117,7 +117,9 @@ body{font-family:var(--font-ui);font-size:var(--fs-md); body.iterator-ro [data-action],body.iterator-ro #primary,body.iterator-ro textarea, body.iterator-ro button.kbtn,body.iterator-ro button.act{pointer-events:none;opacity:.45} body.iterator-ro .rail button,body.iterator-ro .rail input,body.iterator-ro [data-open], -body.iterator-ro .mclose{pointer-events:auto;opacity:1} +body.iterator-ro .mclose,body.iterator-ro .backlog-active button.act, +body.iterator-ro .backlog-active textarea,body.iterator-ro .backlog-active input, +body.iterator-ro .backlog-active select{pointer-events:auto;opacity:1} body.iterator-ro::before{content:"read-only — agent working";position:fixed;top:0;left:50%; transform:translateX(-50%);z-index:100;font-family:var(--font-mono);font-size:var(--fs-xs); color:var(--dot-yellow);background:var(--bg-yellow);border:1px solid var(--dot-yellow); @@ -181,8 +183,8 @@ function primaryClick(){ if(typeof onPrimary==='function') onPrimary(); } // POST a payload to /submit; used by every step's onPrimary and by inline // round-trip actions (split/merge). Shows sending state on the primary button. -async function post(payload, okMsg){ - if(document.body.classList.contains('iterator-ro')){ +async function post(payload, okMsg, options){ + if(document.body.classList.contains('iterator-ro') && !options?.allowWhileWorking){ alert('Claude is working — actions are disabled until it finishes.'); return; } diff --git a/lib/views/hub.mjs b/lib/views/hub.mjs index 08e4e38..6c64059 100644 --- a/lib/views/hub.mjs +++ b/lib/views/hub.mjs @@ -20,7 +20,7 @@ * conflicts } ] } // # of flagged decision conflicts * retired: [ { name, title, created } ] // archived plans, newest first * output: one JSON line to stdout — - * { type:"action", action:"planning"|"test"|"implement"|"review"|"auto-implement" + * { type:"action", action:"planning"|"test"|"implement"|"review"|"review-all"|"implement-wave"|"auto-implement" * |"escalation-restart"|"escalation-guide", * feature:""|null, * prompt:""|null } // escalation-guide only @@ -112,15 +112,31 @@ function render(){ : 'not broken into features yet'); // Auto mode: once the feature set is approved (pending features exist), the // whole test → implement → review loop can run agent-driven. - const pendingReady = CH.some(c => c.status==='pending'); - if(pendingReady){ + const readyWave = Array.isArray(D.readyWave) ? D.readyWave : []; + if(readyWave.length){ + const wave = document.createElement('button'); + wave.className = 'act primary-act'; + wave.textContent = 'Implement next wave'; + wave.title = 'Implement the '+readyWave.length+' feature'+(readyWave.length!==1?'s':'')+' that are dependency-ready now; review remains a separate explicit step'; + wave.addEventListener('click', () => action('implement-wave', null, 'Implementing next ready wave')); + bar.insertBefore(wave, bar.querySelector('.pbar')); + const auto = document.createElement('button'); - auto.className = 'act primary-act'; + auto.className = 'act'; auto.textContent = 'Implement all (auto)'; auto.title = 'Run test \\u2192 implement \\u2192 review for every feature automatically; a reviewer agent stands in for you until escalation'; auto.addEventListener('click', () => action('auto-implement', null, 'Starting auto mode')); bar.insertBefore(auto, bar.querySelector('.pbar')); } + const reviewWave = Array.isArray(D.reviewWave) ? D.reviewWave : []; + if(reviewWave.length > 1){ + const reviewAll = document.createElement('button'); + reviewAll.className = 'act primary-act'; + reviewAll.textContent = 'Review all'; + reviewAll.title = 'Open one review with a feature selector and commit-backed diffs for '+reviewWave.length+' implemented features'; + reviewAll.addEventListener('click', () => action('review-all', null, 'Opening consolidated review')); + bar.insertBefore(reviewAll, bar.querySelector('.pbar')); + } // Tight git flow: surface working-tree dirt so leftovers never linger silently. if(D.dirty && D.dirty.count){ const dw = document.createElement('span'); diff --git a/lib/views/planning.mjs b/lib/views/planning.mjs index ef8ca04..c24e2c3 100644 --- a/lib/views/planning.mjs +++ b/lib/views/planning.mjs @@ -56,7 +56,7 @@ const CH = D.features || []; function backlogAction(payload, button, message){ if(button){ button.disabled = true; button.dataset.label = button.textContent; button.textContent = 'Saving…'; } - post({ type:'backlog', ...payload }, message || 'Saved'); + post({ type:'backlog', ...payload }, message || 'Saved', { allowWhileWorking:true }); } function selectedBacklogGoal(){ const selected = (D.backlog || []).filter(item => item.selected); @@ -267,7 +267,7 @@ function render(){ // Backlog: saved ideas and bugs, separate from active plan features. function renderBacklog(w){ const items = Array.isArray(D.backlog) ? D.backlog : []; - const section = document.createElement('section'); section.className = 'backlog'; + const section = document.createElement('section'); section.className = 'backlog backlog-active'; section.innerHTML = '

Idea backlog

Saved ideas and bugs stay separate from active plan features.

'; const form = document.createElement('form'); form.className = 'backlog-form'; form.innerHTML = ''+ diff --git a/lib/views/review.mjs b/lib/views/review.mjs index 15ffecf..70c2f7a 100644 --- a/lib/views/review.mjs +++ b/lib/views/review.mjs @@ -154,6 +154,11 @@ button.cs{background:var(--accent);border-color:var(--accent);color:var(--accent .fbhint{margin-top:8px;font-size:var(--fs-xs);color:var(--text-muted)} .sdot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:4px;vertical-align:0} .sdot.g{background:var(--dot-green)}.sdot.r{background:var(--dot-red)} +@media(max-width:640px){ + .main{flex-direction:column}.sidebar{width:100%;max-height:34vh;border-right:0;border-bottom:1px solid var(--border)} + .detail{padding:var(--sp-3);padding-bottom:130px}.fb{width:100%;border-left:0;border-radius:0} + .fi{min-height:44px}.sbtns{display:flex}.sbtns .sb{min-height:44px;flex:1} +} `; const BODY = ` @@ -573,9 +578,12 @@ refresh(); export function render(data) { const commitMode = data.mode === "commit"; + let subtitle = "/ review"; + if (commitMode) subtitle = "/ implement"; + if (data.multiReview) subtitle = "/ review all"; return renderPage({ step: "review", - subtitle: commitMode ? "/ implement" : "/ review", + subtitle, branch: data.branch, title: data.plan, data, diff --git a/memory/backlog/index.md b/memory/backlog/index.md index f42193e..858d2c7 100644 --- a/memory/backlog/index.md +++ b/memory/backlog/index.md @@ -2,10 +2,11 @@ type: Backlog title: Iterator backlog description: Saved ideas and bugs kept separate from active plan features. -items: "[]" -timestamp: 2026-07-17T13:58:53.939Z +items: "[{\"id\":\"idea-backlog-always-active\",\"title\":\"idea backlog always active\",\"details\":\"THe idea backlog just writes and reads files in the filesystem so it doesnt need to be deactivated while work in the agent is running so i can plan while work is done.\",\"kind\":\"idea\",\"selected\":true,\"created\":\"2026-07-17T14:07:01.680Z\",\"updated\":\"2026-07-17T14:09:05.129Z\"},{\"id\":\"parallel-features-implement\",\"title\":\"Parallel features implement\",\"details\":\"Add a Implement next wave button that implements all features without dependencies. Also a review all button that opens a review with the with all fgeatueis so to the left i can select a feature and then have the diffs for a feature. the implement automatic is different as it reviews iteself and implements all.\",\"kind\":\"idea\",\"selected\":true,\"created\":\"2026-07-17T14:08:13.716Z\",\"updated\":\"2026-07-17T14:09:05.624Z\"}]" +timestamp: 2026-07-17T14:09:05.625Z --- # Backlog -(empty) +* [idea] idea backlog always active — selected +* [idea] Parallel features implement — selected diff --git a/memory/features/always-available-backlog.md b/memory/features/always-available-backlog.md new file mode 100644 index 0000000..ed37f02 --- /dev/null +++ b/memory/features/always-available-backlog.md @@ -0,0 +1,37 @@ +--- +type: Feature +title: Keep the backlog available during active work +description: Planning continues to show and accept saved filesystem backlog candidates while a plan is active. +status: done +size: medium +depends_on: [] +files: ["lib/gather.mjs", "lib/views/planning.mjs", "lib/session-server.mjs", "test/gather.test.mjs", "test/ui.test.mjs", "test/session-server.test.mjs"] +memories: [pitfalls/cancel-now-after-grace-timer, architecture/browser-server-contract, architecture/workflow-state-ownership, decisions/consume-accepted-backlog-ideas, decisions/iterator-dashboard-feature-workflow, decisions/polish-dashboard-and-multi-agent-workflows, decisions/settings-close-returns-to-work] +timestamp: "2026-07-17T14:16:43.824Z" +tags: [] +commits: + - sha: f44d2f9e2da9ba16c40498873fadf4824c686891 + kind: implement + date: 2026-07-17 +done: 2026-07-17 +reviewed: 2026-07-17 +--- + +# Implementation notes + +Expose the existing bundle backlog in the active-plan Planning payload and keep its mutation path limited to deterministic approved-plan consumption. Render the same compact candidate controls for active and idle plans, including the responsive interaction behavior already used by Planning. + +# Snippets + +```js +return { step: "hub", plan: { title, status }, stage, features, backlog: b.backlog }; +``` + +# Blast radius + +Planning payload and dashboard interactions; backlog candidates must still be consumed only by approved plan writes. + +# Review + +## 2026-07-17 +* **Approved** _(agent review: openai-codex/gpt-5.6-sol)_ — Backlog CRUD and selection remain available during active agent work while all model-dispatching dashboard actions stay protected by the busy guard; focused tests cover both allowed and rejected submissions. diff --git a/memory/features/implement-ready-feature-wave.md b/memory/features/implement-ready-feature-wave.md new file mode 100644 index 0000000..3148d21 --- /dev/null +++ b/memory/features/implement-ready-feature-wave.md @@ -0,0 +1,45 @@ +--- +type: Feature +title: Implement a dependency-ready feature wave +description: A Work action launches implementation for every feature ready at the start of the wave and reports each result. +status: done +size: large +depends_on: [] +files: ["lib/gather.mjs", "lib/status.mjs", "lib/views/hub.mjs", "extensions/iterator.js", "skills/iterator-implement/SKILL.md", "test/gather.test.mjs", "test/status.test.mjs", "test/ui.test.mjs", "test/session-server.test.mjs"] +memories: [pitfalls/cancel-now-after-grace-timer, pitfalls/client-js-template-literal-escaping, architecture/package-and-skill-layout, architecture/workflow-state-ownership, patterns/safe-browser-rendering, decisions/iterator-dashboard-feature-workflow, decisions/polish-dashboard-and-multi-agent-workflows, decisions/powerline-shows-sandbox-ui-port] +timestamp: "2026-07-17T14:33:03.325Z" +tags: [] +commits: + - sha: 01e5a15cedfc71138680d73ed63276ba010eaca8 + kind: implement + date: 2026-07-17 + - sha: cc53c06e7cf02c1c31774902ecc72f585b9fa0ec + kind: implement + date: 2026-07-17 + - sha: 93ee1955897efe51f805bf23f284b593c4e6cf08 + kind: implement + date: 2026-07-17 +reviewed: 2026-07-17 +done: 2026-07-17 +--- + +# Implementation notes + +Use server-derived readiness rather than browser filtering. Add an extension/skill orchestration entry point that snapshots ready features, processes only that snapshot, records per-feature success or failure, refreshes the dashboard between results, and leaves every feature subject to the existing review/acceptance lifecycle. + +# Snippets + +```js +const ready = readiness(b.features, b.settings);\nconst features = b.features.map((c) => ({ name: c.slug, ...ready.get(c.slug) })); +``` + +# Blast radius + +Work controls and feature lifecycle coordination; must not auto-accept or include features unblocked after the wave starts. + +# Review + +## 2026-07-17 +* **Approved** _(agent review: openai-codex/gpt-5.6-sol)_ — The ready-wave snapshot is server-derived and fixed, each feature commits independently without auto-acceptance, failures are reported, and Pause/Continue now serializes resumption behind the aborted turn’s agent_end in both lifecycle orderings. +* **Needs changes** _(agent review: openai-codex/gpt-5.6-sol)_ — Pause/Continue still has a race: Continue can call advanceFeatureWave and dispatch the retried feature before the aborted turn's agent_end fires; that stale agent_end then sees the retried feature active but still pending, marks it failed, and dispatches the next queue member concurrently. Track an abort-in-flight flag/generation: Pause requeues and marks the old turn pending completion, Continue waits when that flag is set, and agent_end clears it then resumes only if state is no longer paused. Add regression coverage for Continue both before and after the aborted agent_end. +* **Needs changes** _(agent review: openai-codex/gpt-5.6-sol)_ — Wave execution ignores the dashboard Pause control: onControl('pause') leaves featureWave active, and agent_end still calls advanceFeatureWave without checking state.paused, so the aborted active feature is marked failed and the next queued feature is dispatched immediately. Preserve/requeue the active item on pause, make advanceFeatureWave no-op while paused, and have Continue resume the wave before falling back to auto mode; add regression coverage for this transition. diff --git a/memory/features/index.md b/memory/features/index.md new file mode 100644 index 0000000..feeaf9e --- /dev/null +++ b/memory/features/index.md @@ -0,0 +1,5 @@ +# Features + +* [Keep the backlog available during active work](always-available-backlog.md) - ✅ done · medium · Planning continues to show and accept saved filesystem backlog candidates while a plan is active. +* [Implement a dependency-ready feature wave](implement-ready-feature-wave.md) - ✅ done · large · A Work action launches implementation for every feature ready at the start of the wave and reports each result. +* [Review implemented features together](review-multiple-implemented-features.md) - ✅ done · large · A consolidated review lets users select an implemented feature and inspect only that feature’s diff and findings. diff --git a/memory/features/review-multiple-implemented-features.md b/memory/features/review-multiple-implemented-features.md new file mode 100644 index 0000000..a3f136d --- /dev/null +++ b/memory/features/review-multiple-implemented-features.md @@ -0,0 +1,37 @@ +--- +type: Feature +title: Review implemented features together +description: A consolidated review lets users select an implemented feature and inspect only that feature’s diff and findings. +status: done +size: large +depends_on: [] +files: ["lib/gather.mjs", "lib/views/hub.mjs", "lib/views/review.mjs", "lib/session-server.mjs", "extensions/iterator.js", "test/gather.test.mjs", "test/ui.test.mjs", "test/session-server.test.mjs"] +memories: [pitfalls/cancel-now-after-grace-timer, pitfalls/client-js-template-literal-escaping, architecture/browser-server-contract, architecture/package-and-skill-layout, architecture/workflow-state-ownership, patterns/safe-browser-rendering, decisions/iterator-dashboard-feature-workflow, decisions/polish-dashboard-and-multi-agent-workflows] +timestamp: "2026-07-17T14:41:24.387Z" +tags: [] +commits: + - sha: e5853320eff434b2aaecca8069ceabceeecdffa3 + kind: implement + date: 2026-07-17 +done: 2026-07-17 +reviewed: 2026-07-17 +--- + +# Implementation notes + +Extend review gathering with an explicit multi-feature scope that rebuilds each selected feature from its own commits, retains per-feature ownership and pitfall findings, and renders a responsive left selector with a selected-feature diff pane. Accept/review feedback must remain attributable to each feature and preserve the explicit acceptance gate. + +# Snippets + +```js +const selected = opts.feature ? b.features.filter((c) => c.slug === opts.feature) : b.features;\nconst features = [];\nfor (const c of selected) { /* build attributed diff */ } +``` + +# Blast radius + +Review gather/render and commit acceptance interfaces; a multi-review must not blend unrelated diffs or silently mark any feature done. + +# Review + +## 2026-07-17 +* **Approved** _(agent review: openai-codex/gpt-5.6-sol)_ — Review all is explicitly scoped to implemented features with recorded commits, rebuilds and attributes each feature diff independently, preserves per-feature feedback and acceptance, and keeps the existing selector usable on desktop and mobile. diff --git a/memory/index.md b/memory/index.md index 1801d32..669e858 100644 --- a/memory/index.md +++ b/memory/index.md @@ -11,6 +11,7 @@ last_memorized_commit: a5d59c50453e568ec486a3c9c017c2506898700e * [Backlog](backlog/index.md) - Saved ideas and bugs outside active plan features. * [Settings](settings.md) - Project settings (auto mode, models, git flow). * [Features](features/) - One document per implementation feature. +* [Plan](plan.md) - Allow durable backlog planning during active work and add wave-level implementation and consolidated review controls. * [Architecture](/architecture/) - How the system is structured. * [Decisions](/decisions/) - Durable product and implementation choices agents should preserve. * [Patterns & Conventions](/patterns/) - How code and workflows are written in this repo. diff --git a/memory/log.md b/memory/log.md index 19d6569..c433c34 100644 --- a/memory/log.md +++ b/memory/log.md @@ -1,6 +1,29 @@ # iterator update log ## 2026-07-17 +* **Plan review**: Whole-plan review recorded (agent). +* **Review**: Reviewed [Review implemented features together](/features/review-multiple-implemented-features.md); approved (agent). +* **Review**: Accepted [Review implemented features together](/features/review-multiple-implemented-features.md) (committed as feature(review-multiple-implemented-features)). +* **Implementation**: Committed feature(review-multiple-implemented-features) on branch iterator/consume-accepted-backlog-ideas; awaiting review. +* **Review**: Reviewed [Implement a dependency-ready feature wave](/features/implement-ready-feature-wave.md); approved (agent). +* **Review**: Accepted [Implement a dependency-ready feature wave](/features/implement-ready-feature-wave.md) (committed as feature(implement-ready-feature-wave)). +* **Implementation**: Committed feature(implement-ready-feature-wave) on branch iterator/consume-accepted-backlog-ideas; awaiting review. +* **Review**: Reviewed [Implement a dependency-ready feature wave](/features/implement-ready-feature-wave.md); changes (agent). +* **Implementation**: Committed feature(implement-ready-feature-wave) on branch iterator/consume-accepted-backlog-ideas; awaiting review. +* **Review**: Reviewed [Implement a dependency-ready feature wave](/features/implement-ready-feature-wave.md); changes (agent). +* **Implementation**: Committed feature(implement-ready-feature-wave) on branch iterator/consume-accepted-backlog-ideas; awaiting review. +* **Review**: Reviewed [Keep the backlog available during active work](/features/always-available-backlog.md); approved (agent). +* **Review**: Accepted [Keep the backlog available during active work](/features/always-available-backlog.md) (committed as feature(always-available-backlog)). +* **Implementation**: Committed feature(always-available-backlog) on branch iterator/consume-accepted-backlog-ideas; awaiting review. +* **Update**: Applied 3 feature adjustment(s). +* **Update**: 2 feature(s) written. +* **Creation**: 3 feature(s) written. +* **Creation**: Plan "Keep backlog planning available and support parallel feature waves" approved on branch iterator/consume-accepted-backlog-ideas. +* **Backlog**: select parallel-features-implement (selected). +* **Backlog**: select idea-backlog-always-active (selected). +* **Backlog**: edit parallel-features-implement. +* **Backlog**: create parallel-features-implement. +* **Backlog**: create idea-backlog-always-active. * **Backlog**: delete remove-elements-from-idea-backlog-when-the-plan-and-feature-is-created. * **Backlog**: delete idea-backlog-layout. * **Backlog**: delete improve-the-interaction-with-claude-code. diff --git a/memory/plan.md b/memory/plan.md new file mode 100644 index 0000000..b87eb7d --- /dev/null +++ b/memory/plan.md @@ -0,0 +1,52 @@ +--- +type: Plan +title: Keep backlog planning available and support parallel feature waves +description: Allow durable backlog planning during active work and add wave-level implementation and consolidated review controls. +status: approved +branch: iterator/consume-accepted-backlog-ideas +created: 2026-07-17 +timestamp: "2026-07-17T14:42:24.417Z" +plan_reviewed: 2026-07-17 +--- + +# Goal + +Keep saved ideas available for planning while other agent work is in progress, and let users implement every dependency-ready feature as a wave with a consolidated, selectable review experience. This reduces workflow idle time without weakening deterministic state or the explicit acceptance gate. + +# Architecture + +- Extend the Planning payload and view so saved filesystem-backed backlog candidates remain readable and selectable independently of an active plan; preserve the approved-plan-only consumption boundary from `decisions/consume-accepted-backlog-ideas` and `decisions/iterator-dashboard-feature-workflow`. +- Add server-derived wave orchestration contracts around existing feature readiness: `lib/status.mjs` remains the authority for dependency satisfaction, while gather supplies the ready feature set and views only render it (`architecture/workflow-state-ownership`). +- Build wave implementation as a deterministic sequence/coordination flow that runs each initially dependency-ready feature independently, records per-feature results, and does not infer or mutate lifecycle state in the browser. +- Extend review payloads and the dashboard review UI to scope multiple implemented features and offer a left-hand feature selector whose selected diff is shown on the right; keep session-server output and interaction contracts machine-readable (`architecture/browser-server-contract`). +- Keep canonical changes in `lib/` and synchronize shipped skill copies after implementation, following the established package-and-skill layout. + +# Dependencies + +(none) + +# Key decisions + +- Backlog candidates remain active planning inputs throughout implementation and are removed only by successful deterministic plan approval (`decisions/consume-accepted-backlog-ideas`). +- “Implement next wave” targets only features that are dependency-ready at the wave's start; it must surface individual failures and never treat newly unblocked features as part of that same wave. +- Automated wave implementation may run its own checks/review preparation, but it must retain Pi's explicit user-controlled review and acceptance gate rather than silently marking features done (`decisions/polish-dashboard-and-multi-agent-workflows`). +- “Review all” aggregates only implemented wave/plan features into one review session, with per-feature diffs and findings kept attributable to the selected feature. +- The dashboard additions follow `memory/design.md`: compact dark controls, blue only for primary/progress actions, responsive stacking below 640px, and no workflow-state derivation in client code. + +# Features + +* [Keep the backlog available during active work](/features/always-available-backlog.md) - Planning continues to show and accept saved filesystem backlog candidates while a plan is active. +* [Implement a dependency-ready feature wave](/features/implement-ready-feature-wave.md) - A Work action launches implementation for every feature ready at the start of the wave and reports each result. +* [Review implemented features together](/features/review-multiple-implemented-features.md) - A consolidated review lets users select an implemented feature and inspect only that feature’s diff and findings. + +# Plan review + +## 2026-07-17 _(agent review: openai-codex/gpt-5.6-sol)_ + +## Verdict + +The five feature commits deliver the approved goal and follow the recorded architecture and key decisions: backlog CRUD/selection remains available during agent work; the dependency-ready set is derived server-side and frozen per implementation wave; wave implementation stops at `implemented` for explicit review; and consolidated review rebuilds each implemented feature from its own commits with selectable, attributable diffs. The dashboard controls follow the saved compact/responsive design, shared `lib/` changes were synchronized into shipped skill copies, all three features are done with approved reviews, and the full test suite passed (346 tests). + +## Loose end + +- **Working tree outside the bundle is not clean.** Uncommitted changes remain in `lib/gather.mjs`, `lib/pi-tools.mjs`, `lib/session-server.mjs`, `test/gather.test.mjs`, `test/pi-tools.test.mjs`, and `test/session-server.test.mjs`. They are not part of the reviewed feature commits and should be inspected, discarded, or committed before retiring/merging the plan. `git diff --check` reports no whitespace errors. diff --git a/memory/state.md b/memory/state.md index 393405f..73f7444 100644 --- a/memory/state.md +++ b/memory/state.md @@ -2,13 +2,13 @@ type: State title: Runtime state description: Machine-owned iterator flow state — never hand-edited. -mode: manual +mode: auto paused: false -phase: idle +phase: done active_feature: null -strikes: "{}" +strikes: "{\"implement-ready-feature-wave\":2}" escalation: null -timestamp: 2026-07-16T11:26:26.735Z +timestamp: 2026-07-17T14:42:34.686Z --- Runtime flow state; read via gather, written only by the state op. diff --git a/memory/usage.md b/memory/usage.md index f105601..cb4185c 100644 --- a/memory/usage.md +++ b/memory/usage.md @@ -2,8 +2,8 @@ type: Usage title: Token usage description: Per-step model/token ledger for the active plan — written only by the usage op. -totals: "{\"steps\":{\"hub\":{\"openai-codex/gpt-5.6-terra\":{\"input\":175681,\"output\":515,\"cacheRead\":308736,\"cacheWrite\":0,\"turns\":5}}},\"features\":{\"retire-plan\":{\"input\":175681,\"output\":515,\"cacheRead\":308736,\"cacheWrite\":0,\"turns\":5}}}" -timestamp: 2026-07-17T13:54:38.269Z +totals: "{\"steps\":{\"hub\":{\"openai-codex/gpt-5.6-terra\":{\"input\":175681,\"output\":515,\"cacheRead\":308736,\"cacheWrite\":0,\"turns\":5}},\"plan\":{\"openai-codex/gpt-5.6-terra\":{\"input\":58734,\"output\":3635,\"cacheRead\":366080,\"cacheWrite\":0,\"turns\":19}},\"implement\":{\"openai-codex/gpt-5.6-terra\":{\"input\":235343,\"output\":6089,\"cacheRead\":2935808,\"cacheWrite\":0,\"turns\":43},\"openai-codex/gpt-5.6-sol\":{\"input\":4385841,\"output\":28968,\"cacheRead\":23019008,\"cacheWrite\":0,\"turns\":126}},\"review\":{\"openai-codex/gpt-5.6-sol\":{\"input\":2129601,\"output\":5671,\"cacheRead\":3197952,\"cacheWrite\":0,\"turns\":22}}},\"features\":{\"retire-plan\":{\"input\":175681,\"output\":515,\"cacheRead\":308736,\"cacheWrite\":0,\"turns\":5},\"always-available-backlog\":{\"input\":436207,\"output\":6962,\"cacheRead\":3155456,\"cacheWrite\":0,\"turns\":47},\"implement-ready-feature-wave\":{\"input\":3803315,\"output\":22133,\"cacheRead\":14354944,\"cacheWrite\":0,\"turns\":95},\"review-multiple-implemented-features\":{\"input\":2483284,\"output\":10394,\"cacheRead\":9985024,\"cacheWrite\":0,\"turns\":44}}}" +timestamp: 2026-07-17T14:42:34.466Z --- # Usage @@ -14,10 +14,32 @@ timestamp: 2026-07-17T13:54:38.269Z | --- | ---: | ---: | ---: | ---: | ---: | | openai-codex/gpt-5.6-terra | 175681 | 515 | 308736 | 0 | 5 | +## plan + +| model | input | output | cache read | cache write | turns | +| --- | ---: | ---: | ---: | ---: | ---: | +| openai-codex/gpt-5.6-terra | 58734 | 3635 | 366080 | 0 | 19 | + +## implement + +| model | input | output | cache read | cache write | turns | +| --- | ---: | ---: | ---: | ---: | ---: | +| openai-codex/gpt-5.6-terra | 235343 | 6089 | 2935808 | 0 | 43 | +| openai-codex/gpt-5.6-sol | 4385841 | 28968 | 23019008 | 0 | 126 | + +## review + +| model | input | output | cache read | cache write | turns | +| --- | ---: | ---: | ---: | ---: | ---: | +| openai-codex/gpt-5.6-sol | 2129601 | 5671 | 3197952 | 0 | 22 | + ## Per feature | feature | input | output | cache read | cache write | turns | | --- | ---: | ---: | ---: | ---: | ---: | | retire-plan | 175681 | 515 | 308736 | 0 | 5 | +| always-available-backlog | 436207 | 6962 | 3155456 | 0 | 47 | +| implement-ready-feature-wave | 3803315 | 22133 | 14354944 | 0 | 95 | +| review-multiple-implemented-features | 2483284 | 10394 | 9985024 | 0 | 44 | -Total: 175681 in / 515 out / 308736 cache-read / 0 cache-write over 5 turns. +Total: 6985200 in / 44878 out / 29827584 cache-read / 0 cache-write over 215 turns. diff --git a/skills/iterator-implement/SKILL.md b/skills/iterator-implement/SKILL.md index 7405499..91b8648 100644 --- a/skills/iterator-implement/SKILL.md +++ b/skills/iterator-implement/SKILL.md @@ -229,6 +229,23 @@ where the accept decision happens. commits (and any uncommitted rework) remain for the user to inspect — `status: done` is never set without an accept. +## Ready-wave dashboard action + +The Work dashboard's **Implement next wave** action snapshots every pending +feature reported dependency-ready by gather at click time, then dispatches one +`/skill:iterator-implement --auto` turn for each snapshot member. +Each turn still implements and commits exactly one named feature under this +skill's normal quality gates. Features unblocked later do not join the running +wave. Failed rounds are reported per feature and do not prevent the remaining +snapshot members from running. Pausing requeues the interrupted feature; +Continue waits for the aborted turn's lifecycle to finish, then retries it +before advancing the snapshot. The wave stops with +successful features at `implemented`; it never opens, accepts, or substitutes +for review. + +This is distinct from **Implement all (auto)**, whose driver performs the full +test → implement → agent-review loop across the plan. + ## Auto mode (`--auto`) When invoked as `/skill:iterator-implement --auto` (dispatched by diff --git a/skills/iterator-review/SKILL.md b/skills/iterator-review/SKILL.md index 15d20af..015477b 100644 --- a/skills/iterator-review/SKILL.md +++ b/skills/iterator-review/SKILL.md @@ -1,15 +1,18 @@ --- name: iterator-review -description: Review one implemented feature from the memory/ bundle — the diff is rebuilt from the feature's feature() commits (working tree only as fallback for uncommitted rounds), shown in the review UI in the browser (or reviewed non-interactively with --agent in auto mode); the verdict lands in the feature's Review section, and approval runs the deterministic accept-commit (status flip to done). Use when the user types /iterator-review, clicks Review on the dashboard, or wants to review a specific feature. +description: Review one implemented feature, or all implemented features together, from the memory/ bundle — diffs are rebuilt from feature commits, shown in the browser review UI (or reviewed non-interactively with --agent), and approval runs deterministic accept-commit. Use for /iterator-review, per-feature Review, or Review all. --- # iterator-review The review step of the iterator flow: **plan → feature → implement → review**. -Reviews **one feature per round** — normally the feature that was just flipped -to `implemented` by `/iterator-implement`. Approval runs the deterministic -accept-commit; a needs-work verdict records the notes and sends the feature -back to implementation (its status stays `implemented`). +Reviews **one feature per round** by default — normally the feature that was +just flipped to `implemented` by `/iterator-implement`. **Review all** is the +explicit exception: it combines every implemented feature's commit-backed diff +in one selectable review while preserving per-feature verdicts. Approval runs +the deterministic accept-commit; a needs-work verdict records the notes and +sends the affected feature back to implementation (its status stays +`implemented`). **pi mode:** see `/../iterator/PI.md`. @@ -21,7 +24,9 @@ or leave the feature implemented. ## When to use this skill When the user types `/iterator-review `, clicks Review on the dashboard, -or the auto-mode driver dispatches `/skill:iterator-review --agent`. +clicks **Review all**, or the auto-mode driver dispatches +`/skill:iterator-review --agent`. For `--all`, gather with feature scope +`all`; only implemented features with recorded commits belong to that scope. If no feature is named, gather the hub payload and pick the `implemented` feature; if none exists, say there is nothing to review and stop. @@ -31,6 +36,8 @@ feature; if none exists, say there is nothing to review and stop. ```sh node /../iterator/gather.mjs --step review --feature +# consolidated dashboard action: +node /../iterator/gather.mjs --step review --feature all ``` Do **not** diff by hand. An `implemented` or `done` feature with commits is @@ -51,13 +58,13 @@ files whose hunks were stripped from the payload — read those with `git show` ### 2a. Human review (default): open the review UI -Set `"mode": "commit"` on the payload (plus `"tests": {…}` per the feature's +Set `"mode": "commit"` on the payload (plus `"tests": {…}` per feature's recorded `tests_status` when it has tests) and pipe it into `node /../iterator/server.mjs` via a heredoc. Then process the result exactly like `/iterator-implement` step 5: -- `accept-commit` → pipe into `write.mjs` (`op: accept-commit`, the single - feature, dispositions verbatim). For an already-committed feature it just +- `accept-commit` → pipe into `write.mjs` (`op: accept-commit`, every returned + feature, dispositions verbatim). For already-committed features it just flips `status: done` and records the verdict (`accepted` in the result); a commit-less feature still gets the full staging + `feature()` commit path. Report `accepted` / `committed`, plus `defaulted` / diff --git a/skills/iterator/lib/gather.mjs b/skills/iterator/lib/gather.mjs index c79e8e3..6d8350b 100644 --- a/skills/iterator/lib/gather.mjs +++ b/skills/iterator/lib/gather.mjs @@ -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) @@ -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, @@ -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)) @@ -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 @@ -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) { diff --git a/skills/iterator/lib/ui.mjs b/skills/iterator/lib/ui.mjs index 71bc52b..5b6ac6f 100644 --- a/skills/iterator/lib/ui.mjs +++ b/skills/iterator/lib/ui.mjs @@ -117,7 +117,9 @@ body{font-family:var(--font-ui);font-size:var(--fs-md); body.iterator-ro [data-action],body.iterator-ro #primary,body.iterator-ro textarea, body.iterator-ro button.kbtn,body.iterator-ro button.act{pointer-events:none;opacity:.45} body.iterator-ro .rail button,body.iterator-ro .rail input,body.iterator-ro [data-open], -body.iterator-ro .mclose{pointer-events:auto;opacity:1} +body.iterator-ro .mclose,body.iterator-ro .backlog-active button.act, +body.iterator-ro .backlog-active textarea,body.iterator-ro .backlog-active input, +body.iterator-ro .backlog-active select{pointer-events:auto;opacity:1} body.iterator-ro::before{content:"read-only — agent working";position:fixed;top:0;left:50%; transform:translateX(-50%);z-index:100;font-family:var(--font-mono);font-size:var(--fs-xs); color:var(--dot-yellow);background:var(--bg-yellow);border:1px solid var(--dot-yellow); @@ -181,8 +183,8 @@ function primaryClick(){ if(typeof onPrimary==='function') onPrimary(); } // POST a payload to /submit; used by every step's onPrimary and by inline // round-trip actions (split/merge). Shows sending state on the primary button. -async function post(payload, okMsg){ - if(document.body.classList.contains('iterator-ro')){ +async function post(payload, okMsg, options){ + if(document.body.classList.contains('iterator-ro') && !options?.allowWhileWorking){ alert('Claude is working — actions are disabled until it finishes.'); return; } diff --git a/skills/iterator/lib/views/hub.mjs b/skills/iterator/lib/views/hub.mjs index 08e4e38..6c64059 100644 --- a/skills/iterator/lib/views/hub.mjs +++ b/skills/iterator/lib/views/hub.mjs @@ -20,7 +20,7 @@ * conflicts } ] } // # of flagged decision conflicts * retired: [ { name, title, created } ] // archived plans, newest first * output: one JSON line to stdout — - * { type:"action", action:"planning"|"test"|"implement"|"review"|"auto-implement" + * { type:"action", action:"planning"|"test"|"implement"|"review"|"review-all"|"implement-wave"|"auto-implement" * |"escalation-restart"|"escalation-guide", * feature:""|null, * prompt:""|null } // escalation-guide only @@ -112,15 +112,31 @@ function render(){ : 'not broken into features yet'); // Auto mode: once the feature set is approved (pending features exist), the // whole test → implement → review loop can run agent-driven. - const pendingReady = CH.some(c => c.status==='pending'); - if(pendingReady){ + const readyWave = Array.isArray(D.readyWave) ? D.readyWave : []; + if(readyWave.length){ + const wave = document.createElement('button'); + wave.className = 'act primary-act'; + wave.textContent = 'Implement next wave'; + wave.title = 'Implement the '+readyWave.length+' feature'+(readyWave.length!==1?'s':'')+' that are dependency-ready now; review remains a separate explicit step'; + wave.addEventListener('click', () => action('implement-wave', null, 'Implementing next ready wave')); + bar.insertBefore(wave, bar.querySelector('.pbar')); + const auto = document.createElement('button'); - auto.className = 'act primary-act'; + auto.className = 'act'; auto.textContent = 'Implement all (auto)'; auto.title = 'Run test \\u2192 implement \\u2192 review for every feature automatically; a reviewer agent stands in for you until escalation'; auto.addEventListener('click', () => action('auto-implement', null, 'Starting auto mode')); bar.insertBefore(auto, bar.querySelector('.pbar')); } + const reviewWave = Array.isArray(D.reviewWave) ? D.reviewWave : []; + if(reviewWave.length > 1){ + const reviewAll = document.createElement('button'); + reviewAll.className = 'act primary-act'; + reviewAll.textContent = 'Review all'; + reviewAll.title = 'Open one review with a feature selector and commit-backed diffs for '+reviewWave.length+' implemented features'; + reviewAll.addEventListener('click', () => action('review-all', null, 'Opening consolidated review')); + bar.insertBefore(reviewAll, bar.querySelector('.pbar')); + } // Tight git flow: surface working-tree dirt so leftovers never linger silently. if(D.dirty && D.dirty.count){ const dw = document.createElement('span'); diff --git a/skills/iterator/lib/views/planning.mjs b/skills/iterator/lib/views/planning.mjs index ef8ca04..c24e2c3 100644 --- a/skills/iterator/lib/views/planning.mjs +++ b/skills/iterator/lib/views/planning.mjs @@ -56,7 +56,7 @@ const CH = D.features || []; function backlogAction(payload, button, message){ if(button){ button.disabled = true; button.dataset.label = button.textContent; button.textContent = 'Saving…'; } - post({ type:'backlog', ...payload }, message || 'Saved'); + post({ type:'backlog', ...payload }, message || 'Saved', { allowWhileWorking:true }); } function selectedBacklogGoal(){ const selected = (D.backlog || []).filter(item => item.selected); @@ -267,7 +267,7 @@ function render(){ // Backlog: saved ideas and bugs, separate from active plan features. function renderBacklog(w){ const items = Array.isArray(D.backlog) ? D.backlog : []; - const section = document.createElement('section'); section.className = 'backlog'; + const section = document.createElement('section'); section.className = 'backlog backlog-active'; section.innerHTML = '

Idea backlog

Saved ideas and bugs stay separate from active plan features.

'; const form = document.createElement('form'); form.className = 'backlog-form'; form.innerHTML = ''+ diff --git a/skills/iterator/lib/views/review.mjs b/skills/iterator/lib/views/review.mjs index 15ffecf..70c2f7a 100644 --- a/skills/iterator/lib/views/review.mjs +++ b/skills/iterator/lib/views/review.mjs @@ -154,6 +154,11 @@ button.cs{background:var(--accent);border-color:var(--accent);color:var(--accent .fbhint{margin-top:8px;font-size:var(--fs-xs);color:var(--text-muted)} .sdot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:4px;vertical-align:0} .sdot.g{background:var(--dot-green)}.sdot.r{background:var(--dot-red)} +@media(max-width:640px){ + .main{flex-direction:column}.sidebar{width:100%;max-height:34vh;border-right:0;border-bottom:1px solid var(--border)} + .detail{padding:var(--sp-3);padding-bottom:130px}.fb{width:100%;border-left:0;border-radius:0} + .fi{min-height:44px}.sbtns{display:flex}.sbtns .sb{min-height:44px;flex:1} +} `; const BODY = ` @@ -573,9 +578,12 @@ refresh(); export function render(data) { const commitMode = data.mode === "commit"; + let subtitle = "/ review"; + if (commitMode) subtitle = "/ implement"; + if (data.multiReview) subtitle = "/ review all"; return renderPage({ step: "review", - subtitle: commitMode ? "/ implement" : "/ review", + subtitle, branch: data.branch, title: data.plan, data, diff --git a/test/gather.test.mjs b/test/gather.test.mjs index a750b88..8745af2 100644 --- a/test/gather.test.mjs +++ b/test/gather.test.mjs @@ -164,6 +164,11 @@ test("gather builds the hub payload from bundle + git state", () => { assert.equal(auth.status, "pending"); assert.deepEqual(auth.dependsOn, ["config-module"]); assert.equal(auth.ready, true, "done dependency satisfies"); + assert.deepEqual( + p.readyWave, + ["auth-middleware"], + "hub carries the server-derived ready-wave snapshot candidates", + ); assert.deepEqual(auth.waitingOn, []); assert.equal( auth.hasDiff, @@ -282,6 +287,55 @@ test("focused review favors its feature over an earlier overlapping file owner", } }); +test("consolidated review rebuilds each implemented feature from its own commits", () => { + const root = makeFixture(); + try { + const config = join(root, "memory", "features", "config-module.md"); + writeFileSync( + config, + readFileSync(config, "utf8").replace( + "status: done", + "status: implemented", + ), + ); + const auth = join(root, "memory", "features", "auth-middleware.md"); + writeFileSync( + auth, + readFileSync(auth, "utf8").replace( + "status: pending", + "status: implemented", + ), + ); + git(root, "add", "."); + git( + root, + "commit", + "-q", + "-m", + "feature(auth-middleware): auth\n\nFeature: auth-middleware", + ); + + const p = gatherReview(root, { feature: "all" }); + assert.equal(p.multiReview, true); + assert.equal(p.source, "commits"); + assert.deepEqual(p.reviewScope, ["config-module", "auth-middleware"]); + assert.deepEqual( + p.features.map((feature) => feature.name), + ["config-module", "auth-middleware"], + ); + assert.deepEqual( + p.features[0].files.map((file) => file.path), + ["src/config.ts"], + ); + assert.deepEqual( + p.features[1].files.map((file) => file.path), + ["src/auth/index.ts"], + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test("review payload carries actual diff stats per feature", () => { const root = makeFixture(); try { @@ -444,16 +498,20 @@ test("plan-review gathers the plan sections, features, and the whole-plan diff", assert.equal(p.plan.title, "Add JWT auth"); assert.equal(p.plan.planReviewed, null); assert.ok(p.plan.goal, "goal section rides along"); - assert.deepEqual( - p.features.map((f) => f.name).sort(), - ["auth-middleware", "config-module"], - ); + assert.deepEqual(p.features.map((f) => f.name).sort(), [ + "auth-middleware", + "config-module", + ]); const done = p.features.find((f) => f.name === "config-module"); assert.ok(done.commits.length >= 1, "feature commits resolved"); assert.ok(p.commits.length >= 1, "ordered commit list"); assert.equal(p.commits[0].feature, "config-module"); assert.ok(p.commits[0].sha && p.commits[0].subject); - assert.match(p.diff, /src\/config\.ts/, "the whole-plan diff covers the feature commit"); + assert.match( + p.diff, + /src\/config\.ts/, + "the whole-plan diff covers the feature commit", + ); assert.ok(!p.diff.includes("memory/"), "bundle bookkeeping excluded"); assert.equal(p.diffTruncated, false); } finally { diff --git a/test/pi-tools.test.mjs b/test/pi-tools.test.mjs index 2ba130a..d134c62 100644 --- a/test/pi-tools.test.mjs +++ b/test/pi-tools.test.mjs @@ -1,417 +1,836 @@ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { - actionToCommand, activityTextFromMessage, bundleExists, featuresDirEntries, - composeAmbientContext, extractPathsFromBash, footerText, mergePayload, runJson, - scriptPath, shouldNudge, uiPort, -} from '../lib/pi-tools.mjs'; - -test('mergePayload: extra wins, gathered object untouched, junk extra ignored', () => { - const gathered = { step: 'plan', title: 'old', dependencies: [] }; - const merged = mergePayload(gathered, { title: 'new', plan: { goal: 'g' } }); - assert.equal(merged.title, 'new'); - assert.deepEqual(merged.plan, { goal: 'g' }); - assert.equal(gathered.title, 'old', 'input must not be mutated'); - assert.deepEqual(mergePayload(gathered, null), gathered); - assert.deepEqual(mergePayload(gathered, 'nonsense'), gathered); -}); - -test('actionToCommand maps hub actions to skill commands', () => { - assert.equal(actionToCommand({ type: 'action', action: 'plan', feature: null }), '/skill:iterator-plan'); - assert.equal(actionToCommand({ type: 'action', action: 'feature', feature: null }), '/skill:iterator-feature'); - assert.equal(actionToCommand({ type: 'action', action: 'test', feature: 'auth' }), '/skill:iterator-test auth'); - assert.equal(actionToCommand({ type: 'action', action: 'implement', feature: 'auth' }), '/skill:iterator-implement auth'); - assert.equal(actionToCommand({ type: 'action', action: 'review', feature: 'auth' }), '/skill:iterator-review auth'); -}); - -test('actionToCommand returns null for cancel/timeout/garbage', () => { - assert.equal(actionToCommand({ type: 'cancel' }), null); - assert.equal(actionToCommand({ type: 'timeout' }), null); - assert.equal(actionToCommand({ type: 'action', action: 'rm -rf' }), null); - assert.equal(actionToCommand(null), null); - assert.equal(actionToCommand({}), null); -}); - -test('bundleExists and featuresDirEntries read the fixture bundle', () => { - const root = mkdtempSync(join(tmpdir(), 'iterator-pitools-')); - try { - mkdirSync(join(root, '.git'), { recursive: true }); // git root marker - assert.equal(bundleExists(root), false); - - mkdirSync(join(root, 'memory', 'features'), { recursive: true }); - writeFileSync(join(root, 'memory', 'plan.md'), '---\ntype: Plan\n---\n'); - assert.equal(bundleExists(root), true); - assert.equal(bundleExists(join(root)), true); - - writeFileSync(join(root, 'memory', 'features', 'auth.md'), - '---\ntype: Feature\nstatus: pending\ntests_status: red\n---\n'); - writeFileSync(join(root, 'memory', 'features', 'index.md'), '# Features\n'); - const entries = featuresDirEntries(root); - assert.deepEqual(entries.map(e => e.slug), ['auth']); - assert.equal(entries[0].fm.tests_status, 'red'); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - -test('runJson surfaces gather output and writer validation errors', async () => { - const root = mkdtempSync(join(tmpdir(), 'iterator-pitools-run-')); - try { - mkdirSync(join(root, '.git'), { recursive: true }); - // gather hub on an empty dir → create-plan shape - const hub = await runJson(scriptPath('gather'), ['--step', 'hub', root], {}); - assert.equal(hub.step, 'hub'); - assert.equal(hub.plan, null); - // writer refuses an unknown op with its own error message - await assert.rejects( - () => runJson(scriptPath('write'), [root], { stdin: '{"op":"nope"}' }), - /unknown op/); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - -test('actionToCommand maps knowledge actions to knowledge skills', () => { - assert.equal(actionToCommand({ type: 'action', action: 'iterator-init' }), '/skill:iterator-init'); - assert.equal(actionToCommand({ type: 'action', action: 'iterator-consolidate' }), '/skill:iterator-consolidate'); - assert.equal(actionToCommand({ type: 'action', action: 'iterator-memorize' }), '/skill:iterator-memorize'); - assert.equal(actionToCommand({ type: 'action', action: 'design' }), '/skill:iterator-design'); - assert.equal( - actionToCommand({ type: 'action', action: 'draft-memory', target: 'pitfalls', prompt: '' }), - '/skill:iterator-knowledge draft-memory pitfalls'); - assert.equal( - actionToCommand({ type: 'action', action: 'update-memory', target: 'pitfalls/gone-anchor', prompt: 'Re-anchor it.' }), - '/skill:iterator-knowledge update-memory pitfalls/gone-anchor — Re-anchor it.'); - assert.equal( - actionToCommand({ type: 'action', action: 'draft-memory-prompt', target: null, prompt: 'Capture the port story.' }), - '/skill:iterator-knowledge draft-memory-prompt — Capture the port story.'); - assert.equal(actionToCommand({ type: 'action', action: 'refresh-format' }), '/skill:iterator-knowledge refresh-format'); - assert.equal(actionToCommand({ type: 'action', action: 'close' }), null); -}); - -test('actionToCommand maps retire to the hub retirement flow', () => { - assert.equal(actionToCommand({ type: 'action', action: 'retire', feature: null }), - '/skill:iterator retire-plan'); -}); - -test('extractPathsFromBash finds path-looking tokens and dedupes', () => { - assert.deepEqual( - extractPathsFromBash('node ./lib/server.mjs test/a.test.mjs && cat lib/server.mjs'), - ['lib/server.mjs', 'test/a.test.mjs']); - assert.deepEqual(extractPathsFromBash('git status'), []); - assert.deepEqual(extractPathsFromBash(''), []); -}); - -test('composeAmbientContext builds the state line and anchored-knowledge list', () => { - const hub = { - plan: { title: 'Add JWT auth', status: 'approved' }, - progress: { done: 3, total: 7 }, - features: [ - { name: 'auth-middleware', testsStatus: 'red' }, - { name: 'config-module', testsStatus: 'green' }, - ], - }; - const implement = { next: { name: 'auth-middleware' } }; - const concepts = [{ - id: 'pitfalls/token-clock-skew', title: 'JWT clock skew', - description: 'Fresh tokens fail without leeway.', - ref: 'memory/pitfalls/token-clock-skew.md', - }]; - const out = composeAmbientContext(hub, implement, concepts); - assert.match(out, /Plan "Add JWT auth" — 3\/7 features done/); - assert.match(out, /next ready: auth-middleware/); - assert.match(out, /tests red: auth-middleware/); - assert.match(out, /\[pitfalls\/token-clock-skew\] JWT clock skew — Fresh tokens fail without leeway\. \(memory\/pitfalls\/token-clock-skew\.md\)/); - - // Knowledge lines alone still inject; nothing at all → null. - assert.match(composeAmbientContext({ plan: null }, null, concepts), /token-clock-skew/); - assert.equal(composeAmbientContext({ plan: null }, null, []), null); - // No red tests → no red segment. - const quiet = composeAmbientContext({ ...hub, features: [] }, { next: null }, []); - assert.doesNotMatch(quiet, /tests red/); - assert.match(quiet, /next ready: none/); -}); - -test('footerText composes segments and omits what is absent', () => { - const hub = { - plan: { title: 'X' }, progress: { done: 3, total: 7 }, - features: [{ name: 'a', testsStatus: 'red' }, { name: 'b', testsStatus: 'green' }], - }; - assert.equal(footerText(hub, { next: { name: 'auth-middleware' } }, 4), - '⛭ 3/7 · next: auth-middleware · 🔴 1 red · 🧠 4 unmemorized'); - assert.equal(footerText(hub, { next: null }, 0), '⛭ 3/7 · 🔴 1 red'); - assert.equal(footerText({ plan: null }, null, 4), '🧠 4 unmemorized'); - assert.equal(footerText({ plan: null }, null, 0), null); -}); - -test('footerText trails the ui port rightmost, and shows it with no plan', () => { - const hub = { - plan: { title: 'X' }, progress: { done: 3, total: 7 }, - features: [{ name: 'a', testsStatus: 'red' }, { name: 'b', testsStatus: 'green' }], - }; - assert.equal(footerText(hub, { next: { name: 'auth' } }, 4, 53421), - '⛭ 3/7 · next: auth · 🔴 1 red · 🧠 4 unmemorized · 🌐 ui:53421'); - assert.equal(footerText({ plan: null }, null, 0, 53421), '🌐 ui:53421', - 'the port is a property of the agent — it shows with nothing else to say'); - assert.equal(footerText({ plan: null }, null, 0, null), null, - 'no port and nothing else clears the segment'); -}); - -test('uiPort reads ITERATOR_DISPLAY_PORT and rejects junk', () => { - const none = '/nonexistent/.pisbx-env'; - assert.equal(uiPort({ ITERATOR_DISPLAY_PORT: '53421' }, none), 53421); - assert.equal(uiPort({}, none), null, 'unset — not sandboxed'); - assert.equal(uiPort({ ITERATOR_DISPLAY_PORT: '' }, none), null); - assert.equal(uiPort({ ITERATOR_DISPLAY_PORT: 'nonsense' }, none), null); - assert.equal(uiPort({ ITERATOR_DISPLAY_PORT: '0' }, none), null, '0 is not a port'); - assert.equal(uiPort({ ITERATOR_DISPLAY_PORT: '-1' }, none), null); -}); - -test('uiPort falls back to ~/.pisbx-env — sbx run never sources it into the env', (t) => { - const dir = mkdtempSync(join(tmpdir(), 'pisbx-')); - t.after(() => rmSync(dir, { recursive: true, force: true })); - const file = join(dir, '.pisbx-env'); - - // The exact line pisbx writes. - writeFileSync(file, 'export ITERATOR_DISPLAY_PORT=49159\n'); - assert.equal(uiPort({}, file), 49159, 'reads the port pisbx wrote'); - assert.equal(uiPort({ ITERATOR_DISPLAY_PORT: '53421' }, file), 53421, - 'a real env var still wins over the file'); - - writeFileSync(file, 'ITERATOR_DISPLAY_PORT="49160"\n'); - assert.equal(uiPort({}, file), 49160, 'bare/quoted assignment also parses'); - - writeFileSync(file, 'export SOMETHING_ELSE=1\n'); - assert.equal(uiPort({}, file), null, 'unrelated file — no port'); - - writeFileSync(file, 'export ITERATOR_DISPLAY_PORT=nonsense\n'); - assert.equal(uiPort({}, file), null, 'junk in the file is not a port'); - - assert.equal(uiPort({}, join(dir, 'missing')), null, 'missing file never throws'); -}); - -test('shouldNudge fires once per threshold-multiple and can be disabled', () => { - assert.equal(shouldNudge(4, 0, 5), false, 'below threshold'); - assert.equal(shouldNudge(5, 0, 5), true, 'reaches threshold'); - assert.equal(shouldNudge(7, 5, 5), false, 'already nudged at 5 — wait for 10'); - assert.equal(shouldNudge(10, 5, 5), true, 'a full threshold past the last nudge'); - assert.equal(shouldNudge(100, 0, 0), false, 'threshold 0 disables'); - assert.equal(shouldNudge(100, 0, NaN), false, 'unparseable env disables'); -}); - -test('actionToCommand carries a typed plan goal through to the skills', () => { - assert.equal( - actionToCommand({ type: 'action', action: 'plan', feature: null, prompt: 'Build a CLI for tides' }), - '/skill:iterator-plan — Build a CLI for tides', - ); - assert.equal( - actionToCommand({ type: 'action', action: 'iterator-init', prompt: 'Build a CLI for tides' }), - '/skill:iterator-init — when initialization finishes, continue into /skill:iterator-plan — Build a CLI for tides', - ); - // No prompt → unchanged classic forms. - assert.equal(actionToCommand({ type: 'action', action: 'plan', feature: null }), '/skill:iterator-plan'); - assert.equal(actionToCommand({ type: 'action', action: 'iterator-init' }), '/skill:iterator-init'); -}); - -test('attributionFromInput maps flow commands to ledger steps', async () => { - const { attributionFromInput } = await import('../lib/pi-tools.mjs'); - assert.deepEqual(attributionFromInput('/skill:iterator-implement auth-middleware'), - { step: 'implement', feature: 'auth-middleware' }); - assert.deepEqual(attributionFromInput('/iterator-plan — build a tide CLI'), - { step: 'plan', feature: null }); - assert.deepEqual(attributionFromInput('/iterator-memorize'), { step: 'memory', feature: null }); - assert.deepEqual(attributionFromInput('/iterator-next'), { step: 'implement', feature: null }); - assert.equal(attributionFromInput('fix the login bug'), null, 'plain prose keeps the previous attribution'); - assert.equal(attributionFromInput('/help'), null); -}); - -test('usageRowFromMessage extracts assistant usage with attribution', async () => { - const { usageRowFromMessage } = await import('../lib/pi-tools.mjs'); - const msg = { - role: 'assistant', provider: 'openai', model: 'gpt-5.5', - usage: { input: 100, output: 40, cacheRead: 10, cacheWrite: 2 }, - }; - assert.deepEqual(usageRowFromMessage(msg, { step: 'review', feature: 'auth' }), { - step: 'review', feature: 'auth', provider: 'openai', model: 'gpt-5.5', - input: 100, output: 40, cacheRead: 10, cacheWrite: 2, - }); - assert.equal(usageRowFromMessage(msg, null).step, 'other'); - assert.equal(usageRowFromMessage({ role: 'user' }, null), null); - assert.equal(usageRowFromMessage({ role: 'assistant' }, null), null, 'no usage → no row'); + actionToCommand, + activityTextFromMessage, + bundleExists, + featuresDirEntries, + composeAmbientContext, + extractPathsFromBash, + footerText, + mergePayload, + runJson, + scriptPath, + shouldNudge, + uiPort, +} from "../lib/pi-tools.mjs"; + +test("mergePayload: extra wins, gathered object untouched, junk extra ignored", () => { + const gathered = { step: "plan", title: "old", dependencies: [] }; + const merged = mergePayload(gathered, { title: "new", plan: { goal: "g" } }); + assert.equal(merged.title, "new"); + assert.deepEqual(merged.plan, { goal: "g" }); + assert.equal(gathered.title, "old", "input must not be mutated"); + assert.deepEqual(mergePayload(gathered, null), gathered); + assert.deepEqual(mergePayload(gathered, "nonsense"), gathered); +}); + +test("actionToCommand maps hub actions to skill commands", () => { + assert.equal( + actionToCommand({ type: "action", action: "plan", feature: null }), + "/skill:iterator-plan", + ); + assert.equal( + actionToCommand({ type: "action", action: "feature", feature: null }), + "/skill:iterator-feature", + ); + assert.equal( + actionToCommand({ type: "action", action: "test", feature: "auth" }), + "/skill:iterator-test auth", + ); + assert.equal( + actionToCommand({ type: "action", action: "implement", feature: "auth" }), + "/skill:iterator-implement auth", + ); + assert.equal( + actionToCommand({ type: "action", action: "review", feature: "auth" }), + "/skill:iterator-review auth", + ); + assert.equal( + actionToCommand({ type: "action", action: "review-all" }), + "/skill:iterator-review --all", + ); +}); + +test("actionToCommand returns null for cancel/timeout/garbage", () => { + assert.equal(actionToCommand({ type: "cancel" }), null); + assert.equal(actionToCommand({ type: "timeout" }), null); + assert.equal(actionToCommand({ type: "action", action: "rm -rf" }), null); + assert.equal(actionToCommand(null), null); + assert.equal(actionToCommand({}), null); +}); + +test("bundleExists and featuresDirEntries read the fixture bundle", () => { + const root = mkdtempSync(join(tmpdir(), "iterator-pitools-")); + try { + mkdirSync(join(root, ".git"), { recursive: true }); // git root marker + assert.equal(bundleExists(root), false); + + mkdirSync(join(root, "memory", "features"), { recursive: true }); + writeFileSync(join(root, "memory", "plan.md"), "---\ntype: Plan\n---\n"); + assert.equal(bundleExists(root), true); + assert.equal(bundleExists(join(root)), true); + + writeFileSync( + join(root, "memory", "features", "auth.md"), + "---\ntype: Feature\nstatus: pending\ntests_status: red\n---\n", + ); + writeFileSync(join(root, "memory", "features", "index.md"), "# Features\n"); + const entries = featuresDirEntries(root); + assert.deepEqual( + entries.map((e) => e.slug), + ["auth"], + ); + assert.equal(entries[0].fm.tests_status, "red"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("runJson surfaces gather output and writer validation errors", async () => { + const root = mkdtempSync(join(tmpdir(), "iterator-pitools-run-")); + try { + mkdirSync(join(root, ".git"), { recursive: true }); + // gather hub on an empty dir → create-plan shape + const hub = await runJson( + scriptPath("gather"), + ["--step", "hub", root], + {}, + ); + assert.equal(hub.step, "hub"); + assert.equal(hub.plan, null); + // writer refuses an unknown op with its own error message + await assert.rejects( + () => runJson(scriptPath("write"), [root], { stdin: '{"op":"nope"}' }), + /unknown op/, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("actionToCommand maps knowledge actions to knowledge skills", () => { + assert.equal( + actionToCommand({ type: "action", action: "iterator-init" }), + "/skill:iterator-init", + ); + assert.equal( + actionToCommand({ type: "action", action: "iterator-consolidate" }), + "/skill:iterator-consolidate", + ); + assert.equal( + actionToCommand({ type: "action", action: "iterator-memorize" }), + "/skill:iterator-memorize", + ); + assert.equal( + actionToCommand({ type: "action", action: "design" }), + "/skill:iterator-design", + ); + assert.equal( + actionToCommand({ + type: "action", + action: "draft-memory", + target: "pitfalls", + prompt: "", + }), + "/skill:iterator-knowledge draft-memory pitfalls", + ); + assert.equal( + actionToCommand({ + type: "action", + action: "update-memory", + target: "pitfalls/gone-anchor", + prompt: "Re-anchor it.", + }), + "/skill:iterator-knowledge update-memory pitfalls/gone-anchor — Re-anchor it.", + ); + assert.equal( + actionToCommand({ + type: "action", + action: "draft-memory-prompt", + target: null, + prompt: "Capture the port story.", + }), + "/skill:iterator-knowledge draft-memory-prompt — Capture the port story.", + ); + assert.equal( + actionToCommand({ type: "action", action: "refresh-format" }), + "/skill:iterator-knowledge refresh-format", + ); + assert.equal(actionToCommand({ type: "action", action: "close" }), null); +}); + +test("actionToCommand maps retire to the hub retirement flow", () => { + assert.equal( + actionToCommand({ type: "action", action: "retire", feature: null }), + "/skill:iterator retire-plan", + ); +}); + +test("extractPathsFromBash finds path-looking tokens and dedupes", () => { + assert.deepEqual( + extractPathsFromBash( + "node ./lib/server.mjs test/a.test.mjs && cat lib/server.mjs", + ), + ["lib/server.mjs", "test/a.test.mjs"], + ); + assert.deepEqual(extractPathsFromBash("git status"), []); + assert.deepEqual(extractPathsFromBash(""), []); +}); + +test("composeAmbientContext builds the state line and anchored-knowledge list", () => { + const hub = { + plan: { title: "Add JWT auth", status: "approved" }, + progress: { done: 3, total: 7 }, + features: [ + { name: "auth-middleware", testsStatus: "red" }, + { name: "config-module", testsStatus: "green" }, + ], + }; + const implement = { next: { name: "auth-middleware" } }; + const concepts = [ + { + id: "pitfalls/token-clock-skew", + title: "JWT clock skew", + description: "Fresh tokens fail without leeway.", + ref: "memory/pitfalls/token-clock-skew.md", + }, + ]; + const out = composeAmbientContext(hub, implement, concepts); + assert.match(out, /Plan "Add JWT auth" — 3\/7 features done/); + assert.match(out, /next ready: auth-middleware/); + assert.match(out, /tests red: auth-middleware/); + assert.match( + out, + /\[pitfalls\/token-clock-skew\] JWT clock skew — Fresh tokens fail without leeway\. \(memory\/pitfalls\/token-clock-skew\.md\)/, + ); + + // Knowledge lines alone still inject; nothing at all → null. + assert.match( + composeAmbientContext({ plan: null }, null, concepts), + /token-clock-skew/, + ); + assert.equal(composeAmbientContext({ plan: null }, null, []), null); + // No red tests → no red segment. + const quiet = composeAmbientContext( + { ...hub, features: [] }, + { next: null }, + [], + ); + assert.doesNotMatch(quiet, /tests red/); + assert.match(quiet, /next ready: none/); +}); + +test("footerText composes segments and omits what is absent", () => { + const hub = { + plan: { title: "X" }, + progress: { done: 3, total: 7 }, + features: [ + { name: "a", testsStatus: "red" }, + { name: "b", testsStatus: "green" }, + ], + }; + assert.equal( + footerText(hub, { next: { name: "auth-middleware" } }, 4), + "⛭ 3/7 · next: auth-middleware · 🔴 1 red · 🧠 4 unmemorized", + ); + assert.equal(footerText(hub, { next: null }, 0), "⛭ 3/7 · 🔴 1 red"); + assert.equal(footerText({ plan: null }, null, 4), "🧠 4 unmemorized"); + assert.equal(footerText({ plan: null }, null, 0), null); +}); + +test("footerText trails the ui port rightmost, and shows it with no plan", () => { + const hub = { + plan: { title: "X" }, + progress: { done: 3, total: 7 }, + features: [ + { name: "a", testsStatus: "red" }, + { name: "b", testsStatus: "green" }, + ], + }; + assert.equal( + footerText(hub, { next: { name: "auth" } }, 4, 53421), + "⛭ 3/7 · next: auth · 🔴 1 red · 🧠 4 unmemorized · 🌐 ui:53421", + ); + assert.equal( + footerText({ plan: null }, null, 0, 53421), + "🌐 ui:53421", + "the port is a property of the agent — it shows with nothing else to say", + ); + assert.equal( + footerText({ plan: null }, null, 0, null), + null, + "no port and nothing else clears the segment", + ); +}); + +test("uiPort reads ITERATOR_DISPLAY_PORT and rejects junk", () => { + const none = "/nonexistent/.pisbx-env"; + assert.equal(uiPort({ ITERATOR_DISPLAY_PORT: "53421" }, none), 53421); + assert.equal(uiPort({}, none), null, "unset — not sandboxed"); + assert.equal(uiPort({ ITERATOR_DISPLAY_PORT: "" }, none), null); + assert.equal(uiPort({ ITERATOR_DISPLAY_PORT: "nonsense" }, none), null); + assert.equal( + uiPort({ ITERATOR_DISPLAY_PORT: "0" }, none), + null, + "0 is not a port", + ); + assert.equal(uiPort({ ITERATOR_DISPLAY_PORT: "-1" }, none), null); +}); + +test("uiPort falls back to ~/.pisbx-env — sbx run never sources it into the env", (t) => { + const dir = mkdtempSync(join(tmpdir(), "pisbx-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const file = join(dir, ".pisbx-env"); + + // The exact line pisbx writes. + writeFileSync(file, "export ITERATOR_DISPLAY_PORT=49159\n"); + assert.equal(uiPort({}, file), 49159, "reads the port pisbx wrote"); + assert.equal( + uiPort({ ITERATOR_DISPLAY_PORT: "53421" }, file), + 53421, + "a real env var still wins over the file", + ); + + writeFileSync(file, 'ITERATOR_DISPLAY_PORT="49160"\n'); + assert.equal(uiPort({}, file), 49160, "bare/quoted assignment also parses"); + + writeFileSync(file, "export SOMETHING_ELSE=1\n"); + assert.equal(uiPort({}, file), null, "unrelated file — no port"); + + writeFileSync(file, "export ITERATOR_DISPLAY_PORT=nonsense\n"); + assert.equal(uiPort({}, file), null, "junk in the file is not a port"); + + assert.equal( + uiPort({}, join(dir, "missing")), + null, + "missing file never throws", + ); +}); + +test("shouldNudge fires once per threshold-multiple and can be disabled", () => { + assert.equal(shouldNudge(4, 0, 5), false, "below threshold"); + assert.equal(shouldNudge(5, 0, 5), true, "reaches threshold"); + assert.equal( + shouldNudge(7, 5, 5), + false, + "already nudged at 5 — wait for 10", + ); + assert.equal( + shouldNudge(10, 5, 5), + true, + "a full threshold past the last nudge", + ); + assert.equal(shouldNudge(100, 0, 0), false, "threshold 0 disables"); + assert.equal(shouldNudge(100, 0, NaN), false, "unparseable env disables"); +}); + +test("actionToCommand carries a typed plan goal through to the skills", () => { + assert.equal( + actionToCommand({ + type: "action", + action: "plan", + feature: null, + prompt: "Build a CLI for tides", + }), + "/skill:iterator-plan — Build a CLI for tides", + ); + assert.equal( + actionToCommand({ + type: "action", + action: "iterator-init", + prompt: "Build a CLI for tides", + }), + "/skill:iterator-init — when initialization finishes, continue into /skill:iterator-plan — Build a CLI for tides", + ); + // No prompt → unchanged classic forms. + assert.equal( + actionToCommand({ type: "action", action: "plan", feature: null }), + "/skill:iterator-plan", + ); + assert.equal( + actionToCommand({ type: "action", action: "iterator-init" }), + "/skill:iterator-init", + ); +}); + +test("attributionFromInput maps flow commands to ledger steps", async () => { + const { attributionFromInput } = await import("../lib/pi-tools.mjs"); + assert.deepEqual( + attributionFromInput("/skill:iterator-implement auth-middleware"), + { step: "implement", feature: "auth-middleware" }, + ); + assert.deepEqual(attributionFromInput("/iterator-plan — build a tide CLI"), { + step: "plan", + feature: null, + }); + assert.deepEqual(attributionFromInput("/iterator-memorize"), { + step: "memory", + feature: null, + }); + assert.deepEqual(attributionFromInput("/iterator-next"), { + step: "implement", + feature: null, + }); + assert.equal( + attributionFromInput("fix the login bug"), + null, + "plain prose keeps the previous attribution", + ); + assert.equal(attributionFromInput("/help"), null); +}); + +test("usageRowFromMessage extracts assistant usage with attribution", async () => { + const { usageRowFromMessage } = await import("../lib/pi-tools.mjs"); + const msg = { + role: "assistant", + provider: "openai", + model: "gpt-5.5", + usage: { input: 100, output: 40, cacheRead: 10, cacheWrite: 2 }, + }; + assert.deepEqual( + usageRowFromMessage(msg, { step: "review", feature: "auth" }), + { + step: "review", + feature: "auth", + provider: "openai", + model: "gpt-5.5", + input: 100, + output: 40, + cacheRead: 10, + cacheWrite: 2, + }, + ); + assert.equal(usageRowFromMessage(msg, null).step, "other"); + assert.equal(usageRowFromMessage({ role: "user" }, null), null); + assert.equal( + usageRowFromMessage({ role: "assistant" }, null), + null, + "no usage → no row", + ); }); // --------------------------------------------------------------------------- // Auto mode state machine -const { nextAutoAction, roleModelSpec, AUTO_PHASE_FOR_STEP } = await import('../lib/pi-tools.mjs'); +const { + completeFeatureWaveAbort, + nextAutoAction, + nextFeatureWaveAction, + pauseFeatureWave, + roleModelSpec, + AUTO_PHASE_FOR_STEP, +} = await import("../lib/pi-tools.mjs"); const S = (over = {}) => ({ - auto_mode: 'on', testing_default: 'on', max_review_iterations: 3, - block_commit_on_leftovers: 'on', ...over, -}); -const ST = (over = {}) => ({ mode: 'auto', paused: false, phase: 'implementing', active_feature: null, strikes: {}, ...over }); -const sess = ({ features = [], next = null, drafts = [], stuck = false, done = 0, total = features.length } = {}) => ({ - hub: { plan: { title: 'P' }, progress: { done, total }, features }, - implement: { next, drafts, stuck }, -}); - -test('nextAutoAction is inert outside active auto mode', () => { - const s = sess({ features: [{ name: 'a', status: 'pending' }], next: { name: 'a', testsStatus: 'none' } }); - assert.equal(nextAutoAction(s, S(), ST({ mode: 'manual' })), null); - assert.equal(nextAutoAction(s, S(), ST({ paused: true })), null); - assert.equal(nextAutoAction({ hub: { plan: null } }, S(), ST()), null, 'no plan'); -}); - -test('nextAutoAction dispatches test → implement → review from bundle state', () => { - const feature = { name: 'a', status: 'pending' }; - // No tests yet + testing on → tester turn. - let a = nextAutoAction(sess({ features: [feature], next: { name: 'a', testsStatus: 'none' } }), S(), ST()); - assert.deepEqual(a, { step: 'test', role: 'tester', feature: 'a', cmd: '/skill:iterator-test a --auto' }); - assert.equal(AUTO_PHASE_FOR_STEP[a.step], 'testing'); - // Tests red, still pending → implementer turn. - a = nextAutoAction(sess({ features: [feature], next: { name: 'a', testsStatus: 'red' } }), S(), ST()); - assert.equal(a.step, 'implement'); - assert.equal(a.cmd, '/skill:iterator-implement a --auto'); - // Testing off skips straight to implement. - a = nextAutoAction(sess({ features: [feature], next: { name: 'a', testsStatus: 'none' } }), S({ testing_default: 'off' }), ST()); - assert.equal(a.step, 'implement'); - // Feature flipped to implemented → reviewer turn (a status, not a diff - // heuristic), even when no other feature is ready. - a = nextAutoAction(sess({ features: [{ name: 'a', status: 'implemented' }], next: null }), S(), ST()); - assert.deepEqual(a, { step: 'review', role: 'reviewer', feature: 'a', cmd: '/skill:iterator-review a --agent' }); - // Awaiting review is never misread as stuck. - a = nextAutoAction( - sess({ features: [{ name: 'a', status: 'implemented' }, { name: 'b', status: 'pending' }], next: null, total: 2 }), - S(), ST(), - ); - assert.equal(a.step, 'review'); -}); - -test('nextAutoAction reads the review verdict from the bundle and strikes', () => { - // Review round returned, feature NOT done → needs-work → strike + rework. - const s = sess({ features: [{ name: 'a', status: 'pending', hasDiff: true }], next: { name: 'a', testsStatus: 'red' } }); - let a = nextAutoAction(s, S(), ST({ phase: 'reviewing', active_feature: 'a' })); - assert.equal(a.step, 'implement'); - assert.equal(a.strike, 'a'); - // Two prior strikes: the third failure escalates. - a = nextAutoAction(s, S(), ST({ phase: 'reviewing', active_feature: 'a', strikes: { a: 2 } })); - assert.equal(a.escalate, true); - assert.match(a.reason, /failed agent review 3/); - // Feature done → approved: the plan is finished, so the once-only - // whole-plan review dispatches before done. - const approved = sess({ features: [{ name: 'a', status: 'done' }], next: null, done: 1, total: 1 }); - a = nextAutoAction(approved, S(), ST({ phase: 'reviewing', active_feature: 'a' })); - assert.deepEqual(a, { step: 'plan-review', role: 'plan_reviewer', cmd: '/skill:iterator-review-plan --auto' }); - // plan_reviewed recorded → truly done (no loop around the plan review). - const reviewed = sess({ features: [{ name: 'a', status: 'done' }], next: null, done: 1, total: 1 }); - reviewed.hub.plan.planReviewed = '2026-07-15'; - a = nextAutoAction(reviewed, S(), ST({ phase: 'reviewing', active_feature: null })); - assert.deepEqual(a, { done: true }); -}); - -test('nextAutoAction escalates on conflicts, prior strikes, drafts, and stuck graphs', () => { - let a = nextAutoAction( - sess({ features: [{ name: 'a', status: 'pending' }], next: { name: 'a', testsStatus: 'red', conflicts: [{ decision: 'decisions/no-orm' }] } }), - S(), ST(), - ); - assert.equal(a.escalate, true); - assert.match(a.reason, /decisions\/no-orm/); - - a = nextAutoAction( - sess({ features: [{ name: 'a', status: 'pending' }], next: { name: 'a', testsStatus: 'red' } }), - S(), ST({ strikes: { a: 3 } }), - ); - assert.equal(a.escalate, true); - - a = nextAutoAction(sess({ features: [], next: null, drafts: ['d'] }), S(), ST()); - assert.match(a.reason, /draft/); - - a = nextAutoAction(sess({ features: [{ name: 'a', status: 'pending' }], next: null, stuck: true, total: 1 }), S(), ST()); - assert.match(a.reason, /cycle or missing/); -}); - -test('roleModelSpec resolves overrides and leaves active alone', () => { - const settings = { - reviewer_model: 'anthropic/claude-opus-4-8', reviewer_thinking: 'high', - implementer_model: 'active', implementer_thinking: 'medium', - }; - assert.deepEqual(roleModelSpec(settings, 'reviewer'), { model: 'anthropic/claude-opus-4-8', thinking: 'high' }); - assert.deepEqual(roleModelSpec(settings, 'implementer'), { model: null, thinking: 'medium' }); - assert.deepEqual(roleModelSpec({}, 'tester'), { model: null, thinking: null }); + auto_mode: "on", + testing_default: "on", + max_review_iterations: 3, + block_commit_on_leftovers: "on", + ...over, +}); +const ST = (over = {}) => ({ + mode: "auto", + paused: false, + phase: "implementing", + active_feature: null, + strikes: {}, + ...over, +}); +const sess = ({ + features = [], + next = null, + drafts = [], + stuck = false, + done = 0, + total = features.length, +} = {}) => ({ + hub: { plan: { title: "P" }, progress: { done, total }, features }, + implement: { next, drafts, stuck }, +}); + +test("nextFeatureWaveAction freezes the queue and reports each implementation result", () => { + let wave = { queue: ["a", "b"], active: null, results: [] }; + let decision = nextFeatureWaveAction(wave, [ + { name: "a", status: "pending", conflicts: 0 }, + { name: "b", status: "pending", conflicts: 0 }, + { name: "later", status: "pending", conflicts: 0 }, + ]); + assert.equal(decision.action.feature, "a"); + assert.equal(decision.action.cmd, "/skill:iterator-implement a --auto"); + assert.deepEqual( + decision.wave.queue, + ["b"], + "newly ready features never join the snapshot", + ); + + wave = decision.wave; + decision = nextFeatureWaveAction(wave, [ + { name: "a", status: "implemented", conflicts: 0 }, + { name: "b", status: "pending", conflicts: 0 }, + { name: "later", status: "pending", conflicts: 0 }, + ]); + assert.equal(decision.action.feature, "b"); + assert.deepEqual(decision.wave.results, [ + { feature: "a", status: "implemented" }, + ]); + + decision = nextFeatureWaveAction(decision.wave, [ + { name: "a", status: "implemented", conflicts: 0 }, + { name: "b", status: "pending", conflicts: 0 }, + ]); + assert.equal(decision.done, true); + assert.deepEqual(decision.wave.results, [ + { feature: "a", status: "implemented" }, + { feature: "b", status: "failed" }, + ]); +}); + +test("pauseFeatureWave waits for the aborted agent_end in either Continue ordering", () => { + const features = [ + { name: "a", status: "pending", conflicts: 0 }, + { name: "b", status: "pending", conflicts: 0 }, + ]; + const paused = pauseFeatureWave({ queue: ["b"], active: "a", results: [] }); + assert.deepEqual(paused, { + queue: ["a", "b"], + active: null, + results: [], + abortPending: true, + }); + + // Continue before the stale agent_end must not dispatch yet. + const earlyContinue = nextFeatureWaveAction(paused, features); + assert.equal(earlyContinue.waiting, true); + assert.equal(earlyContinue.action, undefined); + const afterEarlyAgentEnd = completeFeatureWaveAbort(earlyContinue.wave); + const earlyResumed = nextFeatureWaveAction(afterEarlyAgentEnd, features); + assert.equal(earlyResumed.action.feature, "a"); + assert.deepEqual(earlyResumed.wave.results, []); + + // If agent_end arrives while still paused, a later Continue dispatches once. + const afterLateAgentEnd = completeFeatureWaveAbort(paused); + const lateResumed = nextFeatureWaveAction(afterLateAgentEnd, features); + assert.equal(lateResumed.action.feature, "a"); + assert.deepEqual(lateResumed.wave.results, []); +}); + +test("nextFeatureWaveAction skips conflicts without dispatching them", () => { + const decision = nextFeatureWaveAction( + { queue: ["blocked"], active: null, results: [] }, + [{ name: "blocked", status: "pending", conflicts: 1 }], + ); + assert.equal(decision.done, true); + assert.deepEqual(decision.wave.results, [ + { feature: "blocked", status: "conflict" }, + ]); +}); + +test("nextAutoAction is inert outside active auto mode", () => { + const s = sess({ + features: [{ name: "a", status: "pending" }], + next: { name: "a", testsStatus: "none" }, + }); + assert.equal(nextAutoAction(s, S(), ST({ mode: "manual" })), null); + assert.equal(nextAutoAction(s, S(), ST({ paused: true })), null); + assert.equal( + nextAutoAction({ hub: { plan: null } }, S(), ST()), + null, + "no plan", + ); +}); + +test("nextAutoAction dispatches test → implement → review from bundle state", () => { + const feature = { name: "a", status: "pending" }; + // No tests yet + testing on → tester turn. + let a = nextAutoAction( + sess({ features: [feature], next: { name: "a", testsStatus: "none" } }), + S(), + ST(), + ); + assert.deepEqual(a, { + step: "test", + role: "tester", + feature: "a", + cmd: "/skill:iterator-test a --auto", + }); + assert.equal(AUTO_PHASE_FOR_STEP[a.step], "testing"); + // Tests red, still pending → implementer turn. + a = nextAutoAction( + sess({ features: [feature], next: { name: "a", testsStatus: "red" } }), + S(), + ST(), + ); + assert.equal(a.step, "implement"); + assert.equal(a.cmd, "/skill:iterator-implement a --auto"); + // Testing off skips straight to implement. + a = nextAutoAction( + sess({ features: [feature], next: { name: "a", testsStatus: "none" } }), + S({ testing_default: "off" }), + ST(), + ); + assert.equal(a.step, "implement"); + // Feature flipped to implemented → reviewer turn (a status, not a diff + // heuristic), even when no other feature is ready. + a = nextAutoAction( + sess({ features: [{ name: "a", status: "implemented" }], next: null }), + S(), + ST(), + ); + assert.deepEqual(a, { + step: "review", + role: "reviewer", + feature: "a", + cmd: "/skill:iterator-review a --agent", + }); + // Awaiting review is never misread as stuck. + a = nextAutoAction( + sess({ + features: [ + { name: "a", status: "implemented" }, + { name: "b", status: "pending" }, + ], + next: null, + total: 2, + }), + S(), + ST(), + ); + assert.equal(a.step, "review"); +}); + +test("nextAutoAction reads the review verdict from the bundle and strikes", () => { + // Review round returned, feature NOT done → needs-work → strike + rework. + const s = sess({ + features: [{ name: "a", status: "pending", hasDiff: true }], + next: { name: "a", testsStatus: "red" }, + }); + let a = nextAutoAction( + s, + S(), + ST({ phase: "reviewing", active_feature: "a" }), + ); + assert.equal(a.step, "implement"); + assert.equal(a.strike, "a"); + // Two prior strikes: the third failure escalates. + a = nextAutoAction( + s, + S(), + ST({ phase: "reviewing", active_feature: "a", strikes: { a: 2 } }), + ); + assert.equal(a.escalate, true); + assert.match(a.reason, /failed agent review 3/); + // Feature done → approved: the plan is finished, so the once-only + // whole-plan review dispatches before done. + const approved = sess({ + features: [{ name: "a", status: "done" }], + next: null, + done: 1, + total: 1, + }); + a = nextAutoAction( + approved, + S(), + ST({ phase: "reviewing", active_feature: "a" }), + ); + assert.deepEqual(a, { + step: "plan-review", + role: "plan_reviewer", + cmd: "/skill:iterator-review-plan --auto", + }); + // plan_reviewed recorded → truly done (no loop around the plan review). + const reviewed = sess({ + features: [{ name: "a", status: "done" }], + next: null, + done: 1, + total: 1, + }); + reviewed.hub.plan.planReviewed = "2026-07-15"; + a = nextAutoAction( + reviewed, + S(), + ST({ phase: "reviewing", active_feature: null }), + ); + assert.deepEqual(a, { done: true }); +}); + +test("nextAutoAction escalates on conflicts, prior strikes, drafts, and stuck graphs", () => { + let a = nextAutoAction( + sess({ + features: [{ name: "a", status: "pending" }], + next: { + name: "a", + testsStatus: "red", + conflicts: [{ decision: "decisions/no-orm" }], + }, + }), + S(), + ST(), + ); + assert.equal(a.escalate, true); + assert.match(a.reason, /decisions\/no-orm/); + + a = nextAutoAction( + sess({ + features: [{ name: "a", status: "pending" }], + next: { name: "a", testsStatus: "red" }, + }), + S(), + ST({ strikes: { a: 3 } }), + ); + assert.equal(a.escalate, true); + + a = nextAutoAction( + sess({ features: [], next: null, drafts: ["d"] }), + S(), + ST(), + ); + assert.match(a.reason, /draft/); + + a = nextAutoAction( + sess({ + features: [{ name: "a", status: "pending" }], + next: null, + stuck: true, + total: 1, + }), + S(), + ST(), + ); + assert.match(a.reason, /cycle or missing/); +}); + +test("roleModelSpec resolves overrides and leaves active alone", () => { + const settings = { + reviewer_model: "anthropic/claude-opus-4-8", + reviewer_thinking: "high", + implementer_model: "active", + implementer_thinking: "medium", + }; + assert.deepEqual(roleModelSpec(settings, "reviewer"), { + model: "anthropic/claude-opus-4-8", + thinking: "high", + }); + assert.deepEqual(roleModelSpec(settings, "implementer"), { + model: null, + thinking: "medium", + }); + assert.deepEqual(roleModelSpec({}, "tester"), { + model: null, + thinking: null, + }); }); // --------------------------------------------------------------------------- // activityTextFromMessage — the working overlay's live line -const assistant = (content, extra = {}) => ({ role: 'assistant', content, ...extra }); - -test('activityTextFromMessage: joins text blocks and collapses whitespace', () => { - const msg = assistant([ - { type: 'text', text: 'Adding requireAuth\n\n to src/auth.ts' }, - { type: 'text', text: 'then wiring the router.' }, - ]); - assert.equal( - activityTextFromMessage(msg), - 'Adding requireAuth to src/auth.ts then wiring the router.', - ); -}); - -test('activityTextFromMessage: takes text and skips thinking, even when thinking leads', () => { - // The typical tool-using turn: thinking first, then prose, then tool calls. - const msg = assistant([ - { type: 'thinking', thinking: 'Let me check the config first.' }, - { type: 'text', text: 'Checking the config secret.' }, - { type: 'toolCall', id: '1', name: 'read', arguments: {} }, - ], { stopReason: 'toolUse' }); - assert.equal(activityTextFromMessage(msg), 'Checking the config secret.'); -}); - -test('activityTextFromMessage: falls back to a tool summary when a message has no prose', () => { - const msg = assistant([ - { type: 'thinking', thinking: 'silent work' }, - { type: 'toolCall', id: '1', name: 'read', arguments: {} }, - { type: 'toolCall', id: '2', name: 'edit', arguments: {} }, - { type: 'toolCall', id: '3', name: 'edit', arguments: {} }, - ], { stopReason: 'toolUse' }); - assert.equal(activityTextFromMessage(msg), 'Running read, edit ×2'); -}); - -test('activityTextFromMessage: null for thinking-only and for messages without usable content', () => { - assert.equal(activityTextFromMessage(assistant([{ type: 'thinking', thinking: 'hm' }])), null); - assert.equal(activityTextFromMessage(assistant([])), null); - assert.equal(activityTextFromMessage(assistant(undefined)), null); - assert.equal(activityTextFromMessage(assistant('not an array')), null); - assert.equal(activityTextFromMessage(assistant([{ type: 'text', text: ' ' }])), null); - assert.equal(activityTextFromMessage(null), null); -}); - -test('activityTextFromMessage: only assistant messages produce a line', () => { - // message_end fires for every role — anything but assistant must stay silent. - for (const role of ['user', 'toolResult', 'bashExecution', 'custom', 'branchSummary', 'compactionSummary']) { - assert.equal( - activityTextFromMessage({ role, content: [{ type: 'text', text: 'nope' }] }), - null, - `${role} must not reach the overlay`, - ); - } -}); - -test('activityTextFromMessage: aborted turns stay silent, errored ones still speak', () => { - const content = [{ type: 'text', text: 'partial work' }]; - assert.equal(activityTextFromMessage(assistant(content, { stopReason: 'aborted' })), null); - assert.equal(activityTextFromMessage(assistant(content, { stopReason: 'error' })), 'partial work'); -}); - -test('activityTextFromMessage: clips long prose on a word boundary', () => { - const long = assistant([{ type: 'text', text: 'word '.repeat(2000) }]); - const line = activityTextFromMessage(long); - assert.ok(line.length <= 801, `clipped to the cap, got ${line.length}`); - assert.ok(line.endsWith('…'), 'clipping is visible'); - assert.ok(!line.includes('wor…'), 'cuts between words, not mid-word'); - // A custom cap is honoured, and short text is returned untouched. - assert.equal(activityTextFromMessage(assistant([{ type: 'text', text: 'abcdef' }]), 4), 'abcd…'); - assert.equal(activityTextFromMessage(assistant([{ type: 'text', text: 'abcd' }]), 4), 'abcd'); +const assistant = (content, extra = {}) => ({ + role: "assistant", + content, + ...extra, +}); + +test("activityTextFromMessage: joins text blocks and collapses whitespace", () => { + const msg = assistant([ + { type: "text", text: "Adding requireAuth\n\n to src/auth.ts" }, + { type: "text", text: "then wiring the router." }, + ]); + assert.equal( + activityTextFromMessage(msg), + "Adding requireAuth to src/auth.ts then wiring the router.", + ); +}); + +test("activityTextFromMessage: takes text and skips thinking, even when thinking leads", () => { + // The typical tool-using turn: thinking first, then prose, then tool calls. + const msg = assistant( + [ + { type: "thinking", thinking: "Let me check the config first." }, + { type: "text", text: "Checking the config secret." }, + { type: "toolCall", id: "1", name: "read", arguments: {} }, + ], + { stopReason: "toolUse" }, + ); + assert.equal(activityTextFromMessage(msg), "Checking the config secret."); +}); + +test("activityTextFromMessage: falls back to a tool summary when a message has no prose", () => { + const msg = assistant( + [ + { type: "thinking", thinking: "silent work" }, + { type: "toolCall", id: "1", name: "read", arguments: {} }, + { type: "toolCall", id: "2", name: "edit", arguments: {} }, + { type: "toolCall", id: "3", name: "edit", arguments: {} }, + ], + { stopReason: "toolUse" }, + ); + assert.equal(activityTextFromMessage(msg), "Running read, edit ×2"); +}); + +test("activityTextFromMessage: null for thinking-only and for messages without usable content", () => { + assert.equal( + activityTextFromMessage(assistant([{ type: "thinking", thinking: "hm" }])), + null, + ); + assert.equal(activityTextFromMessage(assistant([])), null); + assert.equal(activityTextFromMessage(assistant(undefined)), null); + assert.equal(activityTextFromMessage(assistant("not an array")), null); + assert.equal( + activityTextFromMessage(assistant([{ type: "text", text: " " }])), + null, + ); + assert.equal(activityTextFromMessage(null), null); +}); + +test("activityTextFromMessage: only assistant messages produce a line", () => { + // message_end fires for every role — anything but assistant must stay silent. + for (const role of [ + "user", + "toolResult", + "bashExecution", + "custom", + "branchSummary", + "compactionSummary", + ]) { + assert.equal( + activityTextFromMessage({ + role, + content: [{ type: "text", text: "nope" }], + }), + null, + `${role} must not reach the overlay`, + ); + } +}); + +test("activityTextFromMessage: aborted turns stay silent, errored ones still speak", () => { + const content = [{ type: "text", text: "partial work" }]; + assert.equal( + activityTextFromMessage(assistant(content, { stopReason: "aborted" })), + null, + ); + assert.equal( + activityTextFromMessage(assistant(content, { stopReason: "error" })), + "partial work", + ); +}); + +test("activityTextFromMessage: clips long prose on a word boundary", () => { + const long = assistant([{ type: "text", text: "word ".repeat(2000) }]); + const line = activityTextFromMessage(long); + assert.ok(line.length <= 801, `clipped to the cap, got ${line.length}`); + assert.ok(line.endsWith("…"), "clipping is visible"); + assert.ok(!line.includes("wor…"), "cuts between words, not mid-word"); + // A custom cap is honoured, and short text is returned untouched. + assert.equal( + activityTextFromMessage(assistant([{ type: "text", text: "abcdef" }]), 4), + "abcd…", + ); + assert.equal( + activityTextFromMessage(assistant([{ type: "text", text: "abcd" }]), 4), + "abcd", + ); }); diff --git a/test/session-server.test.mjs b/test/session-server.test.mjs index c1912c7..fcd6fcc 100644 --- a/test/session-server.test.mjs +++ b/test/session-server.test.mjs @@ -227,6 +227,30 @@ test("unsolicited /submit while working is rejected with 409 busy", async () => } }); +test("backlog writes remain available while an agent is working", async () => { + let unsolicited = null; + const { session, origin } = await startSession({ + onUnsolicited: (r) => (unsolicited = r), + }); + try { + session.showView({ step: "planning", render: () => viewHtml("PLANNING") }); + session.showWorking("Auto: implementing…"); + const res = await fetch(`${origin}/submit?r=${srvMod.RUN_ID}`, { + method: "POST", + body: '{"type":"backlog","action":"create","title":"Next idea"}', + }); + assert.equal(res.status, 200); + await sleep(20); + assert.deepEqual(unsolicited, { + type: "backlog", + action: "create", + title: "Next idea", + }); + } finally { + await session.stop(); + } +}); + test("showWorking accepts a structured payload and replays it to new SSE clients", async () => { const { session, origin } = await startSession(); try { @@ -261,7 +285,11 @@ test("pushActivity keeps the last two lines newest-first and replays them", asyn const sse = await firstSseEvent(origin); assert.equal(sse.event, "working"); assert.deepEqual(sse.data.activity, ["third", "second"]); - assert.equal(sse.data.text, "Auto: implement auth…", "the step header survives"); + assert.equal( + sse.data.text, + "Auto: implement auth…", + "the step header survives", + ); } finally { await session.stop(); } diff --git a/test/ui.test.mjs b/test/ui.test.mjs index 5b303f7..16f43d8 100644 --- a/test/ui.test.mjs +++ b/test/ui.test.mjs @@ -318,6 +318,16 @@ test("planning backlog submits scoped CRUD actions and hands selected candidates /type:'backlog'/, "backlog requests have their own payload type", ); + assert.match( + html, + /allowWhileWorking:true/, + "backlog CRUD stays available while the agent implements", + ); + assert.match( + html, + /backlog backlog-active/, + "the read-only shell exempts only backlog controls", + ); assert.match( html, /action:'select'/, @@ -390,6 +400,15 @@ test("hub gates Implement/Review on status and renders escalation + review-plan assert.match(html, /escalation-restart/); assert.match(html, /escalation-guide/); assert.match(html, /Guide the agent/); + // The ready wave comes from the server payload and remains distinct from + // full auto mode (which also reviews and accepts). + assert.match(html, /Implement next wave/); + assert.match(html, /action\('implement-wave'/); + assert.match(html, /Implement all \(auto\)/); + assert.match(html, /Array\.isArray\(D\.readyWave\)/); + assert.match(html, /Review all/); + assert.match(html, /action\('review-all'/); + assert.match(html, /Array\.isArray\(D\.reviewWave\)/); // Plan-lifecycle controls live on the Planning surface, not Work. assert.doesNotMatch(html, /action\('review-plan'/); assert.doesNotMatch(html, /Retires the plan/); @@ -457,6 +476,7 @@ test("review view groups files by Declared/Tests/Incidental with pre-seeded disp step: "review", branch: "b", mode: "commit", + multiReview: true, hasFeaturesFile: true, hasChanges: true, activeFeature: "a", @@ -489,6 +509,9 @@ test("review view groups files by Declared/Tests/Incidental with pre-seeded disp assert.match(html, /leave uncommitted \(skip\)/); // Dispositions are pre-seeded from the gather defaults — never undisposed. assert.match(html, /file\.defaulted && file\.disposition/); + assert.match(html, /review all/); + assert.match(html, /@media\(max-width:640px\)/); + assert.match(html, /\.main\{flex-direction:column\}/); }); test("planning hero goal box persists an unsent draft and clears it on plan start", async () => {