@uchibeke/gallivant 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/CONTRIBUTING.md +26 -0
  2. package/LICENSE +204 -0
  3. package/README.md +201 -2
  4. package/SECURITY.md +19 -0
  5. package/bin/gallivant.mjs +257 -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 +70 -0
  12. package/docs/agent-setup.md +214 -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 +620 -0
  18. package/docs/config.md +430 -0
  19. package/docs/execution.md +796 -0
  20. package/docs/guardrails.md +311 -0
  21. package/docs/install.md +338 -0
  22. package/docs/launch.md +42 -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 +140 -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 +457 -0
  43. package/lib/commands/init.mjs +34 -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 +195 -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 +100 -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 +631 -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 +133 -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 +1635 -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,150 @@
1
+ // `gallivant status`: what needs you, what is running, and whether the daemon is alive. Built as a model first so
2
+ // `--json` and any UI get the same facts the text view shows.
3
+ import { STATES, handleFor } from "../state.mjs";
4
+ import { readDaemon } from "./up.mjs";
5
+ import { ago } from "../time.mjs";
6
+ // Retries after review is clear, imported from the file that writes them so status cannot drift from the loop.
7
+ import { READY_LABEL_WRITE, COMPLETION_GATE } from "../tick.mjs";
8
+ import { briefDivergence, divergenceReason } from "../prd.mjs";
9
+ import { chainRows } from "../chains.mjs";
10
+ import { executionRunner } from "../execution.mjs";
11
+ import { networkRecoveryDetail } from "../network_recovery.mjs";
12
+ import { probeStatus } from "../probe.mjs";
13
+
14
+ /**
15
+ * The divergence for one entry, never fatal. `status` is what an operator runs when something already looks wrong, so a
16
+ * brief that cannot be read must not be the thing that makes the whole command fail: the row loses one field and the
17
+ * rest of the answer still arrives.
18
+ */
19
+ function divergenceOf(ctx, id, entry, readPrd) {
20
+ try { return briefDivergence({ id, entry, readPrd, readSource: ctx.readSource }); }
21
+ catch { return { verdict: "unknown", why: "", diff: [] }; }
22
+ }
23
+
24
+ export function model(ctx) {
25
+ const { home, config, state, runnerFor, log, readPrd = () => null } = ctx;
26
+ const daemon = readDaemon(home);
27
+ const rows = state.entries().map(([id, e]) => {
28
+ // The brief compared with the operator's own copy, on every row that is still being worked on. The comparison is the
29
+ // tick's too, but `status` runs it itself rather than reading a flag the tick left, for two reasons. An operator who
30
+ // has JUST edited the brief will run `status` before the next tick comes round, and a row that only knew what the
31
+ // last tick knew would say nothing for two minutes about the thing they are asking about. And `status` runs in the
32
+ // operator's own session, where their checkout is present and readable, which is not guaranteed for the daemon.
33
+ //
34
+ // A `done` entry is not compared: it is merged or closed, there is nothing left to certify, and an operator
35
+ // reorganising their briefs afterwards must not be told about finished work.
36
+ const divergence = e.state === "done" ? { verdict: "unknown", why: "", diff: [] } : divergenceOf(ctx, id, e, readPrd);
37
+ const run = executionRunner(ctx, id).current();
38
+ // A run is in flight from dispatch until the tick records its exit, whether or not the process is still alive.
39
+ const inFlight = !!e.last?.run && e.last?.exit == null;
40
+ const handle = handleFor(id, e);
41
+ const round = `round ${e.iteration}/${config.max_iterations}`;
42
+ let section, label, detail;
43
+ if (e.network_retry?.phase === "waiting" && !e.dispatch_stopped && e.state !== STATES.done) { section = "running"; label = "retrying"; detail = networkRecoveryDetail(e.network_retry); }
44
+ else if (e.state === STATES.needsHuman) { section = "needs_you"; label = "needs-human"; detail = e.reason; }
45
+ // `gate_skipped` says the completion gate never ran, which is a different thing from passing it, and a person
46
+ // deciding whether to merge on this line needs to be told which of the two it was.
47
+ else if (e.state === STATES.ready) { section = "needs_you"; label = "ready"; detail = e.gate_skipped ? `green, no blocking findings, RECEIPT NOT CHECKED (${e.gate_skipped})` : "green, no blocking findings, receipt accepted"; }
48
+ else if (e.state === STATES.done) { section = "done"; label = "done"; detail = ""; }
49
+ // `retry` names the one GitHub write the tick is declining a step for, and it is the truest thing this row can say
50
+ // while it is set. Without it, a start run whose identity block will not write read as "ended, settling on the next
51
+ // tick" on every poll: the process HAD ended, and the tick was not settling it, it was retrying one write and
52
+ // counting the failures. It outranks the run's own liveness because it is what the entry is actually waiting on.
53
+ //
54
+ // The label cannot come from the state alone. A `pr-open` entry is `fixing` for the two removals, which a fix round
55
+ // genuinely follows, but the ready-label WRITE is reached only after every readiness condition has passed: checks
56
+ // green, reviewer clear, receipt accepted. Nothing is being fixed, the PR is one label away from a person, and the
57
+ // row said `fixing` for it because the state was the only thing consulted. `blocked` says the entry is finished and
58
+ // held up by GitHub, which is what the operator needs to hear.
59
+ else if (e.retry) { section = "running"; label = [READY_LABEL_WRITE, COMPLETION_GATE].includes(e.retry.op) ? "blocked" : e.state === STATES.running ? "implementing" : "fixing"; detail = `${e.retry.op} failing (${e.retry.count}), retrying${e.retry.error ? `: ${e.retry.error}` : ""}`; }
60
+ else if (inFlight) { section = "running"; label = e.state === STATES.running ? "implementing" : "fixing"; detail = run?.running ? (e.last?.event || "") : "ended, settling on the next tick"; }
61
+ else if (e.state === STATES.running) { section = "running"; label = "starting"; detail = "waiting for the daemon to settle the run"; }
62
+ else { section = "running"; label = "waiting"; detail = e.rereview?.nudged ? "on reviewer, nudged once" : "on reviewer"; }
63
+ if (inFlight && run?.execution && e.state !== STATES.needsHuman) {
64
+ label = run.execution.providerState;
65
+ detail = run.execution.recoveryAction || `last observed ${run.execution.observedAt ?? "unknown"}; provider progress is separate from review readiness`;
66
+ }
67
+
68
+ // A diverged brief outranks everything above and moves the row into NEEDS YOU, whatever the entry is otherwise
69
+ // doing. It outranks them because it is the only thing on this row that says the work itself may be aimed at the
70
+ // wrong target: a red check, a waiting reviewer and a retrying label are all questions about work whose brief nobody
71
+ // disputes. And it moves sections because the operator is the only one who can resolve it, so a row sitting under
72
+ // RUNNING would be telling them to wait for a daemon that is going to stop the entry the moment it judges it.
73
+ //
74
+ // The row says so for an entry the tick has ALREADY stopped too, where `reason` is one of the three: the reason
75
+ // alone is a word, and `status` has room for the first difference, which is the thing the operator has to look at.
76
+ const divReason = divergenceReason(divergence);
77
+ if (divReason) {
78
+ section = "needs_you";
79
+ label = e.state === STATES.needsHuman ? "needs-human" : "brief-moved";
80
+ detail = divergence.diff[0] ? `${divReason}: ${divergence.diff[0]}` : divReason;
81
+ }
82
+ return {
83
+ id, handle, section, label, detail, round, state: e.state, reason: e.reason, gate_skipped: e.gate_skipped ?? null,
84
+ // The branch this PR targets, or will target. It is on every row because the question "what will this open against?"
85
+ // has a wrong answer that costs a whole run, and before the PR exists there is nowhere else to read it.
86
+ base: e.base ?? null,
87
+ // When the daemon last merged the base into this branch, so an operator can see the history was changed and when.
88
+ base_merge: e.base_merge ?? null,
89
+ retry: e.retry ?? null, network_retry: e.network_retry ?? null, pr: e.pr, url: e.url || null, harness: e.harness, since: e.updated_at,
90
+ // `handle` rides along so a consumer can print the command that resolves it without rebuilding the handle from the
91
+ // other fields: the tracker does exactly that, and `handleFor` is not its to know.
92
+ divergence: divReason ? { verdict: divergence.verdict, reason: divReason, why: divergence.why, diff: divergence.diff, path: divergence.path ?? null, handle } : null,
93
+ run: run ? { id: run.id, running: run.running, startedAt: run.startedAt } : null,
94
+ execution: run?.execution ?? e.execution ?? null,
95
+ };
96
+ });
97
+ return {
98
+ daemon: daemon ? { alive: daemon.alive, unverified: Boolean(daemon.unverified), pid: daemon.pid, startedAt: daemon.startedAt, lastTick: daemon.lastTick, nextTick: daemon.nextTick, probes: daemon.probes || {}, stale: Boolean(daemon.stale) } : null,
99
+ latestError: log.latest(),
100
+ chains: chainRows(ctx),
101
+ needs_you: rows.filter(r => r.section === "needs_you"),
102
+ running: rows.filter(r => r.section === "running"),
103
+ done: rows.filter(r => r.section === "done"),
104
+ };
105
+ }
106
+
107
+ const pad = (s, n) => String(s ?? "").padEnd(n);
108
+
109
+ export function render(m, { all = false, clock = Date } = {}) {
110
+ const lines = [];
111
+ if (m.daemon?.unverified) lines.push(`gallivant daemon UNVERIFIED pid ${m.daemon.pid} is alive; verify and stop the older daemon before restarting`);
112
+ else if (!m.daemon || !m.daemon.alive) lines.push("gallivant daemon DOWN run: gallivant up");
113
+ else {
114
+ const probes = Object.entries(m.daemon.probes).map(([k, p]) => `${k} ${probeStatus(p)}`).join(" ");
115
+ const next = m.daemon.nextTick ? `next in ${Math.max(0, Math.round((Date.parse(m.daemon.nextTick) - clock.now()) / 1000))}s` : "";
116
+ lines.push(`gallivant daemon up since ${ago(m.daemon.startedAt, clock)} last tick ${ago(m.daemon.lastTick, clock)} ${next} ${probes}`);
117
+ // A daemon running code that no longer exists on disk. Its own line rather than a word in the header, because the
118
+ // symptom it produces never points at the daemon: runs fail in whatever way the old code failed, so an operator
119
+ // reads a sandbox error or a git error and debugs that instead. See `codeStamp` in lib/fs.mjs.
120
+ if (m.daemon.stale) lines.push(" the daemon is running code older than what is installed; its runs use the old behaviour. Restart it: gallivant down && gallivant up");
121
+ }
122
+ if (m.latestError) lines.push(`latest error: ${m.latestError}`);
123
+ const section = (title, rows) => {
124
+ lines.push("", title);
125
+ if (!rows.length) { lines.push(" (none)"); return; }
126
+ for (const r of rows) {
127
+ // `why` is the hint for a moved brief as well as for a stop, because `why` is where the whole difference is
128
+ // printed and the row only has room for the first line of it.
129
+ const hint = r.label === "needs-human" || r.label === "brief-moved" ? `gallivant why ${r.handle}` : r.label === "ready" ? (r.url || "") : "";
130
+ // The base rides in the round column's neighbour rather than getting one of its own: it is short, it is constant for
131
+ // the life of an entry, and a column of its own would push the hint off an 80-column terminal.
132
+ lines.push(` ${pad(r.handle, 26)} ${pad(r.label, 13)} ${pad(r.detail, 30)} ${pad(r.round, 11)} ${pad(r.base ? `-> ${r.base}` : "", 14)} ${pad(ago(r.since, clock), 10)} ${hint}`.replace(/\s+$/, ""));
133
+ }
134
+ };
135
+ section("NEEDS YOU", m.needs_you);
136
+ section("RUNNING", m.running);
137
+ if (all) section("DONE", m.done);
138
+ const chains = (m.chains || []).filter(c => all || !["done", "cancelled"].includes(c.status));
139
+ if (chains.length) {
140
+ lines.push("", "CHAINS");
141
+ for (const c of chains) lines.push(` ${c.name} ${c.status} ${c.completed}/${c.total} merged -> ${c.base} ${c.reason} gallivant chain ${c.name}`);
142
+ }
143
+ return lines.join("\n");
144
+ }
145
+
146
+ export function status(ctx, { all = false, json = false } = {}) {
147
+ const m = model(ctx);
148
+ const text = json ? JSON.stringify(m, null, 2) : render(m, { all });
149
+ return { text, exitCode: m.daemon?.alive ? 0 : 1 };
150
+ }
@@ -0,0 +1,73 @@
1
+ // Thin CLI adapter. Structured clients use createTaskService directly; question
2
+ // IDs, revision checks and transitions are owned by that same implementation.
3
+ import { createTaskService, TASK_VERSION } from "../tasks.mjs";
4
+ import { MAX_INPUT_BYTES } from "../task_contract.mjs";
5
+ import { acquireCliMutation, withTaskMutationOwner } from "../daemon.mjs";
6
+ import { taskError } from "../task_store.mjs";
7
+
8
+ export async function readTaskStdin(stream = process.stdin, timeoutMs = 5000) {
9
+ if (stream.isTTY) throw taskError("TASK_INPUT", "pipe task text to stdin or pass it as an argument; preparation never opens an interactive prompt");
10
+ return new Promise((resolve, reject) => {
11
+ const chunks = [];
12
+ let size = 0;
13
+ const cleanup = () => { clearTimeout(timer); stream.removeListener("data", data); stream.removeListener("end", end); stream.removeListener("error", error); stream.pause(); };
14
+ const error = e => { cleanup(); reject(e); };
15
+ const data = chunk => {
16
+ size += Buffer.byteLength(chunk);
17
+ if (size > MAX_INPUT_BYTES) return error(taskError("TASK_INPUT", `stdin exceeds ${MAX_INPUT_BYTES} bytes`));
18
+ chunks.push(Buffer.from(chunk));
19
+ };
20
+ const end = () => { cleanup(); resolve(Buffer.concat(chunks).toString("utf8")); };
21
+ const timer = setTimeout(() => error(taskError("TASK_INPUT", "stdin did not finish within five seconds; pipe a complete request or pass text explicitly")), timeoutMs);
22
+ stream.on("data", data).once("end", end).once("error", error);
23
+ stream.resume();
24
+ });
25
+ }
26
+
27
+ export async function taskCommand(ctx, { args, values, service = null, stdin = readTaskStdin }) {
28
+ const [first, id] = args;
29
+ const operation = ["prepare", "answer", "resume", "validate", "start", "inspect", "list"].includes(first) ? first : "prepare";
30
+ const expectedRevision = values.revision === undefined ? undefined : Number(values.revision);
31
+ const shared = { taskId: id, expectedRevision, noDaemon: values["no-daemon"] };
32
+ if (values.context !== undefined && !["prepare", "answer"].includes(operation)) throw taskError("TASK_INPUT", "--context is supported by task prepare and task answer");
33
+ if (values["completion-target"] !== undefined && operation !== "prepare") throw taskError("TASK_INPUT", "--completion-target is selected only at task creation");
34
+ if (operation === "list") return (service || createTaskService(ctx)).list();
35
+ if (operation === "inspect") return (service || createTaskService(ctx)).inspect(shared);
36
+ const owner = acquireCliMutation(ctx.home);
37
+ try { return await withTaskMutationOwner(ctx.home, owner, async () => {
38
+ service ||= createTaskService(ctx, { mutationOwner: owner });
39
+ if (operation === "resume") return await service.resume(shared);
40
+ if (operation === "validate") return await service.validate(shared);
41
+ if (operation === "start") return await service.start({ ...shared, run: values.run });
42
+ if (operation === "answer") {
43
+ const answers = Object.create(null);
44
+ for (const value of values.answer || []) {
45
+ const split = value.indexOf("=");
46
+ if (split < 1) throw taskError("TASK_INPUT", "--answer takes question-id=text");
47
+ const key = value.slice(0, split), text = value.slice(split + 1);
48
+ if (Object.hasOwn(answers, key)) throw taskError("TASK_INPUT", `duplicate answer identifier ${key}`);
49
+ answers[key] = text;
50
+ }
51
+ return await service.answer({ ...shared, answers, operationId: values["request-id"], context: values.context || [], verifyCommands: values.verify || [] });
52
+ }
53
+ const textArgs = first === "prepare" ? args.slice(1) : args;
54
+ if (values.stdin && textArgs.length) throw taskError("TASK_INPUT", "use text or --stdin, not both");
55
+ const text = textArgs.length ? textArgs.join(" ") : await stdin();
56
+ return await service.prepare({ text, dir: values.dir, repo: values.repo, base: values.base, harness: values.harness, context: values.context || [], requestId: values["request-id"], run: values.run, noDaemon: values["no-daemon"], verifyCommands: values.verify || [], ...(values["completion-target"] === undefined ? {} : { completion: { target: values["completion-target"] } }) });
57
+ }); } finally { owner.release(); }
58
+ }
59
+
60
+ export function renderTask(result) {
61
+ if (result.tasks) return result.tasks.map(t => `${t.taskId}: ${t.status} (revision ${t.revision})`).join("\n") || "No saved tasks.";
62
+ return [
63
+ `${result.taskId}: ${result.status} (revision ${result.revision}, contract v${TASK_VERSION})`,
64
+ result.draft && `Draft: ${result.draft.path}`,
65
+ ...result.questions.map(q => `${q.id}: ${q.text}`),
66
+ ...result.failures,
67
+ result.dispatchRecovery && `Dispatch recovery: inspect the clone at ${result.dispatchRecovery.worktree}; start ownership is retained`,
68
+ result.implementation && `Implementation: ${result.implementation.id} (run ${result.implementation.run})`,
69
+ result.completion && `Completion target: ${result.completion.target} (${result.completion.status})`,
70
+ result.daemonError,
71
+ `Next: ${result.nextAction}`,
72
+ ].filter(Boolean).join("\n");
73
+ }
@@ -0,0 +1,228 @@
1
+ // `gallivant up`: the daemon. Reconciles with GitHub on boot, then ticks on a setTimeout chain so ticks never
2
+ // overlap. Health is a file (daemon.json) that `status` reads; a second daemon exits when the first is alive.
3
+ import { join } from "node:path";
4
+ import { spawn } from "node:child_process";
5
+ import { openSync, closeSync } from "node:fs";
6
+ import { fileURLToPath } from "node:url";
7
+ import { makeTick } from "../tick.mjs";
8
+ import { checkGh, checkHarness, probeFix } from "../probe.mjs";
9
+ import { makeProviderHealth } from "../provider_health.mjs";
10
+ import { EXECUTION_LIMITS } from "../execution_io.mjs";
11
+ import { readJson, writeJsonAtomic, VERSION, codeStamp, gallivantHome } from "../fs.mjs";
12
+ import { nowIso, later } from "../time.mjs";
13
+ import { parseBlock } from "../block.mjs";
14
+ import { countRounds, lastOwnCommentAt, reviewerClock } from "../events.mjs";
15
+ import { ensurePrdSnapshot } from "../prd.mjs";
16
+ import { newEntry, entryId, STATES } from "../state.mjs";
17
+ import { receiptHash } from "../deliverable.mjs";
18
+ import { worktreePath, isOurBranch, prdIdFromBranch } from "../git.mjs";
19
+ import { daemonPath, readDaemon, assertDaemonUsable, acquireDaemon, assertUnpinnedHome } from "../daemon.mjs";
20
+ import { repoNames } from "../config.mjs";
21
+
22
+ export { daemonPath, readDaemon } from "../daemon.mjs";
23
+
24
+
25
+ /**
26
+ * `gallivant down`: stop the daemon.
27
+ *
28
+ * It existed as a signal and not as a command, which is a gap an operator pays for at the worst moment. `up` already
29
+ * traps SIGTERM and writes `stoppedAt` before exiting, so the mechanism was there; what was missing was any way to
30
+ * reach it without reading a pid out of `ps`. That came up for real: a daemon had to be stopped because it was running
31
+ * stale code, and the instruction was a raw `kill <pid>`.
32
+ *
33
+ * SIGTERM and not SIGKILL, so the trap runs and the heartbeat records that the daemon stopped on purpose rather than
34
+ * leaving a file that says it is alive with a pid that is not.
35
+ *
36
+ * It does not wait for a run to finish, and says so rather than deciding for the operator. A spawned session is its own
37
+ * process and survives the daemon; the next `up` settles it, because `last.exit` is null and that is what tells
38
+ * `settleRun` the run is still owed an outcome.
39
+ */
40
+ export function down(ctx, { home = ctx?.home ?? gallivantHome() } = {}) {
41
+ const d = readDaemon(home);
42
+ if (!d) return { stopped: false, why: `no daemon has run under ${home}` };
43
+ if (!d.alive) return { stopped: false, why: `no daemon is running with a verified identity (pid ${d.pid}${d.stoppedAt ? `, stopped at ${d.stoppedAt}` : ""})`, pid: d.pid };
44
+ try { process.kill(d.pid, "SIGTERM"); }
45
+ catch (e) { return { stopped: false, why: `pid ${d.pid} could not be signalled (${e.code || e.message})`, pid: d.pid }; }
46
+ ctx?.log?.info?.(`daemon ${d.pid} sent SIGTERM`);
47
+ return { stopped: true, pid: d.pid, startedAt: d.startedAt };
48
+ }
49
+
50
+ /**
51
+ * Bring the registry in line with GitHub: every open PR of ours on a gallivant/* branch with a valid block is
52
+ * watched, iteration recovered from our round comments (since the last reset comment), cursors set so history is
53
+ * not replayed. The PRD snapshot is taken from the PR branch when the daemon has none; that copy was writable by
54
+ * the session, which the log says. Missing checkouts are recreated lazily by the tick when a run is needed.
55
+ */
56
+ export async function reconcile(ctx, ownLogin) {
57
+ const { config, state, gh, log } = ctx;
58
+ const added = [];
59
+ for (const repo of repoNames(config)) {
60
+ let prs;
61
+ try { prs = await gh.prListMine(repo); } catch (e) { log.warn(`reconcile ${repo}: ${e.message}`); continue; }
62
+ for (const pr of prs || []) {
63
+ if (pr.isCrossRepository || !isOurBranch(pr.headRefName)) continue;
64
+ if (state.findByPr(repo, pr.number)) continue;
65
+ const { block, reason } = parseBlock(pr.body);
66
+ if (!block) { log.warn(`reconcile ${repo}#${pr.number}: ${reason}, ignored`); continue; }
67
+ if (block.branch !== pr.headRefName) { log.warn(`reconcile ${repo}#${pr.number}: block branch differs from head, ignored`); continue; }
68
+ const prdId = prdIdFromBranch(pr.headRefName);
69
+ const id = entryId(repo, prdId);
70
+ if (state.get(id)) { log.warn(`reconcile ${repo}#${pr.number}: id ${id} already in use, ignored`); continue; }
71
+ try { state.assertCanRegister(id); }
72
+ catch (e) { log.warn(`reconcile ${repo}#${pr.number}: ${e.message}, ignored`); continue; }
73
+ const [comments, headDate] = await Promise.all([gh.issueComments(repo, pr.number), gh.commitDate(repo, pr.headRefOid).catch(() => null)]);
74
+ const lastOwn = lastOwnCommentAt(comments, ownLogin);
75
+ await ensurePrdSnapshot(ctx, { id, repo, path: block.prd, ref: pr.headRefName });
76
+ // `base` is the branch the PR targets, read off the PR rather than guessed from config: a recovered entry has to merge
77
+ // in the branch this pull request will actually be merged into, and only GitHub knows which that is.
78
+ const entry = newEntry({ prd: block.prd, harness: block.harness, session: block.session, repo, branch: pr.headRefName, worktree: worktreePath(config, repo, prdId), pr: pr.number, url: pr.url, state: STATES.prOpen, nowIso: nowIso(), base: typeof pr.baseRefName === "string" && pr.baseRefName ? pr.baseRefName : null });
79
+ try { state.put(id, {
80
+ ...entry,
81
+ iteration: countRounds(comments, ownLogin),
82
+ // The receipt fingerprint comes from the body `prListMine` already returned, as every other writer of this
83
+ // cursor sets it. Left null, a receipt that was on the PR before the recovery read as a change the next time
84
+ // a run ended, so a run that did nothing counted as a round and posted "Completion receipt updated." to the
85
+ // PR, which `countRounds` then read back as a real round on the recovery after that.
86
+ //
87
+ // `checks_sha` is null here, the same as `register.mjs` sets it and for the same reason, which is the one thing
88
+ // the two files used to disagree about. It is the head whose failing checks were already dispatched, and no head
89
+ // qualifies on a recovery: a fix that had been dispatched AND had pushed would have moved the head, so failures
90
+ // on the head that is actually there have not been fixed by anything. The only case a recovery could dispatch
91
+ // twice is a run that was still in flight when the daemon died, and that run is orphaned and its work lost
92
+ // anyway, so redoing it is the point. Setting it to the recovered head instead suppressed those failures, and
93
+ // suppressing them is not the same as stopping for them: `checks-red` only fires when the reviewer verdict is
94
+ // not "waiting", and on a recovery with a bot reviewer that has not spoken it is, so a red head arrived
95
+ // unaddressed and the entry then stopped with `reviewer-silent`, naming the wrong thing entirely.
96
+ cursor: { since: later(lastOwn, headDate ?? pr.createdAt), dispatched: [], fixed_sha: pr.headRefOid, checks_sha: null, receipt_hash: receiptHash(pr.body) },
97
+ // Through `reviewerClock`, the one place that rule lives, rather than raw. Recovery happens because the operator
98
+ // lost state: a crash, a machine move. An unfloored clock stopped a PR whose last round comment was older than
99
+ // `stall_hours` with `reviewer-silent` on the FIRST tick after the daemon came back, for silence that accrued
100
+ // while no daemon was running to ask, and cost the operator a `register` to resume. The floor gives a recovered
101
+ // entry the same grace a freshly nudged one gets; the ask is not older than the nudge window, because whatever
102
+ // happened before this boot, the daemon is only asking now.
103
+ rereview: { at: reviewerClock({ config, pushedAt: later(lastOwn, pr.createdAt), floorAt: nowIso() }), nudged: false },
104
+ last: { at: nowIso(), event: "recovered from GitHub" },
105
+ }); } catch (e) {
106
+ // A CLI may have reserved this ID during the reads above. Never let one conflict prevent daemon startup.
107
+ log.warn(`reconcile ${repo}#${pr.number}: ${e.message}, ignored`); continue;
108
+ }
109
+ added.push(id);
110
+ log.info(`${id}: recovered from ${pr.url}`);
111
+ }
112
+ }
113
+ return added;
114
+ }
115
+
116
+ /**
117
+ * Start a daemon in the background and return once it is actually up, or null when one already is.
118
+ *
119
+ * `start` calls this because the two commands are one intention: a session that opens a pull request nobody watches
120
+ * is a silent stall, and an operator who has to remember a second command in a second terminal will sometimes not.
121
+ * The child is detached with its output to the log, so it outlives the terminal that ran `start`.
122
+ *
123
+ * It waits for verified process ownership, published before the first GitHub probe. A daemon still loading config
124
+ * has not acquired its lock and does not yet count as started.
125
+ */
126
+ export async function upInBackground(ctx, { bin = process.execPath, script = fileURLToPath(new URL("../../bin/gallivant.mjs", import.meta.url)), waitMs = 10_000, sleep = ms => new Promise(r => setTimeout(r, ms)), spawnFn = spawn } = {}) {
127
+ assertUnpinnedHome(ctx.home);
128
+ const existing = readDaemon(ctx.home);
129
+ assertDaemonUsable(existing, { starting: true });
130
+ if (existing?.alive) return null;
131
+ const out = openSync(join(ctx.home, "daemon.log"), "a", 0o600);
132
+ let child;
133
+ let spawnError = null;
134
+ try {
135
+ child = spawnFn(bin, [script, "up"], { detached: true, stdio: ["ignore", out, out], env: { ...process.env, GALLIVANT_HOME: ctx.home } });
136
+ child.on?.("error", e => { spawnError = e; });
137
+ child.unref();
138
+ } finally { closeSync(out); }
139
+ const deadline = Date.now() + waitMs;
140
+ while (Date.now() < deadline) {
141
+ assertUnpinnedHome(ctx.home);
142
+ const d = readDaemon(ctx.home);
143
+ assertDaemonUsable(d, { starting: true });
144
+ if (d?.alive) return d;
145
+ if (spawnError) throw new Error(`the daemon could not start: ${spawnError.message}; see ${join(ctx.home, "daemon.log")}`);
146
+ if (child.exitCode !== null) throw new Error(`the daemon exited immediately (code ${child.exitCode}); see ${join(ctx.home, "daemon.log")}`);
147
+ await sleep(200);
148
+ }
149
+ child.kill?.("SIGTERM");
150
+ throw new Error(`the daemon did not report itself within ${Math.round(waitMs / 1000)}s; startup was cancelled; see ${join(ctx.home, "daemon.log")}`);
151
+ }
152
+
153
+ export async function up(ctx, { once = false } = {}) {
154
+ const { home, config, gh, notify, log, clock = Date } = ctx;
155
+ const existing = readDaemon(home);
156
+ if (existing?.alive && existing.pid !== process.pid) throw new Error(`daemon already running (pid ${existing.pid})`);
157
+ if (existing?.unverified) throw new Error(`daemon pid ${existing.pid} has an unverifiable process identity; verify and stop the older daemon before restarting`);
158
+ const { owner, release } = acquireDaemon(home);
159
+ let ownLogin = null;
160
+ let timer = null;
161
+ const cleanup = () => {
162
+ if (timer) clearTimeout(timer);
163
+ try { writeJsonAtomic(daemonPath(home), { ...readJson(daemonPath(home), {}), ...owner, stoppedAt: nowIso() }); } catch { /* exiting */ }
164
+ try { release(); } catch { /* stale lock can be recovered next boot */ }
165
+ process.off("SIGINT", stop); process.off("SIGTERM", stop); process.off("exit", cleanup);
166
+ };
167
+ const stop = () => { cleanup(); process.exit(0); };
168
+ process.on("SIGINT", stop); process.on("SIGTERM", stop); process.on("exit", cleanup);
169
+ try {
170
+ // What code this daemon is actually running, recorded once at boot. `status` compares it with the code on disk and
171
+ // says so when they differ: node caches modules at import, so a daemon started before an upgrade goes on executing
172
+ // the old code with no symptom of its own. See `codeStamp` in lib/fs.mjs for the run this cost.
173
+ const bootCode = codeStamp();
174
+ // The live pid always wins over whatever a previous daemon left in the file.
175
+ // Health is a convenience for `status`, not part of the loop. A home that cannot be written must not stop the
176
+ // daemon doing the work it was started for. It matters twice over here: the recovery path below writes health
177
+ // again from inside its own catch, so an unguarded write there would escape the handler meant to keep one bad
178
+ // tick from killing the process.
179
+ const health = patch => {
180
+ try { writeJsonAtomic(daemonPath(home), { ...readJson(daemonPath(home), {}), ...patch, ...owner, login: ownLogin, stoppedAt: null, version: VERSION, code: bootCode }); }
181
+ catch (e) { log.warn(`cannot write ${daemonPath(home)} (${e.code || e.message}); status will not see this tick`); }
182
+ };
183
+ health({ starting: true });
184
+ ownLogin = await gh.user();
185
+ const providerHealth = makeProviderHealth({ home, clock, limits: config.execution, probe: (provider, account, signal) => checkHarness(provider, config, { signal }) });
186
+ const githubHealth = makeProviderHealth({ home, file: join(home, "github-health.json"), clock,
187
+ limits: { ...config.execution, progressMs: config.poll_seconds * 1000 },
188
+ probe: (_provider, _account, signal) => checkGh(gh, { signal }) });
189
+ const tickImpl = makeTick({ ...ctx, ownLogin, providerHealth });
190
+ health({ starting: false });
191
+ keepAwake();
192
+
193
+ const recovered = await reconcile(ctx, ownLogin);
194
+ if (recovered.length) log.info(`recovered ${recovered.length} PR(s)`);
195
+ const tickMs = Math.min(config.poll_seconds * 1000, config.execution?.progressMs || EXECUTION_LIMITS.progressMs);
196
+
197
+ /** One tick, with every failure caught: a daemon that dies on one bad tick is worse than one that logs it. */
198
+ const loop = async () => {
199
+ try {
200
+ const scopes = Object.keys(config.harness).map(h => [h, config.harness[h]?.account || "operator"]);
201
+ // Provider checks and GitHub/progress work use separate lanes. Failed or
202
+ // due/pending health reads fence only that account/provider, even before
203
+ // refresh gets a worker; the previous healthy cache cannot authorize work.
204
+ const refreshing = providerHealth.refresh(scopes);
205
+ const [results] = await Promise.all([tickImpl.tick(), refreshing, githubHealth.refresh([["gh", ownLogin]])]);
206
+ const probes = { gh: githubHealth.get("gh", ownLogin), ...Object.fromEntries(scopes.map(([h, a]) => [h, providerHealth.get(h, a)])) };
207
+ for (const [k, p] of Object.entries(probes)) if (!p.ok) await notify.error(`${k} not ready`, `${probeFix(k, p)} (${p.detail})`);
208
+ const errors = results.filter(r => !r.ok).length;
209
+ health({ lastTick: nowIso(clock), nextTick: new Date(clock.now() + tickMs).toISOString(), probes, entries: results.length, errors });
210
+ } catch (e) {
211
+ log.error(`tick failed: ${e.stack || e.message}`);
212
+ health({ lastTick: nowIso(clock), nextTick: new Date(clock.now() + tickMs).toISOString(), lastError: e.message });
213
+ }
214
+ if (once) return;
215
+ // A referenced timer is what keeps the process alive between ticks; --once returned before scheduling one.
216
+ timer = setTimeout(loop, tickMs);
217
+ };
218
+ log.info(`daemon up as ${ownLogin}, polling every ${config.poll_seconds}s`);
219
+ await loop();
220
+ if (once) cleanup();
221
+ } catch (e) { cleanup(); throw e; }
222
+ }
223
+
224
+ /** On a Mac, hold off idle sleep for as long as the daemon lives. No-op elsewhere or when caffeinate is missing. */
225
+ function keepAwake() {
226
+ if (process.platform !== "darwin") return;
227
+ try { const child = spawn("caffeinate", ["-i", "-w", String(process.pid)], { detached: true, stdio: "ignore" }); child.on("error", () => {}); child.unref(); } catch { /* optional */ }
228
+ }
@@ -0,0 +1,143 @@
1
+ // `gallivant why <handle>`: the reason a PR stopped, what was dispatched, what the harness last said, any permission
2
+ // denials, and APort audit lines from the run's window. Reads files; never calls GitHub.
3
+ import { resolveHandle, shortRepo } from "../state.mjs";
4
+ import { adapterFor } from "../harness.mjs";
5
+ import { prdDivergence, divergenceReason, renderDivergence, prdDecision, sourceDivergence } from "../prd.mjs";
6
+ import { contract } from "../fs.mjs";
7
+ import { aportDirFor, auditEntries } from "../aport.mjs";
8
+ import { READY_LABEL_WRITE, COMPLETION_GATE } from "../tick.mjs";
9
+ import { READY_LABEL } from "../gh.mjs";
10
+ import { executionRunner } from "../execution.mjs";
11
+ import { networkRecoveryDetail } from "../network_recovery.mjs";
12
+ import { redact } from "../execution_io.mjs";
13
+
14
+ export function why(ctx, { handle }) {
15
+ const { config, state, runnerFor, readPrd = () => null } = ctx;
16
+ const id = resolveHandle(state.entries(), handle);
17
+ const e = state.get(id);
18
+ const runner = executionRunner(ctx, id);
19
+ const runId = e.last?.run || runner.current()?.id || null;
20
+ const job = runId ? runner.read(runId) : null;
21
+ const lines = runId && !runner.outcome ? runner.lines(runId) : [];
22
+ const outcome = redact(runId && runner.outcome ? runner.outcome(runId) : lines.length ? adapterFor(e.harness).outcome(lines) : null);
23
+ const out = [];
24
+ out.push(`${id} ${e.state}${e.reason ? ` (${e.reason})` : ""} ${e.harness} round ${e.iteration}/${config.max_iterations}`);
25
+ if (e.url) out.push(`PR: ${e.url}`);
26
+ if (e.network_retry) out.push(`network: ${networkRecoveryDetail(e.network_retry)}; first failure ${e.network_retry.first_at}; automatic recovery is bounded to one hour. Recheck does not restart an agent.`);
27
+ if (e.execution) out.push(`provider: ${e.execution.providerState}; observed ${e.execution.observedAt ?? "unknown"}; reconciled ${e.execution.reconciledAt ?? "never"}; next ${e.execution.nextAttemptAt ?? "unknown"}; action ${e.execution.recoveryAction ?? "none"}`);
28
+ if (e.control) out.push(`operator request: ${e.control.action} ${e.control.status}${e.control.message ? ` — ${e.control.message}` : " (waiting for the daemon and any active run)"}`);
29
+ // The branch this pull request targets. Printed whether or not the PR exists yet, because the case worth catching is
30
+ // the one before it exists: a branch cut from the wrong base and a session told to open against the wrong base are
31
+ // both this line being wrong, and both cost a whole run to find out any other way.
32
+ if (e.base) out.push(`base: ${e.base} (the branch this PR targets, and the one merged in before each round)`);
33
+ // A merge the daemon made is a change to this branch's history that nobody typed, so it is reported rather than left in
34
+ // the log: an operator reading a diff they did not expect needs to know where the extra commit came from.
35
+ if (e.base_merge) {
36
+ out.push(e.base_merge.conflict
37
+ ? `base merge FAILED at ${e.base_merge.at}: origin/${e.base_merge.base} could not be merged into ${e.branch} (${e.base_merge.why}). The merge was aborted, so the checkout is clean.`
38
+ : `base merge: origin/${e.base_merge.base} was merged into ${e.branch} at ${e.base_merge.at}${e.base_merge.sha ? ` as ${e.base_merge.sha.slice(0, 7)}` : ""}${e.base_merge.onto ? `, onto ${e.base_merge.onto.slice(0, 7)}` : ""}. History was added to, never rewritten: no rebase, no force-push.`);
39
+ }
40
+ // A kill is the one stop an operator cannot reconstruct from anything else on this screen: `state` says needs-human,
41
+ // `reason` says timeout, and the exit is 137, none of which says what the run was doing when SIGKILL landed. The run
42
+ // this block was written for had finished: four commits made, its tests green, and the session writing a commit
43
+ // message. So the limit it exceeded is named, and so is where that limit was set, because raising the wrong one of
44
+ // the global and the repository's own is the obvious next mistake.
45
+ if (e.timeout) {
46
+ const where = e.timeout.source === "task" ? "the remaining authorized task runtime allowance" : e.timeout.source === "repo" ? `configured for ${shortRepo(e.repo)}` : "the global run_timeout_minutes";
47
+ out.push(`killed: ran ${e.timeout.ran_minutes}m and exceeded its ${e.timeout.limit}m timeout (${where}). SIGKILL, so the session had no chance to finish what it was doing; the run log below is the last thing it did.`);
48
+ if (e.timeout.source === "task") out.push(" Raising repository or global timeouts cannot extend the saved task allowance; inspect the original authorization before requesting further work.");
49
+ // Work the kill left behind, uncommitted first because it is the fragile half and the half nothing else reports. A
50
+ // run killed one tool call before `git commit` has an unpushed count of zero and a tree full of work, so a commit
51
+ // count alone would have said "nothing stranded" about the worst case there is.
52
+ const { modified = 0, untracked = 0, unpushed = 0 } = e.timeout;
53
+ if (modified || untracked) {
54
+ out.push(` UNCOMMITTED WORK IN THE WORKTREE: ${modified} modified, ${untracked} untracked, held by no commit and in no PR.`,
55
+ " A SIGKILL can land between the last edit and `git commit`, and this one did. Nothing on GitHub reflects these files.",
56
+ ` Read them before anything else touches that directory: \`git -C ${e.worktree || "<worktree>"} status\`.`,
57
+ " Do NOT delete or recreate the checkout: `gallivant resume` rebuilds a missing worktree without asking, which would discard this.");
58
+ }
59
+ if (unpushed) out.push(` ${unpushed} commit${unpushed === 1 ? "" : "s"} had been made and not pushed, so that work is in the worktree only. \`gallivant resume\` pushes them rather than redoing them.`);
60
+ if (!modified && !untracked && !unpushed) out.push(" the worktree was clean and nothing was committed unpushed, so the kill stranded no work.");
61
+ }
62
+ if (e.gate_skipped) out.push(`completion gate: NOT RUN (${e.gate_skipped}); no acceptance criterion was evaluated for this PR`);
63
+
64
+ // The brief against the operator's own copy, with every difference spelled out. This is the place the whole diff goes:
65
+ // `status` has one line per row and the tracker has a paragraph, and neither of those can hold five criteria. It is
66
+ // printed for a stopped entry and for a running one alike, because an operator who edited the brief two minutes ago
67
+ // will ask `why` before the daemon's next tick has had a chance to stop anything.
68
+ //
69
+ // What the gate does about it is stated outright rather than left to be inferred, because the useful thing to know is
70
+ // not that the files differ, it is that no decision is being made about either of them until a person says which brief
71
+ // is the brief.
72
+ // The carried files, before the brief's own divergence, because a document that moved is the cheaper thing to check
73
+ // and an operator reading this wants both in one place. It is its own block rather than folded into the brief's four
74
+ // verdicts: a document has two states, the snapshot's bytes or not, since nothing validates its shape.
75
+ const carried = sourceDivergence({ id, entry: e, readSource: ctx.readSource });
76
+ if (carried.changed.length || carried.missing.length) {
77
+ out.push("", `${carried.checked.length} file${carried.checked.length === 1 ? "" : "s"} carried with this brief, and not all of them still match:`);
78
+ for (const f of carried.changed) out.push(` changed ${f}`);
79
+ for (const f of carried.missing) out.push(` gone ${f}`);
80
+ out.push(" The session was given the snapshot's copy, so it is working from what was carried and not from what is",
81
+ " in your tree now. A changed file is a decision about which version is the brief; a file that is gone is a",
82
+ " question about whether the work still stands.");
83
+ }
84
+
85
+ const div = divergenceFor(ctx, id, e, readPrd);
86
+ if (divergenceReason(div)) {
87
+ out.push("", `the brief has changed since this run started (${divergenceReason(div)}): ${div.why}`);
88
+ out.push(renderDivergence(div));
89
+ if (div.verdict === "changed") {
90
+ out.push(" The completion gate is judging NEITHER brief: the snapshot is one the operator has moved on from, and",
91
+ " the edited copy names criteria this session was never given and has not attested. Decide which is the brief:",
92
+ ` gallivant resume ${e.pr ? `${shortRepo(e.repo)}#${e.pr}` : id} --resnapshot # the edit is the brief; the session is told what moved`,
93
+ ` (or put the brief back the way it was, and the next tick carries on)`);
94
+ }
95
+ if (div.verdict === "illegal") out.push(` ${div.path} would be refused by \`gallivant start\` as it stands. Fix it, then \`gallivant ready\` it again.`);
96
+ if (div.verdict === "gone") out.push(" Restore the file, or `gallivant remove` the entry if the work is no longer wanted.");
97
+ // A decision id that moved says the operator re-ran `ready`, which is a deliberate re-blessing of the brief rather
98
+ // than a stray edit. It is reported and never relied on: `ready` leaves it untouched on a brief that was already
99
+ // ready, and a hand-edit that misses the frontmatter leaves it identical, so it can only ever add confidence to an
100
+ // answer the contract comparison has already given.
101
+ const now = div.repo ? prdDecision(div.repo) : null;
102
+ if (e.prd_decision && now && e.prd_decision !== now) out.push(` and it was re-readied: aport_decision ${e.prd_decision} became ${now}, so the edit was blessed by \`gallivant ready\`.`);
103
+ else if (e.prd_decision && now === e.prd_decision) out.push(` aport_decision is unchanged (${e.prd_decision}), so the edit has not been through \`gallivant ready\`.`);
104
+ }
105
+ if (e.retry) out.push(`waiting on ${e.retry.op === COMPLETION_GATE ? "completion verification" : "GitHub"}: ${e.retry.op} has failed ${e.retry.count} tick${e.retry.count === 1 ? "" : "s"} in a row; the entry stops with "stuck" if it keeps failing`);
106
+ if (e.retry?.op === COMPLETION_GATE) out.push(` checks green and reviewer clear; completion verification is pending: ${e.retry.error ?? "no decision from the gate"}`);
107
+ // The one retry whose op is not a step toward a fix round: everything a person waits for has already happened and
108
+ // only the label that says so will not write, which is worth spelling out where the operator is already reading.
109
+ if (e.retry?.op === READY_LABEL_WRITE) out.push(` this PR is otherwise ready: checks green, reviewer clear, receipt accepted. Only the ${READY_LABEL} label will not write.`);
110
+ // The round comment is the announcement of a round that is already counted, so the operator needs both halves: the
111
+ // work is on the branch and charged, and the PR does not say so. The second half matters because the count on GitHub
112
+ // is what a recovery rebuilds from, so an unposted marker is a round a recovery after this would not see.
113
+ if (e.round_comment) {
114
+ const recovered = e.iteration - 1;
115
+ out.push(` round ${e.iteration} is counted and its push is on the branch; only the comment announcing it is missing, so a recovery from GitHub would count ${recovered} round${recovered === 1 ? "" : "s"} until it posts.`);
116
+ }
117
+ if (e.worktree) out.push(`worktree: ${contract(e.worktree)}`);
118
+ if (e.session) out.push(runner.capabilities?.transport === "remote" ? `session: ${e.session} (owned by ${e.harness}; use its recorded recovery action)` : `session: ${e.session} (by hand: ${adapterFor(e.harness).attach({ pr: e.pr, session: e.session })} in the worktree)`);
119
+ if (e.last) out.push(`last event: ${e.last.event}${e.last.at ? ` at ${e.last.at}` : ""}${e.last.exit != null ? `, exit ${e.last.exit}` : ""}${e.last.error ? `, error: ${e.last.error}` : ""}`);
120
+ if (job) out.push(`run: ${contract(runner.dirOf(job.id))} ${job.exit === null ? "running" : `exited ${job.exit}`}${job.killed ? " (killed)" : ""}`);
121
+ if (outcome) {
122
+ if (outcome.cost != null) out.push(`cost: $${outcome.cost.toFixed(2)}`);
123
+ // Each denial carries the call that was denied and, where the run's own stream recorded it, the guardrail's reason
124
+ // on the line beneath. Both come from the same log: the APort audit file printed below names the pattern that fired
125
+ // and never the path it fired on, so the pairing here is the only place an operator can see a broad pattern catching
126
+ // a legitimate read. A denial whose reason the stream did not carry prints as the call alone.
127
+ if (outcome.denials?.length) out.push("permission denials (the call, and the guardrail's reason where the run recorded it):", ...outcome.denials.map(d => ` - ${d}`));
128
+ if (outcome.lastMessage) out.push("", "harness said:", indent(outcome.lastMessage.trim().slice(-2000)));
129
+ }
130
+ if (job) {
131
+ const audit = auditEntries(aportDirFor(config.harness?.claude?.settings), { fromMs: job.startedAt, toMs: job.endedAt || Date.now(), filter: /allow=false|den|block|warn/i }).slice(-40);
132
+ if (audit.length) out.push("", "APort audit (run window):", ...audit.map(l => " " + l));
133
+ }
134
+ return out.join("\n");
135
+ }
136
+
137
+ const indent = s => s.split("\n").map(l => " " + l).join("\n");
138
+
139
+ /** Never fatal, for the same reason `status`'s is not: `why` is what an operator runs when something is already wrong. */
140
+ function divergenceFor(ctx, id, entry, readPrd) {
141
+ try { return prdDivergence({ id, entry, readPrd }); }
142
+ catch (e) { return { verdict: "unknown", why: e.message, diff: [] }; }
143
+ }