Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
9116540
feature(safe-role-model-handoff): Prevent failed role model restoration
Christoph Jul 20, 2026
dbdf5f5
chore(iterator): record feature commit
Christoph Jul 20, 2026
4befb05
chore(iterator): record feature commits and memory updates
Christoph Jul 20, 2026
e7a69a4
feature(fresh-implementation-session): Start feature implementation i…
Christoph Jul 20, 2026
6cb321d
chore(iterator): record feature commit
Christoph Jul 20, 2026
382ac72
feature(fresh-implementation-session): Preserve role and auto state a…
Christoph Jul 20, 2026
ff02b5f
chore(iterator): record feature commit
Christoph Jul 20, 2026
170f7ae
chore(iterator): record feature commits and memory updates
Christoph Jul 20, 2026
c5f6915
feature(active-plan-workspace): Make Work the active plan workspace
Christoph Jul 20, 2026
614e983
chore(iterator): record feature commit
Christoph Jul 20, 2026
6bcce8b
feature(active-plan-workspace): Clarify Work ownership and verify app…
Christoph Jul 20, 2026
97c0b98
chore(iterator): record feature commit
Christoph Jul 20, 2026
bf53790
chore(iterator): record feature commits and memory updates
Christoph Jul 20, 2026
791f0a7
feature(settings-dashboard-modal): Open Settings as a shell-owned das…
Christoph Jul 20, 2026
1395558
chore(iterator): record feature commit
Christoph Jul 20, 2026
146bbc0
chore(iterator): record feature commits and memory updates
Christoph Jul 20, 2026
9626934
feature(visible-red-test-handoff): Expose committed red tests as Work…
Christoph Jul 20, 2026
332475c
chore(iterator): record feature commit
Christoph Jul 20, 2026
635aa54
chore(iterator): record feature commits and memory updates
Christoph Jul 20, 2026
c4bac70
feature(backlog-file-mentions): Add file mentions to backlog details
Christoph Jul 20, 2026
c54395c
chore(iterator): record feature commit
Christoph Jul 20, 2026
8a08faa
chore(iterator): record feature commits and memory updates
Christoph Jul 20, 2026
e5f2027
feature(backlog-save-during-work): Refresh backlog state without rele…
Christoph Jul 20, 2026
d029f10
chore(iterator): record feature commit
Christoph Jul 20, 2026
d9301e1
chore(iterator): record feature commits and memory updates
Christoph Jul 20, 2026
f169ba8
feature(backlog-filter-and-bulk-select): Filter backlog candidates an…
Christoph Jul 20, 2026
ae63682
chore(iterator): record feature commit
Christoph Jul 20, 2026
0e543d1
feature(backlog-filter-and-bulk-select): Serialize visible backlog se…
Christoph Jul 20, 2026
beb3102
chore(iterator): record feature commit
Christoph Jul 20, 2026
5060b91
chore(iterator): record feature commits and memory updates
Christoph Jul 20, 2026
48f44eb
feature(full-plan-fast-track): Fast-track complete plans through dire…
Christoph Jul 20, 2026
832db0c
chore(iterator): record feature commit
Christoph Jul 20, 2026
ee7ed15
chore(iterator): record feature commits and memory updates
Christoph Jul 20, 2026
dff652e
feature(memory-review-change-focus): Highlight changed sections in me…
Christoph Jul 20, 2026
8d71816
chore(iterator): record feature commit
Christoph Jul 20, 2026
9213ff8
feature(memory-review-change-focus): Preserve query strings while esc…
Christoph Jul 20, 2026
53914b7
chore(iterator): record feature commit
Christoph Jul 20, 2026
41d8df4
chore(iterator): record feature commits and memory updates
Christoph Jul 20, 2026
0285ceb
feature(work-conflict-memory-resolution): Route Work conflicts throug…
Christoph Jul 20, 2026
0183a4d
chore(iterator): record feature commit
Christoph Jul 20, 2026
04ebb47
chore(iterator): record feature commits and memory updates
Christoph Jul 20, 2026
c27d078
improvements
Christoph Jul 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
305 changes: 242 additions & 63 deletions extensions/iterator.js

Large diffs are not rendered by default.

16 changes: 15 additions & 1 deletion lib/gather.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,7 @@ export function gather(startDir) {
];

const trailerMap = featureCommitMap(b.root);
const conceptsById = new Map(loadConcepts(b.memDir).map((c) => [c.id, c]));
// Readiness is computed here, once, server-side — views only render it.
const ready = readiness(b.features, b.settings);
const features = b.features.map((c) => {
Expand All @@ -403,18 +404,31 @@ export function gather(startDir) {
const hasDiff = diffFiles.some((f) => globs.some((re) => re.test(f)));
const recorded = Array.isArray(c.fm.commits) && c.fm.commits.length > 0;
const hasCommits = recorded || trailerMap.has(c.slug);
const tests = listy(c.fm.tests);
const conflicts = featureConflicts(c.fm).map((conflict) => {
const decision = conceptsById.get(conflict.decision);
return {
...conflict,
title: decision?.title || conflict.decision,
description: decision?.description || "",
files: decision?.files || [],
decisionExists: Boolean(decision),
};
});
return {
name: c.slug,
title: c.fm.title || c.slug,
description: c.fm.description || "",
status: c.fm.status || "pending",
size: c.fm.size || "small",
testsStatus: c.fm.tests_status || "none",
tests,
testCount: tests.length,
dependsOn: listy(c.fm.depends_on),
...ready.get(c.slug),
hasDiff,
hasCommits,
conflicts: featureConflicts(c.fm).length,
conflicts,
};
});

Expand Down
70 changes: 65 additions & 5 deletions lib/pi-tools.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,61 @@ export function mergePayload(gathered, extra) {
* Map a hub- or knowledge-view result to the command that runs the chosen
* flow, or null for cancel/timeout/close/anything non-actionable.
*/
export function implementationCommand(
feature,
{ auto = false, guidance = null } = {},
) {
const parts = ["/iterator-implement"];
if (feature) parts.push(feature);
if (auto) parts.push("--auto");
if (guidance) parts.push(`— ${guidance}`);
return parts.join(" ");
}

/** Read and normalize the marker created for an intentional fresh session. */
export function implementationHandoffState(entries, reason) {
if (reason !== "new" || !Array.isArray(entries)) return null;
for (let index = entries.length - 1; index >= 0; index -= 1) {
const entry = entries[index];
if (
entry?.type !== "custom" ||
entry.customType !== "iterator-implementation-handoff" ||
!entry.data ||
typeof entry.data !== "object"
)
continue;
return {
...entry.data,
autoSteps:
Number.isInteger(entry.data.autoSteps) && entry.data.autoSteps >= 0
? entry.data.autoSteps
: 0,
};
}
return null;
}

export function shouldApplyRole(role, { mode, featureWave } = {}) {
if (!role) return false;
// Auto/wave drivers no longer pre-apply the implementer role because the
// implementation runs after a session replacement. The resulting skill
// turn — including a harness-started direct skill invocation — owns it.
if (role === "implementer") return true;
return mode !== "auto" && !featureWave;
}

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"];
// Implementation uses the extension command rather than a direct skill
// expansion: only a command context can create the focused replacement
// session before the skill gets its deterministic feature contract.
if (result.action === "implement")
return implementationCommand(result.feature, { guidance: result.prompt });

// Hub (Work tab): other step flows, optionally scoped to a feature. A typed
// plan goal from the hero rides along so /iterator-plan can skip questions.
const STEPS = ["plan", "feature", "test", "review", "design"];
if (STEPS.includes(result.action)) {
const parts = [`/skill:iterator-${result.action}`];
if (result.feature) parts.push(result.feature);
Expand Down Expand Up @@ -172,12 +221,23 @@ export function composeAmbientContext(hub, implement, concepts = []) {
const p = hub.progress || {};
const red = (hub.features || [])
.filter((c) => c.testsStatus === "red")
.map((c) => c.name);
.map((c) => ({
name: c.name,
tests: Array.isArray(c.tests) ? c.tests.slice(0, 3) : [],
}));
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(", ")}`);
if (red.length) {
parts.push(
`committed red tests: ${red
.map((c) =>
c.tests.length ? `${c.name} (${c.tests.join(", ")})` : c.name,
)
.join("; ")}`,
);
}
lines.push(
`iterator: ${parts.join(" · ")}. Route new implementation work through the feature flow (/iterator-implement or the iterator tools), not ad-hoc edits.`,
);
Expand Down
80 changes: 78 additions & 2 deletions lib/session-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,18 @@ iframe{display:block;width:100%;flex:1;border:0}
#overlay-btns button#ov-abort:hover{border-color:#f85149}
#conn{position:fixed;right:10px;bottom:10px;display:none;font:12px -apple-system,sans-serif;
color:#d29922;background:#161b22;border:1px solid #30363d;border-radius:12px;padding:3px 10px;z-index:60}
#settings-modal{position:fixed;inset:0;display:none;align-items:center;justify-content:center;
padding:var(--modal-gap,24px);background:rgba(1,4,9,.72);z-index:70}
#settings-modal.open{display:flex}
#settings-dialog{width:min(860px,100%);height:min(780px,100%);display:flex;flex-direction:column;
overflow:hidden;background:#0d1117;border:1px solid #30363d;border-radius:10px;box-shadow:0 12px 40px rgba(0,0,0,.5)}
#settings-dialog header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 12px;
color:#c9d1d9;background:#161b22;border-bottom:1px solid #30363d;font:600 13px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
#settings-dialog button{font:12px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#c9d1d9;
background:#21262d;border:1px solid #30363d;border-radius:6px;padding:5px 10px;cursor:pointer}
#settings-dialog button:hover{border-color:#8b949e}
#settings-frame{display:block;width:100%;flex:1;border:0}
@media(max-width:640px){#settings-modal{--modal-gap:0}#settings-dialog{width:100%;height:100%;border-radius:0;border-width:0}}
</style>
</head>
<body>
Expand Down Expand Up @@ -176,12 +188,22 @@ iframe{display:block;width:100%;flex:1;border:0}
</div>
</div>
</main>
<div id="settings-modal" role="dialog" aria-modal="true" aria-label="Project settings" aria-hidden="true">
<section id="settings-dialog">
<header><span>Project settings</span><button id="settings-close" type="button">Close</button></header>
<iframe id="settings-frame" title="Project settings"></iframe>
</section>
</div>
<div id="conn">reconnecting…</div>
<script>
const frame = document.getElementById('v');
const overlay = document.getElementById('overlay');
const overlayText = document.getElementById('overlay-text');
const conn = document.getElementById('conn');
const settingsModal = document.getElementById('settings-modal');
const settingsFrame = document.getElementById('settings-frame');
const settingsClose = document.getElementById('settings-close');
let modalFocus = null;
let tab = ${JSON.stringify(activeTab)};
let seq = 0;
let working = null; // structured working state { text, step?, feature?, progress?, activity? } | null
Expand Down Expand Up @@ -216,6 +238,25 @@ function applyWorking() {
document.getElementById('ov-pause').textContent = paused ? 'Continue' : 'Pause';
overlay.style.display = 'flex';
}
function setModal(open, v) {
if(open){
modalFocus = document.activeElement;
settingsModal.classList.add('open');
settingsModal.setAttribute('aria-hidden', 'false');
settingsFrame.src = '/settings?v=' + (v != null ? v : Date.now());
settingsClose.focus();
return;
}
settingsModal.classList.remove('open');
settingsModal.setAttribute('aria-hidden', 'true');
settingsFrame.removeAttribute('src');
if(modalFocus && typeof modalFocus.focus === 'function') modalFocus.focus();
modalFocus = null;
}
function closeSettings(){
fetch('/submit', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'settings-close' }) }).catch(() => {});
}
function setTab(t, v) {
tab = t;
document.getElementById('context').textContent = './' + tab;
Expand Down Expand Up @@ -248,6 +289,11 @@ es.addEventListener('working', e => {
working = (d && typeof d === 'object') ? d : { text: String(d || 'AI is working…') };
applyWorking();
});
es.addEventListener('modal', e => {
conn.style.display = 'none';
const d = JSON.parse(e.data || '{}');
setModal(!!d.open, d.v);
});
// Control strip: plan/branch/mode chips + pause/continue, fed by 'status'
// events; buttons post deterministic /control actions handled by the
// extension without a model turn.
Expand Down Expand Up @@ -288,6 +334,8 @@ function control(action) {
}
document.getElementById('ctl-pause').addEventListener('click', () => control(paused ? 'continue' : 'pause'));
document.getElementById('ctl-gear').addEventListener('click', () => control('open-settings'));
settingsClose.addEventListener('click', closeSettings);
window.addEventListener('keydown', e => { if(e.key === 'Escape' && settingsModal.classList.contains('open')) closeSettings(); });
document.getElementById('ov-pause').addEventListener('click', () => control(paused ? 'continue' : 'pause'));
document.getElementById('ov-abort').addEventListener('click', () => control('abort'));
es.onopen = () => { conn.style.display = 'none'; };
Expand Down Expand Up @@ -339,6 +387,8 @@ export function createSessionServer({ onUnsolicited, onControl, log } = {}) {
let pending = null; // { resolve, timer, onAbort, signal } | null
let cancelTimer = null;
let status = null; // last control-strip status, replayed on connect
let modalHtml = null; // shell-owned Settings document, independent of tabs
let modalSeq = 0;

const broadcast = (event, data) => {
const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
Expand Down Expand Up @@ -439,6 +489,9 @@ export function createSessionServer({ onUnsolicited, onControl, log } = {}) {
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
if (status)
res.write(`event: status\ndata: ${JSON.stringify(status)}\n\n`);
res.write(
`event: modal\ndata: ${JSON.stringify({ open: modalHtml !== null, v: modalSeq })}\n\n`,
);
const heartbeat = setInterval(() => {
try {
res.write(": ping\n\n");
Expand All @@ -450,6 +503,9 @@ export function createSessionServer({ onUnsolicited, onControl, log } = {}) {
clearInterval(heartbeat);
sseClients.delete(res);
});
} else if (req.method === "GET" && url.pathname === "/settings") {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(modalHtml || PLACEHOLDER);
} else if (req.method === "POST" && url.pathname === "/submit") {
// The run-id guard protects the pending ROUND from stale tabs; with no
// round pending, every visible view (including the inactive tab's
Expand All @@ -476,15 +532,19 @@ export function createSessionServer({ onUnsolicited, onControl, log } = {}) {
// 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) {
const modalClose = parsed.type === "settings-close";
if (!pending && working != null && !backlogWrite && !modalClose) {
say("rejected unsolicited /submit while working");
res.writeHead(409, { "Content-Type": "application/json" });
res.end('{"busy":true}');
return;
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end('{"ok":true}');
if (pending) {
const modalResult =
modalHtml !== null &&
(parsed.type === "settings" || parsed.type === "settings-close");
if (pending && !modalResult) {
// The interactive view temporarily hid this agent's overlay. Resume
// the same ownership claim so its eventual agent_end can release it.
setWorking("Sent to Claude — working…", pending.workingOwner);
Expand Down Expand Up @@ -686,6 +746,22 @@ export function createSessionServer({ onUnsolicited, onControl, log } = {}) {
return setWorking(textOrObj);
},

/** Open shell-owned Settings without replacing the active tab or round. */
showModal({ render }) {
modalHtml = render();
modalSeq += 1;
broadcast("modal", { open: true, v: modalSeq });
},

/** Dismiss Settings and reveal the untouched tab and pending round. */
closeModal() {
if (modalHtml === null) return false;
modalHtml = null;
modalSeq += 1;
broadcast("modal", { open: false, v: modalSeq });
return true;
},

/** Return the current claim, creating a generic one for manual turns. */
ensureWorking(textOrObj = "AI is working…") {
return working === null ? setWorking(textOrObj) : workingOwner;
Expand Down
50 changes: 49 additions & 1 deletion lib/ui.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -217,14 +217,18 @@ async function post(payload, okMsg, options){
function mdToHtml(src){
if(!src || !src.trim()) return '<span class="empty">Empty.</span>';
const escc = s => s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
// inline() already escaped &, <, and > before link matching; only quotes
// remain unsafe in the captured href. Escaping the whole URL again would
// turn &amp; into &amp;amp; and corrupt query parameters.
const attr = s => s.replace(/"/g,'&quot;').replace(/'/g,'&#39;');
const inline = s => escc(s)
.replace(/\`([^\`]+)\`/g, (m,c)=>'<code>'+c+'</code>')
.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>')
.replace(/\\*([^*]+)\\*/g, '<em>$1</em>')
.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, function(m,t,u){
// Only linkify http(s)/mailto/relative targets — never javascript: etc.
return /^(https?:|mailto:|[\\/.#])/i.test(u)
? '<a href="'+u+'" target="_blank" rel="noopener">'+t+'</a>' : m;
? '<a href="'+attr(u)+'" target="_blank" rel="noopener">'+t+'</a>' : m;
});
const lines = src.split('\\n');
let html='', i=0, inList=false, listTag='';
Expand Down Expand Up @@ -255,6 +259,50 @@ function mdToHtml(src){
closeList();
return html;
}

// Split markdown into heading-owned blocks, preserving fenced code verbatim.
// Used by memory review to focus changed sections without trusting raw HTML.
function markdownBlocks(src){
var lines = String(src == null ? '' : src).replace(/\\r/g, '').split('\\n');
var blocks = [], current = [], key = '$intro', labels = {}, fenced = false;
function push(){
var text = current.join('\\n').trim();
if(text) blocks.push({ key:key, text:text });
current = [];
}
lines.forEach(function(line){
var trim = line.trim();
if(trim.startsWith('\`\`\`') || trim.startsWith('~~~')) fenced = !fenced;
var match = fenced ? null : line.match(/^(#{1,6})\\s+(.+)$/);
if(match){
push();
var base = match[2].trim().toLowerCase();
labels[base] = (labels[base] || 0) + 1;
key = base + '#' + labels[base];
}
current.push(line);
});
push();
return blocks;
}
function markdownChangeHtml(next, previous, isCreate){
var before = markdownBlocks(previous);
var after = markdownBlocks(next);
var old = new Map(before.map((block)=> [block.key, block]));
var seen = new Set();
var html = after.map((block)=> {
var prior = old.get(block.key);
seen.add(block.key);
var kind = isCreate || !prior ? 'added' : (prior.text === block.text ? 'unchanged' : 'modified');
var label = kind === 'added' ? 'New section' : (kind === 'modified' ? 'Changed section' : 'Unchanged');
return '<section class="change-block '+kind+'"><div class="change-label">'+label+'</div>'+mdToHtml(block.text)+'</section>';
}).join('');
before.forEach((block)=> {
if(seen.has(block.key)) return;
html += '<section class="change-block removed"><div class="change-label">Removed section</div>'+mdToHtml(block.text)+'</section>';
});
return html || mdToHtml(next);
}
`;

/* ------------------------------------------------------------------ *
Expand Down
Loading
Loading