kaizen-agent 1.5.4 → 1.5.6
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/README.md +4 -3
- package/agents/kaizen-builder.md +2 -0
- package/agents/kaizen-planner.md +9 -2
- package/cli/dashboard.ts +14 -4
- package/cli/install.ts +1 -0
- package/cli/settings.ts +37 -0
- package/cli/state.test.ts +68 -2
- package/cli/state.ts +112 -15
- package/cli/web.ts +144 -10
- package/package.json +1 -1
- package/skills/kaizen/SKILL.md +4 -1
- package/skills/kaizen/spec-build.md +6 -0
- package/skills/kaizen/spec-plan.md +17 -3
- package/skills/kaizen/spec.md +12 -0
- package/web/board.html +57 -21
package/README.md
CHANGED
|
@@ -295,10 +295,11 @@ started here opens your agent in a new terminal exactly as `r` does, and the boa
|
|
|
295
295
|
redraws as your agents write to disk. Mouse first; the TUI's keys still work. Click a card for its plan, review and
|
|
296
296
|
backlog; `⋯` on a card runs, edits, rejects, abandons or archives. `Clear` on Done sends
|
|
297
297
|
every finished run to the archive (`.kaizen/archive.md`, a list, nothing moves), and
|
|
298
|
-
`Archive` in the strip shows what is there, with `Restore` on each card.
|
|
299
|
-
|
|
298
|
+
`Archive` in the strip shows what is there, with `Restore` on each card. The gear at the top right holds the browser's theme
|
|
299
|
+
and every `kaizen settings` knob, same rows, same `config.yml`. `--port N` picks a port, `--no-open` just
|
|
300
300
|
serves, `?all=1` in the URL opens on every project. `--daemon` starts the server
|
|
301
|
-
detached so it outlives the terminal (pid kept in `~/.kaizen/web.pid`); `--stop` ends it.
|
|
301
|
+
detached so it outlives the terminal (pid kept in `~/.kaizen/web.pid`); `--stop` ends it. `kaizen web --shortcut` adds a launcher
|
|
302
|
+
the OS can find — Spotlight on macOS, the app menu on Linux, a Desktop shortcut on Windows.
|
|
302
303
|
|
|
303
304
|
### Mouse
|
|
304
305
|
|
package/agents/kaizen-builder.md
CHANGED
|
@@ -67,6 +67,8 @@ change — do not refactor around it. Write what you changed to
|
|
|
67
67
|
- **Baseline / after** — the checks run before and after, with both results verbatim.
|
|
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
|
+
- **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. The board shows it.
|
|
70
72
|
|
|
71
73
|
## Report back
|
|
72
74
|
|
package/agents/kaizen-planner.md
CHANGED
|
@@ -14,8 +14,9 @@ execute without you present, so anything you leave implicit will be guessed at.
|
|
|
14
14
|
You will be given a run directory path. Your plan carries a **File manifest**: every
|
|
15
15
|
path this run will read or change, flat. The builder and the reviewer read that list
|
|
16
16
|
instead of searching the material a second and third time, so a path you leave out is
|
|
17
|
-
work done twice. Read `00-request.md` from it,
|
|
18
|
-
|
|
17
|
+
work done twice. Read `00-request.md` from it, `notes.md`
|
|
18
|
+
beside it if it exists (context the user attached from the board; treat it as part of
|
|
19
|
+
the request), and `.kaizen/memory.md` if it exists, before touching the codebase.
|
|
19
20
|
|
|
20
21
|
**You do not write code.** No source edits, no installs, no migrations, no
|
|
21
22
|
`git` state changes. The only file you write is `01-plan.md` in the run directory.
|
|
@@ -139,6 +140,12 @@ absorb.
|
|
|
139
140
|
Assumptions that do not change the shape of the plan go in *Open questions* and are
|
|
140
141
|
resolved at the approval, alongside everything else. Do not stop the run for them.
|
|
141
142
|
|
|
143
|
+
**Except when building from nothing.** A new app or product has no material to
|
|
144
|
+
answer platform, users and scale, stack, distribution, access, data, and hosting — and
|
|
145
|
+
a wrong answer there is a rebuild, not a finding. Decide each one in the plan: from
|
|
146
|
+
the request, by proposal with a reason, or as an *Open question* with options where
|
|
147
|
+
the builds differ. Ask them together, once, before any step is written.
|
|
148
|
+
|
|
142
149
|
## Method
|
|
143
150
|
|
|
144
151
|
1. Read the request verbatim. Note what is asked and what is merely implied.
|
package/cli/dashboard.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
searchRoots, knownProjects, remember, findProjects, locate,
|
|
9
9
|
readRuns, backlog, version, label, tilde, section, readFindings, parseItem, tidy, readBacklog, allBacklog,
|
|
10
10
|
startedRuns, normalise, statusOf, columnOf, readInbox, writeInbox, notify, short,
|
|
11
|
-
abandonRun, replaceIdea, boardCards, launchRun,
|
|
11
|
+
abandonRun, replaceIdea, boardCards, launchRun, appendNote, parseNotes,
|
|
12
12
|
} from "./state.ts";
|
|
13
13
|
const rgb = (r: number, g: number, b: number, s: string) =>
|
|
14
14
|
`\x1b[38;2;${r};${g};${b}m${s}\x1b[0m`;
|
|
@@ -540,6 +540,8 @@ export async function dashboard(
|
|
|
540
540
|
lines.push(`${marker} ${dot} ${painted}`);
|
|
541
541
|
}
|
|
542
542
|
if (allProjects && k.where !== "global") lines.push(" " + c.dim(cut(basename(k.where), inner - 4)));
|
|
543
|
+
// First note line only; the rest is for the web board's detail panel.
|
|
544
|
+
if (k.notes) { const n = parseNotes(k.notes); const l = n[n.length - 1]?.text.split("\n")[0]; if (l) lines.push(" " + c.dim(cut(l, inner - 4))); }
|
|
543
545
|
lines.push("");
|
|
544
546
|
}
|
|
545
547
|
return { meta, lines, starts };
|
|
@@ -587,8 +589,8 @@ export async function dashboard(
|
|
|
587
589
|
// space rather than by middots, which made one long undifferentiated line.
|
|
588
590
|
const footer = (k?: Card) => {
|
|
589
591
|
const acts = k?.kind === "idea"
|
|
590
|
-
? "n new e edit x reject d delete r run"
|
|
591
|
-
: k ? "n new idea x abandon this run" : "n new idea";
|
|
592
|
+
? "n new e edit t note x reject d delete r run"
|
|
593
|
+
: k ? "n new idea t note x abandon this run" : "n new idea";
|
|
592
594
|
const scope = allProjects ? "a this project" : "a all projects";
|
|
593
595
|
return ` ${c.dim("↑↓←→ move")} ${c.dim(acts)} ${c.dim(scope)} ${c.dim("q back")}\n`;
|
|
594
596
|
};
|
|
@@ -704,6 +706,11 @@ export async function dashboard(
|
|
|
704
706
|
// abandoning one is the user's decision to make, and /kaizen abort is the
|
|
705
707
|
// same single transition this writes.
|
|
706
708
|
if (target.kind === "run") {
|
|
709
|
+
if (key === "t" && target.id && target.column < 4) {
|
|
710
|
+
const line = await promptLine("Add a note", "");
|
|
711
|
+
if (line) appendNote(target.state, { id: target.id }, line);
|
|
712
|
+
refresh();
|
|
713
|
+
}
|
|
707
714
|
if (key === "x" && target.id) {
|
|
708
715
|
const why = await promptLine(`Abandon ${target.text}? Reason`, "");
|
|
709
716
|
if (why) {
|
|
@@ -717,6 +724,9 @@ export async function dashboard(
|
|
|
717
724
|
if (key === "e") {
|
|
718
725
|
const text = await promptLine("Edit idea", target.text);
|
|
719
726
|
if (text) replaceIdea(target.state, target.text, { status: "open", text });
|
|
727
|
+
} else if (key === "t" && target.status === "idea") {
|
|
728
|
+
const line = await promptLine("Add a note", "");
|
|
729
|
+
if (line) appendNote(target.state, { text: target.text }, line);
|
|
720
730
|
} else if (key === "x") {
|
|
721
731
|
const why = await promptLine(`Reject: ${target.text}`, "");
|
|
722
732
|
// The spec requires a reason on a rejected item; no reason, no rejection.
|
|
@@ -729,7 +739,7 @@ export async function dashboard(
|
|
|
729
739
|
// the prompt the agent is launched with.
|
|
730
740
|
if (mode !== null) {
|
|
731
741
|
const request = `${mode} ${target.text}`.trim();
|
|
732
|
-
await outside(() => startBacklogItem(parseItem(request), target.state));
|
|
742
|
+
await outside(() => startBacklogItem({ ...parseItem(request), notes: target.notes }, target.state));
|
|
733
743
|
// Without this the idea stays open forever and the board shows the
|
|
734
744
|
// same work twice: once as an idea, once as the run it became.
|
|
735
745
|
replaceIdea(target.state, target.text, { status: "started", text: target.text });
|
package/cli/install.ts
CHANGED
|
@@ -210,6 +210,7 @@ if (wantSettings) {
|
|
|
210
210
|
}
|
|
211
211
|
|
|
212
212
|
if (wantWeb) {
|
|
213
|
+
if (args.has("--shortcut")) { const { shortcut } = await import("./web.ts"); console.log(`\n kaizen web shortcut ${await shortcut()}\n`); process.exit(0); }
|
|
213
214
|
const at = Bun.argv.indexOf("--port");
|
|
214
215
|
const port = at !== -1 ? Number(Bun.argv[at + 1]) || undefined : undefined;
|
|
215
216
|
const url = `http://127.0.0.1:${port ?? 7420}/`;
|
package/cli/settings.ts
CHANGED
|
@@ -113,6 +113,43 @@ const SETTINGS: Setting[] = [
|
|
|
113
113
|
{ key: "agent.default", values: ["auto", "claude", "codex", "agy", "opencode", "gemini"], help: "Default coding agent to launch from dashboard backlog" },
|
|
114
114
|
];
|
|
115
115
|
|
|
116
|
+
// The same rows and the same write the TUI uses, for `kaizen web`'s settings panel:
|
|
117
|
+
// one place decides what a setting is called, what it may be, and how it is saved.
|
|
118
|
+
export function listSettings(repoRoot: string) {
|
|
119
|
+
const file = locate();
|
|
120
|
+
const defaults = readFileSync(join(repoRoot, "skills/kaizen/config.default.yml"), "utf8");
|
|
121
|
+
const text = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
122
|
+
const preset = detectPreset(text, defaults);
|
|
123
|
+
return {
|
|
124
|
+
file: tilde(file),
|
|
125
|
+
rows: SETTINGS.map((s) => ({
|
|
126
|
+
...s,
|
|
127
|
+
value: s.key === "preset" ? preset : read(text, s.key) ?? read(defaults, s.key) ?? s.values[0]!,
|
|
128
|
+
})),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function setSetting(repoRoot: string, key: string, value: string) {
|
|
133
|
+
const s = SETTINGS.find((x) => x.key === key);
|
|
134
|
+
if (!s || !s.values.includes(value)) return { error: "unknown setting or value" };
|
|
135
|
+
const file = locate();
|
|
136
|
+
const defaultsPath = join(repoRoot, "skills/kaizen/config.default.yml");
|
|
137
|
+
if (!existsSync(file)) { mkdirSync(dirname(file), { recursive: true }); copyFileSync(defaultsPath, file); }
|
|
138
|
+
const defaults = readFileSync(defaultsPath, "utf8");
|
|
139
|
+
const text = readFileSync(file, "utf8");
|
|
140
|
+
let updated: string;
|
|
141
|
+
if (key === "preset") {
|
|
142
|
+
if (value === "custom") return { error: "custom is what any other change makes" };
|
|
143
|
+
updated = applyPreset(text, value as PresetName, defaults);
|
|
144
|
+
} else {
|
|
145
|
+
updated = write(text, key, value, defaults);
|
|
146
|
+
updated = write(updated, "preset", detectPreset(updated, defaults), defaults);
|
|
147
|
+
if (read(updated, key) !== value) return { error: `${key} did not save; edit ${tilde(file)} by hand` };
|
|
148
|
+
}
|
|
149
|
+
writeFileSync(file, updated);
|
|
150
|
+
return { ok: true };
|
|
151
|
+
}
|
|
152
|
+
|
|
116
153
|
const c = {
|
|
117
154
|
dim: (s: string) => `\x1b[2m${s}\x1b[0m`,
|
|
118
155
|
bold: (s: string) => `\x1b[1m${s}\x1b[0m`,
|
package/cli/state.test.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { tmpdir } from "node:os";
|
|
|
7
7
|
import {
|
|
8
8
|
readRuns, readInbox, writeInbox, replaceIdea, abandonRun, allBacklog, backlog,
|
|
9
9
|
statusOf, columnOf, parseItem, startedRuns, boardCards, readArchive, setArchived,
|
|
10
|
+
requestOf, readNotes, writeNotes, manualCommand, parseNotes, formatNote, appendNote, saveAttachment,
|
|
10
11
|
} from "./state.ts";
|
|
11
12
|
|
|
12
13
|
let state: string;
|
|
@@ -56,7 +57,11 @@ test("statusOf: waiting beats stage, mtime decides running vs stalled", () => {
|
|
|
56
57
|
});
|
|
57
58
|
|
|
58
59
|
test("columnOf: every stage word lands somewhere", () => {
|
|
59
|
-
expect(["plan", "build", "review", "done", "abandoned", "weird"].map(columnOf)).toEqual([1, 2, 3, 4, 4, 1]);
|
|
60
|
+
expect(["plan", "build", "review", "done", "abandoned", "weird"].map((s) => columnOf(s))).toEqual([1, 2, 3, 4, 4, 1]);
|
|
61
|
+
expect(columnOf("implement", "approvals.review")).toBe(3);
|
|
62
|
+
expect(columnOf("implement", "findings")).toBe(3);
|
|
63
|
+
expect(columnOf("implement", "approvals.each_file")).toBe(2);
|
|
64
|
+
expect(columnOf("plan", "approvals.plan")).toBe(1);
|
|
60
65
|
});
|
|
61
66
|
|
|
62
67
|
test("inbox round-trips and ignores malformed lines", () => {
|
|
@@ -116,7 +121,68 @@ test("archive: a run is listed, hidden by flag, never moved", () => {
|
|
|
116
121
|
});
|
|
117
122
|
|
|
118
123
|
test("archive: an idea keeps its line with status archived", () => {
|
|
119
|
-
writeInbox(state, [{ status: "open", text: "park this" }, { status: "archived", text: "parked zq7" }]);
|
|
124
|
+
writeInbox(state, [{ status: "open", text: "park this", notes: "kept" }, { status: "archived", text: "parked zq7" }]);
|
|
120
125
|
const ideas = boardCards([state], Date.now()).filter((k) => k.kind === "idea");
|
|
121
126
|
expect(ideas.map((k) => [k.text, k.archived])).toEqual([["park this", false], ["parked zq7", true]]);
|
|
122
127
|
});
|
|
128
|
+
|
|
129
|
+
test("notes: indented lines under a bullet round-trip; a legacy file is untouched", () => {
|
|
130
|
+
const legacy = "# Inbox\n\nIdeas with no run yet. One line each, same format as a run's backlog.\n\n- open: a\n- rejected: b | why\n";
|
|
131
|
+
writeFileSync(join(state, "inbox.md"), legacy);
|
|
132
|
+
writeInbox(state, readInbox(state));
|
|
133
|
+
expect(readFileSync(join(state, "inbox.md"), "utf8")).toBe(legacy);
|
|
134
|
+
writeInbox(state, [{ status: "open", text: "with notes", notes: "see https://x.y/z\ncli/state.ts:12 | not a reason" }, { status: "open", text: "plain zq8" }]);
|
|
135
|
+
const [a, b] = readInbox(state);
|
|
136
|
+
expect(a).toEqual({ status: "open", text: "with notes", notes: "see https://x.y/z\ncli/state.ts:12 | not a reason" });
|
|
137
|
+
expect(b).toEqual({ status: "open", text: "plain zq8" });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("notes: survive reject and started; edit can replace or clear them", () => {
|
|
141
|
+
replaceIdea(state, "with notes", { status: "started", text: "with notes" });
|
|
142
|
+
expect(readInbox(state)[0]!.notes).toContain("https://x.y/z");
|
|
143
|
+
replaceIdea(state, "with notes", { status: "rejected", text: "with notes | no" });
|
|
144
|
+
expect(readInbox(state)[0]!.notes).toContain("https://x.y/z");
|
|
145
|
+
replaceIdea(state, "plain zq8", { status: "open", text: "plain zq8", notes: "" });
|
|
146
|
+
expect(readInbox(state)[1]!.notes).toBeUndefined();
|
|
147
|
+
expect(boardCards([state], Date.now()).find((k) => k.text === "plain zq8")!.notes).toBeUndefined();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("notes: the prompt is text, blank line, notes; still one quoted argument", () => {
|
|
151
|
+
expect(requestOf("/kaizen do x")).toBe("/kaizen do x");
|
|
152
|
+
expect(requestOf("/kaizen do x", "l1\nl2\n")).toBe("/kaizen do x\n\nl1\nl2");
|
|
153
|
+
const cmd = manualCommand("/p", "claude", requestOf("/kaizen do x", "l1"));
|
|
154
|
+
if (process.platform !== "win32") expect(cmd).toBe('cd "/p" && claude "/kaizen do x\n\nl1"');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("notes: a run's notes.md is read beside it, written only for a real run", () => {
|
|
158
|
+
expect(writeNotes(state, "2026-09-09-nope", "x")).toBe("no such run");
|
|
159
|
+
expect(writeNotes(state, "2026-09-01-waiting", " ref: docs/a.md ")).toBeNull();
|
|
160
|
+
expect(readNotes(state, "2026-09-01-waiting")).toBe("ref: docs/a.md\n");
|
|
161
|
+
expect(boardCards([state], Date.now()).find((k) => k.id === "2026-09-01-waiting")!.notes).toBe("ref: docs/a.md\n");
|
|
162
|
+
writeNotes(state, "2026-09-01-waiting", "");
|
|
163
|
+
expect(readNotes(state, "2026-09-01-waiting")).toBe("");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("notes log: stamped entries, continuation lines, unstamped preamble", () => {
|
|
167
|
+
const src = "hand written\n[2026-09-14 10:00] first\n more\n[2026-09-14 10:05] \n";
|
|
168
|
+
expect(parseNotes(src)).toEqual([
|
|
169
|
+
{ at: "", text: "hand written" },
|
|
170
|
+
{ at: "2026-09-14 10:00", text: "first\nmore" },
|
|
171
|
+
{ at: "2026-09-14 10:05", text: "" },
|
|
172
|
+
]);
|
|
173
|
+
expect(formatNote("a\nb", "2026-09-14 10:00")).toBe("[2026-09-14 10:00] a\n b");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("notes log: append to a run and to an idea; attachments saved and made absolute in the prompt", () => {
|
|
177
|
+
expect(appendNote(state, { id: "2026-09-01-waiting" }, "run note")).toBeNull();
|
|
178
|
+
expect(parseNotes(readNotes(state, "2026-09-01-waiting")).map((n) => n.text)).toEqual(["run note"]);
|
|
179
|
+
writeInbox(state, [{ status: "open", text: "chatty zq6" }]);
|
|
180
|
+
expect(appendNote(state, { text: "chatty zq6" }, "one")).toBeNull();
|
|
181
|
+
expect(appendNote(state, { text: "chatty zq6" }, "two\nlines")).toBeNull();
|
|
182
|
+
expect(appendNote(state, { text: "nope zq6" }, "x")).toBe("no such idea");
|
|
183
|
+
expect(parseNotes(readInbox(state)[0]!.notes!).map((n) => n.text)).toEqual(["one", "two\nlines"]);
|
|
184
|
+
const rel = saveAttachment(state, null, "../evil name.png", new Uint8Array([1]));
|
|
185
|
+
expect(rel).toMatch(/^attachments\/\w+-_evil_name\.png$/);
|
|
186
|
+
expect(requestOf("x", ``, state)).toBe(`x\n\n})`);
|
|
187
|
+
expect(saveAttachment(state, "2026-09-01-waiting", "b.txt", new Uint8Array([2]))).toMatch(/^runs\/2026-09-01-waiting\/attachments\//);
|
|
188
|
+
});
|
package/cli/state.ts
CHANGED
|
@@ -249,7 +249,7 @@ export function readFindings(file: string) {
|
|
|
249
249
|
// Backlog items are often a reviewer's finding pasted verbatim -- a backticked
|
|
250
250
|
// path, a severity, then the sentence. Split those apart so a list can show the
|
|
251
251
|
// severity and the place as columns and leave the prose to speak for itself.
|
|
252
|
-
export type Item = { severity: string | null; where: string | null; text: string; raw: string };
|
|
252
|
+
export type Item = { severity: string | null; where: string | null; text: string; raw: string; notes?: string };
|
|
253
253
|
|
|
254
254
|
export function parseItem(raw: string): Item {
|
|
255
255
|
const m = /^`?([^`:]+(?::\d+)?)`?\s*[:—-]\s*(critical|high|medium|low)\s*[:—-]\s*(.+)$/i.exec(raw);
|
|
@@ -309,6 +309,7 @@ export type Card = {
|
|
|
309
309
|
title?: string; // runs only: first line of the request
|
|
310
310
|
archived: boolean; // hidden from the board unless asked for
|
|
311
311
|
tags?: string[]; // runs only: runner and agent, when recorded
|
|
312
|
+
notes?: string; // inbox notes, or runs/<id>/notes.md
|
|
312
313
|
};
|
|
313
314
|
|
|
314
315
|
// A run is called running on the evidence that it moved recently; there is no PID
|
|
@@ -347,26 +348,37 @@ export function statusOf(r: Run, now: number): Status {
|
|
|
347
348
|
return now - r.moved < RUNNING_WITHIN_MS ? "running" : "stalled";
|
|
348
349
|
}
|
|
349
350
|
|
|
350
|
-
// A stage this build has never heard of still belongs somewhere visible
|
|
351
|
-
//
|
|
352
|
-
|
|
351
|
+
// A stage this build has never heard of still belongs somewhere visible. What the
|
|
352
|
+
// run is waiting on says where it really is (a run awaiting the review approval is
|
|
353
|
+
// in Review whatever its stage word), and PLANNING is the honest guess otherwise:
|
|
354
|
+
// the run has started and has not finished.
|
|
355
|
+
export function columnOf(stage: string, awaiting: string | null = null) {
|
|
353
356
|
if (stage === "build") return 2;
|
|
354
357
|
if (stage === "review") return 3;
|
|
355
358
|
if (stage === "done" || stage === "abandoned") return 4;
|
|
359
|
+
if (awaiting === "approvals.review" || awaiting === "findings") return 3;
|
|
360
|
+
if (awaiting === "approvals.each_file") return 2;
|
|
356
361
|
return 1;
|
|
357
362
|
}
|
|
358
363
|
|
|
359
|
-
|
|
364
|
+
// notes: free text under the bullet -- links, path:line refs, background. Kept as
|
|
365
|
+
// indented continuation lines so a one-line inbox from before notes reads unchanged.
|
|
366
|
+
export type InboxLine = { status: string; text: string; notes?: string };
|
|
360
367
|
|
|
361
368
|
// Ideas with no run yet. Deliberately not backlog.md, which the spec defines as
|
|
362
369
|
// an orphanage for items rescued from pruned runs, and not a run stub each.
|
|
363
370
|
export function readInbox(state: string): InboxLine[] {
|
|
364
371
|
const f = join(state, "inbox.md");
|
|
365
372
|
if (!existsSync(f)) return [];
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
373
|
+
const out: InboxLine[] = [];
|
|
374
|
+
for (const raw of readFileSync(f, "utf8").split("\n")) {
|
|
375
|
+
const m = /^-\s*(open|started|done|rejected|archived):\s*(.+)$/.exec(raw.trim());
|
|
376
|
+
if (m) { out.push({ status: m[1]!, text: m[2]!.trim() }); continue; }
|
|
377
|
+
// An indented line belongs to the bullet above it; anything else is noise.
|
|
378
|
+
const last = out[out.length - 1];
|
|
379
|
+
if (last && /^\s{2,}\S/.test(raw)) last.notes = (last.notes ? last.notes + "\n" : "") + raw.trim();
|
|
380
|
+
}
|
|
381
|
+
return out;
|
|
370
382
|
}
|
|
371
383
|
|
|
372
384
|
// Whole-file rewrite. The board is the only writer today.
|
|
@@ -379,11 +391,93 @@ export function writeInbox(state: string, lines: InboxLine[]) {
|
|
|
379
391
|
"",
|
|
380
392
|
"Ideas with no run yet. One line each, same format as a run's backlog.",
|
|
381
393
|
"",
|
|
382
|
-
...lines.
|
|
394
|
+
...lines.flatMap((l) => [`- ${l.status}: ${l.text}`, ...noteLines(l.notes)]),
|
|
383
395
|
];
|
|
384
396
|
writeFileSync(join(state, "inbox.md"), body.join("\n") + "\n");
|
|
385
397
|
}
|
|
386
398
|
|
|
399
|
+
function noteLines(notes?: string) {
|
|
400
|
+
return (notes ?? "").split("\n").map((n) => n.trim()).filter(Boolean).map((n) => " " + n);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// The request the agent is launched with: the text, then the notes as the rest of the
|
|
404
|
+
// request, so they land verbatim in 00-request.md and the planner reads them.
|
|
405
|
+
export function requestOf(text: string, notes?: string, state?: string) {
|
|
406
|
+
let n = (notes ?? "").trim();
|
|
407
|
+
if (state) n = n.replace(/\]\((attachments\/[^)]+)\)/g, (_, p) => `](${join(state, p)})`);
|
|
408
|
+
return n ? `${text}\n\n${n}` : text;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Free text beside a run -- what the user learned after the idea became a run.
|
|
412
|
+
// Its own file, never state.json: that shape is the resume contract.
|
|
413
|
+
export function readNotes(state: string, id: string) {
|
|
414
|
+
try { return readFileSync(join(state, "runs", id, "notes.md"), "utf8"); } catch { return ""; }
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export function writeNotes(state: string, id: string, text: string): string | null {
|
|
418
|
+
const dir = join(state, "runs", id);
|
|
419
|
+
if (!existsSync(join(dir, "state.json"))) return "no such run";
|
|
420
|
+
writeFileSync(join(dir, "notes.md"), text.trim() ? text.trim() + "\n" : "");
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Notes are a log, one entry per message: `[2026-09-14 15:34] text`, continuation
|
|
425
|
+
// lines indented. Text before the first stamp is one unstamped entry, so notes
|
|
426
|
+
// written by hand still read.
|
|
427
|
+
export type Note = { at: string; text: string };
|
|
428
|
+
|
|
429
|
+
export function parseNotes(src: string): Note[] {
|
|
430
|
+
const out: Note[] = [];
|
|
431
|
+
for (const raw of src.split("\n")) {
|
|
432
|
+
const m = /^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2})\]\s?(.*)$/.exec(raw);
|
|
433
|
+
if (m) { out.push({ at: m[1]!, text: m[2]! }); continue; }
|
|
434
|
+
const last = out[out.length - 1];
|
|
435
|
+
if (last) last.text += "\n" + raw.replace(/^\s{2}/, "");
|
|
436
|
+
else if (raw.trim()) out.push({ at: "", text: raw });
|
|
437
|
+
}
|
|
438
|
+
return out.map((n) => ({ ...n, text: n.text.trim() })).filter((n) => n.text);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export function stamp(now = new Date()) {
|
|
442
|
+
const p = (n: number) => String(n).padStart(2, "0");
|
|
443
|
+
return `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())} ${p(now.getHours())}:${p(now.getMinutes())}`;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export function formatNote(text: string, at = stamp()) {
|
|
447
|
+
return `[${at}] ${text.trim().replace(/\n/g, "\n ")}`;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Append one entry: to runs/<id>/notes.md for a run, or under the idea's inbox
|
|
451
|
+
// bullet. Attachments arrive as markdown links already written into text.
|
|
452
|
+
export function appendNote(state: string, target: { id: string } | { text: string }, text: string): string | null {
|
|
453
|
+
if (!text.trim()) return "empty note";
|
|
454
|
+
if ("id" in target) {
|
|
455
|
+
if (!existsSync(join(state, "runs", target.id, "state.json"))) return "no such run";
|
|
456
|
+
appendFileSync(join(state, "runs", target.id, "notes.md"), formatNote(text) + "\n");
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
459
|
+
const lines = readInbox(state);
|
|
460
|
+
const at = lines.findIndex((l) => l.status === "open" && l.text === target.text);
|
|
461
|
+
if (at < 0) return "no such idea";
|
|
462
|
+
const prev = lines[at]!.notes;
|
|
463
|
+
lines[at] = { ...lines[at]!, notes: (prev ? prev + "\n" : "") + formatNote(text) };
|
|
464
|
+
writeInbox(state, lines);
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// A file beside the notes it belongs to: runs/<id>/attachments/ for a run,
|
|
469
|
+
// <state>/attachments/ for an idea. Returns the path relative to the state dir,
|
|
470
|
+
// which is what the note links and what /file serves.
|
|
471
|
+
// ponytail: no dedupe, no size accounting beyond the request cap in web.ts.
|
|
472
|
+
export function saveAttachment(state: string, id: string | null, name: string, bytes: Uint8Array) {
|
|
473
|
+
const safe = name.replace(/[^\w.-]+/g, "_").replace(/^\.+/, "") || "file";
|
|
474
|
+
const rel = id ? join("runs", id, "attachments") : "attachments";
|
|
475
|
+
mkdirSync(join(state, rel), { recursive: true });
|
|
476
|
+
const file = `${Date.now().toString(36)}-${safe}`;
|
|
477
|
+
writeFileSync(join(state, rel, file), bytes);
|
|
478
|
+
return join(rel, file);
|
|
479
|
+
}
|
|
480
|
+
|
|
387
481
|
// Best effort: a machine without notify-send loses the notification, not the board.
|
|
388
482
|
export function notify(title: string, body: string) {
|
|
389
483
|
try {
|
|
@@ -423,7 +517,7 @@ export function replaceIdea(state: string, text: string, next: InboxLine | null)
|
|
|
423
517
|
const lines = readInbox(state);
|
|
424
518
|
const at = lines.findIndex((l) => (l.status === "open" || l.status === "started") && l.text === text);
|
|
425
519
|
if (at < 0) return; // changed underneath us; the redraw will show why
|
|
426
|
-
if (next) lines[at] = next; else lines.splice(at, 1);
|
|
520
|
+
if (next) lines[at] = { notes: lines[at]!.notes, ...next }; else lines.splice(at, 1);
|
|
427
521
|
writeInbox(state, lines);
|
|
428
522
|
}
|
|
429
523
|
|
|
@@ -493,17 +587,18 @@ export function boardCards(states: string[], now: number): Card[] {
|
|
|
493
587
|
// the common way a run begins.
|
|
494
588
|
if (requests.some((r) => r.includes(normalise(it.text)))) continue;
|
|
495
589
|
next.push(it.status === "started"
|
|
496
|
-
? { kind: "idea", status: "starting", text: it.text, state: st, where, column: 1, awaiting: null, dim: true, archived: false }
|
|
497
|
-
: { kind: "idea", status: "idea", text: it.text, state: st, where, column: 0, awaiting: null, dim: false, archived: it.status === "archived" });
|
|
590
|
+
? { kind: "idea", status: "starting", text: it.text, state: st, where, column: 1, awaiting: null, dim: true, archived: false, notes: it.notes }
|
|
591
|
+
: { kind: "idea", status: "idea", text: it.text, state: st, where, column: 0, awaiting: null, dim: false, archived: it.status === "archived", notes: it.notes });
|
|
498
592
|
}
|
|
499
593
|
for (const r of readRuns(st)) {
|
|
500
594
|
next.push({
|
|
501
|
-
kind: "run", id: r.id, status: statusOf(r, now), text: short(r.id), state: st, where, column: columnOf(r.stage), moved: r.moved,
|
|
595
|
+
kind: "run", id: r.id, status: statusOf(r, now), text: short(r.id), state: st, where, column: columnOf(r.stage, r.awaiting), moved: r.moved,
|
|
502
596
|
title: requestTitle(body(st, r.id)),
|
|
503
597
|
awaiting: r.stage === "abandoned" ? null : r.awaiting,
|
|
504
598
|
dim: r.stage === "abandoned",
|
|
505
599
|
archived: archive.has(r.id),
|
|
506
600
|
tags: [r.runner, r.agent].filter((t): t is string => !!t),
|
|
601
|
+
notes: readNotes(st, r.id) || undefined,
|
|
507
602
|
});
|
|
508
603
|
}
|
|
509
604
|
}
|
|
@@ -530,9 +625,11 @@ export function manualCommand(projectDir: string, agentCmd: string, prompt: stri
|
|
|
530
625
|
|
|
531
626
|
export function launchRun(it: Item, from: string): Launch {
|
|
532
627
|
const projectDir = projectOf(from);
|
|
628
|
+
// The run will be born here; the board must know the dir or the idea never retires.
|
|
629
|
+
remember(projectDir);
|
|
533
630
|
const agent = detectDefaultAgent(projectDir);
|
|
534
631
|
const agentBin = Bun.which(agent.cmd) ?? agent.cmd;
|
|
535
|
-
const prompt = it.where ? `/kaizen ${it.text} (${it.where})` : `/kaizen ${it.text}
|
|
632
|
+
const prompt = requestOf(it.where ? `/kaizen ${it.text} (${it.where})` : `/kaizen ${it.text}`, it.notes, from);
|
|
536
633
|
const fullCmd = [agentBin, prompt];
|
|
537
634
|
const manual = manualCommand(projectDir, agent.cmd, prompt);
|
|
538
635
|
|
package/cli/web.ts
CHANGED
|
@@ -4,7 +4,8 @@ import { existsSync, readFileSync, readdirSync, statSync, watch, type FSWatcher
|
|
|
4
4
|
import { join, dirname } from "node:path";
|
|
5
5
|
import {
|
|
6
6
|
home, type Item, boardCards, knownProjects, remember, locate, label, tilde,
|
|
7
|
-
readInbox, writeInbox, replaceIdea, abandonRun, launchRun, parseItem, short, setArchived,
|
|
7
|
+
readInbox, writeInbox, replaceIdea, abandonRun, launchRun, parseItem, short, setArchived, writeNotes,
|
|
8
|
+
appendNote, saveAttachment,
|
|
8
9
|
} from "./state.ts";
|
|
9
10
|
|
|
10
11
|
const PAGE = join(dirname(import.meta.dir), "web", "board.html");
|
|
@@ -47,6 +48,9 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
47
48
|
for (const c of clients) { try { c.enqueue("data: changed\n\n"); } catch { clients.delete(c); } }
|
|
48
49
|
}, 200);
|
|
49
50
|
};
|
|
51
|
+
// Dev: the page reloads itself when board.html is saved. Server code needs
|
|
52
|
+
// `bun --watch cli/install.ts web`; the page reconnects and reloads on hello.
|
|
53
|
+
try { watch(PAGE, () => { for (const c of clients) { try { c.enqueue("data: reload\n\n"); } catch { clients.delete(c); } } }); } catch { /* no dev reload */ }
|
|
50
54
|
const watchers: FSWatcher[] = [];
|
|
51
55
|
const watchAll = () => {
|
|
52
56
|
for (const w of watchers) { try { w.close(); } catch { /* gone */ } }
|
|
@@ -69,11 +73,26 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
69
73
|
return sum;
|
|
70
74
|
};
|
|
71
75
|
let last = fingerprint();
|
|
72
|
-
|
|
76
|
+
let seen = states(true).join("\n");
|
|
77
|
+
const poll = setInterval(() => {
|
|
78
|
+
// A project registered since start (a run launched into a fresh dir) gets a watcher too.
|
|
79
|
+
const now = states(true).join("\n"); if (now !== seen) { seen = now; watchAll(); }
|
|
80
|
+
const fp = fingerprint(); if (fp !== last) { last = fp; changed(); }
|
|
81
|
+
}, 2000);
|
|
73
82
|
|
|
74
|
-
const
|
|
83
|
+
const BOOT = Date.now().toString(36);
|
|
84
|
+
const ping = setInterval(() => { for (const c of clients) { try { c.enqueue(": ping\n\n"); } catch { clients.delete(c); } } }, 8000);
|
|
85
|
+
// A launcher clicked while a board is already up should show that board, not die.
|
|
86
|
+
const port = opts.port ?? DEFAULT_PORT;
|
|
87
|
+
const server = (() => { try { return serve(); } catch (e) {
|
|
88
|
+
if ((e as { code?: string }).code !== "EADDRINUSE") throw e;
|
|
89
|
+
return null;
|
|
90
|
+
} })();
|
|
91
|
+
if (!server) { const url = `http://127.0.0.1:${port}/`; console.log(`\n kaizen web already at ${url}\n`); if (opts.open !== false) await openApp(url); return; }
|
|
92
|
+
|
|
93
|
+
function serve() { return Bun.serve({
|
|
75
94
|
hostname: "127.0.0.1",
|
|
76
|
-
port
|
|
95
|
+
port,
|
|
77
96
|
async fetch(req) {
|
|
78
97
|
// Host first: a hostname rebound to 127.0.0.1 by an attacker's DNS still
|
|
79
98
|
// arrives with that hostname in Host, and gets nothing.
|
|
@@ -111,13 +130,43 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
111
130
|
id, short: short(id), state: read("state.json"),
|
|
112
131
|
request: read("00-request.md"), plan: read("01-plan.md"), approval: read("02-approval.md"),
|
|
113
132
|
impl: read("03-impl.md"), review: read("04-review.md"), backlog: read("06-backlog.md"),
|
|
133
|
+
notes: read("notes.md"),
|
|
114
134
|
});
|
|
115
135
|
}
|
|
116
136
|
|
|
137
|
+
if (req.method === "GET" && path === "/settings") {
|
|
138
|
+
const { listSettings } = await import("./settings.ts");
|
|
139
|
+
return json(listSettings(repoDir));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (req.method === "POST" && path === "/settings") {
|
|
143
|
+
if (!local(req)) return bad("cross-origin write refused", 403);
|
|
144
|
+
let body: any;
|
|
145
|
+
try { body = await req.json(); } catch { return bad("json body expected"); }
|
|
146
|
+
const { setSetting } = await import("./settings.ts");
|
|
147
|
+
const r = setSetting(repoDir, String(body.key ?? ""), String(body.value ?? ""));
|
|
148
|
+
return "error" in r ? bad(r.error) : json(r);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Attachments only, never a run's own files: the path must sit under an
|
|
152
|
+
// attachments/ directory of a known state dir, with no way back up.
|
|
153
|
+
if (req.method === "GET" && path === "/file") {
|
|
154
|
+
const dir = url.searchParams.get("dir") ?? state;
|
|
155
|
+
const p = url.searchParams.get("p") ?? "";
|
|
156
|
+
if (!dir || !states(true).includes(dir)) return bad("unknown state dir", 404);
|
|
157
|
+
if (!/^(runs\/[\w.-]+\/)?attachments\/[\w.-]+$/.test(p)) return bad("bad path");
|
|
158
|
+
const f = join(dir, p);
|
|
159
|
+
if (!existsSync(f)) return bad("no such file", 404);
|
|
160
|
+
return new Response(Bun.file(f), { headers: { "cache-control": "private, max-age=3600" } });
|
|
161
|
+
}
|
|
162
|
+
|
|
117
163
|
if (req.method === "GET" && path === "/events") {
|
|
118
164
|
let ctrl: ReadableStreamDefaultController;
|
|
119
165
|
const stream = new ReadableStream({
|
|
120
|
-
|
|
166
|
+
// The boot id lets the page tell a server restart from a dropped
|
|
167
|
+
// connection; only the former means new code. The ping keeps Bun's
|
|
168
|
+
// idle timeout from closing the stream every ten seconds.
|
|
169
|
+
start(c) { ctrl = c; clients.add(c); c.enqueue(`data: hello ${BOOT}\n\n`); },
|
|
121
170
|
cancel() { clients.delete(ctrl); },
|
|
122
171
|
});
|
|
123
172
|
return new Response(stream, {
|
|
@@ -135,13 +184,16 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
135
184
|
|
|
136
185
|
if (path === "/inbox") {
|
|
137
186
|
const text = String(body.text ?? "").trim();
|
|
187
|
+
// Absent means untouched; an empty string clears. replaceIdea keeps the
|
|
188
|
+
// old notes for any op that does not send them.
|
|
189
|
+
const notes = typeof body.notes === "string" ? body.notes.trim() : undefined;
|
|
138
190
|
if (body.op === "add") {
|
|
139
191
|
if (!text) return bad("empty idea");
|
|
140
|
-
writeInbox(dir, [...readInbox(dir), { status: "open", text }]);
|
|
192
|
+
writeInbox(dir, [...readInbox(dir), { status: "open", text, notes }]);
|
|
141
193
|
} else if (body.op === "edit") {
|
|
142
194
|
const next = String(body.next ?? "").trim();
|
|
143
195
|
if (!text || !next) return bad("empty idea");
|
|
144
|
-
replaceIdea(dir, text, { status: "open", text: next });
|
|
196
|
+
replaceIdea(dir, text, notes === undefined ? { status: "open", text: next } : { status: "open", text: next, notes });
|
|
145
197
|
} else if (body.op === "reject") {
|
|
146
198
|
const why = String(body.reason ?? "").trim();
|
|
147
199
|
if (!why) return bad("a rejection needs a reason");
|
|
@@ -158,6 +210,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
158
210
|
const kind = body.kind === "full" || body.kind === "lite" ? body.kind : "";
|
|
159
211
|
if (!text) return bad("empty idea");
|
|
160
212
|
const it: Item = parseItem(`${kind} ${text}`.trim());
|
|
213
|
+
it.notes = readInbox(dir).find((l) => l.status === "open" && l.text === text)?.notes;
|
|
161
214
|
const r = launchRun(it, dir);
|
|
162
215
|
if (r.ok) replaceIdea(dir, text, { status: "started", text });
|
|
163
216
|
changed();
|
|
@@ -178,13 +231,42 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
178
231
|
const lines = readInbox(dir);
|
|
179
232
|
const at = lines.findIndex((l) => l.text === text && (archived ? l.status === "open" : l.status === "archived"));
|
|
180
233
|
if (at < 0) return bad("no such idea", 404);
|
|
181
|
-
lines[at] = { status: archived ? "archived" : "open"
|
|
234
|
+
lines[at] = { ...lines[at]!, status: archived ? "archived" : "open" };
|
|
182
235
|
writeInbox(dir, lines);
|
|
183
236
|
} else return bad("id or text required");
|
|
184
237
|
changed();
|
|
185
238
|
return json({ ok: true });
|
|
186
239
|
}
|
|
187
240
|
|
|
241
|
+
// One chat message: text plus files (base64), each file saved and linked
|
|
242
|
+
// from the message so the note carries its own attachments.
|
|
243
|
+
if (path === "/note") {
|
|
244
|
+
const id = typeof body.id === "string" ? body.id : null;
|
|
245
|
+
if (id !== null && !/^[\w.-]+$/.test(id)) return bad("bad run id");
|
|
246
|
+
const text = typeof body.text === "string" ? body.text.trim() : "";
|
|
247
|
+
const files: { name: string; data: string }[] = Array.isArray(body.files) ? body.files : [];
|
|
248
|
+
let total = 0;
|
|
249
|
+
for (const f of files) total += (f.data?.length ?? 0);
|
|
250
|
+
if (total > 20_000_000) return bad("attachments over 15MB");
|
|
251
|
+
const links = files.map((f) => {
|
|
252
|
+
const bytes = Buffer.from(String(f.data).replace(/^data:[^,]*,/, ""), "base64");
|
|
253
|
+
const rel = saveAttachment(dir, id, String(f.name ?? "file"), bytes).replace(/\\/g, "/");
|
|
254
|
+
return (/\.(png|jpe?g|gif|webp|svg)$/i.test(rel) ? "!" : "") + `[${String(f.name ?? "file")}](${rel.replace(/^runs\/[^/]+\//, "")})`;
|
|
255
|
+
});
|
|
256
|
+
const note = [text, ...links].filter(Boolean).join("\n");
|
|
257
|
+
const failed = appendNote(dir, id !== null ? { id } : { text: String(body.for ?? "").trim() }, note);
|
|
258
|
+
changed();
|
|
259
|
+
return failed ? bad(failed, 404) : json({ ok: true });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (path === "/notes") {
|
|
263
|
+
const id = String(body.id ?? "");
|
|
264
|
+
if (!/^[\w.-]+$/.test(id)) return bad("bad run id");
|
|
265
|
+
const failed = writeNotes(dir, id, String(body.text ?? ""));
|
|
266
|
+
changed();
|
|
267
|
+
return failed ? bad(failed, 404) : json({ ok: true });
|
|
268
|
+
}
|
|
269
|
+
|
|
188
270
|
if (path === "/abort") {
|
|
189
271
|
const id = String(body.id ?? "");
|
|
190
272
|
const why = String(body.why ?? "").trim() || "abandoned from the web board";
|
|
@@ -197,7 +279,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
197
279
|
|
|
198
280
|
return new Response("not found", { status: 404 });
|
|
199
281
|
},
|
|
200
|
-
});
|
|
282
|
+
}); }
|
|
201
283
|
|
|
202
284
|
const url = `http://127.0.0.1:${server.port}/`;
|
|
203
285
|
console.log(`\n kaizen web ${url}`);
|
|
@@ -206,7 +288,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
206
288
|
if (opts.open !== false) await openApp(url);
|
|
207
289
|
|
|
208
290
|
await new Promise<void>((resolve) => {
|
|
209
|
-
const stop = () => { clearInterval(poll); for (const w of watchers) { try { w.close(); } catch { /* gone */ } } server.stop(true); resolve(); };
|
|
291
|
+
const stop = () => { clearInterval(poll); clearInterval(ping); for (const w of watchers) { try { w.close(); } catch { /* gone */ } } server.stop(true); resolve(); };
|
|
210
292
|
process.on("SIGINT", stop);
|
|
211
293
|
process.on("SIGTERM", stop);
|
|
212
294
|
});
|
|
@@ -252,3 +334,55 @@ function winChrome(exe: string) {
|
|
|
252
334
|
for (const r of roots) { const p = join(r, sub[exe] ?? exe); if (existsSync(p)) return p; }
|
|
253
335
|
return null;
|
|
254
336
|
}
|
|
337
|
+
|
|
338
|
+
// `kaizen web --shortcut`: a launcher the OS can find — Spotlight on macOS, the app
|
|
339
|
+
// menu / super-space on Linux, the Desktop on Windows. Each one runs `kaizen web` with
|
|
340
|
+
// the bun and script that ran this command, so it works for a git checkout and a
|
|
341
|
+
// bunx install alike.
|
|
342
|
+
export async function shortcut() {
|
|
343
|
+
const bun = process.execPath;
|
|
344
|
+
const script = Bun.argv[1]!;
|
|
345
|
+
const icon = join(dirname(import.meta.dir), "assets", "kaizen-logo.png");
|
|
346
|
+
const hasIcon = existsSync(icon);
|
|
347
|
+
const { mkdirSync, writeFileSync, chmodSync } = await import("node:fs");
|
|
348
|
+
|
|
349
|
+
if (process.platform === "darwin") {
|
|
350
|
+
const app = join(home, "Applications", "Kaizen.app", "Contents");
|
|
351
|
+
mkdirSync(join(app, "MacOS"), { recursive: true });
|
|
352
|
+
writeFileSync(join(app, "MacOS", "kaizen"), `#!/bin/sh\nexec ${q(bun)} ${q(script)} web --daemon\n`);
|
|
353
|
+
chmodSync(join(app, "MacOS", "kaizen"), 0o755);
|
|
354
|
+
writeFileSync(join(app, "Info.plist"), `<?xml version="1.0" encoding="UTF-8"?>
|
|
355
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
356
|
+
<plist version="1.0"><dict>
|
|
357
|
+
<key>CFBundleName</key><string>Kaizen</string>
|
|
358
|
+
<key>CFBundleIdentifier</key><string>dev.kaizen.web</string>
|
|
359
|
+
<key>CFBundleExecutable</key><string>kaizen</string>
|
|
360
|
+
<key>CFBundlePackageType</key><string>APPL</string>
|
|
361
|
+
<key>LSUIElement</key><true/>
|
|
362
|
+
</dict></plist>
|
|
363
|
+
`);
|
|
364
|
+
return `${tilde(join(home, "Applications", "Kaizen.app"))} — Spotlight finds it once indexed`;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (process.platform === "win32") {
|
|
368
|
+
const lnk = join(home, "Desktop", "Kaizen.lnk");
|
|
369
|
+
const ps = `$s=(New-Object -ComObject WScript.Shell).CreateShortcut('${lnk}');$s.TargetPath='${bun}';$s.Arguments='"${script}" web --daemon';$s.WorkingDirectory='${home}';${hasIcon ? `$s.IconLocation='${icon}';` : ""}$s.Save()`;
|
|
370
|
+
await Bun.$`powershell -NoProfile -Command ${ps}`.quiet();
|
|
371
|
+
return lnk;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const dir = join(process.env["XDG_DATA_HOME"] ?? join(home, ".local", "share"), "applications");
|
|
375
|
+
mkdirSync(dir, { recursive: true });
|
|
376
|
+
const file = join(dir, "kaizen.desktop");
|
|
377
|
+
writeFileSync(file, `[Desktop Entry]
|
|
378
|
+
Type=Application
|
|
379
|
+
Name=Kaizen
|
|
380
|
+
Comment=Kaizen board
|
|
381
|
+
Exec=${q(bun)} ${q(script)} web --daemon
|
|
382
|
+
${hasIcon ? `Icon=${icon}\n` : ""}Terminal=false
|
|
383
|
+
Categories=Development;
|
|
384
|
+
`);
|
|
385
|
+
return tilde(file);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const q = (s: string) => (/[\s"']/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s);
|
package/package.json
CHANGED
package/skills/kaizen/SKILL.md
CHANGED
|
@@ -61,7 +61,9 @@ If the user typed `/kaizen` with no argument and a run is in progress, treat it
|
|
|
61
61
|
`~/.kaizen/` keyed by working directory. If neither exists and the user is starting
|
|
62
62
|
work, run `/kaizen init` first: it creates `.kaizen/` with a copy of the `spec*.md`
|
|
63
63
|
files and — where the folder is a git repository — the gitignore entry
|
|
64
|
-
(see [`adapters.md`](adapters.md)).
|
|
64
|
+
(see [`adapters.md`](adapters.md)). Also append the project folder's absolute path
|
|
65
|
+
to `~/.kaizen/projects` if not already listed: the board only retires an idea when
|
|
66
|
+
it can see the run, and it only looks in projects it knows.
|
|
65
67
|
2. Read the config in this order, each file filling in the keys the one before it
|
|
66
68
|
does not set: `.kaizen/config.yml`, then `~/.kaizen/config.yml`, then
|
|
67
69
|
`config.default.yml` in this skill. A project usually has no `config.yml` of its
|
|
@@ -359,6 +361,7 @@ shown automatically.
|
|
|
359
361
|
01-fix.md
|
|
360
362
|
01-recheck.md
|
|
361
363
|
06-backlog.md # what this run deliberately did not do
|
|
364
|
+
notes.md # context attached from the board; the planner reads it
|
|
362
365
|
state.json
|
|
363
366
|
```
|
|
364
367
|
|
|
@@ -65,6 +65,12 @@ deviations from the plan and why, verification carried out with its actual resul
|
|
|
65
65
|
and anything deliberately left undone. Report failures honestly; a false claim of
|
|
66
66
|
success poisons the review stage, which trusts this report.
|
|
67
67
|
|
|
68
|
+
**A `## Preview` section says how to see the result.** For an app or service: the
|
|
69
|
+
commands to start it locally and the URL to open, copied from the material's own README
|
|
70
|
+
where one exists. For anything else — a document, copy, a runbook, a memo — the path
|
|
71
|
+
to open, or the result itself when it fits in a screen. The board shows this section on
|
|
72
|
+
the run's card, so a person can try the work without reading the rest of the report.
|
|
73
|
+
|
|
68
74
|
**The gate block closes the stage.** `03-impl.md` ends with one line per gate item
|
|
69
75
|
from the plan's Verification section:
|
|
70
76
|
|
|
@@ -6,9 +6,11 @@ planner's and the plan approval's contract. The builder and reviewer do not read
|
|
|
6
6
|
---
|
|
7
7
|
## Stage 1 — Plan
|
|
8
8
|
|
|
9
|
-
**Agent:** planner. **Input:** `00-request.md` (including the track), `
|
|
10
|
-
the
|
|
11
|
-
|
|
9
|
+
**Agent:** planner. **Input:** `00-request.md` (including the track), `notes.md` in
|
|
10
|
+
the run directory when present — links, paths, and background the user attached from
|
|
11
|
+
the board, binding like the request itself — `memory.md`, and the existing material —
|
|
12
|
+
the codebase, the manuscript, the current campaign, whatever this run builds on.
|
|
13
|
+
**Output:** `01-plan.md`.
|
|
12
14
|
|
|
13
15
|
The request is usually one sentence. The planner's first job is to expand it — but by
|
|
14
16
|
proposing, not by interrogating. It restates the problem, brings the nearest good
|
|
@@ -23,6 +25,18 @@ different work behind each, nothing available to break the tie — and even then
|
|
|
23
25
|
its recommendation with the question. Questions the existing material can answer are
|
|
24
26
|
never asked; that is the planner's own work.
|
|
25
27
|
|
|
28
|
+
**Building from nothing is the exception: settle the foundation first.** When the
|
|
29
|
+
track is a new product or app, there is no material to answer the questions that
|
|
30
|
+
decide everything after them, and "wrong" for this track is a wrong foundation — the
|
|
31
|
+
one thing a review cannot fix, because fixing it is a rebuild. Before the steps are
|
|
32
|
+
written, the plan states a decision for each of: platform (web, mobile, PWA, native,
|
|
33
|
+
CLI), who and how many will use it, stack, how it is distributed and reached, access
|
|
34
|
+
(open, invite, accounts), what data is kept and where, and hosting. Each is either
|
|
35
|
+
read from the request, proposed with its reason, or — where two answers lead to
|
|
36
|
+
materially different builds — put to the user as an *Open question* with options,
|
|
37
|
+
all in one round, before anything is built. Three quick questions and a default for
|
|
38
|
+
the rest is how a run ends up writing the second app instead of the first.
|
|
39
|
+
|
|
26
40
|
The goals stay the user's. The planner may propose a better solution to what was
|
|
27
41
|
asked, or argue that the request as stated will not achieve what the user wants, but
|
|
28
42
|
it may not widen the goal on its own. Anything beyond the request goes in *Out of
|
package/skills/kaizen/spec.md
CHANGED
|
@@ -104,6 +104,18 @@ Write into `00-request.md`: the verbatim request, the deliverable, what done mea
|
|
|
104
104
|
and what wrong means. Record in `state.json`: `id`, `mode`, `runner`, `agent`, `track`,
|
|
105
105
|
`stage: "plan"`, `awaiting: null`.
|
|
106
106
|
|
|
107
|
+
`stage` takes exactly one of five words, and boards key their columns on them:
|
|
108
|
+
`plan`, `build`, `review`, `done`, `abandoned`. Set it when the stage starts —
|
|
109
|
+
`build` before the builder touches anything, `review` before the reviewer reads — as
|
|
110
|
+
its own write, not tacked onto a longer command that may not finish. Any other word
|
|
111
|
+
(`implement`, `fix`, `building`) is unknown to every reader and shows as still planning.
|
|
112
|
+
|
|
113
|
+
`awaiting` is `null` or the config key of the approval being waited on —
|
|
114
|
+
`approvals.plan`, `approvals.review`, `approvals.each_file` — or `findings` when the
|
|
115
|
+
run has stopped for the user to pick which review findings to fix. Boards read it to
|
|
116
|
+
say what a run is waiting for and where it sits; a word outside this list is shown but
|
|
117
|
+
not understood.
|
|
118
|
+
|
|
107
119
|
`agent` is the tool this run is happening in — `claude`, `codex`, `agy`, `opencode`,
|
|
108
120
|
`gemini`, or `cursor` — written once at intake and never changed, so a board can say
|
|
109
121
|
which agent to look for when a run is waiting. The tool running this stage knows what
|
package/web/board.html
CHANGED
|
@@ -100,9 +100,14 @@ section h2{position:sticky}
|
|
|
100
100
|
|
|
101
101
|
/* inline forms replace a card while editing */
|
|
102
102
|
.inl{display:flex;flex-direction:column;gap:8px;padding:10px 12px;background:var(--bg-2);border:1px solid var(--accent);border-radius:var(--r)}
|
|
103
|
-
.inl input{width:100%;border:1px solid var(--line);border-radius:var(--r);padding:7px 10px;background:var(--bg-2)}
|
|
104
|
-
.inl
|
|
105
|
-
.
|
|
103
|
+
.inl input,.inl textarea,.notes textarea{width:100%;border:1px solid var(--line);border-radius:var(--r);padding:7px 10px;background:var(--bg-2);font:inherit;color:var(--ink)}
|
|
104
|
+
.inl textarea,.notes textarea{min-height:72px;resize:vertical}
|
|
105
|
+
.notes textarea{min-height:160px}
|
|
106
|
+
.inl input::placeholder,.inl textarea::placeholder,.notes textarea::placeholder{color:var(--dim)}
|
|
107
|
+
.inl .row,.notes .row{display:flex;gap:6px;justify-content:flex-end}
|
|
108
|
+
.notes{display:flex;flex-direction:column;gap:8px}
|
|
109
|
+
.chat{display:flex;flex-direction:column;gap:10px;margin:0 0 14px}.msg{border:1px solid var(--line);border-radius:var(--r);padding:8px 10px;background:var(--bg-2)}.msg time{display:block;font-size:11px;color:var(--dim);margin-bottom:4px}.msg p:last-child{margin-bottom:0}.msg img{max-width:100%;max-height:260px;border-radius:4px;display:block;margin:4px 0}
|
|
110
|
+
.notes .tools{display:flex;gap:6px;align-items:center}.notes .tools span{flex:1;font-size:11px;color:var(--dim)}.notes .att{font-size:11px;color:var(--dim)}
|
|
106
111
|
.inl .q{font-size:13px}.inl .q.dim{color:var(--dim)}
|
|
107
112
|
.empty{grid-column:1/-1;display:flex;flex-direction:column;align-items:center;gap:14px;padding-block:60px;color:var(--dim)}
|
|
108
113
|
.mascot{width:120px;height:112px;background:var(--ink);-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 428 400%22%3E%3Cpath fill=%22currentColor%22 fill-rule=%22evenodd%22 d=%22M130 381L122 362L119 346L119 325L124 305L123 294L119 288L117 288L115 285L109 282L89 264L89 262L82 256L82 254L79 252L79 250L76 248L76 246L73 244L70 237L68 236L58 216L53 201L49 182L48 166L45 166L39 170L19 177L7 178L2 176L0 173L0 163L4 157L4 154L7 150L7 147L10 143L16 126L20 120L26 103L30 97L30 94L34 88L37 78L41 72L44 62L48 56L48 53L52 47L52 44L56 38L59 28L64 20L67 10L70 7L247 0L252 4L253 11L250 18L243 25L243 27L209 60L207 60L203 65L201 65L197 70L195 70L191 75L189 75L186 79L180 82L169 92L164 93L161 90L162 85L169 81L171 78L173 78L176 74L178 74L181 70L183 70L186 66L193 62L197 57L199 57L205 50L207 50L235 23L235 21L242 14L244 9L241 9L226 14L220 18L217 18L199 27L198 29L190 32L189 34L183 36L182 38L178 39L177 41L173 42L167 47L163 48L156 54L152 55L151 57L149 57L148 59L146 59L145 61L143 61L142 63L140 63L139 65L137 65L136 67L134 67L133 69L131 69L130 71L116 79L114 82L106 86L104 89L99 91L93 97L88 99L86 102L84 102L82 105L80 105L78 108L76 108L74 111L72 111L70 114L68 114L66 117L64 117L62 120L56 123L53 127L51 127L48 131L41 135L37 140L35 140L15 159L15 161L11 164L9 169L21 167L30 164L34 161L37 161L45 156L48 156L50 140L57 132L71 131L75 133L79 138L81 138L87 135L88 133L94 131L95 129L104 126L107 129L106 134L82 147L82 156L85 172L92 188L94 189L94 194L92 196L88 196L84 192L79 182L74 164L73 144L69 140L62 140L58 144L58 182L62 200L68 217L76 232L82 239L82 241L92 252L92 254L107 269L109 269L112 273L114 273L128 285L132 292L133 307L128 325L128 345L131 361L140 382L149 390L160 392L191 381L228 377L238 373L251 362L251 360L254 358L255 354L258 351L261 341L261 322L259 315L253 304L243 294L232 288L220 285L203 286L188 292L176 303L176 305L170 313L167 323L168 345L178 363L187 370L187 375L184 377L179 376L169 366L161 352L158 341L158 323L162 309L167 300L170 298L170 296L181 286L194 279L207 276L221 276L234 279L244 284L249 289L251 289L263 303L270 322L270 342L266 355L260 363L260 365L257 367L257 369L247 378L243 379L240 382L253 386L263 391L277 391L281 389L290 379L298 358L300 347L300 323L296 307L297 298L302 292L318 287L333 279L335 276L337 276L339 273L341 273L345 268L349 266L349 264L355 259L351 251L349 242L345 243L341 247L329 253L309 258L290 258L276 255L274 257L262 261L236 266L192 266L154 257L130 245L129 243L121 239L119 236L117 236L101 220L101 218L98 216L96 212L95 207L107 192L113 180L119 159L121 157L121 151L118 146L118 142L115 135L114 112L117 101L124 92L129 90L138 90L146 92L159 99L169 109L184 103L204 99L231 100L244 103L259 109L264 103L266 103L268 100L270 100L271 98L280 93L290 90L302 91L309 97L314 112L314 130L311 143L307 152L307 156L310 161L316 182L325 198L332 205L333 210L328 219L322 224L322 226L317 231L315 231L310 237L308 237L306 240L296 245L292 249L309 249L325 245L341 236L347 230L350 210L360 193L360 187L355 178L355 174L357 172L387 172L389 177L384 187L385 195L387 194L391 182L394 180L401 180L415 187L426 190L428 193L428 202L423 211L423 214L421 216L421 219L419 221L419 224L417 226L417 229L415 231L415 234L413 236L413 239L411 241L405 258L402 260L395 260L390 257L388 259L366 259L361 264L361 266L340 285L318 297L307 300L305 302L305 307L309 325L318 323L325 319L334 310L334 308L337 306L341 298L354 283L356 283L362 277L377 271L394 271L400 273L407 277L415 285L420 294L423 306L421 323L416 335L408 344L408 346L401 353L399 353L397 356L395 356L388 362L369 370L353 373L330 373L304 367L301 377L297 385L294 387L294 389L288 395L279 400L261 400L240 391L216 387L192 390L167 400L150 400L139 394L132 386ZM130 155L119 189L116 192L112 201L106 207L107 210L119 207L126 200L134 185L136 184L137 180L149 166L163 158L171 156L180 156L191 160L198 166L202 173L204 182L203 192L195 205L194 216L196 220L205 227L212 229L221 228L227 225L233 219L234 207L225 191L225 177L233 163L235 163L237 160L241 158L248 156L262 157L278 165L289 177L289 179L295 186L297 192L299 193L300 197L309 207L316 210L323 209L313 196L307 184L304 171L297 154L302 142L305 129L304 107L301 101L298 99L291 99L278 104L261 119L257 119L243 112L224 108L197 109L185 112L171 119L167 119L163 114L161 114L157 109L155 109L150 104L138 99L130 99L126 103L123 113L123 128L125 138L131 152ZM380 280L372 282L361 289L361 306L364 310L375 312L382 317L384 321L384 337L386 339L390 341L400 341L408 331L413 319L413 301L409 292L400 283L393 280ZM144 187L141 193L139 194L138 198L134 202L134 204L131 206L131 208L125 214L123 214L120 217L113 218L113 220L124 230L126 230L128 233L138 238L139 240L156 248L180 255L201 258L226 258L257 253L277 246L291 239L292 237L303 231L315 220L315 218L306 216L298 209L298 207L291 199L290 195L288 194L287 190L285 189L281 181L271 171L257 165L245 166L241 168L235 175L234 190L242 203L243 217L240 224L234 231L221 237L207 237L201 235L194 229L192 229L186 220L185 207L187 201L193 193L195 185L193 175L187 168L179 165L171 165L164 167L158 172L156 172L148 180ZM367 179L364 179L367 185L367 195L362 204L360 205L355 219L355 240L357 247L361 253L383 253L388 245L390 236L389 218L383 203L377 195L377 185L380 179ZM319 362L330 364L360 363L376 358L387 352L389 349L382 347L376 341L375 323L371 320L362 319L359 316L357 316L353 310L351 300L348 306L343 311L343 313L333 324L331 324L329 327L320 332L309 334L309 348L307 358ZM130 59L132 59L133 57L147 49L149 46L76 18L66 105L72 100L74 100L76 97L78 97L80 94L82 94L84 91L89 89L91 86L93 86L95 83L97 83L105 76L113 72L115 69L129 61ZM213 204L218 204L225 208L224 213L217 218L211 218L207 216L203 210L205 206ZM39 96L20 142L26 136L28 136L33 130L35 130L38 126L40 126L43 122L45 122L48 118L50 118L53 114L56 113L64 41L56 57L56 60L52 66L49 76L45 82L42 92ZM194 11L101 14L95 16L98 16L100 18L103 18L105 20L108 20L110 22L113 22L115 24L118 24L120 26L123 26L125 28L128 28L130 30L133 30L135 32L138 32L140 34L143 34L145 36L148 36L159 41L164 37L168 36L169 34L173 33L174 31L178 30L179 28L213 11ZM409 203L413 193L412 191L404 189L400 186L396 186L394 190L390 203L395 213L397 230L401 223L407 205ZM418 210L421 200L423 198L423 196L418 195L412 212L407 221L407 224L405 226L405 229L403 231L403 234L401 236L401 239L399 241L399 244L397 246L397 249L395 251L396 254L400 254ZM182 191L178 195L170 194L167 190L168 182L172 179L180 180L183 183ZM256 194L250 195L245 190L245 184L250 179L256 179L261 184L261 190L258 194Z%22/%3E%3C/svg%3E") center/contain no-repeat;mask:url("data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 428 400%22%3E%3Cpath fill=%22currentColor%22 fill-rule=%22evenodd%22 d=%22M130 381L122 362L119 346L119 325L124 305L123 294L119 288L117 288L115 285L109 282L89 264L89 262L82 256L82 254L79 252L79 250L76 248L76 246L73 244L70 237L68 236L58 216L53 201L49 182L48 166L45 166L39 170L19 177L7 178L2 176L0 173L0 163L4 157L4 154L7 150L7 147L10 143L16 126L20 120L26 103L30 97L30 94L34 88L37 78L41 72L44 62L48 56L48 53L52 47L52 44L56 38L59 28L64 20L67 10L70 7L247 0L252 4L253 11L250 18L243 25L243 27L209 60L207 60L203 65L201 65L197 70L195 70L191 75L189 75L186 79L180 82L169 92L164 93L161 90L162 85L169 81L171 78L173 78L176 74L178 74L181 70L183 70L186 66L193 62L197 57L199 57L205 50L207 50L235 23L235 21L242 14L244 9L241 9L226 14L220 18L217 18L199 27L198 29L190 32L189 34L183 36L182 38L178 39L177 41L173 42L167 47L163 48L156 54L152 55L151 57L149 57L148 59L146 59L145 61L143 61L142 63L140 63L139 65L137 65L136 67L134 67L133 69L131 69L130 71L116 79L114 82L106 86L104 89L99 91L93 97L88 99L86 102L84 102L82 105L80 105L78 108L76 108L74 111L72 111L70 114L68 114L66 117L64 117L62 120L56 123L53 127L51 127L48 131L41 135L37 140L35 140L15 159L15 161L11 164L9 169L21 167L30 164L34 161L37 161L45 156L48 156L50 140L57 132L71 131L75 133L79 138L81 138L87 135L88 133L94 131L95 129L104 126L107 129L106 134L82 147L82 156L85 172L92 188L94 189L94 194L92 196L88 196L84 192L79 182L74 164L73 144L69 140L62 140L58 144L58 182L62 200L68 217L76 232L82 239L82 241L92 252L92 254L107 269L109 269L112 273L114 273L128 285L132 292L133 307L128 325L128 345L131 361L140 382L149 390L160 392L191 381L228 377L238 373L251 362L251 360L254 358L255 354L258 351L261 341L261 322L259 315L253 304L243 294L232 288L220 285L203 286L188 292L176 303L176 305L170 313L167 323L168 345L178 363L187 370L187 375L184 377L179 376L169 366L161 352L158 341L158 323L162 309L167 300L170 298L170 296L181 286L194 279L207 276L221 276L234 279L244 284L249 289L251 289L263 303L270 322L270 342L266 355L260 363L260 365L257 367L257 369L247 378L243 379L240 382L253 386L263 391L277 391L281 389L290 379L298 358L300 347L300 323L296 307L297 298L302 292L318 287L333 279L335 276L337 276L339 273L341 273L345 268L349 266L349 264L355 259L351 251L349 242L345 243L341 247L329 253L309 258L290 258L276 255L274 257L262 261L236 266L192 266L154 257L130 245L129 243L121 239L119 236L117 236L101 220L101 218L98 216L96 212L95 207L107 192L113 180L119 159L121 157L121 151L118 146L118 142L115 135L114 112L117 101L124 92L129 90L138 90L146 92L159 99L169 109L184 103L204 99L231 100L244 103L259 109L264 103L266 103L268 100L270 100L271 98L280 93L290 90L302 91L309 97L314 112L314 130L311 143L307 152L307 156L310 161L316 182L325 198L332 205L333 210L328 219L322 224L322 226L317 231L315 231L310 237L308 237L306 240L296 245L292 249L309 249L325 245L341 236L347 230L350 210L360 193L360 187L355 178L355 174L357 172L387 172L389 177L384 187L385 195L387 194L391 182L394 180L401 180L415 187L426 190L428 193L428 202L423 211L423 214L421 216L421 219L419 221L419 224L417 226L417 229L415 231L415 234L413 236L413 239L411 241L405 258L402 260L395 260L390 257L388 259L366 259L361 264L361 266L340 285L318 297L307 300L305 302L305 307L309 325L318 323L325 319L334 310L334 308L337 306L341 298L354 283L356 283L362 277L377 271L394 271L400 273L407 277L415 285L420 294L423 306L421 323L416 335L408 344L408 346L401 353L399 353L397 356L395 356L388 362L369 370L353 373L330 373L304 367L301 377L297 385L294 387L294 389L288 395L279 400L261 400L240 391L216 387L192 390L167 400L150 400L139 394L132 386ZM130 155L119 189L116 192L112 201L106 207L107 210L119 207L126 200L134 185L136 184L137 180L149 166L163 158L171 156L180 156L191 160L198 166L202 173L204 182L203 192L195 205L194 216L196 220L205 227L212 229L221 228L227 225L233 219L234 207L225 191L225 177L233 163L235 163L237 160L241 158L248 156L262 157L278 165L289 177L289 179L295 186L297 192L299 193L300 197L309 207L316 210L323 209L313 196L307 184L304 171L297 154L302 142L305 129L304 107L301 101L298 99L291 99L278 104L261 119L257 119L243 112L224 108L197 109L185 112L171 119L167 119L163 114L161 114L157 109L155 109L150 104L138 99L130 99L126 103L123 113L123 128L125 138L131 152ZM380 280L372 282L361 289L361 306L364 310L375 312L382 317L384 321L384 337L386 339L390 341L400 341L408 331L413 319L413 301L409 292L400 283L393 280ZM144 187L141 193L139 194L138 198L134 202L134 204L131 206L131 208L125 214L123 214L120 217L113 218L113 220L124 230L126 230L128 233L138 238L139 240L156 248L180 255L201 258L226 258L257 253L277 246L291 239L292 237L303 231L315 220L315 218L306 216L298 209L298 207L291 199L290 195L288 194L287 190L285 189L281 181L271 171L257 165L245 166L241 168L235 175L234 190L242 203L243 217L240 224L234 231L221 237L207 237L201 235L194 229L192 229L186 220L185 207L187 201L193 193L195 185L193 175L187 168L179 165L171 165L164 167L158 172L156 172L148 180ZM367 179L364 179L367 185L367 195L362 204L360 205L355 219L355 240L357 247L361 253L383 253L388 245L390 236L389 218L383 203L377 195L377 185L380 179ZM319 362L330 364L360 363L376 358L387 352L389 349L382 347L376 341L375 323L371 320L362 319L359 316L357 316L353 310L351 300L348 306L343 311L343 313L333 324L331 324L329 327L320 332L309 334L309 348L307 358ZM130 59L132 59L133 57L147 49L149 46L76 18L66 105L72 100L74 100L76 97L78 97L80 94L82 94L84 91L89 89L91 86L93 86L95 83L97 83L105 76L113 72L115 69L129 61ZM213 204L218 204L225 208L224 213L217 218L211 218L207 216L203 210L205 206ZM39 96L20 142L26 136L28 136L33 130L35 130L38 126L40 126L43 122L45 122L48 118L50 118L53 114L56 113L64 41L56 57L56 60L52 66L49 76L45 82L42 92ZM194 11L101 14L95 16L98 16L100 18L103 18L105 20L108 20L110 22L113 22L115 24L118 24L120 26L123 26L125 28L128 28L130 30L133 30L135 32L138 32L140 34L143 34L145 36L148 36L159 41L164 37L168 36L169 34L173 33L174 31L178 30L179 28L213 11ZM409 203L413 193L412 191L404 189L400 186L396 186L394 190L390 203L395 213L397 230L401 223L407 205ZM418 210L421 200L423 198L423 196L418 195L412 212L407 221L407 224L405 226L405 229L403 231L403 234L401 236L401 239L399 241L399 244L397 246L397 249L395 251L396 254L400 254ZM182 191L178 195L170 194L167 190L168 182L172 179L180 180L183 183ZM256 194L250 195L245 190L245 184L250 179L256 179L261 184L261 190L258 194Z%22/%3E%3C/svg%3E") center/contain no-repeat}
|
|
@@ -140,7 +145,7 @@ aside .foot:empty{display:none}
|
|
|
140
145
|
.find .loc{color:var(--dim);font-size:12px;margin-left:8px}
|
|
141
146
|
.set{display:grid;grid-template-columns:auto 1fr;gap:10px 20px;align-items:center;font-size:13px}
|
|
142
147
|
.set label{color:var(--dim)}
|
|
143
|
-
.set .opts{display:flex;gap:4px}
|
|
148
|
+
.set .opts{display:flex;flex-wrap:wrap;gap:4px}
|
|
144
149
|
.set .opts button{border:1px solid var(--line);border-radius:var(--r);padding:3px 10px;color:var(--dim)}
|
|
145
150
|
.set .opts button.on{color:var(--ink);border-color:var(--ink)}
|
|
146
151
|
.toast{position:fixed;z-index:12;left:24px;bottom:52px;background:var(--ink);color:var(--bg);border-radius:var(--r);padding:8px 14px;font-size:13px;opacity:0;transition:opacity .2s;pointer-events:none}
|
|
@@ -238,7 +243,7 @@ async function load(){
|
|
|
238
243
|
for(const c of S.cards){if(c.kind!=="run")continue;const k=key(c),before=wasAwaiting.get(k);
|
|
239
244
|
if(!first&&before===null&&c.awaiting)notifyUser("kaizen — waiting on you",c.text+" · "+c.awaiting);wasAwaiting.set(k,c.awaiting)}
|
|
240
245
|
first=false;render();
|
|
241
|
-
if(openKey
|
|
246
|
+
if(openKey){const c=S.cards.find(x=>key(x)===openKey);if(c&&runDoc)openRun(c,true);else if(c&&c.kind==="idea"){const ta=$("pbody").querySelector("textarea");if(!(ta&&document.activeElement===ta))chat(c,c.notes,c.archived||c.status!=="idea")}}}
|
|
242
247
|
async function post(path,body){const r=await fetch(path,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(body)});const j=await r.json().catch(()=>({}));if(!r.ok){toast(j.error||j.why||"failed");if(j.manual)console.log("run manually:",j.manual)}return j}
|
|
243
248
|
|
|
244
249
|
function card(c){
|
|
@@ -249,6 +254,7 @@ function card(c){
|
|
|
249
254
|
else if(c.status==="running")chips.push(`<span class="chip run"><i></i>running</span>`);
|
|
250
255
|
if(all)chips.push(`<span class="chip">${esc(c.where)}</span>`);
|
|
251
256
|
chips.push(...(c.tags||[]).map(t=>{const mark=MARKS[t]?`<svg><use href="#c-${t}"/></svg>`:"";return `<span class="chip${t==="full"||t==="lite"?" accent":""}">${mark}${esc(NAMES[t]||t)}</span>`}));
|
|
257
|
+
if(c.notes)chips.push(`<span class="chip">notes</span>`);
|
|
252
258
|
const pick=picking&&!archive&&c.column===4;
|
|
253
259
|
el.innerHTML=(pick?`<input type="checkbox" class="pick" ${picked.has(k)?"checked":""} aria-label="Select">`:"")+`<div class="c-id"><span class="st d-${st}"><svg><use href="#s-${st}"/></svg></span><span class="idt">${esc(idt)}</span><span class="age">${c.kind==="run"?age(c.moved||S.now,S.now):""}</span><button class="ib sm more" data-a="menu" title="Actions" aria-label="Actions">${ico("more")}</button></div>
|
|
254
260
|
<div class="c-title">${esc(c.kind==="idea"?c.text:(c.title||c.id))}</div>
|
|
@@ -271,7 +277,7 @@ function card(c){
|
|
|
271
277
|
|
|
272
278
|
function inlineForm(c){
|
|
273
279
|
const li=document.createElement("li");li.className="inl";const dir=c.state;
|
|
274
|
-
if(mode==="edit"){li.innerHTML=`<input value="${esc(c.text)}"><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Save</button></div>`;li.querySelector('[data-a=s]').onclick=async()=>{const next=li.querySelector("input").value.trim();mode=null;if(next&&next!==c.text)await post("/inbox",{op:"edit",dir,text:c.text,next});load()}}
|
|
280
|
+
if(mode==="edit"){li.innerHTML=`<input value="${esc(c.text)}"><textarea placeholder="Notes: links, paths, background">${esc(c.notes||"")}</textarea><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Save</button></div>`;li.querySelector('[data-a=s]').onclick=async()=>{const next=li.querySelector("input").value.trim(),notes=li.querySelector("textarea").value.trim();mode=null;if(next&&(next!==c.text||notes!==(c.notes||"")))await post("/inbox",{op:"edit",dir,text:c.text,next,notes});load()}}
|
|
275
281
|
if(mode==="reject"){const idea=c.kind==="idea";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>`;li.querySelector('[data-a=s]').onclick=async()=>{const why=li.querySelector("input").value.trim();if(!why){toast("A reason is required");return}mode=null;const r=idea?await post("/inbox",{op:"reject",dir,text:c.text,reason:why}):await post("/abort",{dir,id:c.id,why});if(r.ok)toast(idea?"Rejected":"Abandoned, reason recorded");load()}}
|
|
276
282
|
if(mode==="delete"){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>`;li.querySelector('[data-a=s]').onclick=async()=>{mode=null;const r=await post("/inbox",{op:"delete",dir,text:c.text});if(r.ok)toast("Deleted");load()}}
|
|
277
283
|
if(mode==="run"){li.innerHTML=`<div class="q">Start a run for "${esc(c.text)}"</div><div class="q dim">Opens your agent in a new terminal with the request typed in.</div><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>`;li.querySelector('[data-a=s]').onclick=()=>startRun(c,"full");li.querySelector('[data-a=l]').onclick=()=>startRun(c,"lite")}
|
|
@@ -317,8 +323,8 @@ function render(){
|
|
|
317
323
|
s.querySelectorAll("[data-p]").forEach(bt=>bt.onclick=async e=>{e.stopPropagation();if(bt.dataset.p==="cancel"){picking=false;picked.clear();render();return}
|
|
318
324
|
const set=list.filter(c=>picked.has(key(c)));picking=false;picked.clear();for(const c of set)await post("/archive",{dir:c.state,id:c.id});toast("Archived "+set.length);load()});}
|
|
319
325
|
const ul=document.createElement("ul");
|
|
320
|
-
if(ci===0&&mode==="new"){const f=document.createElement("li");f.className="inl";f.innerHTML=`<input id="newidea" placeholder="One line, like a commit subject"><div class="row"><button class="btn ghost" id="nc">Cancel</button><button class="btn pri" id="ns">Add to inbox</button></div>`;ul.appendChild(f);
|
|
321
|
-
const inp=f.querySelector("input");const save=async()=>{const text=inp.value.trim();if(!text)return;mode=null;const r=await post("/inbox",{op:"add",text});if(r.ok)toast("Added to inbox.md");load()};
|
|
326
|
+
if(ci===0&&mode==="new"){const f=document.createElement("li");f.className="inl";f.innerHTML=`<input id="newidea" placeholder="One line, like a commit subject"><textarea id="newnotes" placeholder="Notes: links, paths, background (optional)"></textarea><div class="row"><button class="btn ghost" id="nc">Cancel</button><button class="btn pri" id="ns">Add to inbox</button></div>`;ul.appendChild(f);
|
|
327
|
+
const inp=f.querySelector("input");const save=async()=>{const text=inp.value.trim();if(!text)return;const notes=f.querySelector("textarea").value.trim();mode=null;const r=await post("/inbox",{op:"add",text,notes});if(r.ok)toast("Added to inbox.md");load()};
|
|
322
328
|
f.querySelector("#ns").onclick=save;f.querySelector("#nc").onclick=()=>{mode=null;render()};inp.onkeydown=e=>{if(e.key==="Enter")save();if(e.key==="Escape"){mode=null;render()}};setTimeout(()=>inp.focus())}
|
|
323
329
|
const shown=unfolded.has(ci)||list.length<=FOLD+1?list:list.slice(0,FOLD);
|
|
324
330
|
shown.forEach(c=>{if(modeKey===key(c)&&mode){ul.appendChild(inlineForm(c));return}const li=document.createElement("li");li.appendChild(card(c));ul.appendChild(li)});
|
|
@@ -331,19 +337,44 @@ function render(){
|
|
|
331
337
|
// ---- detail panel
|
|
332
338
|
function open(c){
|
|
333
339
|
openKey=key(c);$("panel").classList.add("open");$("scrim").classList.add("on");$("ptitle").textContent=c.text;render();
|
|
334
|
-
if(c.kind==="idea"){runDoc=null;$("psub").textContent="idea · inbox.md · "+c.where;$("ptabs").innerHTML=""
|
|
340
|
+
if(c.kind==="idea"){runDoc=null;$("psub").textContent="idea · inbox.md · "+c.where;$("ptabs").innerHTML="";chat(c,c.notes,c.archived||c.status!=="idea");
|
|
335
341
|
const foot=$("pfoot");foot.innerHTML=c.archived?`<button class="btn pri" data-a="restore">Restore</button>`:c.status==="idea"?`<button class="btn pri" data-a="run">Run</button><button class="btn" data-a="edit">Edit</button><button class="btn" data-a="reject">Reject</button><button class="btn ghost" data-a="archive">Archive</button><button class="btn ghost danger" data-a="delete">Delete</button>`:`<span style="color:var(--dim);font-size:12px">Starting: a run was launched for this idea.</span>`;
|
|
336
342
|
foot.querySelectorAll("[data-a]").forEach(b=>b.onclick=()=>{const a=b.dataset.a;if(a==="archive"||a==="restore"){toggleArchive(c,a==="archive");return}const k=key(c);close();mode=a;modeKey=k;render()});return}
|
|
337
343
|
openRun(c,false)}
|
|
344
|
+
// Notes are a chat: one entry per message, files attached by paste or picker.
|
|
345
|
+
function parseNotes(src){const out=[];for(const raw of (src||"").split("\n")){const m=/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2})\]\s?(.*)$/.exec(raw);if(m){out.push({at:m[1],text:m[2]});continue}const last=out[out.length-1];if(last)last.text+="\n"+raw.replace(/^\s{2}/,"");else if(raw.trim())out.push({at:"",text:raw})}return out.map(n=>({...n,text:n.text.trim()})).filter(n=>n.text)}
|
|
346
|
+
function chat(c,src,ro){
|
|
347
|
+
const base=c.kind==="run"?`runs/${c.id}/`:"";
|
|
348
|
+
const fix=t=>t.replace(/\]\((attachments\/[^)]+)\)/g,(_,p)=>`](/file?dir=${encodeURIComponent(c.state)}&p=${encodeURIComponent(base+p)})`);
|
|
349
|
+
const entries=parseNotes(src);
|
|
350
|
+
let h=`<div class="chat">${entries.map(n=>`<div class="msg">${n.at?`<time>${esc(n.at)}</time>`:""}${md(fix(n.text))}</div>`).join("")||`<p style="color:var(--dim)">No notes yet.</p>`}</div>`;
|
|
351
|
+
if(!ro)h+=`<div class="notes"><textarea placeholder="Add a note. Paste a screenshot, or attach files."></textarea><div class="att" id="natt"></div><div class="tools"><input type="file" id="nfile" multiple hidden><button class="btn ghost" id="npick">Attach</button><span>Enter sends, Shift+Enter newline</span><button class="btn pri" id="nsend">Send</button></div></div>`;
|
|
352
|
+
$("pbody").innerHTML=h;
|
|
353
|
+
if(ro)return;
|
|
354
|
+
const ta=$("pbody").querySelector("textarea"),files=[];
|
|
355
|
+
const show=()=>{$("natt").textContent=files.map(f=>f.name).join(" · ")};
|
|
356
|
+
const add=f=>{files.push(f);show()};
|
|
357
|
+
$("npick").onclick=()=>$("nfile").click();$("nfile").onchange=e=>{[...e.target.files].forEach(add);e.target.value=""};
|
|
358
|
+
ta.onpaste=e=>{for(const it of e.clipboardData.items)if(it.kind==="file"){const f=it.getAsFile();add(new File([f],f.name==="image.png"?`paste-${Date.now()}.png`:f.name,{type:f.type}));e.preventDefault()}};
|
|
359
|
+
const send=async()=>{const text=ta.value.trim();if(!text&&!files.length)return;const enc=await Promise.all(files.map(f=>new Promise(r=>{const rd=new FileReader();rd.onload=()=>r({name:f.name,data:rd.result});rd.readAsDataURL(f)})));
|
|
360
|
+
const body=c.kind==="run"?{dir:c.state,id:c.id,text,files:enc}:{dir:c.state,for:c.text,text,files:enc};const r=await post("/note",body);if(r.ok){files.length=0;ta.value="";toast("Noted");load()}};
|
|
361
|
+
$("nsend").onclick=send;ta.onkeydown=e=>{if(e.key==="Enter"&&!e.shiftKey){e.preventDefault();send()}};
|
|
362
|
+
$("pbody").scrollTop=0}
|
|
363
|
+
// The builder's "## Preview" section of 03-impl.md: how to run the app, or the result itself.
|
|
364
|
+
function preview(impl){const m=impl&&impl.match(/^##\s+Preview\s*\n([\s\S]*?)(?=\n##\s|$(?![\s\S]))/m);return m?m[1].trim():null}
|
|
338
365
|
async function openRun(c,refresh){
|
|
339
366
|
if(!refresh||!runDoc||runDoc.id!==c.id){runDoc=await fetch(`/run/${encodeURIComponent(c.id)}?dir=${encodeURIComponent(c.state)}`).then(r=>r.json())}
|
|
340
367
|
if(openKey!==key(c))return;
|
|
368
|
+
// A refresh while notes are being typed would wipe the textarea.
|
|
369
|
+
const ta=$("pbody").querySelector("textarea");if(refresh&&ta&&document.activeElement===ta)return;
|
|
341
370
|
$("psub").textContent=c.id+" · "+(WORD[c.status]||c.status)+(c.awaiting?" · "+c.awaiting:"");
|
|
342
|
-
const tabs=[["Request","request"],["Plan","plan"],["Impl","impl"],["Review","review"],["Backlog","backlog"]];
|
|
371
|
+
const tabs=[["Request","request"],["Notes","notes"],["Plan","plan"],["Impl","impl"],["Preview","preview"],["Review","review"],["Backlog","backlog"]];
|
|
343
372
|
$("ptabs").innerHTML=tabs.map((t,i)=>`<button class="${i===tab?"on":""}" data-i="${i}">${t[0]}</button>`).join("");
|
|
344
373
|
[...$("ptabs").children].forEach(b=>b.onclick=()=>{tab=+b.dataset.i;openRun(c,true)});
|
|
345
|
-
const k=tabs[tab][1],doc=runDoc[k];
|
|
346
|
-
|
|
374
|
+
const k=tabs[tab][1],doc=k==="preview"?preview(runDoc.impl):runDoc[k];
|
|
375
|
+
if(k==="notes"){const ro=c.archived||c.status==="done"||c.status==="abandoned";
|
|
376
|
+
chat(c,doc,ro);}
|
|
377
|
+
else $("pbody").innerHTML=doc?md(doc):`<p style="color:var(--dim)">No ${({request:"00-request.md",plan:"01-plan.md",impl:"03-impl.md",preview:"'## Preview' in 03-impl.md",review:"04-review.md",backlog:"06-backlog.md"})[k]} yet.</p>`;
|
|
347
378
|
const foot=$("pfoot");
|
|
348
379
|
const arch=c.archived?`<button class="btn pri" data-a="restore">Restore</button>`:`<button class="btn ghost" data-a="archive">Archive</button>`;
|
|
349
380
|
if(c.archived)foot.innerHTML=arch;
|
|
@@ -359,7 +390,7 @@ $("pclose").onclick=close;$("scrim").onclick=close;
|
|
|
359
390
|
// inline code, bold. Everything is escaped first.
|
|
360
391
|
function md(src){
|
|
361
392
|
const lines=src.split("\n"),out=[];let i=0,list=null;
|
|
362
|
-
const inl=s=>esc(s).replace(/`([^`]+)`/g,"<code>$1</code>").replace(/\*\*([^*]+)\*\*/g,"<b>$1</b>");
|
|
393
|
+
const inl=s=>esc(s).replace(/`([^`]+)`/g,"<code>$1</code>").replace(/\*\*([^*]+)\*\*/g,"<b>$1</b>").replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g,'<a href="$2" target="_blank"><img src="$2" alt="$1"></a>').replace(/\[([^\]]+)\]\(([^)\s]+)\)/g,'<a href="$2" target="_blank">$1</a>');
|
|
363
394
|
const endList=()=>{if(list){out.push(`</${list}>`);list=null}};
|
|
364
395
|
while(i<lines.length){const l=lines[i];
|
|
365
396
|
if(/^```/.test(l)){endList();const buf=[];i++;while(i<lines.length&&!/^```/.test(lines[i]))buf.push(lines[i++]);out.push(`<pre>${esc(buf.join("\n"))}</pre>`);i++;continue}
|
|
@@ -372,14 +403,18 @@ function md(src){
|
|
|
372
403
|
endList();return out.join("")}
|
|
373
404
|
|
|
374
405
|
// ---- settings, toast, notifications, keys
|
|
375
|
-
function openSettings(){
|
|
376
|
-
openKey=null;runDoc=null;$("panel").classList.add("open");$("scrim").classList.add("on");$("ptitle").textContent="Settings";$("
|
|
406
|
+
async function openSettings(){
|
|
407
|
+
openKey=null;runDoc=null;$("panel").classList.add("open");$("scrim").classList.add("on");$("ptitle").textContent="Settings";$("ptabs").innerHTML="";$("pfoot").innerHTML="";
|
|
377
408
|
const cur=document.documentElement.dataset.theme||"system";
|
|
378
|
-
|
|
409
|
+
const opts=(id,vals,on)=>`<div class="opts" id="${id}">${vals.map(v=>`<button class="${v===on?"on":""}" data-v="${v}" ${v===""?'title="empty"':""}>${v===""?"—":v}</button>`).join("")}</div>`;
|
|
410
|
+
const cfg=await fetch("/settings").then(r=>r.json()).catch(()=>null);
|
|
411
|
+
$("psub").textContent=cfg?`theme is this browser's; the rest is ${cfg.file}`:"theme is this browser's";
|
|
412
|
+
$("pbody").innerHTML=`<div class="set"><label>Theme</label>${opts("th",["system","light","dark"],cur)}
|
|
379
413
|
<label>Notifications</label><div class="opts" id="nt"><button>${typeof Notification==="undefined"?"unsupported":Notification.permission==="granted"?"on":"ask"}</button></div></div>
|
|
380
|
-
|
|
381
|
-
$("th").querySelectorAll("button").forEach(b=>b.onclick=()=>{const t=b.dataset.
|
|
382
|
-
$("nt").querySelector("button").onclick=()=>{askNotify();setTimeout(openSettings,300)}
|
|
414
|
+
${cfg?`<hr style="border:0;border-top:1px solid var(--line);margin:16px 0"><div class="set">${cfg.rows.map(r=>`<label title="${esc(r.help)}">${r.key}</label>${opts("s-"+r.key.replace(/\./g,"-"),r.values,r.value)}`).join("")}</div>`:`<p style="color:var(--dim);margin-top:20px">Settings could not be read. Run <code>kaizen settings</code>.</p>`}`;
|
|
415
|
+
$("th").querySelectorAll("button").forEach(b=>b.onclick=()=>{const t=b.dataset.v;if(t==="system")delete document.documentElement.dataset.theme;else document.documentElement.dataset.theme=t;try{t==="system"?localStorage.removeItem("kz-theme"):localStorage.setItem("kz-theme",t)}catch(e){}openSettings()});
|
|
416
|
+
$("nt").querySelector("button").onclick=()=>{askNotify();setTimeout(openSettings,300)};
|
|
417
|
+
if(cfg)cfg.rows.forEach(r=>$("s-"+r.key.replace(/\./g,"-")).querySelectorAll("button").forEach(b=>b.onclick=async()=>{if(b.classList.contains("on"))return;const j=await post("/settings",{key:r.key,value:b.dataset.v});if(j.ok)toast(`saved ${r.key} = ${b.dataset.v||'""'}`);openSettings()}))}
|
|
383
418
|
let tt;function toast(s){const t=$("toast");t.textContent=s;t.classList.add("on");clearTimeout(tt);tt=setTimeout(()=>t.classList.remove("on"),4000)}
|
|
384
419
|
function askNotify(){try{if(typeof Notification!=="undefined"&&Notification.permission==="default")Notification.requestPermission()}catch(e){}}
|
|
385
420
|
function notifyUser(title,body){try{if(typeof Notification!=="undefined"&&Notification.permission==="granted")new Notification(title,{body})}catch(e){}}
|
|
@@ -390,7 +425,7 @@ try{const t=localStorage.getItem("kz-theme");if(t)document.documentElement.datas
|
|
|
390
425
|
$("kbd").onclick=()=>$("help").classList.add("on");$("help").onclick=()=>$("help").classList.remove("on");
|
|
391
426
|
document.addEventListener("click",()=>{if(menuKey||clearMenu){menuKey=null;clearMenu=false;render()}});
|
|
392
427
|
document.onkeydown=e=>{
|
|
393
|
-
if(e.target.matches("input,select"))return;
|
|
428
|
+
if(e.target.matches("input,select,textarea,[contenteditable]"))return;
|
|
394
429
|
if(e.key==="?"){$("help").classList.toggle("on");return}
|
|
395
430
|
if(e.key==="Escape"){$("help").classList.remove("on");if(mode){mode=null;render()}else close();return}
|
|
396
431
|
if(e.key==="n"){mode="new";render()}
|
|
@@ -401,7 +436,8 @@ document.onkeydown=e=>{
|
|
|
401
436
|
if(e.key==="r"&&oc.kind==="idea"){close();mode="run";modeKey=key(oc);render()}};
|
|
402
437
|
|
|
403
438
|
// ---- live
|
|
404
|
-
|
|
439
|
+
let boot=null;
|
|
440
|
+
function listen(){const es=new EventSource("/events");es.onmessage=ev=>{if(ev.data==="changed")load();else if(ev.data==="reload")location.reload();else if(ev.data.startsWith("hello ")){const b=ev.data.slice(6);if(boot&&b!==boot)location.reload();boot=b}};es.onerror=()=>{es.close();setTimeout(listen,3000)}}
|
|
405
441
|
load();listen();
|
|
406
442
|
</script>
|
|
407
443
|
</body>
|