kaizen-agent 1.42.2 → 1.43.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.
Files changed (47) hide show
  1. package/agents/kaizen-reviewer.md +1 -1
  2. package/cli/backlog.test.ts +133 -0
  3. package/cli/board.test.ts +10 -4
  4. package/cli/browser.test.ts +1 -1
  5. package/cli/browser.ts +12 -7
  6. package/cli/html.test.ts +19 -0
  7. package/cli/notes.ts +2 -6
  8. package/cli/pty.ts +2 -1
  9. package/cli/settings.test.ts +38 -0
  10. package/cli/settings.ts +37 -30
  11. package/cli/state.test.ts +86 -3
  12. package/cli/state.ts +245 -45
  13. package/cli/web.ts +67 -16
  14. package/package.json +1 -1
  15. package/skills/kaizen/README.md +0 -4
  16. package/skills/kaizen/SKILL.md +4 -1
  17. package/skills/kaizen/config.default.yml +1 -50
  18. package/skills/kaizen/docs.md +3 -2
  19. package/skills/kaizen/settings.md +10 -3
  20. package/skills/kaizen/spec-build.md +3 -2
  21. package/skills/kaizen/spec-main.md +40 -5
  22. package/skills/kaizen/spec-review.md +2 -1
  23. package/skills/kaizen-help/SKILL.md +0 -4
  24. package/web/board/agent-term.ts +1 -1
  25. package/web/board/api.ts +2 -0
  26. package/web/board/approvals.ts +3 -3
  27. package/web/board/cards.ts +284 -108
  28. package/web/board/chat.ts +4 -4
  29. package/web/board/dom.ts +17 -5
  30. package/web/board/fixes.ts +6 -8
  31. package/web/board/git.ts +3 -3
  32. package/web/board/icebox.ts +52 -70
  33. package/web/board/keys.ts +9 -6
  34. package/web/board/live.ts +3 -2
  35. package/web/board/main.ts +1 -0
  36. package/web/board/panel.ts +22 -16
  37. package/web/board/projects.ts +11 -16
  38. package/web/board/settings.ts +60 -29
  39. package/web/board/store.ts +2 -0
  40. package/web/board/terms.ts +279 -0
  41. package/web/board/update.ts +7 -7
  42. package/web/board/views.ts +19 -5
  43. package/web/board.html +40 -2
  44. package/web/board.js +25 -24
  45. package/web/notebook.js +1 -1
  46. package/web/notes.html +2 -0
  47. package/web/term.js +1 -1
@@ -2,11 +2,11 @@ import { store } from "./store.ts";
2
2
  import { where } from "./agent-term.ts";
3
3
  import { load, post } from "./api.ts";
4
4
  import { mic, stopDictation } from "./dictation.ts";
5
- import { $, dataUrls, esc, ico } from "./dom.ts";
5
+ import { $, dataUrls, html, ico, modal, raw } from "./dom.ts";
6
6
  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
- import { open, toggleArchive } from "./panel.ts";
9
+ import { open, openAt, toggleArchive } from "./panel.ts";
10
10
  import { ideaCombo, list, newDir, plogo, short, showList, showable } from "./projects.ts";
11
11
  import { askNotify, toast } from "./toast.ts";
12
12
  import { answer, decided, resumable, resume } from "./approvals.ts";
@@ -25,6 +25,41 @@ let order = (() => {
25
25
  }
26
26
  })();
27
27
  let archive = false; // showing the archive instead of the board
28
+ // The Ideas column shows ideas or the runs' open backlog, never both; kept per browser.
29
+ let ideasTab = (() => {
30
+ try {
31
+ return localStorage.getItem("kz-ideas-tab") === "backlog" ? "backlog" : "ideas";
32
+ } catch (e) {
33
+ return "ideas";
34
+ }
35
+ })();
36
+ // On the Backlog tab: one severity, or null for every one.
37
+ let sev = null;
38
+ const SEVS = [
39
+ ["high", "High+", (s) => s === "critical" || s === "high"],
40
+ ["medium", "Medium", (s) => s === "medium"],
41
+ ["low", "Low", (s) => s === "low"],
42
+ ["none", "No severity", (s) => !s],
43
+ ];
44
+ // Open backlog items as cards for the Ideas column. A card's key names its line by
45
+ // source and number, which is what Start and Reject send back.
46
+ const backlogList = () =>
47
+ (store.S.backlog || []).map((b) => ({
48
+ ...b,
49
+ kind: "backlog",
50
+ status: b.starting ? "starting" : "idea",
51
+ title: b.text,
52
+ column: 0,
53
+ archived: false,
54
+ _k: "backlog:" + b.state + ":" + b.source + "#" + b.n,
55
+ }));
56
+ // A backlog card opens its source run on the Backlog tab, the item marked.
57
+ const show = (c) => {
58
+ if (c.kind !== "backlog") return open(c);
59
+ const r = store.S.cards.find((x) => x.kind === "run" && x.id === c.source && x.state === c.state);
60
+ if (!r) return toast(c.source === "orphan" ? "Kept from a run that was pruned" : "That run is not on this board");
61
+ openAt(r, "backlog", c.n);
62
+ };
28
63
  // Clear on Done: menu, then cherry-pick mode.
29
64
  let picking = false;
30
65
  const picked = new Set();
@@ -62,17 +97,17 @@ function card(c) {
62
97
  el.dataset.k = k;
63
98
  el.setAttribute("role", "button");
64
99
  const st = ICON[c.status] || "idea",
65
- idt = c.kind === "idea" ? "" : c.text.replace(/-/g, " ");
100
+ idt = c.kind === "run" ? c.text.replace(/-/g, " ") : "";
66
101
  // The status icon carries the state; what a waiting run needs is its tooltip.
67
102
  const sttl = c.status === "waiting" ? need(c.awaiting) || "needs you" : c.status || "idea";
68
103
  // Footer: words on the left (project, what it needs), icon-only marks on the right.
69
104
  const chips = [],
70
105
  marks = [],
71
106
  mk = (id, t, cls = "") =>
72
- `<span class="mk${cls}" role="img" title="${esc(t)}" aria-label="${esc(t)}"><svg><use href="#${id}"/></svg></span>`;
107
+ html`<span class="mk${cls}" role="img" title="${t}" aria-label="${t}"><svg><use href="#${id}"/></svg></span>`;
73
108
  if (store.all) {
74
109
  const n = c.where.split("/").filter(Boolean).pop() || c.where;
75
- chips.push(`<span class="chip proj" title="${esc(c.where)}">${esc(n)}</span>`);
110
+ chips.push(html`<span class="chip proj" title="${c.where}">${n}</span>`);
76
111
  }
77
112
  for (const t of c.tags || []) {
78
113
  if (MARKS[t])
@@ -84,8 +119,8 @@ function card(c) {
84
119
  ),
85
120
  );
86
121
  else if (t === "yolo")
87
- marks.push(`<span class="mk warn" title="Started in Yolo: the agent runs commands without asking">yolo</span>`);
88
- else chips.push(`<span class="chip">${esc(NAMES[t] || t)}</span>`);
122
+ marks.push(html`<span class="mk warn" title="Started in Yolo: the agent runs commands without asking">yolo</span>`);
123
+ else chips.push(html`<span class="chip">${NAMES[t] || t}</span>`);
89
124
  }
90
125
  if (c.pty && c.pty.alive && c.pty.asking)
91
126
  marks.push(mk("i-ask", "The agent is asking you something: open it, terminal button at the top", " warn ask"));
@@ -96,10 +131,19 @@ function card(c) {
96
131
  const g = c.notes && /^\[[^\]]*\] (?:Gathered from|Promoted from the icebox:) (.+?)\. Outside text/.exec(c.notes);
97
132
  // A card filed by a run names that run; the parent itself shows nothing.
98
133
  if (c.parent)
99
- 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>`);
100
- if (c.branch) chips.push(`<span class="chip" title="Built on its own branch, ${esc(c.branch)}">branch</span>`);
101
- if (g) chips.push(`<span class="chip" title="From ${esc(g[1])}">gathered</span>`);
102
- else if (c.notes) chips.push(`<span class="chip">notes</span>`);
134
+ chips.push(html`<span class="chip link" title="Filed by ${c.parent}">${c.parent.replace(/^\d{4}-\d\d-\d\d-/, "").replace(/-/g, " ")}</span>`);
135
+ if (c.kind === "backlog") {
136
+ if (c.severity)
137
+ chips.push(html`<span class="chip${c.severity === "critical" || c.severity === "high" ? " warn" : ""}"><i></i>${c.severity}</span>`);
138
+ chips.push(
139
+ c.source === "orphan"
140
+ ? html`<span class="chip" title="Kept from a pruned run">orphaned</span>`
141
+ : html`<span class="chip link" title="Left by ${c.source}${c.at ? " · " + c.at : ""}">${c.source.replace(/^\d{4}-\d\d-\d\d-/, "").replace(/-/g, " ")}</span>`,
142
+ );
143
+ }
144
+ if (c.branch) chips.push(html`<span class="chip" title="Built on its own branch, ${c.branch}">branch</span>`);
145
+ if (g) chips.push(html`<span class="chip" title="From ${g[1]}">gathered</span>`);
146
+ else if (c.notes) chips.push(html`<span class="chip">notes</span>`);
103
147
  // Information only: the panel's button is where it is committed and pushed.
104
148
  if (c.git)
105
149
  marks.push(
@@ -117,17 +161,17 @@ function card(c) {
117
161
  const act =
118
162
  c.kind === "run" && !archive && !pick
119
163
  ? c.status === "waiting"
120
- ? `<button class="btn sm${decided.has(answer(c)) ? "" : " pri"} act" data-a="open">${actionOf(c)}</button>`
164
+ ? html`<button class="btn sm${decided.has(answer(c)) ? "" : " pri"} act" data-a="open">${actionOf(c)}</button>`
121
165
  : c.status === "stalled" && resumable(c)
122
- ? `<button class="btn sm act" data-a="resume">Resume</button>`
166
+ ? html`<button class="btn sm act" data-a="resume">Resume</button>`
123
167
  : ""
124
168
  : "";
125
169
  el.innerHTML =
126
- (pick ? `<input type="checkbox" class="pick" ${picked.has(k) ? "checked" : ""} aria-label="Select">` : "") +
127
- `<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>
128
- <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>
170
+ (pick ? html`<input type="checkbox" class="pick" ${raw(picked.has(k) ? "checked" : "")} aria-label="Select">` : "") +
171
+ html`<div class="c-id"><span class="st d-${st}" role="img" title="${sttl}" aria-label="${sttl}"><svg><use href="#s-${st}"/></svg></span><span class="idt">${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="${String(k === store.menuKey)}">${ico("more")}</button></div>
172
+ <div class="c-title"${c.kind === "idea" && brief(c.text).parts.length ? html` title="${c.text}"` : ""}>${c.kind === "idea" ? brief(c.text).head : c.title || c.id}</div>
129
173
  ${act}
130
- ${chips.length || marks.length ? `<div class="c-foot"><span class="c-chips">${chips.join("")}</span><span class="c-marks">${marks.join("")}</span></div>` : ""}`;
174
+ ${chips.length || marks.length ? html`<div class="c-foot"><span class="c-chips">${chips}</span><span class="c-marks">${marks}</span></div>` : ""}`;
131
175
  const kids = kidsOf(c);
132
176
  if (kids.length) {
133
177
  const on = kidsOpen.has(k),
@@ -136,14 +180,13 @@ function card(c) {
136
180
  const box = document.createElement("div");
137
181
  box.className = "kids";
138
182
  box.innerHTML =
139
- `<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>` +
183
+ html`<button class="kids-t" aria-expanded="${String(on)}">${on ? "▾" : "▸"} ${kids.length} ${kids.length === 1 ? "child" : "children"}${nDone ? ` · ${nDone} done` : ""}${wait ? html`<span class="kids-w" title="A child is waiting on you"></span>` : ""}</button>` +
140
184
  (on
141
- ? `<ul>${kids
185
+ ? html`<ul>${kids
142
186
  .map((x, i) => {
143
187
  const s = ICON[x.status] || "idea";
144
- 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>`;
145
- })
146
- .join("")}</ul>`
188
+ return html`<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">${WORD[x.status] || x.status}</span><span class="kids-n" title="${x.title || x.text || x.id}">${x.kind === "idea" ? brief(x.text).head : (x.text || x.id || "").replace(/-/g, " ")}</span></li>`;
189
+ })}</ul>`
147
190
  : "");
148
191
  box.querySelector(".kids-t").onclick = (e) => {
149
192
  e.stopPropagation();
@@ -166,6 +209,13 @@ function card(c) {
166
209
  items.push(["restore", "Restore", ico("restore")]);
167
210
  items.push(["open", "Open", ico("open")]);
168
211
  if (c.kind === "idea") items.push(null, ["delete", "Delete", ico("trash"), "danger"]);
212
+ } else if (c.kind === "backlog") {
213
+ items.push(
214
+ ["run", c.status === "starting" ? "Run again" : "Run", ico("play")],
215
+ ["open", "Open its run", ico("open")],
216
+ null,
217
+ ["reject", "Reject", ico("x"), "danger"],
218
+ );
169
219
  } else if (c.kind === "idea" && c.status === "idea") {
170
220
  items.push(
171
221
  ["run", "Run", ico("play")],
@@ -187,11 +237,12 @@ function card(c) {
187
237
  } else {
188
238
  items.push(["open", actionOf(c), ico("open")]);
189
239
  items.push(["archive", "Archive", ico("archive")]);
240
+ if (c.column === 1 && c.status !== "abandoned") items.push(["toidea", "Back to idea", ico("restore")]);
190
241
  if (c.status === "waiting" || c.status === "running" || c.status === "stalled") {
191
242
  items.push(null, ["reject", "Abandon", ico("x"), "danger"]);
192
243
  }
193
244
  }
194
- el.innerHTML += `<div class="menu-pop" role="menu">${items.map((i) => (i ? `<button role="menuitem" data-a="${i[0]}" class="${i[3] || ""}">${i[2]}${i[1]}</button>` : "<hr>")).join("")}</div>`;
245
+ el.innerHTML += html`<div class="menu-pop" role="menu">${items.map((i) => (i ? html`<button role="menuitem" data-a="${i[0]}" class="${i[3] || ""}">${i[2]}${i[1]}</button>` : raw("<hr>")))}</div>`;
195
246
  }
196
247
  el.querySelectorAll("[data-a]").forEach(
197
248
  (bt) =>
@@ -206,7 +257,7 @@ function card(c) {
206
257
  return;
207
258
  }
208
259
  if (a === "open") {
209
- open(c);
260
+ show(c);
210
261
  return;
211
262
  }
212
263
  if (a === "resume") {
@@ -217,6 +268,12 @@ function card(c) {
217
268
  toggleArchive(c, a === "archive");
218
269
  return;
219
270
  }
271
+ if (a === "toidea") {
272
+ const r = await post("/to-idea", { dir: c.state, id: c.id });
273
+ if (r.ok) toast("Back in Ideas");
274
+ load();
275
+ return;
276
+ }
220
277
  store.mode = a;
221
278
  store.modeKey = k;
222
279
  render();
@@ -236,12 +293,12 @@ function card(c) {
236
293
  render();
237
294
  return;
238
295
  }
239
- open(c);
296
+ show(c);
240
297
  };
241
298
  el.onkeydown = (e) => {
242
299
  if (e.key === "Enter" || e.key === " ") {
243
300
  e.preventDefault();
244
- open(c);
301
+ show(c);
245
302
  }
246
303
  };
247
304
  return el;
@@ -252,7 +309,7 @@ function inlineForm(c) {
252
309
  li.className = "inl";
253
310
  const dir = c.state;
254
311
  if (store.mode === "edit") {
255
- li.innerHTML = `<input value="${esc(c.text)}"><textarea placeholder="Notes: links, paths, background">${esc(c.notes || "")}</textarea><div class="q dim" id="clong" hidden>The first line is over 72 characters; logs will cut it short.</div><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Save</button></div>`;
312
+ li.innerHTML = html`<input value="${c.text}"><textarea placeholder="Anything the planner should know">${c.notes || ""}</textarea><div class="q dim" id="clong" hidden>The first line is over 72 characters; logs will cut it short.</div><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Save</button></div>`;
256
313
  li.querySelector("[data-a=s]").onclick = async () => {
257
314
  const next = li.querySelector("input").value.trim(),
258
315
  notes = li.querySelector("textarea").value.trim();
@@ -263,8 +320,8 @@ function inlineForm(c) {
263
320
  };
264
321
  }
265
322
  if (store.mode === "reject") {
266
- const idea = c.kind === "idea";
267
- li.innerHTML = `<div class="q">${idea ? "Reject this idea?" : "Abandon this run?"} The reason is recorded.</div><input placeholder="Reason"><div class="row"><button class="btn ghost" data-a="c">Keep it</button><button class="btn warn" data-a="s">${idea ? "Reject" : "Abandon"}</button></div>`;
323
+ const idea = c.kind !== "run";
324
+ li.innerHTML = html`<div class="q">${c.kind === "backlog" ? "Not doing this backlog item?" : idea ? "Reject this idea?" : "Abandon this run?"} The reason is recorded.</div><input placeholder="Why not?"><div class="row"><button class="btn ghost" data-a="c">Keep it</button><button class="btn warn" data-a="s">${idea ? "Reject" : "Abandon"}</button></div>`;
268
325
  li.querySelector("[data-a=s]").onclick = async () => {
269
326
  const why = li.querySelector("input").value.trim();
270
327
  if (!why) {
@@ -272,15 +329,18 @@ function inlineForm(c) {
272
329
  return;
273
330
  }
274
331
  store.mode = null;
275
- const r = idea
276
- ? await post("/inbox", { op: "reject", dir, text: c.text, reason: why })
277
- : await post("/abort", { dir, id: c.id, why });
332
+ const r =
333
+ c.kind === "backlog"
334
+ ? await post("/backlog", { op: "reject", dir, source: c.source, n: c.n, reason: why })
335
+ : idea
336
+ ? await post("/inbox", { op: "reject", dir, text: c.text, reason: why })
337
+ : await post("/abort", { dir, id: c.id, why });
278
338
  if (r.ok) toast(idea ? "Rejected" : "Abandoned, reason recorded");
279
339
  load();
280
340
  };
281
341
  }
282
342
  if (store.mode === "delete") {
283
- li.innerHTML = `<div class="q">Delete "${esc(c.text)}"?</div><div class="q dim">Not recorded anywhere.</div><div class="row"><button class="btn ghost" data-a="c">Keep it</button><button class="btn danger" data-a="s">Delete</button></div>`;
343
+ li.innerHTML = html`<div class="q">Delete "${c.text}"?</div><div class="q dim">Not recorded anywhere.</div><div class="row"><button class="btn ghost" data-a="c">Keep it</button><button class="btn danger" data-a="s">Delete</button></div>`;
284
344
  li.querySelector("[data-a=s]").onclick = async () => {
285
345
  store.mode = null;
286
346
  const r = await post("/inbox", { op: "delete", dir, text: c.text });
@@ -290,7 +350,7 @@ function inlineForm(c) {
290
350
  }
291
351
  if (store.mode === "move") {
292
352
  const ps = store.S.projects.filter((p) => p.dir !== dir);
293
- 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>`;
353
+ li.innerHTML = html`<div class="q">Move "${c.text}" to</div><select class="sel" aria-label="Project">${ps.map((p) => html`<option value="${p.dir}" title="${p.label}">${short(p.label)}</option>`)}</select><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Move</button></div>`;
294
354
  li.querySelector("[data-a=s]").onclick = async () => {
295
355
  const sel = li.querySelector("select"),
296
356
  to = sel.value,
@@ -302,7 +362,7 @@ function inlineForm(c) {
302
362
  };
303
363
  }
304
364
  if (store.mode === "run") {
305
- li.innerHTML = `<div class="q">Run "${esc(c.text)}"</div><div class="q dim">Opens your agent with this request in a terminal.</div><div class="q dim">Full plans, builds, and checks with a separate reviewer. Lite does it in one session.</div><div id="run-pick" style="display:grid;gap:6px"><div id="run-agent" class="agents" role="radiogroup" aria-label="Agent"></div></div><div class="run-actions"><label class="yolo" id="run-yolo" title="Yolo: the agent runs every command and edit without asking" hidden><button type="button" class="chk" role="switch" aria-checked="false"></button>Yolo</label><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn" data-a="l">Lite</button><button class="btn pri" data-a="s">Full run</button></div></div>`;
365
+ li.innerHTML = html`<div class="q">Run "${c.text}"</div><div class="q dim">Opens your agent with this request in a terminal.</div><div class="q dim">Full plans, builds, and checks with a separate reviewer. Lite does it in one session.</div><div id="run-pick" style="display:grid;gap:6px"><div id="run-agent" class="agents" role="radiogroup" aria-label="Agent"></div></div><div class="run-actions"><label class="yolo" id="run-yolo" title="Yolo: the agent runs every command and edit without asking" hidden><button type="button" class="chk" role="switch" aria-checked="false"></button>Yolo</label><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn" data-a="l">Lite</button><button class="btn pri" data-a="s">Full run</button></div></div>`;
306
366
  li.querySelector("[data-a=s]").onclick = () => startRun(c, "full");
307
367
  li.querySelector("[data-a=l]").onclick = () => startRun(c, "lite");
308
368
  runForm(li, c);
@@ -354,7 +414,7 @@ async function runForm(li, c) {
354
414
  box.innerHTML = d.agents
355
415
  .map(
356
416
  (a) =>
357
- `<button type="button" class="agent" role="radio" aria-checked="${a.cmd === runPick.agent}" data-cmd="${esc(a.cmd)}"><svg><use href="#c-${esc(a.cmd)}"/></svg>${esc(a.name)}</button>`,
417
+ html`<button type="button" class="agent" role="radio" aria-checked="${String(a.cmd === runPick.agent)}" data-cmd="${a.cmd}"><svg><use href="#c-${a.cmd}"/></svg>${a.name}</button>`,
358
418
  )
359
419
  .join("");
360
420
  const yb = li.querySelector("#run-yolo");
@@ -394,20 +454,135 @@ async function startRun(c, kind) {
394
454
  const p = runPick && runPick.k === key(c) ? runPick : null;
395
455
  runPick = null;
396
456
  const yolo = !!(p && p.yolo && !$("run-yolo")?.hidden);
397
- const r = await post("/run", { dir: c.state, text: c.text, kind, agent: p ? p.agent : undefined, yolo });
457
+ const backlog = c.kind === "backlog" ? { source: c.source, n: c.n } : undefined;
458
+ const r = await post("/run", { dir: c.state, text: c.text, backlog, kind, agent: p ? p.agent : undefined, yolo });
398
459
  if (r.ok) {
399
- toast((kind === "full" ? "Full" : "Lite") + (yolo ? " yolo" : "") + " run " + where(r) + ": " + via(r.agent));
400
- if (r.pty) store.wantTerm = key(c);
460
+ toast((kind === "full" ? "Full" : kind === "lite" ? "Lite" : "Started") + (yolo ? " yolo" : "") + " run " + where(r) + ": " + via(r.agent));
461
+ // A backlog card has no terminal of its own; the run's card gets it once the run exists.
462
+ if (r.pty && !backlog) store.wantTerm = key(c);
401
463
  }
402
464
  load();
403
465
  }
404
466
 
405
467
  const HINT = { 1: "Ideas you run are planned here", 2: "Approved plans are built here", 3: "Finished builds are checked here" };
406
468
 
469
+ // The new-idea form, wired into f: inline at the top of Ideas, or in a modal.
470
+ function ideaForm(f, close) {
471
+ f.innerHTML = html`${store.all ? html`<div class="combo" id="ndc"><button type="button" class="sel" id="newdir" aria-label="Project for this idea" aria-haspopup="listbox" aria-expanded="false"></button><div class="menu-pop" hidden><input class="sel" aria-label="Search projects" placeholder="Search projects…" spellcheck="false" autocomplete="off"><div id="nds" role="listbox"></div></div></div>` : ""}<input id="newidea" placeholder="What do you want to build?"><textarea id="newnotes" placeholder="Anything the planner should know (optional)"></textarea><div class="att" id="newatt"></div><input type="file" id="newfile" multiple hidden><div class="row"><button type="button" class="ib" id="newpick" aria-label="Attach files" title="Attach files">${ico("clip")}</button><button type="button" class="btn ghost" id="nc">Cancel</button><div class="split"><button type="button" class="btn pri" id="ns">Add</button><button type="button" class="btn pri" id="nm" aria-label="More ways to add" aria-haspopup="menu" aria-expanded="false">${ico("chev")}</button><div class="menu-pop" role="menu" hidden><button type="button" role="menuitem" id="nr" title="Add the idea and run it now">${ico("play")}Start run</button></div></div></div>`;
472
+ const inp = f.querySelector("#newidea"),
473
+ files = [];
474
+ draft(inp, "f:new:0");
475
+ draft(f.querySelector("textarea"), "f:new:1");
476
+ // run: add the idea, then launch it at once with the project's runner and agent.
477
+ const save = async (run) => {
478
+ const text = inp.value.trim();
479
+ if (!text) return;
480
+ const notes = f.querySelector("textarea").value.trim();
481
+ close();
482
+ const dir = store.all ? newDir : null;
483
+ const r = await post("/inbox", dir ? { op: "add", dir, text, notes } : { op: "add", text, notes });
484
+ if (r.ok) {
485
+ toast("Idea added");
486
+ // The idea exists first: a failed upload leaves it as plain text.
487
+ if (files.length) {
488
+ const enc = await dataUrls(files);
489
+ const n = await post("/note", { dir, for: text, text: "", files: enc });
490
+ if (!n.ok) toast("Idea added, files not attached");
491
+ }
492
+ if (run) {
493
+ await load();
494
+ const c = store.S.cards.find((x) => x.kind === "idea" && x.text === text && (!dir || x.state === dir));
495
+ if (c) return startRun(c);
496
+ }
497
+ }
498
+ load();
499
+ };
500
+ const add = (x) => {
501
+ files.push(x);
502
+ f.querySelector("#newatt").textContent = files.map((y) => y.name).join(" · ");
503
+ };
504
+ f.querySelector("#newpick").onclick = () => f.querySelector("#newfile").click();
505
+ f.querySelector("#newfile").onchange = (e) => {
506
+ [...e.target.files].forEach(add);
507
+ e.target.value = "";
508
+ };
509
+ for (const el of [inp, f.querySelector("textarea")])
510
+ el.onpaste = (e) => {
511
+ for (const it of e.clipboardData.items)
512
+ if (it.kind === "file") {
513
+ const x = it.getAsFile();
514
+ add(new File([x], x.name === "image.png" ? `paste-${Date.now()}.png` : x.name, { type: x.type }));
515
+ e.preventDefault();
516
+ }
517
+ };
518
+ if (store.all) ideaCombo(f);
519
+ f.querySelector("#ns").onclick = () => save(false);
520
+ f.querySelector("#nr").onclick = () => save(true);
521
+ {
522
+ const mb = f.querySelector("#nm"),
523
+ menu = f.querySelector(".split .menu-pop");
524
+ // Focus moves into the menu only when it was opened from the keyboard.
525
+ const show = (on, kbd) => {
526
+ menu.hidden = !on;
527
+ mb.setAttribute("aria-expanded", String(on));
528
+ if (on && kbd) setTimeout(() => menu.querySelector("button").focus());
529
+ };
530
+ mb.onclick = (e) => {
531
+ e.stopPropagation();
532
+ show(menu.hidden, e.detail === 0);
533
+ };
534
+ menu.onkeydown = (e) => {
535
+ if (e.key === "Escape") {
536
+ e.preventDefault();
537
+ e.stopPropagation();
538
+ show(false);
539
+ mb.focus();
540
+ }
541
+ };
542
+ f.addEventListener("click", (e) => {
543
+ if (!menu.hidden && !e.target.closest(".split")) show(false);
544
+ });
545
+ const sp = f.querySelector(".split");
546
+ sp.addEventListener("focusout", (e) => {
547
+ if (!sp.contains(e.relatedTarget)) show(false);
548
+ });
549
+ }
550
+ f.querySelector("#nc").onclick = close;
551
+ {
552
+ const m = mic([inp, f.querySelector("textarea")]);
553
+ if (m) f.querySelector(".row").prepend(m);
554
+ }
555
+ inp.onkeydown = (e) => {
556
+ if (e.key === "Enter") {
557
+ e.preventDefault();
558
+ save(false);
559
+ }
560
+ if (e.key === "Escape") {
561
+ e.preventDefault();
562
+ close();
563
+ }
564
+ };
565
+ setTimeout(() => inp.focus());
566
+ }
567
+
568
+ // New idea from the top bar, the empty board or n: the same form, in a modal.
569
+ export function newIdea() {
570
+ if (store.mode === "new") {
571
+ store.mode = null;
572
+ render();
573
+ }
574
+ const dl = modal("");
575
+ const f = dl.querySelector("form");
576
+ f.onsubmit = (e) => e.preventDefault();
577
+ dl.addEventListener("close", () => stopDictation(false));
578
+ ideaForm(f, () => dl.close());
579
+ }
580
+
407
581
  export function render() {
408
582
  // A closed form leaves no draft behind; an open one is reopened after a reload.
409
583
  // A form whose card is gone (restored after a reload, or deleted elsewhere) has nowhere to show.
410
- if (store.mode && store.mode !== "new" && !store.S.cards.some((c) => key(c) === store.modeKey)) store.mode = null;
584
+ if (store.mode && store.mode !== "new" && ![...store.S.cards, ...backlogList()].some((c) => key(c) === store.modeKey))
585
+ store.mode = null;
411
586
  if (store.mode !== "run" && runPick) runPick.yolo = false;
412
587
  if (!store.mode) undraft("f:");
413
588
  try {
@@ -474,8 +649,8 @@ export function render() {
474
649
  const cur = store.all || !store.S.dir ? "All projects" : (store.PL.find((p) => p[1] === store.S.dir) || ["All projects"])[0];
475
650
  $("proj").innerHTML =
476
651
  store.all || !store.S.dir
477
- ? `<span class="plogo" aria-hidden="true">${ico("board")}</span><span>${esc(cur)}</span>`
478
- : `${plogo(store.S.dir, cur)}<span>${esc(short(cur))}</span>`;
652
+ ? html`<span class="plogo" aria-hidden="true">${ico("board")}</span><span>${cur}</span>`
653
+ : html`${plogo(store.S.dir, cur)}<span>${short(cur)}</span>`;
479
654
  $("proj").title = cur;
480
655
  if (!$("projpop").hidden) list();
481
656
  $("showc").hidden = !store.all;
@@ -488,24 +663,24 @@ export function render() {
488
663
  ? '<div class="empty"><div class="mascot"></div><b>Nothing archived</b></div>'
489
664
  : '<div class="empty"><div class="mascot"></div><b>Nothing here yet</b><p>Add an idea and kaizen will plan it, build it, and check the result.</p><button class="btn pri" id="newempty">New idea</button></div>';
490
665
  if (!archive)
491
- $("newempty").onclick = () => {
492
- store.mode = "new";
493
- render();
494
- };
666
+ $("newempty").onclick = newIdea;
495
667
  }
496
668
  COLS.forEach((name, ci) => {
497
669
  const s = document.createElement("section");
498
670
  const q = query.trim().toLowerCase();
499
- const list = cs.filter(
671
+ const bl = ci === 0 && !archive && ideasTab === "backlog";
672
+ const all = bl ? backlogList().filter((c) => !only || store.shown.has(c.state)) : cs;
673
+ const list = all.filter(
500
674
  (c) =>
501
675
  c.column === ci &&
676
+ (!bl || !sev || SEVS.find((x) => x[0] === sev)[2](c.severity)) &&
502
677
  (!filter || c.status === filter) &&
503
678
  (archive || !nested(c)) &&
504
679
  (!q || `${c.title || ""} ${c.text || ""} ${c.id || ""}`.toLowerCase().includes(q)),
505
680
  );
506
681
  // Runs age by their last move, ideas by when they were added, across every project;
507
682
  // an idea from before the stamp falls back to its inbox position.
508
- if (ci < 4) {
683
+ if (ci < 4 && !bl) {
509
684
  const at = (c) => (c.kind === "run" ? c.moved || 0 : c.added || 0),
510
685
  old = order[ci] === "old",
511
686
  pos = (c) => store.S.cards.indexOf(c);
@@ -515,13 +690,66 @@ export function render() {
515
690
  }
516
691
  const clearable = ci === 4 && !archive && list.length;
517
692
  const olderThan = (days) => list.filter((c) => store.S.now - (c.moved || store.S.now) > days * d);
518
- let head = `<h2>${name}<i>${list.length || ""}</i>${ci < 4 && list.length > 1 ? `<button class="btn sm ghost ord" title="Sort order">${order[ci] === "old" ? "Oldest" : "Newest"}</button>` : ""}${ci === 0 && !archive ? `<button class="ib sm" title="New idea" aria-label="New idea">${ico("plus")}</button>` : ""}`;
693
+ // The Ideas column is titled by its two tabs, icons with their counts: ideas, or the
694
+ // runs' leftovers. On Backlog a filter icon narrows it to one severity.
695
+ const tabbed = ci === 0 && !archive,
696
+ nb = tabbed ? backlogList().filter((c) => !only || store.shown.has(c.state)) : [];
697
+ const title = tabbed
698
+ ? `<span class="itabs" role="tablist" aria-label="Ideas column">${[
699
+ ["ideas", "Ideas", `<svg><use href="#s-idea"/></svg>`, cs.filter((c) => c.column === 0).length],
700
+ ["backlog", "Backlog: what finished runs left undone", ico("clock"), nb.length],
701
+ ]
702
+ .map(
703
+ ([v, l, svg, k]) =>
704
+ `<button role="tab" aria-selected="${String(ideasTab === v)}" data-t="${v}" title="${l}" aria-label="${l}, ${k}">${svg}<i>${k}</i></button>`,
705
+ )
706
+ .join("")}</span>`
707
+ : `${name}<i>${list.length || ""}</i>`;
708
+ const sevBtn = bl
709
+ ? `<button class="ib sm sevf${sev ? " on" : ""}" data-sv="1" title="${sev ? "Severity: " + SEVS.find((x) => x[0] === sev)[1] : "Filter by severity"}" aria-label="Filter by severity" aria-haspopup="menu" aria-expanded="${String(store.sevMenu)}">${ico("filter")}</button>` +
710
+ (store.sevMenu
711
+ ? `<div class="menu-pop sev-pop" role="menu"><button role="menuitemradio" aria-checked="${String(!sev)}" data-s="">All severities <i>${nb.length}</i></button><hr>${SEVS.map(
712
+ ([v, l, f]) =>
713
+ `<button role="menuitemradio" aria-checked="${String(sev === v)}" data-s="${v}">${l} <i>${nb.filter((c) => f(c.severity)).length}</i></button>`,
714
+ ).join("")}</div>`
715
+ : "")
716
+ : "";
717
+ let head = `<h2>${title}${ci < 4 && !bl && list.length > 1 ? `<button class="btn sm ghost ord" title="Sort order">${order[ci] === "old" ? "Oldest" : "Newest"}</button>` : ""}${sevBtn}${tabbed ? `<button class="ib sm newi" title="New idea" aria-label="New idea">${ico("plus")}</button>` : ""}`;
519
718
  if (clearable && picking)
520
719
  head += `<span class="pickbar"><button class="btn sm ghost" data-p="cancel">Cancel</button><button class="btn sm pri" data-p="go" ${picked.size ? "" : "disabled"}>Archive ${picked.size || ""}</button></span>`;
521
720
  else if (clearable) head += `<button class="btn sm ghost clear" title="Archive finished runs">Clear ▾</button>`;
522
721
  if (clearable && store.clearMenu)
523
722
  head += `<div class="menu-pop clear-pop"><button data-c="7">Older than a week <i>${olderThan(7).length}</i></button><button data-c="3">Older than 3 days <i>${olderThan(3).length}</i></button><button data-c="0">Everything <i>${list.length}</i></button><hr><button data-c="pick">Pick which…</button></div>`;
524
723
  s.innerHTML = head + `</h2>`;
724
+ if (tabbed) {
725
+ s.querySelectorAll("[data-t]").forEach(
726
+ (bt) =>
727
+ (bt.onclick = () => {
728
+ ideasTab = bt.dataset.t;
729
+ store.sevMenu = false;
730
+ try {
731
+ localStorage.setItem("kz-ideas-tab", ideasTab);
732
+ } catch (e) {}
733
+ render();
734
+ }),
735
+ );
736
+ const sv = s.querySelector("[data-sv]");
737
+ if (sv)
738
+ sv.onclick = (e) => {
739
+ e.stopPropagation();
740
+ store.sevMenu = !store.sevMenu;
741
+ render();
742
+ };
743
+ s.querySelectorAll("[data-s]").forEach(
744
+ (bt) =>
745
+ (bt.onclick = (e) => {
746
+ e.stopPropagation();
747
+ sev = bt.dataset.s || null;
748
+ store.sevMenu = false;
749
+ render();
750
+ }),
751
+ );
752
+ }
525
753
  {
526
754
  const o = s.querySelector(".ord");
527
755
  if (o)
@@ -534,9 +762,10 @@ export function render() {
534
762
  render();
535
763
  };
536
764
  }
537
- if (ci === 0 && !archive)
538
- s.querySelector(".ib").onclick = () => {
765
+ if (tabbed)
766
+ s.querySelector(".newi").onclick = () => {
539
767
  store.mode = "new";
768
+ ideasTab = "ideas";
540
769
  render();
541
770
  };
542
771
  if (clearable) {
@@ -593,66 +822,11 @@ export function render() {
593
822
  if (ci === 0 && store.mode === "new") {
594
823
  const f = document.createElement("li");
595
824
  f.className = "inl";
596
- f.innerHTML = `${store.all ? `<div class="combo" id="ndc"><button class="sel" id="newdir" aria-label="Project for this idea" aria-haspopup="listbox" aria-expanded="false"></button><div class="menu-pop" hidden><input class="sel" aria-label="Search projects" placeholder="Search projects…" spellcheck="false" autocomplete="off"><div id="nds" role="listbox"></div></div></div>` : ""}<input id="newidea" placeholder="What would you like done?"><textarea id="newnotes" placeholder="Notes: links, paths, background (optional)"></textarea><div class="att" id="newatt"></div><input type="file" id="newfile" multiple hidden><div class="row"><button class="ib" id="newpick" aria-label="Attach files" title="Attach files">${ico("clip")}</button><button class="btn ghost" id="nc">Cancel</button><button class="btn pri" id="ns">Add</button></div>`;
597
825
  ul.appendChild(f);
598
- const inp = f.querySelector("#newidea"),
599
- files = [];
600
- draft(inp, "f:new:0");
601
- draft(f.querySelector("textarea"), "f:new:1");
602
- const save = async () => {
603
- const text = inp.value.trim();
604
- if (!text) return;
605
- const notes = f.querySelector("textarea").value.trim();
606
- store.mode = null;
607
- const dir = store.all ? newDir : null;
608
- const r = await post("/inbox", dir ? { op: "add", dir, text, notes } : { op: "add", text, notes });
609
- if (r.ok) {
610
- toast("Idea added");
611
- // The idea exists first: a failed upload leaves it as plain text.
612
- if (files.length) {
613
- const enc = await dataUrls(files);
614
- const n = await post("/note", { dir, for: text, text: "", files: enc });
615
- if (!n.ok) toast("Idea added, files not attached");
616
- }
617
- }
618
- load();
619
- };
620
- const add = (x) => {
621
- files.push(x);
622
- f.querySelector("#newatt").textContent = files.map((y) => y.name).join(" · ");
623
- };
624
- f.querySelector("#newpick").onclick = () => f.querySelector("#newfile").click();
625
- f.querySelector("#newfile").onchange = (e) => {
626
- [...e.target.files].forEach(add);
627
- e.target.value = "";
628
- };
629
- for (const el of [inp, f.querySelector("textarea")])
630
- el.onpaste = (e) => {
631
- for (const it of e.clipboardData.items)
632
- if (it.kind === "file") {
633
- const x = it.getAsFile();
634
- add(new File([x], x.name === "image.png" ? `paste-${Date.now()}.png` : x.name, { type: x.type }));
635
- e.preventDefault();
636
- }
637
- };
638
- if (store.all) ideaCombo(f);
639
- f.querySelector("#ns").onclick = save;
640
- f.querySelector("#nc").onclick = () => {
826
+ ideaForm(f, () => {
641
827
  store.mode = null;
642
828
  render();
643
- };
644
- {
645
- const m = mic([inp, f.querySelector("textarea")]);
646
- if (m) f.querySelector(".row").prepend(m);
647
- }
648
- inp.onkeydown = (e) => {
649
- if (e.key === "Enter") save();
650
- if (e.key === "Escape") {
651
- store.mode = null;
652
- render();
653
- }
654
- };
655
- setTimeout(() => inp.focus());
829
+ });
656
830
  }
657
831
  const shown = unfolded.has(ci) || list.length <= FOLD + 1 ? list : list.slice(0, FOLD);
658
832
  shown.forEach((c) => {
@@ -684,6 +858,8 @@ export function render() {
684
858
  // An empty middle column says what lands in it, so it does not look broken.
685
859
  if (!list.length && ci >= 1 && ci <= 3 && cs.length && !filter && !q && !archive)
686
860
  ul.innerHTML = `<li class="hint">${HINT[ci]}</li>`;
861
+ if (bl && !list.length && !filter && !q)
862
+ ul.innerHTML = `<li class="hint">${sev ? "Nothing at this severity" : "What finished runs left undone shows here"}</li>`;
687
863
  s.appendChild(ul);
688
864
  b.appendChild(s);
689
865
  });