kaizen-agent 1.9.2 → 1.9.4
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/cli/notes.ts +5 -5
- package/cli/settings.ts +10 -9
- package/cli/state.test.ts +9 -0
- package/cli/state.ts +32 -22
- package/cli/web.ts +7 -7
- package/package.json +1 -1
package/cli/notes.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// The notebook's files: plain markdown under <state>/notes/, folders and all, so any
|
|
2
2
|
// editor (Obsidian included) can open the same folder. Every name that arrives from
|
|
3
3
|
// the page goes through safeName before it becomes a path.
|
|
4
|
-
import { existsSync, mkdirSync, readdirSync,
|
|
4
|
+
import { existsSync, mkdirSync, readdirSync, realpathSync, renameSync, rmdirSync, statSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { join, dirname, resolve, sep } from "node:path";
|
|
6
|
-
import { boardCards, reviewFindings } from "./state.ts";
|
|
6
|
+
import { boardCards, reviewFindings, readText } from "./state.ts";
|
|
7
7
|
import { wikilinks, boardLink, resolveRun } from "../web/notes-lib.ts";
|
|
8
8
|
|
|
9
9
|
export type Note = { name: string; mtime: number; text: string };
|
|
@@ -49,7 +49,7 @@ export function listNotes(state: string): Note[] {
|
|
|
49
49
|
try { st = statSync(p); } catch { continue; }
|
|
50
50
|
if (st.isDirectory()) walk(p, prefix + n + "/");
|
|
51
51
|
else if (n.toLowerCase().endsWith(".md") && safeName(prefix + n) === prefix + n.slice(0, -3)) {
|
|
52
|
-
try { out.push({ name: prefix + n.slice(0, -3), mtime: st.mtimeMs, text:
|
|
52
|
+
try { out.push({ name: prefix + n.slice(0, -3), mtime: st.mtimeMs, text: readText(p) }); } catch { /* vanished */ }
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
};
|
|
@@ -65,7 +65,7 @@ export function saveNote(state: string, name: unknown, text: string, base: numbe
|
|
|
65
65
|
if (!t) return { ok: false, why: "bad name" };
|
|
66
66
|
if (existsSync(t.file)) {
|
|
67
67
|
const now = statSync(t.file).mtimeMs;
|
|
68
|
-
if (now !== base) return { ok: false, why: "conflict", text:
|
|
68
|
+
if (now !== base) return { ok: false, why: "conflict", text: readText(t.file), mtime: now };
|
|
69
69
|
} else if (base) return { ok: false, why: "conflict", text: "", mtime: 0 };
|
|
70
70
|
mkdirSync(dirname(t.file), { recursive: true });
|
|
71
71
|
writeFileSync(t.file, text);
|
|
@@ -116,7 +116,7 @@ export function boardIndex(state: string) {
|
|
|
116
116
|
const cards = boardCards([state], Date.now());
|
|
117
117
|
const backlog = (id: string) => {
|
|
118
118
|
let text = "";
|
|
119
|
-
try { text =
|
|
119
|
+
try { text = readText(join(state, "runs", id, "06-backlog.md")); } catch { /* none */ }
|
|
120
120
|
return [...text.matchAll(/^\s*-\s*(open|done|rejected):\s*(.*)$/gm)].map((m, i) => ({ n: i + 1, status: m[1]!, text: m[2]!.trim() }));
|
|
121
121
|
};
|
|
122
122
|
return {
|
package/cli/settings.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Full-screen settings browser for .kaizen/config.yml.
|
|
2
2
|
// Arrow keys move and change; every change is written straight to the file.
|
|
3
|
-
import { existsSync,
|
|
3
|
+
import { existsSync, writeFileSync, copyFileSync, mkdirSync } from "node:fs";
|
|
4
4
|
import { join, dirname } from "node:path";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
|
+
import { readText } from "./state.ts";
|
|
6
7
|
|
|
7
8
|
// `text` is a value typed rather than chosen: a path. It has no values to cycle.
|
|
8
9
|
type Setting = { key: string; label: string; group: string; values: string[]; help: string; text?: boolean };
|
|
@@ -123,8 +124,8 @@ const SETTINGS: Setting[] = [
|
|
|
123
124
|
// one place decides what a setting is called, what it may be, and how it is saved.
|
|
124
125
|
export function listSettings(repoRoot: string) {
|
|
125
126
|
const file = locate();
|
|
126
|
-
const defaults =
|
|
127
|
-
const text = existsSync(file) ?
|
|
127
|
+
const defaults = readText(join(repoRoot, "skills/kaizen/config.default.yml"));
|
|
128
|
+
const text = existsSync(file) ? readText(file) : "";
|
|
128
129
|
const preset = detectPreset(text, defaults);
|
|
129
130
|
return {
|
|
130
131
|
file: tilde(file),
|
|
@@ -141,8 +142,8 @@ export function setSetting(repoRoot: string, key: string, value: string) {
|
|
|
141
142
|
const file = locate();
|
|
142
143
|
const defaultsPath = join(repoRoot, "skills/kaizen/config.default.yml");
|
|
143
144
|
if (!existsSync(file)) { mkdirSync(dirname(file), { recursive: true }); copyFileSync(defaultsPath, file); }
|
|
144
|
-
const defaults =
|
|
145
|
-
const text =
|
|
145
|
+
const defaults = readText(defaultsPath);
|
|
146
|
+
const text = readText(file);
|
|
146
147
|
let updated: string;
|
|
147
148
|
if (key === "preset") {
|
|
148
149
|
if (value === "custom") return { error: "custom is what any other change makes" };
|
|
@@ -180,12 +181,12 @@ export async function settings(repoRoot: string, standalone = true) {
|
|
|
180
181
|
const { stdin, stdout } = process;
|
|
181
182
|
if (!stdin.isTTY) { console.log(`Settings live in ${file}`); return; }
|
|
182
183
|
|
|
183
|
-
const defaults =
|
|
184
|
+
const defaults = readText(join(repoRoot, "skills/kaizen/config.default.yml"));
|
|
184
185
|
let active = 0, saved = "";
|
|
185
186
|
// Screen line -> setting index, rebuilt on every draw; the mouse clicks by line.
|
|
186
187
|
let rowAt: number[] = [];
|
|
187
188
|
const draw = () => {
|
|
188
|
-
const text =
|
|
189
|
+
const text = readText(file);
|
|
189
190
|
const currentPreset = detectPreset(text, defaults);
|
|
190
191
|
// Booleans as a checkbox, numeric scales as a level bar; the file still holds the words.
|
|
191
192
|
const show = (s: Setting, v: string) => {
|
|
@@ -227,7 +228,7 @@ export async function settings(repoRoot: string, standalone = true) {
|
|
|
227
228
|
};
|
|
228
229
|
|
|
229
230
|
stdout.write("\x1b[?1049h\x1b[?25l"); // alternate screen, hide cursor
|
|
230
|
-
const mouse = /^\s*ui:\s*\n(?:\s*#.*\n)*\s*mouse:\s*(\S+)/m.exec(
|
|
231
|
+
const mouse = /^\s*ui:\s*\n(?:\s*#.*\n)*\s*mouse:\s*(\S+)/m.exec(readText(file));
|
|
231
232
|
const mouseOn = mouse?.[1] === "true";
|
|
232
233
|
if (mouseOn) { stdout.write("\x1b[?1000h\x1b[?1006h"); mouseArmed = true; }
|
|
233
234
|
stdin.setRawMode(true);
|
|
@@ -238,7 +239,7 @@ export async function settings(repoRoot: string, standalone = true) {
|
|
|
238
239
|
const s = SETTINGS[active]!;
|
|
239
240
|
const wasMouse = s.key === "ui.mouse";
|
|
240
241
|
if (s.text) { saved = c.dim(`type ${s.key} in the board's settings, or in ${tilde(file)}`); return; }
|
|
241
|
-
const text =
|
|
242
|
+
const text = readText(file);
|
|
242
243
|
if (s.key === "preset") {
|
|
243
244
|
const current = detectPreset(text, defaults);
|
|
244
245
|
const presets: PresetName[] = ["low", "medium", "ultra"];
|
package/cli/state.test.ts
CHANGED
|
@@ -466,3 +466,12 @@ test("notifier: only a run seen before, saying something new, and not a done the
|
|
|
466
466
|
expect(notifier.toString()).toStartWith("function notifier(");
|
|
467
467
|
expect(readFileSync(join(import.meta.dir, "..", "web", "board.html"), "utf8")).toContain("(/*notifier*/)()");
|
|
468
468
|
});
|
|
469
|
+
|
|
470
|
+
test("files written by Windows PowerShell, with a byte order mark, still read: the run shows, the first idea is an idea", () => {
|
|
471
|
+
const st = join(mkdtempSync(join(tmpdir(), "kaizen-bom-")), ".kaizen");
|
|
472
|
+
mkdirSync(join(st, "runs", "r1"), { recursive: true });
|
|
473
|
+
writeFileSync(join(st, "runs", "r1", "state.json"), "" + JSON.stringify({ id: "r1", stage: "plan", awaiting: "approvals.plan" }));
|
|
474
|
+
writeFileSync(join(st, "inbox.md"), "- open: first idea\n");
|
|
475
|
+
expect(readRuns(st).map((r) => [r.id, r.stage])).toEqual([["r1", "plan"]]);
|
|
476
|
+
expect(readInbox(st)).toEqual([{ status: "open", text: "first idea" }]);
|
|
477
|
+
});
|
package/cli/state.ts
CHANGED
|
@@ -25,7 +25,7 @@ export function detectDefaultAgent(projectDir: string): { name: string; cmd: str
|
|
|
25
25
|
? join(projectDir, ".kaizen", "config.yml")
|
|
26
26
|
: join(home, ".kaizen", "config.yml");
|
|
27
27
|
if (existsSync(cfgFile)) {
|
|
28
|
-
const text =
|
|
28
|
+
const text = readText(cfgFile);
|
|
29
29
|
const m = /^\s*agent:\s*\n\s*default:\s*(\S+)/m.exec(text) || /^\s*agent\.default:\s*(\S+)/m.exec(text);
|
|
30
30
|
if (m && m[1] && m[1] !== "auto") {
|
|
31
31
|
const found = KNOWN_AGENTS.find((a) => a.cmd === m[1] || a.name.toLowerCase() === m[1].toLowerCase());
|
|
@@ -59,7 +59,7 @@ const EFFORT_FLAGS: Record<string, (e: string) => string[]> = {
|
|
|
59
59
|
export function agentFlags(projectDir: string, cmd: string, configText?: string): string[] {
|
|
60
60
|
if (configText === undefined) {
|
|
61
61
|
const f = [join(projectDir, ".kaizen", "config.yml"), join(home, ".kaizen", "config.yml")].find(existsSync);
|
|
62
|
-
configText = f ?
|
|
62
|
+
configText = f ? readText(f) : "";
|
|
63
63
|
}
|
|
64
64
|
// ponytail: regex over the yaml, like detectDefaultAgent; a parser when a third key needs it
|
|
65
65
|
const block = new RegExp(`^agent:\\n(?:[ \\t]+.*\\n)*?[ \\t]+${cmd}:[ \\t]*(\\{[^}]*\\}|\\n(?:[ \\t]+[a-z]+:.*\\n?)+)`, "m").exec(configText);
|
|
@@ -77,6 +77,11 @@ export function agentFlags(projectDir: string, cmd: string, configText?: string)
|
|
|
77
77
|
const shq = (a: string) => `'${a.replace(/'/g, `'\\''`)}'`;
|
|
78
78
|
|
|
79
79
|
// Bun.which reads the PATH the process started with unless handed the current one.
|
|
80
|
+
// Windows PowerShell 5.1 writes `Set-Content -Encoding utf8` with a byte order mark,
|
|
81
|
+
// and agents on Windows write run files that way. JSON.parse refuses a leading BOM
|
|
82
|
+
// (the run vanished from the board) and it hides a first line from every ^ regex.
|
|
83
|
+
export const readText = (file: string) => readFileSync(file, "utf8").replace(/^\uFEFF/, "");
|
|
84
|
+
|
|
80
85
|
const onPath = (bin: string) => Bun.which(bin, { PATH: process.env.PATH });
|
|
81
86
|
|
|
82
87
|
// Windows' own programs by full path: a board started from the shortcut or a
|
|
@@ -93,7 +98,7 @@ function answers(cmd: string[]): boolean {
|
|
|
93
98
|
const bin = onPath(cmd[0]);
|
|
94
99
|
if (!bin) return false;
|
|
95
100
|
try {
|
|
96
|
-
return Bun.spawnSync([bin, ...cmd.slice(1)], { stdin: "ignore", stdout: "ignore", stderr: "ignore", timeout: 2000 }).exitCode === 0;
|
|
101
|
+
return Bun.spawnSync([bin, ...cmd.slice(1)], { stdin: "ignore", stdout: "ignore", stderr: "ignore", timeout: 2000, windowsHide: true }).exitCode === 0;
|
|
97
102
|
} catch {
|
|
98
103
|
return false;
|
|
99
104
|
}
|
|
@@ -211,7 +216,7 @@ export function searchRoots(extra: string[] = []): string[] {
|
|
|
211
216
|
|
|
212
217
|
export function knownProjects(): string[] {
|
|
213
218
|
if (!existsSync(REGISTRY)) return [];
|
|
214
|
-
return
|
|
219
|
+
return readText(REGISTRY).split("\n")
|
|
215
220
|
.map((l) => l.trim())
|
|
216
221
|
.filter((l) => l && !l.startsWith("#"))
|
|
217
222
|
.map(nativePath)
|
|
@@ -289,7 +294,7 @@ export function readRuns(state: string): Run[] {
|
|
|
289
294
|
return readdirSync(dir).sort().flatMap((id) => {
|
|
290
295
|
try {
|
|
291
296
|
const file = join(dir, id, "state.json");
|
|
292
|
-
const s = JSON.parse(
|
|
297
|
+
const s = JSON.parse(readText(file));
|
|
293
298
|
// When the run last moved. Kaizen records no liveness signal, so the file's
|
|
294
299
|
// own mtime is the only evidence that anything is still working on it.
|
|
295
300
|
let moved = 0;
|
|
@@ -310,7 +315,7 @@ export function pruneRuns(state: string, keep: number, apply = false): { id: str
|
|
|
310
315
|
const open = r.stage === "done" ? readBacklog(join(dir, "06-backlog.md")) : [];
|
|
311
316
|
if (apply) {
|
|
312
317
|
const orphanage = join(state, "backlog.md");
|
|
313
|
-
const gap = existsSync(orphanage) && !
|
|
318
|
+
const gap = existsSync(orphanage) && !readText(orphanage).endsWith("\n") ? "\n" : "";
|
|
314
319
|
if (open.length) appendFileSync(orphanage, gap + open.map((i) => `- open: ${i} | from ${r.id}\n`).join(""));
|
|
315
320
|
rmSync(dir, { recursive: true, force: true });
|
|
316
321
|
}
|
|
@@ -336,13 +341,13 @@ export function backlog(state: string) {
|
|
|
336
341
|
let n = 0;
|
|
337
342
|
for (const f of files) {
|
|
338
343
|
if (!existsSync(f)) continue;
|
|
339
|
-
n += (
|
|
344
|
+
n += (readText(f).match(/^\s*-\s*open:/gm) ?? []).length;
|
|
340
345
|
}
|
|
341
346
|
return n;
|
|
342
347
|
}
|
|
343
348
|
|
|
344
349
|
export function version(repo: string) {
|
|
345
|
-
try { return JSON.parse(
|
|
350
|
+
try { return JSON.parse(readText(join(repo, "package.json"))).version; } catch { return ""; }
|
|
346
351
|
}
|
|
347
352
|
// The global state directory is not a project and has no parent worth naming.
|
|
348
353
|
export function label(stateDir: string) {
|
|
@@ -354,7 +359,7 @@ export function tilde(p: string) { return p.startsWith(home) ? "~" + p.slice(hom
|
|
|
354
359
|
// The first few real lines of a file's body, blank lines and headings dropped.
|
|
355
360
|
export function section(file: string, n: number) {
|
|
356
361
|
if (!existsSync(file)) return [];
|
|
357
|
-
return
|
|
362
|
+
return readText(file).split("\n")
|
|
358
363
|
.map((l) => l.trim())
|
|
359
364
|
.filter((l) => l && !l.startsWith("#"))
|
|
360
365
|
.slice(0, n);
|
|
@@ -362,7 +367,7 @@ export function section(file: string, n: number) {
|
|
|
362
367
|
|
|
363
368
|
export function readFindings(file: string) {
|
|
364
369
|
if (!existsSync(file)) return [];
|
|
365
|
-
return
|
|
370
|
+
return readText(file).split("\n")
|
|
366
371
|
.map((l) => l.trim())
|
|
367
372
|
.filter((l) => /^\d+\.\s/.test(l) || /\b(critical|high|medium|low)\b:/.test(l));
|
|
368
373
|
}
|
|
@@ -377,7 +382,7 @@ export function reviewFindings(state: string, id: string) {
|
|
|
377
382
|
const base = join(state, "runs", id);
|
|
378
383
|
const read = (f: string) => {
|
|
379
384
|
const p = join(base, f);
|
|
380
|
-
return existsSync(p) ?
|
|
385
|
+
return existsSync(p) ? readText(p).split("\n").map((l) => l.trim()) : [];
|
|
381
386
|
};
|
|
382
387
|
let lines = read("04-review.md");
|
|
383
388
|
const at = lines.findIndex((l) => /^#+\s*findings/i.test(l));
|
|
@@ -390,7 +395,7 @@ export function reviewFindings(state: string, id: string) {
|
|
|
390
395
|
const closed = read("06-backlog.md").flatMap((l) => /^-\s*(?:done|rejected):\s*(.*)$/.exec(l)?.[1] ?? []);
|
|
391
396
|
const iter = join(base, "05-iterations");
|
|
392
397
|
const rechecked = new Set((existsSync(iter) ? readdirSync(iter) : []).filter((f) => f.endsWith("-recheck.md"))
|
|
393
|
-
.flatMap((f) => [...
|
|
398
|
+
.flatMap((f) => [...readText(join(iter, f)).matchAll(/finding\s+(\d+)\W{1,8}closed/gi)].map((m) => Number(m[1]))));
|
|
394
399
|
return lines.flatMap((l) => {
|
|
395
400
|
const m = /^(\d+)\.\s+(.+)$/.exec(l);
|
|
396
401
|
if (!m) return [];
|
|
@@ -439,7 +444,7 @@ export function tidy(text: string) {
|
|
|
439
444
|
|
|
440
445
|
export function readBacklog(file: string) {
|
|
441
446
|
if (!existsSync(file)) return [];
|
|
442
|
-
return
|
|
447
|
+
return readText(file).split("\n")
|
|
443
448
|
.map((l) => l.trim())
|
|
444
449
|
.filter((l) => /^-\s*open:/.test(l))
|
|
445
450
|
.map((l) => l.replace(/^-\s*open:\s*/, ""));
|
|
@@ -493,7 +498,7 @@ export function startedRuns(state: string): string[] {
|
|
|
493
498
|
if (!existsSync(dir)) return [];
|
|
494
499
|
const out: string[] = [];
|
|
495
500
|
for (const id of readdirSync(dir)) {
|
|
496
|
-
try { out.push(normalise(
|
|
501
|
+
try { out.push(normalise(readText(join(dir, id, "00-request.md")))); }
|
|
497
502
|
catch { /* no request file, or unreadable */ }
|
|
498
503
|
}
|
|
499
504
|
return out;
|
|
@@ -539,7 +544,7 @@ export function readInbox(state: string): InboxLine[] {
|
|
|
539
544
|
const f = join(state, "inbox.md");
|
|
540
545
|
if (!existsSync(f)) return [];
|
|
541
546
|
const out: InboxLine[] = [];
|
|
542
|
-
for (const raw of
|
|
547
|
+
for (const raw of readText(f).split("\n")) {
|
|
543
548
|
const m = /^-\s*(open|started|done|rejected|archived):\s*(.+)$/.exec(raw.trim());
|
|
544
549
|
if (m) { out.push({ status: m[1]!, text: m[2]!.trim() }); continue; }
|
|
545
550
|
// An indented line belongs to the bullet above it; anything else is noise.
|
|
@@ -579,7 +584,7 @@ export function requestOf(text: string, notes?: string, state?: string) {
|
|
|
579
584
|
// Free text beside a run -- what the user learned after the idea became a run.
|
|
580
585
|
// Its own file, never state.json: that shape is the resume contract.
|
|
581
586
|
export function readNotes(state: string, id: string) {
|
|
582
|
-
try { return
|
|
587
|
+
try { return readText(join(state, "runs", id, "notes.md")); } catch { return ""; }
|
|
583
588
|
}
|
|
584
589
|
|
|
585
590
|
export function writeNotes(state: string, id: string, text: string): string | null {
|
|
@@ -701,7 +706,7 @@ export function short(id: string) { return id.replace(/^\d{4}-\d{2}-\d{2}-/, "")
|
|
|
701
706
|
export function abandonRun(st: string, id: string, why: string): string | null {
|
|
702
707
|
const file = join(st, "runs", id, "state.json");
|
|
703
708
|
try {
|
|
704
|
-
const run = JSON.parse(
|
|
709
|
+
const run = JSON.parse(readText(file));
|
|
705
710
|
if (run.stage === "abandoned") return "That run is already abandoned.";
|
|
706
711
|
run.stage = "abandoned";
|
|
707
712
|
run.awaiting = null;
|
|
@@ -731,7 +736,7 @@ export function replaceIdea(state: string, text: string, next: InboxLine | null)
|
|
|
731
736
|
export function readArchive(state: string): Set<string> {
|
|
732
737
|
const f = join(state, "archive.md");
|
|
733
738
|
if (!existsSync(f)) return new Set();
|
|
734
|
-
return new Set(
|
|
739
|
+
return new Set(readText(f).split("\n")
|
|
735
740
|
.map((l) => /^-\s*(\S+)/.exec(l.trim())?.[1])
|
|
736
741
|
.filter((x): x is string => !!x));
|
|
737
742
|
}
|
|
@@ -772,7 +777,7 @@ export function boardCards(states: string[], now: number): Card[] {
|
|
|
772
777
|
const bodies = new Map<string, string>();
|
|
773
778
|
const body = (st: string, id: string) => {
|
|
774
779
|
const f = join(st, "runs", id, "00-request.md");
|
|
775
|
-
if (!bodies.has(f)) { try { bodies.set(f,
|
|
780
|
+
if (!bodies.has(f)) { try { bodies.set(f, readText(f)); } catch { bodies.set(f, ""); } }
|
|
776
781
|
return bodies.get(f)!;
|
|
777
782
|
};
|
|
778
783
|
const requests: string[] = [];
|
|
@@ -873,6 +878,9 @@ export function launchRun(it: Item, from: string): Launch {
|
|
|
873
878
|
stdout: "ignore",
|
|
874
879
|
stderr: "ignore",
|
|
875
880
|
detached: term.detached,
|
|
881
|
+
// A detached launch is the terminal window itself; anything else (herdr's
|
|
882
|
+
// launcher) is plumbing, and must not flash a console on Windows.
|
|
883
|
+
windowsHide: !term.detached,
|
|
876
884
|
});
|
|
877
885
|
if (term.detached) proc.unref();
|
|
878
886
|
return { ok: true, agent, prompt, projectDir, cmd: term.cmd };
|
|
@@ -897,7 +905,9 @@ export async function pidOnPort(port: number): Promise<number | null> {
|
|
|
897
905
|
// ---- git, for the board's commit and push. Always argv, never a shell: the commit
|
|
898
906
|
// message is whatever the user typed. A credential prompt has no terminal to appear
|
|
899
907
|
// in, so git is told not to ask and is given a minute at most.
|
|
900
|
-
|
|
908
|
+
// windowsHide: a board started from the Windows shortcut has no console, so each
|
|
909
|
+
// git it runs would open one of its own -- a window flashing on every refresh.
|
|
910
|
+
const gitOpts = () => ({ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, stdin: "ignore" as const, timeout: 60_000, windowsHide: true });
|
|
901
911
|
const gitDone = (code: number | null, stdout: string, stderr: string) => {
|
|
902
912
|
const out = stdout.trimEnd(), err = stderr.trim();
|
|
903
913
|
return { ok: code === 0, out, why: (err || out).split("\n").pop() || "git did not finish" };
|
|
@@ -944,7 +954,7 @@ function runReport(state: string, id: string) {
|
|
|
944
954
|
const base = join(state, "runs", id), its = join(base, "05-iterations");
|
|
945
955
|
const fixes = existsSync(its) ? readdirSync(its).filter((n) => n.endsWith("-fix.md")).map((n) => join(its, n)) : [];
|
|
946
956
|
let text = "", at = 0;
|
|
947
|
-
for (const f of [join(base, "03-impl.md"), ...fixes]) try { text +=
|
|
957
|
+
for (const f of [join(base, "03-impl.md"), ...fixes]) try { text += readText(f) + "\n"; at = Math.max(at, statSync(f).mtimeMs); } catch {}
|
|
948
958
|
return { text, at };
|
|
949
959
|
}
|
|
950
960
|
const names = (report: string, path: string) => report.includes("`" + path + "`") || report.includes("`" + path + ":");
|
|
@@ -987,7 +997,7 @@ export function runGit(state: string, id: string): GitStatus {
|
|
|
987
997
|
// mtime is the liveness signal.
|
|
988
998
|
export function readCommit(state: string, id: string): { sha: string; pushed: boolean } | null {
|
|
989
999
|
try {
|
|
990
|
-
const t =
|
|
1000
|
+
const t = readText(join(state, "runs", id, "07-commit.md"));
|
|
991
1001
|
return { sha: /^commit: (\S+)/m.exec(t)?.[1] ?? "", pushed: /^pushed: yes/m.test(t) };
|
|
992
1002
|
} catch { return null; }
|
|
993
1003
|
}
|
package/cli/web.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// `kaizen web`: the board in a browser. A loopback HTTP server over state.ts and
|
|
2
2
|
// one HTML page; the page never sees the filesystem, only JSON.
|
|
3
|
-
import { existsSync,
|
|
3
|
+
import { existsSync, readdirSync, statSync, watch, appendFileSync, mkdirSync, type FSWatcher } from "node:fs";
|
|
4
4
|
import { join, dirname } from "node:path";
|
|
5
5
|
import {
|
|
6
6
|
home, type Item, type Card, boardCards, knownProjects, remember, locate, label, tilde, searchRoots, findProjects,
|
|
7
7
|
readInbox, writeInbox, replaceIdea, abandonRun, launchRun, parseItem, short, setArchived, writeNotes,
|
|
8
|
-
appendNote, saveAttachment, pidOnPort, cmdExe, sysExe, powershellExe, runGit, cardsGit, readCommit, commitPush, notice, notifier, LOGO,
|
|
8
|
+
appendNote, saveAttachment, pidOnPort, readText, cmdExe, sysExe, powershellExe, runGit, cardsGit, readCommit, commitPush, notice, notifier, LOGO,
|
|
9
9
|
reviewFindings, pickFindings, fixPrompt,
|
|
10
10
|
} from "./state.ts";
|
|
11
11
|
import { readSources, addSource, removeSource, moveSource, gatherSource, gatherAll, gatherDirs, due } from "./sources.ts";
|
|
@@ -19,7 +19,7 @@ const DEFAULT_PORT = 7420;
|
|
|
19
19
|
// Read once at start: a board keeps the code it started with, so this is what it
|
|
20
20
|
// runs, not what is installed now. `/` sends it; `current()` compares the two.
|
|
21
21
|
const VERSION = (() => {
|
|
22
|
-
try { return JSON.parse(
|
|
22
|
+
try { return JSON.parse(readText(join(dirname(import.meta.dir), "package.json"))).version as string; }
|
|
23
23
|
catch { return "unknown"; }
|
|
24
24
|
})();
|
|
25
25
|
|
|
@@ -203,7 +203,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
203
203
|
if (req.method === "GET" && path === "/") {
|
|
204
204
|
if (!existsSync(PAGE)) return new Response("web/board.html missing", { status: 500 });
|
|
205
205
|
// The page gets the notify rule from state.ts rather than keeping its own copy.
|
|
206
|
-
return new Response(
|
|
206
|
+
return new Response(readText(PAGE).replace("/*notifier*/", () => notifier.toString()), {
|
|
207
207
|
headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "x-kaizen-version": VERSION },
|
|
208
208
|
});
|
|
209
209
|
}
|
|
@@ -260,7 +260,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
260
260
|
if (!dir || !states(true).includes(dir) || !/^[\w.-]+$/.test(id)) return bad("no such run", 404);
|
|
261
261
|
const base = join(dir, "runs", id);
|
|
262
262
|
if (!existsSync(base)) return bad("no such run", 404);
|
|
263
|
-
const read = (f: string) => existsSync(join(base, f)) ?
|
|
263
|
+
const read = (f: string) => existsSync(join(base, f)) ? readText(join(base, f)) : null;
|
|
264
264
|
return json({
|
|
265
265
|
id, short: short(id), state: read("state.json"),
|
|
266
266
|
request: read("00-request.md"), plan: read("01-plan.md"), approval: read("02-approval.md"),
|
|
@@ -508,7 +508,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
508
508
|
if (!/^[\w.-]+$/.test(id)) return bad("bad run id");
|
|
509
509
|
if (dir === join(home, ".kaizen")) return bad("no project to commit", 409);
|
|
510
510
|
let run: any;
|
|
511
|
-
try { run = JSON.parse(
|
|
511
|
+
try { run = JSON.parse(readText(join(dir, "runs", id, "state.json"))); } catch { return bad("no such run", 404); }
|
|
512
512
|
if (run.stage !== "done" && run.awaiting !== "approvals.review") return bad("this run is not finished", 409);
|
|
513
513
|
const r = await commitPush(dir, id, String(body.message ?? ""), Array.isArray(body.files) ? body.files.map(String) : []);
|
|
514
514
|
changed();
|
|
@@ -523,7 +523,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
523
523
|
if (!/^[\w.-]+$/.test(id)) return bad("bad run id");
|
|
524
524
|
if (dir === join(home, ".kaizen")) return bad("no project to fix in", 409);
|
|
525
525
|
let run: any;
|
|
526
|
-
try { run = JSON.parse(
|
|
526
|
+
try { run = JSON.parse(readText(join(dir, "runs", id, "state.json"))); } catch { return bad("no such run", 404); }
|
|
527
527
|
if (run.stage !== "done" && run.awaiting !== "approvals.review" && run.awaiting !== "findings") return bad("this run is still being worked on", 409);
|
|
528
528
|
const nums = pickFindings(dir, id, body.nums);
|
|
529
529
|
if (!nums) return bad("pick open findings of this review");
|
package/package.json
CHANGED