@tea-agent/loop-agent 0.11.0 → 0.12.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.
Files changed (76) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +3 -2
  3. package/dist/application/dag/generate-task-dag.js +15 -0
  4. package/dist/application/dag/run-dag.js +10 -0
  5. package/dist/application/dag/validate-dag.js +11 -0
  6. package/dist/commands/init.js +74 -7
  7. package/dist/shared/package-metadata.js +135 -0
  8. package/dist/task/config-types.js +1 -0
  9. package/dist/worker/cli.js +3 -1
  10. package/dist/worker/observability/event-history.js +216 -0
  11. package/dist/worker/observability/read-model.js +312 -83
  12. package/dist/worker/observe/paths.js +17 -0
  13. package/dist/worker/observe/routes.js +165 -21
  14. package/dist/worker/observe/server.js +59 -1
  15. package/dist/worker/observe/static/api.js +27 -0
  16. package/dist/worker/observe/static/app.js +120 -2598
  17. package/dist/worker/observe/static/constants.js +148 -0
  18. package/dist/worker/observe/static/copy.js +67 -0
  19. package/dist/worker/observe/static/dag-helpers.js +172 -0
  20. package/dist/worker/observe/static/dag-model.js +72 -0
  21. package/dist/worker/observe/static/dom.js +61 -0
  22. package/dist/worker/observe/static/format-pool.js +67 -0
  23. package/dist/worker/observe/static/format.js +292 -0
  24. package/dist/worker/observe/static/index.html +300 -82
  25. package/dist/worker/observe/static/kpi.js +94 -0
  26. package/dist/worker/observe/static/relations.js +128 -0
  27. package/dist/worker/observe/static/router.js +85 -0
  28. package/dist/worker/observe/static/run-processing.js +148 -0
  29. package/dist/worker/observe/static/shell-chrome.js +68 -0
  30. package/dist/worker/observe/static/state.js +253 -0
  31. package/dist/worker/observe/static/styles.css +1719 -495
  32. package/dist/worker/observe/static/views/batch.js +226 -0
  33. package/dist/worker/observe/static/views/dag-graph.js +172 -0
  34. package/dist/worker/observe/static/views/dag-inspector.js +477 -0
  35. package/dist/worker/observe/static/views/dag.js +362 -0
  36. package/dist/worker/observe/static/views/dashboard.js +442 -0
  37. package/dist/worker/observe/static/views/failures.js +143 -0
  38. package/dist/worker/observe/static/views/feature.js +453 -0
  39. package/dist/worker/observe/static/views/pool.js +347 -0
  40. package/dist/worker/observe/static/views/run.js +453 -0
  41. package/dist/worker/observe/static/views/session-timeline.js +205 -0
  42. package/dist/worker/observe/static/views/shell.js +7 -0
  43. package/dist/worker/observe/static/views/task.js +260 -0
  44. package/dist/worker/observe/static/views/timeline.js +163 -0
  45. package/dist/workflows/dag/controller-identity.js +104 -0
  46. package/dist/workflows/dag/init-hybrid.js +396 -3
  47. package/dist/workflows/dag/node-execution.js +123 -29
  48. package/dist/workflows/dag/repair-artifact.js +91 -0
  49. package/dist/workflows/dag/report.js +50 -0
  50. package/dist/workflows/dag/retry-policy.js +138 -0
  51. package/dist/workflows/dag/runner.js +32 -0
  52. package/dist/workflows/dag/runtime-contract.js +87 -0
  53. package/dist/workflows/dag/skill-snapshot.js +2 -0
  54. package/dist/workflows/dag/types.js +44 -1
  55. package/dist/workflows/dag/validate.js +68 -4
  56. package/docs/agent-dag-runner.md +26 -1
  57. package/docs/architecture/dag-execution.md +6 -0
  58. package/docs/architecture/evolution.md +4 -3
  59. package/docs/architecture/facts-and-state.md +1 -1
  60. package/docs/design/README.md +4 -3
  61. package/docs/exec-plans/active/README.md +1 -3
  62. package/docs/exec-plans/completed/README.md +11 -0
  63. package/docs/feature-workflow.md +28 -0
  64. package/docs/progress/README.md +18 -0
  65. package/docs/reports/README.md +8 -2
  66. package/docs/templates/agent-dag-report.schema.json +17 -0
  67. package/docs/templates/agent-dag.schema.json +69 -1
  68. package/docs/templates/agent-dag.supervised-implementation.json +8 -2
  69. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +139 -0
  70. package/docs/templates/backend-test-dag.json +276 -0
  71. package/docs/templates/backend-test-dag.retrospect.prompt.md +125 -0
  72. package/docs/templates/backend-test-dag.review-cases.prompt.md +81 -0
  73. package/package.json +1 -1
  74. package/skills/loop-agent/references/command-reference.md +1 -0
  75. package/skills/loop-agent/references/hybrid-dag.md +22 -3
  76. package/skills/loop-agent/references/verification-and-failure-handling.md +6 -0
package/CHANGELOG.md CHANGED
@@ -4,8 +4,29 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.12.0] - 2026-07-16
8
+
9
+ ### 新增
10
+
11
+ - 只读 Pi DAG 节点支持 opt-in 安全重试:仅 planner/scout/reviewer/verifier/closeout 角色且无写入能力的 Pi 节点可声明 `retryPolicy`,在同一 run 内重试模型连接中断(`network`)、provider 限流(`rate-limit`)、临时不可用(`unavailable`)或请求 timeout。生成模板默认总尝试 3 次,手工配置最多 5 次,并使用有上限的指数退避。每次 attempt 保留独立证据,节点耗时、Token 与事件数按尝试聚合;后一次成功不会覆盖前一次失败证据。`quota`、`auth`、`invalid-output`、`write-guard` 与未知失败不重试;supervisor、implementer、writer、dynamic、shell、static、docs-only、decision-gate 节点声明策略会在校验阶段失败。未声明策略的历史 DAG 不新增 attempt artifact,行为保持不变。
12
+ - 新增 `taskKind: "backend-test"` 专用 DAG:从需求分析开始,生成并评审后端功能用例,再生成和执行 pytest 自动化,最后产出测试复盘报告;新 DAG 沿用当前 Pi-only runtime、版本化 runtime contract 与既有执行恢复机制。
13
+
14
+ ### 修复
15
+
16
+ - `loop-agent init check-update` / `init update --apply-safe` 漏检初始化投影文件:fresh `init --profile full` 通过目录复制把整个 `docs/templates/` 与 `skills/`(镜像到 `.agents/skills/`)投影到目标项目,但期望 init surface 只跟踪其中一个子集。旧版本(如 0.11.0)初始化的项目升级 controller 后,缺失的 backend-test 模板、agent-dag schema/prompt、`loop-agent` 必需 references 等文件无法被报告或补齐,`check-update` 会错误返回 `ok: true`。现在期望 surface 会自动发现包内 `docs/templates/` 与 `skills/` 的所有文件并纳入校验;`check-update` 会报告全部缺失/陈旧 surface;`apply-safe` 会确定性补齐缺失文件,对无法证明 ownership 的旧文件仍保持 fail-safe(给出 model merge / human decision,不覆盖用户内容)。`scripts/check-init-surface.sh` 新增 drift 门,阻止“fresh init 会复制、surface 却不跟踪”的漂移。
17
+
18
+ ## [0.11.0] - 2026-07-15
19
+
7
20
  ### 新增
8
21
 
22
+ - 受治理 DAG 的 repair 环节改为显式声明:`shell.repairArtifactGate` 新增 `repairNodeId`,新生成的 supervised DAG 会直接写明由哪个 Pi 修复节点承接 supervisor 的修订请求;旧 DAG 未声明时,只有在能唯一、安全地推导出下游 Pi 修复节点时才继续兼容,缺失、歧义或修复节点不满足受治理写入契约都会在执行前明确报错。
23
+ - 新生成的 DagSpec 升级为 `version: 3`,并携带必需的 `runtimeContract`(`agentRuntime` / `repairWriterProtocol` / 可选最低 controller 版本)。旧 controller 会因不认识 v3 而在解析阶段拒绝,新 controller 则在 `dag validate`、执行和 resume 前检查 capability 与最低版本,避免版本漂移运行到中途才失败;legacy v1/v2 DAG 继续可读。
24
+ - 每个新的 DAG run 必须把实际执行的 controller identity(package version、binary SHA-256、portable fingerprint)冻结为 run-owned 事实并记录内容哈希;identity 无法解析时不创建 run。`dag report` 会展示 identity 与 runtime-contract compatibility,resume 时重新校验;身份漂移、artifact 篡改或 legacy-unpinned run 都会拒绝继续。
25
+ - Observe R5:控制台前端拆为原生 ES modules(router/api/format/state/views 等),`app.js` 作轻量入口并 re-export 可测 API;统一 loading/empty/degraded/error 状态 helper;资源池支持可分享的 hash 筛选(`status`/`q`);不引入框架或构建器,R1–R4 路由/API/行为保持。
26
+ - Observe R4:Batch/Pool 有界事件历史 `GET /api/batches/:batchRunId/events` 与 `GET /api/events`(默认 limit 50、硬顶 200,opaque `cursor`/`nextCursor`,与 SSE/run 整数 `after` 分离);服务端尾部有界 JSONL 扫描;snapshot 事件归约容量硬顶并暴露 `projectionWarnings`;Pool 投影失败 fault-first,避免健康零值 KPI。
27
+ - Observe R3:`#/feature/:featureId` 决策详情(状态、AC 覆盖、blocking、risk、follow-ups、evidence、projectionWarnings);总览 Feature 可下钻;Feature/Task/Batch/Run/DAG 统一对象关系条,仅 snapshot 可证对象出链;建议命令仅可复制,与已执行事实分区。
28
+ - Observe R2:Batch 详情展示完整 summary(含 additive `recordErrors?`/`runErrors?`)、派生总耗时、Task 诊断列(失败分类、follow-up、harnessTaskId、起止)与页内安全 artifact preview;Worker Run 收敛为「主事实 / 执行过程 / 诊断证据」三层,Task/Batch/DAG 深链仅在 canonical 字段且 snapshot 命中时生成。`TaskSummary.batchRunId?` 对外保留。
29
+ - Observe R1:顶栏「资源池」与 `#/pool` 盘点当前 Worker Task(状态分布、最新 run、更新时间、失败分类);`#/task/:taskId` 下钻当前事实、真实关联 batch/run/DAG、失败证据与有界 run history(`GET /api/tasks/:taskId/runs`,limit/cursor,`retryOfWorkerRunId`)。`TaskSummary` additive `updatedAt?`;snapshot additive `taskPool.present` 区分未使用与空池。
9
30
  - 新增版本更新提醒。发现新版本时可以直接确认升级;忽略某个版本后不会重复提醒,后续新版本仍会正常提示。可用 `LOOP_AGENT_DISABLE_UPDATE_CHECK=1` 关闭检查。
10
31
  - 新增 `loop-agent plan create`、`plan complete` 和 `plan check`,统一维护执行计划及其索引,并在生成 DAG 前发现遗漏或不一致。
11
32
  - 目标项目可以选择复用本地 SDD skills,为需求、设计、实现和评审节点补充方法指引;未安装时保持原有流程。
@@ -19,6 +40,8 @@
19
40
 
20
41
  ### 变更
21
42
 
43
+ - `observe serve` 提高对外访问稳定性:HTTP keep-alive 默认约 65s(避免 Node 5s 默认导致连接池复用失败),SSE 增加 keepalive comment 与 `X-Accel-Buffering: no`;新增 `--debug` 在 stderr 打印请求来源/方法/路径/状态/耗时。
44
+ - `observe serve` 允许显式绑定非 loopback 地址(如 `--host 0.0.0.0`)以便局域网访问;默认仍是 `127.0.0.1`,非本机绑定时会在 stderr 提示无鉴权风险。
22
45
  - 根 README 在「核心概念」后增加极简「仓库地图」,方便一眼区分 `src/`、`skills/`、`.harness/`、`docs/` 等职责;细节仍以 `AGENTS.md` 与 `docs/README.md` 为准。
23
46
  - 文档治理收敛:站上「当前规划」与 active plan 同步(无进行中计划时明确写出);`docs/README` 只索引核心契约与产物目录入口,progress/report 全量列表下沉到子目录 README;Dynamic Workflow 路线图迁入 `docs/design/`;补齐 design 漏索引与 DAG recovery playbook 入口。
24
47
  - 活文档校准:Dynamic Workflow 路线图页首标明已落地 vs 设计输入(Pi-only);六个月规划第 1–2 月改为 archive/reports 指针;`repository-analysis` 冻结为基线快照,新增 `docs/reports/current-capability-summary.md`;落地 ADR 0001–0003(Pi-only、Task Pool 根、双树/docs-converge)。
@@ -30,6 +53,8 @@
30
53
 
31
54
  ### 修复
32
55
 
56
+ - 修复 supervised convergence DAG 在 `process-gate-shell` 报「repair artifact gate cannot find downstream repair task」的问题:修复环节不再按固定节点名(历史上的 `repair-cursor` / `repair-pi`)猜测,而是根据 gate 声明的 `repairNodeId` 或唯一安全的下游 Pi 写入节点解析,合法的 `request-revision` 修订请求能正常走完 repair、hard verify 与 review。
57
+ - Observe UI R5:可变 UI 状态改为 `uiState` plain object,修复 Chrome 中 ES module namespace 赋值导致的路由崩溃;`app.js` 收为约 136 行 bootstrap/route/listeners/re-export,KPI、DAG helpers、run processing 与 shell chrome 迁入职责模块,移除 nullable `registerShell/getShell` locator;Pool grouped filters、四态、DAG inspector ARIA/方向键同步收敛,并通过 focused tests、构建治理与 fresh dist Chrome 验证。
33
58
  - 前端 DAG 从 `需求.md` / `执行约束.md` 读取验证命令时不再交给 shell 解释执行;命令会先解析为受限参数列表并拒绝 shell 特殊语法,避免任务文本触发额外命令。
34
59
  - 改进 Windows 下 Git Bash、npm 和集成测试的运行方式,减少环境识别错误与资源竞争导致的误失败。
35
60
 
package/README.md CHANGED
@@ -78,7 +78,7 @@ loop-agent init update --repo-root <target-repo> --bootstrap-surface
78
78
  loop-agent init update --repo-root <target-repo> --apply-safe
79
79
  ```
80
80
 
81
- `check-update` 只读报告 deterministic actions、model merge tasks、human decisions 和 recommended next。`update --bootstrap-surface` 为旧项目补 inferred baseline;`update --apply-safe` 只补缺失文件、目录和 managed block(包括过期的 `.gitignore` managed block),不覆盖已有但无法确认来源的本地文件。
81
+ `check-update` 只读报告 deterministic actions、model merge tasks、human decisions 和 recommended next。期望 init surface 会自动发现包内 `docs/templates/` 与 `skills/` 的所有文件,因此 fresh `init --profile full` 投影到目标项目的每个 template/skill 文件都会被纳入校验。`update --bootstrap-surface` 为旧项目补 inferred baseline;`update --apply-safe` 只补缺失文件、目录和 managed block(包括过期的 `.gitignore` managed block),不覆盖已有但无法确认来源的本地文件;对在旧路径被用户修改过的遗留副本,会在对应标准路径上给出 model merge / human decision,不会静默用包内容覆盖。
82
82
 
83
83
  当初始化由模型/Agent 执行时,它应把初始化当成一个自动化闭环:确认真正不能安全默认的 provider/model、治理根目录或凭据/成本问题后,运行 deterministic init,随后立刻读取目标项目真实文件,补全根 README 的项目概览、技术栈/目录结构、开发与验证命令,并同步适配 `ai_workspace/loop-agent/verification-matrix.md` 和必要的 `scripts/ci-tests.sh`。
84
84
 
@@ -95,7 +95,7 @@ loop-agent dag validate --dag <temp-dir>/<task-id>-dag.json --strict-models --st
95
95
  loop-agent run-dag --dag <temp-dir>/<task-id>-dag.json --cwd .
96
96
  ```
97
97
 
98
- 当前 `dag run-task` 也支持按 `task.json.taskKind` 选择专用模板;例如 `frontend-implementation` 会生成前端契约、design gate、静态验证、行为验证和 review gate 节点链。
98
+ 当前 `dag run-task` 也支持按 `task.json.taskKind` 选择专用模板;`frontend-implementation` 会生成前端契约、design gate、静态验证、行为验证和 review gate 节点链,`backend-test` 会生成从需求分析、功能用例、pytest 自动化到测试复盘的专用节点链。
99
99
 
100
100
  `<temp-dir>` 表示平台原生临时目录;也可以省略 `--output`,再使用命令 JSON 输出里的 `outputPath`。
101
101
 
@@ -169,6 +169,7 @@ nightly wrapper 按 feature 互斥,保留批次/超时退出码,并输出 mo
169
169
  - **可选 SDD skill 嵌入**:如果目标项目在 `.agents/skills/` 中提供 `SDD-requirement-analysis`、`SDD-design-analysis`、`SDD-implementation-test-review`,`dag run-task` 会把它们作为知识与方法补充追加到对应的 Contract、Plan、Implement/Repair、Verify、Review 节点。loop-agent 仍控制 DAG、状态、写入边界、验证和收口;不会自动运行 SDD 初始化/扫描 skill,也不会推进 `ai_workspace` 状态或归档。没有这些 repo-local skills 时,生成结果保持原有默认流程。
170
170
  - **run-owned skill snapshot**:新 DAG run 会在任何节点执行前,把本次实际注入 prompt 的 resolved skill profiles 冻结到 run 自己的 `.runtime/skill-snapshot.json`。后续节点、dynamic child、approve/resume 都使用同一份 hash-anchored snapshot;run 内修改 skill 只会从下一次 run 生效。
171
171
  - **controller identity**:`agent-worker` 把一次 Feature/batch 实际使用的发布包、入口、启动参数和 package 内容 fingerprint 固定下来,并把 identity 传播到 Worker、Task Pool、batch/Feature 与最终验证证据。
172
+ - **只读 Pi 节点安全重试**:仅 planner/scout/reviewer/verifier/closeout 这类没有仓库写入能力的 Pi 节点,遇到模型连接中断、provider 限流、临时不可用或 timeout 时,可在同一 run 内有界重试;生成模板会自动声明默认 `retryPolicy`(总尝试 3 次、最多可配置 5 次、指数退避、单次等待上限 30s)。每次 attempt 保留独立证据,耗时与 Token 用量按尝试聚合,后一次成功不覆盖前一次失败证据。`quota`/`auth`/`invalid-output`/`write-guard` 与未知失败不重试;supervisor、implementer、writer、dynamic、shell、static、docs-only、decision-gate 节点不重试。
172
173
  - **`agent-worker` operator skill**:`skills/agent-worker/` 只负责 Feature Packet、TaskSpec、Task Pool、自举 release train 和失败恢复的外层路由;单个 DAG 实现、DAG kernel 修复和节点执行仍由 `loop-agent` 负责,该 skill 不进入默认 DAG role skills。
173
174
  - **治理文档**:`docs/` 保存原则、工作流、验证矩阵、runtime 边界、计划和报告。
174
175
  - **shell verification**:完成声明必须有可复现命令作为依据,而不是只靠聊天结论。
@@ -204,6 +204,7 @@ export async function generateTaskDagUseCase(input) {
204
204
  await assertExecPlanIndexConsistent(repoRoot);
205
205
  const taskConfig = await loadTaskConfig(repoRoot, parsed.taskId);
206
206
  const isFrontendImplementationTask = taskConfig.taskKind === "frontend-implementation";
207
+ const isBackendTestTask = taskConfig.taskKind === "backend-test";
207
208
  const candidateResult = await initHybridDagFromTask(repoRoot, parsed.taskId, {
208
209
  outputPath: parsed.outputPath,
209
210
  template: "standard-dag",
@@ -230,6 +231,20 @@ export async function generateTaskDagUseCase(input) {
230
231
  profileRouting.selectedByProfile = parsed.profile;
231
232
  }
232
233
  }
234
+ if (isBackendTestTask) {
235
+ profileRouting.selectedTemplate = "backend-test-dag";
236
+ profileRouting.source = "taskKind";
237
+ profileRouting.routingReasons = [
238
+ 'taskKind "backend-test" selects the dedicated backend test DAG template',
239
+ ];
240
+ if (parsed.profile === "auto") {
241
+ profileRouting.selectedByProfile =
242
+ resolveAutoRoutingProfile(profileRouting.candidateProfile);
243
+ }
244
+ else if (parsed.profileExplicit) {
245
+ profileRouting.selectedByProfile = parsed.profile;
246
+ }
247
+ }
233
248
  const initResult = profileRouting.selectedTemplate === "standard-dag"
234
249
  ? candidateResult
235
250
  : await initHybridDagFromTask(repoRoot, parsed.taskId, {
@@ -3,6 +3,9 @@ import { composeDagRunObservers } from "../../workflows/dag/observer-compose.js"
3
3
  import { createDagCanvasObserver, resolveCanvasPath, } from "../../workflows/dag/canvas-observer.js";
4
4
  import { loadDagSpecFromFile, runDag } from "../../workflows/dag/runner.js";
5
5
  import { assertValidDagSpec } from "../../workflows/dag/validate.js";
6
+ import { assertRuntimeContractCompatible } from "../../workflows/dag/runtime-contract.js";
7
+ import { DAG_CONTROLLER_CAPABILITIES } from "../../workflows/dag/runtime-contract.js";
8
+ import { resolveRunningControllerIdentity } from "../../shared/package-metadata.js";
6
9
  function getCanvasFlushErrorMessage(error) {
7
10
  return error instanceof Error ? error.message : String(error);
8
11
  }
@@ -29,6 +32,13 @@ function buildRunDagNextSteps(runId) {
29
32
  export async function runDagUseCase(input) {
30
33
  const spec = await loadDagSpecFromFile(input.dagPath);
31
34
  assertValidDagSpec(spec);
35
+ const runningIdentity = resolveRunningControllerIdentity();
36
+ assertRuntimeContractCompatible(spec, {
37
+ ...DAG_CONTROLLER_CAPABILITIES,
38
+ ...(runningIdentity
39
+ ? { controllerVersion: runningIdentity.packageVersion }
40
+ : {}),
41
+ });
32
42
  const resolvedCanvasPath = resolveCanvasPath({
33
43
  canvasPath: input.canvasPath,
34
44
  canvasName: input.canvasName,
@@ -1,6 +1,9 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { loadDagSpecFromFile } from "../../workflows/dag/runner.js";
3
3
  import { assertValidDagSpec, collectExecutorModelWarnings, collectForbiddenExecutorIssues, collectGovernanceWarnings, validateDagSpec, } from "../../workflows/dag/validate.js";
4
+ import { assertRuntimeContractCompatible } from "../../workflows/dag/runtime-contract.js";
5
+ import { DAG_CONTROLLER_CAPABILITIES } from "../../workflows/dag/runtime-contract.js";
6
+ import { resolveRunningControllerIdentity } from "../../shared/package-metadata.js";
4
7
  import { inferGovernanceProfile } from "../../workflows/dag/governance-profile.js";
5
8
  import { topoSortToRanks } from "../../workflows/dag/topo.js";
6
9
  import { runSpineAudit } from "../../governance/spine-audit.js";
@@ -103,6 +106,14 @@ export async function validateDagUseCase(input) {
103
106
  const spec = await loadDagSpecFromFile(input.dagPath);
104
107
  const rawTaskExecutors = await loadRawTaskExecutorMap(input.dagPath);
105
108
  ensureNoForbiddenExecutors(spec, input.forbidExecutors, rawTaskExecutors);
109
+ // Runtime contract drift is always a hard error at validate time.
110
+ const runningIdentity = resolveRunningControllerIdentity();
111
+ assertRuntimeContractCompatible(spec, {
112
+ ...DAG_CONTROLLER_CAPABILITIES,
113
+ ...(runningIdentity
114
+ ? { controllerVersion: runningIdentity.packageVersion }
115
+ : {}),
116
+ });
106
117
  const validationIssues = validateDagSpec(spec);
107
118
  const expectedMatrices = await loadExpectedExecutorModelMatrices(input.repoRoot);
108
119
  const warnings = collectExecutorModelWarnings(spec, expectedMatrices);
@@ -1036,15 +1036,62 @@ async function readInitSurfaceManifest(assetRoot) {
1036
1036
  const manifest = JSON.parse(raw);
1037
1037
  const initFullRequired = manifest.initFullRequired ?? [];
1038
1038
  const initSurface = manifest.initSurface ?? {};
1039
- const entries = initFullRequired.map((relativePath) => ({
1040
- path: relativePath,
1041
- mode: initSurface[relativePath] ?? inferInitSurfaceMode(relativePath),
1042
- }));
1039
+ const seenPaths = new Set();
1040
+ const entries = [];
1041
+ for (const relativePath of initFullRequired) {
1042
+ if (seenPaths.has(relativePath))
1043
+ continue;
1044
+ seenPaths.add(relativePath);
1045
+ entries.push({
1046
+ path: relativePath,
1047
+ mode: initSurface[relativePath] ?? inferInitSurfaceMode(relativePath),
1048
+ });
1049
+ }
1050
+ // Fresh `init --profile full` copies the entire `docs/templates/` and
1051
+ // `skills/` directories via copyDirMerge. Every file actually projected to a
1052
+ // target project must be represented in the desired init surface so that
1053
+ // check-update can detect missing or stale copies during controller upgrades.
1054
+ // The static manifest intentionally only pins explicit modes for the subset
1055
+ // that needs them; the rest are auto-discovered here as "copied" entries.
1056
+ for (const discovered of await discoverCopiedSurfaceFiles(assetRoot)) {
1057
+ if (seenPaths.has(discovered))
1058
+ continue;
1059
+ seenPaths.add(discovered);
1060
+ entries.push({
1061
+ path: discovered,
1062
+ mode: initSurface[discovered] ?? inferInitSurfaceMode(discovered),
1063
+ });
1064
+ }
1043
1065
  if (!entries.some((entry) => entry.path === INIT_SURFACE_STATE_PATH)) {
1044
1066
  entries.push({ path: INIT_SURFACE_STATE_PATH, mode: "state" });
1045
1067
  }
1046
1068
  return { manifest, raw, sha256: sha256Text(raw), entries };
1047
1069
  }
1070
+ /**
1071
+ * Returns manifest-path entries for every file that fresh `init --profile full`
1072
+ * copies from the bundled package into a target project: all of `docs/templates/`
1073
+ * and all of `skills/` (the latter is mirrored to `.agents/skills/` at the
1074
+ * target). Paths use the package source convention so manifestPathToTargetPath
1075
+ * can remap them per governance root.
1076
+ */
1077
+ async function discoverCopiedSurfaceFiles(assetRoot) {
1078
+ const discovered = [];
1079
+ const roots = [
1080
+ { source: "docs/templates", prefix: "docs/templates/" },
1081
+ { source: "skills", prefix: "skills/" },
1082
+ ];
1083
+ for (const { source, prefix } of roots) {
1084
+ const rootPath = path.join(assetRoot, source);
1085
+ const rootInfo = await stat(rootPath).catch(() => null);
1086
+ if (!rootInfo?.isDirectory())
1087
+ continue;
1088
+ const files = await listRelativeFiles(rootPath);
1089
+ for (const relativePath of files) {
1090
+ discovered.push(`${prefix}${relativePath}`);
1091
+ }
1092
+ }
1093
+ return discovered.sort();
1094
+ }
1048
1095
  function inferInitSurfaceMode(relativePath) {
1049
1096
  if (relativePath === INIT_SURFACE_STATE_PATH)
1050
1097
  return "state";
@@ -1378,8 +1425,9 @@ function hasLegacyHarnessGovernancePaths(harness) {
1378
1425
  return false;
1379
1426
  }
1380
1427
  async function collectRetiredLayoutActions(input) {
1428
+ const blockedTargetPaths = new Set();
1381
1429
  if (!input.recordedState)
1382
- return [];
1430
+ return { actions: [], blockedTargetPaths };
1383
1431
  const actions = [];
1384
1432
  const retiredOwnedPaths = [];
1385
1433
  const currentPaths = new Set(Object.keys(input.currentState.files));
@@ -1399,6 +1447,9 @@ async function collectRetiredLayoutActions(input) {
1399
1447
  path: oldPath,
1400
1448
  reason: `legacy init file may have local edits; review before migrating to ${targetPath}`,
1401
1449
  });
1450
+ // The user owns a modified copy at the legacy path; do not silently
1451
+ // install the package version at the canonical target path.
1452
+ blockedTargetPaths.add(targetPath);
1402
1453
  continue;
1403
1454
  }
1404
1455
  const targetAbsolute = path.join(input.repoRoot, targetPath);
@@ -1442,6 +1493,7 @@ async function collectRetiredLayoutActions(input) {
1442
1493
  path: oldPath,
1443
1494
  reason: `legacy init file is owned, but target path ${targetPath} already exists with different content`,
1444
1495
  });
1496
+ blockedTargetPaths.add(targetPath);
1445
1497
  }
1446
1498
  for (const legacyRoot of ["docs", "skills"]) {
1447
1499
  for (const relativePath of await listRelativeFiles(path.join(input.repoRoot, legacyRoot))) {
@@ -1476,6 +1528,7 @@ async function collectRetiredLayoutActions(input) {
1476
1528
  path: oldPath,
1477
1529
  reason: `legacy init-shaped file is not recorded and differs from the current bundled/generated asset; review before migrating to ${targetPath}`,
1478
1530
  });
1531
+ blockedTargetPaths.add(targetPath);
1479
1532
  continue;
1480
1533
  }
1481
1534
  if (!targetHash) {
@@ -1492,6 +1545,7 @@ async function collectRetiredLayoutActions(input) {
1492
1545
  path: oldPath,
1493
1546
  reason: `legacy init-shaped file matches the package, but target path ${targetPath} already exists with different content`,
1494
1547
  });
1548
+ blockedTargetPaths.add(targetPath);
1495
1549
  }
1496
1550
  }
1497
1551
  for (const dir of collectSafeRetiredDirectories(retiredOwnedPaths)) {
@@ -1501,7 +1555,7 @@ async function collectRetiredLayoutActions(input) {
1501
1555
  reason: "remove directory if it is empty after legacy init files are migrated",
1502
1556
  });
1503
1557
  }
1504
- return actions;
1558
+ return { actions, blockedTargetPaths };
1505
1559
  }
1506
1560
  async function resolveInitProjectContext(input) {
1507
1561
  const harness = await readJsonIfExists(path.join(input.repoRoot, "harness.json"));
@@ -1771,6 +1825,13 @@ function buildTargetFeatureWorkflow(input) {
1771
1825
  "",
1772
1826
  "Before executing a DAG, review profile routing, governance profile, writer writeSet, allowed paths, forbidden paths, shell verification, and decision gate mode.",
1773
1827
  "",
1828
+ "## Specialized Task Kinds",
1829
+ "",
1830
+ "Set `taskKind` in `.harness/tasks/<task-id>/task.json` to route a task to a dedicated DAG profile instead of the default auto profile:",
1831
+ "",
1832
+ "- `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.",
1833
+ "- 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`.",
1834
+ "",
1774
1835
  "## Verification",
1775
1836
  "",
1776
1837
  `Use \`${input.governanceRoot}/verification-matrix.md\` to choose the narrowest command that proves the claim.`,
@@ -2169,7 +2230,7 @@ export async function checkInitUpdate(input) {
2169
2230
  const deterministicActions = [];
2170
2231
  const modelMergeTasks = [];
2171
2232
  const humanDecisions = [];
2172
- const retiredLayoutActions = await collectRetiredLayoutActions({
2233
+ const retiredLayoutResult = await collectRetiredLayoutActions({
2173
2234
  assetRoot,
2174
2235
  repoRoot,
2175
2236
  projectName,
@@ -2178,9 +2239,15 @@ export async function checkInitUpdate(input) {
2178
2239
  currentState,
2179
2240
  humanDecisions,
2180
2241
  });
2242
+ const retiredLayoutActions = retiredLayoutResult.actions;
2181
2243
  const retiredTargetPaths = new Set(retiredLayoutActions
2182
2244
  .filter((action) => action.type === "migrate-owned-file" && action.targetPath)
2183
2245
  .map((action) => action.targetPath));
2246
+ // Targets occupied by a user-modified legacy copy must not be silently
2247
+ // (re)installed from the package; preserve them for a human/model merge.
2248
+ for (const blockedPath of retiredLayoutResult.blockedTargetPaths) {
2249
+ retiredTargetPaths.add(blockedPath);
2250
+ }
2184
2251
  if (!recordedState) {
2185
2252
  deterministicActions.push({
2186
2253
  type: "bootstrap-surface",
@@ -2,7 +2,9 @@ import { createHash } from "node:crypto";
2
2
  import { readFileSync, readdirSync, statSync } from "node:fs";
3
3
  import { realpathSync } from "node:fs";
4
4
  import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
5
6
  import { platform } from "node:os";
7
+ export const LOOP_AGENT_PACKAGE_NAME = "@tea-agent/loop-agent";
6
8
  // ---------------------------------------------------------------------------
7
9
  // Public helpers
8
10
  // ---------------------------------------------------------------------------
@@ -351,3 +353,136 @@ function assertRegularFile(filePath, requested) {
351
353
  }
352
354
  throw new Error(`controller identity: resolved executable for ${JSON.stringify(requested)} is not a readable file: ${filePath}`);
353
355
  }
356
+ // ---------------------------------------------------------------------------
357
+ // Running-controller identity (in-process capture)
358
+ // ---------------------------------------------------------------------------
359
+ function safeRealpath(filePath) {
360
+ try {
361
+ return realpathSync(filePath);
362
+ }
363
+ catch {
364
+ return filePath;
365
+ }
366
+ }
367
+ /**
368
+ * Build a ControllerIdentityV1 from a readable entry file inside a Node package
369
+ * root. Returns `undefined` when any required identity component cannot be
370
+ * resolved. This reuses the same ControllerIdentityV1 protocol the Worker uses;
371
+ * it never invents a second identity shape.
372
+ */
373
+ export function buildControllerIdentityFromEntry(input) {
374
+ const resolvedAt = (input.now ?? new Date()).toISOString();
375
+ const realEntry = safeRealpath(path.resolve(input.entry));
376
+ try {
377
+ if (!statSync(realEntry).isFile())
378
+ return undefined;
379
+ }
380
+ catch {
381
+ return undefined;
382
+ }
383
+ const packageRoot = findPackageRoot(path.dirname(realEntry));
384
+ if (!packageRoot)
385
+ return undefined;
386
+ const packageName = readPackageName(packageRoot);
387
+ if (!packageName)
388
+ return undefined;
389
+ const packageVersion = readPackageVersion(packageRoot);
390
+ if (!packageVersion)
391
+ return undefined;
392
+ const binarySha256 = computeBinarySha256(realEntry);
393
+ if (!binarySha256)
394
+ return undefined;
395
+ let packageFingerprint;
396
+ try {
397
+ packageFingerprint = computePackageFingerprint(packageRoot);
398
+ }
399
+ catch {
400
+ return undefined;
401
+ }
402
+ const isScript = isJavaScriptScript(realEntry);
403
+ const launch = isScript
404
+ ? { command: safeRealpath(process.execPath), argsPrefix: [realEntry] }
405
+ : { command: realEntry, argsPrefix: [] };
406
+ return {
407
+ schemaVersion: 1,
408
+ packageName,
409
+ binName: path.basename(realEntry),
410
+ requested: input.requested,
411
+ entry: path.resolve(input.entry),
412
+ realEntry,
413
+ launch,
414
+ binarySha256,
415
+ packageRoot,
416
+ packageVersion,
417
+ packageFingerprint,
418
+ resolvedAt,
419
+ };
420
+ }
421
+ /**
422
+ * Stable content anchor for a controller identity. Two identities are the same
423
+ * controller when their anchors are equal. Kept in sync with the Worker anchor
424
+ * so DAG-owned and Worker-owned identity facts are comparable.
425
+ */
426
+ export function controllerIdentityAnchor(identity) {
427
+ return JSON.stringify({
428
+ schemaVersion: identity.schemaVersion,
429
+ packageName: identity.packageName,
430
+ binName: identity.binName,
431
+ requested: identity.requested,
432
+ entry: identity.entry,
433
+ realEntry: identity.realEntry,
434
+ launch: identity.launch,
435
+ binarySha256: identity.binarySha256,
436
+ packageRoot: identity.packageRoot,
437
+ packageVersion: identity.packageVersion,
438
+ packageFingerprint: identity.packageFingerprint,
439
+ });
440
+ }
441
+ export function controllerIdentitiesMatch(left, right) {
442
+ return Boolean(left && right && controllerIdentityAnchor(left) === controllerIdentityAnchor(right));
443
+ }
444
+ /**
445
+ * Resolve the identity of the controller that is executing this process.
446
+ *
447
+ * Preference order:
448
+ * 1. The launched CLI entry (argv[1]) when it resolves inside the loop-agent
449
+ * package — this is the real controller binary in production.
450
+ * 2. The package that physically contains this module — always the loop-agent
451
+ * package, which keeps identity resolvable under tests and source runs.
452
+ *
453
+ * Returns `undefined` when no loop-agent-rooted identity can be built. Callers
454
+ * treat `undefined` as legacy-unpinned rather than fabricating an identity.
455
+ */
456
+ export function resolveRunningControllerIdentity(options) {
457
+ const argv = options?.argv ?? process.argv;
458
+ const entryArg = argv[1];
459
+ if (entryArg) {
460
+ try {
461
+ const abs = path.resolve(entryArg);
462
+ const root = findPackageRoot(path.dirname(abs));
463
+ if (root && readPackageName(root) === LOOP_AGENT_PACKAGE_NAME) {
464
+ const identity = buildControllerIdentityFromEntry({
465
+ requested: entryArg,
466
+ entry: abs,
467
+ ...(options?.now ? { now: options.now } : {}),
468
+ });
469
+ if (identity)
470
+ return identity;
471
+ }
472
+ }
473
+ catch {
474
+ // Fall back to the module location below.
475
+ }
476
+ }
477
+ try {
478
+ const moduleFile = fileURLToPath(import.meta.url);
479
+ return buildControllerIdentityFromEntry({
480
+ requested: entryArg ?? "loop-agent",
481
+ entry: moduleFile,
482
+ ...(options?.now ? { now: options.now } : {}),
483
+ });
484
+ }
485
+ catch {
486
+ return undefined;
487
+ }
488
+ }
@@ -11,6 +11,7 @@ export const taskKindSchema = z.enum([
11
11
  "standard",
12
12
  "feature-study",
13
13
  "frontend-implementation",
14
+ "backend-test",
14
15
  ]);
15
16
  export const referenceRepoConfigSchema = z.object({
16
17
  name: z.string().min(1),
@@ -326,7 +326,8 @@ export function buildAgentWorkerProgram() {
326
326
  .command("serve")
327
327
  .requiredOption("--repo <repo-root>", "Target repo root")
328
328
  .option("--port <port>", "HTTP port", "8787")
329
- .option("--host <host>", "Bind host", "127.0.0.1")
329
+ .option("--host <host>", "Bind host (default 127.0.0.1; use 0.0.0.0 for LAN, no auth)", "127.0.0.1")
330
+ .option("--debug", "Log request source/method/path/status to stderr")
330
331
  .description("Start read-only observe HTTP server")
331
332
  .action(async (options) => {
332
333
  const repoRoot = path.resolve(options.repo);
@@ -334,6 +335,7 @@ export function buildAgentWorkerProgram() {
334
335
  repoRoot,
335
336
  host: options.host,
336
337
  port: Number.parseInt(options.port, 10),
338
+ debug: options.debug === true,
337
339
  });
338
340
  process.stdout.write(`${server.url}\n`);
339
341
  await new Promise((resolve) => {