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.
- package/agents/kaizen-reviewer.md +1 -1
- package/cli/backlog.test.ts +133 -0
- package/cli/board.test.ts +10 -4
- package/cli/browser.test.ts +1 -1
- package/cli/browser.ts +12 -7
- package/cli/html.test.ts +19 -0
- package/cli/notes.ts +2 -6
- package/cli/pty.ts +2 -1
- package/cli/settings.test.ts +38 -0
- package/cli/settings.ts +37 -30
- package/cli/state.test.ts +86 -3
- package/cli/state.ts +245 -45
- package/cli/web.ts +67 -16
- package/package.json +1 -1
- package/skills/kaizen/README.md +0 -4
- package/skills/kaizen/SKILL.md +4 -1
- package/skills/kaizen/config.default.yml +1 -50
- package/skills/kaizen/docs.md +3 -2
- package/skills/kaizen/settings.md +10 -3
- package/skills/kaizen/spec-build.md +3 -2
- package/skills/kaizen/spec-main.md +40 -5
- package/skills/kaizen/spec-review.md +2 -1
- package/skills/kaizen-help/SKILL.md +0 -4
- package/web/board/agent-term.ts +1 -1
- package/web/board/api.ts +2 -0
- package/web/board/approvals.ts +3 -3
- package/web/board/cards.ts +284 -108
- package/web/board/chat.ts +4 -4
- package/web/board/dom.ts +17 -5
- package/web/board/fixes.ts +6 -8
- package/web/board/git.ts +3 -3
- package/web/board/icebox.ts +52 -70
- package/web/board/keys.ts +9 -6
- package/web/board/live.ts +3 -2
- package/web/board/main.ts +1 -0
- package/web/board/panel.ts +22 -16
- package/web/board/projects.ts +11 -16
- package/web/board/settings.ts +60 -29
- package/web/board/store.ts +2 -0
- package/web/board/terms.ts +279 -0
- package/web/board/update.ts +7 -7
- package/web/board/views.ts +19 -5
- package/web/board.html +40 -2
- package/web/board.js +25 -24
- package/web/notebook.js +1 -1
- package/web/notes.html +2 -0
- package/web/term.js +1 -1
package/web/board/cards.ts
CHANGED
|
@@ -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,
|
|
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 === "
|
|
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="${
|
|
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="${
|
|
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">${
|
|
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 ${
|
|
100
|
-
if (c.
|
|
101
|
-
|
|
102
|
-
|
|
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="${
|
|
128
|
-
<div class="c-title"${c.kind === "idea" && brief(c.text).parts.length ? ` title="${
|
|
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
|
|
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">${
|
|
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>"))
|
|
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
|
-
|
|
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
|
-
|
|
296
|
+
show(c);
|
|
240
297
|
};
|
|
241
298
|
el.onkeydown = (e) => {
|
|
242
299
|
if (e.key === "Enter" || e.key === " ") {
|
|
243
300
|
e.preventDefault();
|
|
244
|
-
|
|
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="${
|
|
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
|
|
267
|
-
li.innerHTML = `<div class="q">${idea ? "Reject this idea?" : "Abandon this run?"} The reason is recorded.</div><input placeholder="
|
|
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 =
|
|
276
|
-
|
|
277
|
-
|
|
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 "${
|
|
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 "${
|
|
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 "${
|
|
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="${
|
|
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
|
|
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
|
-
|
|
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))
|
|
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>${
|
|
478
|
-
: `${plogo(store.S.dir, 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
|
|
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
|
-
|
|
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 (
|
|
538
|
-
s.querySelector(".
|
|
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
|
-
|
|
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
|
});
|