@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,15 @@
1
+ // Portable execution metadata shared by projections and Node transport adapters.
2
+ export const EXECUTION_TRANSPORTS = Object.freeze({
3
+ claude: "cli", codex: "cli", gemini: "cli", opencode: "cli", goose: "cli",
4
+ "openai-agents": "remote", "codex-cloud": "remote",
5
+ });
6
+ export const PROVIDER_STATES = Object.freeze(["queued", "provisioning", "running", "waiting-for-input", "retrieving-results", "succeeded", "failed", "cancellation-requested", "cancelled", "status-unknown"]);
7
+ export const providerTerminal = state => ["succeeded", "failed", "cancelled"].includes(state);
8
+ const exactIdentity = id => typeof id === "string" && !!id.trim();
9
+ export const ownedRemoteExecution = ex => exactIdentity(ex?.providerJobId) && exactIdentity(ex?.turnId);
10
+ export const executionLive = job => {
11
+ const ex = job?.execution;
12
+ if (!ex) return false;
13
+ const remote = (ex.transport ?? EXECUTION_TRANSPORTS[ex.provider]) === "remote";
14
+ return (remote && (ex.acceptance !== "accepted" || !ownedRemoteExecution(ex))) || !providerTerminal(ex.providerState) || !ex.collected;
15
+ };
@@ -0,0 +1,4 @@
1
+ import { agentsInput } from "./openai_agents_input.mjs";
2
+ import { cloudInput } from "./codex_cloud_input.mjs";
3
+ export const remoteInput = (ctx, id, entry, input, contextPaths) => entry.harness === "codex-cloud"
4
+ ? cloudInput(ctx, id, entry, input, contextPaths) : agentsInput(ctx, id, entry, input, contextPaths);
@@ -0,0 +1,94 @@
1
+ // Every transport operation must honor signal; a missed deadline still fences its late reply.
2
+ // A transport operation includes native verification startup as well as I/O.
3
+ // Two seconds expired during healthy sandbox startup before any provider call.
4
+ // Keep one configurable, bounded deadline and the existing late-reply fences.
5
+ export const EXECUTION_LIMITS = Object.freeze({ deadlineMs: 20_000, progressMs: 10_000, maxBackoffMs: 300_000, concurrency: 4, eventBytes: 65_536, events: 100 });
6
+ export const executionError = (code, message) => Object.assign(new Error(message), { code });
7
+ export function accessProbeFailure(error,signal,fallback,fix=null) {
8
+ const timeout=error?.name==='TimeoutError' || signal?.reason?.name==='TimeoutError';
9
+ const aborted=signal?.aborted || error?.name==='AbortError';
10
+ const detail=timeout?'EXECUTION_DEADLINE':aborted?'EXECUTION_ABORTED':typeof error?.code==='string'?error.code:fallback;
11
+ return {ok:false,detail,fix:timeout?'Provider access check exceeded its configured deadline; inspect host/network latency and execution.deadlineMs. This read-only check did not submit implementation.':aborted?'Provider access check was cancelled; no later response authorizes implementation.':fix||redact(String(error?.message||fallback)),
12
+ ...(Number.isFinite(error?.retryAfterMs)?{retryAfterMs:error.retryAfterMs}:{})};
13
+ }
14
+ // Only deterministic validation of a completed, owned provider result uses this
15
+ // marker. Network, authentication, missing delivery and local I/O stay retryable.
16
+ export const collectionValidationError = message => Object.assign(executionError("EXECUTION_RESULT", message), { collectionValidation: true });
17
+ // Provider records stay claims; the shared writer runs its own verification.
18
+ // Each recorded command must still identify the owned revision and integer exit.
19
+ export const validProviderTestRecords = (tests, inputHead) => Array.isArray(tests) &&
20
+ tests.every(record => record && typeof record.command === "string" && record.inputHead === inputHead && Number.isInteger(record.exit));
21
+ // A trusted transport may mark only local failures before its durable operation
22
+ // and every provider request. Error codes or missing replies alone prove nothing.
23
+ export const dispatchNotSentError = error => Object.assign(error, { dispatchNotSent: true });
24
+ export const EXECUTION_CURSOR_BYTES = 4096;
25
+ export function assertExecutionCursor(cursor, { persisted = false } = {}) {
26
+ if (cursor == null) return;
27
+ // Reject objects before serialization: custom toJSON, cycles and BigInt
28
+ // cannot become durable paging tokens. Bound escaping and UTF-8 as saved.
29
+ const scalar = typeof cursor === "boolean" || (typeof cursor === "number" && Number.isFinite(cursor)) ||
30
+ (typeof cursor === "string" && cursor.length <= EXECUTION_CURSOR_BYTES);
31
+ if (!scalar || Buffer.byteLength(JSON.stringify(cursor)) > EXECUTION_CURSOR_BYTES) {
32
+ throw executionError("EXECUTION_PROTOCOL", persisted
33
+ ? "invalid persisted provider cursor; preserve the run files and restore its exact bounded provider checkpoint before restarting; do not redispatch"
34
+ : "provider cursor must be a JSON scalar of at most 4096 serialized UTF-8 bytes; return a bounded paging token");
35
+ }
36
+ }
37
+ // Omitted sessions retain recorded ownership; reported sessions must be exact.
38
+ export const ownsProviderSession = (owned, reported) => reported == null ||
39
+ (typeof reported === "string" && !!reported.trim() && (owned == null || reported === owned));
40
+ // A collected outcome cannot replace an authoritative root-turn observation.
41
+ // Older collected runs may retain that observation only in providerState.
42
+ export function assertTerminalOutcome(execution, outcome) {
43
+ const observed = execution?.terminalState ?? execution?.providerState;
44
+ if (["succeeded", "failed"].includes(observed) &&
45
+ (outcome?.finished !== true || typeof outcome.ok !== "boolean" || outcome.ok !== (observed === "succeeded"))) {
46
+ throw executionError("EXECUTION_RESULT", "result outcome contradicts the authoritative terminal root; reconcile the exact completed turn");
47
+ }
48
+ }
49
+ export async function boundedCall(fn, { deadlineMs = EXECUTION_LIMITS.deadlineMs } = {}) {
50
+ const controller = new AbortController();
51
+ let timer;
52
+ try {
53
+ return await Promise.race([
54
+ Promise.resolve().then(() => fn(controller.signal)),
55
+ new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(executionError("EXECUTION_DEADLINE", "transport deadline exceeded; reconcile acceptance before retrying")); }, deadlineMs); }),
56
+ ]);
57
+ } finally { clearTimeout(timer); }
58
+ }
59
+ export function nextAttempt(now, failures, retryAfterMs = 0, limits = EXECUTION_LIMITS, jitter = Math.random) {
60
+ const backoff = limits.progressMs * 2 ** Math.min(failures, 10);
61
+ const delay = Number.isFinite(retryAfterMs) ? Math.max(0, retryAfterMs) : 0;
62
+ return now + Math.min(limits.maxBackoffMs, Math.max(delay, backoff + Math.floor(backoff * 0.1 * jitter())));
63
+ }
64
+ // Redaction precedes persistence in projections, journals and result evidence. Callers
65
+ // also supply resolved secrets, including tokens whose formats cannot be recognized.
66
+ export function redact(value, secrets = []) {
67
+ const clean = text => {
68
+ let out = text;
69
+ // Replace enclosing credentials before their substrings, regardless of
70
+ // environment order. Filtering produces a copy, preserving caller input.
71
+ for (const secret of secrets.filter(s => typeof s === "string" && s.length).sort((a, b) => b.length - a.length)) out = out.split(secret).join("[redacted]");
72
+ return out.replace(/\b(?:sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9_]{12,}|github_pat_[A-Za-z0-9_]{12,})\b/g, "[redacted]")
73
+ .replace(/(Bearer\s+)[^\s"\\]+/gi, "$1[redacted]")
74
+ .replace(/((?:api[_-]?key|token|password|secret|authorization)"?\s*[=:]\s*"?)[^"\s,;]+/gi, "$1[redacted]");
75
+ };
76
+ if (typeof value === "string") return clean(value);
77
+ if (Array.isArray(value)) return value.map(v => redact(v, secrets));
78
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, /^(?:api[_-]?key|token|password|secret|authorization|credentials)$/i.test(k) ? "[redacted]" : redact(v, secrets)]));
79
+ return value;
80
+ }
81
+
82
+ // Ownership and artifact manifests are private protocol/publication input:
83
+ // redaction cannot change routing IDs, revisions, repository paths or hashes.
84
+ // Callers validate ownership; public readers must redact their own projection.
85
+ export function redactResultMetadata(result, secrets = []) {
86
+ const { files, artifacts: _artifacts, ...metadata } = result;
87
+ const clean = redact(metadata, secrets);
88
+ for (const key of ["taskId", "runId", "providerJobId", "sessionId", "turnId", "inputHead", "repo", "branch", "pushedHead"]) {
89
+ if (Object.hasOwn(result, key)) clean[key] = result[key];
90
+ }
91
+ if (files !== undefined) clean.files = structuredClone(files);
92
+ clean.artifacts = redact(Array.isArray(files) ? files.slice(0, 100).map(file => ({ path: file?.path, hash: file?.afterHash })) : [], secrets);
93
+ return clean;
94
+ }
@@ -0,0 +1,69 @@
1
+ import { join } from "node:path";
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, appendFileSync, fsyncSync, openSync, closeSync, statSync, readSync, truncateSync } from "node:fs";
4
+ import { ensureDir, readJson, writeJsonAtomic } from "./fs.mjs";
5
+ import { redact } from "./execution_io.mjs";
6
+ import { appendProjection, LOG_LIMITS } from "./run_log.mjs";
7
+ export const JOURNAL_VERSION = 1;
8
+ const size = file => { try { return statSync(file).size; } catch { return 0; } };
9
+ export function journalFor(dir, { taskId, runId, provider, secrets = [] }) {
10
+ ensureDir(dir);
11
+ const stateFile = join(dir, "journal.json"), events = join(dir, "events.jsonl");
12
+ const keys = ensureDir(join(dir, "event-keys"));
13
+ const load = () => {
14
+ const s = readJson(stateFile, { version: JOURNAL_VERSION, sequence: 0 });
15
+ if (s.version !== JOURNAL_VERSION) throw new Error("unsupported journal version; restore a matching backup/package before replay");
16
+ return s;
17
+ };
18
+ const project = record => appendProjection(dir, typeof record.data === "string" && record.data ? record.data : JSON.stringify(record), { sequence: record.sequence });
19
+ // Repair an interrupted append before accepting another event. Inspect at most
20
+ // one bounded record at the saved offset, never scan the journal in full.
21
+ const recover = () => {
22
+ const s = load(), pending = s.pending;
23
+ if (!pending) return;
24
+ let record = null, terminated = false;
25
+ if (existsSync(events)) {
26
+ if (size(events) < pending.offset) throw new Error("acknowledged journal prefix is truncated; preserve the run files and restore a matching journal backup before replay");
27
+ const fd = openSync(events, "r");
28
+ try {
29
+ const buf = Buffer.alloc(LOG_LIMITS.lineBytes + 1);
30
+ const n = readSync(fd, buf, 0, buf.length, pending.offset);
31
+ terminated = buf.subarray(0, n).includes(10);
32
+ try { record = JSON.parse(buf.subarray(0, n).toString("utf8").split("\n")[0]); } catch { /* interrupted record */ }
33
+ } finally { closeSync(fd); }
34
+ }
35
+ if (record?.sequence !== s.sequence) {
36
+ record = { version: JOURNAL_VERSION, taskId, runId, provider, sequence: s.sequence, eventId: pending.eventId, timestamp: pending.timestamp, type: "gap", provenance: "coordinator", data: "interrupted journal append; this event's history is unrecoverable" };
37
+ // Preserve the acknowledged prefix and remove partial/invalid JSON before
38
+ // replacing the pending event. The acknowledged prefix must stay intact.
39
+ if (existsSync(events)) truncateSync(events, pending.offset);
40
+ appendFileSync(events, JSON.stringify(record) + "\n", { mode: 0o600, flush: true });
41
+ } else if (!terminated) {
42
+ // The JSON was fully written but its delimiter was not acknowledged.
43
+ appendFileSync(events, "\n", { mode: 0o600, flush: true });
44
+ }
45
+ writeJsonAtomic(join(keys, pending.hash), { sequence: s.sequence, eventId: pending.eventId });
46
+ if (Math.floor(size(join(dir, "job.idx")) / 8) < s.sequence) project({ ...record, type: "gap", data: "[gap: interrupted projection recovered; consult normalized history]" });
47
+ writeJsonAtomic(stateFile, { version: JOURNAL_VERSION, sequence: s.sequence });
48
+ };
49
+ recover();
50
+ return { recover, append(input) {
51
+ if (typeof input.eventId !== "string" || !input.eventId || input.eventId.length > 512) throw new Error("journal requires a bounded provider event identity");
52
+ recover();
53
+ const state = load();
54
+ const identity = [provider, input.providerJobId ?? null, input.turnId ?? null, input.eventId];
55
+ const hash = createHash("sha256").update(JSON.stringify(identity)).digest("hex");
56
+ const key = join(keys, hash);
57
+ if (existsSync(key)) return readJson(key);
58
+ const sequence = state.sequence + 1;
59
+ let record = redact({ ...input, providerSequence: input.sequence ?? null, version: JOURNAL_VERSION, taskId, runId, provider, sequence, provenance: input.provenance || "provider", timestamp: input.timestamp ?? Date.now() }, secrets);
60
+ if (Buffer.byteLength(JSON.stringify(record)) + 1 > LOG_LIMITS.lineBytes) record = { version: JOURNAL_VERSION, taskId, runId, provider, sequence, eventId: record.eventId, type: "gap", timestamp: record.timestamp, provenance: record.provenance, data: "oversized provider record omitted" };
61
+ writeJsonAtomic(stateFile, { version: JOURNAL_VERSION, sequence, pending: { hash, eventId: record.eventId, timestamp: record.timestamp, offset: size(events) } });
62
+ appendFileSync(events, JSON.stringify(record) + "\n", { mode: 0o600, flush: true });
63
+ writeJsonAtomic(key, { sequence, eventId: record.eventId });
64
+ project(record);
65
+ for (const file of ["job.log", "job.idx"]) { const fd = openSync(join(dir, file), "r"); try { fsyncSync(fd); } finally { closeSync(fd); } }
66
+ writeJsonAtomic(stateFile, { version: JOURNAL_VERSION, sequence });
67
+ return { sequence, eventId: record.eventId };
68
+ } };
69
+ }
@@ -0,0 +1,29 @@
1
+ // Local preparation is an execution operation with a smaller adapter authority.
2
+ // A private HOME alone never qualifies that authority. Native policy lives in
3
+ // the selected capability, not in task drafting/clarification/repair callers.
4
+ import { buildPreparationRun, adapterFor, preparationReceipt, assertSafeArgv, assertSafeEnv } from "./harness.mjs";
5
+ import { preparationEnvironment } from "./task_environment.mjs";
6
+ import { requireHarness } from "./probe.mjs";
7
+ import { ensureDir } from "./fs.mjs";
8
+ import { MAX_PREPARATION_PROMPT_BYTES } from "./task_contract.mjs";
9
+ import { taskError } from "./task_store.mjs";
10
+ import { boundedCall } from "./execution_io.mjs";
11
+
12
+ export async function prepareLocalExecution(provider, capabilities, account, input, build = buildPreparationRun, limits = {}) {
13
+ const receipt = preparationReceipt(provider, capabilities, account);
14
+ const adapter = adapterFor(provider);
15
+ let spec = build({ harness: provider, config: input.config, mode: "start", session: adapter.newSession(), prompt: input.prompt, cwd: input.cwd }, capabilities);
16
+ const check = () => {
17
+ if (spec.boundary !== receipt.boundary) throw taskError("TASK_PREPARATION_BOUNDARY", "preparation builder returned a different authority; no provider call was made");
18
+ assertSafeArgv(spec.args);
19
+ if (typeof spec.args.at(-1) !== "string" || Buffer.byteLength(spec.args.at(-1)) > MAX_PREPARATION_PROMPT_BYTES) throw taskError("TASK_INPUT", `serialized preparation prompt exceeds ${MAX_PREPARATION_PROMPT_BYTES} bytes; select fewer or smaller context files, or prepare a smaller task; saved requirements were retained`);
20
+ };
21
+ check(); // Reject oversized input before a probe or launch intent.
22
+ await requireHarness(provider, input.config, { minimumVersion: spec.minimumVersion });
23
+ ensureDir(input.cwd);
24
+ const env = preparationEnvironment(provider, input.cwd, spec.env, input.sourceEnv, input.callerCwd);
25
+ if (spec.configure) spec = await boundedCall(signal => spec.configure(spec, { cwd: input.cwd, env, signal }), limits);
26
+ check(); // An adapter's final configuration cannot replace its authority.
27
+ assertSafeEnv(env);
28
+ return { receipt, environment: input.environment, spec: { cmd: spec.cmd, args: spec.args, options: { cwd: input.cwd, env, inheritEnv: false } } };
29
+ }
@@ -0,0 +1,266 @@
1
+ // Result transport ends here. Git/gh own the one publication path; the existing
2
+ // tick still owns readiness, receipts, review rounds, conflicts and human merge.
3
+ import { createHash } from "node:crypto";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { unlinkSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
7
+ import { repositoryPath, repositoryRelativePath, assertRepositoryTargets, readRepositoryText, writeRepositoryText } from "./repository_files.mjs";
8
+ import { isOurBranch, runEnv } from "./git.mjs";
9
+ import { ensureDir, readJson, writeJsonAtomic } from "./fs.mjs";
10
+ import { execFileAsync, remoteMatches } from "./gh.mjs";
11
+ import { parsePrd, verifyCommands } from "./prd.mjs";
12
+ import { normalizePublicBody } from "./block.mjs";
13
+ import { assertPublicText, assertNoNul } from "./public_output.mjs";
14
+ import { executionError, ownsProviderSession, assertTerminalOutcome, redact } from "./execution_io.mjs";
15
+ import { acquireProcessLock } from "./daemon.mjs";
16
+ import { idSlug } from "./state.mjs";
17
+ import { prepareVerificationSandbox, validVerificationSandbox, verificationSandboxUnavailable } from "./verification_sandbox.mjs";
18
+ const hash = text => text === null ? null : createHash("sha256").update(text).digest("hex");
19
+
20
+ // Verification executes provider-controlled code, never coordinator credentials.
21
+ // Keep only tooling/display basics and an empty private profile for this list.
22
+ export function verificationEnvironment(root, source = process.env) {
23
+ const basics = ["PATH", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "TERM", "COLORTERM", "NO_COLOR", "FORCE_COLOR", "CI"];
24
+ const env = Object.fromEntries(basics.filter(key => typeof source[key] === "string").map(key => [key, source[key]]));
25
+ const git = runEnv(), count = Number(git.GIT_CONFIG_COUNT);
26
+ const npmUser = join(root, "npm-user.conf"), npmGlobal = join(root, "npm-global.conf");
27
+ // npm rejects one file loaded twice as both user and global configuration.
28
+ for (const path of [npmUser, npmGlobal]) writeFileSync(path, "", { mode: 0o600 });
29
+ return { ...env, PATH: env.PATH || "/usr/bin:/bin", ...git,
30
+ [`GIT_CONFIG_KEY_${count}`]: "credential.helper", [`GIT_CONFIG_VALUE_${count}`]: "", GIT_CONFIG_COUNT: String(count + 1),
31
+ HOME: root, USERPROFILE: root, XDG_CONFIG_HOME: ensureDir(join(root, "config")), XDG_DATA_HOME: ensureDir(join(root, "data")),
32
+ XDG_STATE_HOME: ensureDir(join(root, "state")), XDG_CACHE_HOME: ensureDir(join(root, "cache")),
33
+ TMPDIR: ensureDir(join(root, "tmp")), TMP: join(root, "tmp"), TEMP: join(root, "tmp"),
34
+ GH_CONFIG_DIR: ensureDir(join(root, "gh")), GH_PROMPT_DISABLED: "1", GIT_TERMINAL_PROMPT: "0",
35
+ GIT_SSH_COMMAND: "false", GIT_ASKPASS: "false", SSH_ASKPASS: "false", NPM_CONFIG_USERCONFIG: npmUser, NPM_CONFIG_GLOBALCONFIG: npmGlobal };
36
+ }
37
+
38
+ // Readiness uses the same native enforcement and empty profile as publication,
39
+ // in disposable directories before a checkout exists. No repository code runs.
40
+ export async function checkVerificationSandbox({ signal, sandbox = prepareVerificationSandbox } = {}) {
41
+ let root;
42
+ try {
43
+ signal?.throwIfAborted();
44
+ root = mkdtempSync(join(tmpdir(), "verification-readiness-"));
45
+ const worktree = ensureDir(join(root, "checkout")), home = ensureDir(join(root, "profile"));
46
+ const result = await sandbox({ worktree, home, env: verificationEnvironment(home), signal });
47
+ signal?.throwIfAborted();
48
+ if (!validVerificationSandbox(result?.evidence)) throw verificationSandboxUnavailable();
49
+ return { ok: true, detail: `${result.evidence.driver} filesystem/network enforcement passed`, evidence: result.evidence };
50
+ } catch {
51
+ const failure = verificationSandboxUnavailable();
52
+ return { ok: false, detail: failure.code, fix: failure.message };
53
+ } finally { if (root) rmSync(root, { recursive: true, force: true }); }
54
+ }
55
+
56
+ export async function requireVerificationSandbox(options) {
57
+ const result = await checkVerificationSandbox(options);
58
+ if (!result.ok) throw executionError(result.detail, result.fix);
59
+ return result.evidence;
60
+ }
61
+
62
+ export function executionResultCheckpoint(execution, run = execution?.current?.()) {
63
+ return run && execution?.dirOf ? readJson(join(execution.dirOf(run.id), "result-application.json")) : null;
64
+ }
65
+
66
+ // Queueing a stop and starting publication have one order across processes.
67
+ // Progress holds only execution.lock, so it cannot prevent queueing cancellation.
68
+ // Publication takes publication.lock first, then the existing execution lock to
69
+ // exclude direct stop/dispatch controls until its external writes are finished.
70
+ export function acquirePublicationLock(ctx, id, { execution, dir, ownsExecution = false } = {}) {
71
+ const boundary = typeof execution?.state === "function" && typeof execution.root === "string";
72
+ const root = boundary ? execution.root : ctx.home ? join(ctx.home, "runs", idSlug(id)) : dir;
73
+ let publication, owned;
74
+ try {
75
+ publication = acquireProcessLock(join(ensureDir(root), "publication.lock"), "publication");
76
+ if (ownsExecution && boundary) owned = acquireProcessLock(join(root, "execution.lock"), "execution");
77
+ return { release: () => { try { owned?.release(); } finally { publication.release(); } } };
78
+ } catch (error) {
79
+ publication?.release();
80
+ if (/already running|startup already in progress|lock recovery is in progress/.test(error.message)) {
81
+ throw executionError("EXECUTION_PUBLICATION_BUSY", "Publication or execution is in progress; retry cancellation or publication after it finishes. No cancellation was queued.");
82
+ }
83
+ throw error;
84
+ }
85
+ }
86
+
87
+ export async function reconcileExecutionResult(ctx, input) {
88
+ const checkpoint = join(input.dir, "result-application.json");
89
+ if (readJson(checkpoint)?.phase === "blocked") throw executionError("EXECUTION_RESULT_BLOCKED", "result publication is blocked; inspect result-application.json, result.json and any verification.json, then explicitly resume for correction");
90
+ try { return await applyExecutionResult(ctx, input); }
91
+ catch (error) {
92
+ if (error.code === "EXECUTION_RESULT" && error.publicationValidation) {
93
+ const prior = readJson(checkpoint, {});
94
+ writeJsonAtomic(checkpoint, { ...prior, fingerprint: prior.fingerprint ?? hash(JSON.stringify(input.result)),
95
+ phase: "blocked", reason: "result-validation", blockedAt: new Date().toISOString() });
96
+ }
97
+ throw error;
98
+ }
99
+ }
100
+
101
+ async function applyExecutionResult(ctx, { id, entry, result, dir, verify = execFileAsync, sandbox = prepareVerificationSandbox }) {
102
+ // Collection and unavailable I/O remain retryable; rejected owned publication
103
+ // input requires an explicit corrective run, with no repeated application.
104
+ const fail = (message, retryable = false) => { throw Object.assign(executionError("EXECUTION_RESULT", message), { publicationValidation: !retryable }); };
105
+ const validate = fn => { try { return fn(); } catch (error) { if (error.code) throw error; fail(error.message); } };
106
+ const execution = ctx.executionFor?.(id, entry.harness);
107
+ const assertNotStopped = () => {
108
+ const current = ctx.state?.get(id) || entry;
109
+ const owned = execution?.state?.();
110
+ if (current.dispatch_stopped || current.execution?.quarantined || owned?.stopped || owned?.current?.execution.quarantined) fail("late results are quarantined after a concurrent stop", true);
111
+ };
112
+ assertNotStopped();
113
+ const ex = entry.execution;
114
+ if (!result || typeof result !== "object" || Array.isArray(result)) fail("result requires a bounded object manifest");
115
+ if (!ex || result.taskId !== id || result.runId !== ex.runId || result.providerJobId !== ex.providerJobId || result.turnId !== ex.turnId || result.inputHead !== ex.inputHead || !ownsProviderSession(ex.sessionId, result.sessionId)) fail("result does not belong to this task/run/root turn/input head/session");
116
+ try { assertTerminalOutcome(ex, result.outcome); } catch (error) { fail(error.message); }
117
+ if (result.outcome?.ok === false) fail("failed root-turn results cannot authorize publication; preserve diagnostics and explicitly resume for correction");
118
+ if (entry.dispatch_stopped || ex.quarantined || ["cancelled", "cancellation-requested"].includes(ex.providerState)) fail("late results are quarantined", true);
119
+ if (!isOurBranch(entry.branch) || entry.branch === entry.base || result.repo !== entry.repo || result.branch !== entry.branch) fail("result branch is not owned by the task");
120
+ if (await ctx.git.currentBranch(entry.worktree) !== entry.branch || !remoteMatches(await ctx.git.originOf(entry.worktree), entry.repo)) fail("checkout repository/branch differs from recorded ownership");
121
+ if (!/^[a-f0-9]{40,64}$/.test(result.inputHead || "")) fail("result requires an exact input revision");
122
+ if (!result.pr || typeof result.pr.title !== "string" || typeof result.pr.body !== "string") fail("result requires a reviewable PR title and receipt body");
123
+ validate(() => assertPublicText(result.pr.title, "PR title"));
124
+ validate(() => assertNoNul(result.pr.body, "PR body"));
125
+ const body = validate(() => normalizePublicBody(result.pr.body, { identity: { prd: entry.prd, harness: entry.harness, session: entry.session || result.sessionId, branch: entry.branch }, taskId: id }));
126
+ validate(() => assertPublicText(body, "PR body"));
127
+ const checkpoint = join(dir, "result-application.json");
128
+ const prior = readJson(checkpoint, {});
129
+ const fingerprint = hash(JSON.stringify(result));
130
+ if (prior.fingerprint && prior.fingerprint !== fingerprint) fail("run already owns a different result; inspect the application checkpoint");
131
+ const block = (reason, message, appliedHead = prior.appliedHead) => {
132
+ writeJsonAtomic(checkpoint, { ...readJson(checkpoint, {}), fingerprint, phase: "blocked", appliedHead, reason, blockedAt: new Date().toISOString() });
133
+ throw executionError("EXECUTION_RESULT_BLOCKED", `${message}; preserve the checkout and verification.json, then explicitly resume the same session for a corrective run`);
134
+ };
135
+ if (prior.phase === "blocked") throw executionError("EXECUTION_RESULT_BLOCKED", "result publication is blocked; inspect verification.json and explicitly resume the same session for a corrective run");
136
+ // Commands can have side effects. A lost verification acknowledgement cannot
137
+ // be repaired by replaying the command list on the next coordinator poll.
138
+ if (prior.phase === "verifying") block("verification-interrupted", "verification acknowledgement is unresolved; commands will not be replayed");
139
+ if (prior.phase === "published") return { head: prior.appliedHead, pr: { number: prior.pr, url: prior.url }, evidence: readJson(join(dir, "verification.json"), {}).evidence || [] };
140
+ const verified = prior.phase === "verified";
141
+ const head = await ctx.git.head(entry.worktree);
142
+ const validateTargets = async paths => {
143
+ let tracked;
144
+ try { tracked = await ctx.git.trackedPaths(entry.worktree); }
145
+ catch { fail("tracked repository paths unavailable; preserve the checkout and retrieve complete Git path metadata before publication", true); }
146
+ try { assertRepositoryTargets([], tracked); }
147
+ catch (error) { fail(error.message, true); }
148
+ try { assertRepositoryTargets(paths ?? tracked, tracked); }
149
+ catch (error) { fail(error.message); }
150
+ };
151
+ if (result.pushedHead) {
152
+ if (result.files?.length || await ctx.gh.refSha(entry.repo, entry.branch) !== result.pushedHead || !await ctx.gh.containsCommit(entry.repo, entry.branch, result.inputHead)) fail("pushed result revision or input ancestry cannot be verified");
153
+ if (head !== result.pushedHead) fail("reconcile the exact pushed provider branch into its isolated checkout before collection");
154
+ await validateTargets();
155
+ } else {
156
+ if (!Array.isArray(result.files) || !result.files.length || result.files.length > 100 || Buffer.byteLength(JSON.stringify(result.files)) > 1_048_576) fail("missing, truncated or oversized artifact manifest; use bounded verified artifacts");
157
+ const paths = new Set(), changes = [];
158
+ for (const file of result.files) {
159
+ if (!file || (file.text !== null && typeof file.text !== "string") || file.text?.includes("\0")) fail("duplicate or invalid result file");
160
+ const path = validate(() => repositoryRelativePath(entry.worktree, file.path, { allowMissingParents: true }));
161
+ paths.add(path);
162
+ changes.push({ ...file, path });
163
+ }
164
+ await validateTargets(changes.map(file => file.path));
165
+ let modesMatch;
166
+ try { modesMatch = await ctx.git.resultModesMatch(entry.worktree, result.inputHead, [...paths]); }
167
+ catch { fail("artifact mode metadata unavailable; retrieve complete input, HEAD, index and filesystem modes before publication", true); }
168
+ if (typeof modesMatch !== "boolean") fail("artifact mode metadata unavailable; retrieve complete input, HEAD, index and filesystem modes before publication", true);
169
+ if (modesMatch !== true) fail("artifact target mode differs from its input revision; text artifacts cannot authorize mode changes");
170
+ if (!verified) {
171
+ // Resolve the entire manifest before reading content or applying any file.
172
+ // Commit/recovery ownership uses these same canonical repository targets.
173
+ for (const file of changes) {
174
+ let current = null;
175
+ try { current = validate(() => readRepositoryText(entry.worktree, file.path)); } catch (e) { if (e.code !== "ENOENT") throw e; }
176
+ if (file.afterHash !== hash(file.text)) fail("artifact content hash differs; result may be truncated");
177
+ if (hash(current) !== file.beforeHash && hash(current) !== file.afterHash) fail("artifact base differs from owned checkout");
178
+ file.current = current;
179
+ }
180
+ if (head !== result.inputHead && head !== prior.appliedHead) {
181
+ const applied = changes.every(file => hash(file.current) === file.afterHash);
182
+ if (!applied || !ctx.git.resultHeadMatches || !await ctx.git.resultHeadMatches(entry.worktree, result.inputHead, [...paths])) fail("input head changed; reconcile before applying a result");
183
+ }
184
+ assertNotStopped();
185
+ writeJsonAtomic(checkpoint, { ...prior, fingerprint, phase: "applying", paths: [...paths] });
186
+ for (const file of changes) if (hash(file.current) !== file.afterHash) {
187
+ if (file.text === null) unlinkSync(validate(() => repositoryPath(entry.worktree, file.path)));
188
+ else validate(() => writeRepositoryText(entry.worktree, file.path, file.text));
189
+ }
190
+ await ctx.git.commitResult(entry.worktree, [...paths]);
191
+ }
192
+ }
193
+ const appliedHead = verified ? prior.appliedHead : await ctx.git.head(entry.worktree);
194
+ const assertVerifiedTree = async () => {
195
+ let revision, tree;
196
+ try {
197
+ revision = await ctx.git.head(entry.worktree);
198
+ tree = await ctx.git.dirty(entry.worktree, { strict: true });
199
+ } catch { block("verification-state-unavailable", "cannot verify the checkout revision and clean index/worktree", appliedHead); }
200
+ if (revision !== appliedHead) block("verification-head-changed", "verification changed the input head; review that change before publishing", appliedHead);
201
+ if (!tree || ![tree.modified, tree.untracked].every(count => Number.isInteger(count) && count >= 0)) block("verification-state-unavailable", "cannot verify clean index/worktree status", appliedHead);
202
+ if (tree.modified || tree.untracked) block("verification-dirty", "verification requires a clean index/worktree; preserve uncommitted files before correction", appliedHead);
203
+ };
204
+ await assertVerifiedTree();
205
+ const evidenceFile = join(dir, "verification.json");
206
+ const savedEvidence = readJson(evidenceFile, {});
207
+ let commands;
208
+ try { commands = verifyCommands(parsePrd(ctx.readPrd(id), `${id}.md`)); }
209
+ catch { block("verification-brief-unavailable", "cannot read the approved verification commands", appliedHead); }
210
+ const evidence = verified ? savedEvidence.evidence : [];
211
+ if (verified && (savedEvidence.head !== appliedHead || !Array.isArray(evidence) || evidence.length !== commands.length || evidence.some((item, i) => item.command !== commands[i] || item.head !== appliedHead || item.exit !== 0 || item.source !== "coordinator-executed" || !validVerificationSandbox(item.sandbox)))) {
212
+ block("verification-evidence-unavailable", "saved verification evidence does not cover this revision, approved command list and enforced OS sandbox", appliedHead);
213
+ }
214
+ const saveEvidence = () => writeJsonAtomic(evidenceFile, { version: 1, head: appliedHead, evidence });
215
+ const secrets = Object.entries(process.env).filter(([key]) => /token|password|secret|api.?key/i.test(key)).map(([, value]) => value);
216
+ const cleanOutput = output => execution?.sanitize ? execution.sanitize(output, ex.runId, secrets) : redact(output, secrets);
217
+ if (!verified) {
218
+ writeJsonAtomic(checkpoint, { fingerprint, phase: "verifying", appliedHead });
219
+ saveEvidence();
220
+ }
221
+ let verificationHome;
222
+ try {
223
+ const env = !verified && commands.length ? verificationEnvironment(verificationHome = mkdtempSync(join(dir, "verification-"))) : null;
224
+ let isolation;
225
+ if (env) {
226
+ try {
227
+ isolation = await sandbox({ worktree: entry.worktree, home: verificationHome, env });
228
+ if (!validVerificationSandbox(isolation?.evidence)) throw executionError("EXECUTION_VERIFICATION_SANDBOX", "verification sandbox did not supply enforcement evidence");
229
+ } catch (error) {
230
+ block("verification-sandbox-unavailable", cleanOutput(error.message), appliedHead);
231
+ }
232
+ assertNotStopped();
233
+ }
234
+ for (const command of verified ? [] : commands) {
235
+ assertNotStopped();
236
+ const startedAt = new Date().toISOString();
237
+ try {
238
+ const observed = await verify(isolation.cmd, [...isolation.args, "/bin/sh", "-c", command], { cwd: entry.worktree, timeout: 120_000, maxBuffer: 262_144, env });
239
+ evidence.push({ command, head: appliedHead, startedAt, finishedAt: new Date().toISOString(), exit: 0, source: "coordinator-executed", sandbox: isolation.evidence, output: cleanOutput(`${observed.stdout || ""}${observed.stderr || ""}`) });
240
+ } catch (e) {
241
+ evidence.push({ command, head: appliedHead, startedAt, finishedAt: new Date().toISOString(), exit: typeof e.code === "number" ? e.code : null, source: "coordinator-executed", sandbox: isolation.evidence, output: cleanOutput(`${e.stdout || ""}${e.stderr || ""}`) });
242
+ saveEvidence();
243
+ block("verification-failed", "approved verification failed", appliedHead);
244
+ }
245
+ saveEvidence();
246
+ await assertVerifiedTree();
247
+ }
248
+ } finally {
249
+ if (verificationHome) rmSync(verificationHome, { recursive: true, force: true });
250
+ }
251
+ // Empty checks remain empty evidence, never a claim that tests passed.
252
+ saveEvidence();
253
+ await assertVerifiedTree();
254
+ writeJsonAtomic(checkpoint, { fingerprint, phase: "verified", appliedHead });
255
+ const exclusion = acquirePublicationLock(ctx, id, { execution, dir, ownsExecution: true });
256
+ try {
257
+ assertNotStopped();
258
+ if (execution?.current && execution.current()?.id !== result.runId) fail("publication root changed; reconcile the exact owned result before publishing", true);
259
+ await ctx.git.pushResult(entry.worktree, entry.branch);
260
+ await assertVerifiedTree();
261
+ assertNotStopped();
262
+ const pr = await ctx.gh.publishResultPr({ repo: entry.repo, branch: entry.branch, base: entry.base, title: result.pr.title, body });
263
+ writeJsonAtomic(checkpoint, { fingerprint, phase: "published", appliedHead, pr: pr.number, url: pr.url });
264
+ return { head: appliedHead, pr, evidence };
265
+ } finally { exclusion.release(); }
266
+ }
package/lib/fs.mjs ADDED
@@ -0,0 +1,140 @@
1
+ // Small filesystem and path helpers shared by every module.
2
+ //
3
+ // Everything Gallivant owns lives under one home directory (default ~/.gallivant) so that a single `rm -rf` removes
4
+ // it and so that permissions can be set once. Paths in config may use `~`, which is expanded here and nowhere else.
5
+ import { homedir } from "node:os";
6
+ import { join, dirname, resolve, basename } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, statSync, chmodSync, readdirSync, lstatSync, realpathSync } from "node:fs";
9
+
10
+ export const HOME = homedir();
11
+
12
+ /**
13
+ * Gallivant's own version, from its package.json, read once. Reported in the provenance an APort decision carries,
14
+ * so a decision can be traced to the release that asked for it. Unknown rather than fatal if the file is missing:
15
+ * a version string is never worth failing a run over.
16
+ */
17
+ const PACKAGE = (() => {
18
+ try { return JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")); }
19
+ catch { return {}; }
20
+ })();
21
+ export const VERSION = PACKAGE.version || "unknown";
22
+
23
+ /**
24
+ * The published npm name, read from the same package.json. `init` prints the `npx` and `npm install -g` commands
25
+ * that run this package again, and a name typed into a command module would drift from the one npm publishes.
26
+ */
27
+ export const PACKAGE_NAME = PACKAGE.name || "@uchibeke/gallivant";
28
+
29
+ /**
30
+ * When the code a process is running was last changed, as the newest mtime across `lib/`. Paired with `VERSION`
31
+ * because `VERSION` cannot answer the question this exists for: it comes from package.json, so it stays `0.1.0`
32
+ * across every commit, and a daemon running code from before a fix reports the same string as one running the fix.
33
+ *
34
+ * This is for one failure and it is a bad one. `gallivant up` is long lived and node caches modules at import, so a
35
+ * daemon started before an upgrade keeps executing the old code with no symptom of its own. Observed: a daemon ran
36
+ * for three hours on a `runEnv` that predated its fix by 52 minutes, so every run it spawned failed on a gitconfig
37
+ * path the current code does not name, and the failure looked like a sandbox problem rather than a stale process.
38
+ * Every release can do this again.
39
+ *
40
+ * The newest mtime rather than a git revision, because an operator may run from a tarball or an npm install with no
41
+ * `.git` at all, and because what matters is whether the FILES changed, not whether a commit happened. Unknown rather
42
+ * than fatal, on the same reasoning as `VERSION`.
43
+ */
44
+ export const codeStamp = (dir = join(dirname(fileURLToPath(import.meta.url)))) => {
45
+ let newest = 0;
46
+ const walk = d => {
47
+ let entries = [];
48
+ try { entries = readdirSync(d, { withFileTypes: true }); } catch { return; }
49
+ for (const e of entries) {
50
+ if (e.name.startsWith(".")) continue;
51
+ const full = join(d, e.name);
52
+ if (e.isDirectory()) walk(full);
53
+ else if (e.name.endsWith(".mjs")) { try { newest = Math.max(newest, statSync(full).mtimeMs); } catch { /* raced */ } }
54
+ }
55
+ };
56
+ walk(dir);
57
+ return newest ? Math.round(newest) : null;
58
+ };
59
+
60
+ /** Gallivant's own directory. Overridable for tests and for running a second instance. */
61
+ export const gallivantHome = () => expand(process.env.GALLIVANT_HOME || "~/.gallivant");
62
+
63
+ /** Expand a leading `~` and make the path absolute. Leaves absolute paths alone. */
64
+ export function expand(p) {
65
+ if (!p) return p;
66
+ const s = String(p);
67
+ if (s === "~") return HOME;
68
+ if (s.startsWith("~/")) return join(HOME, s.slice(2));
69
+ return resolve(s);
70
+ }
71
+
72
+ /** Replace the home prefix with `~` for display. */
73
+ /**
74
+ * A file that ships inside the package, resolved from this module so it is found wherever the package is installed.
75
+ * Every quickstart and one of doctor's fix lines used to name `templates/<file>` relative to the reader's cwd, which
76
+ * resolved only inside a clone. Paths into the package are this module's business, not a command's.
77
+ */
78
+ export const templatePath = name => fileURLToPath(new URL(`../templates/${name}`, import.meta.url));
79
+
80
+ export const contract = p => (p && p.startsWith(HOME) ? "~" + p.slice(HOME.length) : p);
81
+
82
+ export function ensureDir(dir, mode = 0o700) {
83
+ mkdirSync(dir, { recursive: true, mode });
84
+ // mkdirSync's mode is masked by umask and ignored for existing dirs; set it explicitly for our private dirs.
85
+ try { chmodSync(dir, mode); } catch { /* not ours to change */ }
86
+ return dir;
87
+ }
88
+
89
+ export const exists = p => existsSync(p);
90
+
91
+ /** True only for a directory that is there now. A file or a broken symlink is not one. */
92
+ export const isDir = p => { try { return statSync(p).isDirectory(); } catch { return false; } };
93
+
94
+ export const fileMode = p => { try { return statSync(p).mode & 0o777; } catch { return null; } };
95
+
96
+ export function readText(p, fallback = null) {
97
+ try { return readFileSync(p, "utf8"); } catch { return fallback; }
98
+ }
99
+
100
+ export function readJson(p, fallback = null) {
101
+ const text = readText(p);
102
+ if (text === null) return fallback;
103
+ try { return JSON.parse(text); } catch (e) { throw new Error(`${p} is not valid JSON: ${e.message}`); }
104
+ }
105
+
106
+ /**
107
+ * Write JSON via a temp file and rename, so a reader never sees a half-written file and a crash mid-write leaves
108
+ * the previous version intact. Mode is applied to the temp file before the rename.
109
+ */
110
+ export function writeJsonAtomic(p, value, mode = 0o600) {
111
+ ensureDir(dirname(p));
112
+ const tmp = `${p}.${process.pid}.tmp`;
113
+ writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", { mode, flush: true });
114
+ renameSync(tmp, p);
115
+ }
116
+
117
+ /**
118
+ * `dirMode` is for the one caller that writes outside Gallivant's own home: the PRD copied into a session's
119
+ * checkout, whose parent directories are part of a source tree and must not be clamped to 0o700.
120
+ */
121
+ export function writeText(p, text, mode = 0o600, dirMode = 0o700) {
122
+ ensureDir(dirname(p), dirMode);
123
+ writeFileSync(p, text, { mode, flush: true });
124
+ }
125
+
126
+ export function appendLine(p, line, mode = 0o600) {
127
+ ensureDir(dirname(p));
128
+ writeFileSync(p, line.endsWith("\n") ? line : line + "\n", { flag: "a", mode });
129
+ }
130
+
131
+ // Resolve existing ancestors of fresh destinations; dangling symlinks fail closed.
132
+ export function canonicalPath(path) {
133
+ path = resolve(path);
134
+ try { lstatSync(path); }
135
+ catch (error) {
136
+ if (error.code !== "ENOENT") throw error;
137
+ return join(canonicalPath(dirname(path)), basename(path));
138
+ }
139
+ return realpathSync(path);
140
+ }