tickmarkr 1.96.0 → 2.0.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.
@@ -6,23 +6,367 @@ import { loadGraph } from "../../graph/graph.js";
6
6
  import { formatSummary, resolveRunMode, runDaemon } from "../../run/daemon.js";
7
7
  import { isRunLockLive } from "../../run/lock.js";
8
8
  import { route, NO_EXPLORE_ENV } from "../../route/router.js";
9
- import { formatJournalNarration, loadRoutingProfile } from "../../run/journal.js";
10
- import { ttyVisual } from "../../adapters/model-lints.js";
11
- import { statusRow } from "../../brand.js";
12
- // T4 (v1.50): lifecycle verdict glyphs on the live narration stream — glyph-first, message text
13
- // unchanged (the doctor/status visual system). TTY-gated: the piped narration surface stays
14
- // byte-identical to formatJournalNarration.
15
- const NARRATION_VERDICTS = {
16
- "task-dispatch": "neutral",
17
- "task-done": "pass",
18
- "task-failed": "fail",
19
- "task-human": "warn",
9
+ import { formatJournalNarration, loadRoutingProfile, newRunId } from "../../run/journal.js";
10
+ import { normalizeGateOutcome } from "../../run/outcome.js";
11
+ import { GLYPHS, LIVE } from "../../brand.js";
12
+ import { cellWidth, fitCells } from "../../tui/cockpit/width.js";
13
+ // ── the operator event rail (v1.99 T2) ──────────────────────────────────────────────────────────
14
+ // A run's TTY narration is an operator EVENT RAIL, not the journal dump it used to echo. The
15
+ // repetitive worker-contact / worker-status polls and the ungated phase-start rows are the bulk of
16
+ // that dump and say nothing an operator acts on, so the rail never draws them; everything that IS a
17
+ // decision, a worker result, a gate start or verdict, a repair, an escalation, a merge or a run
18
+ // lifecycle step becomes ONE compact line — semantic glyph, task/run identity, short label, clipped
19
+ // detail — measured through the cockpit width authority and coloured in the live palette.
20
+ //
21
+ // A pipe sees none of it: off a TTY every event returns formatJournalNarration's exact bytes, so the
22
+ // machine-consumable surface is byte-identical to the raw journal formatter, suppression included.
23
+ const onTty = () => process.stdout.isTTY === true;
24
+ /** Closed repetitive set the rail suppresses on a TTY. An ungated `phase-start` joins them below —
25
+ * it is a phase counter, while a phase-start CARRYING a gate is the gate start the rail draws. */
26
+ export const TTY_NOISE_EVENTS = ["worker-contact", "worker-status"];
27
+ /** The quiet hierarchy: one distinct glyph SHAPE per tone (colour is never the only signal), each
28
+ * painted in the operator live palette — no sixth colour, no glyph outside the brand vocabulary. */
29
+ const RAIL_TONES = {
30
+ fail: { glyph: GLYPHS.fail, paint: LIVE.failure },
31
+ attention: { glyph: GLYPHS.attention, paint: LIVE.attention },
32
+ pass: { glyph: GLYPHS.pass, paint: LIVE.pass },
33
+ active: { glyph: GLYPHS.pointer, paint: LIVE.running },
34
+ neutral: { glyph: GLYPHS.neutral, paint: LIVE.chrome },
20
35
  };
21
- export const narrationLine = (event) => {
22
- const line = formatJournalNarration(event);
23
- const verdict = NARRATION_VERDICTS[event.event];
24
- return verdict !== undefined && ttyVisual() ? statusRow(verdict, line) : line;
36
+ /** Closed retained set: the short operator label and the row's default tone. Labels are the rail's
37
+ * own vocabulary — a raw journal event name is what this surface exists to stop printing. A `pass`
38
+ * or `ok` datum on the event overrides the default tone, so one gate row can read either way.
39
+ *
40
+ * MEMBERSHIP RULE — the daemon journals far more than this, and an allowlist built from whatever
41
+ * the tests happened to cover masks real events. An event earns a row when it changes what the run
42
+ * will DO next or reports an OUTCOME of it: a routing decision, a worker result, a gate start or
43
+ * verdict, a repair, an escalation, a merge, a run lifecycle step. Everything else — how the daemon
44
+ * got there (worktree setup, launch mechanics, baseline and routing lints) and every poll-time
45
+ * observation (contact reads, quota banners, held dead-verdicts, context samples) — stays off the
46
+ * rail and on the pipe, where it is byte-identical to the raw journal.
47
+ *
48
+ * Applying that rule is what put `graph-rehash` and the worker-nudge family here: a rehash is the
49
+ * operator's audited `--graph-changed` release, journaled by the resumed run through THIS sink, and
50
+ * a nudge is a decision the daemon takes on the operator's behalf (it contacts the worker and arms a
51
+ * grace deadline that force-concludes the wait), whose answered/failed/expired rows are that
52
+ * decision's outcome. Neither is mechanics, and both reach this process's narrate callback. */
53
+ export const RAIL_ROWS = {
54
+ // run lifecycle
55
+ "run-start": { label: "started", tone: "active" },
56
+ "run-resume": { label: "resumed", tone: "active" },
57
+ "resume-restore": { label: "restored", tone: "neutral" },
58
+ // the operator's audited --graph-changed release: the resumed run journals it through this sink
59
+ "graph-rehash": { label: "graph rehashed", tone: "attention" },
60
+ "lock-reclaimed": { label: "lock reclaimed", tone: "neutral" },
61
+ "run-end": { label: "finished", tone: "neutral" },
62
+ "tip-verify": { label: "tip verify", tone: "pass" },
63
+ "tip-verify-failed": { label: "tip verify", tone: "fail" },
64
+ "tip-verify-start": { label: "tip verify", tone: "active" },
65
+ "tip-verify-cached": { label: "tip verify", tone: "pass" },
66
+ "exit-cause": { label: "exit cause", tone: "neutral" },
67
+ // decisions — every routing decision the daemon makes on the operator's behalf, not just dispatch
68
+ "task-dispatch": { label: "dispatch", tone: "active" },
69
+ "dispatch-retry": { label: "redispatch", tone: "attention" },
70
+ "route-deviation": { label: "reroute", tone: "attention" },
71
+ "failover-deviation": { label: "reroute", tone: "attention" },
72
+ "quota-failover": { label: "failover", tone: "attention" },
73
+ "dead-channel-failover": { label: "failover", tone: "attention" },
74
+ "provider-death-requeue": { label: "requeue", tone: "attention" },
75
+ "channel-demotion": { label: "channel demoted", tone: "attention" },
76
+ "channel-exclusion": { label: "channel excluded", tone: "attention" },
77
+ "consult-verdict": { label: "consult", tone: "attention" },
78
+ // NOT on this rail: `task-approved`. It is an operator decision and it reads like a rail row, but
79
+ // its only producer is `tickmarkr approve` — a SEPARATE CLI process appending through its own
80
+ // Journal (src/cli/commands/approve.ts), and the daemon reads that event back without ever
81
+ // re-appending it, so no `narrate` callback in this process can ever see it. A retained row for it
82
+ // renders only for a synthetic event. It belongs here the day `approve` delivers to the live run
83
+ // instead of only to the file — see the producer-reachability guard in tests/cli/brand-surfaces.
84
+ // the daemon ACCEPTED a trust dialog on the operator's behalf and the worker now runs with
85
+ // whatever that dialog was granting. It is a decision, it is automatic, and it is the one the
86
+ // operator is least likely to expect — it is on the rail for exactly the reason the rest are.
87
+ "trust-auto-answer": { label: "trust accepted", tone: "attention" },
88
+ "channel-recycle": { label: "channel recycled", tone: "attention" },
89
+ "retry-same-banned": { label: "retry banned", tone: "attention" },
90
+ // an identical gate failure hit the fingerprint cap: the daemon forces a consult and bans the
91
+ // identical retry — a decision that changes the next dispatch, not an observation of this one
92
+ "gate-fingerprint-cap": { label: "repeat capped", tone: "attention" },
93
+ // a scope red every collateral prediction already named: the daemon parks the task WITHOUT
94
+ // charging an attempt, which is the most consequential unchargeable decision it makes
95
+ "scope-authoring": { label: "authoring defect", tone: "attention" },
96
+ "session-reset": { label: "session reset", tone: "attention" },
97
+ "worker-mode-fallback": { label: "dispatch mode", tone: "attention" },
98
+ "delivery-readiness-failed": { label: "delivery failed", tone: "fail" },
99
+ // worker results
100
+ "worker-result": { label: "worker", tone: "active" },
101
+ // the daemon SYNTHESIZED this result from committed work because the worker claimed nothing — it
102
+ // carries no `ok` to read, and it is never a routine worker result: it wants the operator's eye
103
+ "worker-result-harvested": { label: "worker", tone: "attention" },
104
+ // the nudge decision and its three outcomes: the daemon contacted a silent worker and armed a
105
+ // grace deadline, and the answered/failed/expired row says what that contact bought
106
+ "worker-nudge": { label: "nudged", tone: "attention" },
107
+ "worker-nudge-answered": { label: "nudge answered", tone: "pass" },
108
+ "worker-nudge-failed": { label: "nudge undelivered", tone: "attention" },
109
+ "worker-nudge-expired": { label: "nudge expired", tone: "attention" },
110
+ "worker-dead": { label: "worker dead", tone: "fail" },
111
+ "worker-harvest": { label: "harvested", tone: "attention" },
112
+ "work-loss": { label: "work lost", tone: "fail" },
113
+ "task-done": { label: "done", tone: "pass" },
114
+ "task-failed": { label: "failed", tone: "fail" },
115
+ "task-human": { label: "parked", tone: "attention" },
116
+ // gate starts and verdicts
117
+ "phase-start": { label: "gate start", tone: "active" },
118
+ "gate-result": { label: "gate", tone: "pass" },
119
+ "gate-reused": { label: "gate reused", tone: "neutral" },
120
+ "judge-retry": { label: "judge retry", tone: "attention" },
121
+ "review-retry": { label: "review retry", tone: "attention" },
122
+ // repairs and escalations
123
+ "repair-dispatch": { label: "repair", tone: "attention" },
124
+ "repair-attempt": { label: "repair", tone: "attention" },
125
+ "repair-cancelled": { label: "repair off", tone: "attention" },
126
+ "repair-exhausted": { label: "repair spent", tone: "fail" },
127
+ "escalation": { label: "escalated", tone: "attention" },
128
+ "operator-page": { label: "page", tone: "attention" },
129
+ "tip-moved": { label: "tip moved", tone: "attention" },
130
+ // merges
131
+ "merge": { label: "integrated", tone: "pass" },
132
+ "merge-conflict": { label: "conflict", tone: "fail" },
25
133
  };
134
+ /** Cells below which a detail is dropped rather than clipped to an unreadable stub. */
135
+ const RAIL_MIN_DETAIL_CELLS = 8;
136
+ const bucket = (value) => (Array.isArray(value) ? value.length : 0);
137
+ /**
138
+ * `run-end` is the LAST row the operator reads and the one they act on, and its record states none
139
+ * of its outcome in `pass` or `ok` — the generic verdict read finds nothing and the row rendered
140
+ * neutral over a crashed run, a red tip and a park alike. The tone is derived from what the record
141
+ * actually says, in the order `summaryGreen` (below) and `formatSummary` already agree on:
142
+ *
143
+ * a fatal crash, a failed task or a failed integration tip is a FAILURE;
144
+ * an incomplete run — parked, blocked or still-pending work — wants ATTENTION;
145
+ * only a run with none of those is a pass.
146
+ *
147
+ * A rail that paints the terminal record by the same predicate the exit code uses can never show a
148
+ * green tickmark over a run whose exit code is 2.
149
+ */
150
+ const runEndTone = (data) => {
151
+ if (data.fatal === true || bucket(data.failed) > 0 || data.tipVerify === "failed")
152
+ return "fail";
153
+ if (bucket(data.human) + bucket(data.blocked) + bucket(data.pending) > 0)
154
+ return "attention";
155
+ return "pass";
156
+ };
157
+ /** A gate row's non-failure is spelled across `pass`, `skipped`, `verdict` and `infra`, and a row may
158
+ * state NONE of them (src/run/outcome.ts) — so the rail reads the canonical outcome rather than
159
+ * collapsing a decline, a held screen or a dead runner into the green a bare `pass` read gives them. */
160
+ const GATE_OUTCOME_TONES = {
161
+ passed: "pass", failed: "fail", skipped: "neutral", declined: "neutral",
162
+ held: "attention", unavailable: "attention", infra: "attention",
163
+ };
164
+ const railTone = (event, fallback) => {
165
+ if (event.event === "run-end")
166
+ return runEndTone(event.data);
167
+ // `gate-result` is the one event whose data IS a gate result; every other row carrying a `gate`
168
+ // names a gate without reporting one (a start, a reuse, a retry) and keeps its declared tone.
169
+ if (event.event === "gate-result")
170
+ return GATE_OUTCOME_TONES[normalizeGateOutcome(event.data).kind] ?? fallback;
171
+ const verdict = event.data.pass ?? event.data.ok;
172
+ return verdict === true ? "pass" : verdict === false ? "fail" : fallback;
173
+ };
174
+ /**
175
+ * Task rows are their task; run rows are THEIR RUN — the run id the journal names on the event when
176
+ * it carries one, otherwise the id of the run this sink was bound to.
177
+ *
178
+ * There is no generic fallback and there must not be one: `run-start`, `run-resume`, `lock-reclaimed`
179
+ * and every tip-verification row journal no `runId` of their own, so a constant here rendered every
180
+ * run's lifecycle rows identically and an operator reading two rails (or one journal replayed beside
181
+ * a live run) could not tell which run they were watching. The id is threaded in from the command
182
+ * that owns the run — `narrationSink` below, `run()` minting it and `resume` carrying its argument.
183
+ */
184
+ const railIdentity = (event, runId) => event.taskId ?? (typeof event.data.runId === "string" && event.data.runId !== "" ? event.data.runId : runId);
185
+ /**
186
+ * The TTY-ONLY salient projection — a closed table, applied to every retained event alike.
187
+ *
188
+ * `formatJournalNarration` picks exactly ONE detail off a fixed ladder (summary, reason, error,
189
+ * step, action, lint, branch, from…), so on most rows the datum an operator actually acts on is not
190
+ * on the line at all: `worker-dead` states a slot and a cpu reading and renders NOTHING, `exit-cause`
191
+ * never says the cause, `work-loss` never says how much was lost, `channel-demotion` never names the
192
+ * channel it demoted. The pipe cannot be widened — its bytes are the raw formatter's, byte for byte
193
+ * — so the rail projects these fields ITSELF, inside `narrationRow`, after the formatter's detail.
194
+ *
195
+ * A field is here when it is the fact the row exists to report and the ladder cannot reach it.
196
+ */
197
+ const RAIL_SALIENT = [
198
+ // the conflicting paths a merge died on: `merge-conflict` journals `{conflict}` and NOTHING on the
199
+ // ladder reaches it, so without this the operator's worst merge row reads only "conflict"
200
+ { key: "conflict", render: (v) => (typeof v === "string" ? v : undefined) },
201
+ // which gates a repair is being spent on (repair-attempt) or was spent on (repair-exhausted) —
202
+ // both journal `gates: string[]` and neither states a scalar the ladder can pick up
203
+ { key: "gates", render: (v) => (Array.isArray(v) && v.length > 0 ? `gates ${v.join(", ")}` : undefined) },
204
+ { key: "channel", render: (v) => (typeof v === "string" ? `channel ${v}` : undefined) },
205
+ { key: "status", render: (v) => (typeof v === "string" ? `status ${v}` : undefined) },
206
+ { key: "cause", render: (v) => (typeof v === "string" ? `cause ${v}` : undefined) },
207
+ { key: "silentMs", render: (v) => (typeof v === "number" ? `silent ${Math.round(v / 1000)}s` : undefined) },
208
+ { key: "tokens", render: (v) => (typeof v === "number" ? `tokens ${v}` : undefined) },
209
+ { key: "lost", render: (v) => (Array.isArray(v) ? `lost ${v.length}` : undefined) },
210
+ { key: "transcript", render: (v) => (typeof v === "string" ? v : undefined) },
211
+ ];
212
+ const railSalient = (data) => RAIL_SALIENT.flatMap(({ key, render }) => {
213
+ const projected = Object.prototype.hasOwnProperty.call(data, key) ? render(data[key]) : undefined;
214
+ return projected === undefined ? [] : [projected];
215
+ });
216
+ /**
217
+ * The raw formatter's OWN detail ladder — the same fields in the same order, deliberately WITHOUT its
218
+ * trailing `.slice(0, 120)`.
219
+ *
220
+ * The rail used to re-read `formatJournalNarration()` and split its line apart, which meant the TTY
221
+ * projection was cut by a UTF-16 code-unit slice BEFORE the cockpit width authority ever saw it: at a
222
+ * terminal wide enough that the rail clips nothing, a summary of 119 ASCII characters followed by an
223
+ * emoji arrived already halved, an unpaired surrogate on the end of the row. Only the width authority
224
+ * may cut this text, and it cuts on grapheme clusters (`clipCells` below).
225
+ *
226
+ * This is one detail vocabulary stated twice, so `brand-surfaces.test.ts` pins the two against each
227
+ * other over the whole event corpus: the pipe's bytes must equal event/taskId/THIS detail put through
228
+ * the formatter's legacy squeeze-and-slice. A ladder that drifts fails there rather than in a tab.
229
+ */
230
+ const formatterDetail = ({ event, data }) => {
231
+ const assignment = data.assignment;
232
+ const direct = [data.summary, data.reason, data.error, data.step, data.action, data.lint, data.branch, data.from]
233
+ .find((value) => typeof value === "string" || typeof value === "number");
234
+ if (Array.isArray(data.done)) {
235
+ return `done ${data.done.length}, failed ${Array.isArray(data.failed) ? data.failed.length : 0}`;
236
+ }
237
+ if (typeof data.gate === "string") {
238
+ if (event === "tip-verify-failed") {
239
+ return `${data.gate} failed${typeof data.lastMergedTask === "string" ? ` after ${data.lastMergedTask}` : ""}`;
240
+ }
241
+ if (event === "tip-verify")
242
+ return `${data.gate} passed`;
243
+ return `${data.gate}${data.pass === true ? " passed" : data.pass === false ? " failed" : ""}`;
244
+ }
245
+ if (typeof data.code === "number")
246
+ return `exit ${data.code}`;
247
+ if (typeof data.pid === "number")
248
+ return `pid ${data.pid}`;
249
+ if (typeof data.baseRef === "string")
250
+ return `base ${data.baseRef.slice(0, 12)}`;
251
+ if (direct !== undefined)
252
+ return String(direct);
253
+ return typeof assignment?.adapter === "string" && typeof assignment.model === "string"
254
+ ? `${assignment.adapter}:${assignment.model}`
255
+ : undefined;
256
+ };
257
+ /**
258
+ * Event-specific projections, for retained rows that the formatter's ladder and the shared salient
259
+ * table BOTH miss entirely.
260
+ *
261
+ * `RAIL_SALIENT` above is keyed by field name and so has to stay generic; these rows state their
262
+ * one meaningful fact in fields that mean nothing on any other event (`chosen`, `diffBytes`,
263
+ * `gatedCommit`). Without them the row is identity plus label and nothing else at any width: a
264
+ * reroute that never says where to, a repair dispatch that never says how much diff it carried, a
265
+ * moved tip that never names either commit, an auto-answered trust dialog that never names the
266
+ * adapter it answered for.
267
+ */
268
+ // The nudge family states its whole payload in `{slot, attempt}` (+ `graceMs` on the expiry) and the
269
+ // ladder reaches none of it: which attempt was nudged, and how long the grace it spent was.
270
+ const nudgeDetail = (d) => typeof d.attempt === "number"
271
+ ? `attempt ${d.attempt}${typeof d.graceMs === "number" ? `, grace ${Math.round(d.graceMs / 1000)}s` : ""}`
272
+ : undefined;
273
+ const RAIL_PROJECTION = {
274
+ "worker-nudge": nudgeDetail,
275
+ "worker-nudge-answered": nudgeDetail,
276
+ "worker-nudge-failed": nudgeDetail,
277
+ "worker-nudge-expired": nudgeDetail,
278
+ "failover-deviation": (d) => typeof d.chosen === "string"
279
+ ? `to ${d.chosen}${typeof d.static === "string" ? ` over ${d.static}` : ""}`
280
+ : undefined,
281
+ // both rehash hashes: the ladder reaches `from` (null on an unbound journal) and never `to`
282
+ "graph-rehash": (d) => (typeof d.to === "string" ? `to ${d.to.slice(0, 12)}` : undefined),
283
+ "repair-dispatch": (d) => typeof d.diffBytes === "number" ? `diff ${d.diffBytes}B${d.capped === true ? ", capped" : ""}` : undefined,
284
+ "tip-moved": (d) => typeof d.gatedCommit === "string" && typeof d.branchTip === "string"
285
+ ? `gated ${d.gatedCommit.slice(0, 12)}, tip ${d.branchTip.slice(0, 12)}`
286
+ : undefined,
287
+ "trust-auto-answer": (d) => typeof d.adapter === "string" ? `${d.adapter}${typeof d.phase === "string" ? ` ${d.phase}` : ""}` : undefined,
288
+ };
289
+ // The row's text: the formatter's detail first, then the salient fields that detail could not carry.
290
+ // Appending (never prefixing) keeps the operator's prose at the front of the row, so the clip a
291
+ // narrow terminal applies takes the projection and leaves the sentence.
292
+ const railDetail = (event) => [formatterDetail(event) ?? "", RAIL_PROJECTION[event.event]?.(event.data) ?? "", ...railSalient(event.data)]
293
+ .filter((part) => part !== "").join(", ");
294
+ /**
295
+ * Journal detail is WORKER-CONTROLLED text - a summary, an error, a transcript line the worker
296
+ * chose. `cellWidth` charges a terminal control zero cells (correctly: the terminal advances no
297
+ * column for it), so an unsanitized cursor-forward, erase-screen or OSC string measures as FITTING
298
+ * while it walks the cursor across the board the rail sits under, and an embedded SGR repaints the
299
+ * quiet palette from inside a row. Every control byte - C0, DEL and C1, which is every escape and
300
+ * every CSI/OSC introducer there is - becomes a space BEFORE the row is measured or styled, so what
301
+ * the rail paints is exactly what it measured and the only escapes on the line are the palette's own.
302
+ */
303
+ const railPrintable = (text) => text.replace(/[\u0000-\u001F\u007F-\u009F]/gu, " ").replace(/\s+/gu, " ").trim();
304
+ /** Clip to `cells` through the width authority - cluster-safe, and marked when it cut. */
305
+ const clipCells = (text, cells) => cells <= 0 ? "" : cellWidth(text) <= cells ? text : `${fitCells(text, cells - 1).trimEnd()}…`;
306
+ /**
307
+ * One rail row, or null when the TTY rail suppresses this event. Never wider than `columns`: the
308
+ * identity and the label are clipped SEPARATELY and the detail takes only what they leave, so a row
309
+ * can never wrap into a second line and can never lose its meaning to a long identity.
310
+ */
311
+ export function narrationRow(event, runId, columns = process.stdout.columns ?? 80) {
312
+ if (TTY_NOISE_EVENTS.includes(event.event))
313
+ return null;
314
+ const row = RAIL_ROWS[event.event];
315
+ if (!row)
316
+ return null;
317
+ if (event.event === "phase-start" && typeof event.data.gate !== "string")
318
+ return null;
319
+ const tone = RAIL_TONES[railTone(event, row.tone)];
320
+ const glyph = tone.paint(tone.glyph);
321
+ const headCells = Math.max(1, columns - 2); // the glyph and its space
322
+ // The rail's contract is identity AND a short label, so the label reserves its cells FIRST: task
323
+ // ids are operator-authored and a legal long one used to swallow the whole head, leaving a narrow
324
+ // terminal a row carrying an identity and no meaning. The label is short by construction and is
325
+ // never given more than half the head; the identity is clipped separately into what remains.
326
+ const label = clipCells(row.label, Math.floor(headCells / 2));
327
+ const identity = clipCells(railPrintable(railIdentity(event, runId)), headCells - cellWidth(label) - 1);
328
+ const painted = `${glyph} ${LIVE.text(identity)} ${LIVE.chrome(label)}`;
329
+ const detail = railPrintable(railDetail(event));
330
+ const detailCells = headCells - cellWidth(identity) - cellWidth(label) - 4; // the head's space and " — "
331
+ return detail && detailCells >= RAIL_MIN_DETAIL_CELLS
332
+ ? `${painted} ${LIVE.chrome(`— ${clipCells(detail, detailCells)}`)}`
333
+ : painted;
334
+ }
335
+ /** The narration line for one event of the run named by `runId`: the raw journal formatter on a pipe
336
+ * (byte-identical, every event), the quiet rail on a TTY. Null means the rail suppressed it — the
337
+ * caller prints nothing. */
338
+ export const narrationLine = (event, runId) => onTty() ? narrationRow(event, runId) : formatJournalNarration(event);
339
+ /**
340
+ * The daemon's narration sink, BOUND TO THE RUN IT NARRATES: the rail on a TTY, the raw journal
341
+ * formatter on a pipe, and nothing at all for an event the rail suppressed. EVERY command that drives
342
+ * a daemon owes its narration to this sink - a second call site that prints `formatJournalNarration`
343
+ * itself is a surface where the rail does not exist, and the operator meets the old unfiltered dump
344
+ * under the newly stacked board.
345
+ *
346
+ * The binding is what puts a real identity on the run-scoped rows: the daemon's `narrate` callback is
347
+ * handed one event and nothing else, and the lifecycle events carry no run id of their own, so the
348
+ * run id can only come from the command that owns the run. Both daemon-driving commands supply it:
349
+ * `run` below mints the id it then passes to the daemon, and `resume` (src/cli/commands/resume.ts)
350
+ * carries the id the operator named.
351
+ */
352
+ export const narrationSink = (runId) => (event) => {
353
+ const row = narrationLine(event, runId);
354
+ if (row !== null)
355
+ console.log(row);
356
+ };
357
+ /**
358
+ * The run's driver, BOUND to the run's narration sink.
359
+ *
360
+ * A driver journals events of its own that the daemon never sees: `dispatch-retry` is appended by
361
+ * HerdrDriver from inside a pane recovery, through a Journal it opens itself (src/drivers/herdr.ts).
362
+ * Unbound, that event lands in the file and on the pipe while the rail — the operator's only live
363
+ * surface — stays silent about a redispatch that already happened. Both daemon-driving commands
364
+ * wrap their driver here so neither can forget the binding.
365
+ */
366
+ export function bindNarration(driver, narrate) {
367
+ driver.narrateWith?.(narrate);
368
+ return driver;
369
+ }
26
370
  const summaryGreen = (s) => s.failed.length === 0 && s.human.length === 0 && s.blocked.length === 0 && s.pending.length === 0
27
371
  && s.tipVerify !== "failed";
28
372
  // v1.51 T2: --quality is a pure compatibility alias for `--mode partner-led` (this run only). It
@@ -90,12 +434,19 @@ export async function run(argv, cwd = process.cwd()) {
90
434
  if (lints.length)
91
435
  throw new Error(`--route-strict: routing lints present, refusing to dispatch:\n${lints.join("\n")}`);
92
436
  }
437
+ // The run id is minted HERE rather than inside the daemon, because the narration sink has to know
438
+ // which run it is narrating before the first event arrives (the daemon's `narrate` callback is
439
+ // handed an event and nothing else, and `run-start` carries no run id). `runDaemon` uses the id
440
+ // it is given exactly as it would use the one it would otherwise mint itself.
441
+ const runId = newRunId();
442
+ const narrate = narrationSink(runId);
93
443
  const s = await runDaemon(cwd, {
444
+ runId,
94
445
  concurrency: values.concurrency ? Number(values.concurrency) : undefined,
95
- driver: pickDriver(cfg, values.driver),
446
+ driver: bindNarration(pickDriver(cfg, values.driver), narrate),
96
447
  mode: flagMode,
97
448
  supersedes: values.supersedes,
98
- narrate: (event) => console.log(narrationLine(event)),
449
+ narrate,
99
450
  });
100
451
  const out = `run ${s.runId} finished — ${formatSummary(s)} (merge to main is a human decision)`;
101
452
  return { out, code: summaryGreen(s) ? 0 : 2 };