kaizen-agent 1.33.0 → 1.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,11 +2,41 @@
2
2
  <img src="https://raw.githubusercontent.com/hfadhlullah/kaizen/main/assets/banner.png" alt="kaizen" width="760">
3
3
  </div>
4
4
 
5
- <p align="center"><b>See the plan before your AI starts. Get a second opinion when it is done.</b></p>
5
+ <p align="center"><b>One tool to build and maintain your product with AI agents.</b><br>Requests in, a plan you approve, the work, an independent review, done.</p>
6
6
 
7
- **The problem:** an AI agent starts writing before you agree on what it should do, then grades its own work.
7
+ <p align="center"><img src="assets/hero.gif" alt="Animated walkthrough of the real kaizen board with sample data. 1: a request gathered from a support sheet waits in the Icebox and is promoted. 2: it is an idea on the board, and Run opens Claude Code. 3: the agent works in the card's own terminal and writes a plan. 4: the plan panel shows what done and wrong mean, and it is approved. 5: it builds, then a reviewer that never saw the work reports a high finding, a double charge. 6: only that finding is ticked and fixed. 7: the reviewer re-checks it. 8: the run lands in Done, with the low finding left in the backlog." width="760"></p>
8
8
 
9
- **Kaizen:** it plans first and waits for your approval, then an agent that never saw the work reviews it.
9
+ ## The problem
10
+
11
+ You ask an AI coding agent to retry a payment webhook when the bank times out. It starts editing at once, then says *"Done, tests pass."* The tests do pass. They were written by the same agent, from the same assumption: that a timeout means the charge failed. Sometimes the bank said yes and the reply got lost. Now a retry charges the customer twice, and nothing in the agent's report would make you look.
12
+
13
+ None of this is a lack of intelligence. It is a missing workflow:
14
+
15
+ | What goes wrong | What kaizen does |
16
+ | :--- | :--- |
17
+ | The agent decides what "done" means on its own, and you find out from the diff | It writes a plan first, including what *wrong* would look like, and waits for your approve or change |
18
+ | The author grades its own work, so its blind spot is in the code *and* in the check | A second agent that never saw the work reviews it, from the request, the plan and the diff only |
19
+ | Findings get fixed in an endless loop, or silently dropped | Serious findings are fixed and re-checked, at most twice; the rest come to you, and what is left goes to a backlog |
20
+ | Requests live in sheets, docs and social posts; the plan in one chat, the review in another, the commit somewhere else | One board, from an icebox that gathers those requests to an idea, a plan, the build, the review, the fix loop and the commit, every step on file |
21
+
22
+ Many assistants now ask for approval before they act, and some run a checker on their own draft. The difference here is *who* checks: a reviewer that shares the author's context shares its blind spots. Kaizen's reviewer starts cold, on purpose.
23
+
24
+ Every stage is a file in `.kaizen/runs/<id>/`, so a run can be resumed later, by another session or another tool (Claude Code, Codex, Antigravity), and read back when someone asks why the code is the way it is.
25
+
26
+ ## Who it is for
27
+
28
+ **Good fit if you:**
29
+
30
+ - Build and maintain a product, and want one place for it: requests gathered from where they live, then plan, build, review and commit, without switching tools.
31
+ - Already use an AI coding agent, and have caught it confidently wrong more than once.
32
+ - Change things where a mistake is expensive: money, data, migrations, anything a customer sees.
33
+ - Want to see and steer the plan before code is written, not reverse-engineer it from a diff.
34
+
35
+ **Not for you if:**
36
+
37
+ - Your changes are typos and one-liners. A run costs several times one prompt; kaizen takes a short path for small work, but it is not free.
38
+ - You want the agent to ship without you. Kaizen is built to stop and ask.
39
+ - You do not use an AI coding agent. Kaizen runs inside one; it is not one.
10
40
 
11
41
  ## Install
12
42
 
@@ -31,6 +31,7 @@ test("an Instagram caption splits into author, date and the post's own words; no
31
31
  expect(authorOf("https://x.com/ana/status/1")).toBe("@ana");
32
32
  expect(authorOf("https://www.instagram.com/p/AAA")).toBe("");
33
33
  expect(noteOf({ text: "t", author: "@ana", link: "https://x.com/ana/status/1", date: "" })).toBe("@ana · https://x.com/ana/status/1");
34
+ expect(noteOf({ text: "t", author: "@ana", link: "https://x.com/ana/status/1", date: "", images: ["https://pbs.example/a.jpg"] })).toBe("@ana · https://x.com/ana/status/1 img https://pbs.example/a.jpg");
34
35
  });
35
36
 
36
37
  test("no browser, or a link that is not social, is a note and not a crash; no profile is made", async () => {
package/cli/browser.ts CHANGED
@@ -9,7 +9,7 @@ import { join } from "node:path";
9
9
  import { home } from "./state.ts";
10
10
  import { socialOf, PLATFORMS } from "./sources.ts";
11
11
 
12
- export type Post = { text: string; author: string; link: string; date: string };
12
+ export type Post = { text: string; author: string; link: string; date: string; images?: string[] };
13
13
  export type Read = { ok: true; posts: Post[] } | { ok: false; note: string };
14
14
 
15
15
  export const profileDir = () => join(home, ".kaizen", "browser");
@@ -131,7 +131,10 @@ const COLLECT = (re: RegExp) => `(() => {
131
131
  if (time) names.add(time.innerText.trim());
132
132
  const lines = (box.innerText || "").split("\\n").map((l) => l.trim()).filter(Boolean);
133
133
  const text = own ? own.innerText : lines.filter((l) => !names.has(l) && !junk.test(l) && !when.test(l)).join("\\n");
134
- posts.set(k, { link: location.origin + k, text, date: time ? (time.getAttribute("datetime") || time.innerText) : (lines.find((l) => when.test(l)) || "") });
134
+ // A post's own pictures, not avatars or icons: 100px across or more, https only.
135
+ const images = [...box.querySelectorAll("img")].filter((i) => Math.max(i.naturalWidth, i.width) >= 100)
136
+ .map((i) => i.currentSrc || i.src).filter((u) => u.startsWith("https://")).slice(0, 4);
137
+ posts.set(k, { link: location.origin + k, text, images, date: time ? (time.getAttribute("datetime") || time.innerText) : (lines.find((l) => when.test(l)) || "") });
135
138
  }
136
139
  return [...posts.values()];
137
140
  })()`;
@@ -140,7 +143,7 @@ const COLLECT = (re: RegExp) => `(() => {
140
143
  const CAPTION = `(() => {
141
144
  const m = (p) => document.querySelector('meta[property="' + p + '"]')?.getAttribute("content") || "";
142
145
  const time = document.querySelector("time");
143
- return { text: m("og:description") || m("og:title"), date: time ? (time.getAttribute("datetime") || time.innerText) : "" };
146
+ return { text: m("og:description") || m("og:title"), image: m("og:image"), date: time ? (time.getAttribute("datetime") || time.innerText) : "" };
144
147
  })()`;
145
148
 
146
149
  // A sign-in page. OFFERS also counts a page that offers to sign in, which tells only
@@ -158,7 +161,8 @@ export function caption(raw: string): { text: string; author: string; date: stri
158
161
  }
159
162
 
160
163
  // A post's notes, as the icebox shows them: `@author · link · date`.
161
- export const noteOf = (p: Post) => [p.author, p.link, p.date].filter(Boolean).join(" · ");
164
+ // Its images follow as `img <url>` tokens, which ingest takes out of the notes.
165
+ export const noteOf = (p: Post) => [p.author, p.link, p.date].filter(Boolean).join(" · ") + (p.images ?? []).map((u) => ` img ${u}`).join("");
162
166
 
163
167
  export const authorOf = (link: string) => { try { const p = new URL(link).pathname.split("/").filter(Boolean); return p[0]!.startsWith("@") ? p[0]! : p.length > 2 ? "@" + p[0] : ""; } catch { return ""; } };
164
168
 
@@ -217,7 +221,7 @@ export async function readPage(page: Page, url: string, platform: string, max =
217
221
  await page.go(url);
218
222
  if (!onPlatform(await page.js<string>("location.href")) || (await page.js<boolean>(WALL))) return { ok: false, note: signInNote(platform) };
219
223
  if (await page.js<boolean>(CHECK)) return { ok: false, note: checkNote(platform) };
220
- const seen = new Map<string, { link: string; text: string; date: string; author?: string }>();
224
+ const seen = new Map<string, { link: string; text: string; date: string; author?: string; images?: string[] }>();
221
225
  for (let i = 0, still = 0; i < 12 && seen.size < max && still < 3; i++) {
222
226
  const before = seen.size;
223
227
  for (const p of await page.js<any[]>(COLLECT(re)) ?? []) if (!seen.has(p.link) && onPlatform(p.link)) seen.set(p.link, p);
@@ -233,13 +237,13 @@ export async function readPage(page: Page, url: string, platform: string, max =
233
237
  if (!onPlatform(p.link)) continue;
234
238
  await page.go(p.link);
235
239
  if (!onPlatform(await page.js<string>("location.href"))) continue;
236
- const c = await page.js<{ text: string; date: string }>(CAPTION);
240
+ const c = await page.js<{ text: string; date: string; image: string }>(CAPTION);
237
241
  const cap = caption(c?.text ?? "");
238
- read.push({ link: p.link, text: cap.text || p.text, date: c?.date || cap.date || p.date, author: cap.author });
242
+ read.push({ link: p.link, text: cap.text || p.text, date: c?.date || cap.date || p.date, author: cap.author, images: c?.image?.startsWith("https://") ? [c.image] : p.images });
239
243
  }
240
244
  found = read;
241
245
  }
242
- const posts = found.map((p) => ({ link: p.link, author: p.author || authorOf(p.link), date: p.date, text: p.text.trim() })).filter((p) => p.text);
246
+ const posts = found.map((p) => ({ link: p.link, author: p.author || authorOf(p.link), date: p.date, text: p.text.trim(), ...(p.images?.length ? { images: p.images } : {}) })).filter((p) => p.text);
243
247
  if (!posts.length && (await page.js<boolean>(CHECK))) return { ok: false, note: checkNote(platform) };
244
248
  if (!posts.length && (await page.js<boolean>(OFFERS))) return { ok: false, note: signInNote(platform) };
245
249
  return { ok: true, posts };
@@ -7,7 +7,7 @@ import { tmpdir } from "node:os";
7
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
- gatherSource, gatherAll, due, SIGN_IN, UNREADABLE, CAP, moveSource, gatherDirs, writeSources,
10
+ gatherSource, gatherAll, due, takeImages, sheetImages, docImages, imagesUrl, SIGN_IN, UNREADABLE, CAP, moveSource, gatherDirs, writeSources,
11
11
  readIcebox, writeIcebox, promote, dismiss, markReplied, setCategory, socialUrl, socialOf, AGENT_ONLY, PLATFORMS,
12
12
  } from "./sources.ts";
13
13
 
@@ -101,6 +101,9 @@ test("a sheet row whose status is Done or Finish is skipped, not marked seen, an
101
101
  expect(split("sheet", "no\tdetail\tnotes\n1\tDark mode\tDone\n2\tExport\tfinish\n").map((i) => i.text)).toEqual(["Dark mode", "Export"]);
102
102
  // The whole cell names the column, not a word inside it.
103
103
  expect(split("sheet", "detail\tstatus notes\nDark mode\tDone\n")).toEqual([{ text: "Dark mode", notes: "Done" }]);
104
+ // A named column of page names loses to the column holding the requests.
105
+ expect(split("sheet", "NO\tPAGE\tDETAIL\tSTATUS\tNOTES\n1\tDash\tList\tDone\tAdd a chart to the list\n2\tPlan\tTimeline\t\tDropdown closes before an item can be picked\n3\tPlan\t\t\tRename the statuses to proposal and tender\n").map((i) => i.text))
106
+ .toEqual(["Dropdown closes before an item can be picked", "Rename the statuses to proposal and tender"]);
104
107
 
105
108
  addSource(state, SHEET);
106
109
  expect(await gatherSource(state, SHEET, text(sheet17(statuses)), pub)).toMatchObject({ ok: true, added: 5, note: "5 new, 4 done skipped" });
@@ -227,7 +230,7 @@ test("promote: one or several items become one open idea with where they came fr
227
230
  expect(readIcebox(state).map((s) => [s.status, s.idea])).toEqual([
228
231
  ["promoted", idea.text], ["promoted", idea.text], ["new", undefined],
229
232
  ]);
230
- expect(promote(state, [a!.id])).toBe("already promoted or dismissed");
233
+ expect(promote(state, [a!.id])).toBe("already promoted");
231
234
  expect(promote(state, [c!.id], " ")).toBeNull(); // blank text: the item's own
232
235
  expect(readInbox(state).at(-1)!.text).toBe("Tambah personel");
233
236
  expect(dismiss(state, ["nope0000"])).toBe("no such item");
@@ -631,10 +634,14 @@ test("the board refuses to add a source without a project, and moves one between
631
634
  expect(await ice({ op: "promote", dir: a, ids: ["nope0000"] })).toMatchObject({ status: 404 });
632
635
  expect(await ice({ op: "explode", dir: a, ids: [x!.id] })).toMatchObject({ status: 400 });
633
636
  expect(await ice({ op: "promote", dir: a, ids: [x!.id] })).toMatchObject({ status: 200 });
634
- expect(await ice({ op: "promote", dir: a, ids: [x!.id] })).toMatchObject({ status: 409, error: "already promoted or dismissed" });
637
+ expect(await ice({ op: "promote", dir: a, ids: [x!.id] })).toMatchObject({ status: 409, error: "already promoted" });
635
638
  expect(await ice({ op: "dismiss", dir: a, ids: [y!.id] })).toMatchObject({ status: 200 });
636
639
  expect(readInbox(a).map((l) => l.text)).toEqual(["Dark mode"]);
637
640
  expect(readIcebox(a).map((s) => s.status)).toEqual(["promoted", "dismissed"]);
641
+ // A dismissed item can be restored, then dismissed or promoted again.
642
+ expect(await ice({ op: "restore", dir: a, ids: [y!.id] })).toMatchObject({ status: 200 });
643
+ expect(readIcebox(a).map((s) => s.status)).toEqual(["promoted", "new"]);
644
+ expect(await ice({ op: "dismiss", dir: a, ids: [y!.id] })).toMatchObject({ status: 200 });
638
645
  const listedIce = ((await (await fetch(at + "/sources")).json()) as any).icebox;
639
646
  expect(listedIce).toEqual([]);
640
647
  } finally { proc.kill(); await proc.exited; }
@@ -707,3 +714,53 @@ test("a GitHub issues link is read through the API: open issues, no pull request
707
714
  // JSON only when asked: a plain source answering JSON is still refused.
708
715
  expect(await fetchText(api, json, pub)).toEqual({ ok: false, note: UNREADABLE });
709
716
  });
717
+
718
+ test("images ride beside a request, never in its key", () => {
719
+ addSource(state, SHEET);
720
+ expect(ingest(state, SHEET, [{ text: "Broken chart", notes: "@ana · https://x.com/ana/status/1 img https://pbs.example/a.jpg ", images: ["https://cdn.example/c.png", "http://plain/b.jpg"] }])).toEqual({ added: 1, held: 0 });
721
+ const [it] = readIcebox(state);
722
+ expect(it!.notes).toBe("@ana · https://x.com/ana/status/1");
723
+ expect(it!.images).toEqual(["https://cdn.example/c.png", "https://pbs.example/a.jpg"]);
724
+ // The same post read again with fresh CDN links is not new.
725
+ expect(ingest(state, SHEET, [{ text: "Broken chart", notes: "@ana · https://x.com/ana/status/1 img https://pbs.example/z.jpg" }])).toEqual({ added: 0, held: 0 });
726
+ expect(takeImages("a · b").images).toEqual([]);
727
+ expect(takeImages("img broken on page")).toEqual({ notes: "img broken on page", images: [] });
728
+ });
729
+
730
+ test("sheet and doc images land on their row and line", async () => {
731
+ const sheetHtml = `<table><tr><th></th><th>A</th></tr><tr><th id="0R0"><div class="row-header-wrapper">1</div></th><td>Request</td></tr>
732
+ <tr><th id="0R1"><div>2</div></th><td>Fix login</td><td><div><img src="https://lh3.googleusercontent.com/x?a=1&amp;b=2"></div></td></tr>
733
+ <tr><th id="0R2"><div>3</div></th><td>Dark mode</td></tr></table>`;
734
+ expect(sheetImages(sheetHtml)).toEqual(new Map([[2, ["https://lh3.googleusercontent.com/x?a=1&b=2"]]]));
735
+ 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
+ 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
+ expect(sift("doc", "Export is slow\n\n* Add & remove tags\n", docImages(docHtml) as any).items).toEqual([
739
+ { text: "Export is slow", images: ["https://lh7.example/p.png"] }, { text: "* Add & remove tags" }]);
740
+ expect(imagesUrl(SHEET)).toBe("https://docs.google.com/spreadsheets/d/abc123/htmlview/sheet?headers=true&gid=7");
741
+ expect(imagesUrl("https://docs.google.com/document/d/D0c_1/edit")).toBe("https://docs.google.com/document/d/D0c_1/export?format=html");
742
+ expect(imagesUrl("https://notes.example.com/raw/1")).toBeNull();
743
+ // A gather whose image page fails still gathers.
744
+ addSource(state, SHEET);
745
+ expect(await gatherSource(state, SHEET, text("Request\nFix login\n"), pub)).toMatchObject({ ok: true, added: 1 });
746
+ });
747
+
748
+ test("a waiting item takes fresh image links when gathered again", () => {
749
+ addSource(state, SHEET);
750
+ ingest(state, SHEET, [{ text: "Old request" }]);
751
+ ingest(state, SHEET, [{ text: "Old request", images: ["https://cdn.example/new.jpg"] }]);
752
+ expect(readIcebox(state)[0]!.images).toEqual(["https://cdn.example/new.jpg"]);
753
+ // No images read this time: the ones it has stay.
754
+ ingest(state, SHEET, [{ text: "Old request" }]);
755
+ expect(readIcebox(state)[0]!.images).toEqual(["https://cdn.example/new.jpg"]);
756
+ expect(sheetImages(`<tr><th id="0R4" class="row-headers-background"></th><td><img src="https://x.example/i.png"></td></tr>`)).toEqual(new Map([[5, ["https://x.example/i.png"]]]));
757
+ });
758
+
759
+ test("sheet images placed over the grid land on their anchored row", () => {
760
+ // The shape of a real htmlview/sheet page: rows in the table, floating images after it.
761
+ const html = `<table><tbody><tr><th id="0R0"><div>1</div></th><td>Request</td></tr><tr><th id="0R1"><div>2</div></th><td>Fix login</td></tr></tbody></table>`
762
+ + `<div id='embed_11' class='waffle-embedded-object-overlay'><img src='https://docs.google.com/sheets-images-rt/A=s2048' style='display: block;'></div>`
763
+ + `<div id='embed_22' class='waffle-embedded-object-overlay'><img src='https://docs.google.com/sheets-images-rt/B=s2048'></div>`
764
+ + `<script>posObj('0', 'embed_11', 1, 7, 14, 9);posObj('0', 'embed_22', 1, 7, 7, 316);</script>`;
765
+ expect(sheetImages(html)).toEqual(new Map([[2, ["https://docs.google.com/sheets-images-rt/A=s2048", "https://docs.google.com/sheets-images-rt/B=s2048"]]]));
766
+ });
package/cli/sources.ts CHANGED
@@ -134,7 +134,7 @@ function clean(raw: string, extra = ""): { text: string; lines: string[] } | nul
134
134
  // The icebox: everything gathered, held per project until a person or an agent
135
135
  // promotes it to an idea or dismisses it. Promoted and dismissed items stay, so the
136
136
  // 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 };
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[] };
138
138
 
139
139
  const iceFile = (state: string) => join(state, "icebox.json");
140
140
 
@@ -168,9 +168,21 @@ function ideaKey(l: { text: string; notes?: string }): string | null {
168
168
  return normalise(l.text);
169
169
  }
170
170
 
171
+ // Image links a source showed beside a request: https only, at most four. Kept apart
172
+ // from the text and notes and out of the row's key, since Google and social CDN
173
+ // addresses change between reads and would make every row new again.
174
+ export const pics = (urls: string[] = []) => [...new Set(urls.filter((u) => /^https:\/\/\S+$/.test(u) && u.length <= 2000))].slice(0, 4);
175
+
176
+ // `img <url>` tokens in an agent's notes (the --stdin path) are images, not notes.
177
+ export function takeImages(notes = ""): { notes: string; images: string[] } {
178
+ const images: string[] = [];
179
+ notes = notes.replace(/(^|\s)img\s+(https:\/\/\S+)/g, (_, sp, u) => { images.push(u); return sp; }).replace(/[ \t]*·[ \t]*$/gm, "").trim();
180
+ return { notes, images: pics(images) };
181
+ }
182
+
171
183
  // The single place outside text enters kaizen, and it enters the icebox, never Ideas.
172
184
  // Synchronous on purpose: nothing may run between reading the files and writing them.
173
- export function ingest(state: string, url: string, items: { text: string; notes?: string }[]): { added: number; held: number } {
185
+ export function ingest(state: string, url: string, items: Item[]): { added: number; held: number } {
174
186
  const sources = readSources(state);
175
187
  const src = sources.find((s) => s.url === url);
176
188
  if (!src) throw new Error("not a listed source");
@@ -178,19 +190,28 @@ export function ingest(state: string, url: string, items: { text: string; notes?
178
190
  const known = new Set([...src.seen, ...readInbox(state).map(ideaKey).filter((k) => k !== null), ...ice.map((s) => rowKey(s.text, s.notes))]);
179
191
  const fresh: Signal[] = [];
180
192
  const seen: string[] = [];
181
- let held = 0;
193
+ let held = 0, refreshed = false;
182
194
  for (const it of items) {
183
- const c = clean(it.text, it.notes);
195
+ const got = takeImages(it.notes);
196
+ const images = pics([...(it.images ?? []), ...got.images]);
197
+ const c = clean(it.text, got.notes);
184
198
  if (!c) continue;
185
199
  const notes = c.lines.join("\n").slice(0, 2000);
186
200
  const key = rowKey(c.text, notes);
187
- if (known.has(key)) { seen.push(key); continue; }
201
+ if (known.has(key)) {
202
+ seen.push(key);
203
+ // Image links expire (social CDNs) or arrive late (items gathered before images
204
+ // were read): an item still waiting takes the ones read now.
205
+ const old = images.length && ice.find((s) => s.status === "new" && s.source === url && rowKey(s.text, s.notes) === key);
206
+ if (old && String(old.images) !== String(images)) { old.images = images; refreshed = true; }
207
+ continue;
208
+ }
188
209
  if (fresh.length >= CAP) { held++; continue; } // not marked seen: the next gather takes it
189
210
  known.add(key);
190
211
  seen.push(key);
191
- fresh.push({ id: idOf(key), text: c.text, ...(notes ? { notes } : {}), source: url, ...(src.category ? { category: src.category } : {}), at: new Date().toISOString(), status: "new" });
212
+ 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 } : {}) });
192
213
  }
193
- if (fresh.length) writeIcebox(state, [...ice, ...fresh]);
214
+ if (fresh.length || refreshed) writeIcebox(state, [...ice, ...fresh]);
194
215
  // ponytail: seen only grows; trim to the newest few thousand if a source ever gets that long.
195
216
  src.seen = [...new Set([...src.seen, ...seen])];
196
217
  writeSources(state, sources);
@@ -207,7 +228,7 @@ export function promote(state: string, ids: string[], text?: string, details?: s
207
228
  const picked = ids.map((id) => ice.find((s) => s.id === id));
208
229
  if (!ids.length) return "no items named";
209
230
  if (picked.some((s) => !s)) return "no such item";
210
- if (picked.some((s) => s!.status !== "new")) return "already promoted or dismissed";
231
+ if (picked.some((s) => s!.status === "promoted")) return "already promoted";
211
232
  const own = !!text?.trim();
212
233
  const c = clean(own ? text! : picked[0]!.text);
213
234
  if (!c) return "the idea text is too short";
@@ -227,6 +248,17 @@ export function promote(state: string, ids: string[], text?: string, details?: s
227
248
  return null;
228
249
  }
229
250
 
251
+ // A dismissed item back to waiting.
252
+ export function restore(state: string, ids: string[]): string | null {
253
+ const ice = readIcebox(state);
254
+ const picked = ids.map((id) => ice.find((s) => s.id === id));
255
+ if (!ids.length) return "no items named";
256
+ if (picked.some((s) => !s)) return "no such item";
257
+ for (const s of picked) if (s!.status === "dismissed") s!.status = "new";
258
+ writeIcebox(state, ice);
259
+ return null;
260
+ }
261
+
230
262
  export function dismiss(state: string, ids: string[]): string | null {
231
263
  const ice = readIcebox(state);
232
264
  const picked = ids.map((id) => ice.find((s) => s.id === id));
@@ -308,6 +340,59 @@ export function exportUrl(raw: string): { url: string; kind: Kind } {
308
340
  return { url: raw, kind: "text" };
309
341
  }
310
342
 
343
+ // Where a Google sheet or doc shows its images, which its text export drops. A
344
+ // published sheet is left out.
345
+ // ponytail: /d/e/ published sheets get no images; add pubhtml when one asks.
346
+ export function imagesUrl(raw: string): string | null {
347
+ const u = new URL(raw);
348
+ if (u.hostname !== "docs.google.com") return null;
349
+ const t = exportUrl(raw);
350
+ if (t.kind === "sheet" && t.url.includes("/export?")) return t.url.replace("/export?format=tsv&", "/htmlview/sheet?headers=true&");
351
+ if (t.kind === "doc") return t.url.replace("format=txt", "format=html");
352
+ return null;
353
+ }
354
+
355
+ const srcs = (html: string) => [...html.matchAll(/<img\b[^>]*?\ssrc=(?:"([^"]+)"|'([^']+)')/gi)].map((m) => (m[1] ?? m[2])!.replace(/&amp;/g, "&"));
356
+ const plain = (html: string) => html.replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'");
357
+ const lineKey = (t: string) => normalise(t.replace(/^\s*[-*•●○■]\s*/, ""));
358
+
359
+ // A sheet's htmlview: the images in each row, by the row number in its header cell.
360
+ export function sheetImages(html: string): Map<number, string[]> {
361
+ const out = new Map<number, string[]>();
362
+ for (const tr of html.split(/<\/table>/i)[0]!.split(/<tr\b/i).slice(1)) {
363
+ // The header cell shows the row number; its id (`0R4`) counts from zero.
364
+ const shown = /<th\b[^>]*>(?:\s*<[^>]+>)*\s*(\d+)\s*</i.exec(tr)?.[1];
365
+ const id = /<th\b[^>]*\bid="\d+R(\d+)"/i.exec(tr)?.[1];
366
+ const row = shown ? Number(shown) : id ? Number(id) + 1 : 0;
367
+ const got = row ? srcs(tr) : [];
368
+ if (got.length) out.set(row, got);
369
+ }
370
+ // Images placed over the grid sit after the table, each in its own embed box, and
371
+ // the page anchors each box to a row with `posObj(sheet, 'embed_<id>', row, col, …)`,
372
+ // the row counted from zero.
373
+ const anchor = new Map([...html.matchAll(/posObj\(\s*'[^']*'\s*,\s*'(embed_\d+)'\s*,\s*(\d+)/g)].map((m) => [m[1]!, Number(m[2]) + 1]));
374
+ for (const box of html.split(/(?=<div\b[^>]*\bid=['"]embed_\d+['"])/i).slice(1)) {
375
+ const row = anchor.get(/id=['"](embed_\d+)['"]/.exec(box)![1]!);
376
+ const got = row ? srcs(box.split(/<div\b[^>]*\bid=['"](?!embed_)/i)[0]!) : [];
377
+ if (got.length) out.set(row!, [...(out.get(row!) ?? []), ...got]);
378
+ }
379
+ return out;
380
+ }
381
+
382
+ // A doc's html export: each paragraph's images, keyed by its text. An image on a line
383
+ // of its own belongs to the paragraph above it.
384
+ export function docImages(html: string): Map<string, string[]> {
385
+ const out = new Map<string, string[]>();
386
+ let last = "";
387
+ for (const part of html.split(/<\/(?:p|h\d|li)>/i)) {
388
+ const text = lineKey(plain(part.replace(/[\s\S]*<body[^>]*>/i, "")));
389
+ if (text) last = text;
390
+ const got = srcs(part);
391
+ if (got.length && last) out.set(last, [...(out.get(last) ?? []), ...got]);
392
+ }
393
+ return out;
394
+ }
395
+
311
396
  export const SIGN_IN = 'Needs sign-in. Share it as "anyone with the link can view", or run /kaizen gather in your agent.';
312
397
  export const UNREADABLE = "Kaizen cannot read this page on its own. Run /kaizen gather in your agent.";
313
398
  const MAX_BYTES = 1_000_000;
@@ -316,7 +401,7 @@ type Fetch = (url: string, init: RequestInit) => Promise<Response>;
316
401
 
317
402
  // No cookie, no authorization, no redirect followed unchecked: every hop goes
318
403
  // through the same guard as the first address.
319
- export async function fetchText(url: string, doFetch: Fetch = fetch, lookup: Lookup = dnsLookup, json = false): Promise<{ ok: true; body: string } | { ok: false; note: string }> {
404
+ export async function fetchText(url: string, doFetch: Fetch = fetch, lookup: Lookup = dnsLookup, json: boolean | "html" = false): Promise<{ ok: true; body: string } | { ok: false; note: string }> {
320
405
  const signal = AbortSignal.timeout(15000);
321
406
  for (let hop = 0; hop <= 5; hop++) {
322
407
  const why = checkUrl(url);
@@ -326,7 +411,7 @@ export async function fetchText(url: string, doFetch: Fetch = fetch, lookup: Loo
326
411
  const pub = await resolvesPublic(host, lookup);
327
412
  if (pub === null) return { ok: false, note: hop ? "The link redirected to an address that could not be found." : "The link's address could not be found. Check it for a typo." };
328
413
  if (!pub) return { ok: false, note: "Refused: not a public address." };
329
- const res = await doFetch(url, { redirect: "manual", credentials: "omit", signal, headers: { accept: json ? "application/json" : "text/plain, text/*" } });
414
+ const res = await doFetch(url, { redirect: "manual", credentials: "omit", signal, headers: { accept: json === "html" ? "text/html" : json ? "application/json" : "text/plain, text/*" } });
330
415
  if (res.status >= 300 && res.status < 400) {
331
416
  const to = res.headers.get("location");
332
417
  if (!to) return { ok: false, note: `Redirect with nowhere to go (HTTP ${res.status}).` };
@@ -336,7 +421,7 @@ export async function fetchText(url: string, doFetch: Fetch = fetch, lookup: Loo
336
421
  if (res.status === 401 || res.status === 403) return { ok: false, note: SIGN_IN };
337
422
  if (!res.ok) return { ok: false, note: `The link answered HTTP ${res.status}.` };
338
423
  const type = (res.headers.get("content-type") ?? "").toLowerCase();
339
- if (json ? !type.startsWith("application/json") : !type.startsWith("text/") || type.startsWith("text/html")) return { ok: false, note: UNREADABLE };
424
+ if (json === "html" ? !type.startsWith("text/html") : json ? !type.startsWith("application/json") : !type.startsWith("text/") || type.startsWith("text/html")) return { ok: false, note: UNREADABLE };
340
425
  // Read no further than the cap; a cut-off last line is dropped, not gathered half.
341
426
  const chunks: Uint8Array[] = [];
342
427
  let size = 0;
@@ -360,19 +445,22 @@ const wordy = (c: string) => c.length >= 3 && !datum(c);
360
445
  const STATUS = /^status$/i;
361
446
  const DONE = /^(done|finish|finished)$/i;
362
447
 
363
- type Item = { text: string; notes?: string };
448
+ type Item = { text: string; notes?: string; images?: string[] };
364
449
 
365
450
  export const split = (kind: Kind, body: string): Item[] => sift(kind, body).items;
366
451
 
367
452
  // The requests, and how many rows were left out for their status. A row left out is
368
453
  // never handed to ingest, so it is not marked seen: reopened, it is gathered then.
369
- export function sift(kind: Kind, body: string): { items: Item[]; done: number } {
454
+ // `pics`, when given, are the images found beside the rows: by sheet row number for a
455
+ // sheet, by line text for a doc.
456
+ export function sift(kind: Kind, body: string, pics?: Map<number | string, string[]>): { items: Item[]; done: number } {
457
+ const with_ = (it: Item, at: number | string): Item => { const p = pics?.get(at); return p?.length ? { ...it, images: p } : it; };
370
458
  // Pull requests come back from the issues API too; they are not requests. The notes
371
459
  // are the number and link only, so an edited body does not make the issue new.
372
460
  if (kind === "github") return { items: (JSON.parse(body) as any[]).filter((i) => !i.pull_request && typeof i.title === "string")
373
461
  .map((i) => ({ text: i.title, notes: `#${i.number} · ${i.html_url}` })), done: 0 };
374
462
  const rows = body.split(/\r?\n/);
375
- if (kind !== "sheet") return { items: rows.filter((r) => r.trim()).map((text) => ({ text })), done: 0 };
463
+ if (kind !== "sheet") return { items: rows.filter((r) => r.trim()).map((text) => with_({ text }, lineKey(text))), done: 0 };
376
464
  const cells = rows.map((r) => r.split("\t").map((c) => c.trim()));
377
465
  const filled = cells.filter((row) => row.some(Boolean));
378
466
  // Sheets often open with blank rows or a title: the header is looked for in the
@@ -385,21 +473,27 @@ export function sift(kind: Kind, body: string): { items: Item[]; done: number }
385
473
  const head = filled.slice(0, 5).find((row) => !/^\d{1,3}[.)]?$/.test(row.find(Boolean) ?? "") && (named(row, HEADER) >= 0 || named(row, STATUS) >= 0));
386
474
  const data = head ? filled.slice(filled.indexOf(head) + 1) : filled;
387
475
  const status = head ? named(head, STATUS) : -1;
388
- const known = head ? named(head, HEADER) : -1;
389
- // No request column named: one column for the whole sheet, the one holding the most
390
- // text that reads like a request. Never a cell per row, which takes `Open` or a name.
391
- let col = known;
392
- if (col < 0) for (let i = 0, most = 0; i < Math.max(0, ...data.map((row) => row.length)); i++) {
393
- const n = i === status ? 0 : data.reduce((sum, row) => sum + (wordy(row[i] ?? "") ? row[i]!.length : 0), 0);
394
- if (n > most) { most = n; col = i; }
395
- }
476
+ // One column for the whole sheet, the one holding the most text that reads like a
477
+ // request. Never a cell per row, which takes `Open` or a name.
478
+ // Finished rows do not vote: their replies outweigh the requests still open.
479
+ const open = data.filter((row) => status < 0 || !DONE.test(row[status] ?? ""));
480
+ const text = (i: number) => i === status ? 0 : open.reduce((sum, row) => sum + (wordy(row[i] ?? "") ? row[i]!.length : 0), 0);
481
+ let wordiest = -1;
482
+ for (let i = 0, most = 0; i < Math.max(0, ...data.map((row) => row.length)); i++) if (text(i) > most) { most = text(i); wordiest = i; }
483
+ // A named column loses to one holding far more text: `DETAIL` naming the page while
484
+ // `NOTES` holds the request.
485
+ let known = head ? named(head, HEADER) : -1;
486
+ // ponytail: a named column with no wordy text at all keeps it (one-cell title rows, pinned by test).
487
+ if (known >= 0 && wordiest >= 0 && text(known) > 0 && text(wordiest) > 3 * text(known)) known = -1;
488
+ const col = known >= 0 ? known : wordiest;
396
489
  let done = 0;
397
490
  const items = data.flatMap((row) => {
398
491
  const text = row[col] ?? "";
399
492
  if (!text || (known < 0 && !wordy(text))) return [];
400
493
  if (status >= 0 && DONE.test(row[status] ?? "")) { done++; return []; }
401
494
  const rest = row.filter((c, i) => c && i !== col).join(" · ");
402
- return [{ text, ...(rest ? { notes: rest } : {}) }];
495
+ // The export keeps every row from the top, so a row's index is its number less one.
496
+ return [with_({ text, ...(rest ? { notes: rest } : {}) }, cells.indexOf(row) + 1)];
403
497
  });
404
498
  return { items, done };
405
499
  }
@@ -433,9 +527,14 @@ export async function gatherSource(state: string, url: string, doFetch?: Fetch,
433
527
  }
434
528
  try {
435
529
  const target = exportUrl(url);
436
- const got = await fetchText(target.url, doFetch, lookup, target.kind === "github");
530
+ // Images are extra, read alongside the text: a page that cannot be read for them
531
+ // leaves the gather as it was, and costs no time beyond the text's own.
532
+ const at = imagesUrl(url);
533
+ const [got, page] = await Promise.all([fetchText(target.url, doFetch, lookup, target.kind === "github"),
534
+ at ? fetchText(at, doFetch, lookup, "html").catch(() => null) : null]);
437
535
  if (got.ok) {
438
- const { items, done } = sift(target.kind, got.body);
536
+ const found = page?.ok ? (target.kind === "sheet" ? sheetImages(page.body) : docImages(page.body)) : undefined;
537
+ const { items, done } = sift(target.kind, got.body, found as Map<number | string, string[]> | undefined);
439
538
  return record(state, url, ingest(state, url, items), target.kind === "github" ? undefined : got.body.split("\n").filter((r) => r.trim()).length, done);
440
539
  }
441
540
  const last = { at: new Date().toISOString(), ok: false, added: 0, note: got.note };
package/cli/state.test.ts CHANGED
@@ -687,6 +687,16 @@ test("ownerOf: an idea's session belongs to the first run created after it, not
687
687
  expect(ownerOf(st, { ...rec, run: "2026-10-01-x" })).toBe("2026-10-01-x");
688
688
  });
689
689
 
690
+ test("ownerOf: a run that recorded the launch's session id owns it, however its request is worded", () => {
691
+ const p = sessionProject(), st = join(p, ".kaizen");
692
+ newRun(p, "2026-10-02-reworded", "/kaizen something else entirely");
693
+ const f = join(st, "runs", "2026-10-02-reworded", "state.json");
694
+ writeFileSync(f, JSON.stringify({ ...JSON.parse(readFileSync(f, "utf8")), session: "abc" }));
695
+ const rec = { key: "k", run: null, text: "open it on my phone", cmd: "claude", sessionId: "abc", started: Date.now() };
696
+ expect(ownerOf(st, rec)).toBe("2026-10-02-reworded");
697
+ expect(ownerOf(st, { ...rec, sessionId: "other" })).toBe(null);
698
+ });
699
+
690
700
  test("findTerminal records the agent's PID: a POSIX wrapper that execs it, a PowerShell prefix on Windows", () => {
691
701
  const keys = ["PATH", "HERDR_ENV", "TMUX", "TERMINAL", "SystemRoot", "windir", "ComSpec"];
692
702
  const saved = keys.map((k) => process.env[k]);
@@ -998,3 +1008,24 @@ test("the board refuses an upgrade from another origin", async () => {
998
1008
  expect(r.status).toBe(403);
999
1009
  } finally { proc.kill(); await proc.exited; rmSync(homeDir, { recursive: true, force: true }); }
1000
1010
  });
1011
+
1012
+ // KAIZEN_WEB_HOSTS (tailscale serve): the listed host gets in, any other is still refused.
1013
+ test("the board answers only the hosts listed in KAIZEN_WEB_HOSTS", async () => {
1014
+ const homeDir = mkdtempSync(join(tmpdir(), "kaizen-hosts-"));
1015
+ for (const d of [join(homeDir, ".kaizen"), join(homeDir, ".claude")]) mkdirSync(d, { recursive: true });
1016
+ const port = 20000 + Math.floor(Math.random() * 20000), repo = join(import.meta.dir, "..");
1017
+ const proc = Bun.spawn([process.execPath, join(repo, "cli", "install.ts"), "web", "--port", String(port), "--no-open", "--foreground"], {
1018
+ cwd: homeDir, env: { ...process.env, HOME: homeDir, USERPROFILE: homeDir, KAIZEN_HOME: repo, KAIZEN_WEB_HOSTS: " PC.tail1234.ts.net:443 , " }, stdout: "ignore", stderr: "ignore",
1019
+ });
1020
+ try {
1021
+ const at = `http://127.0.0.1:${port}`;
1022
+ for (let i = 0; ; i++) { try { await fetch(at + "/state"); break; } catch { if (i > 100) throw new Error("board did not start"); await Bun.sleep(50); } }
1023
+ expect((await fetch(at + "/state", { headers: { host: "pc.tail1234.ts.net" } })).status).toBe(200);
1024
+ expect((await fetch(at + "/state", { headers: { host: "evil.example" } })).status).toBe(403);
1025
+ expect((await fetch(at + "/state", { headers: { host: "other.tail1234.ts.net" } })).status).toBe(403);
1026
+ expect((await fetch(at + "/upgrade", { method: "POST", headers: { origin: "https://evil.example" } })).status).toBe(403);
1027
+ const m = await fetch(at + "/manifest.webmanifest");
1028
+ expect(m.status).toBe(200);
1029
+ expect((await m.json()).display).toBe("standalone");
1030
+ } finally { proc.kill(); await proc.exited; rmSync(homeDir, { recursive: true, force: true }); }
1031
+ });
package/cli/state.ts CHANGED
@@ -1121,6 +1121,16 @@ export function readSessions(st: string): Session[] {
1121
1121
  // request holds the idea's text: one run per record, never every run that mentions it.
1122
1122
  export function ownerOf(st: string, s: Session): string | null {
1123
1123
  if (s.run) return s.run;
1124
+ // The launch's own conversation id, recorded by the run at intake: holds however the
1125
+ // request was worded. Text matching below is for tools that expose no id.
1126
+ if (s.sessionId) {
1127
+ try {
1128
+ const mine = readdirSync(join(st, "runs")).filter((id) => {
1129
+ try { return JSON.parse(readText(join(st, "runs", id, "state.json"))).session === s.sessionId; } catch { return false; }
1130
+ }).sort();
1131
+ if (mine.length) return mine[0]!;
1132
+ } catch { /* no runs dir */ }
1133
+ }
1124
1134
  const t = normalise(s.text);
1125
1135
  let best: string | null = null, at = Infinity;
1126
1136
  try {