@tea-agent/loop-agent 0.28.8 → 0.28.10

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.28.10] - 2026-08-05
6
+
7
+ ### 重点更新
8
+
9
+ - 修复 DAG 变更清单 schema 校验误杀合法执行清单的问题,恢复夜间测试成功路径
10
+
11
+ ### 修复
12
+
13
+ - DAG writer change-manifest 的 schema 校验现可正确接受 Pi 执行器写入的 effectiveWriteSet 与 approval 元数据字段,修复 verify-pi project-governance-integrity 因 ownership/schema 不匹配而误拒合法 implement-pi 清单的问题
14
+
15
+ ## [0.28.9] - 2026-08-05
16
+
17
+ ### 重点更新
18
+
19
+ - 提升 DAG 执行的可靠性与验证效率
20
+ - 修复初始化时 Pi 模型矩阵的配置问题
21
+
22
+ ### 改进
23
+
24
+ - 提升 DAG 执行的可靠性与验证效率
25
+
26
+ ### 修复
27
+
28
+ - 修复初始化时 Pi 模型矩阵的配置问题
29
+
5
30
  ## [0.28.8] - 2026-08-05
6
31
 
7
32
  ### 重点更新
@@ -37,6 +37,13 @@ function hasEngineeringBoundary(input) {
37
37
  input.fromText ||
38
38
  input.fromDraftPath);
39
39
  }
40
+ /** Inputs that must produce a new managed-contract revision when explicit. */
41
+ function hasContractChangingInput(input) {
42
+ return Boolean(hasEngineeringBoundary(input) ||
43
+ input.title ||
44
+ input.taskKind ||
45
+ input.featureId);
46
+ }
40
47
  function uniqueTransitions(existing, extra) {
41
48
  const set = new Set(existing);
42
49
  for (const item of extra)
@@ -197,6 +204,10 @@ export async function advanceTaskLifecycle(input) {
197
204
  * never reaches prepare-contract / gate.
198
205
  */
199
206
  let importedPrdThisCall = false;
207
+ /** A managed contract mutation is prepared at most once per invocation. */
208
+ let preparedContractThisCall = false;
209
+ /** A successful managed-contract mutation invalidates the prior DAG/gate. */
210
+ let regenerateDagThisCall = false;
200
211
  let snapshot = await observeTaskLifecycle(input.repoRoot, input.taskId);
201
212
  let record = (await loadLifecycleRecord(input.repoRoot, input.taskId)) ??
202
213
  emptyLifecycleRecord(input.taskId);
@@ -251,6 +262,9 @@ export async function advanceTaskLifecycle(input) {
251
262
  verifyCommands: applied.verifyCommands ?? input.verifyCommands,
252
263
  };
253
264
  const hasBoundary = hasEngineeringBoundary(effectiveInput);
265
+ let refreshManagedContractThisCall = Boolean(hasContractChangingInput(effectiveInput) &&
266
+ (snapshot.contract?.effectiveStatus === "managed" ||
267
+ Boolean(snapshot.contract?.revision && snapshot.contract.revision > 0)));
254
268
  if (applied.accepted) {
255
269
  warnings.push({
256
270
  code: "RECOMMENDATIONS_ACCEPTED",
@@ -341,11 +355,13 @@ export async function advanceTaskLifecycle(input) {
341
355
  snapshot: current,
342
356
  hasPrdInput: hasPrd && !importedPrdThisCall,
343
357
  hasEngineeringBoundary: hasBoundary,
358
+ refreshManagedContract: refreshManagedContractThisCall,
359
+ forceDagRefresh: regenerateDagThisCall,
344
360
  approveGate: consumeApproveGate || approvedThisCall,
345
361
  rejectGate: false,
346
362
  dryRun,
347
363
  skipFinalize: Boolean(input.skipFinalize),
348
- forcePrepareContract: Boolean(input.fromDraftPath),
364
+ forcePrepareContract: Boolean(input.fromDraftPath) && !preparedContractThisCall,
349
365
  });
350
366
  if (dryRun) {
351
367
  const plan = planOnce(snapshot);
@@ -496,6 +512,12 @@ export async function advanceTaskLifecycle(input) {
496
512
  });
497
513
  break;
498
514
  }
515
+ preparedContractThisCall = true;
516
+ if (refreshManagedContractThisCall || input.fromDraftPath) {
517
+ // The old DAG and gate were bound to the pre-mutation contract.
518
+ refreshManagedContractThisCall = false;
519
+ regenerateDagThisCall = true;
520
+ }
499
521
  record = appendTransition(record, "contract-managed");
500
522
  completed.push("contract-managed");
501
523
  await saveLifecycleRecord(input.repoRoot, record);
@@ -540,6 +562,7 @@ export async function advanceTaskLifecycle(input) {
540
562
  });
541
563
  break;
542
564
  }
565
+ regenerateDagThisCall = false;
543
566
  record = appendTransition(record, "dag-generated");
544
567
  completed.push("dag-generated");
545
568
  await saveLifecycleRecord(input.repoRoot, record);
@@ -53,7 +53,7 @@ export function planTransitions(input) {
53
53
  // Prefer prepare once PRDs are on disk; re-import only when caller still
54
54
  // signals hasPrdInput (advance.ts latches this after one import per call).
55
55
  const shouldImportPrd = input.hasPrdInput;
56
- if (input.forcePrepareContract) {
56
+ if (input.forcePrepareContract || input.refreshManagedContract) {
57
57
  if (shouldImportPrd) {
58
58
  actions.push({ type: "import-prd" });
59
59
  expectedTransitions.push("prd-imported");
@@ -106,11 +106,14 @@ export function planTransitions(input) {
106
106
  }
107
107
  const dagExists = snapshot.dagDraft?.exists === true;
108
108
  const strictValidated = snapshot.dagDraft?.strictValidated === true;
109
- if (!dagExists) {
109
+ const dagRefreshRequired = Boolean(input.forceDagRefresh ||
110
+ input.refreshManagedContract ||
111
+ input.forcePrepareContract);
112
+ if (!dagExists || dagRefreshRequired) {
110
113
  actions.push({ type: "generate-dag" });
111
114
  expectedTransitions.push("dag-generated");
112
115
  }
113
- if (!strictValidated) {
116
+ if (!strictValidated || dagRefreshRequired) {
114
117
  actions.push({ type: "strict-validate-dag" });
115
118
  expectedTransitions.push("dag-strict-validated");
116
119
  }
@@ -127,7 +130,7 @@ export function planTransitions(input) {
127
130
  snapshot.lifecycleState === "needs-attention" ||
128
131
  snapshot.lifecycleState === "awaiting-decision";
129
132
  const approvedForCurrent = postRunState || (input.approveGate && gate !== null);
130
- if (!approvedForCurrent && (strictValidated || !dagExists)) {
133
+ if (!approvedForCurrent && (strictValidated || !dagExists || dagRefreshRequired)) {
131
134
  // Gate opens after generate+validate in the advance loop; plan includes open.
132
135
  if (!input.approveGate) {
133
136
  actions.push({ type: "open-write-set-gate" });
@@ -588,8 +588,8 @@ export function buildLoopAgentProgram(options) {
588
588
  .option("--profile <profile>", "full|minimal", "full")
589
589
  .option("--merge", "merge existing files")
590
590
  .option("--no-merge", "skip existing files")
591
- .option("--provider <provider>", "model provider")
592
- .option("--model <model>", "model name")
591
+ .option("--provider <provider>", "provider paired with a bare --model (otherwise use provider/model)")
592
+ .option("--model <model>", "qualified provider/model, or a bare model paired with --provider")
593
593
  .option("--json", "print JSON")
594
594
  .option("--markdown", "print Markdown")
595
595
  .option("--bootstrap-surface", "write an inferred .harness/init-surface.json baseline")
@@ -923,15 +923,23 @@ function buildHarness(input) {
923
923
  delete pi.requiresApiKey;
924
924
  mergedExecutors.pi = pi;
925
925
  }
926
- // Prefer DAG-facing executors.*.defaultModel over legacy top-level models.*.
927
- // --provider/--model still apply as a convenience override for pi defaultModel.
928
- if (input.provider || input.model) {
926
+ // Init always writes a Pi tier matrix. `defaultModel` remains runtime
927
+ // compatibility for old projects only and is never fresh-init output.
928
+ if (input.model) {
929
929
  const piExisting = isRecord(mergedExecutors.pi) ? mergedExecutors.pi : {};
930
930
  mergedExecutors.pi = {
931
931
  ...piExisting,
932
- ...(input.model ? { defaultModel: input.model } : {}),
932
+ LOW: input.model,
933
+ MED: input.model,
934
+ HIGH: input.model,
933
935
  };
934
936
  }
937
+ if (isRecord(mergedExecutors.pi)) {
938
+ const pi = { ...mergedExecutors.pi };
939
+ if (hasCompletePiTierMatrix(pi))
940
+ delete pi.defaultModel;
941
+ mergedExecutors.pi = pi;
942
+ }
935
943
  const harness = {
936
944
  ...input.existing,
937
945
  $schema: expectedHarnessSchemaRef(input.governanceRoot),
@@ -977,29 +985,17 @@ function buildHarness(input) {
977
985
  }),
978
986
  executors: mergedExecutors,
979
987
  };
980
- // Do not project legacy step-routing models / modelProfiles / modelRouting into
981
- // new or refreshed harness.json. Keep them only when the target already has them
982
- // so old files remain loadable until the user migrates.
983
- if (isRecord(input.existing.models) &&
984
- Object.keys(input.existing.models).length > 0) {
985
- harness.models = input.existing.models;
986
- }
987
- else {
988
- delete harness.models;
989
- }
990
- if (isRecord(input.existing.modelProfiles) &&
991
- Object.keys(input.existing.modelProfiles).length > 0) {
992
- harness.modelProfiles = input.existing.modelProfiles;
993
- }
994
- else {
995
- delete harness.modelProfiles;
996
- }
997
- if (isRecord(input.existing.modelRouting) &&
998
- Object.keys(input.existing.modelRouting).length > 0) {
999
- harness.modelRouting = input.existing.modelRouting;
1000
- }
1001
- else {
1002
- delete harness.modelRouting;
988
+ // Fresh init owns only the current DAG-facing surface. Legacy step routing is
989
+ // diagnosed by check-update rather than copied into a new harness projection.
990
+ for (const legacyField of [
991
+ "model",
992
+ "models",
993
+ "modelProfiles",
994
+ "modelRouting",
995
+ "verify",
996
+ "sequentialWorkflowRole",
997
+ ]) {
998
+ delete harness[legacyField];
1003
999
  }
1004
1000
  return harness;
1005
1001
  }
@@ -1133,6 +1129,59 @@ function sha256Buffer(value) {
1133
1129
  function isRecord(value) {
1134
1130
  return typeof value === "object" && value !== null && !Array.isArray(value);
1135
1131
  }
1132
+ /** A complete matrix has three effective values, not just three present keys. */
1133
+ function hasCompletePiTierMatrix(pi) {
1134
+ return ["LOW", "MED", "HIGH"].every((tier) => {
1135
+ const value = pi[tier];
1136
+ if (typeof value === "string")
1137
+ return value.length > 0 && value !== "default";
1138
+ if (!isRecord(value))
1139
+ return false;
1140
+ const keys = Object.keys(value);
1141
+ if (!keys.every((key) => key === "model" || key === "thinking"))
1142
+ return false;
1143
+ if (typeof value.model !== "string" || value.model.length === 0)
1144
+ return false;
1145
+ if (value.model === "default")
1146
+ return false;
1147
+ return value.thinking === undefined || typeof value.thinking === "string";
1148
+ });
1149
+ }
1150
+ /** Resolve init CLI input to one unambiguous Pi `provider/model` reference. */
1151
+ export function normalizeInitModelReference(input) {
1152
+ const provider = input.provider?.trim();
1153
+ const model = input.model?.trim();
1154
+ if (input.provider !== undefined && !provider) {
1155
+ throw new Error("init --provider must be a non-empty provider id");
1156
+ }
1157
+ if (input.model !== undefined && !model) {
1158
+ throw new Error("init --model must be a non-empty model reference");
1159
+ }
1160
+ if (provider && !model) {
1161
+ throw new Error("init --provider requires --model");
1162
+ }
1163
+ if (!model)
1164
+ return undefined;
1165
+ const slash = model.indexOf("/");
1166
+ if (slash < 0) {
1167
+ if (!provider) {
1168
+ throw new Error("init --model must be a provider/model reference or be paired with --provider");
1169
+ }
1170
+ if (/\s|\//.test(provider) || /\s/.test(model)) {
1171
+ throw new Error("init provider/model values must not contain whitespace");
1172
+ }
1173
+ return `${provider}/${model}`;
1174
+ }
1175
+ const modelProvider = model.slice(0, slash);
1176
+ const modelId = model.slice(slash + 1);
1177
+ if (!modelProvider || !modelId || /\s/.test(modelProvider) || /\s/.test(modelId)) {
1178
+ throw new Error("init --model must use a non-empty provider/model reference");
1179
+ }
1180
+ if (provider && provider !== modelProvider) {
1181
+ throw new Error("init --provider conflicts with the provider qualified in --model");
1182
+ }
1183
+ return model;
1184
+ }
1136
1185
  function normalizeRelativePath(relativePath) {
1137
1186
  return relativePath.split(path.sep).join("/");
1138
1187
  }
@@ -1506,6 +1555,17 @@ async function writeInitSurfaceState(input) {
1506
1555
  }
1507
1556
  }
1508
1557
  }
1558
+ if (input.acceptCurrentHarnessAsMerge) {
1559
+ const harness = state.files["harness.json"];
1560
+ if (harness?.relationship === "local-existing-unknown" &&
1561
+ harness.currentSha256 !== undefined &&
1562
+ harness.sourceSha256 !== undefined) {
1563
+ harness.acceptedMerge = {
1564
+ currentSha256: harness.currentSha256,
1565
+ desiredSha256: harness.sourceSha256,
1566
+ };
1567
+ }
1568
+ }
1509
1569
  const target = path.join(input.repoRoot, INIT_SURFACE_STATE_PATH);
1510
1570
  await mkdir(path.dirname(target), { recursive: true });
1511
1571
  await writeFile(target, `${JSON.stringify(state, null, 2)}\n`, "utf-8");
@@ -1596,39 +1656,103 @@ function collectSafeRetiredDirectories(paths) {
1596
1656
  }
1597
1657
  return [...dirs].sort((left, right) => right.split("/").length - left.split("/").length);
1598
1658
  }
1599
- function needsHarnessModelMigration(harness) {
1600
- if ("model" in harness ||
1601
- "models" in harness ||
1602
- "modelProfiles" in harness ||
1603
- "modelRouting" in harness) {
1604
- return true;
1659
+ const PI_MODEL_TIERS = ["LOW", "MED", "HIGH"];
1660
+ const LEGACY_MODEL_FIELDS = [
1661
+ "model",
1662
+ "models",
1663
+ "modelProfiles",
1664
+ "modelRouting",
1665
+ "provider",
1666
+ "verify",
1667
+ "sequentialWorkflowRole",
1668
+ ];
1669
+ function isQualifiedModelReference(value) {
1670
+ if (typeof value !== "string" || value.trim() !== value)
1671
+ return false;
1672
+ const slash = value.indexOf("/");
1673
+ return (slash > 0 &&
1674
+ slash < value.length - 1 &&
1675
+ !/\s/.test(value.slice(0, slash)) &&
1676
+ !/\s/.test(value.slice(slash + 1)));
1677
+ }
1678
+ /**
1679
+ * Only a single, qualified old default can be expanded automatically. All
1680
+ * routing/profile/tier ambiguity remains in the target for a bounded merge.
1681
+ */
1682
+ function assessHarnessModelMigration(harness) {
1683
+ const hasLegacyFields = LEGACY_MODEL_FIELDS.some((field) => field in harness);
1684
+ const executors = harness.executors;
1685
+ if (executors !== undefined && !isRecord(executors)) {
1686
+ return hasLegacyFields
1687
+ ? { kind: "ambiguous", reason: "executors is not an object" }
1688
+ : { kind: "none" };
1689
+ }
1690
+ const executorRecord = executors ?? {};
1691
+ if ("cursor" in executorRecord) {
1692
+ return {
1693
+ kind: "ambiguous",
1694
+ reason: "executors.cursor has user-owned runtime semantics",
1695
+ };
1605
1696
  }
1606
- if (isRecord(harness.executors)) {
1607
- if ("cursor" in harness.executors)
1608
- return true;
1609
- if (isRecord(harness.executors.pi)) {
1610
- return "requiresApiKey" in harness.executors.pi;
1611
- }
1697
+ if ("pi" in executorRecord && !isRecord(executorRecord.pi)) {
1698
+ return {
1699
+ kind: "ambiguous",
1700
+ reason: "executors.pi is not an object",
1701
+ };
1612
1702
  }
1613
- return false;
1703
+ const pi = isRecord(executorRecord.pi) ? executorRecord.pi : {};
1704
+ const hasDefaultModel = "defaultModel" in pi;
1705
+ const hasTier = PI_MODEL_TIERS.some((tier) => tier in pi);
1706
+ if (["requiresApiKey", "provider", "model"].some((field) => field in pi) ||
1707
+ Object.keys(pi).some((field) => !["description", "enabled", "defaultModel", ...PI_MODEL_TIERS].includes(field))) {
1708
+ return {
1709
+ kind: "ambiguous",
1710
+ reason: "executors.pi contains unknown or deprecated model semantics",
1711
+ };
1712
+ }
1713
+ if (hasLegacyFields) {
1714
+ return {
1715
+ kind: "ambiguous",
1716
+ reason: "legacy top-level model or workflow fields cannot be proven equivalent",
1717
+ };
1718
+ }
1719
+ if (!hasDefaultModel)
1720
+ return { kind: "none" };
1721
+ if (hasTier) {
1722
+ return {
1723
+ kind: "ambiguous",
1724
+ reason: "existing LOW/MED/HIGH tier configuration cannot be replaced by defaultModel automatically",
1725
+ };
1726
+ }
1727
+ if (pi.defaultModel === "default")
1728
+ return { kind: "none" };
1729
+ if (!isQualifiedModelReference(pi.defaultModel)) {
1730
+ return {
1731
+ kind: "ambiguous",
1732
+ reason: "defaultModel is not a complete provider/model reference",
1733
+ };
1734
+ }
1735
+ return { kind: "safe", model: pi.defaultModel, removeDefaultModel: true };
1736
+ }
1737
+ function needsHarnessModelMigration(harness) {
1738
+ return assessHarnessModelMigration(harness).kind === "safe";
1614
1739
  }
1615
1740
  function migrateHarnessModelFields(harness) {
1741
+ const assessment = assessHarnessModelMigration(harness);
1742
+ if (assessment.kind !== "safe") {
1743
+ throw new Error("ambiguous harness model migration must be merged manually");
1744
+ }
1616
1745
  const next = { ...harness };
1617
- const legacyModel = typeof next.model === "string" ? next.model : undefined;
1618
- delete next.model;
1619
- delete next.models;
1620
- delete next.modelProfiles;
1621
- delete next.modelRouting;
1746
+ for (const field of LEGACY_MODEL_FIELDS)
1747
+ delete next[field];
1622
1748
  const executors = isRecord(next.executors) ? { ...next.executors } : {};
1623
- delete executors.cursor;
1624
1749
  const pi = isRecord(executors.pi) ? { ...executors.pi } : {};
1625
- delete pi.requiresApiKey;
1626
- if (legacyModel) {
1627
- for (const level of ["LOW", "MED", "HIGH"]) {
1628
- if (typeof pi[level] !== "string")
1629
- pi[level] = legacyModel;
1630
- }
1750
+ if (assessment.model) {
1751
+ for (const tier of PI_MODEL_TIERS)
1752
+ pi[tier] = assessment.model;
1631
1753
  }
1754
+ if (assessment.removeDefaultModel)
1755
+ delete pi.defaultModel;
1632
1756
  executors.pi = pi;
1633
1757
  next.executors = executors;
1634
1758
  return next;
@@ -2177,6 +2301,14 @@ function buildTargetLoopAgentHarness(input) {
2177
2301
  "",
2178
2302
  "Use `loop-agent task advance` / `loop-agent task status` for non-trivial implementation work; advanced arbitrary DagSpec uses `loop-agent dag execute`.",
2179
2303
  "",
2304
+ "## Pi Model Matrix",
2305
+ "",
2306
+ "New DAG work reads only `harness.json.executors.pi.LOW`, `MED`, and `HIGH`. Each tier must be a model string or `{ model, thinking? }`; use explicit `provider/model` references when choosing a provider. Fresh init does not write `defaultModel`, `models`, `modelProfiles`, or `modelRouting`. `defaultModel` remains a runtime fallback only for compatible older projects.",
2307
+ "",
2308
+ "```json",
2309
+ '{ "executors": { "pi": { "LOW": "provider/low", "MED": "provider/medium", "HIGH": "provider/high" } } }',
2310
+ "```",
2311
+ "",
2180
2312
  "## Adaptive Liveness",
2181
2313
  "",
2182
2314
  "- Healthy Pi nodes are no longer stopped by a fixed 30-minute deadline. The default 4h absolute max is a final safety bound and cannot be extended by synthetic heartbeats.",
@@ -2273,7 +2405,7 @@ export function buildInitInstructions(input) {
2273
2405
  "",
2274
2406
  `- Project name: default \`${projectName}\`.`,
2275
2407
  `- Governance root: default \`${governanceRoot}\`.`,
2276
- "- Confirm provider/model. Defaults may be used, but the target project may need a different provider/model matrix.",
2408
+ "- Confirm the Pi LOW/MED/HIGH matrix. Explicit input must be `--model provider/model` or a paired `--provider provider --model model`; do not guess a provider for a bare model.",
2277
2409
  "- Default: merge existing AGENTS.md, harness.json, and loop-agent governance docs instead of overwriting user content.",
2278
2410
  "",
2279
2411
  "## Initialization Upgrade Routing",
@@ -2284,7 +2416,7 @@ export function buildInitInstructions(input) {
2284
2416
  "",
2285
2417
  "## Apply Defaults",
2286
2418
  "",
2287
- '- Use the current loop-agent harness.json as the default template, but write target project name plus `adapter: "loop-agent"`.',
2419
+ '- Use the current loop-agent harness.json as the default template, but write target project name plus `adapter: "loop-agent"` and a Pi-only `executors.pi.LOW/MED/HIGH` matrix. Do not write `defaultModel` when all three tiers exist, and never project `model`, `models`, `modelProfiles`, `modelRouting`, `verify`, or `sequentialWorkflowRole`.',
2288
2420
  `- ${DAG_HARD_GATE_TRIGGER}`,
2289
2421
  "- Generate the target project's loop-agent script matrix from templates: structure check, docs index/link checks, active plan status, exec-plan index sync, harness runtime cleanliness, architecture boundaries, skill entry integrity, governance CI, project-test CI, and full CI.",
2290
2422
  "- Copy or project only stack-agnostic governance scripts. For project-specific verification, packaging, release, or maintenance commands, generate the target-project version from templates plus the target repository's actual files instead of copying loop-agent's own TypeScript-specific scripts.",
@@ -2312,7 +2444,7 @@ export function buildInitInstructions(input) {
2312
2444
  "3. Continue immediately into project inspection and adaptation.",
2313
2445
  "4. Finish by running the init health checks and quick verification.",
2314
2446
  "",
2315
- "If provider/model is not specified and the existing/default harness values are usable, proceed with those defaults and record the assumption in the handoff. Ask the user only when the choice affects credentials, cost, availability, or an explicitly requested model backend.",
2447
+ "If provider/model is not specified and the existing tier matrix is usable, proceed with it and record the assumption in the handoff. Ask the user only when the choice affects credentials, cost, availability, or an explicitly requested model backend. An ambiguous old model configuration must stay in a bounded harness.json model merge/human decision; never silently reinterpret it.",
2316
2448
  "",
2317
2449
  "## Required Model Adaptation",
2318
2450
  "",
@@ -2345,11 +2477,21 @@ export async function initializeLoopAgentProject(options) {
2345
2477
  const governanceRoot = options.governanceRoot ?? DEFAULT_GOVERNANCE_ROOT;
2346
2478
  const profile = options.profile ?? "full";
2347
2479
  const merge = options.merge ?? true;
2480
+ const model = normalizeInitModelReference(options);
2348
2481
  const written = [];
2349
2482
  const skipped = [];
2350
2483
  const assetRoot = await findPackageRoot();
2351
2484
  const template = await readJsonIfExists(path.join(assetRoot, "harness.json"));
2352
- const existingHarness = await readJsonIfExists(path.join(repoRoot, "harness.json"));
2485
+ let existingHarness = await readJsonIfExists(path.join(repoRoot, "harness.json"));
2486
+ if (Object.keys(existingHarness).length > 0) {
2487
+ const migration = assessHarnessModelMigration(existingHarness);
2488
+ if (migration.kind === "ambiguous") {
2489
+ throw new Error(`init refuses to overwrite ambiguous legacy model configuration: ${migration.reason}; run init check-update and complete the harness.json model merge`);
2490
+ }
2491
+ if (migration.kind === "safe") {
2492
+ existingHarness = migrateHarnessModelFields(existingHarness);
2493
+ }
2494
+ }
2353
2495
  await mkdir(repoRoot, { recursive: true });
2354
2496
  const readmePath = path.join(repoRoot, "README.md");
2355
2497
  const existingReadme = (await exists(readmePath))
@@ -2371,8 +2513,7 @@ export async function initializeLoopAgentProject(options) {
2371
2513
  template,
2372
2514
  projectName,
2373
2515
  governanceRoot,
2374
- provider: options.provider,
2375
- model: options.model,
2516
+ model,
2376
2517
  });
2377
2518
  await writeText({
2378
2519
  repoRoot,
@@ -2515,6 +2656,7 @@ export async function initializeLoopAgentProject(options) {
2515
2656
  projectName,
2516
2657
  governanceRoot,
2517
2658
  stateKind: "recorded",
2659
+ acceptCurrentHarnessAsMerge: model !== undefined,
2518
2660
  });
2519
2661
  written.push(INIT_SURFACE_STATE_PATH);
2520
2662
  return {
@@ -2764,12 +2906,36 @@ export async function checkInitUpdate(input) {
2764
2906
  reason: `replace the legacy default governanceRoot docs with ${governanceRoot}`,
2765
2907
  });
2766
2908
  }
2767
- if (isRecord(harness) && needsHarnessModelMigration(harness)) {
2768
- deterministicActions.push({
2769
- type: "migrate-harness-model-fields",
2770
- path: "harness.json",
2771
- reason: "harness.json contains legacy model fields or executor fields that can be safely normalized",
2772
- });
2909
+ if (isRecord(harness)) {
2910
+ const modelMigration = assessHarnessModelMigration(harness);
2911
+ if (modelMigration.kind === "safe") {
2912
+ deterministicActions.push({
2913
+ type: "migrate-harness-model-fields",
2914
+ path: "harness.json",
2915
+ reason: "expand one proven-equivalent legacy model into the Pi LOW/MED/HIGH matrix",
2916
+ });
2917
+ }
2918
+ else if (modelMigration.kind === "ambiguous") {
2919
+ const state = currentState.files["harness.json"];
2920
+ const mergeTask = modelMergeTaskFor("harness.json", state, allPaths, recordedState);
2921
+ mergeTask.reason = `legacy model configuration requires a human/model merge: ${modelMigration.reason}`;
2922
+ mergeTask.mergeRules = [
2923
+ "Preserve every existing harness.json field unless the user explicitly approves its migration.",
2924
+ "Map only unambiguous complete provider/model references to executors.pi.LOW, MED, and HIGH.",
2925
+ "Do not retain defaultModel when all three tiers are present; do not invent a provider for bare model ids.",
2926
+ "Do not restore models, modelProfiles, modelRouting, cursor, or other legacy routing fields.",
2927
+ ];
2928
+ mergeTask.verification = [
2929
+ "loop-agent init check-update --repo-root . --json",
2930
+ "loop-agent inspect --repo-root .",
2931
+ "bash scripts/check-repo.sh",
2932
+ ];
2933
+ const existingTask = modelMergeTasks.findIndex((task) => task.path === "harness.json");
2934
+ if (existingTask >= 0)
2935
+ modelMergeTasks[existingTask] = mergeTask;
2936
+ else
2937
+ modelMergeTasks.push(mergeTask);
2938
+ }
2773
2939
  }
2774
2940
  }
2775
2941
  catch {
@@ -3273,6 +3439,7 @@ function parseInitArgs(repoRoot, args) {
3273
3439
  if (versionChoice && !["current", "upgrade", "cancel", "retry"].includes(versionChoice)) {
3274
3440
  throw new Error("init upgrade --version-choice must be current|upgrade|cancel|retry");
3275
3441
  }
3442
+ const normalizedModel = normalizeInitModelReference({ provider, model });
3276
3443
  return {
3277
3444
  repoRoot,
3278
3445
  projectName,
@@ -3280,7 +3447,7 @@ function parseInitArgs(repoRoot, args) {
3280
3447
  profile,
3281
3448
  merge,
3282
3449
  provider,
3283
- model,
3450
+ model: normalizedModel,
3284
3451
  clientRecovery,
3285
3452
  subcommand,
3286
3453
  json,
@@ -311,6 +311,21 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
311
311
  const persona = resolveDagPiPersona(input.task);
312
312
  const step = resolveDagPiStepName(input.task);
313
313
  const isWriteTask = isDagPiWriteTask(input.task);
314
+ if (isWriteTask && input.task.finalWriteSetApproval) {
315
+ const binding = input.task.finalWriteSetApproval;
316
+ const resolved = input.task.resolvedFinalWriteSetApproval;
317
+ if (!resolved ||
318
+ resolved.approvalSourceNodeId !== binding.approvalSourceNodeId ||
319
+ !/^[a-f0-9]{64}$/.test(resolved.approvalDigest)) {
320
+ return {
321
+ ok: false,
322
+ stdout: "",
323
+ stderr: "final-write-set-approval-invalid: writer authorization was not resolved before Pi execution",
324
+ failureCategory: "final-write-set-approval-invalid",
325
+ durationMs: Date.now() - started,
326
+ };
327
+ }
328
+ }
314
329
  const allowedSteps = isWriteTask ? PI_WRITE_STEPS : SAFE_PI_STEPS;
315
330
  if (!allowedSteps.has(step)) {
316
331
  return {
@@ -657,6 +672,9 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
657
672
  runDir: meta.runDir,
658
673
  nodeId: input.task.id,
659
674
  writerNodeId: input.task.id,
675
+ effectiveWriteSet: input.task.writeSet ?? [],
676
+ approvalSourceNodeId: input.task.resolvedFinalWriteSetApproval?.approvalSourceNodeId,
677
+ approvalDigest: input.task.resolvedFinalWriteSetApproval?.approvalDigest,
660
678
  changedFiles: changeManifestChangedFiles,
661
679
  beforeStatus,
662
680
  afterStatus: changeManifestAfterStatus ?? "",
@@ -966,6 +984,13 @@ async function persistWriterChangeManifest(input) {
966
984
  const manifest = {
967
985
  schemaVersion: 1,
968
986
  writerNodeId: input.writerNodeId,
987
+ effectiveWriteSet: [...input.effectiveWriteSet],
988
+ ...(input.approvalSourceNodeId
989
+ ? { approvalSourceNodeId: input.approvalSourceNodeId }
990
+ : {}),
991
+ ...(input.approvalDigest
992
+ ? { approvalDigest: input.approvalDigest }
993
+ : {}),
969
994
  changedFiles: input.changedFiles,
970
995
  beforeStatusSha256: createHash("sha256")
971
996
  .update(input.beforeStatus)