@tea-agent/loop-agent 0.44.0-next.13 → 0.44.0-next.14

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 (145) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +12 -12
  3. package/dist/adapters/loop-agent.js +34 -0
  4. package/dist/build-stamp.json +3 -3
  5. package/dist/cli/command-definitions.js +5 -5
  6. package/dist/cli/program.js +1 -4
  7. package/dist/commands/init-legacy-audit.js +330 -0
  8. package/dist/commands/init-upgrade.js +644 -104
  9. package/dist/commands/init.js +685 -330
  10. package/dist/commands/operator-guide.js +99 -0
  11. package/dist/commands/operator.js +16 -3
  12. package/dist/executors/pi-sdk-executor.js +3 -1
  13. package/dist/governance/harness.js +2 -7
  14. package/dist/governance/manifest-types.js +28 -9
  15. package/dist/governance/project-capability-scanner.js +263 -0
  16. package/dist/governance/project-config.js +96 -0
  17. package/dist/governance/resource-catalog.js +151 -0
  18. package/dist/infrastructure/console/app-data.js +6 -2
  19. package/dist/shared/operator/capabilities.js +2 -2
  20. package/dist/shared/package-metadata.js +23 -7
  21. package/dist/shared/pi-context-pressure/extension.js +23 -0
  22. package/dist/shared/pi-context-pressure/index.js +1 -0
  23. package/dist/shared/pi-context-pressure/telemetry.js +4 -0
  24. package/dist/shared/pi-context-pressure/tool-result-pruner.js +98 -0
  25. package/dist/shared/update/npm-client.js +49 -1
  26. package/dist/worker/cli.js +3 -5
  27. package/dist/worker/console/chat/browser-automation.js +114 -5
  28. package/dist/worker/console/chat/browser-routes.js +21 -0
  29. package/dist/worker/console/chat/chat-event-store.js +7 -6
  30. package/dist/worker/console/chat/context-insights.js +84 -19
  31. package/dist/worker/console/chat/pet/pet-affinity.js +169 -0
  32. package/dist/worker/console/chat/pet/pet-persist.js +109 -0
  33. package/dist/worker/console/chat/pet/pet-registry.js +142 -0
  34. package/dist/worker/console/chat/pet/pet-remarks.js +48 -0
  35. package/dist/worker/console/chat/pet/pet-routes.js +125 -0
  36. package/dist/worker/console/chat/pet/pet-service.js +262 -0
  37. package/dist/worker/console/chat/pet/pet-state.js +99 -0
  38. package/dist/worker/console/chat/pi-mode-loop-isolation.js +17 -2
  39. package/dist/worker/console/chat/pi-runtime/custom-tools/browser-tools.js +2 -2
  40. package/dist/worker/console/chat/pi-runtime.js +7 -2
  41. package/dist/worker/console/chat/routes.js +43 -7
  42. package/dist/worker/console/chat/scheduled-goal-request.js +14 -6
  43. package/dist/worker/console/chat/session-store.js +20 -3
  44. package/dist/worker/console/console-shutdown.js +34 -0
  45. package/dist/worker/console/server.js +2 -1
  46. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-BA7qCL_r.js → abnfDiagram-N423BO3Z-DASc69AM.js} +1 -1
  47. package/dist/worker/console/static/assets/{arc-Df09ajwL.js → arc-h09p-LAc.js} +1 -1
  48. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-CpF9sw5R.js → architectureDiagram-T3A2C74G-CZ3dtLkh.js} +1 -1
  49. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-BtPys8ja.js → blockDiagram-VBNYF7ZC-DMrRhMXG.js} +1 -1
  50. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DZPnTWqT.js → c4Diagram-5PPSVZJV-DQxisHV6.js} +1 -1
  51. package/dist/worker/console/static/assets/channel-D1zgKZfg.js +1 -0
  52. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-BD9D4h1p.js → chunk-2GRJ4B5K-CefVN9zh.js} +1 -1
  53. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-Svn8yBmn.js → chunk-2Q5K7J3B-DmR1Koq2.js} +1 -1
  54. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-B8EbQj-S.js → chunk-5RXB4S5H-NlzrSW7L.js} +1 -1
  55. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-tENZpcpF.js → chunk-5VM5RSS4-DfelJv-0.js} +1 -1
  56. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-DBx71Lco.js → chunk-6Q2QTUOP-BK6RFy58.js} +1 -1
  57. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-1v_QamMz.js → chunk-GF5L2VYU-BAeh9lz4.js} +1 -1
  58. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-BAMCNNBG.js → chunk-JWPE2WC7-ChPS5WzG.js} +1 -1
  59. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-BvuEury-.js → chunk-KBJHAD2P-BoVe5IM6.js} +1 -1
  60. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-YqFuHp8s.js → chunk-RYQCIY6F-C4q_0Lw9.js} +1 -1
  61. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-ouI5T7Mr.js → chunk-XXDRQBXY-Bb9TvjeY.js} +1 -1
  62. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-CXNNsY2M.js +1 -0
  63. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-CXNNsY2M.js +1 -0
  64. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-Bri0_Izk.js → cose-bilkent-JH36ORCC-DmNnL8Ff.js} +1 -1
  65. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-e8Vhhi4X.js → cynefin-VYW2F7L2-Contvx58.js} +1 -1
  66. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-tSIXGQiJ.js → cynefinDiagram-MW4NZA55-Cigq7cjk.js} +1 -1
  67. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-Rl-sF5pt.js → dagre-VZM6K2ZE-dEbC0nxb.js} +1 -1
  68. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-Db6ddPz8.js → diagram-7IWD3JNH-CzIztESV.js} +1 -1
  69. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-Jwd_Xw5S.js → diagram-B4RE2ZJO-Bo14zbaY.js} +1 -1
  70. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-CeC_8InC.js → diagram-LBJQPF4R-C4xUyil6.js} +1 -1
  71. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-LKPRaNq8.js → diagram-Q27KOJAE-CaBY_uYa.js} +1 -1
  72. package/dist/worker/console/static/assets/{diagram-UB23O5K3-DlVQytMW.js → diagram-UB23O5K3-ClUXUeiu.js} +1 -1
  73. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-DnfJZwO-.js → ebnfDiagram-BXEA7PRR-BbGCgjC6.js} +1 -1
  74. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-3axVSXca.js → erDiagram-JOGREHBK-Dr-AcwSl.js} +1 -1
  75. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-xe04-_L-.js → flowDiagram-UKHOOZJN-BG8Gcfyc.js} +1 -1
  76. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-hpmRINiD.js → ganttDiagram-PKOTCBZU-CFVaPs5r.js} +1 -1
  77. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-6HSNz9Ko.js → gitGraphDiagram-DS77QQ5N-ChJ8pm-O.js} +1 -1
  78. package/dist/worker/console/static/assets/index-B_5GhVIO.js +488 -0
  79. package/dist/worker/console/static/assets/index-C7YMBluX.css +1 -0
  80. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-BF6ksWTX.js → infoDiagram-6WML65LV-C5J9-uCZ.js} +1 -1
  81. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-DXisdeD7.js → ishikawaDiagram-WSZJBQD7-BcfjqzDv.js} +1 -1
  82. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-BWu65wmu.js → journeyDiagram-NVQOT4AX-BzzCO-rD.js} +1 -1
  83. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-hfNhHqAI.js → kanban-definition-27J2QSJJ-NCHUZ46h.js} +1 -1
  84. package/dist/worker/console/static/assets/{linear-D6HTfQcp.js → linear-BKZlWCUU.js} +1 -1
  85. package/dist/worker/console/static/assets/{mermaid.core-Iv91R1WP.js → mermaid.core-DbTaQjFE.js} +5 -5
  86. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-CfPChwjR.js → mindmap-definition-FAOFIHXS-CroYl5xQ.js} +1 -1
  87. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-B7TcoqHz.js → pegDiagram-VL7TDLO6-DgMBx-9k.js} +1 -1
  88. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-ouyPKMtQ.js → pieDiagram-7S7Q4E2Y-CyWDmbq1.js} +1 -1
  89. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-B9tuyLbh.js → quadrantDiagram-CIZ2JOQS-BgGEOqSP.js} +1 -1
  90. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-Ck4q9nJs.js → railroadDiagram-AXF67PYL-BMZ7oeeV.js} +1 -1
  91. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-wXLX5Dnp.js → requirementDiagram-LRYGKXZP-DxqyGhcc.js} +1 -1
  92. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-D2XWw6_m.js → sankeyDiagram-W5VNT64P-DnSvNCG8.js} +1 -1
  93. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-DzxKiug4.js → sequenceDiagram-SI44F4Z6-BiZh6f-R.js} +1 -1
  94. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-DWUqK5Us.js → sizeCapture-X5ZJPWSS-BKXSI8WB.js} +1 -1
  95. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-D5Pa4O5g.js → stateDiagram-OKZ733FA-CfSgoYFm.js} +1 -1
  96. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DgHi3vjb.js +1 -0
  97. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-VH7cRnsJ.js → swimlanes-SLNWSIFB-CyGADcHe.js} +2 -2
  98. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Bgvdt1Y0.js +8 -0
  99. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-C4HM5NoZ.js → timeline-definition-Z64GVDOM-HmPaSPOK.js} +1 -1
  100. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-Bu-4hOlA.js → vennDiagram-T6HMQDX7-VA2VBbez.js} +1 -1
  101. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CasFEvvl.js → wardleyDiagram-T6FBY63Y-BxjiBPZa.js} +1 -1
  102. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-D-gpwPSA.js → xychartDiagram-ELKLHX3M--hUY-qFj.js} +1 -1
  103. package/dist/worker/console/static/index.html +2 -2
  104. package/dist/worker/console/static/pets/ember/pet.json +167 -0
  105. package/dist/worker/console/static/pets/ember/spritesheet.png +0 -0
  106. package/dist/worker/console/static-src/operator-chat/details-rail-surfaces.js +4 -3
  107. package/dist/worker/console/static-src/operator-chat/use-stat-dialog.js +95 -0
  108. package/dist/worker/console/workspace-context.js +14 -0
  109. package/dist/worker/console/workspace-initialization.js +25 -16
  110. package/dist/worker/delivery/package.js +4 -1
  111. package/dist/worker/observe/static/operator-chrome.js +1 -42
  112. package/dist/workflows/dag/backend-test-pytest-collection.js +4 -1
  113. package/dist/workflows/dag/final-verification.js +45 -10
  114. package/dist/workflows/dag/hybrid/sources.js +14 -14
  115. package/dist/workflows/dag/hybrid/templates/backend-test.js +5 -5
  116. package/dist/workflows/dag/hybrid/templates/frontend-test.js +4 -4
  117. package/dist/workflows/dag/hybrid/templates/kg-bootstrap.js +6 -6
  118. package/dist/workflows/dag/hybrid/templates/knowledge-sync.js +5 -5
  119. package/dist/workflows/dag/skill-instructions.js +102 -32
  120. package/dist/workflows/dag/skill-snapshot.js +3 -0
  121. package/dist/workflows/dag/skills.js +7 -7
  122. package/dist/workflows/dag/validate.js +2 -2
  123. package/dist/workflows/dag/workspace-checkpoint.js +13 -1
  124. package/docs/architecture/runtime-boundaries.md +28 -22
  125. package/docs/init-surface.manifest.json +43 -295
  126. package/docs/templates/agent-dag.base.json +9 -9
  127. package/docs/templates/agent-dag.final-verification.json +6 -6
  128. package/docs/templates/agent-dag.supervised-implementation.json +12 -12
  129. package/docs/templates/frontend-implementation-dag.json +2 -2
  130. package/docs/templates/frontend-test-dag.json +2 -2
  131. package/docs/templates/hybrid-dag.json +9 -9
  132. package/docs/templates/knowledge-sync-dag.json +8 -8
  133. package/package.json +6 -2
  134. package/resource-catalog.v1.json +1973 -0
  135. package/resources/agent-bridge/loop-agent/SKILL.md +40 -0
  136. package/resources/init-history/legacy-surface.v1.json +33 -0
  137. package/skills/init-update/SKILL.md +34 -0
  138. package/skills/init-update/agents/openai.yaml +6 -0
  139. package/dist/worker/console/static/assets/channel-DiTNLZUm.js +0 -1
  140. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DgL8aiyZ.js +0 -1
  141. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DgL8aiyZ.js +0 -1
  142. package/dist/worker/console/static/assets/index-C_8_gWal.css +0 -1
  143. package/dist/worker/console/static/assets/index-DybcVXCu.js +0 -488
  144. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-C0QIbqO4.js +0 -1
  145. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-BB9CzRHB.js +0 -8
@@ -5,15 +5,21 @@ import { lstat, mkdtemp, mkdir, readFile, readdir, readlink, realpath, rename, r
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
+ import semver from "semver";
8
9
  import { resolveAdapter } from "../adapters/index.js";
9
10
  import { createNpmUpdateClient, } from "../shared/update/npm-client.js";
10
11
  import { auditDocs } from "../governance/checks.js";
11
12
  import { harnessManifestSchema } from "../governance/manifest-types.js";
13
+ import { ProjectCapabilityScanner } from "../governance/project-capability-scanner.js";
14
+ import { loadNormalizedProjectConfig } from "../governance/project-config.js";
15
+ import { CONTROLLER_RESOURCE_IDS, createControllerContextProvider, } from "../governance/resource-catalog.js";
12
16
  import { isInitRuntimeActive, } from "../shared/runtime-activity.js";
13
17
  import { MAX_OVERFLOW_RECOVERIES, MAX_SESSION_RETRIES, OPENCODE_CONTEXT_OVERFLOW_COMPACT_PLUGIN_PATH, OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH, PI_CONTEXT_OVERFLOW_EXTENSION_PATH, PI_PROJECT_SETTINGS_PATH, inspectProjectPiSettings, } from "./client-recovery.js";
14
- import { applyInitUpdate, checkInitUpdate, initializeLoopAgentProject, runInitDoctor, } from "./init.js";
18
+ import { applyInitUpdate, applyInitDecisionBoundActions, checkInitUpdate, initializeLoopAgentProject, runInitDoctor, } from "./init.js";
15
19
  const RUN_ROOT = ".harness/init-upgrades";
16
20
  const GLOBAL_LOCK_NAME = ".writer-lock";
21
+ const PENDING_DECISION_FILE = "decision-application-pending.json";
22
+ const DECISION_REPLACE_REASON = "replace an explicitly approved legacy loop-agent Skill with the minimal external Agent bridge";
17
23
  const INIT_SURFACE_STATE_PATH = ".harness/init-surface.json";
18
24
  const RECOVERY_PATHS = [
19
25
  OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH,
@@ -27,6 +33,80 @@ function sha256(value) {
27
33
  function validRunId(runId) {
28
34
  return /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(runId);
29
35
  }
36
+ async function readRepoLocalDecisionFile(repoRoot, decisionFile) {
37
+ const absolutePath = path.resolve(repoRoot, decisionFile);
38
+ const relativePath = path.relative(repoRoot, absolutePath);
39
+ if (relativePath.length === 0 ||
40
+ relativePath.startsWith(`..${path.sep}`) ||
41
+ path.isAbsolute(relativePath)) {
42
+ throw new Error("init upgrade --decision-file must resolve inside repoRoot");
43
+ }
44
+ const info = await lstat(absolutePath).catch(() => undefined);
45
+ if (!info?.isFile() || info.isSymbolicLink()) {
46
+ throw new Error("init upgrade --decision-file must be a regular non-symlink file");
47
+ }
48
+ return await readFile(absolutePath);
49
+ }
50
+ async function readInitUpgradeDecisionFile(input) {
51
+ const raw = await readRepoLocalDecisionFile(input.repoRoot, input.decisionFile);
52
+ let parsed;
53
+ try {
54
+ parsed = JSON.parse(raw.toString("utf-8"));
55
+ }
56
+ catch {
57
+ throw new Error("init upgrade --decision-file is not valid JSON");
58
+ }
59
+ if (!isRecord(parsed) ||
60
+ parsed.schemaVersion !== 1 ||
61
+ parsed.runId !== input.runId ||
62
+ !Array.isArray(parsed.decisions)) {
63
+ throw new Error("init upgrade --decision-file has an invalid schema or runId binding");
64
+ }
65
+ const decisions = [];
66
+ const decisionPaths = new Set();
67
+ const pendingByPath = new Map(input.pending.map((decision) => [decision.path, decision]));
68
+ for (const value of parsed.decisions) {
69
+ const pending = isRecord(value) && typeof value.path === "string"
70
+ ? pendingByPath.get(value.path)
71
+ : undefined;
72
+ const legacyDisposition = isRecord(value) &&
73
+ (value.disposition === "retain-project-content" ||
74
+ value.disposition === "preserve-unproven" ||
75
+ value.disposition === "delete-listed-file");
76
+ if (!isRecord(value) ||
77
+ typeof value.path !== "string" ||
78
+ (value.disposition !== "detach-as-project-content" &&
79
+ value.disposition !== "replace-with-bridge" &&
80
+ !legacyDisposition) ||
81
+ !isSha256(value.expectedSha256) ||
82
+ decisionPaths.has(value.path) ||
83
+ !pending ||
84
+ (Boolean(pending.candidateId) !== legacyDisposition) ||
85
+ (Boolean(pending.candidateId) &&
86
+ (value.candidateId !== pending.candidateId ||
87
+ value.expectedSha256 !== pending.expectedSha256))) {
88
+ throw new Error("init upgrade --decision-file contains an invalid or duplicate decision");
89
+ }
90
+ decisionPaths.add(value.path);
91
+ decisions.push({
92
+ path: value.path,
93
+ disposition: value.disposition,
94
+ expectedSha256: value.expectedSha256,
95
+ ...(typeof value.candidateId === "string"
96
+ ? { candidateId: value.candidateId }
97
+ : {}),
98
+ });
99
+ }
100
+ const pendingPaths = new Set(input.pending.map((decision) => decision.path));
101
+ if (decisionPaths.size !== pendingPaths.size ||
102
+ [...pendingPaths].some((pendingPath) => !decisionPaths.has(pendingPath))) {
103
+ throw new Error("init upgrade --decision-file must resolve every current human decision exactly once");
104
+ }
105
+ return {
106
+ document: { schemaVersion: 1, runId: input.runId, decisions },
107
+ sha256: sha256(raw),
108
+ };
109
+ }
30
110
  function isRecord(value) {
31
111
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
32
112
  }
@@ -129,8 +209,12 @@ function controllerStateProjection(state) {
129
209
  queuedMergeTasks: cloneJson(state.queuedMergeTasks),
130
210
  ...(state.mergeGuard ? { mergeGuard: cloneJson(state.mergeGuard) } : {}),
131
211
  mergeReceipts: cloneJson(state.mergeReceipts),
212
+ decisionReceipts: cloneJson(state.decisionReceipts ?? []),
132
213
  humanDecisions: cloneJson(state.humanDecisions),
133
214
  actionReceipts: cloneJson(state.actionReceipts),
215
+ coreState: state.coreState,
216
+ migrationState: state.migrationState,
217
+ ...(state.legacyAudit ? { legacyAudit: cloneJson(state.legacyAudit) } : {}),
134
218
  };
135
219
  }
136
220
  function sameJsonValue(left, right) {
@@ -149,6 +233,88 @@ function sameJsonValue(left, right) {
149
233
  return (leftKeys.length === rightKeys.length &&
150
234
  leftKeys.every((key, index) => key === rightKeys[index] && sameJsonValue(left[key], right[key])));
151
235
  }
236
+ function isDecisionAction(value) {
237
+ return (isRecord(value) &&
238
+ typeof value.path === "string" &&
239
+ (value.disposition === "detach-as-project-content" ||
240
+ value.disposition === "replace-with-bridge" ||
241
+ value.disposition === "retain-project-content" ||
242
+ value.disposition === "preserve-unproven" ||
243
+ value.disposition === "delete-listed-file") &&
244
+ isSha256(value.expectedSha256) &&
245
+ (value.candidateId === undefined || typeof value.candidateId === "string"));
246
+ }
247
+ function assertPendingDecisionAuthority(authority, state) {
248
+ if (!authority)
249
+ return undefined;
250
+ const pending = authority.pending;
251
+ if (!isRecord(pending) ||
252
+ pending.schemaVersion !== 1 ||
253
+ pending.runId !== state.runId ||
254
+ pending.repoRoot !== state.repoRoot ||
255
+ !isSha256(pending.decisionFileSha256) ||
256
+ !isRecord(pending.document) ||
257
+ pending.document.schemaVersion !== 1 ||
258
+ pending.document.runId !== state.runId ||
259
+ !Array.isArray(pending.document.decisions) ||
260
+ pending.document.decisions.length === 0 ||
261
+ !pending.document.decisions.every(isDecisionAction) ||
262
+ !isRecord(pending.stateProjection) ||
263
+ !isSha256(pending.stateProjectionSha256) ||
264
+ canonicalSha256(pending.stateProjection) !== pending.stateProjectionSha256 ||
265
+ typeof pending.preparedAt !== "string" ||
266
+ !isSha256(authority.pendingSha256) ||
267
+ canonicalSha256(pending) !== authority.pendingSha256) {
268
+ throw new Error("pending init decision authority is invalid");
269
+ }
270
+ const pre = pending.stateProjection;
271
+ const current = controllerStateProjection(state);
272
+ if (sameJsonValue(current, pre))
273
+ return pending;
274
+ const priorDecisionReceipts = pre.decisionReceipts ?? [];
275
+ const currentDecisionReceipts = current.decisionReceipts ?? [];
276
+ const appended = currentDecisionReceipts.slice(priorDecisionReceipts.length);
277
+ if (currentDecisionReceipts.length !==
278
+ priorDecisionReceipts.length + pending.document.decisions.length ||
279
+ !sameJsonValue(currentDecisionReceipts.slice(0, priorDecisionReceipts.length), priorDecisionReceipts) ||
280
+ appended.some((receipt, index) => {
281
+ const decision = pending.document.decisions[index];
282
+ return (!decision ||
283
+ receipt.path !== decision.path ||
284
+ receipt.disposition !== decision.disposition ||
285
+ receipt.expectedSha256 !== decision.expectedSha256 ||
286
+ receipt.candidateId !== decision.candidateId ||
287
+ receipt.decisionFileSha256 !== pending.decisionFileSha256 ||
288
+ !isSha256(receipt.resultSha256) ||
289
+ typeof receipt.decidedAt !== "string");
290
+ })) {
291
+ throw new Error("pending init decision receipt projection is inconsistent");
292
+ }
293
+ const expectedPost = cloneJson(pre);
294
+ expectedPost.status = "in-progress";
295
+ expectedPost.humanDecisions = [];
296
+ expectedPost.decisionReceipts = cloneJson(currentDecisionReceipts);
297
+ expectedPost.actionReceipts = [
298
+ ...cloneJson(pre.actionReceipts),
299
+ ...pending.document.decisions
300
+ .filter((decision) => decision.disposition === "replace-with-bridge" ||
301
+ decision.disposition === "delete-listed-file")
302
+ .map((decision) => ({
303
+ type: decision.disposition === "replace-with-bridge"
304
+ ? "replace-owned-file"
305
+ : "remove-owned-file",
306
+ path: decision.path,
307
+ expectedSha256: decision.expectedSha256,
308
+ reason: decision.disposition === "replace-with-bridge"
309
+ ? DECISION_REPLACE_REASON
310
+ : "remove an explicitly approved hash-bound legacy init file",
311
+ })),
312
+ ];
313
+ if (!sameJsonValue(current, expectedPost)) {
314
+ throw new Error("pending init decision state differs from controller authority");
315
+ }
316
+ return pending;
317
+ }
152
318
  function isControllerIdentity(value) {
153
319
  if (!isRecord(value))
154
320
  return false;
@@ -205,8 +371,14 @@ function assertStateShape(state, repoRoot, runId) {
205
371
  !Array.isArray(candidate.mergeTasks) ||
206
372
  !Array.isArray(candidate.queuedMergeTasks) ||
207
373
  !Array.isArray(candidate.mergeReceipts) ||
374
+ (candidate.decisionReceipts !== undefined &&
375
+ !Array.isArray(candidate.decisionReceipts)) ||
208
376
  !Array.isArray(candidate.humanDecisions) ||
209
377
  !Array.isArray(candidate.actionReceipts) ||
378
+ (candidate.coreState !== "ready" &&
379
+ candidate.coreState !== "blocked" &&
380
+ candidate.coreState !== "error") ||
381
+ !["clean", "retained", "review-required", "in-progress", "failed"].includes(candidate.migrationState) ||
210
382
  !isHomeFingerprint(candidate.homeFingerprint) ||
211
383
  (candidate.modelCatalog !== undefined &&
212
384
  (!isRecord(candidate.modelCatalog) ||
@@ -257,6 +429,9 @@ function runGitQuery(cwd, args) {
257
429
  function splitNulPaths(output) {
258
430
  return output.split("\0").filter((entry) => entry.length > 0);
259
431
  }
432
+ function quoteGitPath(pathName) {
433
+ return `'${pathName.replaceAll("'", `'"'"'`)}'`;
434
+ }
260
435
  /**
261
436
  * Gitignore migration assessment (PRD 2026-08-13): inventories tracked
262
437
  * `.harness/**` / `.agents/**` with read-only git queries after the managed
@@ -273,7 +448,8 @@ export async function assessGitignoreMigration(repoRoot, input) {
273
448
  needsAgentsReview: false,
274
449
  followUp: [
275
450
  "run `git status --short` and confirm only the intended untrack changes",
276
- "confirm the .harness/ and .agents/ directories still exist locally after untracking",
451
+ "confirm the listed .harness files still exist locally after index-only untracking",
452
+ "keep every tracked .agents path at its existing tracking intent; the bridge remains shared",
277
453
  "run `loop-agent init doctor` to re-check the gitignore loop-agent block",
278
454
  ],
279
455
  };
@@ -329,7 +505,7 @@ export async function assessGitignoreMigration(repoRoot, input) {
329
505
  const harnessTracked = trackedPaths.filter((entry) => entry.startsWith(".harness/"));
330
506
  const agentsTracked = trackedPaths.filter((entry) => entry.startsWith(".agents/"));
331
507
  const stagedRisk = splitNulPaths(staged.stdout);
332
- const needsAgentsReview = agentsTracked.length > 0;
508
+ const needsAgentsReview = false;
333
509
  if (stagedRisk.length > 0) {
334
510
  // Fail closed: staged entries make any untrack advice ambiguous.
335
511
  return {
@@ -344,16 +520,10 @@ export async function assessGitignoreMigration(repoRoot, input) {
344
520
  };
345
521
  }
346
522
  const recommendedCommands = [];
347
- if (harnessTracked.length > 0) {
348
- recommendedCommands.push({
349
- command: "git rm -r --cached --ignore-unmatch .harness",
350
- note: "untracks tracked .harness runtime facts; index-only, working-tree files are kept",
351
- });
352
- }
353
- if (agentsTracked.length > 0) {
523
+ for (const trackedPath of harnessTracked) {
354
524
  recommendedCommands.push({
355
- command: "git rm -r --cached --ignore-unmatch .agents",
356
- note: "untracks tracked .agents local skills; index-only, working-tree files are kept; review team-shareable customizations first",
525
+ command: `git rm --cached --ignore-unmatch -- ${quoteGitPath(trackedPath)}`,
526
+ note: `untracks only ${trackedPath}; the working-tree file is kept`,
357
527
  });
358
528
  }
359
529
  return {
@@ -364,9 +534,7 @@ export async function assessGitignoreMigration(repoRoot, input) {
364
534
  stagedRisk,
365
535
  needsAgentsReview,
366
536
  recommendedCommands,
367
- status: harnessTracked.length > 0 || agentsTracked.length > 0
368
- ? "recommended"
369
- : "clean",
537
+ status: harnessTracked.length > 0 ? "recommended" : "clean",
370
538
  };
371
539
  }
372
540
  function runRoot(repoRoot) {
@@ -440,6 +608,7 @@ async function writeControllerAnchor(input) {
440
608
  let authoritativeHomeFingerprint;
441
609
  let authoritativeGitignoreMigrationSha256;
442
610
  let authoritativeModelCatalogSha256;
611
+ let authoritativeDecisionAuthority;
443
612
  if (input.initialize) {
444
613
  if (await exists(location.file)) {
445
614
  throw new Error("init upgrade controller authority anchor already exists");
@@ -449,8 +618,9 @@ async function writeControllerAnchor(input) {
449
618
  }
450
619
  authoritativeHomeFingerprint = cloneJson(input.state.homeFingerprint);
451
620
  authoritativeModelCatalogSha256 = input.state.modelCatalog
452
- ? canonicalSha256(input.state.modelCatalog)
621
+ ? canonicalSha256(cloneJson(input.state.modelCatalog))
453
622
  : undefined;
623
+ authoritativeDecisionAuthority = undefined;
454
624
  }
455
625
  else {
456
626
  const current = await readControllerAnchor(location.repoRoot, input.state);
@@ -464,6 +634,10 @@ async function writeControllerAnchor(input) {
464
634
  }
465
635
  authoritativeGitignoreMigrationSha256 = digest;
466
636
  authoritativeModelCatalogSha256 = current.modelCatalogSha256;
637
+ authoritativeDecisionAuthority =
638
+ input.decisionAuthority === null
639
+ ? undefined
640
+ : input.decisionAuthority ?? current.decisionAuthority;
467
641
  }
468
642
  await writeJsonAtomic(location.file, {
469
643
  schemaVersion: 1,
@@ -478,6 +652,9 @@ async function writeControllerAnchor(input) {
478
652
  ? { gitignoreMigrationSha256: authoritativeGitignoreMigrationSha256 }
479
653
  : {}),
480
654
  ...(input.mergeAuthority ? { mergeAuthority: input.mergeAuthority } : {}),
655
+ ...(authoritativeDecisionAuthority
656
+ ? { decisionAuthority: authoritativeDecisionAuthority }
657
+ : {}),
481
658
  ...(input.completion ? { completion: input.completion } : {}),
482
659
  });
483
660
  }
@@ -499,7 +676,7 @@ async function readControllerAnchor(repoRoot, state) {
499
676
  throw new Error("init upgrade controller authority anchor binding is invalid");
500
677
  }
501
678
  const expectedModelCatalogSha256 = state.modelCatalog
502
- ? canonicalSha256(state.modelCatalog)
679
+ ? canonicalSha256(cloneJson(state.modelCatalog))
503
680
  : undefined;
504
681
  if (parsed.modelCatalogSha256 !== expectedModelCatalogSha256) {
505
682
  throw new Error("init upgrade model catalog differs from controller authority");
@@ -510,6 +687,59 @@ async function readControllerAnchor(repoRoot, state) {
510
687
  }
511
688
  return parsed;
512
689
  }
690
+ async function readPendingDecisionApplication(input) {
691
+ const pending = assertPendingDecisionAuthority(input.anchor.decisionAuthority, input.state);
692
+ const pendingPath = path.join(input.directory, PENDING_DECISION_FILE);
693
+ if (!pending) {
694
+ await rm(pendingPath, { force: true });
695
+ return undefined;
696
+ }
697
+ let parsed;
698
+ try {
699
+ parsed = JSON.parse(await readFile(pendingPath, "utf-8"));
700
+ }
701
+ catch (error) {
702
+ if (error.code !== "ENOENT") {
703
+ throw new Error("pending init decision record is missing or invalid");
704
+ }
705
+ await writeJsonAtomic(pendingPath, pending);
706
+ parsed = pending;
707
+ }
708
+ if (!sameJsonValue(parsed, pending) ||
709
+ canonicalSha256(parsed) !== input.anchor.decisionAuthority?.pendingSha256) {
710
+ throw new Error("pending init decision record differs from controller authority");
711
+ }
712
+ return pending;
713
+ }
714
+ async function readPendingDecisionProjection(input) {
715
+ const pending = assertPendingDecisionAuthority(input.anchor.decisionAuthority, input.state);
716
+ const pendingPath = path.join(input.directory, PENDING_DECISION_FILE);
717
+ let raw;
718
+ try {
719
+ raw = await readFile(pendingPath, "utf-8");
720
+ }
721
+ catch (error) {
722
+ if (error.code === "ENOENT" && !pending) {
723
+ return undefined;
724
+ }
725
+ throw new Error("pending init decision record is missing or unreadable");
726
+ }
727
+ if (!pending) {
728
+ throw new Error("pending init decision record lacks controller authority");
729
+ }
730
+ let parsed;
731
+ try {
732
+ parsed = JSON.parse(raw);
733
+ }
734
+ catch {
735
+ throw new Error("pending init decision record is invalid");
736
+ }
737
+ if (!sameJsonValue(parsed, pending) ||
738
+ canonicalSha256(parsed) !== input.anchor.decisionAuthority?.pendingSha256) {
739
+ throw new Error("pending init decision record differs from controller authority");
740
+ }
741
+ return pending;
742
+ }
513
743
  function mergeAuthorityFromAnchor(anchor, state) {
514
744
  const authority = anchor.mergeAuthority;
515
745
  if (!authority ||
@@ -633,17 +863,9 @@ async function homeFingerprint() {
633
863
  }
634
864
  }
635
865
  function versionsNewer(candidate, current) {
636
- const parse = (value) => value.match(/^(\d+)\.(\d+)\.(\d+)$/)?.slice(1).map(Number);
637
- const left = parse(candidate);
638
- const right = parse(current);
639
- if (!left || !right)
640
- return false;
641
- for (let index = 0; index < left.length; index += 1) {
642
- if (left[index] === right[index])
643
- continue;
644
- return left[index] > right[index];
645
- }
646
- return false;
866
+ return semver.valid(candidate) !== null && semver.valid(current) !== null
867
+ ? semver.gt(candidate, current)
868
+ : false;
647
869
  }
648
870
  async function readState(repoRoot, runId) {
649
871
  const file = path.join(runDirectory(repoRoot, runId), "state.json");
@@ -684,12 +906,22 @@ function toResult(state, verification, gitignoreMigration) {
684
906
  versionGate: state.versionGate,
685
907
  deterministicActions: state.actionReceipts,
686
908
  modelMergeTasks: state.mergeTasks,
909
+ decisionReceipts: state.decisionReceipts ?? [],
687
910
  humanDecisions: state.humanDecisions,
911
+ coreState: state.coreState,
912
+ migrationState: state.migrationState,
913
+ ...(state.legacyAudit ? { legacyAudit: state.legacyAudit } : {}),
688
914
  nextAction: state.nextAction,
689
915
  ...(verification ? { verification } : {}),
690
916
  ...(gitignoreMigration ? { gitignoreMigration } : {}),
691
917
  };
692
918
  }
919
+ function projectInitStates(state, report) {
920
+ state.coreState = report.coreState;
921
+ state.migrationState = report.migrationState;
922
+ if (report.legacyAudit)
923
+ state.legacyAudit = cloneJson(report.legacyAudit);
924
+ }
693
925
  function renderReport(state, verification, gitignoreMigration) {
694
926
  const lines = [
695
927
  "# loop-agent init upgrade report",
@@ -708,10 +940,13 @@ function renderReport(state, verification, gitignoreMigration) {
708
940
  `- version choice: \`${state.versionGate.choice ?? "pending"}\``,
709
941
  `- deterministic actions applied: ${state.actionReceipts.length}`,
710
942
  `- semantic merges accepted: ${state.mergeReceipts.length}`,
711
- `- preserved user modifications: ${state.mergeReceipts.length}`,
943
+ `- decision-bound dispositions accepted: ${(state.decisionReceipts ?? []).length}`,
944
+ `- preserved user modifications: ${state.mergeReceipts.length + (state.decisionReceipts ?? []).filter((receipt) => receipt.disposition === "detach-as-project-content").length}`,
712
945
  `- retired path actions: ${state.actionReceipts.filter((action) => action.type === "migrate-owned-file" || action.type === "remove-owned-file" || action.type === "remove-empty-directory").length}`,
713
946
  `- remaining model merge tasks: ${state.mergeTasks.length + state.queuedMergeTasks.length}`,
714
947
  `- remaining human decisions: ${state.humanDecisions.length}`,
948
+ `- core state: \`${state.coreState}\``,
949
+ `- migration state: \`${state.migrationState}\``,
715
950
  `- gitignore migration assessment: \`.harness/init-upgrades/${state.runId}/gitignore-migration.json\` (index-only untrack guidance; the controller never modifies the Git index)`,
716
951
  ...(gitignoreMigration
717
952
  ? [
@@ -1225,12 +1460,12 @@ async function freezeVerificationSurface(repoRoot) {
1225
1460
  collected.add(await assertSafeRegularRepoFile(repoRoot, relativePath));
1226
1461
  };
1227
1462
  await add("harness.json");
1228
- const harness = await readVerificationHarness(repoRoot);
1229
- const matrixPath = verificationMatrixPath(harness);
1230
- await add(matrixPath);
1231
- const commands = ["bash scripts/check-repo.sh", await discoverQuickVerification(repoRoot)];
1232
- for (const command of commands) {
1233
- for (const input of await commandRepositoryInputs(repoRoot, command)) {
1463
+ if (await exists(path.join(repoRoot, ".loop-agent", "project.json"))) {
1464
+ await add(".loop-agent/project.json");
1465
+ }
1466
+ const verification = await resolveProjectVerification(repoRoot);
1467
+ for (const command of verification.commands) {
1468
+ for (const input of command.inputs) {
1234
1469
  const normalized = await assertSafeRegularRepoFile(repoRoot, input);
1235
1470
  if (normalized.endsWith(".sh"))
1236
1471
  await staticScriptDependencies(repoRoot, normalized, collected);
@@ -1244,6 +1479,50 @@ async function freezeVerificationSurface(repoRoot) {
1244
1479
  })));
1245
1480
  return { schemaVersion: 1, files };
1246
1481
  }
1482
+ async function resolveProjectVerification(repoRoot) {
1483
+ const config = await loadNormalizedProjectConfig(repoRoot);
1484
+ const configured = config.verification.quick.length > 0
1485
+ ? config.verification.quick
1486
+ : config.verification.standard.length > 0
1487
+ ? config.verification.standard
1488
+ : config.verification.full;
1489
+ if (configured.length > 0) {
1490
+ const commands = [];
1491
+ for (const command of configured) {
1492
+ commands.push({
1493
+ command,
1494
+ inputs: await commandRepositoryInputs(repoRoot, command),
1495
+ });
1496
+ }
1497
+ return {
1498
+ source: config.verification.reproducibility === "tracked"
1499
+ ? "tracked"
1500
+ : "local-only",
1501
+ commands,
1502
+ };
1503
+ }
1504
+ const scan = await new ProjectCapabilityScanner().scan(repoRoot);
1505
+ if (scan.verification.status !== "ready") {
1506
+ throw new Error(`project verification is ${scan.verification.status}; configure .loop-agent/project.json verification before init upgrade`);
1507
+ }
1508
+ const candidates = scan.verification.commands;
1509
+ const selected = candidates.find((candidate) => candidate.id.endsWith(":test")) ??
1510
+ candidates.find((candidate) => candidate.id.endsWith(":typecheck")) ??
1511
+ candidates.find((candidate) => candidate.id.endsWith(":lint")) ??
1512
+ candidates[0];
1513
+ if (!selected) {
1514
+ throw new Error("project verification is unavailable; configure .loop-agent/project.json verification before init upgrade");
1515
+ }
1516
+ return {
1517
+ source: "scanner",
1518
+ commands: [
1519
+ {
1520
+ command: selected.command,
1521
+ inputs: scan.evidence.map((entry) => entry.path),
1522
+ },
1523
+ ],
1524
+ };
1525
+ }
1247
1526
  async function assertFrozenVerificationSurface(repoRoot, surface) {
1248
1527
  if (!isFrozenVerificationSurface(surface)) {
1249
1528
  throw new Error("verification authority surface manifest is invalid");
@@ -1273,6 +1552,32 @@ function isControllerAuthorizedVerificationMergeTask(task, surface) {
1273
1552
  taskPath.endsWith("/verification-matrix.md")) &&
1274
1553
  Boolean(surface?.files.some((entry) => entry.path === taskPath)));
1275
1554
  }
1555
+ function isControllerModelMatrixSelectionTask(task) {
1556
+ if (normalizeAllowedPath(task.path) !== "harness.json")
1557
+ return false;
1558
+ if (task.purpose === "pi-model-matrix-selection")
1559
+ return true;
1560
+ return (task.allowedPaths.length === 1 &&
1561
+ normalizeAllowedPath(task.allowedPaths[0] ?? "") === "harness.json" &&
1562
+ (task.reason ===
1563
+ "harness.json has no complete Pi model matrix that resolves against the current Pi model catalog" ||
1564
+ task.reason.startsWith("only unavailable Pi tiers need reselection: ")) &&
1565
+ (task.mergeRules.includes("Write the complete matrix directly to harness.json; do not run init update because this task authorizes only harness.json. The controller will revalidate all three tiers on continue.") ||
1566
+ task.mergeRules.includes("Apply the complete matrix through init update --apply-safe with all three tier flags so code can revalidate it; valid existing tiers must keep their current effective reference.")));
1567
+ }
1568
+ function executorAuthorityWithoutPiModelTiers(value) {
1569
+ if (!isRecord(value))
1570
+ return value;
1571
+ const executors = { ...value };
1572
+ if (!isRecord(value.pi))
1573
+ return executors;
1574
+ const pi = { ...value.pi };
1575
+ delete pi.LOW;
1576
+ delete pi.MED;
1577
+ delete pi.HIGH;
1578
+ executors.pi = pi;
1579
+ return executors;
1580
+ }
1276
1581
  async function assertFrozenVerificationSurfaceExcept(repoRoot, surface, exemptPath) {
1277
1582
  if (!isFrozenVerificationSurface(surface)) {
1278
1583
  throw new Error("verification authority surface manifest is invalid");
@@ -1294,20 +1599,34 @@ async function validateControllerAuthorizedVerificationMerge(input) {
1294
1599
  if (!isControllerAuthorizedVerificationMergeTask(input.task, input.surface))
1295
1600
  return;
1296
1601
  const harness = await readVerificationHarness(input.repoRoot);
1297
- const matrixPath = normalizeAllowedPath(verificationMatrixPath(harness));
1298
- const frozenMatrixPath = frozenVerificationMatrixPath(input.surface);
1299
- if (matrixPath !== frozenMatrixPath ||
1300
- (taskPath !== "harness.json" && taskPath !== frozenMatrixPath)) {
1301
- throw new Error("verification authority merge must retain the controller-frozen verification matrix path");
1302
- }
1303
1602
  if (taskPath === "harness.json") {
1304
1603
  harnessManifestSchema.parse(harness);
1305
1604
  const authority = input.guard?.verificationAuthority;
1605
+ const executorAuthorityChanged = authority && isControllerModelMatrixSelectionTask(input.task)
1606
+ ? !sameJsonValue(executorAuthorityWithoutPiModelTiers(harness.executors ?? null), executorAuthorityWithoutPiModelTiers(authority.executors))
1607
+ : authority
1608
+ ? !sameJsonValue(harness.executors ?? null, authority.executors)
1609
+ : false;
1306
1610
  if (authority &&
1307
- (!sameJsonValue(harness.executors ?? null, authority.executors) ||
1611
+ (executorAuthorityChanged ||
1308
1612
  !sameJsonValue(harness.workflowPolicy ?? null, authority.workflowPolicy))) {
1309
1613
  throw new Error("verification authority harness.json merge cannot change controller executor or workflow authority");
1310
1614
  }
1615
+ const verification = await resolveProjectVerification(input.repoRoot);
1616
+ const frozenPaths = new Set(input.surface.files.map((entry) => entry.path));
1617
+ for (const command of verification.commands) {
1618
+ for (const relativePath of command.inputs) {
1619
+ if (!frozenPaths.has(normalizeAllowedPath(relativePath))) {
1620
+ throw new Error(`verification authority harness.json merge introduced an unfrozen input: ${relativePath}`);
1621
+ }
1622
+ }
1623
+ }
1624
+ return;
1625
+ }
1626
+ const matrixPath = normalizeAllowedPath(verificationMatrixPath(harness));
1627
+ const frozenMatrixPath = frozenVerificationMatrixPath(input.surface);
1628
+ if (matrixPath !== frozenMatrixPath || taskPath !== frozenMatrixPath) {
1629
+ throw new Error("verification authority merge must retain the controller-frozen verification matrix path");
1311
1630
  }
1312
1631
  const command = await discoverQuickVerification(input.repoRoot);
1313
1632
  tokenizeCommand(command);
@@ -1799,6 +2118,14 @@ const VERIFICATION_OUTPUT_LIMIT = 4 * 1024 * 1024;
1799
2118
  const VERIFICATION_SUPERVISOR_SOURCE = `
1800
2119
  import { spawn } from "node:child_process";
1801
2120
  const specification = JSON.parse(Buffer.from(process.argv[2], "base64url").toString("utf8"));
2121
+ const windowsCommandScript = process.platform === "win32" && /\\.(?:cmd|bat)$/i.test(specification.command);
2122
+ const quoteWindowsCommandArgument = (value) => /^[A-Za-z0-9_@./:\\\\=,+-]+$/.test(value)
2123
+ ? value
2124
+ : '"' + value.replaceAll('"', '""') + '"';
2125
+ const workerCommand = windowsCommandScript ? process.env.ComSpec || "cmd.exe" : specification.command;
2126
+ const workerArgs = windowsCommandScript
2127
+ ? ["/d /s /c " + [specification.command, ...specification.args].map(quoteWindowsCommandArgument).join(" ")]
2128
+ : specification.args;
1802
2129
  let worker;
1803
2130
  let workerClosed = false;
1804
2131
  let exitResult;
@@ -1820,12 +2147,13 @@ const finish = (message) => {
1820
2147
  process.send({ ...message, stdioOpen: !workerClosed }, afterSend);
1821
2148
  };
1822
2149
  try {
1823
- worker = spawn(specification.command, specification.args, {
2150
+ worker = spawn(workerCommand, workerArgs, {
1824
2151
  cwd: process.cwd(),
1825
2152
  env: process.env,
1826
2153
  stdio: ["ignore", "pipe", "pipe"],
1827
2154
  windowsHide: true,
1828
2155
  shell: false,
2156
+ windowsVerbatimArguments: windowsCommandScript,
1829
2157
  });
1830
2158
  worker.stdout?.pipe(process.stdout);
1831
2159
  worker.stderr?.pipe(process.stderr);
@@ -2291,7 +2619,18 @@ async function runVerificationCommand(input) {
2291
2619
  const anchorBefore = await controllerAnchorSnapshot(repoRoot, state);
2292
2620
  const piHomeBefore = await snapshotOptionalDirectory(path.join(os.homedir(), ".pi"));
2293
2621
  const tokens = tokenizeCommand(commandText);
2294
- await commandRepositoryInputs(repoRoot, commandText);
2622
+ if (process.platform === "win32" &&
2623
+ tokens.some((token) => /[%!^]/.test(token))) {
2624
+ throw new Error(`unsafe Windows verification command: ${commandText}`);
2625
+ }
2626
+ if (input.authorityInputs) {
2627
+ for (const relativePath of input.authorityInputs) {
2628
+ await assertSafeRegularRepoFile(repoRoot, relativePath);
2629
+ }
2630
+ }
2631
+ else {
2632
+ await commandRepositoryInputs(repoRoot, commandText);
2633
+ }
2295
2634
  let command = tokens.shift();
2296
2635
  if (process.platform === "win32" && command === "npm")
2297
2636
  command = "npm.cmd";
@@ -2454,6 +2793,7 @@ async function runFinalVerification(state, authoritativeHomeFingerprint, surface
2454
2793
  const finalCheck = await checkInitUpdate({
2455
2794
  repoRoot: state.repoRoot,
2456
2795
  modelCatalog,
2796
+ legacyAudit: true,
2457
2797
  });
2458
2798
  const doctor = await runInitDoctor({ repoRoot: state.repoRoot, modelCatalog });
2459
2799
  let inspect;
@@ -2474,47 +2814,72 @@ async function runFinalVerification(state, authoritativeHomeFingerprint, surface
2474
2814
  error: error instanceof Error ? error.message : String(error),
2475
2815
  };
2476
2816
  }
2477
- let docsAudit;
2817
+ const normalized = await loadNormalizedProjectConfig(state.repoRoot);
2818
+ const documentedGovernance = Object.values(normalized.manifest.entrypoints ?? {}).some((value) => typeof value === "string" && value.length > 0);
2819
+ let docsAudit = {
2820
+ ok: true,
2821
+ status: "not-configured",
2822
+ };
2823
+ if (documentedGovernance) {
2824
+ try {
2825
+ const audit = await auditDocs(state.repoRoot);
2826
+ docsAudit = {
2827
+ ok: audit.summary.errorCount === 0,
2828
+ command: "loop-agent docs audit --repo-root .",
2829
+ errorCount: audit.summary.errorCount,
2830
+ warningCount: audit.summary.warningCount,
2831
+ };
2832
+ }
2833
+ catch (error) {
2834
+ docsAudit = {
2835
+ ok: false,
2836
+ command: "loop-agent docs audit --repo-root .",
2837
+ error: error instanceof Error ? error.message : String(error),
2838
+ };
2839
+ }
2840
+ }
2841
+ let packageSelfCheck;
2478
2842
  try {
2479
- const audit = await auditDocs(state.repoRoot);
2480
- docsAudit = {
2481
- ok: audit.summary.errorCount === 0,
2482
- command: "loop-agent docs audit --repo-root .",
2483
- errorCount: audit.summary.errorCount,
2484
- warningCount: audit.summary.warningCount,
2843
+ const provider = await createControllerContextProvider();
2844
+ await provider.read(CONTROLLER_RESOURCE_IDS.loopAgentBridgeSkill);
2845
+ packageSelfCheck = {
2846
+ ok: true,
2847
+ catalogSha256: provider.identity.catalogDigest,
2848
+ resourceCount: provider.list().length,
2485
2849
  };
2486
2850
  }
2487
2851
  catch (error) {
2488
- docsAudit = {
2852
+ packageSelfCheck = {
2489
2853
  ok: false,
2490
- command: "loop-agent docs audit --repo-root .",
2491
2854
  error: error instanceof Error ? error.message : String(error),
2492
2855
  };
2493
2856
  }
2494
- const checkRepo = await runVerificationCommand({
2495
- state,
2496
- surface,
2497
- commandText: "bash scripts/check-repo.sh",
2498
- });
2499
- let quickVerification;
2857
+ let projectVerification;
2500
2858
  try {
2501
- const quickCommand = await discoverQuickVerification(state.repoRoot);
2502
- quickVerification = await runVerificationCommand({
2503
- state,
2504
- surface,
2505
- commandText: quickCommand,
2506
- });
2859
+ const plan = await resolveProjectVerification(state.repoRoot);
2860
+ const results = [];
2861
+ for (const item of plan.commands) {
2862
+ results.push(await runVerificationCommand({
2863
+ state,
2864
+ surface,
2865
+ commandText: item.command,
2866
+ authorityInputs: item.inputs,
2867
+ }));
2868
+ }
2869
+ projectVerification = {
2870
+ ok: results.length > 0 && results.every((result) => result.ok),
2871
+ source: plan.source,
2872
+ results,
2873
+ };
2507
2874
  }
2508
2875
  catch (error) {
2509
- quickVerification = {
2876
+ projectVerification = {
2510
2877
  ok: false,
2511
- command: "verification-matrix minimum project test command",
2512
- exitCode: null,
2513
- stdout: "",
2514
- stderr: error instanceof Error ? error.message : String(error),
2878
+ source: "unavailable",
2879
+ error: error instanceof Error ? error.message : String(error),
2515
2880
  };
2516
2881
  }
2517
- const recovery = await verifyRecoveryFiles(state.repoRoot);
2882
+ const recovery = { ok: true, status: "not-installed" };
2518
2883
  const projectPi = await inspectProjectPiSettings({ repoRoot: state.repoRoot });
2519
2884
  const homeAfter = await homeFingerprint();
2520
2885
  return {
@@ -2529,6 +2894,8 @@ async function runFinalVerification(state, authoritativeHomeFingerprint, surface
2529
2894
  mergeReceiptCount: state.mergeReceipts.length,
2530
2895
  },
2531
2896
  initSurfaceClean: finalCheck.ok,
2897
+ coreState: finalCheck.coreState,
2898
+ migrationState: finalCheck.migrationState,
2532
2899
  initSurface: {
2533
2900
  deterministicActions: finalCheck.deterministicActions,
2534
2901
  modelMergeTasks: finalCheck.modelMergeTasks,
@@ -2537,15 +2904,14 @@ async function runFinalVerification(state, authoritativeHomeFingerprint, surface
2537
2904
  doctor: { ok: doctor.ok, checks: doctor.checks },
2538
2905
  inspect,
2539
2906
  docsAudit,
2540
- checkRepo,
2541
- quickVerification,
2907
+ packageSelfCheck,
2908
+ projectVerification,
2542
2909
  recovery,
2543
2910
  projectPi: {
2544
2911
  reason: projectPi.reason,
2545
2912
  action: projectPi.action,
2546
- trustRequired: true,
2547
- valid: projectPi.reason === "matches" ||
2548
- projectPi.reason === "enabled-false",
2913
+ trustRequired: projectPi.reason !== "missing-file",
2914
+ valid: projectPi.reason !== "invalid-json",
2549
2915
  },
2550
2916
  userHomeUnchanged: sameJsonValue(homeAfter, authoritativeHomeFingerprint),
2551
2917
  mergeBoundaryClean: state.mergeTasks.length === 0 && state.queuedMergeTasks.length === 0,
@@ -2554,8 +2920,8 @@ async function runFinalVerification(state, authoritativeHomeFingerprint, surface
2554
2920
  }
2555
2921
  function authoritativeVerificationFailureReason(verification) {
2556
2922
  const sources = [
2557
- verification.quickVerification,
2558
- verification.checkRepo,
2923
+ verification.projectVerification,
2924
+ verification.packageSelfCheck,
2559
2925
  verification.inspect,
2560
2926
  verification.docsAudit,
2561
2927
  ];
@@ -2594,28 +2960,25 @@ function verificationPassed(verification) {
2594
2960
  const doctor = verification.doctor;
2595
2961
  const inspect = verification.inspect;
2596
2962
  const docsAudit = verification.docsAudit;
2597
- const checkRepo = verification.checkRepo;
2598
- const quick = verification.quickVerification;
2963
+ const packageSelfCheck = verification.packageSelfCheck;
2964
+ const projectVerification = verification.projectVerification;
2599
2965
  const projectPi = verification.projectPi;
2600
- const recovery = verification.recovery;
2601
2966
  return (verification.initSurfaceClean === true &&
2967
+ verification.coreState === "ready" &&
2968
+ (verification.migrationState === "clean" ||
2969
+ verification.migrationState === "retained") &&
2602
2970
  isRecord(doctor) &&
2603
2971
  doctor.ok === true &&
2604
2972
  isRecord(inspect) &&
2605
2973
  inspect.ok === true &&
2606
2974
  isRecord(docsAudit) &&
2607
2975
  docsAudit.ok === true &&
2608
- isRecord(checkRepo) &&
2609
- checkRepo.ok === true &&
2610
- isRecord(quick) &&
2611
- quick.ok === true &&
2976
+ isRecord(packageSelfCheck) &&
2977
+ packageSelfCheck.ok === true &&
2978
+ isRecord(projectVerification) &&
2979
+ projectVerification.ok === true &&
2612
2980
  isRecord(projectPi) &&
2613
2981
  projectPi.valid === true &&
2614
- isRecord(recovery) &&
2615
- RECOVERY_PATHS.every((recoveryPath) => {
2616
- const entry = recovery[recoveryPath];
2617
- return isRecord(entry) && entry.exists === true && entry.invariant === true;
2618
- }) &&
2619
2982
  verification.userHomeUnchanged === true &&
2620
2983
  verification.mergeBoundaryClean === true &&
2621
2984
  verification.humanDecisionClean === true);
@@ -2687,13 +3050,24 @@ async function readFreshCompletedVerification(repoRoot, state) {
2687
3050
  return { verification, reportMarkdown: report.toString("utf-8") };
2688
3051
  }
2689
3052
  async function readValidatedStatusState(repoRoot, runId) {
2690
- const state = await readState(repoRoot, runId);
3053
+ let state = await readState(repoRoot, runId);
2691
3054
  assertTerminalCoherence(state);
2692
3055
  const directory = runDirectory(repoRoot, runId);
2693
3056
  const anchor = await readControllerAnchor(repoRoot, state);
2694
3057
  if (anchor.mergeAuthority) {
2695
3058
  mergeAuthorityProjection(state, mergeAuthorityFromAnchor(anchor, state));
2696
3059
  }
3060
+ const pendingDecision = await readPendingDecisionProjection({
3061
+ directory,
3062
+ state,
3063
+ anchor,
3064
+ });
3065
+ if (pendingDecision) {
3066
+ state = cloneJson(state);
3067
+ state.status = "in-progress";
3068
+ state.humanDecisions = [];
3069
+ state.nextAction = `Resume the authenticated pending decision application with: loop-agent init upgrade --continue --run-id ${state.runId}`;
3070
+ }
2697
3071
  if (state.status === "completed" || state.status === "cancelled") {
2698
3072
  if (!(await frozenControllerMatches(directory, state))) {
2699
3073
  throw new Error("Controller identity drift detected for persisted init upgrade state");
@@ -2788,6 +3162,138 @@ async function activateModelMergeTask(input) {
2788
3162
  await writeReport(directory, state, undefined, gitignoreMigration);
2789
3163
  return toResult(state, undefined, gitignoreMigration);
2790
3164
  }
3165
+ class DecisionApplicationPendingError extends Error {
3166
+ constructor(error) {
3167
+ super(`init decision application is pending recovery: ${error instanceof Error ? error.message : String(error)}`);
3168
+ this.name = "DecisionApplicationPendingError";
3169
+ }
3170
+ }
3171
+ async function applyDecisionBoundFile(input) {
3172
+ let anchor = await readControllerAnchor(input.state.repoRoot, input.state);
3173
+ let pending = await readPendingDecisionApplication({
3174
+ directory: input.directory,
3175
+ state: input.state,
3176
+ anchor,
3177
+ });
3178
+ if (!pending) {
3179
+ if (!input.decisionFile) {
3180
+ throw new Error("init upgrade requires --decision-file for unresolved human decisions");
3181
+ }
3182
+ const parsed = await readInitUpgradeDecisionFile({
3183
+ repoRoot: input.state.repoRoot,
3184
+ runId: input.state.runId,
3185
+ decisionFile: input.decisionFile,
3186
+ pending: input.state.humanDecisions,
3187
+ });
3188
+ const stateProjection = controllerStateProjection(input.state);
3189
+ pending = {
3190
+ schemaVersion: 1,
3191
+ runId: input.state.runId,
3192
+ repoRoot: input.state.repoRoot,
3193
+ decisionFileSha256: parsed.sha256,
3194
+ document: cloneJson(parsed.document),
3195
+ stateProjection,
3196
+ stateProjectionSha256: canonicalSha256(stateProjection),
3197
+ preparedAt: new Date().toISOString(),
3198
+ };
3199
+ await writeJsonAtomic(path.join(input.directory, PENDING_DECISION_FILE), pending);
3200
+ await writeControllerAnchor({
3201
+ state: input.state,
3202
+ decisionAuthority: {
3203
+ pending,
3204
+ pendingSha256: canonicalSha256(pending),
3205
+ },
3206
+ });
3207
+ anchor = await readControllerAnchor(input.state.repoRoot, input.state);
3208
+ pending = await readPendingDecisionApplication({
3209
+ directory: input.directory,
3210
+ state: input.state,
3211
+ anchor,
3212
+ });
3213
+ if (!pending)
3214
+ throw new Error("pending init decision authority was not established");
3215
+ }
3216
+ else if (input.decisionFile) {
3217
+ try {
3218
+ const suppliedSha256 = sha256(await readRepoLocalDecisionFile(input.state.repoRoot, input.decisionFile));
3219
+ if (suppliedSha256 !== pending.decisionFileSha256) {
3220
+ throw new Error("init upgrade --decision-file differs from the authenticated pending decision");
3221
+ }
3222
+ }
3223
+ catch (error) {
3224
+ throw new DecisionApplicationPendingError(error);
3225
+ }
3226
+ }
3227
+ try {
3228
+ const applied = await applyInitDecisionBoundActions({
3229
+ repoRoot: input.state.repoRoot,
3230
+ actions: pending.document.decisions,
3231
+ resumeAuthority: {
3232
+ runId: pending.runId,
3233
+ pendingSha256: anchor.decisionAuthority.pendingSha256,
3234
+ },
3235
+ });
3236
+ await input.afterActionsApplied?.();
3237
+ const decidedAt = new Date().toISOString();
3238
+ const receipts = applied.map((result) => ({
3239
+ ...result,
3240
+ decisionFileSha256: pending.decisionFileSha256,
3241
+ decidedAt,
3242
+ }));
3243
+ const existingReceiptKeys = new Set((input.state.decisionReceipts ?? []).map((receipt) => canonicalSha256({
3244
+ path: receipt.path,
3245
+ candidateId: receipt.candidateId,
3246
+ disposition: receipt.disposition,
3247
+ expectedSha256: receipt.expectedSha256,
3248
+ resultSha256: receipt.resultSha256,
3249
+ decisionFileSha256: receipt.decisionFileSha256,
3250
+ })));
3251
+ input.state.decisionReceipts = [
3252
+ ...(input.state.decisionReceipts ?? []),
3253
+ ...receipts.filter((receipt) => !existingReceiptKeys.has(canonicalSha256({
3254
+ path: receipt.path,
3255
+ candidateId: receipt.candidateId,
3256
+ disposition: receipt.disposition,
3257
+ expectedSha256: receipt.expectedSha256,
3258
+ resultSha256: receipt.resultSha256,
3259
+ decisionFileSha256: receipt.decisionFileSha256,
3260
+ }))),
3261
+ ];
3262
+ for (const receipt of receipts) {
3263
+ if ((receipt.disposition === "replace-with-bridge" ||
3264
+ receipt.disposition === "delete-listed-file") &&
3265
+ !input.state.actionReceipts.some((action) => action.type ===
3266
+ (receipt.disposition === "replace-with-bridge"
3267
+ ? "replace-owned-file"
3268
+ : "remove-owned-file") &&
3269
+ action.path === receipt.path &&
3270
+ action.expectedSha256 === receipt.expectedSha256)) {
3271
+ input.state.actionReceipts.push({
3272
+ type: receipt.disposition === "replace-with-bridge"
3273
+ ? "replace-owned-file"
3274
+ : "remove-owned-file",
3275
+ path: receipt.path,
3276
+ expectedSha256: receipt.expectedSha256,
3277
+ reason: receipt.disposition === "replace-with-bridge"
3278
+ ? DECISION_REPLACE_REASON
3279
+ : "remove an explicitly approved hash-bound legacy init file",
3280
+ });
3281
+ }
3282
+ }
3283
+ input.state.humanDecisions = [];
3284
+ input.state.status = "in-progress";
3285
+ input.state.nextAction =
3286
+ "Decision-bound dispositions were recorded; re-plan the minimal surface and continue verification.";
3287
+ await writeJsonAtomic(path.join(input.directory, "decision-receipts.json"), input.state.decisionReceipts);
3288
+ await writeJsonAtomic(path.join(input.directory, "change-manifest.json"), input.state.actionReceipts);
3289
+ await saveState(input.directory, input.state);
3290
+ await writeControllerAnchor({ state: input.state, decisionAuthority: null });
3291
+ await rm(path.join(input.directory, PENDING_DECISION_FILE), { force: true });
3292
+ }
3293
+ catch (error) {
3294
+ throw new DecisionApplicationPendingError(error);
3295
+ }
3296
+ }
2791
3297
  async function verifyAndCompleteUpgrade(input) {
2792
3298
  const { directory, state, gitignoreMigration, modelCatalog } = input;
2793
3299
  state.mergeTasks = [];
@@ -2798,6 +3304,7 @@ async function verifyAndCompleteUpgrade(input) {
2798
3304
  let postMerge = await checkInitUpdate({
2799
3305
  repoRoot: state.repoRoot,
2800
3306
  modelCatalog,
3307
+ legacyAudit: true,
2801
3308
  });
2802
3309
  const seen = new Set();
2803
3310
  for (let pass = 0; postMerge.deterministicActions.length > 0 && pass < 2; pass += 1) {
@@ -2809,8 +3316,8 @@ async function verifyAndCompleteUpgrade(input) {
2809
3316
  const converged = await applyInitUpdate({
2810
3317
  repoRoot: state.repoRoot,
2811
3318
  applySafe: true,
2812
- clientRecovery: "project",
2813
3319
  modelCatalog,
3320
+ legacyAudit: true,
2814
3321
  });
2815
3322
  state.actionReceipts.push(...converged.applied);
2816
3323
  await writeJsonAtomic(path.join(directory, "change-manifest.json"), state.actionReceipts);
@@ -2819,6 +3326,7 @@ async function verifyAndCompleteUpgrade(input) {
2819
3326
  postMerge = await checkInitUpdate({
2820
3327
  repoRoot: state.repoRoot,
2821
3328
  modelCatalog,
3329
+ legacyAudit: true,
2822
3330
  });
2823
3331
  }
2824
3332
  if (postMerge.deterministicActions.length > 0) {
@@ -2828,6 +3336,7 @@ async function verifyAndCompleteUpgrade(input) {
2828
3336
  postMerge.humanDecisions.length > 0) {
2829
3337
  throw new Error("accepted semantic merges introduced new non-deterministic init decisions; start a new bounded upgrade run");
2830
3338
  }
3339
+ projectInitStates(state, postMerge);
2831
3340
  }
2832
3341
  state.phase = "VERIFY";
2833
3342
  const anchor = await readControllerAnchor(state.repoRoot, state);
@@ -2839,7 +3348,7 @@ async function verifyAndCompleteUpgrade(input) {
2839
3348
  state.status = "completed";
2840
3349
  state.phase = "COMPLETED";
2841
3350
  state.nextAction =
2842
- "Init upgrade completed; reload the host session so updated AGENTS, skills, and recovery extensions are discovered.";
3351
+ "Init upgrade completed; reload the host session so the minimal bridge and current package resources are discovered.";
2843
3352
  await writeReport(directory, state, verification, gitignoreMigration);
2844
3353
  const finalReportSha256 = sha256(await readFile(path.join(directory, "final-report.md")));
2845
3354
  await writeControllerAnchor({
@@ -2937,7 +3446,9 @@ async function executeUpgrade(directory, state, input) {
2937
3446
  const pre = await checkInitUpdate({
2938
3447
  repoRoot: state.repoRoot,
2939
3448
  modelCatalog: state.modelCatalog,
3449
+ legacyAudit: true,
2940
3450
  });
3451
+ projectInitStates(state, pre);
2941
3452
  await writeJsonAtomic(path.join(directory, "pre-update-report.json"), pre);
2942
3453
  await writeJsonAtomic(path.join(directory, "upgrade-plan.json"), {
2943
3454
  deterministicActions: pre.deterministicActions,
@@ -2947,10 +3458,10 @@ async function executeUpgrade(directory, state, input) {
2947
3458
  "init check-update",
2948
3459
  "init doctor",
2949
3460
  "loop-agent inspect",
2950
- "loop-agent docs audit",
2951
- "bash scripts/check-repo.sh",
2952
- "verification-matrix minimum project test command",
2953
- "project recovery invariants",
3461
+ "package resource catalog self-check",
3462
+ "normalized project verification or bounded capability scan",
3463
+ "project docs audit when configured",
3464
+ "explicit adapter invariants when installed",
2954
3465
  "Pi home fingerprint",
2955
3466
  ],
2956
3467
  });
@@ -2959,9 +3470,7 @@ async function executeUpgrade(directory, state, input) {
2959
3470
  if (pre.surfaceState === "missing") {
2960
3471
  const initialized = await initializeLoopAgentProject({
2961
3472
  repoRoot: state.repoRoot,
2962
- profile: "full",
2963
3473
  merge: true,
2964
- clientRecovery: "project",
2965
3474
  });
2966
3475
  state.actionReceipts.push(...initialized.written.map((writtenPath) => ({
2967
3476
  type: writtenPath === INIT_SURFACE_STATE_PATH
@@ -2969,23 +3478,25 @@ async function executeUpgrade(directory, state, input) {
2969
3478
  : "write-generated-missing",
2970
3479
  path: writtenPath,
2971
3480
  reason: writtenPath === INIT_SURFACE_STATE_PATH
2972
- ? "recorded the complete fresh-project initialization surface"
2973
- : "full-project initialization wrote the required project surface",
3481
+ ? "recorded the minimal fresh-project initialization surface"
3482
+ : "minimal initialization wrote the required project surface",
2974
3483
  })));
2975
3484
  }
2976
3485
  await assertInactive(input);
2977
3486
  const safe = await applyInitUpdate({
2978
3487
  repoRoot: state.repoRoot,
2979
3488
  applySafe: true,
2980
- clientRecovery: "project",
2981
3489
  modelCatalog: state.modelCatalog,
3490
+ legacyAudit: true,
2982
3491
  });
2983
3492
  state.actionReceipts.push(...safe.applied);
2984
3493
  await writeJsonAtomic(path.join(directory, "change-manifest.json"), state.actionReceipts);
2985
3494
  let post = await checkInitUpdate({
2986
3495
  repoRoot: state.repoRoot,
2987
3496
  modelCatalog: state.modelCatalog,
3497
+ legacyAudit: true,
2988
3498
  });
3499
+ projectInitStates(state, post);
2989
3500
  // Converge remaining safe deterministic init actions before the final check.
2990
3501
  // Project/governance-derived drift (e.g. a personalized project rename after
2991
3502
  // an accepted merge) settles here instead of failing finalCheck. Model merge
@@ -3000,8 +3511,8 @@ async function executeUpgrade(directory, state, input) {
3000
3511
  const converged = await applyInitUpdate({
3001
3512
  repoRoot: state.repoRoot,
3002
3513
  applySafe: true,
3003
- clientRecovery: "project",
3004
3514
  modelCatalog: state.modelCatalog,
3515
+ legacyAudit: true,
3005
3516
  });
3006
3517
  state.actionReceipts.push(...converged.applied);
3007
3518
  await writeJsonAtomic(path.join(directory, "change-manifest.json"), state.actionReceipts);
@@ -3010,7 +3521,9 @@ async function executeUpgrade(directory, state, input) {
3010
3521
  post = await checkInitUpdate({
3011
3522
  repoRoot: state.repoRoot,
3012
3523
  modelCatalog: state.modelCatalog,
3524
+ legacyAudit: true,
3013
3525
  });
3526
+ projectInitStates(state, post);
3014
3527
  }
3015
3528
  if (post.deterministicActions.length > 0) {
3016
3529
  throw new Error(`deterministic init update exceeded the 2-pass convergence limit: ${post.deterministicActions.map((action) => `${action.type}:${action.path}`).join(", ")}`);
@@ -3056,7 +3569,7 @@ async function executeUpgrade(directory, state, input) {
3056
3569
  state.status = "needs-human-decision";
3057
3570
  state.phase = "MODEL_MERGE";
3058
3571
  state.nextAction =
3059
- "Resolve the recorded destructive, credential, or ambiguous decisions; the controller will not overwrite uncertain user intent. For gitignore migration: review tracked .agents customizations or resolve the staged blocker first, then rerun the assessment so index-only untrack commands can be recommended.";
3572
+ "Resolve every file decision in a repo-local schemaVersion 1 decision file bound to this runId and each current SHA-256, then call init upgrade --continue --decision-file <path>. The controller preserves detached project content and only replaces the bridge after explicit approval. Resolve staged Git blockers before continuing.";
3060
3573
  await writeJsonAtomic(path.join(directory, "merge-tasks.json"), state.mergeTasks);
3061
3574
  await saveState(directory, state);
3062
3575
  await writeReport(directory, state, undefined, gitignoreMigration);
@@ -3083,6 +3596,9 @@ async function executeUpgrade(directory, state, input) {
3083
3596
  export async function runInitUpgrade(input) {
3084
3597
  const repoRoot = path.resolve(input.repoRoot);
3085
3598
  const mode = input.mode ?? "start";
3599
+ if (input.decisionFile && mode !== "continue") {
3600
+ throw new Error("init upgrade --decision-file requires --continue and --run-id");
3601
+ }
3086
3602
  if ((mode === "status" || mode === "report") && !input.runId) {
3087
3603
  throw new Error("init upgrade --status/--report requires --run-id");
3088
3604
  }
@@ -3151,6 +3667,25 @@ export async function runInitUpgrade(input) {
3151
3667
  if (mode === "continue") {
3152
3668
  state = await readState(repoRoot, runId);
3153
3669
  directory = runDirectory(repoRoot, runId);
3670
+ const decisionAnchor = await readControllerAnchor(repoRoot, state);
3671
+ const pendingDecision = await readPendingDecisionApplication({
3672
+ directory,
3673
+ state,
3674
+ anchor: decisionAnchor,
3675
+ });
3676
+ if (pendingDecision || input.decisionFile) {
3677
+ if ((!pendingDecision && state.phase !== "MODEL_MERGE") ||
3678
+ (!pendingDecision && state.status !== "needs-human-decision") ||
3679
+ (!pendingDecision && state.humanDecisions.length === 0)) {
3680
+ throw new Error("init upgrade --decision-file requires a paused needs-human-decision run");
3681
+ }
3682
+ await applyDecisionBoundFile({
3683
+ directory,
3684
+ state,
3685
+ decisionFile: input.decisionFile,
3686
+ afterActionsApplied: input.afterDecisionActionsApplied,
3687
+ });
3688
+ }
3154
3689
  const recoverableMergeState = state.phase === "MODEL_MERGE" && state.status === "in-progress"
3155
3690
  ? cloneJson(state)
3156
3691
  : undefined;
@@ -3217,10 +3752,13 @@ export async function runInitUpgrade(input) {
3217
3752
  mergeTasks: [],
3218
3753
  queuedMergeTasks: [],
3219
3754
  mergeReceipts: [],
3755
+ decisionReceipts: [],
3220
3756
  humanDecisions: [],
3221
3757
  actionReceipts: [],
3222
3758
  homeFingerprint: await homeFingerprint(),
3223
3759
  modelCatalog: input.modelCatalog,
3760
+ coreState: "blocked",
3761
+ migrationState: "clean",
3224
3762
  };
3225
3763
  await writeControllerAnchor({ state, initialize: true });
3226
3764
  await writeJsonAtomic(path.join(directory, "controller-identity.json"), {
@@ -3238,6 +3776,8 @@ export async function runInitUpgrade(input) {
3238
3776
  catch (error) {
3239
3777
  if (error instanceof VerificationProcessBoundaryError)
3240
3778
  throw error;
3779
+ if (error instanceof DecisionApplicationPendingError)
3780
+ throw error;
3241
3781
  if (!state)
3242
3782
  throw error;
3243
3783
  const nextAction = error instanceof Error ? error.message : String(error);