@tea-agent/loop-agent 0.25.2 → 0.25.4

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.
@@ -1,11 +1,13 @@
1
1
  import { createHash } from "node:crypto";
2
- import { access, copyFile, mkdir, readdir, readFile, rename, rm, rmdir, stat, writeFile } from "node:fs/promises";
2
+ import { access, copyFile, mkdir, readdir, readFile, rename, rm, rmdir, stat, writeFile, } from "node:fs/promises";
3
+ import os from "node:os";
3
4
  import path from "node:path";
4
5
  import { isDeepStrictEqual } from "node:util";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import { copyDir } from "../shared/copy-dir.js";
7
8
  import { isInitRuntimeActive, } from "../shared/runtime-activity.js";
8
9
  import { loadHarnessManifest } from "../governance/harness.js";
10
+ import { OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH, applyPiRetryMerge, buildOpenCodeTransientRetryPluginSource, inspectPiRetryConfig, parseClientRecoveryMode, runClientRecovery, } from "./client-recovery.js";
9
11
  const MANAGED_BLOCK_START = "<!-- LOOP_AGENT_INIT_START -->";
10
12
  const MANAGED_BLOCK_END = "<!-- LOOP_AGENT_INIT_END -->";
11
13
  const GITIGNORE_BLOCK_START = "# LOOP_AGENT_INIT_START";
@@ -14,18 +16,36 @@ const INIT_SURFACE_STATE_PATH = ".harness/init-surface.json";
14
16
  const DEFAULT_GOVERNANCE_ROOT = "ai_workspace/loop-agent";
15
17
  const CORE_DOC_FILES = [
16
18
  { source: "README.md", target: "README.md" },
17
- { source: "architecture/runtime-boundaries.md", target: "architecture/runtime-boundaries.md" },
18
- { source: "governance/development-principles.md", target: "development-principles.md" },
19
+ {
20
+ source: "architecture/runtime-boundaries.md",
21
+ target: "architecture/runtime-boundaries.md",
22
+ },
23
+ {
24
+ source: "governance/development-principles.md",
25
+ target: "development-principles.md",
26
+ },
19
27
  { source: "governance/feature-workflow.md", target: "feature-workflow.md" },
20
- { source: "governance/verification-matrix.md", target: "verification-matrix.md" },
28
+ {
29
+ source: "governance/verification-matrix.md",
30
+ target: "verification-matrix.md",
31
+ },
21
32
  { source: "runtime/loop-agent-harness.md", target: "loop-agent-harness.md" },
22
- { source: "governance/harness-methodology-tdd.md", target: "harness-methodology-tdd.md" },
23
- { source: "governance/harness-methodology-verification.md", target: "harness-methodology-verification.md" },
24
- { source: "governance/harness-methodology-debugging.md", target: "harness-methodology-debugging.md" },
33
+ {
34
+ source: "governance/harness-methodology-tdd.md",
35
+ target: "harness-methodology-tdd.md",
36
+ },
37
+ {
38
+ source: "governance/harness-methodology-verification.md",
39
+ target: "harness-methodology-verification.md",
40
+ },
41
+ {
42
+ source: "governance/harness-methodology-debugging.md",
43
+ target: "harness-methodology-debugging.md",
44
+ },
25
45
  { source: "templates/README.md", target: "templates/README.md" },
26
46
  ];
27
47
  function coreDocSourceForTarget(target) {
28
- return CORE_DOC_FILES.find((entry) => entry.target === target)?.source ?? target;
48
+ return (CORE_DOC_FILES.find((entry) => entry.target === target)?.source ?? target);
29
49
  }
30
50
  const GOVERNANCE_README_DIRS = [
31
51
  "decisions",
@@ -854,7 +874,7 @@ function buildManagedAgentsBlock(input) {
854
874
  "### Agent DAG 路径",
855
875
  "",
856
876
  "```bash",
857
- "loop-agent new-task <task-id> \"任务标题\"",
877
+ 'loop-agent new-task <task-id> "任务标题"',
858
878
  "# write .harness/tasks/<task-id>/source/需求.md",
859
879
  "# write .harness/tasks/<task-id>/source/执行约束.md",
860
880
  "loop-agent dag run-task <task-id> --profile auto --strict-models",
@@ -868,9 +888,12 @@ function buildManagedAgentsBlock(input) {
868
888
  "",
869
889
  "### 任务类型路由(taskKind)",
870
890
  "",
871
- "- 用户明确提出后端测试、接口/API 测试、pytest,或语境明确为后端的自动化测试时,必须把 `.harness/tasks/<task-id>/task.json` 的 `taskKind` 设置为 `\"backend-test\"`,不得保留默认 `standard`。",
891
+ '- 用户明确提出后端测试、接口/API 测试、pytest,或语境明确为后端的自动化测试时,必须把 `.harness/tasks/<task-id>/task.json` 的 `taskKind` 设置为 `"backend-test"`,不得保留默认 `standard`。',
872
892
  "- `backend-test` 是 `taskKind`,不是 `--profile` 的可选值;运行 `dag run-task` 时继续使用 `--profile auto`。",
873
893
  "- 仅出现“自动化测试”且无法判断前后端时,先阅读任务源与目标项目技术栈再决定,禁止无条件路由到 `backend-test`。",
894
+ '- 用户提示词明确是前端实现需求(例如前端页面、UI、组件或交互开发)时,必须把 `.harness/tasks/<task-id>/task.json` 的 `taskKind` 设置为 `"frontend-implementation"`,不得保留默认 `standard`。',
895
+ "- `frontend-implementation` 是 `taskKind`,不是 `--profile` 的可选值;运行 `dag run-task` 时继续使用 `--profile auto`,也可按需显式选择 `minimal` / `standard` / `reviewed` / `supervised`,不要把业务模板名当作 profile。",
896
+ '- 前端自动化测试(浏览器/UI 自动化、Playwright、E2E)继续使用 `taskKind: "frontend-test"`,不得设置为 `frontend-implementation`。',
874
897
  "",
875
898
  "### 运行看板(只读)",
876
899
  "",
@@ -904,7 +927,8 @@ function mergeManagedBlock(existing, block) {
904
927
  const start = existing.indexOf(MANAGED_BLOCK_START);
905
928
  const end = existing.indexOf(MANAGED_BLOCK_END);
906
929
  if (start >= 0 && end > start) {
907
- return `${existing.slice(0, start).trimEnd()}\n\n${block}\n${existing.slice(end + MANAGED_BLOCK_END.length).trimStart()}`.trimEnd() + "\n";
930
+ return (`${existing.slice(0, start).trimEnd()}\n\n${block}\n${existing.slice(end + MANAGED_BLOCK_END.length).trimStart()}`.trimEnd() +
931
+ "\n");
908
932
  }
909
933
  return `${existing.trimEnd()}\n\n${block}\n`;
910
934
  }
@@ -912,7 +936,8 @@ function mergeGitignoreManagedBlock(existing, block) {
912
936
  const start = existing.indexOf(GITIGNORE_BLOCK_START);
913
937
  const end = existing.indexOf(GITIGNORE_BLOCK_END);
914
938
  if (start >= 0 && end > start) {
915
- return `${existing.slice(0, start).trimEnd()}\n\n${block}\n${existing.slice(end + GITIGNORE_BLOCK_END.length).trimStart()}`.trimEnd() + "\n";
939
+ return (`${existing.slice(0, start).trimEnd()}\n\n${block}\n${existing.slice(end + GITIGNORE_BLOCK_END.length).trimStart()}`.trimEnd() +
940
+ "\n");
916
941
  }
917
942
  if (!existing.trim())
918
943
  return `${block}\n`;
@@ -1051,19 +1076,22 @@ function buildHarness(input) {
1051
1076
  // Do not project legacy step-routing models / modelProfiles / modelRouting into
1052
1077
  // new or refreshed harness.json. Keep them only when the target already has them
1053
1078
  // so old files remain loadable until the user migrates.
1054
- if (isRecord(input.existing.models) && Object.keys(input.existing.models).length > 0) {
1079
+ if (isRecord(input.existing.models) &&
1080
+ Object.keys(input.existing.models).length > 0) {
1055
1081
  harness.models = input.existing.models;
1056
1082
  }
1057
1083
  else {
1058
1084
  delete harness.models;
1059
1085
  }
1060
- if (isRecord(input.existing.modelProfiles) && Object.keys(input.existing.modelProfiles).length > 0) {
1086
+ if (isRecord(input.existing.modelProfiles) &&
1087
+ Object.keys(input.existing.modelProfiles).length > 0) {
1061
1088
  harness.modelProfiles = input.existing.modelProfiles;
1062
1089
  }
1063
1090
  else {
1064
1091
  delete harness.modelProfiles;
1065
1092
  }
1066
- if (isRecord(input.existing.modelRouting) && Object.keys(input.existing.modelRouting).length > 0) {
1093
+ if (isRecord(input.existing.modelRouting) &&
1094
+ Object.keys(input.existing.modelRouting).length > 0) {
1067
1095
  harness.modelRouting = input.existing.modelRouting;
1068
1096
  }
1069
1097
  else {
@@ -1110,7 +1138,9 @@ async function copyDirMerge(input) {
1110
1138
  const source = path.join(input.assetRoot, input.sourceRelativePath);
1111
1139
  const target = path.join(input.repoRoot, input.targetRelativePath);
1112
1140
  await copyDir(source, target);
1113
- input.written.push(input.targetRelativePath.endsWith("/") ? input.targetRelativePath : `${input.targetRelativePath}/`);
1141
+ input.written.push(input.targetRelativePath.endsWith("/")
1142
+ ? input.targetRelativePath
1143
+ : `${input.targetRelativePath}/`);
1114
1144
  }
1115
1145
  async function ensureHarnessDirs(repoRoot, written) {
1116
1146
  const dirs = [
@@ -1187,7 +1217,8 @@ function manifestPathToTargetPath(relativePath, governanceRoot) {
1187
1217
  return relativePath;
1188
1218
  }
1189
1219
  function targetPathToManifestPath(relativePath, governanceRoot) {
1190
- if (governanceRoot !== "docs" && relativePath.startsWith(`${governanceRoot}/`)) {
1220
+ if (governanceRoot !== "docs" &&
1221
+ relativePath.startsWith(`${governanceRoot}/`)) {
1191
1222
  return `docs/${relativePath.slice(`${governanceRoot}/`.length)}`;
1192
1223
  }
1193
1224
  if (relativePath.startsWith(".agents/skills/")) {
@@ -1197,7 +1228,9 @@ function targetPathToManifestPath(relativePath, governanceRoot) {
1197
1228
  }
1198
1229
  async function readPackageVersion(assetRoot) {
1199
1230
  const packageJson = JSON.parse(await readFile(path.join(assetRoot, "package.json"), "utf-8"));
1200
- return typeof packageJson.version === "string" ? packageJson.version : "0.0.0";
1231
+ return typeof packageJson.version === "string"
1232
+ ? packageJson.version
1233
+ : "0.0.0";
1201
1234
  }
1202
1235
  async function readInitSurfaceManifest(assetRoot) {
1203
1236
  const manifestPath = path.join(assetRoot, "docs", "init-surface.manifest.json");
@@ -1275,6 +1308,7 @@ function inferInitSurfaceMode(relativePath) {
1275
1308
  return "managed-block";
1276
1309
  }
1277
1310
  if (relativePath === "harness.json" ||
1311
+ relativePath === OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH ||
1278
1312
  relativePath.startsWith("scripts/") ||
1279
1313
  relativePath.startsWith(".harness/prompts/") ||
1280
1314
  relativePath.startsWith("docs/README.md") ||
@@ -1321,6 +1355,9 @@ async function buildDesiredSurfaceContent(input) {
1321
1355
  }), null, 2)}\n`,
1322
1356
  };
1323
1357
  }
1358
+ if (manifestPath === OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH) {
1359
+ return { content: buildOpenCodeTransientRetryPluginSource() };
1360
+ }
1324
1361
  if (manifestPath.startsWith("scripts/")) {
1325
1362
  const scripts = buildInitScriptFiles(input.governanceRoot);
1326
1363
  return { content: scripts[manifestPath] };
@@ -1335,7 +1372,10 @@ async function buildDesiredSurfaceContent(input) {
1335
1372
  const sourceManifestPath = manifestPath.slice(".agents/".length);
1336
1373
  const mirrorSourcePath = path.join(input.assetRoot, sourceManifestPath);
1337
1374
  if (await exists(mirrorSourcePath)) {
1338
- return { content: await readFile(mirrorSourcePath, "utf-8"), sourcePath: mirrorSourcePath };
1375
+ return {
1376
+ content: await readFile(mirrorSourcePath, "utf-8"),
1377
+ sourcePath: mirrorSourcePath,
1378
+ };
1339
1379
  }
1340
1380
  return {};
1341
1381
  }
@@ -1350,7 +1390,10 @@ async function buildDesiredSurfaceContent(input) {
1350
1390
  return { content: generated };
1351
1391
  const mappedSourcePath = path.join(input.assetRoot, "docs", coreDocSourceForTarget(doc));
1352
1392
  if (await exists(mappedSourcePath)) {
1353
- return { content: await readFile(mappedSourcePath, "utf-8"), sourcePath: mappedSourcePath };
1393
+ return {
1394
+ content: await readFile(mappedSourcePath, "utf-8"),
1395
+ sourcePath: mappedSourcePath,
1396
+ };
1354
1397
  }
1355
1398
  }
1356
1399
  const sourcePath = path.join(input.assetRoot, manifestPath);
@@ -1392,7 +1435,10 @@ export async function readRecordedSurfaceControllerVersion(repoRoot) {
1392
1435
  }
1393
1436
  if (!state || typeof state.controllerVersion !== "string")
1394
1437
  return undefined;
1395
- return { controllerVersion: state.controllerVersion, stateKind: state.stateKind };
1438
+ return {
1439
+ controllerVersion: state.controllerVersion,
1440
+ stateKind: state.stateKind,
1441
+ };
1396
1442
  }
1397
1443
  async function buildCurrentSurfaceState(input) {
1398
1444
  const assetRoot = await findPackageRoot();
@@ -1401,7 +1447,10 @@ async function buildCurrentSurfaceState(input) {
1401
1447
  const files = {};
1402
1448
  for (const manifestEntry of manifest.entries) {
1403
1449
  const targetRelativePath = normalizeRelativePath(manifestPathToTargetPath(manifestEntry.path, input.governanceRoot));
1404
- const entry = { ...manifestEntry, path: targetRelativePath };
1450
+ const entry = {
1451
+ ...manifestEntry,
1452
+ path: targetRelativePath,
1453
+ };
1405
1454
  const targetPath = path.join(input.repoRoot, targetRelativePath);
1406
1455
  const targetStat = await stat(targetPath).catch(() => null);
1407
1456
  const desired = await buildDesiredSurfaceContent({
@@ -1416,7 +1465,9 @@ async function buildCurrentSurfaceState(input) {
1416
1465
  status: targetStat ? "present" : "missing",
1417
1466
  relationship: "missing-from-target",
1418
1467
  sourceSha256,
1419
- sourcePath: desired.sourcePath ? repoRelative(assetRoot, desired.sourcePath) : undefined,
1468
+ sourcePath: desired.sourcePath
1469
+ ? repoRelative(assetRoot, desired.sourcePath)
1470
+ : undefined,
1420
1471
  mode: entry.mode,
1421
1472
  };
1422
1473
  if (entry.mode === "state") {
@@ -1434,7 +1485,9 @@ async function buildCurrentSurfaceState(input) {
1434
1485
  if (entry.mode === "directory") {
1435
1486
  files[targetRelativePath] = {
1436
1487
  ...base,
1437
- relationship: targetStat.isDirectory() ? "directory-present" : "local-existing-unknown",
1488
+ relationship: targetStat.isDirectory()
1489
+ ? "directory-present"
1490
+ : "local-existing-unknown",
1438
1491
  };
1439
1492
  continue;
1440
1493
  }
@@ -1448,14 +1501,17 @@ async function buildCurrentSurfaceState(input) {
1448
1501
  files[targetRelativePath] = {
1449
1502
  ...base,
1450
1503
  currentSha256,
1451
- relationship: currentBlock && sourceSha256 && sha256Text(currentBlock) === sourceSha256
1504
+ relationship: currentBlock &&
1505
+ sourceSha256 &&
1506
+ sha256Text(currentBlock) === sourceSha256
1452
1507
  ? "managed-block-current"
1453
1508
  : "managed-block-present",
1454
1509
  };
1455
1510
  continue;
1456
1511
  }
1457
1512
  let semanticallyMatchesGeneratedJson = false;
1458
- if (targetRelativePath === "harness.json" && desired.content !== undefined) {
1513
+ if (targetRelativePath === "harness.json" &&
1514
+ desired.content !== undefined) {
1459
1515
  try {
1460
1516
  semanticallyMatchesGeneratedJson = isDeepStrictEqual(JSON.parse(current.toString("utf-8")), JSON.parse(desired.content));
1461
1517
  }
@@ -1529,7 +1585,9 @@ async function listRelativeFiles(rootPath) {
1529
1585
  const files = [];
1530
1586
  async function walk(currentPath, relativeDir) {
1531
1587
  for (const entry of await readdir(currentPath, { withFileTypes: true })) {
1532
- const relativePath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name;
1588
+ const relativePath = relativeDir
1589
+ ? `${relativeDir}/${entry.name}`
1590
+ : entry.name;
1533
1591
  const absolutePath = path.join(currentPath, entry.name);
1534
1592
  if (entry.isDirectory())
1535
1593
  await walk(absolutePath, relativePath);
@@ -1558,7 +1616,8 @@ async function expectedLegacyInitFileSha(input) {
1558
1616
  }));
1559
1617
  }
1560
1618
  }
1561
- if (!input.legacyPath.startsWith("docs/") && !input.legacyPath.startsWith("skills/")) {
1619
+ if (!input.legacyPath.startsWith("docs/") &&
1620
+ !input.legacyPath.startsWith("skills/")) {
1562
1621
  return undefined;
1563
1622
  }
1564
1623
  if (input.legacyPath.startsWith("docs/")) {
@@ -1572,7 +1631,10 @@ function collectSafeRetiredDirectories(paths) {
1572
1631
  for (const retiredPath of paths) {
1573
1632
  let dir = path.posix.dirname(retiredPath);
1574
1633
  while (dir !== "." && dir !== "/") {
1575
- if (dir === "skills" || dir.startsWith("skills/") || dir === "docs" || dir.startsWith("docs/")) {
1634
+ if (dir === "skills" ||
1635
+ dir.startsWith("skills/") ||
1636
+ dir === "docs" ||
1637
+ dir.startsWith("docs/")) {
1576
1638
  dirs.add(dir);
1577
1639
  }
1578
1640
  dir = path.posix.dirname(dir);
@@ -1583,28 +1645,24 @@ function collectSafeRetiredDirectories(paths) {
1583
1645
  return [...dirs].sort((left, right) => right.split("/").length - left.split("/").length);
1584
1646
  }
1585
1647
  function needsHarnessModelMigration(harness) {
1586
- if ("model" in harness || "models" in harness || "modelProfiles" in harness || "modelRouting" in harness) {
1648
+ if ("model" in harness ||
1649
+ "models" in harness ||
1650
+ "modelProfiles" in harness ||
1651
+ "modelRouting" in harness) {
1587
1652
  return true;
1588
1653
  }
1589
1654
  if (isRecord(harness.executors)) {
1590
1655
  if ("cursor" in harness.executors)
1591
1656
  return true;
1592
1657
  if (isRecord(harness.executors.pi)) {
1593
- return ("requiresApiKey" in harness.executors.pi ||
1594
- "defaultModel" in harness.executors.pi);
1658
+ return "requiresApiKey" in harness.executors.pi;
1595
1659
  }
1596
1660
  }
1597
1661
  return false;
1598
1662
  }
1599
1663
  function migrateHarnessModelFields(harness) {
1600
1664
  const next = { ...harness };
1601
- const legacyModel = typeof next.model === "string"
1602
- ? next.model
1603
- : isRecord(next.executors) &&
1604
- isRecord(next.executors.pi) &&
1605
- typeof next.executors.pi.defaultModel === "string"
1606
- ? next.executors.pi.defaultModel
1607
- : undefined;
1665
+ const legacyModel = typeof next.model === "string" ? next.model : undefined;
1608
1666
  delete next.model;
1609
1667
  delete next.models;
1610
1668
  delete next.modelProfiles;
@@ -1613,7 +1671,6 @@ function migrateHarnessModelFields(harness) {
1613
1671
  delete executors.cursor;
1614
1672
  const pi = isRecord(executors.pi) ? { ...executors.pi } : {};
1615
1673
  delete pi.requiresApiKey;
1616
- delete pi.defaultModel;
1617
1674
  if (legacyModel) {
1618
1675
  for (const level of ["LOW", "MED", "HIGH"]) {
1619
1676
  if (typeof pi[level] !== "string")
@@ -1769,7 +1826,9 @@ async function collectRetiredLayoutActions(input) {
1769
1826
  }
1770
1827
  async function resolveInitProjectContext(input) {
1771
1828
  const harness = await readJsonIfExists(path.join(input.repoRoot, "harness.json"));
1772
- const recordedGovernanceRoot = typeof harness.governanceRoot === "string" ? harness.governanceRoot : undefined;
1829
+ const recordedGovernanceRoot = typeof harness.governanceRoot === "string"
1830
+ ? harness.governanceRoot
1831
+ : undefined;
1773
1832
  return {
1774
1833
  projectName: input.projectName ??
1775
1834
  (typeof harness.project === "string" ? harness.project : undefined) ??
@@ -1816,7 +1875,7 @@ function buildManagedReadmeBlock(input) {
1816
1875
  "默认使用 Agent DAG 作为实现工作流:",
1817
1876
  "",
1818
1877
  "```bash",
1819
- "loop-agent new-task <task-id> \"任务标题\"",
1878
+ 'loop-agent new-task <task-id> "任务标题"',
1820
1879
  "# write .harness/tasks/<task-id>/source/需求.md",
1821
1880
  "# write .harness/tasks/<task-id>/source/执行约束.md",
1822
1881
  "loop-agent dag run-task <task-id> --profile auto --strict-models",
@@ -2005,7 +2064,7 @@ function buildTargetDevelopmentPrinciples(input) {
2005
2064
  "Principle 1 covers **granularity** (one bounded block). This section covers **shape**: each slice should cross the real integration layers the work needs and leave an independently verifiable narrow loop.",
2006
2065
  "",
2007
2066
  "- Every slice needs its own acceptance criteria, verification commands, and failure conditions.",
2008
- "- Prefer vertical tracer bullets over horizontal layering. Paths like \"schema → API → UI → tests\" are *possible* examples only; do not assume every project has those layers.",
2067
+ '- Prefer vertical tracer bullets over horizontal layering. Paths like "schema → API → UI → tests" are *possible* examples only; do not assume every project has those layers.',
2009
2068
  "- Horizontal anti-patterns: finish all of one layer before the next; or write every test first, then implement everything.",
2010
2069
  "- For behavior changes, use one failing test → minimal implementation → green → next behavior. Do not batch all RED then all GREEN.",
2011
2070
  "- Split large features into multiple independently runnable tasks/DAGs instead of one oversized writer across every layer.",
@@ -2048,7 +2107,7 @@ function buildTargetFeatureWorkflow(input) {
2048
2107
  "## Agent DAG Path",
2049
2108
  "",
2050
2109
  "```bash",
2051
- "loop-agent new-task <task-id> \"Task title\"",
2110
+ 'loop-agent new-task <task-id> "Task title"',
2052
2111
  "# write .harness/tasks/<task-id>/source/需求.md",
2053
2112
  "# write .harness/tasks/<task-id>/source/执行约束.md",
2054
2113
  "loop-agent dag run-task <task-id> --profile auto --strict-models",
@@ -2074,11 +2133,11 @@ function buildTargetFeatureWorkflow(input) {
2074
2133
  "",
2075
2134
  "Set an explicit specialized `taskKind` in `.harness/tasks/<task-id>/task.json` only when the dedicated workflow itself is part of the task contract:",
2076
2135
  "",
2077
- "- `taskKind: \"frontend-implementation\"` explicitly selects the frontend DAG for compatibility or intentional override. Optional `frontendMock` config sets `policy: auto|required|disabled`, an existing `serviceRoot`, and generation-time-frozen `verifyCommands`; an unsafe or incomplete explicit required contract produces an assessment-only DAG with no writer, while a complete required contract adds Mock-specific verification only when trusted commands exist. Auto API tasks without a native service may use an existing browser interception harness or reversible request adapter, then continue through static and behavior verification.",
2078
- "- `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.",
2079
- "- `taskKind: \"knowledge-sync\"` selects the Feature-scoped test-knowledge write-back DAG (collect → draft → validate → apply → pointer). Bind `featureId` in `task.json` (or hardConstraints / requirement text). It writes only under `features/<featureId>/…` after final verification evidence exists.",
2080
- "- `taskKind: \"knowledge-graph-bootstrap\"` selects the business knowledge-graph bootstrap DAG (preflight → inventory → propose → validate → review → gate → promote → materialize). AI writes only `knowledge/bootstrap/staging/**`; promote is merge-new-only.",
2081
- "- `taskKind: \"frontend-test\"` selects the FE-test RAG DAG. It writes a traceable frontend RAG package and Markdown case manifest, then executes manifest cases serially with `playwright-cli` in isolated test environments and retains per-case evidence. It never generates pytest or Playwright source code. `frontendTest.maxCasesPerBatch` defaults to 20 (maximum 50); optional `maxTokensPerCase` and `maxTotalTokens` stop only later cases after a completed case's token usage is recorded, marking them `blocked: token-budget-exhausted`. Generated browser startup uses `playwright-cli open --browser=chrome --headed <base-url>`; the generic playwright-cli skill is unchanged.",
2136
+ '- `taskKind: "frontend-implementation"` explicitly selects the frontend DAG for compatibility or intentional override. Optional `frontendMock` config sets `policy: auto|required|disabled`, an existing `serviceRoot`, and generation-time-frozen `verifyCommands`; an unsafe or incomplete explicit required contract produces an assessment-only DAG with no writer, while a complete required contract adds Mock-specific verification only when trusted commands exist. Auto API tasks without a native service may use an existing browser interception harness or reversible request adapter, then continue through static and behavior verification.',
2137
+ '- `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.',
2138
+ '- `taskKind: "knowledge-sync"` selects the Feature-scoped test-knowledge write-back DAG (collect → draft → validate → apply → pointer). Bind `featureId` in `task.json` (or hardConstraints / requirement text). It writes only under `features/<featureId>/…` after final verification evidence exists.',
2139
+ '- `taskKind: "knowledge-graph-bootstrap"` selects the business knowledge-graph bootstrap DAG (preflight → inventory → propose → validate → review → gate → promote → materialize). AI writes only `knowledge/bootstrap/staging/**`; promote is merge-new-only.',
2140
+ '- `taskKind: "frontend-test"` selects the FE-test RAG DAG. It writes a traceable frontend RAG package and Markdown case manifest, then executes manifest cases serially with `playwright-cli` in isolated test environments and retains per-case evidence. It never generates pytest or Playwright source code. `frontendTest.maxCasesPerBatch` defaults to 20 (maximum 50); optional `maxTokensPerCase` and `maxTotalTokens` stop only later cases after a completed case\'s token usage is recorded, marking them `blocked: token-budget-exhausted`. Generated browser startup uses `playwright-cli open --browser=chrome --headed <base-url>`; the generic playwright-cli skill is unchanged.',
2082
2141
  "- 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`.",
2083
2142
  "",
2084
2143
  "Use the package-backed public knowledge CLI for graph operations. Do not require target projects to run package-only kb runtime scripts:",
@@ -2089,14 +2148,14 @@ function buildTargetFeatureWorkflow(input) {
2089
2148
  "loop-agent dag run-task <task-id> # taskKind: knowledge-graph-bootstrap",
2090
2149
  "loop-agent knowledge query --mode by_feature --feature F-2026-004 --json",
2091
2150
  "loop-agent knowledge query --mode by_id --id SVC-order --json",
2092
- "loop-agent knowledge query --mode search --text \"keyword\" --json",
2151
+ 'loop-agent knowledge query --mode search --text "keyword" --json',
2093
2152
  "loop-agent knowledge graph-incremental-prepare --feature F-2026-004 --service <service>",
2094
2153
  "# review the prepared scope/staging; for a manual reviewed promotion:",
2095
2154
  "loop-agent knowledge graph-promote",
2096
2155
  "loop-agent knowledge graph-materialize",
2097
2156
  "```",
2098
2157
  "",
2099
- "Daily Feature test-knowledge write-back still uses `taskKind: \"knowledge-sync\"` with a bound `featureId`, separate from graph bootstrap/incremental entry points.",
2158
+ 'Daily Feature test-knowledge write-back still uses `taskKind: "knowledge-sync"` with a bound `featureId`, separate from graph bootstrap/incremental entry points.',
2100
2159
  "",
2101
2160
  "## Verification",
2102
2161
  "",
@@ -2196,11 +2255,16 @@ function buildGeneratedCoreDoc(input) {
2196
2255
  governanceRoot: input.governanceRoot,
2197
2256
  });
2198
2257
  case "feature-workflow.md":
2199
- return buildTargetFeatureWorkflow({ governanceRoot: input.governanceRoot });
2258
+ return buildTargetFeatureWorkflow({
2259
+ governanceRoot: input.governanceRoot,
2260
+ });
2200
2261
  case "verification-matrix.md":
2201
2262
  return buildTargetVerificationMatrix();
2202
2263
  case "loop-agent-harness.md":
2203
- return buildTargetLoopAgentHarness({ projectName: input.projectName, governanceRoot: input.governanceRoot });
2264
+ return buildTargetLoopAgentHarness({
2265
+ projectName: input.projectName,
2266
+ governanceRoot: input.governanceRoot,
2267
+ });
2204
2268
  default:
2205
2269
  return undefined;
2206
2270
  }
@@ -2258,7 +2322,7 @@ export function buildInitInstructions(input) {
2258
2322
  "",
2259
2323
  "## Apply Defaults",
2260
2324
  "",
2261
- "- Use the current loop-agent harness.json as the default template, but write target project name plus `adapter: \"loop-agent\"`.",
2325
+ '- Use the current loop-agent harness.json as the default template, but write target project name plus `adapter: "loop-agent"`.',
2262
2326
  `- ${DAG_HARD_GATE_TRIGGER}`,
2263
2327
  "- 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.",
2264
2328
  "- 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.",
@@ -2326,7 +2390,9 @@ export async function initializeLoopAgentProject(options) {
2326
2390
  const existingHarness = await readJsonIfExists(path.join(repoRoot, "harness.json"));
2327
2391
  await mkdir(repoRoot, { recursive: true });
2328
2392
  const readmePath = path.join(repoRoot, "README.md");
2329
- const existingReadme = (await exists(readmePath)) ? await readFile(readmePath, "utf-8") : undefined;
2393
+ const existingReadme = (await exists(readmePath))
2394
+ ? await readFile(readmePath, "utf-8")
2395
+ : undefined;
2330
2396
  const readmeContent = existingReadme
2331
2397
  ? mergeManagedBlock(existingReadme, buildManagedReadmeBlock({ projectName, governanceRoot }))
2332
2398
  : buildTargetReadme({ projectName, governanceRoot });
@@ -2355,7 +2421,9 @@ export async function initializeLoopAgentProject(options) {
2355
2421
  skipped,
2356
2422
  });
2357
2423
  const agentsPath = path.join(repoRoot, "AGENTS.md");
2358
- const existingAgents = (await exists(agentsPath)) ? await readFile(agentsPath, "utf-8") : `# AGENTS.md\n`;
2424
+ const existingAgents = (await exists(agentsPath))
2425
+ ? await readFile(agentsPath, "utf-8")
2426
+ : `# AGENTS.md\n`;
2359
2427
  await writeText({
2360
2428
  repoRoot,
2361
2429
  relativePath: "AGENTS.md",
@@ -2377,7 +2445,11 @@ export async function initializeLoopAgentProject(options) {
2377
2445
  skipped,
2378
2446
  });
2379
2447
  for (const doc of CORE_DOC_FILES) {
2380
- const generated = buildGeneratedCoreDoc({ doc: doc.target, projectName, governanceRoot });
2448
+ const generated = buildGeneratedCoreDoc({
2449
+ doc: doc.target,
2450
+ projectName,
2451
+ governanceRoot,
2452
+ });
2381
2453
  if (generated) {
2382
2454
  await writeTextIfMissing({
2383
2455
  repoRoot,
@@ -2443,23 +2515,65 @@ export async function initializeLoopAgentProject(options) {
2443
2515
  }
2444
2516
  await ensureHarnessDirs(repoRoot, written);
2445
2517
  await writeCompatPrompts({ repoRoot, merge, written, skipped });
2446
- await writeInitSurfaceState({ repoRoot, projectName, governanceRoot, stateKind: "recorded" });
2518
+ const clientRecoveryMode = options.clientRecovery ?? "auto";
2519
+ const clientRecovery = clientRecoveryMode === "off"
2520
+ ? undefined
2521
+ : await runClientRecovery({
2522
+ repoRoot,
2523
+ mode: clientRecoveryMode,
2524
+ });
2525
+ if (clientRecovery?.plugin.written) {
2526
+ written.push(OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH);
2527
+ }
2528
+ else if (clientRecoveryMode === "off") {
2529
+ skipped.push(OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH);
2530
+ }
2531
+ await writeInitSurfaceState({
2532
+ repoRoot,
2533
+ projectName,
2534
+ governanceRoot,
2535
+ stateKind: "recorded",
2536
+ });
2447
2537
  written.push(INIT_SURFACE_STATE_PATH);
2448
- return { repoRoot, projectName, governanceRoot, profile, written, skipped };
2538
+ return {
2539
+ repoRoot,
2540
+ projectName,
2541
+ governanceRoot,
2542
+ profile,
2543
+ written,
2544
+ skipped,
2545
+ clientRecovery,
2546
+ };
2449
2547
  }
2450
2548
  function actionForMissing(pathName, state) {
2451
2549
  if (state.mode === "state")
2452
2550
  return undefined;
2453
2551
  if (state.mode === "directory") {
2454
- return { type: "create-directory", path: pathName, reason: "required init directory is missing" };
2552
+ return {
2553
+ type: "create-directory",
2554
+ path: pathName,
2555
+ reason: "required init directory is missing",
2556
+ };
2455
2557
  }
2456
2558
  if (state.mode === "copied") {
2457
- return { type: "copy-missing", path: pathName, reason: "required bundled init file is missing" };
2559
+ return {
2560
+ type: "copy-missing",
2561
+ path: pathName,
2562
+ reason: "required bundled init file is missing",
2563
+ };
2458
2564
  }
2459
2565
  if (state.mode === "generated") {
2460
- return { type: "write-generated-missing", path: pathName, reason: "required generated init file is missing" };
2566
+ return {
2567
+ type: "write-generated-missing",
2568
+ path: pathName,
2569
+ reason: "required generated init file is missing",
2570
+ };
2461
2571
  }
2462
- return { type: "refresh-managed-block", path: pathName, reason: "managed block file is missing or stale" };
2572
+ return {
2573
+ type: "refresh-managed-block",
2574
+ path: pathName,
2575
+ reason: "managed block file is missing or stale",
2576
+ };
2463
2577
  }
2464
2578
  function modelMergeTaskFor(pathName, state, allPaths) {
2465
2579
  return {
@@ -2609,7 +2723,8 @@ export async function checkInitUpdate(input) {
2609
2723
  if (recordedState &&
2610
2724
  isRecord(harness) &&
2611
2725
  governanceRoot !== "docs" &&
2612
- (harness.governanceRoot === "docs" || hasLegacyHarnessGovernancePaths(harness))) {
2726
+ (harness.governanceRoot === "docs" ||
2727
+ hasLegacyHarnessGovernancePaths(harness))) {
2613
2728
  deterministicActions.push({
2614
2729
  type: "migrate-harness-governance-root",
2615
2730
  path: "harness.json",
@@ -2642,12 +2757,19 @@ export async function checkInitUpdate(input) {
2642
2757
  },
2643
2758
  };
2644
2759
  const recommendedNext = recommendedNextFor(partial);
2760
+ // Pi user config is never part of project surface hash; only report semantics.
2761
+ const piInspection = await inspectPiRetryConfig({
2762
+ homeDir: input.homeDir,
2763
+ });
2645
2764
  return {
2646
2765
  ...partial,
2647
2766
  ok: deterministicActions.length === 0 &&
2648
2767
  modelMergeTasks.length === 0 &&
2649
2768
  humanDecisions.length === 0,
2650
2769
  recommendedNext,
2770
+ clientRecovery: {
2771
+ pi: piInspection,
2772
+ },
2651
2773
  };
2652
2774
  }
2653
2775
  async function applySafeAction(input) {
@@ -2672,7 +2794,9 @@ async function applySafeAction(input) {
2672
2794
  if (!(await exists(target)))
2673
2795
  return false;
2674
2796
  const harness = JSON.parse(await readFile(target, "utf-8"));
2675
- if (!isRecord(harness) || !isRecord(harness.executors) || !isRecord(harness.executors.pi)) {
2797
+ if (!isRecord(harness) ||
2798
+ !isRecord(harness.executors) ||
2799
+ !isRecord(harness.executors.pi)) {
2676
2800
  return false;
2677
2801
  }
2678
2802
  if (!Object.hasOwn(harness.executors.pi, "requiresApiKey")) {
@@ -2706,7 +2830,8 @@ async function applySafeAction(input) {
2706
2830
  return false;
2707
2831
  const harness = JSON.parse(await readFile(target, "utf-8"));
2708
2832
  if (!isRecord(harness) ||
2709
- (harness.governanceRoot !== "docs" && !hasLegacyHarnessGovernancePaths(harness))) {
2833
+ (harness.governanceRoot !== "docs" &&
2834
+ !hasLegacyHarnessGovernancePaths(harness))) {
2710
2835
  return false;
2711
2836
  }
2712
2837
  const next = {
@@ -2782,11 +2907,16 @@ async function applySafeAction(input) {
2782
2907
  return false;
2783
2908
  await mkdir(path.dirname(target), { recursive: true });
2784
2909
  if (input.action.type === "refresh-managed-block") {
2785
- const existing = (await exists(target)) ? await readFile(target, "utf-8") : "";
2910
+ const existing = (await exists(target))
2911
+ ? await readFile(target, "utf-8")
2912
+ : "";
2786
2913
  const next = input.action.path === ".gitignore"
2787
2914
  ? mergeGitignoreManagedBlock(existing, desired.content)
2788
2915
  : input.action.path === "README.md" && existing.trim().length === 0
2789
- ? buildTargetReadme({ projectName: input.projectName, governanceRoot: input.governanceRoot })
2916
+ ? buildTargetReadme({
2917
+ projectName: input.projectName,
2918
+ governanceRoot: input.governanceRoot,
2919
+ })
2790
2920
  : input.action.path === "AGENTS.md" && existing.trim().length === 0
2791
2921
  ? `# AGENTS.md\n\n${desired.content}\n`
2792
2922
  : mergeManagedBlock(existing, desired.content);
@@ -2811,8 +2941,14 @@ export async function applyInitUpdate(input) {
2811
2941
  });
2812
2942
  const applied = [];
2813
2943
  const skipped = [];
2944
+ const clientRecoveryMode = input.clientRecovery ?? "auto";
2814
2945
  if (input.bootstrapSurface) {
2815
- await writeInitSurfaceState({ repoRoot, projectName, governanceRoot, stateKind: "inferred-baseline" });
2946
+ await writeInitSurfaceState({
2947
+ repoRoot,
2948
+ projectName,
2949
+ governanceRoot,
2950
+ stateKind: "inferred-baseline",
2951
+ });
2816
2952
  applied.push({
2817
2953
  type: "bootstrap-surface",
2818
2954
  path: INIT_SURFACE_STATE_PATH,
@@ -2823,8 +2959,16 @@ export async function applyInitUpdate(input) {
2823
2959
  const existingSurface = await readExistingSurfaceState(repoRoot);
2824
2960
  // Preserve source strength: recorded stays recorded so unchanged owned files
2825
2961
  // remain deterministic refresh candidates; bootstrap/inferred stays inferred.
2826
- const preservedStateKind = existingSurface?.stateKind === "recorded" ? "recorded" : "inferred-baseline";
2827
- const report = await checkInitUpdate({ repoRoot, projectName, governanceRoot });
2962
+ const preservedStateKind = existingSurface?.stateKind === "recorded"
2963
+ ? "recorded"
2964
+ : "inferred-baseline";
2965
+ const report = await checkInitUpdate({
2966
+ repoRoot,
2967
+ projectName,
2968
+ governanceRoot,
2969
+ clientRecovery: clientRecoveryMode,
2970
+ homeDir: input.homeDir,
2971
+ });
2828
2972
  for (const action of report.deterministicActions) {
2829
2973
  if (action.type === "bootstrap-surface") {
2830
2974
  skipped.push(action);
@@ -2846,12 +2990,36 @@ export async function applyInitUpdate(input) {
2846
2990
  preserveOwnershipFrom: existingSurface,
2847
2991
  });
2848
2992
  }
2993
+ // Pi user config is only mutated with explicit --client-recovery=user.
2994
+ // Do not reinstall the project plugin here — that would bypass ownership and
2995
+ // clobber user-modified plugins; plugin updates stay on deterministic actions.
2996
+ if (clientRecoveryMode === "user") {
2997
+ await applyPiRetryMerge({
2998
+ homeDir: input.homeDir ?? os.homedir(),
2999
+ });
3000
+ }
2849
3001
  return {
2850
3002
  applied,
2851
3003
  skipped,
2852
- report: await checkInitUpdate({ repoRoot, projectName, governanceRoot }),
3004
+ report: await checkInitUpdate({
3005
+ repoRoot,
3006
+ projectName,
3007
+ governanceRoot,
3008
+ clientRecovery: clientRecoveryMode,
3009
+ homeDir: input.homeDir,
3010
+ }),
2853
3011
  };
2854
3012
  }
3013
+ function formatClientRecoverySummary(report) {
3014
+ if (!report.clientRecovery?.pi)
3015
+ return [];
3016
+ const pi = report.clientRecovery.pi;
3017
+ return [
3018
+ `clientRecovery.pi.reason: ${pi.reason}`,
3019
+ `clientRecovery.pi.action: ${pi.action}`,
3020
+ `clientRecovery.pi.path: ${pi.path}`,
3021
+ ];
3022
+ }
2855
3023
  function formatCheckUpdateText(report) {
2856
3024
  return [
2857
3025
  `loop-agent init update check for ${report.repoRoot}`,
@@ -2860,17 +3028,22 @@ function formatCheckUpdateText(report) {
2860
3028
  `deterministicActions: ${report.deterministicActions.length}`,
2861
3029
  `modelMergeTasks: ${report.modelMergeTasks.length}`,
2862
3030
  `humanDecisions: ${report.humanDecisions.length}`,
3031
+ ...formatClientRecoverySummary(report),
2863
3032
  "recommendedNext:",
2864
3033
  ...report.recommendedNext.map((item) => `- ${item}`),
2865
3034
  ].join("\n");
2866
3035
  }
2867
3036
  function formatCheckUpdateMarkdown(report) {
3037
+ const piLines = formatClientRecoverySummary(report).map((line) => `- ${line}`);
2868
3038
  const lines = [
2869
3039
  "# loop-agent init check-update",
2870
3040
  "",
2871
3041
  `- repoRoot: \`${report.repoRoot}\``,
2872
3042
  `- controllerVersion: \`${report.controllerVersion}\``,
2873
3043
  `- surfaceState: \`${report.surfaceState}\``,
3044
+ ...(piLines.length > 0
3045
+ ? ["", "## Client Recovery (Pi user config, read-only)", "", ...piLines]
3046
+ : []),
2874
3047
  "",
2875
3048
  "## Deterministic Actions",
2876
3049
  "",
@@ -2907,21 +3080,29 @@ export async function runInitDoctor(input) {
2907
3080
  add("harness.json", false, error instanceof Error ? error.message : String(error));
2908
3081
  }
2909
3082
  const agents = path.join(repoRoot, "AGENTS.md");
2910
- add("AGENTS.md loop-agent block", (await exists(agents)) && (await readFile(agents, "utf-8")).includes(MANAGED_BLOCK_START), "managed block present");
3083
+ add("AGENTS.md loop-agent block", (await exists(agents)) &&
3084
+ (await readFile(agents, "utf-8")).includes(MANAGED_BLOCK_START), "managed block present");
2911
3085
  const readme = path.join(repoRoot, "README.md");
2912
- add("README loop-agent block", (await exists(readme)) && (await readFile(readme, "utf-8")).includes(MANAGED_BLOCK_START), "managed block present");
3086
+ add("README loop-agent block", (await exists(readme)) &&
3087
+ (await readFile(readme, "utf-8")).includes(MANAGED_BLOCK_START), "managed block present");
2913
3088
  add("repo-local skills", await exists(path.join(repoRoot, ".agents", "skills", "loop-agent", "SKILL.md")), ".agents/skills/loop-agent/SKILL.md");
2914
3089
  const gitignorePath = path.join(repoRoot, ".gitignore");
2915
- const gitignoreContent = (await exists(gitignorePath)) ? await readFile(gitignorePath, "utf-8") : "";
2916
- add("gitignore loop-agent block", gitignoreContent.includes(GITIGNORE_BLOCK_START) && gitignoreContent.includes(".harness/tasks/*"), ".gitignore managed runtime ignores");
3090
+ const gitignoreContent = (await exists(gitignorePath))
3091
+ ? await readFile(gitignorePath, "utf-8")
3092
+ : "";
3093
+ add("gitignore loop-agent block", gitignoreContent.includes(GITIGNORE_BLOCK_START) &&
3094
+ gitignoreContent.includes(".harness/tasks/*"), ".gitignore managed runtime ignores");
2917
3095
  const requiredScripts = Object.keys(INIT_SCRIPT_FILES);
2918
3096
  const missingScripts = [];
2919
3097
  for (const script of requiredScripts) {
2920
3098
  if (!(await exists(path.join(repoRoot, script))))
2921
3099
  missingScripts.push(script);
2922
3100
  }
2923
- add("script matrix", missingScripts.length === 0, missingScripts.length === 0 ? `${requiredScripts.length} scripts` : `missing: ${missingScripts.join(", ")}`);
2924
- add("compat prompts", await exists(path.join(repoRoot, ".harness", "prompts", "analyze.md")) && await exists(path.join(repoRoot, ".harness", "prompts", "plan.md")), ".harness/prompts/analyze.md");
3101
+ add("script matrix", missingScripts.length === 0, missingScripts.length === 0
3102
+ ? `${requiredScripts.length} scripts`
3103
+ : `missing: ${missingScripts.join(", ")}`);
3104
+ add("compat prompts", (await exists(path.join(repoRoot, ".harness", "prompts", "analyze.md"))) &&
3105
+ (await exists(path.join(repoRoot, ".harness", "prompts", "plan.md"))), ".harness/prompts/analyze.md");
2925
3106
  add("harness runtime dirs", await exists(path.join(repoRoot, ".harness", "dag-runs", "active")), ".harness/dag-runs/active");
2926
3107
  return {
2927
3108
  ok: checks.every((check) => check.ok),
@@ -2961,7 +3142,12 @@ export async function runInitReconcile(input) {
2961
3142
  : update.report.modelMergeTasks.length > 0
2962
3143
  ? "needs-model-merge"
2963
3144
  : "needs-safe-update";
2964
- return { status, report: update.report, applied: update.applied, skipped: update.skipped };
3145
+ return {
3146
+ status,
3147
+ report: update.report,
3148
+ applied: update.applied,
3149
+ skipped: update.skipped,
3150
+ };
2965
3151
  }
2966
3152
  function parseInitArgs(repoRoot, args) {
2967
3153
  let subcommand;
@@ -2971,13 +3157,19 @@ function parseInitArgs(repoRoot, args) {
2971
3157
  let merge = true;
2972
3158
  let provider;
2973
3159
  let model;
3160
+ let clientRecovery = "auto";
2974
3161
  let json = false;
2975
3162
  let markdown = false;
2976
3163
  let bootstrapSurface = false;
2977
3164
  let applySafe = false;
2978
3165
  for (let i = 0; i < args.length; i += 1) {
2979
3166
  const arg = args[i];
2980
- if ((arg === "instructions" || arg === "doctor" || arg === "check-update" || arg === "update" || arg === "reconcile") && !subcommand) {
3167
+ if ((arg === "instructions" ||
3168
+ arg === "doctor" ||
3169
+ arg === "check-update" ||
3170
+ arg === "update" ||
3171
+ arg === "reconcile") &&
3172
+ !subcommand) {
2981
3173
  subcommand = arg;
2982
3174
  continue;
2983
3175
  }
@@ -3005,6 +3197,10 @@ function parseInitArgs(repoRoot, args) {
3005
3197
  model = args[++i];
3006
3198
  else if (arg.startsWith("--model="))
3007
3199
  model = arg.slice("--model=".length);
3200
+ else if (arg === "--client-recovery")
3201
+ clientRecovery = parseClientRecoveryMode(args[++i]);
3202
+ else if (arg.startsWith("--client-recovery="))
3203
+ clientRecovery = parseClientRecoveryMode(arg.slice("--client-recovery=".length));
3008
3204
  else if (arg === "--json")
3009
3205
  json = true;
3010
3206
  else if (arg === "--markdown")
@@ -3028,6 +3224,7 @@ function parseInitArgs(repoRoot, args) {
3028
3224
  merge,
3029
3225
  provider,
3030
3226
  model,
3227
+ clientRecovery,
3031
3228
  subcommand,
3032
3229
  json,
3033
3230
  markdown,
@@ -3063,7 +3260,9 @@ export async function runInit(repoRoot, rawArgs, dependencies) {
3063
3260
  throw new Error("usage: init update [--bootstrap-surface] [--apply-safe]");
3064
3261
  }
3065
3262
  const result = await applyInitUpdate(parsed);
3066
- console.log(parsed.json ? JSON.stringify(result, null, 2) : formatInitUpdateResult(result));
3263
+ console.log(parsed.json
3264
+ ? JSON.stringify(result, null, 2)
3265
+ : formatInitUpdateResult(result));
3067
3266
  return;
3068
3267
  }
3069
3268
  if (parsed.subcommand === "reconcile") {
@@ -3071,7 +3270,9 @@ export async function runInit(repoRoot, rawArgs, dependencies) {
3071
3270
  ...parsed,
3072
3271
  readRuntimeActivity: dependencies.readRuntimeActivity,
3073
3272
  });
3074
- console.log(parsed.json ? JSON.stringify(result, null, 2) : formatReconcileResult(result));
3273
+ console.log(parsed.json
3274
+ ? JSON.stringify(result, null, 2)
3275
+ : formatReconcileResult(result));
3075
3276
  return;
3076
3277
  }
3077
3278
  const result = await initializeLoopAgentProject(parsed);