kaizen-agent 1.42.2 → 1.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/state.ts CHANGED
@@ -405,9 +405,11 @@ export function readRuns(state: string): Run[] {
405
405
  const file = join(dir, id, "state.json");
406
406
  const s = JSON.parse(readText(file));
407
407
  // When the run last moved. Kaizen records no liveness signal, so the file's
408
- // own mtime is the only evidence that anything is still working on it.
408
+ // own mtime is the evidence that anything is still working on it, along with
409
+ // progress.md, which a builder ticks between state.json writes.
409
410
  let moved = 0;
410
411
  try { moved = statSync(file).mtimeMs; } catch { /* vanished mid-read */ }
412
+ try { moved = Math.max(moved, statSync(join(dir, id, "progress.md")).mtimeMs); } catch { /* no progress kept */ }
411
413
  return [{ id, stage: s.stage ?? "?", awaiting: s.awaiting ?? null, moved, runner: s.runner, agent: s.agent, branch: typeof s.branch === "string" ? s.branch : undefined }];
412
414
  } catch { return []; }
413
415
  }).reverse();
@@ -612,6 +614,105 @@ export function allBacklog(state: string) {
612
614
  return out;
613
615
  }
614
616
 
617
+ // Every `- open|done|rejected:` line of a backlog file, numbered from 1 in file order.
618
+ // The number is how a notebook link (`#backlog-3`) and a backlog card name an item, so
619
+ // lines are never removed or reordered: closing one rewrites its status word in place.
620
+ export function backlogLines(file: string) {
621
+ let text = "";
622
+ try { text = readText(file); } catch { return []; }
623
+ const out: { n: number; status: string; text: string; line: number }[] = [];
624
+ text.split("\n").forEach((l, line) => {
625
+ const m = /^\s*-\s*(open|done|rejected):\s*(.*)$/.exec(l.replace(/\r$/, ""));
626
+ if (m) out.push({ n: out.length + 1, status: m[1]!, text: m[2]!.trim(), line });
627
+ });
628
+ return out;
629
+ }
630
+
631
+ // A backlog card's source: a run id, or "orphan" for the state dir's backlog.md.
632
+ function backlogFile(state: string, source: string) {
633
+ if (source === "orphan") return join(state, "backlog.md");
634
+ return /^[\w.-]+$/.test(source) ? join(state, "runs", source, "06-backlog.md") : null;
635
+ }
636
+
637
+ // Line n of a backlog, or null when the source or the line does not exist.
638
+ export function backlogItem(state: string, source: string, n: number) {
639
+ const file = backlogFile(state, source);
640
+ return (file && backlogLines(file).find((l) => l.n === n)) || null;
641
+ }
642
+
643
+ // The runs started from a backlog item, by the `Backlog: <source>#<n>` line the board
644
+ // puts in their request (beside `Parent:`). A run with no state.json yet is not counted.
645
+ // ponytail: reads every 00-request.md per refresh, as startedRuns does.
646
+ export function startedBacklog(state: string) {
647
+ const out = new Map<string, { id: string; stage: string }[]>();
648
+ for (const r of readRuns(state)) {
649
+ let body = "";
650
+ try { body = readText(join(state, "runs", r.id, "00-request.md")); } catch { continue; }
651
+ const m = /^\s*(?:\[[^\]]*\]\s*)?Backlog:\s*([\w.-]+)#(\d+)\s*$/im.exec(body);
652
+ if (!m) continue;
653
+ const k = `${m[1]}#${Number(m[2])}`;
654
+ out.set(k, [...(out.get(k) ?? []), { id: r.id, stage: r.stage }]);
655
+ }
656
+ return out;
657
+ }
658
+
659
+ export type BacklogCard = { state: string; where: string; source: string; n: number; text: string; raw: string; severity: string | null; at: string | null };
660
+
661
+ const SEVERITY_RANK: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 };
662
+
663
+ // The open backlog items of every non-abandoned run and the orphanage, as cards for the
664
+ // board's Ideas column: most severe first, then newest run first, then file order. An
665
+ // item a live run was started from is not shown; an abandoned one gives it back.
666
+ export function backlogCards(states: string[]): BacklogCard[] {
667
+ const out: BacklogCard[] = [];
668
+ for (const st of states) {
669
+ const where = label(st);
670
+ const started = startedBacklog(st);
671
+ const runs = existsSync(join(st, "runs")) ? readdirSync(join(st, "runs")).sort().reverse() : [];
672
+ const sources = [...runs.filter((id) => existsSync(join(st, "runs", id, "state.json")) && !abandoned(join(st, "runs", id))), "orphan"];
673
+ for (const source of sources) {
674
+ for (const l of backlogLines(backlogFile(st, source)!)) {
675
+ if (l.status !== "open") continue;
676
+ if ((started.get(`${source}#${l.n}`) ?? []).some((c) => c.stage !== "abandoned")) continue;
677
+ const it = parseItem(l.text);
678
+ out.push({ state: st, where, source, n: l.n, text: it.text, raw: l.text, severity: it.severity, at: it.where });
679
+ }
680
+ }
681
+ }
682
+ const rank = (c: BacklogCard) => SEVERITY_RANK[c.severity ?? ""] ?? 4;
683
+ return out.sort((a, b) => rank(a) - rank(b));
684
+ }
685
+
686
+ // Rewrite the status of backlog line n from open, keeping its text and every other
687
+ // byte of the file; `suffix` is appended after a ` | `. Null when written, else why not.
688
+ export function closeBacklog(state: string, source: string, n: number, status: "done" | "rejected", suffix: string): string | null {
689
+ const file = backlogFile(state, source);
690
+ if (!file || !existsSync(file)) return "no such backlog";
691
+ const item = backlogLines(file).find((l) => l.n === n);
692
+ if (!item) return "no such backlog item";
693
+ if (item.status !== "open") return "that item is already closed";
694
+ // The raw bytes, not readText: a CRLF file or a BOM stays as it was written.
695
+ const lines = readFileSync(file, "utf8").split("\n");
696
+ const old = lines[item.line]!, cr = old.endsWith("\r") ? "\r" : "", bom = item.line === 0 && old.startsWith("\uFEFF") ? "\uFEFF" : "";
697
+ const body = old.slice(bom.length).replace(/\r$/, "");
698
+ if (!/^\s*-\s*open:/.test(body)) return "no such backlog item";
699
+ lines[item.line] = bom + body.replace(/^(\s*-\s*)open:\s*/, `$1${status}: `).replace(/\s+$/, "") + ` | ${suffix}` + cr;
700
+ writeFileSync(file, lines.join("\n"));
701
+ return null;
702
+ }
703
+
704
+ // A backlog item whose run is done is closed by that run: `done: <text> | <run id>`.
705
+ // Run from the board's clock, never from a page load. Reads requests and state.json,
706
+ // writes only backlog files, so no run looks as if it moved.
707
+ export function markBacklogDone(state: string) {
708
+ for (const [k, kids] of startedBacklog(state)) {
709
+ const done = kids.find((c) => c.stage === "done");
710
+ if (!done) continue;
711
+ const [source, n] = k.split("#");
712
+ closeBacklog(state, source!, Number(n), "done", done.id);
713
+ }
714
+ }
715
+
615
716
  // ---------------------------------------------------------------- board data
616
717
 
617
718
  // ---------------------------------------------------------------- board data
@@ -1015,7 +1116,8 @@ export function boardCards(states: string[], now: number): Card[] {
1015
1116
  }
1016
1117
  // A launched idea whose run reworded the request: the board's own launch record,
1017
1118
  // owned by a run (session id in its state.json, see ownerOf), retires it all the same.
1018
- const launchedInto = (st: string, t: string) => readSessions(st).some((s) => !s.run && normalise(s.text.replace(/^(lite|full|auto|plan)\s+/i, "")) === t.replace(/^(lite|full|auto|plan)\s+/i, "") && ownerOf(st, s) !== null);
1119
+ const indexes = new Map<string, RunIndex>(), indexOf = (st: string) => indexes.get(st) ?? indexes.set(st, runIndex(st)).get(st)!;
1120
+ const launchedInto = (st: string, t: string) => readSessions(st).some((s) => !s.run && normalise(s.text.replace(/^(lite|full|auto|plan)\s+/i, "")) === t.replace(/^(lite|full|auto|plan)\s+/i, "") && ownerOf(st, s, indexOf(st)) !== null);
1019
1121
  for (const st of states) {
1020
1122
  const where = label(st);
1021
1123
  const archive = readArchive(st);
@@ -1041,7 +1143,8 @@ export function boardCards(states: string[], now: number): Card[] {
1041
1143
  }
1042
1144
  // Runs started in Yolo. Only yolo records are matched to runs, so a project that
1043
1145
  // never used it pays one directory read.
1044
- const yolos = new Set(readSessions(st).filter((s) => s.yolo).map((s) => ownerOf(st, s)));
1146
+ const yoloRecs = readSessions(st).filter((s) => s.yolo);
1147
+ const yolos = new Set(yoloRecs.length ? yoloRecs.map((s) => ownerOf(st, s, indexOf(st))) : []);
1045
1148
  for (const r of readRuns(st)) {
1046
1149
  next.push({
1047
1150
  kind: "run", id: r.id, status: statusOf(r, now), text: short(r.id), state: st, where, column: columnOf(r.stage, r.awaiting), moved: r.moved,
@@ -1225,33 +1328,45 @@ export function readSessions(st: string): Session[] {
1225
1328
  }).sort((a, b) => a.started - b.started);
1226
1329
  }
1227
1330
 
1331
+ // What ownerOf matches a record against, read once: each conversation id a run recorded
1332
+ // at intake, to the first run (by id) that recorded it, and every run's request with
1333
+ // when it was written.
1334
+ type RunIndex = { bySession: Map<string, string>; requests: { id: string; born: number; text: string }[] };
1335
+ export function runIndex(st: string): RunIndex {
1336
+ const bySession = new Map<string, string>(), requests: RunIndex["requests"] = [];
1337
+ try {
1338
+ for (const id of readdirSync(join(st, "runs")).sort()) {
1339
+ try {
1340
+ const sid = JSON.parse(readText(join(st, "runs", id, "state.json"))).session;
1341
+ if (sid && !bySession.has(sid)) bySession.set(sid, id);
1342
+ } catch { /* no state yet */ }
1343
+ try {
1344
+ const f = join(st, "runs", id, "00-request.md"), st2 = statSync(f);
1345
+ requests.push({ id, born: st2.birthtimeMs || st2.mtimeMs, text: normalise(readText(f)) });
1346
+ } catch { /* no request yet */ }
1347
+ }
1348
+ } catch { /* no runs dir */ }
1349
+ return { bySession, requests };
1350
+ }
1351
+
1228
1352
  // Whose session a record is. A decision's launch names its run. An idea's launch came
1229
1353
  // before its run existed, so it belongs to the first run created after it whose
1230
1354
  // request holds the idea's text: one run per record, never every run that mentions it.
1231
- export function ownerOf(st: string, s: Session): string | null {
1355
+ // A caller asking about many sessions passes runIndex(st) once: built per call, it
1356
+ // reads every run's state.json and request each time.
1357
+ export function ownerOf(st: string, s: Session, idx?: RunIndex): string | null {
1232
1358
  if (s.run) return s.run;
1359
+ const { bySession, requests } = idx ?? runIndex(st);
1233
1360
  // The launch's own conversation id, recorded by the run at intake: holds however the
1234
1361
  // request was worded. Text matching below is for tools that expose no id.
1235
1362
  if (s.sessionId) {
1236
- try {
1237
- const mine = readdirSync(join(st, "runs")).filter((id) => {
1238
- try { return JSON.parse(readText(join(st, "runs", id, "state.json"))).session === s.sessionId; } catch { return false; }
1239
- }).sort();
1240
- if (mine.length) return mine[0]!;
1241
- } catch { /* no runs dir */ }
1363
+ const mine = bySession.get(s.sessionId);
1364
+ if (mine) return mine;
1242
1365
  }
1243
1366
  const t = normalise(s.text);
1244
1367
  let best: string | null = null, at = Infinity;
1245
- try {
1246
- for (const id of readdirSync(join(st, "runs"))) {
1247
- try {
1248
- const f = join(st, "runs", id, "00-request.md");
1249
- const st2 = statSync(f), born = st2.birthtimeMs || st2.mtimeMs;
1250
- // A second of slack: file times come from a coarser clock than Date.now().
1251
- if (born >= s.started - 1000 && born < at && normalise(readText(f)).includes(t)) { best = id; at = born; }
1252
- } catch { /* no request yet */ }
1253
- }
1254
- } catch { /* no runs dir */ }
1368
+ // A second of slack: file times come from a coarser clock than Date.now().
1369
+ for (const r of requests) if (r.born >= s.started - 1000 && r.born < at && r.text.includes(t)) { best = r.id; at = r.born; }
1255
1370
  return best;
1256
1371
  }
1257
1372
 
@@ -1278,16 +1393,17 @@ export function launchMode(projectDir: string): "card" | "terminal" {
1278
1393
  // Whether a live PID is still the agent a record launched, not a stranger that reused
1279
1394
  // the number: the tool's name (and conversation id) in its arguments, started no
1280
1395
  // earlier than the record. On Windows the PID is the PowerShell kaizen opened.
1281
- function isSession(pid: number, s: Session): boolean {
1396
+ // null: the system could not say (no ps, no answer), which callers treat as not ours.
1397
+ function isSession(pid: number, s: Session): boolean | null {
1282
1398
  try {
1283
1399
  if (process.platform === "win32") {
1284
- const out = Bun.spawnSync([sysExe("tasklist.exe"), "/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], { stdin: "ignore", timeout: 5000, windowsHide: true }).stdout.toString();
1285
- return /^"(powershell|pwsh)\.exe"/i.test(out.trim());
1400
+ const out = Bun.spawnSync([sysExe("tasklist.exe"), "/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], { stdin: "ignore", timeout: 5000, windowsHide: true }).stdout.toString().trim();
1401
+ return out.startsWith('"') ? /^"(powershell|pwsh)\.exe"/i.test(out) : null;
1286
1402
  }
1287
1403
  const ps = onPath("ps");
1288
- if (!ps) return false;
1404
+ if (!ps) return null;
1289
1405
  const m = /^\s*(\S+)\s+(.*)$/.exec(Bun.spawnSync([ps, "-o", "etime=,args=", "-p", String(pid)], { stdin: "ignore", timeout: 5000 }).stdout.toString().trim());
1290
- if (!m) return false;
1406
+ if (!m) return null;
1291
1407
  // etime is [[dd-]hh:]mm:ss
1292
1408
  const [d, rest] = m[1].includes("-") ? m[1].split("-") : ["0", m[1]];
1293
1409
  const secs = rest.split(":").reduce((a, n) => a * 60 + Number(n), 0) + Number(d) * 86400;
@@ -1296,7 +1412,7 @@ function isSession(pid: number, s: Session): boolean {
1296
1412
  const idInArgs = s.sessionId && (SESSION_FLAGS[s.cmd]?.start || s.resumed);
1297
1413
  return Date.now() - secs * 1000 >= s.started - 2000 && m[2].includes(s.cmd) && (!idInArgs || m[2].includes(s.sessionId!));
1298
1414
  } catch {
1299
- return false;
1415
+ return null;
1300
1416
  }
1301
1417
  }
1302
1418
 
@@ -1323,7 +1439,19 @@ function closeOne(st: string, s: Session): boolean {
1323
1439
  return closed;
1324
1440
  }
1325
1441
 
1326
- const openSessions = (st: string) => readSessions(st).filter((s) => existsSync(join(sessionsDir(st), s.key + ".pid")));
1442
+ // Sessions with a PID file. A file whose process is gone, or whose number another
1443
+ // process now holds, is dropped on the way: one is left per launch that ended by itself,
1444
+ // and each would be asked about every sweep. Only when the system says so; a PID it
1445
+ // cannot answer for stays.
1446
+ const openSessions = (st: string) => readSessions(st).filter((s) => {
1447
+ const f = join(sessionsDir(st), s.key + ".pid");
1448
+ if (!existsSync(f)) return false;
1449
+ const pid = parseInt(readText(f));
1450
+ let gone = false;
1451
+ try { process.kill(pid, 0); } catch (e: any) { gone = e?.code === "ESRCH"; }
1452
+ if (gone || (pid > 0 && isSession(pid, s) === false)) { rmSync(f, { force: true }); return false; }
1453
+ return true;
1454
+ });
1327
1455
 
1328
1456
  // Close every session kaizen opened for a run, those launched before `before` only,
1329
1457
  // so a decision's own successor is never caught. The record stays: it holds the
@@ -1331,7 +1459,8 @@ const openSessions = (st: string) => readSessions(st).filter((s) => existsSync(j
1331
1459
  export function closeSessions(st: string, id: string, opts: { before?: number } = {}): number {
1332
1460
  try {
1333
1461
  if (!closeEnabled(dirname(st))) return 0;
1334
- return openSessions(st).filter((s) => s.started < (opts.before ?? Infinity) && ownerOf(st, s) === id).filter((s) => closeOne(st, s)).length;
1462
+ const idx = runIndex(st);
1463
+ return openSessions(st).filter((s) => s.started < (opts.before ?? Infinity) && ownerOf(st, s, idx) === id).filter((s) => closeOne(st, s)).length;
1335
1464
  } catch { return 0; }
1336
1465
  }
1337
1466
 
@@ -1343,9 +1472,9 @@ export function sweepSessions(st: string, now: number) {
1343
1472
  try {
1344
1473
  const live = openSessions(st);
1345
1474
  if (!live.length || !closeEnabled(dirname(st))) return;
1346
- const runs = new Map(readRuns(st).map((r) => [r.id, r]));
1475
+ const runs = new Map(readRuns(st).map((r) => [r.id, r])), idx = runIndex(st);
1347
1476
  for (const s of live) {
1348
- const r = runs.get(ownerOf(st, s) ?? "");
1477
+ const r = runs.get(ownerOf(st, s, idx) ?? "");
1349
1478
  if (r && r.stage === "done" && s.started <= r.moved && now - r.moved >= CLOSE_GRACE_MS && !typedSince(st, s, r.moved)) closeOne(st, s);
1350
1479
  }
1351
1480
  } catch { /* the next sweep tries again */ }
@@ -1376,9 +1505,9 @@ export function firstSession(st: string, id: string, cmd: string, cwd: string):
1376
1505
  const f = SESSION_FLAGS[cmd];
1377
1506
  if (!f) return null;
1378
1507
  const all = readSessions(st);
1379
- const taken = new Set(all.flatMap((s) => (s.sessionId ? [s.sessionId] : [])));
1508
+ const taken = new Set(all.flatMap((s) => (s.sessionId ? [s.sessionId] : []))), idx = runIndex(st);
1380
1509
  for (const s of all) {
1381
- if (s.cmd !== cmd || ownerOf(st, s) !== id) continue;
1510
+ if (s.cmd !== cmd || ownerOf(st, s, idx) !== id) continue;
1382
1511
  if (!s.sessionId && f.find) {
1383
1512
  // Before the tool's next launch here, any run's: from then on a conversation may be that launch's.
1384
1513
  const next = all.find((o) => o.cmd === cmd && o.started > s.started)?.started ?? Infinity;
@@ -1397,7 +1526,7 @@ export function firstSession(st: string, id: string, cmd: string, cwd: string):
1397
1526
  // The tool and model a run was started with: its latest recorded launch, else the
1398
1527
  // agent its state.json names. A decision continues on these whatever the config says now.
1399
1528
  export function runAgent(st: string, id: string): { cmd: string; model: string | null } | null {
1400
- const s = readSessions(st).filter((x) => ownerOf(st, x) === id).pop();
1529
+ const idx = runIndex(st), s = readSessions(st).filter((x) => ownerOf(st, x, idx) === id).pop();
1401
1530
  if (s) return { cmd: s.cmd, model: s.model ?? null };
1402
1531
  try {
1403
1532
  const a = JSON.parse(readText(join(st, "runs", id, "state.json"))).agent;
@@ -1469,7 +1598,9 @@ export function launchRun(it: Item, from: string, run?: string, pick: { agent?:
1469
1598
  // Yolo was picked for one tool; a fallback to another opens in its normal mode.
1470
1599
  // A decision on a run started in Yolo keeps it, or its next stage stops on every command.
1471
1600
  const st = join(projectDir, ".kaizen");
1472
- const wasYolo = !!run && !pick.agent && readSessions(st).some((s) => s.yolo && s.cmd === agent.cmd && ownerOf(st, s) === run);
1601
+ const yoloRecs = run && !pick.agent ? readSessions(st).filter((s) => s.yolo && s.cmd === agent.cmd) : [];
1602
+ const idx = yoloRecs.length ? runIndex(st) : undefined;
1603
+ const wasYolo = yoloRecs.some((s) => ownerOf(st, s, idx) === run);
1473
1604
  const yolo = (wasYolo || (!!pick.yolo && agent.cmd === pick.agent)) && !!YOLO_FLAGS[agent.cmd];
1474
1605
  const flags = [...agentFlags(projectDir, agent.cmd, undefined, model), ...(yolo ? YOLO_FLAGS[agent.cmd] : [])];
1475
1606
  // The idea as its run's request will hold it: without the runner word in front.
@@ -1555,7 +1686,8 @@ async function gitAsync(dir: string, ...args: string[]) {
1555
1686
  } catch { return NO_GIT; }
1556
1687
  }
1557
1688
 
1558
- export type GitStatus = { repo: false } | { repo: true; branch: string; remote: string; files: string[]; ahead: number; others?: number; shared?: string[] };
1689
+ export type GitStatus = { repo: false } | { repo: true; branch: string; remote: string; files: string[]; ahead: number; others?: number; shared?: string[]; unpushed?: Unpushed[] };
1690
+ type Unpushed = { at: number; subject: string; files: string[] };
1559
1691
 
1560
1692
  // A project the board can commit for: a git work tree with somewhere to push.
1561
1693
  export function gitStatus(projectDir: string): GitStatus {
@@ -1567,7 +1699,14 @@ export function gitStatus(projectDir: string): GitStatus {
1567
1699
  // No upstream yet means nothing of this branch is on the remote: any commit is ahead.
1568
1700
  const up = git(projectDir, "rev-list", "--count", "@{u}..HEAD");
1569
1701
  const ahead = up.ok ? Number(up.out) : git(projectDir, "rev-parse", "HEAD").ok ? 1 : 0;
1570
- return { repo: true, branch: git(projectDir, "branch", "--show-current").out, remote, files: st ? st.split("\n") : [], ahead };
1702
+ // Each commit not on the remote yet, with its files: a run is ahead only by its own.
1703
+ const unpushed = up.ok && ahead > 0
1704
+ ? git(projectDir, "log", "@{u}..HEAD", "--format=%x00%ct %s", "--name-only").out.split("\0").slice(1).map((c) => {
1705
+ const [head, ...files] = c.trim().split("\n"), sp = head.indexOf(" ");
1706
+ return { at: Number(head.slice(0, sp)) * 1000, subject: head.slice(sp + 1).trim(), files: files.filter(Boolean) };
1707
+ })
1708
+ : undefined;
1709
+ return { repo: true, branch: git(projectDir, "branch", "--show-current").out, remote, files: st ? st.split("\n") : [], ahead, ...(unpushed ? { unpushed } : {}) };
1571
1710
  }
1572
1711
 
1573
1712
  // The path of a `git status --porcelain` line; for a rename, the new one.
@@ -1592,10 +1731,44 @@ function runReports(state: string): Report[] {
1592
1731
  try { ids = readdirSync(join(state, "runs")); } catch {}
1593
1732
  return ids.map((id) => ({ id, ...runReport(state, id) }));
1594
1733
  }
1595
- const runFiles = (changed: string[], mine: Report, rest: Report[]) => changed.filter((l) => {
1734
+ // A run holds a path at time `t` when its report names it, was written by then, and no
1735
+ // other run naming it reported in between.
1736
+ const owns = (mine: Report, rest: Report[], p: string, t: number) =>
1737
+ names(mine.text, p) && mine.at <= t && !rest.some((r) => r.at > mine.at && r.at <= t && names(r.text, p));
1738
+ const lastCommit = (dir: string, p: string) => Number(git(dir, "--literal-pathspecs", "log", "-1", "--format=%ct", "--", p).out) * 1000;
1739
+ // A changed file is the run's unless it was committed after the report: then the run's
1740
+ // work in it is in, and what changed since is someone else's. A commit in the same
1741
+ // second as the report keeps the file offered; hiding real work is the worse mistake.
1742
+ const runFiles = (dir: string, changed: string[], mine: Report, rest: Report[]) => changed.filter((l) => {
1596
1743
  const p = statusPath(l);
1597
- return names(mine.text, p) && !rest.some((r) => r.at > mine.at && names(r.text, p));
1744
+ return owns(mine, rest, p, Infinity) && !(lastCommit(dir, p) > mine.at);
1598
1745
  });
1746
+ // How many unpushed commits each run has, worked out once for every run. A commit is
1747
+ // one run's, so a wide commit is not pinned on every old run that once touched a file
1748
+ // in it: the run whose report holds its subject (the `## Commit` message), else, of
1749
+ // the runs that reported by then (to the end of that second, since commit times are
1750
+ // whole seconds), the one naming most of its paths, the latest on a tie. One naming
1751
+ // none, a merge or a hand commit, goes to the run that reported last, so some run
1752
+ // still offers the push. No upstream yet: nothing is on the remote, every run is ahead by
1753
+ // the branch.
1754
+ function aheadBy(s: GitStatus & { repo: true }, all: Report[]): (id: string) => number {
1755
+ if (!s.unpushed) return () => s.ahead;
1756
+ const n = new Map<string, number>();
1757
+ for (const c of s.unpushed) {
1758
+ // The subject as a line of its own: a short one ("wip") is not found inside prose.
1759
+ let best = c.subject ? all.find((r) => r.text.split("\n").some((l) => l.trim() === c.subject)) : undefined;
1760
+ if (!best) {
1761
+ let most = -1;
1762
+ for (const r of all) {
1763
+ if (!r.text || r.at > c.at + 999) continue;
1764
+ const k = c.files.filter((p) => names(r.text, p)).length;
1765
+ if (k > most || (k === most && r.at > best!.at)) { best = r; most = k; }
1766
+ }
1767
+ }
1768
+ if (best) n.set(best.id, (n.get(best.id) ?? 0) + 1);
1769
+ }
1770
+ return (id) => n.get(id) ?? 0;
1771
+ }
1599
1772
 
1600
1773
  // The project's status narrowed to one run: a changed file is the run's when its
1601
1774
  // report names the path in backticks and no run that reported later names it too,
@@ -1610,14 +1783,15 @@ export function runGit(state: string, id: string): GitStatus {
1610
1783
  if (!s.repo) return s;
1611
1784
  const all = runReports(state), mine = all.find((r) => r.id === id) ?? { id, text: "", at: 0 };
1612
1785
  const rest = all.filter((o) => o.id !== id);
1613
- const files = runFiles(s.files, mine, rest);
1786
+ const files = runFiles(dir, s.files, mine, rest);
1614
1787
  const shared = files.flatMap((l) => {
1615
1788
  const p = statusPath(l);
1616
- const since = Number(git(dir, "--literal-pathspecs", "log", "-1", "--format=%ct", "--", p).out) * 1000;
1789
+ const since = lastCommit(dir, p);
1617
1790
  const who = rest.filter((r) => r.at > since && names(r.text, p)).map((r) => short(r.id));
1618
1791
  return who.length ? [`${p}: ${who.join(", ")}`] : [];
1619
1792
  });
1620
- return { ...s, files, others: s.files.length - files.length, shared };
1793
+ const { unpushed, ...base } = s;
1794
+ return { ...base, files, ahead: aheadBy(s, all)(id), others: s.files.length - files.length, shared };
1621
1795
  }
1622
1796
 
1623
1797
  // What the board did for a run, kept beside the run and not in state.json, whose
@@ -1633,15 +1807,15 @@ export function readCommit(state: string, id: string): { sha: string; pushed: bo
1633
1807
  // (`gitBtn` in web/board/git.ts): one project status and one read of the reports for
1634
1808
  // the lot. A run with nothing to say either way gets no entry.
1635
1809
  export function cardsGit(state: string, ids: string[]): Record<string, "pending" | "pushed"> {
1636
- const s = gitStatus(dirname(state)), out: Record<string, "pending" | "pushed"> = {};
1810
+ const dir = dirname(state), s = gitStatus(dir), out: Record<string, "pending" | "pushed"> = {};
1637
1811
  if (!s.repo) return out;
1638
- const all = runReports(state);
1812
+ const all = runReports(state), ahead = aheadBy(s, all);
1639
1813
  for (const id of ids) {
1640
1814
  const mine = all.find((r) => r.id === id) ?? { id, text: "", at: 0 }, k = readCommit(state, id);
1641
- const files = runFiles(s.files, mine, all.filter((o) => o.id !== id)).length;
1815
+ const files = runFiles(dir, s.files, mine, all.filter((o) => o.id !== id)).length;
1642
1816
  if (files) out[id] = "pending";
1643
1817
  else if (k?.pushed) out[id] = "pushed";
1644
- else if (s.ahead > 0) out[id] = "pending";
1818
+ else if (ahead(id) > 0) out[id] = "pending";
1645
1819
  else if (k || mine.text) out[id] = "pushed";
1646
1820
  }
1647
1821
  return out;
package/cli/web.ts CHANGED
@@ -6,8 +6,8 @@ import {
6
6
  home, type Item, type Card, boardCards, knownProjects, remember, locate, label, tilde, searchRoots, findProjects,
7
7
  readInbox, writeInbox, replaceIdea, moveIdea, abandonRun, launchRun, parseItem, agentChoices, projectOf, KNOWN_AGENTS, plainModel, short, setArchived, writeNotes,
8
8
  appendNote, saveAttachment, pidOnPort, readText, cmdExe, sysExe, powershellExe, runGit, cardsGit, readCommit, commitPush, notice, notifier, LOGO,
9
- reviewFindings, pickFindings, fixPrompt, approvalPrompt, canApproveAwaiting, nativePath, closeSessions, sweepSessions, issueUrl,
10
- launchMode, readSessions, ownerOf, resumePrompt, runAgent, firstSession, webHosts,
9
+ reviewFindings, pickFindings, backlogCards, backlogItem, startedBacklog, closeBacklog, markBacklogDone, fixPrompt, approvalPrompt, canApproveAwaiting, nativePath, closeSessions, sweepSessions, issueUrl,
10
+ launchMode, readSessions, ownerOf, runIndex, resumePrompt, runAgent, firstSession, webHosts,
11
11
  } from "./state.ts";
12
12
  import { attachPty, writePty, resizePty, ptyState, ptyKeys, adoptLoginEnv } from "./pty.ts";
13
13
  import { readSources, addSource, removeSource, moveSource, setCategory, setWriteback, readIcebox, promote, dismiss, restore, addItem, githubRepo, markDone, markReplied, gatherSource, freshImages, gatherAll, gatherDirs, due, socialUrl, socialOf } from "./sources.ts";
@@ -190,17 +190,27 @@ export async function web(repoDir: string, opts: Opts = {}) {
190
190
  const inCard = (dir: string) => ({ card: launchMode(projectOf(dir)) === "card", onChange: changed });
191
191
  // An idea's session, until its run exists and owns it (ownerOf).
192
192
  const ideaPty = new Map<string, string>();
193
+ // Backlog items this board launched whose run has not written its state.json yet:
194
+ // shown as starting, like a started idea. Forgotten on restart; the card then offers Run again.
195
+ const backlogLaunched = new Set<string>();
193
196
  // The card's newest in-card session, live or recently ended. Sessions are only read
194
197
  // while this board has any, so /state costs nothing more for terminal launches.
195
- const ptyOf = (c: Card) => {
198
+ // Owners are worked out once per /state: per card it re-read every session and every
199
+ // run's state.json, about a second on a board of 400 runs.
200
+ const ptyOf = (cards: Card[]) => {
196
201
  const live = new Set(ptyKeys());
197
- if (!live.size) return undefined;
198
- const key = c.kind === "run"
199
- ? readSessions(c.state).filter((s) => live.has(s.key) && ownerOf(c.state, s) === c.id).pop()?.key
200
- : ideaPty.get(`${c.state}\0${c.text}`);
201
- if (!key || !live.has(key)) return undefined;
202
- const st = ptyState(key)!;
203
- return { key, alive: st.alive, asking: st.asking };
202
+ if (!live.size) return () => undefined;
203
+ const runs = new Map<string, string>();
204
+ for (const d of new Set(cards.filter((c) => c.kind === "run").map((c) => c.state))) {
205
+ const ids = runIndex(d);
206
+ for (const s of readSessions(d)) if (live.has(s.key)) runs.set(`${d}\0${ownerOf(d, s, ids)}`, s.key); // oldest first: the newest wins
207
+ }
208
+ return (c: Card) => {
209
+ const key = c.kind === "run" ? runs.get(`${c.state}\0${c.id}`) : ideaPty.get(`${c.state}\0${c.text}`);
210
+ if (!key || !live.has(key)) return undefined;
211
+ const st = ptyState(key)!;
212
+ return { key, alive: st.alive, asking: st.asking };
213
+ };
204
214
  };
205
215
  // Dev: the page reloads itself when board.html or its bundle is saved. Server code needs
206
216
  // `bun --watch cli/install.ts web`; the page reconnects and reloads on hello.
@@ -271,7 +281,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
271
281
  // Done requests are reported back to their sources here, on the board's own clock: a
272
282
  // page load only reads, so opening the Icebox never closes an issue.
273
283
  const loop = setInterval(async () => {
274
- for (const dir of states(true)) { try { markDone(dir); await writeBacks(dir); } catch { /* the next tick tries again */ } }
284
+ for (const dir of states(true)) { try { markDone(dir); markBacklogDone(dir); await writeBacks(dir); } catch { /* the next tick tries again */ } }
275
285
  }, 60_000);
276
286
 
277
287
  const BOOT = Date.now().toString(36);
@@ -443,7 +453,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
443
453
  return !root || d === state || (fold(p).startsWith(inside)
444
454
  && (!isGroup(p) || filled(d) || fold(dirname(p)) !== fold(root)));
445
455
  });
446
- const cards = boardCards(dirs, Date.now());
456
+ const cards = boardCards(dirs, Date.now()), pty = ptyOf(cards);
447
457
  // The git mark, for the cards whose panel offers commit and push: one status per project.
448
458
  const marks = new Map<string, Record<string, string>>();
449
459
  const markable = (c: Card) => c.kind === "run" && !c.archived && (c.status === "done" || c.awaiting === "approvals.review") && c.state !== join(home, ".kaizen");
@@ -456,7 +466,8 @@ export async function web(repoDir: string, opts: Opts = {}) {
456
466
  project, all, dir: state,
457
467
  projects: picker.map((d) => ({ dir: d, label: label(d), logo: !!projectLogo(d) })),
458
468
  fresh: unset.map((d) => ({ dir: join(d, ".kaizen"), label: tilde(d) })),
459
- cards: cards.map((c) => ({ ...c, notice: notice(c), git: markable(c) ? marks.get(c.state)?.[c.id!] : undefined, open: openFindings(c), pty: ptyOf(c) })),
469
+ cards: cards.map((c) => ({ ...c, notice: notice(c), git: markable(c) ? marks.get(c.state)?.[c.id!] : undefined, open: openFindings(c), pty: pty(c) })),
470
+ backlog: backlogCards(dirs).map((b) => ({ ...b, starting: backlogLaunched.has(`${b.state}\0${b.source}#${b.n}`) || undefined })),
460
471
  now: Date.now(),
461
472
  });
462
473
  }
@@ -667,7 +678,8 @@ export async function web(repoDir: string, opts: Opts = {}) {
667
678
  if (!/^(runs\/[\w.-]+\/)?attachments\/[\w.-]+$/.test(p)) return bad("bad path");
668
679
  const f = join(dir, p);
669
680
  if (!existsSync(f)) return bad("no such file", 404);
670
- return new Response(Bun.file(f), { headers: { "cache-control": "private, max-age=3600" } });
681
+ // no-cache: a revise retakes a screenshot under the same name, and the browser must ask again.
682
+ return new Response(Bun.file(f), { headers: { "cache-control": "private, no-cache" } });
671
683
  }
672
684
 
673
685
  // An icebox image, sent on to a link read fresh from its source: the one kept
@@ -781,6 +793,18 @@ export async function web(repoDir: string, opts: Opts = {}) {
781
793
  return json({ ok: true, project: label(dir) });
782
794
  }
783
795
 
796
+ // "Not doing this" on a backlog card: the line is closed in place with the reason,
797
+ // never removed, so a notebook link to it keeps its number.
798
+ if (path === "/backlog") {
799
+ if (body.op !== "reject") return bad("unknown op");
800
+ const why = String(body.reason ?? "").trim();
801
+ if (!why) return bad("a rejection needs a reason");
802
+ const err = closeBacklog(dir, String(body.source ?? ""), Number(body.n), "rejected", why);
803
+ if (err) return bad(err, err === "that item is already closed" ? 409 : 404);
804
+ changed();
805
+ return json({ ok: true });
806
+ }
807
+
784
808
  if (path === "/inbox") {
785
809
  const text = String(body.text ?? "").trim();
786
810
  // Absent means untouched; an empty string clears. replaceIdea keeps the
@@ -922,11 +946,30 @@ export async function web(repoDir: string, opts: Opts = {}) {
922
946
  if (path === "/run") {
923
947
  const text = String(body.text ?? "").trim();
924
948
  const kind = body.kind === "full" || body.kind === "lite" ? body.kind : "";
925
- if (!text) return bad("empty idea");
949
+ if (!text && !body.backlog) return bad("empty idea");
926
950
  const agent = typeof body.agent === "string" && body.agent ? body.agent : undefined;
927
951
  if (agent && !KNOWN_AGENTS.some((a) => a.cmd === agent)) return bad("unknown agent");
928
952
  const model = typeof body.model === "string" ? body.model.trim() : "";
929
953
  if (model && !plainModel(model)) return bad("A model name is letters, digits and . : / @ _ - only");
954
+ // A backlog item runs as an idea would, its text read here from the file, never
955
+ // from the page. The request names its source, which is how the board finds the
956
+ // line again: to hide the card while the run lives, and to close it when done.
957
+ if (body.backlog) {
958
+ const source = String(body.backlog.source ?? ""), n = Number(body.backlog.n);
959
+ const line = backlogItem(dir, source, n);
960
+ if (!line) return bad("no such backlog item", 404);
961
+ if (line.status !== "open") return bad("that item is already closed", 409);
962
+ if ((startedBacklog(dir).get(`${source}#${n}`) ?? []).some((c) => c.stage !== "abandoned")) return bad("a run was already started from that item", 409);
963
+ const p = parseItem(line.text);
964
+ const it: Item = { ...p, text: kind ? `${kind} ${p.text}` : p.text };
965
+ it.notes = [source !== "orphan" ? `Parent: ${source}` : "", `Backlog: ${source}#${n}`, p.text !== line.text ? `Backlog item: ${line.text}` : ""].filter(Boolean).join("\n");
966
+ const r = launchRun(it, dir, undefined, { agent, model: model || undefined, yolo: body.yolo === true, ...inCard(dir) });
967
+ if (r.ok) backlogLaunched.add(`${dir}\0${source}#${n}`);
968
+ changed();
969
+ return json(r.ok
970
+ ? { ok: true, agent: r.agent, project: tilde(r.projectDir), pty: r.pty }
971
+ : { ok: false, why: r.why, manual: r.manual, agent: r.agent }, r.ok ? 200 : 500);
972
+ }
930
973
  const it: Item = parseItem(`${kind} ${text}`.trim());
931
974
  // A `started` idea may be run again: the terminal the first launch opened
932
975
  // can come up empty, and nothing else tells the board a run never began.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-agent",
3
- "version": "1.42.2",
3
+ "version": "1.43.0",
4
4
  "description": "Staged workflow for AI agents — code, writing, research, operations: plan, human approval, build, independent review, bounded fix loop.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,7 +2,7 @@ import { store } from "./store.ts";
2
2
  import { where } from "./agent-term.ts";
3
3
  import { post } from "./api.ts";
4
4
  import { key, via } from "./cards.ts";
5
- import { $, esc } from "./dom.ts";
5
+ import { $, html } from "./dom.ts";
6
6
  import { openRun } from "./panel.ts";
7
7
  import { askNotify, toast } from "./toast.ts";
8
8
 
@@ -57,7 +57,7 @@ export async function decide(c, b, why, answers) {
57
57
  export function confirmRevise(c) {
58
58
  const foot = $("pfoot"),
59
59
  plan = c.awaiting === "approvals.plan";
60
- foot.innerHTML = `<div class="inl" style="flex:1"><div class="q">${plan ? "What should the plan do differently?" : "What should change in the work?"}</div><div class="q dim">Opens your agent on this run with your comment.${plan ? " It revises the plan and comes back here for approval." : " It changes the work, rechecks it and comes back here."}</div><textarea aria-label="Revise comment"></textarea><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Send back</button></div></div>`;
60
+ foot.innerHTML = html`<div class="inl" style="flex:1"><div class="q">${plan ? "What should the plan do differently?" : "What should change in the work?"}</div><div class="q dim">Opens your agent on this run with your comment.${plan ? " It revises the plan and comes back here for approval." : " It changes the work, rechecks it and comes back here."}</div><textarea aria-label="Revise comment"></textarea><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Send back</button></div></div>`;
61
61
  const inp = foot.querySelector("textarea");
62
62
  inp.focus();
63
63
  foot.querySelector("[data-a=c]").onclick = () => {
@@ -78,7 +78,7 @@ export function confirmRevise(c) {
78
78
  // a note for what the options miss. Each answer reaches the agent as one line.
79
79
  export function confirmAnswers(c, qs) {
80
80
  const foot = $("pfoot");
81
- foot.innerHTML = `<div class="inl qa" style="flex:1"><div class="q">The plan asks ${qs.length === 1 ? "one question" : qs.length + " questions"}.</div><div class="q dim">Your answers go to your agent with the approval. It records them and builds the plan.</div>${qs.map((x, i) => `<fieldset><legend>${esc(x.q)}</legend>${x.options.map((o, j) => `<label><input type="${x.multi ? "checkbox" : "radio"}" name="q${i}" value="${j}"${o.rec || (!x.multi && !j && !x.options.some((p) => p.rec)) ? " checked" : ""}> ${esc(o.text)}</label>`).join("")}<input type="text" data-n="${i}" placeholder="Note (optional)" aria-label="Note: ${esc(x.q)}"></fieldset>`).join("")}<div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Approve with these answers</button></div></div>`;
81
+ foot.innerHTML = html`<div class="inl qa" style="flex:1"><div class="q">The plan asks ${qs.length === 1 ? "one question" : qs.length + " questions"}.</div><div class="q dim">Your answers go to your agent with the approval. It records them and builds the plan.</div>${qs.map((x, i) => html`<fieldset><legend>${x.q}</legend>${x.options.map((o, j) => html`<label><input type="${x.multi ? "checkbox" : "radio"}" name="q${i}" value="${j}"${o.rec || (!x.multi && !j && !x.options.some((p) => p.rec)) ? " checked" : ""}> ${o.text}</label>`)}<input type="text" data-n="${i}" placeholder="Note (optional)" aria-label="Note: ${x.q}"></fieldset>`)}<div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Approve with these answers</button></div></div>`;
82
82
  foot.querySelector("[data-a=c]").onclick = () => {
83
83
  foot.innerHTML = "";
84
84
  openRun(c, true);