kaizen-agent 1.17.0 → 1.19.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/cli/board.test.ts +30 -0
- package/cli/install.test.ts +56 -1
- package/cli/install.ts +70 -67
- package/package.json +1 -1
- package/skills/kaizen/spec-build.md +3 -0
- package/web/board.html +56 -9
package/cli/board.test.ts
CHANGED
|
@@ -168,3 +168,33 @@ test("card menu items: appropriate actions and distinct icons across phases", ()
|
|
|
168
168
|
const archIdea = getItems(idea, true);
|
|
169
169
|
expect(archIdea.map((i: any) => i ? i[1] : null)).toEqual(["Restore", "Open", null, "Delete"]);
|
|
170
170
|
});
|
|
171
|
+
|
|
172
|
+
// brief() lifted the same way: a one-line structured idea split for display only.
|
|
173
|
+
const bat = html.indexOf("function brief(");
|
|
174
|
+
const brief = new Function(`${html.slice(bat, html.indexOf("\n\n", bat))}\nreturn brief`)() as (t: string) => { head: string; parts: { label: string; body: string }[] };
|
|
175
|
+
|
|
176
|
+
test("a labelled idea splits into a head and one part per label", () => {
|
|
177
|
+
const b = brief("Improve previews Problem: one dense block. Non-goals: no rewrites. Acceptance criteria: (1) scannable. (2) full text kept.");
|
|
178
|
+
expect(b.head).toBe("Improve previews");
|
|
179
|
+
expect(b.parts.map(p => p.label)).toEqual(["Problem", "Non-goals", "Acceptance criteria"]);
|
|
180
|
+
expect(b.parts[2].body).toBe("1. scannable.\n2. full text kept.");
|
|
181
|
+
expect(md(`### ${b.parts[2].label}\n${b.parts[2].body}`)).toBe("<h3>Acceptance criteria</h3><ol><li>scannable.</li><li>full text kept.</li></ol>");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("plain ideas, and a single label, are left whole", () => {
|
|
185
|
+
for (const t of ["fix the typo in README", "Bug: clicking twice opens two panels"]) expect(brief(t)).toEqual({ head: t, parts: [] });
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("an idea that opens with a label heads with its first part", () => {
|
|
189
|
+
expect(brief("Goal: faster board. Scope: cards only.").head).toBe("Goal: faster board.");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("a capitalised phrase mid-sentence is prose, not a label", () => {
|
|
193
|
+
const b = brief("Fix cards Problem: the Board shows: nothing useful. Scope: cards.");
|
|
194
|
+
expect(b.parts.map(p => p.label)).toEqual(["Problem", "Scope"]);
|
|
195
|
+
expect(b.parts[0].body).toBe("the Board shows: nothing useful.");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("criteria not numbered from 1 keep their own numbers", () => {
|
|
199
|
+
expect(brief("X Goal: g. Criteria: (2) two. (3) three.").parts[1].body).toBe("- (2) two.\n- (3) three.");
|
|
200
|
+
});
|
package/cli/install.test.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// Skipped on Windows: there uninstall also walks every drive (searchRoots) and
|
|
5
5
|
// strips kaizen links from real projects, whatever HOME says.
|
|
6
6
|
import { test, expect } from "bun:test";
|
|
7
|
-
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync, symlinkSync } from "node:fs";
|
|
7
|
+
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync, symlinkSync, cpSync, readFileSync } from "node:fs";
|
|
8
8
|
import { join, dirname } from "node:path";
|
|
9
9
|
import { tmpdir } from "node:os";
|
|
10
10
|
|
|
@@ -83,3 +83,58 @@ it.skipIf(!Bun.which("git"))("uninstall removes a clean clone of kaizen and keep
|
|
|
83
83
|
expect(unpushed.left).toBe(true);
|
|
84
84
|
expect(unpushed.out).toContain("commits not pushed");
|
|
85
85
|
});
|
|
86
|
+
|
|
87
|
+
// An npm package of this checkout, at a version no real release has: no .git, so its
|
|
88
|
+
// installer runs as `bunx kaizen-agent` would. Scratch HOME only; ~/kaizen is a fake.
|
|
89
|
+
const fromPackage = (make: (k: string) => void, custom = false) => {
|
|
90
|
+
const root = mkdtempSync(join(tmpdir(), "kz-replace-"));
|
|
91
|
+
const home = join(root, "home"), pkg = join(root, "pkg");
|
|
92
|
+
const k = custom ? join(root, "kaizen") : join(home, "kaizen");
|
|
93
|
+
mkdirSync(home);
|
|
94
|
+
const src = dirname(dirname(import.meta.path));
|
|
95
|
+
for (const d of ["cli", "skills", "agents", "commands", "web", "package.json"]) {
|
|
96
|
+
cpSync(join(src, d), join(pkg, d), { recursive: true, filter: (p) => !p.includes("node_modules") });
|
|
97
|
+
}
|
|
98
|
+
const pj = join(pkg, "package.json");
|
|
99
|
+
writeFileSync(pj, readFileSync(pj, "utf8").replace(/"version": "[^"]*"/, '"version": "9.9.9"'));
|
|
100
|
+
make(k);
|
|
101
|
+
const env: Record<string, string> = { PATH: process.env.PATH ?? "", HOME: home, USERPROFILE: home, TMPDIR: root };
|
|
102
|
+
if (custom) env.KAIZEN_HOME = k;
|
|
103
|
+
const p = Bun.spawnSync([process.execPath, join(pkg, "cli", "install.ts"), "--yes", "--global"], { cwd: home, env });
|
|
104
|
+
const r = {
|
|
105
|
+
out: p.stdout.toString(),
|
|
106
|
+
version: existsSync(join(k, "package.json")) ? JSON.parse(readFileSync(join(k, "package.json"), "utf8")).version : null,
|
|
107
|
+
oldKept: existsSync(join(`${k}.old`, "mine.txt")),
|
|
108
|
+
mineInPlace: existsSync(join(k, "mine.txt")),
|
|
109
|
+
};
|
|
110
|
+
rmSync(root, { recursive: true, force: true });
|
|
111
|
+
return r;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
it.skipIf(!Bun.which("git"))("an npm package replaces a ~/kaizen clone with local edits, keeping it as .old", () => {
|
|
115
|
+
const r = fromPackage(clone(REPO, true));
|
|
116
|
+
expect(r.version).toBe("9.9.9");
|
|
117
|
+
expect(r.mineInPlace).toBe(false);
|
|
118
|
+
expect(r.oldKept).toBe(true);
|
|
119
|
+
expect(r.out).toContain("replaced");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it.skipIf(!Bun.which("git"))("an npm package never replaces a development checkout", () => {
|
|
123
|
+
expect(fromPackage(clone(REPO, true), true).mineInPlace).toBe(true);
|
|
124
|
+
const unpushed = fromPackage(clone(REPO, true, commit));
|
|
125
|
+
expect(unpushed.mineInPlace).toBe(true);
|
|
126
|
+
expect(unpushed.out).toContain("commits not pushed");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("upgrade with npm unreachable says not upgraded, never up to date", () => {
|
|
130
|
+
const root = mkdtempSync(join(tmpdir(), "kz-offline-"));
|
|
131
|
+
const home = join(root, "home");
|
|
132
|
+
mkdirSync(home);
|
|
133
|
+
kaizenFiles(join(home, "kaizen"));
|
|
134
|
+
const env = { PATH: process.env.PATH ?? "", HOME: home, TMPDIR: root, HTTPS_PROXY: "http://127.0.0.1:9", https_proxy: "http://127.0.0.1:9" };
|
|
135
|
+
const p = Bun.spawnSync([process.execPath, script, "upgrade"], { cwd: home, env });
|
|
136
|
+
rmSync(root, { recursive: true, force: true });
|
|
137
|
+
expect(p.exitCode).not.toBe(0);
|
|
138
|
+
expect(p.stdout.toString()).toContain("Not upgraded");
|
|
139
|
+
expect(p.stdout.toString()).not.toContain("Up to date");
|
|
140
|
+
});
|
package/cli/install.ts
CHANGED
|
@@ -407,35 +407,40 @@ if (interactive && !upgrade && Bun.argv.length === 2) {
|
|
|
407
407
|
|
|
408
408
|
if (upgrade) await clearBunxCache();
|
|
409
409
|
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
|
|
414
|
-
|
|
410
|
+
// Upgrade means the newest release on npm, whatever the install on disk is: a clone
|
|
411
|
+
// with local edits used to fail its pull and still report "Up to date". The npm
|
|
412
|
+
// package's own installer replaces the install (see resolveRepo), so hand it over.
|
|
413
|
+
if (upgrade) {
|
|
414
|
+
const dest = installDir();
|
|
415
|
+
const now = versionOf(dest);
|
|
416
|
+
const why = await devCheckout(dest);
|
|
417
|
+
if (why) {
|
|
418
|
+
console.log(`\n ${c.bold("Not upgraded")} ${c.dim(`${tilde(dest)} is ${why}; update it with git`)}\n`);
|
|
419
|
+
process.exit(1);
|
|
420
|
+
}
|
|
415
421
|
const latest = await npmLatest();
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
if (latest
|
|
421
|
-
|
|
422
|
-
step(`fetching kaizen ${want} from npm`);
|
|
423
|
-
// --force so a stale package manifest cannot resolve the version away, and the
|
|
424
|
-
// failure is printed rather than swallowed: a silent catch here looked exactly
|
|
425
|
-
// like a successful upgrade that changed nothing.
|
|
426
|
-
const r = await Bun.$`${process.execPath} x --force kaizen-agent@${want} --yes`
|
|
427
|
-
.quiet().nothrow();
|
|
428
|
-
const out = clean(r.stdout.toString() + r.stderr.toString()).filter((l) => l.trim());
|
|
429
|
-
for (const l of out) console.log(l.startsWith(" ") ? l : ` ${l}`);
|
|
430
|
-
if (r.exitCode !== 0) {
|
|
431
|
-
console.log(`\n ${c.bold(`could not fetch kaizen ${want}`)}`);
|
|
432
|
-
console.log(` ${c.dim("try:")} ${c.cyan(`bunx kaizen-agent@${want}`)}\n`);
|
|
433
|
-
process.exit(1);
|
|
434
|
-
}
|
|
435
|
-
const after = versionOf(process.env.KAIZEN_HOME ?? join(home, "kaizen"));
|
|
436
|
-
if (after === now) console.log(`\n ${c.bold(`kaizen ${now} is the latest on npm`)}\n`);
|
|
422
|
+
if (!latest) {
|
|
423
|
+
console.log(`\n ${c.bold("Not upgraded")} ${c.dim(`could not reach npm (kaizen ${now})`)}\n`);
|
|
424
|
+
process.exit(1);
|
|
425
|
+
}
|
|
426
|
+
if (latest === now) {
|
|
427
|
+
console.log(`\n ${c.bold("Up to date")}${c.dim(` — kaizen ${now}`)}\n`);
|
|
437
428
|
process.exit(0);
|
|
438
429
|
}
|
|
430
|
+
step(`fetching kaizen ${latest} from npm`);
|
|
431
|
+
// --force so a stale package manifest cannot resolve the version away, and the
|
|
432
|
+
// failure is printed rather than swallowed.
|
|
433
|
+
const r = await Bun.$`${process.execPath} x --force kaizen-agent@${latest} --yes`.quiet().nothrow();
|
|
434
|
+
const out = clean(r.stdout.toString() + r.stderr.toString()).filter((l) => l.trim());
|
|
435
|
+
for (const l of out) console.log(l.startsWith(" ") ? l : ` ${l}`);
|
|
436
|
+
const after = versionOf(dest);
|
|
437
|
+
if (r.exitCode !== 0 || after !== latest) {
|
|
438
|
+
console.log(`\n ${c.bold(`could not install kaizen ${latest}`)}`);
|
|
439
|
+
console.log(` ${c.dim("try:")} ${c.cyan(`bunx kaizen-agent@${latest}`)}\n`);
|
|
440
|
+
process.exit(1);
|
|
441
|
+
}
|
|
442
|
+
console.log(`\n ${c.bold("Upgraded")} ${c.dim(now)} → ${c.green(after)}\n ${c.dim("Restart Claude Code to pick up any new slash commands.")}\n`);
|
|
443
|
+
process.exit(0);
|
|
439
444
|
}
|
|
440
445
|
if (!check && !upgrade) await welcome();
|
|
441
446
|
|
|
@@ -597,41 +602,34 @@ async function resolveRepoQuietly() {
|
|
|
597
602
|
|
|
598
603
|
async function resolveRepo() {
|
|
599
604
|
const here = dirname(import.meta.dir);
|
|
600
|
-
const dest =
|
|
601
|
-
//
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
step(`updated ${tilde(dir)}`);
|
|
612
|
-
} catch {
|
|
613
|
-
step(`kept ${tilde(dir)} ${c.dim("(could not pull)")}`);
|
|
605
|
+
const dest = installDir();
|
|
606
|
+
// Running from a checkout (development, or the launcher's old clone): link it as is.
|
|
607
|
+
if (existsSync(join(here, ".git"))) return here;
|
|
608
|
+
// Running from an npm package: that package is the version wanted, so it replaces
|
|
609
|
+
// whatever ~/kaizen holds -- local edits included -- rather than a pull that any
|
|
610
|
+
// edit makes fail. The previous install is kept as <dest>.old, one deep.
|
|
611
|
+
if (here !== dest && existsSync(dest)) {
|
|
612
|
+
const why = await devCheckout(dest);
|
|
613
|
+
if (why) {
|
|
614
|
+
step(`kept ${tilde(dest)} ${c.dim(`(${why}; not replaced)`)}`);
|
|
615
|
+
return dest;
|
|
614
616
|
}
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
try {
|
|
617
|
+
const fresh = `${dest}.new`, old = `${dest}.old`;
|
|
618
|
+
rmSync(fresh, { recursive: true, force: true });
|
|
619
|
+
cpSync(here, fresh, { recursive: true, dereference: true, filter: (src) => basename(src) !== "node_modules" });
|
|
620
|
+
rmSync(old, { recursive: true, force: true });
|
|
621
|
+
// Windows refuses to move a folder a running kaizen still holds open. The
|
|
622
|
+
// install stays as it was; say how to finish rather than throwing.
|
|
623
|
+
try { renameSync(dest, old); } catch {
|
|
623
624
|
rmSync(fresh, { recursive: true, force: true });
|
|
624
|
-
|
|
625
|
-
rmSync(dest, { recursive: true, force: true });
|
|
626
|
-
renameSync(fresh, dest);
|
|
627
|
-
step(`re-cloned ${tilde(dest)} ${c.dim("(git is available now)")}`);
|
|
625
|
+
step(`kept ${tilde(dest)} ${c.dim("(in use; close kaizen and any dashboard, then run bunx kaizen-agent@latest)")}`);
|
|
628
626
|
return dest;
|
|
629
|
-
} catch {
|
|
630
|
-
rmSync(fresh, { recursive: true, force: true });
|
|
631
|
-
// Fall through to the copy below: an unreachable network is not a reason to
|
|
632
|
-
// leave the install untouched.
|
|
633
627
|
}
|
|
628
|
+
renameSync(fresh, dest);
|
|
629
|
+
step(`replaced ${tilde(dest)} with kaizen ${versionOf(dest)} ${c.dim(`(previous install kept in ${tilde(old)})`)}`);
|
|
630
|
+
return dest;
|
|
634
631
|
}
|
|
632
|
+
if (here === dest) return dest;
|
|
635
633
|
|
|
636
634
|
// Cloning needs an empty directory, and a copy made on a machine that had no git
|
|
637
635
|
// is anything but: installing git later must not turn every run into a fatal.
|
|
@@ -906,14 +904,7 @@ function writePointer() {
|
|
|
906
904
|
|
|
907
905
|
// ---------------------------------------------------------------- done
|
|
908
906
|
|
|
909
|
-
|
|
910
|
-
let version = "";
|
|
911
|
-
try { version = JSON.parse(readFileSync(join(repo, "package.json"), "utf8")).version; } catch {}
|
|
912
|
-
console.log(`
|
|
913
|
-
${c.bold("Up to date")}${version ? c.dim(` — kaizen ${version}`) : ""}
|
|
914
|
-
${c.dim("Restart Claude Code to pick up any new slash commands.")}
|
|
915
|
-
`);
|
|
916
|
-
} else {
|
|
907
|
+
{
|
|
917
908
|
console.log(`
|
|
918
909
|
${c.bold("Next")}
|
|
919
910
|
1 restart Claude Code ${c.dim("— skills load live, slash commands only at session start")}
|
|
@@ -948,14 +939,26 @@ async function runUpgrade(repoDir: string) {
|
|
|
948
939
|
.map((l) => c.dim(l.trim())),
|
|
949
940
|
];
|
|
950
941
|
if (latest && latest !== after) {
|
|
951
|
-
lines.push("", c.dim(
|
|
952
|
-
? `npm publishes ${latest}; this install follows the git clone.`
|
|
953
|
-
: `npm publishes ${latest} — run: bunx kaizen-agent@${latest}`));
|
|
942
|
+
lines.push("", c.dim(`npm publishes ${latest} — run: bunx kaizen-agent@${latest}`));
|
|
954
943
|
}
|
|
955
944
|
lines.push("", c.dim("Restart your agent to pick up new slash commands."));
|
|
956
945
|
return lines;
|
|
957
946
|
}
|
|
958
947
|
|
|
948
|
+
function installDir() { return process.env.KAIZEN_HOME ?? join(home, "kaizen"); }
|
|
949
|
+
|
|
950
|
+
// A folder someone develops kaizen in must never be replaced by a release: a git
|
|
951
|
+
// checkout KAIZEN_HOME points at, or any checkout holding commits on no remote.
|
|
952
|
+
// ~/kaizen as the installer cloned it is fair game, edits and all (kept in .old).
|
|
953
|
+
async function devCheckout(dir: string) {
|
|
954
|
+
if (!existsSync(join(dir, ".git"))) return "";
|
|
955
|
+
if (resolve(dir) !== join(home, "kaizen")) return "a git checkout named by KAIZEN_HOME";
|
|
956
|
+
try {
|
|
957
|
+
const ahead = (await Bun.$`git -C ${dir} rev-list --branches --not --remotes`.quiet().text()).trim();
|
|
958
|
+
return ahead ? "a git checkout with commits not pushed" : "";
|
|
959
|
+
} catch { return ""; }
|
|
960
|
+
}
|
|
961
|
+
|
|
959
962
|
function versionOf(dir: string) {
|
|
960
963
|
try { return JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).version as string; }
|
|
961
964
|
catch { return "?"; }
|
package/package.json
CHANGED
|
@@ -86,6 +86,9 @@ commands to start it locally and the URL to open, copied from the material's own
|
|
|
86
86
|
where one exists. For anything else — a document, copy, a runbook, a memo — the path
|
|
87
87
|
to open, or the result itself when it fits in a screen. The board shows this section on
|
|
88
88
|
the run's card, so a person can try the work without reading the rest of the report.
|
|
89
|
+
Where the result can be seen, put screenshots of it here: save each under
|
|
90
|
+
`runs/<id>/attachments/` (letters, digits, `.`, `-`, `_` only) and embed it as
|
|
91
|
+
``; the board shows them in the Preview tab.
|
|
89
92
|
|
|
90
93
|
It also says how to test the result, under a `### How to test` subhead: numbered steps a
|
|
91
94
|
person follows to check the run did what was asked, each an action and what they should
|
package/web/board.html
CHANGED
|
@@ -35,11 +35,12 @@ select.sel,button.sel{cursor:pointer;appearance:none;padding-right:30px;max-widt
|
|
|
35
35
|
/* project picker: a select-shaped button over a searchable list */
|
|
36
36
|
.combo{position:relative;max-width:100%;display:flex}
|
|
37
37
|
.combo .menu-pop{left:0;right:auto;top:calc(100% + 4px);width:320px;max-width:calc(100vw - 32px);gap:6px;z-index:9}
|
|
38
|
-
.combo .menu-pop[hidden]{display:none}
|
|
38
|
+
.combo[hidden],.combo .menu-pop[hidden]{display:none}
|
|
39
39
|
.combo input.sel{width:100%;max-width:none}
|
|
40
40
|
#projs{display:flex;flex-direction:column;max-height:300px;overflow-y:auto}
|
|
41
41
|
#projs button{display:block;flex:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
42
42
|
#projs button.on{background:var(--hover)}
|
|
43
|
+
#shows{display:flex;flex-direction:column;max-height:300px;overflow-y:auto}#shows label{display:flex;gap:8px;align-items:center;padding:6px 10px;font-size:13px;border-radius:var(--r-s);cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}#shows label:hover{background:var(--hover)}#shows .grp{padding:8px 10px 4px;font-size:11px;color:var(--dim)}
|
|
43
44
|
#projs .grp{padding:8px 10px 4px;font-size:11px;color:var(--dim)}
|
|
44
45
|
.sp{flex:1}
|
|
45
46
|
.ib{width:34px;height:34px;display:grid;place-items:center;color:var(--dim);border-radius:var(--r-s);transition:background .12s,color .12s}
|
|
@@ -86,6 +87,7 @@ ul{list-style:none;margin:0;padding:8px 0 0;display:flex;flex-direction:column;g
|
|
|
86
87
|
.card.menu{z-index:2}
|
|
87
88
|
.card.open{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent)}
|
|
88
89
|
.card.archived .c-title,.card.archived .c-id{opacity:.7}
|
|
90
|
+
section h2 .ord{min-height:22px;padding:0 8px;font-weight:500}
|
|
89
91
|
section h2 .clear{margin-left:auto;min-height:22px;padding:0 8px}
|
|
90
92
|
.clear-pop{top:30px;right:0;min-width:200px}
|
|
91
93
|
.clear-pop button i{font-style:normal;color:var(--dim);margin-left:auto;background:none;padding:0}
|
|
@@ -137,6 +139,8 @@ section h2 .clear{margin-left:auto;min-height:22px;padding:0 8px}
|
|
|
137
139
|
.inl input::placeholder,.inl textarea::placeholder,.notes textarea::placeholder{color:var(--dim)}
|
|
138
140
|
.inl .row,.notes .row{display:flex;gap:6px;justify-content:flex-end}
|
|
139
141
|
.notes{display:flex;flex-direction:column;gap:8px}
|
|
142
|
+
#pbody img{max-width:100%;border-radius:var(--r-s)}
|
|
143
|
+
.desc{border-bottom:1px solid var(--line);padding-bottom:6px;margin-bottom:14px}.desc h3{margin-top:14px}
|
|
140
144
|
.chat{display:flex;flex-direction:column;gap:10px;margin:0 0 14px}.msg{border:1px solid var(--line);border-radius:var(--r);padding:10px 12px;background:var(--bg)}.msg time{display:block;font-size:11px;color:var(--dim);margin-bottom:4px}.msg p:last-child{margin-bottom:0}.msg img{max-width:100%;max-height:260px;border-radius:var(--r-s);display:block;margin:4px 0}
|
|
141
145
|
.notes .tools{display:flex;gap:6px;align-items:center}.notes .tools span{flex:1;font-size:11px;color:var(--dim)}.notes .att{font-size:11px;color:var(--dim)}
|
|
142
146
|
.inl pre.files{margin:0;max-height:140px;overflow:auto;font:12px/1.5 var(--mono);color:var(--dim)}
|
|
@@ -329,6 +333,7 @@ aside .foot .more .menu-pop{left:0;right:auto;top:auto;bottom:calc(100% + 4px)}.
|
|
|
329
333
|
<span>█▀▄ █▀█ █ ▄▀ ██▄ █ ▀█</span>
|
|
330
334
|
<span>█ █ █ █ █ █▄▄ █▄▄ █ █</span></pre>
|
|
331
335
|
<div class="combo" id="combo"><button class="sel" id="proj" aria-label="project" aria-haspopup="listbox" aria-expanded="false"></button><div class="menu-pop" id="projpop" hidden><input class="sel" id="projq" aria-label="Search projects" placeholder="Search projects…" spellcheck="false" autocomplete="off"><div id="projs" role="listbox"></div></div></div>
|
|
336
|
+
<div class="combo" id="showc" hidden><button class="sel" id="show" aria-label="Projects shown" aria-haspopup="true" aria-expanded="false"></button><div class="menu-pop" id="showpop" hidden><input class="sel" id="showq" aria-label="Search projects" placeholder="Search projects…" spellcheck="false" autocomplete="off"><div id="shows"></div><button id="showall">Show all</button></div></div>
|
|
332
337
|
<span class="sp"></span>
|
|
333
338
|
<button class="btn pri" id="newtop">New idea</button>
|
|
334
339
|
<a class="ib" href="/notebook" title="Notebook" aria-label="Notebook"><svg><use href="#i-note"/></svg></a>
|
|
@@ -446,6 +451,8 @@ let S={project:null,projects:[],cards:[],now:Date.now(),all:false};
|
|
|
446
451
|
let all=new URLSearchParams(location.search).get("all")!=="0",openKey=null,mode=null,modeKey=null,filter=null,menuKey=null,tab=0,runDoc=null;
|
|
447
452
|
// A column shows this many cards, then a "more" line; Done is where it matters.
|
|
448
453
|
const FOLD=6;const unfolded=new Set();
|
|
454
|
+
// Per column: "old" puts the oldest first; anything else is newest first.
|
|
455
|
+
let order=(()=>{try{return JSON.parse(localStorage.getItem("kz-order"))||{}}catch(e){return{}}})();
|
|
449
456
|
let archive=false;
|
|
450
457
|
let clearMenu=false,picking=false;const picked=new Set(); // Clear on Done: menu, then cherry-pick mode // showing the archive instead of the board
|
|
451
458
|
// The server writes notifier() from state.ts over the marker: one rule for both UIs.
|
|
@@ -501,7 +508,7 @@ function card(c){
|
|
|
501
508
|
if(c.git){const t=c.git==="pushed"?"Committed & pushed":"Not committed & pushed yet";chips.push(`<span class="chip ${c.git==="pushed"?"ok":"warn"}" role="img" title="${t}" aria-label="${t}"><svg><use href="#c-git"/></svg></span>`)}
|
|
502
509
|
const pick=picking&&!archive&&c.column===4;
|
|
503
510
|
el.innerHTML=(pick?`<input type="checkbox" class="pick" ${picked.has(k)?"checked":""} aria-label="Select">`:"")+`<div class="c-id"><span class="st d-${st}"><svg><use href="#s-${st}"/></svg></span><span class="idt">${esc(idt)}</span><span class="age">${c.kind==="run"?age(c.moved||S.now,S.now):""}</span><button class="ib sm more" data-a="menu" title="Actions" aria-label="Actions" aria-haspopup="menu" aria-expanded="${k===menuKey}">${ico("more")}</button></div>
|
|
504
|
-
<div class="c-title">${esc(c.kind==="idea"?c.text:(c.title||c.id))}</div>
|
|
511
|
+
<div class="c-title"${c.kind==="idea"&&brief(c.text).parts.length?` title="${esc(c.text)}"`:""}>${esc(c.kind==="idea"?brief(c.text).head:(c.title||c.id))}</div>
|
|
505
512
|
<div class="c-chips">${chips.join("")}</div>`;
|
|
506
513
|
if(k===menuKey){
|
|
507
514
|
const items=[];
|
|
@@ -581,7 +588,9 @@ function render(){
|
|
|
581
588
|
if(!mode)undraft("f:");try{sessionStorage.setItem("kz-form",JSON.stringify(["new","edit","reject"].includes(mode)?[mode,modeKey]:null))}catch(e){}
|
|
582
589
|
// The board is rebuilt below; the card that had focus gets it back.
|
|
583
590
|
const fk=focusedKey();
|
|
584
|
-
|
|
591
|
+
// All projects: only the ones ticked in the filter, or every one when none is.
|
|
592
|
+
const only=all&&shown.size&&showable().some(p=>shown.has(p.dir));
|
|
593
|
+
const cs=S.cards.filter(c=>c.archived===archive&&(!only||shown.has(c.state))),b=$("board");b.innerHTML="";
|
|
585
594
|
const nArch=S.cards.filter(c=>c.archived).length;
|
|
586
595
|
const n=s=>cs.filter(c=>c.status===s).length;
|
|
587
596
|
const strip=[];
|
|
@@ -595,16 +604,21 @@ function render(){
|
|
|
595
604
|
PL=[["All projects","all"],...S.projects.map(p=>[p.label,p.dir]),...S.fresh.map(p=>[p.label,p.dir,1]),["Find projects on this PC…","__find"]];
|
|
596
605
|
$("proj").textContent=all||!S.dir?"All projects":(PL.find(p=>p[1]===S.dir)||["All projects"])[0];
|
|
597
606
|
if(!$("projpop").hidden)list();
|
|
607
|
+
$("showc").hidden=!all;const sp=showable(),ns=sp.filter(p=>shown.has(p.dir)).length;$("show").textContent=ns?`Showing ${ns} of ${sp.length}`:"Showing all";
|
|
608
|
+
if(!$("showpop").hidden)showList();
|
|
598
609
|
if(!cs.length&&mode!=="new"){b.innerHTML=archive?'<div class="empty"><div class="mascot"></div><b>Nothing archived</b></div>':'<div class="empty"><div class="mascot"></div><b>Nothing here yet</b><p>Add an idea and kaizen will plan it, build it, and check the result.</p><button class="btn pri" id="newempty">New idea</button></div>';if(!archive)$("newempty").onclick=()=>{mode="new";render()};}
|
|
599
610
|
COLS.forEach((name,ci)=>{
|
|
600
611
|
const s=document.createElement("section");const list=cs.filter(c=>c.column===ci&&(!filter||c.status===filter));
|
|
612
|
+
// Runs age by their last move, ideas by inbox position (new ones are appended).
|
|
613
|
+
if(ci<4){const at=c=>c.kind==="run"?c.moved||0:S.cards.indexOf(c),old=order[ci]==="old";list.sort((a,b)=>old?at(a)-at(b):at(b)-at(a))}
|
|
601
614
|
const clearable=ci===4&&!archive&&list.length;
|
|
602
615
|
const olderThan=days=>list.filter(c=>S.now-(c.moved||S.now)>days*d);
|
|
603
|
-
let head=`<h2>${name}<i>${list.length||""}</i>${ci===0&&!archive?`<button class="ib sm" title="New idea" aria-label="New idea">${ico("plus")}</button>`:""}`;
|
|
616
|
+
let head=`<h2>${name}<i>${list.length||""}</i>${ci<4&&list.length>1?`<button class="btn sm ghost ord" title="Sort order">${order[ci]==="old"?"Oldest":"Newest"}</button>`:""}${ci===0&&!archive?`<button class="ib sm" title="New idea" aria-label="New idea">${ico("plus")}</button>`:""}`;
|
|
604
617
|
if(clearable&&picking)head+=`<span class="pickbar"><button class="btn sm ghost" data-p="cancel">Cancel</button><button class="btn sm pri" data-p="go" ${picked.size?"":"disabled"}>Archive ${picked.size||""}</button></span>`;
|
|
605
618
|
else if(clearable)head+=`<button class="btn sm ghost clear" title="Archive finished runs">Clear ▾</button>`;
|
|
606
619
|
if(clearable&&clearMenu)head+=`<div class="menu-pop clear-pop"><button data-c="7">Older than a week <i>${olderThan(7).length}</i></button><button data-c="3">Older than 3 days <i>${olderThan(3).length}</i></button><button data-c="0">Everything <i>${list.length}</i></button><hr><button data-c="pick">Pick which…</button></div>`;
|
|
607
620
|
s.innerHTML=head+`</h2>`;
|
|
621
|
+
{const o=s.querySelector(".ord");if(o)o.onclick=e=>{e.stopPropagation();order[ci]=order[ci]==="old"?"new":"old";try{localStorage.setItem("kz-order",JSON.stringify(order))}catch(e){}render()}}
|
|
608
622
|
if(ci===0&&!archive)s.querySelector(".ib").onclick=()=>{mode="new";render()};
|
|
609
623
|
if(clearable){
|
|
610
624
|
const cl=s.querySelector(".clear");if(cl)cl.onclick=e=>{e.stopPropagation();clearMenu=!clearMenu;render()};
|
|
@@ -645,7 +659,7 @@ const tabOf=k=>TABS.findIndex(t=>t[1]===k);
|
|
|
645
659
|
// keepTab: a link already chose the tab.
|
|
646
660
|
function open(c,keepTab){
|
|
647
661
|
stopDictation(true); // the panel body is about to be replaced
|
|
648
|
-
fixOff.clear();openKey=key(c);$("panel").classList.add("open");$("scrim").classList.add("on");$("ptitle").textContent=c.text;render();
|
|
662
|
+
fixOff.clear();openKey=key(c);$("panel").classList.add("open");$("scrim").classList.add("on");$("ptitle").textContent=c.kind==="idea"?brief(c.text).head:c.text;render();
|
|
649
663
|
if(c.kind==="idea"){runDoc=null;$("psub").textContent="Idea · "+c.where;$("ptabs").innerHTML="";chat(c,c.notes,c.archived||c.status!=="idea");
|
|
650
664
|
const foot=$("pfoot");foot.innerHTML=c.archived?`<button class="btn pri" data-a="restore">Restore</button>`:c.status==="idea"?`<button class="btn ghost danger" data-a="delete">Delete</button><button class="btn ghost" data-a="archive">Archive</button><span class="dec"><button class="btn" data-a="reject">Reject</button><button class="btn" data-a="edit">Edit</button><button class="btn pri" data-a="run">Run</button></span>`:`<span class="note">Starting: a run was launched for this idea. No terminal with your agent? Run it again.</span><span class="dec"><button class="btn" data-a="run">Run again</button></span>`;
|
|
651
665
|
foot.querySelectorAll("[data-a]").forEach(b=>b.onclick=()=>{const a=b.dataset.a;if(a==="archive"||a==="restore"){toggleArchive(c,a==="archive");return}const k=key(c);close();mode=a;modeKey=k;render()});return}
|
|
@@ -655,10 +669,10 @@ function open(c,keepTab){
|
|
|
655
669
|
function parseNotes(src){const out=[];for(const raw of (src||"").split("\n")){const m=/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2})\]\s?(.*)$/.exec(raw);if(m){out.push({at:m[1],text:m[2]});continue}const last=out[out.length-1];if(last)last.text+="\n"+raw.replace(/^\s{2}/,"");else if(raw.trim())out.push({at:"",text:raw})}return out.map(n=>({...n,text:n.text.trim()})).filter(n=>n.text)}
|
|
656
670
|
let nfocus=null;
|
|
657
671
|
function chat(c,src,ro){
|
|
658
|
-
const
|
|
659
|
-
const fix=t=>t.replace(/\]\((attachments\/[^)]+)\)/g,(_,p)=>`](/file?dir=${encodeURIComponent(c.state)}&p=${encodeURIComponent(base+p)})`);
|
|
672
|
+
const fix=t=>attach(t,c);
|
|
660
673
|
const entries=parseNotes(src);
|
|
661
|
-
|
|
674
|
+
const b=c.kind==="idea"?brief(c.text):{parts:[]};
|
|
675
|
+
let h=(b.parts.length?`<section class="desc">${md(b.parts.map(p=>`### ${p.label}\n${p.body}`).join("\n\n"))}</section>`:"")+`<div class="chat">${entries.map(n=>`<div class="msg">${n.at?`<time>${esc(n.at)}</time>`:""}${md(fix(n.text))}</div>`).join("")||`<p style="color:var(--dim)">No notes yet.</p>`}</div>`;
|
|
662
676
|
if(!ro)h+=`<div class="notes"><textarea placeholder="Add a note. Paste a screenshot, or attach files."></textarea><div class="att" id="natt"></div><div class="tools"><input type="file" id="nfile" multiple hidden><button class="btn ghost" id="npick">Attach</button><span>Enter sends, Shift+Enter newline</span><button class="btn pri" id="nsend">Send</button></div></div>`;
|
|
663
677
|
$("pbody").innerHTML=h;
|
|
664
678
|
if(ro)return;
|
|
@@ -681,6 +695,8 @@ function chat(c,src,ro){
|
|
|
681
695
|
// "## Commit" is the message the builder wrote for the work, offered at commit and push.
|
|
682
696
|
function section(impl,name){const m=impl&&impl.match(new RegExp(`^##\\s+${name}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$(?![\\s\\S]))`,"m"));return m?m[1].trim():null}
|
|
683
697
|
const preview=impl=>section(impl,"Preview");
|
|
698
|
+
// Links to a card's attachments/ (notes, or screenshots the builder put in Preview) via /file.
|
|
699
|
+
const attach=(t,c)=>t.replace(/\]\((attachments\/[^)]+)\)/g,(_,p)=>`](/file?dir=${encodeURIComponent(c.state)}&p=${encodeURIComponent((c.kind==="run"?`runs/${c.id}/`:"")+p)})`);
|
|
684
700
|
const commitMsg=impl=>(section(impl,"Commit")||"").replace(/^```\w*\n|\n```$/g,"").trim();
|
|
685
701
|
// A plan's Open questions as a pick list: `Q: question` lines with `- option` lines under
|
|
686
702
|
// each, "(recommended)" marking the default and "multi-select" in the question allowing
|
|
@@ -717,7 +733,7 @@ async function openRun(c,refresh,again){
|
|
|
717
733
|
const tabs=TABS;
|
|
718
734
|
$("ptabs").innerHTML=tabs.map((t,i)=>`<button class="${i===tab?"on":""}" data-i="${i}">${t[0]}</button>`).join("");
|
|
719
735
|
[...$("ptabs").children].forEach(b=>b.onclick=()=>{tab=+b.dataset.i;openRun(c,true)});
|
|
720
|
-
const k=tabs[tab][1],doc=k==="preview"?preview(runDoc.impl):runDoc[k];
|
|
736
|
+
const k=tabs[tab][1],doc=k==="preview"?(preview(runDoc.impl)&&attach(preview(runDoc.impl),c)):runDoc[k];
|
|
721
737
|
if(k==="notes"){const ro=c.archived||c.status==="done"||c.status==="abandoned";
|
|
722
738
|
chat(c,doc,ro);}
|
|
723
739
|
else $("pbody").innerHTML=doc?md(doc,k==="backlog"):`<p style="color:var(--dim)">No ${({request:"request",plan:"plan",impl:"work report",preview:"preview",review:"review",backlog:"backlog"})[k]} yet.</p>`;
|
|
@@ -831,6 +847,22 @@ async function commitPush(c,message,b){b.disabled=true;b.dataset.busy=1;b.textCo
|
|
|
831
847
|
async function toggleArchive(c,archived){const r=await post("/archive",c.kind==="idea"?{dir:c.state,text:c.text,archived}:{dir:c.state,id:c.id,archived});if(r.ok)toast(archived?"Archived":"Restored");close();load()}
|
|
832
848
|
function close(){const k=openKey;stopDictation(true);openKey=null;runDoc=null;$("panel").classList.remove("open");$("scrim").classList.remove("on");render();if(k)cardEl(k)?.focus({preventScroll:true})}
|
|
833
849
|
$("pclose").onclick=close;$("scrim").onclick=close;
|
|
850
|
+
// An idea is one inbox line, so a structured request arrives as one wall:
|
|
851
|
+
// "Fix x Problem: … Scope: … Acceptance criteria: (1) … (2) …". Split it, for display
|
|
852
|
+
// only, into the text before the first label and one part per label; inline (1) (2)
|
|
853
|
+
// become a numbered list. Fewer than two labels is plain text and is left whole.
|
|
854
|
+
function brief(t){
|
|
855
|
+
// A label opens a sentence; only the first may follow the head without punctuation,
|
|
856
|
+
// and then only as one word ("… previews Problem:"), so "the Board shows: x" is prose.
|
|
857
|
+
const re=/(^|[.!?)]\s+|\s)([A-Z][\w-]*(?: [a-z][\w-]*){0,3}):\s+/g,cut=[];let m;
|
|
858
|
+
while((m=re.exec(t)))if(!/^\s+$/.test(m[1])||(!cut.length&&!m[2].includes(" ")))cut.push([m.index+m[1].length,m[2],re.lastIndex]);
|
|
859
|
+
if(cut.length<2)return {head:t,parts:[]};
|
|
860
|
+
// Inline (1) (2) … become a list; numbers that do not run 1, 2, 3 keep their own text.
|
|
861
|
+
const parts=cut.map(([,label,from],i)=>{const body=t.slice(from,i+1<cut.length?cut[i+1][0]:t.length).trim(),n=body.split(/\s*\((\d+)\)\s+/);
|
|
862
|
+
if(n.length<5)return {label,body};const its=[];for(let k=1;k<n.length;k+=2)its.push([n[k],n[k+1]]);
|
|
863
|
+
const seq=its.every(([d],k)=>+d===k+1);
|
|
864
|
+
return {label,body:(n[0]?n[0]+"\n":"")+its.map(([d,x],k)=>seq?`${k+1}. ${x}`:`- (${d}) ${x}`).join("\n")}});
|
|
865
|
+
return {head:t.slice(0,cut[0][0]).trim()||`${parts[0].label}: ${t.slice(cut[0][2],cut[1][0]).trim()}`,parts}}
|
|
834
866
|
|
|
835
867
|
// Enough markdown for kaizen's own files: headings, lists, fences, tables,
|
|
836
868
|
// inline code, bold. Everything is escaped first. With `tick`, a backlog item
|
|
@@ -986,6 +1018,21 @@ async function pick(v){
|
|
|
986
1018
|
else if(v==="all"){all=true;keep()}
|
|
987
1019
|
else{const j=await post("/cd",{dir:v});if(j.ok){all=false;keep(v)}}
|
|
988
1020
|
load()}
|
|
1021
|
+
// The all-projects filter: tick the projects to show; none ticked shows every one. Kept per browser.
|
|
1022
|
+
let shown=new Set((()=>{try{return JSON.parse(localStorage.getItem("kz-show"))||[]}catch(e){return[]}})());
|
|
1023
|
+
// Known projects, plus any folder a card comes from that is not one (the global inbox).
|
|
1024
|
+
function showable(){const l=[...S.projects];for(const c of S.cards)if(!l.some(p=>p.dir===c.state))l.push({dir:c.state,label:c.where});return l}
|
|
1025
|
+
let SP=[];
|
|
1026
|
+
function showList(){const q=$("showq").value.trim().toLowerCase();SP=showable();
|
|
1027
|
+
$("shows").innerHTML=SP.map((p,i)=>p.label.toLowerCase().includes(q)?`<label title="${esc(p.label)}"><input type="checkbox" data-i="${i}" ${shown.has(p.dir)?"checked":""}>${esc(p.label)}</label>`:"").join("")||`<div class="grp">No match</div>`}
|
|
1028
|
+
function setShown(){try{localStorage.setItem("kz-show",JSON.stringify([...shown]))}catch(e){}render()}
|
|
1029
|
+
$("show").onclick=()=>{const on=$("showpop").hidden;$("showpop").hidden=!on;$("show").setAttribute("aria-expanded",on);if(on){$("showq").value="";showList();$("showq").focus()}};
|
|
1030
|
+
$("showq").oninput=showList;
|
|
1031
|
+
$("showq").onkeydown=e=>{if(e.key==="Escape"){$("showpop").hidden=true;$("show").setAttribute("aria-expanded",false);$("show").focus()}};
|
|
1032
|
+
// render() redraws the list; the ticked box keeps keyboard focus.
|
|
1033
|
+
$("shows").onchange=e=>{const i=e.target.dataset.i,d=SP[i]?.dir;if(!d)return;e.target.checked?shown.add(d):shown.delete(d);setShown();$("shows").querySelector(`[data-i="${i}"]`)?.focus()};
|
|
1034
|
+
$("showall").onclick=()=>{shown.clear();setShown()};
|
|
1035
|
+
document.addEventListener("click",e=>{if(!e.target.closest("#showc")){$("showpop").hidden=true;$("show").setAttribute("aria-expanded",false)}});
|
|
989
1036
|
$("newtop").onclick=()=>{mode="new";render();$("board").scrollTo(0,0)};
|
|
990
1037
|
$("settings").onclick=()=>{setQ="";openSettings()};$("sources").onclick=openSources;
|
|
991
1038
|
try{const t=localStorage.getItem("kz-theme");if(t)document.documentElement.dataset.theme=t}catch(e){}
|