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.
- package/agents/kaizen-builder.md +5 -1
- package/cli/browser.ts +3 -1
- package/cli/install.ts +14 -0
- package/cli/logo.test.ts +18 -0
- package/cli/logo.ts +20 -0
- package/cli/state.test.ts +29 -1
- package/cli/state.ts +35 -2
- package/cli/web.ts +43 -4
- package/package.json +1 -1
- package/skills/kaizen/spec-build.md +15 -4
- package/skills/kaizen/spec-main.md +13 -0
- package/skills/kaizen/spec-review.md +3 -3
- package/web/board/cards.ts +51 -4
- package/web/board/fixes.ts +1 -1
- package/web/board/icebox.ts +119 -29
- package/web/board/main.ts +27 -0
- package/web/board/panel.ts +22 -4
- package/web/board/projects.ts +23 -9
- package/web/board/update.ts +8 -1
- package/web/board.html +65 -29
- package/web/board.js +24 -24
- package/web/notebook.js +1 -1
- package/web/term.js +1 -1
package/agents/kaizen-builder.md
CHANGED
|
@@ -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.
|
|
71
|
+
app; the path to open, or the result itself, for anything else. It must embed at
|
|
72
|
+
least one screenshot of the result (``, 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
|
-
|
|
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/logo.test.ts
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
|
114
|
-
in a code block.
|
|
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
|
``; 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
|
|
47
|
-
`## Preview`
|
|
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
|
package/web/board/cards.ts
CHANGED
|
@@ -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").
|
|
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)),
|
package/web/board/fixes.ts
CHANGED
|
@@ -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
|