tickmarkr 1.72.0 → 1.74.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/dist/cli/commands/approve.js +21 -3
- package/dist/cli/commands/resume.js +10 -2
- package/dist/cli/commands/status.d.ts +2 -0
- package/dist/cli/commands/status.js +260 -41
- package/dist/cli/commands/ui.d.ts +6 -2
- package/dist/cli/commands/ui.js +2 -4
- package/dist/config/config.d.ts +4 -0
- package/dist/config/config.js +6 -1
- package/dist/drivers/herdr.d.ts +5 -0
- package/dist/drivers/herdr.js +100 -10
- package/dist/drivers/subprocess.d.ts +3 -3
- package/dist/drivers/subprocess.js +10 -5
- package/dist/gates/acceptance.js +19 -1
- package/dist/gates/llm.d.ts +4 -0
- package/dist/gates/llm.js +16 -3
- package/dist/gates/run-gates.d.ts +1 -1
- package/dist/gates/run-gates.js +25 -3
- package/dist/route/preference.d.ts +2 -1
- package/dist/route/preference.js +8 -2
- package/dist/run/daemon.d.ts +1 -0
- package/dist/run/daemon.js +139 -4
- package/dist/run/git.d.ts +2 -0
- package/dist/run/git.js +55 -0
- package/dist/run/journal.d.ts +21 -1
- package/dist/run/journal.js +83 -5
- package/dist/tui/ink/components.d.ts +18 -0
- package/dist/tui/ink/components.js +30 -2
- package/dist/tui/ink/studio-app.d.ts +59 -0
- package/dist/tui/ink/studio-app.js +320 -0
- package/package.json +1 -1
- package/dist/tui/app.d.ts +0 -72
- package/dist/tui/app.js +0 -334
- package/dist/tui/engine.d.ts +0 -52
- package/dist/tui/engine.js +0 -92
- package/dist/tui/frame.d.ts +0 -22
- package/dist/tui/frame.js +0 -56
- package/dist/tui/input.d.ts +0 -41
- package/dist/tui/input.js +0 -100
- package/dist/tui/views/consult-dossier.d.ts +0 -49
- package/dist/tui/views/consult-dossier.js +0 -169
- package/dist/tui/views/diff-modal.d.ts +0 -9
- package/dist/tui/views/diff-modal.js +0 -19
- package/dist/tui/views/fleet-view.d.ts +0 -91
- package/dist/tui/views/fleet-view.js +0 -337
- package/dist/tui/views/preview-view.d.ts +0 -7
- package/dist/tui/views/preview-view.js +0 -80
- package/dist/tui/views/profile-view.d.ts +0 -15
- package/dist/tui/views/profile-view.js +0 -93
- package/dist/tui/views/routing-view.d.ts +0 -48
- package/dist/tui/views/routing-view.js +0 -234
- package/dist/tui/views/runs-view.d.ts +0 -70
- package/dist/tui/views/runs-view.js +0 -387
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { userInfo } from "node:os";
|
|
2
|
-
import {
|
|
2
|
+
import { GATE_NAMES } from "../../graph/schema.js";
|
|
3
|
+
import { ATTEMPT_CAP_RELEASE, GATE_SATISFIED_RELEASE, Journal } from "../../run/journal.js";
|
|
3
4
|
// GATE-08 (v1.12): approve a parked human gate so the next `tickmarkr resume <runId>` dispatches it.
|
|
4
5
|
//
|
|
5
6
|
// The approval is a JOURNAL EVENT (task-approved) carrying who and when — it touches ONLY the
|
|
@@ -31,15 +32,32 @@ export async function approve(argv, cwd = process.cwd()) {
|
|
|
31
32
|
}
|
|
32
33
|
// OBS-18: only the most recent task-human for this task decides whether this approval grants a
|
|
33
34
|
// fresh attempt budget. The closed daemon-issued kind, never a human prose string, controls release.
|
|
34
|
-
const
|
|
35
|
+
const events = journal.read();
|
|
36
|
+
let lastHumanIndex = -1;
|
|
37
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
38
|
+
if (events[i].event === "task-human" && events[i].taskId === taskId) {
|
|
39
|
+
lastHumanIndex = i;
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const lastHuman = events[lastHumanIndex];
|
|
35
44
|
const capPark = lastHuman?.data.kind === ATTEMPT_CAP_RELEASE;
|
|
45
|
+
const gateFailPark = lastHuman?.data.kind === "gate-fail";
|
|
46
|
+
const failedGate = gateFailPark
|
|
47
|
+
? events.slice(0, lastHumanIndex).reverse().find((e) => e.event === "gate-result" && e.taskId === taskId && e.data.pass === false
|
|
48
|
+
&& typeof e.data.gate === "string" && GATE_NAMES.includes(e.data.gate))?.data.gate
|
|
49
|
+
: undefined;
|
|
50
|
+
if (gateFailPark && !failedGate) {
|
|
51
|
+
throw new Error(`task ${taskId} is parked on gate-fail but has no failed gate result — refusing to infer one`);
|
|
52
|
+
}
|
|
36
53
|
journal.append("task-approved", taskId, {
|
|
37
54
|
by,
|
|
38
55
|
...(reason ? { reason } : {}),
|
|
39
56
|
via: "cli",
|
|
40
57
|
...(capPark ? { release: ATTEMPT_CAP_RELEASE } : {}),
|
|
58
|
+
...(failedGate ? { release: GATE_SATISFIED_RELEASE, gate: failedGate } : {}),
|
|
41
59
|
});
|
|
42
|
-
return `approved ${taskId} in ${runId} — by ${by}; run \`tickmarkr resume ${runId}\` to dispatch it`;
|
|
60
|
+
return `approved ${taskId} in ${runId} — by ${by}; run \`tickmarkr resume ${runId}\` to ${failedGate ? "continue past the approved gate" : "dispatch it"}`;
|
|
43
61
|
}
|
|
44
62
|
// hand-parsed argv — no CLI framework (house style). Flags --by <name> and --reason <text>; positionals
|
|
45
63
|
// are runId then taskId. Throws usage on missing positionals (mirrors resume.ts/unlock.ts).
|
|
@@ -8,17 +8,25 @@ const summaryGreen = (s) => s.failed.length === 0 && s.human.length === 0 && s.b
|
|
|
8
8
|
export async function resume(argv, cwd = process.cwd()) {
|
|
9
9
|
const runId = argv[0];
|
|
10
10
|
if (!runId)
|
|
11
|
-
throw new Error("usage: tickmarkr resume <run-id>");
|
|
11
|
+
throw new Error("usage: tickmarkr resume <run-id> [--graph-changed] [--retry-failed]");
|
|
12
12
|
// T3: --graph-changed is the operator's audited release of the engagement-identity guard (Sol #2 /
|
|
13
13
|
// Fable F2) — the daemon refuses a mismatched/unbound journal unless this is set, then journals a
|
|
14
14
|
// graph-rehash event naming both hashes. Strip the flag before runId resolution so a bare id still wins.
|
|
15
15
|
const graphChanged = argv.includes("--graph-changed");
|
|
16
|
+
const retryFailed = argv.includes("--retry-failed");
|
|
16
17
|
const cfg = loadConfig(cwd);
|
|
17
18
|
const collisions = denyPreferCollisions(cfg);
|
|
18
19
|
if (collisions.length) {
|
|
19
20
|
throw new Error(collisions.map(denyPreferCollisionLine).join("; "));
|
|
20
21
|
}
|
|
21
|
-
const s = await runDaemon(cwd, {
|
|
22
|
+
const s = await runDaemon(cwd, {
|
|
23
|
+
runId,
|
|
24
|
+
resume: true,
|
|
25
|
+
graphChanged,
|
|
26
|
+
retryFailed,
|
|
27
|
+
driver: pickDriver(cfg),
|
|
28
|
+
narrate: (event) => console.log(formatJournalNarration(event)),
|
|
29
|
+
});
|
|
22
30
|
const out = `resumed ${s.runId} — ${formatSummary(s)}`;
|
|
23
31
|
return { out, code: summaryGreen(s) ? 0 : 2 };
|
|
24
32
|
}
|
|
@@ -3,6 +3,8 @@ import { type JournalEvent } from "../../run/journal.js";
|
|
|
3
3
|
export type StatusOpts = {
|
|
4
4
|
iterations?: number;
|
|
5
5
|
sleep?: (ms: number) => Promise<void>;
|
|
6
|
+
now?: () => number;
|
|
7
|
+
readWorkerOutput?: (taskId: string, attempt: number, runId: string) => Promise<string | undefined>;
|
|
6
8
|
};
|
|
7
9
|
export declare const GATE_KEYS: {
|
|
8
10
|
readonly build: "B";
|
|
@@ -1,15 +1,100 @@
|
|
|
1
1
|
import { BANNER, GLYPHS, dim, fail, legend, ok, rule, statusRow, title, warn } from "../../brand.js";
|
|
2
|
+
import { HerdrDriver } from "../../drivers/herdr.js";
|
|
3
|
+
import { formatOwnedName } from "../../drivers/types.js";
|
|
2
4
|
import { blockedTasks, graphDefinitionHash, loadGraph } from "../../graph/graph.js";
|
|
3
5
|
import { GATE_NAMES } from "../../graph/schema.js";
|
|
4
6
|
import { foldActivity } from "../../run/activity.js";
|
|
5
7
|
import { Journal, engagementComparable, isQualityFailureParkKind, recordedTaskFailureKind, runHasEnded, } from "../../run/journal.js";
|
|
8
|
+
import { normalizeStallSnapshot } from "../../run/stall.js";
|
|
6
9
|
// ponytail: fixed 2s refresh; promote to config.visibility.* only when an operator asks.
|
|
7
10
|
const REFRESH_MS = 2000;
|
|
8
|
-
const NOT_COMPARABLE_NOTICE = "graph recompiled since this run — task states not comparable;
|
|
11
|
+
const NOT_COMPARABLE_NOTICE = "graph recompiled since this run — task states not comparable; resume with `--graph-changed` to audit this recompile";
|
|
12
|
+
const PRIOR_GRAPH_MARKER = "prior graph";
|
|
9
13
|
// The timer must keep the process ALIVE: an unref'd timer here let the event loop drain after the
|
|
10
14
|
// first frame, so a live `--watch` printed once and exited 0 (OBS-11). Never unref this.
|
|
11
15
|
const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
12
16
|
export const GATE_KEYS = { build: "B", test: "T", lint: "L", evidence: "E", scope: "S", acceptance: "A", review: "R" };
|
|
17
|
+
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
18
|
+
const ASCII_SPINNER = ["|", "/", "-", "\\"];
|
|
19
|
+
const SAVE_TERMINAL_TITLE = "\x1b[22;0t";
|
|
20
|
+
const RESTORE_TERMINAL_TITLE = "\x1b[23;0t";
|
|
21
|
+
const taskPhase = (value) => {
|
|
22
|
+
if (value === "worker" || value === "gates" || value === "judge" || value === "review" || value === "merge")
|
|
23
|
+
return value;
|
|
24
|
+
if (typeof value !== "string" || !value.startsWith("gate:"))
|
|
25
|
+
return undefined;
|
|
26
|
+
return GATE_NAMES.includes(value.slice("gate:".length)) ? value : undefined;
|
|
27
|
+
};
|
|
28
|
+
const phaseGate = (phase) => {
|
|
29
|
+
if (phase === "judge")
|
|
30
|
+
return "acceptance";
|
|
31
|
+
if (phase === "review")
|
|
32
|
+
return "review";
|
|
33
|
+
return phase.startsWith("gate:") ? phase.slice("gate:".length) : undefined;
|
|
34
|
+
};
|
|
35
|
+
// A phase is live only between its append-only start marker and the matching real outcome. A newer
|
|
36
|
+
// start for the same task supersedes the prior phase (gates → named gate, worker retry → worker).
|
|
37
|
+
// No clock participates in this fold: time only decorates the derived live interval at render time.
|
|
38
|
+
const livePhases = (events) => {
|
|
39
|
+
const live = new Map();
|
|
40
|
+
for (let order = 0; order < events.length; order++) {
|
|
41
|
+
const event = events[order];
|
|
42
|
+
if (event.event === "run-start" || event.event === "run-resume" || event.event === "run-end" || event.event === "superseded") {
|
|
43
|
+
live.clear();
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (event.event === "phase-start" && event.taskId) {
|
|
47
|
+
const phase = taskPhase(event.data.phase);
|
|
48
|
+
const startedAt = Date.parse(event.ts);
|
|
49
|
+
const attempt = typeof event.data.attempt === "number" && Number.isInteger(event.data.attempt) && event.data.attempt >= 0
|
|
50
|
+
? event.data.attempt
|
|
51
|
+
: 0;
|
|
52
|
+
if (phase && Number.isFinite(startedAt))
|
|
53
|
+
live.set(event.taskId, { taskId: event.taskId, phase, startedAt, order, attempt });
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (!event.taskId)
|
|
57
|
+
continue;
|
|
58
|
+
const active = live.get(event.taskId);
|
|
59
|
+
if (!active)
|
|
60
|
+
continue;
|
|
61
|
+
const gate = phaseGate(active.phase);
|
|
62
|
+
const matched = (active.phase === "worker" && event.event === "worker-result")
|
|
63
|
+
|| (active.phase === "gates" && event.event === "gate-result")
|
|
64
|
+
|| (gate !== undefined && event.event === "gate-result" && event.data.gate === gate)
|
|
65
|
+
|| (active.phase === "merge" && event.event === "merge")
|
|
66
|
+
|| event.event === "task-done"
|
|
67
|
+
|| event.event === "task-failed"
|
|
68
|
+
|| event.event === "task-human"
|
|
69
|
+
|| event.event === "task-approved"
|
|
70
|
+
|| event.event === "task-dispatch";
|
|
71
|
+
if (matched)
|
|
72
|
+
live.delete(event.taskId);
|
|
73
|
+
}
|
|
74
|
+
return live;
|
|
75
|
+
};
|
|
76
|
+
const phaseLabel = (phase) => phase.startsWith("gate:") ? `gate ${phase.slice("gate:".length)}` : phase;
|
|
77
|
+
const fmtElapsed = (ms) => {
|
|
78
|
+
const seconds = Math.max(0, Math.floor(ms / 1000));
|
|
79
|
+
if (seconds < 60)
|
|
80
|
+
return `${seconds}s`;
|
|
81
|
+
const minutes = Math.floor(seconds / 60);
|
|
82
|
+
if (minutes < 60)
|
|
83
|
+
return `${minutes}m${seconds % 60}s`;
|
|
84
|
+
const hours = Math.floor(minutes / 60);
|
|
85
|
+
return `${hours}h${minutes % 60}m${seconds % 60}s`;
|
|
86
|
+
};
|
|
87
|
+
const workerOutputAge = (phase, now, worker) => now - (worker?.phaseStartedAt === phase.startedAt && worker.hasOutput ? worker.lastOutputAt : phase.startedAt);
|
|
88
|
+
const phaseDetail = (phase, now, worker) => {
|
|
89
|
+
const elapsed = fmtElapsed(now - phase.startedAt);
|
|
90
|
+
if (phase.phase !== "worker")
|
|
91
|
+
return `${phaseLabel(phase.phase)} · ${elapsed} elapsed`;
|
|
92
|
+
const age = fmtElapsed(workerOutputAge(phase, now, worker));
|
|
93
|
+
const output = worker?.phaseStartedAt === phase.startedAt && worker.hasOutput
|
|
94
|
+
? `last output ${age} ago`
|
|
95
|
+
: `no output ${age}`;
|
|
96
|
+
return `${phaseLabel(phase.phase)} · ${elapsed} elapsed · ${output}`;
|
|
97
|
+
};
|
|
13
98
|
const attemptStartIdx = (events, taskId) => {
|
|
14
99
|
let idx = -1;
|
|
15
100
|
for (let i = 0; i < events.length; i++) {
|
|
@@ -36,23 +121,28 @@ export const gateBox = (state, unicode) => {
|
|
|
36
121
|
return state === "pass" ? "[x]" : state === "fail" ? "[!]" : state === "skip" ? "." : "[ ]";
|
|
37
122
|
};
|
|
38
123
|
export const defaultGateStates = (task) => GATE_NAMES.map((gate) => task.gates.includes(gate) ? "open" : "skip");
|
|
39
|
-
|
|
124
|
+
const gateSnapshot = (task, events, rehashAt) => {
|
|
40
125
|
const outcomes = new Map();
|
|
41
126
|
const start = attemptStartIdx(events, task.id);
|
|
42
127
|
if (start >= 0) {
|
|
43
|
-
for (
|
|
128
|
+
for (let eventIndex = start; eventIndex < events.length; eventIndex++) {
|
|
129
|
+
const e = events[eventIndex];
|
|
44
130
|
if (e.taskId !== task.id || e.event !== "gate-result" || typeof e.data.gate !== "string")
|
|
45
131
|
continue;
|
|
46
132
|
if (e.data.skipped === true)
|
|
47
|
-
outcomes.set(e.data.gate, "skip");
|
|
133
|
+
outcomes.set(e.data.gate, { state: "skip", eventIndex });
|
|
48
134
|
else if (e.data.pass === true)
|
|
49
|
-
outcomes.set(e.data.gate, "pass");
|
|
135
|
+
outcomes.set(e.data.gate, { state: "pass", eventIndex });
|
|
50
136
|
else if (e.data.pass === false)
|
|
51
|
-
outcomes.set(e.data.gate, "fail");
|
|
137
|
+
outcomes.set(e.data.gate, { state: "fail", eventIndex });
|
|
52
138
|
}
|
|
53
139
|
}
|
|
54
|
-
return
|
|
140
|
+
return {
|
|
141
|
+
states: GATE_NAMES.map((gate) => task.gates.includes(gate) ? outcomes.get(gate)?.state ?? "open" : "skip"),
|
|
142
|
+
priorGraph: rehashAt !== undefined && [...outcomes.values()].some((outcome) => outcome.eventIndex < rehashAt),
|
|
143
|
+
};
|
|
55
144
|
};
|
|
145
|
+
export const gateStates = (task, events) => gateSnapshot(task, events).states;
|
|
56
146
|
// verdict semantics only: pass brand green, fail red, skip/open dim chrome — everything else stays quiet
|
|
57
147
|
const GATE_STATE_TOKEN = { pass: ok, fail, skip: dim, open: dim };
|
|
58
148
|
// TTY cells are bare glyphs in fixed GATE_NAMES order — gate identity lives once in the frame
|
|
@@ -114,11 +204,11 @@ const terminalFailureCause = (events) => {
|
|
|
114
204
|
}
|
|
115
205
|
return undefined;
|
|
116
206
|
};
|
|
117
|
-
const liveness = (events) => {
|
|
207
|
+
const liveness = (events, now = Date.now()) => {
|
|
118
208
|
const last = events.at(-1);
|
|
119
209
|
if (!last)
|
|
120
210
|
return "last event unknown · daemon pid unknown";
|
|
121
|
-
const age = fmtAge(
|
|
211
|
+
const age = fmtAge(now - Date.parse(last.ts));
|
|
122
212
|
const pid = daemonPid(events);
|
|
123
213
|
const cause = terminalFailureCause(events);
|
|
124
214
|
if (pid === undefined)
|
|
@@ -136,7 +226,25 @@ const liveness = (events) => {
|
|
|
136
226
|
} // EPERM ⇒ alive
|
|
137
227
|
return `last event ${age} ago · daemon pid ${pid} ${state}${cause ? ` · ${cause}` : ""}`;
|
|
138
228
|
};
|
|
139
|
-
|
|
229
|
+
// Resume's shared comparator remains the fail-closed baseline. Status may additionally accept the
|
|
230
|
+
// daemon's audited graph-rehash release, but only when its `from` names that baseline identity (or
|
|
231
|
+
// null for an explicitly released legacy journal) and its `to` names the graph loaded now.
|
|
232
|
+
const statusEngagement = (events, loadedHash) => {
|
|
233
|
+
const baseline = engagementComparable(events, loadedHash);
|
|
234
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
235
|
+
const event = events[i];
|
|
236
|
+
if (event.event !== "graph-rehash")
|
|
237
|
+
continue;
|
|
238
|
+
const auditsBaseline = baseline.comparable || baseline.reason === "mismatch"
|
|
239
|
+
? event.data.from === baseline.recorded
|
|
240
|
+
: event.data.from === null;
|
|
241
|
+
return event.data.to === loadedHash && auditsBaseline
|
|
242
|
+
? { comparable: true, rehashAt: i }
|
|
243
|
+
: { comparable: false };
|
|
244
|
+
}
|
|
245
|
+
return { comparable: baseline.comparable };
|
|
246
|
+
};
|
|
247
|
+
const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness = new Map()) => {
|
|
140
248
|
const g = loadGraph(cwd);
|
|
141
249
|
const runId = Journal.latestRunId(cwd, { withJournal: true });
|
|
142
250
|
const assignments = new Map();
|
|
@@ -144,16 +252,18 @@ const renderFrame = (cwd) => {
|
|
|
144
252
|
let events = [];
|
|
145
253
|
const contexts = new Map();
|
|
146
254
|
let comparable = false;
|
|
255
|
+
let rehashAt;
|
|
147
256
|
let supersededBy; // v1.53 T5: this run is dead — a newer run replaced it
|
|
148
257
|
if (runId) {
|
|
149
258
|
const j = Journal.open(cwd, runId);
|
|
150
259
|
events = j.read();
|
|
151
260
|
const sup = [...events].reverse().find((e) => e.event === "superseded" && typeof e.data.by === "string");
|
|
152
261
|
supersededBy = sup?.data.by;
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
|
|
156
|
-
comparable =
|
|
262
|
+
// The resume comparator is the fail-closed baseline; a matching graph-rehash is the daemon's
|
|
263
|
+
// append-only audit that authorizes this status replay after stop-amend-resume.
|
|
264
|
+
const engagement = statusEngagement(events, graphDefinitionHash(g));
|
|
265
|
+
comparable = engagement.comparable;
|
|
266
|
+
rehashAt = engagement.rehashAt;
|
|
157
267
|
if (comparable) {
|
|
158
268
|
replayed = j.replayStatuses();
|
|
159
269
|
for (const e of events) {
|
|
@@ -179,6 +289,12 @@ const renderFrame = (cwd) => {
|
|
|
179
289
|
const width = process.stdout.columns ?? 120;
|
|
180
290
|
const done = effective.tasks.filter((t) => t.status === "done").length;
|
|
181
291
|
const ended = comparable && runHasEnded(events);
|
|
292
|
+
const taskIds = new Set(g.tasks.map((task) => task.id));
|
|
293
|
+
const phases = comparable ? livePhases(events) : new Map();
|
|
294
|
+
for (const taskId of phases.keys())
|
|
295
|
+
if (!taskIds.has(taskId))
|
|
296
|
+
phases.delete(taskId);
|
|
297
|
+
const hotPhase = [...phases.values()].sort((a, b) => a.order - b.order).at(-1);
|
|
182
298
|
const cells = g.tasks.map((t) => {
|
|
183
299
|
const st = replayed?.get(t.id) ?? t.status;
|
|
184
300
|
const failureKind = comparable ? recordedTaskFailureKind(events, t.id) : undefined;
|
|
@@ -188,27 +304,37 @@ const renderFrame = (cwd) => {
|
|
|
188
304
|
const redTier = terminal && (ended
|
|
189
305
|
|| (failureKind !== undefined && isQualityFailureParkKind(failureKind))
|
|
190
306
|
|| (st === "failed" && failureKind === undefined));
|
|
191
|
-
const
|
|
192
|
-
const
|
|
307
|
+
const livePhase = phases.get(t.id);
|
|
308
|
+
const isStarved = !livePhase && starved.has(t.id);
|
|
309
|
+
const phrase = livePhase ? phaseDetail(livePhase, now, workerLiveness.get(t.id)) : isStarved ? undefined : activity.cells.get(t.id);
|
|
193
310
|
const label = isStarved ? " starved" : phrase ? ` ${phrase}` : "";
|
|
194
311
|
const channel = assignments.get(t.id) ?? "-";
|
|
195
312
|
const ctx = contexts.get(t.id);
|
|
196
313
|
const assignCol = ctx !== undefined ? `${channel}${divider}ctx ${ctx}` : channel;
|
|
197
|
-
|
|
314
|
+
const gates = comparable
|
|
315
|
+
? gateSnapshot(t, events, rehashAt)
|
|
316
|
+
: { states: defaultGateStates(t), priorGraph: false };
|
|
317
|
+
return { t, st, failureKind, redTier, label, assignCol, isStarved, phrase, channel, ctx, livePhase, ...gates };
|
|
198
318
|
});
|
|
199
319
|
if (!unicode) {
|
|
200
|
-
// machine/CI surface —
|
|
201
|
-
|
|
320
|
+
// machine/CI surface — journals without phase-start stay byte-identical; new phase-aware frames
|
|
321
|
+
// use an ASCII spinner so pipes never receive terminal-only braille/ANSI.
|
|
322
|
+
const rows = cells.map(({ t, st, label, assignCol, livePhase, states, priorGraph }) => {
|
|
202
323
|
const chain = gateChain(states, false);
|
|
203
|
-
const prefix = ` ${taskBox(st)} ${t.id} `;
|
|
204
|
-
const suffix = ` ${chain} ${String(st)}${label} ${assignCol}`;
|
|
324
|
+
const prefix = livePhase ? ` ${ASCII_SPINNER[animationFrame % ASCII_SPINNER.length]} ${t.id} ` : ` ${taskBox(st)} ${t.id} `;
|
|
325
|
+
const suffix = ` ${chain}${priorGraph ? ` ${PRIOR_GRAPH_MARKER}` : ""} ${livePhase ? "running" : String(st)}${label} ${assignCol}`;
|
|
205
326
|
return `${prefix}${shortGoal(t.goal, Math.max(0, width - prefix.length - suffix.length))}${suffix}`;
|
|
206
327
|
});
|
|
207
328
|
const header = runId
|
|
208
|
-
? `tickmarkr status${divider}run ${runId}${supersededBy ? `${divider}superseded by ${supersededBy}` : ""}${!comparable ? `${divider}${NOT_COMPARABLE_NOTICE}` : ""}${divider}${liveness(events).replaceAll(" · ", divider)}${divider}${done}/${g.tasks.length} done`
|
|
329
|
+
? `tickmarkr status${divider}run ${runId}${supersededBy ? `${divider}superseded by ${supersededBy}` : ""}${!comparable ? `${divider}${NOT_COMPARABLE_NOTICE}` : ""}${divider}${liveness(events, now).replaceAll(" · ", divider)}${divider}${done}/${g.tasks.length} done`
|
|
209
330
|
: `tickmarkr status${divider}no runs yet${divider}${done}/${g.tasks.length} done`;
|
|
210
331
|
const legendLine = ` gates: ${GATE_NAMES.map((gate) => `${GATE_KEYS[gate]} ${gate}`).join(divider)}`;
|
|
211
|
-
return
|
|
332
|
+
return {
|
|
333
|
+
content: [header, legendLine, ...rows].join("\n"),
|
|
334
|
+
...(hotPhase ? { hotPhase } : {}),
|
|
335
|
+
...(runId ? { runId } : {}),
|
|
336
|
+
workerPhases: [...phases.values()].filter((phase) => phase.phase === "worker"),
|
|
337
|
+
};
|
|
212
338
|
}
|
|
213
339
|
// TTY: cockpit frame composed through src/brand.ts (CLI-DESIGN.md) — dominant run title,
|
|
214
340
|
// dim chrome, semantic color only on verdicts, completion gauge, and column-aligned status
|
|
@@ -219,7 +345,7 @@ const renderFrame = (cwd) => {
|
|
|
219
345
|
const gaugeCells = 10;
|
|
220
346
|
const fill = g.tasks.length ? Math.round((done / g.tasks.length) * gaugeCells) : 0;
|
|
221
347
|
const gauge = (fill ? (anyFailed ? fail : ok)("█".repeat(fill)) : "") + (fill < gaugeCells ? dim("░".repeat(gaugeCells - fill)) : "");
|
|
222
|
-
const live = liveness(events)
|
|
348
|
+
const live = liveness(events, now)
|
|
223
349
|
.replace(/\bdead\b/, fail("dead"))
|
|
224
350
|
.replace(/\bfinished\b/, dim("finished"))
|
|
225
351
|
.replace(/\balive\b/, ok("alive"))
|
|
@@ -241,7 +367,7 @@ const renderFrame = (cwd) => {
|
|
|
241
367
|
// aligned under the goal: gate chain, live activity phrase (or channel), ctx. Long channel names
|
|
242
368
|
// and activity phrases live on line 2 only, so they can never squeeze the goal or wrap line 1.
|
|
243
369
|
const taskVerdict = (c) => c.st === "done" ? "pass" : c.redTier ? "fail" : c.st === "failed" || c.st === "human" ? "warn" : "neutral";
|
|
244
|
-
const statusWord = (c) => c.redTier ? "failed" : c.st === "failed" ? "warn" : String(c.st);
|
|
370
|
+
const statusWord = (c) => c.livePhase ? "running" : c.redTier ? "failed" : c.st === "failed" ? "warn" : String(c.st);
|
|
245
371
|
const idW = Math.max(...cells.map((c) => c.t.id.length), 2);
|
|
246
372
|
// plain-text status suffixes for width math only — starved / failed gate names / approval hint
|
|
247
373
|
const suffixPlain = (c) => (c.isStarved ? " · starved" : "") + failedSuffix(c.states) + humanGateSuffix(c.t, c.st, c.states);
|
|
@@ -251,9 +377,11 @@ const renderFrame = (cwd) => {
|
|
|
251
377
|
const goalW = Math.max(8, ...goals.map((s) => s.length));
|
|
252
378
|
const indent = " ".repeat(idW + 5); // line 2 starts under the goal column
|
|
253
379
|
const rows = cells.map((c, i) => {
|
|
254
|
-
const { t, st, failureKind, redTier, states, isStarved, phrase, channel, ctx } = c;
|
|
380
|
+
const { t, st, failureKind, redTier, states, priorGraph, isStarved, phrase, channel, ctx, livePhase } = c;
|
|
255
381
|
const word = statusWord(c);
|
|
256
|
-
const
|
|
382
|
+
const staleWorker = livePhase?.phase === "worker"
|
|
383
|
+
&& workerOutputAge(livePhase, now, workerLiveness.get(t.id)) >= 60_000;
|
|
384
|
+
const stWord = staleWorker ? warn(word) : st === "done" ? ok(word) : redTier ? fail(word) : st === "failed" || st === "human" ? warn(word) : word;
|
|
257
385
|
// a fail names its gate in words right here — the one moment gate identity is needed on a row
|
|
258
386
|
const f = failedGates(states);
|
|
259
387
|
const human = humanGateSuffix(t, st, states);
|
|
@@ -261,9 +389,13 @@ const renderFrame = (cwd) => {
|
|
|
261
389
|
(isStarved ? dot + fail("starved") : "") +
|
|
262
390
|
(f.length ? dot + fail(f.join(", ")) : "") +
|
|
263
391
|
(human ? dot + warn("awaiting approval") : "");
|
|
264
|
-
const
|
|
392
|
+
const taskLabel = `${t.id.padEnd(idW)} ${goals[i].padEnd(goalW)} ${statusCell}`;
|
|
393
|
+
const line1 = livePhase
|
|
394
|
+
? ` ${(staleWorker ? warn : dim)(SPINNER[animationFrame % SPINNER.length])} ${taskLabel}`
|
|
395
|
+
: ` ${statusRow(taskVerdict(c), taskLabel)}`;
|
|
265
396
|
// activity already names its channel for in-flight attempts — never repeat it
|
|
266
397
|
const detail = [
|
|
398
|
+
...(priorGraph ? [PRIOR_GRAPH_MARKER] : []),
|
|
267
399
|
...(phrase ? [phrase, ...(phrase.includes(channel) || channel === "-" ? [] : [channel])] : [channel]),
|
|
268
400
|
...(failureKind && !phrase?.includes(failureKind) ? [failureKind] : []),
|
|
269
401
|
...(ctx !== undefined ? [`ctx ${ctx}`] : []),
|
|
@@ -272,28 +404,115 @@ const renderFrame = (cwd) => {
|
|
|
272
404
|
}).flat();
|
|
273
405
|
if (!nowLine.length)
|
|
274
406
|
rows.pop(); // cards end blank-separated; drop the dangling one
|
|
275
|
-
return
|
|
407
|
+
return {
|
|
408
|
+
content: [header, hr, gatesLegend, "", ...rows, ...nowLine].join("\n"),
|
|
409
|
+
...(hotPhase ? { hotPhase } : {}),
|
|
410
|
+
...(runId ? { runId } : {}),
|
|
411
|
+
workerPhases: [...phases.values()].filter((phase) => phase.phase === "worker"),
|
|
412
|
+
};
|
|
276
413
|
};
|
|
277
414
|
export async function status(argv, cwd = process.cwd(), opts = {}) {
|
|
278
415
|
// cockpit surface: banner + frame on a TTY (doctor's pattern); pipes get the bare frame
|
|
279
|
-
if (!argv.includes("--watch"))
|
|
280
|
-
|
|
416
|
+
if (!argv.includes("--watch")) {
|
|
417
|
+
const { content } = renderFrame(cwd, opts.now?.() ?? Date.now());
|
|
418
|
+
return visual() ? BANNER + content : content;
|
|
419
|
+
}
|
|
281
420
|
const iterations = opts.iterations ?? Infinity;
|
|
282
421
|
const sleep = opts.sleep ?? defaultSleep;
|
|
422
|
+
const now = opts.now ?? Date.now;
|
|
283
423
|
const bounded = Number.isFinite(iterations);
|
|
284
424
|
const frames = [];
|
|
285
425
|
const sep = "\n---\n";
|
|
286
426
|
const tty = visual();
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
427
|
+
const workerLiveness = new Map();
|
|
428
|
+
const herdr = HerdrDriver.available() ? new HerdrDriver() : undefined;
|
|
429
|
+
const readWorkerOutput = opts.readWorkerOutput ?? (herdr
|
|
430
|
+
? async (taskId, attempt, runId) => {
|
|
431
|
+
const name = formatOwnedName({ role: "worker", taskId, attempt, runId });
|
|
432
|
+
try {
|
|
433
|
+
return await herdr.read({ id: name, name, cwd }, 80);
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
return undefined;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
: undefined);
|
|
440
|
+
const observeWorkerOutput = async (active, runId, observedAt) => {
|
|
441
|
+
const activeIds = new Set(active.map((phase) => phase.taskId));
|
|
442
|
+
for (const taskId of workerLiveness.keys())
|
|
443
|
+
if (!activeIds.has(taskId))
|
|
444
|
+
workerLiveness.delete(taskId);
|
|
445
|
+
if (!readWorkerOutput || !runId)
|
|
446
|
+
return;
|
|
447
|
+
await Promise.all(active.map(async (phase) => {
|
|
448
|
+
const output = await readWorkerOutput(phase.taskId, phase.attempt, runId);
|
|
449
|
+
if (output === undefined)
|
|
450
|
+
return;
|
|
451
|
+
const snapshot = normalizeStallSnapshot(output);
|
|
452
|
+
const prior = workerLiveness.get(phase.taskId);
|
|
453
|
+
if (!prior || prior.phaseStartedAt !== phase.startedAt) {
|
|
454
|
+
const hasOutput = snapshot.trim().length > 0;
|
|
455
|
+
workerLiveness.set(phase.taskId, {
|
|
456
|
+
phaseStartedAt: phase.startedAt,
|
|
457
|
+
snapshot,
|
|
458
|
+
hasOutput,
|
|
459
|
+
lastOutputAt: hasOutput ? observedAt : phase.startedAt,
|
|
460
|
+
});
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
if (snapshot !== prior.snapshot && snapshot.trim().length > 0) {
|
|
464
|
+
prior.hasOutput = true;
|
|
465
|
+
prior.lastOutputAt = observedAt;
|
|
466
|
+
}
|
|
467
|
+
prior.snapshot = snapshot;
|
|
468
|
+
}));
|
|
469
|
+
};
|
|
470
|
+
let titleSaved = false;
|
|
471
|
+
const restoreTitle = () => {
|
|
472
|
+
if (!titleSaved)
|
|
473
|
+
return;
|
|
474
|
+
titleSaved = false;
|
|
475
|
+
process.stdout.write(RESTORE_TERMINAL_TITLE);
|
|
476
|
+
};
|
|
477
|
+
const updateTitle = (hotPhase, nowMs) => {
|
|
478
|
+
if (!hotPhase) {
|
|
479
|
+
if (titleSaved) {
|
|
480
|
+
process.removeListener("exit", restoreTitle);
|
|
481
|
+
restoreTitle();
|
|
482
|
+
}
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
if (!titleSaved) {
|
|
486
|
+
process.stdout.write(SAVE_TERMINAL_TITLE);
|
|
487
|
+
titleSaved = true;
|
|
488
|
+
process.once("exit", restoreTitle);
|
|
489
|
+
}
|
|
490
|
+
process.stdout.write(`\x1b]0;⏳ ${hotPhase.taskId} ${phaseLabel(hotPhase.phase)} ${fmtElapsed(nowMs - hotPhase.startedAt)}\x07`);
|
|
491
|
+
};
|
|
492
|
+
try {
|
|
493
|
+
for (let i = 0; i < iterations; i++) {
|
|
494
|
+
const nowMs = now();
|
|
495
|
+
const frame = renderFrame(cwd, nowMs, i, workerLiveness);
|
|
496
|
+
if (tty) {
|
|
497
|
+
updateTitle(frame.hotPhase, nowMs);
|
|
498
|
+
process.stdout.write(`\x1b[2J\x1b[H${BANNER}${frame.content}\n${legend(` watching · refresh ${REFRESH_MS / 1000}s · ^C to quit`)}`);
|
|
499
|
+
}
|
|
500
|
+
else {
|
|
501
|
+
process.stdout.write(frame.content + sep);
|
|
502
|
+
}
|
|
503
|
+
if (bounded)
|
|
504
|
+
frames.push(frame.content);
|
|
505
|
+
if (i + 1 < iterations) {
|
|
506
|
+
await observeWorkerOutput(frame.workerPhases, frame.runId, nowMs);
|
|
507
|
+
await sleep(REFRESH_MS);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
finally {
|
|
512
|
+
if (titleSaved) {
|
|
513
|
+
process.removeListener("exit", restoreTitle);
|
|
514
|
+
restoreTitle();
|
|
515
|
+
}
|
|
297
516
|
}
|
|
298
517
|
return bounded ? frames.join(sep) : "";
|
|
299
518
|
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
type StudioIO = {
|
|
2
|
+
input: NodeJS.ReadStream;
|
|
3
|
+
output: NodeJS.WriteStream;
|
|
4
|
+
};
|
|
5
|
+
export declare function ui(_argv: string[], io?: Partial<StudioIO>): Promise<string | {
|
|
3
6
|
out: string;
|
|
4
7
|
code: number;
|
|
5
8
|
}>;
|
|
9
|
+
export {};
|
package/dist/cli/commands/ui.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { StudioApp } from "../../tui/app.js";
|
|
2
1
|
const NON_TTY_MSG = "tickmarkr ui: studio requires a TTY — use `tickmarkr fleet --print` or `tickmarkr status --watch` for line-mode output";
|
|
3
2
|
export async function ui(_argv, io = {}) {
|
|
4
3
|
const input = io.input ?? process.stdin;
|
|
@@ -6,8 +5,7 @@ export async function ui(_argv, io = {}) {
|
|
|
6
5
|
if (input.isTTY !== true || output.isTTY !== true) {
|
|
7
6
|
return { out: NON_TTY_MSG, code: 1 };
|
|
8
7
|
}
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
await app.exited;
|
|
8
|
+
const { runStudioInk } = await import("../../tui/ink/studio-app.js");
|
|
9
|
+
await runStudioInk({ input, output });
|
|
12
10
|
return "ui: closed";
|
|
13
11
|
}
|
package/dist/config/config.d.ts
CHANGED
|
@@ -109,6 +109,10 @@ export declare const TickmarkrConfigSchema: z.ZodObject<{
|
|
|
109
109
|
deny: z.ZodOptional<z.ZodObject<{
|
|
110
110
|
adapters: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
111
111
|
models: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
112
|
+
workers: z.ZodOptional<z.ZodObject<{
|
|
113
|
+
adapters: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
114
|
+
models: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
115
|
+
}, z.core.$strip>>;
|
|
112
116
|
}, z.core.$strip>>;
|
|
113
117
|
}, z.core.$strip>;
|
|
114
118
|
tiers: z.ZodRecord<z.ZodString, z.ZodObject<{
|
package/dist/config/config.js
CHANGED
|
@@ -51,6 +51,9 @@ const PrefBlockSchema = z.object({
|
|
|
51
51
|
adapters: z.array(z.string()).optional(),
|
|
52
52
|
models: z.array(z.string()).optional(),
|
|
53
53
|
});
|
|
54
|
+
const DenyBlockSchema = PrefBlockSchema.extend({
|
|
55
|
+
workers: PrefBlockSchema.optional(),
|
|
56
|
+
});
|
|
54
57
|
// v1.20 REC-02: operator-maintained price table for the usage/cost report (src/report/cost.ts). Two
|
|
55
58
|
// channel economics, never conflated (spec cost model): API = tokens × per-Mtok rate; sub = flat plan
|
|
56
59
|
// amortized over a windows/month RANGE (+ an API-equivalent counterfactual when the model has a rate).
|
|
@@ -125,7 +128,7 @@ export const TickmarkrConfigSchema = z.object({
|
|
|
125
128
|
// Pre-v1.21 doctor.json lacks per-model verdicts. Keep legacy unknown-is-routable behavior opt-in.
|
|
126
129
|
allowUnverifiedModels: z.boolean(),
|
|
127
130
|
allow: PrefBlockSchema.optional(),
|
|
128
|
-
deny:
|
|
131
|
+
deny: DenyBlockSchema.optional(),
|
|
129
132
|
}),
|
|
130
133
|
tiers: z.record(z.string(), TierEntrySchema),
|
|
131
134
|
pricing: z.record(z.string(), z.number()),
|
|
@@ -471,6 +474,8 @@ export function configTemplate(overlay) {
|
|
|
471
474
|
# deny: # optional fleet denylist; deny beats allow on conflict
|
|
472
475
|
# models:
|
|
473
476
|
# - pi:zai/glm-5.2 # OBS-57: pi passes run-start probe but hangs at finish without TICKMARKR_RESULT — remove after no-trailer demotion ships (v1.46 provider-outage taxonomy)
|
|
477
|
+
# workers: # optional worker-only deny; flat adapters/models still deny every role
|
|
478
|
+
# models: [kimi:kimi-code/k3]
|
|
474
479
|
# # incident-born deny/pin entries MUST name OBS id + root cause + removal condition (see docs/codebase/CONVENTIONS.md)
|
|
475
480
|
# # entry grammar: adapter id | model id | adapter:model (entries in either list accept all three forms)
|
|
476
481
|
# # a hint pinning a denied channel FAILS at plan time (RoutingError) — never silent reroute
|
package/dist/drivers/herdr.d.ts
CHANGED
|
@@ -12,12 +12,16 @@ export declare class HerdrDriver implements ExecutorDriver {
|
|
|
12
12
|
private groups;
|
|
13
13
|
private groupSerial;
|
|
14
14
|
private deliverySerial;
|
|
15
|
+
private dispatchLeases;
|
|
16
|
+
private deliveredPanes;
|
|
15
17
|
private ws;
|
|
16
18
|
private callerPane;
|
|
17
19
|
private watches;
|
|
18
20
|
constructor(bin?: string, workersPerTab?: number);
|
|
19
21
|
private serial;
|
|
20
22
|
private deliveryQueue;
|
|
23
|
+
private reserveDispatch;
|
|
24
|
+
private verifyPaneIdentityBinding;
|
|
21
25
|
private deliveryMatches;
|
|
22
26
|
static available(): boolean;
|
|
23
27
|
private herdr;
|
|
@@ -35,6 +39,7 @@ export declare class HerdrDriver implements ExecutorDriver {
|
|
|
35
39
|
private joinGroup;
|
|
36
40
|
run(slot: Slot, cmd: string): Promise<void>;
|
|
37
41
|
private deliver;
|
|
42
|
+
private settleDeliveryLine;
|
|
38
43
|
private deliveryReadMatches;
|
|
39
44
|
private waitOk;
|
|
40
45
|
waitOutput(slot: Slot, pattern: string, timeoutMs: number, opts?: {
|