tickmarkr 2.6.8 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -505,9 +505,12 @@ export async function refreshCatalogCommand(opts) {
505
505
  let modelsDev = current.catalog.modelsDev;
506
506
  let modelsDevUpdated = false;
507
507
  try {
508
- modelsDev = await fetchCatalog(fetcher, MODELS_DEV_CATALOG_URL, {}, timeoutMs, (r) => r.json());
509
- if (!validModelsDevCatalog(modelsDev))
508
+ // v2.6.9 (queue row 97): validate before the payload replaces the spine — an invalid payload left in
509
+ // modelsDev would be written beside an updated sibling leg and make the whole cache unreadable.
510
+ const fetched = await fetchCatalog(fetcher, MODELS_DEV_CATALOG_URL, {}, timeoutMs, (r) => r.json());
511
+ if (!validModelsDevCatalog(fetched))
510
512
  throw new Error("models.dev catalog schema is invalid");
513
+ modelsDev = fetched;
511
514
  modelsDevUpdated = true;
512
515
  legAt.modelsDev = stamp;
513
516
  legs.push({ leg: "models.dev", status: "updated", detail: "fetched", retry: "next retry after this leg is stale" });
@@ -3,6 +3,7 @@ export declare function readCodexModelsCache(path?: string): {
3
3
  models: string[];
4
4
  fetchedAt?: string;
5
5
  retirements?: Record<string, ModelRetirement | null>;
6
+ hidden?: string[];
6
7
  };
7
8
  export declare const CODEX_GIT_GRANT = "-c \"sandbox_workspace_write.writable_roots=[$(tkr_common=$(git rev-parse --path-format=absolute --git-common-dir); tkr_gitdir=$(git rev-parse --absolute-git-dir); if [ \"$tkr_gitdir\" != \"$tkr_common\" ]; then printf '\"%s\",' \"$tkr_gitdir\"; fi; printf '\"%s/objects\",\"%s/refs\",\"%s/logs\"' \"$tkr_common\" \"$tkr_common\" \"$tkr_common\")]\"";
8
9
  export declare const CODEX_TRUST_DIALOG: TrustDialog;
@@ -61,23 +61,29 @@ function codexSessionDayDirs(sessions, sinceMs) {
61
61
  // Fails OPEN to { models: [] } on missing/corrupt cache. Version drift observed (installed CLI
62
62
  // 0.143.0 vs cache client_version 0.144.0, 2026-07-10) — hence the defensive try/catch and no shape
63
63
  // assumptions. CODEX_HOME is codex's own relocation env. fetchedAt is the honest codex knowledge age.
64
- // v2.6.4 T2 (B1a): each listed entry's `upgrade` is the CLI's own retirement notice (0.159.0 cache,
65
- // 2026-09-29: gpt-5.5 → {model: gpt-5.6-sol, retirement_at: 2026-10-14T19:00:00Z}; every other listed
66
- // id carries upgrade: null). null records known clean; a malformed or absent `upgrade` records nothing,
67
- // so that id stays unknown. An unreadable cache returns no retirements at all (unknown, never clean).
64
+ // v2.6.4 T2 (B1a): each entry's `upgrade` is the CLI's own retirement notice; its successor is whatever
65
+ // that notice names — never a fixed id in this file. A listed row with upgrade: null records known clean;
66
+ // a malformed or absent `upgrade` records nothing, so that id stays unknown. v2.6.9 (queue row 64): the CLI
67
+ // HIDES a model before it retires it (0.162.0, 2026-10-08: gpt-5.5 visibility "hide" with a dated
68
+ // upgrade), so a valid dated notice is read from hidden rows too; a hidden row still never lists, and a
69
+ // hidden row without a dated notice records nothing (delisted is not known clean). `hidden` names every
70
+ // visibility "hide" row (D-1526: routing evidence). An unreadable cache returns neither (unknown, never clean).
68
71
  export function readCodexModelsCache(path) {
69
72
  const p = path ?? join(process.env.CODEX_HOME || join(homedir(), ".codex"), "models_cache.json");
70
73
  try {
71
74
  const d = JSON.parse(readFileSync(p, "utf8"));
72
- const listed = (d.models ?? [])
73
- .filter((m) => m?.visibility === "list" && typeof m.slug === "string" && m.slug.length > 0 && MODEL_ID_RE.test(m.slug));
75
+ const rows = (d.models ?? [])
76
+ .filter((m) => typeof m?.slug === "string" && m.slug.length > 0 && MODEL_ID_RE.test(m.slug));
77
+ const listed = rows.filter((m) => m.visibility === "list");
74
78
  const retirements = {};
75
- for (const m of listed) {
79
+ for (const m of rows) {
76
80
  const notice = codexRetirement(m.upgrade);
77
- if (notice !== undefined)
78
- retirements[m.slug] = notice;
81
+ if (notice === undefined || (notice === null && m.visibility !== "list"))
82
+ continue;
83
+ retirements[m.slug] = notice;
79
84
  }
80
- return { models: listed.map((m) => m.slug), fetchedAt: typeof d.fetched_at === "string" ? d.fetched_at : undefined, retirements };
85
+ const hidden = rows.filter((m) => m.visibility === "hide").map((m) => m.slug);
86
+ return { models: listed.map((m) => m.slug), fetchedAt: typeof d.fetched_at === "string" ? d.fetched_at : undefined, retirements, hidden };
81
87
  }
82
88
  catch {
83
89
  return { models: [] };
@@ -305,6 +311,8 @@ export const codex = {
305
311
  listModelsFetchedAt: () => readCodexModelsCache().fetchedAt,
306
312
  // v2.6.4 T2 (B1a): the cache's per-model retirement notices, persisted beside models in doctor.json.
307
313
  listModelsRetirements: () => readCodexModelsCache().retirements,
314
+ // v2.6.9 (D-1526): the ids the cache marks hidden, persisted beside models as routing evidence.
315
+ listModelsHidden: () => readCodexModelsCache().hidden,
308
316
  collectUsage(cwd, sinceMs) {
309
317
  try {
310
318
  const real = realpathSync(cwd);
@@ -497,13 +497,15 @@ function storedRetirement(record, model) {
497
497
  return { retiresAt: n.retiresAt };
498
498
  return typeof n.successor === "string" && MODEL_ID_RE.test(n.successor) ? { retiresAt: n.retiresAt, successor: n.successor } : undefined;
499
499
  }
500
- // Configured AND listed models only — a configured id the CLI stopped listing already carries the
501
- // tombstone advisory. The CLI's notice is quoted as-is: its successor is the CLI's upgrade target, never
502
- // a tier or benchmark claim, and no model name or visibility is read as one. Advisory: routing unchanged.
503
- function retirementLints(id, listedConfigured, record, nowMs) {
500
+ // Configured models the CLI lists, plus configured models it stopped listing that still carry a dated
501
+ // notice (the CLI hides a model before retiring it); any other unlisted id carries the bare tombstone
502
+ // advisory instead. The CLI's notice is quoted as-is: its successor is the CLI's upgrade target, never
503
+ // a tier or benchmark claim, and no model name or visibility is read as one. The notice never changes routing;
504
+ // the CLI's hide does (D-1526), so a hidden id's lint says it is not routed instead of "routing unchanged".
505
+ function retirementLints(id, configured, listed, hiddenIds, record, nowMs) {
504
506
  const lints = [];
505
507
  const unknown = [];
506
- for (const model of listedConfigured) {
508
+ for (const model of configured) {
507
509
  const notice = storedRetirement(record, model);
508
510
  if (notice === undefined) {
509
511
  unknown.push(model);
@@ -511,12 +513,14 @@ function retirementLints(id, listedConfigured, record, nowMs) {
511
513
  }
512
514
  if (!notice)
513
515
  continue; // listed with no notice: known clean
516
+ const hidden = listed.includes(model) ? "" : " (the CLI no longer lists it)";
517
+ const routing = hiddenIds.includes(model) && !listed.includes(model) ? "not routed while the CLI hides it" : "routing unchanged";
514
518
  const successor = notice.successor
515
519
  ? `successor ${notice.successor} (named by the CLI — not a tier claim; classify it per benchmark policy)`
516
520
  : "no successor named";
517
521
  lints.push(nowMs >= Date.parse(notice.retiresAt)
518
- ? `${id}: ${model} retired ${notice.retiresAt} per the CLI's notice; ${successor} — tombstone it (${model}: null overlay) or verify the id (advisory — routing unchanged)`
519
- : `${id}: ${model} retires ${notice.retiresAt} per the CLI's notice; ${successor} — tombstone it (${model}: null overlay) before then (advisory — routing unchanged)`);
522
+ ? `${id}: ${model} retired ${notice.retiresAt} per the CLI's notice${hidden}; ${successor} — tombstone it (${model}: null overlay) or verify the id (advisory — ${routing})`
523
+ : `${id}: ${model} retires ${notice.retiresAt} per the CLI's notice${hidden}; ${successor} — tombstone it (${model}: null overlay) before then (advisory — ${routing})`);
520
524
  }
521
525
  if (unknown.length) {
522
526
  const why = record && typeof record === "object"
@@ -555,19 +559,25 @@ export function modelLints(cfg, health, adapters, opts) {
555
559
  }
556
560
  const h = health[id];
557
561
  const detected = h?.models ?? []; // MANDATORY default: pre-v1.5 files lack a populated models array
562
+ const configured = Object.keys(cfg.tiers[id]?.models ?? {});
563
+ // a configured id the CLI stopped listing but still carries a dated notice for gets the dated lint, not the bare one
564
+ const noticed = (model) => !!adapter.listModelsRetirements && !!storedRetirement(h?.modelRetirements, model);
558
565
  if (detected.length === 0) {
559
566
  if (h?.installed)
560
567
  lints.push(`${id}: no detection data — run tickmarkr doctor`);
568
+ // D-1526: a cache holding only hidden rows lists nothing, yet its dated notices are still the CLI's word
569
+ if (adapter.listModelsRetirements)
570
+ lints.push(...retirementLints(id, configured.filter(noticed), detected, h?.modelsHidden ?? [], h?.modelRetirements, nowMs));
561
571
  continue; // no data to diff or age
562
572
  }
563
- const configured = Object.keys(cfg.tiers[id]?.models ?? {});
564
573
  for (const model of configured) {
565
- if (!detected.includes(model)) {
574
+ if (!detected.includes(model) && !noticed(model)) {
566
575
  lints.push(`${id}: tiers lists ${model} — CLI no longer reports it; tombstone it (${model}: null overlay) or verify the id`);
567
576
  }
568
577
  }
569
- if (adapter.listModelsRetirements)
570
- lints.push(...retirementLints(id, configured.filter((m) => detected.includes(m)), h?.modelRetirements, nowMs));
578
+ if (adapter.listModelsRetirements) {
579
+ lints.push(...retirementLints(id, configured.filter((m) => detected.includes(m) || noticed(m)), detected, h?.modelsHidden ?? [], h?.modelRetirements, nowMs));
580
+ }
571
581
  const extra = collapseUnclassified(detected, new Set(configured));
572
582
  if (extra.length) {
573
583
  const shown = extra.slice(0, cap).map((row) => row.model).join(", ");
@@ -49,7 +49,6 @@ const VENDORED_MODEL_WINDOW_CLAIMS = [
49
49
  { modelId: "gpt-6-sol", window: 272_000, source: CODEX_CLI_CACHE_SOURCE, readDate: CODEX_CLI_CACHE_READ_DATE },
50
50
  { modelId: "gpt-6-luna", window: 272_000, source: CODEX_CLI_CACHE_SOURCE, readDate: CODEX_CLI_CACHE_READ_DATE },
51
51
  { modelId: "gpt-5.6-sol", window: 272_000, source: CODEX_CLI_CACHE_SOURCE, readDate: CODEX_CLI_CACHE_READ_DATE },
52
- { modelId: "gpt-5.5", window: 272_000, source: CODEX_CLI_CACHE_SOURCE, readDate: CODEX_CLI_CACHE_READ_DATE },
53
52
  { modelId: "gpt-5.6-terra", window: 272_000, source: CODEX_CLI_CACHE_SOURCE, readDate: CODEX_CLI_CACHE_READ_DATE },
54
53
  { modelId: "gpt-5.6-luna", window: 272_000, source: CODEX_CLI_CACHE_SOURCE, readDate: CODEX_CLI_CACHE_READ_DATE },
55
54
  { modelId: "composer-2.5", window: 200_000, source: CURSOR_SOURCE, readDate: READ_DATE },
@@ -107,14 +107,18 @@ function modelListValidated(h) {
107
107
  return !!h?.modelsDetectedAt && Array.isArray(h.models);
108
108
  }
109
109
  export function invalidConfiguredModels(cfg, adapterId, h) {
110
+ const configured = Object.keys(cfg.tiers[adapterId]?.models ?? {});
111
+ // v2.6.9 (queue row 64, D-1526): an id the CLI's own list marks hidden is positive evidence it stopped offering
112
+ // it — out whatever the overlap below. The fail-open that follows covers only ids with no such evidence.
113
+ const hidden = new Set(h?.modelsHidden ?? []);
114
+ const hiddenConfigured = configured.filter((m) => hidden.has(m) && !(h?.models ?? []).includes(m));
110
115
  if (!modelListValidated(h))
111
- return [];
116
+ return hiddenConfigured;
112
117
  const listed = new Set(h.models);
113
- const configured = Object.keys(cfg.tiers[adapterId]?.models ?? {});
114
118
  // Fail open until the CLI list intersects at least one configured model — proves the list is authoritative
115
119
  // for this adapter's tier namespace (MODEL-04: unrelated detected ids must not invalidate routing).
116
120
  if (!configured.some((m) => listed.has(m)))
117
- return [];
121
+ return hiddenConfigured;
118
122
  return configured.filter((m) => !listed.has(m));
119
123
  }
120
124
  export function modelAliasExclusions(cfg, adapters, health) {
@@ -51,6 +51,7 @@ export interface AuthHealth {
51
51
  modelAuth?: Record<string, ModelAuth>;
52
52
  modelIdentities?: Record<string, string>;
53
53
  modelRetirements?: Record<string, ModelRetirement | null>;
54
+ modelsHidden?: string[];
54
55
  codexCommit?: CodexCommitStatus;
55
56
  launchServices?: LaunchServicesRecord;
56
57
  }
@@ -206,6 +207,7 @@ export interface WorkerAdapter {
206
207
  listModels?(): Promise<string[]>;
207
208
  listModelsFetchedAt?(): string | undefined;
208
209
  listModelsRetirements?(): Record<string, ModelRetirement | null> | undefined;
210
+ listModelsHidden?(): string[] | undefined;
209
211
  collectUsage?(cwd: string, sinceMs: number): TokenUsage | undefined;
210
212
  contextUsage?(session: SessionRef): ContextUsage | null;
211
213
  readSessionTranscript?(session: SessionRef): {
@@ -600,6 +600,9 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
600
600
  const retirements = a.listModelsRetirements?.();
601
601
  if (retirements)
602
602
  health[a.id].modelRetirements = retirements;
603
+ const hidden = a.listModelsHidden?.(); // v2.6.9 (D-1526): the CLI's hidden flag, read by routing
604
+ if (hidden)
605
+ health[a.id].modelsHidden = hidden;
603
606
  }
604
607
  catch { /* fail open: leave models as-is, doctor stays healthy */ }
605
608
  }
@@ -551,20 +551,21 @@ export const DEFAULT_CONFIG = {
551
551
  // astra=frontier and luna=cheap follow the provider's own descriptions in the local cache (read
552
552
  // 2026-09-29: "Frontier intelligence for the most demanding work" / "Fast and affordable model for
553
553
  // easier tasks"); sol=frontier keeps the shipped workhorse-family mapping (gpt-5.6-sol above) by
554
- // continuity. Learned routing or an overlay may retier on evidence. gpt-5.5 stays seeded until its
555
- // CLI-announced retirement (2026-10-14T19:00:00Z → gpt-5.6-sol); gpt-5.6-sol is not retiring.
556
- // Windows: the same snapshot lists context_window=272000 for all seven — the budget the CLI launches
554
+ // continuity. Learned routing or an overlay may retier on evidence. v2.6.9 (queue row 64): gpt-5.5 left
555
+ // the seed — the CLI hides it and announces its retirement (2026-10-14T19:00:00Z); its successor is
556
+ // whatever the CLI's notice names (doctor quotes it), never a fixed id here.
557
+ // Windows: the same snapshot lists context_window=272000 for the six still seeded — the budget the CLI launches
557
558
  // with, and the authority for these seeds. The API's published 1,050,000 is a different surface and is
558
559
  // not contradicted. A later cache listing more ids (gpt-6.1-sol) does not widen this sealed set.
559
560
  codex: {
560
561
  vendor: "openai", channel: "sub",
561
562
  models: {
562
563
  "gpt-6-astra": "frontier", "gpt-6-sol": "frontier", "gpt-6-luna": "cheap",
563
- "gpt-5.6-sol": "frontier", "gpt-5.5": "frontier", "gpt-5.6-terra": "mid", "gpt-5.6-luna": "cheap",
564
+ "gpt-5.6-sol": "frontier", "gpt-5.6-terra": "mid", "gpt-5.6-luna": "cheap",
564
565
  },
565
566
  windows: {
566
567
  "gpt-6-astra": 272_000, "gpt-6-sol": 272_000, "gpt-6-luna": 272_000,
567
- "gpt-5.6-sol": 272_000, "gpt-5.5": 272_000, "gpt-5.6-terra": 272_000, "gpt-5.6-luna": 272_000,
568
+ "gpt-5.6-sol": 272_000, "gpt-5.6-terra": 272_000, "gpt-5.6-luna": 272_000,
568
569
  },
569
570
  },
570
571
  // grok-4.5 (xAI, released 2026-07-08) → mid: AA Intelligence 54 (#4), Terminal-Bench 2.1 83.3%
@@ -253,8 +253,8 @@ export declare function verifyIntegrationTipCached(intWt: string, commands: Reco
253
253
  type SuitePidProbe = (pid: number) => number | undefined;
254
254
  /** Count full-suite roots in one process-table snapshot. The probes are arguments so the ownership
255
255
  * rules remain testable on hosts that forbid process inspection; production supplies cwd and the
256
- * inherited TICKMARKR_SUITE_PARENT marker from the process itself. */
257
- export declare function countLiveSuites(snapshot: string, repoRoot: string, daemonPid?: number, cwdForPid?: (pid: number) => string | undefined, suiteParentForPid?: SuitePidProbe): number;
256
+ * inherited TICKMARKR_SUITE_PARENT marker from one batched async probe (suite-census.ts, queue row 103). */
257
+ export declare function countLiveSuites(snapshot: string, repoRoot: string, daemonPid: number, cwdForPid: (pid: number) => string | undefined, suiteParentForPid: SuitePidProbe): number;
258
258
  export declare const setLiveSuiteCountForTests: (probe: (repoRoot: string) => Promise<number>) => void;
259
259
  export declare const resetLiveSuiteCountForTests: () => void;
260
260
  /** Live full-suite roots attributable to this repository or this daemon. Ancestors are excluded so
@@ -9,6 +9,7 @@ import { createRequire } from "node:module";
9
9
  import { tmpdir } from "node:os";
10
10
  import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
+ import { batchedProcessProbes, runnerPids } from "./suite-census.js";
12
13
  import { stringify } from "yaml";
13
14
  import { BOOTSTRAP_FAILURE_RE, classifyDeadChannel, classifyTransientCapacity, trailerPattern, writePrompt } from "../adapters/prompt.js";
14
15
  import { allAdapters, getAdapter, probeAll, readDoctor, rolePools } from "../adapters/registry.js";
@@ -1243,48 +1244,14 @@ async function observeWorkerProcessTree(marker, cwd) {
1243
1244
  }
1244
1245
  return tree.size === 0 ? "empty" : "running";
1245
1246
  }
1246
- function processCwd(pid) {
1247
- try {
1248
- return realpathSync(readlinkSync(`/proc/${pid}/cwd`));
1249
- }
1250
- catch { /* Darwin has no /proc */ }
1251
- try {
1252
- const out = execFileSync("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"], {
1253
- encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000,
1254
- });
1255
- const path = out.split("\n").find((line) => line.startsWith("n"))?.slice(1);
1256
- return path ? realpathSync(path) : undefined;
1257
- }
1258
- catch {
1259
- return undefined;
1260
- }
1261
- }
1262
- function processSuiteParent(pid) {
1263
- try {
1264
- const env = readFileSync(`/proc/${pid}/environ`, "utf8").split("\0");
1265
- const value = env.find((entry) => entry.startsWith(`${SUITE_PARENT_ENV}=`))?.slice(SUITE_PARENT_ENV.length + 1);
1266
- return value && /^\d+$/.test(value) ? Number(value) : undefined;
1267
- }
1268
- catch { /* Darwin has no /proc process environments */ }
1269
- try {
1270
- const out = execFileSync("ps", ["eww", "-p", String(pid), "-o", "command="], {
1271
- encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000,
1272
- });
1273
- const value = new RegExp(`(?:^|\\s)${SUITE_PARENT_ENV}=(\\d+)(?:\\s|$)`).exec(out)?.[1];
1274
- return value ? Number(value) : undefined;
1275
- }
1276
- catch {
1277
- return undefined;
1278
- }
1279
- }
1280
1247
  const pathAtOrBelow = (root, candidate) => {
1281
1248
  const rel = relative(root, candidate);
1282
1249
  return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
1283
1250
  };
1284
1251
  /** Count full-suite roots in one process-table snapshot. The probes are arguments so the ownership
1285
1252
  * rules remain testable on hosts that forbid process inspection; production supplies cwd and the
1286
- * inherited TICKMARKR_SUITE_PARENT marker from the process itself. */
1287
- export function countLiveSuites(snapshot, repoRoot, daemonPid = process.pid, cwdForPid = processCwd, suiteParentForPid = processSuiteParent) {
1253
+ * inherited TICKMARKR_SUITE_PARENT marker from one batched async probe (suite-census.ts, queue row 103). */
1254
+ export function countLiveSuites(snapshot, repoRoot, daemonPid, cwdForPid, suiteParentForPid) {
1288
1255
  const rows = [];
1289
1256
  for (const line of snapshot.split("\n")) {
1290
1257
  const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(line);
@@ -1344,7 +1311,11 @@ export async function liveSuiteCount(repoRoot) {
1344
1311
  if (liveSuiteCountForTests)
1345
1312
  return liveSuiteCountForTests(repoRoot);
1346
1313
  const snapshot = await shGit("ps -Aww -o pid=,ppid=,state=,command=", repoRoot, 15_000);
1347
- return snapshot.code === 0 ? countLiveSuites(snapshot.stdout, repoRoot) : 0;
1314
+ if (snapshot.code !== 0)
1315
+ return 0;
1316
+ // row 103: one batched async probe per snapshot — a synchronous lsof/ps per pid held this loop 44–46 s
1317
+ const probes = await batchedProcessProbes(runnerPids(snapshot.stdout));
1318
+ return countLiveSuites(snapshot.stdout, repoRoot, process.pid, probes.cwd, probes.suiteParent);
1348
1319
  }
1349
1320
  const OBSERVE_CHUNK_BYTES = 64 * 1024;
1350
1321
  const OBSERVE_BUDGET_BYTES = 256 * 1024 * 1024;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Queue row 103 (D-1553): the live-suite census asks each runner-looking process for its cwd and its inherited
3
+ * TICKMARKR_SUITE_PARENT marker. It used to ask one pid at a time with a synchronous `lsof` and `ps`, which held the
4
+ * daemon's event loop 44–46 s under a full suite (the stall traces recorded for queue row 8). Here every pid is answered by /proc where it
5
+ * exists (Linux, child-free), else by ONE async `lsof` and ONE async `ps eww`, plus one more `lsof` for suite parents
6
+ * not already probed — the census asks for a parent's cwd too. The answers feed countLiveSuites' existing lookups.
7
+ */
8
+ export type ProbeExec = (file: string, args: readonly string[]) => Promise<string>;
9
+ /** The pids of every live (non-zombie) row of a `ps -o pid=,ppid=,state=,command=` snapshot whose command is a runner. */
10
+ export declare function runnerPids(snapshot: string): number[];
11
+ /** The /proc reads, injectable so the Linux branch is testable anywhere; each throws where /proc has no entry. */
12
+ export interface ProcReader {
13
+ cwd(pid: number): string;
14
+ environ(pid: number): string;
15
+ }
16
+ export interface CensusProbes {
17
+ cwd: (pid: number) => string | undefined;
18
+ suiteParent: (pid: number) => number | undefined;
19
+ }
20
+ export declare function batchedProcessProbes(pids: readonly number[], exec?: ProbeExec, proc?: ProcReader): Promise<CensusProbes>;
@@ -0,0 +1,100 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readFileSync, readlinkSync, realpathSync } from "node:fs";
3
+ import { isRunnerCommand } from "./lease.js";
4
+ import { SUITE_PARENT_ENV } from "./git.js";
5
+ // A pid that exits between the snapshot and the probe makes lsof/ps exit non-zero while still printing every other
6
+ // row, so an ordinary non-zero exit (a numeric code, no signal) keeps whatever was printed. D-1564: a timeout, a kill, a
7
+ // spawn failure or an output-cap overflow is no answer — the base's execFileSync threw there and discarded any partial
8
+ // output, so these answer nothing even when they buffered rows first.
9
+ // D-1561: execFile can also throw SYNCHRONOUSLY (EPERM, EAGAIN under fork pressure); the base's per-pid try/catch answered
10
+ // that as "unknown", so this probe never throws or rejects either — it answers nothing.
11
+ const probeExec = (file, args) => new Promise((resolve) => {
12
+ try {
13
+ execFile(file, [...args], { encoding: "utf8", timeout: 15_000, maxBuffer: 16 * 1024 * 1024 }, (error, stdout) => resolve(error && (error.killed || error.signal || typeof error.code !== "number") ? "" : String(stdout ?? "")));
14
+ }
15
+ catch {
16
+ resolve("");
17
+ }
18
+ });
19
+ /** The pids of every live (non-zombie) row of a `ps -o pid=,ppid=,state=,command=` snapshot whose command is a runner. */
20
+ export function runnerPids(snapshot) {
21
+ const pids = [];
22
+ for (const line of snapshot.split("\n")) {
23
+ const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(line);
24
+ if (match && !match[3].startsWith("Z") && isRunnerCommand(match[4]))
25
+ pids.push(Number(match[1]));
26
+ }
27
+ return pids;
28
+ }
29
+ const procReader = {
30
+ cwd: (pid) => realpathSync(readlinkSync(`/proc/${pid}/cwd`)),
31
+ environ: (pid) => readFileSync(`/proc/${pid}/environ`, "utf8"),
32
+ };
33
+ export async function batchedProcessProbes(pids, exec = probeExec, proc = procReader) {
34
+ // the census never rejects, whatever exec does (queue row 103's closed case table, D-1561)
35
+ const ask = async (file, args) => { try {
36
+ return await exec(file, args);
37
+ }
38
+ catch {
39
+ return "";
40
+ } };
41
+ const cwds = new Map();
42
+ const parents = new Map();
43
+ const parentIn = (text) => new RegExp(`(?:^|\\s)${SUITE_PARENT_ENV}=(\\d+)(?:\\s|$)`).exec(text)?.[1];
44
+ // D-1567 batch poisoning: lsof and ps fail the WHOLE command for one -p member they cannot take (a marker of 22 digits
45
+ // prints as 1e+21, 310 nines as Infinity; ps refuses one above int32), erasing every other pid's answer. The base probed
46
+ // per pid, so such a pid lost only its own answer: it stays unanswered here and never joins a batch.
47
+ const probeable = (pid) => Number.isSafeInteger(pid) && pid > 0 && pid <= 2_147_483_647;
48
+ const probeCwds = async (wanted) => {
49
+ const rest = [];
50
+ for (const pid of wanted.filter(probeable)) {
51
+ try {
52
+ cwds.set(pid, proc.cwd(pid));
53
+ }
54
+ catch {
55
+ rest.push(pid);
56
+ } // Darwin has no /proc
57
+ }
58
+ if (rest.length === 0)
59
+ return;
60
+ let pid;
61
+ for (const line of (await ask("lsof", ["-a", "-p", rest.join(","), "-d", "cwd", "-Fn"])).split("\n")) {
62
+ if (line.startsWith("p"))
63
+ pid = Number(line.slice(1));
64
+ else if (line.startsWith("n") && pid !== undefined) {
65
+ try {
66
+ cwds.set(pid, realpathSync(line.slice(1)));
67
+ }
68
+ catch { /* the directory is gone */ }
69
+ }
70
+ }
71
+ };
72
+ const probeParents = async (wanted) => {
73
+ const rest = [];
74
+ for (const pid of wanted.filter(probeable)) {
75
+ try {
76
+ // the base's exact rule (7c8009772 daemon.ts:1418-1423, D-1558): ONE NUL-delimited entry, its WHOLE value digits —
77
+ // a joined string would let another variable's text, or a value with trailing junk, pass for the marker
78
+ const value = proc.environ(pid).split("\0").find((entry) => entry.startsWith(`${SUITE_PARENT_ENV}=`))?.slice(SUITE_PARENT_ENV.length + 1);
79
+ if (value && /^\d+$/.test(value))
80
+ parents.set(pid, Number(value));
81
+ }
82
+ catch {
83
+ rest.push(pid);
84
+ } // Darwin has no /proc process environments
85
+ }
86
+ if (rest.length === 0)
87
+ return;
88
+ for (const line of (await ask("ps", ["eww", "-p", rest.join(","), "-o", "pid=,command="])).split("\n")) {
89
+ const row = /^\s*(\d+)\s+(.*)$/.exec(line);
90
+ const value = row ? parentIn(row[2]) : undefined;
91
+ if (row && value)
92
+ parents.set(Number(row[1]), Number(value));
93
+ }
94
+ };
95
+ const unique = [...new Set(pids)];
96
+ await probeCwds(unique);
97
+ await probeParents(unique);
98
+ await probeCwds([...new Set(parents.values())].filter((pid) => !cwds.has(pid)));
99
+ return { cwd: (pid) => cwds.get(pid), suiteParent: (pid) => parents.get(pid) };
100
+ }
@@ -44,6 +44,18 @@ function ledgerChannelId(line) {
44
44
  const slash = head.indexOf("/");
45
45
  return slash === -1 ? undefined : `${head.slice(0, slash)}:${head.slice(slash + 1)}`;
46
46
  }
47
+ /**
48
+ * Space picks exactly as Enter does in every single-select overlay (operator field report 2026-10-08,
49
+ * "can't choose here space key doesn't select" — the reach picker even OPENS on Space). Space keeps its
50
+ * own meaning only where it has one: the multi-select chains (candidates, prefer) toggle on it, the text
51
+ * stages (add-model, the classify note) type it, and the review diff confirms only on `y`. The lift
52
+ * listing is a confirm, not a pick: it clears a deny entry for every channel that entry covers, so only
53
+ * Enter lifts — a stray Space there does nothing.
54
+ */
55
+ const spacePicks = (overlay) => overlay.kind === "classify"
56
+ ? overlay.stage !== "note"
57
+ : overlay.kind === "presets" || overlay.kind === "assign" || overlay.kind === "poolmode"
58
+ || overlay.kind === "judge" || overlay.kind === "reach";
47
59
  const REACH_CHOICES = [
48
60
  { id: "in", label: "in", gloss: "every seat may route here" },
49
61
  { id: "out-workers", label: "out · workers", gloss: "workers skip it; judge/review/consult unaffected" },
@@ -991,6 +1003,7 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
991
1003
  return true;
992
1004
  };
993
1005
  if (overlay) {
1006
+ const pick = key.return || (input === " " && spacePicks(overlay));
994
1007
  if (overlay.kind === "presets") {
995
1008
  const rowCount = modeOptions.length + (overlay.home ? 1 : 0);
996
1009
  if (key.escape) {
@@ -1015,7 +1028,7 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
1015
1028
  bump();
1016
1029
  return;
1017
1030
  }
1018
- if (key.return) {
1031
+ if (pick) {
1019
1032
  if (overlay.home && overlay.at === modeOptions.length) {
1020
1033
  setOverlay(null); // custom → the browser IS the full editor
1021
1034
  return;
@@ -1065,8 +1078,10 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
1065
1078
  finish({ kind: "discard" });
1066
1079
  return;
1067
1080
  }
1081
+ // the review screen holds every staged edit by definition — `q` takes the same two presses
1082
+ // as every other screen, never one keystroke to lose the session (`n` is the explicit discard)
1068
1083
  if (input === "q") {
1069
- finish({ kind: "quit" });
1084
+ guardedQuit(armed);
1070
1085
  return;
1071
1086
  }
1072
1087
  if (input === "y") {
@@ -1097,7 +1112,7 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
1097
1112
  bump();
1098
1113
  return;
1099
1114
  }
1100
- if (key.return) {
1115
+ if (pick) {
1101
1116
  const channel = CHANNELS[overlay.channelAt];
1102
1117
  ui.channelByAdapter[overlay.adapter] = channel;
1103
1118
  // OBS-508: a bulk stage waiting on the one first-touch channel answer resumes here.
@@ -1125,7 +1140,7 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
1125
1140
  bump();
1126
1141
  return;
1127
1142
  }
1128
- if (key.return) {
1143
+ if (pick) {
1129
1144
  // OBS-508: keep the suggested band → the evidence note arrives pre-typed; override the
1130
1145
  // band → the note starts empty (the suggestion argues for a DIFFERENT tier).
1131
1146
  const chosen = TIERS[overlay.tierAt];
@@ -1208,7 +1223,7 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
1208
1223
  bump();
1209
1224
  return;
1210
1225
  }
1211
- if (key.return && rows[overlay.at]) {
1226
+ if (pick && rows[overlay.at]) {
1212
1227
  const shape = rows[overlay.at].id;
1213
1228
  ui.map = { ...ui.map, [shape]: { pin: { via: overlay.adapter, model: overlay.model } } };
1214
1229
  setOverlay(null);
@@ -1305,7 +1320,7 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
1305
1320
  bump();
1306
1321
  return;
1307
1322
  }
1308
- if (key.return) {
1323
+ if (pick) {
1309
1324
  const { shape, chain } = overlay.picker;
1310
1325
  // a pool is the shape's WHOLE declaration: pin and prefer leave with it (schema
1311
1326
  // exclusivity — pin+pool and pool+prefer both fail config load)
@@ -1383,7 +1398,7 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
1383
1398
  bump();
1384
1399
  return;
1385
1400
  }
1386
- if (key.return) {
1401
+ if (pick) {
1387
1402
  const choice = REACH_CHOICES[overlay.at].id;
1388
1403
  setOverlay(null);
1389
1404
  applyReach(overlay.target, choice);
@@ -1420,7 +1435,8 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
1420
1435
  bump();
1421
1436
  return;
1422
1437
  }
1423
- if (key.return && rows[overlay.at]) {
1438
+ // Space picks too (spacePicks): a judge picker that swallowed it read as "can't choose"
1439
+ if (pick && rows[overlay.at]) {
1424
1440
  // single-select: one seat or back to the config default — never a chain
1425
1441
  const picked = rows[overlay.at];
1426
1442
  ui.judgeSeat = picked === judgeKeepRow ? null : picked;
@@ -2045,7 +2061,7 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
2045
2061
  const rows = [judgeKeepRow, ...judgeSeats].filter((row) => matches(row, ui.filter));
2046
2062
  const { visible, start, above, below } = windowRows(rows, overlay.at, capacity);
2047
2063
  const selectedSeat = ui.judgeSeat ?? judgeKeepRow;
2048
- return (_jsxs(OverlayPanel, { title: "pick \u00B7 judge", width: bodyW, children: [_jsx(Text, { dimColor: true, children: "one seat judges acceptance criteria \u2014 failover stays runtime (GATE-09)" }), _jsx(SearchRow, { filter: ui.filter, active: true }), above > 0 && _jsx(ElisionMark, { count: above, side: "above" }), visible.map((label, index) => (_jsxs(Text, { children: [_jsx(Pointer, { on: start + index === overlay.at }), label === selectedSeat ? _jsx(Glyph, { kind: "on" }) : _jsx(Text, { children: " " }), _jsx(Text, { bold: start + index === overlay.at, children: ` ${clip(label, bodyW - 10)}` })] }, label))), below > 0 && _jsx(ElisionMark, { count: below, side: "below" })] }));
2064
+ return (_jsxs(OverlayPanel, { title: "pick \u00B7 judge", width: bodyW, children: [_jsx(Text, { dimColor: true, wrap: "truncate", children: clip("Space or Enter picks · Esc keeps · one seat judges acceptance criteria — failover stays runtime (GATE-09)", bodyW - 4) }), _jsx(SearchRow, { filter: ui.filter, active: true }), above > 0 && _jsx(ElisionMark, { count: above, side: "above" }), visible.map((label, index) => (_jsxs(Text, { children: [_jsx(Pointer, { on: start + index === overlay.at }), label === selectedSeat ? _jsx(Glyph, { kind: "on" }) : _jsx(Text, { children: " " }), _jsx(Text, { bold: start + index === overlay.at, children: ` ${clip(label, bodyW - 10)}` })] }, label))), below > 0 && _jsx(ElisionMark, { count: below, side: "below" })] }));
2049
2065
  })();
2050
2066
  const modelVisibilityLine = () => {
2051
2067
  const hidden = hiddenModelCount();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "2.6.8",
3
+ "version": "2.7.0",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -114,6 +114,9 @@
114
114
  "scripts/export-public.sh",
115
115
  "scripts/verify-export.sh",
116
116
  "tests/scripts/verify-export.test.ts",
117
+ "scripts/check-analysis.mjs",
118
+ "scripts/atlas.mjs",
119
+ "tests/docs/analysis-library.test.ts",
117
120
  ".github/workflows/ci.yml",
118
121
  "**/*.local.*"
119
122
  ]