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.
- package/agents/kaizen-reviewer.md +1 -1
- package/cli/backlog.test.ts +133 -0
- package/cli/board.test.ts +10 -4
- package/cli/browser.test.ts +1 -1
- package/cli/browser.ts +12 -7
- package/cli/html.test.ts +19 -0
- package/cli/notes.ts +2 -6
- package/cli/pty.ts +2 -1
- package/cli/settings.test.ts +38 -0
- package/cli/settings.ts +37 -30
- package/cli/state.test.ts +86 -3
- package/cli/state.ts +245 -45
- package/cli/web.ts +67 -16
- package/package.json +1 -1
- package/skills/kaizen/README.md +0 -4
- package/skills/kaizen/SKILL.md +4 -1
- package/skills/kaizen/config.default.yml +1 -50
- package/skills/kaizen/docs.md +3 -2
- package/skills/kaizen/settings.md +10 -3
- package/skills/kaizen/spec-build.md +3 -2
- package/skills/kaizen/spec-main.md +40 -5
- package/skills/kaizen/spec-review.md +2 -1
- package/skills/kaizen-help/SKILL.md +0 -4
- package/web/board/agent-term.ts +1 -1
- package/web/board/api.ts +2 -0
- package/web/board/approvals.ts +3 -3
- package/web/board/cards.ts +284 -108
- package/web/board/chat.ts +4 -4
- package/web/board/dom.ts +17 -5
- package/web/board/fixes.ts +6 -8
- package/web/board/git.ts +3 -3
- package/web/board/icebox.ts +52 -70
- package/web/board/keys.ts +9 -6
- package/web/board/live.ts +3 -2
- package/web/board/main.ts +1 -0
- package/web/board/panel.ts +22 -16
- package/web/board/projects.ts +11 -16
- package/web/board/settings.ts +60 -29
- package/web/board/store.ts +2 -0
- package/web/board/terms.ts +279 -0
- package/web/board/update.ts +7 -7
- package/web/board/views.ts +19 -5
- package/web/board.html +40 -2
- package/web/board.js +25 -24
- package/web/notebook.js +1 -1
- package/web/notes.html +2 -0
- 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()
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1237
|
-
|
|
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
|
-
|
|
1246
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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;
|