kaizen-agent 1.9.4 → 1.11.0
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 +24 -6
- package/agents/kaizen-builder.md +4 -1
- package/cli/board.test.ts +115 -0
- package/cli/dashboard.ts +1 -0
- package/cli/install.test.ts +85 -0
- package/cli/install.ts +60 -10
- package/cli/notes.test.ts +1 -1
- package/cli/settings.test.ts +50 -0
- package/cli/settings.ts +91 -36
- package/cli/sources.test.ts +44 -5
- package/cli/sources.ts +11 -6
- package/cli/state.test.ts +326 -3
- package/cli/state.ts +361 -47
- package/cli/web.ts +134 -16
- package/package.json +1 -1
- package/skills/kaizen/SKILL.md +5 -1
- package/skills/kaizen/config.default.yml +10 -4
- package/skills/kaizen/spec-build.md +17 -0
- package/skills/kaizen/spec-plan.md +6 -1
- package/web/board.html +363 -85
- package/web/notebook.js +2 -2
- package/web/notes-lib.ts +1 -1
- package/web/notes.html +1 -0
package/cli/settings.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { homedir } from "node:os";
|
|
|
6
6
|
import { readText } from "./state.ts";
|
|
7
7
|
|
|
8
8
|
// `text` is a value typed rather than chosen: a path. It has no values to cycle.
|
|
9
|
-
type Setting = { key: string; label: string; group: string; values: string[]; help: string; text?: boolean };
|
|
9
|
+
type Setting = { key: string; label: string; group: string; values: string[]; help: string; text?: boolean; folder?: boolean };
|
|
10
10
|
|
|
11
11
|
export type PresetName = "low" | "medium" | "ultra";
|
|
12
12
|
|
|
@@ -96,28 +96,28 @@ export function applyPreset(text: string, preset: PresetName, defaults: string):
|
|
|
96
96
|
|
|
97
97
|
const SETTINGS: Setting[] = [
|
|
98
98
|
{ key: "preset", label: "Preset", group: "Preset", values: ["low", "medium", "ultra", "custom"], help: "Sets every row below at once. low: you do it here, step by step. medium: balanced. ultra: hands-off. Change any row and it becomes custom" },
|
|
99
|
-
{ key: "mode", label: "Stop for approval", group: "
|
|
100
|
-
{ key: "
|
|
101
|
-
{ key: "
|
|
102
|
-
{ key: "
|
|
103
|
-
{ key: "approvals.plan", label: "Approve the plan first", group: "Planning", values: ["true", "false"], help: "Show the plan and wait for your OK before anything is built" },
|
|
104
|
-
{ key: "track.default", label: "Kind of work", group: "Planning", values: ["auto", "software", "writing", "communication", "operations", "research"], help: "Pin what runs in this project are (a docs repo is always writing). auto: the planner infers it per run" },
|
|
105
|
-
{ key: "build.executor", label: "Who builds", group: "Building", values: ["subagent", "inline", "ask"], help: "subagent: a separate agent builds and reports back. inline: this session builds while you watch. ask: choose at the plan approval" },
|
|
106
|
-
{ key: "approvals.each_file", label: "Confirm every file edit", group: "Building", values: ["true", "false"], help: "Ask before each individual file change. Maximum control, slowest" },
|
|
107
|
-
{ key: "git.branch_before_implement", label: "Branch before building", group: "Building", values: ["true", "false"], help: "Create a branch before building when you are on the default branch" },
|
|
108
|
-
{ key: "approvals.review", label: "Approve after review", group: "Review", values: ["true", "false"], help: "After the review, wait for your OK before the run is called done" },
|
|
109
|
-
{ key: "auto_fix.enabled", label: "Fix findings automatically", group: "Review", values: ["true", "false"], help: "true: findings severe enough get fixed and re-reviewed without asking. false: every finding stops and asks you" },
|
|
110
|
-
{ key: "auto_fix.min_severity", label: "Auto-fix from severity", group: "Review", values: ["critical", "high", "medium", "low"], help: "The least severe finding still fixed automatically. low fixes everything; critical fixes almost nothing" },
|
|
111
|
-
{ key: "auto_fix.max_iterations", label: "Max fix rounds", group: "Review", values: ["1", "2", "3", "4", "5"], help: "Fix-then-recheck rounds before leftovers are handed to you. Each round is a full build plus a full review, the priciest knob here" },
|
|
112
|
-
{ key: "review.write_memory", label: "Remember lessons", group: "Review", values: ["true", "false"], help: "Let the reviewer save what it learned to .kaizen/memory.md, which every future run reads" },
|
|
113
|
-
{ key: "review.memory_max_lines", label: "Memory size cap", group: "Review", values: ["50", "100", "200", "400"], help: "Lines memory.md may hold before old lessons are pruned. Every run reads it, so bigger costs more" },
|
|
99
|
+
{ key: "mode", label: "Stop for approval", group: "Approvals", values: ["approve", "auto", "plan-only", "review-only"], help: "approve: pause and ask you at each enabled checkpoint. auto: never pause. plan-only: write the plan and stop. review-only: just audit existing work" },
|
|
100
|
+
{ key: "approvals.plan", label: "Approve the plan first", group: "Approvals", values: ["true", "false"], help: "Show the plan and wait for your OK before anything is built" },
|
|
101
|
+
{ key: "approvals.review", label: "Approve after review", group: "Approvals", values: ["true", "false"], help: "After the review, wait for your OK before the run is called done" },
|
|
102
|
+
{ key: "approvals.each_file", label: "Confirm every file edit", group: "Approvals", values: ["true", "false"], help: "Ask before each individual file change. Maximum control, slowest" },
|
|
114
103
|
{ key: "runner", label: "Stage isolation", group: "Agents", values: ["full", "lite"], help: "full: each stage is a fresh agent, so the reviewer never saw the work (costs more). lite: this one session does every stage, cheaper, reviewer may miss its own mistakes" },
|
|
104
|
+
{ key: "build.executor", label: "Who builds", group: "Agents", values: ["subagent", "inline", "ask"], help: "subagent: a separate agent builds and reports back. inline: this session builds while you watch. ask: choose at the plan approval" },
|
|
105
|
+
{ key: "agent.default", label: "Tool the board launches", group: "Agents", values: ["auto", "claude", "codex", "agy", "opencode", "gemini"], help: "Coding tool opened when you start a run from the board. auto: Claude Code if installed, else the first one found" },
|
|
115
106
|
{ key: "subagents.model", label: "Model for stage agents", group: "Agents", values: ["inherit", "opus", "sonnet", "haiku"], help: "Which Claude model the planner, builder and reviewer run on. inherit: same as your main session. Claude Code only" },
|
|
116
107
|
{ key: "subagents.effort", label: "Effort for stage agents", group: "Agents", values: ["inherit", "low", "medium", "high"], help: "How hard the stage agents think. low is faster and cheaper; high finds more. Claude Code only" },
|
|
117
|
-
{ key: "
|
|
108
|
+
{ key: "auto_fix.enabled", label: "Fix findings automatically", group: "Auto-fix", values: ["true", "false"], help: "true: findings severe enough get fixed and re-reviewed without asking. false: every finding stops and asks you" },
|
|
109
|
+
{ key: "auto_fix.min_severity", label: "Auto-fix from severity", group: "Auto-fix", values: ["critical", "high", "medium", "low"], help: "The least severe finding still fixed automatically. low fixes everything; critical fixes almost nothing" },
|
|
110
|
+
{ key: "auto_fix.max_iterations", label: "Max fix rounds", group: "Auto-fix", values: ["1", "2", "3", "4", "5"], help: "Fix-then-recheck rounds before leftovers are handed to you. Each round is a full build plus a full review, the priciest knob here" },
|
|
111
|
+
{ key: "git.auto_commit", label: "Commit when done", group: "Git", values: ["false", "true"], help: "Commit the work when a run finishes. Never pushes" },
|
|
112
|
+
{ key: "git.branch_before_implement", label: "Branch before building", group: "Git", values: ["true", "false"], help: "Create a branch before building when you are on the default branch" },
|
|
113
|
+
{ key: "track.default", label: "Kind of work", group: "Project", values: ["auto", "software", "writing", "communication", "operations", "research"], help: "Pin what runs in this project are (a docs repo is always writing). auto: the planner infers it per run" },
|
|
114
|
+
{ key: "review.write_memory", label: "Remember lessons", group: "Project", values: ["true", "false"], help: "Let the reviewer save what it learned to .kaizen/memory.md, which every future run reads" },
|
|
115
|
+
{ key: "review.memory_max_lines", label: "Memory size cap", group: "Project", values: ["50", "100", "200", "400"], help: "Lines memory.md may hold before old lessons are pruned. Every run reads it, so bigger costs more" },
|
|
116
|
+
{ key: "state.keep_runs", label: "Runs to keep", group: "Project", values: ["5", "10", "20", "50"], help: "Finished runs kept before the oldest is pruned. Open backlog items are rescued first" },
|
|
118
117
|
{ key: "dictation.model", label: "Dictation model", group: "Board", values: ["tiny", "base", "small", "large-v3-turbo"], help: "Whisper model the web board dictates with, in the browser. tiny ~60 MB rough; base ~165 MB English-leaning; small ~410 MB accurate across languages; large-v3-turbo ~1.5 GB best, needs a capable GPU. Downloaded once on the next mic press" },
|
|
119
118
|
{ key: "sources.every", label: "Gather requests", group: "Board", values: ["off", "1h", "6h", "24h"], help: "How often the board checks your request sources for new ideas while it is running. off: only when you press Gather now" },
|
|
120
|
-
{ key: "board.projects_dir", label: "Projects folder", group: "Board", values: [], text: true, help:
|
|
119
|
+
{ key: "board.projects_dir", label: "Projects folder", group: "Board", values: [], text: true, folder: true, help: `A folder your projects live in, e.g. ${process.platform === "win32" ? join(homedir(), "Projects") : "~/Projects"}. Every folder inside it appears in the board's project list, ready to choose; choosing one sets it up. Empty: only projects kaizen already knows` },
|
|
120
|
+
{ key: "ui.mouse", label: "Enable mouse", group: "Terminal", values: ["false", "true"], help: "Click to select, click again to act. While on, the terminal cannot select text with the mouse" },
|
|
121
121
|
];
|
|
122
122
|
|
|
123
123
|
// The same rows and the same write the TUI uses, for `kaizen web`'s settings panel:
|
|
@@ -125,13 +125,14 @@ const SETTINGS: Setting[] = [
|
|
|
125
125
|
export function listSettings(repoRoot: string) {
|
|
126
126
|
const file = locate();
|
|
127
127
|
const defaults = readText(join(repoRoot, "skills/kaizen/config.default.yml"));
|
|
128
|
-
const
|
|
128
|
+
const load = (f: string) => existsSync(f) ? readText(f) : "";
|
|
129
|
+
const text = load(file), board = load(globalFile());
|
|
129
130
|
const preset = detectPreset(text, defaults);
|
|
130
131
|
return {
|
|
131
132
|
file: tilde(file),
|
|
132
133
|
rows: SETTINGS.map((s) => ({
|
|
133
134
|
...s,
|
|
134
|
-
value: s.key === "preset" ? preset : (read(text, s.key) || read(defaults, s.key)) ?? s.values[0] ?? "",
|
|
135
|
+
value: s.key === "preset" ? preset : (read(s.group === "Board" ? board : text, s.key) || read(defaults, s.key)) ?? s.values[0] ?? "",
|
|
135
136
|
})),
|
|
136
137
|
};
|
|
137
138
|
}
|
|
@@ -139,7 +140,7 @@ export function listSettings(repoRoot: string) {
|
|
|
139
140
|
export function setSetting(repoRoot: string, key: string, value: string) {
|
|
140
141
|
const s = SETTINGS.find((x) => x.key === key);
|
|
141
142
|
if (!s || !(s.text || s.values.includes(value))) return { error: "unknown setting or value" };
|
|
142
|
-
const file =
|
|
143
|
+
const file = fileFor(s);
|
|
143
144
|
const defaultsPath = join(repoRoot, "skills/kaizen/config.default.yml");
|
|
144
145
|
if (!existsSync(file)) { mkdirSync(dirname(file), { recursive: true }); copyFileSync(defaultsPath, file); }
|
|
145
146
|
const defaults = readText(defaultsPath);
|
|
@@ -183,6 +184,16 @@ export async function settings(repoRoot: string, standalone = true) {
|
|
|
183
184
|
|
|
184
185
|
const defaults = readText(join(repoRoot, "skills/kaizen/config.default.yml"));
|
|
185
186
|
let active = 0, saved = "";
|
|
187
|
+
// `/` filters the rows by name, help, key, group or option; moving only visits matches.
|
|
188
|
+
let query = "", typing = false;
|
|
189
|
+
const shown = () => SETTINGS.flatMap((s, i) =>
|
|
190
|
+
`${s.label} ${s.help} ${s.key} ${s.group} ${s.values.join(" ")}`.toLowerCase().includes(query.toLowerCase()) ? [i] : []);
|
|
191
|
+
const move = (step: number) => {
|
|
192
|
+
const list = shown();
|
|
193
|
+
if (!list.length) return;
|
|
194
|
+
const at = list.indexOf(active);
|
|
195
|
+
active = at < 0 ? list[0]! : list[(at + step + list.length) % list.length]!;
|
|
196
|
+
};
|
|
186
197
|
// Screen line -> setting index, rebuilt on every draw; the mouse clicks by line.
|
|
187
198
|
let rowAt: number[] = [];
|
|
188
199
|
const draw = () => {
|
|
@@ -200,15 +211,22 @@ export async function settings(repoRoot: string, standalone = true) {
|
|
|
200
211
|
const rows = SETTINGS.map((s) => ({
|
|
201
212
|
s,
|
|
202
213
|
// Older configs spell "infer the track" as an empty string; show what it means.
|
|
203
|
-
|
|
214
|
+
// The preset as a game's quick preset: arrows either side, the tier lit after it. No colour codes: the selected row wraps this in inverse, which a reset would end.
|
|
215
|
+
value: s.key === "preset"
|
|
216
|
+
? `◁ ${currentPreset.padEnd(6)} ▷ ${(["low", "medium", "ultra"] as const).map((p) => p === currentPreset ? "━" : "─").join("")}`
|
|
217
|
+
: show(s, (read(fileFor(s) === file ? text : readText(fileFor(s)), s.key) || (s.key === "track.default" ? "auto" : undefined)) ?? c.dim("(default)")),
|
|
204
218
|
}));
|
|
205
219
|
const width = Math.max(...SETTINGS.map((s) => s.label.length)) + 4;
|
|
206
220
|
|
|
207
221
|
stdout.write("\x1b[H\x1b[2J"); // home, clear
|
|
208
|
-
|
|
222
|
+
// On the title line, so the screen-line -> row map below is unchanged.
|
|
223
|
+
const search = typing || query ? ` / ${query}${typing ? "▏" : ""}` : "";
|
|
224
|
+
stdout.write(`\n ${c.bold("kaizen settings")} ${c.dim(tilde(file))}${c.cyan(search)}\n`);
|
|
209
225
|
rowAt = [];
|
|
210
226
|
let line = 2, group = "";
|
|
227
|
+
const list = shown();
|
|
211
228
|
for (const [i, { s, value }] of rows.entries()) {
|
|
229
|
+
if (!list.includes(i)) continue;
|
|
212
230
|
if (s.group !== group) {
|
|
213
231
|
group = s.group;
|
|
214
232
|
stdout.write(`\n ${c.dim(group.toUpperCase())}\n`);
|
|
@@ -222,9 +240,14 @@ export async function settings(repoRoot: string, standalone = true) {
|
|
|
222
240
|
rowAt[line++] = i;
|
|
223
241
|
}
|
|
224
242
|
const cur = SETTINGS[active]!;
|
|
225
|
-
stdout.write(`\n ${c.dim(
|
|
226
|
-
|
|
227
|
-
|
|
243
|
+
if (!list.length) stdout.write(`\n ${c.dim(`no setting matches "${query}"`)}\n\n`);
|
|
244
|
+
else {
|
|
245
|
+
stdout.write(`\n ${c.dim(cur.key)} ${c.dim("options:")} ${cur.values.map((v) => v || '""').join(c.dim(" / "))}\n`);
|
|
246
|
+
stdout.write(` ${cur.help}\n`);
|
|
247
|
+
}
|
|
248
|
+
stdout.write(`\n ${c.dim(typing
|
|
249
|
+
? "type to filter · enter or ↑↓ keep it · esc clear"
|
|
250
|
+
: `↑↓ pick a setting · ←→ or enter to change it (saved instantly) · / search · esc ${query ? "clear search" : "back"}`)} ${saved}\n`);
|
|
228
251
|
};
|
|
229
252
|
|
|
230
253
|
stdout.write("\x1b[?1049h\x1b[?25l"); // alternate screen, hide cursor
|
|
@@ -236,17 +259,20 @@ export async function settings(repoRoot: string, standalone = true) {
|
|
|
236
259
|
draw();
|
|
237
260
|
|
|
238
261
|
function cycle(step: number) {
|
|
262
|
+
if (!shown().includes(active)) return;
|
|
239
263
|
const s = SETTINGS[active]!;
|
|
240
264
|
const wasMouse = s.key === "ui.mouse";
|
|
241
|
-
|
|
242
|
-
|
|
265
|
+
const f = fileFor(s);
|
|
266
|
+
if (s.text) { saved = c.dim(`type ${s.key} in the board's settings, or in ${tilde(f)}`); return; }
|
|
267
|
+
if (!existsSync(f)) { mkdirSync(dirname(f), { recursive: true }); copyFileSync(join(repoRoot, "skills/kaizen/config.default.yml"), f); }
|
|
268
|
+
const text = readText(f);
|
|
243
269
|
if (s.key === "preset") {
|
|
244
270
|
const current = detectPreset(text, defaults);
|
|
245
271
|
const presets: PresetName[] = ["low", "medium", "ultra"];
|
|
246
272
|
const at = presets.indexOf(current as PresetName);
|
|
247
273
|
const next = presets[((at < 0 ? 0 : at) + step + presets.length) % presets.length]!;
|
|
248
274
|
const updated = applyPreset(text, next, defaults);
|
|
249
|
-
writeFileSync(
|
|
275
|
+
writeFileSync(f, updated);
|
|
250
276
|
saved = c.green(`applied preset: ${next}`);
|
|
251
277
|
} else {
|
|
252
278
|
const now = read(text, s.key) ?? s.values[0]!;
|
|
@@ -255,7 +281,7 @@ export async function settings(repoRoot: string, standalone = true) {
|
|
|
255
281
|
let updated = write(text, s.key, next, defaults);
|
|
256
282
|
const detected = detectPreset(updated, defaults);
|
|
257
283
|
updated = write(updated, "preset", detected, defaults);
|
|
258
|
-
writeFileSync(
|
|
284
|
+
writeFileSync(f, updated);
|
|
259
285
|
// Only claim a save that happened. Reporting one that did not is worse
|
|
260
286
|
// than failing loudly: the setting reads back unchanged and nobody knows why.
|
|
261
287
|
saved = read(updated, s.key) === next
|
|
@@ -279,8 +305,8 @@ export async function settings(repoRoot: string, standalone = true) {
|
|
|
279
305
|
if (m) {
|
|
280
306
|
i += m[0].length - 1;
|
|
281
307
|
const button = +m[1]!, y = +m[3]!, press = m[4] === "M";
|
|
282
|
-
if (button === 64)
|
|
283
|
-
else if (button === 65)
|
|
308
|
+
if (button === 64) move(-1);
|
|
309
|
+
else if (button === 65) move(1);
|
|
284
310
|
else if (press && button === 0) {
|
|
285
311
|
const row = rowAt[y - 1];
|
|
286
312
|
if (row === undefined) continue;
|
|
@@ -292,6 +318,23 @@ export async function settings(repoRoot: string, standalone = true) {
|
|
|
292
318
|
draw();
|
|
293
319
|
continue;
|
|
294
320
|
}
|
|
321
|
+
// While searching, keys type into the query: esc drops it, enter or an
|
|
322
|
+
// arrow keeps it and goes back to picking.
|
|
323
|
+
if (typing) {
|
|
324
|
+
if (rest === "\x1b") { query = ""; typing = false; }
|
|
325
|
+
else if (rest.startsWith("\r")) typing = false;
|
|
326
|
+
else if (rest.startsWith("\x1b[A") || rest.startsWith("\x1b[B")) { typing = false; move(rest[2] === "A" ? -1 : 1); i += 2; }
|
|
327
|
+
else if (rest.startsWith("\x1b[")) { i += 2; continue; }
|
|
328
|
+
else if (rest.startsWith("\x7f") || rest.startsWith("\b")) query = query.slice(0, -1);
|
|
329
|
+
else if (rest.startsWith("\x03")) { stdin.off("data", onData); return resolve(); }
|
|
330
|
+
else if (rest[0]! >= " ") query += rest[0];
|
|
331
|
+
else continue;
|
|
332
|
+
if (!shown().includes(active)) move(0);
|
|
333
|
+
draw();
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (rest.startsWith("/")) { query = ""; typing = true; draw(); continue; }
|
|
337
|
+
if (rest === "\x1b" && query) { query = ""; draw(); continue; }
|
|
295
338
|
// esc, q, backspace, and left-at-the-edge all mean "back to where I came
|
|
296
339
|
// from" -- the settings screen is always something you opened from
|
|
297
340
|
// somewhere else.
|
|
@@ -299,13 +342,13 @@ export async function settings(repoRoot: string, standalone = true) {
|
|
|
299
342
|
|| rest.startsWith("\x7f") || rest.startsWith("\b")) {
|
|
300
343
|
stdin.off("data", onData); return resolve();
|
|
301
344
|
}
|
|
302
|
-
if (rest.startsWith("\x1b[A")) {
|
|
303
|
-
else if (rest.startsWith("\x1b[B")) {
|
|
345
|
+
if (rest.startsWith("\x1b[A")) { move(-1); i += 2; }
|
|
346
|
+
else if (rest.startsWith("\x1b[B")) { move(1); i += 2; }
|
|
304
347
|
else if (rest.startsWith("\x1b[C") || rest.startsWith("\x1b[D") || rest.startsWith("\r")) {
|
|
305
348
|
cycle(rest.startsWith("\x1b[D") ? -1 : 1);
|
|
306
349
|
if (!rest.startsWith("\r")) i += 2;
|
|
307
|
-
} else if (rest.startsWith("k"))
|
|
308
|
-
else if (rest.startsWith("j"))
|
|
350
|
+
} else if (rest.startsWith("k")) move(-1);
|
|
351
|
+
else if (rest.startsWith("j")) move(1);
|
|
309
352
|
else continue;
|
|
310
353
|
draw();
|
|
311
354
|
}
|
|
@@ -335,9 +378,21 @@ function locate() {
|
|
|
335
378
|
if (up === dir) break;
|
|
336
379
|
dir = up;
|
|
337
380
|
}
|
|
381
|
+
return globalFile();
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function globalFile() {
|
|
338
385
|
return join(homedir(), ".kaizen", "config.yml");
|
|
339
386
|
}
|
|
340
387
|
|
|
388
|
+
// Board settings describe this machine's board (its projects folder, dictation,
|
|
389
|
+
// how often it gathers), not a project. They always live in the global config:
|
|
390
|
+
// a project's own config.yml, often committed to its repo, would otherwise hide
|
|
391
|
+
// them the moment the board moves into that project.
|
|
392
|
+
function fileFor(s: Setting) {
|
|
393
|
+
return s.group === "Board" ? globalFile() : locate();
|
|
394
|
+
}
|
|
395
|
+
|
|
341
396
|
// Read and write a dotted key by walking indentation, so the comment above every
|
|
342
397
|
// key survives. Parsing to an object and dumping it back would delete all of them.
|
|
343
398
|
function lineOf(text: string, key: string) {
|
package/cli/sources.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Request sources against a throwaway .kaizen/. No network: the fetch and the DNS
|
|
2
2
|
// lookup are both injected. Nothing here launches an agent or a terminal.
|
|
3
3
|
import { test, expect, beforeEach, afterAll } from "bun:test";
|
|
4
|
-
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from "node:fs";
|
|
4
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync, chmodSync } from "node:fs";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
7
|
import { readInbox, writeInbox, replaceIdea, parseItem } from "./state.ts";
|
|
@@ -250,6 +250,22 @@ test("a first word that is a kaizen command is prefixed", () => {
|
|
|
250
250
|
}
|
|
251
251
|
});
|
|
252
252
|
|
|
253
|
+
// A one-cell header stays the header, whatever the rows under it hold. A title above
|
|
254
|
+
// it is read as the header (backlog), as it always was.
|
|
255
|
+
test("split: a one-cell header is the header over any rows below it", async () => {
|
|
256
|
+
const feedback = "Feedback\nNO\tPIC\tDETAIL\tSTATUS\n1\tBudi\tTombol export tidak muncul\tOpen\n2\tSiti\tTambah opsi\tDone\n";
|
|
257
|
+
expect(sift("sheet", feedback)).toEqual({ items: [{ text: "NO", notes: "PIC · DETAIL · STATUS" }, { text: "1", notes: "Budi · Tombol export tidak muncul · Open" }, { text: "2", notes: "Siti · Tambah opsi · Done" }], done: 0 });
|
|
258
|
+
expect(sift("sheet", "Request\nFix login\nAdd export\n")).toEqual({ items: [{ text: "Fix login" }, { text: "Add export" }], done: 0 });
|
|
259
|
+
expect(sift("sheet", "Request\nFix login\nAdd export\tsee ticket 5\n")).toEqual({ items: [{ text: "Fix login" }, { text: "Add export", notes: "see ticket 5" }], done: 0 });
|
|
260
|
+
expect(sift("sheet", "Request\t\nFix login\tBudi\nAdd export\tSiti\n")).toEqual({ items: [{ text: "Fix login", notes: "Budi" }, { text: "Add export", notes: "Siti" }], done: 0 });
|
|
261
|
+
// A later row whose lone cell reads like a header word is still data.
|
|
262
|
+
expect(sift("sheet", "Request\nFix login\tBug\nRename page heading\tTitle\nAdd export\tFeature\n")).toEqual({ items: [{ text: "Fix login", notes: "Bug" }, { text: "Rename page heading", notes: "Title" }, { text: "Add export", notes: "Feature" }], done: 0 });
|
|
263
|
+
expect(sift("sheet", "Request\nFix login\tStatus\nAdd export\tdone\n")).toEqual({ items: [{ text: "Fix login", notes: "Status" }, { text: "Add export", notes: "done" }], done: 0 });
|
|
264
|
+
const guessed = "1\tOpen\tTombol export tidak muncul\n2\tOpen\tTambah opsi ingat saya\n";
|
|
265
|
+
addSource(state, SHEET);
|
|
266
|
+
expect(await gatherSource(state, SHEET, text(guessed), pub)).toMatchObject({ added: 2, note: "2 new" });
|
|
267
|
+
});
|
|
268
|
+
|
|
253
269
|
// G-06
|
|
254
270
|
test("the URL guard refuses anything that is not public https", async () => {
|
|
255
271
|
for (const u of [
|
|
@@ -267,14 +283,23 @@ test("the URL guard refuses anything that is not public https", async () => {
|
|
|
267
283
|
expect(await resolvesPublic("x.example", async () => [{ address: "93.184.216.34" }, { address }])).toBe(false);
|
|
268
284
|
}
|
|
269
285
|
expect(await resolvesPublic("x.example", async () => [{ address: "93.184.216.34" }, { address: "2606:2800:220:1::1" }, { address: "172.32.0.1" }])).toBe(true);
|
|
270
|
-
expect(await resolvesPublic("x.example", async () => [])).
|
|
271
|
-
expect(await resolvesPublic("x.example", async () => { throw new Error("ENOTFOUND"); })).
|
|
286
|
+
expect(await resolvesPublic("x.example", async () => [])).toBeNull();
|
|
287
|
+
expect(await resolvesPublic("x.example", async () => { throw new Error("ENOTFOUND"); })).toBeNull();
|
|
272
288
|
|
|
273
289
|
// A hostname resolving to a private address is never fetched.
|
|
274
290
|
let calls = 0;
|
|
275
291
|
const spy = async () => { calls++; return new Response("secret", { headers: { "content-type": "text/plain" } }); };
|
|
276
292
|
expect(await fetchText("https://rebind.example/a", spy, async () => [{ address: "192.168.1.1" }])).toEqual({ ok: false, note: "Refused: not a public address." });
|
|
277
293
|
expect(calls).toBe(0);
|
|
294
|
+
// A name that does not resolve reads as a typo, and is not fetched either.
|
|
295
|
+
for (const look of [async () => [], async () => { throw new Error("ENOTFOUND"); }]) {
|
|
296
|
+
expect(await fetchText("https://kaizen-nope.invalid/a", spy, look)).toEqual({ ok: false, note: "The link's address could not be found. Check it for a typo." });
|
|
297
|
+
}
|
|
298
|
+
expect(calls).toBe(0);
|
|
299
|
+
// Only the user's own link can hold their typo; a redirect hop is named as such.
|
|
300
|
+
const hopper = async () => new Response(null, { status: 302, headers: { location: "https://gone.invalid/b" } });
|
|
301
|
+
const look = async (h: string) => h === "gone.invalid" ? [] : [{ address: "93.184.216.34" }];
|
|
302
|
+
expect(await fetchText("https://example.com/a", hopper, look)).toEqual({ ok: false, note: "The link redirected to an address that could not be found." });
|
|
278
303
|
|
|
279
304
|
// Nor is a redirect hop to any of them.
|
|
280
305
|
for (const to of ["http://example.com/a", "file:///etc/passwd", "https://localhost/a", "https://127.0.0.1/a", "https://10.0.0.5/a", "https://192.168.1.1/a", "https://169.254.169.254/a", "https://[::1]/a", "https://inner.example/a"]) {
|
|
@@ -406,10 +431,10 @@ test.skipIf(process.platform === "win32")("launch: the xterm and macOS branches
|
|
|
406
431
|
expect(x.cmd.slice(0, 5)).toEqual(["xterm", "-e", "sh", "-c", 'cd "$1" && shift && exec "$@"']);
|
|
407
432
|
expect(Bun.spawnSync(x.cmd.slice(2), { cwd: root }).stdout.toString()).toBe(prompt + cwd + "\n");
|
|
408
433
|
// macOS: run the real line with the stub; what is typed into the login shell is
|
|
409
|
-
// `sh <temp path>` and nothing from the prompt or the directory.
|
|
434
|
+
// `exec sh <temp path>` and nothing from the prompt or the directory.
|
|
410
435
|
const osa = join(root, "osa.txt");
|
|
411
436
|
Bun.spawnSync(mac.cmd, { cwd: root, env: { PATH: `${bin}:/usr/bin:/bin`, OSA_OUT: osa } });
|
|
412
|
-
const typed =
|
|
437
|
+
const typed = /\nset t to do script "exec (sh [\w/.-]+)"\n/.exec(readFileSync(osa, "utf8"))![1]!;
|
|
413
438
|
expect(Bun.spawnSync(["/bin/sh", "-c", typed], { cwd: root }).stdout.toString()).toBe(prompt + cwd + "\n");
|
|
414
439
|
expect(existsSync(typed.slice(3))).toBe(false); // the one-shot script removed itself
|
|
415
440
|
expect(existsSync(join(cwd, "canary")) || existsSync(join(root, "canary"))).toBe(false);
|
|
@@ -442,6 +467,20 @@ test("move: label, seen and last go along; a duplicate is refused; no idea moves
|
|
|
442
467
|
expect(readInbox(other)).toEqual([]);
|
|
443
468
|
});
|
|
444
469
|
|
|
470
|
+
// Root ignores file modes, and Windows read-only does not stop this write.
|
|
471
|
+
test.skipIf(process.platform === "win32" || process.getuid?.() === 0)("move: when the old project cannot be written, the source stays only there", () => {
|
|
472
|
+
const other = join(root, "other" + n, ".kaizen");
|
|
473
|
+
mkdirSync(other, { recursive: true });
|
|
474
|
+
addSource(state, SHEET);
|
|
475
|
+
const f = join(state, "sources.json");
|
|
476
|
+
chmodSync(f, 0o444);
|
|
477
|
+
try {
|
|
478
|
+
expect(() => moveSource(state, other, SHEET)).toThrow();
|
|
479
|
+
expect(readSources(other).map((s) => s.url)).toEqual([]);
|
|
480
|
+
expect(readSources(state).map((s) => s.url)).toEqual([SHEET]);
|
|
481
|
+
} finally { chmodSync(f, 0o644); }
|
|
482
|
+
});
|
|
483
|
+
|
|
445
484
|
test("gather everything: each source's ideas land in its own project and no other", async () => {
|
|
446
485
|
const other = join(root, "other" + n, ".kaizen"), third = join(root, "third" + n, ".kaizen");
|
|
447
486
|
for (const d of [other, third]) mkdirSync(d, { recursive: true });
|
package/cli/sources.ts
CHANGED
|
@@ -55,7 +55,8 @@ export function removeSource(state: string, url: string): string | null {
|
|
|
55
55
|
|
|
56
56
|
// A source belongs to one project. Moving it carries its label, seen list and last
|
|
57
57
|
// result, so nothing is gathered twice; ideas already in the old inbox stay there.
|
|
58
|
-
// Written to the new project first
|
|
58
|
+
// Written to the new project first, and taken back out if the old one cannot be
|
|
59
|
+
// written: only a crash between the two writes leaves it listed twice, never lost.
|
|
59
60
|
export function moveSource(from: string, to: string, url: string): string | null {
|
|
60
61
|
if (from === to) return "already in that project";
|
|
61
62
|
const list = readSources(from);
|
|
@@ -64,7 +65,8 @@ export function moveSource(from: string, to: string, url: string): string | null
|
|
|
64
65
|
const dest = readSources(to);
|
|
65
66
|
if (dest.some((s) => s.url === url)) return "that project already has this link";
|
|
66
67
|
writeSources(to, [...dest, src]);
|
|
67
|
-
writeSources(from, list.filter((s) => s.url !== url));
|
|
68
|
+
try { writeSources(from, list.filter((s) => s.url !== url)); }
|
|
69
|
+
catch (e) { writeSources(to, dest); throw e; }
|
|
68
70
|
return null;
|
|
69
71
|
}
|
|
70
72
|
|
|
@@ -144,11 +146,12 @@ type Lookup = (host: string, opts: { all: true }) => Promise<{ address: string }
|
|
|
144
146
|
// ponytail: check-then-connect. The fetch resolves the name again and is not pinned
|
|
145
147
|
// to the address checked here, so DNS rebinding on a listed hostname gets through.
|
|
146
148
|
// Accepted because the user adds every link by hand; pin the socket if that changes.
|
|
147
|
-
|
|
149
|
+
// null: the name does not resolve at all, most often a typo.
|
|
150
|
+
export async function resolvesPublic(host: string, lookup: Lookup = dnsLookup): Promise<boolean | null> {
|
|
148
151
|
try {
|
|
149
152
|
const found = await lookup(host, { all: true });
|
|
150
|
-
return found.length
|
|
151
|
-
} catch { return
|
|
153
|
+
return found.length ? found.every((f) => !isPrivate(f.address)) : null;
|
|
154
|
+
} catch { return null; }
|
|
152
155
|
}
|
|
153
156
|
|
|
154
157
|
// Google serves a sheet or a doc as plain text from its export address, to anyone
|
|
@@ -186,7 +189,9 @@ export async function fetchText(url: string, doFetch: Fetch = fetch, lookup: Loo
|
|
|
186
189
|
if (why) return { ok: false, note: `Refused: ${why}.` };
|
|
187
190
|
const host = new URL(url).hostname;
|
|
188
191
|
if (host === "accounts.google.com") return { ok: false, note: SIGN_IN };
|
|
189
|
-
|
|
192
|
+
const pub = await resolvesPublic(host, lookup);
|
|
193
|
+
if (pub === null) return { ok: false, note: hop ? "The link redirected to an address that could not be found." : "The link's address could not be found. Check it for a typo." };
|
|
194
|
+
if (!pub) return { ok: false, note: "Refused: not a public address." };
|
|
190
195
|
const res = await doFetch(url, { redirect: "manual", credentials: "omit", signal, headers: { accept: "text/plain, text/*" } });
|
|
191
196
|
if (res.status >= 300 && res.status < 400) {
|
|
192
197
|
const to = res.headers.get("location");
|