@tea-agent/loop-agent 0.33.7-beta.0 → 0.34.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 (83) hide show
  1. package/CHANGELOG.md +38 -17
  2. package/dist/application/task-lifecycle/advance.js +254 -4
  3. package/dist/application/task-lifecycle/gates.js +50 -0
  4. package/dist/application/task-lifecycle/observe.js +11 -2
  5. package/dist/commands/init-upgrade.js +32 -1
  6. package/dist/commands/init.js +94 -3
  7. package/dist/executors/shell-executor.js +4 -91
  8. package/dist/executors/shell-write-guard.js +26 -8
  9. package/dist/shared/operator/capabilities.js +72 -42
  10. package/dist/shared/resilient-git.js +133 -0
  11. package/dist/task/source-prepare/artifact-meta.js +137 -0
  12. package/dist/task/source-prepare/index.js +2 -0
  13. package/dist/task/source-prepare/parse-intent.js +58 -10
  14. package/dist/task/source-prepare/prepare.js +180 -16
  15. package/dist/task/source-prepare/reference-integrity.js +18 -2
  16. package/dist/task/source-prepare/semantic-intake.js +404 -0
  17. package/dist/worker/console/app-data.js +2 -0
  18. package/dist/worker/console/chat/chat-event-store.js +190 -25
  19. package/dist/worker/console/chat/model-resolver.js +17 -0
  20. package/dist/worker/console/chat/pi-console-config.js +250 -32
  21. package/dist/worker/console/chat/pi-runtime.js +1007 -188
  22. package/dist/worker/console/chat/resource-loader.js +5 -4
  23. package/dist/worker/console/chat/routes.js +495 -157
  24. package/dist/worker/console/chat/runtime-context.js +48 -12
  25. package/dist/worker/console/chat/runtime-selection.js +59 -0
  26. package/dist/worker/console/chat/session-store.js +39 -0
  27. package/dist/worker/console/chat/shortcuts.js +1 -0
  28. package/dist/worker/console/chat/tool-adapter.js +9 -3
  29. package/dist/worker/console/chat/tools.js +5 -1
  30. package/dist/worker/console/dag-execution-receipt.js +380 -0
  31. package/dist/worker/console/operator-actions.js +559 -68
  32. package/dist/worker/console/server.js +8 -15
  33. package/dist/worker/console/static/assets/index-BQkhJpV8.css +1 -0
  34. package/dist/worker/console/static/assets/index-CMHovlqG.js +32 -0
  35. package/dist/worker/console/static/index.html +2 -2
  36. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +45 -8
  37. package/dist/worker/console/static-src/operator-chat/landing-density.js +23 -0
  38. package/dist/worker/console/static-src/operator-chat/refs.js +9 -0
  39. package/dist/worker/console/static-src/operator-chat/runtime-snapshot-store.js +257 -0
  40. package/dist/worker/console/static-src/operator-chat/session-title-watcher.js +128 -0
  41. package/dist/worker/console/static-src/operator-chat/sidebar-split.js +90 -0
  42. package/dist/worker/console/static-src/operator-chat/spatial-overlay.js +37 -0
  43. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +125 -22
  44. package/dist/worker/console/static-src/operator-chat/useChatStream.js +215 -184
  45. package/dist/worker/console/static-src/operator-chat/useChatThread.js +49 -5
  46. package/dist/worker/console/static-src/operator-chat/useComposer.js +17 -0
  47. package/dist/worker/console/static-src/operator-chat/useOverlayFocus.js +84 -0
  48. package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +225 -74
  49. package/dist/worker/console/static-src/operator-chat/useRuntimeSnapshot.js +196 -0
  50. package/dist/worker/console/static-src/operator-chat/useWorkspaceLayout.js +58 -0
  51. package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +31 -0
  52. package/dist/worker/delivery/final-verification.js +13 -5
  53. package/dist/worker/delivery/package.js +31 -19
  54. package/dist/worker/delivery/verification-bundle.js +6 -4
  55. package/dist/worker/observe/static/operator-chrome.css +5 -2
  56. package/dist/worker/observe/static/operator-chrome.js +6 -1
  57. package/dist/worker/observe/static/styles.css +39 -9
  58. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +33 -462
  59. package/dist/workflows/dag/backend-test-case-manifest.js +0 -4
  60. package/dist/workflows/dag/backend-test-markdown-workflow.js +1 -25
  61. package/dist/workflows/dag/backend-test-module-stem.js +0 -5
  62. package/dist/workflows/dag/backend-test-pytest-collection.js +24 -345
  63. package/dist/workflows/dag/backend-test-scenario-param.js +82 -269
  64. package/dist/workflows/dag/backend-test-writer-completeness.js +16 -47
  65. package/dist/workflows/dag/dynamic-runtime/map.js +8 -24
  66. package/dist/workflows/dag/frontend-worktree-diff.js +12 -27
  67. package/dist/workflows/dag/init-hybrid.js +34 -46
  68. package/dist/workflows/dag/types.js +0 -7
  69. package/dist/workflows/dag/workspace-checkpoint.js +8 -27
  70. package/docs/templates/backend-test-dag.json +29 -32
  71. package/harness.json +1 -1
  72. package/package.json +1 -1
  73. package/skills/local-jacoco-coverage/SKILL.md +281 -0
  74. package/skills/local-jacoco-coverage/references/requirement-to-source-mapping.md +85 -0
  75. package/skills/local-jacoco-coverage/references/runtime-alignment.md +106 -0
  76. package/skills/local-jacoco-coverage/scripts/run-coverage-analysis.sh +148 -0
  77. package/skills/local-jacoco-coverage/scripts/start-jacoco-agent.sh +110 -0
  78. package/skills/loop-agent/references/command-reference.md +3 -1
  79. package/skills/loop-agent/references/source-and-plan-practice.md +13 -0
  80. package/skills/loop-agent/references/task-workflow.md +4 -0
  81. package/dist/worker/console/chat/instruction-skills.js +0 -217
  82. package/dist/worker/console/static/assets/index-CnUXAqxG.css +0 -1
  83. package/dist/worker/console/static/assets/index-CteJFFL2.js +0 -29
package/CHANGELOG.md CHANGED
@@ -2,38 +2,59 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
- ## [0.33.7-beta.0] - 2026-08-11
5
+ ## [0.34.0] - 2026-08-11
6
6
 
7
7
  ### 重点更新
8
8
 
9
- - backend-test 收口 source-fidelity P0:绑定 source coverage、有界 Markdown/pytest 同步、item-level eligibility 与单次 eligible 业务执行;my-webapp R107 16 节点 finished
9
+ - Operator Chat 会话创建实现即时响应,Landing 页面与创建请求延迟从秒级降至毫秒级
10
+ - 新增桌面端紧凑响应式布局,根据工作区宽度自动适配 comfortable/compact/narrow 三档显示模式
11
+ - 新增自动会话标题功能,基于首条用户消息生成临时标题,并在首轮对话完成后异步生成正式标题
12
+ - 新增 local-jacoco-coverage 技能,将本地 Java 服务挂载、测试执行与覆盖率切片分析串成标准化链路
10
13
 
11
14
  ### 新增
12
15
 
13
- - backend-test 新增绑定 source references 的来源覆盖事实、Markdown 一轮增量同步和 pytest correspondence 一轮有界修复,在业务测试体执行前保留 initial/effective/residual 证据
14
- - backend-test 新增 item-level execution eligibility:只授权 payload-safe 的 pytest items,excluded items 与真实业务失败分开展示
16
+ - Operator Chat 桌面紧凑响应式布局:按工作区宽度切换 comfortable(≥1450px)/ compact(1100–1449px)/ narrow(<1100px)三档;compact 档使用图标窄栏与覆盖抽屉,narrow 档使用顶栏入口;会话与仓库面板支持拖拽分栏并记忆比例
17
+ - Operator Chat 自动会话标题:首条用户消息持久化后生成清洗有界的临时标题,首轮对话成功完成后异步生成正式标题;手工重命名、归档和删除操作可拒绝迟到的自动标题覆盖
18
+ - local-jacoco-coverage 技能:将本地启动 Java 服务挂载 jacocoagent、运行 backend-test DAG、按需求文档做覆盖率切片分析三步串联为可审计、可重复的本地链路,包含强制流程手册、runtime 对齐参考、需求映射规范与编排脚本
15
19
 
16
20
  ### 改进
17
21
 
18
- - backend-test Markdown/pytest map 的默认总 token 预算从 `600000` 提升到 `3000000`,降低多模块生成时过早触发 `dynamic map token budget exhausted` 的概率;仍保持预算耗尽 fail-closed
19
- - Coverage Matrix 接受 `R400001` 等错误码 Rule Key,并保留单元格内 `ACTIVE|ARCHIVED` 未转义枚举管道符,避免合法行被拆成 10 列后整表丢弃
20
- - scenario-param TP 精确匹配多行 `场景意图`,保留 `ACTIVE` 大小写字面量,识别 `"a" * N` 长度表达式与空白 `empty`,request-level `nominal` 允许 `None` 查询过滤;避免把合法 payload 误判为 MISMATCH 并错误排除 eligible items
21
- - backend-test 支持 evidence-only 元用例:Markdown 显式声明 `脚本/primary symbol=无` 且仅绑定 assertion/cross-cutting TP 时,不再强制 1:1 pytest 符号,也不再把其算作 MISSING_PYTEST/SCRIPT_MISMATCH
22
- - generated pytest 内的 `ScopeMismatch`/setup fixture 失败若完全归因于 `testcase/**` 映射脚本,则归为 REPAIRABLE 并进入唯一一轮 collection repair,不再直接 BLOCKED
23
- - scenario-param 观测跳过 `pytest.param("TP-...", value, id="TP-...")` 的前导 TP 标签,并支持长度意图用数字位置参数表达
22
+ - Operator Chat 会话创建改为即时响应:先落盘会话骨架与持久记录后立即返回成功,完整 AgentSession 在后台异步初始化,真实 Chrome 测试中 Landing 延迟从 1346ms 降至 22ms、创建请求从 1269ms 降至 16ms
23
+ - 会话创建期间侧栏与 Landing 页共享 busy 状态反馈,抑制双击导致的重复创建
24
+ - 删除正在初始化的会话不会被迟到的后台初始化任务复活,初始化失败统一返回 HTTP 503 错误码
24
25
 
25
- ### 修复
26
+ ## [0.33.6] - 2026-08-11
27
+
28
+ ### 新增
26
29
 
27
- - 修复 readiness 错误信息截断与无效 Coverage Matrix 状态语义,避免超长错误或 advisory 矩阵状态误阻断后续节点
28
- - 修复 scenario 排除项未绑定 payload 字段、request-level 不确定性被误升级为硬失败的问题
30
+ - stabilize PRD intake + add Semantic Intake fallback
31
+ - Operator Chat 单事件流 + boot epoch fence(Task C)
32
+ - gate writeSet delta + run summary(操作面增强)
33
+ - supervise DAG runs autonomously
34
+ - ChatEventStore 事件持久化压缩(coalescing + maxBytes 双限制)
35
+ - reconcile(cursor_expired) 自动恢复而非全局致命错误
36
+ - 接入 Operator Chat 实时运行时控制
37
+ - open operator chat resource loading
38
+ - enrich execution report and URL resolution
39
+
40
+ ### 改进
29
41
 
30
- ## [0.33.6-beta.0] - 2026-08-11
42
+ - style(task-advance): biome autoformat 对齐(无功能变化)
43
+ - style(console): apply biome formatting from pi-lens autoformat
31
44
 
32
45
  ### 修复
33
46
 
34
- - 修复 backend-test correspondence scanner 将下一 Case 的分隔注释误归入前一个 pytest function region,造成虚假的 `MULTIPLE_PYTEST`、script mismatch 和额外 Test Point;现在按函数实际缩进边界隔离 body,并只从精确 `Case-ID:` 元数据或 primary symbol 绑定 Case
35
- - 修复 backend-test README 其他区域的 Markdown 链接误提取为业务模块、允许 `a401606` 等 opaque/hash-like stem 和异常多模块进入 map 的问题;现在只解析唯一 `## Module Index`,非法、mixed 或超过 8 个模块时 README-only 有界重试并由 manifest fail-closed
36
- - 修复 backend-test map 总 token 预算耗尽后只生成部分 Markdown/pytest 资产但 parent 仍可能显示成功的问题;budget-blocked child 现在使 backend map 明确 ERROR,并保留 expected/finished/blocked/token 事实
47
+ - unify resilient read-only command execution
48
+ - preserve user-owned Pi model routing
49
+ - show installed package metadata
50
+ - escape preflight backtick matcher
51
+ - align chat streaming reliability with pi-web
52
+ - split rerun defaults and repair selector escaping
53
+ - ignore delimiters inside pytest strings
54
+ - resolve target URL from runtime context
55
+ - reject priority-only module stems
56
+ - stop desiredSha256 mechanical checks from blocking generated-content upgrades
57
+ - use mobius header brand icon
37
58
 
38
59
  ## [0.33.5] - 2026-08-11
39
60
 
@@ -3,6 +3,9 @@ import path from "node:path";
3
3
  import { createTask, getTaskPaths, loadTaskConfig, } from "../../task/runtime.js";
4
4
  import { importPrdDocument } from "../../task/source-references.js";
5
5
  import { prepareTaskSource } from "../../task/source-prepare/index.js";
6
+ import { isIntakeSoftGapCode, resolvePrdImportRole, } from "../../task/source-prepare/artifact-meta.js";
7
+ import { evaluateSemanticIntakeEligibility, isSemanticIntakeDisabled, readSemanticIntakeAttempted, runSemanticIntake, } from "../../task/source-prepare/semantic-intake.js";
8
+ import { validateImportedPrdReferences } from "../../task/source-prepare/reference-integrity.js";
6
9
  import { logicalRevisionForState, observeTaskContractState, recoverTaskContract, } from "../../task/contract/index.js";
7
10
  import { generateTaskDagUseCase } from "../dag/generate-task-dag.js";
8
11
  import { validateDagUseCase } from "../dag/validate-dag.js";
@@ -10,7 +13,7 @@ import { runDagUseCase } from "../dag/run-dag.js";
10
13
  import { promoteRunEvidence, closeoutTask } from "../../records/promotion.js";
11
14
  import { resolveRunningControllerIdentity } from "../../shared/package-metadata.js";
12
15
  import { assessDagRunLiveness, locateDagRun, readDagRunState, } from "../../workflows/dag/lifecycle.js";
13
- import { buildGateBinding, buildWriteSetGate, controllerIdentityAnchor, gateMatches, hashCanonicalPayload, parseGateToken, } from "./gates.js";
16
+ import { buildGateBinding, buildWriteSetGate, buildWriteSetGateDelta, controllerIdentityAnchor, gateMatches, hashCanonicalPayload, parseGateToken, } from "./gates.js";
14
17
  import { appendTransition, emptyLifecycleRecord, loadLifecycleRecord, recordRunAssociation, saveLifecycleRecord, } from "./record.js";
15
18
  import { collectNormalizedWriteSetFromSpec, hashDagSpecFileContent, observeTaskLifecycle, } from "./observe.js";
16
19
  import { planTransitions, } from "./plan-transitions.js";
@@ -98,6 +101,42 @@ function toRunAssociation(input) {
98
101
  source: input.source ?? "lifecycle-record",
99
102
  };
100
103
  }
104
+ /** Compact run completion summary for the advance result (AC-005/AC-006):
105
+ * per-node status, first VERDICT line found in any node stdout, and nodes
106
+ * flagged with a write-guard / out-of-bounds failure category. */
107
+ function buildRunSummary(state) {
108
+ const nodes = Object.entries(state.nodes ?? {}).map(([id, node]) => ({
109
+ id,
110
+ status: node.status,
111
+ }));
112
+ const ordered = (state.ranks ?? []).flat();
113
+ nodes.sort((a, b) => {
114
+ const ai = ordered.indexOf(a.id);
115
+ const bi = ordered.indexOf(b.id);
116
+ return ((ai === -1 ? ordered.length : ai) - (bi === -1 ? ordered.length : bi));
117
+ });
118
+ let verdict = null;
119
+ const outOfBounds = [];
120
+ for (const [id, node] of Object.entries(state.nodes ?? {})) {
121
+ if (!verdict && typeof node.stdout === "string") {
122
+ const match = node.stdout.match(/^\s*VERDICT:\s*(.+)$/im);
123
+ if (match)
124
+ verdict = match[1].trim();
125
+ }
126
+ if (node.failureCategory &&
127
+ /^write-guard|out-of-bounds|write-set|boundary/i.test(node.failureCategory)) {
128
+ outOfBounds.push({ nodeId: id, failureCategory: node.failureCategory });
129
+ }
130
+ }
131
+ return {
132
+ runId: state.runId,
133
+ status: state.status,
134
+ nodes,
135
+ ...(verdict ? { verdict } : { verdict: null }),
136
+ ...(outOfBounds.length > 0 ? { outOfBounds } : {}),
137
+ failureCategory: state.failureCategory ?? null,
138
+ };
139
+ }
101
140
  async function buildCurrentGate(repoRoot, taskId) {
102
141
  const paths = getTaskPaths(repoRoot, taskId);
103
142
  if (!(await pathExists(paths.dagDraftPath)))
@@ -113,6 +152,7 @@ async function buildCurrentGate(repoRoot, taskId) {
113
152
  const contractState = await observeTaskContractState(repoRoot, taskId);
114
153
  const identity = resolveRunningControllerIdentity();
115
154
  let writeSet = collectNormalizedWriteSetFromSpec(spec);
155
+ let contractAllowedPaths = [];
116
156
  if (writeSet.length === 0) {
117
157
  try {
118
158
  const config = await loadTaskConfig(repoRoot, taskId);
@@ -122,6 +162,18 @@ async function buildCurrentGate(repoRoot, taskId) {
122
162
  // leave empty
123
163
  }
124
164
  }
165
+ else {
166
+ try {
167
+ const config = await loadTaskConfig(repoRoot, taskId);
168
+ contractAllowedPaths = config.allowedPaths ?? [];
169
+ }
170
+ catch {
171
+ // leave empty
172
+ }
173
+ }
174
+ const writers = spec.tasks
175
+ .filter((task) => task.writePolicy === "exclusive")
176
+ .map((task) => ({ id: task.id, writeSet: task.writeSet ?? [] }));
125
177
  const bound = buildGateBinding({
126
178
  taskId,
127
179
  contractRevision: logicalRevisionForState(contractState),
@@ -141,7 +193,12 @@ async function buildCurrentGate(repoRoot, taskId) {
141
193
  }
142
194
  : null),
143
195
  });
144
- return buildWriteSetGate({ taskId, bound });
196
+ const delta = buildWriteSetGateDelta({
197
+ writeSet,
198
+ contractAllowedPaths,
199
+ writers,
200
+ });
201
+ return buildWriteSetGate({ taskId, bound, delta });
145
202
  }
146
203
  async function monitorRun(input) {
147
204
  const started = Date.now();
@@ -213,6 +270,7 @@ export async function advanceTaskLifecycle(input) {
213
270
  let promotion = null;
214
271
  let closeout = null;
215
272
  let run = null;
273
+ let runSummary = null;
216
274
  let consumeApproveGate = Boolean(input.approveGateToken);
217
275
  /** After a successful approve, keep approve intent for finalize replan only. */
218
276
  let approvedThisCall = false;
@@ -226,6 +284,7 @@ export async function advanceTaskLifecycle(input) {
226
284
  let preparedContractThisCall = false;
227
285
  /** A successful managed-contract mutation invalidates the prior DAG/gate. */
228
286
  let regenerateDagThisCall = false;
287
+ let semanticIntakeAttemptedThisCall = false;
229
288
  let snapshot = await observeTaskLifecycle(input.repoRoot, input.taskId);
230
289
  let record = (await loadLifecycleRecord(input.repoRoot, input.taskId)) ??
231
290
  emptyLifecycleRecord(input.taskId);
@@ -457,11 +516,25 @@ export async function advanceTaskLifecycle(input) {
457
516
  }
458
517
  if (action.type === "import-prd") {
459
518
  for (const prd of input.prdPaths ?? []) {
519
+ let markdown;
520
+ try {
521
+ markdown = await readFile(path.resolve(prd.filePath), "utf-8");
522
+ }
523
+ catch {
524
+ markdown = undefined;
525
+ }
526
+ const role = resolvePrdImportRole({
527
+ filePath: prd.filePath,
528
+ explicitRole: prd.role,
529
+ markdown,
530
+ });
460
531
  await importPrdDocument({
461
532
  repoRoot: input.repoRoot,
462
533
  taskId: input.taskId,
463
534
  filePath: prd.filePath,
464
- role: prd.role,
535
+ role,
536
+ // Prefer basename-aligned names for multi-file product suites.
537
+ name: path.basename(prd.filePath).replace(/\.(md|markdown|txt)$/iu, "") || undefined,
465
538
  });
466
539
  }
467
540
  // Latch even when prdPaths is empty so a mis-planned import cannot loop.
@@ -494,7 +567,7 @@ export async function advanceTaskLifecycle(input) {
494
567
  break;
495
568
  }
496
569
  }
497
- const prepareResult = await prepareTaskSource({
570
+ let prepareResult = await prepareTaskSource({
498
571
  repoRoot: input.repoRoot,
499
572
  taskId: input.taskId,
500
573
  mode: "apply",
@@ -520,12 +593,153 @@ export async function advanceTaskLifecycle(input) {
520
593
  },
521
594
  });
522
595
  if (!prepareResult.ok) {
596
+ const gapCodes = (prepareResult.gaps ?? [])
597
+ .filter((gap) => gap.level === "blocking")
598
+ .map((gap) => gap.code);
599
+ const softIntake = Boolean(prepareResult.error?.details?.softIntake) ||
600
+ (gapCodes.length > 0 &&
601
+ gapCodes.every((code) => isIntakeSoftGapCode(code)));
602
+ // P2: one-shot semantic intake when structure failed but sources exist.
603
+ if (softIntake &&
604
+ (completed.includes("prd-imported") ||
605
+ (snapshot.source?.importedPrdCount ?? 0) > 0) &&
606
+ !semanticIntakeAttemptedThisCall) {
607
+ const already = await readSemanticIntakeAttempted(input.repoRoot, input.taskId);
608
+ const eligibility = evaluateSemanticIntakeEligibility({
609
+ hasImportedPrd: true,
610
+ gaps: prepareResult.gaps ?? [],
611
+ alreadyAttempted: already,
612
+ disabled: isSemanticIntakeDisabled(),
613
+ });
614
+ if (eligibility.eligible) {
615
+ semanticIntakeAttemptedThisCall = true;
616
+ warnings.push({
617
+ code: "SEMANTIC_INTAKE_STARTING",
618
+ message: eligibility.reason,
619
+ });
620
+ const integrity = await validateImportedPrdReferences({
621
+ repoRoot: input.repoRoot,
622
+ taskId: input.taskId,
623
+ requireParseableRequirement: false,
624
+ });
625
+ const documents = integrity.documents
626
+ .filter((doc) => doc.content)
627
+ .map((doc) => ({
628
+ role: doc.role,
629
+ materializedPath: doc.materializedPath,
630
+ absolutePath: doc.absolutePath,
631
+ content: doc.content,
632
+ }));
633
+ const semantic = await runSemanticIntake({
634
+ repoRoot: input.repoRoot,
635
+ taskId: input.taskId,
636
+ title: input.title ?? prepareResult.title ?? input.taskId,
637
+ taskKind: input.taskKind ?? prepareResult.taskKind,
638
+ featureId: input.featureId,
639
+ documents,
640
+ flags: {
641
+ title: input.title,
642
+ taskKind: input.taskKind,
643
+ featureId: input.featureId,
644
+ allowedPaths: effectiveInput.allowedPaths,
645
+ forbiddenPaths: input.forbiddenPaths,
646
+ verifyCommands: effectiveInput.verifyCommands,
647
+ },
648
+ });
649
+ if (semantic.ok) {
650
+ const retry = await prepareTaskSource({
651
+ repoRoot: input.repoRoot,
652
+ taskId: input.taskId,
653
+ mode: "apply",
654
+ intent: {
655
+ kind: "draft",
656
+ draft: semantic.draft,
657
+ sourcePath: semantic.artifactPath,
658
+ },
659
+ flags: {
660
+ title: input.title,
661
+ taskKind: input.taskKind,
662
+ featureId: input.featureId,
663
+ allowedPaths: effectiveInput.allowedPaths,
664
+ forbiddenPaths: input.forbiddenPaths,
665
+ verifyCommands: effectiveInput.verifyCommands,
666
+ },
667
+ });
668
+ if (retry.ok) {
669
+ warnings.push({
670
+ code: "SEMANTIC_INTAKE_APPLIED",
671
+ message: "structured requirement draft via semantic intake; projected managed source",
672
+ });
673
+ preparedContractThisCall = true;
674
+ if (refreshManagedContractThisCall || input.fromDraftPath) {
675
+ refreshManagedContractThisCall = false;
676
+ regenerateDagThisCall = true;
677
+ }
678
+ record = appendTransition(record, "contract-managed");
679
+ completed.push("contract-managed");
680
+ await saveLifecycleRecord(input.repoRoot, record);
681
+ snapshot = await observeTaskLifecycle(input.repoRoot, input.taskId);
682
+ continue;
683
+ }
684
+ prepareResult = retry;
685
+ // fall through with retry gaps
686
+ }
687
+ else {
688
+ warnings.push({
689
+ code: semantic.code,
690
+ message: semantic.message,
691
+ });
692
+ }
693
+ }
694
+ else if (!already) {
695
+ warnings.push({
696
+ code: "SEMANTIC_INTAKE_SKIPPED",
697
+ message: eligibility.reason,
698
+ });
699
+ }
700
+ }
701
+ // Soft intake: PRD may already be imported; stop before DAG without
702
+ // treating the whole advance as a hard parse failure.
703
+ const gapCodesAfter = (prepareResult.gaps ?? [])
704
+ .filter((gap) => gap.level === "blocking")
705
+ .map((gap) => gap.code);
706
+ const softIntakeAfter = Boolean(prepareResult.error?.details?.softIntake) ||
707
+ (gapCodesAfter.length > 0 &&
708
+ gapCodesAfter.every((code) => isIntakeSoftGapCode(code)));
709
+ if (softIntakeAfter && completed.includes("prd-imported")) {
710
+ warnings.push({
711
+ code: "INTAKE_INCOMPLETE",
712
+ message: prepareResult.error?.message ??
713
+ "PRD imported but intake incomplete; DAG not generated",
714
+ });
715
+ for (const gap of prepareResult.gaps ?? []) {
716
+ if (gap.level === "blocking") {
717
+ warnings.push({ code: gap.code, message: gap.message });
718
+ }
719
+ }
720
+ snapshot = await observeTaskLifecycle(input.repoRoot, input.taskId);
721
+ return {
722
+ taskId: input.taskId,
723
+ lifecycleState: "intake-incomplete",
724
+ completedTransitions: uniqueTransitions(record.completedTransitions, completed),
725
+ gate: null,
726
+ blockers: [],
727
+ warnings,
728
+ next: {
729
+ kind: "advance",
730
+ command: prepareResult.next?.[0] ??
731
+ `loop-agent task advance ${input.taskId} --prd <product-requirement.md> --allowed-path <glob> --json`,
732
+ },
733
+ recommendations,
734
+ };
735
+ }
523
736
  blockers.push({
524
737
  code: prepareResult.error?.code ?? "PREPARE_FAILED",
525
738
  message: prepareResult.error?.message ?? "task source prepare failed",
526
739
  details: {
527
740
  gaps: prepareResult.gaps,
528
741
  outcome: prepareResult.outcome,
742
+ softIntake: softIntakeAfter,
529
743
  },
530
744
  });
531
745
  break;
@@ -814,6 +1028,22 @@ export async function advanceTaskLifecycle(input) {
814
1028
  }
815
1029
  await saveLifecycleRecord(input.repoRoot, record);
816
1030
  }
1031
+ // Build the compact run summary (AC-005/AC-006) from the settled run
1032
+ // state so the operator sees node statuses/verdict/outOfBounds without
1033
+ // reading state.json. Best-effort: skip when the run dir is unavailable.
1034
+ if (run?.runDir) {
1035
+ try {
1036
+ const settledState = await readDagRunState(run.runDir);
1037
+ const summary = buildRunSummary(settledState);
1038
+ if (summary) {
1039
+ summary.next = snapshot.next;
1040
+ runSummary = summary;
1041
+ }
1042
+ }
1043
+ catch {
1044
+ // leave runSummary null
1045
+ }
1046
+ }
817
1047
  snapshot = await observeTaskLifecycle(input.repoRoot, input.taskId);
818
1048
  // Re-plan so promote/closeout can run in the same call after success.
819
1049
  continue;
@@ -869,6 +1099,25 @@ export async function advanceTaskLifecycle(input) {
869
1099
  (await buildCurrentGate(input.repoRoot, input.taskId)))
870
1100
  : snapshot.gate;
871
1101
  await exportDagDraftIfRequested(input.repoRoot, input.taskId, input.dagOutputPath);
1102
+ // Fall back to the latest associated run state when this call did not
1103
+ // itself monitor/start a run (e.g. re-observe after a previously settled
1104
+ // run): the operator still wants the compact summary in the result.
1105
+ if (!runSummary) {
1106
+ const settledRun = run ?? snapshot.latestRun ?? snapshot.activeRun;
1107
+ if (settledRun?.runDir) {
1108
+ try {
1109
+ const settledState = await readDagRunState(settledRun.runDir);
1110
+ const summary = buildRunSummary(settledState);
1111
+ if (summary) {
1112
+ summary.next = snapshot.next;
1113
+ runSummary = summary;
1114
+ }
1115
+ }
1116
+ catch {
1117
+ // leave runSummary null
1118
+ }
1119
+ }
1120
+ }
872
1121
  return {
873
1122
  taskId: input.taskId,
874
1123
  lifecycleState: snapshot.lifecycleState,
@@ -878,6 +1127,7 @@ export async function advanceTaskLifecycle(input) {
878
1127
  warnings,
879
1128
  next: snapshot.next,
880
1129
  run,
1130
+ runSummary,
881
1131
  promotion,
882
1132
  closeout,
883
1133
  recommendations,
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { pathMatchesPattern } from "../../shared/git-progress.js";
2
3
  export function normalizeWriteSet(writeSet) {
3
4
  return [
4
5
  ...new Set(writeSet.map((entry) => entry.replace(/\\/g, "/").trim()).filter(Boolean)),
@@ -42,6 +43,54 @@ export function computeGateDigest(bound) {
42
43
  writeSet: normalizeWriteSet(bound.writeSet),
43
44
  });
44
45
  }
46
+ /**
47
+ * Deterministic writeSet vs contract allowedPaths delta (2026-08-11).
48
+ *
49
+ * Classifies every normalized writeSet entry against the task contract's
50
+ * allowedPaths:
51
+ * - exactMatch: entry equals a contract allowedPath
52
+ * - narrowerThanContract: entry is covered by a broader contract glob
53
+ * (e.g. writeSet `src/worker/console/chat/**` under
54
+ * contract `src/**`)
55
+ * - additions: entry outside the contract, attributed to the
56
+ * exclusive writer node(s) that declared it
57
+ *
58
+ * `writers` is the list of { id, writeSet } from the DAG spec's exclusive
59
+ * writer tasks. The delta is a read-only field on the gate output and is
60
+ * NEVER included in the gate digest (AC-004: digest stays stable).
61
+ */
62
+ export function buildWriteSetGateDelta(input) {
63
+ const writeSet = normalizeWriteSet(input.writeSet);
64
+ const contract = normalizeWriteSet(input.contractAllowedPaths);
65
+ const exactMatch = [];
66
+ const narrowerThanContract = [];
67
+ const additions = [];
68
+ for (const entry of writeSet) {
69
+ if (contract.includes(entry)) {
70
+ exactMatch.push(entry);
71
+ continue;
72
+ }
73
+ // Covered by a broader contract glob? Use the entry itself as a probe
74
+ // path against each contract pattern (pathMatchesPattern treats a
75
+ // non-glob entry as an exact-or-prefix match and glob entries via
76
+ // globToRegExp).
77
+ const covered = contract.some((contractEntry) => pathMatchesPattern(entry, contractEntry));
78
+ if (covered) {
79
+ narrowerThanContract.push(entry);
80
+ continue;
81
+ }
82
+ const source = (input.writers ?? [])
83
+ .filter((writer) => normalizeWriteSet(writer.writeSet).includes(entry))
84
+ .map((writer) => writer.id)
85
+ .sort((a, b) => a.localeCompare(b));
86
+ additions.push({
87
+ entry,
88
+ source,
89
+ coveredByContract: false,
90
+ });
91
+ }
92
+ return { exactMatch, narrowerThanContract, additions };
93
+ }
45
94
  export function buildWriteSetGate(input) {
46
95
  const digest = computeGateDigest(input.bound);
47
96
  const id = "write-set-review";
@@ -54,6 +103,7 @@ export function buildWriteSetGate(input) {
54
103
  approveCommand: `loop-agent task advance ${input.taskId} --approve-gate ${id}:${digest} --json`,
55
104
  rejectCommand: `loop-agent task advance ${input.taskId} --reject-gate ${id}:${digest} --reason <text> --json`,
56
105
  bound: input.bound,
106
+ delta: input.delta ?? null,
57
107
  };
58
108
  }
59
109
  export function parseGateToken(token) {
@@ -5,7 +5,7 @@ import { logicalRevisionForState, observeTaskContractState, } from "../../task/c
5
5
  import { assessDagRunLiveness, listAllDagRunEntries, readDagRunSpec, readDagRunState, } from "../../workflows/dag/lifecycle.js";
6
6
  import { resolveRunningControllerIdentity } from "../../shared/package-metadata.js";
7
7
  import { loadLifecycleRecord } from "./record.js";
8
- import { buildGateBinding, buildWriteSetGate, controllerIdentityAnchor, hashCanonicalPayload, normalizeWriteSet, sha256Hex, } from "./gates.js";
8
+ import { buildGateBinding, buildWriteSetGate, buildWriteSetGateDelta, controllerIdentityAnchor, hashCanonicalPayload, normalizeWriteSet, sha256Hex, } from "./gates.js";
9
9
  async function exists(filePath) {
10
10
  try {
11
11
  await access(filePath);
@@ -263,6 +263,15 @@ export async function observeTaskLifecycle(repoRoot, taskId) {
263
263
  : null),
264
264
  })
265
265
  : null;
266
+ const gateDelta = gateBound != null
267
+ ? buildWriteSetGateDelta({
268
+ writeSet: gateBound.writeSet,
269
+ contractAllowedPaths: taskConfigAllowed,
270
+ writers: (parsedSpec?.tasks ?? [])
271
+ .filter((task) => task.writePolicy === "exclusive")
272
+ .map((task) => ({ id: task.id, writeSet: task.writeSet ?? [] })),
273
+ })
274
+ : null;
266
275
  const lastGate = record?.lastGate;
267
276
  const currentDigest = gateBound != null
268
277
  ? buildWriteSetGate({ taskId, bound: gateBound }).digest
@@ -277,7 +286,7 @@ export async function observeTaskLifecycle(repoRoot, taskId) {
277
286
  // not already approved/rejected. Rejected same-digest gates stay closed
278
287
  // until contract/source/DAG/writeSet/controller binding changes.
279
288
  const gate = gateBound && strictValidated && !approvedCurrent && !rejectedCurrent
280
- ? buildWriteSetGate({ taskId, bound: gateBound })
289
+ ? buildWriteSetGate({ taskId, bound: gateBound, delta: gateDelta })
281
290
  : null;
282
291
  const gateOpen = Boolean(gate);
283
292
  let legacyState;
@@ -1023,6 +1023,7 @@ function assertPendingAcceptance(value, state, task) {
1023
1023
  }
1024
1024
  const pending = value;
1025
1025
  const desiredSha256 = task.evidence.desiredSha256;
1026
+ const sourceAnchorSha256 = task.evidence.sourceAnchorSha256;
1026
1027
  const guard = pending.guard;
1027
1028
  const receipt = pending.receipt;
1028
1029
  const transition = pending.transition;
@@ -1039,6 +1040,9 @@ function assertPendingAcceptance(value, state, task) {
1039
1040
  !isSha256(guard.desiredSha256) ||
1040
1041
  !isSha256(desiredSha256) ||
1041
1042
  guard.desiredSha256 !== desiredSha256 ||
1043
+ (sourceAnchorSha256 === undefined
1044
+ ? guard.sourceAnchorSha256 !== undefined
1045
+ : guard.sourceAnchorSha256 !== sourceAnchorSha256) ||
1042
1046
  !isMergeReceipt(receipt) ||
1043
1047
  receipt.taskId !== guard.taskId ||
1044
1048
  receipt.path !== guard.path ||
@@ -1076,6 +1080,9 @@ function acceptedSurface(input) {
1076
1080
  acceptedEntry.acceptedMerge = {
1077
1081
  currentSha256: input.currentSha256,
1078
1082
  desiredSha256: input.desiredSha256,
1083
+ ...(typeof input.task.evidence.sourceAnchorSha256 === "string"
1084
+ ? { sourceAnchorSha256: input.task.evidence.sourceAnchorSha256 }
1085
+ : {}),
1079
1086
  };
1080
1087
  return { beforeEntry: cloneJson(entry), accepted };
1081
1088
  }
@@ -1210,6 +1217,7 @@ async function recordAcceptedMerge(input) {
1210
1217
  if (!desiredSha256) {
1211
1218
  throw new Error(`init upgrade cannot accept ${input.task.path}: desired hash evidence is missing`);
1212
1219
  }
1220
+ const sourceAnchorSha256 = input.task.evidence.sourceAnchorSha256;
1213
1221
  const canonicalPath = normalizeAllowedPath(input.task.path);
1214
1222
  const surfacePath = path.join(input.state.repoRoot, INIT_SURFACE_STATE_PATH);
1215
1223
  let pending = input.pending;
@@ -1263,6 +1271,9 @@ async function recordAcceptedMerge(input) {
1263
1271
  allowedPaths: [canonicalPath],
1264
1272
  currentSha256: input.currentSha256,
1265
1273
  desiredSha256,
1274
+ ...(sourceAnchorSha256 !== undefined
1275
+ ? { sourceAnchorSha256 }
1276
+ : {}),
1266
1277
  },
1267
1278
  receipt,
1268
1279
  transition: {
@@ -2412,7 +2423,27 @@ async function executeUpgrade(directory, state, input) {
2412
2423
  });
2413
2424
  state.actionReceipts.push(...safe.applied);
2414
2425
  await writeJsonAtomic(path.join(directory, "change-manifest.json"), state.actionReceipts);
2415
- const post = await checkInitUpdate({ repoRoot: state.repoRoot });
2426
+ let post = await checkInitUpdate({ repoRoot: state.repoRoot });
2427
+ // Converge remaining safe deterministic init actions before the final check.
2428
+ // Project/governance-derived drift (e.g. a personalized project rename after
2429
+ // an accepted merge) settles here instead of failing finalCheck. Model merge
2430
+ // tasks and human decisions are never auto-applied.
2431
+ while (post.deterministicActions.length > 0) {
2432
+ const converged = await applyInitUpdate({
2433
+ repoRoot: state.repoRoot,
2434
+ applySafe: true,
2435
+ clientRecovery: "project",
2436
+ });
2437
+ state.actionReceipts.push(...converged.applied);
2438
+ await writeJsonAtomic(path.join(directory, "change-manifest.json"), state.actionReceipts);
2439
+ if (converged.applied.length === 0)
2440
+ break;
2441
+ post = await checkInitUpdate({ repoRoot: state.repoRoot });
2442
+ if (post.humanDecisions.length > 0 ||
2443
+ post.modelMergeTasks.length > 0) {
2444
+ break;
2445
+ }
2446
+ }
2416
2447
  state.humanDecisions = post.humanDecisions;
2417
2448
  if (post.humanDecisions.length > 0) {
2418
2449
  state.mergeTasks = [];