@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
package/lib/tasks.mjs ADDED
@@ -0,0 +1,513 @@
1
+ // Versioned, in-process task API. Importing this module reads no user config,
2
+ // starts no daemon and makes no network calls. Clients supply the usual context.
3
+ import { acquireServiceMutation } from "./daemon.mjs";
4
+ import { randomBytes } from "node:crypto";
5
+ import { isDeepStrictEqual } from "node:util";
6
+ import { join, dirname, resolve } from "node:path";
7
+ import { existsSync, statSync } from "node:fs";
8
+ import { ensureDir, readText } from "./fs.mjs";
9
+ import { resolveBase } from "./config.mjs";
10
+ import { capabilitiesFor, executionCapabilities } from "./harness.mjs";
11
+ import { repositoryPath, readRepositoryText, writeRepositoryText } from "./repository_files.mjs";
12
+ import { request as prdRequest } from "./sources/prd.mjs";
13
+ import { contentDigest, captureStartInput } from "./start_contract.mjs";
14
+ import { ready } from "./commands/ready.mjs";
15
+ import { start } from "./commands/start.mjs";
16
+ import { upInBackground } from "./commands/up.mjs";
17
+ import { entryId } from "./state.mjs";
18
+ import { taskStore, taskRoot, taskError } from "./task_store.mjs";
19
+ import { redact } from "./execution_io.mjs";
20
+ import { executionRunner } from "./execution.mjs";
21
+ import { recoverControl } from "./control.mjs";
22
+ import { canonical } from "./learning_data.mjs";
23
+ import { COMPLETION_CAPABILITIES, completionPolicy, completionView } from "./task_completion.mjs";
24
+ import { ownerDelivery, ownerImplementation, assertDeliveryAnswer } from "./owner_delivery.mjs";
25
+ import { prepareTurn, assertPreparationBoundary, preparationCancellationPending, preparationPreflightRetryable, PREPARATION_PENDING } from "./task_prepare.mjs";
26
+ import { TASK_VERSION, MAX_REPAIRS, MAX_CONTEXT_BYTES, MANAGER_LOCKFILES, requireText, fragments, requireTaskCapacity, parseTaskSources, parseDraft, renderDraft, validateDraft, collectCommands, mappedQuestions } from "./task_contract.mjs";
27
+
28
+ export { TASK_VERSION } from "./task_contract.mjs";
29
+
30
+ function selectedPaths(selected) {
31
+ if (!Array.isArray(selected) || selected.some(p => typeof p !== "string")) throw taskError("TASK_INPUT", "context must be an array of selected repository file paths");
32
+ const selection = parseTaskSources(selected);
33
+ if (selection.problems.length) throw taskError("TASK_INPUT", selection.problems.join("; "));
34
+ return selection.paths;
35
+ }
36
+
37
+ function selectedContext(checkout, selected, pinned = [], secrets = null) {
38
+ const selection = selectedPaths(selected);
39
+ const paths = new Set([...pinned.map(c => c.path), ...selection]);
40
+ for (const p of ["AGENTS.md", "package.json", "pyproject.toml", "Cargo.toml", "go.mod", "Makefile"]) {
41
+ if (existsSync(join(checkout, p))) paths.add(p);
42
+ }
43
+ for (const manifest of paths) if (/(?:^|\/)package\.json$/.test(manifest)) {
44
+ for (const name of Object.keys(MANAGER_LOCKFILES)) {
45
+ const path = join(dirname(manifest), name);
46
+ if (existsSync(join(checkout, path))) paths.add(path);
47
+ }
48
+ }
49
+ for (const p of selection) {
50
+ let dir = dirname(p);
51
+ while (dir !== "." && dir !== "/") {
52
+ const instructions = join(dir, "AGENTS.md");
53
+ if (existsSync(join(checkout, instructions))) paths.add(instructions);
54
+ const parent = dirname(dir);
55
+ if (parent === dir) break;
56
+ dir = parent;
57
+ }
58
+ }
59
+ const parsed = parseTaskSources([...paths]);
60
+ if (parsed.problems.length) throw taskError("TASK_INPUT", parsed.problems.join("; "));
61
+ let bytes = 0;
62
+ return parsed.paths.map(path => {
63
+ const previous = pinned.find(c => c.path === path);
64
+ // Accepted bytes stay pinned when collecting instructions for new documents.
65
+ // Explicit reselection must match them, rather than overwrite earlier context.
66
+ const read = !previous || selection.includes(path);
67
+ if (read && statSync(repositoryPath(checkout, path)).size + bytes > MAX_CONTEXT_BYTES) throw taskError("TASK_INPUT", `selected context exceeds ${MAX_CONTEXT_BYTES} bytes; select fewer documents`);
68
+ const text = read ? readRepositoryText(checkout, path) : previous.text;
69
+ if (secrets !== null && text !== redact(text, secrets)) throw taskError("TASK_SECRET", "Selected context contains credential material; remove it before preparation");
70
+ bytes += Buffer.byteLength(text);
71
+ if (text.includes("\0") || bytes > MAX_CONTEXT_BYTES) throw taskError("TASK_INPUT", `selected context must be text under ${MAX_CONTEXT_BYTES} total bytes; select fewer documents`);
72
+ const hash = contentDigest(text);
73
+ if (previous && hash !== previous.hash) throw taskError("TASK_CONFLICT", `selected context is already pinned: ${path}; use a new task to replace accepted context`);
74
+ return { path, text, hash };
75
+ });
76
+ }
77
+
78
+ function explicitCommands(value) {
79
+ if (!Array.isArray(value)) throw taskError("TASK_INPUT", "verifyCommands must be an array of explicitly selected commands");
80
+ return value;
81
+ }
82
+ function commandCandidates(context, explicit = []) {
83
+ return collectCommands(context, explicitCommands(explicit)).commands;
84
+ }
85
+
86
+ const same = isDeepStrictEqual;
87
+ function implementationMatches(task, entry) {
88
+ return entry.task?.id === task.id && entry.task?.digest === task.readiness?.digest && same(entry.task?.completion, task.completion) && same(entry.task?.ownerDelivery, task.ownerDelivery);
89
+ }
90
+ function questionOwnership(task) {
91
+ const ownership = { ...task.questionMappings };
92
+ if (!task.output?.questions.length) return ownership;
93
+ const mappings = mappedQuestions(task.output);
94
+ for (const q of task.output.questions) {
95
+ if (!same(task.questionHistory[q.id], q)) throw new Error(`saved question ${q.id} differs from its accepted history`);
96
+ const owned = ownership[q.id];
97
+ if (owned && (new Set(owned).size !== owned.length || owned.length !== mappings[q.id].length || owned.some(id => !mappings[q.id].includes(id)))) throw new Error(`saved question ${q.id} ownership differs from its unresolved mappings`);
98
+ ownership[q.id] = mappings[q.id];
99
+ }
100
+ return ownership;
101
+ }
102
+ // Only explicit pre-effect refusals carry this proof. Unknown/provider errors
103
+ // must retain their durable delivery intent for reconciliation.
104
+ const taskRejection = (code, message) => Object.assign(taskError(code, message), { taskEffects: "none" });
105
+ const beforeTaskEffects = fn => {
106
+ try { return fn(); } catch (error) { error.taskEffects = "none"; throw error; }
107
+ };
108
+ const expectRevision = (task, expected) => {
109
+ if (!Number.isSafeInteger(expected) || expected !== task.revision) throw taskRejection("TASK_CONFLICT", `expected revision ${expected ?? "missing"}, current revision ${task.revision}; inspect the task and retry with its current revision`);
110
+ };
111
+ const mutate = (task, store) => { task.revision++; task.updatedAt = new Date().toISOString(); store.save(task); };
112
+
113
+ export function createTaskService(ctx, { prepare = prepareTurn, decideReady = ready, dispatch = start, daemon = upInBackground, store = taskStore(ctx.home), mutationOwner = null } = {}) {
114
+ const result = task => {
115
+ const entry = task.implementation && ctx.state.get(task.implementation.id);
116
+ const claim = ctx.state.startClaim(entryId(task.repo, task.id));
117
+ const candidates = collectCommands(task.context, task.explicitVerifyCommands || []);
118
+ return {
119
+ version: TASK_VERSION, taskId: task.id, revision: task.revision, status: task.status,
120
+ original: task.original, repo: task.repo, checkout: task.checkout, callerCwd: task.callerCwd || task.checkout, base: task.base, baseSource: task.baseSource, harness: task.harness,
121
+ questions: task.questions, answers: task.answers, requirements: task.requirements,
122
+ assumptions: task.output?.assumptions || [], mapping: task.output?.mapping || [], stories: task.output?.stories || [],
123
+ draft: task.draft ? { path: task.draft.path, hash: task.draft.hash, revision: task.draft.revision, current: task.draft.requirementsHash === contentDigest(JSON.stringify(task.requirements)), text: readText(task.draft.path) } : null,
124
+ readiness: task.readiness, validation: task.validation || null,
125
+ completion: completionView(task.completion, entry, task.ownerDelivery, entry ? executionRunner(ctx, task.implementation.id, entry.harness)?.current?.() : null),
126
+ errorCode: task.errorCode || null,
127
+ implementation: task.implementation ? { ...task.implementation, state: entry?.state || null, pr: entry?.pr || null, url: entry?.url || null } : null, failures: task.failures,
128
+ dispatchRecovery: claim?.clone || null,
129
+ preparation: { repairs: task.repairs, attempts: task.attempts.map(({ message: _message, ...attempt }) => attempt), allowance: task.allowance },
130
+ sources: task.context.map(c => ({ path: c.path, hash: c.hash })),
131
+ verifyCommands: candidates.commands.map(command => ({ command, sources: candidates.sources[command] || [] })), commandIssues: candidates.issues,
132
+ artifacts: task.draft ? [{ id: "brief", path: task.draft.path, hash: task.draft.hash }, ...task.output.sources.map(path => ({ id: `source:${path}`, path: join(task.draft.root, path), hash: task.context.find(c => c.path === path).hash }))] : [],
133
+ nextAction: preparationCancellationPending(task) ? "resume the owned cancellation with the current revision; no new preparation or implementation is authorized" : task.status === "needs-input" ? "answer the saved question IDs using the current revision" : task.status === "ready" ? (task.runAuthorized ? "start the approved revision" : "review the draft, then start with explicit run authorization") : task.status === "started" ? "follow the implementation through status, why, and the tracker" : task.status === "blocked" ? (preparationPreflightRetryable(task) ? "resume the current revision to retry the pre-submit provider check; no model job was submitted" : "resolve the reported failure; inspect the saved evidence before retrying") : "resume preparation with the current revision",
134
+ };
135
+ };
136
+ const requestForTask = task => {
137
+ const text = readText(task.draft?.path);
138
+ if (text === null) throw taskError("TASK_CONFLICT", "saved draft is missing; inspect the task artifacts");
139
+ const req = { ...prdRequest(text, task.draft.path), checkout: task.draft.root, relPath: `docs/prds/${task.id}.md`, repo: task.repo, defaultBranch: task.defaultBranch };
140
+ const sources = parseTaskSources(task.output.sources);
141
+ if (sources.problems.length) throw taskError("TASK_CONFLICT", sources.problems.join("; "));
142
+ for (const path of sources.paths) {
143
+ const source = task.context.find(c => c.path === path);
144
+ if (!source) throw taskError("TASK_CONFLICT", `saved source was not selected: ${path}`);
145
+ if (contentDigest(readRepositoryText(task.draft.root, path)) !== source.hash) throw taskError("TASK_CONFLICT", `saved source snapshot changed: ${path}`);
146
+ }
147
+ return req;
148
+ };
149
+ const savedDraft = task => {
150
+ const req = requestForTask(task);
151
+ if (contentDigest(req.text) !== task.draft.hash) {
152
+ // ready writes status/decision before intake can save the acknowledgement.
153
+ // Recover that narrow write, then ask the actual gate again. Body, sources,
154
+ // runtime and all other settings still have to match the pinned contract.
155
+ if (task.status !== "validating" || !task.readiness?.pending || req.status !== "ready" || !validateDraft(task, req).ok) throw taskError("TASK_CONFLICT", "draft revision changed outside intake; accepted requirements were not overwritten");
156
+ task.draft.hash = contentDigest(req.text);
157
+ store.save(task);
158
+ }
159
+ return req;
160
+ };
161
+ const block = (task, error) => {
162
+ if (error.code === "TASK_PREPARATION_BOUNDARY") task.readiness = null;
163
+ task.status = "blocked";
164
+ task.failures = [error.message];
165
+ task.errorCode = error.code || "TASK_FAILED";
166
+ mutate(task, store);
167
+ return result(task);
168
+ };
169
+ const validateInternal = async task => {
170
+ assertPreparationBoundary(task, ctx);
171
+ const request = savedDraft(task);
172
+ const validation = validateDraft(task, request);
173
+ if (task.questions.length) return result(task);
174
+ if (!validation.ok) return block(task, taskError("TASK_VALIDATION", validation.failures.join("\n")));
175
+ if (task.readiness?.allow && task.readiness.hash === task.draft.hash) return result(task);
176
+ // Persist the decision intent too; a crash can repeat the readiness check,
177
+ // but can never treat an unrecorded/denied result as approval.
178
+ task.readiness = { allow: false, revision: task.revision, hash: task.draft.hash, pending: true };
179
+ store.save(task);
180
+ const decision = await decideReady({ path: request.path, config: ctx.config, apiKey: ctx.aportApiKey, log: ctx.log });
181
+ if (!decision.ok) {
182
+ task.readiness = { allow: false, hash: task.draft.hash, report: decision.report, decisionId: decision.decisionId };
183
+ return block(task, taskError("TASK_READINESS_DENIED", decision.report));
184
+ }
185
+ const approved = requestForTask(task);
186
+ const post = validateDraft(task, approved);
187
+ if (!post.ok || approved.status !== "ready") throw taskError("TASK_CONFLICT", "readiness did not preserve the generated contract or mark it ready");
188
+ task.draft.hash = contentDigest(approved.text);
189
+ const input = captureStartInput(approved, task.base);
190
+ task.readiness = { allow: true, hash: task.draft.hash, digest: input.digest, decisionId: decision.decisionId, report: decision.report };
191
+ task.status = "ready";
192
+ task.failures = [];
193
+ mutate(task, store);
194
+ task.readiness.revision = task.revision;
195
+ store.save(task);
196
+ return result(task);
197
+ };
198
+ const startInternal = async (task, noDaemon = false) => {
199
+ const id = entryId(task.repo, task.id);
200
+ const entry = ctx.state.get(id);
201
+ if (entry) {
202
+ if (!implementationMatches(task, entry)) throw taskError("TASK_CONFLICT", "implementation ownership or completion target differs from the approved intake");
203
+ task.implementation = { id, run: entry.task.run || entry.last?.run, worktree: entry.worktree, branch: entry.branch, base: entry.base, baseSource: entry.task.baseSource || task.baseSource, session: entry.session };
204
+ task.status = "started";
205
+ const saved = store.read(task.id);
206
+ if (saved.status !== "started" || !same(task.implementation, saved.implementation)) mutate(task, store);
207
+ } else {
208
+ const claim = ctx.state.startClaim(id);
209
+ if (claim) return block(task, taskError("TASK_DISPATCH_UNKNOWN", `unresolved start claim for ${id}; inspect ${claim.clone?.worktree ? `its clone at ${claim.clone.worktree}, ` : ""}its run and snapshots before recovery; no duplicate was dispatched`));
210
+ if (task.dispatchIntent) return block(task, taskError("TASK_DISPATCH_UNKNOWN", `dispatch acceptance for ${id} is unknown; inspect its saved artifacts before recovery; no duplicate was dispatched`));
211
+ if (!task.runAuthorized) throw taskError("TASK_AUTHORIZATION", "start requires explicit run authorization");
212
+ assertPreparationBoundary(task, ctx);
213
+ const req = savedDraft(task);
214
+ if (task.questions.length || !task.readiness?.allow || task.readiness.hash !== task.draft.hash || task.readiness.digest !== captureStartInput(req, task.base).digest) throw taskError("TASK_NOT_READY", "current revision has unresolved requirements or no exact readiness approval; validate it before starting");
215
+ const validation = validateDraft(task, req);
216
+ if (!validation.ok) throw taskError("TASK_CONFLICT", validation.failures.join("\n"));
217
+ let deliveryAllowance;
218
+ if (ctx.enforceTaskAllowance) {
219
+ if (task.attempts.some(a => a.cost == null && !Number.isFinite(a.reservedUsd))) throw taskError("TASK_ALLOWANCE", "Preparation cost is unknown without a reservation; reconcile it before authorizing implementation");
220
+ const spent = task.attempts.reduce((sum, a) => sum + Math.max(a.cost ?? 0, a.reservedUsd ?? 0), 0);
221
+ deliveryAllowance = { budgetUsd: task.allowance.budgetUsd - spent, runtimeMs: task.allowance.runtimeMs - task.attempts.reduce((sum, a) => sum + (a.elapsedMs || 0), 0) };
222
+ if (deliveryAllowance.budgetUsd <= 0 || deliveryAllowance.runtimeMs <= 0) throw taskError("TASK_ALLOWANCE", "Preparation exhausted the task allowance; no implementation was dispatched");
223
+ }
224
+ task.dispatchIntent = { id, revision: task.revision, digest: task.readiness.digest, at: new Date().toISOString() };
225
+ store.save(task);
226
+ try {
227
+ task.implementation = await dispatch(ctx, { path: req.path, request: req, base: task.base, task: { id: task.id, revision: task.revision, digest: task.readiness.digest, baseSource: task.baseSource, ...(task.completion ? { completion: task.completion } : {}), ...(task.ownerDelivery ? { ownerDelivery: task.ownerDelivery } : {}), ...(deliveryAllowance ? { deliveryAllowance } : {}) } });
228
+ } catch (e) {
229
+ if (e.startUncertain === false && !ctx.state.get(id) && !ctx.state.startClaim(id)) task.dispatchIntent = null;
230
+ return block(task, e);
231
+ }
232
+ task.status = "started";
233
+ mutate(task, store);
234
+ }
235
+ if (!noDaemon) {
236
+ try { await daemon(ctx); task.daemonError = null; }
237
+ catch (e) { task.daemonError = `implementation is saved, but the daemon could not start: ${e.message}; run gallivant up after fixing it; do not dispatch again`; }
238
+ store.save(task);
239
+ }
240
+ return { ...result(task), daemonError: task.daemonError || null };
241
+ };
242
+ const prepareInternal = async (task, noDaemon = false) => {
243
+ if (task.status === "started") return startInternal(task, noDaemon);
244
+ // An implementation accepted before this safety change still belongs to its
245
+ // original dispatch. Reconcile that ownership without preparing or spawning.
246
+ if (ctx.state.get(entryId(task.repo, task.id))) return startInternal(task, noDaemon);
247
+ if (["needs-input", "ready"].includes(task.status)) {
248
+ try { assertPreparationBoundary(task, ctx); }
249
+ catch (e) { return block(task, e); }
250
+ return task.status === "ready" && task.runAuthorized ? startInternal(task, noDaemon) : result(task);
251
+ }
252
+ // A blocked legacy timeout may still own a pending remote stop. An explicit
253
+ // resume reconciles only that control; it cannot retry preparation/refusals.
254
+ if (task.status === "blocked" && !preparationCancellationPending(task)) {
255
+ if (!preparationPreflightRetryable(task)) return result(task);
256
+ task.status = "preparing";
257
+ task.failures = task.preflightFailure.validationFailures || [];
258
+ task.errorCode = null;
259
+ delete task.preflightFailure;
260
+ mutate(task, store);
261
+ }
262
+ // A legacy answer may have been saved just before a crash. Its unresolved
263
+ // output still proves ownership; recover that before replay or a new turn.
264
+ try { task.questionMappings = questionOwnership(task); }
265
+ catch (e) { return block(task, taskError("TASK_CONFLICT", `${e.message}; inspect the saved brief before resuming`)); }
266
+ if (task.status === "validating") {
267
+ try {
268
+ const validated = await validateInternal(task);
269
+ return task.runAuthorized && validated.readiness?.allow ? await startInternal(task, noDaemon) : validated;
270
+ } catch (e) { return block(task, e); }
271
+ }
272
+ // Rebuild inferred candidates on reopen; a legacy cached command is not an
273
+ // explicit owner selection. Saved validation applies the same policy.
274
+ task.commandCandidates = commandCandidates(task.context, task.explicitVerifyCommands || []);
275
+ let failures = task.failures || [];
276
+ for (;;) {
277
+ let message;
278
+ try {
279
+ // Reconcile an already-owned turn before rejecting legacy oversized
280
+ // state, so usage/cleanup survive recovery. Never launch a new turn.
281
+ const previous = task.attempts.at(-1);
282
+ if (!previous || (previous.settled && previous.consumed)) requireTaskCapacity(task);
283
+ message = await prepare(ctx, task, store, failures);
284
+ if (message === PREPARATION_PENDING) return result(task); // Owned remote preparation is pending.
285
+ if (Array.isArray(ctx.taskSecrets) && message !== redact(message, ctx.taskSecrets)) throw taskError("TASK_SECRET", "Preparation output contains credential material; inspect the private provider record before recovery");
286
+ requireTaskCapacity(task);
287
+ }
288
+ catch (e) { if (e.code === "TASK_INPUT") task.readiness = null; return block(task, e); }
289
+ try {
290
+ const output = parseDraft(message, task);
291
+ task.output = output;
292
+ task.questions = output.questions;
293
+ for (const q of output.questions) task.questionHistory[q.id] = q;
294
+ task.questionMappings = { ...task.questionMappings, ...mappedQuestions(output) };
295
+ const root = ensureDir(join(taskRoot(ctx.home, task.id), "drafts", `revision-${task.revision + 1}`));
296
+ for (const c of task.context) writeRepositoryText(root, c.path, c.text);
297
+ const rel = `docs/prds/${task.id}.md`;
298
+ const text = renderDraft(task, output);
299
+ writeRepositoryText(root, rel, text);
300
+ task.draft = { root, path: join(root, rel), hash: contentDigest(text), revision: task.revision + 1, requirementsHash: contentDigest(JSON.stringify(task.requirements)) };
301
+ task.readiness = null;
302
+ const validation = validateDraft(task, requestForTask(task));
303
+ task.validation = { ok: validation.ok, failures: validation.failures };
304
+ if (output.questions.length) {
305
+ if (task.attempts.at(-1)) task.attempts.at(-1).consumed = true;
306
+ task.status = "needs-input";
307
+ task.failures = [];
308
+ mutate(task, store);
309
+ return result(task);
310
+ }
311
+ if (!validation.ok) throw taskError("TASK_VALIDATION", validation.failures.join("\n"));
312
+ } catch (e) {
313
+ if (task.attempts.at(-1)) task.attempts.at(-1).consumed = true;
314
+ failures = [e.message];
315
+ task.failures = failures;
316
+ if (task.repairs >= MAX_REPAIRS) return block(task, taskError("TASK_VALIDATION", `after ${MAX_REPAIRS} repair attempts: ${failures.join("; ")}`));
317
+ task.repairs++;
318
+ mutate(task, store);
319
+ continue;
320
+ }
321
+ if (task.attempts.at(-1)) task.attempts.at(-1).consumed = true;
322
+ task.status = "validating";
323
+ mutate(task, store);
324
+ try {
325
+ const validated = await validateInternal(task);
326
+ if (!validated.readiness?.allow) return validated;
327
+ return task.runAuthorized ? await startInternal(task, noDaemon) : validated;
328
+ } catch (e) { return block(task, e); }
329
+ }
330
+ };
331
+ const service = {
332
+ version: TASK_VERSION,
333
+ capabilities: () => ({ version: TASK_VERSION, providers: executionCapabilities(), completion: COMPLETION_CAPABILITIES }),
334
+ inspect: ({ taskId }) => result(store.read(taskId)),
335
+ list: () => ({ version: TASK_VERSION, tasks: store.list().map(result) }),
336
+ prepare: async ({ text, dir = process.cwd(), repo = null, base = null, harness = null, context = [], requestId = null, run = false, noDaemon = false, verifyCommands = [], completion }) => {
337
+ // Capture before any async repository resolution or isolated planning turn.
338
+ // Repository selection can differ from the invocation directory.
339
+ const callerCwd = process.cwd();
340
+ beforeTaskEffects(() => requireText(text, "task request"));
341
+ const selectedCompletion = beforeTaskEffects(() => completionPolicy(completion));
342
+ const originalRequirements = beforeTaskEffects(() => requireTaskCapacity({ requirements: fragments(text) }));
343
+ if (requestId !== null) beforeTaskEffects(() => requireText(requestId, "request identity", 256));
344
+ beforeTaskEffects(() => selectedPaths(context)); // Pure path checks precede even legacy identity replay.
345
+ const checkout = await ctx.git.toplevel(resolve(callerCwd, dir));
346
+ const info = await ctx.gh.repoInfo(checkout);
347
+ if (!/^[\w.-]+\/[\w.-]+$/.test(info.nameWithOwner)) throw taskRejection("TASK_INPUT", "repository must resolve to owner/name");
348
+ if (repo && repo.toLowerCase() !== info.nameWithOwner.toLowerCase()) throw taskRejection("TASK_CONFLICT", "selected repository does not match the checkout");
349
+ const selected = harness || Object.keys(ctx.config.harness)[0];
350
+ beforeTaskEffects(() => capabilitiesFor(selected));
351
+ if (!ctx.config.harness[selected]) throw taskRejection("TASK_INPUT", `harness ${selected} is not configured`);
352
+ const selection = beforeTaskEffects(() => resolveBase({ config: ctx.config, repo: info.nameWithOwner, defaultBranch: info.defaultBranch, asked: base }));
353
+ const resolvedBase = selection.base;
354
+ if (!resolvedBase) throw taskRejection("TASK_INPUT", "repository has no resolved base; supply --base");
355
+ // Retry identity includes repo, but not content: different content using the
356
+ // same identity must conflict rather than silently allocate a second task.
357
+ const id = requestId ? `task-${contentDigest(JSON.stringify([info.nameWithOwner.toLowerCase(), requestId])).slice(0, 24)}` : `task-${randomBytes(12).toString("hex")}`;
358
+ return store.locked(id, async () => {
359
+ // Omitted policy retains legacy request signatures and replay identity.
360
+ const signature = contentDigest(JSON.stringify({ text, checkout, base: resolvedBase, harness: selected, context, verifyCommands, run, ...(completion === undefined ? {} : { completion: selectedCompletion }) }));
361
+ let task;
362
+ try { task = store.read(id); } catch (e) { if (e.code !== "TASK_NOT_FOUND") throw e; }
363
+ if (task) {
364
+ if (task.signature !== signature) throw taskRejection("TASK_CONFLICT", "request identity already belongs to different input; reopen that task or use a new identity");
365
+ if (["ready", "needs-input"].includes(task.status)) {
366
+ if (ctx.state.get(entryId(task.repo, task.id))) return startInternal(task, noDaemon);
367
+ try { assertPreparationBoundary(task, ctx); }
368
+ catch (e) { return block(task, e); }
369
+ }
370
+ // Retries retain the first accepted path base, including version-1
371
+ // records whose signatures predate callerCwd. Readiness may have been
372
+ // saved just before a crash; honor its already-persisted authorization.
373
+ return ["preparing", "validating"].includes(task.status) || (task.status === "ready" && task.runAuthorized)
374
+ ? prepareInternal(task, noDaemon) : result(task);
375
+ }
376
+ // Only new tasks use this ownership version. Saved request identities,
377
+ // approved drafts and provider receipts retain their original semantics.
378
+ const delivery = beforeTaskEffects(() => ownerDelivery(text));
379
+ const requirements = delivery ? ownerImplementation(delivery).flatMap(text => fragments(text)).map((r, i) => ({ ...r, id: `r-${i + 1}` })) : originalRequirements;
380
+ beforeTaskEffects(() => requireTaskCapacity({ requirements, ...(delivery ? { ownerDelivery: delivery } : {}) }));
381
+ const selectedFiles = beforeTaskEffects(() => selectedContext(checkout, context, [], ctx.taskSecrets ?? null));
382
+ const explicit = beforeTaskEffects(() => [...explicitCommands(verifyCommands)]);
383
+ const candidates = beforeTaskEffects(() => commandCandidates(selectedFiles, explicit));
384
+ task = {
385
+ version: TASK_VERSION, id, revision: 1, original: text, signature, requestId,
386
+ checkout, callerCwd, repo: info.nameWithOwner.toLowerCase(), defaultBranch: info.defaultBranch, base: resolvedBase, baseSource: selection.source, harness: selected,
387
+ context: selectedFiles, explicitVerifyCommands: explicit, commandCandidates: candidates, completion: selectedCompletion,
388
+ ...(delivery ? { ownerDelivery: delivery } : {}),
389
+ requirements, answers: {}, questionHistory: {}, questionMappings: {}, questions: [],
390
+ output: null, draft: null, readiness: null, implementation: null, dispatchIntent: null,
391
+ runAuthorized: run === true, status: "preparing", repairs: 0, attempts: [], failures: [],
392
+ allowance: { budgetUsd: ctx.config.max_budget_usd, runtimeMs: ctx.config.run_timeout_minutes * 60_000 },
393
+ createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), operations: {},
394
+ };
395
+ store.save(task);
396
+ return prepareInternal(task, noDaemon);
397
+ });
398
+ },
399
+ answer: async ({ taskId, expectedRevision, answers, operationId, context = [], verifyCommands = [], noDaemon = false }) => store.locked(taskId, async () => {
400
+ const task = store.read(taskId);
401
+ beforeTaskEffects(() => requireText(operationId, "answer operation identity", 256));
402
+ if (!answers || Array.isArray(answers) || typeof answers !== "object" || !Object.keys(answers).length) throw taskRejection("TASK_INPUT", "answers must map saved question IDs to text");
403
+ beforeTaskEffects(() => selectedPaths(context)); // Duplicate answers still never reread accepted files.
404
+ // Retain exact legacy retries; new receipts ignore object member order.
405
+ const signatureInput = { answers, verifyCommands, ...(context.length ? { context } : {}) };
406
+ const signature = "canonical-v1:" + contentDigest(canonical(JSON.parse(JSON.stringify(signatureInput))));
407
+ if (Object.hasOwn(task.operations, operationId)) {
408
+ if (task.operations[operationId] !== signature && task.operations[operationId] !== contentDigest(JSON.stringify(signatureInput))) throw taskRejection("TASK_CONFLICT", "answer identity already belongs to different answers");
409
+ return ["preparing", "validating"].includes(task.status) || (task.status === "ready" && task.runAuthorized)
410
+ ? prepareInternal(task, noDaemon) : result(task);
411
+ }
412
+ expectRevision(task, expectedRevision);
413
+ if (task.status !== "needs-input") throw taskRejection("TASK_CONFLICT", "this intake is not waiting for answers");
414
+ beforeTaskEffects(() => assertPreparationBoundary(task, ctx));
415
+ let questionMappings;
416
+ try { questionMappings = questionOwnership(task); }
417
+ catch (e) { throw taskRejection("TASK_CONFLICT", `${e.message}; inspect the saved brief before answering`); }
418
+ for (const [id, value] of Object.entries(answers)) {
419
+ beforeTaskEffects(() => requireText(value, `answer ${id}`));
420
+ beforeTaskEffects(() => assertDeliveryAnswer(task, value));
421
+ if (!task.questions.some(q => q.id === id)) throw taskRejection("TASK_INPUT", `unknown answer identifier ${id}`);
422
+ if (task.answers[id] && task.answers[id] !== value) throw taskRejection("TASK_CONFLICT", `answer ${id} is already accepted`);
423
+ }
424
+ const requirements = beforeTaskEffects(() => requireTaskCapacity({ ...task, questionMappings, answers: { ...task.answers, ...answers }, requirements: [...task.requirements, ...Object.entries(answers).flatMap(([id, value]) => fragments(value, `a-${id}`))] }));
425
+ const selectedFiles = beforeTaskEffects(() => context.length ? selectedContext(task.checkout, context, task.context, ctx.taskSecrets ?? null) : task.context);
426
+ const explicit = beforeTaskEffects(() => [...new Set([...(task.explicitVerifyCommands || []), ...explicitCommands(verifyCommands)])]);
427
+ const candidates = beforeTaskEffects(() => commandCandidates(selectedFiles, explicit));
428
+ // Backfill only ownership proven by a saved unresolved mapping, never by
429
+ // an older question's claimed requirement list alone.
430
+ task.questionMappings = questionMappings;
431
+ for (const [id, value] of Object.entries(answers)) {
432
+ task.answers[id] = value;
433
+ }
434
+ task.requirements = requirements;
435
+ task.context = selectedFiles;
436
+ task.explicitVerifyCommands = explicit;
437
+ task.commandCandidates = candidates;
438
+ // Computed properties are own data keys, including __proto__, and survive
439
+ // JSON persistence without invoking a plain object's prototype setter.
440
+ task.operations = { ...task.operations, [operationId]: signature };
441
+ task.questions = task.questions.filter(q => !task.answers[q.id]);
442
+ task.status = task.questions.length ? "needs-input" : "preparing";
443
+ task.readiness = null;
444
+ mutate(task, store);
445
+ return task.status === "preparing" ? prepareInternal(task, noDaemon) : result(task);
446
+ }),
447
+ resume: async ({ taskId, expectedRevision, noDaemon = false }) => store.locked(taskId, async () => {
448
+ const task = store.read(taskId);
449
+ expectRevision(task, expectedRevision);
450
+ const id=entryId(task.repo,task.id),entry=ctx.state.get(id);
451
+ if (entry) {
452
+ if (!implementationMatches(task, entry)) throw taskRejection("TASK_CONFLICT", "implementation ownership or completion target differs from the approved intake");
453
+ const runner=executionRunner(ctx,id,task.harness);
454
+ // Bind an already queued ambiguous Resume to observation recovery before
455
+ // this explicit rearm can adopt its root. It must never become a new turn.
456
+ const recoveredControl=await recoverControl(ctx,id),run=runner.current?.();
457
+ if (recoveredControl || run?.execution?.providerState==="status-unknown" && runner.recover) {
458
+ try {
459
+ const observed=recoveredControl?run:await runner.recover({authorized:true,operationId:`task-resume:${task.id}:${expectedRevision}`});
460
+ if(observed?.execution?.acceptance==="accepted"){task.failures=[];task.errorCode=null;}
461
+ }
462
+ catch(error) {return block(task,error);}
463
+ // Consume this revision even if adoption below leaves the view stable.
464
+ // A stale/replayed request cannot reset another retry circuit.
465
+ mutate(task,store);
466
+ }
467
+ }
468
+ return prepareInternal(task, noDaemon);
469
+ }),
470
+ validate: async ({ taskId, expectedRevision }) => store.locked(taskId, async () => {
471
+ const task = store.read(taskId);
472
+ expectRevision(task, expectedRevision);
473
+ // The previous question-bearing draft can outlive an accepted final answer.
474
+ // Leave its owned turn and revision intact for explicit preparation recovery.
475
+ if (task.status === "preparing") throw taskRejection("TASK_NOT_READY", "resume preparation with the current revision before validating its draft");
476
+ if (!task.draft) throw taskRejection("TASK_NOT_READY", "no generated draft to validate");
477
+ if (task.status === "started") return result(task);
478
+ beforeTaskEffects(() => assertPreparationBoundary(task, ctx));
479
+ return validateInternal(task);
480
+ }),
481
+ start: async ({ taskId, expectedRevision, run = false, noDaemon = false }) => store.locked(taskId, async () => {
482
+ const task = store.read(taskId);
483
+ expectRevision(task, expectedRevision);
484
+ if (task.status !== "started" && !ctx.state.get(entryId(task.repo, task.id))) beforeTaskEffects(() => assertPreparationBoundary(task, ctx));
485
+ if (run === true && !task.runAuthorized) {
486
+ if (task.questions.length || !task.readiness?.allow) throw taskRejection("TASK_NOT_READY", "resolve requirements and validate the current draft before authorizing start");
487
+ const request = savedDraft(task);
488
+ if (!validateDraft(task, request).ok) throw taskRejection("TASK_CONFLICT", "approved requirements changed; no start authorization was saved");
489
+ task.runAuthorized = true; mutate(task, store);
490
+ }
491
+ return startInternal(task, noDaemon);
492
+ }),
493
+ };
494
+ // Preserve synchronous inspect/list and asynchronous mutations while giving
495
+ // every structured error the same version/code envelope the CLI renders.
496
+ const normalizeError = e => { e.version ??= TASK_VERSION; e.code ??= "TASK_FAILED"; return e; };
497
+ for (const key of ["prepare", "answer", "resume", "validate", "start", "inspect", "list"]) {
498
+ const original = service[key];
499
+ const operation = ["inspect", "list"].includes(key) ? original : async (...args) => {
500
+ let owner;
501
+ try { owner = acquireServiceMutation(ctx.home, mutationOwner); }
502
+ catch (error) { error.code ??= "TASK_BUSY"; throw error; }
503
+ try { return await original(...args); } finally { owner.release(); }
504
+ };
505
+ service[key] = (...args) => {
506
+ try {
507
+ const value = operation(...args);
508
+ return value instanceof Promise ? value.catch(e => { throw normalizeError(e); }) : value;
509
+ } catch (e) { throw normalizeError(e); }
510
+ };
511
+ }
512
+ return service;
513
+ }