tickmarkr 2.6.4 → 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 (63) hide show
  1. package/dist/adapters/catalog-remote.d.ts +23 -0
  2. package/dist/adapters/catalog-remote.js +38 -0
  3. package/dist/adapters/claude-code.d.ts +1 -1
  4. package/dist/adapters/claude-code.js +4 -1
  5. package/dist/adapters/codex-commit-check.d.ts +25 -0
  6. package/dist/adapters/codex-commit-check.js +108 -0
  7. package/dist/adapters/model-discovery-check.d.ts +33 -0
  8. package/dist/adapters/model-discovery-check.js +123 -0
  9. package/dist/adapters/model-lints.d.ts +1 -1
  10. package/dist/adapters/model-lints.js +4 -0
  11. package/dist/adapters/prompt.js +3 -2
  12. package/dist/adapters/types.d.ts +7 -0
  13. package/dist/cli/commands/approve.js +10 -4
  14. package/dist/cli/commands/doctor.d.ts +2 -0
  15. package/dist/cli/commands/doctor.js +14 -2
  16. package/dist/cli/commands/fleet.d.ts +10 -1
  17. package/dist/cli/commands/fleet.js +55 -38
  18. package/dist/cli/commands/init.js +5 -1
  19. package/dist/cli/commands/plan.js +65 -4
  20. package/dist/cli/commands/verify.d.ts +1 -1
  21. package/dist/cli/commands/verify.js +28 -6
  22. package/dist/config/fleet-overlay.d.ts +2 -1
  23. package/dist/config/fleet-overlay.js +77 -4
  24. package/dist/drivers/orca.d.ts +19 -1
  25. package/dist/drivers/orca.js +161 -31
  26. package/dist/gates/cache.d.ts +4 -0
  27. package/dist/gates/cache.js +10 -2
  28. package/dist/gates/llm.d.ts +4 -1
  29. package/dist/gates/llm.js +6 -1
  30. package/dist/gates/review.js +8 -3
  31. package/dist/gates/run-gates.d.ts +3 -2
  32. package/dist/gates/run-gates.js +91 -50
  33. package/dist/run/daemon.js +150 -19
  34. package/dist/run/git.js +9 -0
  35. package/dist/run/journal.d.ts +6 -2
  36. package/dist/run/journal.js +22 -7
  37. package/dist/run/launchservices-check.d.ts +56 -0
  38. package/dist/run/launchservices-check.js +117 -0
  39. package/dist/run/operator-state.d.ts +22 -2
  40. package/dist/run/operator-state.js +20 -3
  41. package/dist/run/title-environment.d.ts +45 -0
  42. package/dist/run/title-environment.js +127 -0
  43. package/dist/run/title-preload.cjs +38 -0
  44. package/dist/run/title-preload.d.cts +1 -0
  45. package/dist/tui/cockpit/board.js +12 -6
  46. package/dist/tui/cockpit/keys.d.ts +8 -0
  47. package/dist/tui/cockpit/keys.js +4 -1
  48. package/dist/tui/cockpit/layout.d.ts +2 -2
  49. package/dist/tui/cockpit/layout.js +2 -2
  50. package/dist/tui/cockpit/live-runtime.js +46 -20
  51. package/dist/tui/cockpit/live-store.d.ts +24 -1
  52. package/dist/tui/cockpit/live-store.js +99 -8
  53. package/dist/tui/cockpit/log-view.d.ts +28 -0
  54. package/dist/tui/cockpit/log-view.js +60 -0
  55. package/dist/tui/cockpit/run-view.js +4 -0
  56. package/dist/tui/cockpit/shell.d.ts +2 -1
  57. package/dist/tui/cockpit/shell.js +6 -4
  58. package/dist/tui/ink/fleet-app.d.ts +12 -6
  59. package/dist/tui/ink/fleet-app.js +17 -11
  60. package/package.json +1 -1
  61. package/skills/tickmarkr-auto/SKILL.md +32 -2
  62. package/skills/tickmarkr-loop/SKILL.md +46 -2
  63. package/skills/tickmarkr-overseer/SKILL.md +83 -8
@@ -1,5 +1,5 @@
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
4
  /** Stat-to-fstat observations one poll spends on a journal a writer keeps appending to. */
5
5
  export declare const APPEND_RACE_OBSERVATIONS = 3;
@@ -27,9 +27,12 @@ export interface JournalLine extends Omit<OperatorRecord, "event"> {
27
27
  endOffset: number;
28
28
  }
29
29
  export interface TailSnapshot {
30
+ /** `digest` (I2): sha256 of every byte the tail consumed, [0, offset) — the bytes its fold was built from. */
30
31
  source: string;
31
32
  generation: number;
32
33
  identity?: string;
34
+ version?: string;
35
+ digest: string;
33
36
  offset: number;
34
37
  lines: number;
35
38
  history: readonly JournalLine[];
@@ -64,11 +67,17 @@ export declare class JournalTail {
64
67
  private lastSuccessfulReadAt?;
65
68
  private failure?;
66
69
  private stale;
70
+ private hash;
71
+ private invalid;
67
72
  constructor(source: string, hooks?: {
68
73
  record?: (record: OperatorRecord) => void;
69
74
  reset?: () => void;
70
75
  });
71
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;
72
81
  /** A same-inode append between stat and fstat is retried; exhaustion keeps the last good snapshot, pending. */
73
82
  poll(now?: number): TailSnapshot;
74
83
  /** One stat-to-fstat observation. False when an append raced it; nothing was consumed or reset. */
@@ -86,6 +95,18 @@ export interface CachedSource<T = unknown> {
86
95
  error?: string;
87
96
  observedAt: number;
88
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;
89
110
  export interface LiveStoreOptions {
90
111
  cwd: string;
91
112
  runId: string;
@@ -102,6 +123,7 @@ export declare function createLiveStore(options: LiveStoreOptions): {
102
123
  delayed: boolean;
103
124
  freshness: string;
104
125
  operator: import("../../run/operator-state.js").OperatorSnapshot;
126
+ owed: OwedAuthority | undefined;
105
127
  journal: TailSnapshot;
106
128
  graph: CachedSource<{
107
129
  version: 1;
@@ -204,6 +226,7 @@ export declare function createLiveStore(options: LiveStoreOptions): {
204
226
  delayed: boolean;
205
227
  freshness: string;
206
228
  operator: import("../../run/operator-state.js").OperatorSnapshot;
229
+ owed: OwedAuthority | undefined;
207
230
  journal: TailSnapshot;
208
231
  graph: CachedSource<{
209
232
  version: 1;
@@ -1,8 +1,9 @@
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";
@@ -14,6 +15,12 @@ export const STORE_LIMITS = { graphBytes: 16 * 1024 * 1024, history: 256, histor
14
15
  const errorText = (e) => e instanceof Error ? e.message : String(e);
15
16
  const fileIdentity = (st) => `${st.dev}:${st.ino}`;
16
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
+ };
17
24
  function decodeLine(bytes, source, line, offset, endOffset, generation, oversized = false) {
18
25
  const base = { source, line, id: `${source}#L${line}`, offset, endOffset, generation };
19
26
  let raw = "";
@@ -24,7 +31,7 @@ function decodeLine(bytes, source, line, offset, endOffset, generation, oversize
24
31
  if (!raw.trim())
25
32
  return { ...base, raw };
26
33
  const e = JSON.parse(raw);
27
- 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))
28
35
  throw new Error("invalid journal record");
29
36
  return { ...base, raw, event: e };
30
37
  }
@@ -51,6 +58,8 @@ export class JournalTail {
51
58
  lastSuccessfulReadAt;
52
59
  failure;
53
60
  stale = false;
61
+ hash = createHash("sha256");
62
+ invalid = false;
54
63
  constructor(source, hooks = {}) {
55
64
  this.source = source;
56
65
  this.hooks = hooks;
@@ -66,8 +75,14 @@ export class JournalTail {
66
75
  this.errors = [];
67
76
  this.malformedCount = 0;
68
77
  this.generation++;
78
+ this.hash = createHash("sha256");
79
+ this.invalid = false;
69
80
  this.hooks.reset?.();
70
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; }
71
86
  /** A same-inode append between stat and fstat is retried; exhaustion keeps the last good snapshot, pending. */
72
87
  poll(now = Date.now()) {
73
88
  try {
@@ -94,7 +109,7 @@ export class JournalTail {
94
109
  const st = statSync(this.source, { bigint: true });
95
110
  if (!st.isFile())
96
111
  throw new Error("journal source is not a regular file");
97
- const replaced = !this.st || fileIdentity(st) !== fileIdentity(this.st) || st.size < this.st.size || (st.size === this.st.size && stamp(st) !== stamp(this.st));
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));
98
113
  if (Number(st.size) > (replaced ? 0 : this.offset)) {
99
114
  fd = openSync(this.source, "r");
100
115
  const opened = fstatSync(fd, { bigint: true });
@@ -115,6 +130,7 @@ export class JournalTail {
115
130
  throw new Error("journal truncated during read");
116
131
  this.bytesRead += count;
117
132
  remaining -= count;
133
+ this.hash.update(buffer.subarray(0, count));
118
134
  let begin = 0;
119
135
  for (let index = 0; index < count; index++)
120
136
  if (buffer[index] === 10) {
@@ -158,7 +174,7 @@ export class JournalTail {
158
174
  }
159
175
  snapshot() {
160
176
  return {
161
- 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,
162
178
  history: this.history.slice(), errors: this.errors.slice(), malformedCount: this.malformedCount,
163
179
  pending: this.carryBytes ? { line: this.line + 1, bytes: this.carryBytes } : undefined,
164
180
  backlogBytes: Math.max(0, Number(this.st?.size ?? 0) - this.offset),
@@ -262,6 +278,48 @@ class JsonSource {
262
278
  }
263
279
  }
264
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
+ }
265
323
  /** C1 mount/C6 reader boundary. No timers or presence writes: the mount owns scheduling and disarm. */
266
324
  export function createLiveStore(options) {
267
325
  const now = options.now ?? Date.now;
@@ -285,10 +343,41 @@ export function createLiveStore(options) {
285
343
  let disposed = false;
286
344
  let queued;
287
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
+ };
288
367
  function observe() {
289
368
  const observedAt = now();
290
369
  const delayed = lastObservation !== undefined && observedAt - lastObservation > 2 * OBSERVATION_INTERVAL_MS;
291
- 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
+ }
292
381
  const graphReading = graph.read(observedAt);
293
382
  const configReading = config.read(observedAt);
294
383
  const cacheReading = cache.read(observedAt);
@@ -301,11 +390,13 @@ export function createLiveStore(options) {
301
390
  if (metrics.length > STORE_LIMITS.metrics)
302
391
  metrics.shift();
303
392
  lastObservation = observedAt;
304
- const readable = journal.status === "readable" && journal.backlogBytes === 0;
393
+ const readable = isReadable(journal);
305
394
  return {
306
395
  sequence: ++sequence, observedAt, delayed, freshness: errors.length ? "failed" : delayed ? "delayed" : "fresh",
307
- operator: fold.snapshot({ graph: graphReading.status === "readable" ? graphReading.value : undefined, sequence, observedAt, readable, graphAvailability: { status: graphReading.status, error: graphReading.error } }),
308
- 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,
309
400
  lock: { ...owner, state: lockState, alive, expired: owner.identity ? observedAt - Number(owner.identity.split(":")[3]) / 1e6 > STALE_MS : undefined },
310
401
  supervision, errors, actionsEnabled: readable && !errors.length && !delayed,
311
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.4",
3
+ "version": "2.6.5",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -12,7 +12,7 @@ Use the canonical loop skill's [cockpit, parked decisions and printed twins](../
12
12
  for each target: delivered views are **1 Home, 4 Run, 5 Evidence**; Fleet/Bootstrap and
13
13
  Plan/Health remain follow-ons with CLI entries. The recorded 1/3 merged, human T2, blocked
14
14
  T3 case stays PARTIAL despite tip pass. Review the Run confirmation, read back the appended
15
- `task-approved` receipt, and explicitly `tickmarkr resume <runId>` when no live owner exists;
15
+ `task-approved` receipt, and explicitly launch `tickmarkr resume <runId>` with the host-owned split form below when no live owner exists;
16
16
  approval records permission and never dispatches work itself. Non-TTY readers keep `status`,
17
17
  `report` and default-watch line output, with `--watch --plain` as the TTY fallback.
18
18
  Keep `name: tickmarkr-auto` and the installed links to canonical `skills/` intact.
@@ -92,7 +92,37 @@ After sending, **confirm delivery** by reading the target pane and verifying the
92
92
  1. **Prepare** — confirm the target list. Run the [binary preflight](#binary-preflight-before-compile-or-run). Check `git status`, confirm no tickmarkr run is active, and work from a non-main branch.
93
93
  2. **Compile** — run `tickmarkr compile <spec-or-directory>`. Fix source-spec defects instead of editing the generated graph.
94
94
  3. **Plan** — run `tickmarkr plan`. Review routes, capability-floor warnings, and human gates before execution.
95
- 4. **Run** — run `tickmarkr run`. A watch ending the seat's turn is no watch: keep a **blocking journal consumer** alive for the run's terminal events — the shipped watcher below, or a foreground `until grep` on the run's terminal events — and ensure it is re-armed at most every twenty minutes. Never rely on a `Monitor`-only wake. Watch the run journal rather than polling agents, using the shipped watcher — `.claude/skills/tickmarkr-overseer/scripts/watch-journal.sh <state-dir>/runs 20 28800` — which takes a line baseline at arm time, then wakes ONCE on `run-end`, `task-human`, `task-failed` or `consult-verdict` and grades the run-end summary against every execution clause for you; the debt clause is yours — read CURRENT `tickmarkr status <runId>` (step 5). Re-arm after every wake. ⛔ Never `tail -F | grep -m1` (run-end is the journal's last line, so tail never notices the broken pipe and the watcher hangs forever) and never a pane-level done wait (it fires on every agent turn end, not mission end). ⚠ A bare whole-file `grep -q '"event":"run-end"'` is the trap the watcher exists to avoid: on a resume it matches the PREVIOUS run's run-end and returns instantly, so a re-armed watcher reads as coverage that does not exist. Resolve blocked interactions in the relevant agent session.
95
+ 4. **Run** — launch `tickmarkr run` using the [host-owned split form](#host-owned-daemon-and-detached-beats), and start detached beats with their run-end stop items. A watch ending the seat's turn is no watch: keep a **blocking journal consumer** alive for the run's terminal events — the shipped watcher below, or a foreground `until grep` on the run's terminal events — and ensure it is re-armed at most every twenty minutes. Never rely on a `Monitor`-only wake. Watch the run journal rather than polling agents, using the shipped watcher — `.claude/skills/tickmarkr-overseer/scripts/watch-journal.sh <state-dir>/runs 20 28800` — which takes a line baseline at arm time, then wakes ONCE on `run-end`, `task-human`, `task-failed` or `consult-verdict` and grades the run-end summary against every execution clause for you; the debt clause is yours — read CURRENT `tickmarkr status <runId>` (step 5). Re-arm after every wake. ⛔ Never `tail -F | grep -m1` (run-end is the journal's last line, so tail never notices the broken pipe and the watcher hangs forever) and never a pane-level done wait (it fires on every agent turn end, not mission end). ⚠ A bare whole-file `grep -q '"event":"run-end"'` is the trap the watcher exists to avoid: on a resume it matches the PREVIOUS run's run-end and returns instantly, so a re-armed watcher reads as coverage that does not exist. Resolve blocked interactions in the relevant agent session.
96
96
  5. **Verify and consolidate** — continue only after a green run. A run is green when the run-end event exists in the journal, the tip verify is not "failed", the summary's `failed`, `human`, `blocked` and `pending` buckets are all empty, and CURRENT `tickmarkr status <runId>` reads its owed checks outstanding empty AND known (`outstanding 0`) — a run with a parked task is partial, not green. Empty execution buckets alone are not green either (D-660): every bucket empty and the tip passed, but an operator waived one review, leaves one accepted-risk review check owed — status reads `outstanding 1 (T7 review)` and `run`/`resume` still exit 0 on execution alone, so the run is execution complete, not green. It turns green only when a `tickmarkr verify --record <runId>` discharge moves CURRENT status to `outstanding 0`, including a discharge landing after run-end — the historical run-end record keeps the old count, so never read debt from it; `outstanding unknown` is never green. Tickmarkr consolidates accepted work on `tickmarkr/<runId>` and never signs off to the main branch. A human controls any later release merge.
97
97
  6. **Record** — `tickmarkr report <runId> --md` prints Markdown to stdout; redirect explicitly beside the spec (for example `tickmarkr report <runId> --md > feature.record.md`) and commit the execution record when the repository tracks those records.
98
98
  7. **Continue** — move to the next requested target. If a target fails or is parked, stop with the journal evidence rather than silently skipping it.
99
+
100
+ ## Host-owned daemon and detached beats
101
+
102
+ Launch the daemon as a SPLIT of the orchestrator's own pane in the ORCH tab, owned by the
103
+ host PTY. Never launch it as an agent harness background task or in a separate tab. The daemon
104
+ self-places its board from that split, keeping both daemon and board in the ORCH tab.
105
+
106
+ - **On Orca (`TERM_PROGRAM=Orca` and non-empty `ORCA_TERMINAL_HANDLE`)**:
107
+ `orca terminal split --terminal "$ORCA_TERMINAL_HANDLE" --direction vertical --command "tickmarkr run"`.
108
+ - **On herdr (`HERDR_ENV=1`)**: use `herdr pane split` on the orchestrator's own pane, then
109
+ `herdr pane run <new> "tickmarkr run"` in the returned split.
110
+
111
+ For `resume`, use the same host-owned split form with `tickmarkr resume <runId>` in place of
112
+ `tickmarkr run`. After either launch, read this repository's lock pid and walk its ppid chain
113
+ in the process table to the host PTY; verify no agent session is an ancestor. If an agent is
114
+ an ancestor, stop that launch and relaunch through the host split before continuing.
115
+
116
+ Start `tickmarkr beat <tier> --seat <seat> --loop` as a DETACHED background process, one per
117
+ (tier, seat), from the repository root. Use setsid plus nohup (or the host's equivalent detached
118
+ session facility), redirect output to a file, and record a pidfile under the repository's state
119
+ dir. Verify ppid 1 and no agent-session ancestor. Never put beats in a harness background task
120
+ or a visible tab. The lifecycle must be idempotent: start checks the pidfile against the live
121
+ process payload and returns an "already running" no-op for the same (repo, tier, seat); status
122
+ checks that payload and beat freshness; stop sends `--stand-down` for the same tier and seat,
123
+ verifies the loop exits within one interval, then removes its pidfile. A stale or reused pid
124
+ must never be killed without matching its live payload. Include a run-end stop item for every
125
+ beat started, also on failure, park or handoff; verify DISARMED and remove retired beat files.
126
+
127
+ Keep only watchers that must WAKE the seat in the harness. Re-arm them on each wake and at
128
+ their harness cap; daemon and beat lifetimes must not depend on that cap.