tickmarkr 1.97.0 → 2.1.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.
- package/README.md +19 -1
- package/dist/brand.d.ts +28 -0
- package/dist/brand.js +41 -0
- package/dist/cli/commands/compile.js +32 -1
- package/dist/cli/commands/doctor.d.ts +19 -0
- package/dist/cli/commands/doctor.js +59 -0
- package/dist/cli/commands/init.js +5 -2
- package/dist/cli/commands/resume.js +25 -8
- package/dist/cli/commands/run.d.ts +61 -1
- package/dist/cli/commands/run.js +372 -18
- package/dist/cli/commands/status.js +145 -28
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +1 -1
- package/dist/compile/collateral.d.ts +25 -0
- package/dist/compile/collateral.js +46 -11
- package/dist/compile/native.js +10 -0
- package/dist/config/config.d.ts +1 -0
- package/dist/config/config.js +2 -2
- package/dist/drivers/herdr.d.ts +19 -13
- package/dist/drivers/herdr.js +90 -26
- package/dist/drivers/index.d.ts +5 -1
- package/dist/drivers/index.js +16 -1
- package/dist/drivers/orca.d.ts +189 -0
- package/dist/drivers/orca.js +879 -0
- package/dist/drivers/types.d.ts +2 -0
- package/dist/gates/acceptance.js +17 -7
- package/dist/gates/llm.d.ts +19 -0
- package/dist/gates/llm.js +104 -6
- package/dist/gates/run-gates.d.ts +18 -0
- package/dist/gates/run-gates.js +195 -29
- package/dist/gates/scope.d.ts +9 -1
- package/dist/gates/scope.js +22 -2
- package/dist/graph/graph.d.ts +1 -0
- package/dist/graph/graph.js +19 -2
- package/dist/report/compare.js +17 -2
- package/dist/run/daemon.d.ts +1 -8
- package/dist/run/daemon.js +231 -246
- package/dist/run/environment.d.ts +18 -1
- package/dist/run/environment.js +19 -2
- package/dist/run/journal.d.ts +50 -3
- package/dist/run/journal.js +181 -5
- package/dist/run/protocol.d.ts +4 -4
- package/dist/run/stall.d.ts +30 -0
- package/dist/run/stall.js +173 -0
- package/dist/tui/ink/init-app.js +14 -4
- package/package.json +1 -1
- package/skills/tickmarkr-overseer/SKILL.md +27 -15
package/dist/cli/commands/run.js
CHANGED
|
@@ -1,28 +1,372 @@
|
|
|
1
1
|
import { parseArgs } from "node:util";
|
|
2
2
|
import { allAdapters, discoverChannels, probeAll, readDoctor } from "../../adapters/registry.js";
|
|
3
3
|
import { ROUTING_MODES } from "../../config/config.js";
|
|
4
|
-
import { pickDriver } from "../../drivers/index.js";
|
|
4
|
+
import { parseDriverOverride, pickDriver } from "../../drivers/index.js";
|
|
5
5
|
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 {
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
@@ -49,6 +393,9 @@ export async function run(argv, cwd = process.cwd()) {
|
|
|
49
393
|
if (!Number.isInteger(n) || n <= 0)
|
|
50
394
|
throw new Error(`--concurrency must be a positive integer (got ${values.concurrency})`);
|
|
51
395
|
}
|
|
396
|
+
// Keep invalid input at the argv boundary. In particular, do not cast a string into the closed
|
|
397
|
+
// driver union and accidentally turn an unknown explicit choice into auto-selection.
|
|
398
|
+
const driverOverride = parseDriverOverride(values.driver);
|
|
52
399
|
if (values.quality && values.mode !== undefined) {
|
|
53
400
|
throw new Error("--quality is a compatibility alias for --mode partner-led and cannot be combined with an explicit --mode — pass one or the other");
|
|
54
401
|
}
|
|
@@ -90,12 +437,19 @@ export async function run(argv, cwd = process.cwd()) {
|
|
|
90
437
|
if (lints.length)
|
|
91
438
|
throw new Error(`--route-strict: routing lints present, refusing to dispatch:\n${lints.join("\n")}`);
|
|
92
439
|
}
|
|
440
|
+
// The run id is minted HERE rather than inside the daemon, because the narration sink has to know
|
|
441
|
+
// which run it is narrating before the first event arrives (the daemon's `narrate` callback is
|
|
442
|
+
// handed an event and nothing else, and `run-start` carries no run id). `runDaemon` uses the id
|
|
443
|
+
// it is given exactly as it would use the one it would otherwise mint itself.
|
|
444
|
+
const runId = newRunId();
|
|
445
|
+
const narrate = narrationSink(runId);
|
|
93
446
|
const s = await runDaemon(cwd, {
|
|
447
|
+
runId,
|
|
94
448
|
concurrency: values.concurrency ? Number(values.concurrency) : undefined,
|
|
95
|
-
driver: pickDriver(cfg,
|
|
449
|
+
driver: bindNarration(pickDriver(cfg, driverOverride), narrate),
|
|
96
450
|
mode: flagMode,
|
|
97
451
|
supersedes: values.supersedes,
|
|
98
|
-
narrate
|
|
452
|
+
narrate,
|
|
99
453
|
});
|
|
100
454
|
const out = `run ${s.runId} finished — ${formatSummary(s)} (merge to main is a human decision)`;
|
|
101
455
|
return { out, code: summaryGreen(s) ? 0 : 2 };
|