tickmarkr 2.1.5 → 2.1.7

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.
@@ -1,7 +1,7 @@
1
1
  import { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { tickmarkrDir } from "../../graph/graph.js";
4
- import { SUPERVISION_BEAT_MS, SUPERVISION_STALE_MS, SUPERVISION_TIERS, beatSupervision, supervisionStandDownPath, } from "../../run/supervision.js";
4
+ import { SUPERVISION_BEAT_MS, SUPERVISION_DEFAULT_THRESHOLD_PCT, SUPERVISION_STALE_MS, SUPERVISION_TIERS, beatSupervision, supervisionStandDownPath, } from "../../run/supervision.js";
5
5
  // SUP-04: the writer side of supervision, as a VERB. `beatSupervision` and `SUPERVISION_BEAT_MS` shipped
6
6
  // with exactly one in-repo caller — the daemon, on one tier — so `status` printed
7
7
  // `orchestrator ARMED / overseer ABSENT / watch ABSENT` while a real overseer worked the run: two thirds
@@ -18,16 +18,22 @@ import { SUPERVISION_BEAT_MS, SUPERVISION_STALE_MS, SUPERVISION_TIERS, beatSuper
18
18
  // reads UNREADABLE. Here the writes are unguarded: a failure leaves the process with a non-zero exit
19
19
  // and a message instead of a claim. The LOOP belongs to the caller, which is what makes the beat
20
20
  // evidence: stop calling it and the tier ages into STALE on its own.
21
+ const VALUE_OPTIONS = ["--seat", "--arm-id", "--pct", "--threshold-pct"];
21
22
  export async function beat(argv, cwd = process.cwd()) {
22
23
  const standDown = argv.includes("--stand-down");
23
24
  const seat = seatOf(argv);
24
- const named = argv.find((a, i) => !a.startsWith("--") && argv[i - 1] !== "--seat");
25
+ const pct = percentageOf(argv, "--pct");
26
+ const thresholdPct = percentageOf(argv, "--threshold-pct") ?? SUPERVISION_DEFAULT_THRESHOLD_PCT;
27
+ const armId = optionOf(argv, "--arm-id");
28
+ const named = argv.find((a, i) => !a.startsWith("--") && !VALUE_OPTIONS.includes(argv[i - 1] ?? ""));
25
29
  if (!isTier(named)) {
26
- throw new Error(`usage: tickmarkr beat <${SUPERVISION_TIERS.join("|")}> --seat <identity> [--stand-down] — got ${named ? `\`${named}\`` : "no tier"}`);
30
+ throw new Error(`usage: tickmarkr beat <${SUPERVISION_TIERS.join("|")}> --seat <identity> ` +
31
+ `[--arm-id <identity> --pct <0..100> --threshold-pct <0..100>] [--stand-down] — ` +
32
+ `got ${named ? `\`${named}\`` : "no tier"}`);
27
33
  }
28
34
  // SUP-05: NO SEAT, NO BEAT — and the refusal comes before any write, so a refused invocation leaves
29
- // the tier exactly as it found it. This verb is one-shot: the pid it would otherwise record has
30
- // already exited by the time anyone reads the record, so tier + pid + instant is a beat nobody can
35
+ // the tier exactly as it found it. This verb is one-shot: the exitedWriterPid it records has
36
+ // already exited by the time anyone reads the record, so tier + writer + instant is a beat nobody can
31
37
  // attribute to a seat. Measured 2026-08-26: a consult seat ran the documented loop verbatim and the
32
38
  // board read that tier ARMED with no seat of that tier having armed anything, and a seatless ARMED
33
39
  // reads as coverage — worse than ABSENT, because ABSENT sends someone to look.
@@ -42,16 +48,35 @@ export async function beat(argv, cwd = process.cwd()) {
42
48
  // uncleared marker would render DISARMED while this verb claimed ARMED, so the removal is unguarded
43
49
  // too — `force` makes the ordinary "no marker" case a no-op, and anything else is a real failure.
44
50
  rmSync(supervisionStandDownPath(cwd, named), { force: true, recursive: true });
45
- beatSupervision(cwd, named, seat);
51
+ beatSupervision(cwd, named, seat, pct === undefined ? undefined : { armId: armId ?? seat, pct, thresholdPct });
46
52
  return `${named} ARMED as ${seat} — beat again every ${SUPERVISION_BEAT_MS / 1_000}s; the tier reads STALE ${SUPERVISION_STALE_MS / 1_000}s after the last beat`;
47
53
  }
48
54
  /** `--seat <identity>` or `--seat=<identity>`; blank and missing are the same answer — none. */
49
55
  function seatOf(argv) {
50
- const inline = argv.find((a) => a.startsWith("--seat="))?.slice("--seat=".length);
51
- const spaced = argv[argv.indexOf("--seat") + 1];
52
- const seat = (inline ?? (argv.includes("--seat") ? spaced : undefined))?.trim();
56
+ const seat = optionOf(argv, "--seat")?.trim();
53
57
  return seat && !seat.startsWith("--") ? seat : undefined;
54
58
  }
59
+ /** A `--name value` or `--name=value` option, excluding a missing value or the next flag. */
60
+ function optionOf(argv, name) {
61
+ const inline = argv.find((a) => a.startsWith(`${name}=`))?.slice(name.length + 1).trim();
62
+ const spaced = argv[argv.indexOf(name) + 1]?.trim();
63
+ const value = inline ?? (argv.includes(name) ? spaced : undefined);
64
+ return value && !value.startsWith("--") ? value : undefined;
65
+ }
66
+ function percentageOf(argv, name) {
67
+ const raw = optionOf(argv, name);
68
+ if (raw === undefined) {
69
+ if (argv.includes(name) || argv.some((a) => a.startsWith(`${name}=`))) {
70
+ throw new Error(`${name} needs a number from 0 through 100`);
71
+ }
72
+ return undefined;
73
+ }
74
+ const value = Number(raw);
75
+ if (!Number.isFinite(value) || value < 0 || value > 100) {
76
+ throw new Error(`${name} must be a number from 0 through 100`);
77
+ }
78
+ return value;
79
+ }
55
80
  // Stand-down is a RECORDED act, not a silence: the marker tells a reader this watcher left on purpose,
56
81
  // so the tier reads DISARMED rather than ageing out as a death. Published atomically — written aside,
57
82
  // renamed over — because a torn marker is rejected by the reader, and a rejected stand-down reports a
@@ -63,7 +88,9 @@ function standDownTier(repoRoot, tier, seat) {
63
88
  mkdirSync(dirname(p), { recursive: true });
64
89
  // The marker names the seat for the same reason the beat does: "someone stood this tier down" is not
65
90
  // a hand-off anyone can act on, and on a seat tier the reader rejects an anonymous one outright.
66
- writeFileSync(tmp, JSON.stringify({ tier, seat, pid: process.pid, disarmedAt: new Date().toISOString() }) + "\n");
91
+ writeFileSync(tmp, JSON.stringify({
92
+ tier, seat, exitedWriterPid: process.pid, disarmedAt: new Date().toISOString(),
93
+ }) + "\n");
67
94
  renameSync(tmp, p);
68
95
  return `${tier} DISARMED — ${seat} handed off; status reads it stood down, not dead`;
69
96
  }
@@ -167,7 +167,7 @@ const railTone = (event, fallback) => {
167
167
  // `gate-result` is the one event whose data IS a gate result; every other row carrying a `gate`
168
168
  // names a gate without reporting one (a start, a reuse, a retry) and keeps its declared tone.
169
169
  if (event.event === "gate-result")
170
- return GATE_OUTCOME_TONES[normalizeGateOutcome(event.data).kind] ?? fallback;
170
+ return GATE_OUTCOME_TONES[normalizeGateOutcome(event.data).kind];
171
171
  const verdict = event.data.pass ?? event.data.ok;
172
172
  return verdict === true ? "pass" : verdict === false ? "fail" : fallback;
173
173
  };
@@ -226,7 +226,24 @@ const railSalient = (data) => RAIL_SALIENT.flatMap(({ key, render }) => {
226
226
  * This is one detail vocabulary stated twice, so `brand-surfaces.test.ts` pins the two against each
227
227
  * other over the whole event corpus: the pipe's bytes must equal event/taskId/THIS detail put through
228
228
  * the formatter's legacy squeeze-and-slice. A ladder that drifts fails there rather than in a tab.
229
+ * Gate-result verdict words are the deliberate exception: the pipe is byte-frozen, while the TTY rail
230
+ * names the normalized outcome so a held selected-test screen is not narrated as a pass.
229
231
  */
232
+ const gateResultDetail = (data) => {
233
+ if (typeof data.gate !== "string")
234
+ return undefined;
235
+ switch (normalizeGateOutcome(data).kind) {
236
+ case "passed":
237
+ return `${data.gate} passed`;
238
+ case "failed":
239
+ return `${data.gate} failed`;
240
+ case "held":
241
+ // The kind is already normalized; the selected-test list only chooses this surface noun.
242
+ return Array.isArray(data.selectedTests) ? `${data.gate} selected-test screen` : `${data.gate} held`;
243
+ default:
244
+ return data.gate;
245
+ }
246
+ };
230
247
  const formatterDetail = ({ event, data }) => {
231
248
  const assignment = data.assignment;
232
249
  const direct = [data.summary, data.reason, data.error, data.step, data.action, data.lint, data.branch, data.from]
@@ -240,7 +257,9 @@ const formatterDetail = ({ event, data }) => {
240
257
  }
241
258
  if (event === "tip-verify")
242
259
  return `${data.gate} passed`;
243
- return `${data.gate}${data.pass === true ? " passed" : data.pass === false ? " failed" : ""}`;
260
+ if (event === "gate-result")
261
+ return gateResultDetail(data);
262
+ return `${data.gate}`;
244
263
  }
245
264
  if (typeof data.code === "number")
246
265
  return `exit ${data.code}`;
@@ -0,0 +1,21 @@
1
+ export declare const STATS_COLUMNS: readonly ["author", "reviewer", "dispatches", "deliveries", "delivery rate", "attempts to green", "real reds", "infra reds", "within-task rescues"];
2
+ export interface ChannelStats {
3
+ author: string;
4
+ reviewers: string[];
5
+ dispatches: number;
6
+ deliveries: number;
7
+ deliveryRate: number;
8
+ /** Mean journal-recorded attempts among this channel's delivered tasks; null when it delivered none. */
9
+ attemptsToGreen: number | null;
10
+ realReds: number;
11
+ infraReds: number;
12
+ rescues: string[];
13
+ }
14
+ export interface StatsReport {
15
+ runs: number;
16
+ channels: ChannelStats[];
17
+ }
18
+ /** Reduce every readable run journal under the repository's state directory. */
19
+ export declare function collectChannelStats(cwd?: string): StatsReport;
20
+ export declare function renderStats(report: StatsReport): string;
21
+ export declare function stats(argv: string[], cwd?: string): Promise<string>;
@@ -0,0 +1,200 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { stateDirName } from "../../graph/graph.js";
4
+ import { Journal } from "../../run/journal.js";
5
+ import { normalizeGateOutcome } from "../../run/outcome.js";
6
+ // The operator-facing schema is deliberately closed. Adding a fact to the table therefore requires
7
+ // adding it here as a conscious compatibility change, rather than letting incidental journal fields
8
+ // leak into an analytics surface.
9
+ export const STATS_COLUMNS = [
10
+ "author",
11
+ "reviewer",
12
+ "dispatches",
13
+ "deliveries",
14
+ "delivery rate",
15
+ "attempts to green",
16
+ "real reds",
17
+ "infra reds",
18
+ "within-task rescues",
19
+ ];
20
+ const assignmentAuthor = (data) => {
21
+ const assignment = data.assignment;
22
+ if (!assignment || typeof assignment !== "object")
23
+ return undefined;
24
+ const { adapter, model } = assignment;
25
+ return typeof adapter === "string" && typeof model === "string"
26
+ ? `${adapter}:${model}`
27
+ : undefined;
28
+ };
29
+ // Current journals write the reviewer into review prose; the structured field is also accepted so
30
+ // journals produced through gateResultJournalData retain their stronger identity representation.
31
+ const reviewerFrom = (data) => {
32
+ if (typeof data.reviewer === "string" && data.reviewer.trim())
33
+ return data.reviewer.trim();
34
+ if (typeof data.details !== "string")
35
+ return undefined;
36
+ return /\breviewer(?:\s+|:\s*)([\w@./+-]+:[\w@./+-]+)/iu.exec(data.details)?.[1];
37
+ };
38
+ const historyFor = (tasks, taskId) => {
39
+ let history = tasks.get(taskId);
40
+ if (!history) {
41
+ history = { dispatches: [], deliveries: [] };
42
+ tasks.set(taskId, history);
43
+ }
44
+ return history;
45
+ };
46
+ const dispatchedAuthorFor = (history, data) => {
47
+ const attempt = typeof data.attempt === "number" && Number.isInteger(data.attempt)
48
+ ? data.attempt
49
+ : undefined;
50
+ if (attempt !== undefined) {
51
+ const matched = [...history.dispatches].reverse().find((dispatch) => dispatch.attempt === attempt);
52
+ if (matched)
53
+ return matched.author;
54
+ }
55
+ return history.dispatches.at(-1)?.author;
56
+ };
57
+ const runIdsWithJournals = (cwd) => {
58
+ const runsDir = join(cwd, stateDirName(cwd), "runs");
59
+ if (!existsSync(runsDir))
60
+ return [];
61
+ return readdirSync(runsDir, { withFileTypes: true })
62
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith("run-")
63
+ && existsSync(join(runsDir, entry.name, "journal.jsonl")))
64
+ .map((entry) => entry.name)
65
+ .sort();
66
+ };
67
+ /** Reduce every readable run journal under the repository's state directory. */
68
+ export function collectChannelStats(cwd = process.cwd()) {
69
+ const runIds = runIdsWithJournals(cwd);
70
+ const channels = new Map();
71
+ const channelFor = (author) => {
72
+ let channel = channels.get(author);
73
+ if (!channel) {
74
+ channel = {
75
+ author, reviewers: new Set(), dispatches: 0, deliveries: 0,
76
+ attemptsToGreen: 0, realReds: 0, infraReds: 0, rescues: new Set(),
77
+ };
78
+ channels.set(author, channel);
79
+ }
80
+ return channel;
81
+ };
82
+ for (const runId of runIds) {
83
+ const events = Journal.open(cwd, runId).read();
84
+ const tasks = new Map();
85
+ for (const [eventIndex, event] of events.entries()) {
86
+ if (!event.taskId)
87
+ continue;
88
+ const history = historyFor(tasks, event.taskId);
89
+ if (event.event === "task-dispatch") {
90
+ const author = assignmentAuthor(event.data);
91
+ if (!author)
92
+ continue;
93
+ channelFor(author).dispatches += 1;
94
+ history.dispatches.push({
95
+ author,
96
+ ...(typeof event.data.attempt === "number" && Number.isInteger(event.data.attempt)
97
+ ? { attempt: event.data.attempt }
98
+ : {}),
99
+ eventIndex,
100
+ });
101
+ continue;
102
+ }
103
+ if (event.event === "task-done") {
104
+ const author = assignmentAuthor(event.data) ?? history.dispatches.at(-1)?.author;
105
+ if (!author)
106
+ continue;
107
+ const channel = channelFor(author);
108
+ channel.deliveries += 1;
109
+ const recordedAttempts = typeof event.data.attempts === "number"
110
+ && Number.isInteger(event.data.attempts) && event.data.attempts >= 0
111
+ ? event.data.attempts
112
+ : history.dispatches.filter((dispatch) => dispatch.eventIndex < eventIndex).length;
113
+ channel.attemptsToGreen += recordedAttempts;
114
+ history.deliveries.push({ author, eventIndex });
115
+ continue;
116
+ }
117
+ if (event.event === "review-retry") {
118
+ const author = dispatchedAuthorFor(history, event.data);
119
+ if (!author)
120
+ continue;
121
+ for (const reviewer of [event.data.flaked, event.data.retried]) {
122
+ if (typeof reviewer === "string" && reviewer.trim())
123
+ channelFor(author).reviewers.add(reviewer.trim());
124
+ }
125
+ continue;
126
+ }
127
+ if (event.event !== "gate-result")
128
+ continue;
129
+ const author = dispatchedAuthorFor(history, event.data);
130
+ if (!author)
131
+ continue;
132
+ const channel = channelFor(author);
133
+ if (event.data.gate === "review") {
134
+ const reviewer = reviewerFrom(event.data);
135
+ if (reviewer)
136
+ channel.reviewers.add(reviewer);
137
+ }
138
+ const outcome = normalizeGateOutcome(event.data);
139
+ if (outcome.kind === "infra")
140
+ channel.infraReds += 1;
141
+ else if (outcome.kind === "failed")
142
+ channel.realReds += 1;
143
+ }
144
+ // A rescue is task-matched, not attempt-matched: one edge says the failed author and delivering
145
+ // author faced the same task in the same run. Repeated attempts on the failed author do not make
146
+ // the task easier and therefore do not manufacture extra controlled comparisons.
147
+ for (const [taskId, history] of tasks) {
148
+ for (const delivery of history.deliveries) {
149
+ const failedAuthors = new Set(history.dispatches
150
+ .filter((dispatch) => dispatch.eventIndex < delivery.eventIndex && dispatch.author !== delivery.author)
151
+ .map((dispatch) => dispatch.author));
152
+ for (const failedAuthor of failedAuthors) {
153
+ channelFor(delivery.author).rescues.add(`${failedAuthor} → ${delivery.author} (${runId}/${taskId})`);
154
+ }
155
+ }
156
+ }
157
+ }
158
+ return {
159
+ runs: runIds.length,
160
+ channels: [...channels.values()]
161
+ .sort((a, b) => a.author.localeCompare(b.author, "en"))
162
+ .map((channel) => ({
163
+ author: channel.author,
164
+ reviewers: [...channel.reviewers].sort((a, b) => a.localeCompare(b, "en")),
165
+ dispatches: channel.dispatches,
166
+ deliveries: channel.deliveries,
167
+ deliveryRate: channel.dispatches === 0 ? 0 : channel.deliveries / channel.dispatches,
168
+ attemptsToGreen: channel.deliveries === 0 ? null : channel.attemptsToGreen / channel.deliveries,
169
+ realReds: channel.realReds,
170
+ infraReds: channel.infraReds,
171
+ rescues: [...channel.rescues].sort((a, b) => a.localeCompare(b, "en")),
172
+ })),
173
+ };
174
+ }
175
+ const percent = (rate) => `${Number((rate * 100).toFixed(1))}%`;
176
+ export function renderStats(report) {
177
+ const lines = [`tickmarkr stats — ${report.runs} run${report.runs === 1 ? "" : "s"}`];
178
+ if (report.channels.length === 0)
179
+ return [...lines, "no channels"].join("\n");
180
+ lines.push(STATS_COLUMNS.join(" | "));
181
+ for (const channel of report.channels) {
182
+ lines.push([
183
+ channel.author,
184
+ channel.reviewers.join(", ") || "—",
185
+ channel.dispatches,
186
+ channel.deliveries,
187
+ percent(channel.deliveryRate),
188
+ channel.attemptsToGreen === null ? "—" : Number(channel.attemptsToGreen.toFixed(2)),
189
+ channel.realReds,
190
+ channel.infraReds,
191
+ channel.rescues.join("; ") || "—",
192
+ ].join(" | "));
193
+ }
194
+ return lines.join("\n");
195
+ }
196
+ export async function stats(argv, cwd = process.cwd()) {
197
+ if (argv.length > 0)
198
+ throw new Error("stats takes no run id — it reads every run in the state directory");
199
+ return renderStats(collectChannelStats(cwd));
200
+ }
@@ -7,12 +7,12 @@ import { formatOwnedName, parseOwnedName, } from "../../drivers/types.js";
7
7
  import { blockedTasks, graphDefinitionHash, loadGraph, stateDirName } from "../../graph/graph.js";
8
8
  import { GATE_NAMES } from "../../graph/schema.js";
9
9
  import { foldActivity } from "../../run/activity.js";
10
- import { Journal, engagementComparable, isQualityFailureParkKind, recordedTaskFailureKind, runHasEnded, } from "../../run/journal.js";
10
+ import { Journal, engagementComparable, isQualityFailureParkKind, preservedRefsByTask, recordedTaskFailureKind, runHasEnded, upheldFeedbackByTask, } from "../../run/journal.js";
11
11
  import { isPidLive } from "../../run/lock.js";
12
12
  import { normalizeGateOutcome } from "../../run/outcome.js";
13
13
  import { desiredPanes } from "../../run/reconcile.js";
14
14
  import { normalizeStallSnapshot } from "../../run/stall.js";
15
- import { armSupervision, readSupervision, supervisionText } from "../../run/supervision.js";
15
+ import { armWatchSupervision, readSupervision, supervisionText } from "../../run/supervision.js";
16
16
  import { deriveRunCockpitData, } from "../../tui/cockpit/derive.js";
17
17
  import { COCKPIT_COLUMN_FLOOR } from "../../tui/cockpit/layout.js";
18
18
  import { cellWidth, fitCells, wrapCells } from "../../tui/cockpit/width.js";
@@ -109,6 +109,15 @@ const ASCII_SPINNER = ["|", "/", "-", "\\"];
109
109
  const SAVE_TERMINAL_TITLE = "\x1b[22;0t";
110
110
  const RESTORE_TERMINAL_TITLE = "\x1b[23;0t";
111
111
  const decisionEvidence = (stateDir, runId, sequence) => `${stateDir}/runs/${runId}/journal.jsonl#L${sequence}`;
112
+ const DECISION_GATE_VERDICTS = {
113
+ passed: "passed",
114
+ failed: "failed",
115
+ skipped: "skipped",
116
+ declined: "skipped",
117
+ held: "unknown",
118
+ unavailable: "unknown",
119
+ infra: "unknown",
120
+ };
112
121
  // v1.79 T4: a deterministic JSONL projection over journal truth. Sequence/evidence come from the
113
122
  // append-only line position, timestamps and claims come from the row, and no watcher-local clock or
114
123
  // filesystem write participates. Re-reading the same bytes therefore returns the same event bytes.
@@ -126,13 +135,7 @@ export const decisionEventsFromJournal = (events, runId, stateDir = ".tickmarkr"
126
135
  return [{ ...base, type: "phase-change", tier: "routine", phase: event.data.phase }];
127
136
  }
128
137
  if (event.event === "gate-result" && typeof event.data.gate === "string") {
129
- const verdict = event.data.skipped === true
130
- ? "skipped"
131
- : event.data.pass === true
132
- ? "passed"
133
- : event.data.pass === false
134
- ? "failed"
135
- : "unknown";
138
+ const verdict = DECISION_GATE_VERDICTS[normalizeGateOutcome(event.data).kind];
136
139
  return [{
137
140
  ...base,
138
141
  type: "gate-verdict",
@@ -381,6 +384,13 @@ export const gateBox = (state, unicode) => {
381
384
  return state === "pass" ? "[x]" : state === "fail" ? "[!]" : state === "skip" ? "." : "[ ]";
382
385
  };
383
386
  export const defaultGateStates = (task) => GATE_NAMES.map((gate) => task.gates.includes(gate) ? "open" : "skip");
387
+ const GATE_OUTCOME_STATES = {
388
+ // The board state is a rendering vocabulary over the normalized outcome, not another field reader.
389
+ passed: "pass",
390
+ failed: "fail",
391
+ skipped: "skip",
392
+ declined: "skip",
393
+ };
384
394
  const gateSnapshot = (task, events, rehashAt) => {
385
395
  const outcomes = new Map();
386
396
  const start = attemptStartIdx(events, task.id);
@@ -389,12 +399,9 @@ const gateSnapshot = (task, events, rehashAt) => {
389
399
  const e = events[eventIndex];
390
400
  if (e.taskId !== task.id || e.event !== "gate-result" || typeof e.data.gate !== "string")
391
401
  continue;
392
- if (e.data.skipped === true)
393
- outcomes.set(e.data.gate, { state: "skip", eventIndex });
394
- else if (e.data.pass === true)
395
- outcomes.set(e.data.gate, { state: "pass", eventIndex });
396
- else if (e.data.pass === false)
397
- outcomes.set(e.data.gate, { state: "fail", eventIndex });
402
+ const state = GATE_OUTCOME_STATES[normalizeGateOutcome(e.data).kind];
403
+ if (state)
404
+ outcomes.set(e.data.gate, { state, eventIndex });
398
405
  }
399
406
  }
400
407
  return {
@@ -519,7 +526,7 @@ const foldTaskEffort = (tasks, events) => {
519
526
  task.dispatches += 1;
520
527
  else if (event.event === "gate-result" && event.data.gate === "review"
521
528
  && event.data.replayMeasurement !== true
522
- && REVIEW_VERDICTS.has(normalizeGateOutcome(event.data.outcome ?? event.data).kind))
529
+ && REVIEW_VERDICTS.has(normalizeGateOutcome(event.data).kind))
523
530
  task.reviews += 1;
524
531
  else if (event.event === "task-human")
525
532
  task.parks += 1;
@@ -979,6 +986,21 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
979
986
  if (!taskIds.has(taskId))
980
987
  phases.delete(taskId);
981
988
  const hotPhase = [...phases.values()].sort((a, b) => a.order - b.order).at(-1);
989
+ // OBS-738: recovery facts stay on the journal's two established reducers. The prefix fold keeps an
990
+ // older journal equally readable by asking upheldFeedbackByTask what was active at that exact
991
+ // restore boundary; current resume-restore rows additionally carry the same task for narration.
992
+ const preservedRefs = preservedRefsByTask(events);
993
+ const restoredUpheldFeedback = new Set(events.flatMap((event, index) => event.event === "resume-restore" && event.taskId
994
+ && upheldFeedbackByTask(events.slice(0, index)).has(event.taskId)
995
+ ? [event.taskId]
996
+ : []));
997
+ const recoveryLinesForTask = (taskId) => [
998
+ ...(preservedRefs.get(taskId) ?? []).flatMap(({ ref, diffCommand }) => [
999
+ `preserved worktree — ${taskId} — ${ref}`,
1000
+ ` ${diffCommand}`,
1001
+ ]),
1002
+ ...(restoredUpheldFeedback.has(taskId) ? [`upheld feedback restored for ${taskId}`] : []),
1003
+ ].map(sanitizeTaskText);
982
1004
  const cells = renderedTasks.map((t) => {
983
1005
  const folded = journalRowsOnly && comparable ? taskRows.get(t.id) : undefined;
984
1006
  const st = folded?.state ?? replayed?.get(t.id) ?? t.status;
@@ -1011,7 +1033,7 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
1011
1033
  if (!unicode) {
1012
1034
  // machine/CI surface — journals without phase-start stay byte-identical; new phase-aware frames
1013
1035
  // use an ASCII spinner so pipes never receive terminal-only braille/ANSI.
1014
- const rows = cells.map(({ t, st, merged, label, assignCol, livePhase, states, priorGraph, pane }) => {
1036
+ const rows = cells.flatMap(({ t, st, merged, label, assignCol, livePhase, states, priorGraph, pane }) => {
1015
1037
  const chain = gateChain(states, false);
1016
1038
  const prefix = livePhase ? ` ${ASCII_SPINNER[animationFrame % ASCII_SPINNER.length]} ${t.id} ` : ` ${surfaceTaskBox(st, merged)} ${t.id} `;
1017
1039
  const suffix = ` ${chain}${priorGraph ? ` ${PRIOR_GRAPH_MARKER}` : ""} ${livePhase ? "running" : surfaceStatusWord(st)}${label} ${assignCol}${pane ? ` pane ${pane}` : ""}`;
@@ -1020,7 +1042,10 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
1020
1042
  // can exceed the terminal width, and the pre-floor math paid for it out of the TITLE — 20 of
1021
1043
  // 41 rows in a live 41-task run named no task at all. These rows already overflow `width`
1022
1044
  // (a pane name is 60 columns on its own), so the floor costs wrapping, never the graph.
1023
- return `${prefix}${shortGoal(t.title, Math.max(MACHINE_TITLE_FLOOR, width - prefix.length - suffix.length))}${suffix}`;
1045
+ return [
1046
+ `${prefix}${shortGoal(t.title, Math.max(MACHINE_TITLE_FLOOR, width - prefix.length - suffix.length))}${suffix}`,
1047
+ ...recoveryLinesForTask(t.id).map((line) => ` ${line}`),
1048
+ ];
1024
1049
  });
1025
1050
  const zone = journalRowsOnly ? `${divider}zone ${localZoneLabel(zoneReference)}` : "";
1026
1051
  const header = runId
@@ -1210,6 +1235,12 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
1210
1235
  return [legend(headerRow), dim(ruleRow), ...rows];
1211
1236
  };
1212
1237
  const gatesLegend = legend(` gates ${GATE_NAMES.map((gate) => `${gate.slice(0, 2)} ${gate}`).join(" ")}`);
1238
+ const recoveryLines = cells.flatMap((cell) => recoveryLinesForTask(cell.t.id));
1239
+ const recoverySection = recoveryLines.length === 0 ? [] : [
1240
+ "",
1241
+ ` ${ok("▌")} ${title("RECOVERY")}`,
1242
+ ...recoveryLines.map((line) => legend(` ${line}`)),
1243
+ ];
1213
1244
  const effort = effortPanel(g.tasks, record && !comparable ? undefined : events, boardColumns);
1214
1245
  return {
1215
1246
  content: boardRows([
@@ -1220,6 +1251,7 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
1220
1251
  taskSection,
1221
1252
  "",
1222
1253
  ...tableRows(),
1254
+ ...recoverySection,
1223
1255
  gatesLegend,
1224
1256
  "",
1225
1257
  rule(boardColumns),
@@ -1383,7 +1415,7 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
1383
1415
  // it INVERTS it: armSupervision's interval outlives the board in any host that outlives one board, so
1384
1416
  // a dead board keeps writing and reads ARMED. That is the over-claiming direction, the one an operator
1385
1417
  // acts on, and the one this instrument exists to close.
1386
- const armed = bounded ? undefined : armSupervision(cwd, "watch", opts.supervisionBeatMs);
1418
+ const armed = bounded ? undefined : armWatchSupervision(cwd, opts.supervisionBeatMs);
1387
1419
  let titleSaved = false;
1388
1420
  const restoreTitle = () => {
1389
1421
  if (!titleSaved)
@@ -5,7 +5,7 @@ export type CommandResult = string | {
5
5
  };
6
6
  export type CommandMap = Record<string, (argv: string[]) => Promise<CommandResult>>;
7
7
  export declare const COMMANDS: CommandMap;
8
- export declare const USAGE = "tickmarkr \u2014 spec-driven orchestration harness for AI coding agents\nusage: tickmarkr <command>\n init guided setup + doctor; init --agent [--force] [--docs] adds agent skills/docs\n doctor re-probe adapters, herdr, auth; print capability matrix (--fix writes the test-runner ignore when a safe edit exists)\n fleet interactive fleet editor (fleet --print for CI drift checks)\n compile <src> spec \u2192 .tickmarkr/graph.json (fails without acceptance criteria)\n scope <intent> draft a compiled native spec beside an answered intent (--force to overwrite)\n plan dry-run routing table + cost estimate + floor lints\n eval run checked-in fixtures against every channel in isolated temp repos\n run execute the graph (--concurrency N --driver auto|herdr|subprocess|orca --route-strict; orca runs only when named)\n status live run state\n verify run the gate battery standalone against merge-base(--base, HEAD)..HEAD \u2014 no daemon, one verdict (--base main --criteria <file> | --task <id> [--files <glob>] [--author adapter:model] [--no-review] [--json])\n resume <id> continue a run from its journal\n report <id> cost/quality report (--md for committable execution record)\n profile show learned routing profile (profile reset = forget history via cursor, keeps telemetry)\n ui open the Fleet Studio TUI (full-screen tabbed cockpit)\n unlock remove a stale/garbage run lock (refuses if the holder is alive)\n beat <tier> record one supervision beat for orchestrator|orchestrator-context|overseer|overseer-context|watch, --seat <identity> required (--stand-down to hand off); a supervising seat's own watcher loop calls it, and status reads the tier STALE once the beats stop\n approve <id> <task> release a park (--uphold sides with the reviewer and funds a fixed attempt; --by <name> --reason <text>); takes effect on resume";
8
+ export declare const USAGE = "tickmarkr \u2014 spec-driven orchestration harness for AI coding agents\nusage: tickmarkr <command>\n init guided setup + doctor; init --agent [--force] [--docs] adds agent skills/docs\n doctor re-probe adapters, herdr, auth; print capability matrix (--fix writes the test-runner ignore when a safe edit exists)\n fleet interactive fleet editor (fleet --print for CI drift checks)\n compile <src> spec \u2192 .tickmarkr/graph.json (fails without acceptance criteria)\n scope <intent> draft a compiled native spec beside an answered intent (--force to overwrite)\n plan dry-run routing table + cost estimate + floor lints\n eval run checked-in fixtures against every channel in isolated temp repos\n run execute the graph (--concurrency N --driver auto|herdr|subprocess|orca --route-strict; orca runs only when named)\n status live run state\n stats all-run channel delivery, red, rescue, author and reviewer statistics\n verify run the gate battery standalone against merge-base(--base, HEAD)..HEAD \u2014 no daemon, one verdict (--base main --criteria <file> | --task <id> [--files <glob>] [--author adapter:model] [--no-review] [--json])\n resume <id> continue a run from its journal\n report <id> cost/quality report (--md for committable execution record)\n profile show learned routing profile (profile reset = forget history via cursor, keeps telemetry)\n ui open the Fleet Studio TUI (full-screen tabbed cockpit)\n unlock remove a stale/garbage run lock (refuses if the holder is alive)\n beat <tier> record one supervision beat for orchestrator|orchestrator-context|overseer|overseer-context|watch, --seat <identity> required (--stand-down to hand off); a supervising seat's own watcher loop calls it, and status reads the tier STALE once the beats stop\n approve <id> <task> release a park (--uphold sides with the reviewer and funds a fixed attempt; --by <name> --reason <text>); takes effect on resume";
9
9
  export declare function dispatch(cmd: string | undefined, argv: string[], commands?: CommandMap): Promise<{
10
10
  out: string;
11
11
  code: number;
package/dist/cli/index.js CHANGED
@@ -14,6 +14,7 @@ import { report } from "./commands/report.js";
14
14
  import { resume } from "./commands/resume.js";
15
15
  import { run } from "./commands/run.js";
16
16
  import { scope } from "./commands/scope.js";
17
+ import { stats } from "./commands/stats.js";
17
18
  import { status } from "./commands/status.js";
18
19
  import { ui } from "./commands/ui.js";
19
20
  import { unlock } from "./commands/unlock.js";
@@ -21,7 +22,7 @@ import { verify } from "./commands/verify.js";
21
22
  import { version } from "./commands/version.js";
22
23
  const normalize = (r) => typeof r === "string" ? { out: r, code: 0 } : r;
23
24
  export const COMMANDS = {
24
- init, doctor, fleet, compile, scope, plan, run, status, resume, report, profile, ui, unlock, approve, beat, version, verify, eval: evalCommand,
25
+ init, doctor, fleet, compile, scope, plan, run, status, stats, resume, report, profile, ui, unlock, approve, beat, version, verify, eval: evalCommand,
25
26
  };
26
27
  const VERSION_FLAGS = new Set(["version", "--version", "-v"]);
27
28
  const HELP_CMDS = new Set(["help", "-h", "--help"]);
@@ -37,6 +38,7 @@ usage: tickmarkr <command>
37
38
  eval run checked-in fixtures against every channel in isolated temp repos
38
39
  run execute the graph (--concurrency N --driver auto|herdr|subprocess|orca --route-strict; orca runs only when named)
39
40
  status live run state
41
+ stats all-run channel delivery, red, rescue, author and reviewer statistics
40
42
  verify run the gate battery standalone against merge-base(--base, HEAD)..HEAD — no daemon, one verdict (--base main --criteria <file> | --task <id> [--files <glob>] [--author adapter:model] [--no-review] [--json])
41
43
  resume <id> continue a run from its journal
42
44
  report <id> cost/quality report (--md for committable execution record)
@@ -10,6 +10,6 @@ export type PlanIR = {
10
10
  tasks: RunGraph["tasks"];
11
11
  };
12
12
  export type PlanFinalizationHook = (plan: PlanIR) => PlanIR;
13
- export declare function finalizePlan(plan: PlanIR, src: string): RunGraph;
13
+ export declare function finalizePlan(plan: PlanIR, src: string, repoRoot?: string): RunGraph;
14
14
  export declare function compileSource(src: string, type?: SourceType, root?: string, beforeFinalize?: PlanFinalizationHook): RunGraph;
15
15
  export {};
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { validateGraph } from "../graph/schema.js";
4
4
  import { taskUnitContractErrors } from "./collateral.js";
5
+ import { ownershipFindings, renderOwnershipFinding } from "./ownership.js";
5
6
  import { CompileError } from "./common.js";
6
7
  import { compileGsd, isGsdPhaseDir } from "./gsd.js";
7
8
  import { compileNative, TICKMARKR_NATIVE_MARKER } from "./native.js";
@@ -40,8 +41,8 @@ function enforceTaskUnitContract(g, src) {
40
41
  }
41
42
  return g;
42
43
  }
43
- export function finalizePlan(plan, src) {
44
- const graph = validateGraph({
44
+ export function finalizePlan(plan, src, repoRoot) {
45
+ const graph = enforceTaskUnitContract(validateGraph({
45
46
  version: plan.version,
46
47
  ...(plan.mode !== undefined ? { mode: plan.mode } : {}),
47
48
  spec: {
@@ -51,8 +52,16 @@ export function finalizePlan(plan, src) {
51
52
  ...(plan.base !== undefined ? { base: plan.base } : {}),
52
53
  },
53
54
  tasks: plan.tasks,
54
- });
55
- return enforceTaskUnitContract(graph, src);
55
+ }), src);
56
+ // overseer-217: report-only in 2.1.7. The 2.1.8 removal condition is one real authored-graph run
57
+ // plus a measured false-positive rate for the conventional source-name → test-name mapping.
58
+ // Until then this warning MUST NOT become a compile abort: a heuristic cannot deadlock compile.
59
+ if (repoRoot) {
60
+ for (const finding of ownershipFindings(graph.tasks, repoRoot)) {
61
+ console.warn(renderOwnershipFinding(finding));
62
+ }
63
+ }
64
+ return graph;
56
65
  }
57
66
  function compilePlan(src, type, root) {
58
67
  const kind = type ?? detect(src);
@@ -75,5 +84,5 @@ function compilePlan(src, type, root) {
75
84
  };
76
85
  }
77
86
  export function compileSource(src, type, root, beforeFinalize = (plan) => plan) {
78
- return finalizePlan(beforeFinalize(compilePlan(src, type, root)), src);
87
+ return finalizePlan(beforeFinalize(compilePlan(src, type, root)), src, root);
79
88
  }
@@ -0,0 +1,25 @@
1
+ import type { Task } from "../graph/schema.js";
2
+ export type OwnershipFinding = {
3
+ code: "unowned-test";
4
+ test: string;
5
+ taskIds: string[];
6
+ detail: string;
7
+ } | {
8
+ code: "test-path-outside-allowlist";
9
+ taskId: string;
10
+ test: string;
11
+ path: string;
12
+ detail: string;
13
+ } | {
14
+ code: "unordered-context-write";
15
+ taskId: string;
16
+ ownerTaskId: string;
17
+ path: string;
18
+ detail: string;
19
+ };
20
+ /**
21
+ * Advisory cross-task ownership check. Findings are data: callers may report them, but this checker
22
+ * never throws and never changes the graph.
23
+ */
24
+ export declare function ownershipFindings(tasks: readonly Task[], repoRoot: string): OwnershipFinding[];
25
+ export declare function renderOwnershipFinding(finding: OwnershipFinding): string;