@uchibeke/gallivant 0.2.0 → 0.3.0

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 (133) hide show
  1. package/README.md +120 -60
  2. package/SECURITY.md +9 -0
  3. package/bin/gallivant.mjs +41 -14
  4. package/bin/promote-bitbucket.mjs +40 -0
  5. package/bin/task-pr.mjs +9 -0
  6. package/bin/task-review.mjs +15 -0
  7. package/docs/README.md +6 -2
  8. package/docs/agent-setup.md +55 -41
  9. package/docs/architecture.md +11 -3
  10. package/docs/bitbucket-promotion.md +69 -0
  11. package/docs/bitbucket-review.md +330 -0
  12. package/docs/chains.md +30 -1
  13. package/docs/codex-cloud.md +7 -0
  14. package/docs/codex-review.md +50 -0
  15. package/docs/commands.md +103 -14
  16. package/docs/config.md +45 -13
  17. package/docs/execution.md +1 -1
  18. package/docs/first-party-review.md +266 -0
  19. package/docs/git-hosts.md +294 -0
  20. package/docs/guardrails.md +56 -0
  21. package/docs/install.md +137 -67
  22. package/docs/launch.md +7 -4
  23. package/docs/network-recovery.md +9 -0
  24. package/docs/openai-agents.md +3 -2
  25. package/docs/prd.md +23 -2
  26. package/docs/preparation.md +2 -2
  27. package/docs/release-qualification.md +1 -1
  28. package/docs/releasing.md +46 -22
  29. package/docs/task-delivery.md +5 -5
  30. package/docs/task-profiles.md +537 -0
  31. package/docs/tasks.md +59 -197
  32. package/lib/attribution.mjs +7 -3
  33. package/lib/block.mjs +10 -1
  34. package/lib/chains.mjs +101 -30
  35. package/lib/codex_cloud.mjs +2 -1
  36. package/lib/codex_cloud_input.mjs +15 -6
  37. package/lib/codex_cloud_result.mjs +1 -1
  38. package/lib/commands/doctor.mjs +22 -453
  39. package/lib/commands/init.mjs +281 -28
  40. package/lib/commands/register.mjs +81 -13
  41. package/lib/commands/resume.mjs +5 -1
  42. package/lib/commands/review.mjs +66 -0
  43. package/lib/commands/serve.mjs +8 -0
  44. package/lib/commands/start.mjs +30 -11
  45. package/lib/commands/status.mjs +18 -2
  46. package/lib/commands/task.mjs +9 -1
  47. package/lib/commands/up.mjs +19 -11
  48. package/lib/commands/why.mjs +7 -3
  49. package/lib/config.mjs +42 -9
  50. package/lib/context.mjs +28 -21
  51. package/lib/control.mjs +21 -6
  52. package/lib/credential.mjs +20 -14
  53. package/lib/deliverable.mjs +2 -0
  54. package/lib/events.mjs +96 -53
  55. package/lib/execution.mjs +132 -11
  56. package/lib/execution_preparation.mjs +16 -1
  57. package/lib/execution_result.mjs +26 -5
  58. package/lib/first_party_review.mjs +618 -0
  59. package/lib/fs.mjs +10 -3
  60. package/lib/gh.mjs +144 -56
  61. package/lib/git.mjs +133 -32
  62. package/lib/git_diff.mjs +121 -0
  63. package/lib/harness.mjs +14 -10
  64. package/lib/host_contract.mjs +71 -0
  65. package/lib/host_definitions.mjs +22 -0
  66. package/lib/hosts/bitbucket.mjs +553 -0
  67. package/lib/hosts/bitbucket_contract.mjs +57 -0
  68. package/lib/hosts/bitbucket_credential.mjs +62 -0
  69. package/lib/hosts/bitbucket_review.mjs +44 -0
  70. package/lib/hosts/bitbucket_review_producer.mjs +50 -0
  71. package/lib/hosts/github.mjs +57 -0
  72. package/lib/hosts/github_contract.mjs +27 -0
  73. package/lib/hosts/github_doctor.mjs +482 -0
  74. package/lib/hosts/github_review_summary.mjs +38 -0
  75. package/lib/hosts/transport_doctor.mjs +35 -0
  76. package/lib/hosts.mjs +130 -0
  77. package/lib/learning_qualification.mjs +1 -1
  78. package/lib/notify.mjs +1 -1
  79. package/lib/openai_agents_input.mjs +7 -2
  80. package/lib/openai_agents_sources.mjs +16 -8
  81. package/lib/plugin_package.mjs +13 -1
  82. package/lib/pr_operation.mjs +41 -0
  83. package/lib/prd.mjs +18 -3
  84. package/lib/preparation_codex.mjs +6 -1
  85. package/lib/probe.mjs +6 -4
  86. package/lib/process.mjs +24 -0
  87. package/lib/promotion.mjs +132 -0
  88. package/lib/promotion_git.mjs +63 -0
  89. package/lib/prompt.mjs +16 -13
  90. package/lib/provider_health.mjs +14 -3
  91. package/lib/repository.mjs +68 -0
  92. package/lib/repository_preflight.mjs +11 -0
  93. package/lib/review_evaluation.mjs +25 -0
  94. package/lib/review_evidence.mjs +500 -0
  95. package/lib/review_history.mjs +73 -0
  96. package/lib/review_image.mjs +145 -0
  97. package/lib/review_link.mjs +12 -0
  98. package/lib/review_policy.mjs +132 -0
  99. package/lib/review_policy_contract.mjs +18 -0
  100. package/lib/review_state.mjs +13 -0
  101. package/lib/review_visual.mjs +94 -0
  102. package/lib/runtime-environment.mjs +6 -0
  103. package/lib/runtime_node.mjs +3 -2
  104. package/lib/runtime_state.mjs +39 -5
  105. package/lib/sources/prd.mjs +6 -3
  106. package/lib/state.mjs +137 -24
  107. package/lib/task_completion.mjs +7 -3
  108. package/lib/task_contract.mjs +201 -27
  109. package/lib/task_delivery.mjs +9 -2
  110. package/lib/task_delivery_contract.mjs +13 -9
  111. package/lib/task_delivery_view.mjs +18 -2
  112. package/lib/task_environment.mjs +13 -1
  113. package/lib/task_limits.mjs +13 -0
  114. package/lib/task_model_config.mjs +99 -0
  115. package/lib/task_model_route.mjs +243 -0
  116. package/lib/task_prepare.mjs +21 -8
  117. package/lib/task_profile.mjs +1875 -0
  118. package/lib/task_repository_context.mjs +65 -0
  119. package/lib/tasks.mjs +252 -51
  120. package/lib/tick.mjs +373 -98
  121. package/lib/time.mjs +1 -0
  122. package/lib/tracker/pages.mjs +19 -5
  123. package/lib/tracker/review.mjs +21 -0
  124. package/lib/tracker_process.mjs +8 -1
  125. package/lib/verification_sandbox.mjs +2 -1
  126. package/lib/work_artifacts.mjs +79 -0
  127. package/npm-shrinkwrap.json +12 -3
  128. package/package.json +9 -3
  129. package/templates/plugin/task-delivery.SKILL.md +17 -10
  130. package/templates/plugin/task-intake-contract.json +18 -0
  131. package/templates/plugin/task-profile-requests.mjs +1448 -0
  132. package/templates/tracker/tracker.css +3 -0
  133. package/templates/tracker/tracker.js +38 -0
package/lib/execution.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  // Versioned durable transport boundary. No transport implements a review loop.
2
2
  import { join } from "node:path";
3
- import { existsSync, rmSync } from "node:fs";
3
+ import { existsSync, readdirSync, rmSync } from "node:fs";
4
4
  import { randomUUID, createHash } from "node:crypto";
5
+ import { isDeepStrictEqual } from "node:util";
5
6
  import { ensureDir, readJson, writeJsonAtomic } from "./fs.mjs";
6
7
  import { acquireProcessLock } from "./daemon.mjs";
7
8
  import { capabilitiesFor, adapterFor, preparationReceipt, isPreparationReceipt } from "./harness.mjs";
@@ -11,6 +12,7 @@ import { prepareLocalExecution } from "./execution_preparation.mjs";
11
12
  import { EXECUTION_LIMITS, boundedCall, nextAttempt, executionError, assertExecutionCursor, ownsProviderSession, assertTerminalOutcome, redact, redactResultMetadata } from "./execution_io.mjs";
12
13
  import { journalFor } from "./execution_journal.mjs";
13
14
  import { requestRunCancellation } from "./run.mjs";
15
+ import { verifyReviewImageCopy } from "./review_image.mjs";
14
16
 
15
17
  export const EXECUTION_VERSION = 1;
16
18
  const exactIdentity = id => typeof id === "string" && !!id.trim();
@@ -29,8 +31,92 @@ const preparationFingerprint = input => {
29
31
  const { receipt: _receipt, ...prepared } = input.prepared || {};
30
32
  return digest({ environment: input.environment, ...(input.spec ? { spec: input.spec } : { prompt: input.prompt, prepared }) });
31
33
  };
34
+ function verifyPreparedReviewImage(input, transport) {
35
+ const spec = input.spec, args = spec?.args, image = spec?.imageAttachment;
36
+ if (!Array.isArray(args)) return;
37
+ const flags = args.slice(0, -1).filter(arg => arg === "--image" || arg === "-i" || typeof arg === "string" && (arg.startsWith("--image=") || arg.startsWith("-i=")));
38
+ if (!image && flags.length === 0) return;
39
+ if (transport || input.kind !== "intake" || input.preparation?.boundary !== "codex-draft-sandbox-v1" || flags.length !== 1 || args.at(-4) !== "--image" || args.at(-3) !== image?.file || args.at(-2) !== "--" || !spec.options?.cwd) throw executionError("TASK_PREPARATION_BOUNDARY", "image attachment does not match the restricted prepared input");
40
+ verifyReviewImageCopy(spec.options.cwd, image.file, image.expected);
41
+ }
32
42
  export const executionRunner = (ctx, id, provider = ctx.state.get(id)?.harness) => ctx.executionFor && provider ? ctx.executionFor(id, provider) : ctx.runnerFor(id);
33
43
 
44
+ const epochFile = (root, epoch) => join(root, epoch ? `execution-${epoch}.json` : "execution.json");
45
+ const validEpoch = epoch => Number.isSafeInteger(epoch) && epoch >= 0 && epoch <= 1000;
46
+ const latestLedger = root => {
47
+ const epochs = readdirSync(root, { withFileTypes: true }).filter(file => file.isFile() && /^execution-[1-9][0-9]*\.json$/.test(file.name))
48
+ .map(file => Number(file.name.slice(10, -5)));
49
+ if (!epochs.length) return null;
50
+ const epoch = Math.max(...epochs);
51
+ if (!validEpoch(epoch)) throw executionError("EXECUTION_VERSION", "invalid execution handoff epoch; restore its matching ledger backup");
52
+ return { epoch, ledger: readJson(epochFile(root, epoch)) };
53
+ };
54
+
55
+ // Reserve a new provider epoch before changing the registry or starting a run.
56
+ // Its empty ledger is also the durable route recovered after state.json loss.
57
+ export function reserveExecutionEpoch({ root, taskId, provider, account, epoch, pr = null }) {
58
+ if (!validEpoch(epoch) || epoch === 0) throw executionError("EXECUTION_VERSION", "invalid execution handoff epoch");
59
+ const file = epochFile(root, epoch);
60
+ if (existsSync(file)) {
61
+ const saved = readJson(file);
62
+ if (saved?.version !== EXECUTION_VERSION || saved.taskId !== taskId || saved.provider !== provider || saved.account !== account ||
63
+ saved.handoff && (saved.handoff.epoch !== epoch || pr != null && saved.handoff.pr !== pr)) {
64
+ throw executionError("EXECUTION_OWNERSHIP", "reserved handoff ledger belongs to a different execution; restore its matching backup");
65
+ }
66
+ return;
67
+ }
68
+ writeJsonAtomic(file, { version: EXECUTION_VERSION, taskId, provider, account, operations: {}, current: null, stopped: null,
69
+ ...(pr != null ? { handoff: { epoch, pr } } : {}) });
70
+ }
71
+
72
+ // A registered PR may change local harnesses only after its previous run has
73
+ // settled. Keep that provider's execution.json intact and give the new harness
74
+ // a separate ledger while run directories remain under the same task root.
75
+ // The legacy inference repairs registrations made before the epoch was saved:
76
+ // it is deliberately limited to the exact settled reset, not an arbitrary
77
+ // provider mismatch or a remote task whose ownership remains unresolved.
78
+ export function executionEpoch({ root, taskId, entry, provider, account, runner }) {
79
+ if (entry?.execution_epoch != null) {
80
+ if (!validEpoch(entry.execution_epoch)) throw executionError("EXECUTION_VERSION", "invalid execution handoff epoch; restore its matching registry backup");
81
+ }
82
+ const latest = existsSync(root) ? latestLedger(root) : null;
83
+ if (latest) {
84
+ const pending = entry?.handoff_pending;
85
+ const oldEpoch = entry?.execution_epoch ?? 0;
86
+ // A reset interrupted after reserving its new empty ledger keeps the old
87
+ // registry entry fenced. Let a retry inspect that exact old ledger, while
88
+ // `dispatch_stopped` prevents a daemon from launching the old provider.
89
+ if (entry?.dispatch_stopped === "harness-handoff-pending" && pending?.from === provider &&
90
+ pending.to === latest.ledger?.provider && pending.epoch === latest.epoch && pending.pr === entry.pr &&
91
+ oldEpoch === latest.epoch - 1 && latest.ledger?.taskId === taskId && latest.ledger.handoff?.pr === entry.pr &&
92
+ latest.ledger.current === null && !latest.ledger.stopped && Object.keys(latest.ledger.operations ?? {}).length === 0) {
93
+ return oldEpoch;
94
+ }
95
+ if (entry?.execution_epoch != null && entry.execution_epoch !== latest.epoch) throw executionError("EXECUTION_OWNERSHIP", "registry and latest handoff ledger disagree; reconcile the interrupted reset before dispatch");
96
+ if (latest.ledger?.version !== EXECUTION_VERSION || latest.ledger.taskId !== taskId || latest.ledger.provider !== provider || latest.ledger.account !== account ||
97
+ latest.ledger.handoff && (latest.ledger.handoff.epoch !== latest.epoch || latest.ledger.handoff.pr !== entry?.pr)) {
98
+ throw executionError("EXECUTION_OWNERSHIP", "latest handoff ledger belongs to another provider/account; do not reopen an older session");
99
+ }
100
+ return latest.epoch;
101
+ }
102
+ if (entry?.execution_epoch != null) {
103
+ if (entry.execution_epoch > 0) throw executionError("EXECUTION_OWNERSHIP", "recorded handoff ledger is missing; restore its matching backup before dispatch");
104
+ return 0;
105
+ }
106
+ const old = readJson(epochFile(root, 0));
107
+ if (!old || old.provider === provider && old.account === account) return 0;
108
+ const prior = old.current?.execution;
109
+ const current = runner?.current();
110
+ const exactReset = entry?.pr && entry.session === null &&
111
+ entry.last?.run === old.current?.id && entry.last?.exit === old.current?.exit &&
112
+ entry.execution?.provider === old.provider && entry.execution?.account === old.account &&
113
+ old.taskId === taskId && old.account === "operator" && account === "operator" &&
114
+ prior?.transport === "cli" && prior.provider === old.provider && prior.collected === true &&
115
+ ["succeeded", "failed"].includes(prior.providerState) && !old.stopped &&
116
+ current?.id === old.current.id && current.running === false;
117
+ return exactReset ? 1 : 0;
118
+ }
119
+
34
120
  // A sessionless resume is allowed only for an owned, proven unsubmitted first
35
121
  // launch. Older refusal entries can recover from the private transport proof;
36
122
  // a registry reason or an absent session alone never authorizes a new session.
@@ -53,9 +139,9 @@ export function assertExecutionDependency(taskService) {
53
139
  * Methods accept scoped immutable input plus AbortSignal and return bounded JSON.
54
140
  * A remote adapter never needs a local command, PID, or inherited environment.
55
141
  */
56
- export function makeExecution({ root, taskId, provider, account = "operator", transport = null, runner = null, legacySessionId = null, capabilities = capabilitiesFor(provider), clock = Date, limits = {}, secrets = [], jitter = Math.random }) {
142
+ export function makeExecution({ root, taskId, provider, account = "operator", transport = null, runner = null, legacySessionId = null, ledgerFile = join(root, "execution.json"), ledgerEpoch = 0, capabilities = capabilitiesFor(provider), clock = Date, limits = {}, secrets = [], jitter = Math.random }) {
57
143
  const bounds = { ...EXECUTION_LIMITS, ...limits };
58
- const file = join(root, "execution.json");
144
+ const file = ledgerFile;
59
145
  const collectionRejection = s => {
60
146
  const job = s.current, ex = job?.execution;
61
147
  if (!transport || !ownedRemote(ex) || !["succeeded", "failed"].includes(ex.terminalState)) return null;
@@ -243,10 +329,11 @@ export function makeExecution({ root, taskId, provider, account = "operator", tr
243
329
  if (prior.acceptance !== "accepted" || (transport && !ownedRemote(prior.job?.execution))) throw executionError("EXECUTION_UNKNOWN", `submission ${prior.runId} acceptance is unknown; reconcile its recorded idempotency key; no new dispatch`);
244
330
  return { ...prior.job };
245
331
  }
332
+ if (input.kind === "intake") verifyPreparedReviewImage(input, transport);
246
333
  if (s.learningContinuity?.pending && (method !== "submit" || input.sessionId)) throw executionError("EXECUTION_CONTINUATION", "retired advice requires a new provider session, not continuation");
247
334
  if (s.stopped) throw executionError("EXECUTION_STOPPED", `dispatch stopped: ${s.stopped}; late results remain quarantined`);
248
335
  if (s.current && executionLive(s.current)) throw executionError("BUSY", "previous provider work is not settled");
249
- const legacy = !transport && !s.current ? runner.current() : null;
336
+ const legacy = !transport && !s.current && ledgerEpoch === 0 ? runner.current() : null;
250
337
  if (legacy?.running) throw executionError("BUSY", "legacy local writer is still running");
251
338
  const ownedSession = transport ? s.current?.execution.sessionId : s.current
252
339
  ? localSession(s, s.current.id, s.current.execution.sessionId)
@@ -280,6 +367,7 @@ export function makeExecution({ root, taskId, provider, account = "operator", tr
280
367
  if (transport) project(s);
281
368
  let accepted;
282
369
  try {
370
+ if (!transport && input.kind === "intake") verifyPreparedReviewImage(input, transport);
283
371
  accepted = transport ? await call(method, { ...input, taskId, provider, account, runId: id, idempotencyKey: intent.idempotencyKey })
284
372
  : await runner.start(input.name, input.spec.cmd, input.spec.args, { ...input.spec.options, runId: id, privateOutput: { taskId, runId: id, provider } });
285
373
  } catch (e) {
@@ -313,9 +401,9 @@ export function makeExecution({ root, taskId, provider, account = "operator", tr
313
401
  const s = read();
314
402
  if (!transport) {
315
403
  const job = runner.current();
316
- if (job && (!s.current || s.current.id === job.id)) return { ...job, ...(s.current ? { execution: s.current.execution,
404
+ if (job && ((ledgerEpoch === 0 && !s.current) || s.current?.id === job.id)) return { ...job, ...(s.current ? { execution: s.current.execution,
317
405
  ...(s.current.execution.quarantined ? { exit: s.current.exit, endedAt: s.current.endedAt } : {}) } : {}) };
318
- if (!s.current) return job; // Legacy local runs retain their exact identity.
406
+ if (!s.current) return ledgerEpoch === 0 ? job : null; // Only the original epoch may adopt a legacy local run.
319
407
  }
320
408
  return s.current ? { ...s.current, running: executionLive(s.current) } : null;
321
409
  };
@@ -324,6 +412,7 @@ export function makeExecution({ root, taskId, provider, account = "operator", tr
324
412
  if (!transport) runner.observe?.();
325
413
  if (!s.current) return current();
326
414
  if (!transport) {
415
+ const before = structuredClone(s);
327
416
  let job = runner.current();
328
417
  if (!job || job.id !== s.current.id) return current();
329
418
  if (s.current.execution.acceptance === "unknown") {
@@ -342,11 +431,23 @@ export function makeExecution({ root, taskId, provider, account = "operator", tr
342
431
  catch (error) { s.current.execution.recoveryAction = error.code || "local shutdown remains unverified"; }
343
432
  if (!job || job.id !== s.current.id) { save(s); return current(); }
344
433
  }
345
- Object.assign(s.current.execution, { providerState: job.running ? s.stopped || job.killed ? "cancellation-requested" : "running" : s.current.execution.collected ? s.current.execution.providerState : "retrieving-results", observedAt: clock.now(), reconciledAt: clock.now() });
434
+ Object.assign(s.current.execution, { providerState: job.running ? s.stopped || job.killed ? "cancellation-requested" : "running" : s.current.execution.collected ? s.current.execution.providerState : "retrieving-results" });
346
435
  if (s.stopped && !job.running) confirmLocalStop(s, job);
347
436
  const { running: _running, ...record } = job;
348
437
  s.current = { ...record, ...(s.current.execution.quarantined ? { exit: s.current.exit, endedAt: s.current.endedAt } : {}), execution: s.current.execution };
349
- save(s); runner.annotate?.(s.current.id, { execution: s.current.execution }); return current();
438
+ // Still observe liveness/output every tick. Persist real changes at
439
+ // once, but coalesce unchanged live observations to one per minute.
440
+ // Settled runs need no heartbeat. Never defer a launch or stop intent.
441
+ const ex = s.current.execution;
442
+ if (!isDeepStrictEqual(before, s) || job.running &&
443
+ (ex.reconciledAt == null || clock.now() - ex.reconciledAt >= 60_000)) {
444
+ Object.assign(ex, { observedAt: clock.now(), reconciledAt: clock.now() });
445
+ save(s);
446
+ }
447
+ // Repair a projection after an interrupted write without rewriting an
448
+ // already matching job.json on every poll.
449
+ if (!isDeepStrictEqual(job.execution, ex)) runner.annotate?.(s.current.id, { execution: ex });
450
+ return current();
350
451
  }
351
452
  const ex = s.current.execution;
352
453
  if (ex.collected && terminal(ex.providerState)) return current();
@@ -470,7 +571,7 @@ export function makeExecution({ root, taskId, provider, account = "operator", tr
470
571
  if (!s.stopped || reason !== "timeout") s.stopped = reason;
471
572
  // An upgrade may have an owned local writer but no boundary submission.
472
573
  // Preserve its identity for stop recovery without inventing a paid intent.
473
- if (!s.current && !transport) {
574
+ if (!s.current && !transport && ledgerEpoch === 0) {
474
575
  const job = runner.current();
475
576
  // Completed pre-boundary runs need only the future-dispatch fence.
476
577
  // Adopt live/unfinished writers for the existing shutdown recovery.
@@ -519,7 +620,7 @@ export function makeExecution({ root, taskId, provider, account = "operator", tr
519
620
  });
520
621
  }
521
622
  return {
522
- root, provider, account, capabilities, state: read, current, reconcile, collect, cancel,
623
+ root, provider, account, ledgerEpoch, capabilities, state: read, current, reconcile, collect, cancel,
523
624
  recover: async ({ operationId, authorized = false } = {}) => {
524
625
  const pending=await locked(async s => {
525
626
  const job=s.current,ex=job?.execution;
@@ -550,6 +651,7 @@ export function makeExecution({ root, taskId, provider, account = "operator", tr
550
651
  sanitize: (value, id, extraSecrets = []) => redact(value, [...secrets, ...(readJson(join(dirOf(id), "sensitive-redactions.json"))?.secrets || []), ...extraSecrets]),
551
652
  prepare: async (input, { build } = {}) => {
552
653
  authorize(input);
654
+ if (input.reviewImage != null && (transport || input.kind !== "intake" || provider !== "codex")) throw executionError("TASK_PREPARATION_BOUNDARY", "image review requires the restricted local reviewer; no fallback is available");
553
655
  if (input.kind !== "intake") return transport ? call("prepare", input) : input;
554
656
  authorizePreparation(read());
555
657
  const receipt = preparationReceipt(provider, capabilities, account);
@@ -625,7 +727,26 @@ export function makeExecution({ root, taskId, provider, account = "operator", tr
625
727
  return job;
626
728
  } finally { lock.release(); }
627
729
  },
628
- kill: id => { if (transport) throw executionError("EXECUTION_CONTROL", "remote cancellation must be requested and confirmed by the coordinator"); return runner.kill(id); },
730
+ kill: id => {
731
+ if (transport) throw executionError("EXECUTION_CONTROL", "remote cancellation must be requested and confirmed by the coordinator");
732
+ ensureDir(root);
733
+ const lock = acquireProcessLock(join(root, "execution.lock"), "execution");
734
+ try {
735
+ const s = read();
736
+ if (s.current) {
737
+ if (s.current.id !== id) throw executionError("EXECUTION_OWNERSHIP", "interrupt does not own the current local run");
738
+ // First-turn sessions can be discovered in the stream before their
739
+ // terminal result. Retain that verified identity BEFORE stopping and
740
+ // quarantining the run; continuation must not depend on late output.
741
+ const sessionId = localSession(s, id, adapterFor(provider).sessionFrom(runner.lines(id)));
742
+ if (sessionId !== s.current.execution.sessionId) {
743
+ s.current.execution.sessionId = sessionId;
744
+ save(s); runner.annotate?.(id, { execution: s.current.execution });
745
+ }
746
+ }
747
+ return runner.kill(id);
748
+ } finally { lock.release(); }
749
+ },
629
750
  waitForKill: id => runner?.waitForKill(id), dirOf,
630
751
  };
631
752
  }
@@ -8,6 +8,7 @@ import { ensureDir } from "./fs.mjs";
8
8
  import { MAX_PREPARATION_PROMPT_BYTES } from "./task_contract.mjs";
9
9
  import { taskError } from "./task_store.mjs";
10
10
  import { boundedCall } from "./execution_io.mjs";
11
+ import { copyReviewImage, REVIEW_IMAGE_LIMITS } from "./review_image.mjs";
11
12
 
12
13
  export async function prepareLocalExecution(provider, capabilities, account, input, build = buildPreparationRun, limits = {}) {
13
14
  const receipt = preparationReceipt(provider, capabilities, account);
@@ -25,5 +26,19 @@ export async function prepareLocalExecution(provider, capabilities, account, inp
25
26
  if (spec.configure) spec = await boundedCall(signal => spec.configure(spec, { cwd: input.cwd, env, signal }), limits);
26
27
  check(); // An adapter's final configuration cannot replace its authority.
27
28
  assertSafeEnv(env);
28
- return { receipt, environment: input.environment, spec: { cmd: spec.cmd, args: spec.args, options: { cwd: input.cwd, env, inheritEnv: false } } };
29
+ let copy, imageAttachment;
30
+ try {
31
+ if (input.reviewImage != null) {
32
+ if (provider !== "codex" || spec.reviewImageBoundary !== "coordinator-copy-v1" || spec.boundary !== "codex-draft-sandbox-v1") throw taskError("TASK_PREPARATION_BOUNDARY", "image attachment requires the restricted reviewer boundary");
33
+ const { content, ...expected } = input.reviewImage;
34
+ if (typeof content !== "string" || !Number.isSafeInteger(expected.bytes) || expected.bytes < 1 || expected.bytes > REVIEW_IMAGE_LIMITS.individualBytes || content.length !== Math.ceil(expected.bytes / 3) * 4 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(content)) throw taskError("TASK_PREPARATION_BOUNDARY", "image attachment bytes are incomplete");
35
+ const bytes = Buffer.from(content, "base64");
36
+ if (bytes.length !== expected.bytes || bytes.toString("base64") !== content) throw taskError("TASK_PREPARATION_BOUNDARY", "image attachment bytes differ");
37
+ copy = copyReviewImage(input.cwd, bytes, expected);
38
+ spec.args = [...spec.args.slice(0, -1), "--image", copy.file, "--", spec.args.at(-1)];
39
+ imageAttachment = { file: copy.file, expected };
40
+ check();
41
+ }
42
+ return { receipt, environment: input.environment, spec: { cmd: spec.cmd, args: spec.args, options: { cwd: input.cwd, env, inheritEnv: false }, ...(imageAttachment ? { imageAttachment } : {}) } };
43
+ } catch (error) { copy?.cleanup(); throw error; }
29
44
  }
@@ -1,3 +1,6 @@
1
+ import { publicationPolicyOf, assertPublicationPaths, workArtifactReason } from "./work_artifacts.mjs";
2
+ import { assertAgentsCheckout } from "./openai_agents_sources.mjs";
3
+ import { hostApi } from "./hosts.mjs";
1
4
  // Result transport ends here. Git/gh own the one publication path; the existing
2
5
  // tick still owns readiness, receipts, review rounds, conflicts and human merge.
3
6
  import { createHash } from "node:crypto";
@@ -7,7 +10,8 @@ import { unlinkSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
7
10
  import { repositoryPath, repositoryRelativePath, assertRepositoryTargets, readRepositoryText, writeRepositoryText } from "./repository_files.mjs";
8
11
  import { isOurBranch, runEnv } from "./git.mjs";
9
12
  import { ensureDir, readJson, writeJsonAtomic } from "./fs.mjs";
10
- import { execFileAsync, remoteMatches } from "./gh.mjs";
13
+ import { execFileAsync } from "./process.mjs";
14
+ import { remoteMatches } from "./repository.mjs";
11
15
  import { parsePrd, verifyCommands } from "./prd.mjs";
12
16
  import { normalizePublicBody } from "./block.mjs";
13
17
  import { assertPublicText, assertNoNul } from "./public_output.mjs";
@@ -103,6 +107,8 @@ async function applyExecutionResult(ctx, { id, entry, result, dir, verify = exec
103
107
  // input requires an explicit corrective run, with no repeated application.
104
108
  const fail = (message, retryable = false) => { throw Object.assign(executionError("EXECUTION_RESULT", message), { publicationValidation: !retryable }); };
105
109
  const validate = fn => { try { return fn(); } catch (error) { if (error.code) throw error; fail(error.message); } };
110
+ const approvedBrief = ctx.readPrd(id) || "";
111
+ const publicationPolicy = validate(() => publicationPolicyOf(parsePrd(approvedBrief, `${id}.md`), { sources: entry.sources || [] }));
106
112
  const execution = ctx.executionFor?.(id, entry.harness);
107
113
  const assertNotStopped = () => {
108
114
  const current = ctx.state?.get(id) || entry;
@@ -149,7 +155,7 @@ async function applyExecutionResult(ctx, { id, entry, result, dir, verify = exec
149
155
  catch (error) { fail(error.message); }
150
156
  };
151
157
  if (result.pushedHead) {
152
- if (result.files?.length || await ctx.gh.refSha(entry.repo, entry.branch) !== result.pushedHead || !await ctx.gh.containsCommit(entry.repo, entry.branch, result.inputHead)) fail("pushed result revision or input ancestry cannot be verified");
158
+ if (result.files?.length || await hostApi(ctx).refSha(entry.repo, entry.branch) !== result.pushedHead || !await hostApi(ctx).containsCommit(entry.repo, entry.branch, result.inputHead)) fail("pushed result revision or input ancestry cannot be verified");
153
159
  if (head !== result.pushedHead) fail("reconcile the exact pushed provider branch into its isolated checkout before collection");
154
160
  await validateTargets();
155
161
  } else {
@@ -161,6 +167,7 @@ async function applyExecutionResult(ctx, { id, entry, result, dir, verify = exec
161
167
  paths.add(path);
162
168
  changes.push({ ...file, path });
163
169
  }
170
+ validate(() => assertPublicationPaths(changes.filter(file => file.text !== null).map(file => file.path), publicationPolicy));
164
171
  await validateTargets(changes.map(file => file.path));
165
172
  let modesMatch;
166
173
  try { modesMatch = await ctx.git.resultModesMatch(entry.worktree, result.inputHead, [...paths]); }
@@ -191,6 +198,15 @@ async function applyExecutionResult(ctx, { id, entry, result, dir, verify = exec
191
198
  }
192
199
  }
193
200
  const appliedHead = verified ? prior.appliedHead : await ctx.git.head(entry.worktree);
201
+ // A provider may return an already-pushed branch or a resumed publication may
202
+ // contain earlier commits. Check the full branch, not only this turn's manifest.
203
+ let publicationPaths;
204
+ try { publicationPaths = await ctx.git.publicationPaths(entry.worktree, entry.base); }
205
+ catch { fail("complete branch publication paths unavailable; retrieve Git base metadata before publication", true); }
206
+ validate(() => assertPublicationPaths(publicationPaths, publicationPolicy));
207
+ const privateInputPaths = [...new Set([...publicationPolicy.inputFiles, ...(entry.prd ? [entry.prd] : [])])]
208
+ .filter(path => workArtifactReason(path, publicationPolicy));
209
+ const privateInputs = privateInputPaths.map(path => ({ path, text: path === entry.prd ? approvedBrief : ctx.readSource?.(id, path) }));
194
210
  const assertVerifiedTree = async () => {
195
211
  let revision, tree;
196
212
  try {
@@ -199,7 +215,12 @@ async function applyExecutionResult(ctx, { id, entry, result, dir, verify = exec
199
215
  } catch { block("verification-state-unavailable", "cannot verify the checkout revision and clean index/worktree", appliedHead); }
200
216
  if (revision !== appliedHead) block("verification-head-changed", "verification changed the input head; review that change before publishing", appliedHead);
201
217
  if (!tree || ![tree.modified, tree.untracked].every(count => Number.isInteger(count) && count >= 0)) block("verification-state-unavailable", "cannot verify clean index/worktree status", appliedHead);
202
- if (tree.modified || tree.untracked) block("verification-dirty", "verification requires a clean index/worktree; preserve uncommitted files before correction", appliedHead);
218
+ if (privateInputs.length) {
219
+ try {
220
+ await assertAgentsCheckout({ worktree: entry.worktree, overlays: privateInputs, immutableOverlays: true });
221
+ if (await ctx.git.resultModesMatch(entry.worktree, result.inputHead, privateInputPaths) !== true) throw new Error("private input modes changed");
222
+ } catch { block("verification-dirty", "verification requires a clean index/worktree apart from unchanged approved private inputs; preserve uncommitted files before correction", appliedHead); }
223
+ } else if (tree.modified || tree.untracked) block("verification-dirty", "verification requires a clean index/worktree; preserve uncommitted files before correction", appliedHead);
203
224
  };
204
225
  await assertVerifiedTree();
205
226
  const evidenceFile = join(dir, "verification.json");
@@ -256,10 +277,10 @@ async function applyExecutionResult(ctx, { id, entry, result, dir, verify = exec
256
277
  try {
257
278
  assertNotStopped();
258
279
  if (execution?.current && execution.current()?.id !== result.runId) fail("publication root changed; reconcile the exact owned result before publishing", true);
259
- await ctx.git.pushResult(entry.worktree, entry.branch);
280
+ await ctx.git.pushResult(entry.worktree, entry.branch, entry.repo);
260
281
  await assertVerifiedTree();
261
282
  assertNotStopped();
262
- const pr = await ctx.gh.publishResultPr({ repo: entry.repo, branch: entry.branch, base: entry.base, title: result.pr.title, body });
283
+ const pr = await hostApi(ctx).publishResultPr({ repo: entry.repo, branch: entry.branch, base: entry.base, title: result.pr.title, body });
263
284
  writeJsonAtomic(checkpoint, { fingerprint, phase: "published", appliedHead, pr: pr.number, url: pr.url });
264
285
  return { head: appliedHead, pr, evidence };
265
286
  } finally { exclusion.release(); }