tickmarkr 2.6.3 → 2.6.5

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 (79) hide show
  1. package/README.md +10 -2
  2. package/dist/adapters/catalog-remote.d.ts +23 -0
  3. package/dist/adapters/catalog-remote.js +38 -0
  4. package/dist/adapters/claude-code.d.ts +1 -1
  5. package/dist/adapters/claude-code.js +4 -1
  6. package/dist/adapters/codex-commit-check.d.ts +25 -0
  7. package/dist/adapters/codex-commit-check.js +108 -0
  8. package/dist/adapters/codex.d.ts +2 -1
  9. package/dist/adapters/codex.js +26 -5
  10. package/dist/adapters/model-discovery-check.d.ts +33 -0
  11. package/dist/adapters/model-discovery-check.js +123 -0
  12. package/dist/adapters/model-lints.d.ts +2 -1
  13. package/dist/adapters/model-lints.js +52 -1
  14. package/dist/adapters/model-windows.d.ts +2 -1
  15. package/dist/adapters/model-windows.js +18 -7
  16. package/dist/adapters/prompt.js +3 -2
  17. package/dist/adapters/types.d.ts +13 -0
  18. package/dist/cli/commands/approve.js +37 -6
  19. package/dist/cli/commands/doctor.d.ts +4 -0
  20. package/dist/cli/commands/doctor.js +20 -3
  21. package/dist/cli/commands/fleet.d.ts +10 -1
  22. package/dist/cli/commands/fleet.js +279 -95
  23. package/dist/cli/commands/init.js +10 -4
  24. package/dist/cli/commands/plan.js +65 -4
  25. package/dist/cli/commands/report.d.ts +39 -2
  26. package/dist/cli/commands/report.js +227 -10
  27. package/dist/cli/commands/resume.js +2 -4
  28. package/dist/cli/commands/run.d.ts +11 -0
  29. package/dist/cli/commands/run.js +33 -5
  30. package/dist/cli/commands/status.js +30 -3
  31. package/dist/cli/commands/verify.d.ts +1 -1
  32. package/dist/cli/commands/verify.js +356 -123
  33. package/dist/compile/native.js +130 -0
  34. package/dist/config/config.d.ts +25 -7
  35. package/dist/config/config.js +92 -11
  36. package/dist/config/fleet-overlay.d.ts +2 -1
  37. package/dist/config/fleet-overlay.js +77 -4
  38. package/dist/drivers/orca.d.ts +19 -1
  39. package/dist/drivers/orca.js +161 -31
  40. package/dist/gates/cache.d.ts +4 -0
  41. package/dist/gates/cache.js +10 -2
  42. package/dist/gates/llm.d.ts +4 -1
  43. package/dist/gates/llm.js +6 -1
  44. package/dist/gates/review.js +8 -3
  45. package/dist/gates/run-gates.d.ts +3 -2
  46. package/dist/gates/run-gates.js +91 -50
  47. package/dist/route/router.d.ts +11 -0
  48. package/dist/route/router.js +29 -8
  49. package/dist/run/daemon.js +158 -22
  50. package/dist/run/git.js +9 -0
  51. package/dist/run/journal.d.ts +101 -1
  52. package/dist/run/journal.js +352 -2
  53. package/dist/run/launchservices-check.d.ts +56 -0
  54. package/dist/run/launchservices-check.js +117 -0
  55. package/dist/run/operator-state.d.ts +22 -2
  56. package/dist/run/operator-state.js +20 -3
  57. package/dist/run/title-environment.d.ts +45 -0
  58. package/dist/run/title-environment.js +127 -0
  59. package/dist/run/title-preload.cjs +38 -0
  60. package/dist/run/title-preload.d.cts +1 -0
  61. package/dist/tui/cockpit/board.js +12 -6
  62. package/dist/tui/cockpit/keys.d.ts +8 -0
  63. package/dist/tui/cockpit/keys.js +4 -1
  64. package/dist/tui/cockpit/layout.d.ts +2 -2
  65. package/dist/tui/cockpit/layout.js +2 -2
  66. package/dist/tui/cockpit/live-runtime.js +46 -20
  67. package/dist/tui/cockpit/live-store.d.ts +30 -1
  68. package/dist/tui/cockpit/live-store.js +134 -18
  69. package/dist/tui/cockpit/log-view.d.ts +28 -0
  70. package/dist/tui/cockpit/log-view.js +60 -0
  71. package/dist/tui/cockpit/run-view.js +4 -0
  72. package/dist/tui/cockpit/shell.d.ts +2 -1
  73. package/dist/tui/cockpit/shell.js +6 -4
  74. package/dist/tui/ink/fleet-app.d.ts +12 -6
  75. package/dist/tui/ink/fleet-app.js +17 -11
  76. package/package.json +1 -1
  77. package/skills/tickmarkr-auto/SKILL.md +33 -3
  78. package/skills/tickmarkr-loop/SKILL.md +55 -5
  79. package/skills/tickmarkr-overseer/SKILL.md +83 -8
@@ -1,6 +1,8 @@
1
1
  import { type JournalEvent } from "../../run/journal.js";
2
- import { type OperatorRecord } from "../../run/operator-state.js";
2
+ import { type OperatorRecord, type OwedAuthority } from "../../run/operator-state.js";
3
3
  export declare const OBSERVATION_INTERVAL_MS = 1000;
4
+ /** Stat-to-fstat observations one poll spends on a journal a writer keeps appending to. */
5
+ export declare const APPEND_RACE_OBSERVATIONS = 3;
4
6
  export declare const STORE_LIMITS: {
5
7
  readonly graphBytes: number;
6
8
  readonly history: 256;
@@ -25,9 +27,12 @@ export interface JournalLine extends Omit<OperatorRecord, "event"> {
25
27
  endOffset: number;
26
28
  }
27
29
  export interface TailSnapshot {
30
+ /** `digest` (I2): sha256 of every byte the tail consumed, [0, offset) — the bytes its fold was built from. */
28
31
  source: string;
29
32
  generation: number;
30
33
  identity?: string;
34
+ version?: string;
35
+ digest: string;
31
36
  offset: number;
32
37
  lines: number;
33
38
  history: readonly JournalLine[];
@@ -61,12 +66,22 @@ export declare class JournalTail {
61
66
  private bytesRead;
62
67
  private lastSuccessfulReadAt?;
63
68
  private failure?;
69
+ private stale;
70
+ private hash;
71
+ private invalid;
64
72
  constructor(source: string, hooks?: {
65
73
  record?: (record: OperatorRecord) => void;
66
74
  reset?: () => void;
67
75
  });
68
76
  private reset;
77
+ /** I2: the consumed prefix was found rewritten (an in-place rewrite then growth reads as an append), so the
78
+ * next poll treats the file as replaced and refolds it from its first byte. */
79
+ invalidate(): void;
80
+ get invalidated(): boolean;
81
+ /** A same-inode append between stat and fstat is retried; exhaustion keeps the last good snapshot, pending. */
69
82
  poll(now?: number): TailSnapshot;
83
+ /** One stat-to-fstat observation. False when an append raced it; nothing was consumed or reset. */
84
+ private observe;
70
85
  private appendCarry;
71
86
  snapshot(): TailSnapshot;
72
87
  /** Demand paging scans with fixed buffers, independent of the retained history; no growing line index. */
@@ -80,6 +95,18 @@ export interface CachedSource<T = unknown> {
80
95
  error?: string;
81
96
  observedAt: number;
82
97
  }
98
+ /** I2: the journal basis an owed-check fold read — the observed file version, its generation and the bytes consumed. */
99
+ export declare const journalBasis: (journal: TailSnapshot) => string;
100
+ /** I2: the bytes the tail consumed are no longer the file's prefix — rewritten in place, then grown. */
101
+ export declare class JournalRewritten extends Error {
102
+ constructor();
103
+ }
104
+ /** I2 (D-857): the ONE way any reader beside the tail gets `journal`'s bytes — every consumed byte of the very
105
+ * file version the tail observed (an in-place rewrite, even an equal-length one, changes it), stamped before
106
+ * and after the read, and proved by digest to be the very bytes the tail's fold consumed (a rewrite followed by
107
+ * growth keeps the tail appending). Any change throws, so no second read is ever combined with this
108
+ * observation's lifecycle, owed or basis. */
109
+ export declare function readObservedJournal(journal: TailSnapshot): Buffer;
83
110
  export interface LiveStoreOptions {
84
111
  cwd: string;
85
112
  runId: string;
@@ -96,6 +123,7 @@ export declare function createLiveStore(options: LiveStoreOptions): {
96
123
  delayed: boolean;
97
124
  freshness: string;
98
125
  operator: import("../../run/operator-state.js").OperatorSnapshot;
126
+ owed: OwedAuthority | undefined;
99
127
  journal: TailSnapshot;
100
128
  graph: CachedSource<{
101
129
  version: 1;
@@ -198,6 +226,7 @@ export declare function createLiveStore(options: LiveStoreOptions): {
198
226
  delayed: boolean;
199
227
  freshness: string;
200
228
  operator: import("../../run/operator-state.js").OperatorSnapshot;
229
+ owed: OwedAuthority | undefined;
201
230
  journal: TailSnapshot;
202
231
  graph: CachedSource<{
203
232
  version: 1;
@@ -1,17 +1,26 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { closeSync, fstatSync, openSync, readSync, statSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import { graphPath, stateDirName } from "../../graph/graph.js";
4
5
  import { validateGraph } from "../../graph/schema.js";
5
- import { parseRunId } from "../../run/journal.js";
6
+ import { foldOwedChecks, parseJournalText, parseRunId } from "../../run/journal.js";
6
7
  import { isPidLive, STALE_MS } from "../../run/lock.js";
7
8
  import { readTierLiveness, SUPERVISION_TIERS } from "../../run/supervision.js";
8
9
  import { OperatorStateFold } from "../../run/operator-state.js";
9
10
  export const OBSERVATION_INTERVAL_MS = 1_000;
11
+ /** Stat-to-fstat observations one poll spends on a journal a writer keeps appending to. */
12
+ export const APPEND_RACE_OBSERVATIONS = 3;
10
13
  // Graph declarations have a separate 16 MiB bound; journal retention remains unchanged.
11
14
  export const STORE_LIMITS = { graphBytes: 16 * 1024 * 1024, history: 256, historyBytes: 2 * 1024 * 1024, recordBytes: 1024 * 1024, readBytes: 1024 * 1024, subscribers: 64, metrics: 12, errors: 32 };
12
15
  const errorText = (e) => e instanceof Error ? e.message : String(e);
13
16
  const fileIdentity = (st) => `${st.dev}:${st.ino}`;
14
17
  const stamp = (st) => `${fileIdentity(st)}:${st.size}:${st.mtimeNs}:${st.ctimeNs}`;
18
+ /** The one record shape the tail accepts; the complete-journal (owed) read validates against it too. */
19
+ const isJournalRecord = (e) => {
20
+ const r = e;
21
+ return !!r && typeof r === "object" && typeof r.ts === "string" && Number.isFinite(Date.parse(r.ts)) && typeof r.event === "string"
22
+ && !!r.data && typeof r.data === "object" && !Array.isArray(r.data) && (r.taskId === undefined || typeof r.taskId === "string");
23
+ };
15
24
  function decodeLine(bytes, source, line, offset, endOffset, generation, oversized = false) {
16
25
  const base = { source, line, id: `${source}#L${line}`, offset, endOffset, generation };
17
26
  let raw = "";
@@ -22,7 +31,7 @@ function decodeLine(bytes, source, line, offset, endOffset, generation, oversize
22
31
  if (!raw.trim())
23
32
  return { ...base, raw };
24
33
  const e = JSON.parse(raw);
25
- if (!e || typeof e !== "object" || typeof e.ts !== "string" || !Number.isFinite(Date.parse(e.ts)) || typeof e.event !== "string" || !e.data || typeof e.data !== "object" || Array.isArray(e.data) || (e.taskId !== undefined && typeof e.taskId !== "string"))
34
+ if (!isJournalRecord(e))
26
35
  throw new Error("invalid journal record");
27
36
  return { ...base, raw, event: e };
28
37
  }
@@ -48,6 +57,9 @@ export class JournalTail {
48
57
  bytesRead = 0;
49
58
  lastSuccessfulReadAt;
50
59
  failure;
60
+ stale = false;
61
+ hash = createHash("sha256");
62
+ invalid = false;
51
63
  constructor(source, hooks = {}) {
52
64
  this.source = source;
53
65
  this.hooks = hooks;
@@ -63,20 +75,53 @@ export class JournalTail {
63
75
  this.errors = [];
64
76
  this.malformedCount = 0;
65
77
  this.generation++;
78
+ this.hash = createHash("sha256");
79
+ this.invalid = false;
66
80
  this.hooks.reset?.();
67
81
  }
82
+ /** I2: the consumed prefix was found rewritten (an in-place rewrite then growth reads as an append), so the
83
+ * next poll treats the file as replaced and refolds it from its first byte. */
84
+ invalidate() { this.invalid = true; }
85
+ get invalidated() { return this.invalid; }
86
+ /** A same-inode append between stat and fstat is retried; exhaustion keeps the last good snapshot, pending. */
68
87
  poll(now = Date.now()) {
88
+ try {
89
+ for (let observation = 1; !this.observe(); observation++) {
90
+ if (observation === APPEND_RACE_OBSERVATIONS) {
91
+ this.stale = true;
92
+ this.failure = undefined;
93
+ return this.snapshot();
94
+ }
95
+ }
96
+ this.stale = false;
97
+ this.failure = undefined;
98
+ this.lastSuccessfulReadAt = now;
99
+ }
100
+ catch (e) {
101
+ this.failure = { source: this.source, error: errorText(e) };
102
+ }
103
+ return this.snapshot();
104
+ }
105
+ /** One stat-to-fstat observation. False when an append raced it; nothing was consumed or reset. */
106
+ observe() {
69
107
  let fd;
70
108
  try {
71
109
  const st = statSync(this.source, { bigint: true });
72
110
  if (!st.isFile())
73
111
  throw new Error("journal source is not a regular file");
74
- if (!this.st || fileIdentity(st) !== fileIdentity(this.st) || st.size < this.st.size || (st.size === this.st.size && stamp(st) !== stamp(this.st)))
75
- this.reset();
76
- if (Number(st.size) > this.offset) {
112
+ const replaced = this.invalid || !this.st || fileIdentity(st) !== fileIdentity(this.st) || st.size < this.st.size || (st.size === this.st.size && stamp(st) !== stamp(this.st));
113
+ if (Number(st.size) > (replaced ? 0 : this.offset)) {
77
114
  fd = openSync(this.source, "r");
78
- if (stamp(fstatSync(fd, { bigint: true })) !== stamp(st))
115
+ const opened = fstatSync(fd, { bigint: true });
116
+ if (stamp(opened) !== stamp(st)) {
117
+ if (fileIdentity(opened) === fileIdentity(st) && opened.size > st.size)
118
+ return false;
79
119
  throw new Error("journal changed before read; retry observation");
120
+ }
121
+ }
122
+ if (replaced)
123
+ this.reset();
124
+ if (fd !== undefined) {
80
125
  let remaining = STORE_LIMITS.readBytes;
81
126
  const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, Number(st.size) - this.offset));
82
127
  while (this.offset < Number(st.size) && remaining > 0) {
@@ -85,6 +130,7 @@ export class JournalTail {
85
130
  throw new Error("journal truncated during read");
86
131
  this.bytesRead += count;
87
132
  remaining -= count;
133
+ this.hash.update(buffer.subarray(0, count));
88
134
  let begin = 0;
89
135
  for (let index = 0; index < count; index++)
90
136
  if (buffer[index] === 10) {
@@ -113,17 +159,12 @@ export class JournalTail {
113
159
  }
114
160
  }
115
161
  this.st = st;
116
- this.failure = undefined;
117
- this.lastSuccessfulReadAt = now;
118
- }
119
- catch (e) {
120
- this.failure = { source: this.source, error: errorText(e) };
162
+ return true;
121
163
  }
122
164
  finally {
123
165
  if (fd !== undefined)
124
166
  closeSync(fd);
125
167
  }
126
- return this.snapshot();
127
168
  }
128
169
  appendCarry(bytes) {
129
170
  this.carryBytes += bytes.length;
@@ -133,11 +174,11 @@ export class JournalTail {
133
174
  }
134
175
  snapshot() {
135
176
  return {
136
- source: this.source, generation: this.generation, identity: this.st && fileIdentity(this.st), offset: this.offset, lines: this.line,
177
+ source: this.source, generation: this.generation, identity: this.st && fileIdentity(this.st), version: this.st && stamp(this.st), digest: this.hash.copy().digest("hex"), offset: this.offset, lines: this.line,
137
178
  history: this.history.slice(), errors: this.errors.slice(), malformedCount: this.malformedCount,
138
179
  pending: this.carryBytes ? { line: this.line + 1, bytes: this.carryBytes } : undefined,
139
180
  backlogBytes: Math.max(0, Number(this.st?.size ?? 0) - this.offset),
140
- status: this.failure ? "unreadable" : this.malformedCount ? "corrupt" : this.carryBytes ? "pending" : "readable",
181
+ status: this.failure ? "unreadable" : this.malformedCount ? "corrupt" : this.carryBytes || this.stale ? "pending" : "readable",
141
182
  error: this.failure, lastSuccessfulReadAt: this.lastSuccessfulReadAt, bytesRead: this.bytesRead,
142
183
  };
143
184
  }
@@ -237,6 +278,48 @@ class JsonSource {
237
278
  }
238
279
  }
239
280
  }
281
+ /** I2: the journal basis an owed-check fold read — the observed file version, its generation and the bytes consumed. */
282
+ export const journalBasis = (journal) => `${journal.source}:${journal.generation}:${journal.version}:${journal.offset}`;
283
+ const unknownOwed = (reason) => ({ known: false, debt: "unknown", outstanding: [], acceptedRisk: [], discharged: [], unknown: [{ reason }] });
284
+ /** I2: the bytes the tail consumed are no longer the file's prefix — rewritten in place, then grown. */
285
+ export class JournalRewritten extends Error {
286
+ constructor() { super("journal prefix rewritten since the tail consumed it"); }
287
+ }
288
+ /** I2 (D-857): the ONE way any reader beside the tail gets `journal`'s bytes — every consumed byte of the very
289
+ * file version the tail observed (an in-place rewrite, even an equal-length one, changes it), stamped before
290
+ * and after the read, and proved by digest to be the very bytes the tail's fold consumed (a rewrite followed by
291
+ * growth keeps the tail appending). Any change throws, so no second read is ever combined with this
292
+ * observation's lifecycle, owed or basis. */
293
+ export function readObservedJournal(journal) {
294
+ const fd = openSync(journal.source, "r");
295
+ try {
296
+ const observed = () => { if (stamp(fstatSync(fd, { bigint: true })) !== journal.version)
297
+ throw new Error("journal changed since its observation"); };
298
+ observed();
299
+ const bytes = Buffer.alloc(journal.offset);
300
+ for (let length = 0; length < bytes.length;) {
301
+ const n = readSync(fd, bytes, length, bytes.length - length, length);
302
+ if (!n)
303
+ throw new Error("journal truncated since its observation");
304
+ length += n;
305
+ }
306
+ observed();
307
+ if (createHash("sha256").update(bytes).digest("hex") !== journal.digest)
308
+ throw new JournalRewritten();
309
+ return bytes;
310
+ }
311
+ finally {
312
+ closeSync(fd);
313
+ }
314
+ }
315
+ /** The complete journal behind `journal`'s basis: its observed bytes, every non-blank line a valid record. */
316
+ function readLedger(journal) {
317
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(readObservedJournal(journal));
318
+ const events = parseJournalText(text);
319
+ if (events.length !== text.split("\n").filter(line => line.trim()).length || !events.every(isJournalRecord))
320
+ throw new Error("complete journal holds an invalid line");
321
+ return events;
322
+ }
240
323
  /** C1 mount/C6 reader boundary. No timers or presence writes: the mount owns scheduling and disarm. */
241
324
  export function createLiveStore(options) {
242
325
  const now = options.now ?? Date.now;
@@ -260,10 +343,41 @@ export function createLiveStore(options) {
260
343
  let disposed = false;
261
344
  let queued;
262
345
  const metrics = [];
346
+ // I2: the complete journal is re-read only when its basis moves; its owed fold re-reads every discharge
347
+ // artifact on every observation, and only the immutable git re-proof is reused through this store's memo.
348
+ // ponytail: an ended run's whole parsed journal stays resident; fold a debt-only projection if that measures large.
349
+ const owedMemo = new Set();
350
+ let ledger;
351
+ const owedAuthority = (journal) => {
352
+ const basis = journalBasis(journal);
353
+ if (ledger?.basis !== basis) {
354
+ // Never cache a failure: only a successful read is reused; a failed one is unknown debt now and re-read next observation.
355
+ try {
356
+ ledger = { basis, events: readLedger(journal) };
357
+ }
358
+ catch (e) {
359
+ ledger = undefined;
360
+ if (e instanceof JournalRewritten)
361
+ tail.invalidate();
362
+ return { basis, fold: unknownOwed(`complete journal unreadable: ${errorText(e)}`) };
363
+ }
364
+ }
365
+ return { basis, fold: foldOwedChecks(ledger.events, options.cwd, owedMemo) };
366
+ };
263
367
  function observe() {
264
368
  const observedAt = now();
265
369
  const delayed = lastObservation !== undefined && observedAt - lastObservation > 2 * OBSERVATION_INTERVAL_MS;
266
- const journal = tail.poll(observedAt);
370
+ const isReadable = (j) => j.status === "readable" && j.backlogBytes === 0;
371
+ // Debt decides only an ended run's headline; a running or unreadable journal never pays for the fold.
372
+ const owedOf = (j) => isReadable(j) && fold.ended ? owedAuthority(j) : undefined;
373
+ let journal = tail.poll(observedAt);
374
+ let owed = owedOf(journal);
375
+ // I2: the owed read found the tail's consumed prefix rewritten, so its fold (lifecycle) is stale. Refold the
376
+ // whole file now: lifecycle and debt then come from the same bytes. A repeat rewrite is unknown debt.
377
+ if (tail.invalidated) {
378
+ journal = tail.poll(observedAt);
379
+ owed = owedOf(journal);
380
+ }
267
381
  const graphReading = graph.read(observedAt);
268
382
  const configReading = config.read(observedAt);
269
383
  const cacheReading = cache.read(observedAt);
@@ -276,11 +390,13 @@ export function createLiveStore(options) {
276
390
  if (metrics.length > STORE_LIMITS.metrics)
277
391
  metrics.shift();
278
392
  lastObservation = observedAt;
279
- const readable = journal.status === "readable" && journal.backlogBytes === 0;
393
+ const readable = isReadable(journal);
280
394
  return {
281
395
  sequence: ++sequence, observedAt, delayed, freshness: errors.length ? "failed" : delayed ? "delayed" : "fresh",
282
- operator: fold.snapshot({ graph: graphReading.status === "readable" ? graphReading.value : undefined, sequence, observedAt, readable, graphAvailability: { status: graphReading.status, error: graphReading.error } }),
283
- journal, graph: graphReading, config: configReading, cache: cacheReading,
396
+ // I1: only this run's own lock naming a provably-dead (ESRCH) holder settles unfinished cells; an alive,
397
+ // EPERM, foreign, unreadable or absent lock is not proof the work stopped.
398
+ operator: fold.snapshot({ graph: graphReading.status === "readable" ? graphReading.value : undefined, sequence, observedAt, readable, graphAvailability: { status: graphReading.status, error: graphReading.error }, daemonDead: lockState === "dead", owed, basis: journalBasis(journal) }),
399
+ owed, journal, graph: graphReading, config: configReading, cache: cacheReading,
284
400
  lock: { ...owner, state: lockState, alive, expired: owner.identity ? observedAt - Number(owner.identity.split(":")[3]) / 1e6 > STALE_MS : undefined },
285
401
  supervision, errors, actionsEnabled: readable && !errors.length && !delayed,
286
402
  viewport, inputSequence, metrics: metrics.slice(),
@@ -0,0 +1,28 @@
1
+ import type { JournalEvent } from "../../run/journal.js";
2
+ import { type JournalLine, type TailSnapshot } from "./live-store.js";
3
+ /** The board uses the daemon's TTY narration, including its suppression and sanitization. */
4
+ export declare const renderLogLines: (events: readonly JournalEvent[], runId: string, columns: number) => string[];
5
+ export interface LogRow {
6
+ line: number;
7
+ text: string;
8
+ }
9
+ export interface LogPage {
10
+ rows: readonly LogRow[];
11
+ error?: string;
12
+ }
13
+ type PageReader = (firstLine: number, count?: number, generation?: number) => readonly JournalLine[];
14
+ interface LogSource {
15
+ journal: TailSnapshot;
16
+ page: PageReader;
17
+ runId: string;
18
+ columns: number;
19
+ }
20
+ /** Demand-page backwards to fill the viewport, including events outside the store's retained tail.
21
+ * Only the requested visible rows survive; suppressed events never consume viewport rows. */
22
+ export declare function readLogPage(source: LogSource, count: number, end?: number): LogPage;
23
+ /** Move by visible narration rows. Undefined means follow the journal tail again. */
24
+ export declare function moveLogEnd(source: LogSource, end: number | undefined, delta: number): number | undefined;
25
+ export declare function LogView({ page }: {
26
+ page: LogPage;
27
+ }): import("react").JSX.Element;
28
+ export {};
@@ -0,0 +1,60 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { narrationRow } from "../../cli/commands/run.js";
4
+ import { STORE_LIMITS } from "./live-store.js";
5
+ /** The board uses the daemon's TTY narration, including its suppression and sanitization. */
6
+ export const renderLogLines = (events, runId, columns) => events.map(event => narrationRow(event, runId, columns)).filter((line) => line !== null);
7
+ const narrate = (rows, source) => rows.flatMap(row => row.event
8
+ ? renderLogLines([row.event], source.runId, source.columns).map(text => ({ line: row.line, text })) : []);
9
+ /** Demand-page backwards to fill the viewport, including events outside the store's retained tail.
10
+ * Only the requested visible rows survive; suppressed events never consume viewport rows. */
11
+ export function readLogPage(source, count, end = source.journal.lines) {
12
+ let cursor = Math.min(end, source.journal.lines);
13
+ let rows = [];
14
+ try {
15
+ while (cursor > 0 && rows.length < count) {
16
+ const first = Math.max(1, cursor - STORE_LIMITS.history + 1);
17
+ const retained = source.journal.history.filter(row => row.line >= first && row.line <= cursor);
18
+ const page = retained.length === cursor - first + 1 ? retained
19
+ : source.page(first, cursor - first + 1, source.journal.generation);
20
+ rows = [...narrate(page, source), ...rows].slice(-count);
21
+ cursor = first - 1;
22
+ }
23
+ return { rows };
24
+ }
25
+ catch (error) {
26
+ return { rows: [], error: error instanceof Error ? error.message : String(error) };
27
+ }
28
+ }
29
+ /** Move by visible narration rows. Undefined means follow the journal tail again. */
30
+ export function moveLogEnd(source, end, delta) {
31
+ const cursor = Math.min(end ?? source.journal.lines, source.journal.lines);
32
+ if (delta < 0) {
33
+ const rows = readLogPage(source, -delta + 1, cursor).rows;
34
+ return rows[Math.max(0, rows.length - 1 + delta)]?.line ?? end;
35
+ }
36
+ let first = cursor + 1, remaining = delta;
37
+ while (first <= source.journal.lines) {
38
+ const last = Math.min(source.journal.lines, first + STORE_LIMITS.history - 1);
39
+ let rows;
40
+ try {
41
+ rows = narrate(source.page(first, last - first + 1, source.journal.generation), source);
42
+ }
43
+ catch {
44
+ return end;
45
+ } // A stale journal observation must never terminate the board's key handler.
46
+ if (rows.length >= remaining) {
47
+ const line = rows[remaining - 1].line;
48
+ return line === source.journal.lines ? undefined : line;
49
+ }
50
+ remaining -= rows.length;
51
+ first = last + 1;
52
+ }
53
+ return undefined;
54
+ }
55
+ export function LogView({ page }) {
56
+ return _jsx(Box, { flexDirection: "column", children: page.error
57
+ ? _jsxs(Text, { children: ["Log unavailable: ", page.error] })
58
+ : page.rows.length ? page.rows.map(row => _jsx(Text, { wrap: "truncate-end", children: row.text }, row.line))
59
+ : _jsx(Text, { children: "No narrated events" }) });
60
+ }
@@ -98,6 +98,10 @@ export function runGateCells(task, evidence, rows = []) {
98
98
  else if (cell.state === "queued") {
99
99
  labels.push(`queued — ${row?.event?.event ?? "wait"}${typeof data.count === "number" ? ` (${data.count} suites)` : ""}`);
100
100
  }
101
+ else if (cell.state === "unknown" && row?.event?.event !== "gate-result") {
102
+ // I1: a start or wait row settled at a terminal boundary — no verdict ever measured it.
103
+ labels.push(`unknown — ${row?.event?.event ?? "start"} never finished`);
104
+ }
101
105
  else if (data.disabled === true) {
102
106
  labels.push("disabled by policy");
103
107
  }
@@ -2,13 +2,14 @@ import { type ReactNode } from "react";
2
2
  import { type ShellPlan } from "./layout.js";
3
3
  import { SHELL_BINDINGS } from "./keys.js";
4
4
  import type { LiveStoreSnapshot } from "./live-store.js";
5
- export type ShellView = "home" | "run" | "evidence";
5
+ export type ShellView = "home" | "run" | "evidence" | "log";
6
6
  export type ShellFocus = ShellPlan["focus"][number];
7
7
  export interface ShellState {
8
8
  view: ShellView;
9
9
  focus: ShellFocus;
10
10
  scroll: number;
11
11
  help: boolean;
12
+ logEnd?: number;
12
13
  evidenceSection?: number;
13
14
  canOpen?: boolean;
14
15
  shortcutColumns?: number;
@@ -13,7 +13,8 @@ export const shellHelp = (bindings = SHELL_BINDINGS) => [
13
13
  "Esc closes deepest overlay; it never quits.",
14
14
  "Text: q1? is literal; Enter applies; Esc cancels.",
15
15
  "Confirm: y alone confirms; n/Esc cancels.",
16
- "Tab now moves focus; view numbers are 1/4/5.",
16
+ "Tab now moves focus; view numbers are 1/4/5/7.",
17
+ "Log: arrows/page keys scroll; f toggles tail follow.",
17
18
  "Fleet: tickmarkr fleet", "Plan: tickmarkr plan", "Health: tickmarkr doctor",
18
19
  ];
19
20
  /** Read the Yoga geometry that actually painted, including clipped/scrolled leaves.
@@ -65,10 +66,11 @@ export function CockpitShell({ snapshot, state, columns, rows, version, children
65
66
  const overlay = state.editor !== undefined ? [`${state.editorKind === "export" ? "Export destination" : "Filter"}: ${state.editor}`, "Enter Apply | Esc Cancel"] : state.overlay ?? (state.help ? shellHelp(bindings) : undefined);
66
67
  const totalRows = overlay?.length ?? contentRows;
67
68
  const displayOffset = overlay ? Math.min(state.overlayOffset, Math.max(0, totalRows - plan.bodyRows)) : scroll;
68
- const position = `${totalRows ? displayOffset + 1 : 0}–${Math.min(totalRows, displayOffset + plan.bodyRows)}/${totalRows} | ${Math.max(0, totalRows - plan.bodyRows)} hidden`;
69
+ const logMode = state.view === "log" ? `${state.logEnd === undefined ? "FOLLOW" : "PAUSED"} | ` : "";
70
+ const position = `${logMode}${totalRows ? displayOffset + 1 : 0}–${Math.min(totalRows, displayOffset + plan.bodyRows)}/${totalRows} | ${Math.max(0, totalRows - plan.bodyRows)} hidden`;
69
71
  const keybar = bindings.filter(b => ["focus", "open", "actions", "help", "quit"].includes(b.action) && (columns >= 60 || b.action !== "focus")).map(b => `${b.key} ${columns < 60 && b.action === "actions" ? "Act" : b.label}`).join(" | ");
70
72
  const status = columns < 60 ? `${op.label} | tip ${op.currentTip.toUpperCase()}` : `${op.label} | MERGED ${op.merged}/${op.planned ?? "?"} | CURRENT TIP ${op.currentTip.toUpperCase()} | ${snapshot.freshness}`;
71
73
  return _jsx(ShellPresentation.Provider, { value: true, children: _jsx(ShellTheme.Provider, { value: { mode: colour, palette }, children: _jsxs(Box, { ref: root, flexDirection: "column", width: columns, height: rows, flexShrink: 0, backgroundColor: ink(palette.surface), children: [line(`tickmarkr ${version} | ${state.view.toUpperCase()} | ${snapshot.journal.source.split("/").at(-2) ?? "run identity unknown"}`), line(columns < 60
72
- ? `1H 4R 5E | ${position}`
73
- : `1 Home 4 Run 5 Evidence | ${state.focus} | ${position}`), line("─".repeat(columns), columns, palette.chrome), _jsxs(Box, { height: plan.bodyRows, flexShrink: 0, flexDirection: "row", overflow: "hidden", children: [_jsx(Box, { width: 1, flexDirection: "column", children: Array.from({ length: plan.bodyRows }, (_, i) => _jsx(Text, { color: ink(palette.chrome), children: "\u2502" }, i)) }), plan.rail > 0 && _jsxs(_Fragment, { children: [_jsx(Box, { width: plan.rail, flexDirection: "column", children: ["1 Home", "4 Run", "5 Evidence", "", "CLI:", "fleet", "plan", "doctor"].map((t, i) => _jsx(Text, { color: ink(palette.chrome), children: fitCells(t, plan.rail) }, i)) }), _jsx(Box, { width: 1, flexDirection: "column", children: Array.from({ length: plan.bodyRows }, (_, i) => _jsx(Text, { color: ink(palette.chrome), children: "\u2502" }, i)) })] }), _jsxs(Box, { width: plan.bodyColumns, height: plan.bodyRows, flexDirection: "column", overflow: "hidden", flexShrink: 0, children: [overlay?.slice(displayOffset, displayOffset + plan.bodyRows).map((t, i) => _jsx(Text, { wrap: "truncate-end", color: ink(palette.text), children: fitCells(t, plan.bodyColumns) }, i)), _jsx(Box, { ref: leaf, display: overlay ? "none" : "flex", flexDirection: "column", flexShrink: 0, marginTop: -scroll, children: children })] }), plan.shortcuts > 0 && _jsxs(_Fragment, { children: [_jsx(Box, { width: 1, flexDirection: "column", children: Array.from({ length: plan.bodyRows }, (_, i) => _jsx(Text, { color: ink(palette.chrome), children: "\u2502" }, i)) }), _jsx(Box, { width: plan.shortcuts, flexDirection: "column", children: bindings.map(b => _jsx(Text, { color: ink(palette.chrome), wrap: "truncate-end", children: fitCells(`${b.key} ${b.label}`, plan.shortcuts) }, b.key)) })] }), _jsx(Box, { width: 1, flexDirection: "column", children: Array.from({ length: plan.bodyRows }, (_, i) => _jsx(Text, { color: ink(palette.chrome), children: "\u2502" }, i)) })] }), line("─".repeat(columns), columns, palette.chrome), line(status), line(keybar), line("─".repeat(columns), columns, palette.chrome)] }) }) });
74
+ ? `1H 4R 5E 7L | ${position}`
75
+ : `1 Home 4 Run 5 Evidence 7 Log | ${state.focus} | ${position}`), line("─".repeat(columns), columns, palette.chrome), _jsxs(Box, { height: plan.bodyRows, flexShrink: 0, flexDirection: "row", overflow: "hidden", children: [_jsx(Box, { width: 1, flexDirection: "column", children: Array.from({ length: plan.bodyRows }, (_, i) => _jsx(Text, { color: ink(palette.chrome), children: "\u2502" }, i)) }), plan.rail > 0 && _jsxs(_Fragment, { children: [_jsx(Box, { width: plan.rail, flexDirection: "column", children: ["1 Home", "4 Run", "5 Evidence", "7 Log", "", "CLI:", "fleet", "plan", "doctor"].map((t, i) => _jsx(Text, { color: ink(palette.chrome), children: fitCells(t, plan.rail) }, i)) }), _jsx(Box, { width: 1, flexDirection: "column", children: Array.from({ length: plan.bodyRows }, (_, i) => _jsx(Text, { color: ink(palette.chrome), children: "\u2502" }, i)) })] }), _jsxs(Box, { width: plan.bodyColumns, height: plan.bodyRows, flexDirection: "column", overflow: "hidden", flexShrink: 0, children: [overlay?.slice(displayOffset, displayOffset + plan.bodyRows).map((t, i) => _jsx(Text, { wrap: "truncate-end", color: ink(palette.text), children: fitCells(t, plan.bodyColumns) }, i)), _jsx(Box, { ref: leaf, display: overlay ? "none" : "flex", flexDirection: "column", flexShrink: 0, marginTop: -scroll, children: children })] }), plan.shortcuts > 0 && _jsxs(_Fragment, { children: [_jsx(Box, { width: 1, flexDirection: "column", children: Array.from({ length: plan.bodyRows }, (_, i) => _jsx(Text, { color: ink(palette.chrome), children: "\u2502" }, i)) }), _jsx(Box, { width: plan.shortcuts, flexDirection: "column", children: bindings.map(b => _jsx(Text, { color: ink(palette.chrome), wrap: "truncate-end", children: fitCells(`${b.key} ${b.label}`, plan.shortcuts) }, b.key)) })] }), _jsx(Box, { width: 1, flexDirection: "column", children: Array.from({ length: plan.bodyRows }, (_, i) => _jsx(Text, { color: ink(palette.chrome), children: "\u2502" }, i)) })] }), line("─".repeat(columns), columns, palette.chrome), line(status), line(keybar), line("─".repeat(columns), columns, palette.chrome)] }) }) });
74
76
  }
@@ -16,6 +16,12 @@ export type FleetEditorState = Record<DenyScopeKey, string[]> & {
16
16
  };
17
17
  steering: Record<FleetSteeringKey, string[] | undefined>;
18
18
  };
19
+ /** T8: the staged classifications and efforts every preview folds — the same metadata a review stages,
20
+ * so a preview ranks what w would write before the first review and after an escaped one. */
21
+ export type FleetStagedMetadata = Pick<FleetEditorState, "classifications" | "efforts">;
22
+ /** OBS-525: auto reverts the shape's WHOLE routing declaration — pool included, not just the pin.
23
+ * prefer survives: it biases auto routing and is valid without pin/pool. */
24
+ export declare function autoMapEntry(entry: MapEntry | undefined): MapEntry;
19
25
  export type FleetOverlayReview = {
20
26
  kind: "empty";
21
27
  } | {
@@ -136,9 +142,9 @@ export declare function FleetApp({ ageMs, agents, initialAllowOut, initialEffort
136
142
  initialMode: RoutingMode;
137
143
  modeOptions: FleetModeOption[];
138
144
  initialMap: Record<string, MapEntry>;
139
- modePreview: (mode: RoutingMode, map: Record<string, MapEntry>, deny: FleetStagedDeny) => string[];
140
- shapeRows: (mode: RoutingMode, map: Record<string, MapEntry>, deny: FleetStagedDeny) => FleetShapeRow[];
141
- candidatesForShape: (shape: Shape, mode: RoutingMode, map: Record<string, MapEntry>, deny: FleetStagedDeny) => {
145
+ modePreview: (mode: RoutingMode, map: Record<string, MapEntry>, deny: FleetStagedDeny, stage?: FleetStagedMetadata) => string[];
146
+ shapeRows: (mode: RoutingMode, map: Record<string, MapEntry>, deny: FleetStagedDeny, stage?: FleetStagedMetadata) => FleetShapeRow[];
147
+ candidatesForShape: (shape: Shape, mode: RoutingMode, map: Record<string, MapEntry>, deny: FleetStagedDeny, stage?: FleetStagedMetadata) => {
142
148
  rows: FleetCandidateOption[];
143
149
  excludedNote?: string;
144
150
  ledger?: string[];
@@ -177,9 +183,9 @@ export declare function runFleetInkEditor({ ageMs, adapters, health, initialAllo
177
183
  initialMode: RoutingMode;
178
184
  modeOptions: FleetModeOption[];
179
185
  initialMap: Record<string, MapEntry>;
180
- modePreview: (mode: RoutingMode, map: Record<string, MapEntry>, deny: FleetStagedDeny) => string[];
181
- shapeRows: (mode: RoutingMode, map: Record<string, MapEntry>, deny: FleetStagedDeny) => FleetShapeRow[];
182
- candidatesForShape: (shape: Shape, mode: RoutingMode, map: Record<string, MapEntry>, deny: FleetStagedDeny) => {
186
+ modePreview: (mode: RoutingMode, map: Record<string, MapEntry>, deny: FleetStagedDeny, stage?: FleetStagedMetadata) => string[];
187
+ shapeRows: (mode: RoutingMode, map: Record<string, MapEntry>, deny: FleetStagedDeny, stage?: FleetStagedMetadata) => FleetShapeRow[];
188
+ candidatesForShape: (shape: Shape, mode: RoutingMode, map: Record<string, MapEntry>, deny: FleetStagedDeny, stage?: FleetStagedMetadata) => {
183
189
  rows: FleetCandidateOption[];
184
190
  excludedNote?: string;
185
191
  ledger?: string[];
@@ -12,6 +12,12 @@ import { windowRows } from "./components.js";
12
12
  import { wrapCells } from "../cockpit/width.js";
13
13
  import { clip, clipPathTail, ElisionMark, enterAltScreen, fmtCtx, fmtMs, fmtUsdPair, Glyph, INK, inkInput, inkOutput, KeyBar, OverlayPanel, padCell, padCellStart, Pointer, SearchRow, } from "./frame.js";
14
14
  const STEERING_KEYS = ["review", "consult"];
15
+ /** OBS-525: auto reverts the shape's WHOLE routing declaration — pool included, not just the pin.
16
+ * prefer survives: it biases auto routing and is valid without pin/pool. */
17
+ export function autoMapEntry(entry) {
18
+ const { pin: _pin, pool: _pool, ...rest } = entry ?? {};
19
+ return rest;
20
+ }
15
21
  /** every scope's staged list, empty — the "every policy scope open" probe the picker lifts against */
16
22
  export const openStagedDeny = () => ({
17
23
  ...Object.fromEntries(DENY_SCOPES.map((scope) => [stagedDenyKeyOf(scope), []])),
@@ -346,7 +352,7 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
346
352
  ...Object.fromEntries(DENY_SCOPES.map((scope) => [stagedDenyKeyOf(scope), [...ui[scope.key]].sort()])),
347
353
  allowOut: [...ui.allowOut].sort(),
348
354
  });
349
- const shapeList = () => shapeRows(ui.selectedMode, ui.map, stagedDeny());
355
+ const shapeList = () => shapeRows(ui.selectedMode, ui.map, stagedDeny(), stagedMetadata());
350
356
  const steeringList = () => [
351
357
  ...STEERING_KEYS.map((key) => ({
352
358
  id: key,
@@ -369,6 +375,11 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
369
375
  ...modelGroups.map((_, index) => ({ kind: "adapter", index })),
370
376
  ];
371
377
  // ── state transitions ──────────────────────────────────────────────────────
378
+ // T8: the previews fold the CURRENT staged metadata — never only what the last review saw
379
+ const stagedMetadata = () => {
380
+ const { classifications, efforts } = editorState();
381
+ return { classifications, efforts };
382
+ };
372
383
  const editorState = () => ({
373
384
  ...denyLists(),
374
385
  allowOut: [...ui.allowOut].sort(),
@@ -733,7 +744,7 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
733
744
  // channel must be OFFERED at or above the floor once every policy scope is open — a failed
734
745
  // model probe (dropped by discovery), a pin or a pool keep it out of that list and a floor
735
746
  // marks it belowFloor, so nothing here lifts it.
736
- const eligible = candidatesForShape(overlay.shape, ui.selectedMode, ui.map, openStagedDeny()).rows
747
+ const eligible = candidatesForShape(overlay.shape, ui.selectedMode, ui.map, openStagedDeny(), stagedMetadata()).rows
737
748
  .some((row) => row.id === id && !row.belowFloor);
738
749
  if (!eligible) {
739
750
  // the ledger line spells its reasons; drop the row label and the reach segment for the notice
@@ -756,7 +767,7 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
756
767
  const text = `lift: cleared ${found.entry} from ${found.scope}${after.reach === "in" ? "" : ` — still out: ${after.reasons.join("; ")}`}`;
757
768
  ui.lastEdit = { id, text };
758
769
  ui.notice = text;
759
- const picked = candidatesForShape(overlay.shape, ui.selectedMode, ui.map, stagedDeny());
770
+ const picked = candidatesForShape(overlay.shape, ui.selectedMode, ui.map, stagedDeny(), stagedMetadata());
760
771
  ui.overlay = { ...overlay, rows: picked.rows, excludedNote: picked.excludedNote, ledger: picked.ledger ?? [], at: 0 };
761
772
  bump();
762
773
  };
@@ -1590,17 +1601,12 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
1590
1601
  if (!shape)
1591
1602
  return;
1592
1603
  if (hotkey === "a") {
1593
- // OBS-525: auto reverts the shape's WHOLE routing declaration — pool included, not just
1594
- // the pin. prefer survives: it biases auto routing and is valid without pin/pool.
1595
- const nextEntry = { ...ui.map[shape] };
1596
- delete nextEntry.pin;
1597
- delete nextEntry.pool;
1598
- ui.map = { ...ui.map, [shape]: nextEntry };
1604
+ ui.map = { ...ui.map, [shape]: autoMapEntry(ui.map[shape]) };
1599
1605
  bump();
1600
1606
  return;
1601
1607
  }
1602
1608
  if (key.return || hotkey === "p") {
1603
- const picked = candidatesForShape(shape, ui.selectedMode, ui.map, stagedDeny());
1609
+ const picked = candidatesForShape(shape, ui.selectedMode, ui.map, stagedDeny(), stagedMetadata());
1604
1610
  setOverlay({
1605
1611
  kind: "candidates",
1606
1612
  shape,
@@ -1791,7 +1797,7 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
1791
1797
  if (!overlay)
1792
1798
  return null;
1793
1799
  if (overlay.kind === "presets") {
1794
- const details = modeOptions[overlay.at] ? modePreview(modeOptions[overlay.at].id, ui.map, stagedDeny()) : [];
1800
+ const details = modeOptions[overlay.at] ? modePreview(modeOptions[overlay.at].id, ui.map, stagedDeny(), stagedMetadata()) : [];
1795
1801
  return (_jsxs(OverlayPanel, { title: overlay.home ? "routing preset" : "routing mode", width: bodyW, children: [_jsx(Text, { dimColor: true, children: overlay.home ? "a preset routes every shape — custom opens the browser" : "floors move with the mode; the browser edits the rest" }), _jsx(Text, { children: " " }), modeOptions.map((option, index) => (_jsxs(Text, { children: [_jsx(Pointer, { on: overlay.at === index }), option.id === ui.selectedMode ? _jsx(Glyph, { kind: "on" }) : _jsx(Text, { children: " " }), _jsx(Text, { bold: overlay.at === index, children: ` ${padCell(option.id, 12)}` }), _jsx(Text, { dimColor: true, children: clip(option.gloss, bodyW - 20) })] }, option.id))), overlay.home && (_jsxs(Text, { children: [_jsx(Pointer, { on: overlay.at === modeOptions.length }), _jsx(Text, { children: " " }), _jsx(Text, { bold: overlay.at === modeOptions.length, children: ` ${padCell("custom", 12)}` }), _jsx(Text, { dimColor: true, children: clip("open the fleet browser (models · shapes · steering)", bodyW - 20) })] })), _jsx(Text, { children: " " }), details.map((line) => _jsx(Text, { dimColor: true, children: line }, line))] }));
1796
1802
  }
1797
1803
  if (overlay.kind === "review") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "2.6.3",
3
+ "version": "2.6.5",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",