@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,432 @@
1
+ // One line of a harness's JSONL output -> the shape command-center's log renderer already takes.
2
+ //
3
+ // This sits beside lib/harness.mjs because it is the same knowledge: each harness's stream has its own event names
4
+ // and its own nesting, exactly as `outcome` and `sessionFrom` already have to know. Putting the reader in the
5
+ // tracker would mean the tracker knowing five stream shapes, and a sixth harness would then be edited in two files.
6
+ //
7
+ // The renderer it feeds is command-center's public/logline.js, ported into templates/tracker/tracker.js. That file
8
+ // takes { kind, tool, arg } and nothing else, so everything below is a mapping onto those three fields. Which is why
9
+ // the parse can differ per harness while the rendering stays one copy.
10
+ //
11
+ // No library, for the same reason command-center has none: the whole job is JSON.parse plus a field lookup. A parse
12
+ // that fails, or an event shape nothing here recognises, returns null and the caller shows the raw line instead. A
13
+ // line nobody can read is still information, and blanking it would hide the one line an operator needed.
14
+ //
15
+ // This module has no imports on purpose: the browser gets it by having lib/tracker/assets.mjs splice its source
16
+ // into the served script, so there is one copy of the parse rather than two that drift.
17
+
18
+ /** The tool-call inputs worth putting in the argument column, in the order they are preferred. */
19
+ const ARG_KEYS = ["command", "file_path", "pattern", "url", "description", "prompt"];
20
+
21
+ /** The first of ARG_KEYS the input actually has, as a single line. */
22
+ const argOf = input => {
23
+ if (!input || typeof input !== "object") return "";
24
+ for (const k of ARG_KEYS) {
25
+ const v = input[k];
26
+ if (typeof v === "string" && v.trim()) return oneLine(v);
27
+ if (typeof v === "number") return String(v);
28
+ }
29
+ return "";
30
+ };
31
+
32
+ /** Newlines collapse: a row is one line tall, and a heredoc in a Bash command would otherwise be forty. */
33
+ const oneLine = s => String(s).replace(/\s*\n\s*/g, " ").trim();
34
+
35
+ /**
36
+ * A hook decision, from the JSON the hook printed on stdout. APort's Claude Code hook writes Claude's official
37
+ * schema: `{ hookSpecificOutput: { permissionDecision, permissionDecisionReason } }`, and "deny" there is a blocked
38
+ * tool call. That is the single most important line a run can produce, so it gets its own kind rather than arriving
39
+ * as another "meta" tool row the eye slides past.
40
+ */
41
+ function hookDecision(ev) {
42
+ const raw = ev.output || ev.stdout || "";
43
+ let out = null;
44
+ if (raw && typeof raw === "string") { try { out = JSON.parse(raw); } catch { out = null; } }
45
+ const spec = out?.hookSpecificOutput || null;
46
+ const decision = spec?.permissionDecision || out?.decision || null;
47
+ const reason = spec?.permissionDecisionReason || out?.reason || "";
48
+ // exit 2 blocks as well, with the reason on stderr, and the hook's own header documents that path.
49
+ const blockedByExit = ev.exit_code === 2;
50
+ if (decision === "deny" || blockedByExit) {
51
+ return { kind: "deny", tool: "BLOCKED", arg: oneLine(reason || ev.stderr || `${ev.hook_name || "hook"} blocked the call`) };
52
+ }
53
+ return null;
54
+ }
55
+
56
+ /**
57
+ * Claude Code. `--output-format stream-json --verbose`, verified against the run tree under ~/.gallivant/runs on
58
+ * 2026-09-27: assistant (tool_use and text blocks), user (tool results), system (init, hook_started, hook_response,
59
+ * task_started, task_notification), rate_limit_event, tool_progress, result.
60
+ */
61
+ function claudeLine(ev) {
62
+ // A heartbeat for a call that is still running. Claude Code emits one every 30 seconds for a tool that has not
63
+ // returned, and a long Bash step collects a dozen of them: on the run this was read off, one `npm ci` gathered
64
+ // fourteen. Drawing each as its own row would be fourteen identical lines of noise; skipping them the way a clean
65
+ // tool result is skipped would throw away the one thing the reader wanted, because a build sitting for six minutes
66
+ // with nothing on screen is exactly the case they complained about.
67
+ //
68
+ // So it is neither a row nor nothing: it is an UPDATE to the row the call already has. `parent_tool_use_id` names
69
+ // that call, tool rows carry their own id, and the renderer finds the row and rewrites its elapsed time in place.
70
+ // One row that counts upwards, which is what a progress indicator is. `kind` says "beat" rather than "tool" so no
71
+ // caller can mistake it for a call of its own.
72
+ if (ev.type === "tool_progress") {
73
+ const id = ev.parent_tool_use_id || ev.tool_use_id || "";
74
+ const secs = Number(ev.elapsed_time_seconds);
75
+ return { kind: "beat", of: String(id), tool: ev.tool_name || "tool", secs: Number.isFinite(secs) ? secs : null };
76
+ }
77
+ if (ev.type === "assistant") {
78
+ const blocks = ev.message?.content || [];
79
+ // One event can carry prose and several tool calls at once: Claude batches parallel calls into one message. So a
80
+ // line can be more than one row, and the renderer joins them. Order is kept, which is the order the model wrote
81
+ // them in, so the prose that introduces a batch of calls still reads above the calls.
82
+ const rows = [];
83
+ for (const b of blocks) {
84
+ // `id` is the call's own tool_use_id, carried so a later `tool_progress` heartbeat can find this row and count
85
+ // up on it rather than adding one of its own. Nothing else reads it and it is absent when the harness omits it.
86
+ if (b?.type === "tool_use") rows.push({ kind: "tool", tool: b.name || "tool", arg: argOf(b.input), id: b.id ? String(b.id) : "" });
87
+ // Prose the model wrote is markdown, and a long one is a report with headings and lists in it. It gets the same
88
+ // treatment the final message does, for the same reason: one row a screen wide is the thing this rendering
89
+ // exists to stop, and the model's own paragraph breaks are the only structure worth keeping.
90
+ else if (b?.type === "text" && b.text?.trim()) rows.push(...report(b.text));
91
+ }
92
+ return rows.length === 0 ? null : rows.length === 1 ? rows[0] : rows;
93
+ }
94
+ // Tool results. A success is not news: its output is what the run read, often hundreds of lines of it, and the
95
+ // pane is a narrative of what the run is DOING. So a clean result is recognised and dropped rather than shown
96
+ // raw, which is the one case where "nothing" is the right row and not a parse failure.
97
+ if (ev.type === "user") {
98
+ const blocks = ev.message?.content || [];
99
+ const bad = blocks.find(b => b?.type === "tool_result" && b.is_error);
100
+ if (!bad) return blocks.some(b => b?.type === "tool_result") ? { kind: "skip" } : null;
101
+ const why = oneLine(resultText(bad.content));
102
+ // A guardrail refusal comes back as an error result, not as a hook event: the hook denies, Claude turns the
103
+ // denial into the tool's result, and `tool_result_meta.non_execution_kind` says the tool never ran. Verified
104
+ // against a live run on 2026-09-27, where APort blocked two Bash calls. That is the line an operator is
105
+ // scanning for, so it does not get to look like a tool that merely errored.
106
+ const blocked = (ev.tool_result_meta || []).some(m => m?.non_execution_kind === "permission-rule") || /^APort denied\b/.test(why);
107
+ if (blocked) return { kind: "deny", tool: "BLOCKED", arg: why.slice(0, 400) };
108
+ return { kind: "tool", tool: "ERROR", arg: why.slice(0, 300), tone: "bad" };
109
+ }
110
+ if (ev.type === "system") {
111
+ if (ev.subtype === "hook_response") return hookDecision(ev) || { kind: "tool", tool: "hook", arg: `${ev.hook_name || "hook"} ${ev.outcome || ""}`.trim(), tone: "meta" };
112
+ if (ev.subtype === "hook_started") return { kind: "tool", tool: "hook", arg: ev.hook_name || ev.hook_event || "started", tone: "meta" };
113
+ if (ev.subtype === "init") return { kind: "head", text: `session ${ev.session_id || "?"} in ${ev.cwd || "?"}` };
114
+ if (ev.subtype === "task_started") return { kind: "tool", tool: "task", arg: ev.description || ev.task_id || "started", tone: "meta" };
115
+ if (ev.subtype === "task_notification") return { kind: "tool", tool: "task", arg: `${ev.summary || ev.task_id || "task"} ${ev.status || ""}`.trim(), tone: "meta" };
116
+ return { kind: "tool", tool: "system", arg: ev.subtype || "", tone: "meta" };
117
+ }
118
+ if (ev.type === "rate_limit_event") {
119
+ const i = ev.rate_limit_info || {};
120
+ return { kind: "tool", tool: "limits", arg: `${i.rateLimitType || "rate limit"} ${i.status || ""}`.trim(), tone: "meta" };
121
+ }
122
+ if (ev.type === "result") {
123
+ // `errors` and `terminal_reason` are the SHORT reason; `subtype` (error_max_budget_usd) is the fallback. The
124
+ // model's own report goes above as prose, so a run that ended mid-task still says why, in its own words.
125
+ const why = (ev.errors || []).join("; ") || ev.terminal_reason || ev.subtype || "";
126
+ return [...report(ev.result), done(!ev.is_error, why, ev.total_cost_usd)];
127
+ }
128
+ return null;
129
+ }
130
+
131
+ /** A tool result's content, which is a string on some events and a block list on others. */
132
+ const resultText = content => {
133
+ if (typeof content === "string") return content;
134
+ if (Array.isArray(content)) return content.map(c => (typeof c === "string" ? c : c?.text || "")).join(" ");
135
+ return "";
136
+ };
137
+
138
+ /**
139
+ * The closing line. Same shape command-center's DONE regex produces, so the renderer's `.ll.d` row with its top
140
+ * border and its ok/bad colour needs no change: only the text differs, and the cost is the part an operator reads.
141
+ *
142
+ * `why` is a SHORT reason, never the model's report. A Claude `result` event carries the whole final message in
143
+ * `result`, which on the run this was built against was twelve paragraphs of markdown. Collapsed onto the closing
144
+ * line that became one unreadable row a screen wide, which is the bug this rendering exists to stop. The report is
145
+ * prose and is emitted as prose above the closing line by the caller; this function only ever gets the reason.
146
+ */
147
+ function done(ok, why, cost) {
148
+ const parts = [ok ? "done" : "failed"];
149
+ if (typeof cost === "number") parts.push(`$${cost.toFixed(4)}`);
150
+ if (!ok && why) parts.push(oneLine(why).slice(0, 120));
151
+ return { kind: "done", text: parts.join(" "), ok };
152
+ }
153
+
154
+ /** A fence opener or closer: three or more backticks or tildes, with an optional language word on the opener. */
155
+ const FENCE = /^\s{0,3}(`{3,}|~{3,})\s*([^\s`]*)/;
156
+ /** A list item: a bullet or a number, with the indent kept so a nested item can be told from a top-level one. */
157
+ const ITEM = /^(\s*)(?:([-*+])|(\d{1,9})[.)])\s+(.*)$/;
158
+ /** A table row: a line fenced by pipes at both ends, so a sentence that merely contains a `|` is not read as one. */
159
+ const PIPE = /^\s{0,3}\|.*\|\s*$/;
160
+ /** The separator row under a table's header: pipes, dashes, colons and nothing else. */
161
+ const RULER = /^\s{0,3}\|(?:\s*:?-{1,}:?\s*\|)+\s*$/;
162
+ /**
163
+ * A horizontal rule: three or more of the same marker on a line of its own, spaces between them allowed, so `* * *`
164
+ * is a rule rather than a bullet list holding `* *`. It cannot collide with RULER, which needs pipes and this one
165
+ * allows none, so which of the two is tested first does not matter.
166
+ */
167
+ const HR = /^\s{0,3}(?:-\s*){3,}$|^\s{0,3}(?:\*\s*){3,}$|^\s{0,3}(?:_\s*){3,}$/;
168
+ /** A blockquote line. The marker is structure and the text after it is what the quote holds. */
169
+ const QUOTE = /^\s{0,3}>\s?(.*)$/;
170
+ /** A heading. Kept as its own row so the renderer can give it the weight the model asked for. */
171
+ const HEAD_MD = /^\s{0,3}(#{1,6})\s+(.*)$/;
172
+
173
+ /** A pipe row's cells, with the outer pipes dropped. An escaped `\|` is a pipe in a cell, not a cell boundary. */
174
+ const cells = row => row.trim().replace(/^\|/, "").replace(/\|\s*$/, "")
175
+ .split(/(?<!\\)\|/).map(c => oneLine(c).replace(/\\\|/g, "|"));
176
+
177
+ /**
178
+ * The model's final message, as the blocks it wrote.
179
+ *
180
+ * It arrives as one JSON string holding markdown. Rendering it line at a time is what the reader complained about:
181
+ * a fenced code block came out as N prose rows with the emphasis characters inside it eaten, a pipe table came out as
182
+ * rows of literal pipes, and a bullet list came out as rows each starting with a dash. So the split is a block scan
183
+ * rather than a regex on paragraph boundaries, and each block becomes one row the renderer knows how to draw.
184
+ *
185
+ * Six block kinds, which is every one that appears in the run tree under ~/.gallivant/runs:
186
+ *
187
+ * { kind: "text", text } a paragraph, or a heading, with its wrapped lines joined
188
+ * { kind: "fence", code, lang } a fenced block, verbatim, never touched by the inline pass
189
+ * { kind: "list", items, ordered } a run of bullets or numbers, as { text, depth }
190
+ * { kind: "table", head, rows } a pipe table with its separator row seen
191
+ * { kind: "quote", lines } a run of `>` lines
192
+ * { kind: "rule" } a horizontal rule
193
+ *
194
+ * A fence wins over everything: inside one, a `|` is a pipe and a `-` is a dash and no other rule looks at the line.
195
+ * That is the whole point of a fence and it is why the scan is a state machine rather than six independent splits.
196
+ *
197
+ * An UNCLOSED fence is the case streaming makes ordinary, because half a report is on screen while the rest is still
198
+ * arriving. It does not swallow the rest of the report and it does not throw: the lines collected so far come back as
199
+ * a fence row that renders as the `<pre>` it will become, so the reader sees the code they already have. The next
200
+ * event's rows are appended under it as usual. Same for a pipe table whose separator row has not arrived: without a
201
+ * ruler it is not a table, so its lines are paragraphs, which is what they look like anyway.
202
+ */
203
+ function report(message) {
204
+ const text = typeof message === "string" ? message : "";
205
+ if (!text.trim()) return [];
206
+ const lines = text.split("\n");
207
+ const out = [];
208
+ // A paragraph is collected across lines and flushed when something ends it, so a wrapped sentence still joins.
209
+ let para = [];
210
+ const flushPara = () => { const t = oneLine(para.join("\n")); para = []; if (t) out.push({ kind: "text", text: t }); };
211
+
212
+ for (let i = 0; i < lines.length; i++) {
213
+ const line = lines[i];
214
+
215
+ // ---- a fence, which owns every line until its own closer --------------------
216
+ const fence = FENCE.exec(line);
217
+ if (fence) {
218
+ flushPara();
219
+ const [, marker, lang] = fence;
220
+ const body = [];
221
+ let closed = false;
222
+ // A closer is the same marker, at least as long, with nothing after it. A ``` inside a ~~~ block is content.
223
+ const closer = new RegExp(`^\\s{0,3}${marker[0] === "`" ? "`" : "~"}{${marker.length},}\\s*$`);
224
+ for (i++; i < lines.length; i++) {
225
+ if (closer.test(lines[i])) { closed = true; break; }
226
+ body.push(lines[i]);
227
+ }
228
+ // `lang` is the model's own word and reaches the renderer as a class, which vets it. An unclosed fence still
229
+ // produces its row: the code that did arrive is what the reader wants to see.
230
+ out.push({ kind: "fence", code: body.join("\n"), lang: lang || "", open: !closed });
231
+ continue;
232
+ }
233
+
234
+ // ---- a blank line ends whatever was being collected ------------------------
235
+ if (!line.trim()) { flushPara(); continue; }
236
+
237
+ // ---- a heading is its own row ----------------------------------------------
238
+ const head = HEAD_MD.exec(line);
239
+ if (head) { flushPara(); out.push({ kind: "text", text: oneLine(line) }); continue; }
240
+
241
+ // ---- a horizontal rule ------------------------------------------------------
242
+ // Before the list rule, because `* * *` is a rule and not three bullets. A table's ruler row cannot reach here:
243
+ // it needs pipes and HR allows none.
244
+ if (HR.test(line)) { flushPara(); out.push({ kind: "rule" }); continue; }
245
+
246
+ // ---- a table, but only once its separator row is in hand -------------------
247
+ // The ruler is what makes a run of pipe lines a table rather than prose that happens to contain a pipe. It has to
248
+ // be the line after the header, so the lookahead is one line and no further.
249
+ if (PIPE.test(line) && i + 1 < lines.length && RULER.test(lines[i + 1])) {
250
+ flushPara();
251
+ const head2 = cells(line);
252
+ const rows = [];
253
+ for (i += 2; i < lines.length && PIPE.test(lines[i]); i++) rows.push(cells(lines[i]));
254
+ i--;
255
+ // Ragged rows are normal in agent output: the renderer pads and clips to the header's width rather than
256
+ // building a table whose cell count varies by row, which is what makes a browser reflow the columns.
257
+ out.push({ kind: "table", head: head2, rows });
258
+ continue;
259
+ }
260
+
261
+ // ---- a blockquote, as one block rather than a marker stripped per line ------
262
+ const quote = QUOTE.exec(line);
263
+ if (quote) {
264
+ flushPara();
265
+ const held = [quote[1]];
266
+ for (i++; i < lines.length; i++) {
267
+ const q = QUOTE.exec(lines[i]);
268
+ if (!q) { i--; break; }
269
+ held.push(q[1]);
270
+ }
271
+ // Blank lines inside the quote are kept as paragraph breaks and the renderer draws each run as its own line.
272
+ out.push({ kind: "quote", lines: held });
273
+ continue;
274
+ }
275
+
276
+ // ---- a list, as one block with its items ------------------------------------
277
+ const item = ITEM.exec(line);
278
+ if (item) {
279
+ flushPara();
280
+ const ordered = item[3] !== undefined;
281
+ const items = [];
282
+ // A continuation line under an item belongs to that item: the model wrapped it, and starting a new item for it
283
+ // would read as a list twice as long as the one it wrote.
284
+ const add = (indent, body) => items.push({ depth: Math.min(3, Math.floor(indent.replace(/\t/g, " ").length / 2)), text: body });
285
+ add(item[1], item[4]);
286
+ for (i++; i < lines.length; i++) {
287
+ const next = ITEM.exec(lines[i]);
288
+ if (next) {
289
+ // A bullet under a numbered list, or the other way, ends this list and starts the next: they are two lists
290
+ // and one <ol> holding bullets would lie about what the model wrote.
291
+ if ((next[3] !== undefined) !== ordered) { i--; break; }
292
+ add(next[1], next[4]);
293
+ continue;
294
+ }
295
+ // A blank line, a fence, a heading or a table ends the list. Anything else indented is a continuation.
296
+ if (!lines[i].trim() || FENCE.test(lines[i]) || HEAD_MD.test(lines[i]) || PIPE.test(lines[i]) || HR.test(lines[i])) { i--; break; }
297
+ if (/^\s/.test(lines[i])) { items[items.length - 1].text += " " + lines[i].trim(); continue; }
298
+ i--;
299
+ break;
300
+ }
301
+ out.push({ kind: "list", ordered, items: items.map(it => ({ ...it, text: oneLine(it.text) })) });
302
+ continue;
303
+ }
304
+
305
+ para.push(line);
306
+ }
307
+ flushPara();
308
+ return out;
309
+ }
310
+
311
+ /**
312
+ * Codex. `exec --json`, captured from a real run against codex-cli 0.157.0 on 2026-09-27: thread.started,
313
+ * turn.started, item.started and item.completed (each carrying an `item` with its own `type`), turn.completed.
314
+ *
315
+ * Two details the capture settled that guessing would have got wrong. A tool call fires TWICE, once as
316
+ * `item.started` and again as `item.completed` with the output filled in, so drawing both would double every row:
317
+ * the started event is what the pane wants, because it is the one that arrives while the call is running.
318
+ * And `turn.completed.usage` carries token counts and no cost at all, so there is no cost to put on the closing
319
+ * line; `outcome` in lib/harness.mjs already reports codex's cost as null for the same reason.
320
+ *
321
+ * Only `agent_message` and `command_execution` were observed. An item of any other type is a row named after its
322
+ * own type with no argument, which says something happened without inventing a field name to describe it.
323
+ */
324
+ function codexLine(ev) {
325
+ if (ev.type === "thread.started") return { kind: "head", text: `thread ${ev.thread_id || "?"}` };
326
+ // The completed twin of a call already on screen. Its output is the tool result, which the pane does not show.
327
+ if (ev.type === "item.completed" && ev.item?.type === "command_execution") return { kind: "skip" };
328
+ if (ev.type === "item.started" || ev.type === "item.completed") {
329
+ const item = ev.item || {};
330
+ // Prose arrives on `item.completed`; the started twin has no text yet and is nothing to draw.
331
+ if (item.type === "agent_message") return item.text?.trim() ? report(item.text) : { kind: "skip" };
332
+ if (item.type === "command_execution") return { kind: "tool", tool: "Bash", arg: oneLine(item.command || "") };
333
+ return { kind: "tool", tool: item.type || "item", arg: "", tone: "meta" };
334
+ }
335
+ if (ev.type === "turn.started") return { kind: "skip" };
336
+ if (ev.type === "turn.completed") return done(true, null, null);
337
+ if (ev.type === "turn.failed" || ev.type === "error") return done(false, ev.error?.message || ev.message || "turn failed", null);
338
+ return null;
339
+ }
340
+
341
+ /**
342
+ * Gemini, opencode and goose. `outcome` in lib/harness.mjs names the events each one's OUTCOME is read from, and
343
+ * those were round-tripped on this machine. The per-line shape was not: no run of any of the three has been
344
+ * captured line by line here, so what a tool call looks like in their streams is not known the way it is for claude
345
+ * and codex, both of which were read off real runs.
346
+ *
347
+ * So each of the three reads only the events lib/harness.mjs already proves exist, and returns null for the rest,
348
+ * which shows the raw line. Guessing would be worse than a raw line: it would put a confident row on screen with a
349
+ * field name nobody has seen in it, and nothing on the page would say it was invented. A raw line at least says
350
+ * "here is what the harness wrote". Capture one run each, the way codex's was captured, then extend these three.
351
+ *
352
+ * Which means their tool calls show as raw JSON today. That is the honest state and it is not the finished one.
353
+ */
354
+ function geminiLine(ev) {
355
+ if (ev.type === "init") return { kind: "head", text: `session ${ev.session_id || "?"}` };
356
+ if (ev.type === "message" && ev.role === "assistant" && typeof ev.content === "string") return { kind: "text", text: oneLine(ev.content) };
357
+ if (ev.type === "result") {
358
+ const ok = ev.status === "success";
359
+ return done(ok, ok ? null : ev.error?.message || ev.status, null);
360
+ }
361
+ return null;
362
+ }
363
+
364
+ function opencodeLine(ev) {
365
+ if (ev.type === "text" && ev.part?.text) return { kind: "text", text: oneLine(ev.part.text) };
366
+ if (ev.type === "step_finish") {
367
+ const reason = ev.part?.reason ?? null;
368
+ return done(reason === "stop", reason === "stop" ? null : `turn ended with reason "${reason}"`, ev.part?.cost);
369
+ }
370
+ if (ev.type === "error") return done(false, ev.error?.message || ev.message || "error", null);
371
+ return null;
372
+ }
373
+
374
+ function gooseLine(ev) {
375
+ // goose splits one assistant reply across several `message` events, as lib/harness.mjs records, so a row per
376
+ // event would be a row per fragment. The fragments are joined into prose and only a whole-looking one is shown.
377
+ if (ev.type === "message" && ev.message?.role === "assistant") {
378
+ const t = (ev.message.content || []).filter(b => b?.type === "text").map(b => b.text).join("").trim();
379
+ return t ? { kind: "text", text: oneLine(t) } : null;
380
+ }
381
+ if (ev.type === "complete") return done(true, null, ev.cost_usd);
382
+ if (ev.type === "error") return done(false, ev.error?.message || ev.message || "error", null);
383
+ return null;
384
+ }
385
+
386
+ /** One reader per harness, keyed by the same names lib/harness.mjs's `adapters` uses. */
387
+ export const readers = {
388
+ claude: claudeLine,
389
+ codex: codexLine,
390
+ gemini: geminiLine,
391
+ opencode: opencodeLine,
392
+ goose: gooseLine,
393
+ };
394
+
395
+ /**
396
+ * One log line for `harness`, as { kind, tool, arg }, a list of those, or null.
397
+ *
398
+ * null means "render the line as itself, escaped": a line that is not JSON at all (the runner's own output, a
399
+ * harness writing to stderr, a crash message), or an event this harness's reader does not claim. Never blank, because
400
+ * a line nobody can read is still evidence and the pane is the only place it is ever seen.
401
+ *
402
+ * `{ kind: "skip" }` is the one exception, and it is the opposite case: an event the reader DOES recognise and whose
403
+ * content is not news, which today means a tool result that succeeded. Dropping that is a decision, not a failure.
404
+ *
405
+ * `{ kind: "beat" }` is the third answer: an event that belongs on a row the pane already drew. A heartbeat for a
406
+ * call still running is not a new line and it is not nothing either, so it names the row it updates and the renderer
407
+ * rewrites that row rather than appending.
408
+ */
409
+ export function harnessLine(harness, line) {
410
+ const read = readers[harness];
411
+ if (!read) return null;
412
+ let ev = null;
413
+ try { ev = JSON.parse(line); } catch { return null; }
414
+ if (!ev || typeof ev !== "object") return null;
415
+ const out = read(ev);
416
+ if (!out) return null;
417
+ // A reader may answer with one row or, when an event carried several, a list of them. Toning is the same either way.
418
+ for (const row of Array.isArray(out) ? out : [out]) if (row.kind === "tool" && !row.tone) row.tone = TONE[row.tool] || "";
419
+ return out;
420
+ }
421
+
422
+ /**
423
+ * Which tools are worth colouring apart. command-center's TOOL_KIND, with the rows Gallivant's own stream adds:
424
+ * `hook`, `task`, `limits` and `system` are bookkeeping and read as meta; ERROR is not a tool at all and is toned
425
+ * by the reader that produced it.
426
+ */
427
+ export const TONE = {
428
+ WebSearch: "search", WebFetch: "fetch", Read: "read", Write: "write", Edit: "write",
429
+ Bash: "shell", Agent: "agent", Task: "agent", ToolSearch: "meta", ListAgents: "meta",
430
+ SendMessage: "meta", Monitor: "meta", Glob: "read", Grep: "read",
431
+ hook: "meta", task: "meta", limits: "meta", system: "meta",
432
+ };
package/lib/hash.mjs ADDED
@@ -0,0 +1,16 @@
1
+ // One fingerprint spelling for the whole product.
2
+ //
3
+ // `receiptHash` in deliverable.mjs was the first of these: twelve hex characters of a sha1, short enough to sit in a
4
+ // registry cursor and in a log line, long enough that two different texts never collide in a tree this size. The
5
+ // brief's contract needed the same thing, and a second spelling of "fingerprint a string" is exactly the duplication
6
+ // this codebase keeps paying for, so it is one function that both call rather than two that agree today.
7
+ //
8
+ // Not a security boundary and never used as one. Nothing signs anything with this: it answers "is this the same text
9
+ // as before", where both texts are ours and neither side is trying to collide. sha1 is what the existing receipt
10
+ // cursor already stores, and changing it would invalidate every cursor on disk for no gain.
11
+ import { createHash } from "node:crypto";
12
+
13
+ export const HASH_LENGTH = 12;
14
+
15
+ /** Twelve hex characters of the sha1 of `text`. `null` for nothing to hash, so a caller can tell absent from empty. */
16
+ export const shortHash = text => (text == null ? null : createHash("sha1").update(String(text)).digest("hex").slice(0, HASH_LENGTH));
@@ -0,0 +1,98 @@
1
+ // The one place the git identity a run commits as is resolved.
2
+ //
3
+ // A run commits AS THE OPERATOR. That is the product decision and it is not configurable: the operator wrote the brief
4
+ // and is shipping the work, and the harness is a tool in the way an editor or a compiler is. Nobody adds
5
+ // `Co-Authored-By: vim`. A `gallivant <gallivant@users.noreply.github.com>` committer tells an employer and a reviewer
6
+ // "a robot did this", which is both wrong about who is responsible and the signal that gets a PR discounted in review.
7
+ // So there is one identity path and no flag to take another: no `--as-bot`, no config key.
8
+ //
9
+ // Why this needs a module at all, rather than being two more literals in `RUN_GIT_CONFIG`. The operator's identity lives
10
+ // in `~/.gitconfig`, and `runEnv` deliberately sets `GIT_CONFIG_NOSYSTEM=1` and `GIT_CONFIG_GLOBAL=/dev/null`, which
11
+ // drops exactly that file. That is not an oversight to undo: pointing a run back at `~/.gitconfig` is what commit
12
+ // `5b69814` fixed, because a sandboxed session cannot read outside its worktree and every git command failed, `git
13
+ // status` included, on a file it was denied. Verified under the run env: `git config --get user.email` returns nothing.
14
+ //
15
+ // So the daemon reads the identity where there is no sandbox, and passes the VALUES through the env pairs git already
16
+ // takes. The run names no file and the sandbox stays as strict as it is.
17
+ //
18
+ // Resolved per run and never cached at boot, for the reason `lib/credential.mjs` gives for the token: the daemon is
19
+ // long-lived and an operator may change their identity under it, and a value captured once would go stale with every
20
+ // later commit carrying the old one.
21
+ //
22
+ // A historical note, because this change looks like a revert and is not. The commits on the first live PR show
23
+ // `gallivant <the operator's real email>`. That was an accident: they predate `5b69814`, when the broken
24
+ // `GIT_CONFIG_GLOBAL` left `~/.gitconfig` in play and git picked the email out of it. The behaviour the user wants was
25
+ // happening by luck, the sandbox fix removed it, and this module makes it deliberate.
26
+ import { execFile } from "node:child_process";
27
+
28
+ const GIT_TIMEOUT_MS = 20_000;
29
+
30
+ const execFileAsyncQuiet = (cmd, args, opts) =>
31
+ new Promise(resolve => execFile(cmd, args, { timeout: GIT_TIMEOUT_MS, ...opts }, (err, stdout) => resolve(err ? "" : String(stdout))));
32
+
33
+ /**
34
+ * The operator's git identity, as `{ name, email }`, or `{ name: null, email: null, why }` when the machine has none.
35
+ *
36
+ * Asked of git rather than parsed out of `~/.gitconfig`, so every place git itself would look is covered: the global
37
+ * file, an `include` in it, a system file, `XDG_CONFIG_HOME`. Parsing the file here would reimplement git's own
38
+ * resolution and drift from it, which is the same reasoning `credential.mjs` gives for asking `gh` rather than reading
39
+ * `hosts.yml`.
40
+ *
41
+ * Run WITHOUT the run env on purpose. `runEnv`'s `GIT_CONFIG_NOSYSTEM` and `GIT_CONFIG_GLOBAL=/dev/null` are what hide
42
+ * this identity from a session; a resolver that inherited them would ask git to read the files it has just been told to
43
+ * ignore and would always answer "none". The daemon is not sandboxed, which is the whole reason the read happens here.
44
+ *
45
+ * `exec` is injected so a test drives both branches without depending on whether the machine running the suite has a
46
+ * git identity configured, which a CI container usually does not.
47
+ */
48
+ export async function resolveIdentity({ exec = execFileAsyncQuiet } = {}) {
49
+ const get = async key => (await exec("git", ["config", "--get", key], { env: cleanEnv() })).trim();
50
+ const [name, email] = await Promise.all([get("user.name"), get("user.email")]);
51
+ if (!name || !email) return { name: null, email: null, why: NO_IDENTITY };
52
+ return { name, email };
53
+ }
54
+
55
+ /**
56
+ * The daemon's own environment with the run's git overrides stripped, so this read sees the operator's real config.
57
+ *
58
+ * The daemon can itself be started from a shell that exported these (a test harness, a CI job, an operator debugging a
59
+ * run by hand), and any one of them would make the read answer "no identity configured" on a machine that has one.
60
+ */
61
+ function cleanEnv() {
62
+ const env = { ...process.env };
63
+ for (const k of Object.keys(env)) if (/^GIT_CONFIG(_|$)/.test(k) || k === "GIT_AUTHOR_NAME" || k === "GIT_AUTHOR_EMAIL" || k === "GIT_COMMITTER_NAME" || k === "GIT_COMMITTER_EMAIL") delete env[k];
64
+ return env;
65
+ }
66
+
67
+ /**
68
+ * What an operator with no identity is told. It refuses before a run is spawned rather than falling back to a bot, for
69
+ * the reason the base-branch check refuses early: no money is spent on a session that cannot commit what it writes.
70
+ *
71
+ * Exactly two commands, because there is one identity path and no escape hatch to mention.
72
+ */
73
+ export const NO_IDENTITY = [
74
+ "no git identity on this machine. Gallivant commits as you, so it needs one.",
75
+ ' git config --global user.name "Your Name"',
76
+ ' git config --global user.email "you@example.com"',
77
+ ].join("\n");
78
+
79
+ /** One line for a log or a `doctor` row. The email is the operator's own and is already in every commit they make. */
80
+ export const describeIdentity = ({ name, email }) => (name && email ? `${name} <${email}>` : "none");
81
+
82
+ /**
83
+ * The identity a run will commit as, or a throw. The one place `start`, `resume` and every fix round reach, so the
84
+ * refusal is written once and cannot be enforced on one path and forgotten on another.
85
+ *
86
+ * It throws rather than returning a reason because both callers do the same thing with it and neither can continue: a
87
+ * session that cannot commit what it writes is a session whose whole cost is wasted. Same shape and the same reasoning
88
+ * as the credential refusal it sits beside in both callers.
89
+ *
90
+ * `ctx.identity` is what a test (and `makeContext`) can substitute, exactly as `ctx.credential` already is, so no test
91
+ * depends on whether the machine running the suite has a git identity configured.
92
+ */
93
+ export async function requireIdentity(ctx = {}) {
94
+ const resolve = ctx.identity ?? (() => resolveIdentity());
95
+ const id = await resolve();
96
+ if (!id?.name || !id?.email) throw Object.assign(new Error(id?.why || NO_IDENTITY), { code: "NO_IDENTITY" });
97
+ return id;
98
+ }