@tea-agent/loop-agent 0.28.1 → 0.28.2

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 (66) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +25 -0
  3. package/README.md +11 -1
  4. package/dist/cli/command-definitions.js +2 -1
  5. package/dist/commands/client-recovery.js +111 -8
  6. package/dist/commands/dag-init-hybrid.js +1 -1
  7. package/dist/commands/init-upgrade.js +2479 -0
  8. package/dist/commands/init.js +120 -9
  9. package/dist/governance/manifest-types.js +65 -0
  10. package/dist/shared/operator/capabilities.js +350 -2
  11. package/dist/task/worktree.js +256 -39
  12. package/dist/worker/cli.js +22 -12
  13. package/dist/worker/console/chat/workspace-landing.js +16 -6
  14. package/dist/worker/console/observe-health-match.js +2 -0
  15. package/dist/worker/console/observe-link.js +4 -0
  16. package/dist/worker/console/operator-actions.js +183 -4
  17. package/dist/worker/console/operator-selection.js +13 -0
  18. package/dist/worker/console/static/assets/index-BfRgtLF4.js +29 -0
  19. package/dist/worker/console/static/index.html +1 -1
  20. package/dist/worker/observe/health.js +1 -0
  21. package/dist/worker/observe/night-jobs.js +104 -0
  22. package/dist/worker/observe/routes.js +48 -0
  23. package/dist/worker/observe/static/app.js +3 -0
  24. package/dist/worker/observe/static/constants.js +1 -0
  25. package/dist/worker/observe/static/index.html +47 -0
  26. package/dist/worker/observe/static/router.js +10 -0
  27. package/dist/worker/observe/static/shell-chrome.js +1 -0
  28. package/dist/worker/observe/static/views/night.js +201 -0
  29. package/dist/worker/report/morning-report.js +56 -16
  30. package/dist/worker/run-task/execute-prepared-task.js +153 -0
  31. package/dist/worker/runner/single-task-attempt.js +147 -0
  32. package/dist/worker/scheduler/admission.js +536 -0
  33. package/dist/worker/scheduler/auto-followup.js +99 -0
  34. package/dist/worker/scheduler/cli.js +539 -0
  35. package/dist/worker/scheduler/dispatcher.js +503 -0
  36. package/dist/worker/scheduler/doctor.js +346 -0
  37. package/dist/worker/scheduler/evidence.js +170 -0
  38. package/dist/worker/scheduler/git-base.js +52 -0
  39. package/dist/worker/scheduler/index.js +23 -0
  40. package/dist/worker/scheduler/lease.js +114 -0
  41. package/dist/worker/scheduler/lifecycle.js +348 -0
  42. package/dist/worker/scheduler/lock.js +80 -0
  43. package/dist/worker/scheduler/morning-window.js +161 -0
  44. package/dist/worker/scheduler/night-git-finalizer.js +88 -0
  45. package/dist/worker/scheduler/night-harvest.js +421 -0
  46. package/dist/worker/scheduler/paths.js +84 -0
  47. package/dist/worker/scheduler/prepared-attempt-recovery.js +471 -0
  48. package/dist/worker/scheduler/recovery.js +277 -0
  49. package/dist/worker/scheduler/reservation.js +146 -0
  50. package/dist/worker/scheduler/retry.js +199 -0
  51. package/dist/worker/scheduler/scheduler-loop.js +272 -0
  52. package/dist/worker/scheduler/store.js +275 -0
  53. package/dist/worker/scheduler/traceability.js +54 -0
  54. package/dist/worker/scheduler/trigger.js +258 -0
  55. package/dist/worker/scheduler/types.js +369 -0
  56. package/dist/worker/scheduler/workspace-adapter.js +91 -0
  57. package/docs/architecture/runtime-boundaries.md +9 -0
  58. package/docs/init-surface.manifest.json +9 -2
  59. package/docs/templates/harness.schema.json +107 -0
  60. package/docs/templates/init-managed-agents.md +18 -8
  61. package/harness.json +22 -0
  62. package/package.json +1 -1
  63. package/skills/loop-agent/SKILL.md +28 -36
  64. package/skills/loop-agent/references/command-reference.md +40 -16
  65. package/skills/loop-agent/references/hybrid-dag.md +1 -1
  66. package/dist/worker/console/static/assets/index-CNO7n6qB.js +0 -29
@@ -0,0 +1,2479 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { watch } from "node:fs";
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import { lstat, mkdtemp, mkdir, readFile, readdir, readlink, realpath, rename, rm, stat, writeFile, } from "node:fs/promises";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { resolveAdapter } from "../adapters/index.js";
9
+ import { createNpmUpdateClient } from "../cli/update/npm-client.js";
10
+ import { auditDocs } from "../governance/checks.js";
11
+ import { isInitRuntimeActive, } from "../shared/runtime-activity.js";
12
+ 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";
13
+ import { applyInitUpdate, checkInitUpdate, initializeLoopAgentProject, runInitDoctor, } from "./init.js";
14
+ const RUN_ROOT = ".harness/init-upgrades";
15
+ const GLOBAL_LOCK_NAME = ".writer-lock";
16
+ const INIT_SURFACE_STATE_PATH = ".harness/init-surface.json";
17
+ const RECOVERY_PATHS = [
18
+ OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH,
19
+ OPENCODE_CONTEXT_OVERFLOW_COMPACT_PLUGIN_PATH,
20
+ PI_CONTEXT_OVERFLOW_EXTENSION_PATH,
21
+ PI_PROJECT_SETTINGS_PATH,
22
+ ];
23
+ function sha256(value) {
24
+ return createHash("sha256").update(value).digest("hex");
25
+ }
26
+ function validRunId(runId) {
27
+ return /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(runId);
28
+ }
29
+ function isRecord(value) {
30
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
31
+ }
32
+ function isSha256(value) {
33
+ return typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
34
+ }
35
+ function isHomeFingerprint(value) {
36
+ if (!isRecord(value))
37
+ return false;
38
+ const keys = Object.keys(value).sort();
39
+ if (value.present === false) {
40
+ return keys.length === 1 && keys[0] === "present";
41
+ }
42
+ return (value.present === true &&
43
+ keys.length === 2 &&
44
+ keys[0] === "present" &&
45
+ keys[1] === "sha256" &&
46
+ isSha256(value.sha256));
47
+ }
48
+ function cloneJson(value) {
49
+ return JSON.parse(JSON.stringify(value));
50
+ }
51
+ function serializeJson(value) {
52
+ return `${JSON.stringify(value, null, 2)}\n`;
53
+ }
54
+ /** Stable hash for controller authority records, independent of JSON key order. */
55
+ function canonicalJson(value) {
56
+ if (value === null || typeof value !== "object")
57
+ return JSON.stringify(value);
58
+ if (Array.isArray(value))
59
+ return `[${value.map(canonicalJson).join(",")}]`;
60
+ const record = value;
61
+ return `{${Object.keys(record)
62
+ .sort()
63
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
64
+ .join(",")}}`;
65
+ }
66
+ function canonicalSha256(value) {
67
+ return sha256(canonicalJson(value));
68
+ }
69
+ function controllerStateProjection(state) {
70
+ return {
71
+ phase: state.phase,
72
+ status: state.status,
73
+ mergeTasks: cloneJson(state.mergeTasks),
74
+ ...(state.mergeGuard ? { mergeGuard: cloneJson(state.mergeGuard) } : {}),
75
+ mergeReceipts: cloneJson(state.mergeReceipts),
76
+ humanDecisions: cloneJson(state.humanDecisions),
77
+ actionReceipts: cloneJson(state.actionReceipts),
78
+ };
79
+ }
80
+ function sameJsonValue(left, right) {
81
+ if (left === right)
82
+ return true;
83
+ if (Array.isArray(left) || Array.isArray(right)) {
84
+ return (Array.isArray(left) &&
85
+ Array.isArray(right) &&
86
+ left.length === right.length &&
87
+ left.every((entry, index) => sameJsonValue(entry, right[index])));
88
+ }
89
+ if (!isRecord(left) || !isRecord(right))
90
+ return false;
91
+ const leftKeys = Object.keys(left).sort();
92
+ const rightKeys = Object.keys(right).sort();
93
+ return (leftKeys.length === rightKeys.length &&
94
+ leftKeys.every((key, index) => key === rightKeys[index] && sameJsonValue(left[key], right[key])));
95
+ }
96
+ function isControllerIdentity(value) {
97
+ if (!isRecord(value))
98
+ return false;
99
+ const identity = value;
100
+ return (identity.packageName === "@tea-agent/loop-agent" &&
101
+ typeof identity.version === "string" &&
102
+ identity.version.length > 0 &&
103
+ typeof identity.packageRoot === "string" &&
104
+ path.isAbsolute(identity.packageRoot) &&
105
+ typeof identity.cliEntry === "string" &&
106
+ path.isAbsolute(identity.cliEntry) &&
107
+ isSha256(identity.cliEntrySha256) &&
108
+ identity.portableIdentity ===
109
+ `@tea-agent/loop-agent@${identity.version}:${identity.cliEntrySha256}`);
110
+ }
111
+ function sameControllerIdentity(left, right) {
112
+ return (left.packageName === right.packageName &&
113
+ left.version === right.version &&
114
+ left.packageRoot === right.packageRoot &&
115
+ left.cliEntry === right.cliEntry &&
116
+ left.cliEntrySha256 === right.cliEntrySha256 &&
117
+ left.portableIdentity === right.portableIdentity);
118
+ }
119
+ function assertStateShape(state, repoRoot, runId) {
120
+ if (!isRecord(state))
121
+ throw new Error("invalid init upgrade state");
122
+ const candidate = state;
123
+ const phases = [
124
+ "DISCOVER",
125
+ "VERSION_GATE",
126
+ "PLAN",
127
+ "APPLY_SAFE",
128
+ "MODEL_MERGE",
129
+ "VERIFY",
130
+ "COMPLETED",
131
+ ];
132
+ const statuses = [
133
+ "in-progress",
134
+ "completed",
135
+ "needs-human-decision",
136
+ "failed",
137
+ "cancelled",
138
+ ];
139
+ if (candidate.schemaVersion !== 1 ||
140
+ candidate.runId !== runId ||
141
+ candidate.repoRoot !== repoRoot ||
142
+ !phases.includes(candidate.phase) ||
143
+ !statuses.includes(candidate.status) ||
144
+ typeof candidate.createdAt !== "string" ||
145
+ typeof candidate.updatedAt !== "string" ||
146
+ typeof candidate.nextAction !== "string" ||
147
+ !isControllerIdentity(candidate.controllerIdentity) ||
148
+ !isRecord(candidate.versionGate) ||
149
+ !Array.isArray(candidate.mergeTasks) ||
150
+ !Array.isArray(candidate.mergeReceipts) ||
151
+ !Array.isArray(candidate.humanDecisions) ||
152
+ !Array.isArray(candidate.actionReceipts) ||
153
+ !isHomeFingerprint(candidate.homeFingerprint)) {
154
+ throw new Error("invalid init upgrade state or repoRoot authority");
155
+ }
156
+ }
157
+ function assertTerminalCoherence(state) {
158
+ if (state.status === "completed" && state.phase !== "COMPLETED") {
159
+ throw new Error("invalid init upgrade terminal status/phase coherence");
160
+ }
161
+ if (state.status === "cancelled" && state.phase !== "VERSION_GATE") {
162
+ throw new Error("invalid init upgrade terminal status/phase coherence");
163
+ }
164
+ if (state.phase === "COMPLETED" && state.status !== "completed") {
165
+ throw new Error("invalid init upgrade status/phase coherence");
166
+ }
167
+ if (state.status === "needs-human-decision" &&
168
+ state.phase !== "VERSION_GATE" &&
169
+ state.phase !== "MODEL_MERGE") {
170
+ throw new Error("invalid init upgrade status/phase coherence");
171
+ }
172
+ }
173
+ function runRoot(repoRoot) {
174
+ return path.join(repoRoot, RUN_ROOT);
175
+ }
176
+ function runDirectory(repoRoot, runId) {
177
+ if (!validRunId(runId))
178
+ throw new Error("invalid init upgrade run id");
179
+ return path.join(runRoot(repoRoot), runId);
180
+ }
181
+ async function exists(filePath) {
182
+ return Boolean(await stat(filePath).catch(() => undefined));
183
+ }
184
+ async function writeJsonAtomic(filePath, value) {
185
+ await mkdir(path.dirname(filePath), { recursive: true });
186
+ const temp = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
187
+ try {
188
+ await writeFile(temp, `${JSON.stringify(value, null, 2)}\n`, "utf-8");
189
+ await rename(temp, filePath);
190
+ }
191
+ catch (error) {
192
+ await rm(temp, { force: true }).catch(() => undefined);
193
+ throw error;
194
+ }
195
+ }
196
+ async function controllerAnchorPath(input) {
197
+ const repoRoot = await realpath(input.repoRoot).catch(() => {
198
+ throw new Error("init upgrade controller authority requires a readable Git repository");
199
+ });
200
+ const marker = path.join(repoRoot, ".git");
201
+ const markerStat = await lstat(marker).catch(() => undefined);
202
+ let gitDirectory;
203
+ if (markerStat?.isDirectory()) {
204
+ gitDirectory = await realpath(marker);
205
+ }
206
+ else if (markerStat?.isFile()) {
207
+ const text = await readFile(marker, "utf-8").catch(() => "");
208
+ const match = /^gitdir:\s*(.+?)\s*\r?\n?$/.exec(text);
209
+ if (!match || !match[1]) {
210
+ throw new Error("init upgrade controller authority rejected an invalid Git worktree file");
211
+ }
212
+ gitDirectory = await realpath(path.resolve(path.dirname(marker), match[1]));
213
+ }
214
+ else {
215
+ throw new Error("init upgrade controller authority requires a Git repository");
216
+ }
217
+ const gitStat = await stat(gitDirectory).catch(() => undefined);
218
+ if (!gitStat?.isDirectory() || !(await stat(path.join(gitDirectory, "HEAD")).catch(() => undefined))?.isFile()) {
219
+ throw new Error("init upgrade controller authority Git directory is unusable");
220
+ }
221
+ let anchorRoot = path.join(gitDirectory, "loop-agent-init-upgrades");
222
+ if (await lstat(anchorRoot).catch(() => undefined)) {
223
+ anchorRoot = await realpath(anchorRoot);
224
+ if (!isWithin(gitDirectory, anchorRoot)) {
225
+ throw new Error("init upgrade controller authority anchor path escapes Git directory");
226
+ }
227
+ }
228
+ const file = path.join(anchorRoot, `${sha256(`${repoRoot}\0${input.runId}`)}.json`);
229
+ if (!isWithin(anchorRoot, file)) {
230
+ throw new Error("init upgrade controller authority anchor path escapes Git directory");
231
+ }
232
+ return { repoRoot, gitDirectory, anchorRoot, file };
233
+ }
234
+ async function writeControllerAnchor(input) {
235
+ let location = await controllerAnchorPath(input.state);
236
+ await mkdir(location.anchorRoot, { recursive: true });
237
+ location = await controllerAnchorPath(input.state);
238
+ if (!isWithin(location.gitDirectory, location.anchorRoot)) {
239
+ throw new Error("init upgrade controller authority anchor path escapes Git directory");
240
+ }
241
+ let authoritativeHomeFingerprint;
242
+ if (input.initialize) {
243
+ if (await exists(location.file)) {
244
+ throw new Error("init upgrade controller authority anchor already exists");
245
+ }
246
+ if (!isHomeFingerprint(input.state.homeFingerprint)) {
247
+ throw new Error("init upgrade controller home fingerprint is invalid");
248
+ }
249
+ authoritativeHomeFingerprint = cloneJson(input.state.homeFingerprint);
250
+ }
251
+ else {
252
+ const current = await readControllerAnchor(location.repoRoot, input.state);
253
+ authoritativeHomeFingerprint = current.homeFingerprint;
254
+ }
255
+ await writeJsonAtomic(location.file, {
256
+ schemaVersion: 1,
257
+ repoRoot: location.repoRoot,
258
+ runId: input.state.runId,
259
+ controllerIdentity: input.state.controllerIdentity,
260
+ homeFingerprint: authoritativeHomeFingerprint,
261
+ ...(input.mergeAuthority ? { mergeAuthority: input.mergeAuthority } : {}),
262
+ ...(input.completion ? { completion: input.completion } : {}),
263
+ });
264
+ }
265
+ async function readControllerAnchor(repoRoot, state) {
266
+ const location = await controllerAnchorPath({ repoRoot, runId: state.runId });
267
+ let parsed;
268
+ try {
269
+ parsed = JSON.parse(await readFile(location.file, "utf-8"));
270
+ }
271
+ catch {
272
+ throw new Error("init upgrade controller authority anchor is missing or invalid");
273
+ }
274
+ if (!isRecord(parsed) ||
275
+ parsed.schemaVersion !== 1 ||
276
+ parsed.repoRoot !== location.repoRoot ||
277
+ parsed.runId !== state.runId ||
278
+ !isControllerIdentity(parsed.controllerIdentity) ||
279
+ !sameControllerIdentity(parsed.controllerIdentity, state.controllerIdentity)) {
280
+ throw new Error("init upgrade controller authority anchor binding is invalid");
281
+ }
282
+ if (!isHomeFingerprint(parsed.homeFingerprint) ||
283
+ !sameJsonValue(parsed.homeFingerprint, state.homeFingerprint)) {
284
+ throw new Error("init upgrade controller authority home fingerprint binding is invalid");
285
+ }
286
+ return parsed;
287
+ }
288
+ function mergeAuthorityFromAnchor(anchor, state) {
289
+ const authority = anchor.mergeAuthority;
290
+ if (!authority ||
291
+ !isRecord(authority.baselineWorkspace) ||
292
+ !Number.isInteger(authority.priorMergeReceiptCount) ||
293
+ authority.priorMergeReceiptCount < 0 ||
294
+ !isSha256(authority.priorMergeReceiptsSha256) ||
295
+ !isRecord(authority.stateProjection) ||
296
+ !isSha256(authority.stateProjectionSha256) ||
297
+ canonicalSha256(authority.stateProjection) !== authority.stateProjectionSha256 ||
298
+ !isFrozenVerificationSurface(authority.verificationSurface)) {
299
+ throw new Error("init upgrade controller merge baseline authority is missing or inconsistent");
300
+ }
301
+ validateTaskBoundary(authority.task);
302
+ if (authority.pending) {
303
+ assertPendingAcceptance(authority.pending, state, authority.task);
304
+ if (authority.pending.transition.priorMergeReceiptCount !==
305
+ authority.priorMergeReceiptCount) {
306
+ throw new Error("pending merge acceptance receipt count differs from controller authority");
307
+ }
308
+ }
309
+ return authority;
310
+ }
311
+ function expectedPostAcceptanceProjection(authority) {
312
+ if (!authority.pending) {
313
+ throw new Error("post-acceptance merge state projection lacks controller merge authority for pending acceptance");
314
+ }
315
+ const projection = cloneJson(authority.stateProjection);
316
+ projection.phase = "PLAN";
317
+ projection.status = "in-progress";
318
+ projection.mergeTasks = [];
319
+ delete projection.mergeGuard;
320
+ projection.mergeReceipts.push(cloneJson(authority.pending.receipt));
321
+ return projection;
322
+ }
323
+ function mergeAuthorityProjection(state, authority) {
324
+ const preAcceptance = state.phase === "MODEL_MERGE";
325
+ if (preAcceptance) {
326
+ if (state.mergeTasks.length !== 1) {
327
+ throw new Error("init upgrade merge state must contain exactly one canonical current merge task");
328
+ }
329
+ if (!sameJsonValue(state.mergeTasks[0], authority.task)) {
330
+ throw new Error("init upgrade merge state is inconsistent with the canonical current merge task");
331
+ }
332
+ if (!state.mergeGuard) {
333
+ throw new Error("init upgrade merge guard is missing; refusing to continue an unbounded model merge");
334
+ }
335
+ if (!sameJsonValue(state.mergeGuard.task, authority.task)) {
336
+ throw new Error("init upgrade merge guard is inconsistent with the canonical current merge task");
337
+ }
338
+ if (!sameJsonValue(state.mergeGuard.baselineWorkspace, authority.baselineWorkspace)) {
339
+ throw new Error("init upgrade merge guard projection differs from controller baseline authority");
340
+ }
341
+ if (state.mergeReceipts.length !== authority.priorMergeReceiptCount) {
342
+ throw new Error("persisted pre-acceptance merge receipt count differs from controller authority");
343
+ }
344
+ }
345
+ const priorReceipts = state.mergeReceipts.slice(0, authority.priorMergeReceiptCount);
346
+ if (priorReceipts.length !== authority.priorMergeReceiptCount ||
347
+ sha256(serializeJson(priorReceipts)) !== authority.priorMergeReceiptsSha256) {
348
+ throw new Error("persisted merge receipt prefix differs from controller authority");
349
+ }
350
+ const currentProjection = controllerStateProjection(state);
351
+ const expectedProjection = preAcceptance
352
+ ? authority.stateProjection
353
+ : expectedPostAcceptanceProjection(authority);
354
+ if (canonicalSha256(currentProjection) !== canonicalSha256(expectedProjection) ||
355
+ !sameJsonValue(currentProjection, expectedProjection)) {
356
+ throw new Error("persisted merge state projection differs from controller merge authority");
357
+ }
358
+ if (preAcceptance)
359
+ return "pre-acceptance";
360
+ const postAcceptance = Boolean(authority.pending) &&
361
+ state.phase === "PLAN" &&
362
+ state.mergeTasks.length === 0 &&
363
+ state.mergeGuard === undefined &&
364
+ state.mergeReceipts.length === authority.priorMergeReceiptCount + 1 &&
365
+ sameJsonValue(state.mergeReceipts[authority.priorMergeReceiptCount], authority.pending?.receipt);
366
+ if (postAcceptance)
367
+ return "post-acceptance";
368
+ throw new Error("persisted merge state projection differs from controller merge authority");
369
+ }
370
+ async function packageRoot() {
371
+ let current = path.dirname(fileURLToPath(import.meta.url));
372
+ while (true) {
373
+ if (await exists(path.join(current, "package.json")))
374
+ return current;
375
+ const parent = path.dirname(current);
376
+ if (parent === current)
377
+ throw new Error("unable to resolve loop-agent package root");
378
+ current = parent;
379
+ }
380
+ }
381
+ async function readIdentity() {
382
+ const root = await packageRoot();
383
+ const pkg = JSON.parse(await readFile(path.join(root, "package.json"), "utf-8"));
384
+ if (typeof pkg.version !== "string")
385
+ throw new Error("loop-agent package version is missing");
386
+ const cliEntry = path.resolve(process.argv[1] ?? path.join(root, "src", "cli.ts"));
387
+ const cliBytes = await readFile(cliEntry).catch(() => Buffer.from("unreadable-cli-entry"));
388
+ const cliEntrySha256 = sha256(cliBytes);
389
+ return {
390
+ packageName: "@tea-agent/loop-agent",
391
+ version: pkg.version,
392
+ packageRoot: root,
393
+ cliEntry,
394
+ cliEntrySha256,
395
+ portableIdentity: `@tea-agent/loop-agent@${pkg.version}:${cliEntrySha256}`,
396
+ };
397
+ }
398
+ async function homeFingerprint() {
399
+ const homeSettings = path.join(os.homedir(), ".pi", "agent", "settings.json");
400
+ try {
401
+ return { present: true, sha256: sha256(await readFile(homeSettings)) };
402
+ }
403
+ catch {
404
+ return { present: false };
405
+ }
406
+ }
407
+ function versionsNewer(candidate, current) {
408
+ const parse = (value) => value.match(/^(\d+)\.(\d+)\.(\d+)$/)?.slice(1).map(Number);
409
+ const left = parse(candidate);
410
+ const right = parse(current);
411
+ if (!left || !right)
412
+ return false;
413
+ for (let index = 0; index < left.length; index += 1) {
414
+ if (left[index] === right[index])
415
+ continue;
416
+ return left[index] > right[index];
417
+ }
418
+ return false;
419
+ }
420
+ async function readState(repoRoot, runId) {
421
+ const file = path.join(runDirectory(repoRoot, runId), "state.json");
422
+ const parsed = JSON.parse(await readFile(file, "utf-8"));
423
+ assertStateShape(parsed, repoRoot, runId);
424
+ return parsed;
425
+ }
426
+ async function frozenControllerMatches(directory, state) {
427
+ const parsed = JSON.parse(await readFile(path.join(directory, "controller-identity.json"), "utf-8"));
428
+ if (!isRecord(parsed) ||
429
+ parsed.schemaVersion !== 1 ||
430
+ parsed.runId !== state.runId ||
431
+ parsed.repoRoot !== state.repoRoot ||
432
+ !isControllerIdentity(parsed.controllerIdentity)) {
433
+ throw new Error("invalid frozen init upgrade controller identity");
434
+ }
435
+ const frozen = {
436
+ schemaVersion: 1,
437
+ runId: parsed.runId,
438
+ repoRoot: parsed.repoRoot,
439
+ controllerIdentity: parsed.controllerIdentity,
440
+ };
441
+ if (!sameControllerIdentity(frozen.controllerIdentity, state.controllerIdentity)) {
442
+ throw new Error("persisted init upgrade controller identity is inconsistent");
443
+ }
444
+ return sameControllerIdentity(await readIdentity(), frozen.controllerIdentity);
445
+ }
446
+ async function saveState(directory, state) {
447
+ state.updatedAt = new Date().toISOString();
448
+ await writeJsonAtomic(path.join(directory, "state.json"), state);
449
+ }
450
+ function toResult(state, verification) {
451
+ return {
452
+ runId: state.runId,
453
+ status: state.status,
454
+ phase: state.phase,
455
+ controllerIdentity: state.controllerIdentity,
456
+ deterministicActions: state.actionReceipts,
457
+ modelMergeTasks: state.mergeTasks,
458
+ humanDecisions: state.humanDecisions,
459
+ nextAction: state.nextAction,
460
+ ...(verification ? { verification } : {}),
461
+ };
462
+ }
463
+ function renderReport(state, verification) {
464
+ const lines = [
465
+ "# loop-agent init upgrade report",
466
+ "",
467
+ `- runId: \`${state.runId}\``,
468
+ `- status: \`${state.status}\``,
469
+ `- phase: \`${state.phase}\``,
470
+ `- controller: \`${state.controllerIdentity.portableIdentity}\``,
471
+ `- version gate: \`${state.versionGate.outcome}\``,
472
+ `- version choice: \`${state.versionGate.choice ?? "pending"}\``,
473
+ `- deterministic actions applied: ${state.actionReceipts.length}`,
474
+ `- semantic merges accepted: ${state.mergeReceipts.length}`,
475
+ `- preserved user modifications: ${state.mergeReceipts.length}`,
476
+ `- retired path actions: ${state.actionReceipts.filter((action) => action.type === "migrate-owned-file" || action.type === "remove-owned-file" || action.type === "remove-empty-directory").length}`,
477
+ `- remaining model merge tasks: ${state.mergeTasks.length}`,
478
+ `- remaining human decisions: ${state.humanDecisions.length}`,
479
+ "",
480
+ "## Next action",
481
+ "",
482
+ state.nextAction,
483
+ "",
484
+ "## Project Pi trust",
485
+ "",
486
+ "Pi must trust the target project before loading `.pi/settings.json` and `.pi/extensions/`. This upgrade never writes `~/.pi/agent/settings.json`.",
487
+ "",
488
+ ...(verification
489
+ ? [
490
+ "## Verification",
491
+ "",
492
+ "```json",
493
+ JSON.stringify(verification, null, 2),
494
+ "```",
495
+ ]
496
+ : []),
497
+ ];
498
+ return `${lines.join("\n")}\n`;
499
+ }
500
+ async function writeReport(directory, state, verification) {
501
+ await writeFile(path.join(directory, "final-report.md"), renderReport(state, verification), "utf-8");
502
+ }
503
+ async function assertInactive(input) {
504
+ let activity;
505
+ try {
506
+ activity = await input.readRuntimeActivity(path.resolve(input.repoRoot));
507
+ }
508
+ catch (error) {
509
+ throw new Error(`init upgrade blocked: runtime activity is unknown (${error instanceof Error ? error.message : String(error)})`);
510
+ }
511
+ if (isInitRuntimeActive(activity)) {
512
+ throw new Error("init upgrade blocked: active DAG, Worker, or exclusive writer is present");
513
+ }
514
+ return activity;
515
+ }
516
+ function processLiveness(pid) {
517
+ if (!Number.isInteger(pid) || pid <= 0)
518
+ return "unknown";
519
+ try {
520
+ process.kill(pid, 0);
521
+ return "active";
522
+ }
523
+ catch (error) {
524
+ const code = error.code;
525
+ if (code === "ESRCH")
526
+ return "stale";
527
+ return "unknown";
528
+ }
529
+ }
530
+ async function acquireGlobalLock(input) {
531
+ const root = runRoot(input.repoRoot);
532
+ await mkdir(root, { recursive: true });
533
+ const lock = path.join(root, GLOBAL_LOCK_NAME);
534
+ const token = randomUUID();
535
+ const owner = {
536
+ schemaVersion: 1,
537
+ token,
538
+ runId: input.runId,
539
+ pid: process.pid,
540
+ hostname: os.hostname(),
541
+ acquiredAt: new Date().toISOString(),
542
+ };
543
+ const tryCreate = async () => {
544
+ try {
545
+ await mkdir(lock);
546
+ await writeJsonAtomic(path.join(lock, "owner.json"), owner);
547
+ return true;
548
+ }
549
+ catch (error) {
550
+ if (error.code === "EEXIST")
551
+ return false;
552
+ throw error;
553
+ }
554
+ };
555
+ if (!(await tryCreate())) {
556
+ let existing;
557
+ try {
558
+ existing = JSON.parse(await readFile(path.join(lock, "owner.json"), "utf-8"));
559
+ }
560
+ catch {
561
+ throw new Error("init upgrade blocked: global upgrade owner is unreadable; lock state is unknown");
562
+ }
563
+ if (existing.hostname !== os.hostname() ||
564
+ typeof existing.pid !== "number") {
565
+ throw new Error(`init upgrade blocked: global upgrade owner is active or unknown (runId=${String(existing.runId ?? "unknown")})`);
566
+ }
567
+ const liveness = processLiveness(existing.pid);
568
+ if (liveness !== "stale") {
569
+ throw new Error(`init upgrade blocked: global upgrade owner is ${liveness} (runId=${String(existing.runId ?? "unknown")})`);
570
+ }
571
+ await rm(lock, { recursive: true, force: true });
572
+ if (!(await tryCreate())) {
573
+ throw new Error("init upgrade blocked: another upgrade writer acquired the global lock");
574
+ }
575
+ }
576
+ return async () => {
577
+ try {
578
+ const current = JSON.parse(await readFile(path.join(lock, "owner.json"), "utf-8"));
579
+ if (current.token === token) {
580
+ await rm(lock, { recursive: true, force: true });
581
+ }
582
+ }
583
+ catch {
584
+ // Fail closed: never remove a lock whose ownership can no longer be proven.
585
+ }
586
+ };
587
+ }
588
+ async function currentVersionGate(input, identity) {
589
+ try {
590
+ const latest = input.latestVersion
591
+ ? await input.latestVersion()
592
+ : await createNpmUpdateClient({
593
+ currentVersion: identity.version,
594
+ packageRoot: identity.packageRoot,
595
+ }).latestVersion();
596
+ if (!latest)
597
+ return { outcome: "registry-unavailable" };
598
+ return versionsNewer(latest, identity.version)
599
+ ? { outcome: "newer-available", latestVersion: latest }
600
+ : { outcome: "latest", latestVersion: latest };
601
+ }
602
+ catch {
603
+ return { outcome: "registry-unavailable" };
604
+ }
605
+ }
606
+ function toRepoPath(repoRoot, absolutePath) {
607
+ return path.relative(repoRoot, absolutePath).split(path.sep).join("/");
608
+ }
609
+ function isWithin(root, candidate) {
610
+ const relative = path.relative(root, candidate);
611
+ return (relative === "" ||
612
+ (!relative.startsWith("..") && !path.isAbsolute(relative)));
613
+ }
614
+ function normalizeAllowedPath(relativePath) {
615
+ if (!relativePath ||
616
+ path.isAbsolute(relativePath) ||
617
+ /[?*[\]{}!]/.test(relativePath)) {
618
+ throw new Error(`init upgrade merge guard rejected non-concrete allowedPath: ${relativePath}`);
619
+ }
620
+ const normalized = path.posix.normalize(relativePath.replace(/\\/g, "/"));
621
+ if (normalized === "." || normalized.startsWith("../")) {
622
+ throw new Error(`init upgrade merge guard rejected path outside repo: ${relativePath}`);
623
+ }
624
+ return normalized;
625
+ }
626
+ async function assertSafeMergePath(repoRoot, relativePath) {
627
+ const normalized = normalizeAllowedPath(relativePath);
628
+ const rootReal = await realpath(repoRoot);
629
+ const target = path.resolve(repoRoot, normalized);
630
+ if (!isWithin(path.resolve(repoRoot), target)) {
631
+ throw new Error(`init upgrade merge path escapes repo: ${normalized}`);
632
+ }
633
+ const targetStat = await lstat(target).catch((error) => {
634
+ if (error.code === "ENOENT")
635
+ return undefined;
636
+ throw error;
637
+ });
638
+ if (targetStat?.isSymbolicLink()) {
639
+ throw new Error(`init upgrade merge path is a symlink: ${normalized}`);
640
+ }
641
+ if (targetStat && !targetStat.isFile()) {
642
+ throw new Error(`init upgrade merge path is not a file: ${normalized}`);
643
+ }
644
+ const containmentTarget = targetStat ? target : path.dirname(target);
645
+ const containedReal = await realpath(containmentTarget);
646
+ if (!isWithin(rootReal, containedReal)) {
647
+ throw new Error(`init upgrade merge realpath escapes repo: ${normalized}`);
648
+ }
649
+ }
650
+ async function snapshotWorkspace(repoRoot) {
651
+ const snapshot = {};
652
+ async function visit(directory) {
653
+ const entries = await readdir(directory, { withFileTypes: true });
654
+ entries.sort((left, right) => left.name.localeCompare(right.name));
655
+ for (const entry of entries) {
656
+ const absolute = path.join(directory, entry.name);
657
+ const relative = toRepoPath(repoRoot, absolute);
658
+ if (relative === ".git" || relative.startsWith(".git/"))
659
+ continue;
660
+ if (relative === RUN_ROOT || relative.startsWith(`${RUN_ROOT}/`))
661
+ continue;
662
+ if (entry.isSymbolicLink()) {
663
+ snapshot[relative] = `symlink:${await readlink(absolute)}`;
664
+ continue;
665
+ }
666
+ if (entry.isDirectory()) {
667
+ snapshot[relative] = "directory";
668
+ await visit(absolute);
669
+ continue;
670
+ }
671
+ if (entry.isFile()) {
672
+ snapshot[relative] = `file:${sha256(await readFile(absolute))}`;
673
+ continue;
674
+ }
675
+ snapshot[relative] = "special";
676
+ }
677
+ }
678
+ await visit(repoRoot);
679
+ return snapshot;
680
+ }
681
+ function changedWorkspacePaths(before, after) {
682
+ return [...new Set([...Object.keys(before), ...Object.keys(after)])]
683
+ .filter((relativePath) => before[relativePath] !== after[relativePath])
684
+ .sort();
685
+ }
686
+ function isFrozenVerificationSurface(value) {
687
+ return (isRecord(value) &&
688
+ value.schemaVersion === 1 &&
689
+ Array.isArray(value.files) &&
690
+ value.files.length > 0 &&
691
+ value.files.every((entry) => isRecord(entry) &&
692
+ typeof entry.path === "string" &&
693
+ entry.path === normalizeAllowedPath(entry.path) &&
694
+ isSha256(entry.sha256)) &&
695
+ new Set(value.files.map((entry) => entry.path)).size === value.files.length);
696
+ }
697
+ async function assertSafeRegularRepoFile(repoRoot, relativePath) {
698
+ const normalized = normalizeAllowedPath(relativePath);
699
+ await assertSafeMergePath(repoRoot, normalized);
700
+ const target = path.join(repoRoot, normalized);
701
+ const targetStat = await lstat(target).catch(() => undefined);
702
+ if (!targetStat?.isFile() || targetStat.isSymbolicLink()) {
703
+ throw new Error(`verification authority input is not a regular file: ${normalized}`);
704
+ }
705
+ return normalized;
706
+ }
707
+ async function staticScriptDependencies(repoRoot, relativePath, collected) {
708
+ const normalized = await assertSafeRegularRepoFile(repoRoot, relativePath);
709
+ if (collected.has(normalized))
710
+ return;
711
+ collected.add(normalized);
712
+ const content = await readFile(path.join(repoRoot, normalized), "utf-8");
713
+ const dependencies = new Set();
714
+ for (const match of content.matchAll(/(?:^|[;\s])(?:bash|source|\.)\s+["']?([A-Za-z0-9_./-]+\.sh)["']?/gm)) {
715
+ if (match[1])
716
+ dependencies.add(match[1]);
717
+ }
718
+ // check-repo-style arrays dispatch quoted script paths indirectly through a variable.
719
+ for (const match of content.matchAll(/["'](scripts\/[A-Za-z0-9_./-]+\.sh)["']/g)) {
720
+ if (match[1])
721
+ dependencies.add(match[1]);
722
+ }
723
+ for (const dependency of dependencies) {
724
+ if (!path.isAbsolute(dependency) && !dependency.startsWith("../")) {
725
+ await staticScriptDependencies(repoRoot, dependency, collected);
726
+ }
727
+ }
728
+ // Direct interpreter inputs are verification authority too. Only existing,
729
+ // concrete repo files are accepted; optional absent fallback branches are not.
730
+ for (const match of content.matchAll(/(?:scripts|src)\/[A-Za-z0-9_./-]+\.(?:mjs|cjs|js|ts)/g)) {
731
+ const dependency = match[0];
732
+ if (await exists(path.join(repoRoot, dependency))) {
733
+ collected.add(await assertSafeRegularRepoFile(repoRoot, dependency));
734
+ }
735
+ }
736
+ }
737
+ async function existingRepoFileTokens(repoRoot, tokens) {
738
+ const files = [];
739
+ for (const token of tokens) {
740
+ const candidate = token.includes("=") ? token.slice(token.indexOf("=") + 1) : token;
741
+ if (!candidate || candidate.startsWith("-") || path.isAbsolute(candidate)) {
742
+ continue;
743
+ }
744
+ let normalized;
745
+ try {
746
+ normalized = normalizeAllowedPath(candidate);
747
+ }
748
+ catch {
749
+ continue;
750
+ }
751
+ if (await exists(path.join(repoRoot, normalized))) {
752
+ files.push(await assertSafeRegularRepoFile(repoRoot, normalized));
753
+ }
754
+ }
755
+ return [...new Set(files)];
756
+ }
757
+ async function commandRepositoryInputs(repoRoot, commandText, npmScriptStack = []) {
758
+ const tokens = tokenizeCommand(commandText);
759
+ const command = tokens[0];
760
+ if (!command)
761
+ throw new Error("empty verification command");
762
+ if (command === "node" || command === "node.exe") {
763
+ if (tokens.some((token) => ["-e", "--eval", "-p", "--print"].includes(token))) {
764
+ throw new Error("verification authority rejects direct node code evaluation");
765
+ }
766
+ const files = await existingRepoFileTokens(repoRoot, tokens.slice(1));
767
+ if (files.length === 0) {
768
+ throw new Error("verification authority requires node to execute a concrete repo-local entry");
769
+ }
770
+ return files;
771
+ }
772
+ if (command === "bash") {
773
+ if (tokens.length < 2 || !tokens[1]?.endsWith(".sh")) {
774
+ throw new Error("verification authority requires bash to execute one concrete repo-local script");
775
+ }
776
+ return [await assertSafeRegularRepoFile(repoRoot, tokens[1])];
777
+ }
778
+ if (command === "npm" || command === "npm.cmd") {
779
+ if (tokens.some((token) => ["-e", "--eval", "-p", "--print"].includes(token))) {
780
+ throw new Error("verification authority rejects package command code evaluation");
781
+ }
782
+ let scriptName;
783
+ if (tokens[1] === "run" || tokens[1] === "run-script")
784
+ scriptName = tokens[2];
785
+ else if (["test", "start", "stop", "restart"].includes(tokens[1] ?? ""))
786
+ scriptName = tokens[1];
787
+ if (!scriptName || scriptName.startsWith("-")) {
788
+ throw new Error("verification authority requires npm to select one concrete package script");
789
+ }
790
+ if (npmScriptStack.includes(scriptName)) {
791
+ throw new Error(`verification authority rejected recursive npm script: ${scriptName}`);
792
+ }
793
+ const packagePath = await assertSafeRegularRepoFile(repoRoot, "package.json");
794
+ const packageJson = JSON.parse(await readFile(path.join(repoRoot, packagePath), "utf-8"));
795
+ const script = packageJson.scripts?.[scriptName];
796
+ if (typeof script !== "string" || !script.trim()) {
797
+ throw new Error(`verification authority package script is missing: ${scriptName}`);
798
+ }
799
+ const nested = [];
800
+ for (const lifecycleName of [`pre${scriptName}`, scriptName, `post${scriptName}`]) {
801
+ const lifecycleScript = packageJson.scripts?.[lifecycleName];
802
+ if (typeof lifecycleScript !== "string" || !lifecycleScript.trim())
803
+ continue;
804
+ nested.push(...(await commandRepositoryInputs(repoRoot, lifecycleScript, [
805
+ ...npmScriptStack,
806
+ scriptName,
807
+ lifecycleName,
808
+ ])));
809
+ }
810
+ return [...new Set([packagePath, ...nested])];
811
+ }
812
+ if (command === "npx" || command === "npx.cmd") {
813
+ if (tokens.some((token) => ["-e", "--eval", "-p", "--print", "-c", "--call"].includes(token))) {
814
+ throw new Error("verification authority rejects package command code evaluation");
815
+ }
816
+ let runnerIndex = 1;
817
+ while (["-y", "--yes", "--no-install"].includes(tokens[runnerIndex] ?? "")) {
818
+ runnerIndex += 1;
819
+ }
820
+ const runner = tokens[runnerIndex];
821
+ if (!runner || runner.startsWith("-")) {
822
+ throw new Error("verification authority requires npx to select one concrete runner");
823
+ }
824
+ const files = await existingRepoFileTokens(repoRoot, tokens.slice(runnerIndex));
825
+ if (files.length === 0) {
826
+ throw new Error("verification authority requires npx to bind a concrete repo-local entry");
827
+ }
828
+ const inputs = [...files];
829
+ if (await exists(path.join(repoRoot, "package.json"))) {
830
+ inputs.unshift(await assertSafeRegularRepoFile(repoRoot, "package.json"));
831
+ }
832
+ return [...new Set(inputs)];
833
+ }
834
+ if (command.includes("/") || command.startsWith(".")) {
835
+ return [await assertSafeRegularRepoFile(repoRoot, command)];
836
+ }
837
+ const directInputs = await existingRepoFileTokens(repoRoot, tokens.slice(1));
838
+ if (directInputs.length > 0)
839
+ return directInputs;
840
+ throw new Error(`verification authority rejects unbound executable: ${command}`);
841
+ }
842
+ async function freezeVerificationSurface(repoRoot) {
843
+ const collected = new Set();
844
+ const add = async (relativePath) => {
845
+ collected.add(await assertSafeRegularRepoFile(repoRoot, relativePath));
846
+ };
847
+ await add("harness.json");
848
+ const harness = JSON.parse(await readFile(path.join(repoRoot, "harness.json"), "utf-8"));
849
+ const governanceRoot = typeof harness.governanceRoot === "string" ? harness.governanceRoot : "ai_workspace/loop-agent";
850
+ const matrixPath = typeof harness.entrypoints?.verificationMatrix === "string"
851
+ ? harness.entrypoints.verificationMatrix
852
+ : `${governanceRoot}/verification-matrix.md`;
853
+ await add(matrixPath);
854
+ const commands = ["bash scripts/check-repo.sh", await discoverQuickVerification(repoRoot)];
855
+ for (const command of commands) {
856
+ for (const input of await commandRepositoryInputs(repoRoot, command)) {
857
+ const normalized = await assertSafeRegularRepoFile(repoRoot, input);
858
+ if (normalized.endsWith(".sh"))
859
+ await staticScriptDependencies(repoRoot, normalized, collected);
860
+ else
861
+ collected.add(normalized);
862
+ }
863
+ }
864
+ const files = await Promise.all([...collected].sort().map(async (relativePath) => ({
865
+ path: relativePath,
866
+ sha256: sha256(await readFile(path.join(repoRoot, relativePath))),
867
+ })));
868
+ return { schemaVersion: 1, files };
869
+ }
870
+ async function assertFrozenVerificationSurface(repoRoot, surface) {
871
+ if (!isFrozenVerificationSurface(surface)) {
872
+ throw new Error("verification authority surface manifest is invalid");
873
+ }
874
+ for (const entry of surface.files) {
875
+ const normalized = await assertSafeRegularRepoFile(repoRoot, entry.path);
876
+ if (normalized !== entry.path || sha256(await readFile(path.join(repoRoot, normalized))) !== entry.sha256) {
877
+ throw new Error(`verification authority surface changed: ${entry.path}`);
878
+ }
879
+ }
880
+ }
881
+ function assertTaskOutsideVerificationSurface(task, surface) {
882
+ const taskPath = normalizeAllowedPath(task.path);
883
+ if (surface.files.some((entry) => entry.path === taskPath)) {
884
+ throw new Error(`init upgrade merge task targets authority-sensitive verification input: ${taskPath}`);
885
+ }
886
+ }
887
+ async function snapshotDirectory(directory) {
888
+ const snapshot = {};
889
+ const visit = async (absolute, relative) => {
890
+ const entries = await readdir(absolute, { withFileTypes: true });
891
+ entries.sort((left, right) => left.name.localeCompare(right.name));
892
+ for (const entry of entries) {
893
+ const childRelative = relative ? `${relative}/${entry.name}` : entry.name;
894
+ const child = path.join(absolute, entry.name);
895
+ if (entry.isSymbolicLink())
896
+ snapshot[childRelative] = `symlink:${await readlink(child)}`;
897
+ else if (entry.isDirectory()) {
898
+ snapshot[childRelative] = "directory";
899
+ await visit(child, childRelative);
900
+ }
901
+ else if (entry.isFile())
902
+ snapshot[childRelative] = `file:${sha256(await readFile(child))}`;
903
+ else
904
+ snapshot[childRelative] = "special";
905
+ }
906
+ };
907
+ await visit(directory, "");
908
+ return snapshot;
909
+ }
910
+ async function controllerAnchorSnapshot(repoRoot, state) {
911
+ const location = await controllerAnchorPath({ repoRoot, runId: state.runId });
912
+ return await snapshotDirectory(location.anchorRoot);
913
+ }
914
+ function validateTaskBoundary(task) {
915
+ const allowed = task.allowedPaths.map(normalizeAllowedPath);
916
+ if (allowed.length !== 1 || allowed[0] !== normalizeAllowedPath(task.path)) {
917
+ throw new Error(`init upgrade merge task ${task.taskId} must declare exactly its concrete single-file path`);
918
+ }
919
+ return allowed;
920
+ }
921
+ async function createMergeGuard(repoRoot, task) {
922
+ for (const allowedPath of validateTaskBoundary(task)) {
923
+ await assertSafeMergePath(repoRoot, allowedPath);
924
+ }
925
+ return { task, baselineWorkspace: await snapshotWorkspace(repoRoot) };
926
+ }
927
+ function isMergeReceipt(value) {
928
+ if (!isRecord(value))
929
+ return false;
930
+ return (typeof value.taskId === "string" &&
931
+ typeof value.path === "string" &&
932
+ isSha256(value.currentSha256) &&
933
+ isSha256(value.desiredSha256) &&
934
+ typeof value.acceptedAt === "string" &&
935
+ !Number.isNaN(Date.parse(value.acceptedAt)));
936
+ }
937
+ function assertPendingAcceptance(value, state, task) {
938
+ if (!isRecord(value) ||
939
+ value.schemaVersion !== 1 ||
940
+ !isRecord(value.guard) ||
941
+ !isRecord(value.transition) ||
942
+ !isRecord(value.surfaceTransition)) {
943
+ throw new Error("invalid pending merge acceptance record");
944
+ }
945
+ const pending = value;
946
+ const desiredSha256 = task.evidence.desiredSha256;
947
+ const guard = pending.guard;
948
+ const receipt = pending.receipt;
949
+ const transition = pending.transition;
950
+ const surfaceTransition = pending.surfaceTransition;
951
+ const canonicalPath = normalizeAllowedPath(task.path);
952
+ if (pending.runId !== state.runId ||
953
+ pending.repoRoot !== state.repoRoot ||
954
+ guard.taskId !== task.taskId ||
955
+ guard.path !== canonicalPath ||
956
+ !Array.isArray(guard.allowedPaths) ||
957
+ guard.allowedPaths.length !== 1 ||
958
+ guard.allowedPaths[0] !== canonicalPath ||
959
+ !isSha256(guard.currentSha256) ||
960
+ !isSha256(guard.desiredSha256) ||
961
+ !isSha256(desiredSha256) ||
962
+ guard.desiredSha256 !== desiredSha256 ||
963
+ !isMergeReceipt(receipt) ||
964
+ receipt.taskId !== guard.taskId ||
965
+ receipt.path !== guard.path ||
966
+ receipt.currentSha256 !== guard.currentSha256 ||
967
+ receipt.desiredSha256 !== guard.desiredSha256 ||
968
+ transition.fromPhase !== "MODEL_MERGE" ||
969
+ transition.toPhase !== "PLAN" ||
970
+ !Number.isInteger(transition.priorMergeReceiptCount) ||
971
+ transition.priorMergeReceiptCount < 0 ||
972
+ surfaceTransition.path !== canonicalPath ||
973
+ !isSha256(surfaceTransition.beforeSha256) ||
974
+ !isSha256(surfaceTransition.acceptedSha256) ||
975
+ !isRecord(surfaceTransition.beforeEntry)) {
976
+ throw new Error("pending merge acceptance binding is invalid");
977
+ }
978
+ }
979
+ function acceptedSurface(input) {
980
+ if (!isRecord(input.surface) || !isRecord(input.surface.files)) {
981
+ throw new Error("init upgrade merge surface transition is invalid");
982
+ }
983
+ const canonicalPath = normalizeAllowedPath(input.task.path);
984
+ const entry = input.surface.files[canonicalPath];
985
+ if (!isRecord(entry)) {
986
+ throw new Error(`init upgrade cannot accept ${canonicalPath}: recorded surface entry is missing`);
987
+ }
988
+ const accepted = cloneJson(input.surface);
989
+ const acceptedFiles = accepted.files;
990
+ const acceptedEntry = isRecord(acceptedFiles)
991
+ ? acceptedFiles[canonicalPath]
992
+ : undefined;
993
+ if (!isRecord(acceptedEntry)) {
994
+ throw new Error("init upgrade merge surface transition is invalid");
995
+ }
996
+ acceptedEntry.currentSha256 = input.currentSha256;
997
+ acceptedEntry.acceptedMerge = {
998
+ currentSha256: input.currentSha256,
999
+ desiredSha256: input.desiredSha256,
1000
+ };
1001
+ return { beforeEntry: cloneJson(entry), accepted };
1002
+ }
1003
+ async function validatePendingSurfaceTransition(input) {
1004
+ const transition = input.pending.surfaceTransition;
1005
+ const baselineSurface = input.guard.baselineWorkspace[INIT_SURFACE_STATE_PATH];
1006
+ if (baselineSurface !== `file:${transition.beforeSha256}`) {
1007
+ throw new Error("pending merge acceptance surface transition is not bound to the merge baseline");
1008
+ }
1009
+ const surfacePath = path.join(input.repoRoot, INIT_SURFACE_STATE_PATH);
1010
+ let surfaceText;
1011
+ let surface;
1012
+ try {
1013
+ surfaceText = await readFile(surfacePath, "utf-8");
1014
+ surface = JSON.parse(surfaceText);
1015
+ }
1016
+ catch {
1017
+ throw new Error("pending merge acceptance init-surface is invalid");
1018
+ }
1019
+ const actualSha256 = sha256(surfaceText);
1020
+ if (actualSha256 === transition.beforeSha256) {
1021
+ if (!isRecord(surface) ||
1022
+ !isRecord(surface.files) ||
1023
+ !sameJsonValue(surface.files[normalizeAllowedPath(input.task.path)], transition.beforeEntry)) {
1024
+ throw new Error("pending merge acceptance before surface transition is inconsistent");
1025
+ }
1026
+ const expected = acceptedSurface({
1027
+ surface,
1028
+ task: input.task,
1029
+ currentSha256: input.pending.guard.currentSha256,
1030
+ desiredSha256: input.pending.guard.desiredSha256,
1031
+ }).accepted;
1032
+ if (sha256(serializeJson(expected)) !== transition.acceptedSha256) {
1033
+ throw new Error("pending merge acceptance accepted surface transition is inconsistent");
1034
+ }
1035
+ return "before";
1036
+ }
1037
+ if (actualSha256 !== transition.acceptedSha256) {
1038
+ throw new Error("pending merge acceptance init-surface contains an unrelated modification");
1039
+ }
1040
+ if (!isRecord(surface) || !isRecord(surface.files)) {
1041
+ throw new Error("pending merge acceptance init-surface is invalid");
1042
+ }
1043
+ const reconstructedBefore = cloneJson(surface);
1044
+ const reconstructedFiles = reconstructedBefore.files;
1045
+ if (!isRecord(reconstructedFiles)) {
1046
+ throw new Error("pending merge acceptance init-surface is invalid");
1047
+ }
1048
+ reconstructedFiles[normalizeAllowedPath(input.task.path)] = cloneJson(transition.beforeEntry);
1049
+ if (sha256(serializeJson(reconstructedBefore)) !== transition.beforeSha256) {
1050
+ throw new Error("pending merge acceptance surface transition changed unrelated facts");
1051
+ }
1052
+ const expectedAccepted = acceptedSurface({
1053
+ surface: reconstructedBefore,
1054
+ task: input.task,
1055
+ currentSha256: input.pending.guard.currentSha256,
1056
+ desiredSha256: input.pending.guard.desiredSha256,
1057
+ }).accepted;
1058
+ if (serializeJson(expectedAccepted) !== surfaceText) {
1059
+ throw new Error("pending merge acceptance accepted surface transition is not exact");
1060
+ }
1061
+ return "accepted";
1062
+ }
1063
+ async function readPendingAcceptance(input) {
1064
+ const file = path.join(input.directory, "merge-acceptance-pending.json");
1065
+ if (!(await exists(file)))
1066
+ return input.authoritativePending;
1067
+ let parsed;
1068
+ try {
1069
+ parsed = JSON.parse(await readFile(file, "utf-8"));
1070
+ }
1071
+ catch {
1072
+ throw new Error("invalid pending merge acceptance record");
1073
+ }
1074
+ assertPendingAcceptance(parsed, input.state, input.task);
1075
+ if (!input.authoritativePending || !sameJsonValue(parsed, input.authoritativePending)) {
1076
+ throw new Error("pending merge acceptance is not authorized by the controller anchor");
1077
+ }
1078
+ return input.authoritativePending;
1079
+ }
1080
+ async function verifyMergedFileInvariants(repoRoot, task) {
1081
+ await assertSafeMergePath(repoRoot, task.path);
1082
+ const content = await readFile(path.join(repoRoot, task.path), "utf-8");
1083
+ if (!content.trim())
1084
+ throw new Error(`init upgrade merge invariant failed: ${task.path} is empty`);
1085
+ if (task.path.endsWith(".json")) {
1086
+ const parsed = JSON.parse(content);
1087
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1088
+ throw new Error(`init upgrade merge invariant failed: ${task.path} must contain a JSON object`);
1089
+ }
1090
+ }
1091
+ if (task.path === OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH) {
1092
+ for (const token of [
1093
+ `MAX_RETRIES = ${MAX_SESSION_RETRIES}`,
1094
+ "plugin-ignore-permanent-error",
1095
+ "recoveryWorkers",
1096
+ "readSessionStatus",
1097
+ ]) {
1098
+ if (!content.includes(token))
1099
+ throw new Error(`init upgrade merge invariant failed: ${task.path} is missing ${token}`);
1100
+ }
1101
+ }
1102
+ if (task.path === OPENCODE_CONTEXT_OVERFLOW_COMPACT_PLUGIN_PATH) {
1103
+ for (const token of [
1104
+ `MAX_OVERFLOW_RECOVERIES = ${MAX_OVERFLOW_RECOVERIES}`,
1105
+ "pendingOverflow",
1106
+ "deferredResume",
1107
+ "recoveryWorkers",
1108
+ "compactOrSummarize",
1109
+ ]) {
1110
+ if (!content.includes(token))
1111
+ throw new Error(`init upgrade merge invariant failed: ${task.path} is missing ${token}`);
1112
+ }
1113
+ }
1114
+ if (task.path === PI_CONTEXT_OVERFLOW_EXTENSION_PATH) {
1115
+ for (const token of ["message_end", "context_length_exceeded:"]) {
1116
+ if (!content.includes(token))
1117
+ throw new Error(`init upgrade merge invariant failed: ${task.path} is missing ${token}`);
1118
+ }
1119
+ }
1120
+ if (task.path === "AGENTS.md" ||
1121
+ task.path.endsWith("/loop-agent/SKILL.md")) {
1122
+ for (const token of ["init upgrade", "init check-update"]) {
1123
+ if (!content.includes(token))
1124
+ throw new Error(`init upgrade merge invariant failed: ${task.path} is missing ${token}`);
1125
+ }
1126
+ }
1127
+ return sha256(content);
1128
+ }
1129
+ async function recordAcceptedMerge(input) {
1130
+ const desiredSha256 = input.task.evidence.desiredSha256;
1131
+ if (!desiredSha256) {
1132
+ throw new Error(`init upgrade cannot accept ${input.task.path}: desired hash evidence is missing`);
1133
+ }
1134
+ const canonicalPath = normalizeAllowedPath(input.task.path);
1135
+ const surfacePath = path.join(input.state.repoRoot, INIT_SURFACE_STATE_PATH);
1136
+ let pending = input.pending;
1137
+ let receipt;
1138
+ let accepted;
1139
+ if (pending) {
1140
+ receipt = pending.receipt;
1141
+ if (input.pendingSurfaceState !== "before" &&
1142
+ input.pendingSurfaceState !== "accepted") {
1143
+ throw new Error("pending merge acceptance surface transition was not validated");
1144
+ }
1145
+ if (input.pendingSurfaceState === "before") {
1146
+ const surface = JSON.parse(await readFile(surfacePath, "utf-8"));
1147
+ accepted = acceptedSurface({
1148
+ surface,
1149
+ task: input.task,
1150
+ currentSha256: input.currentSha256,
1151
+ desiredSha256,
1152
+ }).accepted;
1153
+ }
1154
+ }
1155
+ else {
1156
+ const surfaceText = await readFile(surfacePath, "utf-8");
1157
+ const surface = JSON.parse(surfaceText);
1158
+ const transition = acceptedSurface({
1159
+ surface,
1160
+ task: input.task,
1161
+ currentSha256: input.currentSha256,
1162
+ desiredSha256,
1163
+ });
1164
+ const beforeSha256 = sha256(surfaceText);
1165
+ if (input.guard.baselineWorkspace[INIT_SURFACE_STATE_PATH] !==
1166
+ `file:${beforeSha256}`) {
1167
+ throw new Error("init upgrade merge surface no longer matches the guarded baseline");
1168
+ }
1169
+ accepted = transition.accepted;
1170
+ receipt = {
1171
+ taskId: input.task.taskId,
1172
+ path: canonicalPath,
1173
+ currentSha256: input.currentSha256,
1174
+ desiredSha256,
1175
+ acceptedAt: new Date().toISOString(),
1176
+ };
1177
+ pending = {
1178
+ schemaVersion: 1,
1179
+ runId: input.state.runId,
1180
+ repoRoot: input.state.repoRoot,
1181
+ guard: {
1182
+ taskId: input.task.taskId,
1183
+ path: canonicalPath,
1184
+ allowedPaths: [canonicalPath],
1185
+ currentSha256: input.currentSha256,
1186
+ desiredSha256,
1187
+ },
1188
+ receipt,
1189
+ transition: {
1190
+ fromPhase: "MODEL_MERGE",
1191
+ toPhase: "PLAN",
1192
+ priorMergeReceiptCount: input.state.mergeReceipts.length,
1193
+ },
1194
+ surfaceTransition: {
1195
+ path: canonicalPath,
1196
+ beforeSha256,
1197
+ acceptedSha256: sha256(serializeJson(accepted)),
1198
+ beforeEntry: transition.beforeEntry,
1199
+ },
1200
+ };
1201
+ await writeControllerAnchor({
1202
+ state: input.state,
1203
+ mergeAuthority: {
1204
+ task: input.task,
1205
+ baselineWorkspace: input.guard.baselineWorkspace,
1206
+ priorMergeReceiptCount: input.state.mergeReceipts.length,
1207
+ priorMergeReceiptsSha256: sha256(serializeJson(input.state.mergeReceipts)),
1208
+ stateProjection: controllerStateProjection(input.state),
1209
+ stateProjectionSha256: canonicalSha256(controllerStateProjection(input.state)),
1210
+ verificationSurface: input.verificationSurface,
1211
+ pending,
1212
+ },
1213
+ });
1214
+ await writeJsonAtomic(path.join(input.directory, "merge-acceptance-pending.json"), pending);
1215
+ }
1216
+ if (accepted)
1217
+ await writeJsonAtomic(surfacePath, accepted);
1218
+ if (!pending) {
1219
+ throw new Error("pending merge acceptance was not established");
1220
+ }
1221
+ const priorMergeReceiptCount = pending.transition.priorMergeReceiptCount;
1222
+ if (input.state.mergeReceipts.length === priorMergeReceiptCount) {
1223
+ input.state.mergeReceipts.push(receipt);
1224
+ }
1225
+ else if (input.state.mergeReceipts.length !== priorMergeReceiptCount + 1 ||
1226
+ !sameJsonValue(input.state.mergeReceipts[priorMergeReceiptCount], receipt)) {
1227
+ throw new Error("pending merge acceptance receipt projection is inconsistent");
1228
+ }
1229
+ await writeJsonAtomic(path.join(input.directory, "model-merge-receipts.json"), input.state.mergeReceipts);
1230
+ }
1231
+ async function validateContinueMerge(directory, state, anchor) {
1232
+ if (!anchor.mergeAuthority) {
1233
+ if (state.phase === "MODEL_MERGE" && state.mergeTasks.length > 0) {
1234
+ throw new Error("init upgrade controller merge baseline authority is missing or inconsistent");
1235
+ }
1236
+ return "none";
1237
+ }
1238
+ const authority = mergeAuthorityFromAnchor(anchor, state);
1239
+ const projection = mergeAuthorityProjection(state, authority);
1240
+ await assertFrozenVerificationSurface(state.repoRoot, authority.verificationSurface);
1241
+ const task = authority.task;
1242
+ const authoritativeGuard = {
1243
+ task,
1244
+ baselineWorkspace: authority.baselineWorkspace,
1245
+ };
1246
+ const allowed = new Set(validateTaskBoundary(task));
1247
+ for (const allowedPath of allowed) {
1248
+ await assertSafeMergePath(state.repoRoot, allowedPath);
1249
+ }
1250
+ const current = await snapshotWorkspace(state.repoRoot);
1251
+ const changed = changedWorkspacePaths(authoritativeGuard.baselineWorkspace, current);
1252
+ const pending = await readPendingAcceptance({
1253
+ directory,
1254
+ state,
1255
+ task,
1256
+ authoritativePending: authority.pending,
1257
+ });
1258
+ const pendingSurfaceState = pending
1259
+ ? await validatePendingSurfaceTransition({
1260
+ repoRoot: state.repoRoot,
1261
+ guard: authoritativeGuard,
1262
+ task,
1263
+ pending,
1264
+ })
1265
+ : undefined;
1266
+ const unexpected = changed.filter((relativePath) => {
1267
+ if (allowed.has(relativePath))
1268
+ return false;
1269
+ return !(relativePath === INIT_SURFACE_STATE_PATH &&
1270
+ pendingSurfaceState === "accepted");
1271
+ });
1272
+ if (unexpected.length > 0) {
1273
+ throw new Error(`init upgrade merge write-guard blocked out-of-bound workspace changes: ${unexpected.join(", ")}`);
1274
+ }
1275
+ const changedAllowed = changed.filter((relativePath) => allowed.has(relativePath));
1276
+ if (changedAllowed.length === 0) {
1277
+ if (pending) {
1278
+ throw new Error("pending merge acceptance cannot authorize a merge without an allowed workspace change");
1279
+ }
1280
+ state.nextAction = `No change was detected in allowedPaths=[${[...allowed].join(", ")}]. Complete the current merge task before calling --continue.`;
1281
+ await saveState(directory, state);
1282
+ await writeReport(directory, state);
1283
+ return "no-change";
1284
+ }
1285
+ const currentSha256 = await verifyMergedFileInvariants(state.repoRoot, task);
1286
+ if (pending && pending.guard.currentSha256 !== currentSha256) {
1287
+ throw new Error("pending merge acceptance current hash is inconsistent");
1288
+ }
1289
+ if (projection === "post-acceptance") {
1290
+ if (!pending || pendingSurfaceState !== "accepted") {
1291
+ throw new Error("post-acceptance merge state lacks the exact controller-authorized surface transition");
1292
+ }
1293
+ return "accepted";
1294
+ }
1295
+ await recordAcceptedMerge({
1296
+ directory,
1297
+ state,
1298
+ guard: authoritativeGuard,
1299
+ task,
1300
+ currentSha256,
1301
+ ...(pending && pendingSurfaceState
1302
+ ? { pending, pendingSurfaceState }
1303
+ : {}),
1304
+ verificationSurface: authority.verificationSurface,
1305
+ });
1306
+ state.mergeTasks = [];
1307
+ state.mergeGuard = undefined;
1308
+ state.phase = "PLAN";
1309
+ state.nextAction = "Accepted the bounded semantic merge; replan and verify the current workspace.";
1310
+ await saveState(directory, state);
1311
+ await rm(path.join(directory, "merge-acceptance-pending.json"), {
1312
+ force: true,
1313
+ });
1314
+ return "accepted";
1315
+ }
1316
+ function boundedOutput(value) {
1317
+ return value.length <= 8000 ? value : value.slice(value.length - 8000);
1318
+ }
1319
+ const VERIFICATION_COMMAND_DEADLINE_MS = 300_000;
1320
+ const VERIFICATION_STDIO_GRACE_MS = 500;
1321
+ const VERIFICATION_TREE_SETTLE_MS = 500;
1322
+ const VERIFICATION_QUIET_WINDOW_MS = 500;
1323
+ const VERIFICATION_OUTPUT_LIMIT = 4 * 1024 * 1024;
1324
+ const VERIFICATION_SUPERVISOR_SOURCE = `
1325
+ import { spawn } from "node:child_process";
1326
+ const specification = JSON.parse(Buffer.from(process.argv[2], "base64url").toString("utf8"));
1327
+ let worker;
1328
+ let workerClosed = false;
1329
+ let exitResult;
1330
+ let sent = false;
1331
+ const finish = (message) => {
1332
+ if (sent) return;
1333
+ sent = true;
1334
+ const afterSend = () => {
1335
+ if (specification.holdOpen) {
1336
+ setInterval(() => {}, 60000);
1337
+ return;
1338
+ }
1339
+ worker?.stdout?.destroy();
1340
+ worker?.stderr?.destroy();
1341
+ process.disconnect?.();
1342
+ process.exit(0);
1343
+ };
1344
+ if (typeof process.send !== "function") process.exit(97);
1345
+ process.send({ ...message, stdioOpen: !workerClosed }, afterSend);
1346
+ };
1347
+ try {
1348
+ worker = spawn(specification.command, specification.args, {
1349
+ cwd: process.cwd(),
1350
+ env: process.env,
1351
+ stdio: ["ignore", "pipe", "pipe"],
1352
+ windowsHide: true,
1353
+ shell: false,
1354
+ });
1355
+ worker.stdout?.pipe(process.stdout);
1356
+ worker.stderr?.pipe(process.stderr);
1357
+ worker.once("close", () => {
1358
+ workerClosed = true;
1359
+ if (exitResult) finish(exitResult);
1360
+ });
1361
+ worker.once("error", (error) => finish({
1362
+ type: "result",
1363
+ ok: false,
1364
+ exitCode: null,
1365
+ error: error instanceof Error ? error.message : String(error),
1366
+ }));
1367
+ worker.once("exit", (code, signal) => {
1368
+ exitResult = {
1369
+ type: "result",
1370
+ ok: code === 0 && signal === null,
1371
+ exitCode: typeof code === "number" ? code : 1,
1372
+ ...(signal ? { error: "verification command exited by signal " + signal } : {}),
1373
+ };
1374
+ if (workerClosed) finish(exitResult);
1375
+ else setTimeout(() => finish(exitResult), specification.stdioGraceMs);
1376
+ });
1377
+ } catch (error) {
1378
+ finish({
1379
+ type: "result",
1380
+ ok: false,
1381
+ exitCode: null,
1382
+ error: error instanceof Error ? error.message : String(error),
1383
+ });
1384
+ }
1385
+ process.once("disconnect", () => {
1386
+ if (!sent) worker?.kill("SIGTERM");
1387
+ process.exit(98);
1388
+ });
1389
+ `;
1390
+ class VerificationProcessBoundaryError extends Error {
1391
+ constructor(message) {
1392
+ super(message);
1393
+ this.name = "VerificationProcessBoundaryError";
1394
+ }
1395
+ }
1396
+ function tokenizeCommand(command) {
1397
+ if (!command.trim() || /[;&|><`$\r\n]/.test(command)) {
1398
+ throw new Error(`unsafe verification command: ${command}`);
1399
+ }
1400
+ const tokens = [];
1401
+ let token = "";
1402
+ let quote;
1403
+ for (let index = 0; index < command.length; index += 1) {
1404
+ const character = command[index];
1405
+ if (quote) {
1406
+ if (character === quote)
1407
+ quote = undefined;
1408
+ else
1409
+ token += character;
1410
+ continue;
1411
+ }
1412
+ if (character === '"' || character === "'") {
1413
+ quote = character;
1414
+ continue;
1415
+ }
1416
+ if (/\s/.test(character)) {
1417
+ if (token) {
1418
+ tokens.push(token);
1419
+ token = "";
1420
+ }
1421
+ continue;
1422
+ }
1423
+ token += character;
1424
+ }
1425
+ if (quote)
1426
+ throw new Error(`unterminated quote in verification command: ${command}`);
1427
+ if (token)
1428
+ tokens.push(token);
1429
+ if (tokens.length === 0)
1430
+ throw new Error("empty verification command");
1431
+ return tokens;
1432
+ }
1433
+ async function snapshotOptionalDirectory(directory) {
1434
+ const entry = await lstat(directory).catch((error) => {
1435
+ if (error.code === "ENOENT")
1436
+ return undefined;
1437
+ throw error;
1438
+ });
1439
+ if (!entry)
1440
+ return {};
1441
+ if (!entry.isDirectory() || entry.isSymbolicLink()) {
1442
+ throw new Error("real Pi home is not a regular directory");
1443
+ }
1444
+ return await snapshotDirectory(directory);
1445
+ }
1446
+ function verificationSandboxEnvironment(sandbox) {
1447
+ const home = path.join(sandbox, "home");
1448
+ const temporary = path.join(sandbox, "tmp");
1449
+ const config = path.join(sandbox, "config");
1450
+ const cache = path.join(sandbox, "cache");
1451
+ const pathValue = process.env.PATH;
1452
+ if (!pathValue)
1453
+ throw new Error("verification isolation requires PATH");
1454
+ const env = {
1455
+ PATH: pathValue,
1456
+ HOME: home,
1457
+ USERPROFILE: home,
1458
+ XDG_CONFIG_HOME: config,
1459
+ XDG_CACHE_HOME: cache,
1460
+ XDG_DATA_HOME: path.join(sandbox, "data"),
1461
+ TMPDIR: temporary,
1462
+ TMP: temporary,
1463
+ TEMP: temporary,
1464
+ PI_HOME: path.join(sandbox, "pi-home"),
1465
+ PI_CONFIG_DIR: path.join(config, "pi"),
1466
+ PI_CACHE_DIR: path.join(cache, "pi"),
1467
+ npm_config_cache: path.join(cache, "npm"),
1468
+ HARNESS_ALLOW_ACTIVE_DAG_RUNS: "1",
1469
+ };
1470
+ for (const name of ["SystemRoot", "WINDIR", "ComSpec", "PATHEXT", "LANG", "LC_ALL"]) {
1471
+ if (process.env[name])
1472
+ env[name] = process.env[name];
1473
+ }
1474
+ return env;
1475
+ }
1476
+ async function createVerificationSandbox() {
1477
+ const sandbox = await mkdtemp(path.join(os.tmpdir(), "loop-agent-init-verification-"));
1478
+ await Promise.all(["home", "tmp", "config", "cache", "data", "pi-home"].map((directory) => mkdir(path.join(sandbox, directory), { recursive: true })));
1479
+ return sandbox;
1480
+ }
1481
+ async function createVerificationWriteAudit(input) {
1482
+ const events = [];
1483
+ const failures = [];
1484
+ const watchers = [];
1485
+ const watched = new Set();
1486
+ const piHome = path.join(os.homedir(), ".pi");
1487
+ const anchor = await controllerAnchorPath(input.state);
1488
+ const note = (scope, file) => {
1489
+ events.push(`${scope}=${file.split(path.sep).join("/")}`);
1490
+ };
1491
+ const ignoredWorkspacePath = (candidate) => {
1492
+ const relative = toRepoPath(input.repoRoot, candidate);
1493
+ return (relative === ".git" ||
1494
+ relative.startsWith(".git/") ||
1495
+ relative === "node_modules" ||
1496
+ relative.startsWith("node_modules/") ||
1497
+ relative === RUN_ROOT ||
1498
+ relative.startsWith(`${RUN_ROOT}/`));
1499
+ };
1500
+ const install = async (scope, directory, ignore) => {
1501
+ const canonical = await realpath(directory);
1502
+ if (watched.has(canonical))
1503
+ return;
1504
+ watched.add(canonical);
1505
+ let watcher;
1506
+ try {
1507
+ watcher = watch(canonical, { persistent: false }, (_eventType, filename) => {
1508
+ const candidate = filename
1509
+ ? path.join(canonical, filename.toString())
1510
+ : canonical;
1511
+ if (ignore?.(candidate))
1512
+ return;
1513
+ note(scope, path.relative(directory, candidate) || ".");
1514
+ // A parent rename can introduce a new directory. The event is already
1515
+ // a failure; installing its watcher closes the delayed-descendant gap.
1516
+ void install(scope, candidate, ignore).catch((error) => {
1517
+ const code = error.code;
1518
+ if (code !== "ENOENT" && code !== "ENOTDIR") {
1519
+ failures.push(`watcher refresh failed for ${scope}: ${error instanceof Error ? error.message : String(error)}`);
1520
+ }
1521
+ });
1522
+ });
1523
+ watcher.on("error", (error) => {
1524
+ failures.push(`watcher failed for ${scope}: ${error.message}`);
1525
+ });
1526
+ watchers.push(watcher);
1527
+ }
1528
+ catch (error) {
1529
+ throw new Error(`verification watcher is unavailable for ${scope}: ${error instanceof Error ? error.message : String(error)}`);
1530
+ }
1531
+ const entries = await readdir(canonical, { withFileTypes: true });
1532
+ for (const entry of entries) {
1533
+ const child = path.join(canonical, entry.name);
1534
+ if (ignore?.(child) || !entry.isDirectory() || entry.isSymbolicLink())
1535
+ continue;
1536
+ await install(scope, child, ignore);
1537
+ }
1538
+ };
1539
+ await install("workspace", input.repoRoot, ignoredWorkspacePath);
1540
+ await install("controller", runRoot(input.repoRoot));
1541
+ await install("controller-anchor", anchor.anchorRoot);
1542
+ const piHomeEntry = await lstat(piHome).catch((error) => {
1543
+ if (error.code === "ENOENT")
1544
+ return undefined;
1545
+ throw error;
1546
+ });
1547
+ if (piHomeEntry) {
1548
+ if (!piHomeEntry.isDirectory() || piHomeEntry.isSymbolicLink()) {
1549
+ throw new Error("real Pi home is not a regular directory");
1550
+ }
1551
+ await install("pi-home", piHome);
1552
+ }
1553
+ else {
1554
+ const home = await realpath(os.homedir());
1555
+ let homeWatcher;
1556
+ try {
1557
+ homeWatcher = watch(home, { persistent: false }, (_eventType, filename) => {
1558
+ if (!filename || filename.toString() === ".pi")
1559
+ note("pi-home", ".");
1560
+ });
1561
+ homeWatcher.on("error", (error) => {
1562
+ failures.push(`watcher failed for pi-home parent: ${error.message}`);
1563
+ });
1564
+ watchers.push(homeWatcher);
1565
+ }
1566
+ catch (error) {
1567
+ throw new Error(`verification watcher is unavailable for pi-home: ${error instanceof Error ? error.message : String(error)}`);
1568
+ }
1569
+ }
1570
+ return {
1571
+ events,
1572
+ failures,
1573
+ close: () => {
1574
+ for (const watcher of watchers)
1575
+ watcher.close();
1576
+ },
1577
+ };
1578
+ }
1579
+ function appendVerificationOutput(current, chunk) {
1580
+ const combined = `${current}${chunk.toString()}`;
1581
+ return combined.length <= VERIFICATION_OUTPUT_LIMIT
1582
+ ? combined
1583
+ : combined.slice(combined.length - VERIFICATION_OUTPUT_LIMIT);
1584
+ }
1585
+ async function waitForBounded(promise, timeoutMs) {
1586
+ let timeout;
1587
+ const completed = await Promise.race([
1588
+ promise.then(() => true),
1589
+ new Promise((resolve) => {
1590
+ timeout = setTimeout(() => resolve(false), timeoutMs);
1591
+ }),
1592
+ ]);
1593
+ if (timeout)
1594
+ clearTimeout(timeout);
1595
+ return completed;
1596
+ }
1597
+ function processBoundaryExists(pid, group) {
1598
+ try {
1599
+ process.kill(group ? -pid : pid, 0);
1600
+ return true;
1601
+ }
1602
+ catch (error) {
1603
+ if (error.code === "ESRCH")
1604
+ return false;
1605
+ throw error;
1606
+ }
1607
+ }
1608
+ async function runSupervisedVerification(input) {
1609
+ const supervisorPath = path.join(input.sandbox, "verification-supervisor.mjs");
1610
+ await writeFile(supervisorPath, VERIFICATION_SUPERVISOR_SOURCE, "utf-8");
1611
+ // The fixed supervisor, not the repository command, is the direct detached
1612
+ // child. It reports worker exit over IPC and keeps the Windows tree root alive
1613
+ // until the controller performs bounded taskkill /T convergence.
1614
+ const specification = Buffer.from(JSON.stringify({
1615
+ command: input.command,
1616
+ args: input.args,
1617
+ stdioGraceMs: VERIFICATION_STDIO_GRACE_MS,
1618
+ holdOpen: process.platform === "win32",
1619
+ })).toString("base64url");
1620
+ let stdout = "";
1621
+ let stderr = "";
1622
+ const supervisor = spawn(process.execPath, [supervisorPath, specification], {
1623
+ cwd: input.repoRoot,
1624
+ env: verificationSandboxEnvironment(input.sandbox),
1625
+ stdio: ["ignore", "pipe", "pipe", "ipc"],
1626
+ detached: true,
1627
+ windowsHide: true,
1628
+ shell: false,
1629
+ });
1630
+ supervisor.stdout?.on("data", (chunk) => {
1631
+ stdout = appendVerificationOutput(stdout, chunk);
1632
+ });
1633
+ supervisor.stderr?.on("data", (chunk) => {
1634
+ stderr = appendVerificationOutput(stderr, chunk);
1635
+ });
1636
+ const waitForExit = new Promise((resolve) => {
1637
+ supervisor.once("exit", () => resolve());
1638
+ supervisor.once("error", () => resolve());
1639
+ });
1640
+ const outcome = await new Promise((resolve) => {
1641
+ let settled = false;
1642
+ const finish = (value) => {
1643
+ if (settled)
1644
+ return;
1645
+ settled = true;
1646
+ clearTimeout(deadline);
1647
+ resolve(value);
1648
+ };
1649
+ const deadline = setTimeout(() => {
1650
+ finish({
1651
+ reported: false,
1652
+ ok: false,
1653
+ exitCode: null,
1654
+ stdioOpen: true,
1655
+ timedOut: true,
1656
+ error: `verification command exceeded ${VERIFICATION_COMMAND_DEADLINE_MS}ms deadline`,
1657
+ });
1658
+ }, VERIFICATION_COMMAND_DEADLINE_MS);
1659
+ supervisor.once("error", (error) => {
1660
+ finish({
1661
+ reported: false,
1662
+ ok: false,
1663
+ exitCode: null,
1664
+ stdioOpen: false,
1665
+ timedOut: false,
1666
+ error: `verification supervisor failed to start: ${error.message}`,
1667
+ });
1668
+ });
1669
+ supervisor.on("message", (message) => {
1670
+ if (!isRecord(message) ||
1671
+ message.type !== "result" ||
1672
+ typeof message.ok !== "boolean" ||
1673
+ (message.exitCode !== null && typeof message.exitCode !== "number") ||
1674
+ typeof message.stdioOpen !== "boolean" ||
1675
+ (message.error !== undefined && typeof message.error !== "string")) {
1676
+ finish({
1677
+ reported: false,
1678
+ ok: false,
1679
+ exitCode: null,
1680
+ stdioOpen: false,
1681
+ timedOut: false,
1682
+ error: "verification supervisor returned an invalid protocol message",
1683
+ });
1684
+ return;
1685
+ }
1686
+ finish({
1687
+ reported: true,
1688
+ ok: message.ok,
1689
+ exitCode: message.exitCode,
1690
+ stdioOpen: message.stdioOpen,
1691
+ timedOut: false,
1692
+ ...(message.error ? { error: message.error } : {}),
1693
+ });
1694
+ });
1695
+ supervisor.once("exit", (code, signal) => {
1696
+ setTimeout(() => {
1697
+ finish({
1698
+ reported: false,
1699
+ ok: false,
1700
+ exitCode: typeof code === "number" ? code : null,
1701
+ stdioOpen: false,
1702
+ timedOut: false,
1703
+ error: `verification supervisor exited before reporting command completion${signal ? ` (${signal})` : ""}`,
1704
+ });
1705
+ }, 0);
1706
+ });
1707
+ });
1708
+ return {
1709
+ pid: supervisor.pid,
1710
+ outcome,
1711
+ waitForExit,
1712
+ readResult: () => ({
1713
+ ok: outcome.ok && !outcome.timedOut,
1714
+ command: input.commandText,
1715
+ exitCode: outcome.exitCode,
1716
+ stdout: boundedOutput(stdout),
1717
+ stderr: boundedOutput(`${stderr}${outcome.error ? `${stderr ? "\n" : ""}${outcome.error}` : ""}`),
1718
+ }),
1719
+ };
1720
+ }
1721
+ async function settleVerificationProcessTree(execution) {
1722
+ const pid = execution.pid;
1723
+ if (!pid || !Number.isInteger(pid) || pid <= 0)
1724
+ return false;
1725
+ if (process.platform === "win32") {
1726
+ // holdOpen keeps this exact boundary PID alive, avoiding a post-exit tree
1727
+ // lookup against a vanished direct process.
1728
+ const taskkillOutput = await new Promise((resolve, reject) => {
1729
+ execFile("taskkill", ["/pid", String(pid), "/t", "/f"], { windowsHide: true, timeout: 5_000, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => {
1730
+ if (error) {
1731
+ reject(new Error(`verification Windows process tree did not converge: ${boundedOutput(`${stdout}\n${stderr}\n${error.message}`)}`));
1732
+ return;
1733
+ }
1734
+ resolve(`${stdout}\n${stderr}`);
1735
+ });
1736
+ });
1737
+ await new Promise((resolve) => setTimeout(resolve, VERIFICATION_TREE_SETTLE_MS));
1738
+ if (processBoundaryExists(pid, false)) {
1739
+ throw new Error("verification Windows process tree did not converge");
1740
+ }
1741
+ const terminatedLines = taskkillOutput
1742
+ .split(/\r?\n/)
1743
+ .map((line) => line.trim())
1744
+ .filter(Boolean);
1745
+ return execution.outcome.stdioOpen || terminatedLines.length > 1;
1746
+ }
1747
+ if (execution.outcome.reported) {
1748
+ await waitForBounded(execution.waitForExit, VERIFICATION_TREE_SETTLE_MS);
1749
+ }
1750
+ let descendantsObserved = false;
1751
+ if (processBoundaryExists(pid, true)) {
1752
+ descendantsObserved = execution.outcome.reported;
1753
+ try {
1754
+ process.kill(-pid, "SIGTERM");
1755
+ }
1756
+ catch (error) {
1757
+ if (error.code !== "ESRCH")
1758
+ throw error;
1759
+ }
1760
+ }
1761
+ await new Promise((resolve) => setTimeout(resolve, VERIFICATION_TREE_SETTLE_MS));
1762
+ if (processBoundaryExists(pid, true)) {
1763
+ try {
1764
+ process.kill(-pid, "SIGKILL");
1765
+ }
1766
+ catch (error) {
1767
+ if (error.code !== "ESRCH")
1768
+ throw error;
1769
+ }
1770
+ await new Promise((resolve) => setTimeout(resolve, VERIFICATION_TREE_SETTLE_MS));
1771
+ if (processBoundaryExists(pid, true)) {
1772
+ throw new Error("verification descendant process group did not converge");
1773
+ }
1774
+ }
1775
+ return descendantsObserved || execution.outcome.stdioOpen;
1776
+ }
1777
+ function scheduleDeferredVerificationCleanup(input) {
1778
+ const retry = async () => {
1779
+ try {
1780
+ await settleVerificationProcessTree(input.execution);
1781
+ await new Promise((resolve) => setTimeout(resolve, VERIFICATION_QUIET_WINDOW_MS));
1782
+ input.audit.close();
1783
+ await rm(input.sandbox, { recursive: true, force: true });
1784
+ if (await exists(input.sandbox)) {
1785
+ throw new Error("verification sandbox cleanup was incomplete");
1786
+ }
1787
+ }
1788
+ catch {
1789
+ // Keep the controller event loop alive while the watcher remains open.
1790
+ // Exiting before boundary proof would abandon an unmonitored descendant.
1791
+ setTimeout(() => void retry(), VERIFICATION_TREE_SETTLE_MS);
1792
+ }
1793
+ };
1794
+ setTimeout(() => void retry(), VERIFICATION_TREE_SETTLE_MS);
1795
+ }
1796
+ async function runVerificationCommand(input) {
1797
+ const { state, surface, commandText } = input;
1798
+ const repoRoot = state.repoRoot;
1799
+ let sandbox;
1800
+ let audit;
1801
+ let execution;
1802
+ let boundaryConverged = false;
1803
+ let descendantsObserved = false;
1804
+ let failure;
1805
+ let boundaryFailure;
1806
+ let result;
1807
+ try {
1808
+ await assertFrozenVerificationSurface(repoRoot, surface);
1809
+ const workspaceBefore = await snapshotWorkspace(repoRoot);
1810
+ const controllerBefore = await snapshotDirectory(runRoot(repoRoot));
1811
+ const anchorBefore = await controllerAnchorSnapshot(repoRoot, state);
1812
+ const piHomeBefore = await snapshotOptionalDirectory(path.join(os.homedir(), ".pi"));
1813
+ const tokens = tokenizeCommand(commandText);
1814
+ await commandRepositoryInputs(repoRoot, commandText);
1815
+ let command = tokens.shift();
1816
+ if (process.platform === "win32" && command === "npm")
1817
+ command = "npm.cmd";
1818
+ if (process.platform === "win32" && command === "npx")
1819
+ command = "npx.cmd";
1820
+ if (command === "bash" && tokens[0])
1821
+ await assertSafeMergePath(repoRoot, tokens[0]);
1822
+ sandbox = await createVerificationSandbox();
1823
+ audit = await createVerificationWriteAudit({ repoRoot, state });
1824
+ execution = await runSupervisedVerification({
1825
+ repoRoot,
1826
+ sandbox,
1827
+ command,
1828
+ args: tokens,
1829
+ commandText,
1830
+ });
1831
+ descendantsObserved = await settleVerificationProcessTree(execution);
1832
+ boundaryConverged = true;
1833
+ // Keep event coverage through a bounded quiet window after tree convergence.
1834
+ await new Promise((resolve) => setTimeout(resolve, VERIFICATION_QUIET_WINDOW_MS));
1835
+ result = execution.readResult();
1836
+ if (audit.failures.length)
1837
+ throw new Error(audit.failures.join("; "));
1838
+ await assertFrozenVerificationSurface(repoRoot, surface);
1839
+ const changedWorkspace = changedWorkspacePaths(workspaceBefore, await snapshotWorkspace(repoRoot));
1840
+ const changedController = changedWorkspacePaths(controllerBefore, await snapshotDirectory(runRoot(repoRoot)));
1841
+ const changedAnchors = changedWorkspacePaths(anchorBefore, await controllerAnchorSnapshot(repoRoot, state));
1842
+ const changedPiHome = changedWorkspacePaths(piHomeBefore, await snapshotOptionalDirectory(path.join(os.homedir(), ".pi")));
1843
+ if (descendantsObserved || audit.events.length || changedWorkspace.length || changedController.length || changedAnchors.length || changedPiHome.length) {
1844
+ return {
1845
+ ...result,
1846
+ ok: false,
1847
+ stderr: boundedOutput(`${result.stderr}\nverification write guard blocked changes: ${[
1848
+ ...(descendantsObserved ? ["process-tree=descendant"] : []),
1849
+ ...(audit.events.length ? [`events=${audit.events.join(",")}`] : []),
1850
+ ...(changedWorkspace.length ? [`workspace=${changedWorkspace.join(",")}`] : []),
1851
+ ...(changedController.length ? [`controller=${changedController.join(",")}`] : []),
1852
+ ...(changedAnchors.length ? [`controller-anchor=${changedAnchors.join(",")}`] : []),
1853
+ ...(changedPiHome.length ? [`pi-home=${changedPiHome.join(",")}`] : []),
1854
+ ].join("; ")}`),
1855
+ };
1856
+ }
1857
+ }
1858
+ catch (error) {
1859
+ failure = error;
1860
+ }
1861
+ finally {
1862
+ if (execution && !boundaryConverged) {
1863
+ try {
1864
+ descendantsObserved =
1865
+ (await settleVerificationProcessTree(execution)) || descendantsObserved;
1866
+ boundaryConverged = true;
1867
+ await new Promise((resolve) => setTimeout(resolve, VERIFICATION_QUIET_WINDOW_MS));
1868
+ }
1869
+ catch (cleanupError) {
1870
+ boundaryFailure = new VerificationProcessBoundaryError(`init upgrade ${state.runId} verification process boundary termination is unconfirmed${execution.pid ? ` for pid ${execution.pid}` : ""}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
1871
+ if (audit && sandbox) {
1872
+ scheduleDeferredVerificationCleanup({ execution, audit, sandbox });
1873
+ }
1874
+ }
1875
+ }
1876
+ if (!boundaryFailure) {
1877
+ if (audit)
1878
+ audit.close();
1879
+ if (sandbox) {
1880
+ try {
1881
+ await rm(sandbox, { recursive: true, force: true });
1882
+ if (await exists(sandbox))
1883
+ throw new Error("verification sandbox cleanup was incomplete");
1884
+ }
1885
+ catch (error) {
1886
+ failure ??= error;
1887
+ }
1888
+ }
1889
+ }
1890
+ if (boundaryFailure)
1891
+ throw boundaryFailure;
1892
+ }
1893
+ if (failure || !result) {
1894
+ return {
1895
+ ok: false,
1896
+ command: commandText,
1897
+ exitCode: null,
1898
+ stdout: "",
1899
+ stderr: failure instanceof Error ? failure.message : String(failure ?? "verification command did not return a result"),
1900
+ };
1901
+ }
1902
+ return result;
1903
+ }
1904
+ async function discoverQuickVerification(repoRoot) {
1905
+ const harness = JSON.parse(await readFile(path.join(repoRoot, "harness.json"), "utf-8"));
1906
+ const governanceRoot = typeof harness.governanceRoot === "string"
1907
+ ? harness.governanceRoot
1908
+ : "ai_workspace/loop-agent";
1909
+ const matrixPath = typeof harness.entrypoints?.verificationMatrix === "string"
1910
+ ? harness.entrypoints.verificationMatrix
1911
+ : `${governanceRoot}/verification-matrix.md`;
1912
+ await assertSafeMergePath(repoRoot, matrixPath);
1913
+ const matrix = await readFile(path.join(repoRoot, matrixPath), "utf-8");
1914
+ const targetTests = matrix.match(/^\|\s*target project tests are valid\s*\|\s*`([^`]+)`/im)?.[1];
1915
+ if (!targetTests) {
1916
+ throw new Error(`target verification matrix does not declare a concrete minimum project test command: ${matrixPath}`);
1917
+ }
1918
+ return targetTests.trim();
1919
+ }
1920
+ async function verifyRecoveryFiles(repoRoot) {
1921
+ const verification = {};
1922
+ for (const relativePath of RECOVERY_PATHS) {
1923
+ const present = await exists(path.join(repoRoot, relativePath));
1924
+ let invariant = present;
1925
+ if (present && relativePath !== PI_PROJECT_SETTINGS_PATH) {
1926
+ try {
1927
+ const content = await readFile(path.join(repoRoot, relativePath), "utf-8");
1928
+ if (relativePath === OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH) {
1929
+ invariant =
1930
+ content.includes(`MAX_RETRIES = ${MAX_SESSION_RETRIES}`) &&
1931
+ content.includes("plugin-ignore-permanent-error") &&
1932
+ content.includes("recoveryWorkers");
1933
+ }
1934
+ else if (relativePath === OPENCODE_CONTEXT_OVERFLOW_COMPACT_PLUGIN_PATH) {
1935
+ invariant =
1936
+ content.includes(`MAX_OVERFLOW_RECOVERIES = ${MAX_OVERFLOW_RECOVERIES}`) &&
1937
+ content.includes("compactOrSummarize") &&
1938
+ content.includes("recoveryWorkers");
1939
+ }
1940
+ else {
1941
+ invariant =
1942
+ content.includes("message_end") &&
1943
+ content.includes("context_length_exceeded:");
1944
+ }
1945
+ }
1946
+ catch {
1947
+ invariant = false;
1948
+ }
1949
+ }
1950
+ verification[relativePath] = { exists: present, invariant };
1951
+ }
1952
+ return verification;
1953
+ }
1954
+ async function runFinalVerification(state, authoritativeHomeFingerprint, surface) {
1955
+ await assertFrozenVerificationSurface(state.repoRoot, surface);
1956
+ const finalCheck = await checkInitUpdate({ repoRoot: state.repoRoot });
1957
+ const doctor = await runInitDoctor({ repoRoot: state.repoRoot });
1958
+ let inspect;
1959
+ try {
1960
+ const adapter = await resolveAdapter(state.repoRoot);
1961
+ const manifest = await adapter.loadHarnessManifest(state.repoRoot);
1962
+ inspect = {
1963
+ ok: true,
1964
+ command: "loop-agent inspect --repo-root .",
1965
+ adapter: adapter.name,
1966
+ project: manifest.project,
1967
+ };
1968
+ }
1969
+ catch (error) {
1970
+ inspect = {
1971
+ ok: false,
1972
+ command: "loop-agent inspect --repo-root .",
1973
+ error: error instanceof Error ? error.message : String(error),
1974
+ };
1975
+ }
1976
+ let docsAudit;
1977
+ try {
1978
+ const audit = await auditDocs(state.repoRoot);
1979
+ docsAudit = {
1980
+ ok: audit.summary.errorCount === 0,
1981
+ command: "loop-agent docs audit --repo-root .",
1982
+ errorCount: audit.summary.errorCount,
1983
+ warningCount: audit.summary.warningCount,
1984
+ };
1985
+ }
1986
+ catch (error) {
1987
+ docsAudit = {
1988
+ ok: false,
1989
+ command: "loop-agent docs audit --repo-root .",
1990
+ error: error instanceof Error ? error.message : String(error),
1991
+ };
1992
+ }
1993
+ const checkRepo = await runVerificationCommand({
1994
+ state,
1995
+ surface,
1996
+ commandText: "bash scripts/check-repo.sh",
1997
+ });
1998
+ let quickVerification;
1999
+ try {
2000
+ const quickCommand = await discoverQuickVerification(state.repoRoot);
2001
+ quickVerification = await runVerificationCommand({
2002
+ state,
2003
+ surface,
2004
+ commandText: quickCommand,
2005
+ });
2006
+ }
2007
+ catch (error) {
2008
+ quickVerification = {
2009
+ ok: false,
2010
+ command: "verification-matrix minimum project test command",
2011
+ exitCode: null,
2012
+ stdout: "",
2013
+ stderr: error instanceof Error ? error.message : String(error),
2014
+ };
2015
+ }
2016
+ const recovery = await verifyRecoveryFiles(state.repoRoot);
2017
+ const projectPi = await inspectProjectPiSettings({ repoRoot: state.repoRoot });
2018
+ const homeAfter = await homeFingerprint();
2019
+ return {
2020
+ completion: {
2021
+ schemaVersion: 1,
2022
+ runId: state.runId,
2023
+ repoRoot: state.repoRoot,
2024
+ status: "completed",
2025
+ phase: "COMPLETED",
2026
+ controllerIdentity: state.controllerIdentity,
2027
+ actionReceiptCount: state.actionReceipts.length,
2028
+ mergeReceiptCount: state.mergeReceipts.length,
2029
+ },
2030
+ initSurfaceClean: finalCheck.ok,
2031
+ doctor: { ok: doctor.ok, checks: doctor.checks },
2032
+ inspect,
2033
+ docsAudit,
2034
+ checkRepo,
2035
+ quickVerification,
2036
+ recovery,
2037
+ projectPi: {
2038
+ reason: projectPi.reason,
2039
+ action: projectPi.action,
2040
+ trustRequired: true,
2041
+ valid: projectPi.reason === "matches" ||
2042
+ projectPi.reason === "enabled-false",
2043
+ },
2044
+ userHomeUnchanged: sameJsonValue(homeAfter, authoritativeHomeFingerprint),
2045
+ mergeBoundaryClean: state.mergeTasks.length === 0,
2046
+ humanDecisionClean: state.humanDecisions.length === 0,
2047
+ };
2048
+ }
2049
+ function verificationPassed(verification) {
2050
+ const doctor = verification.doctor;
2051
+ const inspect = verification.inspect;
2052
+ const docsAudit = verification.docsAudit;
2053
+ const checkRepo = verification.checkRepo;
2054
+ const quick = verification.quickVerification;
2055
+ const projectPi = verification.projectPi;
2056
+ const recovery = verification.recovery;
2057
+ return (verification.initSurfaceClean === true &&
2058
+ isRecord(doctor) &&
2059
+ doctor.ok === true &&
2060
+ isRecord(inspect) &&
2061
+ inspect.ok === true &&
2062
+ isRecord(docsAudit) &&
2063
+ docsAudit.ok === true &&
2064
+ isRecord(checkRepo) &&
2065
+ checkRepo.ok === true &&
2066
+ isRecord(quick) &&
2067
+ quick.ok === true &&
2068
+ isRecord(projectPi) &&
2069
+ projectPi.valid === true &&
2070
+ isRecord(recovery) &&
2071
+ RECOVERY_PATHS.every((recoveryPath) => {
2072
+ const entry = recovery[recoveryPath];
2073
+ return isRecord(entry) && entry.exists === true && entry.invariant === true;
2074
+ }) &&
2075
+ verification.userHomeUnchanged === true &&
2076
+ verification.mergeBoundaryClean === true &&
2077
+ verification.humanDecisionClean === true);
2078
+ }
2079
+ function assertCompletedVerification(state, verification) {
2080
+ if (!isRecord(verification) || !isRecord(verification.completion)) {
2081
+ throw new Error("completed init upgrade verification evidence is invalid");
2082
+ }
2083
+ const completion = verification.completion;
2084
+ if (completion.schemaVersion !== 1 ||
2085
+ completion.runId !== state.runId ||
2086
+ completion.repoRoot !== state.repoRoot ||
2087
+ completion.status !== "completed" ||
2088
+ completion.phase !== "COMPLETED" ||
2089
+ !isControllerIdentity(completion.controllerIdentity) ||
2090
+ !sameControllerIdentity(completion.controllerIdentity, state.controllerIdentity) ||
2091
+ completion.actionReceiptCount !== state.actionReceipts.length ||
2092
+ completion.mergeReceiptCount !== state.mergeReceipts.length ||
2093
+ !verificationPassed(verification)) {
2094
+ throw new Error("completed init upgrade verification evidence is inconsistent");
2095
+ }
2096
+ }
2097
+ function assertAnchorCompletion(anchor, state) {
2098
+ const completion = anchor.completion;
2099
+ if (!completion ||
2100
+ !sameJsonValue(anchor.homeFingerprint, state.homeFingerprint) ||
2101
+ completion.status !== "completed" ||
2102
+ completion.phase !== "COMPLETED" ||
2103
+ completion.actionReceiptCount !== state.actionReceipts.length ||
2104
+ completion.mergeReceiptCount !== state.mergeReceipts.length ||
2105
+ !isSha256(completion.actionReceiptsSha256) ||
2106
+ !isSha256(completion.mergeReceiptsSha256) ||
2107
+ !isSha256(completion.stateProjectionSha256) ||
2108
+ !isSha256(completion.finalReportSha256) ||
2109
+ !isFrozenVerificationSurface(completion.verificationSurface) ||
2110
+ completion.actionReceiptsSha256 !== sha256(serializeJson(state.actionReceipts)) ||
2111
+ completion.mergeReceiptsSha256 !== sha256(serializeJson(state.mergeReceipts)) ||
2112
+ completion.stateProjectionSha256 !== canonicalSha256(controllerStateProjection(state))) {
2113
+ throw new Error("completed init upgrade state is not authorized by the controller anchor");
2114
+ }
2115
+ }
2116
+ async function readFreshCompletedVerification(repoRoot, state) {
2117
+ const anchor = await readControllerAnchor(repoRoot, state);
2118
+ assertAnchorCompletion(anchor, state);
2119
+ const report = await readFile(path.join(runDirectory(repoRoot, state.runId), "final-report.md")).catch(() => {
2120
+ throw new Error("completed init upgrade report is missing or unreadable");
2121
+ });
2122
+ if (sha256(report) !== anchor.completion?.finalReportSha256) {
2123
+ throw new Error("completed init upgrade report is not authorized by the controller anchor");
2124
+ }
2125
+ if (!anchor.completion?.verificationSurface) {
2126
+ throw new Error("completed verification surface authority is missing");
2127
+ }
2128
+ const verification = await runFinalVerification(state, anchor.homeFingerprint, anchor.completion.verificationSurface);
2129
+ assertCompletedVerification(state, verification);
2130
+ return { verification, reportMarkdown: report.toString("utf-8") };
2131
+ }
2132
+ async function readValidatedStatusState(repoRoot, runId) {
2133
+ const state = await readState(repoRoot, runId);
2134
+ assertTerminalCoherence(state);
2135
+ const directory = runDirectory(repoRoot, runId);
2136
+ const anchor = await readControllerAnchor(repoRoot, state);
2137
+ if (anchor.mergeAuthority) {
2138
+ mergeAuthorityProjection(state, mergeAuthorityFromAnchor(anchor, state));
2139
+ }
2140
+ if (state.status === "completed" || state.status === "cancelled") {
2141
+ if (!(await frozenControllerMatches(directory, state))) {
2142
+ throw new Error("Controller identity drift detected for persisted init upgrade state");
2143
+ }
2144
+ }
2145
+ return { state, directory };
2146
+ }
2147
+ async function readStatusResult(repoRoot, runId) {
2148
+ const { state } = await readValidatedStatusState(repoRoot, runId);
2149
+ if (state.status !== "completed")
2150
+ return toResult(state);
2151
+ const authenticated = await readFreshCompletedVerification(repoRoot, state);
2152
+ return toResult(state, authenticated.verification);
2153
+ }
2154
+ async function executeUpgrade(directory, state, input) {
2155
+ const identity = await readIdentity();
2156
+ if (!sameControllerIdentity(identity, state.controllerIdentity)) {
2157
+ return {
2158
+ ...toResult(state),
2159
+ status: "failed",
2160
+ nextAction: "Controller identity drift detected. Re-run with the frozen controller; no target-project writes were made.",
2161
+ };
2162
+ }
2163
+ state.phase = "VERSION_GATE";
2164
+ state.versionGate = await currentVersionGate(input, identity);
2165
+ const choice = input.versionChoice;
2166
+ if (state.versionGate.outcome === "newer-available" && !choice) {
2167
+ state.status = "needs-human-decision";
2168
+ state.nextAction =
2169
+ "Choose --version-choice current, upgrade, or cancel; the controller identity remains frozen until a choice is recorded.";
2170
+ await saveState(directory, state);
2171
+ await writeReport(directory, state);
2172
+ return toResult(state);
2173
+ }
2174
+ if (state.versionGate.outcome === "registry-unavailable" && !choice) {
2175
+ state.status = "needs-human-decision";
2176
+ state.nextAction =
2177
+ "npm registry is unavailable. Choose --version-choice current, retry, or cancel.";
2178
+ await saveState(directory, state);
2179
+ await writeReport(directory, state);
2180
+ return toResult(state);
2181
+ }
2182
+ state.versionGate.choice = choice ?? "current";
2183
+ if (choice === "cancel") {
2184
+ state.status = "cancelled";
2185
+ state.nextAction = "Upgrade cancelled before init-surface mutation.";
2186
+ await saveState(directory, state);
2187
+ await writeReport(directory, state);
2188
+ return toResult(state);
2189
+ }
2190
+ if (choice === "upgrade") {
2191
+ if (state.versionGate.outcome !== "newer-available") {
2192
+ state.status = "needs-human-decision";
2193
+ state.nextAction =
2194
+ "No newer controller was confirmed. Choose current, retry the registry, or cancel.";
2195
+ }
2196
+ else {
2197
+ state.status = "cancelled";
2198
+ state.nextAction = `This frozen run is closed before mutation. Install and verify @tea-agent/loop-agent@${state.versionGate.latestVersion}, then start a new run without --run-id so the new controller freezes a new identity.`;
2199
+ }
2200
+ await saveState(directory, state);
2201
+ await writeReport(directory, state);
2202
+ return toResult(state);
2203
+ }
2204
+ if (choice === "retry" &&
2205
+ state.versionGate.outcome === "registry-unavailable") {
2206
+ state.status = "needs-human-decision";
2207
+ state.nextAction =
2208
+ "npm registry remains unavailable after retry. Choose --version-choice current, retry, or cancel.";
2209
+ await saveState(directory, state);
2210
+ await writeReport(directory, state);
2211
+ return toResult(state);
2212
+ }
2213
+ state.status = "in-progress";
2214
+ state.phase = "PLAN";
2215
+ const pre = await checkInitUpdate({ repoRoot: state.repoRoot });
2216
+ await writeJsonAtomic(path.join(directory, "pre-update-report.json"), pre);
2217
+ await writeJsonAtomic(path.join(directory, "upgrade-plan.json"), {
2218
+ deterministicActions: pre.deterministicActions,
2219
+ modelMergeTasks: pre.modelMergeTasks,
2220
+ humanDecisions: pre.humanDecisions,
2221
+ verificationPlan: [
2222
+ "init check-update",
2223
+ "init doctor",
2224
+ "loop-agent inspect",
2225
+ "loop-agent docs audit",
2226
+ "bash scripts/check-repo.sh",
2227
+ "verification-matrix minimum project test command",
2228
+ "project recovery invariants",
2229
+ "Pi home fingerprint",
2230
+ ],
2231
+ });
2232
+ state.phase = "APPLY_SAFE";
2233
+ await assertInactive(input);
2234
+ if (pre.surfaceState === "missing") {
2235
+ const initialized = await initializeLoopAgentProject({
2236
+ repoRoot: state.repoRoot,
2237
+ profile: "full",
2238
+ merge: true,
2239
+ clientRecovery: "project",
2240
+ });
2241
+ state.actionReceipts.push(...initialized.written.map((writtenPath) => ({
2242
+ type: writtenPath === INIT_SURFACE_STATE_PATH
2243
+ ? "bootstrap-surface"
2244
+ : "write-generated-missing",
2245
+ path: writtenPath,
2246
+ reason: writtenPath === INIT_SURFACE_STATE_PATH
2247
+ ? "recorded the complete fresh-project initialization surface"
2248
+ : "full-project initialization wrote the required project surface",
2249
+ })));
2250
+ }
2251
+ await assertInactive(input);
2252
+ const safe = await applyInitUpdate({
2253
+ repoRoot: state.repoRoot,
2254
+ applySafe: true,
2255
+ clientRecovery: "project",
2256
+ });
2257
+ state.actionReceipts.push(...safe.applied);
2258
+ await writeJsonAtomic(path.join(directory, "change-manifest.json"), state.actionReceipts);
2259
+ const post = await checkInitUpdate({ repoRoot: state.repoRoot });
2260
+ state.humanDecisions = post.humanDecisions;
2261
+ if (post.humanDecisions.length > 0) {
2262
+ state.mergeTasks = [];
2263
+ state.mergeGuard = undefined;
2264
+ state.status = "needs-human-decision";
2265
+ state.phase = "MODEL_MERGE";
2266
+ state.nextAction =
2267
+ "Resolve the recorded destructive, credential, or ambiguous decisions; the controller will not overwrite uncertain user intent.";
2268
+ await writeJsonAtomic(path.join(directory, "merge-tasks.json"), state.mergeTasks);
2269
+ await saveState(directory, state);
2270
+ await writeReport(directory, state);
2271
+ return toResult(state);
2272
+ }
2273
+ if (post.modelMergeTasks.length > 0) {
2274
+ const activeTask = post.modelMergeTasks[0];
2275
+ const verificationSurface = await freezeVerificationSurface(state.repoRoot);
2276
+ assertTaskOutsideVerificationSurface(activeTask, verificationSurface);
2277
+ state.mergeTasks = [activeTask];
2278
+ state.mergeGuard = await createMergeGuard(state.repoRoot, activeTask);
2279
+ // Bind the Git-dir anchor to the state actually handed to the model.
2280
+ state.phase = "MODEL_MERGE";
2281
+ state.status = "in-progress";
2282
+ await writeControllerAnchor({
2283
+ state,
2284
+ mergeAuthority: {
2285
+ ...state.mergeGuard,
2286
+ priorMergeReceiptCount: state.mergeReceipts.length,
2287
+ priorMergeReceiptsSha256: sha256(serializeJson(state.mergeReceipts)),
2288
+ stateProjection: controllerStateProjection(state),
2289
+ stateProjectionSha256: canonicalSha256(controllerStateProjection(state)),
2290
+ verificationSurface,
2291
+ },
2292
+ });
2293
+ state.nextAction = `Complete only task ${activeTask.taskId} within allowedPaths=[${activeTask.allowedPaths.join(", ")}], then call init upgrade --continue. The controller will reject every other workspace change and all symlink/realpath escapes.`;
2294
+ await writeJsonAtomic(path.join(directory, "merge-tasks.json"), state.mergeTasks);
2295
+ await saveState(directory, state);
2296
+ await writeReport(directory, state);
2297
+ return toResult(state);
2298
+ }
2299
+ state.mergeTasks = [];
2300
+ state.mergeGuard = undefined;
2301
+ state.humanDecisions = [];
2302
+ state.phase = "VERIFY";
2303
+ const anchor = await readControllerAnchor(state.repoRoot, state);
2304
+ const verificationSurface = await freezeVerificationSurface(state.repoRoot);
2305
+ const verification = await runFinalVerification(state, anchor.homeFingerprint, verificationSurface);
2306
+ await writeJsonAtomic(path.join(directory, "verification.json"), verification);
2307
+ if (verificationPassed(verification)) {
2308
+ state.status = "completed";
2309
+ state.phase = "COMPLETED";
2310
+ state.nextAction =
2311
+ "Init upgrade completed; reload the host session so updated AGENTS, skills, and recovery extensions are discovered.";
2312
+ // The report is controller-produced before its exact bytes are anchored.
2313
+ await writeReport(directory, state, verification);
2314
+ const finalReportSha256 = sha256(await readFile(path.join(directory, "final-report.md")));
2315
+ await writeControllerAnchor({
2316
+ state,
2317
+ completion: {
2318
+ status: "completed",
2319
+ phase: "COMPLETED",
2320
+ actionReceiptCount: state.actionReceipts.length,
2321
+ mergeReceiptCount: state.mergeReceipts.length,
2322
+ actionReceiptsSha256: sha256(serializeJson(state.actionReceipts)),
2323
+ mergeReceiptsSha256: sha256(serializeJson(state.mergeReceipts)),
2324
+ stateProjectionSha256: canonicalSha256(controllerStateProjection(state)),
2325
+ verificationSurface,
2326
+ finalReportSha256,
2327
+ },
2328
+ });
2329
+ }
2330
+ else {
2331
+ state.status = "failed";
2332
+ state.nextAction =
2333
+ "Verification failed. Inspect verification.json; repair only the controller-returned concrete merge path or address the reported external blocker, then use --continue.";
2334
+ }
2335
+ await saveState(directory, state);
2336
+ await writeReport(directory, state, verification);
2337
+ return toResult(state, verification);
2338
+ }
2339
+ export async function runInitUpgrade(input) {
2340
+ const repoRoot = path.resolve(input.repoRoot);
2341
+ const mode = input.mode ?? "start";
2342
+ if ((mode === "status" || mode === "report") && !input.runId) {
2343
+ throw new Error("init upgrade --status/--report requires --run-id");
2344
+ }
2345
+ if (mode === "status")
2346
+ return await readStatusResult(repoRoot, input.runId);
2347
+ if (mode === "report") {
2348
+ const { state } = await readValidatedStatusState(repoRoot, input.runId);
2349
+ if (state.status === "completed") {
2350
+ const authenticated = await readFreshCompletedVerification(repoRoot, state);
2351
+ return { markdown: authenticated.reportMarkdown };
2352
+ }
2353
+ // Non-completed reports are projections of the validated state, never an
2354
+ // unauthenticated read of a mutable run-local report file.
2355
+ return { markdown: renderReport(state) };
2356
+ }
2357
+ const runId = mode === "continue"
2358
+ ? input.runId
2359
+ : input.runId ??
2360
+ `init-upgrade-${new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14)}-${randomUUID().slice(0, 8)}`;
2361
+ if (!runId)
2362
+ throw new Error("init upgrade --continue requires --run-id");
2363
+ if (!validRunId(runId))
2364
+ throw new Error("invalid init upgrade run id");
2365
+ if (mode === "continue") {
2366
+ const persisted = await readState(repoRoot, runId);
2367
+ try {
2368
+ assertTerminalCoherence(persisted);
2369
+ const anchor = await readControllerAnchor(repoRoot, persisted);
2370
+ if (anchor.mergeAuthority) {
2371
+ mergeAuthorityProjection(persisted, mergeAuthorityFromAnchor(anchor, persisted));
2372
+ }
2373
+ const directory = runDirectory(repoRoot, runId);
2374
+ if (persisted.status === "completed" || persisted.status === "cancelled") {
2375
+ if (!(await frozenControllerMatches(directory, persisted))) {
2376
+ throw new Error("Controller identity drift detected for persisted init upgrade state");
2377
+ }
2378
+ if (persisted.status !== "completed")
2379
+ return toResult(persisted);
2380
+ const authenticated = await readFreshCompletedVerification(repoRoot, persisted);
2381
+ return toResult(persisted, authenticated.verification);
2382
+ }
2383
+ }
2384
+ catch (error) {
2385
+ return {
2386
+ ...toResult(persisted),
2387
+ status: "failed",
2388
+ nextAction: error instanceof Error ? error.message : String(error),
2389
+ };
2390
+ }
2391
+ }
2392
+ await assertInactive({ ...input, repoRoot });
2393
+ if (mode === "start") {
2394
+ await controllerAnchorPath({ repoRoot, runId });
2395
+ }
2396
+ const release = await acquireGlobalLock({ repoRoot, runId });
2397
+ let directory = runDirectory(repoRoot, runId);
2398
+ let state;
2399
+ try {
2400
+ await assertInactive({ ...input, repoRoot });
2401
+ if (mode === "continue") {
2402
+ state = await readState(repoRoot, runId);
2403
+ directory = runDirectory(repoRoot, runId);
2404
+ const recoverableMergeState = state.phase === "MODEL_MERGE" && state.status === "in-progress"
2405
+ ? cloneJson(state)
2406
+ : undefined;
2407
+ let mergeOutcome;
2408
+ try {
2409
+ const anchor = await readControllerAnchor(repoRoot, state);
2410
+ mergeOutcome = await validateContinueMerge(directory, state, anchor);
2411
+ }
2412
+ catch (error) {
2413
+ if (!recoverableMergeState)
2414
+ throw error;
2415
+ return {
2416
+ ...toResult(recoverableMergeState),
2417
+ status: "failed",
2418
+ nextAction: error instanceof Error ? error.message : String(error),
2419
+ };
2420
+ }
2421
+ if (mergeOutcome === "no-change")
2422
+ return toResult(state);
2423
+ if (state.phase !== "MODEL_MERGE") {
2424
+ await rm(path.join(directory, "merge-acceptance-pending.json"), {
2425
+ force: true,
2426
+ });
2427
+ }
2428
+ }
2429
+ else {
2430
+ directory = runDirectory(repoRoot, runId);
2431
+ await mkdir(directory, { recursive: false });
2432
+ const identity = await readIdentity();
2433
+ const now = new Date().toISOString();
2434
+ state = {
2435
+ schemaVersion: 1,
2436
+ runId,
2437
+ repoRoot,
2438
+ phase: "DISCOVER",
2439
+ status: "in-progress",
2440
+ createdAt: now,
2441
+ updatedAt: now,
2442
+ controllerIdentity: identity,
2443
+ versionGate: { outcome: "latest" },
2444
+ nextAction: "Evaluate controller version and init surface.",
2445
+ mergeTasks: [],
2446
+ mergeReceipts: [],
2447
+ humanDecisions: [],
2448
+ actionReceipts: [],
2449
+ homeFingerprint: await homeFingerprint(),
2450
+ };
2451
+ await writeControllerAnchor({ state, initialize: true });
2452
+ await writeJsonAtomic(path.join(directory, "controller-identity.json"), {
2453
+ schemaVersion: 1,
2454
+ runId,
2455
+ repoRoot,
2456
+ controllerIdentity: identity,
2457
+ });
2458
+ await saveState(directory, state);
2459
+ }
2460
+ if (!state)
2461
+ throw new Error("init upgrade state was not initialized");
2462
+ return await executeUpgrade(directory, state, { ...input, repoRoot });
2463
+ }
2464
+ catch (error) {
2465
+ if (error instanceof VerificationProcessBoundaryError)
2466
+ throw error;
2467
+ if (!state)
2468
+ throw error;
2469
+ const nextAction = error instanceof Error ? error.message : String(error);
2470
+ state.status = "failed";
2471
+ state.nextAction = nextAction;
2472
+ await saveState(directory, state);
2473
+ await writeReport(directory, state);
2474
+ return toResult(state);
2475
+ }
2476
+ finally {
2477
+ await release();
2478
+ }
2479
+ }