tickmarkr 1.81.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 (49) 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.js +13 -1
  8. package/dist/cli/index.d.ts +1 -1
  9. package/dist/cli/index.js +1 -1
  10. package/dist/compile/collateral.d.ts +19 -0
  11. package/dist/compile/collateral.js +90 -0
  12. package/dist/compile/index.js +18 -4
  13. package/dist/drivers/herdr.d.ts +1 -0
  14. package/dist/drivers/herdr.js +19 -0
  15. package/dist/drivers/types.d.ts +1 -0
  16. package/dist/gates/acceptance.js +50 -8
  17. package/dist/gates/llm.js +34 -19
  18. package/dist/gates/review.d.ts +9 -1
  19. package/dist/gates/review.js +173 -7
  20. package/dist/gates/run-gates.d.ts +1 -0
  21. package/dist/gates/run-gates.js +18 -1
  22. package/dist/report/bundle.d.ts +6 -1
  23. package/dist/report/bundle.js +13 -3
  24. package/dist/run/daemon.d.ts +5 -0
  25. package/dist/run/daemon.js +188 -27
  26. package/dist/run/journal.d.ts +5 -0
  27. package/dist/run/journal.js +71 -1
  28. package/dist/tui/cockpit/capture.d.ts +107 -1
  29. package/dist/tui/cockpit/capture.js +287 -10
  30. package/dist/tui/cockpit/components.d.ts +12 -46
  31. package/dist/tui/cockpit/components.js +48 -48
  32. package/dist/tui/cockpit/derive.d.ts +73 -1
  33. package/dist/tui/cockpit/derive.js +211 -15
  34. package/dist/tui/cockpit/keys.d.ts +114 -19
  35. package/dist/tui/cockpit/keys.js +413 -44
  36. package/dist/tui/cockpit/layout.d.ts +162 -0
  37. package/dist/tui/cockpit/layout.js +229 -0
  38. package/dist/tui/cockpit/live.d.ts +52 -3
  39. package/dist/tui/cockpit/live.js +216 -40
  40. package/dist/tui/cockpit/run-cockpit.d.ts +64 -8
  41. package/dist/tui/cockpit/run-cockpit.js +686 -163
  42. package/dist/tui/cockpit/setup-cockpit.d.ts +146 -1
  43. package/dist/tui/cockpit/setup-cockpit.js +337 -5
  44. package/dist/tui/cockpit/views.d.ts +72 -0
  45. package/dist/tui/cockpit/views.js +56 -0
  46. package/dist/tui/cockpit/width.d.ts +105 -0
  47. package/dist/tui/cockpit/width.js +276 -0
  48. package/fixtures/speckit-sample/tasks.md +2 -2
  49. package/package.json +3 -1
@@ -1,11 +1,12 @@
1
- import { readFileSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { render, useApp, useInput } from "ink";
4
- import { createElement, useEffect, useState } from "react";
5
- import { stateDirName } from "../../graph/graph.js";
4
+ import { createElement, useEffect, useRef, useSyncExternalStore } from "react";
5
+ import { graphPath, loadGraph, stateDirName } from "../../graph/graph.js";
6
6
  import { Journal, parseRunId } from "../../run/journal.js";
7
- import { deriveRunCockpitData, RunCockpitFrame } from "./run-cockpit.js";
8
- import { applyFilterInput, dispatchRunKey, initialRunInteractionState, SURFACE_KEY_BINDINGS, } from "./keys.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";
9
10
  // ponytail: fixed 2s re-derive cadence, matching `status --watch`; promote to a
10
11
  // config knob only if an operator asks.
11
12
  const REFRESH_MS = 2_000;
@@ -30,6 +31,22 @@ export function loadEngagementSource(cwd, runId) {
30
31
  const raw = readFileSync(path, "utf8");
31
32
  return { fileName: `${id}.journal.jsonl`, raw };
32
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
+ }
33
50
  // The same s/m/h age vocabulary `status` speaks (status.ts fmtAge).
34
51
  export function formatEventAge(ageMs) {
35
52
  const safe = Math.max(0, ageMs);
@@ -64,65 +81,223 @@ function lastEventMs(raw) {
64
81
  * than as current. Throws on unreadable bytes (empty capture, no run-start) —
65
82
  * the caller refuses instead of rendering a plausible surface from nothing.
66
83
  */
67
- export function deriveLiveRunCockpitData(source, binaryVersion, now = Date.now) {
68
- const data = deriveRunCockpitData(source, binaryVersion);
84
+ export function deriveLiveRunCockpitData(source, binaryVersion, now = Date.now, graph) {
85
+ const data = deriveRunCockpitData(source, binaryVersion, { graph });
69
86
  const lastMs = lastEventMs(source.raw);
70
87
  const staleness = lastMs === undefined
71
88
  ? "last event unknown"
72
89
  : `last event ${formatEventAge(now() - lastMs)} ago`;
73
90
  return { ...data, elapsed: `${data.elapsed} · ${staleness}` };
74
91
  }
75
- function LiveApp({ cwd, runId, binaryVersion, columns, rows, refreshMs, now, }) {
76
- const { exit } = useApp();
77
- const [data, setData] = useState(() => deriveLiveRunCockpitData(loadEngagementSource(cwd, runId), binaryVersion, now));
78
- const [interaction, setInteraction] = useState(initialRunInteractionState);
79
- useEffect(() => {
80
- const timer = setInterval(() => {
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: () => {
81
163
  try {
82
- setData(deriveLiveRunCockpitData(loadEngagementSource(cwd, runId), binaryVersion, now));
164
+ const refreshed = derive();
165
+ return transition((current) => ({
166
+ ...current,
167
+ data: refreshed,
168
+ interaction: reconcileLiveRunInteraction(current.interaction, refreshed),
169
+ }));
83
170
  }
84
171
  catch {
85
- // a torn read mid-append keeps the last good frame rather than blanking the surface
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;
86
175
  }
87
- }, refreshMs);
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);
88
262
  return () => clearInterval(timer);
89
- }, [cwd, runId, binaryVersion, refreshMs, now]);
263
+ }, [delivery, refreshMs]);
264
+ useEffect(() => {
265
+ if (surface.interaction.quit)
266
+ exit();
267
+ }, [exit, surface.interaction.quit]);
90
268
  useInput((input, key) => {
91
269
  if (key.ctrl && input === "c") {
92
270
  exit();
93
271
  return;
94
272
  }
95
- // While the / prompt is open it owns the keyboard: characters grow the
96
- // query and no advertised binding fires.
97
- if (interaction.filterPrompt) {
98
- const prompted = applyFilterInput({ input, key }, interaction);
99
- if (prompted !== undefined)
100
- setInteraction(prompted);
101
- return;
102
- }
103
- const next = dispatchRunKey({ input, key }, interaction, SURFACE_KEY_BINDINGS.run);
104
- if (next === undefined)
105
- return;
106
- setInteraction(next);
107
- if (next.quit)
108
- exit();
273
+ delivery.key({ input, key }, columns);
274
+ });
275
+ return createElement(RunCockpitFrame, {
276
+ data: surface.data,
277
+ columns,
278
+ rows,
279
+ interaction: surface.interaction,
109
280
  });
110
- return createElement(RunCockpitFrame, { data, columns, rows, interaction });
111
281
  }
112
- export async function runLiveCockpit({ input, output, cwd, runId, binaryVersion, refreshMs = REFRESH_MS, now = Date.now, }) {
113
- const columns = output.columns ?? 80;
114
- const rows = output.rows ?? 24;
115
- const app = render(createElement(LiveApp, {
282
+ export async function runLiveCockpit({ input, output, cwd, runId, binaryVersion, refreshMs = REFRESH_MS, now = Date.now, debug = false, onDelivery, }) {
283
+ const delivery = createLiveCockpitDelivery({
116
284
  cwd,
117
285
  runId,
118
286
  binaryVersion,
119
- columns,
120
- rows,
121
- refreshMs,
122
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,
123
297
  }), {
124
298
  stdin: input,
125
- stdout: output,
299
+ stdout: size.stdout,
300
+ debug,
126
301
  exitOnCtrlC: false,
127
302
  patchConsole: false,
128
303
  });
@@ -130,6 +305,7 @@ export async function runLiveCockpit({ input, output, cwd, runId, binaryVersion,
130
305
  await app.waitUntilExit();
131
306
  }
132
307
  finally {
308
+ size.close();
133
309
  app.unmount();
134
310
  }
135
311
  }
@@ -1,9 +1,14 @@
1
1
  import { type ReactElement } from "react";
2
- import type { RunCockpitData } from "./derive.js";
3
- import { type RunInteractionState } from "./keys.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";
4
7
  export { deriveRunCockpitData } from "./derive.js";
5
8
  export type { RunCockpitData } from "./derive.js";
6
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;
7
12
  /**
8
13
  * How many rows one line of body text occupies once the renderer wraps it into
9
14
  * `columns`. This mirrors the renderer's own word wrap — greedy, and breaking a
@@ -13,14 +18,65 @@ export { PANEL_CHROME_ROWS } from "./components.js";
13
18
  export declare function wrappedRows(text: string, columns: number): number;
14
19
  /** The rows a bordered panel occupies when its body wraps into `columns`. */
15
20
  export declare function panelRows(lines: readonly string[], columns: number): number;
16
- export declare function RunCockpitFrame({ data, columns, rows, interaction, }: {
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, }: {
17
65
  data: RunCockpitData;
18
66
  columns: number;
19
67
  rows?: number;
20
- /**
21
- * The run surface's interaction state. Omitted — or at its initial value —
22
- * the frame is byte-identical to the pinned appearance; every transition a
23
- * binding makes is drawn from it.
24
- */
25
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;
26
82
  }): ReactElement;