From f44d2f9e2da9ba16c40498873fadf4824c686891 Mon Sep 17 00:00:00 2001 From: Christoph Maier Date: Fri, 17 Jul 2026 16:15:38 +0200 Subject: [PATCH 01/14] feature(always-available-backlog): Keep backlog CRUD available during agent work Feature: always-available-backlog --- lib/session-server.mjs | 23 +++++------ lib/ui.mjs | 8 ++-- lib/views/planning.mjs | 4 +- memory/backlog/index.md | 7 ++-- memory/features/always-available-backlog.md | 26 +++++++++++++ .../features/implement-ready-feature-wave.md | 26 +++++++++++++ memory/features/index.md | 5 +++ .../review-multiple-implemented-features.md | 26 +++++++++++++ memory/index.md | 1 + memory/log.md | 10 +++++ memory/plan.md | 39 +++++++++++++++++++ memory/state.md | 8 ++-- memory/usage.md | 19 +++++++-- skills/iterator/lib/ui.mjs | 8 ++-- skills/iterator/lib/views/planning.mjs | 4 +- test/session-server.test.mjs | 24 ++++++++++++ test/ui.test.mjs | 10 +++++ 17 files changed, 217 insertions(+), 31 deletions(-) create mode 100644 memory/features/always-available-backlog.md create mode 100644 memory/features/implement-ready-feature-wave.md create mode 100644 memory/features/index.md create mode 100644 memory/features/review-multiple-implemented-features.md create mode 100644 memory/plan.md diff --git a/lib/session-server.mjs b/lib/session-server.mjs index 1ccfcbb..ae56a0c 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); 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/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/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..5423933 --- /dev/null +++ b/memory/features/always-available-backlog.md @@ -0,0 +1,26 @@ +--- +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: implemented +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:15:38.037Z" +tags: [] +--- + +# 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. diff --git a/memory/features/implement-ready-feature-wave.md b/memory/features/implement-ready-feature-wave.md new file mode 100644 index 0000000..a5acd1c --- /dev/null +++ b/memory/features/implement-ready-feature-wave.md @@ -0,0 +1,26 @@ +--- +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: pending +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:11:42.932Z" +tags: [] +--- + +# 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. diff --git a/memory/features/index.md b/memory/features/index.md new file mode 100644 index 0000000..f4d7f52 --- /dev/null +++ b/memory/features/index.md @@ -0,0 +1,5 @@ +# Features + +* [Keep the backlog available during active work](always-available-backlog.md) - ⬜ pending · 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) - ⬜ pending · 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) - ⬜ pending · 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..0397af9 --- /dev/null +++ b/memory/features/review-multiple-implemented-features.md @@ -0,0 +1,26 @@ +--- +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: pending +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:11:42.933Z" +tags: [] +--- + +# 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. 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..e8cfb55 100644 --- a/memory/log.md +++ b/memory/log.md @@ -1,6 +1,16 @@ # iterator update log ## 2026-07-17 +* **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..696bbd6 --- /dev/null +++ b/memory/plan.md @@ -0,0 +1,39 @@ +--- +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:10:13.814Z +--- + +# 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. diff --git a/memory/state.md b/memory/state.md index 393405f..2f5d3ec 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 -active_feature: null +phase: implementing +active_feature: always-available-backlog strikes: "{}" escalation: null -timestamp: 2026-07-16T11:26:26.735Z +timestamp: 2026-07-17T14:14:06.744Z --- Runtime flow state; read via gather, written only by the state op. diff --git a/memory/usage.md b/memory/usage.md index f105601..6c13fe4 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\":131897,\"output\":3234,\"cacheRead\":1006080,\"cacheWrite\":0,\"turns\":21}}},\"features\":{\"retire-plan\":{\"input\":175681,\"output\":515,\"cacheRead\":308736,\"cacheWrite\":0,\"turns\":5},\"always-available-backlog\":{\"input\":131897,\"output\":3234,\"cacheRead\":1006080,\"cacheWrite\":0,\"turns\":21}}}" +timestamp: 2026-07-17T14:14:06.573Z --- # Usage @@ -14,10 +14,23 @@ 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 | 131897 | 3234 | 1006080 | 0 | 21 | + ## Per feature | feature | input | output | cache read | cache write | turns | | --- | ---: | ---: | ---: | ---: | ---: | | retire-plan | 175681 | 515 | 308736 | 0 | 5 | +| always-available-backlog | 131897 | 3234 | 1006080 | 0 | 21 | -Total: 175681 in / 515 out / 308736 cache-read / 0 cache-write over 5 turns. +Total: 366312 in / 7384 out / 1680896 cache-read / 0 cache-write over 45 turns. 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/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/test/session-server.test.mjs b/test/session-server.test.mjs index c1912c7..7862e0b 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 { diff --git a/test/ui.test.mjs b/test/ui.test.mjs index 5b303f7..cdd04ba 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'/, From 0e5604037508b7cb4fac855b4813cc494f84d3c6 Mon Sep 17 00:00:00 2001 From: Christoph Maier Date: Fri, 17 Jul 2026 16:15:38 +0200 Subject: [PATCH 02/14] chore(iterator): record feature commit --- memory/features/always-available-backlog.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/memory/features/always-available-backlog.md b/memory/features/always-available-backlog.md index 5423933..861c6aa 100644 --- a/memory/features/always-available-backlog.md +++ b/memory/features/always-available-backlog.md @@ -7,8 +7,12 @@ 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:15:38.037Z" +timestamp: "2026-07-17T14:15:38.119Z" tags: [] +commits: + - sha: f44d2f9e2da9ba16c40498873fadf4824c686891 + kind: implement + date: 2026-07-17 --- # Implementation notes From 522e47287e0f6d5154f44554819d77ca5cbad5a8 Mon Sep 17 00:00:00 2001 From: Christoph Maier Date: Fri, 17 Jul 2026 16:16:38 +0200 Subject: [PATCH 03/14] chore(iterator): record feature commits and memory updates --- memory/features/always-available-backlog.md | 5 +++-- memory/features/index.md | 2 +- memory/log.md | 1 + memory/state.md | 4 ++-- memory/usage.md | 10 +++++----- 5 files changed, 12 insertions(+), 10 deletions(-) diff --git a/memory/features/always-available-backlog.md b/memory/features/always-available-backlog.md index 861c6aa..aa2bf36 100644 --- a/memory/features/always-available-backlog.md +++ b/memory/features/always-available-backlog.md @@ -2,17 +2,18 @@ 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: implemented +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:15:38.119Z" +timestamp: "2026-07-17T14:16:38.813Z" tags: [] commits: - sha: f44d2f9e2da9ba16c40498873fadf4824c686891 kind: implement date: 2026-07-17 +done: 2026-07-17 --- # Implementation notes diff --git a/memory/features/index.md b/memory/features/index.md index f4d7f52..91cea3f 100644 --- a/memory/features/index.md +++ b/memory/features/index.md @@ -1,5 +1,5 @@ # Features -* [Keep the backlog available during active work](always-available-backlog.md) - ⬜ pending · medium · Planning continues to show and accept saved filesystem backlog candidates while a plan is active. +* [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) - ⬜ pending · 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) - ⬜ pending · large · A consolidated review lets users select an implemented feature and inspect only that feature’s diff and findings. diff --git a/memory/log.md b/memory/log.md index e8cfb55..345a520 100644 --- a/memory/log.md +++ b/memory/log.md @@ -1,6 +1,7 @@ # iterator update log ## 2026-07-17 +* **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. diff --git a/memory/state.md b/memory/state.md index 2f5d3ec..79d2f23 100644 --- a/memory/state.md +++ b/memory/state.md @@ -4,11 +4,11 @@ title: Runtime state description: Machine-owned iterator flow state — never hand-edited. mode: auto paused: false -phase: implementing +phase: reviewing active_feature: always-available-backlog strikes: "{}" escalation: null -timestamp: 2026-07-17T14:14:06.744Z +timestamp: 2026-07-17T14:16:16.944Z --- Runtime flow state; read via gather, written only by the state op. diff --git a/memory/usage.md b/memory/usage.md index 6c13fe4..8118039 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}},\"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\":131897,\"output\":3234,\"cacheRead\":1006080,\"cacheWrite\":0,\"turns\":21}}},\"features\":{\"retire-plan\":{\"input\":175681,\"output\":515,\"cacheRead\":308736,\"cacheWrite\":0,\"turns\":5},\"always-available-backlog\":{\"input\":131897,\"output\":3234,\"cacheRead\":1006080,\"cacheWrite\":0,\"turns\":21}}}" -timestamp: 2026-07-17T14:14:06.573Z +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}}},\"features\":{\"retire-plan\":{\"input\":175681,\"output\":515,\"cacheRead\":308736,\"cacheWrite\":0,\"turns\":5},\"always-available-backlog\":{\"input\":235343,\"output\":6089,\"cacheRead\":2935808,\"cacheWrite\":0,\"turns\":43}}}" +timestamp: 2026-07-17T14:16:16.745Z --- # Usage @@ -24,13 +24,13 @@ timestamp: 2026-07-17T14:14:06.573Z | model | input | output | cache read | cache write | turns | | --- | ---: | ---: | ---: | ---: | ---: | -| openai-codex/gpt-5.6-terra | 131897 | 3234 | 1006080 | 0 | 21 | +| openai-codex/gpt-5.6-terra | 235343 | 6089 | 2935808 | 0 | 43 | ## Per feature | feature | input | output | cache read | cache write | turns | | --- | ---: | ---: | ---: | ---: | ---: | | retire-plan | 175681 | 515 | 308736 | 0 | 5 | -| always-available-backlog | 131897 | 3234 | 1006080 | 0 | 21 | +| always-available-backlog | 235343 | 6089 | 2935808 | 0 | 43 | -Total: 366312 in / 7384 out / 1680896 cache-read / 0 cache-write over 45 turns. +Total: 469758 in / 10239 out / 3610624 cache-read / 0 cache-write over 67 turns. From 01e5a15cedfc71138680d73ed63276ba010eaca8 Mon Sep 17 00:00:00 2001 From: Christoph Maier Date: Fri, 17 Jul 2026 16:22:32 +0200 Subject: [PATCH 04/14] feature(implement-ready-feature-wave): Implement dependency-ready feature waves Feature: implement-ready-feature-wave --- extensions/iterator.js | 105 +++++++++++++++++- lib/gather.mjs | 6 + lib/pi-tools.mjs | 59 ++++++++++ lib/views/hub.mjs | 15 ++- memory/features/always-available-backlog.md | 8 +- .../features/implement-ready-feature-wave.md | 4 +- memory/log.md | 2 + memory/state.md | 6 +- memory/usage.md | 14 ++- skills/iterator-implement/SKILL.md | 14 +++ skills/iterator/lib/gather.mjs | 6 + skills/iterator/lib/views/hub.mjs | 15 ++- test/gather.test.mjs | 5 + test/pi-tools.test.mjs | 42 ++++++- test/ui.test.mjs | 6 + 15 files changed, 285 insertions(+), 22 deletions(-) diff --git a/extensions/iterator.js b/extensions/iterator.js index 472fcb9..aaeb010 100644 --- a/extensions/iterator.js +++ b/extensions/iterator.js @@ -61,6 +61,7 @@ import { footerText, mergePayload, nextAutoAction, + nextFeatureWaveAction, projectRoot, roleModelSpec, runJson, @@ -455,6 +456,7 @@ export default function iteratorExtension(pi) { await pushStatus(cwd); 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 +495,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 +562,94 @@ 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 } = await gatherSession(cwd); + const previousResults = featureWave.results.length; + const decision = nextFeatureWaveAction(featureWave, hub.features || []); + if (!decision) return; + featureWave = decision.wave; + 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 +943,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 +1549,10 @@ 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); + // A ready-wave snapshot advances before auto mode. Wave implementation + // intentionally stops at implemented; review remains a separate action. + if (featureWave) await advanceFeatureWave(ctx.cwd); + else await kickAuto(ctx.cwd); }); // --------------------------------------------------------------------- diff --git a/lib/gather.mjs b/lib/gather.mjs index c79e8e3..77dadae 100644 --- a/lib/gather.mjs +++ b/lib/gather.mjs @@ -332,6 +332,7 @@ export function gather(startDir) { stage: planStage(null, [], b.settings), progress: { done: 0, total: 0 }, features: [], + readyWave: [], // Tracked files for the goal box's @-mention suggestions (capped so // the embedded payload stays small). files: git(["ls-files"], b.root) @@ -416,6 +417,11 @@ 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), knowledgeInitialized: knowledgeReady(b.memDir), settings: b.settings, state: b.state, diff --git a/lib/pi-tools.mjs b/lib/pi-tools.mjs index 5d3601a..5fe929a 100644 --- a/lib/pi-tools.mjs +++ b/lib/pi-tools.mjs @@ -181,6 +181,65 @@ export const AUTO_PHASE_FOR_STEP = { 'plan-review': 'reviewing', }; +/** + * 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; + const byName = new Map(features.map(feature => [feature.name, feature])); + const next = { + queue: [...wave.queue], + active: wave.active || null, + results: [...wave.results], + }; + + 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; diff --git a/lib/views/hub.mjs b/lib/views/hub.mjs index 08e4e38..b50818d 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"|"implement-wave"|"auto-implement" * |"escalation-restart"|"escalation-guide", * feature:""|null, * prompt:""|null } // escalation-guide only @@ -112,10 +112,17 @@ 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')); diff --git a/memory/features/always-available-backlog.md b/memory/features/always-available-backlog.md index aa2bf36..ed37f02 100644 --- a/memory/features/always-available-backlog.md +++ b/memory/features/always-available-backlog.md @@ -7,13 +7,14 @@ 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:38.813Z" +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 @@ -29,3 +30,8 @@ return { step: "hub", plan: { title, status }, stage, features, backlog: b.backl # 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 index a5acd1c..3237980 100644 --- a/memory/features/implement-ready-feature-wave.md +++ b/memory/features/implement-ready-feature-wave.md @@ -2,12 +2,12 @@ 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: pending +status: implemented 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:11:42.932Z" +timestamp: "2026-07-17T14:22:32.827Z" tags: [] --- diff --git a/memory/log.md b/memory/log.md index 345a520..76fbd85 100644 --- a/memory/log.md +++ b/memory/log.md @@ -1,6 +1,8 @@ # iterator update log ## 2026-07-17 +* **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). diff --git a/memory/state.md b/memory/state.md index 79d2f23..e1dbac0 100644 --- a/memory/state.md +++ b/memory/state.md @@ -4,11 +4,11 @@ title: Runtime state description: Machine-owned iterator flow state — never hand-edited. mode: auto paused: false -phase: reviewing -active_feature: always-available-backlog +phase: implementing +active_feature: implement-ready-feature-wave strikes: "{}" escalation: null -timestamp: 2026-07-17T14:16:16.944Z +timestamp: 2026-07-17T14:16:48.238Z --- Runtime flow state; read via gather, written only by the state op. diff --git a/memory/usage.md b/memory/usage.md index 8118039..f50ae19 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}},\"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}}},\"features\":{\"retire-plan\":{\"input\":175681,\"output\":515,\"cacheRead\":308736,\"cacheWrite\":0,\"turns\":5},\"always-available-backlog\":{\"input\":235343,\"output\":6089,\"cacheRead\":2935808,\"cacheWrite\":0,\"turns\":43}}}" -timestamp: 2026-07-17T14:16:16.745Z +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}},\"review\":{\"openai-codex/gpt-5.6-sol\":{\"input\":200864,\"output\":873,\"cacheRead\":219648,\"cacheWrite\":0,\"turns\":4}}},\"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}}}" +timestamp: 2026-07-17T14:16:48.035Z --- # Usage @@ -26,11 +26,17 @@ timestamp: 2026-07-17T14:16:16.745Z | --- | ---: | ---: | ---: | ---: | ---: | | openai-codex/gpt-5.6-terra | 235343 | 6089 | 2935808 | 0 | 43 | +## review + +| model | input | output | cache read | cache write | turns | +| --- | ---: | ---: | ---: | ---: | ---: | +| openai-codex/gpt-5.6-sol | 200864 | 873 | 219648 | 0 | 4 | + ## Per feature | feature | input | output | cache read | cache write | turns | | --- | ---: | ---: | ---: | ---: | ---: | | retire-plan | 175681 | 515 | 308736 | 0 | 5 | -| always-available-backlog | 235343 | 6089 | 2935808 | 0 | 43 | +| always-available-backlog | 436207 | 6962 | 3155456 | 0 | 47 | -Total: 469758 in / 10239 out / 3610624 cache-read / 0 cache-write over 67 turns. +Total: 670622 in / 11112 out / 3830272 cache-read / 0 cache-write over 71 turns. diff --git a/skills/iterator-implement/SKILL.md b/skills/iterator-implement/SKILL.md index 7405499..48ee224 100644 --- a/skills/iterator-implement/SKILL.md +++ b/skills/iterator-implement/SKILL.md @@ -229,6 +229,20 @@ 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. 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/lib/gather.mjs b/skills/iterator/lib/gather.mjs index c79e8e3..77dadae 100644 --- a/skills/iterator/lib/gather.mjs +++ b/skills/iterator/lib/gather.mjs @@ -332,6 +332,7 @@ export function gather(startDir) { stage: planStage(null, [], b.settings), progress: { done: 0, total: 0 }, features: [], + readyWave: [], // Tracked files for the goal box's @-mention suggestions (capped so // the embedded payload stays small). files: git(["ls-files"], b.root) @@ -416,6 +417,11 @@ 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), knowledgeInitialized: knowledgeReady(b.memDir), settings: b.settings, state: b.state, diff --git a/skills/iterator/lib/views/hub.mjs b/skills/iterator/lib/views/hub.mjs index 08e4e38..b50818d 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"|"implement-wave"|"auto-implement" * |"escalation-restart"|"escalation-guide", * feature:""|null, * prompt:""|null } // escalation-guide only @@ -112,10 +112,17 @@ 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')); diff --git a/test/gather.test.mjs b/test/gather.test.mjs index a750b88..26b50df 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, diff --git a/test/pi-tools.test.mjs b/test/pi-tools.test.mjs index 2ba130a..5736de6 100644 --- a/test/pi-tools.test.mjs +++ b/test/pi-tools.test.mjs @@ -246,7 +246,7 @@ test('usageRowFromMessage extracts assistant usage with attribution', async () = // --------------------------------------------------------------------------- // Auto mode state machine -const { nextAutoAction, roleModelSpec, AUTO_PHASE_FOR_STEP } = await import('../lib/pi-tools.mjs'); +const { nextAutoAction, nextFeatureWaveAction, roleModelSpec, AUTO_PHASE_FOR_STEP } = await import('../lib/pi-tools.mjs'); const S = (over = {}) => ({ auto_mode: 'on', testing_default: 'on', max_review_iterations: 3, @@ -258,6 +258,46 @@ const sess = ({ features = [], next = null, drafts = [], stuck = false, done = 0 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('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); diff --git a/test/ui.test.mjs b/test/ui.test.mjs index cdd04ba..17f1cd9 100644 --- a/test/ui.test.mjs +++ b/test/ui.test.mjs @@ -400,6 +400,12 @@ 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\)/); // Plan-lifecycle controls live on the Planning surface, not Work. assert.doesNotMatch(html, /action\('review-plan'/); assert.doesNotMatch(html, /Retires the plan/); From 8142a3ed6988234e1e34197f2fac19dec9146d08 Mon Sep 17 00:00:00 2001 From: Christoph Maier Date: Fri, 17 Jul 2026 16:22:32 +0200 Subject: [PATCH 05/14] chore(iterator): record feature commit --- memory/features/implement-ready-feature-wave.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/memory/features/implement-ready-feature-wave.md b/memory/features/implement-ready-feature-wave.md index 3237980..c384609 100644 --- a/memory/features/implement-ready-feature-wave.md +++ b/memory/features/implement-ready-feature-wave.md @@ -7,8 +7,12 @@ 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:22:32.827Z" +timestamp: "2026-07-17T14:22:32.907Z" tags: [] +commits: + - sha: 01e5a15cedfc71138680d73ed63276ba010eaca8 + kind: implement + date: 2026-07-17 --- # Implementation notes From cc53c06e7cf02c1c31774902ecc72f585b9fa0ec Mon Sep 17 00:00:00 2001 From: Christoph Maier Date: Fri, 17 Jul 2026 16:27:35 +0200 Subject: [PATCH 06/14] feature(implement-ready-feature-wave): Honor pause and continue during feature waves Feature: implement-ready-feature-wave --- extensions/iterator.js | 17 ++++++++++++++--- lib/pi-tools.mjs | 10 ++++++++++ memory/features/implement-ready-feature-wave.md | 8 +++++++- memory/log.md | 2 ++ memory/state.md | 4 ++-- memory/usage.md | 10 ++++++---- skills/iterator-implement/SKILL.md | 6 ++++-- test/pi-tools.test.mjs | 13 ++++++++++++- 8 files changed, 57 insertions(+), 13 deletions(-) diff --git a/extensions/iterator.js b/extensions/iterator.js index aaeb010..d764115 100644 --- a/extensions/iterator.js +++ b/extensions/iterator.js @@ -62,6 +62,7 @@ import { mergePayload, nextAutoAction, nextFeatureWaveAction, + pauseFeatureWave, projectRoot, roleModelSpec, runJson, @@ -437,6 +438,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. @@ -454,7 +459,8 @@ 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, @@ -566,7 +572,10 @@ export default function iteratorExtension(pi) { const advanceFeatureWave = async (cwd) => { if (!featureWave || !bundleExists(cwd)) return; try { - const { hub, settings } = await gatherSession(cwd); + 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; @@ -579,7 +588,9 @@ export default function iteratorExtension(pi) { } if (decision.done) { const results = featureWave.results; - const implemented = results.filter((result) => result.status === "implemented").length; + const implemented = results.filter( + (result) => result.status === "implemented", + ).length; const failed = results.length - implemented; featureWave = null; await writeState({ diff --git a/lib/pi-tools.mjs b/lib/pi-tools.mjs index 5fe929a..451f436 100644 --- a/lib/pi-tools.mjs +++ b/lib/pi-tools.mjs @@ -181,6 +181,16 @@ export const AUTO_PHASE_FOR_STEP = { '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], + }; +} + /** * Advance an implementation-wave snapshot after each agent turn. * diff --git a/memory/features/implement-ready-feature-wave.md b/memory/features/implement-ready-feature-wave.md index c384609..f23fe79 100644 --- a/memory/features/implement-ready-feature-wave.md +++ b/memory/features/implement-ready-feature-wave.md @@ -7,12 +7,13 @@ 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:22:32.907Z" +timestamp: "2026-07-17T14:27:35.727Z" tags: [] commits: - sha: 01e5a15cedfc71138680d73ed63276ba010eaca8 kind: implement date: 2026-07-17 +reviewed: 2026-07-17 --- # Implementation notes @@ -28,3 +29,8 @@ const ready = readiness(b.features, b.settings);\nconst features = b.features.ma # Blast radius Work controls and feature lifecycle coordination; must not auto-accept or include features unblocked after the wave starts. + +# Review + +## 2026-07-17 +* **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/log.md b/memory/log.md index 76fbd85..c46053d 100644 --- a/memory/log.md +++ b/memory/log.md @@ -2,6 +2,8 @@ ## 2026-07-17 * **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. diff --git a/memory/state.md b/memory/state.md index e1dbac0..6212743 100644 --- a/memory/state.md +++ b/memory/state.md @@ -6,9 +6,9 @@ mode: auto paused: false phase: implementing active_feature: implement-ready-feature-wave -strikes: "{}" +strikes: "{\"implement-ready-feature-wave\":1}" escalation: null -timestamp: 2026-07-17T14:16:48.238Z +timestamp: 2026-07-17T14:23:44.326Z --- Runtime flow state; read via gather, written only by the state op. diff --git a/memory/usage.md b/memory/usage.md index f50ae19..7a360a6 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}},\"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}},\"review\":{\"openai-codex/gpt-5.6-sol\":{\"input\":200864,\"output\":873,\"cacheRead\":219648,\"cacheWrite\":0,\"turns\":4}}},\"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}}}" -timestamp: 2026-07-17T14:16:48.035Z +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\":977867,\"output\":9814,\"cacheRead\":3878912,\"cacheWrite\":0,\"turns\":35}},\"review\":{\"openai-codex/gpt-5.6-sol\":{\"input\":600832,\"output\":2307,\"cacheRead\":344064,\"cacheWrite\":0,\"turns\":7}}},\"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\":1377835,\"output\":11248,\"cacheRead\":4003328,\"cacheWrite\":0,\"turns\":38}}}" +timestamp: 2026-07-17T14:23:44.075Z --- # Usage @@ -25,12 +25,13 @@ timestamp: 2026-07-17T14:16:48.035Z | 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 | 977867 | 9814 | 3878912 | 0 | 35 | ## review | model | input | output | cache read | cache write | turns | | --- | ---: | ---: | ---: | ---: | ---: | -| openai-codex/gpt-5.6-sol | 200864 | 873 | 219648 | 0 | 4 | +| openai-codex/gpt-5.6-sol | 600832 | 2307 | 344064 | 0 | 7 | ## Per feature @@ -38,5 +39,6 @@ timestamp: 2026-07-17T14:16:48.035Z | --- | ---: | ---: | ---: | ---: | ---: | | retire-plan | 175681 | 515 | 308736 | 0 | 5 | | always-available-backlog | 436207 | 6962 | 3155456 | 0 | 47 | +| implement-ready-feature-wave | 1377835 | 11248 | 4003328 | 0 | 38 | -Total: 670622 in / 11112 out / 3830272 cache-read / 0 cache-write over 71 turns. +Total: 2048457 in / 22360 out / 7833600 cache-read / 0 cache-write over 109 turns. diff --git a/skills/iterator-implement/SKILL.md b/skills/iterator-implement/SKILL.md index 48ee224..66ed310 100644 --- a/skills/iterator-implement/SKILL.md +++ b/skills/iterator-implement/SKILL.md @@ -237,8 +237,10 @@ feature reported dependency-ready by gather at click time, then dispatches one 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. The wave stops with successful features at -`implemented`; it never opens, accepts, or substitutes for review. +snapshot members from running. Pausing requeues the interrupted feature, and +Continue 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. diff --git a/test/pi-tools.test.mjs b/test/pi-tools.test.mjs index 5736de6..f7e54a4 100644 --- a/test/pi-tools.test.mjs +++ b/test/pi-tools.test.mjs @@ -246,7 +246,7 @@ test('usageRowFromMessage extracts assistant usage with attribution', async () = // --------------------------------------------------------------------------- // Auto mode state machine -const { nextAutoAction, nextFeatureWaveAction, roleModelSpec, AUTO_PHASE_FOR_STEP } = await import('../lib/pi-tools.mjs'); +const { 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, @@ -289,6 +289,17 @@ test('nextFeatureWaveAction freezes the queue and reports each implementation re ]); }); +test('pauseFeatureWave requeues the interrupted item for Continue', () => { + const paused = pauseFeatureWave({ queue: ['b'], active: 'a', results: [] }); + assert.deepEqual(paused, { queue: ['a', 'b'], active: null, results: [] }); + const resumed = nextFeatureWaveAction(paused, [ + { name: 'a', status: 'pending', conflicts: 0 }, + { name: 'b', status: 'pending', conflicts: 0 }, + ]); + assert.equal(resumed.action.feature, 'a', 'Continue retries the paused feature'); + assert.deepEqual(resumed.wave.results, [], 'pause is not recorded as a failure'); +}); + test('nextFeatureWaveAction skips conflicts without dispatching them', () => { const decision = nextFeatureWaveAction( { queue: ['blocked'], active: null, results: [] }, From e691546cd98e9cdc7a1f81185891626b45bdf399 Mon Sep 17 00:00:00 2001 From: Christoph Maier Date: Fri, 17 Jul 2026 16:27:35 +0200 Subject: [PATCH 07/14] chore(iterator): record feature commit --- memory/features/implement-ready-feature-wave.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/memory/features/implement-ready-feature-wave.md b/memory/features/implement-ready-feature-wave.md index f23fe79..27b1259 100644 --- a/memory/features/implement-ready-feature-wave.md +++ b/memory/features/implement-ready-feature-wave.md @@ -7,12 +7,15 @@ 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:27:35.727Z" +timestamp: "2026-07-17T14:27:35.798Z" tags: [] commits: - sha: 01e5a15cedfc71138680d73ed63276ba010eaca8 kind: implement date: 2026-07-17 + - sha: cc53c06e7cf02c1c31774902ecc72f585b9fa0ec + kind: implement + date: 2026-07-17 reviewed: 2026-07-17 --- From 93ee1955897efe51f805bf23f284b593c4e6cf08 Mon Sep 17 00:00:00 2001 From: Christoph Maier Date: Fri, 17 Jul 2026 16:32:06 +0200 Subject: [PATCH 08/14] feature(implement-ready-feature-wave): Serialize wave resume after aborted turns Feature: implement-ready-feature-wave --- extensions/iterator.js | 21 ++++++++++--- lib/pi-tools.mjs | 9 ++++++ .../features/implement-ready-feature-wave.md | 3 +- memory/log.md | 2 ++ memory/state.md | 4 +-- memory/usage.md | 12 +++---- skills/iterator-implement/SKILL.md | 5 +-- test/pi-tools.test.mjs | 31 ++++++++++++++----- 8 files changed, 64 insertions(+), 23 deletions(-) diff --git a/extensions/iterator.js b/extensions/iterator.js index d764115..ee03834 100644 --- a/extensions/iterator.js +++ b/extensions/iterator.js @@ -57,6 +57,7 @@ import { bundleExists, featuresDirEntries, composeAmbientContext, + completeFeatureWaveAbort, extractPathsFromBash, footerText, mergePayload, @@ -580,6 +581,7 @@ export default function iteratorExtension(pi) { 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}`, @@ -1560,10 +1562,21 @@ export default function iteratorExtension(pi) { await flushUsage(ctx.cwd); await refreshHub(ctx.cwd); await refreshStatus(ctx); - // A ready-wave snapshot advances before auto mode. Wave implementation - // intentionally stops at implemented; review remains a separate action. - if (featureWave) await advanceFeatureWave(ctx.cwd); - else 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/pi-tools.mjs b/lib/pi-tools.mjs index 451f436..10831a7 100644 --- a/lib/pi-tools.mjs +++ b/lib/pi-tools.mjs @@ -188,9 +188,16 @@ export function pauseFeatureWave(wave) { 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. * @@ -201,11 +208,13 @@ export function pauseFeatureWave(wave) { */ 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) { diff --git a/memory/features/implement-ready-feature-wave.md b/memory/features/implement-ready-feature-wave.md index 27b1259..5b67b13 100644 --- a/memory/features/implement-ready-feature-wave.md +++ b/memory/features/implement-ready-feature-wave.md @@ -7,7 +7,7 @@ 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:27:35.798Z" +timestamp: "2026-07-17T14:32:06.236Z" tags: [] commits: - sha: 01e5a15cedfc71138680d73ed63276ba010eaca8 @@ -36,4 +36,5 @@ Work controls and feature lifecycle coordination; must not auto-accept or includ # Review ## 2026-07-17 +* **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/log.md b/memory/log.md index c46053d..8b510e0 100644 --- a/memory/log.md +++ b/memory/log.md @@ -4,6 +4,8 @@ * **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. diff --git a/memory/state.md b/memory/state.md index 6212743..c038d51 100644 --- a/memory/state.md +++ b/memory/state.md @@ -6,9 +6,9 @@ mode: auto paused: false phase: implementing active_feature: implement-ready-feature-wave -strikes: "{\"implement-ready-feature-wave\":1}" +strikes: "{\"implement-ready-feature-wave\":2}" escalation: null -timestamp: 2026-07-17T14:23:44.326Z +timestamp: 2026-07-17T14:28:32.794Z --- Runtime flow state; read via gather, written only by the state op. diff --git a/memory/usage.md b/memory/usage.md index 7a360a6..0af8153 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}},\"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\":977867,\"output\":9814,\"cacheRead\":3878912,\"cacheWrite\":0,\"turns\":35}},\"review\":{\"openai-codex/gpt-5.6-sol\":{\"input\":600832,\"output\":2307,\"cacheRead\":344064,\"cacheWrite\":0,\"turns\":7}}},\"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\":1377835,\"output\":11248,\"cacheRead\":4003328,\"cacheWrite\":0,\"turns\":38}}}" -timestamp: 2026-07-17T14:23:44.075Z +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\":1843203,\"output\":14092,\"cacheRead\":8547840,\"cacheWrite\":0,\"turns\":62}},\"review\":{\"openai-codex/gpt-5.6-sol\":{\"input\":1102634,\"output\":3221,\"cacheRead\":531456,\"cacheWrite\":0,\"turns\":10}}},\"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\":2744973,\"output\":16440,\"cacheRead\":8859648,\"cacheWrite\":0,\"turns\":68}}}" +timestamp: 2026-07-17T14:28:32.431Z --- # Usage @@ -25,13 +25,13 @@ timestamp: 2026-07-17T14:23:44.075Z | 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 | 977867 | 9814 | 3878912 | 0 | 35 | +| openai-codex/gpt-5.6-sol | 1843203 | 14092 | 8547840 | 0 | 62 | ## review | model | input | output | cache read | cache write | turns | | --- | ---: | ---: | ---: | ---: | ---: | -| openai-codex/gpt-5.6-sol | 600832 | 2307 | 344064 | 0 | 7 | +| openai-codex/gpt-5.6-sol | 1102634 | 3221 | 531456 | 0 | 10 | ## Per feature @@ -39,6 +39,6 @@ timestamp: 2026-07-17T14:23:44.075Z | --- | ---: | ---: | ---: | ---: | ---: | | retire-plan | 175681 | 515 | 308736 | 0 | 5 | | always-available-backlog | 436207 | 6962 | 3155456 | 0 | 47 | -| implement-ready-feature-wave | 1377835 | 11248 | 4003328 | 0 | 38 | +| implement-ready-feature-wave | 2744973 | 16440 | 8859648 | 0 | 68 | -Total: 2048457 in / 22360 out / 7833600 cache-read / 0 cache-write over 109 turns. +Total: 3415595 in / 27552 out / 12689920 cache-read / 0 cache-write over 139 turns. diff --git a/skills/iterator-implement/SKILL.md b/skills/iterator-implement/SKILL.md index 66ed310..91b8648 100644 --- a/skills/iterator-implement/SKILL.md +++ b/skills/iterator-implement/SKILL.md @@ -237,8 +237,9 @@ feature reported dependency-ready by gather at click time, then dispatches one 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, and -Continue retries it before advancing the snapshot. The wave stops with +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. diff --git a/test/pi-tools.test.mjs b/test/pi-tools.test.mjs index f7e54a4..8bc8640 100644 --- a/test/pi-tools.test.mjs +++ b/test/pi-tools.test.mjs @@ -246,7 +246,7 @@ test('usageRowFromMessage extracts assistant usage with attribution', async () = // --------------------------------------------------------------------------- // Auto mode state machine -const { nextAutoAction, nextFeatureWaveAction, pauseFeatureWave, 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, @@ -289,15 +289,30 @@ test('nextFeatureWaveAction freezes the queue and reports each implementation re ]); }); -test('pauseFeatureWave requeues the interrupted item for Continue', () => { - const paused = pauseFeatureWave({ queue: ['b'], active: 'a', results: [] }); - assert.deepEqual(paused, { queue: ['a', 'b'], active: null, results: [] }); - const resumed = nextFeatureWaveAction(paused, [ +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 }, - ]); - assert.equal(resumed.action.feature, 'a', 'Continue retries the paused feature'); - assert.deepEqual(resumed.wave.results, [], 'pause is not recorded as a failure'); + ]; + 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', () => { From 5ab664cb36b5495bcdc5b489cca4eb67802a0f72 Mon Sep 17 00:00:00 2001 From: Christoph Maier Date: Fri, 17 Jul 2026 16:32:06 +0200 Subject: [PATCH 09/14] chore(iterator): record feature commit --- memory/features/implement-ready-feature-wave.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/memory/features/implement-ready-feature-wave.md b/memory/features/implement-ready-feature-wave.md index 5b67b13..f76f2b7 100644 --- a/memory/features/implement-ready-feature-wave.md +++ b/memory/features/implement-ready-feature-wave.md @@ -7,7 +7,7 @@ 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:32:06.236Z" +timestamp: "2026-07-17T14:32:06.307Z" tags: [] commits: - sha: 01e5a15cedfc71138680d73ed63276ba010eaca8 @@ -16,6 +16,9 @@ commits: - sha: cc53c06e7cf02c1c31774902ecc72f585b9fa0ec kind: implement date: 2026-07-17 + - sha: 93ee1955897efe51f805bf23f284b593c4e6cf08 + kind: implement + date: 2026-07-17 reviewed: 2026-07-17 --- From e0113765b149c3c9547c7b24c210986c3a7eef6e Mon Sep 17 00:00:00 2001 From: Christoph Maier Date: Fri, 17 Jul 2026 16:32:57 +0200 Subject: [PATCH 10/14] chore(iterator): record feature commits and memory updates --- memory/features/implement-ready-feature-wave.md | 5 +++-- memory/features/index.md | 2 +- memory/log.md | 1 + memory/state.md | 4 ++-- memory/usage.md | 10 +++++----- skills/iterator/lib/gather.mjs | 9 ++------- 6 files changed, 14 insertions(+), 17 deletions(-) diff --git a/memory/features/implement-ready-feature-wave.md b/memory/features/implement-ready-feature-wave.md index f76f2b7..6e83751 100644 --- a/memory/features/implement-ready-feature-wave.md +++ b/memory/features/implement-ready-feature-wave.md @@ -2,12 +2,12 @@ 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: implemented +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:32:06.307Z" +timestamp: "2026-07-17T14:32:57.792Z" tags: [] commits: - sha: 01e5a15cedfc71138680d73ed63276ba010eaca8 @@ -20,6 +20,7 @@ commits: kind: implement date: 2026-07-17 reviewed: 2026-07-17 +done: 2026-07-17 --- # Implementation notes diff --git a/memory/features/index.md b/memory/features/index.md index 91cea3f..9c50ef7 100644 --- a/memory/features/index.md +++ b/memory/features/index.md @@ -1,5 +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) - ⬜ pending · large · A Work action launches implementation for every feature ready at the start of the wave and reports each result. +* [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) - ⬜ pending · large · A consolidated review lets users select an implemented feature and inspect only that feature’s diff and findings. diff --git a/memory/log.md b/memory/log.md index 8b510e0..4a5ef77 100644 --- a/memory/log.md +++ b/memory/log.md @@ -1,6 +1,7 @@ # iterator update log ## 2026-07-17 +* **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. diff --git a/memory/state.md b/memory/state.md index c038d51..27eb4d8 100644 --- a/memory/state.md +++ b/memory/state.md @@ -4,11 +4,11 @@ title: Runtime state description: Machine-owned iterator flow state — never hand-edited. mode: auto paused: false -phase: implementing +phase: reviewing active_feature: implement-ready-feature-wave strikes: "{\"implement-ready-feature-wave\":2}" escalation: null -timestamp: 2026-07-17T14:28:32.794Z +timestamp: 2026-07-17T14:32:43.014Z --- Runtime flow state; read via gather, written only by the state op. diff --git a/memory/usage.md b/memory/usage.md index 0af8153..ed444b9 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}},\"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\":1843203,\"output\":14092,\"cacheRead\":8547840,\"cacheWrite\":0,\"turns\":62}},\"review\":{\"openai-codex/gpt-5.6-sol\":{\"input\":1102634,\"output\":3221,\"cacheRead\":531456,\"cacheWrite\":0,\"turns\":10}}},\"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\":2744973,\"output\":16440,\"cacheRead\":8859648,\"cacheWrite\":0,\"turns\":68}}}" -timestamp: 2026-07-17T14:28:32.431Z +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\":2588774,\"output\":19329,\"cacheRead\":13316096,\"cacheWrite\":0,\"turns\":85}},\"review\":{\"openai-codex/gpt-5.6-sol\":{\"input\":1102634,\"output\":3221,\"cacheRead\":531456,\"cacheWrite\":0,\"turns\":10}}},\"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\":3490544,\"output\":21677,\"cacheRead\":13627904,\"cacheWrite\":0,\"turns\":91}}}" +timestamp: 2026-07-17T14:32:42.792Z --- # Usage @@ -25,7 +25,7 @@ timestamp: 2026-07-17T14:28:32.431Z | 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 | 1843203 | 14092 | 8547840 | 0 | 62 | +| openai-codex/gpt-5.6-sol | 2588774 | 19329 | 13316096 | 0 | 85 | ## review @@ -39,6 +39,6 @@ timestamp: 2026-07-17T14:28:32.431Z | --- | ---: | ---: | ---: | ---: | ---: | | retire-plan | 175681 | 515 | 308736 | 0 | 5 | | always-available-backlog | 436207 | 6962 | 3155456 | 0 | 47 | -| implement-ready-feature-wave | 2744973 | 16440 | 8859648 | 0 | 68 | +| implement-ready-feature-wave | 3490544 | 21677 | 13627904 | 0 | 91 | -Total: 3415595 in / 27552 out / 12689920 cache-read / 0 cache-write over 139 turns. +Total: 4161166 in / 32789 out / 17458176 cache-read / 0 cache-write over 162 turns. diff --git a/skills/iterator/lib/gather.mjs b/skills/iterator/lib/gather.mjs index 77dadae..0358f79 100644 --- a/skills/iterator/lib/gather.mjs +++ b/skills/iterator/lib/gather.mjs @@ -1124,9 +1124,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)) @@ -1611,10 +1609,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) { From e5853320eff434b2aaecca8069ceabceeecdffa3 Mon Sep 17 00:00:00 2001 From: Christoph Maier Date: Fri, 17 Jul 2026 16:40:04 +0200 Subject: [PATCH 11/14] feature(review-multiple-implemented-features): Add consolidated review for implemented features Feature: review-multiple-implemented-features --- lib/gather.mjs | 48 ++++++++++++++++ lib/pi-tools.mjs | 2 + lib/views/hub.mjs | 11 +++- lib/views/review.mjs | 10 +++- .../features/implement-ready-feature-wave.md | 3 +- .../review-multiple-implemented-features.md | 4 +- memory/log.md | 2 + memory/state.md | 6 +- memory/usage.md | 13 +++-- skills/iterator-review/SKILL.md | 25 +++++--- skills/iterator/lib/gather.mjs | 57 ++++++++++++++++++- skills/iterator/lib/views/hub.mjs | 11 +++- skills/iterator/lib/views/review.mjs | 10 +++- test/gather.test.mjs | 43 ++++++++++++++ test/pi-tools.test.mjs | 1 + test/ui.test.mjs | 7 +++ 16 files changed, 226 insertions(+), 27 deletions(-) diff --git a/lib/gather.mjs b/lib/gather.mjs index 77dadae..2a05b36 100644 --- a/lib/gather.mjs +++ b/lib/gather.mjs @@ -333,6 +333,7 @@ export function gather(startDir) { 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) @@ -422,6 +423,9 @@ export function gather(startDir) { 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, @@ -1386,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 diff --git a/lib/pi-tools.mjs b/lib/pi-tools.mjs index 10831a7..50ef62b 100644 --- a/lib/pi-tools.mjs +++ b/lib/pi-tools.mjs @@ -35,6 +35,8 @@ export function actionToCommand(result) { 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. diff --git a/lib/views/hub.mjs b/lib/views/hub.mjs index b50818d..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"|"implement-wave"|"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 @@ -128,6 +128,15 @@ function render(){ 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/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/features/implement-ready-feature-wave.md b/memory/features/implement-ready-feature-wave.md index 6e83751..3148d21 100644 --- a/memory/features/implement-ready-feature-wave.md +++ b/memory/features/implement-ready-feature-wave.md @@ -7,7 +7,7 @@ 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:32:57.792Z" +timestamp: "2026-07-17T14:33:03.325Z" tags: [] commits: - sha: 01e5a15cedfc71138680d73ed63276ba010eaca8 @@ -40,5 +40,6 @@ Work controls and feature lifecycle coordination; must not auto-accept or includ # 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/review-multiple-implemented-features.md b/memory/features/review-multiple-implemented-features.md index 0397af9..59dbc78 100644 --- a/memory/features/review-multiple-implemented-features.md +++ b/memory/features/review-multiple-implemented-features.md @@ -2,12 +2,12 @@ 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: pending +status: implemented 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:11:42.933Z" +timestamp: "2026-07-17T14:40:04.345Z" tags: [] --- diff --git a/memory/log.md b/memory/log.md index 4a5ef77..f91be8c 100644 --- a/memory/log.md +++ b/memory/log.md @@ -1,6 +1,8 @@ # iterator update log ## 2026-07-17 +* **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). diff --git a/memory/state.md b/memory/state.md index 27eb4d8..4c8575a 100644 --- a/memory/state.md +++ b/memory/state.md @@ -4,11 +4,11 @@ title: Runtime state description: Machine-owned iterator flow state — never hand-edited. mode: auto paused: false -phase: reviewing -active_feature: implement-ready-feature-wave +phase: implementing +active_feature: review-multiple-implemented-features strikes: "{\"implement-ready-feature-wave\":2}" escalation: null -timestamp: 2026-07-17T14:32:43.014Z +timestamp: 2026-07-17T14:33:25.254Z --- Runtime flow state; read via gather, written only by the state op. diff --git a/memory/usage.md b/memory/usage.md index ed444b9..aede7d7 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}},\"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\":2588774,\"output\":19329,\"cacheRead\":13316096,\"cacheWrite\":0,\"turns\":85}},\"review\":{\"openai-codex/gpt-5.6-sol\":{\"input\":1102634,\"output\":3221,\"cacheRead\":531456,\"cacheWrite\":0,\"turns\":10}}},\"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\":3490544,\"output\":21677,\"cacheRead\":13627904,\"cacheWrite\":0,\"turns\":91}}}" -timestamp: 2026-07-17T14:32:42.792Z +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\":2605464,\"output\":19460,\"cacheRead\":14090752,\"cacheWrite\":0,\"turns\":90}},\"review\":{\"openai-codex/gpt-5.6-sol\":{\"input\":1415405,\"output\":3677,\"cacheRead\":1258496,\"cacheWrite\":0,\"turns\":14}}},\"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\":16690,\"output\":131,\"cacheRead\":774656,\"cacheWrite\":0,\"turns\":5}}}" +timestamp: 2026-07-17T14:33:25.034Z --- # Usage @@ -25,13 +25,13 @@ timestamp: 2026-07-17T14:32:42.792Z | 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 | 2588774 | 19329 | 13316096 | 0 | 85 | +| openai-codex/gpt-5.6-sol | 2605464 | 19460 | 14090752 | 0 | 90 | ## review | model | input | output | cache read | cache write | turns | | --- | ---: | ---: | ---: | ---: | ---: | -| openai-codex/gpt-5.6-sol | 1102634 | 3221 | 531456 | 0 | 10 | +| openai-codex/gpt-5.6-sol | 1415405 | 3677 | 1258496 | 0 | 14 | ## Per feature @@ -39,6 +39,7 @@ timestamp: 2026-07-17T14:32:42.792Z | --- | ---: | ---: | ---: | ---: | ---: | | retire-plan | 175681 | 515 | 308736 | 0 | 5 | | always-available-backlog | 436207 | 6962 | 3155456 | 0 | 47 | -| implement-ready-feature-wave | 3490544 | 21677 | 13627904 | 0 | 91 | +| implement-ready-feature-wave | 3803315 | 22133 | 14354944 | 0 | 95 | +| review-multiple-implemented-features | 16690 | 131 | 774656 | 0 | 5 | -Total: 4161166 in / 32789 out / 17458176 cache-read / 0 cache-write over 162 turns. +Total: 4490627 in / 33376 out / 18959872 cache-read / 0 cache-write over 171 turns. 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 0358f79..2a05b36 100644 --- a/skills/iterator/lib/gather.mjs +++ b/skills/iterator/lib/gather.mjs @@ -333,6 +333,7 @@ export function gather(startDir) { 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) @@ -422,6 +423,9 @@ export function gather(startDir) { 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, @@ -1124,7 +1128,9 @@ 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)) @@ -1384,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 @@ -1609,7 +1659,10 @@ 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/views/hub.mjs b/skills/iterator/lib/views/hub.mjs index b50818d..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"|"implement-wave"|"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 @@ -128,6 +128,15 @@ function render(){ 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/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 26b50df..9d189d8 100644 --- a/test/gather.test.mjs +++ b/test/gather.test.mjs @@ -287,6 +287,49 @@ 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 { diff --git a/test/pi-tools.test.mjs b/test/pi-tools.test.mjs index 8bc8640..3e53929 100644 --- a/test/pi-tools.test.mjs +++ b/test/pi-tools.test.mjs @@ -25,6 +25,7 @@ test('actionToCommand maps hub actions to skill commands', () => { 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', () => { diff --git a/test/ui.test.mjs b/test/ui.test.mjs index 17f1cd9..16f43d8 100644 --- a/test/ui.test.mjs +++ b/test/ui.test.mjs @@ -406,6 +406,9 @@ test("hub gates Implement/Review on status and renders escalation + review-plan 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/); @@ -473,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", @@ -505,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 () => { From c2f1cbdac9bb6bd7a0c1a787efeb7d9eecfa9538 Mon Sep 17 00:00:00 2001 From: Christoph Maier Date: Fri, 17 Jul 2026 16:40:04 +0200 Subject: [PATCH 12/14] chore(iterator): record feature commit --- memory/features/review-multiple-implemented-features.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/memory/features/review-multiple-implemented-features.md b/memory/features/review-multiple-implemented-features.md index 59dbc78..1a525c6 100644 --- a/memory/features/review-multiple-implemented-features.md +++ b/memory/features/review-multiple-implemented-features.md @@ -7,8 +7,12 @@ 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:40:04.345Z" +timestamp: "2026-07-17T14:40:04.423Z" tags: [] +commits: + - sha: e5853320eff434b2aaecca8069ceabceeecdffa3 + kind: implement + date: 2026-07-17 --- # Implementation notes From 9680bc09bf2ef2befcf6576ecb7c3a46e3932c30 Mon Sep 17 00:00:00 2001 From: Christoph Maier Date: Fri, 17 Jul 2026 16:41:15 +0200 Subject: [PATCH 13/14] chore(iterator): record feature commits and memory updates --- memory/features/index.md | 2 +- .../review-multiple-implemented-features.md | 5 +++-- memory/log.md | 1 + memory/state.md | 4 ++-- memory/usage.md | 10 +++++----- skills/iterator/lib/gather.mjs | 13 +++++-------- 6 files changed, 17 insertions(+), 18 deletions(-) diff --git a/memory/features/index.md b/memory/features/index.md index 9c50ef7..feeaf9e 100644 --- a/memory/features/index.md +++ b/memory/features/index.md @@ -2,4 +2,4 @@ * [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) - ⬜ pending · large · A consolidated review lets users select an implemented feature and inspect only that feature’s diff and findings. +* [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 index 1a525c6..9d5635d 100644 --- a/memory/features/review-multiple-implemented-features.md +++ b/memory/features/review-multiple-implemented-features.md @@ -2,17 +2,18 @@ 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: implemented +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:40:04.423Z" +timestamp: "2026-07-17T14:41:15.578Z" tags: [] commits: - sha: e5853320eff434b2aaecca8069ceabceeecdffa3 kind: implement date: 2026-07-17 +done: 2026-07-17 --- # Implementation notes diff --git a/memory/log.md b/memory/log.md index f91be8c..f231eaf 100644 --- a/memory/log.md +++ b/memory/log.md @@ -1,6 +1,7 @@ # iterator update log ## 2026-07-17 +* **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)). diff --git a/memory/state.md b/memory/state.md index 4c8575a..64a9c91 100644 --- a/memory/state.md +++ b/memory/state.md @@ -4,11 +4,11 @@ title: Runtime state description: Machine-owned iterator flow state — never hand-edited. mode: auto paused: false -phase: implementing +phase: reviewing active_feature: review-multiple-implemented-features strikes: "{\"implement-ready-feature-wave\":2}" escalation: null -timestamp: 2026-07-17T14:33:25.254Z +timestamp: 2026-07-17T14:40:56.766Z --- Runtime flow state; read via gather, written only by the state op. diff --git a/memory/usage.md b/memory/usage.md index aede7d7..6e2827f 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}},\"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\":2605464,\"output\":19460,\"cacheRead\":14090752,\"cacheWrite\":0,\"turns\":90}},\"review\":{\"openai-codex/gpt-5.6-sol\":{\"input\":1415405,\"output\":3677,\"cacheRead\":1258496,\"cacheWrite\":0,\"turns\":14}}},\"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\":16690,\"output\":131,\"cacheRead\":774656,\"cacheWrite\":0,\"turns\":5}}}" -timestamp: 2026-07-17T14:33:25.034Z +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\":1415405,\"output\":3677,\"cacheRead\":1258496,\"cacheWrite\":0,\"turns\":14}}},\"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\":1797067,\"output\":9639,\"cacheRead\":9702912,\"cacheWrite\":0,\"turns\":41}}}" +timestamp: 2026-07-17T14:40:56.544Z --- # Usage @@ -25,7 +25,7 @@ timestamp: 2026-07-17T14:33:25.034Z | 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 | 2605464 | 19460 | 14090752 | 0 | 90 | +| openai-codex/gpt-5.6-sol | 4385841 | 28968 | 23019008 | 0 | 126 | ## review @@ -40,6 +40,6 @@ timestamp: 2026-07-17T14:33:25.034Z | 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 | 16690 | 131 | 774656 | 0 | 5 | +| review-multiple-implemented-features | 1797067 | 9639 | 9702912 | 0 | 41 | -Total: 4490627 in / 33376 out / 18959872 cache-read / 0 cache-write over 171 turns. +Total: 6271004 in / 42884 out / 27888128 cache-read / 0 cache-write over 207 turns. diff --git a/skills/iterator/lib/gather.mjs b/skills/iterator/lib/gather.mjs index 2a05b36..6d8350b 100644 --- a/skills/iterator/lib/gather.mjs +++ b/skills/iterator/lib/gather.mjs @@ -424,7 +424,9 @@ export function gather(startDir) { .filter((feature) => feature.status === "pending" && feature.ready) .map((feature) => feature.name), reviewWave: features - .filter((feature) => feature.status === "implemented" && feature.hasCommits) + .filter( + (feature) => feature.status === "implemented" && feature.hasCommits, + ) .map((feature) => feature.name), knowledgeInitialized: knowledgeReady(b.memDir), settings: b.settings, @@ -1128,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)) @@ -1659,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) { From f5637bef745deb5cfac16f2ccb30defa2e8d73ae Mon Sep 17 00:00:00 2001 From: Christoph Maier Date: Fri, 17 Jul 2026 16:56:45 +0200 Subject: [PATCH 14/14] refactoring --- lib/gather.mjs | 13 +- lib/pi-tools.mjs | 750 +++++----- lib/session-server.mjs | 5 +- .../review-multiple-implemented-features.md | 8 +- memory/log.md | 2 + memory/plan.md | 15 +- memory/state.md | 6 +- memory/usage.md | 10 +- test/gather.test.mjs | 24 +- test/pi-tools.test.mjs | 1292 +++++++++++------ test/session-server.test.mjs | 6 +- 11 files changed, 1294 insertions(+), 837 deletions(-) diff --git a/lib/gather.mjs b/lib/gather.mjs index 2a05b36..6d8350b 100644 --- a/lib/gather.mjs +++ b/lib/gather.mjs @@ -424,7 +424,9 @@ export function gather(startDir) { .filter((feature) => feature.status === "pending" && feature.ready) .map((feature) => feature.name), reviewWave: features - .filter((feature) => feature.status === "implemented" && feature.hasCommits) + .filter( + (feature) => feature.status === "implemented" && feature.hasCommits, + ) .map((feature) => feature.name), knowledgeInitialized: knowledgeReady(b.memDir), settings: b.settings, @@ -1128,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)) @@ -1659,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 50ef62b..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,76 +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: 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; + 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), + ); } /** @@ -101,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; } // --------------------------------------------------------------------------- @@ -129,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)]; } /** @@ -150,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; } // --------------------------------------------------------------------------- @@ -177,27 +202,28 @@ 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), - }; + 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 }; + if (!wave) return null; + return { ...wave, abortPending: false }; } /** @@ -209,56 +235,59 @@ export function completeFeatureWaveAbort(wave) { * 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 }; + 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 }; } /** @@ -275,86 +304,118 @@ export function nextFeatureWaveAction(wave, features = []) { * 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`, + }; } /** @@ -363,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", }; /** @@ -397,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 }; } /** @@ -408,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, + }; } // --------------------------------------------------------------------------- @@ -429,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(", ")}`; }; /** @@ -460,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; /** @@ -495,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 + } } /** @@ -515,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; } /** @@ -535,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 ae56a0c..023cb8a 100644 --- a/lib/session-server.mjs +++ b/lib/session-server.mjs @@ -657,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/memory/features/review-multiple-implemented-features.md b/memory/features/review-multiple-implemented-features.md index 9d5635d..a3f136d 100644 --- a/memory/features/review-multiple-implemented-features.md +++ b/memory/features/review-multiple-implemented-features.md @@ -7,13 +7,14 @@ 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:15.578Z" +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 @@ -29,3 +30,8 @@ const selected = opts.feature ? b.features.filter((c) => c.slug === opts.feature # 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/log.md b/memory/log.md index f231eaf..c433c34 100644 --- a/memory/log.md +++ b/memory/log.md @@ -1,6 +1,8 @@ # 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). diff --git a/memory/plan.md b/memory/plan.md index 696bbd6..b87eb7d 100644 --- a/memory/plan.md +++ b/memory/plan.md @@ -5,7 +5,8 @@ description: Allow durable backlog planning during active work and add wave-leve status: approved branch: iterator/consume-accepted-backlog-ideas created: 2026-07-17 -timestamp: 2026-07-17T14:10:13.814Z +timestamp: "2026-07-17T14:42:24.417Z" +plan_reviewed: 2026-07-17 --- # Goal @@ -37,3 +38,15 @@ Keep saved ideas available for planning while other agent work is in progress, a * [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 64a9c91..73f7444 100644 --- a/memory/state.md +++ b/memory/state.md @@ -4,11 +4,11 @@ title: Runtime state description: Machine-owned iterator flow state — never hand-edited. mode: auto paused: false -phase: reviewing -active_feature: review-multiple-implemented-features +phase: done +active_feature: null strikes: "{\"implement-ready-feature-wave\":2}" escalation: null -timestamp: 2026-07-17T14:40:56.766Z +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 6e2827f..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}},\"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\":1415405,\"output\":3677,\"cacheRead\":1258496,\"cacheWrite\":0,\"turns\":14}}},\"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\":1797067,\"output\":9639,\"cacheRead\":9702912,\"cacheWrite\":0,\"turns\":41}}}" -timestamp: 2026-07-17T14:40:56.544Z +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 @@ -31,7 +31,7 @@ timestamp: 2026-07-17T14:40:56.544Z | model | input | output | cache read | cache write | turns | | --- | ---: | ---: | ---: | ---: | ---: | -| openai-codex/gpt-5.6-sol | 1415405 | 3677 | 1258496 | 0 | 14 | +| openai-codex/gpt-5.6-sol | 2129601 | 5671 | 3197952 | 0 | 22 | ## Per feature @@ -40,6 +40,6 @@ timestamp: 2026-07-17T14:40:56.544Z | 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 | 1797067 | 9639 | 9702912 | 0 | 41 | +| review-multiple-implemented-features | 2483284 | 10394 | 9985024 | 0 | 44 | -Total: 6271004 in / 42884 out / 27888128 cache-read / 0 cache-write over 207 turns. +Total: 6985200 in / 44878 out / 29827584 cache-read / 0 cache-write over 215 turns. diff --git a/test/gather.test.mjs b/test/gather.test.mjs index 9d189d8..8745af2 100644 --- a/test/gather.test.mjs +++ b/test/gather.test.mjs @@ -293,12 +293,18 @@ test("consolidated review rebuilds each implemented feature from its own commits const config = join(root, "memory", "features", "config-module.md"); writeFileSync( config, - readFileSync(config, "utf8").replace("status: done", "status: implemented"), + 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"), + readFileSync(auth, "utf8").replace( + "status: pending", + "status: implemented", + ), ); git(root, "add", "."); git( @@ -492,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 3e53929..d134c62 100644 --- a/test/pi-tools.test.mjs +++ b/test/pi-tools.test.mjs @@ -1,484 +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'); - 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'); + 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 { completeFeatureWaveAbort, nextAutoAction, nextFeatureWaveAction, pauseFeatureWave, 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('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 }); + 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 7862e0b..fcd6fcc 100644 --- a/test/session-server.test.mjs +++ b/test/session-server.test.mjs @@ -285,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(); }