kaizen-agent 1.34.12 → 1.36.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.
@@ -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");
@@ -0,0 +1,18 @@
1
+ import { test, expect } from "bun:test";
2
+ import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { projectLogo } from "./logo.ts";
6
+
7
+ test("projectLogo finds a fixed-name logo and nothing else", () => {
8
+ const root = mkdtempSync(join(tmpdir(), "kz-logo-"));
9
+ mkdirSync(join(root, ".kaizen"));
10
+ expect(projectLogo(join(root, ".kaizen"))).toBeNull();
11
+ writeFileSync(join(root, "brand.png"), "x");
12
+ expect(projectLogo(join(root, ".kaizen"))).toBeNull();
13
+ mkdirSync(join(root, "public"));
14
+ writeFileSync(join(root, "public", "favicon.svg"), "<svg/>");
15
+ expect(projectLogo(join(root, ".kaizen"))).toBe(join(root, "public", "favicon.svg"));
16
+ writeFileSync(join(root, "logo.png"), "x");
17
+ expect(projectLogo(join(root, ".kaizen"))).toBe(join(root, "logo.png"));
18
+ });
package/cli/logo.ts ADDED
@@ -0,0 +1,20 @@
1
+ // A project's logo, when it has one: the first of a fixed list of files in the project
2
+ // folder (the parent of its .kaizen). Fixed names only, so the board never serves a path
3
+ // a request chose.
4
+ import { existsSync, statSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+
7
+ export const LOGO_TYPES: Record<string, string> = { png: "image/png", svg: "image/svg+xml", jpg: "image/jpeg", webp: "image/webp", ico: "image/x-icon" };
8
+ const NAMES = ["logo", "icon", "favicon"];
9
+ const DIRS = ["", "assets", "public", "static", ".github", "docs", "web"];
10
+
11
+ export function projectLogo(stateDir: string): string | null {
12
+ const root = dirname(stateDir);
13
+ for (const d of DIRS)
14
+ for (const n of NAMES)
15
+ for (const e of Object.keys(LOGO_TYPES)) {
16
+ const f = join(root, d, `${n}.${e}`);
17
+ try { if (existsSync(f) && statSync(f).isFile() && statSync(f).size < 2_000_000) return f; } catch {}
18
+ }
19
+ return null;
20
+ }
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
@@ -11,6 +11,7 @@ import {
11
11
  } from "./state.ts";
12
12
  import { attachPty, writePty, resizePty, ptyState, ptyKeys, adoptLoginEnv } from "./pty.ts";
13
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
+ import { projectLogo, LOGO_TYPES } from "./logo.ts";
14
15
  import * as chatgpt from "./chatgpt.ts";
15
16
  import * as google from "./google.ts";
16
17
  import { writeBacks } from "./writeback.ts";
@@ -76,6 +77,23 @@ export function newer(a: string, b: string) {
76
77
  return false;
77
78
  }
78
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
+
79
97
  type Opts = { port?: number; open?: boolean };
80
98
 
81
99
  export async function web(repoDir: string, opts: Opts = {}) {
@@ -229,7 +247,13 @@ export async function web(repoDir: string, opts: Opts = {}) {
229
247
  if (ms) for (const dir of states(true)) {
230
248
  let list;
231
249
  try { list = readSources(dir); } catch { continue; } // unreadable: left as it is
232
- 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
+ }
233
257
  }
234
258
  } catch { /* the next tick tries again */ }
235
259
  busy = false;
@@ -370,6 +394,16 @@ export async function web(repoDir: string, opts: Opts = {}) {
370
394
  return new Response(Bun.file(LOGO), { headers: { "content-type": "image/png", "cache-control": "max-age=86400" } });
371
395
  }
372
396
 
397
+ // A project's own logo, for the project pickers: only a known project, only the fixed
398
+ // names in LOGOS, sandboxed so an SVG opened directly runs nothing on this origin.
399
+ if (req.method === "GET" && path === "/project-logo") {
400
+ const dir = url.searchParams.get("dir") ?? "";
401
+ const f = states(true).includes(dir) ? projectLogo(dir) : null;
402
+ if (!f) return bad("no logo", 404);
403
+ const ext = f.slice(f.lastIndexOf(".") + 1).toLowerCase();
404
+ return new Response(Bun.file(f), { headers: { "content-type": LOGO_TYPES[ext], "content-security-policy": "sandbox", "x-content-type-options": "nosniff", "cache-control": "max-age=300" } });
405
+ }
406
+
373
407
  // Lets a phone add the board to its home screen as a standalone app.
374
408
  if (req.method === "GET" && path === "/manifest.webmanifest") {
375
409
  return new Response(JSON.stringify({
@@ -406,11 +440,14 @@ export async function web(repoDir: string, opts: Opts = {}) {
406
440
  const markable = (c: Card) => c.kind === "run" && !c.archived && (c.status === "done" || c.awaiting === "approvals.review") && c.state !== join(home, ".kaizen");
407
441
  for (const d of new Set(cards.filter(markable).map((c) => c.state)))
408
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;
409
446
  return json({
410
447
  project, all, dir: state,
411
- projects: picker.map((d) => ({ dir: d, label: label(d) })),
448
+ projects: picker.map((d) => ({ dir: d, label: label(d), logo: !!projectLogo(d) })),
412
449
  fresh: unset.map((d) => ({ dir: join(d, ".kaizen"), label: tilde(d) })),
413
- 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) })),
414
451
  now: Date.now(),
415
452
  });
416
453
  }
@@ -509,7 +546,9 @@ export async function web(repoDir: string, opts: Opts = {}) {
509
546
  // What this board runs and what npm has: the page shows an Update button on a newer one.
510
547
  if (req.method === "GET" && path === "/version") {
511
548
  const v = await npmLatest(url.searchParams.has("fresh"));
512
- 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 });
513
552
  }
514
553
 
515
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.34.12",
3
+ "version": "1.36.0",
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
@@ -7,7 +7,7 @@ import { draft, undraft } from "./drafts.ts";
7
7
  import { COLS, ICON, MARKS, NAMES, WORD, age, d, need } from "./labels.ts";
8
8
  import { brief } from "./markdown.ts";
9
9
  import { open, toggleArchive } from "./panel.ts";
10
- import { ideaCombo, list, newDir, short, showList, showable } from "./projects.ts";
10
+ import { ideaCombo, list, newDir, plogo, short, showList, showable } from "./projects.ts";
11
11
  import { askNotify, toast } from "./toast.ts";
12
12
 
13
13
  let filter = null;
@@ -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">${esc(x.kind === "idea" ? brief(x.text).head : x.title || x.id)}</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) {
@@ -227,7 +271,7 @@ function inlineForm(c) {
227
271
  }
228
272
  if (store.mode === "move") {
229
273
  const ps = store.S.projects.filter((p) => p.dir !== dir);
230
- li.innerHTML = `<div class="q">Move "${esc(c.text)}" to</div><select class="sel" aria-label="Project">${ps.map((p) => `<option value="${esc(p.dir)}">${esc(p.label)}</option>`).join("")}</select><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Move</button></div>`;
274
+ li.innerHTML = `<div class="q">Move "${esc(c.text)}" to</div><select class="sel" aria-label="Project">${ps.map((p) => `<option value="${esc(p.dir)}" title="${esc(p.label)}">${esc(short(p.label))}</option>`).join("")}</select><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Move</button></div>`;
231
275
  li.querySelector("[data-a=s]").onclick = async () => {
232
276
  const sel = li.querySelector("select"),
233
277
  to = sel.value,
@@ -400,7 +444,10 @@ export function render() {
400
444
  ["Find projects on this PC…", "__find"],
401
445
  ];
402
446
  const cur = store.all || !store.S.dir ? "All projects" : (store.PL.find((p) => p[1] === store.S.dir) || ["All projects"])[0];
403
- $("proj").textContent = short(cur);
447
+ $("proj").innerHTML =
448
+ store.all || !store.S.dir
449
+ ? `<span class="plogo" aria-hidden="true">${ico("board")}</span><span>${esc(cur)}</span>`
450
+ : `${plogo(store.S.dir, cur)}<span>${esc(short(cur))}</span>`;
404
451
  $("proj").title = cur;
405
452
  if (!$("projpop").hidden) list();
406
453
  $("showc").hidden = !store.all;
@@ -420,7 +467,7 @@ export function render() {
420
467
  }
421
468
  COLS.forEach((name, ci) => {
422
469
  const s = document.createElement("section");
423
- 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)));
424
471
  // Runs age by their last move, ideas by inbox position (new ones are appended).
425
472
  if (ci < 4) {
426
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