tickmarkr 1.80.0 → 1.83.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +4 -4
  2. package/dist/brand.d.ts +4 -1
  3. package/dist/brand.js +46 -7
  4. package/dist/cli/commands/approve.js +53 -8
  5. package/dist/cli/commands/plan.js +17 -2
  6. package/dist/cli/commands/report.js +15 -6
  7. package/dist/cli/commands/ui.d.ts +1 -1
  8. package/dist/cli/commands/ui.js +47 -4
  9. package/dist/cli/index.d.ts +1 -1
  10. package/dist/cli/index.js +1 -1
  11. package/dist/compile/collateral.d.ts +19 -0
  12. package/dist/compile/collateral.js +90 -0
  13. package/dist/compile/index.js +18 -4
  14. package/dist/drivers/herdr.d.ts +1 -0
  15. package/dist/drivers/herdr.js +19 -0
  16. package/dist/drivers/types.d.ts +1 -0
  17. package/dist/gates/acceptance.js +50 -8
  18. package/dist/gates/llm.js +34 -19
  19. package/dist/gates/review.d.ts +9 -1
  20. package/dist/gates/review.js +173 -7
  21. package/dist/gates/run-gates.d.ts +1 -0
  22. package/dist/gates/run-gates.js +18 -1
  23. package/dist/report/bundle.d.ts +6 -1
  24. package/dist/report/bundle.js +13 -3
  25. package/dist/run/daemon.d.ts +5 -0
  26. package/dist/run/daemon.js +188 -27
  27. package/dist/run/journal.d.ts +5 -0
  28. package/dist/run/journal.js +71 -1
  29. package/dist/tui/cockpit/capture.d.ts +107 -1
  30. package/dist/tui/cockpit/capture.js +287 -10
  31. package/dist/tui/cockpit/components.d.ts +15 -86
  32. package/dist/tui/cockpit/components.js +49 -55
  33. package/dist/tui/cockpit/derive.d.ts +73 -1
  34. package/dist/tui/cockpit/derive.js +211 -15
  35. package/dist/tui/cockpit/keys.d.ts +244 -0
  36. package/dist/tui/cockpit/keys.js +634 -0
  37. package/dist/tui/cockpit/layout.d.ts +162 -0
  38. package/dist/tui/cockpit/layout.js +229 -0
  39. package/dist/tui/cockpit/live.d.ts +80 -0
  40. package/dist/tui/cockpit/live.js +311 -0
  41. package/dist/tui/cockpit/run-cockpit.d.ts +65 -2
  42. package/dist/tui/cockpit/run-cockpit.js +705 -128
  43. package/dist/tui/cockpit/setup-cockpit.d.ts +149 -1
  44. package/dist/tui/cockpit/setup-cockpit.js +432 -56
  45. package/dist/tui/cockpit/views.d.ts +72 -0
  46. package/dist/tui/cockpit/views.js +56 -0
  47. package/dist/tui/cockpit/width.d.ts +105 -0
  48. package/dist/tui/cockpit/width.js +276 -0
  49. package/fixtures/speckit-sample/tasks.md +2 -2
  50. package/package.json +3 -1
@@ -0,0 +1,311 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { render, useApp, useInput } from "ink";
4
+ import { createElement, useEffect, useRef, useSyncExternalStore } from "react";
5
+ import { graphPath, loadGraph, stateDirName } from "../../graph/graph.js";
6
+ import { Journal, parseRunId } from "../../run/journal.js";
7
+ import { deriveRunCockpitData } from "./derive.js";
8
+ import { deriveRunViewRows, runKeyColumns, RunCockpitFrame } from "./run-cockpit.js";
9
+ import { dispatchRunSurfaceKey, openingRunSurfaceState, reconcileRunInteraction, RUN_INPUT_BINDINGS, selectableRunViewRowIds, } from "./keys.js";
10
+ // ponytail: fixed 2s re-derive cadence, matching `status --watch`; promote to a
11
+ // config knob only if an operator asks.
12
+ const REFRESH_MS = 2_000;
13
+ /**
14
+ * THE engagement selection rule — stated once, here, so it can change in one
15
+ * place without touching the renderer or the command: an explicit engagement
16
+ * reference from the command line wins; the bare command opens the most
17
+ * recently started engagement that has a readable journal.
18
+ */
19
+ export function selectEngagementRunId(cwd, explicit) {
20
+ if (explicit !== undefined)
21
+ return parseRunId(explicit);
22
+ return Journal.latestRunId(cwd, { withJournal: true });
23
+ }
24
+ /**
25
+ * The journal bytes of a real engagement — never a committed capture. A read
26
+ * failure throws: the caller refuses rather than drawing from nothing.
27
+ */
28
+ export function loadEngagementSource(cwd, runId) {
29
+ const id = parseRunId(runId);
30
+ const path = join(cwd, stateDirName(cwd), "runs", id, "journal.jsonl");
31
+ const raw = readFileSync(path, "utf8");
32
+ return { fileName: `${id}.journal.jsonl`, raw };
33
+ }
34
+ /**
35
+ * The compiled graph, when the repository has one. It lends the tasks view its
36
+ * identities and nothing else, so an engagement with no compiled graph at all
37
+ * still draws every task the journal mentions rather than refusing to open.
38
+ *
39
+ * Absence is the only fallback. A graph that exists and cannot be read, parsed
40
+ * or validated is a fault, and swallowing it would quietly demote the surface:
41
+ * every task the graph alone knows about would vanish from the rows, and a
42
+ * selection standing on one of them would be cleared as if the engagement had
43
+ * dropped it. So it throws, and the caller keeps its last good frame instead.
44
+ */
45
+ export function loadEngagementGraph(cwd) {
46
+ if (!existsSync(graphPath(cwd)))
47
+ return undefined;
48
+ return loadGraph(cwd);
49
+ }
50
+ // The same s/m/h age vocabulary `status` speaks (status.ts fmtAge).
51
+ export function formatEventAge(ageMs) {
52
+ const safe = Math.max(0, ageMs);
53
+ if (safe < 90_000)
54
+ return `${Math.floor(safe / 1_000)}s`;
55
+ if (safe < 5_400_000)
56
+ return `${Math.floor(safe / 60_000)}m`;
57
+ return `${Math.floor(safe / 3_600_000)}h`;
58
+ }
59
+ function lastEventMs(raw) {
60
+ const lines = raw.split("\n");
61
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
62
+ const line = lines[index].trim();
63
+ if (!line)
64
+ continue;
65
+ try {
66
+ const parsed = JSON.parse(line);
67
+ const ms = typeof parsed.ts === "string" ? Date.parse(parsed.ts) : NaN;
68
+ if (Number.isFinite(ms))
69
+ return ms;
70
+ }
71
+ catch {
72
+ // a torn trailing line does not hide the event written before it
73
+ }
74
+ }
75
+ return undefined;
76
+ }
77
+ /**
78
+ * Live derivation over a real engagement's journal bytes. The header's
79
+ * elapsed field additionally states how long ago the engagement's last event
80
+ * arrived, so an engagement that has stopped moving reads as stale rather
81
+ * than as current. Throws on unreadable bytes (empty capture, no run-start) —
82
+ * the caller refuses instead of rendering a plausible surface from nothing.
83
+ */
84
+ export function deriveLiveRunCockpitData(source, binaryVersion, now = Date.now, graph) {
85
+ const data = deriveRunCockpitData(source, binaryVersion, { graph });
86
+ const lastMs = lastEventMs(source.raw);
87
+ const staleness = lastMs === undefined
88
+ ? "last event unknown"
89
+ : `last event ${formatEventAge(now() - lastMs)} ago`;
90
+ return { ...data, elapsed: `${data.elapsed} · ${staleness}` };
91
+ }
92
+ /**
93
+ * Repair the interaction state against the rows a refreshed view now owns.
94
+ * Selection is held by the row's source identity, so it survives rows landing
95
+ * above it and is dropped — never slid onto whoever inherited its index —
96
+ * when the refreshed engagement no longer carries it.
97
+ */
98
+ export function reconcileLiveRunInteraction(interaction, data) {
99
+ return reconcileRunInteraction(interaction, liveRunViewRowIds(interaction, data));
100
+ }
101
+ /**
102
+ * The rows the live surface hands its keys — the engagement's own identities,
103
+ * minus the journal's, which are never selectable in either tab. This is the
104
+ * one place rows enter the dispatcher, so no key can reach a journal row.
105
+ */
106
+ export function liveRunViewRowIds(interaction, data) {
107
+ return selectableRunViewRowIds(interaction.activeView,
108
+ // The frame's own rows, so a key can never stand on a row the surface did
109
+ // not draw — one derivation, read by both.
110
+ deriveRunViewRows(data, interaction.activeView, interaction.filterQuery)
111
+ .map((row) => row.id));
112
+ }
113
+ function createLiveCockpitDelivery({ cwd, runId, binaryVersion, now, }) {
114
+ // Both sources are read every refresh: a recompile between refreshes changes
115
+ // which tasks exist, and the surface must draw the graph the repository has
116
+ // now rather than the one it had when the cockpit opened.
117
+ const derive = () => deriveLiveRunCockpitData(loadEngagementSource(cwd, runId), binaryVersion, now, loadEngagementGraph(cwd));
118
+ const opening = openingRunSurfaceState();
119
+ let surface = {
120
+ data: derive(),
121
+ interaction: opening.interaction,
122
+ stashed: opening.stashed,
123
+ };
124
+ const listeners = new Set();
125
+ let batchDepth = 0;
126
+ let pendingDraw = false;
127
+ const publish = () => {
128
+ if (batchDepth > 0) {
129
+ pendingDraw = true;
130
+ return;
131
+ }
132
+ for (const listener of listeners)
133
+ listener();
134
+ };
135
+ const transition = (apply) => {
136
+ const next = apply(surface);
137
+ if (next === surface)
138
+ return false;
139
+ surface = next;
140
+ publish();
141
+ return true;
142
+ };
143
+ const delivery = {
144
+ snapshot: () => surface,
145
+ subscribe: (listener) => {
146
+ listeners.add(listener);
147
+ return () => listeners.delete(listener);
148
+ },
149
+ batch: (deliver) => {
150
+ batchDepth += 1;
151
+ try {
152
+ return deliver();
153
+ }
154
+ finally {
155
+ batchDepth -= 1;
156
+ if (batchDepth === 0 && pendingDraw) {
157
+ pendingDraw = false;
158
+ publish();
159
+ }
160
+ }
161
+ },
162
+ refresh: () => {
163
+ try {
164
+ const refreshed = derive();
165
+ return transition((current) => ({
166
+ ...current,
167
+ data: refreshed,
168
+ interaction: reconcileLiveRunInteraction(current.interaction, refreshed),
169
+ }));
170
+ }
171
+ catch {
172
+ // A torn read mid-append, or a graph caught mid-recompile, keeps the
173
+ // last good frame rather than demoting or blanking the surface.
174
+ return false;
175
+ }
176
+ },
177
+ // Every key goes to the one surface dispatcher, in every state the surface
178
+ // can be in. The prompt is a scope inside that registry rather than an
179
+ // owner in front of it, so a global key — Tab above all — reaches its
180
+ // handler with a prompt open exactly as it does without one.
181
+ key: (event, columns = Number.MAX_SAFE_INTEGER) => transition((current) => {
182
+ const next = dispatchRunSurfaceKey(event, { interaction: current.interaction, stashed: current.stashed }, RUN_INPUT_BINDINGS, liveRunViewRowIds(current.interaction, current.data),
183
+ // The dispatcher decides on rail visibility, and the plan owns that.
184
+ runKeyColumns(columns));
185
+ if (next === undefined)
186
+ return current;
187
+ const interaction = reconcileLiveRunInteraction(next.interaction, current.data);
188
+ return interaction === current.interaction && next.stashed === current.stashed
189
+ ? current
190
+ : { ...current, interaction, stashed: next.stashed };
191
+ }),
192
+ };
193
+ return delivery;
194
+ }
195
+ function measureOutput(output) {
196
+ const read = () => `${output.columns ?? 80}:${output.rows ?? 24}`;
197
+ let size = read();
198
+ const listeners = new Set();
199
+ const heldInkResizeListeners = new Set();
200
+ const resized = () => {
201
+ size = read();
202
+ for (const listener of listeners)
203
+ listener();
204
+ };
205
+ output.on("resize", resized);
206
+ // Ink otherwise repaints its previous tree as soon as the stream resizes.
207
+ // Hold that repaint until React has published the newly measured plan.
208
+ const subscribe = (event, listener) => {
209
+ if (event === "resize")
210
+ heldInkResizeListeners.add(listener);
211
+ else
212
+ output.on(event, listener);
213
+ return stdout;
214
+ };
215
+ const unsubscribe = (event, listener) => {
216
+ if (event === "resize")
217
+ heldInkResizeListeners.delete(listener);
218
+ else
219
+ output.off(event, listener);
220
+ return stdout;
221
+ };
222
+ const stdout = new Proxy(output, {
223
+ get(target, property) {
224
+ if (property === "on" || property === "addListener")
225
+ return subscribe;
226
+ if (property === "off" || property === "removeListener")
227
+ return unsubscribe;
228
+ const value = Reflect.get(target, property);
229
+ return typeof value === "function" && !Object.hasOwn(target, property)
230
+ ? value.bind(target)
231
+ : value;
232
+ },
233
+ });
234
+ return {
235
+ subscribe: (listener) => {
236
+ listeners.add(listener);
237
+ return () => listeners.delete(listener);
238
+ },
239
+ snapshot: () => size,
240
+ stdout,
241
+ settle: () => {
242
+ for (const listener of heldInkResizeListeners)
243
+ listener();
244
+ },
245
+ close: () => output.off("resize", resized),
246
+ };
247
+ }
248
+ function LiveApp({ delivery, size, refreshMs, }) {
249
+ const { exit } = useApp();
250
+ const surface = useSyncExternalStore(delivery.subscribe, delivery.snapshot, delivery.snapshot);
251
+ const measuredSize = useSyncExternalStore(size.subscribe, size.snapshot, size.snapshot);
252
+ const [columns, rows] = measuredSize.split(":").map((part) => Math.max(0, Math.floor(Number(part))));
253
+ const drawnSize = useRef(measuredSize);
254
+ useEffect(() => {
255
+ if (drawnSize.current === measuredSize)
256
+ return;
257
+ drawnSize.current = measuredSize;
258
+ size.settle();
259
+ }, [measuredSize, size]);
260
+ useEffect(() => {
261
+ const timer = setInterval(delivery.refresh, refreshMs);
262
+ return () => clearInterval(timer);
263
+ }, [delivery, refreshMs]);
264
+ useEffect(() => {
265
+ if (surface.interaction.quit)
266
+ exit();
267
+ }, [exit, surface.interaction.quit]);
268
+ useInput((input, key) => {
269
+ if (key.ctrl && input === "c") {
270
+ exit();
271
+ return;
272
+ }
273
+ delivery.key({ input, key }, columns);
274
+ });
275
+ return createElement(RunCockpitFrame, {
276
+ data: surface.data,
277
+ columns,
278
+ rows,
279
+ interaction: surface.interaction,
280
+ });
281
+ }
282
+ export async function runLiveCockpit({ input, output, cwd, runId, binaryVersion, refreshMs = REFRESH_MS, now = Date.now, debug = false, onDelivery, }) {
283
+ const delivery = createLiveCockpitDelivery({
284
+ cwd,
285
+ runId,
286
+ binaryVersion,
287
+ now,
288
+ });
289
+ onDelivery?.(delivery);
290
+ // Subscribe before Ink mounts so no stale-size repaint can overtake the
291
+ // surface's newly planned frame.
292
+ const size = measureOutput(output);
293
+ const app = render(createElement(LiveApp, {
294
+ delivery,
295
+ size,
296
+ refreshMs,
297
+ }), {
298
+ stdin: input,
299
+ stdout: size.stdout,
300
+ debug,
301
+ exitOnCtrlC: false,
302
+ patchConsole: false,
303
+ });
304
+ try {
305
+ await app.waitUntilExit();
306
+ }
307
+ finally {
308
+ size.close();
309
+ app.unmount();
310
+ }
311
+ }
@@ -1,8 +1,14 @@
1
1
  import { type ReactElement } from "react";
2
- import type { RunCockpitData } from "./derive.js";
2
+ import { type JournalRow } from "./components.js";
3
+ import { type RunCockpitData } from "./derive.js";
4
+ import { type KeybarEntry, type RunInteractionState, type RunKeyProjection } from "./keys.js";
5
+ import { type RunViewId } from "./views.js";
6
+ import { type FramePlan, type FrameRegion, type FrameState, type FrameCockpitLayout, type FrameView, type MeasuredSize, type PlannedFrame } from "./layout.js";
3
7
  export { deriveRunCockpitData } from "./derive.js";
4
8
  export type { RunCockpitData } from "./derive.js";
5
9
  export { PANEL_CHROME_ROWS } from "./components.js";
10
+ /** The width focus and key routing resolve at. */
11
+ export declare function runKeyColumns(columns: number): number;
6
12
  /**
7
13
  * How many rows one line of body text occupies once the renderer wraps it into
8
14
  * `columns`. This mirrors the renderer's own word wrap — greedy, and breaking a
@@ -12,8 +18,65 @@ export { PANEL_CHROME_ROWS } from "./components.js";
12
18
  export declare function wrappedRows(text: string, columns: number): number;
13
19
  /** The rows a bordered panel occupies when its body wraps into `columns`. */
14
20
  export declare function panelRows(lines: readonly string[], columns: number): number;
15
- export declare function RunCockpitFrame({ data, columns, rows, }: {
21
+ /**
22
+ * The selectable rows owned by one view. The overview's journal is a passive
23
+ * tail, not its item collection; tasks, gates and fleet remain honestly empty
24
+ * until their dedicated derivations land. Journal rows keep the source-record
25
+ * identities assigned by the production journal derivation.
26
+ */
27
+ export declare function deriveRunViewRows(data: RunCockpitData, viewId: RunViewId, filterQuery?: string): readonly JournalRow[];
28
+ /** What each drawn region draws: the resolved budgets its panels consume. */
29
+ export type RegionContent = {
30
+ readonly body: number;
31
+ readonly journal: number;
32
+ readonly bodyColumns: number;
33
+ readonly sideRails: boolean;
34
+ readonly stats: FrameCockpitLayout["stats"]["mode"] | "none";
35
+ readonly progressBar: boolean;
36
+ readonly progressCaption: boolean;
37
+ readonly approvedProgress: boolean;
38
+ };
39
+ export type PlannedRunCockpitFrame = {
40
+ readonly plan: FramePlan;
41
+ readonly columns: number;
42
+ readonly rows?: number;
43
+ /** What fills the drawn regions; absent on the unsized legacy surface. */
44
+ readonly content?: RegionContent;
45
+ /** The inputs planFrame planned from, so the guard can re-plan and refuse
46
+ * a span that moved after planning. */
47
+ readonly planning?: {
48
+ readonly size: MeasuredSize;
49
+ readonly view: FrameView;
50
+ readonly state: FrameState;
51
+ };
52
+ readonly interaction: RunInteractionState;
53
+ readonly keyEntries: readonly KeybarEntry[];
54
+ };
55
+ /** Resolve every draw-time input; the plan is planFrame's own return, consumed unmodified. */
56
+ export declare function planRunCockpitFrame({ data, columns, rows, interaction, keyProjection, }: {
57
+ data: RunCockpitData;
58
+ columns: number;
59
+ rows?: number;
60
+ interaction?: RunInteractionState;
61
+ keyProjection?: RunKeyProjection;
62
+ }): PlannedRunCockpitFrame;
63
+ /** The public surface always enters paint through one measured plan. */
64
+ export declare function RunCockpitFrame({ data, columns, rows, interaction, keyProjection, }: {
16
65
  data: RunCockpitData;
17
66
  columns: number;
18
67
  rows?: number;
68
+ interaction?: RunInteractionState;
69
+ keyProjection?: RunKeyProjection;
70
+ }): ReactElement;
71
+ /**
72
+ * Assert the plan's tiled extents, the bytes production paints, and — given
73
+ * the offsets read off the painted nodes — that every planned region was
74
+ * drawn where the plan puts it.
75
+ */
76
+ export declare function assertFrameConformance(plan: PlannedFrame, drawnFrame: string, drawnRegions?: readonly FrameRegion[]): void;
77
+ /** Production paint consumes only resolved plan inputs and fails closed with
78
+ * the same conformance assertion the acceptance suite runs. */
79
+ export declare function RunCockpitFrameFromPlan({ data, plannedFrame, }: {
80
+ data: RunCockpitData;
81
+ plannedFrame: PlannedRunCockpitFrame;
19
82
  }): ReactElement;