kaizen-agent 1.42.2 → 1.43.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/backlog.test.ts +133 -0
- package/cli/board.test.ts +7 -2
- package/cli/html.test.ts +19 -0
- package/cli/notes.ts +2 -6
- package/cli/state.test.ts +61 -2
- package/cli/state.ts +219 -45
- package/cli/web.ts +58 -15
- package/package.json +1 -1
- package/web/board/approvals.ts +3 -3
- package/web/board/cards.ts +240 -107
- package/web/board/chat.ts +4 -4
- package/web/board/dom.ts +17 -5
- package/web/board/fixes.ts +6 -8
- package/web/board/git.ts +3 -3
- package/web/board/icebox.ts +51 -69
- package/web/board/keys.ts +7 -6
- package/web/board/main.ts +1 -0
- package/web/board/panel.ts +22 -16
- package/web/board/projects.ts +9 -14
- package/web/board/settings.ts +18 -22
- package/web/board/store.ts +2 -0
- package/web/board/update.ts +7 -7
- package/web/board.html +12 -0
- package/web/board.js +23 -23
- package/web/notebook.js +1 -1
- package/web/term.js +1 -1
package/cli/state.ts
CHANGED
|
@@ -405,9 +405,11 @@ export function readRuns(state: string): Run[] {
|
|
|
405
405
|
const file = join(dir, id, "state.json");
|
|
406
406
|
const s = JSON.parse(readText(file));
|
|
407
407
|
// When the run last moved. Kaizen records no liveness signal, so the file's
|
|
408
|
-
// own mtime is the
|
|
408
|
+
// own mtime is the evidence that anything is still working on it, along with
|
|
409
|
+
// progress.md, which a builder ticks between state.json writes.
|
|
409
410
|
let moved = 0;
|
|
410
411
|
try { moved = statSync(file).mtimeMs; } catch { /* vanished mid-read */ }
|
|
412
|
+
try { moved = Math.max(moved, statSync(join(dir, id, "progress.md")).mtimeMs); } catch { /* no progress kept */ }
|
|
411
413
|
return [{ id, stage: s.stage ?? "?", awaiting: s.awaiting ?? null, moved, runner: s.runner, agent: s.agent, branch: typeof s.branch === "string" ? s.branch : undefined }];
|
|
412
414
|
} catch { return []; }
|
|
413
415
|
}).reverse();
|
|
@@ -612,6 +614,105 @@ export function allBacklog(state: string) {
|
|
|
612
614
|
return out;
|
|
613
615
|
}
|
|
614
616
|
|
|
617
|
+
// Every `- open|done|rejected:` line of a backlog file, numbered from 1 in file order.
|
|
618
|
+
// The number is how a notebook link (`#backlog-3`) and a backlog card name an item, so
|
|
619
|
+
// lines are never removed or reordered: closing one rewrites its status word in place.
|
|
620
|
+
export function backlogLines(file: string) {
|
|
621
|
+
let text = "";
|
|
622
|
+
try { text = readText(file); } catch { return []; }
|
|
623
|
+
const out: { n: number; status: string; text: string; line: number }[] = [];
|
|
624
|
+
text.split("\n").forEach((l, line) => {
|
|
625
|
+
const m = /^\s*-\s*(open|done|rejected):\s*(.*)$/.exec(l.replace(/\r$/, ""));
|
|
626
|
+
if (m) out.push({ n: out.length + 1, status: m[1]!, text: m[2]!.trim(), line });
|
|
627
|
+
});
|
|
628
|
+
return out;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// A backlog card's source: a run id, or "orphan" for the state dir's backlog.md.
|
|
632
|
+
function backlogFile(state: string, source: string) {
|
|
633
|
+
if (source === "orphan") return join(state, "backlog.md");
|
|
634
|
+
return /^[\w.-]+$/.test(source) ? join(state, "runs", source, "06-backlog.md") : null;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Line n of a backlog, or null when the source or the line does not exist.
|
|
638
|
+
export function backlogItem(state: string, source: string, n: number) {
|
|
639
|
+
const file = backlogFile(state, source);
|
|
640
|
+
return (file && backlogLines(file).find((l) => l.n === n)) || null;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// The runs started from a backlog item, by the `Backlog: <source>#<n>` line the board
|
|
644
|
+
// puts in their request (beside `Parent:`). A run with no state.json yet is not counted.
|
|
645
|
+
// ponytail: reads every 00-request.md per refresh, as startedRuns does.
|
|
646
|
+
export function startedBacklog(state: string) {
|
|
647
|
+
const out = new Map<string, { id: string; stage: string }[]>();
|
|
648
|
+
for (const r of readRuns(state)) {
|
|
649
|
+
let body = "";
|
|
650
|
+
try { body = readText(join(state, "runs", r.id, "00-request.md")); } catch { continue; }
|
|
651
|
+
const m = /^\s*(?:\[[^\]]*\]\s*)?Backlog:\s*([\w.-]+)#(\d+)\s*$/im.exec(body);
|
|
652
|
+
if (!m) continue;
|
|
653
|
+
const k = `${m[1]}#${Number(m[2])}`;
|
|
654
|
+
out.set(k, [...(out.get(k) ?? []), { id: r.id, stage: r.stage }]);
|
|
655
|
+
}
|
|
656
|
+
return out;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
export type BacklogCard = { state: string; where: string; source: string; n: number; text: string; raw: string; severity: string | null; at: string | null };
|
|
660
|
+
|
|
661
|
+
const SEVERITY_RANK: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
662
|
+
|
|
663
|
+
// The open backlog items of every non-abandoned run and the orphanage, as cards for the
|
|
664
|
+
// board's Ideas column: most severe first, then newest run first, then file order. An
|
|
665
|
+
// item a live run was started from is not shown; an abandoned one gives it back.
|
|
666
|
+
export function backlogCards(states: string[]): BacklogCard[] {
|
|
667
|
+
const out: BacklogCard[] = [];
|
|
668
|
+
for (const st of states) {
|
|
669
|
+
const where = label(st);
|
|
670
|
+
const started = startedBacklog(st);
|
|
671
|
+
const runs = existsSync(join(st, "runs")) ? readdirSync(join(st, "runs")).sort().reverse() : [];
|
|
672
|
+
const sources = [...runs.filter((id) => existsSync(join(st, "runs", id, "state.json")) && !abandoned(join(st, "runs", id))), "orphan"];
|
|
673
|
+
for (const source of sources) {
|
|
674
|
+
for (const l of backlogLines(backlogFile(st, source)!)) {
|
|
675
|
+
if (l.status !== "open") continue;
|
|
676
|
+
if ((started.get(`${source}#${l.n}`) ?? []).some((c) => c.stage !== "abandoned")) continue;
|
|
677
|
+
const it = parseItem(l.text);
|
|
678
|
+
out.push({ state: st, where, source, n: l.n, text: it.text, raw: l.text, severity: it.severity, at: it.where });
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
const rank = (c: BacklogCard) => SEVERITY_RANK[c.severity ?? ""] ?? 4;
|
|
683
|
+
return out.sort((a, b) => rank(a) - rank(b));
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Rewrite the status of backlog line n from open, keeping its text and every other
|
|
687
|
+
// byte of the file; `suffix` is appended after a ` | `. Null when written, else why not.
|
|
688
|
+
export function closeBacklog(state: string, source: string, n: number, status: "done" | "rejected", suffix: string): string | null {
|
|
689
|
+
const file = backlogFile(state, source);
|
|
690
|
+
if (!file || !existsSync(file)) return "no such backlog";
|
|
691
|
+
const item = backlogLines(file).find((l) => l.n === n);
|
|
692
|
+
if (!item) return "no such backlog item";
|
|
693
|
+
if (item.status !== "open") return "that item is already closed";
|
|
694
|
+
// The raw bytes, not readText: a CRLF file or a BOM stays as it was written.
|
|
695
|
+
const lines = readFileSync(file, "utf8").split("\n");
|
|
696
|
+
const old = lines[item.line]!, cr = old.endsWith("\r") ? "\r" : "", bom = item.line === 0 && old.startsWith("\uFEFF") ? "\uFEFF" : "";
|
|
697
|
+
const body = old.slice(bom.length).replace(/\r$/, "");
|
|
698
|
+
if (!/^\s*-\s*open:/.test(body)) return "no such backlog item";
|
|
699
|
+
lines[item.line] = bom + body.replace(/^(\s*-\s*)open:\s*/, `$1${status}: `).replace(/\s+$/, "") + ` | ${suffix}` + cr;
|
|
700
|
+
writeFileSync(file, lines.join("\n"));
|
|
701
|
+
return null;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// A backlog item whose run is done is closed by that run: `done: <text> | <run id>`.
|
|
705
|
+
// Run from the board's clock, never from a page load. Reads requests and state.json,
|
|
706
|
+
// writes only backlog files, so no run looks as if it moved.
|
|
707
|
+
export function markBacklogDone(state: string) {
|
|
708
|
+
for (const [k, kids] of startedBacklog(state)) {
|
|
709
|
+
const done = kids.find((c) => c.stage === "done");
|
|
710
|
+
if (!done) continue;
|
|
711
|
+
const [source, n] = k.split("#");
|
|
712
|
+
closeBacklog(state, source!, Number(n), "done", done.id);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
615
716
|
// ---------------------------------------------------------------- board data
|
|
616
717
|
|
|
617
718
|
// ---------------------------------------------------------------- board data
|
|
@@ -1015,7 +1116,8 @@ export function boardCards(states: string[], now: number): Card[] {
|
|
|
1015
1116
|
}
|
|
1016
1117
|
// A launched idea whose run reworded the request: the board's own launch record,
|
|
1017
1118
|
// owned by a run (session id in its state.json, see ownerOf), retires it all the same.
|
|
1018
|
-
const
|
|
1119
|
+
const indexes = new Map<string, RunIndex>(), indexOf = (st: string) => indexes.get(st) ?? indexes.set(st, runIndex(st)).get(st)!;
|
|
1120
|
+
const launchedInto = (st: string, t: string) => readSessions(st).some((s) => !s.run && normalise(s.text.replace(/^(lite|full|auto|plan)\s+/i, "")) === t.replace(/^(lite|full|auto|plan)\s+/i, "") && ownerOf(st, s, indexOf(st)) !== null);
|
|
1019
1121
|
for (const st of states) {
|
|
1020
1122
|
const where = label(st);
|
|
1021
1123
|
const archive = readArchive(st);
|
|
@@ -1041,7 +1143,8 @@ export function boardCards(states: string[], now: number): Card[] {
|
|
|
1041
1143
|
}
|
|
1042
1144
|
// Runs started in Yolo. Only yolo records are matched to runs, so a project that
|
|
1043
1145
|
// never used it pays one directory read.
|
|
1044
|
-
const
|
|
1146
|
+
const yoloRecs = readSessions(st).filter((s) => s.yolo);
|
|
1147
|
+
const yolos = new Set(yoloRecs.length ? yoloRecs.map((s) => ownerOf(st, s, indexOf(st))) : []);
|
|
1045
1148
|
for (const r of readRuns(st)) {
|
|
1046
1149
|
next.push({
|
|
1047
1150
|
kind: "run", id: r.id, status: statusOf(r, now), text: short(r.id), state: st, where, column: columnOf(r.stage, r.awaiting), moved: r.moved,
|
|
@@ -1225,33 +1328,45 @@ export function readSessions(st: string): Session[] {
|
|
|
1225
1328
|
}).sort((a, b) => a.started - b.started);
|
|
1226
1329
|
}
|
|
1227
1330
|
|
|
1331
|
+
// What ownerOf matches a record against, read once: each conversation id a run recorded
|
|
1332
|
+
// at intake, to the first run (by id) that recorded it, and every run's request with
|
|
1333
|
+
// when it was written.
|
|
1334
|
+
type RunIndex = { bySession: Map<string, string>; requests: { id: string; born: number; text: string }[] };
|
|
1335
|
+
export function runIndex(st: string): RunIndex {
|
|
1336
|
+
const bySession = new Map<string, string>(), requests: RunIndex["requests"] = [];
|
|
1337
|
+
try {
|
|
1338
|
+
for (const id of readdirSync(join(st, "runs")).sort()) {
|
|
1339
|
+
try {
|
|
1340
|
+
const sid = JSON.parse(readText(join(st, "runs", id, "state.json"))).session;
|
|
1341
|
+
if (sid && !bySession.has(sid)) bySession.set(sid, id);
|
|
1342
|
+
} catch { /* no state yet */ }
|
|
1343
|
+
try {
|
|
1344
|
+
const f = join(st, "runs", id, "00-request.md"), st2 = statSync(f);
|
|
1345
|
+
requests.push({ id, born: st2.birthtimeMs || st2.mtimeMs, text: normalise(readText(f)) });
|
|
1346
|
+
} catch { /* no request yet */ }
|
|
1347
|
+
}
|
|
1348
|
+
} catch { /* no runs dir */ }
|
|
1349
|
+
return { bySession, requests };
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1228
1352
|
// Whose session a record is. A decision's launch names its run. An idea's launch came
|
|
1229
1353
|
// before its run existed, so it belongs to the first run created after it whose
|
|
1230
1354
|
// request holds the idea's text: one run per record, never every run that mentions it.
|
|
1231
|
-
|
|
1355
|
+
// A caller asking about many sessions passes runIndex(st) once: built per call, it
|
|
1356
|
+
// reads every run's state.json and request each time.
|
|
1357
|
+
export function ownerOf(st: string, s: Session, idx?: RunIndex): string | null {
|
|
1232
1358
|
if (s.run) return s.run;
|
|
1359
|
+
const { bySession, requests } = idx ?? runIndex(st);
|
|
1233
1360
|
// The launch's own conversation id, recorded by the run at intake: holds however the
|
|
1234
1361
|
// request was worded. Text matching below is for tools that expose no id.
|
|
1235
1362
|
if (s.sessionId) {
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
try { return JSON.parse(readText(join(st, "runs", id, "state.json"))).session === s.sessionId; } catch { return false; }
|
|
1239
|
-
}).sort();
|
|
1240
|
-
if (mine.length) return mine[0]!;
|
|
1241
|
-
} catch { /* no runs dir */ }
|
|
1363
|
+
const mine = bySession.get(s.sessionId);
|
|
1364
|
+
if (mine) return mine;
|
|
1242
1365
|
}
|
|
1243
1366
|
const t = normalise(s.text);
|
|
1244
1367
|
let best: string | null = null, at = Infinity;
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
try {
|
|
1248
|
-
const f = join(st, "runs", id, "00-request.md");
|
|
1249
|
-
const st2 = statSync(f), born = st2.birthtimeMs || st2.mtimeMs;
|
|
1250
|
-
// A second of slack: file times come from a coarser clock than Date.now().
|
|
1251
|
-
if (born >= s.started - 1000 && born < at && normalise(readText(f)).includes(t)) { best = id; at = born; }
|
|
1252
|
-
} catch { /* no request yet */ }
|
|
1253
|
-
}
|
|
1254
|
-
} catch { /* no runs dir */ }
|
|
1368
|
+
// A second of slack: file times come from a coarser clock than Date.now().
|
|
1369
|
+
for (const r of requests) if (r.born >= s.started - 1000 && r.born < at && r.text.includes(t)) { best = r.id; at = r.born; }
|
|
1255
1370
|
return best;
|
|
1256
1371
|
}
|
|
1257
1372
|
|
|
@@ -1278,16 +1393,17 @@ export function launchMode(projectDir: string): "card" | "terminal" {
|
|
|
1278
1393
|
// Whether a live PID is still the agent a record launched, not a stranger that reused
|
|
1279
1394
|
// the number: the tool's name (and conversation id) in its arguments, started no
|
|
1280
1395
|
// earlier than the record. On Windows the PID is the PowerShell kaizen opened.
|
|
1281
|
-
|
|
1396
|
+
// null: the system could not say (no ps, no answer), which callers treat as not ours.
|
|
1397
|
+
function isSession(pid: number, s: Session): boolean | null {
|
|
1282
1398
|
try {
|
|
1283
1399
|
if (process.platform === "win32") {
|
|
1284
|
-
const out = Bun.spawnSync([sysExe("tasklist.exe"), "/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], { stdin: "ignore", timeout: 5000, windowsHide: true }).stdout.toString();
|
|
1285
|
-
return /^"(powershell|pwsh)\.exe"/i.test(out
|
|
1400
|
+
const out = Bun.spawnSync([sysExe("tasklist.exe"), "/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], { stdin: "ignore", timeout: 5000, windowsHide: true }).stdout.toString().trim();
|
|
1401
|
+
return out.startsWith('"') ? /^"(powershell|pwsh)\.exe"/i.test(out) : null;
|
|
1286
1402
|
}
|
|
1287
1403
|
const ps = onPath("ps");
|
|
1288
|
-
if (!ps) return
|
|
1404
|
+
if (!ps) return null;
|
|
1289
1405
|
const m = /^\s*(\S+)\s+(.*)$/.exec(Bun.spawnSync([ps, "-o", "etime=,args=", "-p", String(pid)], { stdin: "ignore", timeout: 5000 }).stdout.toString().trim());
|
|
1290
|
-
if (!m) return
|
|
1406
|
+
if (!m) return null;
|
|
1291
1407
|
// etime is [[dd-]hh:]mm:ss
|
|
1292
1408
|
const [d, rest] = m[1].includes("-") ? m[1].split("-") : ["0", m[1]];
|
|
1293
1409
|
const secs = rest.split(":").reduce((a, n) => a * 60 + Number(n), 0) + Number(d) * 86400;
|
|
@@ -1296,7 +1412,7 @@ function isSession(pid: number, s: Session): boolean {
|
|
|
1296
1412
|
const idInArgs = s.sessionId && (SESSION_FLAGS[s.cmd]?.start || s.resumed);
|
|
1297
1413
|
return Date.now() - secs * 1000 >= s.started - 2000 && m[2].includes(s.cmd) && (!idInArgs || m[2].includes(s.sessionId!));
|
|
1298
1414
|
} catch {
|
|
1299
|
-
return
|
|
1415
|
+
return null;
|
|
1300
1416
|
}
|
|
1301
1417
|
}
|
|
1302
1418
|
|
|
@@ -1323,7 +1439,19 @@ function closeOne(st: string, s: Session): boolean {
|
|
|
1323
1439
|
return closed;
|
|
1324
1440
|
}
|
|
1325
1441
|
|
|
1326
|
-
|
|
1442
|
+
// Sessions with a PID file. A file whose process is gone, or whose number another
|
|
1443
|
+
// process now holds, is dropped on the way: one is left per launch that ended by itself,
|
|
1444
|
+
// and each would be asked about every sweep. Only when the system says so; a PID it
|
|
1445
|
+
// cannot answer for stays.
|
|
1446
|
+
const openSessions = (st: string) => readSessions(st).filter((s) => {
|
|
1447
|
+
const f = join(sessionsDir(st), s.key + ".pid");
|
|
1448
|
+
if (!existsSync(f)) return false;
|
|
1449
|
+
const pid = parseInt(readText(f));
|
|
1450
|
+
let gone = false;
|
|
1451
|
+
try { process.kill(pid, 0); } catch (e: any) { gone = e?.code === "ESRCH"; }
|
|
1452
|
+
if (gone || (pid > 0 && isSession(pid, s) === false)) { rmSync(f, { force: true }); return false; }
|
|
1453
|
+
return true;
|
|
1454
|
+
});
|
|
1327
1455
|
|
|
1328
1456
|
// Close every session kaizen opened for a run, those launched before `before` only,
|
|
1329
1457
|
// so a decision's own successor is never caught. The record stays: it holds the
|
|
@@ -1331,7 +1459,8 @@ const openSessions = (st: string) => readSessions(st).filter((s) => existsSync(j
|
|
|
1331
1459
|
export function closeSessions(st: string, id: string, opts: { before?: number } = {}): number {
|
|
1332
1460
|
try {
|
|
1333
1461
|
if (!closeEnabled(dirname(st))) return 0;
|
|
1334
|
-
|
|
1462
|
+
const idx = runIndex(st);
|
|
1463
|
+
return openSessions(st).filter((s) => s.started < (opts.before ?? Infinity) && ownerOf(st, s, idx) === id).filter((s) => closeOne(st, s)).length;
|
|
1335
1464
|
} catch { return 0; }
|
|
1336
1465
|
}
|
|
1337
1466
|
|
|
@@ -1343,9 +1472,9 @@ export function sweepSessions(st: string, now: number) {
|
|
|
1343
1472
|
try {
|
|
1344
1473
|
const live = openSessions(st);
|
|
1345
1474
|
if (!live.length || !closeEnabled(dirname(st))) return;
|
|
1346
|
-
const runs = new Map(readRuns(st).map((r) => [r.id, r]));
|
|
1475
|
+
const runs = new Map(readRuns(st).map((r) => [r.id, r])), idx = runIndex(st);
|
|
1347
1476
|
for (const s of live) {
|
|
1348
|
-
const r = runs.get(ownerOf(st, s) ?? "");
|
|
1477
|
+
const r = runs.get(ownerOf(st, s, idx) ?? "");
|
|
1349
1478
|
if (r && r.stage === "done" && s.started <= r.moved && now - r.moved >= CLOSE_GRACE_MS && !typedSince(st, s, r.moved)) closeOne(st, s);
|
|
1350
1479
|
}
|
|
1351
1480
|
} catch { /* the next sweep tries again */ }
|
|
@@ -1376,9 +1505,9 @@ export function firstSession(st: string, id: string, cmd: string, cwd: string):
|
|
|
1376
1505
|
const f = SESSION_FLAGS[cmd];
|
|
1377
1506
|
if (!f) return null;
|
|
1378
1507
|
const all = readSessions(st);
|
|
1379
|
-
const taken = new Set(all.flatMap((s) => (s.sessionId ? [s.sessionId] : [])));
|
|
1508
|
+
const taken = new Set(all.flatMap((s) => (s.sessionId ? [s.sessionId] : []))), idx = runIndex(st);
|
|
1380
1509
|
for (const s of all) {
|
|
1381
|
-
if (s.cmd !== cmd || ownerOf(st, s) !== id) continue;
|
|
1510
|
+
if (s.cmd !== cmd || ownerOf(st, s, idx) !== id) continue;
|
|
1382
1511
|
if (!s.sessionId && f.find) {
|
|
1383
1512
|
// Before the tool's next launch here, any run's: from then on a conversation may be that launch's.
|
|
1384
1513
|
const next = all.find((o) => o.cmd === cmd && o.started > s.started)?.started ?? Infinity;
|
|
@@ -1397,7 +1526,7 @@ export function firstSession(st: string, id: string, cmd: string, cwd: string):
|
|
|
1397
1526
|
// The tool and model a run was started with: its latest recorded launch, else the
|
|
1398
1527
|
// agent its state.json names. A decision continues on these whatever the config says now.
|
|
1399
1528
|
export function runAgent(st: string, id: string): { cmd: string; model: string | null } | null {
|
|
1400
|
-
const s = readSessions(st).filter((x) => ownerOf(st, x) === id).pop();
|
|
1529
|
+
const idx = runIndex(st), s = readSessions(st).filter((x) => ownerOf(st, x, idx) === id).pop();
|
|
1401
1530
|
if (s) return { cmd: s.cmd, model: s.model ?? null };
|
|
1402
1531
|
try {
|
|
1403
1532
|
const a = JSON.parse(readText(join(st, "runs", id, "state.json"))).agent;
|
|
@@ -1469,7 +1598,9 @@ export function launchRun(it: Item, from: string, run?: string, pick: { agent?:
|
|
|
1469
1598
|
// Yolo was picked for one tool; a fallback to another opens in its normal mode.
|
|
1470
1599
|
// A decision on a run started in Yolo keeps it, or its next stage stops on every command.
|
|
1471
1600
|
const st = join(projectDir, ".kaizen");
|
|
1472
|
-
const
|
|
1601
|
+
const yoloRecs = run && !pick.agent ? readSessions(st).filter((s) => s.yolo && s.cmd === agent.cmd) : [];
|
|
1602
|
+
const idx = yoloRecs.length ? runIndex(st) : undefined;
|
|
1603
|
+
const wasYolo = yoloRecs.some((s) => ownerOf(st, s, idx) === run);
|
|
1473
1604
|
const yolo = (wasYolo || (!!pick.yolo && agent.cmd === pick.agent)) && !!YOLO_FLAGS[agent.cmd];
|
|
1474
1605
|
const flags = [...agentFlags(projectDir, agent.cmd, undefined, model), ...(yolo ? YOLO_FLAGS[agent.cmd] : [])];
|
|
1475
1606
|
// The idea as its run's request will hold it: without the runner word in front.
|
|
@@ -1555,7 +1686,8 @@ async function gitAsync(dir: string, ...args: string[]) {
|
|
|
1555
1686
|
} catch { return NO_GIT; }
|
|
1556
1687
|
}
|
|
1557
1688
|
|
|
1558
|
-
export type GitStatus = { repo: false } | { repo: true; branch: string; remote: string; files: string[]; ahead: number; others?: number; shared?: string[] };
|
|
1689
|
+
export type GitStatus = { repo: false } | { repo: true; branch: string; remote: string; files: string[]; ahead: number; others?: number; shared?: string[]; unpushed?: Unpushed[] };
|
|
1690
|
+
type Unpushed = { at: number; subject: string; files: string[] };
|
|
1559
1691
|
|
|
1560
1692
|
// A project the board can commit for: a git work tree with somewhere to push.
|
|
1561
1693
|
export function gitStatus(projectDir: string): GitStatus {
|
|
@@ -1567,7 +1699,14 @@ export function gitStatus(projectDir: string): GitStatus {
|
|
|
1567
1699
|
// No upstream yet means nothing of this branch is on the remote: any commit is ahead.
|
|
1568
1700
|
const up = git(projectDir, "rev-list", "--count", "@{u}..HEAD");
|
|
1569
1701
|
const ahead = up.ok ? Number(up.out) : git(projectDir, "rev-parse", "HEAD").ok ? 1 : 0;
|
|
1570
|
-
|
|
1702
|
+
// Each commit not on the remote yet, with its files: a run is ahead only by its own.
|
|
1703
|
+
const unpushed = up.ok && ahead > 0
|
|
1704
|
+
? git(projectDir, "log", "@{u}..HEAD", "--format=%x00%ct %s", "--name-only").out.split("\0").slice(1).map((c) => {
|
|
1705
|
+
const [head, ...files] = c.trim().split("\n"), sp = head.indexOf(" ");
|
|
1706
|
+
return { at: Number(head.slice(0, sp)) * 1000, subject: head.slice(sp + 1).trim(), files: files.filter(Boolean) };
|
|
1707
|
+
})
|
|
1708
|
+
: undefined;
|
|
1709
|
+
return { repo: true, branch: git(projectDir, "branch", "--show-current").out, remote, files: st ? st.split("\n") : [], ahead, ...(unpushed ? { unpushed } : {}) };
|
|
1571
1710
|
}
|
|
1572
1711
|
|
|
1573
1712
|
// The path of a `git status --porcelain` line; for a rename, the new one.
|
|
@@ -1592,10 +1731,44 @@ function runReports(state: string): Report[] {
|
|
|
1592
1731
|
try { ids = readdirSync(join(state, "runs")); } catch {}
|
|
1593
1732
|
return ids.map((id) => ({ id, ...runReport(state, id) }));
|
|
1594
1733
|
}
|
|
1595
|
-
|
|
1734
|
+
// A run holds a path at time `t` when its report names it, was written by then, and no
|
|
1735
|
+
// other run naming it reported in between.
|
|
1736
|
+
const owns = (mine: Report, rest: Report[], p: string, t: number) =>
|
|
1737
|
+
names(mine.text, p) && mine.at <= t && !rest.some((r) => r.at > mine.at && r.at <= t && names(r.text, p));
|
|
1738
|
+
const lastCommit = (dir: string, p: string) => Number(git(dir, "--literal-pathspecs", "log", "-1", "--format=%ct", "--", p).out) * 1000;
|
|
1739
|
+
// A changed file is the run's unless it was committed after the report: then the run's
|
|
1740
|
+
// work in it is in, and what changed since is someone else's. A commit in the same
|
|
1741
|
+
// second as the report keeps the file offered; hiding real work is the worse mistake.
|
|
1742
|
+
const runFiles = (dir: string, changed: string[], mine: Report, rest: Report[]) => changed.filter((l) => {
|
|
1596
1743
|
const p = statusPath(l);
|
|
1597
|
-
return
|
|
1744
|
+
return owns(mine, rest, p, Infinity) && !(lastCommit(dir, p) > mine.at);
|
|
1598
1745
|
});
|
|
1746
|
+
// How many unpushed commits each run has, worked out once for every run. A commit is
|
|
1747
|
+
// one run's, so a wide commit is not pinned on every old run that once touched a file
|
|
1748
|
+
// in it: the run whose report holds its subject (the `## Commit` message), else, of
|
|
1749
|
+
// the runs that reported by then (to the end of that second, since commit times are
|
|
1750
|
+
// whole seconds), the one naming most of its paths, the latest on a tie. One naming
|
|
1751
|
+
// none, a merge or a hand commit, goes to the run that reported last, so some run
|
|
1752
|
+
// still offers the push. No upstream yet: nothing is on the remote, every run is ahead by
|
|
1753
|
+
// the branch.
|
|
1754
|
+
function aheadBy(s: GitStatus & { repo: true }, all: Report[]): (id: string) => number {
|
|
1755
|
+
if (!s.unpushed) return () => s.ahead;
|
|
1756
|
+
const n = new Map<string, number>();
|
|
1757
|
+
for (const c of s.unpushed) {
|
|
1758
|
+
// The subject as a line of its own: a short one ("wip") is not found inside prose.
|
|
1759
|
+
let best = c.subject ? all.find((r) => r.text.split("\n").some((l) => l.trim() === c.subject)) : undefined;
|
|
1760
|
+
if (!best) {
|
|
1761
|
+
let most = -1;
|
|
1762
|
+
for (const r of all) {
|
|
1763
|
+
if (!r.text || r.at > c.at + 999) continue;
|
|
1764
|
+
const k = c.files.filter((p) => names(r.text, p)).length;
|
|
1765
|
+
if (k > most || (k === most && r.at > best!.at)) { best = r; most = k; }
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
if (best) n.set(best.id, (n.get(best.id) ?? 0) + 1);
|
|
1769
|
+
}
|
|
1770
|
+
return (id) => n.get(id) ?? 0;
|
|
1771
|
+
}
|
|
1599
1772
|
|
|
1600
1773
|
// The project's status narrowed to one run: a changed file is the run's when its
|
|
1601
1774
|
// report names the path in backticks and no run that reported later names it too,
|
|
@@ -1610,14 +1783,15 @@ export function runGit(state: string, id: string): GitStatus {
|
|
|
1610
1783
|
if (!s.repo) return s;
|
|
1611
1784
|
const all = runReports(state), mine = all.find((r) => r.id === id) ?? { id, text: "", at: 0 };
|
|
1612
1785
|
const rest = all.filter((o) => o.id !== id);
|
|
1613
|
-
const files = runFiles(s.files, mine, rest);
|
|
1786
|
+
const files = runFiles(dir, s.files, mine, rest);
|
|
1614
1787
|
const shared = files.flatMap((l) => {
|
|
1615
1788
|
const p = statusPath(l);
|
|
1616
|
-
const since =
|
|
1789
|
+
const since = lastCommit(dir, p);
|
|
1617
1790
|
const who = rest.filter((r) => r.at > since && names(r.text, p)).map((r) => short(r.id));
|
|
1618
1791
|
return who.length ? [`${p}: ${who.join(", ")}`] : [];
|
|
1619
1792
|
});
|
|
1620
|
-
|
|
1793
|
+
const { unpushed, ...base } = s;
|
|
1794
|
+
return { ...base, files, ahead: aheadBy(s, all)(id), others: s.files.length - files.length, shared };
|
|
1621
1795
|
}
|
|
1622
1796
|
|
|
1623
1797
|
// What the board did for a run, kept beside the run and not in state.json, whose
|
|
@@ -1633,15 +1807,15 @@ export function readCommit(state: string, id: string): { sha: string; pushed: bo
|
|
|
1633
1807
|
// (`gitBtn` in web/board/git.ts): one project status and one read of the reports for
|
|
1634
1808
|
// the lot. A run with nothing to say either way gets no entry.
|
|
1635
1809
|
export function cardsGit(state: string, ids: string[]): Record<string, "pending" | "pushed"> {
|
|
1636
|
-
const
|
|
1810
|
+
const dir = dirname(state), s = gitStatus(dir), out: Record<string, "pending" | "pushed"> = {};
|
|
1637
1811
|
if (!s.repo) return out;
|
|
1638
|
-
const all = runReports(state);
|
|
1812
|
+
const all = runReports(state), ahead = aheadBy(s, all);
|
|
1639
1813
|
for (const id of ids) {
|
|
1640
1814
|
const mine = all.find((r) => r.id === id) ?? { id, text: "", at: 0 }, k = readCommit(state, id);
|
|
1641
|
-
const files = runFiles(s.files, mine, all.filter((o) => o.id !== id)).length;
|
|
1815
|
+
const files = runFiles(dir, s.files, mine, all.filter((o) => o.id !== id)).length;
|
|
1642
1816
|
if (files) out[id] = "pending";
|
|
1643
1817
|
else if (k?.pushed) out[id] = "pushed";
|
|
1644
|
-
else if (
|
|
1818
|
+
else if (ahead(id) > 0) out[id] = "pending";
|
|
1645
1819
|
else if (k || mine.text) out[id] = "pushed";
|
|
1646
1820
|
}
|
|
1647
1821
|
return out;
|
package/cli/web.ts
CHANGED
|
@@ -6,8 +6,8 @@ import {
|
|
|
6
6
|
home, type Item, type Card, boardCards, knownProjects, remember, locate, label, tilde, searchRoots, findProjects,
|
|
7
7
|
readInbox, writeInbox, replaceIdea, moveIdea, abandonRun, launchRun, parseItem, agentChoices, projectOf, KNOWN_AGENTS, plainModel, short, setArchived, writeNotes,
|
|
8
8
|
appendNote, saveAttachment, pidOnPort, readText, cmdExe, sysExe, powershellExe, runGit, cardsGit, readCommit, commitPush, notice, notifier, LOGO,
|
|
9
|
-
reviewFindings, pickFindings, fixPrompt, approvalPrompt, canApproveAwaiting, nativePath, closeSessions, sweepSessions, issueUrl,
|
|
10
|
-
launchMode, readSessions, ownerOf, resumePrompt, runAgent, firstSession, webHosts,
|
|
9
|
+
reviewFindings, pickFindings, backlogCards, backlogItem, startedBacklog, closeBacklog, markBacklogDone, fixPrompt, approvalPrompt, canApproveAwaiting, nativePath, closeSessions, sweepSessions, issueUrl,
|
|
10
|
+
launchMode, readSessions, ownerOf, runIndex, resumePrompt, runAgent, firstSession, webHosts,
|
|
11
11
|
} from "./state.ts";
|
|
12
12
|
import { attachPty, writePty, resizePty, ptyState, ptyKeys, adoptLoginEnv } from "./pty.ts";
|
|
13
13
|
import { readSources, addSource, removeSource, moveSource, setCategory, setWriteback, readIcebox, promote, dismiss, restore, addItem, githubRepo, markDone, markReplied, gatherSource, freshImages, gatherAll, gatherDirs, due, socialUrl, socialOf } from "./sources.ts";
|
|
@@ -190,17 +190,27 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
190
190
|
const inCard = (dir: string) => ({ card: launchMode(projectOf(dir)) === "card", onChange: changed });
|
|
191
191
|
// An idea's session, until its run exists and owns it (ownerOf).
|
|
192
192
|
const ideaPty = new Map<string, string>();
|
|
193
|
+
// Backlog items this board launched whose run has not written its state.json yet:
|
|
194
|
+
// shown as starting, like a started idea. Forgotten on restart; the card then offers Run again.
|
|
195
|
+
const backlogLaunched = new Set<string>();
|
|
193
196
|
// The card's newest in-card session, live or recently ended. Sessions are only read
|
|
194
197
|
// while this board has any, so /state costs nothing more for terminal launches.
|
|
195
|
-
|
|
198
|
+
// Owners are worked out once per /state: per card it re-read every session and every
|
|
199
|
+
// run's state.json, about a second on a board of 400 runs.
|
|
200
|
+
const ptyOf = (cards: Card[]) => {
|
|
196
201
|
const live = new Set(ptyKeys());
|
|
197
|
-
if (!live.size) return undefined;
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
return
|
|
202
|
+
if (!live.size) return () => undefined;
|
|
203
|
+
const runs = new Map<string, string>();
|
|
204
|
+
for (const d of new Set(cards.filter((c) => c.kind === "run").map((c) => c.state))) {
|
|
205
|
+
const ids = runIndex(d);
|
|
206
|
+
for (const s of readSessions(d)) if (live.has(s.key)) runs.set(`${d}\0${ownerOf(d, s, ids)}`, s.key); // oldest first: the newest wins
|
|
207
|
+
}
|
|
208
|
+
return (c: Card) => {
|
|
209
|
+
const key = c.kind === "run" ? runs.get(`${c.state}\0${c.id}`) : ideaPty.get(`${c.state}\0${c.text}`);
|
|
210
|
+
if (!key || !live.has(key)) return undefined;
|
|
211
|
+
const st = ptyState(key)!;
|
|
212
|
+
return { key, alive: st.alive, asking: st.asking };
|
|
213
|
+
};
|
|
204
214
|
};
|
|
205
215
|
// Dev: the page reloads itself when board.html or its bundle is saved. Server code needs
|
|
206
216
|
// `bun --watch cli/install.ts web`; the page reconnects and reloads on hello.
|
|
@@ -271,7 +281,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
271
281
|
// Done requests are reported back to their sources here, on the board's own clock: a
|
|
272
282
|
// page load only reads, so opening the Icebox never closes an issue.
|
|
273
283
|
const loop = setInterval(async () => {
|
|
274
|
-
for (const dir of states(true)) { try { markDone(dir); await writeBacks(dir); } catch { /* the next tick tries again */ } }
|
|
284
|
+
for (const dir of states(true)) { try { markDone(dir); markBacklogDone(dir); await writeBacks(dir); } catch { /* the next tick tries again */ } }
|
|
275
285
|
}, 60_000);
|
|
276
286
|
|
|
277
287
|
const BOOT = Date.now().toString(36);
|
|
@@ -443,7 +453,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
443
453
|
return !root || d === state || (fold(p).startsWith(inside)
|
|
444
454
|
&& (!isGroup(p) || filled(d) || fold(dirname(p)) !== fold(root)));
|
|
445
455
|
});
|
|
446
|
-
const cards = boardCards(dirs, Date.now());
|
|
456
|
+
const cards = boardCards(dirs, Date.now()), pty = ptyOf(cards);
|
|
447
457
|
// The git mark, for the cards whose panel offers commit and push: one status per project.
|
|
448
458
|
const marks = new Map<string, Record<string, string>>();
|
|
449
459
|
const markable = (c: Card) => c.kind === "run" && !c.archived && (c.status === "done" || c.awaiting === "approvals.review") && c.state !== join(home, ".kaizen");
|
|
@@ -456,7 +466,8 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
456
466
|
project, all, dir: state,
|
|
457
467
|
projects: picker.map((d) => ({ dir: d, label: label(d), logo: !!projectLogo(d) })),
|
|
458
468
|
fresh: unset.map((d) => ({ dir: join(d, ".kaizen"), label: tilde(d) })),
|
|
459
|
-
cards: cards.map((c) => ({ ...c, notice: notice(c), git: markable(c) ? marks.get(c.state)?.[c.id!] : undefined, open: openFindings(c), pty:
|
|
469
|
+
cards: cards.map((c) => ({ ...c, notice: notice(c), git: markable(c) ? marks.get(c.state)?.[c.id!] : undefined, open: openFindings(c), pty: pty(c) })),
|
|
470
|
+
backlog: backlogCards(dirs).map((b) => ({ ...b, starting: backlogLaunched.has(`${b.state}\0${b.source}#${b.n}`) || undefined })),
|
|
460
471
|
now: Date.now(),
|
|
461
472
|
});
|
|
462
473
|
}
|
|
@@ -667,7 +678,8 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
667
678
|
if (!/^(runs\/[\w.-]+\/)?attachments\/[\w.-]+$/.test(p)) return bad("bad path");
|
|
668
679
|
const f = join(dir, p);
|
|
669
680
|
if (!existsSync(f)) return bad("no such file", 404);
|
|
670
|
-
|
|
681
|
+
// no-cache: a revise retakes a screenshot under the same name, and the browser must ask again.
|
|
682
|
+
return new Response(Bun.file(f), { headers: { "cache-control": "private, no-cache" } });
|
|
671
683
|
}
|
|
672
684
|
|
|
673
685
|
// An icebox image, sent on to a link read fresh from its source: the one kept
|
|
@@ -781,6 +793,18 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
781
793
|
return json({ ok: true, project: label(dir) });
|
|
782
794
|
}
|
|
783
795
|
|
|
796
|
+
// "Not doing this" on a backlog card: the line is closed in place with the reason,
|
|
797
|
+
// never removed, so a notebook link to it keeps its number.
|
|
798
|
+
if (path === "/backlog") {
|
|
799
|
+
if (body.op !== "reject") return bad("unknown op");
|
|
800
|
+
const why = String(body.reason ?? "").trim();
|
|
801
|
+
if (!why) return bad("a rejection needs a reason");
|
|
802
|
+
const err = closeBacklog(dir, String(body.source ?? ""), Number(body.n), "rejected", why);
|
|
803
|
+
if (err) return bad(err, err === "that item is already closed" ? 409 : 404);
|
|
804
|
+
changed();
|
|
805
|
+
return json({ ok: true });
|
|
806
|
+
}
|
|
807
|
+
|
|
784
808
|
if (path === "/inbox") {
|
|
785
809
|
const text = String(body.text ?? "").trim();
|
|
786
810
|
// Absent means untouched; an empty string clears. replaceIdea keeps the
|
|
@@ -922,11 +946,30 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
922
946
|
if (path === "/run") {
|
|
923
947
|
const text = String(body.text ?? "").trim();
|
|
924
948
|
const kind = body.kind === "full" || body.kind === "lite" ? body.kind : "";
|
|
925
|
-
if (!text) return bad("empty idea");
|
|
949
|
+
if (!text && !body.backlog) return bad("empty idea");
|
|
926
950
|
const agent = typeof body.agent === "string" && body.agent ? body.agent : undefined;
|
|
927
951
|
if (agent && !KNOWN_AGENTS.some((a) => a.cmd === agent)) return bad("unknown agent");
|
|
928
952
|
const model = typeof body.model === "string" ? body.model.trim() : "";
|
|
929
953
|
if (model && !plainModel(model)) return bad("A model name is letters, digits and . : / @ _ - only");
|
|
954
|
+
// A backlog item runs as an idea would, its text read here from the file, never
|
|
955
|
+
// from the page. The request names its source, which is how the board finds the
|
|
956
|
+
// line again: to hide the card while the run lives, and to close it when done.
|
|
957
|
+
if (body.backlog) {
|
|
958
|
+
const source = String(body.backlog.source ?? ""), n = Number(body.backlog.n);
|
|
959
|
+
const line = backlogItem(dir, source, n);
|
|
960
|
+
if (!line) return bad("no such backlog item", 404);
|
|
961
|
+
if (line.status !== "open") return bad("that item is already closed", 409);
|
|
962
|
+
if ((startedBacklog(dir).get(`${source}#${n}`) ?? []).some((c) => c.stage !== "abandoned")) return bad("a run was already started from that item", 409);
|
|
963
|
+
const p = parseItem(line.text);
|
|
964
|
+
const it: Item = { ...p, text: kind ? `${kind} ${p.text}` : p.text };
|
|
965
|
+
it.notes = [source !== "orphan" ? `Parent: ${source}` : "", `Backlog: ${source}#${n}`, p.text !== line.text ? `Backlog item: ${line.text}` : ""].filter(Boolean).join("\n");
|
|
966
|
+
const r = launchRun(it, dir, undefined, { agent, model: model || undefined, yolo: body.yolo === true, ...inCard(dir) });
|
|
967
|
+
if (r.ok) backlogLaunched.add(`${dir}\0${source}#${n}`);
|
|
968
|
+
changed();
|
|
969
|
+
return json(r.ok
|
|
970
|
+
? { ok: true, agent: r.agent, project: tilde(r.projectDir), pty: r.pty }
|
|
971
|
+
: { ok: false, why: r.why, manual: r.manual, agent: r.agent }, r.ok ? 200 : 500);
|
|
972
|
+
}
|
|
930
973
|
const it: Item = parseItem(`${kind} ${text}`.trim());
|
|
931
974
|
// A `started` idea may be run again: the terminal the first launch opened
|
|
932
975
|
// can come up empty, and nothing else tells the board a run never began.
|
package/package.json
CHANGED
package/web/board/approvals.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { store } from "./store.ts";
|
|
|
2
2
|
import { where } from "./agent-term.ts";
|
|
3
3
|
import { post } from "./api.ts";
|
|
4
4
|
import { key, via } from "./cards.ts";
|
|
5
|
-
import { $,
|
|
5
|
+
import { $, html } from "./dom.ts";
|
|
6
6
|
import { openRun } from "./panel.ts";
|
|
7
7
|
import { askNotify, toast } from "./toast.ts";
|
|
8
8
|
|
|
@@ -57,7 +57,7 @@ export async function decide(c, b, why, answers) {
|
|
|
57
57
|
export function confirmRevise(c) {
|
|
58
58
|
const foot = $("pfoot"),
|
|
59
59
|
plan = c.awaiting === "approvals.plan";
|
|
60
|
-
foot.innerHTML = `<div class="inl" style="flex:1"><div class="q">${plan ? "What should the plan do differently?" : "What should change in the work?"}</div><div class="q dim">Opens your agent on this run with your comment.${plan ? " It revises the plan and comes back here for approval." : " It changes the work, rechecks it and comes back here."}</div><textarea aria-label="Revise comment"></textarea><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Send back</button></div></div>`;
|
|
60
|
+
foot.innerHTML = html`<div class="inl" style="flex:1"><div class="q">${plan ? "What should the plan do differently?" : "What should change in the work?"}</div><div class="q dim">Opens your agent on this run with your comment.${plan ? " It revises the plan and comes back here for approval." : " It changes the work, rechecks it and comes back here."}</div><textarea aria-label="Revise comment"></textarea><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Send back</button></div></div>`;
|
|
61
61
|
const inp = foot.querySelector("textarea");
|
|
62
62
|
inp.focus();
|
|
63
63
|
foot.querySelector("[data-a=c]").onclick = () => {
|
|
@@ -78,7 +78,7 @@ export function confirmRevise(c) {
|
|
|
78
78
|
// a note for what the options miss. Each answer reaches the agent as one line.
|
|
79
79
|
export function confirmAnswers(c, qs) {
|
|
80
80
|
const foot = $("pfoot");
|
|
81
|
-
foot.innerHTML = `<div class="inl qa" style="flex:1"><div class="q">The plan asks ${qs.length === 1 ? "one question" : qs.length + " questions"}.</div><div class="q dim">Your answers go to your agent with the approval. It records them and builds the plan.</div>${qs.map((x, i) => `<fieldset><legend>${
|
|
81
|
+
foot.innerHTML = html`<div class="inl qa" style="flex:1"><div class="q">The plan asks ${qs.length === 1 ? "one question" : qs.length + " questions"}.</div><div class="q dim">Your answers go to your agent with the approval. It records them and builds the plan.</div>${qs.map((x, i) => html`<fieldset><legend>${x.q}</legend>${x.options.map((o, j) => html`<label><input type="${x.multi ? "checkbox" : "radio"}" name="q${i}" value="${j}"${o.rec || (!x.multi && !j && !x.options.some((p) => p.rec)) ? " checked" : ""}> ${o.text}</label>`)}<input type="text" data-n="${i}" placeholder="Note (optional)" aria-label="Note: ${x.q}"></fieldset>`)}<div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Approve with these answers</button></div></div>`;
|
|
82
82
|
foot.querySelector("[data-a=c]").onclick = () => {
|
|
83
83
|
foot.innerHTML = "";
|
|
84
84
|
openRun(c, true);
|