pi-plans 0.8.0 → 0.8.2

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.
@@ -337,9 +337,13 @@ describe("task-tree execution core", () => {
337
337
  persistTaskProgress(ctx);
338
338
  }
339
339
  // Simulate an exhausted budget persisted from earlier attempts.
340
+ // v0.9.3: pin the (now non-default) 5-round budget so this test keeps
341
+ // exercising the same numeric-exhaustion path.
342
+ getExecution()!.reviewBudget = 5;
343
+ getExecution()!.reviewRoundsTotal = REVIEW_MAX_ROUNDS;
340
344
  getExecution()!.audit.rounds = REVIEW_MAX_ROUNDS;
341
345
  getExecution()!.audit.failed = ["VC-002"];
342
- mutateCheckpoint(workdir, runId!, (cp) => applyExecutionProgress(cp, { audit: { rounds: REVIEW_MAX_ROUNDS, lastResult: "VC-002" } }));
346
+ mutateCheckpoint(workdir, runId!, (cp) => applyExecutionProgress(cp, { reviewBudget: 5, reviewRoundsTotal: REVIEW_MAX_ROUNDS, audit: { rounds: REVIEW_MAX_ROUNDS, lastResult: "VC-002" } }));
343
347
  const snapshot = getExecution();
344
348
  const ctxUi = { ...makeCtx(workdir), mode: "tui" as const };
345
349
  await restoreFromSession(ctxUi, [{ type: "custom", customType: "pi-plans-exec", data: snapshot }]);
@@ -363,6 +367,8 @@ describe("task-tree execution core", () => {
363
367
  applyTaskUpdate(getExecution()!.tasks, id, "complete", `${id} evidence`);
364
368
  persistTaskProgress(ctx3);
365
369
  }
370
+ getExecution()!.reviewBudget = 5;
371
+ getExecution()!.reviewRoundsTotal = REVIEW_MAX_ROUNDS;
366
372
  getExecution()!.audit.rounds = REVIEW_MAX_ROUNDS;
367
373
  getExecution()!.audit.failed = ["VC-002"];
368
374
  const headlessCtx = { ...makeCtx(wd2), hasUI: false } as never;
@@ -415,9 +421,20 @@ describe("task-tree execution core", () => {
415
421
  persistTaskProgress(ctx);
416
422
  }
417
423
  // Exhaust the budget, then pause at the cap exactly like the loop does.
424
+ // v0.9.3: an explicit 5-round budget keeps the old numeric-cap shape.
425
+ getExecution()!.reviewBudget = 5;
426
+ getExecution()!.reviewRoundsTotal = REVIEW_MAX_ROUNDS;
418
427
  getExecution()!.audit.rounds = REVIEW_MAX_ROUNDS;
419
428
  getExecution()!.audit.failed = ["VC-001", "VC-002"];
420
- const ctxTui = { ...makeCtx(workdir), mode: "tui" as const };
429
+ // The grant re-opens the picker (v0.9.3): this TUI host answers through
430
+ // its menu with the same 5-round budget; a bare panel resolution would
431
+ // mean "closed without a choice" and keep the run paused.
432
+ const baseCtx = makeCtx(workdir);
433
+ const ctxTui = {
434
+ ...baseCtx,
435
+ mode: "tui" as const,
436
+ ui: { ...(baseCtx as { ui: Record<string, unknown> }).ui, select: async (_title: string, options: string[]) => options.find((o) => o.startsWith("5 round")) },
437
+ };
421
438
  const snapshot = getExecution();
422
439
  await restoreFromSession(ctxTui, [{ type: "custom", customType: "pi-plans-exec", data: snapshot }]);
423
440
  await __awaitReviewRoundForTests();
@@ -433,14 +450,16 @@ describe("task-tree execution core", () => {
433
450
  assert.equal(getExecution()!.audit.rounds, beforeRounds, "budget survives input");
434
451
  // The explicit surface grants the fresh budget and completes the run.
435
452
  const { resumeActiveExecution } = await import("../src/exec.ts");
436
- const resumed = resumeActiveExecution(ctxTui);
437
- assert.equal(resumed, true);
438
- assert.equal(getExecution()!.audit.rounds, 0, "resume grants a fresh review budget");
453
+ const resumed = await resumeActiveExecution(ctxTui);
454
+ assert.equal(resumed.resumed, true);
455
+ assert.equal(resumed.grantedBudget, 5, "the grant reports the re-picked budget");
439
456
  await __awaitReviewRoundForTests(); // detached grant chain completes the run
440
457
  assert.equal(runnerCalls, 1, "review re-ran after the resume (fresh budget)");
441
458
  const final = loadCheckpoint(workdir, runId!);
442
459
  assert.ok(final.status === "ok");
443
460
  assert.equal(final.checkpoint.phase, "completed");
461
+ // The granted round committed as budget round 1 — proof the counter reset.
462
+ assert.equal(final.checkpoint.execution?.audit?.rounds ?? 1, 1, "the granted budget restarted at round 1");
444
463
  __setAuditRunnerForTests(null);
445
464
  });
446
465
 
@@ -455,6 +474,9 @@ describe("task-tree execution core", () => {
455
474
  applyTaskUpdate(getExecution()!.tasks, id, "complete", `${id} evidence`);
456
475
  persistTaskProgress(ctx);
457
476
  }
477
+ // v0.9.3: pin the 5-round budget (the no-UI default is 3) so the retry
478
+ // chain still spends exactly REVIEW_MAX_ROUNDS rounds.
479
+ getExecution()!.reviewBudget = 5;
458
480
  const snapshot = getExecution();
459
481
  await restoreFromSession(ctx, [{ type: "custom", customType: "pi-plans-exec", data: snapshot }]);
460
482
  const ex = getExecution()!;
@@ -464,6 +486,7 @@ describe("task-tree execution core", () => {
464
486
  assert.equal(ex.tasks.find((t) => t.id === id)?.status, "complete", `${id} not rolled back`);
465
487
  }
466
488
  assert.equal(ex.audit.rounds, REVIEW_MAX_ROUNDS, "the retry chain spent the whole budget");
489
+ assert.equal(ex.reviewRoundsTotal, REVIEW_MAX_ROUNDS, "the run-cumulative counter tracks every committed round");
467
490
  assert.equal(ex.stall.paused, true, "the loop pauses at the cap");
468
491
  assert.match(ex.stall.pausedReason ?? "", /execution review exhausted 5 rounds/);
469
492
  assert.equal(
@@ -549,6 +572,8 @@ describe("task-tree execution core", () => {
549
572
  applyTaskUpdate(getExecution()!.tasks, id, "complete", `${id} evidence`);
550
573
  persistTaskProgress(ctx);
551
574
  }
575
+ // v0.9.3: pin the 5-round budget so the retry chain keeps its old length.
576
+ getExecution()!.reviewBudget = 5;
552
577
  const snapshot = getExecution();
553
578
  await restoreFromSession(ctx, [{ type: "custom", customType: "pi-plans-exec", data: snapshot }]);
554
579
  const ex = getExecution()!;
@@ -617,6 +642,8 @@ describe("task-tree execution core", () => {
617
642
  applyTaskUpdate(getExecution()!.tasks, id, "complete", `${id} evidence`);
618
643
  persistTaskProgress(ctx);
619
644
  }
645
+ getExecution()!.reviewBudget = 5;
646
+ getExecution()!.reviewRoundsTotal = REVIEW_MAX_ROUNDS;
620
647
  getExecution()!.audit.rounds = REVIEW_MAX_ROUNDS;
621
648
  getExecution()!.audit.failed = ["VC-002"];
622
649
  const ctxUi = { ...makeCtx(workdir), mode: "tui" as const };
@@ -0,0 +1,57 @@
1
+ # PLAN_v2 - lattice-code v0.4 host/daemon migration (trimmed fixture)
2
+
3
+ This file is a TRIM of the real plan at
4
+ `lattice-code/.git/pi-plans/plans/2026-10-02-v0-4-ts-host-python-daemon/PLAN_v2.md`:
5
+ only the tasks covered by execution-review round 1's failed checks, and those
6
+ check lines, are kept verbatim. The run's checkpoint slices live in `state.json`.
7
+
8
+ ## Tasks
9
+
10
+ - `Task-2`: S37 host 脚手架、隔离层与双栈门 — deps: Task-1; files: package.json, bun.lock, packages/host/package.json, packages/host/tsconfig.json, packages/host/src/cli.ts, packages/host/src/env.ts, packages/host/src/providers.ts, packages/host/src/gate.ts, packages/host/test/env.test.ts, Makefile, catalog/catalog_v1.json, tools/catalog/build.py, src/lattice/llm/catalog/resolver.py, tests/isolation/; wave: 2
11
+ - `Task-2.1`: 根 Bun workspace + `packages/host` 脚手架;三库锁版本安装;tsc `--noEmit` strict;`Makefile` 新增 `lint-ts`/`test-ts`/`gate`(pytest + `bun test` + `bun install --frozen-lockfile`)——自本步起每个提交双栈门禁
12
+ - `Task-2.2`: 隔离层:`$LATTICE_HOME` 布局(`pi-compat/`、`sessions/`、`daemon-venv/`、`bin/`、`logs/`);启动即设六个 `PI_*` 环境变量(定位为纵深防御);自管 `auth.json`(0600+原子写)显式 `apiKey` 注入 pi-ai;spawn 子进程环境清除一切 `*_API_KEY` 形态变量(杜绝 pi-ai 的 env 回退);v0.4 不注册 OAuth 型 provider;pi-tui 构造显式传 `logDirectory: $LATTICE_HOME/logs`(该参数属 app 装配,本步先立约定与测试钩子)
13
+ - `Task-2.3`: catalog 中立迁移为基座:`catalog_v1.json`(context_window + effort 矩阵 + disabled_levels/disabled_reason)迁至仓库根 `catalog/`;`make catalog` 与 `tools/catalog/build.py` 重指向;冻结期 `llm/catalog/resolver.py` 改读新路径(保持 S46 删除前可用);TS `providers.ts` overlay 合并以 `catalog/` 为基座(字段级合并/同 id 替换/自定 provider 透传),pi-ai `builtinModels()` 为未覆盖模型的次级补充
14
+ - `Task-2.4`: `tests/isolation/`:全 HOME 递归 manifest diff(path+size+mtime+sha256,排除 `$LATTICE_HOME`,复用 `util/hash.py` 的 sha256 语义)为唯一硬证明;wave-2 范围 = `bun run packages/host/src/cli.ts` 引导链路 + 六变量注入断言 + 预置非空 pi 后复跑(Task-10 后对编译二进制复跑同一套件)
15
+ - `Task-3`: S38 pi-tui 界面重建 — deps: Task-2; files: packages/host/src/ui/app.ts, packages/host/src/ui/theme.ts, packages/host/src/ui/banner.ts, packages/host/src/ui/rail.ts, packages/host/src/ui/statusbar.ts, packages/host/src/ui/input.ts, packages/host/src/ui/cards.ts, packages/host/src/ui/panels.ts, packages/host/test/ui/; wave: 3
16
+ - `Task-3.1`: `app.ts` 四区装配(Planning box / Execution tasks / 卡片流 / 输入框)+ `theme.ts` 语义 token → pi-tui theme(四色彩模式;宽度计算复用 pi-tui 导出的 `visibleWidth`,不自建第二宽度引擎)
17
+ - `Task-3.2`: banner 三档梯子阈值即数据(full ≥60 / wordmark ≥14 / single <14;58 列对齐)+ 双 `⌗` 差异化(rail 与输入框前缀 token 不同、单色下字形不同)
18
+ - `Task-3.3`: rail 六态(色+单色字形)+ 扇出段(`N/M · K leases`)+ 路由段(`→ role`);statusbar 弹性三段(plan title 吸收剩余宽度、model 段贴右误差 ≤1、定宽防抖;Ctx 段数据源 `catalog/`)
19
+ - `Task-3.4`: 输入框 + 卡片三态 + >24 行折叠标注;纯函数 + `render(width)` 行宽不变量(Lattice 侧义务:8–130 列扫描断言 `visibleWidth(line) <= width`)+ 多行样式逐行重应用;bun test 快照用例
20
+ - `Task-4`: S39 daemon 与 JSONL 协议(含幂等对账) — deps: Task-2; files: schema/protocol.json, tools/gen_protocol_ts.mjs, tools/gen_protocol_py.py, packages/host/src/protocol.ts, packages/host/src/daemon.ts, packages/host/src/gen/protocol.gen.ts, packages/host/test/daemon.test.ts, src/lattice/daemon/__init__.py, src/lattice/daemon/server.py, src/lattice/daemon/dispatch.py, src/lattice/daemon/__main__.py, src/lattice/daemon/protocol_gen.py, src/lattice/graph/writer.py, src/lattice/graph/facts.py, tests/test_daemon_protocol.py, tests/test_daemon_crash.py; wave: 3
21
+ - `Task-4.1`: 冻结前穷举 daemon op 面:图读(`cmds/read.py:build_table` 全条目)、magics 只读视图、会话 create/exec/close(含握手帧携带 role/caps/write_paths)、写 op、lease/commit、snapshot/restore、health/version;`schema/protocol.json`(JSON Schema + `schema_version` + durable `request_id` 字段 + replayable 标记);双侧现成生成器(json-schema-to-typescript / datamodel-code-generator)+ 提交产物 + 再生成 diff 零一致性用例
22
+ - `Task-4.2`: Python `daemon/`:`__main__.py --stdio --schema-version N` 为唯一 spawn 契约;stdio JSONL 服务端(stdout 仅帧);分派按会话解析 per-agent `GraphHandle`(`assembly.py` ensure_agents),一切 op 走 handle 公共方法、绝不直投 `writer.submit`;test/gate op 强制走两阶段提交缝(`commit_prepare` 离环执行 + `commit_finalize`),不阻塞读循环与心跳;durable request_id 贯穿 `PendingItem` 与 facts(`_persist_intent`/`_persist_done` 均落 id),启动时对账开放 id、重复 id 返回已记录结果;租约重建语义写入协议文档;`_apply_one()` 保持无 `await`
23
+ - `Task-4.3`: `daemon.ts` 客户端:spawn、请求 id 生成与幂等、超时与未确认队列、SIGKILL 后重启 + 按 request_id 对账重放、背压上限;冷启动计时埋点(含 snapshot 恢复);RTT 预算限定读类帧(机器/采样法入 evidence)
24
+ - `Task-5`: S40 agent loop 与 plan 链上移 — deps: Task-3, Task-4; files: packages/host/src/loop.ts, packages/host/src/chain.ts, packages/host/src/roles.ts, packages/host/src/transport/record_replay.ts, packages/host/src/ui/cards.ts, packages/host/test/loop.test.ts, packages/host/test/chain.test.ts, packages/host/test/fixtures/cassettes/; wave: 4
25
+ - `Task-5.1`: `loop.ts`:基于 pi-agent-core 的工具调用循环 + daemon 工具桥(图读经协议;权限由 Python 侧 handle 强制)
26
+ - `Task-5.2`: `chain.ts`:plan 链 revision 生命周期(envelope.json 由 TS 直写 `.lattice/plans/<id>/`;reviewer 冷启动不含自己上一轮原文、跨 revision `must_survive` 仍被引用);`roles.ts` 承接 ROLE_NAMES
27
+ - `Task-5.3`: 真实 provider 录制装置:录制缝 = pi-ai transport shim;以现有 `tests/cassettes/chain.json` 形状为 cassette schema 种子;fixtures 默认合成、真实转录提交前脱敏扫描(无凭据/无绝对家路径/无 org id);revision 闭环用例跑在录制 cassette 上
28
+ - `Task-5.4`: cards 接线真实 chain 数据(wave 状态接线归 Task-6)
29
+ - `Task-6`: S41 扇出调度与预算 — deps: Task-5; files: packages/host/src/schedule.ts, packages/host/src/budget.ts, packages/host/src/ui/panels.ts, packages/host/test/schedule.test.ts; wave: 5
30
+ - `Task-6.1`: 波次划分:同 wave 文件集两两不相交;N executor 生命周期(spawn/回收/失败隔离);panels 接线 wave/budget 真状态
31
+ - `Task-6.2`: 三类硬预算(token / 时长 / 写次数)越限即停;`budget.ts` 承接预算超限错误类型
32
+ - `Task-6.3`: 越界写拒绝:租约与乐观 hash 校验留 Python 侧经 daemon 强制(改未租约文件被拒并回错误帧);TS 只发请求不做本地放行
33
+ - `Task-7`: S42 picker 与键位 — deps: Task-3; files: packages/host/src/ui/picker.ts, packages/host/src/keys.ts, packages/host/test/picker.test.ts; wave: 4
34
+ - `Task-7.1`: `/login` 两级屏(认证方式 → (provider, method) 行 + 凭据徽章)对接自管 auth;完成登录仅在未设或已是该 provider 时落 `default` 角色;仅列出免 OAuth 的认证方式
35
+ - `Task-7.2`: 模型选择器两级(provider 页 → model 页,type-to-filter;`←→` 调 effort、`Tab` 循环 role、`Ctrl+P` 唤起;effort 草稿 Enter 提交 Esc 丢弃);数据源 `catalog/`(context_window + effort 矩阵)
36
+ - `Task-7.3`: 禁用态带原因(`disabled_levels`/`disabled_reason`);键位冲突表为零
37
+ - `Task-9`: S44 会话持久化与 resume — deps: Task-8; files: packages/host/src/session.ts, packages/host/src/ui/resume.ts, packages/host/src/cli.ts, packages/host/test/session.test.ts; wave: 6
38
+ - `Task-9.1`: 会话 JSONL 落 `$LATTICE_HOME/sessions/`,定位为**纯重放 transcript(非权威)**:envelope.json + 图 snapshot 是唯一权威;恢复时对话从 transcript 重放、plan/图状态从权威源重建
39
+ - `Task-9.2`: `lattice resume` 列表选择恢复(cli.ts 注册);断言单改 transcript 不改变恢复结果;重启后图等价
40
+ - `Task-10`: S45 打包与分发 — deps: Task-6, Task-8, Task-9; files: scripts/build-binaries.sh, scripts/install.sh, scripts/bootstrap-python.sh, Makefile, package.json, pyproject.toml, README.md, tests/isolation/; wave: 7
41
+ - `Task-10.1`: `bun build --compile` 五平台(darwin-arm64/x64、linux-arm64/x64、windows-x64;x64 走 baseline target;pi-tui 原生加速器为可选件缺失即 JS 回退,不内嵌 `.node`)
42
+ - `Task-10.2`: sidecar wheel:`pyproject.toml` bump 0.4.0,`uv build` 产 wheel 随二进制入 dist;bootstrap:首启检测 `python3.11+` → 引导 uv 至 `$LATTICE_HOME` → `uv venv $LATTICE_HOME/daemon-venv` + 离线安装 sidecar wheel;`kernel_version` 握手协商,不匹配给类型化明确报错
43
+ - `Task-10.3`: 安装脚本:二进制+sidecar 落 `$LATTICE_HOME/bin`,打印 PATH 建议不自动改;干净机器(无 Node/Bun/pi)验证;isolation 套件对编译二进制复跑;README 安装文档改写与形态一致
44
+
45
+ ## Verification Checks
46
+
47
+ - [ ] `VC-095` covers `Task-4`; pass condition: daemon 被 SIGKILL 后 host 重启并按 request_id 对账重放——无重复应用(受影响文件 sha256 不变 + fact 计数不变),重复 id 返回已记录结果,租约重建语义有文档与用例;evidence: `tests/test_daemon_crash.py`(含 fsync 后 SIGKILL 场景);metric: ≥2 用例通过。
48
+ - [ ] `VC-102` covers `Task-3`; pass condition: rail 与输入框前缀颜色 token 不同、单色下字形不同;evidence: 快照 + 断言;metric: 彩色/单色各 ≥1。
49
+ - [ ] `VC-104` covers `Task-3`; pass condition: status bar 弹性三段在 8–130 列 model 段贴右误差 ≤1、定宽防抖、Ctx 段数据源 `catalog/`;evidence: 宽度扫描用例;metric: 0 溢出 + 误差 ≤1 列。
50
+ - [ ] `VC-106` covers `Task-5`; pass condition: 真实 provider 录制 cassette 下完整 revision 闭环;reviewer 冷启动不含自己上一轮原文;跨 revision `must_survive` 仍被引用;evidence: `bun test` 链路用例 + cassette fixture;metric: 三项各 ≥1。
51
+ - [ ] `VC-107` covers `Task-6`; pass condition: 同 wave 文件集运行时两两不相交(executor 预检拒绝交集);N=3 真并发(`wall < 0.6 × Σ`);三类预算超限即停;改未租约文件被拒(经 daemon 校验回错误帧);evidence: `bun test` + daemon 拒绝帧断言;metric: 四项各 ≥1。
52
+ - [ ] `VC-111` covers `Task-9`; pass condition: transcript 为纯重放(单改 transcript 不改变恢复结果);resume 后对话与图状态等价;evidence: resume 往返用例 + 图等价断言;metric: 两项各 ≥1 用例。
53
+ - [ ] `VC-118` covers `Task-4`; pass condition: `writer._dispatch` 每分支与 `cmds/read.py:build_table` 每条目均映射到 schema 帧(op 面无缺口);evidence: 映射完备性检查用例(工作树可判);metric: 0 缺口。
54
+ - [ ] `VC-119` covers `Task-5`; pass condition: 提交的 cassette 无凭据、无绝对家路径、无 org id;evidence: 脱敏扫描用例;metric: 0 命中。
55
+ - [ ] `VC-121` covers `Task-10`; pass condition: bootstrap 离线安装 sidecar wheel 后 `kernel_version` 握手匹配且 daemon 可用;evidence: 干净 venv 安装 + 握手用例;metric: ≥1 用例。
56
+ - [ ] `VC-122` covers `Task-2` and `Task-7`; pass condition: picker 列出的每个模型都有 `context_window` 与 effort 矩阵,且 ≥1 可见禁用级(含原因);evidence: `bun test` 数据完备性用例(对 `catalog/` 全量断言);metric: 0 缺失。
57
+ - [ ] `VC-123` covers `Task-10`; pass condition: README 安装文档与新分发形态(二进制 + sidecar + uv 自举)一致;evidence: 文档审读 + 安装步骤复跑;metric: 0 处失配。
@@ -0,0 +1,158 @@
1
+ {
2
+ "_source": {
3
+ "run": "lattice-code 20261002T080333Z-v0-4-ts-host-python-daemon",
4
+ "checkpoint_slices": [
5
+ "execution.tasks (only the covered tasks)",
6
+ "execution.audit.rounds",
7
+ "execution.audit.lastResult",
8
+ "execution.audit.undeterminable"
9
+ ],
10
+ "real_state": "round 1 rolled back the failed checks' coverage; the repair round re-closed everything except the parent Task-2, which is still pending here",
11
+ "note": "no `execution.blocked` key: the checkpoint predates the feature, which is exactly what the backfill must handle"
12
+ },
13
+ "tasks": {
14
+ "Task-2": {
15
+ "status": "pending",
16
+ "evidence": "提交 ce36d13(22 文件,+1101/−34)。make gate 全绿(frozen install + pytest 902 + bun test 11);ruff 干净;门禁三查(更新版白名单)clean。VC-089/090/091/112 的种子用例就位(wave-2 形态:bun run 宿主)。"
17
+ },
18
+ "Task-2.1": {
19
+ "status": "complete",
20
+ "evidence": "修复轮补强(78ba6c1):provider.ts overlay 层不变;隔离层经 F-001/F-014 相关修复复验(tests/isolation 对 bun-run 与新编译二进制各 8 用例全绿——含重跑的三条 manifest/env/共存证明)。VC-089~093 复验通过。"
21
+ },
22
+ "Task-2.2": {
23
+ "status": "complete",
24
+ "evidence": "隔离层原样复验:六变量 + sanitizedChildEnv + auth 0600 + XDG_CACHE_HOME 重定向;daemonPython 增 dev-venv 探测;bootstrap.ts 子进程沿用 sanitizedChildEnv。env.test 全绿。"
25
+ },
26
+ "Task-2.3": {
27
+ "status": "complete",
28
+ "evidence": "catalog/ 中立基座不变;statusContextFromCatalog(app.ts)新增:Ctx 段数据源接 catalog context_window(F-015 修复),VC-104 catalog-fed 用例(dual_marks.test)+ picker 全量 catalog 完备性用例(VC-122,见 Task-7 重开证据)。"
29
+ },
30
+ "Task-2.4": {
31
+ "status": "complete",
32
+ "evidence": "隔离套件对重建后的编译二进制复跑全绿(doctor 链路 + 预置 pi 共存 + 六变量);manifest diff 证明不变。"
33
+ },
34
+ "Task-3": {
35
+ "status": "complete",
36
+ "evidence": "提交 5e18b99 + 修复轮 78ba6c1(挂载 + 数据源 + 差异化)。bun test 115/115、gate 全绿。"
37
+ },
38
+ "Task-3.1": {
39
+ "status": "complete",
40
+ "evidence": "四区装配现有生产挂载(F-002 修复):mountTui/runTui 进 cli.ts 默认路径(TTY 时真跑 pi-tui 主屏 + Editor 焦点 + Ctrl+C 退出);statusRegion 由 statusContextFromCatalog 供数(F-015)。app.test 4 用例 + dual_marks 全绿。"
41
+ },
42
+ "Task-3.2": {
43
+ "status": "complete",
44
+ "evidence": "banner 梯子不变;双 ⌗ 差异化修复(F-012):input 前缀改 accent_dim 非加粗(与内核侧 prompt_glyph 奇偶校验一致),六相位逐一断言 rail token ≠ accent_dim、三档样式互异、ascii 档 [X] 码 ≠ [#](dual_marks.test 6 用例)。"
45
+ },
46
+ "Task-3.3": {
47
+ "status": "complete",
48
+ "evidence": "rail 六态不变;statusbar 补 VC-104 断言:catalog-fed Ctx 真值渲染(Ctx 12.5%/200k)、模型组贴行尾 40–130 全扫、占位槽零抖动。"
49
+ },
50
+ "Task-3.4": {
51
+ "status": "complete",
52
+ "evidence": "卡片/输入线不变;宽度扫描 8–130 与样式逐行断言维持全绿。"
53
+ },
54
+ "Task-4": {
55
+ "status": "complete",
56
+ "evidence": "提交 2dbb046 + 修复轮 78ba6c1。pytest 218(含新 lease-less CONFLICT 帧、bootstrap 握手 2 例)、bun 115、gate 全绿。"
57
+ },
58
+ "Task-4.1": {
59
+ "status": "complete",
60
+ "evidence": "schema 增 read.peek/commit.abort/graph.reparse(F-007),双侧再生成(sha 25e658d1…)+ --check 双绿。"
61
+ },
62
+ "Task-4.2": {
63
+ "status": "complete",
64
+ "evidence": "daemon 修复:graph.test 改经 asyncio.to_thread 离环跑门(F-003——读循环/心跳不再被 120s 测试阻塞);commit.finalize 校验 staged ticket、commit.abort 走 handle.abort_commit(F-006);handle 增 abort_commit/reparse;EOF 排空竞态修复(in-flight 响应先写后关);read.peek/graph.reparse 接线。VC-116/117 维持,VC-118 升级为 _dispatch 分支 + build_table 条目动态枚举映射(正则抽源 + 临时仓建 store)。"
65
+ },
66
+ "Task-4.3": {
67
+ "status": "complete",
68
+ "evidence": "F-001 修复:request id 每逻辑请求恰铸一次,重放复用同一 rid(send 显式收 rid;requestIds 日志);专测:在飞 session.exec 被 kill → 重启重放 → requestIds 切片 ≥2 次发送且去重后恰 1 个 id。VC-095 主机半边成立(daemon 半边 tests/py 两用例原有)。"
69
+ },
70
+ "Task-5": {
71
+ "status": "complete",
72
+ "evidence": "提交 db1f0b8 + 修复轮 78ba6c1。VC-106(cassette 闭环 + 冷启动 + must_survive)与 VC-119(脱敏零命中)用例就位。"
73
+ },
74
+ "Task-5.1": {
75
+ "status": "complete",
76
+ "evidence": "F-011 修复:RoleLoop 重建于 pi-agent-core `Agent`(transcript 状态、工具执行、turn 生命周期皆库驱动;我们的 stream 适配器经 createAssistantMessageEventStream 供帧,LoopTools 以真 AgentTool 声明注入,错误经 stopReason=error 呈现为抛出)。headless 冒烟双态复绿。"
77
+ },
78
+ "Task-5.2": {
79
+ "status": "complete",
80
+ "evidence": "chain/envelope/冷启动规则不变;错误从适配器透传(errorMessage → throw)。"
81
+ },
82
+ "Task-5.3": {
83
+ "status": "complete",
84
+ "evidence": "F-009/F-010 修复:提交 cassette 产物 fixtures/cassettes/revision-loop.json(4 条目:拒绝→修订→批准);扫描器增 org-/org_/acct_ 三类 org-id 模式并计入 redact;cassette 驱动全修订闭环用例(仅凭录制驱动 ChainDriver 到 approved、rev=2、双产物在盘)+ 提交产物零泄漏断言 + 脏样本捕获断言。"
85
+ },
86
+ "Task-5.4": {
87
+ "status": "complete",
88
+ "evidence": "cards 接线不变,维持全绿。"
89
+ },
90
+ "Task-6": {
91
+ "status": "complete",
92
+ "evidence": "提交 0a7dcaf + 修复轮 78ba6c1。schedule.test 14 用例全绿。"
93
+ },
94
+ "Task-6.1": {
95
+ "status": "complete",
96
+ "evidence": "F-008 修复:runWave 增运行时预检 assertDisjointWave(WaveOverlapError 携带碰撞对,./ 归一,executor 起跑前拒绝)+ 调度产物逐波过检用例;无租约写经真 daemon 拒绝用例(tests/py:graph.write 无 lease → ok:false CONFLICT \"holds no lease\")。"
97
+ },
98
+ "Task-6.2": {
99
+ "status": "complete",
100
+ "evidence": "三轴预算与隔离语义不变,全绿维持。"
101
+ },
102
+ "Task-6.3": {
103
+ "status": "complete",
104
+ "evidence": "租约强制留 kernel 侧不变;CONFLICT 帧现由真 daemon 用例钉死(VC-107 第四项闭环)。"
105
+ },
106
+ "Task-7": {
107
+ "status": "complete",
108
+ "evidence": "提交 353edad + 修复轮 78ba6c1。picker.test 全绿。"
109
+ },
110
+ "Task-7.1": {
111
+ "status": "complete",
112
+ "evidence": "/login 两级与徽章语义不变(env 态维持刻意缺席)。"
113
+ },
114
+ "Task-7.2": {
115
+ "status": "complete",
116
+ "evidence": "选择器 FSM 不变;F-004 修复:catalog effort 的 null 值视为显式禁用(\"maps no native parameter\" 原因),缺失级维持 \"exposes no native parameter\";VC-122 全量断言:catalog_v1.json 每模型 context_window≥1 且 effort≥1 级,禁用级模型 ≥1(picker.test 新增全量用例)。"
117
+ },
118
+ "Task-7.3": {
119
+ "status": "complete",
120
+ "evidence": "禁用级带原因 + 键位零冲突维持(含 null-effort 模型渲染 disabled 列表)。"
121
+ },
122
+ "Task-9": {
123
+ "status": "complete",
124
+ "evidence": "提交 c5b9def + 修复轮 78ba6c1。VC-111 两项闭环。"
125
+ },
126
+ "Task-9.1": {
127
+ "status": "complete",
128
+ "evidence": "F-005 修复:生产路径写会话(runHeadless 建 session、user/assistant/end 事件落 $LATTICE_HOME/sessions);lattice resume 起真 DaemonClient(state.health 图恢复报告)后再回放 transcript;VC-111 强化:真 daemon 图等价往返用例(恢复前后 read.ls id 集恒等 + 篡改只显于回放)。"
129
+ },
130
+ "Task-9.2": {
131
+ "status": "complete",
132
+ "evidence": "resume 半边同上(daemon 恢复 + transcript 纯显示);session.test 5 用例全绿。"
133
+ },
134
+ "Task-10": {
135
+ "status": "complete",
136
+ "evidence": "提交 4325370 + 修复轮 78ba6c1。VC-093/121/123 证据链补齐。"
137
+ },
138
+ "Task-10.1": {
139
+ "status": "complete",
140
+ "evidence": "五平台 + sidecar 不变(修复轮后 make dist 重建,二进制嵌入修复后 TS)。"
141
+ },
142
+ "Task-10.2": {
143
+ "status": "complete",
144
+ "evidence": "F-013 修复:tests/py/test_bootstrap_handshake.py —— 临时 venv 离线优先装 dist/kernel wheel(uv 缺席走 ensurepip+pip 回退),断言 wheel==installed==pyproject 0.4.0,且安装的 daemon 应答 ping(2 用例实跑通过;无 wheel 时 skip)。"
145
+ },
146
+ "Task-10.3": {
147
+ "status": "complete",
148
+ "evidence": "F-014 修复:README 全面改写(Quickstart 旧流程/ui.json/tmux/--glob 全清;新 Install/How-it-works/Development 与分发一致);tests/py/test_readme_shape.py 三用例钉死(必含 $LATTICE_HOME/install.sh/-p,七类陈旧片段零命中,dev 命令在)。"
149
+ }
150
+ },
151
+ "audit": {
152
+ "rounds": 1,
153
+ "lastResult": "VC-095,VC-102,VC-104,VC-106,VC-107,VC-111,VC-118,VC-119,VC-121,VC-122,VC-123",
154
+ "undeterminable": [
155
+ "VC-109"
156
+ ]
157
+ }
158
+ }
@@ -331,12 +331,12 @@ describe("refine overlay wiring", () => {
331
331
  it("threads the chrome language through both overlay construction sites (issue #3, VC-007)", () => {
332
332
  const refineSource = fs.readFileSync(path.join(process.cwd(), "tools", "refine.ts"), "utf8");
333
333
  assert.match(refineSource, /const overlayLang = uiLanguageFromTag\(config\.language\.tag\)/);
334
- assert.match(refineSource, /new RefineOverlayController\("reviewer", lanes, relayAbort, lang\)/);
334
+ assert.match(refineSource, /new RefineOverlayController\(\s*"reviewer",\s*lanes,\s*relayAbort,\s*lang,/);
335
335
  assert.match(refineSource, /modelLabel,\s*\n\s*overlayLang,/);
336
336
  const refsSource = fs.readFileSync(path.join(process.cwd(), "tools", "analyze-refs.ts"), "utf8");
337
337
  assert.match(
338
338
  refsSource,
339
- /new RefineOverlayController\("refs", batch\.map\(\(job\) => \(\{ id: job\.laneId, label: job\.laneId \}\)\), relayAbort, resolveUiLanguage\(workdir\)\)/,
339
+ /new RefineOverlayController\(\s*"refs",\s*batch\.map\(\(job\) => \(\{ id: job\.laneId, label: job\.laneId \}\)\),\s*relayAbort,\s*resolveUiLanguage\(workdir\),/,
340
340
  );
341
341
  });
342
342
  });
@@ -533,4 +533,27 @@ describe("refine overlay kitty and fallback key handling (issue #2)", () => {
533
533
  __clearPiTuiForTests();
534
534
  }
535
535
  });
536
+
537
+ it("forwards unhandled keys instead of swallowing them (Ctrl+Shift+T while focused)", () => {
538
+ // pi-tui routes input ONLY to the focused component (no bubbling), so
539
+ // an open overlay used to swallow every global shortcut. Unhandled keys
540
+ // must reach the onUnhandledKey hook so callers can re-dispatch them.
541
+ const calls: string[] = [];
542
+ const component = new RefineOverlayComponent(
543
+ fakeTheme,
544
+ "auditor",
545
+ [readyLane("lane-1", "reviewer-1", "output")],
546
+ () => {},
547
+ undefined,
548
+ undefined,
549
+ "en",
550
+ (data) => calls.push(data),
551
+ );
552
+ // kitty CSI-u form of Ctrl+Shift+T — not an overlay key.
553
+ component.handleInput("\x1b[84;6u");
554
+ assert.deepEqual(calls, ["\x1b[84;6u"], "unhandled keys are forwarded");
555
+ // Handled keys (arrow down) are NOT forwarded.
556
+ component.handleInput("\x1b[B");
557
+ assert.deepEqual(calls, ["\x1b[84;6u"], "handled keys stay internal");
558
+ });
536
559
  });
@@ -8,7 +8,7 @@ import * as path from "node:path";
8
8
  import { after, before, describe, it } from "node:test";
9
9
  import { listResumeCandidates, pickDefaultCandidate } from "../src/resume.ts";
10
10
  import { migrateRunIntoCurrentWorktree, resumePlansCommand } from "../src/resume-command.ts";
11
- import { createCheckpoint, loadCheckpoint, mutateCheckpoint, applyQuestionAsked, applyQuestionAnswered } from "../src/workflow-state.ts";
11
+ import { createCheckpoint, loadCheckpoint, mutateCheckpoint, applyExecutionApproved, applyExecutionProgress, applyPlanWritten, applyQuestionAsked, applyQuestionAnswered, planIdentityOf, resolveHeadAt } from "../src/workflow-state.ts";
12
12
  import { acquireOwnership, processStartOf } from "../src/run-ownership.ts";
13
13
  import { resetRunBindingForTests } from "../src/run-context.ts";
14
14
  import { initState, setArtifactRoot, setRunStatus, startRun, StateError } from "../src/state.ts";
@@ -399,3 +399,47 @@ describe("impl-review crash-window recovery (removed in v0.6.1)", () => {
399
399
  });
400
400
  });
401
401
 
402
+ describe("execution resume brief with a blocked review (v0.9.2)", () => {
403
+ const BLOCKED_PLAN = `# PLAN_v1 - blocked brief
404
+
405
+ ## Tasks
406
+
407
+ - Task-1: engine — files: lib/engine.js; wave: 1
408
+ - Task-2: host — files: lib/host.js; wave: 1
409
+
410
+ ## Verification Checks
411
+
412
+ - [ ] \`VC-001\` covers \`Task-1\`; pass condition: engine works; evidence: tests; metric: green.
413
+ - [ ] \`VC-002\` covers \`Task-2\`; pass condition: host works; evidence: tests; metric: green.
414
+ `;
415
+
416
+ it("names the open rolled-back task and how to unblock the review", async () => {
417
+ resetRunBindingForTests();
418
+ // A matching HEAD is required: an unverifiable head re-opens every task
419
+ // (reverifyAll) and legitimately drops the blocker with the progress.
420
+ const workdir = setupRepo("blocked-brief", { commit: true });
421
+ const run = startRun(workdir, { topic: "blockedbrief", skill: "plan-normal", requestText: "b" }).run;
422
+ const planPath = path.join(run.artifact_dir, "PLAN_v1.md");
423
+ fs.mkdirSync(run.artifact_dir, { recursive: true });
424
+ fs.writeFileSync(planPath, BLOCKED_PLAN, "utf8");
425
+ createCheckpoint(workdir, { runId: run.run_id, originWorkdir: workdir, workdir });
426
+ mutateCheckpoint(workdir, run.run_id, (cp) => {
427
+ const plan = planIdentityOf(planPath, 1);
428
+ let next = applyPlanWritten({ ...cp, nextAction: "accept-execute" }, plan);
429
+ next = applyExecutionApproved(next, { plan, worktree: workdir, headAtApproval: resolveHeadAt(workdir), approvedAt: next.updatedAt });
430
+ return applyExecutionProgress(next, {
431
+ tasks: { "Task-1": { status: "complete", evidence: "e" }, "Task-2": { status: "pending" } },
432
+ blocked: { rolledBack: ["Task-2"], tasks: ["Task-2"], round: 1, escalatedRounds: 2, since: next.updatedAt },
433
+ });
434
+ });
435
+ setRunStatus(workdir, run.run_id, "executing");
436
+ const mock = makeCtx(workdir);
437
+ await resumePlansCommand(ctxAdapter(mock) as never, BASE_DIR);
438
+ assert.equal(mock.userMessages.length, 1, "one resume brief");
439
+ const brief = mock.userMessages[0]!;
440
+ assert.match(brief, /PI-PLANS RESUME/);
441
+ assert.match(brief, /Review blocked: Task-2 was reopened by execution review round 1 and is still open/);
442
+ assert.match(brief, /close it with plans_update_task/);
443
+ assert.match(brief, /no review round is running while these are open\./i);
444
+ });
445
+ });
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Execution-review budget tests (v0.9.3): the budget arithmetic, the shared
3
+ * review-pause predicate, the unlimited no-progress valve, and the picker's
4
+ * fallback matrix. The loop-level behaviour (resolution timing, tolerated
5
+ * completion, grants) lives in tests/exec-review-loop.test.ts.
6
+ */
7
+
8
+ import * as assert from "node:assert/strict";
9
+ import { describe, it } from "node:test";
10
+ import {
11
+ askReviewBudget,
12
+ budgetExhausted,
13
+ bumpNoProgress,
14
+ formatReviewBudget,
15
+ isReviewPauseReason,
16
+ noProgressSignature,
17
+ noProgressTripped,
18
+ resolveStoredBudget,
19
+ reviewBudgetPanelAvailable,
20
+ unlimitedHardCapCeiling,
21
+ DEFAULT_REVIEW_BUDGET,
22
+ LEGACY_REVIEW_MAX_ROUNDS,
23
+ NO_PROGRESS_MAX_STREAK,
24
+ REVIEW_BUDGET_CHOICES,
25
+ UNLIMITED_HARD_CAP,
26
+ } from "../src/review-budget.ts";
27
+ import { reviewBudgetChrome } from "../src/ui-language.ts";
28
+
29
+ describe("review budget arithmetic (v0.9.3)", () => {
30
+ it("resolves a stored budget, the legacy bound, and the undecided state", () => {
31
+ assert.equal(DEFAULT_REVIEW_BUDGET, 3);
32
+ assert.equal(LEGACY_REVIEW_MAX_ROUNDS, 5);
33
+ assert.equal(UNLIMITED_HARD_CAP, 50);
34
+ assert.deepEqual([...REVIEW_BUDGET_CHOICES], [1, 2, 3, 5, "unlimited"]);
35
+ // Undecided: a fresh run has not picked yet.
36
+ assert.equal(resolveStoredBudget(undefined, 0), undefined);
37
+ // Legacy: a pre-feature checkpoint that already spent rounds keeps 5.
38
+ assert.equal(resolveStoredBudget(undefined, 2), LEGACY_REVIEW_MAX_ROUNDS);
39
+ // A stored value wins.
40
+ assert.equal(resolveStoredBudget("unlimited", 2), "unlimited");
41
+ assert.equal(resolveStoredBudget(1, 0), 1);
42
+ });
43
+
44
+ it("formats the budget the way status lines do", () => {
45
+ assert.equal(formatReviewBudget(1), "1");
46
+ assert.equal(formatReviewBudget(3), "3");
47
+ assert.equal(formatReviewBudget(5), "5");
48
+ assert.equal(formatReviewBudget("unlimited"), "∞");
49
+ });
50
+
51
+ it("measures numeric budgets per grant and unlimited across the whole run", () => {
52
+ const counters = { reviewRoundsTotal: 0, reviewCapExtension: 0 };
53
+ assert.equal(budgetExhausted(3, 2, counters), false);
54
+ assert.equal(budgetExhausted(3, 3, counters), true);
55
+ assert.equal(budgetExhausted(1, 1, counters), true);
56
+ // Unlimited ignores the per-grant counter and uses the cumulative one.
57
+ assert.equal(budgetExhausted("unlimited", 9999, counters), false);
58
+ assert.equal(budgetExhausted("unlimited", 0, { reviewRoundsTotal: UNLIMITED_HARD_CAP - 1, reviewCapExtension: 0 }), false);
59
+ assert.equal(budgetExhausted("unlimited", 0, { reviewRoundsTotal: UNLIMITED_HARD_CAP, reviewCapExtension: 0 }), true);
60
+ // An explicit unlimited grant lifts the cap by exactly one window.
61
+ assert.equal(budgetExhausted("unlimited", 0, { reviewRoundsTotal: UNLIMITED_HARD_CAP + 10, reviewCapExtension: 50 }), false);
62
+ assert.equal(unlimitedHardCapCeiling({ reviewRoundsTotal: 12, reviewCapExtension: 50 }), 100);
63
+ });
64
+
65
+ it("recognizes every review pause reason and nothing else (shared predicate)", () => {
66
+ assert.equal(isReviewPauseReason("execution review exhausted 3 rounds (failed: VC-002). Only /plans-execute"), true);
67
+ assert.equal(isReviewPauseReason("execution review exhausted 50 rounds (unlimited budget safety cap)"), true);
68
+ assert.equal(isReviewPauseReason("completion audit exhausted 5 rounds (legacy prefix)"), true);
69
+ assert.equal(isReviewPauseReason("execution review stalled — 3 consecutive rounds reported the same outcome"), true);
70
+ // A blocked wake names a different state and must never be grantable.
71
+ assert.equal(isReviewPauseReason("blocked: review round 2 cannot start — Task-2 still open"), false);
72
+ assert.equal(isReviewPauseReason(undefined), false);
73
+ assert.equal(isReviewPauseReason(""), false);
74
+ });
75
+ });
76
+
77
+ describe("no-progress valve (v0.9.3)", () => {
78
+ it("signs the post-classification triple deterministically", () => {
79
+ const base = noProgressSignature(["VC-002"], ["VC-001"], ["F-001"]);
80
+ assert.equal(base, noProgressSignature(["VC-002"], ["VC-001"], ["F-001"]));
81
+ // Order and duplicates never change the signature.
82
+ assert.equal(base, noProgressSignature(["VC-002", "VC-002"], ["VC-001"], ["F-001"]));
83
+ // Any real movement changes it — the valve only fires on a dead loop.
84
+ assert.notEqual(base, noProgressSignature([], ["VC-001"], ["F-001"]));
85
+ assert.notEqual(base, noProgressSignature(["VC-002"], [], ["F-001"]));
86
+ assert.notEqual(base, noProgressSignature(["VC-002"], ["VC-001"], []));
87
+ assert.notEqual(base, noProgressSignature(["VC-001"], ["VC-002"], ["F-001"]));
88
+ });
89
+
90
+ it("counts consecutive identical outcomes and trips on the third", () => {
91
+ const sig = noProgressSignature([], ["VC-001", "VC-002"], []);
92
+ const first = bumpNoProgress(undefined, sig);
93
+ assert.deepEqual(first, { key: sig, streak: 1 });
94
+ assert.equal(noProgressTripped(first), false);
95
+ const second = bumpNoProgress(first, sig);
96
+ assert.equal(second.streak, 2);
97
+ assert.equal(noProgressTripped(second), false);
98
+ const third = bumpNoProgress(second, sig);
99
+ assert.equal(third.streak, NO_PROGRESS_MAX_STREAK);
100
+ assert.equal(noProgressTripped(third), true);
101
+ // Progress resets the streak to one.
102
+ const moved = bumpNoProgress(third, noProgressSignature(["VC-001"], ["VC-002"], []));
103
+ assert.equal(moved.streak, 1);
104
+ assert.equal(noProgressTripped(moved), false);
105
+ });
106
+ });
107
+
108
+ describe("budget picker (v0.9.3)", () => {
109
+ const theme = { fg: (_color: string, text: string) => text, bold: (text: string) => text };
110
+
111
+ it("falls back to null without a native surface (the default-budget path)", async () => {
112
+ assert.equal(reviewBudgetPanelAvailable({ mode: "print", hasUI: false }), false);
113
+ assert.equal(reviewBudgetPanelAvailable({ mode: "tui", hasUI: true, ui: {} }), false);
114
+ assert.equal(await askReviewBudget({ mode: "print", hasUI: false }, "en"), null);
115
+ assert.equal(await askReviewBudget({ mode: "tui", hasUI: true, ui: {} }, "en"), null);
116
+ // RPC: ui.custom exists but resolves undefined (no overlay support).
117
+ assert.equal(await askReviewBudget({ mode: "rpc", hasUI: true, ui: { custom: async () => undefined } }, "en"), null);
118
+ });
119
+
120
+ it("treats a headless session as panel-less even though ui.select exists (round-1 F-001)", async () => {
121
+ // The SDK's ExtensionUIContext.select is REQUIRED, so json/print
122
+ // sessions still carry a function; hasUI is the availability signal.
123
+ const headless = {
124
+ mode: "json" as const,
125
+ hasUI: false,
126
+ ui: {
127
+ select: async () => {
128
+ throw new Error("a headless select must never be asked");
129
+ },
130
+ custom: async () => {
131
+ throw new Error("a headless custom must never be asked");
132
+ },
133
+ },
134
+ };
135
+ assert.equal(reviewBudgetPanelAvailable(headless), false, "hasUI false means no panel");
136
+ assert.equal(await askReviewBudget(headless, "en"), null, "and the picker answers null without calling the UI");
137
+ // A TUI/RPC host with hasUI true keeps the native surfaces.
138
+ assert.equal(reviewBudgetPanelAvailable({ mode: "rpc", hasUI: true, ui: { select: async () => undefined } }), true);
139
+ assert.equal(reviewBudgetPanelAvailable({ mode: "tui", hasUI: true, ui: { custom: async () => null } }), true);
140
+ });
141
+
142
+ it("returns the menu selection and maps the label back to a budget", async () => {
143
+ const titles: string[] = [];
144
+ const pick = {
145
+ mode: "rpc",
146
+ hasUI: true,
147
+ ui: {
148
+ select: async (title: string, options: string[]): Promise<string | undefined> => {
149
+ titles.push(title);
150
+ return options[3];
151
+ },
152
+ },
153
+ };
154
+ // options[3] = "5 rounds" (en); the title carries the current value.
155
+ assert.equal(await askReviewBudget(pick, "en", 3), 5);
156
+ assert.match(titles[0] ?? "", /current: 3/);
157
+ // Cancelled select → null (the caller decides: default at first ask,
158
+ // keep-paused at a grant).
159
+ assert.equal(await askReviewBudget({ mode: "rpc", hasUI: true, ui: { select: async () => undefined } }, "en"), null);
160
+ });
161
+
162
+ it("builds the TUI panel, returns its pick, and treats Esc as no answer", async () => {
163
+ const panelHost = {
164
+ mode: "tui",
165
+ hasUI: true,
166
+ ui: {
167
+ // Construction must succeed with the host's minimal tui/theme —
168
+ // exactly what the real overlay passes in tests.
169
+ custom: (render: (tui: unknown, theme: unknown, kb: unknown, done: (value: unknown) => void) => unknown) => {
170
+ render({ requestRender() {} }, theme, undefined, () => {});
171
+ return Promise.resolve(2);
172
+ },
173
+ },
174
+ };
175
+ assert.equal(reviewBudgetPanelAvailable(panelHost), true);
176
+ assert.equal(await askReviewBudget(panelHost, "zh", 1), 2);
177
+ const escHost = {
178
+ mode: "tui",
179
+ hasUI: true,
180
+ ui: {
181
+ custom: (render: (tui: unknown, theme: unknown, kb: unknown, done: (value: unknown) => void) => unknown) => {
182
+ render({ requestRender() {} }, theme, undefined, () => {});
183
+ return Promise.resolve(null);
184
+ },
185
+ },
186
+ };
187
+ assert.equal(await askReviewBudget(escHost as never, "zh", 3), null);
188
+ });
189
+
190
+ it("ships zh and en chrome for the panel and its rows", () => {
191
+ const zh = reviewBudgetChrome("zh");
192
+ const en = reviewBudgetChrome("en");
193
+ assert.match(zh.unlimitedOption, /无上限/);
194
+ assert.match(zh.roundsOption(3), /3 轮/);
195
+ assert.match(zh.currentSuffix("3"), /当前 3/);
196
+ assert.match(en.unlimitedOption, /unlimited/);
197
+ assert.match(en.roundsOption(1), /^1 round$/);
198
+ assert.match(en.roundsOption(3), /^3 rounds$/);
199
+ assert.notEqual(zh.panelTitle, en.panelTitle);
200
+ });
201
+ });