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 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
+ });
@@ -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
- // An install made without git is a copy, not a clone, so there is nothing to pull:
411
- // upgrading itself would copy ~/kaizen over ~/kaizen and report "already up to
412
- // date" forever. The new version comes from npm, and the installer inside it does
413
- // the copying and relinking, so hand the job over and stop.
414
- if (upgrade && !existsSync(join(dirname(import.meta.dir), ".git"))) {
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
- const now = versionOf(dirname(import.meta.dir));
417
- // A copy can only upgrade through npm. When the version lookup fails, bun still
418
- // resolves "latest" its own way, so ask for that rather than falling through --
419
- // which would copy this install over itself and call it up to date.
420
- if (latest !== now) {
421
- const want = latest ?? "latest";
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 = process.env.KAIZEN_HOME ?? join(home, "kaizen");
601
- // The launcher runs the clone itself, so an upgrade from inside it must pull
602
- // that clone -- returning it untouched was "Up to date" at whatever was there.
603
- for (const dir of [here, dest]) {
604
- if (!existsSync(join(dir, ".git"))) continue;
605
- if (!upgrade && dir === here) return dir;
606
- // A pull can refuse for reasons that have nothing to do with linking -- local
607
- // edits, a detached head, no network. Linking the checkout already on disk
608
- // still works, so say what happened and carry on.
609
- try {
610
- await Bun.$`git -C ${dir} pull --ff-only`.quiet();
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
- return dir;
616
- }
617
- // A copy made before git was installed can never be pulled, so it keeps upgrading
618
- // through npm and its caches. Once git is here, replace it with a real clone --
619
- // into a sibling first, so a clone that fails leaves the working copy alone.
620
- if (Bun.which("git") && existsSync(join(dest, "cli", "install.ts"))) {
621
- const fresh = `${dest}.new`;
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
- await Bun.$`git clone --quiet ${REPO_URL} ${fresh}`;
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
- if (upgrade) {
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(existsSync(join(repoDir, ".git"))
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-agent",
3
- "version": "1.17.0",
3
+ "version": "1.19.0",
4
4
  "description": "Staged workflow for AI agents — code, writing, research, operations: plan, human approval, build, independent review, bounded fix loop.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
+ `![what it shows](attachments/<name>.png)`; 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
- const cs=S.cards.filter(c=>c.archived===archive),b=$("board");b.innerHTML="";
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 base=c.kind==="run"?`runs/${c.id}/`:"";
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
- let h=`<div class="chat">${entries.map(n=>`<div class="msg">${n.at?`<time>${esc(n.at)}</time>`:""}${md(fix(n.text))}</div>`).join("")||`<p style="color:var(--dim)">No notes yet.</p>`}</div>`;
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){}