tickmarkr 2.1.2 → 2.1.3

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.
@@ -209,7 +209,7 @@ export const claudeCode = {
209
209
  channels: (cfg) => channelsFromConfig("claude-code", cfg),
210
210
  // v1.65 T3: every flag the command builders below hardcode — doctor checks `claude --help` still
211
211
  // lists each (all present on claude 2.x, verified 2026-07-22). Advisory only, never routing.
212
- hardcodedFlags: { binary: "claude", flags: ["-p", "--model", "--permission-mode", "--strict-mcp-config", "--mcp-config", "--output-format", "-r"] },
212
+ hardcodedFlags: { binary: "claude", flags: ["-p", "--model", "--permission-mode", "--strict-mcp-config", "--mcp-config", "--output-format", "-r", "--prompt-suggestions"] },
213
213
  // --strict-mcp-config --mcp-config '{"mcpServers":{}}': pin the MCP surface to empty so fresh-worktree
214
214
  // workers/gates don't load project .mcp.json servers (herdr scrapes dialogs as idle — v1.4 incident,
215
215
  // memory tickmarkr-worker-mcp-dialog-stall). Live-verified 2026-07-10 on claude 2.1.205 (operator check):
@@ -223,10 +223,25 @@ export const claudeCode = {
223
223
  // HYG-03 / OBS-137: the residual first-entry dialog is workspace trust, not MCP config loading.
224
224
  // Claude's only store is global last-writer-wins ~/.claude.json, so tickmarkr still does not seed it;
225
225
  // the daemon safely answers only the exact adapter-declared dialog once per slot.
226
- interactiveCommand: (promptFile, model) => `claude --model ${shq(model)} --strict-mcp-config --mcp-config '{"mcpServers":{}}' --permission-mode bypassPermissions "$(cat ${shq(promptFile)})"`,
226
+ //
227
+ // v2.1.3 T7 --prompt-suggestions false: claude paints a PREDICTED next prompt into the editor after
228
+ // a turn. On a pane it is pixel-identical to a seat's own unsubmitted draft, so every occupancy
229
+ // reader downstream — the input-box occupiedMatch above, the stall watchdog that sweeps for a
230
+ // parked turn — reads the vendor's ghost text as work in progress and waits out its window on a
231
+ // worker that is idle. Suppressed at the SOURCE instead: the two builders that seed a real terminal
232
+ // are exactly these (interactive, resume); the -p builder has no editor to paint into and is out of
233
+ // scope. Live-checked against claude 2.1.246: the flag parses and `false` is an allowed choice
234
+ // ("Allowed choices are true, false, 1, 0, yes, no, on, off").
235
+ // Placement is NOT free: --mcp-config above is variadic and eats the next positional (2026-07-10
236
+ // live check ate the prompt), and --prompt-suggestions takes an OPTIONAL value — appended directly
237
+ // before the prompt it would swallow it the same way. So the setting's value is always followed by
238
+ // another flag, never by the prompt positional.
239
+ interactiveCommand: (promptFile, model) => `claude --model ${shq(model)} --strict-mcp-config --mcp-config '{"mcpServers":{}}' --prompt-suggestions false --permission-mode bypassPermissions "$(cat ${shq(promptFile)})"`,
227
240
  trustDialog: CLAUDE_TRUST_DIALOG,
228
241
  inputBox: CLAUDE_INPUT_BOX,
229
- resumeCommand: (sessionId, promptFile, model) => `claude -r ${shq(sessionId)} --model ${shq(model)} --strict-mcp-config --mcp-config '{"mcpServers":{}}' --permission-mode bypassPermissions "$(cat ${shq(promptFile)})"`,
242
+ // A resumed attempt lands in the same painted editor, so it carries the same ghost-text suppression
243
+ // and the same value-then-flag placement.
244
+ resumeCommand: (sessionId, promptFile, model) => `claude -r ${shq(sessionId)} --model ${shq(model)} --strict-mcp-config --mcp-config '{"mcpServers":{}}' --prompt-suggestions false --permission-mode bypassPermissions "$(cat ${shq(promptFile)})"`,
230
245
  invoke(task, _cwd, a, ctx) {
231
246
  return { command: this.headlessCommand(ctx.promptFile, a.model) };
232
247
  },
@@ -1,3 +1,9 @@
1
1
  import { type WorkerAdapter } from "./types.js";
2
2
  export declare function parsePiModels(raw: string): string[];
3
+ export interface ServedModelDrift {
4
+ pinned: string;
5
+ served: string;
6
+ }
7
+ export declare function readPiServedModels(): ServedModelDrift[];
8
+ export declare function servedModelNote(drifts?: ServedModelDrift[]): string;
3
9
  export declare const pi: WorkerAdapter;
@@ -32,6 +32,92 @@ export function parsePiModels(raw) {
32
32
  .map(([provider, model]) => `${provider}/${model}`)
33
33
  .filter((id) => MODEL_ID_RE.test(id));
34
34
  }
35
+ // Newest-first by mtime, capped — mtime picks WHICH files to scan, never a record's cursor.
36
+ // Shared by collectUsage (spend) and readPiServedModels (v2.1.3 T7): same store, same bounds.
37
+ function sessionFileStats(dir) {
38
+ return readdirSync(dir)
39
+ .filter((f) => f.endsWith(".jsonl"))
40
+ .map((f) => {
41
+ const path = join(dir, f);
42
+ try {
43
+ return { f: path, m: statSync(path).mtimeMs };
44
+ }
45
+ catch {
46
+ return undefined; // a stat failure just drops that file
47
+ }
48
+ })
49
+ .filter((x) => x !== undefined);
50
+ }
51
+ function newestSessionFiles(entries) {
52
+ return [...entries].sort((a, b) => b.m - a.m).slice(0, MAX_SESSION_FILES).map((x) => x.f);
53
+ }
54
+ // ponytail: three distinct pairs is the note's ceiling — raise it if a real store ever shows more.
55
+ const MAX_DRIFT_PAIRS = 3;
56
+ export function readPiServedModels() {
57
+ const root = join(homedir(), ".pi", "agent", "sessions");
58
+ let files;
59
+ try {
60
+ files = newestSessionFiles(readdirSync(root).flatMap((slug) => {
61
+ try {
62
+ return sessionFileStats(join(root, slug));
63
+ }
64
+ catch {
65
+ return []; // not a directory / unreadable ⇒ skip
66
+ }
67
+ }));
68
+ }
69
+ catch {
70
+ return []; // no store ⇒ nothing to report
71
+ }
72
+ const out = [];
73
+ const seen = new Set();
74
+ for (const file of files) {
75
+ let text;
76
+ try {
77
+ text = readFileSync(file, "utf8").slice(0, MAX_SESSION_BYTES);
78
+ }
79
+ catch {
80
+ continue;
81
+ }
82
+ for (const line of text.split("\n")) {
83
+ if (!line.trim())
84
+ continue;
85
+ let recRaw;
86
+ try {
87
+ recRaw = JSON.parse(line);
88
+ }
89
+ catch {
90
+ continue;
91
+ }
92
+ if (recRaw === null || typeof recRaw !== "object" || Array.isArray(recRaw))
93
+ continue;
94
+ const rec = recRaw;
95
+ if (rec.type !== "message" || rec.message?.role !== "assistant")
96
+ continue;
97
+ const pinned = rec.message?.model;
98
+ const served = rec.message?.responseModel;
99
+ // absent responseModel === the two agree. Never a mismatch, never an alarm.
100
+ if (typeof served !== "string" || typeof pinned !== "string" || served === pinned)
101
+ continue;
102
+ if (!MODEL_ID_RE.test(served) || !MODEL_ID_RE.test(pinned))
103
+ continue;
104
+ const key = `${pinned}\u0000${served}`;
105
+ if (seen.has(key))
106
+ continue;
107
+ seen.add(key);
108
+ out.push({ pinned, served });
109
+ if (out.length >= MAX_DRIFT_PAIRS)
110
+ return out;
111
+ }
112
+ }
113
+ return out;
114
+ }
115
+ // The probe's note clause, or "" when the store reports no discrepancy at all.
116
+ export function servedModelNote(drifts = readPiServedModels()) {
117
+ if (drifts.length === 0)
118
+ return "";
119
+ return `served-model drift: ${drifts.map((d) => `pinned ${d.pinned} served ${d.served}`).join(", ")}`;
120
+ }
35
121
  export const pi = {
36
122
  id: "pi",
37
123
  // FLEET-04: cross-vendor review honesty — GLM's provider (pi's own label is "zai"; either is
@@ -42,10 +128,17 @@ export const pi = {
42
128
  const h = probeVersion("pi");
43
129
  if (!h.installed)
44
130
  return h;
131
+ // The store is local and independently useful: preserve its advisory even when the network/auth
132
+ // capability call below fails. A failed model listing must not erase evidence of a substitution.
133
+ const drift = servedModelNote();
45
134
  const r = spawnSync("pi", ["--list-models"], { encoding: "utf8", timeout: 15000 });
46
- if (r.error || r.status !== 0)
47
- return h;
48
- return { ...h, servable: parsePiModels(r.stdout || ""), note: "auth verified via pi --list-models (free; auth-filtered by pi)" };
135
+ if (r.error || r.status !== 0) {
136
+ return drift ? { ...h, note: `${h.note ? `${h.note}; ` : ""}${drift}` } : h;
137
+ }
138
+ // v2.1.3 T7: the probe is the production caller that already reads this store, and doctor/fleet
139
+ // already render its note — so the served-model discrepancy is reported where it is already read.
140
+ const note = `auth verified via pi --list-models (free; auth-filtered by pi)${drift ? `; ${drift}` : ""}`;
141
+ return { ...h, servable: parsePiModels(r.stdout || ""), note };
49
142
  },
50
143
  channels: (cfg) => channelsFromConfig("pi", cfg),
51
144
  // v1.65 T3: every flag the command builders below hardcode — verified in `pi --help` 2026-07-22.
@@ -84,25 +177,13 @@ export const pi = {
84
177
  const real = realpathSync(cwd);
85
178
  const slug = "-" + real.replaceAll("/", "-") + "--";
86
179
  const dir = join(homedir(), ".pi", "agent", "sessions", slug);
87
- const files = readdirSync(dir)
88
- .filter((f) => f.endsWith(".jsonl"))
89
- .map((f) => {
90
- try {
91
- return { f, m: statSync(join(dir, f)).mtimeMs };
92
- }
93
- catch {
94
- return undefined;
95
- }
96
- })
97
- .filter((x) => x !== undefined)
98
- .sort((a, b) => b.m - a.m)
99
- .slice(0, MAX_SESSION_FILES);
180
+ const files = newestSessionFiles(sessionFileStats(dir));
100
181
  let input = 0, output = 0, kept = false;
101
182
  let cacheRead, cacheWrite;
102
- for (const { f } of files) {
183
+ for (const f of files) {
103
184
  let text;
104
185
  try {
105
- text = readFileSync(join(dir, f), "utf8").slice(0, MAX_SESSION_BYTES);
186
+ text = readFileSync(f, "utf8").slice(0, MAX_SESSION_BYTES);
106
187
  }
107
188
  catch {
108
189
  continue;
@@ -20,31 +20,51 @@ import { SUPERVISION_BEAT_MS, SUPERVISION_STALE_MS, SUPERVISION_TIERS, beatSuper
20
20
  // evidence: stop calling it and the tier ages into STALE on its own.
21
21
  export async function beat(argv, cwd = process.cwd()) {
22
22
  const standDown = argv.includes("--stand-down");
23
- const named = argv.find((a) => !a.startsWith("--"));
23
+ const seat = seatOf(argv);
24
+ const named = argv.find((a, i) => !a.startsWith("--") && argv[i - 1] !== "--seat");
24
25
  if (!isTier(named)) {
25
- throw new Error(`usage: tickmarkr beat <${SUPERVISION_TIERS.join("|")}> [--stand-down] — got ${named ? `\`${named}\`` : "no tier"}`);
26
+ throw new Error(`usage: tickmarkr beat <${SUPERVISION_TIERS.join("|")}> --seat <identity> [--stand-down] — got ${named ? `\`${named}\`` : "no tier"}`);
27
+ }
28
+ // 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
31
+ // attribute to a seat. Measured 2026-08-26: a consult seat ran the documented loop verbatim and the
32
+ // board read that tier ARMED with no seat of that tier having armed anything, and a seatless ARMED
33
+ // reads as coverage — worse than ABSENT, because ABSENT sends someone to look.
34
+ if (!seat) {
35
+ throw new Error(`${named} needs --seat <identity> — a beat that names no seat arms a tier nobody occupies` +
36
+ " (pass the seat's own pane id or agent name)");
26
37
  }
27
38
  if (standDown)
28
- return standDownTier(cwd, named);
39
+ return standDownTier(cwd, named, seat);
29
40
  // Clear a stand-down marker left by an earlier session BEFORE beating: a valid marker outranks every
30
41
  // beat that does not strictly follow it, and two writes landing in the same millisecond do not. An
31
42
  // uncleared marker would render DISARMED while this verb claimed ARMED, so the removal is unguarded
32
43
  // too — `force` makes the ordinary "no marker" case a no-op, and anything else is a real failure.
33
44
  rmSync(supervisionStandDownPath(cwd, named), { force: true, recursive: true });
34
- beatSupervision(cwd, named);
35
- return `${named} ARMED — beat again every ${SUPERVISION_BEAT_MS / 1_000}s; the tier reads STALE ${SUPERVISION_STALE_MS / 1_000}s after the last beat`;
45
+ beatSupervision(cwd, named, seat);
46
+ 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
+ }
48
+ /** `--seat <identity>` or `--seat=<identity>`; blank and missing are the same answer — none. */
49
+ 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();
53
+ return seat && !seat.startsWith("--") ? seat : undefined;
36
54
  }
37
55
  // Stand-down is a RECORDED act, not a silence: the marker tells a reader this watcher left on purpose,
38
56
  // so the tier reads DISARMED rather than ageing out as a death. Published atomically — written aside,
39
57
  // renamed over — because a torn marker is rejected by the reader, and a rejected stand-down reports a
40
58
  // deliberate hand-off as a death.
41
- function standDownTier(repoRoot, tier) {
59
+ function standDownTier(repoRoot, tier, seat) {
42
60
  tickmarkrDir(repoRoot); // the write path DOES create — markers land inside the gitignored state dir
43
61
  const p = supervisionStandDownPath(repoRoot, tier);
44
62
  const tmp = `${p}.${process.pid}.tmp`;
45
63
  mkdirSync(dirname(p), { recursive: true });
46
- writeFileSync(tmp, JSON.stringify({ tier, pid: process.pid, disarmedAt: new Date().toISOString() }) + "\n");
64
+ // The marker names the seat for the same reason the beat does: "someone stood this tier down" is not
65
+ // 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");
47
67
  renameSync(tmp, p);
48
- return `${tier} DISARMED — hand-off recorded; status reads it stood down, not dead`;
68
+ return `${tier} DISARMED — ${seat} handed off; status reads it stood down, not dead`;
49
69
  }
50
70
  const isTier = (v) => SUPERVISION_TIERS.includes(v ?? "");
@@ -109,7 +109,7 @@ Before \`tickmarkr compile\` or \`tickmarkr run\`: run \`tickmarkr version\`, re
109
109
 
110
110
  ### Tip-verify-before-green
111
111
 
112
- A run is green only when the run-end event exists in the journal AND tip verify is not "failed". Never report green to the operator, tab titles, or records until both hold.
112
+ A run is green only when ALL of these hold: the run-end event exists in the journal, tip verify is not "failed", AND the run-end summary's \`failed\`, \`human\`, \`blocked\` and \`pending\` buckets are every one of them empty. The shorter "run-end plus tip verify" form is NOT sufficient: a run ending \`done=[T1,T3,T4] human=[T2] tipVerify=passed\` is a closed PARTIAL run — three of four delivered, one parked — and calling it green is how a park becomes invisible. Never report green to the operator, tab titles, or records until every clause holds; when one does not, name the bucket that is not empty.
113
113
 
114
114
  ### Verified handoffs
115
115
 
@@ -203,6 +203,24 @@ function skillDrift(cwd) {
203
203
  }
204
204
  // Name what drifted. "stale" alone sends the reader to diff three directories by hand, and the file that
205
205
  // matters is usually the one they never knew shipped.
206
+ // The GENERATED GUIDANCE BLOCK goes stale by exactly the shape skillDrift exists for: the package
207
+ // moves, the rendered copy does not, and nothing in the repo changes to hint at it. Worse than a skill,
208
+ // because a doc the host reads as law can be gitignored — this project's own AGENTS.md kept two
209
+ // superseded preflight rules (patch-version drift tolerated, a machine-wide process match required)
210
+ // through the very release that fixed the tracked copy, and a consultation was launched under the stale
211
+ // text with nobody able to see it. Same rule as skillDrift: a doc that was never written is not drift.
212
+ const docsDrift = (cwd) => hostTargets(cwd).map(({ docPath }) => docPath).filter((docPath) => {
213
+ if (!existsSync(docPath))
214
+ return false;
215
+ const current = readFileSync(docPath, "utf8");
216
+ const begin = current.indexOf(DOCS_BEGIN);
217
+ if (begin < 0)
218
+ return false; // no block installed here — --docs writes one, that is not drift
219
+ const end = current.indexOf(DOCS_END, begin);
220
+ if (end < 0)
221
+ return true; // a truncated block is stale by definition
222
+ return current.slice(begin, end + DOCS_END.length) !== AGENT_DOCS.trimEnd();
223
+ });
206
224
  const describeDrift = (d) => [
207
225
  d.missing.length ? `missing ${d.missing.join(", ")}` : "",
208
226
  d.modified.length ? `modified ${d.modified.join(", ")}` : "",
@@ -361,6 +379,15 @@ export async function init(argv, cwd = process.cwd(), io = {}) {
361
379
  notes.push(`stale ${join(d.dir, "SKILL.md")} — ${describeDrift(d)}; run tickmarkr init --agent --force to refresh it`);
362
380
  }
363
381
  }
382
+ // Same reasoning, one layer up — and the refresh command is `--agent --force`, the same one the skill
383
+ // note names. `--docs` governs only the APPEND path for a repo with no block yet; replacing an existing
384
+ // block is gated on force (see the marked-region branch in installAgentFiles). Naming `--docs` here
385
+ // would send the reader to a flag that silently does nothing for the case they are in.
386
+ if (!(values.agent && values.force)) {
387
+ for (const docPath of docsDrift(cwd)) {
388
+ notes.push(`stale ${docPath} — its tickmarkr guidance block does not match this version; run tickmarkr init --agent --force to refresh it`);
389
+ }
390
+ }
364
391
  const input = io.input ?? process.stdin;
365
392
  const output = io.output ?? process.stdout;
366
393
  const interactive = input.isTTY === true && output.isTTY === true && !(values.yes ?? false);
@@ -8,6 +8,7 @@ import { blockedTasks, graphDefinitionHash, loadGraph, stateDirName } from "../.
8
8
  import { GATE_NAMES } from "../../graph/schema.js";
9
9
  import { foldActivity } from "../../run/activity.js";
10
10
  import { Journal, engagementComparable, isQualityFailureParkKind, recordedTaskFailureKind, runHasEnded, } from "../../run/journal.js";
11
+ import { isPidLive } from "../../run/lock.js";
11
12
  import { normalizeGateOutcome } from "../../run/outcome.js";
12
13
  import { desiredPanes } from "../../run/reconcile.js";
13
14
  import { normalizeStallSnapshot } from "../../run/stall.js";
@@ -617,6 +618,12 @@ const daemonPid = (events) => {
617
618
  }
618
619
  return undefined;
619
620
  };
621
+ const daemonLiveness = (events) => {
622
+ const pid = daemonPid(events);
623
+ if (pid === undefined)
624
+ return { state: "unknown" };
625
+ return { state: isPidLive(pid) ? "alive" : "dead", pid };
626
+ };
620
627
  const terminalFailureCause = (events) => {
621
628
  for (let i = events.length - 1; i >= 0; i--) {
622
629
  const e = events[i];
@@ -792,27 +799,19 @@ const keepPanesSetting = (cwd) => {
792
799
  return DEFAULT_CONFIG.visibility.keepPanes;
793
800
  }
794
801
  };
795
- const liveness = (events, now = Date.now()) => {
802
+ const liveness = (events, daemon, now = Date.now()) => {
796
803
  const last = events.at(-1);
797
804
  if (!last)
798
805
  return "last event unknown · daemon pid unknown";
799
806
  const age = fmtAge(now - Date.parse(last.ts));
800
- const pid = daemonPid(events);
801
807
  const cause = terminalFailureCause(events);
802
- if (pid === undefined)
808
+ if (daemon.state === "unknown")
803
809
  return `last event ${age} ago · daemon pid unknown${cause ? ` · ${cause}` : ""}`;
804
810
  // a dead pid after run-end is a clean exit, not a crash — "dead" is only alarming (red) while
805
811
  // the run is still incomplete (operator's crash indicator)
806
812
  const ended = events.some((e) => e.event === "run-end");
807
- let state;
808
- try {
809
- process.kill(pid, 0);
810
- state = "alive";
811
- } // no throw ⇒ alive
812
- catch (k) {
813
- state = k.code === "ESRCH" ? (ended ? "finished" : "dead") : "alive";
814
- } // EPERM ⇒ alive
815
- return `last event ${age} ago · daemon pid ${pid} ${state}${cause ? ` · ${cause}` : ""}`;
813
+ const state = daemon.state === "alive" ? "alive" : ended ? "finished" : "dead";
814
+ return `last event ${age} ago · daemon pid ${daemon.pid} ${state}${cause ? ` · ${cause}` : ""}`;
816
815
  };
817
816
  // Resume's shared comparator remains the fail-closed baseline. Status may additionally accept the
818
817
  // daemon's audited graph-rehash release, but only when its `from` names that baseline identity (or
@@ -899,6 +898,9 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
899
898
  const record = readRunRecord(cwd, g, namedRunId);
900
899
  const runId = record?.runId;
901
900
  const events = record?.events ?? [];
901
+ // The header and every live-phase consumer share this exact process reading. Proved death retires
902
+ // stale phase markers; unknown identity deliberately does not, preserving pre-identity journals.
903
+ const daemon = daemonLiveness(events);
902
904
  const comparable = record?.comparable ?? false;
903
905
  const rehashAt = record?.rehashAt;
904
906
  const assignments = new Map();
@@ -972,7 +974,7 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
972
974
  ? workerPaneLocators(events, runId, keepPanesSetting(cwd))
973
975
  : new Map();
974
976
  const taskIds = new Set(g.tasks.map((task) => task.id));
975
- const phases = comparable ? livePhases(events) : new Map();
977
+ const phases = comparable && daemon.state !== "dead" ? livePhases(events) : new Map();
976
978
  for (const taskId of phases.keys())
977
979
  if (!taskIds.has(taskId))
978
980
  phases.delete(taskId);
@@ -1022,7 +1024,7 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
1022
1024
  });
1023
1025
  const zone = journalRowsOnly ? `${divider}zone ${localZoneLabel(zoneReference)}` : "";
1024
1026
  const header = runId
1025
- ? `tickmarkr status${divider}run ${runId}${zone}${supersededBy ? `${divider}superseded by ${supersededBy}` : ""}${!comparable ? `${divider}${NOT_COMPARABLE_NOTICE}` : ""}${divider}${liveness(events, now).replaceAll(" · ", divider)}${verify ? `${divider}${verify}` : ""}${divider}${tipPhase ? `${done}/${total} tasks done${divider}run not verified` : `${done}/${total} done`}`
1027
+ ? `tickmarkr status${divider}run ${runId}${zone}${supersededBy ? `${divider}superseded by ${supersededBy}` : ""}${!comparable ? `${divider}${NOT_COMPARABLE_NOTICE}` : ""}${divider}${liveness(events, daemon, now).replaceAll(" · ", divider)}${verify ? `${divider}${verify}` : ""}${divider}${tipPhase ? `${done}/${total} tasks done${divider}run not verified` : `${done}/${total} done`}`
1026
1028
  : `tickmarkr status${zone}${divider}no runs yet${divider}${done}/${total} done`;
1027
1029
  const legendLine = ` gates: ${GATE_NAMES.map((gate) => `${GATE_KEYS[gate]} ${gate}`).join(divider)}`;
1028
1030
  return {
@@ -1046,7 +1048,7 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
1046
1048
  const tally = tipPhase
1047
1049
  ? `${progressTone(`${progressGlyph} ${done}/${total} tasks done`)}${dot}${progressTone("run not verified")}`
1048
1050
  : done === total && total > 0 ? ok(`${done}/${total} done`) : information(`${done}/${total} done`);
1049
- const live = liveness(events, now)
1051
+ const live = liveness(events, daemon, now)
1050
1052
  .replace(/\bdead\b/u, fail("dead"))
1051
1053
  .replace(/\bfinished\b/u, dim("finished"))
1052
1054
  .replace(/\balive\b/u, running("alive"))
@@ -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|overseer|watch (--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 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
@@ -43,7 +43,7 @@ usage: tickmarkr <command>
43
43
  profile show learned routing profile (profile reset = forget history via cursor, keeps telemetry)
44
44
  ui open the Fleet Studio TUI (full-screen tabbed cockpit)
45
45
  unlock remove a stale/garbage run lock (refuses if the holder is alive)
46
- beat <tier> record one supervision beat for orchestrator|overseer|watch (--stand-down to hand off); a supervising seat's own watcher loop calls it, and status reads the tier STALE once the beats stop
46
+ 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
47
47
  approve <id> <task> release a park (--uphold sides with the reviewer and funds a fixed attempt; --by <name> --reason <text>); takes effect on resume`;
48
48
  // pure, testable dispatcher: resolves a command, forwards argv, shapes the result — no side effects.
49
49
  // unknown/missing cmd → USAGE (exit 1 if a cmd was typed, 0 for bare `tickmarkr`); a handler throw becomes
@@ -12,6 +12,12 @@ export interface BaselineCommand {
12
12
  missingCommand?: boolean;
13
13
  /** What this command actually took at capture, on a pristine tree. Absent in pre-v1.90 baselines. */
14
14
  durationMs?: number;
15
+ /** Sum of the per-file durations named by the runner; null when its output names none. */
16
+ fileDurationSumMs?: number | null;
17
+ /** fileDurationSumMs / durationMs — average implied file concurrency, not a configured fork count. */
18
+ impliedParallelism?: number | null;
19
+ /** The slowest per-file entry named by the runner; null when per-file timing is unavailable. */
20
+ longestFile?: BaselineFileDuration | null;
15
21
  /** The ceiling that measurement implies, persisted so every later battery uses the same number. */
16
22
  ceilingMs?: number;
17
23
  /**
@@ -20,6 +26,10 @@ export interface BaselineCommand {
20
26
  */
21
27
  infra?: true;
22
28
  }
29
+ export interface BaselineFileDuration {
30
+ file: string;
31
+ durationMs: number;
32
+ }
23
33
  export interface Baseline {
24
34
  commands: Record<string, BaselineCommand>;
25
35
  warnings?: BaselineWarning[];
@@ -24,6 +24,12 @@ const SUMMARY_FAIL_RE = /^\s*(?:Tests?\s+(?:Files?\s+)?(?:\d+|#)\s+failed|#\s+fa
24
24
  const ERROR_ANCHOR_RE = /^\s*(?:Error|[A-Za-z_$][\w$]*Error):\s+\S/;
25
25
  const TSC_ERROR_RE = /^\s*\S.*\((?:\d+|#),(?:\d+|#)\):\s+error\s+[A-Z]+(?:\d+|#):/i; // tsc
26
26
  const LINTER_ERROR_RE = /^\s*(?:\d+|#):(?:\d+|#)\s+error\s+\S/; // eslint stylish
27
+ // T5: package.json selecting oxlint's stylish format is this repository's local remedy because the
28
+ // existing LINTER_ERROR_RE already reads it. PATH_FIRST_LINTER_ERROR_RE is the shipped fix: it reads
29
+ // the default `path:line:column: error …` form for every user without requiring their script to change.
30
+ // Keep the path at line start — accepting a position mid-sentence turns ordinary runner prose into a
31
+ // manufactured fresh failure.
32
+ const PATH_FIRST_LINTER_ERROR_RE = /^\s*\S+:(?:\d+|#):(?:\d+|#):\s+error\b(?:\s+\S)?/;
27
33
  // The failure shapes non-Vitest runners name their tests with: pytest's short summary
28
34
  // (`FAILED tests/t.py::test_x - AssertionError`, `ERROR tests/t.py::fixture`), go test
29
35
  // (`--- FAIL: TestFoo (0.00s)`), TAP / node:test (`not ok 1 - name`, whose summary line lands in
@@ -97,7 +103,7 @@ const namesFailureEitherForm = (l) => {
97
103
  return stripped !== undefined && namesFailure(stripped);
98
104
  };
99
105
  const isFailureShaped = (l) => namesFailure(l) || SUMMARY_FAIL_RE.test(l) || ERROR_ANCHOR_RE.test(l)
100
- || TSC_ERROR_RE.test(l) || LINTER_ERROR_RE.test(l) || X_GLYPH_FAIL_RE.test(l);
106
+ || TSC_ERROR_RE.test(l) || LINTER_ERROR_RE.test(l) || PATH_FIRST_LINTER_ERROR_RE.test(l) || X_GLYPH_FAIL_RE.test(l);
101
107
  const VOCAB_RE = /\b(?:error|fail(?:ed|ure|ing)?)\b/i;
102
108
  // T9 — the infra/regression discriminator. A runner that died because the MACHINE ran out of
103
109
  // processes, file descriptors or memory never finished asking the question, so its nonzero exit is
@@ -128,6 +134,33 @@ export function classifyFailureOutput(output) {
128
134
  return lines.some(isInfraLine) ? "infra" : undefined;
129
135
  }
130
136
  const normalizeLine = (l) => l.replace(/\d+/g, "#").replace(/\s+/g, " ").trim();
137
+ // Vitest's default reporter names file durations as
138
+ // `✓ |project| tests/example.test.ts (12 tests) 1.23s` (❯ for a red file). The runner may be
139
+ // turbo-prefixed, but a test-name timing has no parenthesized file tally and therefore cannot enter
140
+ // this measurement. These are observations of runner output, not a promise that every runner exposes
141
+ // them — callers record null, never zero, when no line matches.
142
+ const FILE_DURATION_RE = /^\s*(?:(?:[\w@./-]+:\s*)*)[✓✔×❯]\s+(?:\|[^|\r\n]+\|\s+)?(\S+)\s+\([^\r\n)]*\)\s+(\d+(?:\.\d+)?)\s*(ms|s|m)\b/;
143
+ const durationUnitMs = (unit) => unit === "m" ? 60_000 : unit === "s" ? 1_000 : 1;
144
+ function fileTiming(output, wallClockMs) {
145
+ const files = [];
146
+ for (const line of output.split("\n")) {
147
+ const match = FILE_DURATION_RE.exec(line.replace(ANSI_RE, ""));
148
+ if (!match)
149
+ continue;
150
+ const durationMs = Number(match[2]) * durationUnitMs(match[3]);
151
+ if (Number.isFinite(durationMs))
152
+ files.push({ file: match[1], durationMs });
153
+ }
154
+ if (!files.length)
155
+ return { fileDurationSumMs: null, impliedParallelism: null, longestFile: null };
156
+ const fileDurationSumMs = files.reduce((sum, entry) => sum + entry.durationMs, 0);
157
+ const longestFile = files.reduce((longest, entry) => entry.durationMs > longest.durationMs ? entry : longest);
158
+ return {
159
+ fileDurationSumMs,
160
+ impliedParallelism: wallClockMs > 0 ? fileDurationSumMs / wallClockMs : null,
161
+ longestFile,
162
+ };
163
+ }
131
164
  // A failing command whose output holds no shape any runner here names. The marker is content-free and
132
165
  // constant: downstream consumers (tip verify journals fingerprint counts) still see that the command
133
166
  // failed, while no line of the output — narration, status strip, stack trace — can enter the set. Being
@@ -363,16 +396,26 @@ export async function captureBaseline(cwd, commands) {
363
396
  console.error(`tickmarkr: baseline capture for "${name}" was killed at its ${CAPTURE_CEILING_MS}ms ceiling — `
364
397
  + `it recorded NO fingerprints, so nothing is forgiven and every gate will treat a pre-existing `
365
398
  + `failure as a fresh one. Raise the ceiling or shorten the command.`);
366
- base.commands[name] = { infra: true, fingerprints: [], durationMs, ceilingMs: effectiveCeilingMs({ durationMs }) };
399
+ base.commands[name] = {
400
+ infra: true,
401
+ fingerprints: [],
402
+ durationMs,
403
+ fileDurationSumMs: null,
404
+ impliedParallelism: null,
405
+ longestFile: null,
406
+ ceilingMs: effectiveCeilingMs({ durationMs }),
407
+ };
367
408
  continue;
368
409
  }
410
+ const raw = (r.stdout + "\n" + r.stderr).split(cwd).join("");
369
411
  base.commands[name] = {
370
412
  exitCode: r.code,
371
413
  // a command that exits 0 has no failures to fingerprint — recording any would be a lie the
372
414
  // compare step then has to forgive
373
- fingerprints: r.code === 0 ? [] : fingerprint((r.stdout + "\n" + r.stderr).split(cwd).join("")),
415
+ fingerprints: r.code === 0 ? [] : fingerprint(raw),
374
416
  missingCommand: missingConfiguredCommand(cmd, r),
375
417
  durationMs,
418
+ ...fileTiming(raw, durationMs),
376
419
  ceilingMs: effectiveCeilingMs({ durationMs }),
377
420
  };
378
421
  }
package/dist/run/git.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { spawn } from "node:child_process";
1
2
  import { ROUTING_ENV_SEAMS } from "../route/router.js";
2
3
  export { ROUTING_ENV_SEAMS };
3
4
  export declare const FORK_CAP_ENV = "VITEST_MAX_FORKS";
@@ -42,6 +43,10 @@ export interface ShResult {
42
43
  timedOut?: boolean;
43
44
  durationMs?: number;
44
45
  }
46
+ export declare const setSpawnForTests: (fn: typeof spawn) => void;
47
+ export declare const resetSpawnForTests: () => void;
48
+ export declare const SPAWN_ATTEMPT_LIMIT = 4;
49
+ export declare const SPAWN_RETRY_BACKOFF_MS = 50;
45
50
  export declare function sh(cmd: string, cwd: string, timeoutMs?: number): Promise<ShResult>;
46
51
  export declare function shGit(cmd: string, cwd: string, timeoutMs?: number): Promise<ShResult>;
47
52
  export declare function shOk(cmd: string, cwd: string): Promise<string>;
package/dist/run/git.js CHANGED
@@ -64,6 +64,31 @@ export const runWithForkBudget = (concurrency, fn) => forkBudget.run(String(deri
64
64
  export const resolvedForkCap = () => forkBudget.getStore() ?? DEFAULT_FORK_CAP;
65
65
  /** The shipped shell ceiling: the fallback every caller gets when nothing measured a better one. */
66
66
  export const DEFAULT_SHELL_TIMEOUT_MS = 600000;
67
+ /**
68
+ * OBS-688: every gate, every baseline capture and every tip verification reaches the machine through
69
+ * this one seam, and the seam took its spawn from the standard library directly — so the one failure
70
+ * it must handle, the kernel REFUSING the fork, was unreachable from a fixture and could only be
71
+ * described. It is injectable here for exactly that reason; production always holds `spawn` itself.
72
+ * Undefined-until-set, never captured at module load: the standard library binding stays read at
73
+ * CALL time, exactly as before this seam existed, so a suite that mocks `node:child_process` without
74
+ * a `spawn` export still imports this module (tests/adapters/pi-auth.test.ts does).
75
+ */
76
+ let spawnChild;
77
+ export const setSpawnForTests = (fn) => { spawnChild = fn; };
78
+ export const resetSpawnForTests = () => { spawnChild = undefined; };
79
+ /**
80
+ * A refusal is NOT evidence about the work: the command never started, so nothing ran, and a retry
81
+ * cannot repeat a side effect. That argument is the whole safety case for retrying here, and it
82
+ * holds for exactly one closed case — the kernel refused the fork for a temporary resource shortage
83
+ * (EAGAIN: the fork table is full, which a gate burst does to a box twice in one night). Every
84
+ * other spawn error is a standing fact about the machine — a missing interpreter above all — and
85
+ * retrying it buys nothing while delaying every genuine failure by the whole backoff, so it returns
86
+ * on the first read. The retry is also gated on the child having produced NO byte and never having
87
+ * emitted `spawn`: past either, a command has run and re-running it is a side effect, never a retry.
88
+ */
89
+ const RETRYABLE_SPAWN_CODE = "EAGAIN";
90
+ export const SPAWN_ATTEMPT_LIMIT = 4;
91
+ export const SPAWN_RETRY_BACKOFF_MS = 50;
67
92
  // stdin "ignore": same class as HARD-05 / SubprocessDriver — never leave an open pipe a child can block on
68
93
  // (pi -p / codex exec wait for stdin EOF). timedOut distinguishes SIGKILL-timeout from a real nonzero exit.
69
94
  function shell(cmd, cwd, timeoutMs, login) {
@@ -77,14 +102,14 @@ function shell(cmd, cwd, timeoutMs, login) {
77
102
  // OBS-110: apply the run's own fork cap only when the operator has not already set one.
78
103
  if (!(FORK_CAP_ENV in env))
79
104
  env[FORK_CAP_ENV] = resolvedForkCap();
80
- return new Promise((resolve) => {
105
+ const attempt = () => new Promise((resolve) => {
81
106
  const startedAt = Date.now();
82
107
  // detached: bash gets its own process group so a timeout can kill the whole tree —
83
108
  // SIGKILLing bash alone orphans grandchildren (codex/pi) that hold the stdio pipes
84
109
  // open, so "close" never fires and the promise wedges forever (v1.33.1 init hang).
85
- const p = spawn("bash", [login ? "-lc" : "-c", cmd], { cwd, env, stdio: ["ignore", "pipe", "pipe"], detached: true });
110
+ const p = (spawnChild ?? spawn)("bash", [login ? "-lc" : "-c", cmd], { cwd, env, stdio: ["ignore", "pipe", "pipe"], detached: true });
86
111
  let stdout = "", stderr = "";
87
- let timedOut = false, done = false;
112
+ let timedOut = false, done = false, started = false;
88
113
  const finish = (code, err) => {
89
114
  if (done)
90
115
  return;
@@ -101,14 +126,37 @@ function shell(cmd, cwd, timeoutMs, login) {
101
126
  p.kill("SIGKILL");
102
127
  }
103
128
  }, timeoutMs);
129
+ p.on("spawn", () => { started = true; }); // the command exists from here on — never retryable past it
104
130
  p.stdout.on("data", (d) => (stdout += d));
105
131
  p.stderr.on("data", (d) => (stderr += d));
106
- p.on("error", (e) => finish(127, String(e)));
132
+ p.on("error", (e) => {
133
+ if (!done && !started && !stdout && !stderr && e.code === RETRYABLE_SPAWN_CODE) {
134
+ done = true;
135
+ clearTimeout(timer);
136
+ resolve({ refused: e });
137
+ return;
138
+ }
139
+ finish(127, String(e));
140
+ });
107
141
  p.on("close", (code) => finish(code ?? 1));
108
142
  // "close" waits for stdio to drain; a surviving pipe-holder must not outlive the timeout
109
143
  p.on("exit", (code) => { if (timedOut)
110
144
  finish(code ?? 1); });
111
145
  });
146
+ return (async () => {
147
+ const startedAt = Date.now();
148
+ for (let n = 1;; n++) {
149
+ const r = await attempt();
150
+ if (!("refused" in r))
151
+ return r;
152
+ // Bounded, and the bound is what makes a persisting shortage a REPORTED failure rather than a
153
+ // wedged daemon: past it the caller gets the refusal's own text under exit 127, as before.
154
+ if (n >= SPAWN_ATTEMPT_LIMIT) {
155
+ return { code: 127, stdout: "", stderr: String(r.refused), durationMs: Date.now() - startedAt };
156
+ }
157
+ await new Promise((wake) => setTimeout(wake, SPAWN_RETRY_BACKOFF_MS * n));
158
+ }
159
+ })();
112
160
  }
113
161
  export function sh(cmd, cwd, timeoutMs = DEFAULT_SHELL_TIMEOUT_MS) {
114
162
  return shell(cmd, cwd, timeoutMs, true);