kaizen-agent 1.34.2 → 1.34.3

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/google.ts ADDED
@@ -0,0 +1,99 @@
1
+ // Sign in with Google, for one thing: writing "done" back into the sheet a request was
2
+ // gathered from. The user brings their own OAuth client (Desktop app type, Sheets API
3
+ // on): a client secret shipped in an open-source app would be shared by every install.
4
+ // Tokens stay in ~/.kaizen/google.json, owner-only, and go to Google hosts only.
5
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { home } from "./state.ts";
8
+
9
+ const AUTH = "https://accounts.google.com/o/oauth2/v2/auth";
10
+ const TOKEN = "https://oauth2.googleapis.com/token";
11
+ const SCOPE = "https://www.googleapis.com/auth/spreadsheets";
12
+ const FILE = () => join(home, ".kaizen", "google.json");
13
+
14
+ type Creds = { client_id?: string; client_secret?: string; refresh_token?: string; access_token?: string; expires_at?: number };
15
+
16
+ const load = (): Creds => { try { return JSON.parse(readFileSync(FILE(), "utf8")); } catch { return {}; } };
17
+
18
+ function save(c: Creds) {
19
+ mkdirSync(join(home, ".kaizen"), { recursive: true });
20
+ const tmp = FILE() + ".tmp";
21
+ writeFileSync(tmp, JSON.stringify(c, null, 2) + "\n", { mode: 0o600 });
22
+ renameSync(tmp, FILE());
23
+ }
24
+
25
+ export function status() {
26
+ const c = existsSync(FILE()) ? load() : {};
27
+ return { connected: !!c.refresh_token, hasClient: !!(c.client_id && c.client_secret) };
28
+ }
29
+
30
+ export function setClient(id: string, secret: string) {
31
+ save({ client_id: id.trim(), client_secret: secret.trim() }); // a new client voids the old sign-in
32
+ }
33
+
34
+ export function signOut() { const { client_id, client_secret } = load(); save({ client_id, client_secret }); }
35
+
36
+ const b64url = (b: ArrayBuffer | Uint8Array) => Buffer.from(b instanceof Uint8Array ? b : new Uint8Array(b)).toString("base64url");
37
+ const rand = () => b64url(crypto.getRandomValues(new Uint8Array(32)));
38
+
39
+ let pending: { state: string; verifier: string; redirect: string } | null = null;
40
+
41
+ export async function authorizeUrl(port: number): Promise<string> {
42
+ const c = load();
43
+ if (!c.client_id || !c.client_secret) throw new Error("Add the Google client id and secret first.");
44
+ const verifier = rand(), state = rand();
45
+ const redirect = `http://127.0.0.1:${port}/google-callback`;
46
+ pending = { state, verifier, redirect };
47
+ const challenge = b64url(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)));
48
+ return `${AUTH}?${new URLSearchParams({ client_id: c.client_id, redirect_uri: redirect, response_type: "code", scope: SCOPE, access_type: "offline", prompt: "consent", state, code_challenge_method: "S256", code_challenge: challenge })}`;
49
+ }
50
+
51
+ async function token(form: Record<string, string>) {
52
+ const r = await fetch(TOKEN, { method: "POST", body: new URLSearchParams(form), signal: AbortSignal.timeout(30_000) });
53
+ const j: any = await r.json().catch(() => ({}));
54
+ if (!r.ok) throw new Error(j.error_description || j.error || `sign-in failed (HTTP ${r.status})`);
55
+ return j;
56
+ }
57
+
58
+ // Null when signed in; otherwise why not.
59
+ export async function callback(q: URLSearchParams): Promise<string | null> {
60
+ const p = pending, c = load();
61
+ if (!p || q.get("state") !== p.state) return "This sign-in is stale. Start it again from the Icebox.";
62
+ pending = null;
63
+ if (q.get("error")) return q.get("error");
64
+ if (!q.get("code") || !c.client_id || !c.client_secret) return "Google sent no sign-in code back.";
65
+ try {
66
+ const t = await token({ grant_type: "authorization_code", client_id: c.client_id, client_secret: c.client_secret, code: q.get("code")!, code_verifier: p.verifier, redirect_uri: p.redirect });
67
+ if (!t.refresh_token) return "Google gave no refresh token. Remove kaizen from your Google account's third-party access and connect again.";
68
+ if (!String(t.scope ?? "").split(" ").includes(SCOPE)) return "Google did not grant access to your sheets.";
69
+ save({ ...c, refresh_token: t.refresh_token, access_token: t.access_token, expires_at: Date.now() + (t.expires_in ?? 3600) * 1000 });
70
+ return null;
71
+ } catch (e) { return (e as Error).message; }
72
+ }
73
+
74
+ async function access(): Promise<string> {
75
+ const c = load();
76
+ if (!c.refresh_token || !c.client_id || !c.client_secret) throw new Error("Google is not connected.");
77
+ if (c.access_token && (c.expires_at ?? 0) > Date.now() + 60_000) return c.access_token;
78
+ try {
79
+ const t = await token({ grant_type: "refresh_token", client_id: c.client_id, client_secret: c.client_secret, refresh_token: c.refresh_token });
80
+ save({ ...c, access_token: t.access_token, expires_at: Date.now() + (t.expires_in ?? 3600) * 1000 });
81
+ return t.access_token;
82
+ } catch (e) {
83
+ if (/invalid_grant/.test((e as Error).message)) { signOut(); throw new Error("Google signed kaizen out. Connect again from the Icebox."); }
84
+ throw e;
85
+ }
86
+ }
87
+
88
+ // A Sheets API call. Only the Sheets host is ever sent the token.
89
+ export async function sheets(path: string, init: { method?: string; body?: unknown } = {}, doFetch: typeof fetch = fetch, token: () => Promise<string> = access): Promise<any> {
90
+ const r = await doFetch(`https://sheets.googleapis.com/v4/spreadsheets/${path}`, {
91
+ method: init.method ?? "GET",
92
+ headers: { authorization: `Bearer ${await token()}`, ...(init.body ? { "content-type": "application/json" } : {}) },
93
+ ...(init.body ? { body: JSON.stringify(init.body) } : {}),
94
+ signal: AbortSignal.timeout(30_000),
95
+ });
96
+ const j: any = await r.json().catch(() => ({}));
97
+ if (!r.ok) throw new Error(j.error?.message || `Google Sheets answered HTTP ${r.status}`);
98
+ return j;
99
+ }
package/cli/install.ts CHANGED
@@ -346,6 +346,20 @@ if (wantSettings) {
346
346
  process.exit(0);
347
347
  }
348
348
 
349
+ // A run's own branch and worktree: `start` makes them before the build, `land` pushes
350
+ // and merges them at the final approval. Run from the project folder.
351
+ if (args.has("worktree")) {
352
+ const [, verb = "", id = ""] = Bun.argv.slice(2).filter((a) => !a.startsWith("-"));
353
+ const { locate } = await import("./state.ts");
354
+ const { startWorktree, landWorktree } = await import("./worktree.ts");
355
+ const state = locate();
356
+ if (!state || (verb !== "start" && verb !== "land")) { console.log("\n usage: kaizen worktree start|land <run-id>, in a project with .kaizen\n"); process.exit(1); }
357
+ const project = dirname(state);
358
+ const r = verb === "start" ? startWorktree(project, id) : landWorktree(project, id);
359
+ console.log(r.ok ? `\n ${verb === "start" ? `worktree ready: ${(r as { path: string }).path}` : `landed by ${(r as { how: string }).how}`}\n` : `\n ${r.why}\n`);
360
+ process.exit(r.ok ? 0 : 1);
361
+ }
362
+
349
363
  // Deleting runs is a job for code with a test, not for an agent with `rm`. Lists by
350
364
  // default; --yes is what deletes.
351
365
  if (args.has("prune")) {
package/cli/settings.ts CHANGED
@@ -30,6 +30,7 @@ export const PRESETS: Record<PresetName, {
30
30
  "auto_fix.max_iterations": "1",
31
31
  "git.auto_commit": "false",
32
32
  "git.branch_before_implement": "false",
33
+ "git.worktree_per_run": "false",
33
34
  },
34
35
  },
35
36
  medium: {
@@ -47,6 +48,7 @@ export const PRESETS: Record<PresetName, {
47
48
  "auto_fix.max_iterations": "2",
48
49
  "git.auto_commit": "false",
49
50
  "git.branch_before_implement": "true",
51
+ "git.worktree_per_run": "true",
50
52
  },
51
53
  },
52
54
  ultra: {
@@ -64,6 +66,7 @@ export const PRESETS: Record<PresetName, {
64
66
  "auto_fix.max_iterations": "4",
65
67
  "git.auto_commit": "true",
66
68
  "git.branch_before_implement": "true",
69
+ "git.worktree_per_run": "true",
67
70
  },
68
71
  },
69
72
  };
@@ -111,6 +114,7 @@ const SETTINGS: Setting[] = [
111
114
  { key: "auto_fix.max_iterations", label: "Max fix rounds", group: "Auto-fix", values: ["1", "2", "3", "4", "5"], help: "Fix-then-recheck rounds before leftovers are handed to you. Each round is a full build plus a full review, the priciest knob here" },
112
115
  { key: "git.auto_commit", label: "Commit when done", group: "Git", values: ["false", "true"], help: "Commit the work when a run finishes. Never pushes" },
113
116
  { key: "git.branch_before_implement", label: "Branch before building", group: "Git", values: ["true", "false"], help: "Create a branch before building when you are on the default branch" },
117
+ { key: "git.worktree_per_run", label: "Branch and folder per run", group: "Git", values: ["true", "false"], help: "Build each run on its own branch in its own git worktree, then land it by pull request. false: every run builds in the project folder" },
114
118
  { key: "track.default", label: "Kind of work", group: "Project", values: ["auto", "software", "writing", "communication", "operations", "research"], help: "Pin what runs in this project are (a docs repo is always writing). auto: the planner infers it per run" },
115
119
  { key: "review.write_memory", label: "Remember lessons", group: "Project", values: ["true", "false"], help: "Let the reviewer save what it learned to .kaizen/memory.md, which every future run reads" },
116
120
  { key: "review.memory_max_lines", label: "Memory size cap", group: "Project", values: ["50", "100", "200", "400"], help: "Lines memory.md may hold before old lessons are pruned. Every run reads it, so bigger costs more" },
@@ -8,7 +8,7 @@ import { readInbox, writeInbox, replaceIdea, parseItem } from "./state.ts";
8
8
  import {
9
9
  readSources, addSource, removeSource, ingest, checkUrl, resolvesPublic, exportUrl, fetchText, split, sift,
10
10
  gatherSource, freshImages, gatherAll, due, takeImages, sheetImages, docImages, imagesUrl, SIGN_IN, UNREADABLE, CAP, moveSource, gatherDirs, writeSources,
11
- readIcebox, writeIcebox, promote, dismiss, markReplied, setCategory, socialUrl, socialOf, AGENT_ONLY, PLATFORMS,
11
+ readIcebox, writeIcebox, promote, markDone, dismiss, markReplied, setCategory, socialUrl, socialOf, AGENT_ONLY, PLATFORMS,
12
12
  } from "./sources.ts";
13
13
 
14
14
  const root = realpathSync(mkdtempSync(join(tmpdir(), "kaizen-src-"))); // macOS: tmpdir is a symlink into /private
@@ -145,7 +145,7 @@ test("split: a title row above the header is skipped; without a header one colum
145
145
  test("split: a header row holding a `#`, a year or a date cell is still the header", async () => {
146
146
  for (const head of ["#\tRequest\tStatus", "No\tRequest\tStatus\t2026", "No\tRequest\tStatus\t2026-09-30", "No\tRequest\tStatus\t17-Sep-2026", "\tRequest\tStatus"]) {
147
147
  const body = `${head}\n1\tFix login page\tOpen\n2\tAdd export\tDone\n`;
148
- expect(sift("sheet", body)).toEqual({ items: [{ text: "Fix login page", notes: "1 · Open" }], done: 1 });
148
+ expect(sift("sheet", body)).toEqual({ items: [{ text: "Fix login page", notes: "1 · Open", row: 2 }], done: 1 });
149
149
  }
150
150
  // A numbered row is data even when one of its cells is a header word said once.
151
151
  expect(split("sheet", "1\tIssue\tLogin fails on Safari\n2\tBug\tExport drops rows\n3\tBug\tSearch is slow\n").map((i) => i.text))
@@ -347,15 +347,16 @@ test("a first word that is a kaizen command is prefixed", () => {
347
347
 
348
348
  // A one-cell header stays the header, whatever the rows under it hold. A title above
349
349
  // it is read as the header (backlog), as it always was.
350
+ const noRow = (r: ReturnType<typeof sift>) => ({ ...r, items: r.items.map(({ row, ...i }) => i) });
350
351
  test("split: a one-cell header is the header over any rows below it", async () => {
351
352
  const feedback = "Feedback\nNO\tPIC\tDETAIL\tSTATUS\n1\tBudi\tTombol export tidak muncul\tOpen\n2\tSiti\tTambah opsi\tDone\n";
352
- expect(sift("sheet", feedback)).toEqual({ items: [{ text: "NO", notes: "PIC · DETAIL · STATUS" }, { text: "1", notes: "Budi · Tombol export tidak muncul · Open" }, { text: "2", notes: "Siti · Tambah opsi · Done" }], done: 0 });
353
- expect(sift("sheet", "Request\nFix login\nAdd export\n")).toEqual({ items: [{ text: "Fix login" }, { text: "Add export" }], done: 0 });
354
- expect(sift("sheet", "Request\nFix login\nAdd export\tsee ticket 5\n")).toEqual({ items: [{ text: "Fix login" }, { text: "Add export", notes: "see ticket 5" }], done: 0 });
355
- expect(sift("sheet", "Request\t\nFix login\tBudi\nAdd export\tSiti\n")).toEqual({ items: [{ text: "Fix login", notes: "Budi" }, { text: "Add export", notes: "Siti" }], done: 0 });
353
+ expect(sift("sheet", feedback)).toEqual({ items: [{ text: "NO", notes: "PIC · DETAIL · STATUS", row: 2 }, { text: "1", notes: "Budi · Tombol export tidak muncul · Open", row: 3 }, { text: "2", notes: "Siti · Tambah opsi · Done", row: 4 }], done: 0 });
354
+ expect(sift("sheet", "Request\nFix login\nAdd export\n")).toEqual({ items: [{ text: "Fix login", row: 2 }, { text: "Add export", row: 3 }], done: 0 });
355
+ expect(noRow(sift("sheet", "Request\nFix login\nAdd export\tsee ticket 5\n"))).toEqual({ items: [{ text: "Fix login" }, { text: "Add export", notes: "see ticket 5" }], done: 0 });
356
+ expect(noRow(sift("sheet", "Request\t\nFix login\tBudi\nAdd export\tSiti\n"))).toEqual({ items: [{ text: "Fix login", notes: "Budi" }, { text: "Add export", notes: "Siti" }], done: 0 });
356
357
  // A later row whose lone cell reads like a header word is still data.
357
- expect(sift("sheet", "Request\nFix login\tBug\nRename page heading\tTitle\nAdd export\tFeature\n")).toEqual({ items: [{ text: "Fix login", notes: "Bug" }, { text: "Rename page heading", notes: "Title" }, { text: "Add export", notes: "Feature" }], done: 0 });
358
- expect(sift("sheet", "Request\nFix login\tStatus\nAdd export\tdone\n")).toEqual({ items: [{ text: "Fix login", notes: "Status" }, { text: "Add export", notes: "done" }], done: 0 });
358
+ expect(noRow(sift("sheet", "Request\nFix login\tBug\nRename page heading\tTitle\nAdd export\tFeature\n"))).toEqual({ items: [{ text: "Fix login", notes: "Bug" }, { text: "Rename page heading", notes: "Title" }, { text: "Add export", notes: "Feature" }], done: 0 });
359
+ expect(noRow(sift("sheet", "Request\nFix login\tStatus\nAdd export\tdone\n"))).toEqual({ items: [{ text: "Fix login", notes: "Status" }, { text: "Add export", notes: "done" }], done: 0 });
359
360
  const guessed = "1\tOpen\tTombol export tidak muncul\n2\tOpen\tTambah opsi ingat saya\n";
360
361
  addSource(state, SHEET);
361
362
  expect(await gatherSource(state, SHEET, text(guessed), pub)).toMatchObject({ added: 2, note: "2 new" });
@@ -643,7 +644,7 @@ test("the board refuses to add a source without a project, and moves one between
643
644
  expect(readIcebox(a).map((s) => s.status)).toEqual(["promoted", "new"]);
644
645
  expect(await ice({ op: "dismiss", dir: a, ids: [y!.id] })).toMatchObject({ status: 200 });
645
646
  const listedIce = ((await (await fetch(at + "/sources")).json()) as any).icebox;
646
- expect(listedIce).toEqual([]);
647
+ expect(listedIce.map((s: any) => s.status)).toEqual(["promoted"]); // promoted stays; dismissed comes apart
647
648
  } finally { proc.kill(); await proc.exited; }
648
649
  }, 30_000); // a cold board start is slow on Windows
649
650
 
@@ -733,7 +734,7 @@ test("sheet and doc images land on their row and line", async () => {
733
734
  <tr><th id="0R2"><div>3</div></th><td>Dark mode</td></tr></table>`;
734
735
  expect(sheetImages(sheetHtml)).toEqual(new Map([[2, ["https://lh3.googleusercontent.com/x?a=1&b=2"]]]));
735
736
  expect(sift("sheet", "Request\nFix login\nDark mode\n", sheetImages(sheetHtml) as any).items).toEqual([
736
- { text: "Fix login", images: ["https://lh3.googleusercontent.com/x?a=1&b=2"] }, { text: "Dark mode" }]);
737
+ { text: "Fix login", row: 2, images: ["https://lh3.googleusercontent.com/x?a=1&b=2"] }, { text: "Dark mode", row: 3 }]);
737
738
  const docHtml = `<html><body><p><span>Export is slow</span></p><p><img src="https://lh7.example/p.png"></p><li>Add &amp; remove tags</li></body></html>`;
738
739
  expect(sift("doc", "Export is slow\n\n* Add & remove tags\n", docImages(docHtml) as any).items).toEqual([
739
740
  { text: "Export is slow", images: ["https://lh7.example/p.png"] }, { text: "* Add & remove tags" }]);
@@ -789,3 +790,31 @@ test("a fresh image link on a host the item never used is not taken", async () =
789
790
  : new Response("Request\nFix login\n", { headers: { "content-type": "text/plain" } });
790
791
  expect(await freshImages(state, id, page, pub)).toEqual(["https://docs.google.com/sheets-images-rt/OLD=s2048"]);
791
792
  });
793
+
794
+ test("a promoted item stays promoted until its run is done, then reads done", () => {
795
+ writeIcebox(state, [{ id: "a1", text: "Dark mode", source: "s", at: "2026-10-17", status: "new" }]);
796
+ expect(promote(state, ["a1"])).toBeNull();
797
+ const run = join(state, "runs", "r1");
798
+ mkdirSync(run, { recursive: true });
799
+ writeFileSync(join(run, "00-request.md"), "Dark mode");
800
+ writeFileSync(join(run, "state.json"), JSON.stringify({ stage: "build" }));
801
+ markDone(state);
802
+ expect(readIcebox(state)[0]!.status).toBe("promoted");
803
+ writeFileSync(join(run, "state.json"), JSON.stringify({ stage: "done" }));
804
+ markDone(state);
805
+ expect(readIcebox(state)[0]!.status).toBe("done");
806
+ });
807
+
808
+ test("a done run that only mentions the idea does not finish it", () => {
809
+ writeIcebox(state, [{ id: "a1", text: "Test", source: "s", at: "2026-10-17", status: "new" }]);
810
+ expect(promote(state, ["a1"])).toBeNull();
811
+ const run = join(state, "runs", "r1");
812
+ mkdirSync(run, { recursive: true });
813
+ writeFileSync(join(run, "00-request.md"), "lite fix the test runner on Windows");
814
+ writeFileSync(join(run, "state.json"), JSON.stringify({ stage: "done" }));
815
+ markDone(state);
816
+ expect(readIcebox(state)[0]!.status).toBe("promoted");
817
+ writeFileSync(join(run, "00-request.md"), "lite Test");
818
+ markDone(state);
819
+ expect(readIcebox(state)[0]).toMatchObject({ status: "done", run: "r1" });
820
+ });
package/cli/sources.ts CHANGED
@@ -7,10 +7,10 @@ import { join } from "node:path";
7
7
  import { isIP } from "node:net";
8
8
  import { createHash } from "node:crypto";
9
9
  import { lookup as dnsLookup } from "node:dns/promises";
10
- import { readInbox, writeInbox, normalise, formatNote, parseItem } from "./state.ts";
10
+ import { readInbox, writeInbox, normalise, formatNote, parseItem, readRuns, readText } from "./state.ts";
11
11
 
12
12
  export type Last = { at: string; ok: boolean; added: number; note: string };
13
- export type Source = { url: string; label?: string; category?: string; added: string; last?: Last; seen: string[] };
13
+ export type Source = { url: string; label?: string; category?: string; added: string; last?: Last; seen: string[]; writeback?: boolean };
14
14
  export type Result = { url: string } & Last;
15
15
 
16
16
  const file = (state: string) => join(state, "sources.json");
@@ -84,6 +84,16 @@ export function setCategory(state: string, url: string, category: string): strin
84
84
  return null;
85
85
  }
86
86
 
87
+ // Whether a done item is reported back to this source (see writeback.ts). Off by default.
88
+ export function setWriteback(state: string, url: string, on: boolean): string | null {
89
+ const list = readSources(state);
90
+ const src = list.find((s) => s.url === url);
91
+ if (!src) return "no such source";
92
+ if (on) src.writeback = true; else delete src.writeback;
93
+ writeSources(state, list);
94
+ return null;
95
+ }
96
+
87
97
  // Drops the entry and its seen list. Items already gathered stay in the icebox.
88
98
  export function removeSource(state: string, url: string): string | null {
89
99
  const list = readSources(state);
@@ -134,7 +144,7 @@ function clean(raw: string, extra = ""): { text: string; lines: string[] } | nul
134
144
  // The icebox: everything gathered, held per project until a person or an agent
135
145
  // promotes it to an idea or dismisses it. Promoted and dismissed items stay, so the
136
146
  // same request never comes back.
137
- export type Signal = { id: string; text: string; notes?: string; source: string; category?: string; at: string; status: "new" | "promoted" | "dismissed"; idea?: string; replied?: string; images?: string[] };
147
+ export type Signal = { id: string; text: string; notes?: string; source: string; category?: string; at: string; status: "new" | "promoted" | "dismissed" | "done"; idea?: string; replied?: string; images?: string[]; row?: number; run?: string; written?: string; writeError?: string };
138
148
 
139
149
  const iceFile = (state: string) => join(state, "icebox.json");
140
150
 
@@ -188,7 +198,7 @@ function shape(it: Item) {
188
198
  const c = clean(it.text, got.notes);
189
199
  if (!c) return null;
190
200
  const notes = c.lines.join("\n").slice(0, 2000);
191
- return { c, notes, images: pics([...(it.images ?? []), ...got.images]), key: rowKey(c.text, notes) };
201
+ return { c, notes, row: it.row, images: pics([...(it.images ?? []), ...got.images]), key: rowKey(c.text, notes) };
192
202
  }
193
203
 
194
204
  export function ingest(state: string, url: string, items: Item[]): { added: number; held: number } {
@@ -203,9 +213,12 @@ export function ingest(state: string, url: string, items: Item[]): { added: numb
203
213
  for (const it of items) {
204
214
  const s = shape(it);
205
215
  if (!s) continue;
206
- const { c, notes, images, key } = s;
216
+ const { c, notes, images, key, row } = s;
207
217
  if (known.has(key)) {
208
218
  seen.push(key);
219
+ // The row can move; the newest read is the one a write-back trusts.
220
+ const at = row && ice.find((s) => s.source === url && rowKey(s.text, s.notes) === key);
221
+ if (at && at.row !== row) { at.row = row; refreshed = true; }
209
222
  // Image links expire (social CDNs) or arrive late (items gathered before images
210
223
  // were read): an item still waiting takes the ones read now.
211
224
  const old = images.length && ice.find((s) => s.status === "new" && s.source === url && rowKey(s.text, s.notes) === key);
@@ -215,7 +228,7 @@ export function ingest(state: string, url: string, items: Item[]): { added: numb
215
228
  if (fresh.length >= CAP) { held++; continue; } // not marked seen: the next gather takes it
216
229
  known.add(key);
217
230
  seen.push(key);
218
- fresh.push({ id: idOf(key), text: c.text, ...(notes ? { notes } : {}), source: url, ...(src.category ? { category: src.category } : {}), at: new Date().toISOString(), status: "new", ...(images.length ? { images } : {}) });
231
+ fresh.push({ id: idOf(key), text: c.text, ...(notes ? { notes } : {}), source: url, ...(src.category ? { category: src.category } : {}), at: new Date().toISOString(), status: "new", ...(images.length ? { images } : {}), ...(row ? { row } : {}) });
219
232
  }
220
233
  if (fresh.length || refreshed) writeIcebox(state, [...ice, ...fresh]);
221
234
  // ponytail: seen only grows; trim to the newest few thousand if a source ever gets that long.
@@ -254,6 +267,21 @@ export function promote(state: string, ids: string[], text?: string, details?: s
254
267
  return null;
255
268
  }
256
269
 
270
+ // A promoted item stays in the icebox until the run its idea became is done; then it
271
+ // is marked done. Matched the way the board retires an idea: some line of the run's
272
+ // request is the idea (after any `/kaizen lite` prefix), never merely contains it.
273
+ export function markDone(state: string): void {
274
+ const ice = readIcebox(state);
275
+ const waiting = ice.filter((s) => s.status === "promoted" && s.idea);
276
+ if (!waiting.length) return;
277
+ const done = readRuns(state).filter((r) => r.stage === "done").flatMap((r) => {
278
+ try { return [{ id: r.id, lines: readText(join(state, "runs", r.id, "00-request.md")).split("\n").map((l) => normalise(l.replace(/^[\s>*"'`-]+|[\s"'`.]+$/g, ""))) }]; } catch { return []; }
279
+ });
280
+ let changed = false;
281
+ for (const s of waiting) { const t = normalise(s.idea!), r = done.find((d) => d.lines.some((l) => l === t || l.endsWith(" " + t))); if (r) { s.status = "done"; s.run = r.id; changed = true; } }
282
+ if (changed) writeIcebox(state, ice);
283
+ }
284
+
257
285
  // A dismissed item back to waiting.
258
286
  export function restore(state: string, ids: string[]): string | null {
259
287
  const ice = readIcebox(state);
@@ -451,9 +479,9 @@ const wordy = (c: string) => c.length >= 3 && !datum(c);
451
479
  const STATUS = /^status$/i;
452
480
  const DONE = /^(done|finish|finished)$/i;
453
481
 
454
- type Item = { text: string; notes?: string; images?: string[] };
482
+ type Item = { text: string; notes?: string; images?: string[]; row?: number };
455
483
 
456
- export const split = (kind: Kind, body: string): Item[] => sift(kind, body).items;
484
+ export const split = (kind: Kind, body: string): Item[] => sift(kind, body).items.map(({ row, ...it }) => it); // the row number is write-back's, not what a read says
457
485
 
458
486
  // The requests, and how many rows were left out for their status. A row left out is
459
487
  // never handed to ingest, so it is not marked seen: reopened, it is gathered then.
@@ -499,7 +527,7 @@ export function sift(kind: Kind, body: string, pics?: Map<number | string, strin
499
527
  if (status >= 0 && DONE.test(row[status] ?? "")) { done++; return []; }
500
528
  const rest = row.filter((c, i) => c && i !== col).join(" · ");
501
529
  // The export keeps every row from the top, so a row's index is its number less one.
502
- return [with_({ text, ...(rest ? { notes: rest } : {}) }, cells.indexOf(row) + 1)];
530
+ return [with_({ text, ...(rest ? { notes: rest } : {}), row: cells.indexOf(row) + 1 }, cells.indexOf(row) + 1)];
503
531
  });
504
532
  return { items, done };
505
533
  }
package/cli/state.test.ts CHANGED
@@ -8,7 +8,7 @@ import { issueUrl, systemInfo, scrubHome, REPO_URL, home as realHome, trustFolde
8
8
  import * as stateModule from "./state.ts";
9
9
  import {
10
10
  readRuns, readInbox, writeInbox, replaceIdea, moveIdea, abandonRun, allBacklog, backlog,
11
- statusOf, columnOf, parseItem, startedRuns, boardCards, readArchive, setArchived,
11
+ requestTitle, statusOf, columnOf, parseItem, startedRuns, boardCards, 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,
14
14
  } from "./state.ts";
@@ -138,6 +138,11 @@ test("approvalPrompt: approve or revise, the comment kept on one line", () => {
138
138
  expect(p).toContain('to revise: "drop step 3, keep "the rest"".');
139
139
  expect(p).toContain("revise 01-plan.md");
140
140
  expect(p).not.toContain("\n");
141
+ expect(approvalPrompt("r", "approvals.review")).toContain("commit and push this run's changed files only");
142
+ expect(approvalPrompt("r", "findings")).toContain("commit and push");
143
+ expect(approvalPrompt("r", "approvals.review")).toContain("kaizen worktree land");
144
+ expect(approvalPrompt("r", "approvals.review", "rename it")).not.toContain("push");
145
+ expect(approvalPrompt("r", "approvals.plan")).not.toContain("push");
141
146
  expect(approvalPrompt("r", "approvals.review", "rename it")).toContain('final approval: "rename it". Record it in 02-approval.md, change the work');
142
147
  });
143
148
 
@@ -1029,3 +1034,8 @@ test("the board answers only the hosts listed in KAIZEN_WEB_HOSTS", async () =>
1029
1034
  expect((await m.json()).display).toBe("standalone");
1030
1035
  } finally { proc.kill(); await proc.exited; rmSync(homeDir, { recursive: true, force: true }); }
1031
1036
  });
1037
+
1038
+ test("requestTitle: drops the launch command, keeps the first sentence, caps at 90", () => {
1039
+ expect(requestTitle("/kaizen lite simplify the card. Then more words")).toBe("simplify the card.");
1040
+ expect(requestTitle("x".repeat(200)).length).toBe(90);
1041
+ });
package/cli/state.ts CHANGED
@@ -6,12 +6,13 @@ import { Database } from "bun:sqlite";
6
6
  import { join, dirname, win32, posix } from "node:path";
7
7
  import { homedir, arch, cpus, release, totalmem, version as osVersion } from "node:os";
8
8
  import { startPty, ptySupported } from "./pty.ts";
9
+ import { codeDir, projectFromWorktree } from "./worktree.ts";
9
10
 
10
11
  // The real path: process.cwd() is always physical, so a raw $HOME that crosses a
11
12
  // symlink (/home -> var/home) would never compare equal to it.
12
13
  export const home = (() => { try { return realpathSync(homedir()); } catch { return homedir(); } })();
13
14
 
14
- export type Run = { id: string; stage: string; awaiting: string | null; moved: number; runner?: string; agent?: string };
15
+ export type Run = { id: string; stage: string; awaiting: string | null; moved: number; runner?: string; agent?: string; branch?: string };
15
16
 
16
17
  export const KNOWN_AGENTS = [
17
18
  { name: "Claude Code", dir: ".claude", cmd: "claude" },
@@ -356,7 +357,8 @@ export function findProjects(root: string, depth = 4): string[] {
356
357
  // The same spelling knownProjects() gives: a terminal handing out c:\... (lower-case
357
358
  // drive) would otherwise make every POST from the board fail "unknown state dir".
358
359
  export function locate() {
359
- let dir = nativePath(process.cwd());
360
+ // A run's worktree holds a partial .kaizen of its own (the committed notes); the runs are the project's.
361
+ let dir = projectFromWorktree(nativePath(process.cwd()));
360
362
  for (;;) {
361
363
  if (existsSync(join(dir, ".kaizen"))) return join(dir, ".kaizen");
362
364
  if (existsSync(join(dir, ".git"))) return null;
@@ -379,7 +381,7 @@ export function readRuns(state: string): Run[] {
379
381
  // own mtime is the only evidence that anything is still working on it.
380
382
  let moved = 0;
381
383
  try { moved = statSync(file).mtimeMs; } catch { /* vanished mid-read */ }
382
- return [{ id, stage: s.stage ?? "?", awaiting: s.awaiting ?? null, moved, runner: s.runner, agent: s.agent }];
384
+ return [{ id, stage: s.stage ?? "?", awaiting: s.awaiting ?? null, moved, runner: s.runner, agent: s.agent, branch: typeof s.branch === "string" ? s.branch : undefined }];
383
385
  } catch { return []; }
384
386
  }).reverse();
385
387
  }
@@ -511,16 +513,22 @@ export function canApproveAwaiting(awaiting: unknown): awaiting is "approvals.pl
511
513
  // The board's answer to an approval the run is waiting on. The comment is collapsed to
512
514
  // one line: where the prompt is typed into a shell (herdr), a newline sends it early.
513
515
  // Answers are the plan's open questions answered at the approval, one per question.
516
+ // Approving a run to done also asks for its commit and push. A run with its own worktree
517
+ // is landed by `kaizen worktree land`, which pushes the branch and merges it; any other
518
+ // run pushes where it is, and a failed push is reported, not retried.
519
+ const COMMIT_PUSH = " Then commit and push this run's changed files only, with the ## Commit message from 03-impl.md (if already committed and nothing changed, only push); if the push fails, say so and stop."
520
+ + " If state.json names a `worktree`, commit there instead and then run `kaizen worktree land` with this run's id from the project folder in place of the push; report its result as it prints it.";
521
+
514
522
  export function approvalPrompt(id: string, awaiting: string, why?: string, answers?: string[]) {
515
523
  const plan = awaiting === "approvals.plan";
516
524
  const one = (t: string) => t.replace(/\s+/g, " ").trim();
517
525
  const note = why === undefined ? undefined : one(why);
518
- if (awaiting === "findings" && !note) return `run ${id}: the user approved the run as-is at the findings gate. Record approval in 02-approval.md, leave every remaining finding open in 06-backlog.md, do not fix or mark findings closed, and finish the run.`;
526
+ if (awaiting === "findings" && !note) return `run ${id}: the user approved the run as-is at the findings gate. Record approval in 02-approval.md, leave every remaining finding open in 06-backlog.md, do not fix or mark findings closed, and finish the run.${COMMIT_PUSH}`;
519
527
  if (plan && !note && answers?.length) return `run ${id}: the plan is approved from the board, with answers to its open questions: `
520
528
  + answers.map((a, i) => `${i + 1}) ${one(a).replace(/[.;]$/, "")}`).join("; ") + `. Record them in 02-approval.md and build the plan with them.`;
521
529
  if (!note) return plan
522
530
  ? `run ${id}: the plan is approved from the board. Record it in 02-approval.md and build it, first asking any of its open questions still unanswered.`
523
- : `run ${id}: the work is approved from the board at the final approval. Record it, write the backlog and finish the run.`;
531
+ : `run ${id}: the work is approved from the board at the final approval. Record it, write the backlog and finish the run.${COMMIT_PUSH}`;
524
532
  return plan
525
533
  ? `run ${id}: the plan is sent back from the board to revise: "${note}". Record it in 02-approval.md, revise 01-plan.md with it, and stop at the plan approval again.`
526
534
  : `run ${id}: the work is sent back from the board at the final approval: "${note}". Record it in 02-approval.md, change the work to address it, recheck, and stop at the final approval again.`;
@@ -588,6 +596,7 @@ export type Card = {
588
596
  moved?: number; // runs only: state.json mtime
589
597
  title?: string; // runs only: first line of the request
590
598
  archived: boolean; // hidden from the board unless asked for
599
+ branch?: string; // runs only: the run's own branch while it has a worktree
591
600
  tags?: string[]; // runs only: runner, agent, "yolo", "unrechecked"
592
601
  notes?: string; // inbox notes, or runs/<id>/notes.md
593
602
  };
@@ -894,7 +903,9 @@ export function requestTitle(text: string) {
894
903
  .filter((l) => l && !l.startsWith("#"))
895
904
  .slice(0, 6)
896
905
  .find((l) => l && !l.endsWith(":") && !/^---/.test(l)) ?? "";
897
- return line.length > 140 ? line.slice(0, 139) + "…" : line;
906
+ // Drop the command that launched it, keep the first sentence: a card reads, it does not quote.
907
+ const first = line.replace(/^\/kaizen(?:-\w+)?\s+(?:(?:lite|full|auto|plan)\s+)?/i, "").split(/(?<=[.!?])\s/)[0];
908
+ return first.length > 90 ? first.slice(0, 89).trimEnd() + "…" : first;
898
909
  }
899
910
 
900
911
  // Every card on the board, for the state dirs asked for. Retirement of an idea is
@@ -952,6 +963,7 @@ export function boardCards(states: string[], now: number): Card[] {
952
963
  awaiting: r.stage === "abandoned" ? null : r.awaiting,
953
964
  dim: r.stage === "abandoned",
954
965
  archived: archive.has(r.id),
966
+ branch: r.branch,
955
967
  tags: [r.runner, r.agent, yolos.has(r.id) ? "yolo" : undefined, unrechecked(st, r.id) ? "unrechecked" : undefined].filter((t): t is string => !!t),
956
968
  notes: readNotes(st, r.id) || undefined,
957
969
  });
@@ -1467,7 +1479,7 @@ const runFiles = (changed: string[], mine: Report, rest: Report[]) => changed.fi
1467
1479
  // ponytail: runs in the same file are named, not separated; that takes a worktree
1468
1480
  // per run or a saved patch per run.
1469
1481
  export function runGit(state: string, id: string): GitStatus {
1470
- const dir = dirname(state), s = gitStatus(dir);
1482
+ const dir = codeDir(dirname(state), id), s = gitStatus(dir);
1471
1483
  if (!s.repo) return s;
1472
1484
  const all = runReports(state), mine = all.find((r) => r.id === id) ?? { id, text: "", at: 0 };
1473
1485
  const rest = all.filter((o) => o.id !== id);
@@ -1517,7 +1529,7 @@ const COMMIT_TYPE = new RegExp(`^(${COMMIT_TYPES.join("|")})(\\([^)\\n]+\\))?!?:
1517
1529
  // as committed and not pushed. `shown` is the file list the user confirmed: anything
1518
1530
  // that changed since is refused, not swept in unseen.
1519
1531
  export async function commitPush(state: string, id: string, message: string, shown: string[]): Promise<{ ok: true; sha: string } | { ok: false; why: string }> {
1520
- const dir = dirname(state);
1532
+ const dir = codeDir(dirname(state), id);
1521
1533
  const s = runGit(state, id);
1522
1534
  if (!s.repo) return { ok: false, why: "This project has no git remote." };
1523
1535
  if (s.files.join("\n") !== shown.join("\n")) return { ok: false, why: "The changed files are no longer the ones shown. Look at the list again." };
package/cli/web.ts CHANGED
@@ -10,8 +10,10 @@ import {
10
10
  launchMode, readSessions, ownerOf, resumePrompt, runAgent, firstSession,
11
11
  } from "./state.ts";
12
12
  import { attachPty, writePty, resizePty, ptyState, ptyKeys, adoptLoginEnv } from "./pty.ts";
13
- import { readSources, addSource, removeSource, moveSource, setCategory, readIcebox, promote, dismiss, restore, markReplied, gatherSource, freshImages, gatherAll, gatherDirs, due, socialUrl, socialOf } from "./sources.ts";
13
+ import { readSources, addSource, removeSource, moveSource, setCategory, setWriteback, readIcebox, promote, dismiss, restore, markDone, markReplied, gatherSource, freshImages, gatherAll, gatherDirs, due, socialUrl, socialOf } from "./sources.ts";
14
14
  import * as chatgpt from "./chatgpt.ts";
15
+ import * as google from "./google.ts";
16
+ import { writeBacks } from "./writeback.ts";
15
17
  import { postLink, sendReply } from "./browser.ts";
16
18
  import { listNotes, saveNote, renameNote, deleteNote, boardIndex, linksTo } from "./notes.ts";
17
19
 
@@ -234,6 +236,12 @@ export async function web(repoDir: string, opts: Opts = {}) {
234
236
  busy = false;
235
237
  }, 60_000);
236
238
 
239
+ // Done requests are reported back to their sources here, on the board's own clock: a
240
+ // page load only reads, so opening the Icebox never closes an issue.
241
+ const loop = setInterval(async () => {
242
+ for (const dir of states(true)) { try { markDone(dir); await writeBacks(dir); } catch { /* the next tick tries again */ } }
243
+ }, 60_000);
244
+
237
245
  const BOOT = Date.now().toString(36);
238
246
  const ping = setInterval(() => { for (const c of clients) { try { c.enqueue(": ping\n\n"); } catch { clients.delete(c); } } }, 8000);
239
247
  // A launcher clicked while a board is already up should show that board, not die.
@@ -439,16 +447,17 @@ export async function web(repoDir: string, opts: Opts = {}) {
439
447
  // The seen list is bookkeeping, not something the page shows.
440
448
  try { sources.push(...readSources(d).map(({ seen, ...s }) => ({ ...s, social: socialOf(s.url), dir: d, project: label(d) }))); }
441
449
  catch { failed.push(label(d)); }
442
- // Only what still waits for a decision; promoted and dismissed items are record.
443
- // Dismissed ones come apart, for the Dismissed category, where they can be restored.
450
+ // Waiting and promoted items stay in the list; dismissed and done ones come apart,
451
+ // for their own categories.
444
452
  try {
453
+ markDone(d);
445
454
  const all = readIcebox(d).map((s) => ({ ...s, post: postLink(s.notes), dir: d, project: label(d) }));
446
- icebox.push(...all.filter((s) => s.status === "new"));
447
- dismissed.push(...all.filter((s) => s.status === "dismissed"));
455
+ icebox.push(...all.filter((s) => s.status === "new" || s.status === "promoted"));
456
+ dismissed.push(...all.filter((s) => s.status === "dismissed" || s.status === "done"));
448
457
  }
449
458
  catch { failed.push(label(d) + " (icebox)"); }
450
459
  }
451
- return json({ sources, icebox, dismissed, failed, every: await every(), gatherer: { use: await setting("sources.agent"), ...chatgpt.status() } });
460
+ return json({ sources, icebox, dismissed, failed, every: await every(), gatherer: { use: await setting("sources.agent"), ...chatgpt.status() }, google: google.status() });
452
461
  }
453
462
 
454
463
  // The Run form's choices for one project: installed tools and their models.
@@ -490,6 +499,14 @@ export async function web(repoDir: string, opts: Opts = {}) {
490
499
  return new Response(`<!doctype html><meta charset="utf-8"><title>kaizen</title><body style="font:15px system-ui;padding:40px;max-width:560px;margin:auto">${msg.replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`)}</body>`, { headers: { "content-type": "text/html; charset=utf-8" } });
491
500
  }
492
501
 
502
+ // Sign in with Google comes back here, for writing a done request into its sheet.
503
+ if (req.method === "GET" && path === "/google-callback") {
504
+ const why = await google.callback(url.searchParams);
505
+ if (!why) changed();
506
+ const msg = why ? `Could not connect Google: ${why}` : "Google connected. Done requests are written back to sheets that have Write back on. You can close this tab.";
507
+ return new Response(`<!doctype html><meta charset="utf-8"><title>kaizen</title><body style="font:15px system-ui;padding:40px;max-width:560px;margin:auto">${msg.replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`)}</body>`, { headers: { "content-type": "text/html; charset=utf-8" } });
508
+ }
509
+
493
510
  // What this board runs and what npm has: the page shows an Update button on a newer one.
494
511
  if (req.method === "GET" && path === "/version") {
495
512
  const v = await npmLatest(url.searchParams.has("fresh"));
@@ -520,6 +537,22 @@ export async function web(repoDir: string, opts: Opts = {}) {
520
537
  } finally { upgrading = false; }
521
538
  }
522
539
 
540
+ if (req.method === "GET" && path === "/google") return json(google.status());
541
+
542
+ // The Google client the user created, and connecting or disconnecting Google.
543
+ if (req.method === "POST" && path === "/google") {
544
+ if (!local(req)) return bad("cross-origin write refused", 403);
545
+ let body: any;
546
+ try { body = await req.json(); } catch { return bad("json body expected"); }
547
+ if (body.op === "client") {
548
+ if (typeof body.id !== "string" || typeof body.secret !== "string" || !body.id.trim() || !body.secret.trim()) return bad("client id and secret expected");
549
+ google.setClient(body.id, body.secret); changed(); return json({ ok: true });
550
+ }
551
+ if (body.op === "connect") { try { return json({ ok: true, url: await google.authorizeUrl(Number(url.port)) }); } catch (e) { return bad((e as Error).message); } }
552
+ if (body.op === "disconnect") { google.signOut(); changed(); return json({ ok: true }); }
553
+ return bad("unknown op");
554
+ }
555
+
523
556
  // Connect or disconnect the ChatGPT plan. The page opens the URL it is handed.
524
557
  if (req.method === "POST" && path === "/chatgpt") {
525
558
  if (!local(req)) return bad("cross-origin write refused", 403);
@@ -682,6 +715,9 @@ export async function web(repoDir: string, opts: Opts = {}) {
682
715
  } else if (body.op === "category") {
683
716
  const why = setCategory(dir, link, String(body.category ?? ""));
684
717
  if (why) return bad(why, 404);
718
+ } else if (body.op === "writeback") {
719
+ const why = setWriteback(dir, link, body.on === true);
720
+ if (why) return bad(why, 404);
685
721
  } else if (body.op === "remove") {
686
722
  const why = removeSource(dir, link);
687
723
  if (why) return bad(why, 404);
@@ -955,7 +991,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
955
991
  if (opts.open !== false) await openApp(url);
956
992
 
957
993
  await new Promise<void>((resolve) => {
958
- const stop = () => { clearInterval(poll); clearInterval(ping); clearInterval(gather); for (const w of watchers) { try { w.close(); } catch { /* gone */ } } server.stop(true); resolve(); };
994
+ const stop = () => { clearInterval(poll); clearInterval(ping); clearInterval(gather); clearInterval(loop); for (const w of watchers) { try { w.close(); } catch { /* gone */ } } server.stop(true); resolve(); };
959
995
  process.on("SIGINT", stop);
960
996
  process.on("SIGTERM", stop);
961
997
  });