@tea-agent/loop-agent 0.28.13 → 0.29.1

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.
Files changed (48) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +73 -15
  3. package/dist/commands/client-recovery.js +56 -1
  4. package/dist/commands/init-upgrade.js +186 -21
  5. package/dist/commands/init.js +1 -1
  6. package/dist/executors/dag-pi-executor.js +52 -3
  7. package/dist/executors/pi-playwright-cli-tool.js +14 -8
  8. package/dist/executors/shell-executor.js +288 -0
  9. package/dist/task/config-types.js +21 -0
  10. package/dist/worker/console/app-data.js +132 -11
  11. package/dist/worker/console/chat/pi-runtime.js +24 -42
  12. package/dist/worker/console/chat/resource-loader.js +11 -20
  13. package/dist/worker/console/chat/routes.js +7 -8
  14. package/dist/worker/console/chat/runtime-context.js +1 -1
  15. package/dist/worker/console/chat/tools.js +67 -54
  16. package/dist/worker/console/operation-runner.js +15 -1
  17. package/dist/worker/console/operation-store.js +70 -49
  18. package/dist/worker/console/operator-actions.js +57 -1
  19. package/dist/worker/console/static/assets/index-Cwx-ZVEQ.js +29 -0
  20. package/dist/worker/console/static/favicon.svg +37 -0
  21. package/dist/worker/console/static/index.html +2 -1
  22. package/dist/workflows/dag/backend-test-scenario-param.js +846 -0
  23. package/dist/workflows/dag/backend-test-writer-completeness.js +418 -0
  24. package/dist/workflows/dag/frontend-test-case-checklist.js +94 -15
  25. package/dist/workflows/dag/frontend-test-case-manifest.js +104 -0
  26. package/dist/workflows/dag/frontend-test-html-report.js +106 -24
  27. package/dist/workflows/dag/frontend-test-result-contract.js +3 -0
  28. package/dist/workflows/dag/init-hybrid.js +187 -108
  29. package/dist/workflows/dag/node-execution.js +31 -2
  30. package/dist/workflows/dag/retry-policy.js +55 -18
  31. package/dist/workflows/dag/types.js +41 -0
  32. package/dist/workflows/dag/validate.js +42 -4
  33. package/docs/operations/README.md +1 -1
  34. package/docs/templates/README.md +2 -1
  35. package/docs/templates/agent-dag.schema.json +9 -4
  36. package/docs/templates/backend-test-dag.json +36 -11
  37. package/docs/templates/frontend-test-case-checklist.md +1 -1
  38. package/docs/templates/frontend-test-dag.generate-cases.prompt.md +9 -1
  39. package/docs/templates/frontend-test-dag.json +125 -267
  40. package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +7 -1
  41. package/docs/templates/frontend-test-dag.review-cases.prompt.md +1 -1
  42. package/docs/templates/frontend-test-standard-scenarios.v1.json +114 -0
  43. package/docs/templates/init-managed-agents.md +1 -1
  44. package/harness.json +2 -2
  45. package/package.json +2 -1
  46. package/skills/playwright-cli/SKILL.md +1 -1
  47. package/skills/playwright-cli-case-generator/SKILL.md +1 -1
  48. package/dist/worker/console/static/assets/index-BfRgtLF4.js +0 -29
package/AGENTS.md CHANGED
@@ -49,7 +49,7 @@
49
49
 
50
50
  启动 loop-agent DAG 流程后,主 agent 应自主推进到整个流程结束,不要中途请求无谓的人工确认(如“要我现在执行 DAG 吗?”)。只要 dry-run / validate / writeSet 审查通过就应直接执行并跑完全部 ranks;遇到真问题(契约不一致、writeSet 越界、verify 失败且超出 maxFixLoops、用户明显未授权的高风险动作)才停。即便 profile 是 supervised/reviewed,也不默认在 review-gate 主动停下等人——控制器会在需要人工 approve 时自行提示,主 agent 的职责是推动流程跑完。
51
51
 
52
- DAG 执行期间主 agent 必须持续轮询监视直到 run 结束(FINISHED / FAILED / 需要 approve),不能轮询一次就停下等用户;轮询间隔应合理(避免频繁唤醒浪费 token,也不得住一个节点上赌一次就走)。判活必须用可靠方式:看 `state.json` 的 `runner.heartbeatAt` 是否持续刷新 + `session-events.jsonl` 是否在增长 + `dag doctor` 的 `liveness` 字段,不要只用 `tasklist /FI "PID eq X"` 这类过滤语法在 Git Bash 下会误报 DEAD,从而错判一个正常工作的 run 为 orphaned。遇到疑似 stall 先按 `docs/runtime/agent-dag-runner.md` 查 `lastMeaningfulProgressAt` / provider 活动,有真实进展就继续等,绝不盲目 supersede
52
+ DAG 执行期间主 agent 必须持续轮询监视直到 run 结束(FINISHED / FAILED / 需要 approve),不能轮询一次就停下等用户;轮询间隔应合理(避免频繁唤醒浪费 token,也不得住一个节点上赌一次就走)。判活必须用可靠方式:看 `state.json` 的 `runner.heartbeatAt` 是否持续刷新 + `session-events.jsonl` 是否在增长 + `dag doctor` 的 `liveness` 字段,不要只用 `tasklist /FI "PID eq X"` 这类过滤语法在 Git Bash 下会误报 DEAD,从而错判一个正常工作的 run 为 orphaned。遇到疑似 stall 先按 `docs/runtime/agent-dag-runner.md` 查 `lastMeaningfulProgressAt` / provider 活动,有真实进展就继续等,绝不盲目 supersede。持续监视过程中,主 agent 可在合适节点(例如 rank/节点状态变化、进入 verify/closeout、出现 stall 嫌疑或需要 approve 时)向用户做简短进度汇报(当前节点、状态、是否有风险),避免长时间静默;汇报是告知,不是请求确认,不得因此停下流程。
53
53
 
54
54
  DAG 执行期间,主 agent 不得修改 writeSet 外的任何工作区文件(包括 AGENTS.md、docs、根配置等)。bounded writer 节点的 write guard 用「节点运行期间的工作区 diff」作为越界证据,不区分改动来自 pi 还是主会话——主会话在 pi 节点跑的时候改了 writeSet 外文件,会让 write guard 把账算到 pi 头上、判节点 ERROR、下游 cascade skip。需要改文档/约束时,要么在 DAG 启动前改完,要么等 run 结束后改;务必与 DAG 写入节点在时间上互斥。排查任务路由问题时优先用 `resolveTaskDagTemplateSelection` / `classifyTaskDemand` 等纯函数探针或 `--output` 指定临时路径,不要反复 `dag execute --dry-run` / 无必要的 lifecycle 探测。dry-run 不执行节点;若确需预演,检查命令返回的 `.harness/dag-runs/dry-run/<runId>/` `runDir`,它不进入 active overview,也不能作为 `dag resume` 目标。优先用纯函数探针或 `task status`。
55
55
 
package/CHANGELOG.md CHANGED
@@ -2,6 +2,66 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.29.1] - 2026-08-07
6
+
7
+ ### 重点更新
8
+
9
+ - 前端测试默认执行流程大幅精简,收敛至约 11 个静态节点,合并了多项检查与报告步骤
10
+ - 前端测试浏览器默认改为无头(headless)模式,减少环境干扰
11
+ - 优化证据缺失处理逻辑,非路径逃逸的异常不再强制阻断报告生成
12
+ - 修复文档说明被误判为可执行命令的问题,提升执行稳定性
13
+
14
+ ### 新增
15
+
16
+ - 新增前端测试标准场景物化节点,支持执行后针对阻塞或缺失证据进行最多两轮有界重跑
17
+ - 新增前端测试标准场景规范模板(frontend-test-standard-scenarios.v1.json)
18
+ - 前端测试独占节点支持 tools-only 写保护策略,在保留工具级写沙箱的同时跳过 Git 基线的硬失败限制
19
+
20
+ ### 改进
21
+
22
+ - 前端测试默认 DAG 结构精简至约 11 个静态节点:合并双重重跑、检查清单与清单文件、证据与结果、L5 与 HTML 报告
23
+ - 前端测试主 HTML 报告不再依赖回顾(retrospect)节点,回顾功能改为可选开启
24
+ - 权威清单文件(manifest.json)仅记录规范化后的测试用例,并在未配置重试时省略相关静态节点
25
+ - 强化运营 HTML 报告的状态过滤功能,并优化失败与阻塞原因的展示
26
+
27
+ ### 修复
28
+
29
+ - 修复前端测试检查清单将不可用或阻塞状态的说明文字误判为非法可执行命令的问题
30
+ - 修复前端测试收尾阶段因证据缺失或格式错误导致强制失败的问题,现仅对路径逃逸强制报错,其余降级为建议性提示
31
+ - 修复前端测试合并后清单文件物化异常的问题,确保仅写入规范化用例并恢复负向测试用例
32
+ - 回退误改的全局默认 Pi 模型(DEFAULT_PI_MODEL),将其恢复为 glm-5.2,模型路由统一交由 harness 复杂度矩阵处理
33
+
34
+ ## [0.29.0] - 2026-08-07
35
+
36
+ ### 重点更新
37
+
38
+ - 新增 Automatic SemVer 自动化发布链,基于 Conventional Commit 增量自动决定版本号,且绝不自动发布破坏性 major 版本
39
+ - Operator Chat 默认开放完整的 Pi 工具集(read/write/edit/bash 等),在保持安全硬边界的前提下提升操作便捷性
40
+ - 后端测试在 16K 最大输出限制下引入分文件写入纪律与完整性恢复机制,彻底避免残缺资产被误判为成功
41
+ - 控制台新增横向莫比乌斯环页签图标,以暖黑双环表达持续运行,并用克制橙色交接面标记受控切换
42
+
43
+ ### 新增
44
+
45
+ - 新增 Automatic SemVer 每日发布入口:严格依据最新稳定 tag 到 main HEAD 的增量决定版本,feat 触发 minor,其余触发 patch,破坏性标记会安全停止
46
+ - Operator Chat 默认注册并开放完整的 Pi 工具(read/write/edit/bash/grep/find/ls),Capabilities 与 system prompt 对齐 ADR 0011
47
+ - Loop Operator Console 新增横向暖黑莫比乌斯环页签图标,并覆盖静态资源与 UI 边界检查
48
+ - 客户端恢复机制支持静默忽略瞬时中断错误,并写入详细的恢复日志供运维人员核查
49
+
50
+ ### 改进
51
+
52
+ - 后端测试在 pytest 执行前增加场景参数一致性检查,支持确定性有界自修,并在执行后生成结构化的《测试失败用例分析报告》
53
+ - 测试框架不再硬编码模型 ID,改为动态解析真实的 Pi 模型矩阵,提升跨环境兼容性
54
+ - 初始化升级流程在控制器基线合并中增加验证权限守卫,提升操作安全性
55
+ - AGENTS 及初始化托管块现允许在监控 DAG 时进行简短的进度更新
56
+
57
+ ### 修复
58
+
59
+ - 修复控制台在用户主动切换标签页时,任务失败错误信息残留并跨标签页持续显示的问题
60
+ - 修复并发操作更新导致 JSON 路径产生竞态的问题,现对操作写入进行串行化处理
61
+ - 修复 Windows 环境下文件原子替换遇到短暂占用时导致操作卡死的问题,现有界重试并清理临时文件
62
+ - 修复后端测试 Markdown 生成器在已确认变更但无 diff 时误判的问题,现会进行严格的有界重试
63
+ - 改进 Windows 环境下临时目录清理机制,在遇到目录非空或繁忙(ENOTEMPTY/EBUSY)错误时自动重试
64
+
5
65
  ## [0.28.13] - 2026-08-06
6
66
 
7
67
  ### 重点更新
@@ -174,26 +234,24 @@
174
234
 
175
235
  ## [0.28.1] - 2026-08-05
176
236
 
237
+ ### 重点更新
238
+
239
+ - 提升前端/后端任务执行稳定性,减少“看起来成功、实际未写入”的误判
240
+ - 缩短默认测试耗时,改善本地与发布验证体验
241
+
177
242
  ### 改进
178
243
 
179
- - cut npm test wall clock to ~264s and archive finished plans
180
- - avoid routing maintenance tasks to frontend dag
181
- - make openspec evidence non-blocking
182
- - disable frontend lint generation
183
- - tolerate prose before frontend contract json
184
- - fix frontend topology and verdict tests
185
- - reduce llm protocol validation failures
186
- - fix frontend prewrite contract normalization
244
+ - 默认测试流程更快,并归档已完成计划
245
+ - 维护类任务不再误入前端实现流程
246
+ - 部分证据与 lint 检查改为非阻断,降低无关噪声对交付的干扰
247
+ - 模型输出格式更宽松:说明文字、行内通过结论等常见写法更不易被误杀
187
248
 
188
249
  ### 修复
189
250
 
190
- - stabilize identity-sensitive tests
191
- - fail closed on backend writer no-op
192
- - validate frontend contract before writing
193
- - add structured frontend contract node
194
- - harden verification and JSON parsing
195
- - accept inline pass verdict
196
- - normalize empty model fields
251
+ - 后端生成任务在未实际写入文件时不再误判为成功
252
+ - 前端写入前补齐契约校验,避免无效契约进入实现阶段
253
+ - 修正失败分类误把路径限制提示当成鉴权失败的问题
254
+ - 加强验证与结果解析,减少畸形输出导致的卡死或误判
197
255
 
198
256
  ## [0.28.0] - 2026-08-05
199
257
 
@@ -8,6 +8,7 @@ export const CLIENT_RECOVERY_MODES = [
8
8
  "off",
9
9
  ];
10
10
  export const OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH = ".opencode/plugins/loop-agent-transient-retry.js";
11
+ export const OPENCODE_TRANSIENT_RETRY_LOG_PATH = ".opencode/plugins/loop-agent-transient-retry.log";
11
12
  export const OPENCODE_CONTEXT_OVERFLOW_COMPACT_PLUGIN_PATH = ".opencode/plugins/loop-agent-context-overflow-compact.js";
12
13
  export const PI_CONTEXT_OVERFLOW_EXTENSION_PATH = ".pi/extensions/loop-agent-context-overflow.js";
13
14
  /** Pi project settings are part of the managed init surface; never default to home. */
@@ -511,12 +512,29 @@ export function buildOpenCodeTransientRetryPluginSource() {
511
512
  * Compensates transient UnknownError cases that OpenCode built-in APIError
512
513
  * retry does not cover. Never runs concurrently with session.status === "retry".
513
514
  * Permanent failures use interaction "${PERMANENT_ERROR_INTERACTION}".
515
+ * User aborts (MessageAbortedError / cancel words) are silently cleared.
516
+ * Best-effort file log: ${OPENCODE_TRANSIENT_RETRY_LOG_PATH}.
514
517
  */
518
+ import path from "node:path";
519
+ import { fileURLToPath } from "node:url";
520
+ import { appendFile, mkdir } from "node:fs/promises";
521
+
515
522
  const PLUGIN_PATH = ${JSON.stringify(OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH)};
523
+ const LOG_FILE_NAME = ${JSON.stringify(path.basename(OPENCODE_TRANSIENT_RETRY_LOG_PATH))};
524
+ const LOG_PATH = (() => {
525
+ try {
526
+ const dir =
527
+ import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url));
528
+ return path.join(dir, LOG_FILE_NAME);
529
+ } catch {
530
+ return ${JSON.stringify(OPENCODE_TRANSIENT_RETRY_LOG_PATH)};
531
+ }
532
+ })();
516
533
  const PERMANENT_INTERACTION = ${JSON.stringify(PERMANENT_ERROR_INTERACTION)};
517
534
  const BACKOFF_MS = ${JSON.stringify([...BACKOFF_MS])};
518
535
  const MAX_RETRIES = ${MAX_SESSION_RETRIES};
519
536
  ${buildGeneratedContextOverflowPatternSource()}
537
+ void logToFile("load ok");
520
538
 
521
539
  const sessionState = new Map();
522
540
  const sessionStatus = new Map();
@@ -542,6 +560,26 @@ function textOf(error) {
542
560
  }
543
561
  }
544
562
 
563
+ async function logToFile(line) {
564
+ try {
565
+ await mkdir(path.dirname(LOG_PATH), { recursive: true });
566
+ await appendFile(
567
+ LOG_PATH,
568
+ new Date().toISOString() + " " + line + "\\n",
569
+ "utf-8",
570
+ );
571
+ } catch {
572
+ // Best-effort observability log; never throw (ENOENT/EISDIR tolerated).
573
+ }
574
+ }
575
+
576
+ function isUserAbort(error) {
577
+ if (error && typeof error === "object" && typeof error.name === "string") {
578
+ if (/^MessageAbortedError$/i.test(error.name)) return true;
579
+ }
580
+ return /aborted by user|user cancel|cancelled|canceled/i.test(textOf(error));
581
+ }
582
+
545
583
  function isPermanent(error) {
546
584
  const text = textOf(error);
547
585
  if (error && typeof error === "object" && typeof error.name === "string") {
@@ -624,6 +662,7 @@ function promptWasAccepted(response) {
624
662
  export default async function loopAgentTransientRetryPlugin({ client, $ }) {
625
663
  void $;
626
664
  void PLUGIN_PATH;
665
+ void logToFile("setup ok");
627
666
 
628
667
  async function drainPendingRecovery(sessionId) {
629
668
  const state = getState(sessionId);
@@ -662,9 +701,16 @@ export default async function loopAgentTransientRetryPlugin({ client, $ }) {
662
701
  throwOnError: true,
663
702
  });
664
703
  } catch {
704
+ void logToFile(
705
+ "prompt attempt=" + nextAttempt + " accepted=false session=" + sessionId,
706
+ );
665
707
  return;
666
708
  }
667
- if (!promptWasAccepted(response)) return;
709
+ const accepted = promptWasAccepted(response);
710
+ void logToFile(
711
+ "prompt attempt=" + nextAttempt + " accepted=" + accepted + " session=" + sessionId,
712
+ );
713
+ if (!accepted) return;
668
714
 
669
715
  state.attempt = nextAttempt;
670
716
  if (pendingErrors.get(sessionId) === pending) {
@@ -680,9 +726,11 @@ export default async function loopAgentTransientRetryPlugin({ client, $ }) {
680
726
  }
681
727
  state.locked = true;
682
728
  state.rerunRequested = false;
729
+ void logToFile("worker start session=" + sessionId);
683
730
  const worker = drainPendingRecovery(sessionId)
684
731
  .catch(() => {})
685
732
  .finally(() => {
733
+ void logToFile("worker end session=" + sessionId);
686
734
  recoveryWorkers.delete(sessionId);
687
735
  const rerun = state.rerunRequested;
688
736
  state.rerunRequested = false;
@@ -751,12 +799,19 @@ export default async function loopAgentTransientRetryPlugin({ client, $ }) {
751
799
  if (event.type === "session.error") {
752
800
  if (!sessionId) return;
753
801
  const error = properties.error || properties;
802
+ if (isUserAbort(error)) {
803
+ clearSession(sessionId);
804
+ void logToFile("classify abort session=" + sessionId);
805
+ return undefined;
806
+ }
754
807
  if (!isTransientUnknown(error)) {
755
808
  pendingErrors.delete(sessionId);
756
809
  getState(sessionId).rerunRequested = false;
810
+ void logToFile("classify permanent session=" + sessionId);
757
811
  return { interaction: PERMANENT_INTERACTION, reason: "plugin-ignore-permanent-error" };
758
812
  }
759
813
  pendingErrors.set(sessionId, { error });
814
+ void logToFile("classify transient session=" + sessionId);
760
815
  startRecoveryWorker(sessionId);
761
816
  return { pending: true };
762
817
  }
@@ -1,5 +1,5 @@
1
1
  import { execFile, spawn } from "node:child_process";
2
- import { watch } from "node:fs";
2
+ import { lstatSync, watch } from "node:fs";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
4
  import { lstat, mkdtemp, mkdir, readFile, readdir, readlink, realpath, rename, rm, stat, writeFile, } from "node:fs/promises";
5
5
  import os from "node:os";
@@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url";
8
8
  import { resolveAdapter } from "../adapters/index.js";
9
9
  import { createNpmUpdateClient } from "../cli/update/npm-client.js";
10
10
  import { auditDocs } from "../governance/checks.js";
11
+ import { harnessManifestSchema } from "../governance/manifest-types.js";
11
12
  import { isInitRuntimeActive, } from "../shared/runtime-activity.js";
12
13
  import { MAX_OVERFLOW_RECOVERIES, MAX_SESSION_RETRIES, OPENCODE_CONTEXT_OVERFLOW_COMPACT_PLUGIN_PATH, OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH, PI_CONTEXT_OVERFLOW_EXTENSION_PATH, PI_PROJECT_SETTINGS_PATH, inspectProjectPiSettings, } from "./client-recovery.js";
13
14
  import { applyInitUpdate, checkInitUpdate, initializeLoopAgentProject, runInitDoctor, } from "./init.js";
@@ -338,6 +339,9 @@ function mergeAuthorityProjection(state, authority) {
338
339
  if (!sameJsonValue(state.mergeGuard.baselineWorkspace, authority.baselineWorkspace)) {
339
340
  throw new Error("init upgrade merge guard projection differs from controller baseline authority");
340
341
  }
342
+ if (!sameJsonValue(state.mergeGuard.verificationAuthority, authority.verificationAuthority)) {
343
+ throw new Error("init upgrade merge guard verification authority differs from controller baseline authority");
344
+ }
341
345
  if (state.mergeReceipts.length !== authority.priorMergeReceiptCount) {
342
346
  throw new Error("persisted pre-acceptance merge receipt count differs from controller authority");
343
347
  }
@@ -653,6 +657,12 @@ async function snapshotWorkspace(repoRoot) {
653
657
  const entries = await readdir(directory, { withFileTypes: true });
654
658
  entries.sort((left, right) => left.name.localeCompare(right.name));
655
659
  for (const entry of entries) {
660
+ // Git may expose the same temp repository through a short/long Windows
661
+ // path pair, making path.relative() lose the leading `.git` segment.
662
+ // Exclude the top-level Git administration directory by entry identity;
663
+ // init-upgrade protects its own controller anchors separately.
664
+ if (directory === repoRoot && entry.name === ".git")
665
+ continue;
656
666
  const absolute = path.join(directory, entry.name);
657
667
  const relative = toRepoPath(repoRoot, absolute);
658
668
  if (relative === ".git" || relative.startsWith(".git/"))
@@ -845,11 +855,8 @@ async function freezeVerificationSurface(repoRoot) {
845
855
  collected.add(await assertSafeRegularRepoFile(repoRoot, relativePath));
846
856
  };
847
857
  await add("harness.json");
848
- const harness = JSON.parse(await readFile(path.join(repoRoot, "harness.json"), "utf-8"));
849
- const governanceRoot = typeof harness.governanceRoot === "string" ? harness.governanceRoot : "ai_workspace/loop-agent";
850
- const matrixPath = typeof harness.entrypoints?.verificationMatrix === "string"
851
- ? harness.entrypoints.verificationMatrix
852
- : `${governanceRoot}/verification-matrix.md`;
858
+ const harness = await readVerificationHarness(repoRoot);
859
+ const matrixPath = verificationMatrixPath(harness);
853
860
  await add(matrixPath);
854
861
  const commands = ["bash scripts/check-repo.sh", await discoverQuickVerification(repoRoot)];
855
862
  for (const command of commands) {
@@ -878,9 +885,68 @@ async function assertFrozenVerificationSurface(repoRoot, surface) {
878
885
  }
879
886
  }
880
887
  }
888
+ function frozenVerificationMatrixPath(surface) {
889
+ const paths = surface.files
890
+ .map((entry) => entry.path)
891
+ .filter((pathName) => pathName === "verification-matrix.md" ||
892
+ pathName.endsWith("/verification-matrix.md"));
893
+ if (paths.length !== 1) {
894
+ throw new Error("verification authority surface must contain exactly one verification-matrix.md");
895
+ }
896
+ return paths[0];
897
+ }
898
+ function isControllerAuthorizedVerificationMergeTask(task, surface) {
899
+ const taskPath = normalizeAllowedPath(task.path);
900
+ if (taskPath === "harness.json")
901
+ return true;
902
+ return ((taskPath === "verification-matrix.md" ||
903
+ taskPath.endsWith("/verification-matrix.md")) &&
904
+ Boolean(surface?.files.some((entry) => entry.path === taskPath)));
905
+ }
906
+ async function assertFrozenVerificationSurfaceExcept(repoRoot, surface, exemptPath) {
907
+ if (!isFrozenVerificationSurface(surface)) {
908
+ throw new Error("verification authority surface manifest is invalid");
909
+ }
910
+ const canonicalExemptPath = exemptPath
911
+ ? normalizeAllowedPath(exemptPath)
912
+ : undefined;
913
+ for (const entry of surface.files) {
914
+ if (entry.path === canonicalExemptPath)
915
+ continue;
916
+ const normalized = await assertSafeRegularRepoFile(repoRoot, entry.path);
917
+ if (normalized !== entry.path || sha256(await readFile(path.join(repoRoot, normalized))) !== entry.sha256) {
918
+ throw new Error(`verification authority surface changed: ${entry.path}`);
919
+ }
920
+ }
921
+ }
922
+ async function validateControllerAuthorizedVerificationMerge(input) {
923
+ const taskPath = normalizeAllowedPath(input.task.path);
924
+ if (!isControllerAuthorizedVerificationMergeTask(input.task, input.surface))
925
+ return;
926
+ const harness = await readVerificationHarness(input.repoRoot);
927
+ const matrixPath = normalizeAllowedPath(verificationMatrixPath(harness));
928
+ const frozenMatrixPath = frozenVerificationMatrixPath(input.surface);
929
+ if (matrixPath !== frozenMatrixPath ||
930
+ (taskPath !== "harness.json" && taskPath !== frozenMatrixPath)) {
931
+ throw new Error("verification authority merge must retain the controller-frozen verification matrix path");
932
+ }
933
+ if (taskPath === "harness.json") {
934
+ harnessManifestSchema.parse(harness);
935
+ const authority = input.guard?.verificationAuthority;
936
+ if (authority &&
937
+ (!sameJsonValue(harness.executors ?? null, authority.executors) ||
938
+ !sameJsonValue(harness.workflowPolicy ?? null, authority.workflowPolicy))) {
939
+ throw new Error("verification authority harness.json merge cannot change controller executor or workflow authority");
940
+ }
941
+ }
942
+ const command = await discoverQuickVerification(input.repoRoot);
943
+ tokenizeCommand(command);
944
+ await commandRepositoryInputs(input.repoRoot, command);
945
+ }
881
946
  function assertTaskOutsideVerificationSurface(task, surface) {
882
947
  const taskPath = normalizeAllowedPath(task.path);
883
- if (surface.files.some((entry) => entry.path === taskPath)) {
948
+ if (surface.files.some((entry) => entry.path === taskPath) &&
949
+ !isControllerAuthorizedVerificationMergeTask(task, surface)) {
884
950
  throw new Error(`init upgrade merge task targets authority-sensitive verification input: ${taskPath}`);
885
951
  }
886
952
  }
@@ -918,11 +984,24 @@ function validateTaskBoundary(task) {
918
984
  }
919
985
  return allowed;
920
986
  }
987
+ async function harnessVerificationAuthority(repoRoot, task) {
988
+ if (normalizeAllowedPath(task.path) !== "harness.json")
989
+ return undefined;
990
+ const harness = await readVerificationHarness(repoRoot);
991
+ return {
992
+ executors: cloneJson(harness.executors ?? null),
993
+ workflowPolicy: cloneJson(harness.workflowPolicy ?? null),
994
+ };
995
+ }
921
996
  async function createMergeGuard(repoRoot, task) {
922
997
  for (const allowedPath of validateTaskBoundary(task)) {
923
998
  await assertSafeMergePath(repoRoot, allowedPath);
924
999
  }
925
- return { task, baselineWorkspace: await snapshotWorkspace(repoRoot) };
1000
+ return {
1001
+ task,
1002
+ baselineWorkspace: await snapshotWorkspace(repoRoot),
1003
+ verificationAuthority: await harnessVerificationAuthority(repoRoot, task),
1004
+ };
926
1005
  }
927
1006
  function isMergeReceipt(value) {
928
1007
  if (!isRecord(value))
@@ -1203,6 +1282,7 @@ async function recordAcceptedMerge(input) {
1203
1282
  mergeAuthority: {
1204
1283
  task: input.task,
1205
1284
  baselineWorkspace: input.guard.baselineWorkspace,
1285
+ verificationAuthority: input.guard.verificationAuthority,
1206
1286
  priorMergeReceiptCount: input.state.mergeReceipts.length,
1207
1287
  priorMergeReceiptsSha256: sha256(serializeJson(input.state.mergeReceipts)),
1208
1288
  stateProjection: controllerStateProjection(input.state),
@@ -1237,11 +1317,14 @@ async function validateContinueMerge(directory, state, anchor) {
1237
1317
  }
1238
1318
  const authority = mergeAuthorityFromAnchor(anchor, state);
1239
1319
  const projection = mergeAuthorityProjection(state, authority);
1240
- await assertFrozenVerificationSurface(state.repoRoot, authority.verificationSurface);
1241
1320
  const task = authority.task;
1321
+ await assertFrozenVerificationSurfaceExcept(state.repoRoot, authority.verificationSurface, isControllerAuthorizedVerificationMergeTask(task, authority.verificationSurface)
1322
+ ? task.path
1323
+ : undefined);
1242
1324
  const authoritativeGuard = {
1243
1325
  task,
1244
1326
  baselineWorkspace: authority.baselineWorkspace,
1327
+ verificationAuthority: authority.verificationAuthority,
1245
1328
  };
1246
1329
  const allowed = new Set(validateTaskBoundary(task));
1247
1330
  for (const allowedPath of allowed) {
@@ -1283,6 +1366,12 @@ async function validateContinueMerge(directory, state, anchor) {
1283
1366
  return "no-change";
1284
1367
  }
1285
1368
  const currentSha256 = await verifyMergedFileInvariants(state.repoRoot, task);
1369
+ await validateControllerAuthorizedVerificationMerge({
1370
+ repoRoot: state.repoRoot,
1371
+ task,
1372
+ surface: authority.verificationSurface,
1373
+ guard: authoritativeGuard,
1374
+ });
1286
1375
  if (pending && pending.guard.currentSha256 !== currentSha256) {
1287
1376
  throw new Error("pending merge acceptance current hash is inconsistent");
1288
1377
  }
@@ -1483,13 +1572,14 @@ async function createVerificationWriteAudit(input) {
1483
1572
  const failures = [];
1484
1573
  const watchers = [];
1485
1574
  const watched = new Set();
1575
+ const canonicalRepoRoot = await realpath(input.repoRoot);
1486
1576
  const piHome = path.join(os.homedir(), ".pi");
1487
1577
  const anchor = await controllerAnchorPath(input.state);
1488
1578
  const note = (scope, file) => {
1489
1579
  events.push(`${scope}=${file.split(path.sep).join("/")}`);
1490
1580
  };
1491
1581
  const ignoredWorkspacePath = (candidate) => {
1492
- const relative = toRepoPath(input.repoRoot, candidate);
1582
+ const relative = toRepoPath(canonicalRepoRoot, candidate);
1493
1583
  return (relative === ".git" ||
1494
1584
  relative.startsWith(".git/") ||
1495
1585
  relative === "node_modules" ||
@@ -1510,9 +1600,21 @@ async function createVerificationWriteAudit(input) {
1510
1600
  : canonical;
1511
1601
  if (ignore?.(candidate))
1512
1602
  return;
1513
- note(scope, path.relative(directory, candidate) || ".");
1514
- // A parent rename can introduce a new directory. The event is already
1515
- // a failure; installing its watcher closes the delayed-descendant gap.
1603
+ let existingDirectory = false;
1604
+ try {
1605
+ existingDirectory = lstatSync(candidate).isDirectory();
1606
+ }
1607
+ catch {
1608
+ // A removed or transient path is still a write-boundary event.
1609
+ }
1610
+ // Windows can report a parent-directory notification when a read-only
1611
+ // traversal opens an existing child directory. Nested watchers own real
1612
+ // mutations below that child; persistent changes are also snapshot-checked.
1613
+ if (!existingDirectory) {
1614
+ note(scope, path.relative(directory, candidate) || ".");
1615
+ }
1616
+ // A parent rename can introduce a new directory. Installing its watcher
1617
+ // closes the delayed-descendant gap even when the container event is noise.
1516
1618
  void install(scope, candidate, ignore).catch((error) => {
1517
1619
  const code = error.code;
1518
1620
  if (code !== "ENOENT" && code !== "ENOTDIR") {
@@ -1536,7 +1638,7 @@ async function createVerificationWriteAudit(input) {
1536
1638
  await install(scope, child, ignore);
1537
1639
  }
1538
1640
  };
1539
- await install("workspace", input.repoRoot, ignoredWorkspacePath);
1641
+ await install("workspace", canonicalRepoRoot, ignoredWorkspacePath);
1540
1642
  await install("controller", runRoot(input.repoRoot));
1541
1643
  await install("controller-anchor", anchor.anchorRoot);
1542
1644
  const piHomeEntry = await lstat(piHome).catch((error) => {
@@ -1901,14 +2003,24 @@ async function runVerificationCommand(input) {
1901
2003
  }
1902
2004
  return result;
1903
2005
  }
1904
- async function discoverQuickVerification(repoRoot) {
1905
- const harness = JSON.parse(await readFile(path.join(repoRoot, "harness.json"), "utf-8"));
2006
+ function verificationMatrixPath(harness) {
1906
2007
  const governanceRoot = typeof harness.governanceRoot === "string"
1907
2008
  ? harness.governanceRoot
1908
2009
  : "ai_workspace/loop-agent";
1909
- const matrixPath = typeof harness.entrypoints?.verificationMatrix === "string"
2010
+ return typeof harness.entrypoints?.verificationMatrix === "string"
1910
2011
  ? harness.entrypoints.verificationMatrix
1911
2012
  : `${governanceRoot}/verification-matrix.md`;
2013
+ }
2014
+ async function readVerificationHarness(repoRoot) {
2015
+ const parsed = JSON.parse(await readFile(path.join(repoRoot, "harness.json"), "utf-8"));
2016
+ if (!isRecord(parsed)) {
2017
+ throw new Error("verification authority harness.json must contain a JSON object");
2018
+ }
2019
+ return parsed;
2020
+ }
2021
+ async function discoverQuickVerification(repoRoot) {
2022
+ const harness = await readVerificationHarness(repoRoot);
2023
+ const matrixPath = verificationMatrixPath(harness);
1912
2024
  await assertSafeMergePath(repoRoot, matrixPath);
1913
2025
  const matrix = await readFile(path.join(repoRoot, matrixPath), "utf-8");
1914
2026
  const targetTests = matrix.match(/^\|\s*target project tests are valid\s*\|\s*`([^`]+)`/im)?.[1];
@@ -2046,6 +2158,44 @@ async function runFinalVerification(state, authoritativeHomeFingerprint, surface
2046
2158
  humanDecisionClean: state.humanDecisions.length === 0,
2047
2159
  };
2048
2160
  }
2161
+ function authoritativeVerificationFailureReason(verification) {
2162
+ const sources = [
2163
+ verification.quickVerification,
2164
+ verification.checkRepo,
2165
+ verification.inspect,
2166
+ verification.docsAudit,
2167
+ ];
2168
+ for (const source of sources) {
2169
+ if (!isRecord(source))
2170
+ continue;
2171
+ const candidates = [
2172
+ typeof source.error === "string" ? source.error : undefined,
2173
+ source.exitCode === null && typeof source.stderr === "string"
2174
+ ? source.stderr
2175
+ : undefined,
2176
+ ];
2177
+ for (const value of candidates) {
2178
+ if (!value)
2179
+ continue;
2180
+ for (const line of value.split(/\r?\n/)) {
2181
+ const reason = line.trim();
2182
+ if (reason.length <= 500 &&
2183
+ (reason ===
2184
+ "verification authority harness.json merge cannot change controller executor or workflow authority" ||
2185
+ reason ===
2186
+ "verification authority rejects direct node code evaluation" ||
2187
+ reason ===
2188
+ "verification authority rejects package command code evaluation" ||
2189
+ /^verification authority (?:surface changed|input is not a regular file): [A-Za-z0-9_./-]+$/.test(reason) ||
2190
+ reason ===
2191
+ "verification authority merge must retain the controller-frozen verification matrix path")) {
2192
+ return reason;
2193
+ }
2194
+ }
2195
+ }
2196
+ }
2197
+ return undefined;
2198
+ }
2049
2199
  function verificationPassed(verification) {
2050
2200
  const doctor = verification.doctor;
2051
2201
  const inspect = verification.inspect;
@@ -2160,9 +2310,15 @@ async function executeUpgrade(directory, state, input) {
2160
2310
  nextAction: "Controller identity drift detected. Re-run with the frozen controller; no target-project writes were made.",
2161
2311
  };
2162
2312
  }
2163
- state.phase = "VERSION_GATE";
2313
+ const recordedCurrentChoice = input.mode === "continue" &&
2314
+ input.versionChoice === undefined &&
2315
+ state.versionGate.choice === "current"
2316
+ ? "current"
2317
+ : undefined;
2318
+ if (!recordedCurrentChoice)
2319
+ state.phase = "VERSION_GATE";
2164
2320
  state.versionGate = await currentVersionGate(input, identity);
2165
- const choice = input.versionChoice;
2321
+ const choice = input.versionChoice ?? recordedCurrentChoice;
2166
2322
  if (state.versionGate.outcome === "newer-available" && !choice) {
2167
2323
  state.status = "needs-human-decision";
2168
2324
  state.nextAction =
@@ -2274,6 +2430,13 @@ async function executeUpgrade(directory, state, input) {
2274
2430
  const activeTask = post.modelMergeTasks[0];
2275
2431
  const verificationSurface = await freezeVerificationSurface(state.repoRoot);
2276
2432
  assertTaskOutsideVerificationSurface(activeTask, verificationSurface);
2433
+ if (verificationSurface.files.some((entry) => entry.path === normalizeAllowedPath(activeTask.path))) {
2434
+ await validateControllerAuthorizedVerificationMerge({
2435
+ repoRoot: state.repoRoot,
2436
+ task: activeTask,
2437
+ surface: verificationSurface,
2438
+ });
2439
+ }
2277
2440
  state.mergeTasks = [activeTask];
2278
2441
  state.mergeGuard = await createMergeGuard(state.repoRoot, activeTask);
2279
2442
  // Bind the Git-dir anchor to the state actually handed to the model.
@@ -2329,8 +2492,10 @@ async function executeUpgrade(directory, state, input) {
2329
2492
  }
2330
2493
  else {
2331
2494
  state.status = "failed";
2332
- state.nextAction =
2333
- "Verification failed. Inspect verification.json; repair only the controller-returned concrete merge path or address the reported external blocker, then use --continue.";
2495
+ const authorityReason = authoritativeVerificationFailureReason(verification);
2496
+ state.nextAction = authorityReason
2497
+ ? `Verification failed: ${authorityReason}. Inspect verification.json, repair only the controller-returned concrete merge path, then use --continue.`
2498
+ : "Verification failed. Inspect verification.json; repair only the controller-returned concrete merge path or address the reported external blocker, then use --continue.";
2334
2499
  }
2335
2500
  await saveState(directory, state);
2336
2501
  await writeReport(directory, state, verification);
@@ -2217,7 +2217,7 @@ function buildTargetFeatureWorkflow(input) {
2217
2217
  '- `taskKind: "backend-test"` selects the dedicated backend test DAG. Its Pi nodes analyze requirements, generate and review backend cases, generate pytest, and retrospect on results; shell gate/execution nodes enforce the review verdict and run the target project\'s pytest. The backend test templates (`backend-test-dag.json` and the `backend-test-dag.*.prompt.md` files) ship inside the loop-agent package as static references and are projected to target projects under the governance `templates/` directory.',
2218
2218
  '- `taskKind: "knowledge-sync"` selects the Feature-scoped test-knowledge write-back DAG (collect → draft → validate → apply → pointer). Bind `featureId` in `task.json` (or hardConstraints / requirement text). It writes only under `features/<featureId>/…` after final verification evidence exists.',
2219
2219
  '- `taskKind: "knowledge-graph-bootstrap"` selects the business knowledge-graph bootstrap DAG (preflight → inventory → propose → validate → review → gate → promote → materialize). AI writes only `knowledge/bootstrap/staging/**`; promote is merge-new-only.',
2220
- '- `taskKind: "frontend-test"` selects the FE-test RAG DAG. It writes a traceable frontend RAG package and Markdown case manifest, then executes manifest cases serially with `playwright-cli` in isolated test environments and retains per-case evidence. It never generates pytest or Playwright source code. `frontendTest.maxCasesPerBatch` defaults to 20 (maximum 50); optional `maxTokensPerCase` and `maxTotalTokens` stop only later cases after a completed case\'s token usage is recorded, marking them `blocked: token-budget-exhausted`. Generated browser startup uses `playwright-cli open --browser=chrome --headed <base-url>`; the generic playwright-cli skill is unchanged.',
2220
+ '- `taskKind: "frontend-test"` selects the FE-test RAG DAG. It writes a traceable frontend RAG package and Markdown case manifest, then executes manifest cases serially with `playwright-cli` in isolated test environments and retains per-case evidence. It never generates pytest or Playwright source code. `frontendTest.maxCasesPerBatch` defaults to 20 (maximum 50); optional `maxTokensPerCase` and `maxTotalTokens` stop only later cases after a completed case\'s token usage is recorded, marking them `blocked: token-budget-exhausted`. Generated browser startup uses `playwright-cli open --browser=chrome --headless <base-url>`; the generic playwright-cli skill is unchanged.',
2221
2221
  "- Only eligible read-only Pi nodes (planner, scout, reviewer, verifier, closeout with no write-capable tool profile) receive the conservative automatic retry policy. Supervisor, implementer, writer, docs-only, dynamic, shell, static, and decision-gate nodes are not retried automatically. Eligible nodes cannot write repository files; the controller only records immutable attempt evidence under `.harness/dag-runs/<state>/<run-id>/<node-id>/attempt-<n>.json`.",
2222
2222
  "",
2223
2223
  "Use the package-backed public knowledge CLI for graph operations. Do not require target projects to run package-only kb runtime scripts:",