@tea-agent/loop-agent 0.33.5 → 0.33.6

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 (57) hide show
  1. package/CHANGELOG.md +33 -0
  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-write-guard.js +26 -8
  8. package/dist/shared/operator/capabilities.js +72 -42
  9. package/dist/shared/resilient-git.js +133 -0
  10. package/dist/task/source-prepare/artifact-meta.js +137 -0
  11. package/dist/task/source-prepare/index.js +2 -0
  12. package/dist/task/source-prepare/parse-intent.js +58 -10
  13. package/dist/task/source-prepare/prepare.js +180 -16
  14. package/dist/task/source-prepare/reference-integrity.js +18 -2
  15. package/dist/task/source-prepare/semantic-intake.js +404 -0
  16. package/dist/worker/console/app-data.js +2 -0
  17. package/dist/worker/console/chat/chat-event-store.js +190 -25
  18. package/dist/worker/console/chat/pi-console-config.js +250 -32
  19. package/dist/worker/console/chat/pi-runtime.js +625 -71
  20. package/dist/worker/console/chat/resource-loader.js +5 -4
  21. package/dist/worker/console/chat/routes.js +324 -146
  22. package/dist/worker/console/chat/runtime-context.js +48 -12
  23. package/dist/worker/console/chat/runtime-selection.js +59 -0
  24. package/dist/worker/console/chat/shortcuts.js +1 -0
  25. package/dist/worker/console/chat/tool-adapter.js +9 -3
  26. package/dist/worker/console/chat/tools.js +5 -1
  27. package/dist/worker/console/dag-execution-receipt.js +380 -0
  28. package/dist/worker/console/operator-actions.js +559 -68
  29. package/dist/worker/console/server.js +8 -15
  30. package/dist/worker/console/static/assets/index-BUOLppPr.js +28 -0
  31. package/dist/worker/console/static/assets/index-C1KzazY5.css +1 -0
  32. package/dist/worker/console/static/index.html +2 -2
  33. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +45 -8
  34. package/dist/worker/console/static-src/operator-chat/refs.js +9 -0
  35. package/dist/worker/console/static-src/operator-chat/runtime-snapshot-store.js +257 -0
  36. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +16 -0
  37. package/dist/worker/console/static-src/operator-chat/useChatStream.js +210 -184
  38. package/dist/worker/console/static-src/operator-chat/useChatThread.js +49 -5
  39. package/dist/worker/console/static-src/operator-chat/useComposer.js +17 -0
  40. package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +225 -74
  41. package/dist/worker/console/static-src/operator-chat/useRuntimeSnapshot.js +196 -0
  42. package/dist/worker/delivery/final-verification.js +13 -5
  43. package/dist/worker/delivery/package.js +31 -19
  44. package/dist/worker/delivery/verification-bundle.js +6 -4
  45. package/dist/worker/observe/static/operator-chrome.css +5 -2
  46. package/dist/worker/observe/static/operator-chrome.js +6 -1
  47. package/dist/worker/observe/static/styles.css +39 -9
  48. package/dist/workflows/dag/frontend-worktree-diff.js +12 -27
  49. package/dist/workflows/dag/workspace-checkpoint.js +8 -27
  50. package/harness.json +1 -1
  51. package/package.json +1 -1
  52. package/skills/loop-agent/references/command-reference.md +3 -1
  53. package/skills/loop-agent/references/source-and-plan-practice.md +13 -0
  54. package/skills/loop-agent/references/task-workflow.md +4 -0
  55. package/dist/worker/console/chat/instruction-skills.js +0 -217
  56. package/dist/worker/console/static/assets/index-CnUXAqxG.css +0 -1
  57. package/dist/worker/console/static/assets/index-CteJFFL2.js +0 -29
package/CHANGELOG.md CHANGED
@@ -2,6 +2,39 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.33.6] - 2026-08-11
6
+
7
+ ### 新增
8
+
9
+ - stabilize PRD intake + add Semantic Intake fallback
10
+ - Operator Chat 单事件流 + boot epoch fence(Task C)
11
+ - gate writeSet delta + run summary(操作面增强)
12
+ - supervise DAG runs autonomously
13
+ - ChatEventStore 事件持久化压缩(coalescing + maxBytes 双限制)
14
+ - reconcile(cursor_expired) 自动恢复而非全局致命错误
15
+ - 接入 Operator Chat 实时运行时控制
16
+ - open operator chat resource loading
17
+ - enrich execution report and URL resolution
18
+
19
+ ### 改进
20
+
21
+ - style(task-advance): biome autoformat 对齐(无功能变化)
22
+ - style(console): apply biome formatting from pi-lens autoformat
23
+
24
+ ### 修复
25
+
26
+ - unify resilient read-only command execution
27
+ - preserve user-owned Pi model routing
28
+ - show installed package metadata
29
+ - escape preflight backtick matcher
30
+ - align chat streaming reliability with pi-web
31
+ - split rerun defaults and repair selector escaping
32
+ - ignore delimiters inside pytest strings
33
+ - resolve target URL from runtime context
34
+ - reject priority-only module stems
35
+ - stop desiredSha256 mechanical checks from blocking generated-content upgrades
36
+ - use mobius header brand icon
37
+
5
38
  ## [0.33.5] - 2026-08-11
6
39
 
7
40
  ### 重点更新
@@ -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 = [];