@uchibeke/gallivant 0.0.0-stage → 0.2.1

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 (168) hide show
  1. package/CONTRIBUTING.md +26 -0
  2. package/LICENSE +204 -0
  3. package/README.md +239 -2
  4. package/SECURITY.md +19 -0
  5. package/bin/gallivant.mjs +261 -0
  6. package/bin/package-plugin.mjs +16 -0
  7. package/bin/release-live.mjs +29 -0
  8. package/bin/release-package.mjs +23 -0
  9. package/bin/session-runtime.mjs +13 -0
  10. package/bin/task-delivery-state.mjs +12 -0
  11. package/docs/README.md +72 -0
  12. package/docs/agent-setup.md +228 -0
  13. package/docs/app.md +111 -0
  14. package/docs/architecture.md +265 -0
  15. package/docs/chains.md +102 -0
  16. package/docs/codex-cloud.md +284 -0
  17. package/docs/commands.md +649 -0
  18. package/docs/config.md +437 -0
  19. package/docs/execution.md +796 -0
  20. package/docs/guardrails.md +311 -0
  21. package/docs/install.md +398 -0
  22. package/docs/launch.md +45 -0
  23. package/docs/learning.md +369 -0
  24. package/docs/network-recovery.md +101 -0
  25. package/docs/openai-agents.md +457 -0
  26. package/docs/prd.md +246 -0
  27. package/docs/preparation.md +110 -0
  28. package/docs/release-qualification.md +196 -0
  29. package/docs/releasing.md +149 -0
  30. package/docs/runtime-environment.md +113 -0
  31. package/docs/task-delivery.md +476 -0
  32. package/docs/tasks.md +739 -0
  33. package/lib/aport.mjs +95 -0
  34. package/lib/attribution.mjs +95 -0
  35. package/lib/block.mjs +89 -0
  36. package/lib/capabilities.mjs +3 -0
  37. package/lib/chains.mjs +222 -0
  38. package/lib/codex_cloud.mjs +160 -0
  39. package/lib/codex_cloud_config.mjs +20 -0
  40. package/lib/codex_cloud_input.mjs +49 -0
  41. package/lib/codex_cloud_result.mjs +74 -0
  42. package/lib/commands/doctor.mjs +466 -0
  43. package/lib/commands/init.mjs +245 -0
  44. package/lib/commands/learn.mjs +54 -0
  45. package/lib/commands/new.mjs +47 -0
  46. package/lib/commands/ready.mjs +45 -0
  47. package/lib/commands/register.mjs +249 -0
  48. package/lib/commands/resume.mjs +224 -0
  49. package/lib/commands/serve.mjs +349 -0
  50. package/lib/commands/start.mjs +247 -0
  51. package/lib/commands/start_services.mjs +22 -0
  52. package/lib/commands/status.mjs +150 -0
  53. package/lib/commands/task.mjs +73 -0
  54. package/lib/commands/up.mjs +228 -0
  55. package/lib/commands/why.mjs +143 -0
  56. package/lib/config.mjs +461 -0
  57. package/lib/context.mjs +102 -0
  58. package/lib/control.mjs +202 -0
  59. package/lib/control_contract.mjs +16 -0
  60. package/lib/coordinator.mjs +19 -0
  61. package/lib/credential.mjs +161 -0
  62. package/lib/daemon.mjs +156 -0
  63. package/lib/deliverable.mjs +357 -0
  64. package/lib/events.mjs +261 -0
  65. package/lib/executable.mjs +31 -0
  66. package/lib/execution.mjs +707 -0
  67. package/lib/execution_contract.mjs +15 -0
  68. package/lib/execution_input.mjs +4 -0
  69. package/lib/execution_io.mjs +94 -0
  70. package/lib/execution_journal.mjs +69 -0
  71. package/lib/execution_preparation.mjs +29 -0
  72. package/lib/execution_result.mjs +266 -0
  73. package/lib/fs.mjs +140 -0
  74. package/lib/gh.mjs +289 -0
  75. package/lib/git.mjs +434 -0
  76. package/lib/harness.mjs +521 -0
  77. package/lib/harness_log.mjs +432 -0
  78. package/lib/hash.mjs +16 -0
  79. package/lib/identity.mjs +98 -0
  80. package/lib/learning.mjs +654 -0
  81. package/lib/learning_contract.mjs +2 -0
  82. package/lib/learning_data.mjs +135 -0
  83. package/lib/learning_input.mjs +111 -0
  84. package/lib/learning_qualification.mjs +67 -0
  85. package/lib/learning_signer.mjs +151 -0
  86. package/lib/learning_store.mjs +88 -0
  87. package/lib/log.mjs +53 -0
  88. package/lib/network_diagnostics.mjs +64 -0
  89. package/lib/network_recovery.mjs +42 -0
  90. package/lib/notify.mjs +58 -0
  91. package/lib/openai_agents.mjs +373 -0
  92. package/lib/openai_agents_config.mjs +14 -0
  93. package/lib/openai_agents_http.mjs +87 -0
  94. package/lib/openai_agents_input.mjs +15 -0
  95. package/lib/openai_agents_sources.mjs +112 -0
  96. package/lib/owner_delivery.mjs +443 -0
  97. package/lib/plugin_package.mjs +340 -0
  98. package/lib/prd.mjs +635 -0
  99. package/lib/preparation_agents.mjs +33 -0
  100. package/lib/preparation_codex.mjs +212 -0
  101. package/lib/probe.mjs +126 -0
  102. package/lib/prompt.mjs +238 -0
  103. package/lib/provider_health.mjs +55 -0
  104. package/lib/public-format.mjs +31 -0
  105. package/lib/public_output.mjs +49 -0
  106. package/lib/release_package.mjs +154 -0
  107. package/lib/repository_files.mjs +86 -0
  108. package/lib/run.mjs +385 -0
  109. package/lib/run_log.mjs +94 -0
  110. package/lib/runtime-environment.mjs +87 -0
  111. package/lib/runtime_dependencies.mjs +15 -0
  112. package/lib/runtime_node.mjs +107 -0
  113. package/lib/runtime_state.mjs +174 -0
  114. package/lib/sources/index.mjs +27 -0
  115. package/lib/sources/prd.mjs +50 -0
  116. package/lib/start_contract.mjs +37 -0
  117. package/lib/state.mjs +246 -0
  118. package/lib/task_allowance.mjs +31 -0
  119. package/lib/task_completion.mjs +61 -0
  120. package/lib/task_contract.mjs +450 -0
  121. package/lib/task_delivery.mjs +219 -0
  122. package/lib/task_delivery_cancel.mjs +34 -0
  123. package/lib/task_delivery_contract.mjs +61 -0
  124. package/lib/task_delivery_mcp.mjs +40 -0
  125. package/lib/task_delivery_store.mjs +43 -0
  126. package/lib/task_delivery_view.mjs +88 -0
  127. package/lib/task_environment.mjs +281 -0
  128. package/lib/task_prepare.mjs +224 -0
  129. package/lib/task_store.mjs +38 -0
  130. package/lib/tasks.mjs +513 -0
  131. package/lib/tick.mjs +1654 -0
  132. package/lib/time.mjs +36 -0
  133. package/lib/tracker/assets.mjs +43 -0
  134. package/lib/tracker/pages.mjs +320 -0
  135. package/lib/tracker/runs.mjs +102 -0
  136. package/lib/tracker/stream.mjs +74 -0
  137. package/lib/tracker_process.mjs +107 -0
  138. package/lib/verification_sandbox.mjs +181 -0
  139. package/node_modules/smol-toml/LICENSE +24 -0
  140. package/node_modules/smol-toml/README.md +605 -0
  141. package/node_modules/smol-toml/dist/date.d.ts +41 -0
  142. package/node_modules/smol-toml/dist/date.js +156 -0
  143. package/node_modules/smol-toml/dist/error.d.ts +37 -0
  144. package/node_modules/smol-toml/dist/error.js +67 -0
  145. package/node_modules/smol-toml/dist/extract.js +289 -0
  146. package/node_modules/smol-toml/dist/index.cjs +770 -0
  147. package/node_modules/smol-toml/dist/index.d.ts +44 -0
  148. package/node_modules/smol-toml/dist/index.js +34 -0
  149. package/node_modules/smol-toml/dist/parse.d.ts +73 -0
  150. package/node_modules/smol-toml/dist/parse.js +164 -0
  151. package/node_modules/smol-toml/dist/primitive.js +151 -0
  152. package/node_modules/smol-toml/dist/stringify.d.ts +32 -0
  153. package/node_modules/smol-toml/dist/stringify.js +219 -0
  154. package/node_modules/smol-toml/dist/struct.js +156 -0
  155. package/node_modules/smol-toml/dist/util.d.ts +43 -0
  156. package/node_modules/smol-toml/dist/util.js +57 -0
  157. package/node_modules/smol-toml/package.json +68 -0
  158. package/npm-shrinkwrap.json +1823 -0
  159. package/package.json +105 -4
  160. package/templates/claude-settings.json +18 -0
  161. package/templates/passport.gallivant.json +83 -0
  162. package/templates/plugin/bundled-runtime.json +26 -0
  163. package/templates/plugin/schemas/mcp.schema.json +120 -0
  164. package/templates/plugin/schemas/plugin.schema.json +65 -0
  165. package/templates/plugin/task-delivery.SKILL.md +60 -0
  166. package/templates/prd.md +32 -0
  167. package/templates/tracker/tracker.css +301 -0
  168. package/templates/tracker/tracker.js +721 -0
@@ -0,0 +1,249 @@
1
+ // `gallivant register <pr-url>`: put a hand-made PR, or one opened from a terminal session, into the loop.
2
+ // On an entry that already exists it is the reset: reason cleared, iteration zero, a reset comment posted so the
3
+ // count recovered from GitHub agrees, watching again.
4
+ import { resolve } from "node:path";
5
+ import { newEntry, entryId, STATES } from "../state.mjs";
6
+ import { worktreePath, isOurBranch, prdIdFromBranch } from "../git.mjs";
7
+ import { parseBlock, normalizePublicBody } from "../block.mjs";
8
+ import { repoFromUrl, remoteMatches, RESET_MARKER } from "../gh.mjs";
9
+ import { receiptHash } from "../deliverable.mjs";
10
+ import { rememberRepo, defaultHarness } from "../config.mjs";
11
+ import { isSessionFor, capabilitiesFor } from "../harness.mjs";
12
+ import { executionEpoch, reserveExecutionEpoch } from "../execution.mjs";
13
+ import { acquireRuntimeOwner } from "../daemon.mjs";
14
+ import { ensurePrdSnapshot, toPrdId } from "../prd.mjs";
15
+ import { nowIso, floorSecond } from "../time.mjs";
16
+
17
+ /** The PRD id for a hand-made branch: our own convention when the branch follows it, else a slug of the name. */
18
+ const prdIdFor = branch => prdIdFromBranch(branch) ?? toPrdId(branch);
19
+
20
+ export async function register(ctx, { url, harness = null, session = null, worktree = null }) {
21
+ const { config, state, gh, git, log } = ctx;
22
+ const publicSince = floorSecond(nowIso());
23
+ // Resolved against the directory `register` runs in, before anything reads it. The registry is read back by a
24
+ // daemon whose working directory is its own, so a relative path stored as typed would point somewhere else there:
25
+ // the harness would run in the wrong tree, or `ensureWorktree` would clone over it. Absolute from here on.
26
+ if (worktree) worktree = resolve(worktree);
27
+ const parsed = repoFromUrl(url);
28
+ if (!parsed) throw new Error(`not a GitHub PR URL: ${url}`);
29
+ const pr = await gh.prView(parsed.repo, parsed.number);
30
+ // The repo as GitHub spells it, so an oddly cased URL never yields a second entry for the same PR.
31
+ const repo = repoFromUrl(pr.url)?.repo || parsed.repo;
32
+ if (pr.isCrossRepository) throw new Error("fork PRs are not registered");
33
+ if (pr.state !== "OPEN") throw new Error(`PR is ${pr.state}`);
34
+
35
+ const { block } = parseBlock(pr.body);
36
+ const prdId = prdIdFor(pr.headRefName);
37
+ const found = state.findByPr(repo, pr.number);
38
+ const id = found ? found[0] : entryId(repo, prdId);
39
+ const existing = found ? found[1] : state.get(id);
40
+ state.assertCanRegister(id);
41
+ if (existing && existing.pr !== pr.number && existing.pr !== null) throw new Error(`${id} already watches PR #${existing.pr}`);
42
+ const chosenHarness = harness || existing?.harness || block?.harness || defaultHarness(config);
43
+ if (!config.harness[chosenHarness]) throw new Error(`harness "${chosenHarness}" is not configured`);
44
+ const switching = existing && chosenHarness !== existing.harness;
45
+ // A daemon holds this lock for its entire lifetime. Claim it through the
46
+ // GitHub reset and registry write so an old-provider tick cannot dispatch
47
+ // after the settlement check, nor can a daemon boot midway through it.
48
+ const owner = switching ? (ctx.acquireRuntimeOwner ?? acquireRuntimeOwner)(ctx.home) : null;
49
+ let fenced = false, remoteMayBeNew = false, reservationAttempted = false;
50
+ try {
51
+ if (switching && JSON.stringify(state.get(id)) !== JSON.stringify(existing)) {
52
+ throw new Error("the watched entry changed while stopping the coordinator; inspect its latest run and retry the harness reset");
53
+ }
54
+ let nextEpoch = existing?.execution_epoch ?? 0;
55
+ if (switching) {
56
+ if (existing.handoff_pending && (existing.dispatch_stopped !== "harness-handoff-pending" || existing.handoff_pending.to !== chosenHarness || existing.handoff_pending.pr !== pr.number)) {
57
+ throw new Error("another provider handoff is pending; inspect and finish that reset before choosing a different harness");
58
+ }
59
+ if (existing.task || capabilitiesFor(existing.harness).transport !== "cli" || capabilitiesFor(chosenHarness).transport !== "cli") throw new Error("changing harness on an owned task or remote execution requires an explicit provider handoff; nothing was registered");
60
+ if (session) throw new Error("a cross-harness reset requires a fresh provider session; --session cannot prove ownership in the new execution ledger");
61
+ if (existing.last?.run && existing.last.exit == null) throw new Error("the previous run is not settled; wait for its outcome before changing harness");
62
+ const previous = ctx.executionFor?.(id, existing.harness);
63
+ const ledger = previous?.state?.();
64
+ const job = previous?.current?.();
65
+ if (job?.running || ledger?.stopped || ledger?.current && (ledger.current.id !== existing.last?.run || ledger.current.exit == null || ledger.current.execution?.collected !== true)) {
66
+ throw new Error("the previous provider still owns unsettled execution; reconcile it before changing harness");
67
+ }
68
+ const root = previous?.root;
69
+ nextEpoch = root ? executionEpoch({ root, taskId: id, entry: existing, provider: existing.harness, account: previous.account, runner: ctx.runnerFor?.(id) }) + 1 : nextEpoch + 1;
70
+ }
71
+ if (session && !worktree && !existing?.worktree) throw new Error("--session needs --worktree: both harnesses key sessions by the directory they ran in");
72
+ // The same rule the block is held to, so the flag and the block agree on what a session is. A value typed here
73
+ // reaches argv position 2 on the next fix round, right after `--resume` or `--name`, where `assertSafeArgv` matches
74
+ // flag spellings and not values in unexpected positions: `--session '--approval-mode=yolo'` was accepted, and
75
+ // goose derives its session FILE from the name, so `--session ../../../etc/passwd` chose where that file went.
76
+ if (session && !isSessionFor(chosenHarness, session)) throw new Error(`--session "${String(session).slice(0, 60)}" is not a ${chosenHarness} session id; nothing was registered`);
77
+
78
+ if (worktree) await assertCheckout(git, worktree, repo, pr.headRefName);
79
+ const dir = worktree || existing?.worktree || await git.fromBranch({ nameWithOwner: repo, prdId, branch: pr.headRefName, path: worktreePath(config, repo, prdId) });
80
+ // The final harness and session pair, checked as a PAIR. `--session` is validated against the chosen harness above,
81
+ // but an INHERITED session never was: `--harness codex` with no `--session` carried the old harness's id straight
82
+ // through, so changing claude to opencode kept a UUID where opencode needs `ses_...`. The next fix round then resumed
83
+ // an id its harness cannot mean, and the block written with it was refused by `parseBlock` on the way back, which
84
+ // kills the recovery the block exists for.
85
+ //
86
+ // Cleared rather than refused. A session id from another harness is not a mistake the operator made, it is a value
87
+ // that has no meaning under the new harness: opencode cannot resume claude's conversation whatever it is called. So
88
+ // the honest result is no session, which is what a fresh registration has, and the next fix round starts one. What it
89
+ // must not be is silent, because the operator loses a conversation they may have wanted: the drop is logged with both
90
+ // harnesses and the id, and `register` returns the session it actually stored so the caller never reports a resume it
91
+ // did not keep. An explicit `--session` is a different case and still refuses above, because an operator who typed an
92
+ // id meant that id.
93
+ let chosenSession = session || existing?.session || block?.session || null;
94
+ const from = existing?.session === chosenSession ? existing.harness : block?.harness;
95
+ if (chosenSession && !session && (from !== chosenHarness || !isSessionFor(chosenHarness, chosenSession))) {
96
+ log.warn(`${id}: session ${chosenSession} is not a ${chosenHarness} session id${from && from !== chosenHarness ? ` (it is ${from}'s)` : ""}; dropped, and the next fix round will start a fresh ${chosenHarness} session`);
97
+ chosenSession = null;
98
+ }
99
+
100
+ // The block records the branch as it is; a hand-made branch keeps its name, and recovery only trusts our own.
101
+ // It is written when there is none, and rewritten when `--harness` or `--session` overrode what it records. The
102
+ // invariant is that the block and the entry never disagree about identity: reconciliation rebuilds an entry from
103
+ // the block alone after `state.json` is lost, so a block left saying the old harness or the old session would
104
+ // restore the wrong one. The PRD path in an existing block is kept; only identity is being corrected here.
105
+ const blockPrd = block?.prd || existing?.prd || (isOurBranch(pr.headRefName) ? `docs/prds/${prdId}.md` : null);
106
+ const identity = { prd: blockPrd, harness: chosenHarness, session: chosenSession, branch: pr.headRefName };
107
+ const writesRecovery = isOurBranch(pr.headRefName) && blockPrd !== null;
108
+ const stale = writesRecovery && block && (block.harness !== chosenHarness || block.session !== chosenSession || normalizePublicBody(pr.body, { identity, taskId: id }) !== pr.body);
109
+ remoteMayBeNew = switching && block?.harness === chosenHarness && block?.session === chosenSession;
110
+
111
+ // Two durable GitHub writes, and the ORDER between them is the whole of this. Both are preconditions of the local
112
+ // reset, so either failing throws and leaves the registry untouched; what the order decides is what GitHub is left
113
+ // saying when the SECOND one fails.
114
+ //
115
+ // The body write is the point of no return, so it goes last. It changes the recovery identity: `reconcile` rebuilds
116
+ // an entry from the block alone once `state.json` is lost, reading the harness and the session out of it. With the
117
+ // body written first and the comment then failing, `register` reported that nothing was reset and left the registry
118
+ // on the OLD identity while GitHub already recorded the new one, so a running daemon and a later recovery resumed
119
+ // different harnesses or different sessions for the same PR. That is the one outcome no retry recovers from, because
120
+ // neither side is wrong on its own terms and nothing compares them.
121
+ //
122
+ // The comment is safely repeatable, which is what qualifies it to go first. `countRounds` takes the LAST reset
123
+ // marker among the daemon's own comments and counts the round comments after it (`events.mjs:208`), so a second
124
+ // marker counts exactly as one does. A failed body write therefore leaves a reset comment on the PR, the registry
125
+ // untouched, and an identity that still agrees with itself on both sides; running the same command again completes
126
+ // the reset and the extra comment changes no count.
127
+ //
128
+ // Throwing is the choice for both, where the tick's equivalent has to leave a retryable state instead: `register` is
129
+ // one interactive command with an operator in front of it, and it already refuses for other preconditions (a fork
130
+ // PR, a closed PR, an unconfigured harness, a mistyped `--worktree`).
131
+
132
+ // The reset comment IS the reset boundary. `countRounds` finds it among the daemon's own comments and counts the
133
+ // round comments after it; with the comment missing, every round comment of every earlier round is still in front of
134
+ // it, so a recovery after `state.json` is lost rebuilds the iteration this command had just zeroed. An entry restored
135
+ // at `max_iterations` then stops with `reason: "iterations"` on its next event, having done no work in this round at
136
+ // all, and the operator is told the budget is spent on a PR they had just reset.
137
+ if (switching) {
138
+ state.put(id, { ...existing, state: STATES.needsHuman, reason: "harness-handoff-pending",
139
+ dispatch_stopped: "harness-handoff-pending",
140
+ handoff_pending: { from: existing.harness, to: chosenHarness, epoch: nextEpoch, pr: pr.number, at: nowIso() } });
141
+ fenced = true;
142
+ }
143
+ if (existing) {
144
+ try { await gh.comment(repo, pr.number, `Watching again from here.\n\n${RESET_MARKER}`); }
145
+ catch (e) { throw new Error(`cannot post the reset comment on ${pr.url}: ${e.message}. Nothing was reset; the entry keeps its ${existing.iteration} round${existing.iteration === 1 ? "" : "s"} and its state, and its recorded harness and session are unchanged. Fix the cause and run register again.`); }
146
+ }
147
+ // The identity of record, written last of the two and still before anything reaches the registry, so a failure here
148
+ // leaves GitHub and the registry agreeing on the OLD identity rather than disagreeing.
149
+ if (writesRecovery && (!block || stale)) {
150
+ if (switching) remoteMayBeNew = true; // A failed reply cannot prove that the body write did not land.
151
+ try { const updated = await gh.setRecoveryMetadata(repo, pr.number, identity, { taskId: id }); pr.body = updated.body; }
152
+ // The message names the identity that survived, and only claims the PR agrees when the PR actually had a block to
153
+ // agree with: an existing entry on a PR with no block at all is a case where the registry has an identity and
154
+ // GitHub has none, which is what this write was about to fix and is not the same as the two of them agreeing.
155
+ catch (e) { throw new Error(`cannot write the gallivant block to ${pr.url}: ${e.message}. Nothing was registered${existing ? `: the entry keeps harness ${existing.harness} and session ${existing.session ?? "none"}${!switching ? block ? ", which is still what the PR records" : ", and the PR still records no identity at all" : ""}` : ""}.${switching ? " The old entry remains fenced because the remote write may have landed." : ""} Fix the cause and run register again.`); }
156
+ if (stale) log.info(`${id}: PR block updated to harness ${chosenHarness}, session ${chosenSession ?? "none"}`);
157
+ }
158
+
159
+ if (switching && ctx.executionFor) {
160
+ const previous = ctx.executionFor(id, existing.harness);
161
+ reservationAttempted = true;
162
+ (ctx.reserveExecutionEpoch ?? reserveExecutionEpoch)({ root: previous.root, taskId: id, provider: chosenHarness,
163
+ account: config.harness[chosenHarness]?.account || "operator", epoch: nextEpoch, pr: pr.number });
164
+ }
165
+
166
+ // `base` on the entry is the branch this PR targets, and it comes from the PR itself. A registered PR is one a person
167
+ // opened, so the operator's config has no say in where it was aimed: `baseRefName` is the truth, and it is what the
168
+ // daemon merges in before every later round. An existing entry keeps whatever it had unless the PR says otherwise,
169
+ // which is how a retargeted PR is picked up on the next register.
170
+ const prBase = typeof pr.baseRefName === "string" && pr.baseRefName ? pr.baseRefName : null;
171
+ const base = existing || newEntry({ prd: blockPrd, harness: chosenHarness, repo, branch: pr.headRefName, worktree: dir, state: STATES.prOpen, nowIso: nowIso(), base: prBase });
172
+ // A fresh registration sees the PR's whole history, so feedback already on it gets addressed; a reset starts now.
173
+ // A reset keeps `last.run` and its exit so the tick never re-settles a run that already ended.
174
+ //
175
+ // Floored to the second. GitHub stamps comments and reviews to the second while `nowIso()` carries milliseconds,
176
+ // and the cursor comparison is inclusive of its own boundary, so a cursor of 02:00:00.500Z would sit after every
177
+ // piece of feedback written in that second and lose it for good.
178
+ const since = floorSecond(existing ? nowIso() : pr.createdAt);
179
+ const entry = {
180
+ ...base, public_since: existing?.public_since || publicSince, harness: chosenHarness, session: chosenSession, worktree: dir, pr: pr.number, url: pr.url, base: prBase ?? base.base ?? null,
181
+ ...(switching ? { execution_epoch: nextEpoch, execution_handoff: { from: existing.harness, to: chosenHarness, previousRun: existing.last?.run ?? null, at: nowIso() },
182
+ dispatch_stopped: null, handoff_pending: null } : {}),
183
+ // `retry` goes with `reason`: it counts the consecutive failures of one GitHub write the tick declines a step for,
184
+ // and a reset is the operator saying the cause is dealt with. Left behind, an entry reset after a `stuck` stop would
185
+ // carry four failures into its next one and stop again on the first write that failed.
186
+ //
187
+ // `round_comment` goes with `iteration`, for the same reason and one more. It is the comment a counted round still
188
+ // owes the PR, and the reset zeroes the count, so a debt carried across it would announce a round the registry no
189
+ // longer claims: the daemon would post "round 0" and `why` would report a recovery counting minus one. The reset
190
+ // comment posted above is the new boundary anyway, so every marker before it is already outside the count and one
191
+ // more would change nothing. `labels_pending` goes with them, though it is inert rather than wrong: it is only read
192
+ // on a `needs-human` entry and `needsHuman` recomputes it on the next stop, so a stale true never acts. It is
193
+ // cleared anyway, because a reset entry carrying a debt from before the reset is a thing a reader has to reason
194
+ // about, and there is no reading of it that should survive.
195
+ //
196
+ // `prd_divergence` goes with `reason` for the same reason and is inert in the same way. It is the difference between
197
+ // the brief the run started against and the operator's own copy, recorded when the tick stopped the entry for it, and
198
+ // every reader of it (`status`, `why`, the tracker) recomputes the comparison live rather than trusting the field. So
199
+ // a stale one never decides anything. It is cleared because a reset entry carrying the record of a stop it no longer
200
+ // has is a thing a reader has to reason about. What is deliberately NOT cleared is `checkout` and `prd_fingerprint`:
201
+ // those say where the operator's copy lives and what contract the snapshot carries, which a reset does not change,
202
+ // and dropping them would silently turn the comparison off for a PR the operator has just asked to watch again.
203
+ state: STATES.prOpen, reason: null, retry: null, network_retry: null, iteration: 0, round_comment: null, public_fix: null, labels_pending: false, prd_divergence: null,
204
+ // `checks_sha` is null, and `up.mjs`'s `reconcile` now agrees rather than setting it to the recovered head. It names
205
+ // the head whose failing checks were already dispatched, and on a PR being taken over no head qualifies: a fix that
206
+ // had been dispatched and had pushed would have moved the head, so red checks on the head that is actually there
207
+ // have not been addressed by anything, and the operator running this command is asking for them to be.
208
+ cursor: { since, dispatched: [], fixed_sha: pr.headRefOid, checks_sha: null, receipt_hash: receiptHash(pr.body) },
209
+ rereview: { at: since, nudged: false },
210
+ last: existing ? { ...(existing.last || {}), at: nowIso(), event: "reset by register" } : null,
211
+ updated_at: nowIso(),
212
+ };
213
+ state.put(id, entry);
214
+ fenced = false;
215
+ // The snapshot the completion gate reads, taken the same way `reconcile` takes it. Without it `prdFor` returns
216
+ // nothing, the gate logs "no PRD snapshot" and the PR can be marked ready with no acceptance criterion evaluated.
217
+ // After the entry is stored, so a GitHub read that fails leaves a watched PR rather than an aborted registration.
218
+ // A snapshot already on disk is kept, so a reset never replaces the authoritative copy with the one the session
219
+ // could have edited.
220
+ await ensurePrdSnapshot(ctx, { id, repo, path: blockPrd, ref: pr.headRefName });
221
+ if (rememberRepo(repo, config.home)) await gh.ensureLabels(repo).catch(e => log.warn(`${repo}: labels not created: ${e.message}`));
222
+ log.info(`${id}: ${existing ? "reset and " : ""}watching ${pr.url} with ${chosenHarness}${chosenSession ? " (resume)" : " (fresh session on first fix)"}`);
223
+ return { id, pr: pr.number, worktree: dir, harness: chosenHarness, session: chosenSession };
224
+ } catch (error) {
225
+ // Before any possible identity write or ledger reservation, a failed reset
226
+ // may restore the exact prior entry. Once either may have landed, retain
227
+ // the dispatch fence so a restarted daemon cannot launch the old harness.
228
+ if (fenced && !remoteMayBeNew && !reservationAttempted) state.put(id, existing);
229
+ throw error;
230
+ } finally { owner?.release(); }
231
+ }
232
+
233
+ /**
234
+ * A worktree passed on the command line is stored as given and the harness then edits and pushes from it, so it is
235
+ * checked before it is accepted: same repository on `origin`, already on the PR's head branch. A mistyped or stale
236
+ * path is an error rather than a second repository quietly taking the session's commits. It is never relocated or
237
+ * re-cloned, because a resumed session is keyed to the directory it ran in.
238
+ */
239
+ async function assertCheckout(git, dir, repo, branch) {
240
+ if (!git.exists(dir)) throw new Error(`--worktree ${dir} is not a git checkout`);
241
+ let origin;
242
+ try { origin = await git.originOf(dir); }
243
+ catch (e) { throw new Error(`--worktree ${dir} has no origin remote: ${e.message}`); }
244
+ if (!remoteMatches(origin, repo)) throw new Error(`--worktree ${dir} is a clone of ${origin}, not ${repo}`);
245
+ let current;
246
+ try { current = await git.currentBranch(dir); }
247
+ catch (e) { throw new Error(`--worktree ${dir}: cannot read the checked-out branch: ${e.message}`); }
248
+ if (current !== branch) throw new Error(`--worktree ${dir} is on ${current}, not the PR's branch ${branch}; check it out there first`);
249
+ }
@@ -0,0 +1,224 @@
1
+ // Resume a stopped session after validating its contract and preserving any unsettled run.
2
+ import { NETWORK_RECOVERY_WINDOW } from "../network_recovery.mjs";
3
+ import { learningDelivered, learningDeliveryPatch } from "../learning_input.mjs";
4
+ import { resolveHandle, handleFor, STATES } from "../state.mjs";
5
+ import { readRepositoryText, writeRepositoryText, repositoryPath } from "../repository_files.mjs";
6
+ import { makeTick } from "../tick.mjs";
7
+ import { adapterFor, capabilitiesFor } from "../harness.mjs";
8
+ import { resumePrompt, startPrompt } from "../prompt.mjs";
9
+ import { verifyCommands, parsePrd, prdDivergence, sourceDivergence, sourceDiff, sourcePathsOf, divergenceReason, renderDivergence, resolvePrdSnapshot, retakePrdSnapshot } from "../prd.mjs";
10
+ import { criteriaFromPrd, receiptInstructions } from "../deliverable.mjs";
11
+ import { nowIso, floorSecond } from "../time.mjs";
12
+ import { requireHarness } from "../probe.mjs";
13
+ import { executionRunner, unsubmittedInitialDispatch } from "../execution.mjs";
14
+ import { assertAgentsCheckout } from "../openai_agents_sources.mjs";
15
+
16
+ /** States worth resuming. A `done` entry is merged or closed, and a `running` one already has a live run. */
17
+ const RESUMABLE = new Set([STATES.needsHuman, STATES.prOpen, STATES.ready]);
18
+
19
+ export async function resume(ctx, { handle, note = null, resnapshot = false, freshSession = false, automaticNetworkRecovery = false }) {
20
+ const { state, git, log, readPrd } = ctx;
21
+ const id = resolveHandle(state.entries(), handle);
22
+ let entry = state.get(id);
23
+ if (entry.dispatch_stopped) throw new Error(`dispatch stopped: ${entry.dispatch_stopped}; cancellation and late results must be reconciled before any new task`);
24
+
25
+ if (!RESUMABLE.has(entry.state)) throw new Error(`${handleFor(id, entry)} is ${entry.state}; resume takes a stopped or watched entry`);
26
+ const runner = executionRunner(ctx, id);
27
+ const initialDispatch = !entry.session ? unsubmittedInitialDispatch(entry, runner) : null;
28
+ if (!entry.session && !initialDispatch) throw new Error(`${handleFor(id, entry)} has no session to resume and no proven unsubmitted initial launch. Inspect its start/execution ownership before retrying; remove the entry only if the work is no longer wanted.`);
29
+ if (initialDispatch && !readPrd(id)) throw new Error("unsubmitted initial launch requires its saved approved brief; restore it before resume");
30
+
31
+ const divergence = prdDivergence({ id, entry, readPrd });
32
+ const divReason = divergenceReason(divergence);
33
+ let briefChanged = [...(entry.brief_changes ?? [])];
34
+ if (divReason) {
35
+ if (divergence.verdict !== "changed" || !resnapshot) {
36
+ throw new Error([
37
+ `${handleFor(id, entry)}: ${divergence.why}`,
38
+ renderDivergence(divergence),
39
+ divergence.verdict === "changed"
40
+ ? `Nothing was resumed. The session holds the brief this run started against, so resuming would put it back to work on one contract while the gate judges another.\nDecide: rerun with --resnapshot to make the edited brief the brief (the session is told what changed), or put the brief back the way it was.`
41
+ : divergence.verdict === "illegal"
42
+ ? `Nothing was resumed. ${divergence.path} would be refused by \`gallivant start\` as it stands, so it cannot become the brief the gate judges. Fix it and \`gallivant ready\` it again.`
43
+ : `Nothing was resumed. Restore the file, or \`gallivant remove ${handleFor(id, entry)}\` if the work is no longer wanted.`,
44
+ ].filter(Boolean).join("\n"));
45
+ }
46
+ }
47
+
48
+ if (resnapshot && divergence.repo && divergence.repo.harness !== entry.harness) {
49
+ throw new Error(`${handleFor(id, entry)}: cannot change harness from ${entry.harness} to ${divergence.repo.harness} with --resnapshot; the recorded session belongs to ${entry.harness}. Restore the harness or start a new entry.`);
50
+ }
51
+ const sources = sourceDivergence({ id, entry, readSource: ctx.readSource });
52
+ const sourceChanges = sourceDiff(sources);
53
+ if (sourceChanges.length && !resnapshot) {
54
+ throw new Error(`${handleFor(id, entry)}: carried files differ from the snapshot:\n${sourceChanges.join("\n")}\nNothing was resumed. Restore the files or use --resnapshot to accept the edited sources; remove deleted files from the brief's sources list first.`);
55
+ }
56
+ if (resnapshot && sourceChanges.length && !divergence.repo) throw new Error("cannot resnapshot carried files without the operator's brief; restore the brief first");
57
+ const acceptedSources = resnapshot && divergence.repo ? sourcePathsOf(divergence.repo) : null;
58
+ const sourceTexts = new Map();
59
+ for (const rel of acceptedSources ?? []) {
60
+ const text = readRepositoryText(entry.checkout, rel);
61
+ if (text.includes("\u0000")) throw new Error(`${rel} is not text`);
62
+ sourceTexts.set(rel, text);
63
+ }
64
+
65
+ if (freshSession && (!entry.session || !entry.learning?.rules?.length && !entry.learning_revocation?.freshSessionRequired)) throw new Error("fresh-session requires recorded learned context or its revocation; no task state was reset");
66
+ if (freshSession && !readPrd(id)) throw new Error("fresh-session requires the saved approved brief for continuity");
67
+ const current = runner.current();
68
+ if (current?.running) throw new Error(`${handleFor(id, entry)} already has a run in flight (${current.id}); wait for it or stop it first`);
69
+ if (entry.last?.run && entry.last.exit == null) {
70
+ throw new Error(`${handleFor(id, entry)}: run ${entry.last.run} still needs settling; wait for the daemon (gallivant up) before resuming`);
71
+ }
72
+
73
+ // Manual resume does not pass through the daemon's probes. Refuse before recreating or merging the checkout,
74
+ // or accepting a changed brief, so upgrading and rerunning resume preserves the stopped run exactly as it was.
75
+ if (runner.capabilities?.transport === "remote") {
76
+ if (!runner.capabilities.available) throw new Error(`execution provider unavailable: ${runner.capabilities.reason}`);
77
+ }
78
+ await requireHarness(entry.harness, ctx.config);
79
+
80
+ const recreated = !git.exists(entry.worktree);
81
+ if (recreated) {
82
+ log.info(`${id}: recreating the checkout at ${entry.worktree}`);
83
+ await git.fromBranch({ nameWithOwner: entry.repo, prdId: id.split("/").pop(), branch: entry.branch, path: entry.worktree });
84
+ }
85
+
86
+ const tick = makeTick({ ...ctx, ownLogin: ctx.ownLogin ?? "resume" });
87
+
88
+ // A correction must be able to recover unfinished work, including operator-carried
89
+ // files. Leave the index and tree intact; only the automatic base merge is deferred.
90
+ const tree = git.dirty ? await git.dirty(entry.worktree, { strict: true }) : null;
91
+ const uncommitted = tree && (tree.modified || tree.untracked) ? tree : null;
92
+ if (capabilitiesFor(entry.harness).transport === "remote" && uncommitted) {
93
+ await assertAgentsCheckout({ worktree: entry.worktree, overlays: [entry.prd, ...(acceptedSources ?? entry.sources ?? [])].filter(Boolean) });
94
+ }
95
+ const copyingBrief = initialDispatch || divReason || recreated || briefChanged.length || sourceChanges.length;
96
+ const sourcesToCopy = acceptedSources ?? ((initialDispatch || recreated || briefChanged.length) ? entry.sources ?? [] : []);
97
+ if (copyingBrief || sourcesToCopy.length) {
98
+ // Explicitly accepting a revised contract may replace its previous snapshot,
99
+ // but must never overwrite session edits to that file. Unrelated unfinished
100
+ // task files and unchanged carried instructions do not block the correction.
101
+ const replacements = sourcesToCopy.map(rel => [rel, ctx.readSource(id, rel), sourceTexts.get(rel) ?? ctx.readSource(id, rel)]);
102
+ if (entry.prd && (divReason || recreated || briefChanged.length)) replacements.push([entry.prd, readPrd(id), divReason ? divergence.repoText : readPrd(id)]);
103
+ for (const [rel, before, after] of replacements) {
104
+ let current = null;
105
+ try { current = readRepositoryText(entry.worktree, rel); }
106
+ catch (error) { if (error.code !== "ENOENT") throw error; }
107
+ // Ignored files do not appear in Git status. Compare their bytes as well;
108
+ // absent files in a clean/recreated checkout are safe to restore.
109
+ if ((current !== null || uncommitted && !initialDispatch) && current !== before && current !== after) {
110
+ throw new Error(`Cannot resnapshot ${rel}: local edits differ from both the previous snapshot and the accepted replacement. Preserve those edits before retrying; no files or snapshots were replaced.`);
111
+ }
112
+ }
113
+ }
114
+
115
+ // A failed fetch or merge must leave the accepted contract unchanged.
116
+ const merged = uncommitted ? null : await tick.mergeBaseInto(id, entry);
117
+ if (merged?.conflict) {
118
+ throw new Error([
119
+ `${handleFor(id, entry)}: ${entry.branch} cannot be merged with origin/${merged.base}: ${merged.why}`,
120
+ "Nothing was resumed, and the checkout was left clean: the merge was aborted.",
121
+ `Resolve it in ${entry.worktree} (\`git merge origin/${merged.base}\`, fix the conflicts, commit, push), then resume again.`,
122
+ ].join("\n"));
123
+ }
124
+ if (merged?.merged) entry = state.get(id) ?? entry;
125
+
126
+ await tick.reopen(id, entry);
127
+ if (copyingBrief && entry.prd) repositoryPath(entry.worktree, entry.prd, { createParents: true });
128
+ for (const rel of sourcesToCopy) repositoryPath(entry.worktree, rel, { createParents: true });
129
+ briefChanged = [...new Set([...briefChanged, ...(divReason ? divergence.diff : []), ...sourceChanges])];
130
+ // Persist the notice before snapshots move, so a failed write or spawn cannot silently lose it.
131
+ if (briefChanged.length) state.update(id, { brief_changes: briefChanged });
132
+
133
+ // Source writes must succeed before accepting a new manifest in the authoritative PRD.
134
+ if (acceptedSources) for (const [rel, text] of sourceTexts) ctx.writeSource(id, rel, text);
135
+
136
+ if (divReason) {
137
+ resolvePrdSnapshot(ctx, { id, entry, divergence, by: "gallivant resume --resnapshot" });
138
+ entry = state.get(id);
139
+ } else if (divergence.verdict === "same" && divergence.repoText !== undefined && divergence.repoText !== readPrd(id)) {
140
+ retakePrdSnapshot(ctx, { id, divergence });
141
+ log.info(`${id}: the brief was edited outside its contract; snapshot re-taken from ${divergence.path} before resuming`);
142
+ entry = state.get(id);
143
+ }
144
+
145
+ if (acceptedSources) {
146
+ entry = state.update(id, { sources: acceptedSources });
147
+ }
148
+
149
+ const text = readPrd(id);
150
+ const parsed = text ? parsePrd(text, `${id}.md`) : null;
151
+
152
+ if ((initialDispatch || briefChanged.length || recreated) && entry.prd && text) writeRepositoryText(entry.worktree, entry.prd, text);
153
+ for (const rel of sourcesToCopy) {
154
+ const source = ctx.readSource(id, rel);
155
+ if (source == null) throw new Error(`${rel}: carried-file snapshot is missing; restore it before resuming`);
156
+ writeRepositoryText(entry.worktree, rel, source);
157
+ }
158
+
159
+ const unpushed = git.unpushed ? await git.unpushed(entry.worktree, entry.branch, entry.base ?? null) : [];
160
+ if (unpushed.length) log.info(`${id}: ${unpushed.length} unpushed commit${unpushed.length === 1 ? "" : "s"} on ${entry.branch}; the session is told to preserve and review them before pushing`);
161
+
162
+ let prompt = initialDispatch ? [startPrompt({ prd: parsed, repo: entry.repo, branch: entry.branch, base: entry.base, verify: verifyCommands(parsed), receipt: receiptInstructions(criteriaFromPrd(parsed), id), carried: entry.sources ?? [] }),
163
+ "This is the first provider implementation; the previous launch was refused before submission. Work from the supplied current source revision and preserve its commits.", ...(note ? [`Operator clarification: ${note}`] : [])].join("\n\n") : resumePrompt({
164
+ repo: entry.repo,
165
+ pr: entry.pr,
166
+ base: entry.base,
167
+ branch: entry.branch,
168
+ reason: entry.reason,
169
+ note,
170
+ verify: parsed ? verifyCommands(parsed) : [],
171
+ receipt: parsed ? receiptInstructions(criteriaFromPrd(parsed), id) : null,
172
+ briefChanged,
173
+ unpushed,
174
+ merged: merged?.merged ? merged : null,
175
+ uncommitted,
176
+ carried: entry.sources ?? [],
177
+ });
178
+
179
+ if (freshSession) {
180
+ if (runner.capabilities && !runner.freshLearningSession) throw new Error("execution provider cannot establish fresh learning continuity");
181
+ const transition = entry.learning_continuity?.status === "prepared" ? entry.learning_continuity : {
182
+ status: "prepared", previousSession: entry.session, previousRun: entry.last?.run ?? null,
183
+ newSession: runner.capabilities?.transport === "remote" ? null : adapterFor(entry.harness).newSession(),
184
+ };
185
+ // Durable intent is separate from task/review ownership; failures retain the
186
+ // old session and retry the same newly allocated local session.
187
+ entry = state.update(id, { learning_continuity: transition });
188
+ if (runner.freshLearningSession) await runner.freshLearningSession({ runId: transition.previousRun, sessionId: transition.previousSession });
189
+ prompt = `Continue this same logical task in a fresh provider session. Preserve the existing branch, checkout, PR, review cursors, iteration and budget. Previous provider memory is unavailable. Reread the approved brief and carried files before proceeding.\n\nApproved brief snapshot:\n${text}\n\n${prompt}`;
190
+ entry = { ...entry, session: null };
191
+ }
192
+
193
+ // The resumed process may publish and exit before the daemon's first poll. Begin public-output observation
194
+ // before spawning it, and preserve an earlier boundary so unresolved replies cannot disappear on resume.
195
+ if (!entry.public_since) {
196
+ const saved = state.update(id, { public_since: floorSecond(nowIso()) });
197
+ entry = freshSession ? { ...saved, session: null } : saved;
198
+ }
199
+ const { job, session, managed } = await tick.startRun(id, initialDispatch ? { ...entry, initial_dispatch: initialDispatch } : entry, { name: initialDispatch ? "start" : "resume", prompt,
200
+ dispatchDeadline: automaticNetworkRecovery ? Date.parse(entry.network_retry.first_at) + NETWORK_RECOVERY_WINDOW : null });
201
+
202
+ state.update(id, current => ({
203
+ ...learningDeliveryPatch(current, managed || {}, job),
204
+ ...(freshSession ? { learning_continuity: { ...entry.learning_continuity, status: "accepted", run: job.id, session }, learning_revocation: null } : {}),
205
+ state: entry.pr ? STATES.prOpen : STATES.running,
206
+ reason: null,
207
+ gate_skipped: null,
208
+ labels_pending: false,
209
+ public_fix: null,
210
+ retry: null,
211
+ network_retry: automaticNetworkRecovery ? { ...entry.network_retry, phase: "running", next_at: null } : null,
212
+ brief_changes: null,
213
+ session,
214
+ ...(initialDispatch ? { initial_dispatch: null } : {}),
215
+ ...(job.execution ? { execution: job.execution } : {}),
216
+ prd_divergence: null,
217
+ timeout: null,
218
+ last: { at: nowIso(), run: job.id, exit: null, event: `resumed after ${entry.reason ?? entry.state}${briefChanged.length ? ", brief resnapshotted" : ""}` },
219
+ updated_at: nowIso(),
220
+ }));
221
+ await learningDelivered(ctx, id);
222
+ log.info(`${id}: resumed ${entry.harness} session ${session} (run ${job.id})${briefChanged.length ? ` against the edited brief (${briefChanged.length} change${briefChanged.length === 1 ? "" : "s"})` : ""}`);
223
+ return { id, handle: handleFor(id, entry), run: job.id, session, ...(initialDispatch ? { initialLaunch: true } : {}), worktree: entry.worktree, resnapshotted: briefChanged, attach: runner.capabilities?.transport === "remote" ? null : adapterFor(entry.harness).attach({ pr: entry.pr, session }) };
224
+ }