kaizen-agent 1.35.0 → 1.36.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.
@@ -68,7 +68,11 @@ change — do not refactor around it. Write what you changed to
68
68
  - **Verification** — each check carried out, with its actual result. Quote failures.
69
69
  - **Not done** — steps skipped, out-of-scope observations, known gaps.
70
70
  - **Preview** — how to see the result: commands to run it locally and the URL for an
71
- app; the path to open, or the result itself, for anything else. Then `### How to test`:
71
+ app; the path to open, or the result itself, for anything else. It must embed at
72
+ least one screenshot of the result (`![…](attachments/<name>.png)`, saved under
73
+ `runs/<id>/attachments/`, e.g. `playwright screenshot <url> <path>`) whenever it shows on
74
+ a screen. On a revise of a run that already shows screenshots, retake them so
75
+ none shows the result from before the change. Then `### How to test`:
72
76
  numbered steps from the plan's gate, each an action and what the person should see,
73
77
  plus any test command with its expected result. `###` subheads only — the board ends
74
78
  the section at the next `##`. The board shows it.
package/cli/browser.ts CHANGED
@@ -220,7 +220,9 @@ export async function readPage(page: Page, url: string, platform: string, max =
220
220
  const re = POST[platform]!;
221
221
  await page.go(url);
222
222
  if (!onPlatform(await page.js<string>("location.href")) || (await page.js<boolean>(WALL))) return { ok: false, note: signInNote(platform) };
223
- if (await page.js<boolean>(CHECK)) return { ok: false, note: checkNote(platform) };
223
+ // TikTok lays a closable check over posts it has already shown: close it and read on.
224
+ // A check that leaves no posts still fails below.
225
+ await page.js(`document.querySelector("#captcha_close_button")?.click()`);
224
226
  const seen = new Map<string, { link: string; text: string; date: string; author?: string; images?: string[] }>();
225
227
  for (let i = 0, still = 0; i < 12 && seen.size < max && still < 3; i++) {
226
228
  const before = seen.size;
package/cli/install.ts CHANGED
@@ -91,6 +91,20 @@ if (Bun.argv[2] === "browser") {
91
91
  process.exit(0);
92
92
  }
93
93
 
94
+ // `kaizen card add "<text>" [--parent <run id>]`: what a run files as new work, linked
95
+ // to the run it came from.
96
+ if (Bun.argv[2] === "card") {
97
+ const { locate, label, addCard } = await import("./state.ts");
98
+ const state = locate() ?? join(home, ".kaizen");
99
+ const args = Bun.argv.slice(4), at = args.indexOf("--parent");
100
+ const parent = at >= 0 ? args.splice(at, 2)[1] : undefined;
101
+ if (Bun.argv[3] !== "add" || !args.length || (at >= 0 && !parent)) { console.log(`\n kaizen card add "<text>" [--parent <run id>]\n`); process.exit(1); }
102
+ const r = addCard(state, args.join(" "), parent);
103
+ if (typeof r === "string") { console.log(`\n ${r}\n`); process.exit(1); }
104
+ console.log(`\n ${c.green("added")} ${args.join(" ")}\n ${c.dim(`parent ${r.parent} · project ${label(state)}`)}\n`);
105
+ process.exit(0);
106
+ }
107
+
94
108
  if (Bun.argv[2] === "sources" || Bun.argv[2] === "gather" || Bun.argv[2] === "icebox") {
95
109
  const { locate, label } = await import("./state.ts");
96
110
  const src = await import("./sources.ts");
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
- requestTitle, statusOf, columnOf, parseItem, startedRuns, boardCards, readArchive, setArchived,
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,
14
14
  } from "./state.ts";
@@ -1047,3 +1047,31 @@ test("requestTitle: drops the launch command, keeps the first sentence, caps at
1047
1047
  expect(requestTitle("/kaizen lite simplify the card. Then more words")).toBe("simplify the card.");
1048
1048
  expect(requestTitle("x".repeat(200)).length).toBe(90);
1049
1049
  });
1050
+
1051
+ test("parent/child: a Parent line links a run and an idea to the run that filed them", () => {
1052
+ expect(parentOf("do x\nFollow-up of: 2026-01-01-a.")).toBe("2026-01-01-a");
1053
+ expect(parentOf(" [2026-10-18 20:57] Parent: 2026-01-01-a")).toBe("2026-01-01-a");
1054
+ expect(parentOf("no parent here")).toBeUndefined();
1055
+ const st = join(mkdtempSync(join(tmpdir(), "kaizen-pc-")), ".kaizen");
1056
+ for (const [id, req] of [["2026-01-01-a", "parent work"], ["2026-01-02-b", "child work\nParent: 2026-01-01-a"]]) {
1057
+ mkdirSync(join(st, "runs", id), { recursive: true });
1058
+ writeFileSync(join(st, "runs", id, "00-request.md"), req + "\n");
1059
+ writeFileSync(join(st, "runs", id, "state.json"), JSON.stringify({ stage: "done", awaiting: null }));
1060
+ }
1061
+ writeFileSync(join(st, "inbox.md"), "# Inbox\n\n- open: follow-on idea\n Parent: 2026-01-01-a\n");
1062
+ const cards = boardCards([st], Date.now());
1063
+ expect(cards.find((k) => k.id === "2026-01-01-a")!.children).toEqual(["2026-01-02-b"]);
1064
+ expect(cards.find((k) => k.id === "2026-01-02-b")!.parent).toBe("2026-01-01-a");
1065
+ expect(cards.find((k) => k.kind === "idea")!.parent).toBe("2026-01-01-a");
1066
+ // The done child is noted in its parent once, however often the board refreshes.
1067
+ boardCards([st], Date.now());
1068
+ expect(readFileSync(join(st, "runs", "2026-01-01-a", "notes.md"), "utf8").match(/Child 2026-01-02-b done: child work/g)!.length).toBe(1);
1069
+ // card add: no run in flight, so a parent must be named.
1070
+ expect(addCard(st, "next thing")).toMatch(/no run in flight/);
1071
+ writeFileSync(join(st, "runs", "2026-01-02-b", "state.json"), JSON.stringify({ stage: "build", awaiting: null }));
1072
+ expect(addCard(st, "next thing")).toEqual({ parent: "2026-01-02-b" });
1073
+ expect(addCard(st, "next thing")).toMatch(/already/);
1074
+ expect(addCard(st, "other", "nope")).toMatch(/no such run/);
1075
+ expect(boardCards([st], Date.now()).find((k) => k.text === "next thing")!.parent).toBe("2026-01-02-b");
1076
+ rmSync(dirname(st), { recursive: true, force: true });
1077
+ });
package/cli/state.ts CHANGED
@@ -615,8 +615,16 @@ export type Card = {
615
615
  branch?: string; // runs only: the run's own branch while it has a worktree
616
616
  tags?: string[]; // runs only: runner, agent, "yolo", "unrechecked"
617
617
  notes?: string; // inbox notes, or runs/<id>/notes.md
618
+ parent?: string; // the run this card was filed from
619
+ children?: string[]; // runs only: runs that name this one as parent
618
620
  };
619
621
 
622
+ // A `Parent: <run id>` line (or the older `Follow-up of: <run id>`) in a request or
623
+ // an idea's notes is what links a card to the run it came from.
624
+ export function parentOf(text?: string) {
625
+ return /^\s*(?:\[[^\]]*\]\s*)?(?:Parent|Follow-up of):\s*(\S+)/im.exec(text ?? "")?.[1]?.replace(/[.,]$/, "");
626
+ }
627
+
620
628
  // A run is called running on the evidence that it moved recently; there is no PID
621
629
  // to ask. Anything in flight and older than this is stalled, not running -- the
622
630
  // difference the board exists to show.
@@ -770,6 +778,20 @@ export function appendNote(state: string, target: { id: string } | { text: strin
770
778
  return null;
771
779
  }
772
780
 
781
+ // A card filed by a run: an open idea whose note names the run as its parent. The
782
+ // parent is the one named, else the newest run still in flight.
783
+ export function addCard(state: string, text: string, parent?: string): { parent: string } | string {
784
+ text = text.trim().replace(/\s+/g, " ");
785
+ if (!text) return "empty card";
786
+ const id = parent ?? readRuns(state).find((r) => r.stage !== "done" && r.stage !== "abandoned")?.id;
787
+ if (!id) return "no run in flight to be its parent; pass --parent <run id>";
788
+ if (!existsSync(join(state, "runs", id, "state.json"))) return `no such run: ${id}`;
789
+ const lines = readInbox(state);
790
+ if (lines.some((l) => l.status === "open" && l.text === text)) return "that card is already on the board";
791
+ writeInbox(state, [...lines, { status: "open", text, notes: `Parent: ${id}` }]);
792
+ return { parent: id };
793
+ }
794
+
773
795
  // A file beside the notes it belongs to: runs/<id>/attachments/ for a run,
774
796
  // <state>/attachments/ for an idea. Returns the path relative to the state dir,
775
797
  // which is what the note links and what /file serves.
@@ -966,8 +988,8 @@ export function boardCards(states: string[], now: number): Card[] {
966
988
  const t = normalise(it.text);
967
989
  if (it.status === "started" ? requests.some((r) => r.includes(t)) : lines.some((l) => l === t || l.endsWith(" " + t))) continue;
968
990
  next.push(it.status === "started"
969
- ? { kind: "idea", status: "starting", text: it.text, state: st, where, column: 1, awaiting: null, dim: true, archived: false, notes: it.notes }
970
- : { kind: "idea", status: "idea", text: it.text, state: st, where, column: 0, awaiting: null, dim: false, archived: it.status === "archived", notes: it.notes });
991
+ ? { kind: "idea", status: "starting", text: it.text, state: st, where, column: 1, awaiting: null, dim: true, archived: false, notes: it.notes, parent: parentOf(it.notes) }
992
+ : { kind: "idea", status: "idea", text: it.text, state: st, where, column: 0, awaiting: null, dim: false, archived: it.status === "archived", notes: it.notes, parent: parentOf(it.notes) });
971
993
  }
972
994
  // Runs started in Yolo. Only yolo records are matched to runs, so a project that
973
995
  // never used it pays one directory read.
@@ -982,9 +1004,20 @@ export function boardCards(states: string[], now: number): Card[] {
982
1004
  branch: r.branch,
983
1005
  tags: [r.runner, r.agent, yolos.has(r.id) ? "yolo" : undefined, unrechecked(st, r.id) ? "unrechecked" : undefined].filter((t): t is string => !!t),
984
1006
  notes: readNotes(st, r.id) || undefined,
1007
+ parent: parentOf(body(st, r.id)),
985
1008
  });
986
1009
  }
987
1010
  }
1011
+ for (const k of next) if (k.kind === "run") {
1012
+ const kids = next.filter((c) => c.kind === "run" && c.parent === k.id && c.state === k.state);
1013
+ if (kids.length) k.children = kids.map((c) => c.id!);
1014
+ // A child that is done tells its parent once, so the parent has it on record
1015
+ // whether or not the agent remembered to.
1016
+ for (const c of kids) if (c.status === "done" && !(k.notes ?? "").includes(`Child ${c.id} done`)) {
1017
+ const line = `Child ${c.id} done: ${c.title || c.text}`;
1018
+ if (!appendNote(k.state, { id: k.id! }, line)) k.notes = (k.notes ? k.notes + "\n" : "") + line;
1019
+ }
1020
+ }
988
1021
  // Done reads newest first: by `moved`, the time each card prints as its age.
989
1022
  // Sorted into the slots the done cards already hold, so no other card moves.
990
1023
  const done = next.filter((k) => k.column === 4).sort((a, b) => (b.moved ?? 0) - (a.moved ?? 0));
package/cli/web.ts CHANGED
@@ -77,6 +77,23 @@ export function newer(a: string, b: string) {
77
77
  return false;
78
78
  }
79
79
 
80
+ // What each newer release brings, for the update bar's What's new: the GitHub release
81
+ // titles ("v1.35.0: project logos, ...") above `from`, newest first. Cached like
82
+ // npmLatest; GitHub unreachable gives none and the bar shows no What's new.
83
+ let rel: { list: { v: string; text: string }[]; at: number } = { list: [], at: 0 };
84
+ async function releaseNotes(from: string, fresh = false) {
85
+ if (fresh || !rel.list.length || Date.now() - rel.at > 6 * 3600_000) {
86
+ try {
87
+ const r = await fetch("https://api.github.com/repos/hfadhlullah/kaizen/releases?per_page=30", { signal: AbortSignal.timeout(10000) });
88
+ const list = ((await r.json()) as { tag_name: string; name?: string }[])
89
+ .map((x) => ({ v: x.tag_name.replace(/^v/, ""), text: (x.name || "").replace(/^v?[\d.]+:\s*/, "") }))
90
+ .filter((x) => x.text);
91
+ rel = { list, at: Date.now() };
92
+ } catch {}
93
+ }
94
+ return rel.list.filter((x) => newer(x.v, from));
95
+ }
96
+
80
97
  type Opts = { port?: number; open?: boolean };
81
98
 
82
99
  export async function web(repoDir: string, opts: Opts = {}) {
@@ -230,7 +247,13 @@ export async function web(repoDir: string, opts: Opts = {}) {
230
247
  if (ms) for (const dir of states(true)) {
231
248
  let list;
232
249
  try { list = readSources(dir); } catch { continue; } // unreadable: left as it is
233
- for (const s of list) if (due(s, ms, Date.now()) && (await gatherSource(dir, s.url)).added) changed();
250
+ // Social sources need a reader that picks posts: only ChatGPT can do that unattended.
251
+ const gpt = await setting("sources.agent") === "chatgpt" && chatgpt.status().connected;
252
+ for (const s of list) {
253
+ if (!due(s, ms, Date.now())) continue;
254
+ if (!socialOf(s.url)) { if ((await gatherSource(dir, s.url)).added) changed(); }
255
+ else if (gpt) { await chatgpt.gatherSource(dir, s.url, await setting("sources.model"), (await setting("sources.effort")) || "low"); changed(); }
256
+ }
234
257
  }
235
258
  } catch { /* the next tick tries again */ }
236
259
  busy = false;
@@ -417,11 +440,14 @@ export async function web(repoDir: string, opts: Opts = {}) {
417
440
  const markable = (c: Card) => c.kind === "run" && !c.archived && (c.status === "done" || c.awaiting === "approvals.review") && c.state !== join(home, ".kaizen");
418
441
  for (const d of new Set(cards.filter(markable).map((c) => c.state)))
419
442
  marks.set(d, cardsGit(d, cards.filter((c) => markable(c) && c.state === d).map((c) => c.id!)));
443
+ // Open findings the panel's Fix findings form would offer, on the same runs.
444
+ const openFindings = (c: Card) => c.kind === "run" && !c.archived && (c.status === "done" || c.awaiting === "findings" || c.awaiting === "approvals.review") && c.state !== join(home, ".kaizen")
445
+ ? reviewFindings(c.state, c.id!).filter((f) => !f.done).length || undefined : undefined;
420
446
  return json({
421
447
  project, all, dir: state,
422
448
  projects: picker.map((d) => ({ dir: d, label: label(d), logo: !!projectLogo(d) })),
423
449
  fresh: unset.map((d) => ({ dir: join(d, ".kaizen"), label: tilde(d) })),
424
- cards: cards.map((c) => ({ ...c, notice: notice(c), git: markable(c) ? marks.get(c.state)?.[c.id!] : undefined, pty: ptyOf(c) })),
450
+ cards: cards.map((c) => ({ ...c, notice: notice(c), git: markable(c) ? marks.get(c.state)?.[c.id!] : undefined, open: openFindings(c), pty: ptyOf(c) })),
425
451
  now: Date.now(),
426
452
  });
427
453
  }
@@ -520,7 +546,9 @@ export async function web(repoDir: string, opts: Opts = {}) {
520
546
  // What this board runs and what npm has: the page shows an Update button on a newer one.
521
547
  if (req.method === "GET" && path === "/version") {
522
548
  const v = await npmLatest(url.searchParams.has("fresh"));
523
- return json({ version: VERSION, latest: v, update: !!v && newer(v, VERSION) });
549
+ const update = !!v && newer(v, VERSION);
550
+ const notes = update ? await releaseNotes(VERSION, url.searchParams.has("fresh")) : [];
551
+ return json({ version: VERSION, latest: v, update, notes });
524
552
  }
525
553
 
526
554
  // `kaizen upgrade`, run here with no window, then a new board on this port: it
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-agent",
3
- "version": "1.35.0",
3
+ "version": "1.36.1",
4
4
  "description": "Staged workflow for AI agents — code, writing, research, operations: plan, human approval, build, independent review, bounded fix loop.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -108,15 +108,26 @@ commands to start it locally and the URL to open, copied from the material's own
108
108
  where one exists. For anything else — a document, copy, a runbook, a memo — the path
109
109
  to open, or the result itself when it fits in a screen. The board shows this section on
110
110
  the run's card, so a person can try the work without reading the rest of the report.
111
- Where the result can be seen, put screenshots of it here. On a `code` track this is required, not optional: run the result and attach proof.
111
+ Every run whose result shows on a screen must put at least one screenshot of it here,
112
+ on any track and in every runner — this is required, not optional: run the result and capture it.
112
113
  A UI (a page, a board, a terminal screen, an app window) gets a screenshot of the changed
113
- screen; a CLI, library or API gets the real output of the command that exercises it, pasted
114
- in a code block. A code report with no proof is incomplete. Save each under
114
+ screen; a document gets one of it rendered; a CLI gets one of the terminal running it, with
115
+ the real output also pasted in a code block. Only a result with no screen at all — a
116
+ library, an API — may give command output alone. A report with no screenshot is incomplete. Save each under
115
117
  `runs/<id>/attachments/` (letters, digits, `.`, `-`, `_` only) and embed it as
116
118
  `![what it shows](attachments/<name>.png)`; the board shows them in the Preview tab.
117
- A headless browser does it: `playwright screenshot <url> runs/<id>/attachments/<name>.png`.
119
+ A headless browser does it: `playwright screenshot <url> runs/<id>/attachments/<name>.png`,
120
+ or `chromium --headless --screenshot=<abs path>.png --window-size=1280,900 <url>` where
121
+ playwright has no browser installed. A file (document, diff, output) opens as `file://<path>`.
118
122
  Where no proof can be produced, say why in one line under `## Preview`.
119
123
 
124
+ **A revise retakes the screenshots.** When the run being revised, or the run a follow-up
125
+ names in `Follow-up of:`, already shows screenshots in its `## Preview`, the build that
126
+ answers the revise captures the same views again after the change. In a revised run,
127
+ overwrite each stale image under the same name (or replace its embed) so the Preview tab
128
+ never shows the result from before the revise; in a follow-up run, embed the new ones in
129
+ its own `## Preview`. A view the revise removed is dropped, not left showing the old result.
130
+
120
131
  It also says how to test the result, under a `### How to test` subhead: numbered steps a
121
132
  person follows to check the run did what was asked, each an action and what they should
122
133
  see — "open Settings → Board, clear the field: it shows `not set`". Derive the steps
@@ -118,3 +118,16 @@ change is that it is written down rather than spoken and discarded.
118
118
  Set `stage: "done"` in `state.json` once the user accepts.
119
119
 
120
120
  ---
121
+
122
+ ## Child cards
123
+
124
+ A run may file new work as its own card instead of growing its scope — under either
125
+ runner. Under `full`, subagents only suggest it in their report; the main thread files it,
126
+ from the project folder: `kaizen card add "<the work, one line>"`. It links the card to
127
+ the run in flight (`--parent <run id>` names another), and the link follows the card into
128
+ the run it becomes. The board shows the parent's name on the child.
129
+
130
+ When a child run reaches `done`, the board appends `Child <child id> done: <title>` to
131
+ the parent's `notes.md` by itself. The parent documents it from there: if it is still in
132
+ flight its next stage reads `notes.md`; if it is already `done`, the entry is the record.
133
+ Never reopen a finished parent.
@@ -43,9 +43,9 @@ Five checks are universal, in every track:
43
43
  now is a `high` finding at minimum, whatever else the work achieved. If the builder
44
44
  recorded no baseline, that absence is itself the finding. So is a `03-impl.md`
45
45
  without a `## Preview` section, or one without `### How to test` steps: `medium`,
46
- since the user is left with no way to try the work. A `code` run whose
47
- `## Preview` has no proof (screenshot for a UI, real command output otherwise) and gives
48
- no reason is `high`.
46
+ since the user is left with no way to try the work. A run whose result
47
+ shows on a screen and whose `## Preview` embeds no screenshot, or a `code` run with no
48
+ proof at all, and that gives no reason, is `high`.
49
49
 
50
50
  5. **Acceptance** — every criterion, `G-nn` and `R-nn`, answered against the work
51
51
  rather than against `03-impl.md`, by trying to falsify it: name the concrete check
@@ -31,6 +31,14 @@ const picked = new Set();
31
31
  export const key = (c) =>
32
32
  c._k || (c.kind === "idea" ? "idea:" + c.state + ":" + c.text : "run:" + c.state + ":" + c.id);
33
33
 
34
+ // Cards filed by a run sit inside it while it is on the board and not done; once the
35
+ // parent is done, archived or gone, they are back in their own columns.
36
+ const kidsOf = (p) => (p.kind === "run" ? store.S.cards.filter((c) => c.parent === p.id && c.state === p.state) : []);
37
+ const nested = (c) =>
38
+ !!c.parent &&
39
+ store.S.cards.some((p) => p.kind === "run" && p.id === c.parent && p.state === c.state && !p.archived && p.column !== 4);
40
+ const kidsOpen = new Set();
41
+
34
42
  function card(c) {
35
43
  const k = key(c),
36
44
  el = document.createElement("div");
@@ -73,6 +81,9 @@ function card(c) {
73
81
  // A gathered idea's notes open with the stamp promote() writes (ingest() wrote it before
74
82
  // the icebox); its chip names the source.
75
83
  const g = c.notes && /^\[[^\]]*\] (?:Gathered from|Promoted from the icebox:) (.+?)\. Outside text/.exec(c.notes);
84
+ // A card filed by a run names that run; the parent itself shows nothing.
85
+ if (c.parent)
86
+ chips.push(`<span class="chip link" title="Filed by ${esc(c.parent)}">${esc(c.parent.replace(/^\d{4}-\d\d-\d\d-/, "").replace(/-/g, " "))}</span>`);
76
87
  if (c.branch) chips.push(`<span class="chip" title="Built on its own branch, ${esc(c.branch)}">branch</span>`);
77
88
  if (g) chips.push(`<span class="chip" title="From ${esc(g[1])}">gathered</span>`);
78
89
  else if (c.notes) chips.push(`<span class="chip">notes</span>`);
@@ -85,12 +96,45 @@ function card(c) {
85
96
  c.git === "pushed" ? " ok" : " warn",
86
97
  ),
87
98
  );
99
+ if (c.open) marks.push(mk("i-bug", `${c.open} open finding${c.open === 1 ? "" : "s"} to fix`, " warn"));
88
100
  const pick = picking && !archive && c.column === 4;
89
101
  el.innerHTML =
90
102
  (pick ? `<input type="checkbox" class="pick" ${picked.has(k) ? "checked" : ""} aria-label="Select">` : "") +
91
103
  `<div class="c-id"><span class="st d-${st}" role="img" title="${esc(sttl)}" aria-label="${esc(sttl)}"><svg><use href="#s-${st}"/></svg></span><span class="idt">${esc(idt)}</span><span class="age">${c.kind === "run" ? age(c.moved || store.S.now, store.S.now) : ""}</span><button class="ib sm more" data-a="menu" title="Actions" aria-label="Actions" aria-haspopup="menu" aria-expanded="${k === store.menuKey}">${ico("more")}</button></div>
92
104
  <div class="c-title"${c.kind === "idea" && brief(c.text).parts.length ? ` title="${esc(c.text)}"` : ""}>${esc(c.kind === "idea" ? brief(c.text).head : c.title || c.id)}</div>
93
105
  ${chips.length || marks.length ? `<div class="c-foot"><span class="c-chips">${chips.join("")}</span><span class="c-marks">${marks.join("")}</span></div>` : ""}`;
106
+ const kids = kidsOf(c);
107
+ if (kids.length) {
108
+ const on = kidsOpen.has(k),
109
+ nDone = kids.filter((x) => x.status === "done").length,
110
+ wait = kids.some((x) => x.status === "waiting");
111
+ const box = document.createElement("div");
112
+ box.className = "kids";
113
+ box.innerHTML =
114
+ `<button class="kids-t" aria-expanded="${on}">${on ? "▾" : "▸"} ${kids.length} ${kids.length === 1 ? "child" : "children"}${nDone ? ` · ${nDone} done` : ""}${wait ? `<span class="kids-w" title="A child is waiting on you"></span>` : ""}</button>` +
115
+ (on
116
+ ? `<ul>${kids
117
+ .map((x, i) => {
118
+ const s = ICON[x.status] || "idea";
119
+ return `<li data-i="${i}" tabindex="0" role="button" title="Open"><span class="st d-${s}"><svg><use href="#s-${s}"/></svg></span><span class="kids-s">${esc(WORD[x.status] || x.status)}</span><span class="kids-n" title="${esc(x.title || x.text || x.id)}">${esc(x.kind === "idea" ? brief(x.text).head : (x.text || x.id || "").replace(/-/g, " "))}</span></li>`;
120
+ })
121
+ .join("")}</ul>`
122
+ : "");
123
+ box.querySelector(".kids-t").onclick = (e) => {
124
+ e.stopPropagation();
125
+ on ? kidsOpen.delete(k) : kidsOpen.add(k);
126
+ render();
127
+ };
128
+ box.querySelectorAll("li").forEach((li) => {
129
+ const go = (e) => {
130
+ e.stopPropagation();
131
+ open(kids[+li.dataset.i]);
132
+ };
133
+ li.onclick = go;
134
+ li.onkeydown = (e) => (e.key === "Enter" || e.key === " ") && (e.preventDefault(), go(e));
135
+ });
136
+ el.appendChild(box);
137
+ }
94
138
  if (k === store.menuKey) {
95
139
  const items = [];
96
140
  if (archive) {
@@ -423,7 +467,7 @@ export function render() {
423
467
  }
424
468
  COLS.forEach((name, ci) => {
425
469
  const s = document.createElement("section");
426
- const list = cs.filter((c) => c.column === ci && (!filter || c.status === filter));
470
+ const list = cs.filter((c) => c.column === ci && (!filter || c.status === filter) && (archive || !nested(c)));
427
471
  // Runs age by their last move, ideas by inbox position (new ones are appended).
428
472
  if (ci < 4) {
429
473
  const at = (c) => (c.kind === "run" ? c.moved || 0 : store.S.cards.indexOf(c)),
@@ -40,7 +40,7 @@ export function fixForm(c) {
40
40
  fs
41
41
  .map((f) => {
42
42
  const x = f.done ? " (closed)" : sent(f) ? " (sent)" : "";
43
- return `<label class="${x ? "off" : ""}"><input type="checkbox" data-n="${f.n}" ${x ? "checked disabled" : fixOff.has(k(f.n)) ? "" : "checked"}><span>${f.n}. ${md(f.text).replace(/^<p>|<\/p>$/g, "")}${x}</span></label>`;
43
+ return `<label class="${f.done ? "off done" : x ? "off" : ""}"><input type="checkbox" data-n="${f.n}" ${x ? "checked disabled" : fixOff.has(k(f.n)) ? "" : "checked"}><span>${f.n}. ${md(f.text).replace(/^<p>|<\/p>$/g, "")}${x}</span></label>`;
44
44
  })
45
45
  .join("") +
46
46
  (open.length
@@ -391,6 +391,8 @@ export async function openSources() {
391
391
  G.use === "ask" && !G.connected
392
392
  ? `<div class="cg-onb"><b>Gather social posts with your ChatGPT plan</b><p>Connect ChatGPT Plus or Pro and Gather reads X, TikTok, Instagram and Threads sources right here, counted against your plan, with no agent to open. Skip it and Gather keeps using your agent. You can change this in Settings.</p><div class="acts"><button class="btn ghost" id="cg-skip">Skip for now</button><button class="btn pri" id="cg-go">Continue with ChatGPT</button></div></div>`
393
393
  : "";
394
+ // A rebuild replaces the drawer: keep where it was scrolled to.
395
+ const at = $("src-sec")?.querySelector(".body")?.scrollTop ?? 0;
394
396
  $("pbody").innerHTML =
395
397
  d && d.error
396
398
  ? `<p style="color:var(--dim)">${esc(d.error)}</p>`
@@ -398,6 +400,8 @@ export async function openSources() {
398
400
  ? `<p style="color:var(--dim)">The icebox could not be read.</p>`
399
401
  : onb + bar + bulk + fails + inbox + sec;
400
402
  if (!d || d.error) return;
403
+ const body = $("src-sec")?.querySelector(".body");
404
+ if (body) body.scrollTop = at;
401
405
  if (iceDraft && iceDraft[0] === iceForm && $("ice-title")) {
402
406
  $("ice-title").value = iceDraft[1];
403
407
  $("ice-details").value = iceDraft[2];
@@ -130,7 +130,8 @@ export async function openRun(c, refresh, again) {
130
130
  // The board's own status mark, then what it waits on: chips, not a dotted line.
131
131
  $("psub").innerHTML =
132
132
  `<span class="pst"><span class="st d-${ICON[c.status] || "idea"}"><svg><use href="#s-${ICON[c.status] || "idea"}"/></svg></span>${esc(cap(WORD[c.status] || c.status))}</span>` +
133
- (c.awaiting ? `<span class="pst dim">${esc(cap(need(c.awaiting)))}</span>` : "");
133
+ (c.awaiting ? `<span class="pst dim">${esc(cap(need(c.awaiting)))}</span>` : "") +
134
+ (c.parent ? `<span class="pst link" title="Filed by this run">${esc(c.parent)}</span>` : "");
134
135
  // Only tabs with something in them show. A card whose agent runs in the board gets its
135
136
  // terminal behind the header's terminal button, chosen once right after a launch from here.
136
137
  const tabs = TABS.filter(
@@ -47,7 +47,14 @@ export function updBar() {
47
47
  }
48
48
  const d = document.createElement("div");
49
49
  d.className = "updbar";
50
- d.innerHTML = `<span>kaizen <b>${esc(upd.latest)}</b> is available. You are running ${esc(upd.version)}.</span><button class="btn pri" id="upd-go">Update kaizen</button>`;
50
+ // What's new: each newer release's one-line title, shown on hover, focus or click.
51
+ const notes = (upd.notes || [])
52
+ .map((n) => `<li><b>${esc(n.v)}</b> ${esc(n.text)}</li>`)
53
+ .join("");
54
+ const news = notes
55
+ ? `<span class="updnew"><button class="btn" aria-describedby="upd-notes">What's new</button><span class="updpop" id="upd-notes" role="tooltip"><ul>${notes}</ul><a href="https://github.com/hfadhlullah/kaizen/releases" target="_blank" rel="noopener">All release notes</a></span></span>`
56
+ : "";
57
+ d.innerHTML = `<span>kaizen <b>${esc(upd.latest)}</b> is available. You are running ${esc(upd.version)}.</span><span class="updact">${news}<button class="btn pri" id="upd-go">Update kaizen</button></span>`;
51
58
  $("pbody").prepend(d);
52
59
  const b = $("upd-go");
53
60
  b.onclick = async () => {
package/web/board.html CHANGED
@@ -179,6 +179,8 @@ section h2 .clear{margin-left:auto;min-height:22px;padding:0 8px}
179
179
  .chip.accent i{background:var(--accent)}.chip.accent svg{color:var(--accent)}
180
180
  .chip.ok svg{color:var(--ok)}
181
181
  .chip.run{color:var(--ink)}.chip.run i{background:var(--run)}
182
+ .chip.link,.pst.link{color:var(--accent);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--accent) 40%,transparent)}.pst.link{padding-left:9px;background:color-mix(in srgb,var(--accent) 10%,transparent)}
183
+ .kids{margin-top:6px;padding-top:6px;border-top:1px solid color-mix(in srgb,var(--line) 70%,transparent)}.kids-t{all:unset;cursor:pointer;display:flex;align-items:center;gap:6px;width:100%;font-size:12px;color:var(--dim)}.kids-t:focus-visible,.kids li:focus-visible{outline:2px solid var(--accent);border-radius:4px}.kids-w{width:7px;height:7px;border-radius:50%;background:var(--warn);margin-left:auto}.kids ul{list-style:none;margin:4px 0 0;padding:0;display:flex;flex-direction:column;gap:1px}.kids li{display:grid;grid-template-columns:16px auto 1fr;gap:8px;align-items:start;font-size:12px;padding:3px 4px;border-radius:6px;cursor:pointer;min-width:0}.kids li:hover{background:var(--hover)}.kids-s{font-family:var(--mono);font-size:11px;color:var(--dim);white-space:nowrap}.kids-n{overflow-wrap:anywhere;line-height:1.35;color:var(--ink)}.kids .st{width:14px;height:14px}
182
184
  .st.d-idea{color:var(--dim)}.st.d-start{color:var(--run)}.st.d-run{color:var(--run)}.st.d-stall{color:var(--dim)}.st.d-wait{color:var(--warn)}.st.d-done{color:var(--ok)}.st.d-aband{color:var(--dim)}
183
185
  .d-run{color:var(--run)}.d-wait{color:var(--warn)}.d-done{color:var(--ok)}
184
186
  .pulse{display:inline-block;width:7px;height:7px;border-radius:50%;background:currentColor;vertical-align:middle}
@@ -212,7 +214,7 @@ aside.wide{width:100%;border-left:0}#pbody:has(.term){display:flex;flex-directio
212
214
  .chat{display:flex;flex-direction:column;gap:10px;margin:0 0 14px}.msg{border:1px solid var(--line);border-radius:var(--r);padding:10px 12px;background:var(--bg)}.msg time{display:block;font-size:11px;color:var(--dim);margin-bottom:4px}.msg p:last-child{margin-bottom:0}.msg img{max-width:100%;max-height:260px;border-radius:var(--r-s);display:block;margin:4px 0}
213
215
  .notes .tools{display:flex;gap:6px;align-items:center}.notes .tools span{flex:1;font-size:11px;color:var(--dim)}.notes .att{font-size:11px;color:var(--dim)}
214
216
  .inl pre.files{margin:0;max-height:140px;overflow:auto;font:12px/1.5 var(--mono);color:var(--dim)}
215
- .inl.fix{margin-top:16px}.inl.fix label{display:flex;gap:8px;align-items:baseline;font-size:13px;cursor:pointer}.inl.fix label.off{color:var(--dim);cursor:default}.inl.fix input{flex:none;width:auto;margin:0;accent-color:var(--accent);position:relative;top:2px}
217
+ .inl.fix{margin-top:16px}.inl.fix label{display:flex;gap:8px;align-items:baseline;font-size:13px;cursor:pointer}.inl.fix label.off{color:var(--dim);cursor:default}.inl.fix label.done span{text-decoration:line-through}.inl.fix input{flex:none;width:auto;margin:0;accent-color:var(--accent);position:relative;top:2px}
216
218
  .inl.qa{max-height:60vh;overflow:auto}.inl.qa fieldset{border:0;padding:0;margin:0;display:flex;flex-direction:column;gap:4px}.inl.qa legend{font-size:13px;font-weight:500;padding:0;margin-bottom:4px}.inl.qa label{display:flex;gap:8px;align-items:baseline;font-size:13px;cursor:pointer}.inl.qa label input{flex:none;width:auto;margin:0;accent-color:var(--accent);position:relative;top:2px}
217
219
  .inl .q{font-size:14px;font-weight:500}.inl .q.dim{color:var(--dim);font-size:13px;font-weight:400}
218
220
  .empty{grid-column:1/-1;display:flex;flex-direction:column;align-items:center;gap:12px;padding-block:60px;color:var(--dim);text-align:center}
@@ -312,6 +314,11 @@ aside .foot .more .menu-pop{left:0;right:auto;top:auto;bottom:calc(100% + 4px)}.
312
314
  .toast.on{opacity:1}
313
315
  .rail a.upd svg{overflow:visible}.rail a.upd{position:relative}.rail a.upd::after{content:"";position:absolute;left:28px;top:8px;width:8px;height:8px;border-radius:50%;background:var(--accent);box-shadow:0 0 0 2px var(--bg)}
314
316
  .updbar{display:flex;align-items:center;gap:12px;flex-wrap:wrap;justify-content:space-between;margin:0 0 36px;padding:10px 14px;border:1px solid var(--accent);border-radius:var(--r);background:color-mix(in srgb,var(--accent) 10%,var(--bg-2));font-size:13px}
317
+ .updact{display:flex;gap:8px;align-items:center}
318
+ .updnew{position:relative}
319
+ .updpop{display:none;position:absolute;right:0;top:calc(100% + 6px);z-index:20;width:min(360px,80vw);padding:10px 14px;border:1px solid var(--line);border-radius:var(--r);background:var(--bg-2);box-shadow:var(--shadow-1);font-size:13px;line-height:1.5}
320
+ .updnew:hover .updpop,.updnew:focus-within .updpop{display:block}
321
+ .updpop ul{margin:0 0 8px;padding-left:18px}.updpop li{margin:2px 0}.updpop a{color:var(--accent)}
315
322
  .updbar.quiet{border-color:var(--line);background:var(--bg-2);color:var(--dim)}
316
323
  .toast.on.rep{pointer-events:auto}.toast a{color:inherit;margin-left:8px;text-decoration:underline;text-underline-offset:2px}
317
324
  .help{position:fixed;inset:0;z-index:12;display:none;align-items:center;justify-content:center;background:color-mix(in srgb,var(--bg) 60%,transparent);backdrop-filter:blur(3px)}