kaizen-agent 1.9.1 → 1.9.3
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/install.ts +1 -1
- package/cli/notes.ts +5 -5
- package/cli/settings.ts +10 -9
- package/cli/state.test.ts +51 -1
- package/cli/state.ts +63 -27
- package/cli/web.ts +14 -10
- package/package.json +1 -1
package/cli/install.ts
CHANGED
|
@@ -244,7 +244,7 @@ async function noAgent() {
|
|
|
244
244
|
async function openUrl(url: string) {
|
|
245
245
|
// `start` is a cmd builtin, not a program, so it has to go through cmd.
|
|
246
246
|
if (process.platform === "win32") {
|
|
247
|
-
try { await Bun
|
|
247
|
+
try { const { cmdExe } = await import("./state.ts"); await Bun.$`${cmdExe()} /c start "" ${url}`.quiet(); return true; } catch { return false; }
|
|
248
248
|
}
|
|
249
249
|
const opener = process.platform === "darwin" ? "open" : "xdg-open";
|
|
250
250
|
if (!Bun.which(opener)) return false;
|
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
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
readRuns, readInbox, writeInbox, replaceIdea, abandonRun, allBacklog, backlog,
|
|
9
9
|
statusOf, columnOf, parseItem, startedRuns, boardCards, readArchive, setArchived,
|
|
10
10
|
requestOf, agentFlags, readNotes, writeNotes, manualCommand, parseNotes, formatNote, appendNote, saveAttachment,
|
|
11
|
-
reviewFindings, pickFindings, fixPrompt, gitStatus, runGit, cardsGit, commitPush, readCommit, findTerminal, pruneRuns, unrechecked, notice, notifier, notifyArgs, LOGO, type Card,
|
|
11
|
+
reviewFindings, pickFindings, fixPrompt, gitStatus, runGit, cardsGit, commitPush, readCommit, findTerminal, nativePath, pruneRuns, unrechecked, notice, notifier, notifyArgs, LOGO, type Card,
|
|
12
12
|
} from "./state.ts";
|
|
13
13
|
|
|
14
14
|
let state: string;
|
|
@@ -355,6 +355,47 @@ test.skipIf(process.platform === "win32")("findTerminal: a running herdr wins, t
|
|
|
355
355
|
}
|
|
356
356
|
});
|
|
357
357
|
|
|
358
|
+
test("nativePath on Windows reads the spellings Git Bash writes into the projects registry as one folder", () => {
|
|
359
|
+
const platform = Object.getOwnPropertyDescriptor(process, "platform")!;
|
|
360
|
+
try {
|
|
361
|
+
Object.defineProperty(process, "platform", { value: "win32" });
|
|
362
|
+
for (const p of ["/c/Users/me/proj", "C:/Users/me/proj", "c:\\Users\\me\\proj\\", "C:\\Users\\me\\proj"])
|
|
363
|
+
expect(nativePath(p)).toBe("C:\\Users\\me\\proj");
|
|
364
|
+
expect(nativePath("/d")).toBe("D:\\");
|
|
365
|
+
} finally { Object.defineProperty(process, "platform", platform); }
|
|
366
|
+
expect(nativePath("/c/Users/me")).toBe("/c/Users/me"); // elsewhere a path is left alone
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("findTerminal on Windows names cmd and PowerShell by full path, so a PATH without System32 still launches", () => {
|
|
370
|
+
const keys = ["PATH", "ComSpec", "SystemRoot", "windir"];
|
|
371
|
+
const saved = keys.map((k) => process.env[k]);
|
|
372
|
+
const platform = Object.getOwnPropertyDescriptor(process, "platform")!;
|
|
373
|
+
try {
|
|
374
|
+
Object.defineProperty(process, "platform", { value: "win32" });
|
|
375
|
+
process.env.PATH = mkdtempSync(join(tmpdir(), "kz-empty-"));
|
|
376
|
+
for (const k of keys.slice(1)) delete process.env[k];
|
|
377
|
+
process.env.SystemRoot = "D:\\Win";
|
|
378
|
+
const t = findTerminal("C:\\p", ["claude", "/kaizen x"])!;
|
|
379
|
+
expect(t.cmd[0]).toBe("D:\\Win\\System32\\cmd.exe");
|
|
380
|
+
expect(t.cmd[6]).toBe("D:\\Win\\System32\\WindowsPowerShell\\v1.0\\powershell.exe");
|
|
381
|
+
process.env.ComSpec = "E:\\cmd.exe";
|
|
382
|
+
expect(findTerminal("C:\\p", ["claude"])!.cmd[0]).toBe("E:\\cmd.exe");
|
|
383
|
+
// A running herdr: a tab, and the run typed into it as one encoded line.
|
|
384
|
+
writeFileSync(join(process.env.PATH, "herdr"), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
|
|
385
|
+
const h = findTerminal("C:\\p", ["claude", "/kaizen it's\nnote"])!;
|
|
386
|
+
expect([h.cmd[0], h.cmd[1], h.cmd[2], h.detached]).toEqual(["D:\\Win\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", "-NoProfile", "-EncodedCommand", false]);
|
|
387
|
+
const launch = Buffer.from(h.cmd[3], "base64").toString("utf16le");
|
|
388
|
+
expect(launch).toContain("tab create --cwd 'C:\\p' --label kaizen --focus");
|
|
389
|
+
const typed = /pane run \$Matches\[1\] '(.*)' \}/.exec(launch)![1].replace(/''/g, "'");
|
|
390
|
+
expect(typed).not.toContain("\n");
|
|
391
|
+
const run = Buffer.from(typed.split(" -EncodedCommand ")[1], "base64").toString("utf16le");
|
|
392
|
+
expect(run).toBe("Set-Location 'C:\\p'; & 'claude' '/kaizen it''s\nnote'");
|
|
393
|
+
} finally {
|
|
394
|
+
Object.defineProperty(process, "platform", platform);
|
|
395
|
+
keys.forEach((k, i) => (saved[i] === undefined ? delete process.env[k] : (process.env[k] = saved[i])));
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
|
|
358
399
|
test("prune: only finished runs beyond keep go, open items are rescued, a dry run deletes nothing", () => {
|
|
359
400
|
const st = join(mkdtempSync(join(tmpdir(), "kaizen-prune-")), ".kaizen");
|
|
360
401
|
const mk = (id: string, stage: string, backlog = "") => {
|
|
@@ -425,3 +466,12 @@ test("notifier: only a run seen before, saying something new, and not a done the
|
|
|
425
466
|
expect(notifier.toString()).toStartWith("function notifier(");
|
|
426
467
|
expect(readFileSync(join(import.meta.dir, "..", "web", "board.html"), "utf8")).toContain("(/*notifier*/)()");
|
|
427
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
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// `.kaizen/` directory. No terminal, no HTTP: the TUI (`dashboard.ts`) and the web
|
|
3
3
|
// board (`web.ts`) both import this and draw it their own way.
|
|
4
4
|
import { existsSync, readFileSync, readdirSync, appendFileSync, writeFileSync, mkdirSync, statSync, realpathSync, rmSync } from "node:fs";
|
|
5
|
-
import { join, dirname } from "node:path";
|
|
5
|
+
import { join, dirname, win32 } from "node:path";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
|
|
8
8
|
// The real path: process.cwd() is always physical, so a raw $HOME that crosses a
|
|
@@ -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,8 +77,21 @@ 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
|
|
|
87
|
+
// Windows' own programs by full path: a board started from the shortcut or a
|
|
88
|
+
// stripped-down environment can have a PATH without System32, and a bare "cmd.exe"
|
|
89
|
+
// then fails with "Executable not found in $PATH".
|
|
90
|
+
const sysRoot = () => process.env.SystemRoot ?? process.env.windir ?? "C:\\Windows";
|
|
91
|
+
export const sysExe = (name: string) => `${sysRoot()}\\System32\\${name}`;
|
|
92
|
+
export const cmdExe = () => process.env.ComSpec ?? sysExe("cmd.exe");
|
|
93
|
+
export const powershellExe = () => onPath("pwsh.exe") ?? onPath("powershell.exe") ?? sysExe("WindowsPowerShell\\v1.0\\powershell.exe");
|
|
94
|
+
|
|
82
95
|
// Whether a multiplexer's server is up: its own CLI says so by exit code. spawnSync
|
|
83
96
|
// throws on a missing binary, so the path is resolved first.
|
|
84
97
|
function answers(cmd: string[]): boolean {
|
|
@@ -102,16 +115,28 @@ export function findTerminal(cwd: string, fullCmd: string[]): { cmd: string[]; d
|
|
|
102
115
|
// Terminal hosts the shell when installed; otherwise `start` gives it a window.
|
|
103
116
|
if (process.platform === "win32") {
|
|
104
117
|
const q = (a: string) => `'${a.replace(/'/g, "''")}'`;
|
|
105
|
-
const shell =
|
|
118
|
+
const shell = powershellExe();
|
|
106
119
|
const ps = `Set-Location ${q(cwd)}; & ${fullCmd.map(q).join(" ")}`;
|
|
107
120
|
const enc = Buffer.from(ps, "utf16le").toString("base64");
|
|
108
|
-
|
|
109
|
-
|
|
121
|
+
// herdr (beta on Windows) first, as elsewhere: a new tab, and the run typed into
|
|
122
|
+
// its PowerShell. Typed text ends at a newline, so what is typed is one line, the
|
|
123
|
+
// same encoded command; -EncodedCommand is also not a script file, so an
|
|
124
|
+
// execution policy that blocks .ps1 files does not stop it.
|
|
125
|
+
const herdr = onPath("herdr");
|
|
126
|
+
if (herdr && answers([herdr, "workspace", "list"])) {
|
|
127
|
+
const typed = `& ${q(shell)} -NoProfile -EncodedCommand ${enc}`;
|
|
128
|
+
const launch = `$o = & ${q(herdr)} tab create --cwd ${q(cwd)} --label kaizen --focus | Out-String
|
|
129
|
+
if ($o -match '"pane_id":"([^"]*)"') { & ${q(herdr)} pane run $Matches[1] ${q(typed)} } else { exit 1 }`;
|
|
130
|
+
return { cmd: [shell, "-NoProfile", "-EncodedCommand", Buffer.from(launch, "utf16le").toString("base64")], detached: false };
|
|
131
|
+
}
|
|
132
|
+
const wt = onPath("wt.exe");
|
|
133
|
+
if (wt) {
|
|
134
|
+
return { cmd: [wt, "-d", cwd, shell, "-NoExit", "-EncodedCommand", enc], detached: true };
|
|
110
135
|
}
|
|
111
136
|
// `start` reads a title only when quoted; an unquoted word is the command, so
|
|
112
137
|
// "kaizen" here ran kaizen's own launcher. The empty title is what Bun quotes.
|
|
113
138
|
return {
|
|
114
|
-
cmd: [
|
|
139
|
+
cmd: [cmdExe(), "/c", "start", "", "/D", cwd, shell, "-NoExit", "-EncodedCommand", enc],
|
|
115
140
|
detached: true,
|
|
116
141
|
};
|
|
117
142
|
}
|
|
@@ -191,9 +216,20 @@ export function searchRoots(extra: string[] = []): string[] {
|
|
|
191
216
|
|
|
192
217
|
export function knownProjects(): string[] {
|
|
193
218
|
if (!existsSync(REGISTRY)) return [];
|
|
194
|
-
return
|
|
219
|
+
return readText(REGISTRY).split("\n")
|
|
195
220
|
.map((l) => l.trim())
|
|
196
|
-
.filter((l) => l && !l.startsWith("#"))
|
|
221
|
+
.filter((l) => l && !l.startsWith("#"))
|
|
222
|
+
.map(nativePath)
|
|
223
|
+
.filter((d, i, all) => all.indexOf(d) === i);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// On Windows the registry is also written by agents running Git Bash, which says
|
|
227
|
+
// /c/Users/me/proj or C:/Users/me/proj. Read as is, the first is C:\c\Users\..., a
|
|
228
|
+
// folder that does not exist, and the project drops off the board. One spelling
|
|
229
|
+
// per folder, drive letter upper case, so the same project is not listed twice.
|
|
230
|
+
export function nativePath(p: string) {
|
|
231
|
+
if (process.platform !== "win32") return p;
|
|
232
|
+
return win32.resolve(p.replace(/^\/([a-zA-Z])(?=\/|$)/, "$1:/")).replace(/^[a-z]:/, (d) => d.toUpperCase());
|
|
197
233
|
}
|
|
198
234
|
|
|
199
235
|
// Never fails the command that triggered it: a read-only home is not a reason for a
|
|
@@ -258,7 +294,7 @@ export function readRuns(state: string): Run[] {
|
|
|
258
294
|
return readdirSync(dir).sort().flatMap((id) => {
|
|
259
295
|
try {
|
|
260
296
|
const file = join(dir, id, "state.json");
|
|
261
|
-
const s = JSON.parse(
|
|
297
|
+
const s = JSON.parse(readText(file));
|
|
262
298
|
// When the run last moved. Kaizen records no liveness signal, so the file's
|
|
263
299
|
// own mtime is the only evidence that anything is still working on it.
|
|
264
300
|
let moved = 0;
|
|
@@ -279,7 +315,7 @@ export function pruneRuns(state: string, keep: number, apply = false): { id: str
|
|
|
279
315
|
const open = r.stage === "done" ? readBacklog(join(dir, "06-backlog.md")) : [];
|
|
280
316
|
if (apply) {
|
|
281
317
|
const orphanage = join(state, "backlog.md");
|
|
282
|
-
const gap = existsSync(orphanage) && !
|
|
318
|
+
const gap = existsSync(orphanage) && !readText(orphanage).endsWith("\n") ? "\n" : "";
|
|
283
319
|
if (open.length) appendFileSync(orphanage, gap + open.map((i) => `- open: ${i} | from ${r.id}\n`).join(""));
|
|
284
320
|
rmSync(dir, { recursive: true, force: true });
|
|
285
321
|
}
|
|
@@ -305,13 +341,13 @@ export function backlog(state: string) {
|
|
|
305
341
|
let n = 0;
|
|
306
342
|
for (const f of files) {
|
|
307
343
|
if (!existsSync(f)) continue;
|
|
308
|
-
n += (
|
|
344
|
+
n += (readText(f).match(/^\s*-\s*open:/gm) ?? []).length;
|
|
309
345
|
}
|
|
310
346
|
return n;
|
|
311
347
|
}
|
|
312
348
|
|
|
313
349
|
export function version(repo: string) {
|
|
314
|
-
try { return JSON.parse(
|
|
350
|
+
try { return JSON.parse(readText(join(repo, "package.json"))).version; } catch { return ""; }
|
|
315
351
|
}
|
|
316
352
|
// The global state directory is not a project and has no parent worth naming.
|
|
317
353
|
export function label(stateDir: string) {
|
|
@@ -323,7 +359,7 @@ export function tilde(p: string) { return p.startsWith(home) ? "~" + p.slice(hom
|
|
|
323
359
|
// The first few real lines of a file's body, blank lines and headings dropped.
|
|
324
360
|
export function section(file: string, n: number) {
|
|
325
361
|
if (!existsSync(file)) return [];
|
|
326
|
-
return
|
|
362
|
+
return readText(file).split("\n")
|
|
327
363
|
.map((l) => l.trim())
|
|
328
364
|
.filter((l) => l && !l.startsWith("#"))
|
|
329
365
|
.slice(0, n);
|
|
@@ -331,7 +367,7 @@ export function section(file: string, n: number) {
|
|
|
331
367
|
|
|
332
368
|
export function readFindings(file: string) {
|
|
333
369
|
if (!existsSync(file)) return [];
|
|
334
|
-
return
|
|
370
|
+
return readText(file).split("\n")
|
|
335
371
|
.map((l) => l.trim())
|
|
336
372
|
.filter((l) => /^\d+\.\s/.test(l) || /\b(critical|high|medium|low)\b:/.test(l));
|
|
337
373
|
}
|
|
@@ -346,7 +382,7 @@ export function reviewFindings(state: string, id: string) {
|
|
|
346
382
|
const base = join(state, "runs", id);
|
|
347
383
|
const read = (f: string) => {
|
|
348
384
|
const p = join(base, f);
|
|
349
|
-
return existsSync(p) ?
|
|
385
|
+
return existsSync(p) ? readText(p).split("\n").map((l) => l.trim()) : [];
|
|
350
386
|
};
|
|
351
387
|
let lines = read("04-review.md");
|
|
352
388
|
const at = lines.findIndex((l) => /^#+\s*findings/i.test(l));
|
|
@@ -359,7 +395,7 @@ export function reviewFindings(state: string, id: string) {
|
|
|
359
395
|
const closed = read("06-backlog.md").flatMap((l) => /^-\s*(?:done|rejected):\s*(.*)$/.exec(l)?.[1] ?? []);
|
|
360
396
|
const iter = join(base, "05-iterations");
|
|
361
397
|
const rechecked = new Set((existsSync(iter) ? readdirSync(iter) : []).filter((f) => f.endsWith("-recheck.md"))
|
|
362
|
-
.flatMap((f) => [...
|
|
398
|
+
.flatMap((f) => [...readText(join(iter, f)).matchAll(/finding\s+(\d+)\W{1,8}closed/gi)].map((m) => Number(m[1]))));
|
|
363
399
|
return lines.flatMap((l) => {
|
|
364
400
|
const m = /^(\d+)\.\s+(.+)$/.exec(l);
|
|
365
401
|
if (!m) return [];
|
|
@@ -408,7 +444,7 @@ export function tidy(text: string) {
|
|
|
408
444
|
|
|
409
445
|
export function readBacklog(file: string) {
|
|
410
446
|
if (!existsSync(file)) return [];
|
|
411
|
-
return
|
|
447
|
+
return readText(file).split("\n")
|
|
412
448
|
.map((l) => l.trim())
|
|
413
449
|
.filter((l) => /^-\s*open:/.test(l))
|
|
414
450
|
.map((l) => l.replace(/^-\s*open:\s*/, ""));
|
|
@@ -462,7 +498,7 @@ export function startedRuns(state: string): string[] {
|
|
|
462
498
|
if (!existsSync(dir)) return [];
|
|
463
499
|
const out: string[] = [];
|
|
464
500
|
for (const id of readdirSync(dir)) {
|
|
465
|
-
try { out.push(normalise(
|
|
501
|
+
try { out.push(normalise(readText(join(dir, id, "00-request.md")))); }
|
|
466
502
|
catch { /* no request file, or unreadable */ }
|
|
467
503
|
}
|
|
468
504
|
return out;
|
|
@@ -508,7 +544,7 @@ export function readInbox(state: string): InboxLine[] {
|
|
|
508
544
|
const f = join(state, "inbox.md");
|
|
509
545
|
if (!existsSync(f)) return [];
|
|
510
546
|
const out: InboxLine[] = [];
|
|
511
|
-
for (const raw of
|
|
547
|
+
for (const raw of readText(f).split("\n")) {
|
|
512
548
|
const m = /^-\s*(open|started|done|rejected|archived):\s*(.+)$/.exec(raw.trim());
|
|
513
549
|
if (m) { out.push({ status: m[1]!, text: m[2]!.trim() }); continue; }
|
|
514
550
|
// An indented line belongs to the bullet above it; anything else is noise.
|
|
@@ -548,7 +584,7 @@ export function requestOf(text: string, notes?: string, state?: string) {
|
|
|
548
584
|
// Free text beside a run -- what the user learned after the idea became a run.
|
|
549
585
|
// Its own file, never state.json: that shape is the resume contract.
|
|
550
586
|
export function readNotes(state: string, id: string) {
|
|
551
|
-
try { return
|
|
587
|
+
try { return readText(join(state, "runs", id, "notes.md")); } catch { return ""; }
|
|
552
588
|
}
|
|
553
589
|
|
|
554
590
|
export function writeNotes(state: string, id: string, text: string): string | null {
|
|
@@ -670,7 +706,7 @@ export function short(id: string) { return id.replace(/^\d{4}-\d{2}-\d{2}-/, "")
|
|
|
670
706
|
export function abandonRun(st: string, id: string, why: string): string | null {
|
|
671
707
|
const file = join(st, "runs", id, "state.json");
|
|
672
708
|
try {
|
|
673
|
-
const run = JSON.parse(
|
|
709
|
+
const run = JSON.parse(readText(file));
|
|
674
710
|
if (run.stage === "abandoned") return "That run is already abandoned.";
|
|
675
711
|
run.stage = "abandoned";
|
|
676
712
|
run.awaiting = null;
|
|
@@ -700,7 +736,7 @@ export function replaceIdea(state: string, text: string, next: InboxLine | null)
|
|
|
700
736
|
export function readArchive(state: string): Set<string> {
|
|
701
737
|
const f = join(state, "archive.md");
|
|
702
738
|
if (!existsSync(f)) return new Set();
|
|
703
|
-
return new Set(
|
|
739
|
+
return new Set(readText(f).split("\n")
|
|
704
740
|
.map((l) => /^-\s*(\S+)/.exec(l.trim())?.[1])
|
|
705
741
|
.filter((x): x is string => !!x));
|
|
706
742
|
}
|
|
@@ -741,7 +777,7 @@ export function boardCards(states: string[], now: number): Card[] {
|
|
|
741
777
|
const bodies = new Map<string, string>();
|
|
742
778
|
const body = (st: string, id: string) => {
|
|
743
779
|
const f = join(st, "runs", id, "00-request.md");
|
|
744
|
-
if (!bodies.has(f)) { try { bodies.set(f,
|
|
780
|
+
if (!bodies.has(f)) { try { bodies.set(f, readText(f)); } catch { bodies.set(f, ""); } }
|
|
745
781
|
return bodies.get(f)!;
|
|
746
782
|
};
|
|
747
783
|
const requests: string[] = [];
|
|
@@ -854,7 +890,7 @@ export function launchRun(it: Item, from: string): Launch {
|
|
|
854
890
|
export async function pidOnPort(port: number): Promise<number | null> {
|
|
855
891
|
try {
|
|
856
892
|
if (process.platform === "win32") {
|
|
857
|
-
const out = await Bun
|
|
893
|
+
const out = await Bun.$`${sysExe("netstat.exe")} -ano -p tcp`.text();
|
|
858
894
|
const m = new RegExp(`127\\.0\\.0\\.1:${port}\\s+\\S+\\s+LISTENING\\s+(\\d+)`).exec(out);
|
|
859
895
|
return m ? Number(m[1]) : null;
|
|
860
896
|
}
|
|
@@ -913,7 +949,7 @@ function runReport(state: string, id: string) {
|
|
|
913
949
|
const base = join(state, "runs", id), its = join(base, "05-iterations");
|
|
914
950
|
const fixes = existsSync(its) ? readdirSync(its).filter((n) => n.endsWith("-fix.md")).map((n) => join(its, n)) : [];
|
|
915
951
|
let text = "", at = 0;
|
|
916
|
-
for (const f of [join(base, "03-impl.md"), ...fixes]) try { text +=
|
|
952
|
+
for (const f of [join(base, "03-impl.md"), ...fixes]) try { text += readText(f) + "\n"; at = Math.max(at, statSync(f).mtimeMs); } catch {}
|
|
917
953
|
return { text, at };
|
|
918
954
|
}
|
|
919
955
|
const names = (report: string, path: string) => report.includes("`" + path + "`") || report.includes("`" + path + ":");
|
|
@@ -956,7 +992,7 @@ export function runGit(state: string, id: string): GitStatus {
|
|
|
956
992
|
// mtime is the liveness signal.
|
|
957
993
|
export function readCommit(state: string, id: string): { sha: string; pushed: boolean } | null {
|
|
958
994
|
try {
|
|
959
|
-
const t =
|
|
995
|
+
const t = readText(join(state, "runs", id, "07-commit.md"));
|
|
960
996
|
return { sha: /^commit: (\S+)/m.exec(t)?.[1] ?? "", pushed: /^pushed: yes/m.test(t) };
|
|
961
997
|
} catch { return null; }
|
|
962
998
|
}
|
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, 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
|
|
|
@@ -188,6 +188,10 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
188
188
|
function serve(port: number) { return Bun.serve({
|
|
189
189
|
hostname: "127.0.0.1",
|
|
190
190
|
port,
|
|
191
|
+
// Bun drops a request after 10s idle by default. A slow /state (git status in a
|
|
192
|
+
// big repo, on Windows) then comes back as nothing, and the page shows an empty
|
|
193
|
+
// board. Loopback only, so nothing waits on us but our own page.
|
|
194
|
+
idleTimeout: 0,
|
|
191
195
|
async fetch(req) {
|
|
192
196
|
// Host first: a hostname rebound to 127.0.0.1 by an attacker's DNS still
|
|
193
197
|
// arrives with that hostname in Host, and gets nothing.
|
|
@@ -199,7 +203,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
199
203
|
if (req.method === "GET" && path === "/") {
|
|
200
204
|
if (!existsSync(PAGE)) return new Response("web/board.html missing", { status: 500 });
|
|
201
205
|
// The page gets the notify rule from state.ts rather than keeping its own copy.
|
|
202
|
-
return new Response(
|
|
206
|
+
return new Response(readText(PAGE).replace("/*notifier*/", () => notifier.toString()), {
|
|
203
207
|
headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "x-kaizen-version": VERSION },
|
|
204
208
|
});
|
|
205
209
|
}
|
|
@@ -256,7 +260,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
256
260
|
if (!dir || !states(true).includes(dir) || !/^[\w.-]+$/.test(id)) return bad("no such run", 404);
|
|
257
261
|
const base = join(dir, "runs", id);
|
|
258
262
|
if (!existsSync(base)) return bad("no such run", 404);
|
|
259
|
-
const read = (f: string) => existsSync(join(base, f)) ?
|
|
263
|
+
const read = (f: string) => existsSync(join(base, f)) ? readText(join(base, f)) : null;
|
|
260
264
|
return json({
|
|
261
265
|
id, short: short(id), state: read("state.json"),
|
|
262
266
|
request: read("00-request.md"), plan: read("01-plan.md"), approval: read("02-approval.md"),
|
|
@@ -504,7 +508,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
504
508
|
if (!/^[\w.-]+$/.test(id)) return bad("bad run id");
|
|
505
509
|
if (dir === join(home, ".kaizen")) return bad("no project to commit", 409);
|
|
506
510
|
let run: any;
|
|
507
|
-
try { run = JSON.parse(
|
|
511
|
+
try { run = JSON.parse(readText(join(dir, "runs", id, "state.json"))); } catch { return bad("no such run", 404); }
|
|
508
512
|
if (run.stage !== "done" && run.awaiting !== "approvals.review") return bad("this run is not finished", 409);
|
|
509
513
|
const r = await commitPush(dir, id, String(body.message ?? ""), Array.isArray(body.files) ? body.files.map(String) : []);
|
|
510
514
|
changed();
|
|
@@ -519,7 +523,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
519
523
|
if (!/^[\w.-]+$/.test(id)) return bad("bad run id");
|
|
520
524
|
if (dir === join(home, ".kaizen")) return bad("no project to fix in", 409);
|
|
521
525
|
let run: any;
|
|
522
|
-
try { run = JSON.parse(
|
|
526
|
+
try { run = JSON.parse(readText(join(dir, "runs", id, "state.json"))); } catch { return bad("no such run", 404); }
|
|
523
527
|
if (run.stage !== "done" && run.awaiting !== "approvals.review" && run.awaiting !== "findings") return bad("this run is still being worked on", 409);
|
|
524
528
|
const nums = pickFindings(dir, id, body.nums);
|
|
525
529
|
if (!nums) return bad("pick open findings of this review");
|
|
@@ -583,7 +587,7 @@ export async function openApp(url: string) {
|
|
|
583
587
|
} catch { /* fall through to a plain tab */ }
|
|
584
588
|
// `start` is a cmd builtin, not a program, so it has to go through cmd.
|
|
585
589
|
if (process.platform === "win32") {
|
|
586
|
-
try { await Bun
|
|
590
|
+
try { await Bun.$`${cmdExe()} /c start "" ${url}`.quiet(); return true; } catch { return false; }
|
|
587
591
|
}
|
|
588
592
|
const opener = process.platform === "darwin" ? "open" : "xdg-open";
|
|
589
593
|
if (!Bun.which(opener)) return false;
|
|
@@ -644,8 +648,8 @@ export async function shortcut() {
|
|
|
644
648
|
mkdirSync(dirname(vbs), { recursive: true });
|
|
645
649
|
const vq = (s: string) => `""${s}""`; // a quoted arg inside a VBScript string
|
|
646
650
|
writeFileSync(vbs, `CreateObject("WScript.Shell").Run "${vq(bun)} ${vq(script)} web --daemon", 0, False\r\n`);
|
|
647
|
-
const ps = `$s=(New-Object -ComObject WScript.Shell).CreateShortcut('${lnk}');$s.TargetPath='wscript.exe';$s.Arguments='"${vbs}"';$s.WorkingDirectory='${home}';${existsSync(ico) ? `$s.IconLocation='${ico}';` : ""}$s.Save()`;
|
|
648
|
-
await Bun
|
|
651
|
+
const ps = `$s=(New-Object -ComObject WScript.Shell).CreateShortcut('${lnk}');$s.TargetPath='${sysExe("wscript.exe")}';$s.Arguments='"${vbs}"';$s.WorkingDirectory='${home}';${existsSync(ico) ? `$s.IconLocation='${ico}';` : ""}$s.Save()`;
|
|
652
|
+
await Bun.$`${powershellExe()} -NoProfile -Command ${ps}`.quiet();
|
|
649
653
|
return `${lnk} — double-click starts the board in the background and opens it; kaizen web --stop ends it`;
|
|
650
654
|
}
|
|
651
655
|
|
package/package.json
CHANGED