@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,521 @@
1
+ // Harness adapters. Each one knows four things about its CLI: how to start a session, how to resume one, where the
2
+ // session id comes from, and how to read the outcome of a run from its JSONL output. Flags live here, not in config,
3
+ // so an operator never has to know them and an upgrade changes one file.
4
+ //
5
+ // Argument/session contracts checked against Claude Code 2.1.263 and Codex 0.158.0. Codex config overrides work for both `exec` and
6
+ // `exec resume`, whose flags differ. The gemini, opencode
7
+ // and goose session contracts were checked on 2026-09-25 against 0.59.0, 1.18.31 and 1.50.1.
8
+ // Coding/push evidence and current blocks are separate: docs/audit/harness-live-validation.md.
9
+ import { randomUUID } from "node:crypto";
10
+ import { dirname } from "node:path";
11
+ import { runtimeEnvironment } from "./runtime-environment.mjs";
12
+ import { codexPreparation } from "./preparation_codex.mjs";
13
+ import { agentsPreparation } from "./preparation_agents.mjs";
14
+ import { CLOUD_DISPATCH_UNAVAILABLE } from "./codex_cloud_config.mjs";
15
+ import { EXECUTION_TRANSPORTS } from "./execution_contract.mjs";
16
+
17
+ export const MIN_VERSIONS = { claude: "2.1.263", codex: "0.158.0", gemini: "0.59.0", opencode: "1.18.31", goose: "1.50.1" };
18
+
19
+ // workspace-write protects .git too: add, commit and push's local tracking updates need approval. `never`
20
+ // stranded real runs at index.lock and .git/config; saved allow rules only hid the problem for some commands.
21
+ // Keep the sandbox and route escalations to Codex's reviewer, so headless runs need no operator click.
22
+ const CODEX_OVERRIDES = [
23
+ "-c", 'approval_policy="on-request"',
24
+ "-c", 'approvals_reviewer="auto_review"',
25
+ "-c", 'sandbox_mode="workspace-write"',
26
+ "-c", "sandbox_workspace_write.network_access=true",
27
+ "--skip-git-repo-check",
28
+ ];
29
+
30
+ // stream-json is real JSONL on both: gemini emits init/message/result, goose emits message/complete. Neither has
31
+ // a spend cap flag, so the wall-clock timeout is what bounds them, as with codex.
32
+ const GEMINI_COMMON = ["--output-format", "stream-json", "-p"];
33
+ const GOOSE_COMMON = ["--output-format", "stream-json"];
34
+
35
+ const claudeCommon = ({ budget, settings }) => [
36
+ "-p", "--permission-mode", "auto", "--permission-prompts", "none",
37
+ "--max-budget-usd", String(budget), "--settings", settings,
38
+ "--output-format", "stream-json", "--verbose",
39
+ ];
40
+
41
+ /** The shape claude, codex and gemini all spell a session id with. */
42
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
43
+
44
+ /** Last non-empty parsed JSONL line matching `pred`, scanning from the end. */
45
+ const findLast = (lines, pred) => {
46
+ for (let i = lines.length - 1; i >= 0; i--) {
47
+ const ev = parse(lines[i]);
48
+ if (ev && pred(ev)) return ev;
49
+ }
50
+ return null;
51
+ };
52
+ const parse = l => { try { return JSON.parse(l); } catch { return null; } };
53
+ const events = lines => lines.map(parse).filter(Boolean);
54
+
55
+ /**
56
+ * Why each denied call was denied, keyed by the tool_use_id that names the call.
57
+ *
58
+ * `result.permission_denials` says WHAT was denied (tool name and the whole input) and never says why. The reason is
59
+ * written somewhere else in the same stream: the hook denies, Claude turns the denial into the tool's result, and that
60
+ * `tool_result` carries the guardrail's own sentence. Both halves carry the same `tool_use_id`, so they join exactly
61
+ * and need no timestamp guessing.
62
+ *
63
+ * This is the whole fix for a run where APort blocked `grep -n ... web/src/lib/products.config.ts` on the pattern
64
+ * "Access to sensitive configuration files". The audit line names the pattern and not the path; the denial list names
65
+ * the path and not the pattern. An operator reading either one alone cannot tell a correct block from a broad one
66
+ * catching a legitimate read of a project source file, which is what that one was.
67
+ */
68
+ const denialReasons = lines => {
69
+ const by = new Map();
70
+ for (const ev of events(lines)) {
71
+ if (ev.type !== "user") continue;
72
+ for (const b of ev.message?.content || []) {
73
+ if (b?.type !== "tool_result" || !b.is_error || !b.tool_use_id) continue;
74
+ const text = typeof b.content === "string" ? b.content : Array.isArray(b.content) ? b.content.map(c => (typeof c === "string" ? c : c?.text || "")).join(" ") : "";
75
+ // Only a guardrail refusal, never an ordinary tool error: a failing command's stderr is not a reason a call was
76
+ // denied, and putting it on a denial line would say the guardrail said something it did not.
77
+ const blocked = (ev.tool_result_meta || []).some(m => m?.non_execution_kind === "permission-rule") || /^APort denied\b/.test(text.trim());
78
+ if (blocked && text.trim()) by.set(String(b.tool_use_id), text.trim().replace(/\s+/g, " ").slice(0, 300));
79
+ }
80
+ }
81
+ return by;
82
+ };
83
+
84
+ // What an adapter has to do, and who is in. The loop cannot work without four things: start a run
85
+ // non-interactively, resume THAT session by id (not "the latest"), emit a machine-readable stream the run's
86
+ // outcome can be read from, and report its own session id so the PR body can carry it.
87
+ //
88
+ // Five harnesses meet that contract and are implemented below. Each round-trip was run on this machine on
89
+ // 2026-09-25 the same way: write a number in one process, resume that session id in a second process, assert the
90
+ // number came back. The versions seen were Gemini CLI 0.59.0, opencode 1.18.31 and goose 1.50.1.
91
+ //
92
+ // cursor-agent NOT ADDED, and the blocker is a login, not a missing capability. It has every flag this needs:
93
+ // `--print --output-format stream-json`, `--resume <chatId>`, and `create-chat` prints a UUID to
94
+ // pre-assign. It is simply not signed in here: a headless run draws the interactive "Press any key
95
+ // to sign in" screen and exits non-zero, so the event stream could not be observed. Adding it would
96
+ // mean guessing the shape of a stream nobody has seen. Sign in, capture one run, then write it.
97
+ //
98
+ // Adding one is a new entry in this object and its own tests: `HARNESSES` is the keys of this object, so there is no
99
+ // second list to keep in step. Nothing else names a harness. `signIn.fix` is the command the operator runs when the
100
+ // check fails, kept beside the check rather than in `doctor`, which used to tell three of these to run `codex login`.
101
+ export const adapters = {
102
+ claude: {
103
+ name: "claude",
104
+ preparation: {
105
+ version: 1, boundary: "claude-no-tools-v1", authority: "text-only",
106
+ // Built-ins, MCP discovery and commands all stay disabled. Flags precede
107
+ // the positional prompt so variadic tool/config options cannot swallow it.
108
+ render: spec => ["--restricted", "--tools", "", "--disallowedTools", "*", "--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}', "--disable-slash-commands", ...spec.args],
109
+ },
110
+ preparationEnv: ["ANTHROPIC_BASE_URL", "ANTHROPIC_MODEL", "ANTHROPIC_SMALL_FAST_MODEL", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY", "CLAUDE_CODE_SKIP_BEDROCK_AUTH", "CLAUDE_CODE_SKIP_VERTEX_AUTH", "CLAUDE_CODE_SKIP_FOUNDRY_AUTH", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL"],
111
+ newSession: () => randomUUID(),
112
+ // `--session-id` takes a UUID and claude refuses anything else, so that is exactly what a block may carry.
113
+ isSession: s => UUID.test(s),
114
+ start: ({ session, prompt, budget, settings }) => ["--session-id", session, ...claudeCommon({ budget, settings }), prompt],
115
+ resume: ({ session, prompt, budget, settings }) => ["--resume", session, ...claudeCommon({ budget, settings }), prompt],
116
+ env: ({ configDir }) => (configDir ? { APORT_CLAUDE_CODE_CONFIG_DIR: configDir } : {}),
117
+ sessionFrom: lines => findLast(lines, ev => ev.type === "system" && ev.subtype === "init")?.session_id ?? null,
118
+ outcome(lines) {
119
+ const result = findLast(lines, ev => ev.type === "result");
120
+ const lastText = findLast(lines, ev => ev.type === "assistant")?.message?.content?.filter(c => c.type === "text").map(c => c.text).join("\n") ?? null;
121
+ if (!result) return { ok: false, finished: false, lastMessage: lastText, denials: [], cost: null, error: "no result event" };
122
+ const reasons = denialReasons(lines);
123
+ return {
124
+ ok: !result.is_error,
125
+ finished: true,
126
+ lastMessage: typeof result.result === "string" ? result.result : lastText,
127
+ // The reason is appended rather than replacing anything, so a denial whose reason is not in the stream reads
128
+ // exactly as it did before and nothing about the line pretends to know more than the log recorded.
129
+ denials: (result.permission_denials || []).map(d => {
130
+ const why = d.tool_use_id ? reasons.get(String(d.tool_use_id)) : null;
131
+ return `${d.tool_name}: ${JSON.stringify(d.tool_input).slice(0, 200)}${why ? `\n ${why}` : ""}`;
132
+ }),
133
+ cost: result.total_cost_usd ?? null,
134
+ error: result.is_error ? (result.result || result.subtype || "error") : null,
135
+ };
136
+ },
137
+ signIn: { scope: "local-sign-in", args: ["auth", "status"], ok: (code, out) => code === 0 && /"loggedIn":\s*true/.test(out), fix: "claude auth login" },
138
+ version: { args: ["--version"], parse: out => (out.match(/(\d+\.\d+\.\d+)/) || [])[1] },
139
+ // --add-dir grants tool access to a directory; it does not widen what may be written, because writes stay
140
+ // inside the sandbox declared in the Gallivant settings file that --settings points at.
141
+ context: { render: paths => paths.flatMap(p => ["--add-dir", p]) },
142
+ attach: ({ session }) => `claude --resume ${session}`,
143
+ },
144
+ codex: {
145
+ name: "codex",
146
+ preparation: codexPreparation,
147
+ preparationEnv: ["OPENAI_API_KEY", "OPENAI_BASE_URL", "OPENAI_ORG_ID", "OPENAI_PROJECT_ID", "CODEX_API_KEY", "CODEX_CA_CERTIFICATE"],
148
+ newSession: () => null,
149
+ // Thread ids read off `thread.started` are UUIDs.
150
+ isSession: s => UUID.test(s),
151
+ start: ({ prompt }) => ["exec", "--json", ...CODEX_OVERRIDES, prompt],
152
+ resume: ({ session, prompt }) => ["exec", "resume", session, "--json", ...CODEX_OVERRIDES, prompt],
153
+ env: () => ({}),
154
+ sessionFrom: lines => events(lines).find(ev => ev.type === "thread.started")?.thread_id ?? null,
155
+ outcome(lines) {
156
+ const evs = events(lines);
157
+ const failed = evs.findLast(ev => ev.type === "turn.failed" || ev.type === "error");
158
+ const completed = evs.findLast(ev => ev.type === "turn.completed");
159
+ const lastText = evs.findLast(ev => ev.type === "item.completed" && ev.item?.type === "agent_message")?.item?.text ?? null;
160
+ if (!completed && !failed) return { ok: false, finished: false, lastMessage: lastText, denials: [], cost: null, error: "no turn.completed event" };
161
+ return { ok: !failed, finished: true, lastMessage: lastText, denials: [], cost: null, error: failed ? (failed.error?.message || failed.message || "turn failed") : null };
162
+ },
163
+ signIn: { scope: "local-sign-in", args: ["login", "status"], ok: code => code === 0, fix: "codex login" },
164
+ version: { args: ["--version"], parse: out => (out.match(/(\d+\.\d+\.\d+)/) || [])[1] },
165
+ // Codex cannot grant read on ONE directory. Checked against 0.157.0 on 2026-09-25:
166
+ // --add-dir <DIR> its own help calls these "Additional directories that should be WRITABLE alongside the
167
+ // primary workspace". Write, not read.
168
+ // -c sandbox_permissions=["disk-full-read-access"] the only value the help documents, and it is the whole
169
+ // disk. A per-path spelling (disk-read-access=/x) parses and is then dropped: the run
170
+ // prints "ignoring 1 unrecognized configuration setting" and reads nothing extra.
171
+ // -c sandbox_workspace_write.readable_roots=[...] reported as ignored by name in that same warning.
172
+ // Full disk read to get one directory is a wider grant than the operator asked for, and ignoring the setting
173
+ // would leave them believing a run can read something it cannot. So the run is refused instead.
174
+ context: { refuse: "Codex has no per-directory read grant: --add-dir makes a directory writable, and the only read permission it documents is disk-full-read-access, the whole disk. Drop context_paths for this brief, or give it to a harness that can (claude)." },
175
+ attach: ({ session }) => `codex resume ${session}`,
176
+ },
177
+ // Gemini CLI. `--skip-trust` is mandatory for a daemon: without it a headless run in an untrusted directory
178
+ // refuses with "not running in a trusted directory" and the run is wasted. GEMINI_CLI_TRUST_WORKSPACE=true is
179
+ // the env equivalent; the flag is passed as well so the argv alone is enough to reproduce a run by hand.
180
+ //
181
+ // On --resume: the help text says it takes "latest" or an index number. That is incomplete. A session UUID
182
+ // works, and it is the only form that resumes a SPECIFIC session, which is the whole contract here. Verified by
183
+ // round-trip: a number written under a generated UUID in one process came back when a second process resumed
184
+ // that id. Do not "fix" this to an index; an index is relative to the session list and moves under the daemon.
185
+ gemini: {
186
+ name: "gemini",
187
+ preparationEnv: ["GEMINI_API_KEY", "GOOGLE_API_KEY", "GEMINI_MODEL", "GOOGLE_GENAI_USE_VERTEXAI", "GOOGLE_CLOUD_PROJECT", "GOOGLE_CLOUD_LOCATION", "GOOGLE_APPLICATION_CREDENTIALS"],
188
+ newSession: () => randomUUID(),
189
+ isSession: s => UUID.test(s),
190
+ start: ({ session, prompt }) => ["--skip-trust", "--session-id", session, ...GEMINI_COMMON, prompt],
191
+ resume: ({ session, prompt }) => ["--skip-trust", "--resume", session, ...GEMINI_COMMON, prompt],
192
+ env: () => ({ GEMINI_CLI_TRUST_WORKSPACE: "true" }),
193
+ sessionFrom: lines => findLast(lines, ev => ev.type === "init")?.session_id ?? null,
194
+ outcome(lines) {
195
+ const result = findLast(lines, ev => ev.type === "result");
196
+ const lastText = findLast(lines, ev => ev.type === "message" && ev.role === "assistant")?.content ?? null;
197
+ if (!result) return { ok: false, finished: false, lastMessage: lastText, denials: [], cost: null, error: "no result event" };
198
+ const ok = result.status === "success";
199
+ return { ok, finished: true, lastMessage: lastText, denials: [], cost: null, error: ok ? null : (result.error?.message || result.status || "error") };
200
+ },
201
+ // Gemini CLI has no `auth status` subcommand: credentials come from a stored OAuth login or GEMINI_API_KEY
202
+ // and there is no command that reports on them without spending a call. So this only proves the binary runs.
203
+ // A run that is not authenticated fails on its first model call and shows up as a failed run, not as a
204
+ // failed probe. That is weaker than claude's or codex's sign-in check; `doctor` cannot pre-empt it.
205
+ signIn: { scope: "local-binary", args: ["--version"], ok: code => code === 0, fix: "gemini, then /auth, or set GEMINI_API_KEY" },
206
+ version: { args: ["--version"], parse: out => (out.match(/(\d+\.\d+\.\d+)/) || [])[1] },
207
+ // --include-directories looks like the flag and is not. It adds a directory to the WORKSPACE, and Gemini's
208
+ // own file-system tool reference says every file tool, write_file included, operates within the workspace
209
+ // root. The flag grants write as well as read, which is the one thing this setting must not do, and there is
210
+ // no read-only spelling. Refuse rather than escalate.
211
+ context: { refuse: "Gemini CLI has no read-only directory flag: --include-directories adds to the workspace, which is its write boundary as well as its read boundary. Drop context_paths for this brief, or give it to a harness that can (claude)." },
212
+ attach: ({ session }) => `gemini --resume ${session}`,
213
+ },
214
+ // opencode. `run` is the headless form and `-s <id>` continues a named session.
215
+ //
216
+ // A session id cannot be pre-assigned: ids look like `ses_f25fcd907ffesl1i92jsR2lh9I`, they are minted by the
217
+ // server, and `opencode session` offers only `list` and `delete`, no `create`. So newSession() returns null and
218
+ // the id is read back off the run, exactly as codex does. Every JSONL line carries `sessionID`, so the first
219
+ // line is enough.
220
+ //
221
+ // The earlier note that this was "gated on an APort target" no longer blocks anything: opencode run is plain
222
+ // stdout JSONL and the gate the daemon applies is its own deliverable check, which does not care which CLI
223
+ // produced the run. Nothing in the APort hook path is opencode-specific.
224
+ opencode: {
225
+ name: "opencode",
226
+ preparationEnv: ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", "OPENROUTER_API_KEY", "XAI_API_KEY", "DEEPSEEK_API_KEY", "MISTRAL_API_KEY", "AZURE_OPENAI_API_KEY", "AZURE_OPENAI_ENDPOINT", "OPENCODE_CONFIG", "OPENCODE_CONFIG_CONTENT"],
227
+ newSession: () => null,
228
+ // Server-minted, and they look like `ses_f25fcd907ffesl1i92jsR2lh9I`: the prefix plus a case-sensitive token.
229
+ isSession: s => /^ses_[A-Za-z0-9]{8,64}$/.test(s),
230
+ start: ({ prompt }) => ["run", "--format", "json", prompt],
231
+ resume: ({ session, prompt }) => ["run", "--session", session, "--format", "json", prompt],
232
+ env: () => ({}),
233
+ sessionFrom: lines => events(lines).find(ev => ev.sessionID)?.sessionID ?? null,
234
+ outcome(lines) {
235
+ const evs = events(lines);
236
+ const finish = evs.findLast(ev => ev.type === "step_finish");
237
+ const steps = evs.filter(ev => ev.type === "step_finish");
238
+ const errored = evs.findLast(ev => ev.type === "error");
239
+ const lastText = evs.findLast(ev => ev.type === "text")?.part?.text ?? null;
240
+ if (!finish && !errored) return { ok: false, finished: false, lastMessage: lastText, denials: [], cost: null, error: "no step_finish event" };
241
+ // `reason` is "stop" on a clean turn; anything else (an aborted or length-capped turn) is not a success.
242
+ const reason = finish?.part?.reason ?? null;
243
+ const ok = !errored && reason === "stop";
244
+ return {
245
+ ok, finished: true, lastMessage: lastText, denials: [],
246
+ // Cost belongs to each step, not just the final answer. A coding turn can have many tool steps.
247
+ // Missing step costs mean an unknown total; a provider-reported zero remains a real zero.
248
+ cost: steps.length && steps.every(ev => typeof ev.part?.cost === "number" && Number.isFinite(ev.part.cost))
249
+ ? steps.reduce((total, ev) => total + ev.part.cost, 0) : null,
250
+ error: ok ? null : (errored?.error?.message || errored?.message || (reason ? `turn ended with reason "${reason}"` : "turn failed")),
251
+ };
252
+ },
253
+ // `auth list` prints the stored credentials and ends with "N credentials"; zero of them means no provider is
254
+ // configured and every run would fail at the first model call.
255
+ signIn: { scope: "local-credentials", args: ["auth", "list"], ok: (code, out) => code === 0 && !/\b0 credentials\b/.test(out) && /credential/i.test(out), fix: "opencode auth login" },
256
+ version: { args: ["--version"], parse: out => (out.match(/(\d+\.\d+\.\d+)/) || [])[1] },
257
+ // No flag adds a second directory: `--dir` moves the working directory, it does not add to it. opencode is
258
+ // not sandboxed by Gallivant either, so there is nothing to widen and nothing to refuse. The setting has no
259
+ // effect here, and `doctor` says so rather than leaving the operator to guess.
260
+ context: null,
261
+ attach: ({ session }) => `opencode run -s ${session}`,
262
+ },
263
+ // goose. Two flags look like they name a session and only one of them does both halves of the job:
264
+ // `--session-id <id>` is resume-only and refuses with "--session-id can only be used with --resume flag", so it
265
+ // cannot create the session the daemon will later come back to. `-n/--name <NAME>` names a new session AND,
266
+ // with `-r/--resume`, resumes that specific one. So the adapter uses -n for both and never uses --session-id.
267
+ // Verified by round-trip against a generated name. Resuming a name that does not exist prints "No session found
268
+ // with name ..." and exits non-zero, which `outcome` reports as unfinished rather than as a silent success.
269
+ //
270
+ // The name is a session id here, so it has to be safe as a filename: goose derives the session file from it.
271
+ goose: {
272
+ name: "goose",
273
+ // Authentication is selected only after resolving the effective provider.
274
+ preparationEnv: ["GOOSE_PROVIDER", "GOOSE_MODEL"],
275
+ newSession: () => `work-${randomUUID()}`,
276
+ // The name the daemon minted, and goose derives the session FILE from it, so it must stay one safe path segment.
277
+ isSession: s => /^(?:work|gallivant)-[0-9a-zA-Z-]{1,64}$/.test(s) && !s.includes(".."),
278
+ start: ({ session, prompt }) => ["run", "--name", session, ...GOOSE_COMMON, "-t", prompt],
279
+ resume: ({ session, prompt }) => ["run", "--resume", "--name", session, ...GOOSE_COMMON, "-t", prompt],
280
+ // GOOSE_MODE is how goose bounds tool calls; there is no CLI flag for it. "approve" would hang a headless run
281
+ // waiting for a human, "chat" cannot edit files at all. "smart_approve" is the bounded setting that still lets
282
+ // a run work, and it is set here rather than left to the operator's ~/.config/goose/config.yaml so a run never
283
+ // inherits a looser mode from the machine.
284
+ env: () => ({ GOOSE_MODE: "smart_approve" }),
285
+ // goose does not print the session name back in its JSONL; the daemon already knows it because it generated
286
+ // it, so there is nothing to parse and nothing to guess.
287
+ sessionFrom: () => null,
288
+ outcome(lines) {
289
+ const evs = events(lines);
290
+ const complete = evs.findLast(ev => ev.type === "complete");
291
+ const errored = evs.findLast(ev => ev.type === "error");
292
+ // goose streams an assistant reply as several `message` events, so the last one is a fragment, not the
293
+ // answer. Verified: resuming a session that had been told 8585 produced "858" then "5" as two events.
294
+ // Joining every assistant chunk is the only way to read the reply back whole.
295
+ const chunks = evs.filter(ev => ev.type === "message" && ev.message?.role === "assistant")
296
+ .flatMap(ev => (ev.message.content || []).filter(c => c.type === "text" && typeof c.text === "string").map(c => c.text));
297
+ const text = chunks.join("");
298
+ const lastText = text || null;
299
+ // Goose 1.50.1 emits provider failures as a visible assistant message followed by `complete`, with exit 0.
300
+ // This is the CLI's failure envelope, not a successful model answer (observed with a provider HTTP 404).
301
+ // Its prefix must begin a native text block, not an example embedded in model prose. Read forward from
302
+ // those boundaries in the joined stream so split prefixes and failures after earlier output still work.
303
+ let providerError = null;
304
+ let offset = 0;
305
+ for (const chunk of chunks) {
306
+ if (text.startsWith("Ran into this error: Request failed:", offset)) {
307
+ providerError = text.slice(offset + "Ran into this error: ".length);
308
+ break;
309
+ }
310
+ offset += chunk.length;
311
+ }
312
+ // Authentication failures can instead be typed content blocks, also followed by `complete` and exit 0.
313
+ const contentError = evs.flatMap(ev => ev.message?.content || []).find(c => c.type === "error");
314
+ const credits = evs.flatMap(ev => ev.message?.content || []).find(c => c.type === "systemNotification" && c.notificationType === "creditsExhausted");
315
+ if (!complete && !errored) return { ok: false, finished: false, lastMessage: lastText, denials: [], cost: null, error: "no complete event" };
316
+ return {
317
+ ok: !errored && !providerError && !contentError && !credits, finished: true, lastMessage: lastText, denials: [],
318
+ cost: complete?.cost_usd ?? null,
319
+ error: errored ? (errored.error?.message || errored.message || "run failed") : providerError || (contentError ? contentError.message || contentError.kind || "provider error" : null) || (credits ? `creditsExhausted: ${credits.msg || "provider credits exhausted"}` : null),
320
+ };
321
+ },
322
+ // Like gemini: goose keeps provider keys in ~/.config/goose and has no status command that checks them
323
+ // without making a call, so this proves only that the binary runs. Same weaker guarantee, same consequence.
324
+ signIn: { scope: "local-binary", args: ["--version"], ok: code => code === 0, fix: "goose configure" },
325
+ version: { args: ["--version"], parse: out => (out.match(/(\d+\.\d+\.\d+)/) || [])[1] },
326
+ // `goose run` has no directory flag at all. Same as opencode: no effect rather than a refusal.
327
+ context: null,
328
+ attach: ({ session }) => `goose run --resume --name ${session} -s`,
329
+ },
330
+ };
331
+
332
+ // This is the capability registry; CLI adapters above remain its compatibility view.
333
+ // Remote providers implement transport; the coordinator owns review decisions.
334
+ export const executionRegistry = Object.freeze({
335
+ ...Object.fromEntries(Object.entries(adapters).map(([name, adapter]) => [name, Object.freeze({
336
+ name, adapter, available: true, transport: EXECUTION_TRANSPORTS[name], authentication: "operator-cli",
337
+ environments: ["isolated-checkout"], continuation: "native-session", observability: ["jsonl", "process"],
338
+ controls: { cancel: true, interrupt: true }, cost: { source: name === "claude" ? "provider-usd" : "unknown-or-provider", hardCap: name === "claude" },
339
+ policy: name === "codex" ? ["workspace-write", "auto-review"] : name === "claude" ? ["configured-hooks"] : [],
340
+ preparation: adapter.preparation || null,
341
+ })])),
342
+ "openai-agents": Object.freeze({ name: "openai-agents", available: true, isSession: value => typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(value), transport: EXECUTION_TRANSPORTS["openai-agents"], authentication: "project-api-key", environments: ["openai_hosted"], continuation: "native-session", observability: ["events", "saved-items", "polling"], controls: { cancel: true, interrupt: false }, cost: { source: "api-usage-and-hosted-compute", hardCap: false }, policy: [], preparation: agentsPreparation }),
343
+ "codex-cloud": Object.freeze({ name: "codex-cloud", available: false, reason: CLOUD_DISPATCH_UNAVAILABLE.reason, fix: CLOUD_DISPATCH_UNAVAILABLE.fix, isSession: value => typeof value === "string" && /^task_[A-Za-z0-9_-]{1,200}$/.test(value), transport: EXECUTION_TRANSPORTS["codex-cloud"], authentication: "operator-chatgpt", environments: [], continuation: "successor-task", observability: ["polling", "summary", "diff", "task-link"], controls: { cancel: false, interrupt: false }, cost: { source: "chatgpt-account", hardCap: false }, policy: [], preparation: null, liveQualified: false }),
344
+ });
345
+ export const capabilitiesFor = name => {
346
+ const c = executionRegistry[name];
347
+ if (!c) throw new Error(`unknown execution provider ${name}`);
348
+ return c;
349
+ };
350
+ export const executionCapabilities = () => Object.values(executionRegistry).map(({ adapter, isSession, preparation, ...capabilities }) => ({
351
+ ...capabilities, preparation: preparation ? { version: preparation.version, boundary: preparation.boundary, authority: preparation.authority, ...(preparation.environment ? { environment: preparation.environment } : {}) } : null,
352
+ }));
353
+
354
+ export const preparationCapability = (provider, capabilities = capabilitiesFor(provider)) => capabilities.preparation || null;
355
+ export const preparationReceipt = (provider, capabilities = capabilitiesFor(provider), account = "operator") => {
356
+ const p = preparationCapability(provider, capabilities);
357
+ if (!p || p.version !== 1 || !p.boundary || !p.authority) {
358
+ const error = new Error(`${provider} preparation is unavailable: its adapter has no enforced read/write boundary or tool-free mode; use a hand-authored PRD with ready/start until an isolated preparation adapter is supported; no provider call was made`);
359
+ error.code = "TASK_PREPARATION_BOUNDARY";
360
+ throw error;
361
+ }
362
+ return { version: 1, provider, account, boundary: p.boundary, authority: p.authority };
363
+ };
364
+ export function isPreparationReceipt(provider, receipt, capabilities = capabilitiesFor(provider), account = "operator") {
365
+ const p = preparationCapability(provider, capabilities);
366
+ if (!p || p.version !== 1) return false;
367
+ // Version-1 intake stored only the boundary string. Its selected provider must
368
+ // still declare that exact contract; another adapter's boundary cannot qualify it.
369
+ if (typeof receipt === "string") return receipt === p.boundary;
370
+ return receipt?.version === 1 && receipt.provider === provider && receipt.account === account && receipt.boundary === p.boundary && receipt.authority === p.authority;
371
+ }
372
+
373
+ export const adapterFor = name => {
374
+ const a = adapters[name];
375
+ if (!a) throw new Error(`unknown harness "${name}"; known: ${Object.keys(adapters).join(", ")}`);
376
+ return a;
377
+ };
378
+
379
+ /**
380
+ * Refuse any argv that switches a harness's own safety off. The daemon never needs these, and the check is on the
381
+ * resolved argv rather than on each adapter so a flag cannot arrive from a prompt, a config value or a future edit
382
+ * to one adapter without passing here first.
383
+ *
384
+ * What the equivalent of --dangerously-skip-permissions is for each harness, as of the versions above:
385
+ *
386
+ * claude --dangerously-skip-permissions
387
+ * codex --dangerously-bypass-approvals-and-sandbox, and the same thing spelled as a config override:
388
+ * approval policy alone does not remove the sandbox, but sandbox_mode="danger-full-
389
+ * access" removes the sandbox, so the override VALUE is checked too, not just the flag name.
390
+ * gemini -y / --yolo, and --approval-mode yolo, which are the same switch by two spellings.
391
+ * opencode --auto, whose own help calls it "auto-approve permissions that are not explicitly denied
392
+ * (dangerous!)".
393
+ * goose no CLI flag at all. goose is bounded by the GOOSE_MODE env var, so there is nothing in argv to
394
+ * refuse; the adapter pins GOOSE_MODE=smart_approve instead. GOOSE_MODE is checked in assertSafeEnv.
395
+ */
396
+ const DANGEROUS_FLAGS = new Set(["-y", "--yolo", "--auto"]);
397
+ const DANGEROUS_VALUES = [/^sandbox_mode=.*danger/i, /^yolo$/i];
398
+ // A flag whose value makes it dangerous, and the values that do. Both spellings are one rule: a CLI that accepts
399
+ // `--flag value` accepts `--flag=value`, so a list of literal spellings refused one and let the other through.
400
+ const DANGEROUS_WITH_VALUE = [
401
+ ["--approval-mode", /^yolo$/i],
402
+ ["--sandbox", /danger/i],
403
+ ["-s", /danger/i],
404
+ ["--yolo", /^(true|1|yes)$/i],
405
+ ];
406
+
407
+ export function assertSafeArgv(args) {
408
+ for (let i = 0; i < args.length; i++) {
409
+ const a = String(args[i]);
410
+ if (/^--dangerously-/.test(a) || DANGEROUS_FLAGS.has(a)) throw new Error(`refusing to run with ${a}`);
411
+ for (const [flag, bad] of DANGEROUS_WITH_VALUE) {
412
+ // `--flag value` is two argv elements, `--flag=value` is one. Refuse both, or the `=` form is a way past this.
413
+ const eq = a.startsWith(`${flag}=`) ? a.slice(flag.length + 1) : null;
414
+ if (eq !== null ? bad.test(eq) : a === flag && bad.test(String(args[i + 1] ?? ""))) throw new Error(`refusing to run with ${a}${eq === null ? ` ${args[i + 1]}` : ""}`);
415
+ }
416
+ if (DANGEROUS_VALUES.some(re => re.test(a))) throw new Error(`refusing to run with ${a}`);
417
+ }
418
+ return args;
419
+ }
420
+
421
+ /**
422
+ * Whether a string is a session id this harness could have minted. The block already holds every session it reads
423
+ * off GitHub to this rule (`lib/block.mjs`); `register --session` is the one source that did not, and its value goes
424
+ * straight into argv position 2 after `--resume` or `--name`. `assertSafeArgv` cannot save it: it matches flag
425
+ * spellings, and a value in an unexpected position is not a spelling. So the flag and the block agree from here.
426
+ */
427
+ export const isSessionFor = (harness, session) => (executionRegistry[harness]?.isSession || adapters[harness]?.isSession)?.(String(session ?? "")) === true;
428
+
429
+ /** The same refusal for the environment, because goose's permission mode is an env var and not a flag. */
430
+ export function assertSafeEnv(env) {
431
+ const mode = env.GOOSE_MODE;
432
+ if (mode && !["smart_approve", "approve", "chat"].includes(mode)) throw new Error(`refusing to run with GOOSE_MODE=${mode}`);
433
+ return env;
434
+ }
435
+
436
+ /**
437
+ * How a harness handles extra read-only directories: "grant" when it has a flag for exactly that, "refuse" when
438
+ * its nearest flag would also grant write or would grant the whole disk, "none" when it has no such flag at all.
439
+ * `doctor` prints this so an operator with a list and the wrong harness is told, not left to guess.
440
+ */
441
+ export function contextSupport(harness) {
442
+ const c = adapterFor(harness).context;
443
+ if (!c) return { kind: "none", detail: `${harness} has no flag that adds a directory; context_paths has no effect on its runs` };
444
+ if (c.refuse) return { kind: "refuse", detail: c.refuse };
445
+ return { kind: "grant", detail: `${harness} grants read on each path and writes stay bounded as before` };
446
+ }
447
+
448
+ /**
449
+ * A pure launch description: { cmd, args, env, runtimeSettings? }. `mode` is "start" or "resume".
450
+ * Prompts are passed as the final argv element; both CLIs accept that and it keeps stdin free.
451
+ *
452
+ * `contextPaths` are extra directories the run may READ, already resolved and validated by
453
+ * `resolveContextPaths`/`validateContextPaths` in lib/config.mjs. An empty list renders nothing, so a run with no
454
+ * extras has exactly the argv it had before this existed. A harness that cannot grant read on a specific directory
455
+ * without also granting write, or without granting the whole disk, refuses the run rather than escalating; one with
456
+ * no directory flag at all ignores the list, because there is nothing to widen and a failed run would help nobody.
457
+ */
458
+ export function buildRun({ harness, config, mode, session, prompt, cwd, contextPaths = [], runtimeSetup = true }) {
459
+ const a = adapterFor(harness);
460
+ const h = config.harness[harness];
461
+ if (!h?.bin) throw new Error(`harness.${harness}.bin is not configured`);
462
+ // The shell may resolve another installation. A real plugin validation launched an old PATH Codex from a
463
+ // healthy newer parent and misdiagnosed its model rejection as an account problem. Carry the exact executable
464
+ // on every dispatch, including resumes; changing PATH alone would not survive a login shell's startup files.
465
+ const runtime = [
466
+ `Gallivant launched this session with the configured ${harness} executable: ${JSON.stringify(h.bin)}.`,
467
+ "For task-authorized tests of this harness, invoke that exact executable, quoted as one path, instead of assuming the bare command on PATH is the same installation. This does not authorize nested jobs, model/provider changes, extra paid calls or weaker permissions. Keep the task's existing scope and security settings.",
468
+ "Before substantial implementation or retrying blocked work, check the brief's mandatory external prerequisites: required host/plugin support, model access and any browser or capture surface. Use the available native tools and the smallest authorized check; version/sign-in checks alone do not prove model access or installed-plugin behavior. If a required capability is unavailable, report the observed failure, affected criteria and exact remedy early. Continue useful independent work, preserve unmet attestations, and do not repeat unchanged blocked calls or replace required live evidence with fixtures.",
469
+ ].join("\n");
470
+ const ctx = { session, prompt: `${runtime}\n\n${prompt}`, budget: config.max_budget_usd, settings: h.settings, cwd };
471
+ const base = mode === "start" ? a.start(ctx) : a.resume(ctx);
472
+ const args = assertSafeArgv(withContext(a, base, contextPaths));
473
+ const env = assertSafeEnv({ ...runtimeEnvironment(process.env, process.execPath, cwd), ...a.env({ configDir: h.settings ? dirname(h.settings) : null }) });
474
+ // Construction runs before cloning and again when managed input changes. Only
475
+ // the runner may materialize settings, once it owns an actual launch directory.
476
+ return { cmd: h.bin, args, env, ...(harness === "claude" && runtimeSetup && h.settings
477
+ ? { runtimeSettings: { settings: h.settings, node: process.execPath } } : {}) };
478
+ }
479
+
480
+ // A workspace and a private HOME are not a filesystem boundary. The existing
481
+ // implementation adapters remain unchanged; preparation needs a separate,
482
+ // enforceable contract before untrusted context can reach a model with tools.
483
+ // Compatibility export for old integrations; lifecycle gates use the selected
484
+ // capability and versioned receipt, never this adapter-specific constant.
485
+ export const PREPARATION_BOUNDARY = "claude-no-tools-v1";
486
+ export function buildPreparationRun(options, capabilities = capabilitiesFor(options.harness)) {
487
+ const receipt = preparationReceipt(options.harness, capabilities);
488
+ const capability = preparationCapability(options.harness, capabilities);
489
+ if (typeof capability.render !== "function") throw new Error("CLI preparation capability requires an enforced argv renderer");
490
+ // Tool-free preparation must not install executable session hooks or create
491
+ // settings snapshots outside its separately enforced preparation boundary.
492
+ const spec = buildRun({ ...options, runtimeSetup: false });
493
+ spec.args = assertSafeArgv(capability.render(spec));
494
+ return { ...spec, boundary: receipt.boundary, configure: capability.configure, minimumVersion: capability.minimumVersion };
495
+ }
496
+
497
+ /**
498
+ * The adapter's argv with the read-only directories folded in. The flags go BEFORE the prompt, which every adapter
499
+ * passes last, so nothing turns a path into part of the brief.
500
+ */
501
+ /**
502
+ * Insert a harness's context-path flags, keeping the prompt last.
503
+ *
504
+ * The flags go at the FRONT, not immediately before the prompt. `--add-dir` takes a variadic list, so
505
+ * `--add-dir <dir> <prompt>` reads the prompt as another directory and Claude Code then refuses the run with
506
+ * "Input must be provided either through stdin or as a prompt argument when using --print". That is a real failure
507
+ * seen on a live run: the session started, the hook fired, and the harness exited before doing any work. Putting the
508
+ * flags ahead of everything leaves the prompt adjacent to nothing that can swallow it.
509
+ */
510
+ function withContext(a, args, paths) {
511
+ if (!paths.length) return args;
512
+ if (!a.context) return args; // no flag exists: the list has no effect here
513
+ if (a.context.refuse) throw new Error(`refusing to run ${a.name} with context_paths: ${a.context.refuse}`);
514
+ return [...a.context.render(paths), ...args];
515
+ }
516
+
517
+ export const compareVersions = (a, b) => {
518
+ const pa = String(a).split(".").map(Number), pb = String(b).split(".").map(Number);
519
+ for (let i = 0; i < 3; i++) { if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0); }
520
+ return 0;
521
+ };