kaizen-agent 1.42.2 → 1.43.1

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.
Files changed (47) hide show
  1. package/agents/kaizen-reviewer.md +1 -1
  2. package/cli/backlog.test.ts +133 -0
  3. package/cli/board.test.ts +10 -4
  4. package/cli/browser.test.ts +1 -1
  5. package/cli/browser.ts +12 -7
  6. package/cli/html.test.ts +19 -0
  7. package/cli/notes.ts +2 -6
  8. package/cli/pty.ts +2 -1
  9. package/cli/settings.test.ts +38 -0
  10. package/cli/settings.ts +37 -30
  11. package/cli/state.test.ts +86 -3
  12. package/cli/state.ts +245 -45
  13. package/cli/web.ts +67 -16
  14. package/package.json +1 -1
  15. package/skills/kaizen/README.md +0 -4
  16. package/skills/kaizen/SKILL.md +4 -1
  17. package/skills/kaizen/config.default.yml +1 -50
  18. package/skills/kaizen/docs.md +3 -2
  19. package/skills/kaizen/settings.md +10 -3
  20. package/skills/kaizen/spec-build.md +3 -2
  21. package/skills/kaizen/spec-main.md +40 -5
  22. package/skills/kaizen/spec-review.md +2 -1
  23. package/skills/kaizen-help/SKILL.md +0 -4
  24. package/web/board/agent-term.ts +1 -1
  25. package/web/board/api.ts +2 -0
  26. package/web/board/approvals.ts +3 -3
  27. package/web/board/cards.ts +284 -108
  28. package/web/board/chat.ts +4 -4
  29. package/web/board/dom.ts +17 -5
  30. package/web/board/fixes.ts +6 -8
  31. package/web/board/git.ts +3 -3
  32. package/web/board/icebox.ts +52 -70
  33. package/web/board/keys.ts +9 -6
  34. package/web/board/live.ts +3 -2
  35. package/web/board/main.ts +1 -0
  36. package/web/board/panel.ts +22 -16
  37. package/web/board/projects.ts +11 -16
  38. package/web/board/settings.ts +60 -29
  39. package/web/board/store.ts +2 -0
  40. package/web/board/terms.ts +279 -0
  41. package/web/board/update.ts +7 -7
  42. package/web/board/views.ts +19 -5
  43. package/web/board.html +40 -2
  44. package/web/board.js +25 -24
  45. package/web/notebook.js +1 -1
  46. package/web/notes.html +2 -0
  47. package/web/term.js +1 -1
package/cli/state.test.ts CHANGED
@@ -7,7 +7,7 @@ import { tmpdir } from "node:os";
7
7
  import { issueUrl, systemInfo, scrubHome, REPO_URL, home as realHome, trustFolder } from "./state.ts";
8
8
  import * as stateModule from "./state.ts";
9
9
  import {
10
- readRuns, readInbox, writeInbox, replaceIdea, moveIdea, abandonRun, allBacklog, backlog,
10
+ readRuns, readInbox, writeInbox, replaceIdea, moveIdea, abandonRun, runToIdea, allBacklog, backlog,
11
11
  requestTitle, statusOf, columnOf, parseItem, startedRuns, boardCards, parentOf, addCard, readArchive, setArchived,
12
12
  requestOf, agentFlags, plainModel, readNotes, writeNotes, manualCommand, parseNotes, formatNote, appendNote, saveAttachment,
13
13
  reviewFindings, pickFindings, fixPrompt, approvalPrompt, sessionLaunch, closeSessions, sweepSessions, readSessions, ownerOf, CLOSE_GRACE_MS, gitStatus, runGit, cardsGit, commitPush, readCommit, findTerminal, nativePath, pruneRuns, unrechecked, notice, notifier, notifyArgs, LOGO, type Card,
@@ -57,6 +57,11 @@ test("statusOf: waiting beats stage, mtime decides running vs stalled", () => {
57
57
  expect(statusOf(stale!, now)).toBe("stalled");
58
58
  expect(statusOf({ ...stale!, moved: now }, now)).toBe("running");
59
59
  expect(statusOf({ ...stale!, stage: "abandoned", awaiting: "x" }, now)).toBe("abandoned");
60
+ // A builder ticking progress.md is moving, though state.json is old.
61
+ const progress = join(state, "runs", "2026-09-03-stale", "progress.md");
62
+ writeFileSync(progress, "- doing: step 2\n");
63
+ try { expect(statusOf(readRuns(state)[0]!, now)).toBe("running"); } finally { rmSync(progress); }
64
+ expect(statusOf(readRuns(state)[0]!, now)).toBe("stalled");
60
65
  });
61
66
 
62
67
  test("columnOf: every stage word lands somewhere", () => {
@@ -171,6 +176,30 @@ test("backlog: an abandoned run's items are neither listed nor counted", () => {
171
176
  rmSync(dir, { recursive: true });
172
177
  });
173
178
 
179
+ test("runToIdea: a planning run goes back to its idea, newer than the run", () => {
180
+ const id = "2026-09-04-planning", dir = join(state, "runs", id);
181
+ mkdirSync(dir, { recursive: true });
182
+ writeFileSync(join(dir, "state.json"), JSON.stringify({ id, stage: "plan", awaiting: "approvals.plan" }));
183
+ writeFileSync(join(dir, "00-request.md"), "/kaizen lite export invoices as csv\n");
184
+ const then = new Date(Date.now() - 30_000);
185
+ utimesSync(join(dir, "00-request.md"), then, then);
186
+ writeInbox(state, [...readInbox(state), { status: "started", text: "export invoices", added: Date.now() - 60_000 }, { status: "started", text: "export invoices as csv", notes: "see #12", added: Date.now() - 60_000 }]);
187
+ expect(runToIdea(state, id)).toBeNull();
188
+ expect(JSON.parse(readFileSync(join(dir, "state.json"), "utf8")).stage).toBe("abandoned");
189
+ const cards = boardCards([state], Date.now());
190
+ expect(cards.find((c) => c.kind === "idea" && c.text === "export invoices as csv")).toMatchObject({ status: "idea", notes: "see #12" });
191
+ expect(cards.find((c) => c.kind === "run" && c.id === id)?.archived).toBe(true);
192
+ expect(readInbox(state).filter((l) => l.text === "export invoices as csv").length).toBe(1);
193
+ expect(readInbox(state).find((l) => l.text === "export invoices")?.status).toBe("started");
194
+ expect(runToIdea(state, id)).toMatch(/planning/);
195
+ writeFileSync(join(dir, "state.json"), JSON.stringify({ id, stage: "plan" }));
196
+ rmSync(join(dir, "00-request.md"));
197
+ expect(runToIdea(state, id)).toMatch(/no request/);
198
+ replaceIdea(state, "export invoices", null);
199
+ setArchived(state, id, false);
200
+ rmSync(dir, { recursive: true });
201
+ });
202
+
174
203
  test("abandonRun refuses a done run", () => {
175
204
  expect(abandonRun(state, "2026-09-02-done", "late")).toMatch(/already done/);
176
205
  });
@@ -501,6 +530,52 @@ test("commitPush: commits the run's files only, pushes, and records it; the mess
501
530
  expect(cardsGit(join(root, ".kaizen"), ["r1"])).toEqual({}); // not a repo
502
531
  }, 60_000); // dozens of git calls, ~250ms each on Windows
503
532
 
533
+ test("runGit, cardsGit: a run committed elsewhere is not offered the edits made since, nor another run's unpushed commit", () => {
534
+ const root = mkdtempSync(join(tmpdir(), "kz-git-"));
535
+ const bare = join(root, "remote.git"), proj = join(root, "proj"), st = join(proj, ".kaizen");
536
+ // Commit times pinned (seconds since the epoch), so each sits where the test says.
537
+ const sh = (cwd: string, at: number | null, ...a: string[]) => Bun.spawnSync(["git", "-C", cwd, ...a], { env: { ...process.env, ...(at ? { GIT_COMMITTER_DATE: `@${at} +0000`, GIT_AUTHOR_DATE: `@${at} +0000` } : {}) } }).stdout.toString().trim();
538
+ const report = (id: string, text: string, at: number) => {
539
+ mkdirSync(join(st, "runs", id), { recursive: true });
540
+ writeFileSync(join(st, "runs", id, "03-impl.md"), text);
541
+ utimesSync(join(st, "runs", id, "03-impl.md"), new Date(at * 1000), new Date(at * 1000));
542
+ };
543
+ Bun.spawnSync(["git", "init", "-q", "--bare", bare]);
544
+ Bun.spawnSync(["git", "init", "-q", proj]);
545
+ for (const kv of [["user.name", "t"], ["user.email", "t@t"], ["commit.gpgsign", "false"], ["core.hooksPath", "/dev/null"]]) sh(proj, null, "config", ...kv);
546
+ sh(proj, null, "remote", "add", "origin", bare);
547
+ writeFileSync(join(proj, ".gitignore"), ".kaizen/\n");
548
+ writeFileSync(join(proj, "x.txt"), "1");
549
+ report("old", "- `x.txt`: x\n", 1000);
550
+ sh(proj, null, "add", "-A");
551
+ sh(proj, 2000, "commit", "-qm", "feat: x");
552
+ sh(proj, null, "push", "-qu", "origin", "HEAD");
553
+ // Edited again after the run's commit, by someone with no run of their own.
554
+ writeFileSync(join(proj, "x.txt"), "2");
555
+ expect(runGit(st, "old")).toMatchObject({ files: [], ahead: 0, others: 1 });
556
+ expect(cardsGit(st, ["old"])).toEqual({ old: "pushed" });
557
+ // Another run's commit, not pushed: the branch is ahead, the old run is not.
558
+ writeFileSync(join(proj, "y.txt"), "y");
559
+ report("new", "- `y.txt`: y\n", 3000);
560
+ sh(proj, null, "add", "y.txt");
561
+ sh(proj, 4000, "commit", "-qm", "feat: y");
562
+ expect(runGit(st, "old")).toMatchObject({ files: [], ahead: 0 });
563
+ expect(runGit(st, "new")).toMatchObject({ files: [], ahead: 1 });
564
+ expect(cardsGit(st, ["old", "new"])).toEqual({ old: "pushed", new: "pending" });
565
+ // The old run reporting again after its commit: the edit is its work after all.
566
+ report("old", "- `x.txt`: x, again\n", 5000);
567
+ expect((runGit(st, "old") as any).files).toEqual([" M x.txt"]);
568
+ // A commit no run's report names still gets a push: from the run that reported last by then.
569
+ const commit = (f: string, at: number, msg: string) => { writeFileSync(join(proj, f), f); sh(proj, null, "add", f); sh(proj, at, "commit", "-qm", msg); };
570
+ commit("z.txt", 6000, "chore: z");
571
+ expect(runGit(st, "old")).toMatchObject({ ahead: 1 });
572
+ expect(runGit(st, "new")).toMatchObject({ ahead: 1 });
573
+ // A run whose report holds the commit's subject as a line owns it, over the file count.
574
+ report("new", "- `y.txt`: y\n\n## Commit\nchore: z\n", 3000);
575
+ expect(runGit(st, "old")).toMatchObject({ ahead: 0 });
576
+ expect(runGit(st, "new")).toMatchObject({ ahead: 2 });
577
+ }, 60_000);
578
+
504
579
  test.skipIf(process.platform === "win32")("findTerminal: a running herdr wins, then tmux, else neither; the prompt stays one literal argument", () => {
505
580
  const bin = mkdtempSync(join(tmpdir(), "kz-bin-"));
506
581
  const stub = (name: string, code: number) => writeFileSync(join(bin, name), `#!/bin/sh\nexit ${code}\n`, { mode: 0o755 });
@@ -921,7 +996,7 @@ test.skipIf(process.platform === "win32")("closeSessions ends only the run's own
921
996
  const alive = (pr: ReturnType<typeof Bun.spawn>) => pr.exitCode === null && pr.signalCode === null;
922
997
  const procs: Record<string, ReturnType<typeof Bun.spawn>> = {
923
998
  mine: start("mine", {}),
924
- later: start("later", { started: Date.now() + 60_000 }), // the decision's own successor
999
+ later: start("later", { started: Date.now() - 1000 }), // the decision's own successor: after `before` below
925
1000
  stranger: start("stranger", { cmd: "notkzagent" }), // a PID that is not this agent
926
1001
  other: start("other", { run: "2026-10-01-b" }), // another run's
927
1002
  };
@@ -929,13 +1004,14 @@ test.skipIf(process.platform === "win32")("closeSessions ends only the run's own
929
1004
  writeFileSync(join(dir, "dead.json"), JSON.stringify({ run: "2026-10-01-a", text: "a", cmd: "kzagent", sessionId: null, started: 0 }));
930
1005
  writeFileSync(join(dir, "dead.pid"), "999999\n");
931
1006
  try {
932
- expect(closeSessions(st, "2026-10-01-a", { before: Date.now() })).toBe(1);
1007
+ expect(closeSessions(st, "2026-10-01-a", { before: Date.now() - 3000 })).toBe(1);
933
1008
  expect(await procs.mine.exited).not.toBe(0);
934
1009
  for (const k of ["later", "stranger", "other"] as const) expect(alive(procs[k])).toBe(true);
935
1010
  expect(existsSync(join(dir, "mine.pid"))).toBe(false);
936
1011
  expect(existsSync(join(dir, "mine.json"))).toBe(true); // its conversation id is still needed
937
1012
  expect(existsSync(join(dir, "dead.pid"))).toBe(false);
938
1013
  expect(existsSync(join(dir, "later.pid"))).toBe(true);
1014
+ expect(existsSync(join(dir, "stranger.pid"))).toBe(false); // its number is held by something else: dropped, not killed
939
1015
 
940
1016
  // Off: nothing is ended.
941
1017
  writeFileSync(join(st, "config.yml"), "agent:\n default: claude\n close: off\n");
@@ -951,6 +1027,13 @@ test.skipIf(process.platform === "win32")("closeSessions ends only the run's own
951
1027
  sweepSessions(st, Date.now() + CLOSE_GRACE_MS);
952
1028
  expect(await procs.other.exited).not.toBe(0);
953
1029
  expect(alive(procs.later)).toBe(true); // run a is still waiting
1030
+ // A PID file whose process is gone is dropped by the sweep, whatever its run is doing.
1031
+ writeFileSync(join(dir, "dead2.json"), JSON.stringify({ run: "2026-10-01-a", text: "a", cmd: "kzagent", sessionId: null, started: 0 }));
1032
+ writeFileSync(join(dir, "dead2.pid"), "999999\n");
1033
+ sweepSessions(st, Date.now());
1034
+ expect(existsSync(join(dir, "dead2.pid"))).toBe(false);
1035
+ expect(existsSync(join(dir, "dead2.json"))).toBe(true);
1036
+ expect(existsSync(join(dir, "later.pid"))).toBe(true);
954
1037
 
955
1038
  // A fix launched on a done run, after it last moved, is doing the work: kept.
956
1039
  const fix = start("fix", { run: "2026-10-01-b", started: Date.now() + 1000 });
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
@@ -914,6 +1015,32 @@ export function abandonRun(st: string, id: string, why: string): string | null {
914
1015
  }
915
1016
  }
916
1017
 
1018
+ // A run still planning, back to the idea it came from: abandoned and archived (the
1019
+ // record stays), and the idea open again. Its fresh stamp is newer than the run's
1020
+ // request, so the old run no longer retires it.
1021
+ export function runToIdea(st: string, id: string): string | null {
1022
+ let stage = "";
1023
+ try { stage = JSON.parse(readText(join(st, "runs", id, "state.json"))).stage; } catch {}
1024
+ if (stage !== "plan") return "Only a run still in planning goes back to an idea.";
1025
+ let req = "";
1026
+ try { req = readText(join(st, "runs", id, "00-request.md")).trim(); } catch {}
1027
+ const lines = readInbox(st);
1028
+ // The longest started idea the request holds: "test" must not stand in for "test login".
1029
+ let at = -1;
1030
+ lines.forEach((l, i) => { if (l.status === "started" && normalise(req).includes(normalise(l.text)) && (at < 0 || l.text.length > lines[at]!.text.length)) at = i; });
1031
+ const [first = "", ...rest] = req.split("\n");
1032
+ const idea: InboxLine = at >= 0
1033
+ ? { ...lines[at]!, status: "open", added: Date.now() }
1034
+ : { status: "open", text: first.replace(/^\/kaizen(?:-\w+)?\s+(?:(?:lite|full|auto|plan)\s+)?/i, "").trim(), notes: rest.join("\n").trim() || undefined, added: Date.now() };
1035
+ if (!idea.text) return "That run has no request to make an idea of.";
1036
+ const failed = abandonRun(st, id, "Sent back to ideas from the board.");
1037
+ if (failed) return failed;
1038
+ setArchived(st, id, true);
1039
+ if (at >= 0) lines.splice(at, 1);
1040
+ writeInbox(st, [...lines, idea]);
1041
+ return null;
1042
+ }
1043
+
917
1044
  export function replaceIdea(state: string, text: string, next: InboxLine | null) {
918
1045
  const lines = readInbox(state);
919
1046
  const at = lines.findIndex((l) => (l.status === "open" || l.status === "started") && l.text === text);
@@ -1015,7 +1142,8 @@ export function boardCards(states: string[], now: number): Card[] {
1015
1142
  }
1016
1143
  // A launched idea whose run reworded the request: the board's own launch record,
1017
1144
  // 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);
1145
+ const indexes = new Map<string, RunIndex>(), indexOf = (st: string) => indexes.get(st) ?? indexes.set(st, runIndex(st)).get(st)!;
1146
+ 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
1147
  for (const st of states) {
1020
1148
  const where = label(st);
1021
1149
  const archive = readArchive(st);
@@ -1041,7 +1169,8 @@ export function boardCards(states: string[], now: number): Card[] {
1041
1169
  }
1042
1170
  // Runs started in Yolo. Only yolo records are matched to runs, so a project that
1043
1171
  // never used it pays one directory read.
1044
- const yolos = new Set(readSessions(st).filter((s) => s.yolo).map((s) => ownerOf(st, s)));
1172
+ const yoloRecs = readSessions(st).filter((s) => s.yolo);
1173
+ const yolos = new Set(yoloRecs.length ? yoloRecs.map((s) => ownerOf(st, s, indexOf(st))) : []);
1045
1174
  for (const r of readRuns(st)) {
1046
1175
  next.push({
1047
1176
  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 +1354,45 @@ export function readSessions(st: string): Session[] {
1225
1354
  }).sort((a, b) => a.started - b.started);
1226
1355
  }
1227
1356
 
1357
+ // What ownerOf matches a record against, read once: each conversation id a run recorded
1358
+ // at intake, to the first run (by id) that recorded it, and every run's request with
1359
+ // when it was written.
1360
+ type RunIndex = { bySession: Map<string, string>; requests: { id: string; born: number; text: string }[] };
1361
+ export function runIndex(st: string): RunIndex {
1362
+ const bySession = new Map<string, string>(), requests: RunIndex["requests"] = [];
1363
+ try {
1364
+ for (const id of readdirSync(join(st, "runs")).sort()) {
1365
+ try {
1366
+ const sid = JSON.parse(readText(join(st, "runs", id, "state.json"))).session;
1367
+ if (sid && !bySession.has(sid)) bySession.set(sid, id);
1368
+ } catch { /* no state yet */ }
1369
+ try {
1370
+ const f = join(st, "runs", id, "00-request.md"), st2 = statSync(f);
1371
+ requests.push({ id, born: st2.birthtimeMs || st2.mtimeMs, text: normalise(readText(f)) });
1372
+ } catch { /* no request yet */ }
1373
+ }
1374
+ } catch { /* no runs dir */ }
1375
+ return { bySession, requests };
1376
+ }
1377
+
1228
1378
  // Whose session a record is. A decision's launch names its run. An idea's launch came
1229
1379
  // before its run existed, so it belongs to the first run created after it whose
1230
1380
  // 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 {
1381
+ // A caller asking about many sessions passes runIndex(st) once: built per call, it
1382
+ // reads every run's state.json and request each time.
1383
+ export function ownerOf(st: string, s: Session, idx?: RunIndex): string | null {
1232
1384
  if (s.run) return s.run;
1385
+ const { bySession, requests } = idx ?? runIndex(st);
1233
1386
  // The launch's own conversation id, recorded by the run at intake: holds however the
1234
1387
  // request was worded. Text matching below is for tools that expose no id.
1235
1388
  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 */ }
1389
+ const mine = bySession.get(s.sessionId);
1390
+ if (mine) return mine;
1242
1391
  }
1243
1392
  const t = normalise(s.text);
1244
1393
  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 */ }
1394
+ // A second of slack: file times come from a coarser clock than Date.now().
1395
+ 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
1396
  return best;
1256
1397
  }
1257
1398
 
@@ -1278,16 +1419,17 @@ export function launchMode(projectDir: string): "card" | "terminal" {
1278
1419
  // Whether a live PID is still the agent a record launched, not a stranger that reused
1279
1420
  // the number: the tool's name (and conversation id) in its arguments, started no
1280
1421
  // earlier than the record. On Windows the PID is the PowerShell kaizen opened.
1281
- function isSession(pid: number, s: Session): boolean {
1422
+ // null: the system could not say (no ps, no answer), which callers treat as not ours.
1423
+ function isSession(pid: number, s: Session): boolean | null {
1282
1424
  try {
1283
1425
  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());
1426
+ const out = Bun.spawnSync([sysExe("tasklist.exe"), "/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], { stdin: "ignore", timeout: 5000, windowsHide: true }).stdout.toString().trim();
1427
+ return out.startsWith('"') ? /^"(powershell|pwsh)\.exe"/i.test(out) : null;
1286
1428
  }
1287
1429
  const ps = onPath("ps");
1288
- if (!ps) return false;
1430
+ if (!ps) return null;
1289
1431
  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;
1432
+ if (!m) return null;
1291
1433
  // etime is [[dd-]hh:]mm:ss
1292
1434
  const [d, rest] = m[1].includes("-") ? m[1].split("-") : ["0", m[1]];
1293
1435
  const secs = rest.split(":").reduce((a, n) => a * 60 + Number(n), 0) + Number(d) * 86400;
@@ -1296,7 +1438,7 @@ function isSession(pid: number, s: Session): boolean {
1296
1438
  const idInArgs = s.sessionId && (SESSION_FLAGS[s.cmd]?.start || s.resumed);
1297
1439
  return Date.now() - secs * 1000 >= s.started - 2000 && m[2].includes(s.cmd) && (!idInArgs || m[2].includes(s.sessionId!));
1298
1440
  } catch {
1299
- return false;
1441
+ return null;
1300
1442
  }
1301
1443
  }
1302
1444
 
@@ -1323,7 +1465,19 @@ function closeOne(st: string, s: Session): boolean {
1323
1465
  return closed;
1324
1466
  }
1325
1467
 
1326
- const openSessions = (st: string) => readSessions(st).filter((s) => existsSync(join(sessionsDir(st), s.key + ".pid")));
1468
+ // Sessions with a PID file. A file whose process is gone, or whose number another
1469
+ // process now holds, is dropped on the way: one is left per launch that ended by itself,
1470
+ // and each would be asked about every sweep. Only when the system says so; a PID it
1471
+ // cannot answer for stays.
1472
+ const openSessions = (st: string) => readSessions(st).filter((s) => {
1473
+ const f = join(sessionsDir(st), s.key + ".pid");
1474
+ if (!existsSync(f)) return false;
1475
+ const pid = parseInt(readText(f));
1476
+ let gone = false;
1477
+ try { process.kill(pid, 0); } catch (e: any) { gone = e?.code === "ESRCH"; }
1478
+ if (gone || (pid > 0 && isSession(pid, s) === false)) { rmSync(f, { force: true }); return false; }
1479
+ return true;
1480
+ });
1327
1481
 
1328
1482
  // Close every session kaizen opened for a run, those launched before `before` only,
1329
1483
  // so a decision's own successor is never caught. The record stays: it holds the
@@ -1331,7 +1485,8 @@ const openSessions = (st: string) => readSessions(st).filter((s) => existsSync(j
1331
1485
  export function closeSessions(st: string, id: string, opts: { before?: number } = {}): number {
1332
1486
  try {
1333
1487
  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;
1488
+ const idx = runIndex(st);
1489
+ return openSessions(st).filter((s) => s.started < (opts.before ?? Infinity) && ownerOf(st, s, idx) === id).filter((s) => closeOne(st, s)).length;
1335
1490
  } catch { return 0; }
1336
1491
  }
1337
1492
 
@@ -1343,9 +1498,9 @@ export function sweepSessions(st: string, now: number) {
1343
1498
  try {
1344
1499
  const live = openSessions(st);
1345
1500
  if (!live.length || !closeEnabled(dirname(st))) return;
1346
- const runs = new Map(readRuns(st).map((r) => [r.id, r]));
1501
+ const runs = new Map(readRuns(st).map((r) => [r.id, r])), idx = runIndex(st);
1347
1502
  for (const s of live) {
1348
- const r = runs.get(ownerOf(st, s) ?? "");
1503
+ const r = runs.get(ownerOf(st, s, idx) ?? "");
1349
1504
  if (r && r.stage === "done" && s.started <= r.moved && now - r.moved >= CLOSE_GRACE_MS && !typedSince(st, s, r.moved)) closeOne(st, s);
1350
1505
  }
1351
1506
  } catch { /* the next sweep tries again */ }
@@ -1376,9 +1531,9 @@ export function firstSession(st: string, id: string, cmd: string, cwd: string):
1376
1531
  const f = SESSION_FLAGS[cmd];
1377
1532
  if (!f) return null;
1378
1533
  const all = readSessions(st);
1379
- const taken = new Set(all.flatMap((s) => (s.sessionId ? [s.sessionId] : [])));
1534
+ const taken = new Set(all.flatMap((s) => (s.sessionId ? [s.sessionId] : []))), idx = runIndex(st);
1380
1535
  for (const s of all) {
1381
- if (s.cmd !== cmd || ownerOf(st, s) !== id) continue;
1536
+ if (s.cmd !== cmd || ownerOf(st, s, idx) !== id) continue;
1382
1537
  if (!s.sessionId && f.find) {
1383
1538
  // Before the tool's next launch here, any run's: from then on a conversation may be that launch's.
1384
1539
  const next = all.find((o) => o.cmd === cmd && o.started > s.started)?.started ?? Infinity;
@@ -1397,7 +1552,7 @@ export function firstSession(st: string, id: string, cmd: string, cwd: string):
1397
1552
  // The tool and model a run was started with: its latest recorded launch, else the
1398
1553
  // agent its state.json names. A decision continues on these whatever the config says now.
1399
1554
  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();
1555
+ const idx = runIndex(st), s = readSessions(st).filter((x) => ownerOf(st, x, idx) === id).pop();
1401
1556
  if (s) return { cmd: s.cmd, model: s.model ?? null };
1402
1557
  try {
1403
1558
  const a = JSON.parse(readText(join(st, "runs", id, "state.json"))).agent;
@@ -1469,7 +1624,9 @@ export function launchRun(it: Item, from: string, run?: string, pick: { agent?:
1469
1624
  // Yolo was picked for one tool; a fallback to another opens in its normal mode.
1470
1625
  // A decision on a run started in Yolo keeps it, or its next stage stops on every command.
1471
1626
  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);
1627
+ const yoloRecs = run && !pick.agent ? readSessions(st).filter((s) => s.yolo && s.cmd === agent.cmd) : [];
1628
+ const idx = yoloRecs.length ? runIndex(st) : undefined;
1629
+ const wasYolo = yoloRecs.some((s) => ownerOf(st, s, idx) === run);
1473
1630
  const yolo = (wasYolo || (!!pick.yolo && agent.cmd === pick.agent)) && !!YOLO_FLAGS[agent.cmd];
1474
1631
  const flags = [...agentFlags(projectDir, agent.cmd, undefined, model), ...(yolo ? YOLO_FLAGS[agent.cmd] : [])];
1475
1632
  // The idea as its run's request will hold it: without the runner word in front.
@@ -1555,7 +1712,8 @@ async function gitAsync(dir: string, ...args: string[]) {
1555
1712
  } catch { return NO_GIT; }
1556
1713
  }
1557
1714
 
1558
- export type GitStatus = { repo: false } | { repo: true; branch: string; remote: string; files: string[]; ahead: number; others?: number; shared?: string[] };
1715
+ export type GitStatus = { repo: false } | { repo: true; branch: string; remote: string; files: string[]; ahead: number; others?: number; shared?: string[]; unpushed?: Unpushed[] };
1716
+ type Unpushed = { at: number; subject: string; files: string[] };
1559
1717
 
1560
1718
  // A project the board can commit for: a git work tree with somewhere to push.
1561
1719
  export function gitStatus(projectDir: string): GitStatus {
@@ -1567,7 +1725,14 @@ export function gitStatus(projectDir: string): GitStatus {
1567
1725
  // No upstream yet means nothing of this branch is on the remote: any commit is ahead.
1568
1726
  const up = git(projectDir, "rev-list", "--count", "@{u}..HEAD");
1569
1727
  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 };
1728
+ // Each commit not on the remote yet, with its files: a run is ahead only by its own.
1729
+ const unpushed = up.ok && ahead > 0
1730
+ ? git(projectDir, "log", "@{u}..HEAD", "--format=%x00%ct %s", "--name-only").out.split("\0").slice(1).map((c) => {
1731
+ const [head, ...files] = c.trim().split("\n"), sp = head.indexOf(" ");
1732
+ return { at: Number(head.slice(0, sp)) * 1000, subject: head.slice(sp + 1).trim(), files: files.filter(Boolean) };
1733
+ })
1734
+ : undefined;
1735
+ return { repo: true, branch: git(projectDir, "branch", "--show-current").out, remote, files: st ? st.split("\n") : [], ahead, ...(unpushed ? { unpushed } : {}) };
1571
1736
  }
1572
1737
 
1573
1738
  // The path of a `git status --porcelain` line; for a rename, the new one.
@@ -1592,10 +1757,44 @@ function runReports(state: string): Report[] {
1592
1757
  try { ids = readdirSync(join(state, "runs")); } catch {}
1593
1758
  return ids.map((id) => ({ id, ...runReport(state, id) }));
1594
1759
  }
1595
- const runFiles = (changed: string[], mine: Report, rest: Report[]) => changed.filter((l) => {
1760
+ // A run holds a path at time `t` when its report names it, was written by then, and no
1761
+ // other run naming it reported in between.
1762
+ const owns = (mine: Report, rest: Report[], p: string, t: number) =>
1763
+ names(mine.text, p) && mine.at <= t && !rest.some((r) => r.at > mine.at && r.at <= t && names(r.text, p));
1764
+ const lastCommit = (dir: string, p: string) => Number(git(dir, "--literal-pathspecs", "log", "-1", "--format=%ct", "--", p).out) * 1000;
1765
+ // A changed file is the run's unless it was committed after the report: then the run's
1766
+ // work in it is in, and what changed since is someone else's. A commit in the same
1767
+ // second as the report keeps the file offered; hiding real work is the worse mistake.
1768
+ const runFiles = (dir: string, changed: string[], mine: Report, rest: Report[]) => changed.filter((l) => {
1596
1769
  const p = statusPath(l);
1597
- return names(mine.text, p) && !rest.some((r) => r.at > mine.at && names(r.text, p));
1770
+ return owns(mine, rest, p, Infinity) && !(lastCommit(dir, p) > mine.at);
1598
1771
  });
1772
+ // How many unpushed commits each run has, worked out once for every run. A commit is
1773
+ // one run's, so a wide commit is not pinned on every old run that once touched a file
1774
+ // in it: the run whose report holds its subject (the `## Commit` message), else, of
1775
+ // the runs that reported by then (to the end of that second, since commit times are
1776
+ // whole seconds), the one naming most of its paths, the latest on a tie. One naming
1777
+ // none, a merge or a hand commit, goes to the run that reported last, so some run
1778
+ // still offers the push. No upstream yet: nothing is on the remote, every run is ahead by
1779
+ // the branch.
1780
+ function aheadBy(s: GitStatus & { repo: true }, all: Report[]): (id: string) => number {
1781
+ if (!s.unpushed) return () => s.ahead;
1782
+ const n = new Map<string, number>();
1783
+ for (const c of s.unpushed) {
1784
+ // The subject as a line of its own: a short one ("wip") is not found inside prose.
1785
+ let best = c.subject ? all.find((r) => r.text.split("\n").some((l) => l.trim() === c.subject)) : undefined;
1786
+ if (!best) {
1787
+ let most = -1;
1788
+ for (const r of all) {
1789
+ if (!r.text || r.at > c.at + 999) continue;
1790
+ const k = c.files.filter((p) => names(r.text, p)).length;
1791
+ if (k > most || (k === most && r.at > best!.at)) { best = r; most = k; }
1792
+ }
1793
+ }
1794
+ if (best) n.set(best.id, (n.get(best.id) ?? 0) + 1);
1795
+ }
1796
+ return (id) => n.get(id) ?? 0;
1797
+ }
1599
1798
 
1600
1799
  // The project's status narrowed to one run: a changed file is the run's when its
1601
1800
  // report names the path in backticks and no run that reported later names it too,
@@ -1610,14 +1809,15 @@ export function runGit(state: string, id: string): GitStatus {
1610
1809
  if (!s.repo) return s;
1611
1810
  const all = runReports(state), mine = all.find((r) => r.id === id) ?? { id, text: "", at: 0 };
1612
1811
  const rest = all.filter((o) => o.id !== id);
1613
- const files = runFiles(s.files, mine, rest);
1812
+ const files = runFiles(dir, s.files, mine, rest);
1614
1813
  const shared = files.flatMap((l) => {
1615
1814
  const p = statusPath(l);
1616
- const since = Number(git(dir, "--literal-pathspecs", "log", "-1", "--format=%ct", "--", p).out) * 1000;
1815
+ const since = lastCommit(dir, p);
1617
1816
  const who = rest.filter((r) => r.at > since && names(r.text, p)).map((r) => short(r.id));
1618
1817
  return who.length ? [`${p}: ${who.join(", ")}`] : [];
1619
1818
  });
1620
- return { ...s, files, others: s.files.length - files.length, shared };
1819
+ const { unpushed, ...base } = s;
1820
+ return { ...base, files, ahead: aheadBy(s, all)(id), others: s.files.length - files.length, shared };
1621
1821
  }
1622
1822
 
1623
1823
  // What the board did for a run, kept beside the run and not in state.json, whose
@@ -1633,15 +1833,15 @@ export function readCommit(state: string, id: string): { sha: string; pushed: bo
1633
1833
  // (`gitBtn` in web/board/git.ts): one project status and one read of the reports for
1634
1834
  // the lot. A run with nothing to say either way gets no entry.
1635
1835
  export function cardsGit(state: string, ids: string[]): Record<string, "pending" | "pushed"> {
1636
- const s = gitStatus(dirname(state)), out: Record<string, "pending" | "pushed"> = {};
1836
+ const dir = dirname(state), s = gitStatus(dir), out: Record<string, "pending" | "pushed"> = {};
1637
1837
  if (!s.repo) return out;
1638
- const all = runReports(state);
1838
+ const all = runReports(state), ahead = aheadBy(s, all);
1639
1839
  for (const id of ids) {
1640
1840
  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;
1841
+ const files = runFiles(dir, s.files, mine, all.filter((o) => o.id !== id)).length;
1642
1842
  if (files) out[id] = "pending";
1643
1843
  else if (k?.pushed) out[id] = "pushed";
1644
- else if (s.ahead > 0) out[id] = "pending";
1844
+ else if (ahead(id) > 0) out[id] = "pending";
1645
1845
  else if (k || mine.text) out[id] = "pushed";
1646
1846
  }
1647
1847
  return out;