tickmarkr 2.2.1 → 2.4.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.
Files changed (81) hide show
  1. package/README.md +10 -9
  2. package/dist/adapters/catalog-remote.d.ts +1 -4
  3. package/dist/adapters/catalog-remote.js +52 -42
  4. package/dist/adapters/catalog.js +5 -3
  5. package/dist/adapters/claude-code.d.ts +1 -1
  6. package/dist/adapters/claude-code.js +8 -5
  7. package/dist/adapters/model-lints.d.ts +9 -5
  8. package/dist/adapters/model-lints.js +56 -15
  9. package/dist/adapters/model-windows.js +11 -0
  10. package/dist/adapters/prompt.js +1 -0
  11. package/dist/adapters/qwen.d.ts +5 -0
  12. package/dist/adapters/qwen.js +153 -0
  13. package/dist/adapters/types.d.ts +21 -1
  14. package/dist/adapters/types.js +43 -2
  15. package/dist/cli/commands/approve.js +5 -4
  16. package/dist/cli/commands/beat.js +7 -4
  17. package/dist/cli/commands/compile.js +32 -6
  18. package/dist/cli/commands/doctor.d.ts +9 -4
  19. package/dist/cli/commands/doctor.js +87 -13
  20. package/dist/cli/commands/fleet.d.ts +4 -0
  21. package/dist/cli/commands/fleet.js +53 -14
  22. package/dist/cli/commands/init.js +36 -21
  23. package/dist/cli/commands/plan.js +45 -7
  24. package/dist/cli/commands/report.js +37 -1
  25. package/dist/cli/commands/status.d.ts +1 -0
  26. package/dist/cli/commands/status.js +45 -1
  27. package/dist/cli/commands/verify.d.ts +6 -0
  28. package/dist/cli/commands/verify.js +145 -25
  29. package/dist/cli/index.d.ts +1 -1
  30. package/dist/cli/index.js +2 -2
  31. package/dist/compile/collateral.d.ts +2 -9
  32. package/dist/compile/collateral.js +17 -18
  33. package/dist/compile/index.d.ts +4 -1
  34. package/dist/compile/index.js +41 -7
  35. package/dist/compile/native.d.ts +4 -2
  36. package/dist/compile/native.js +58 -9
  37. package/dist/compile/ownership.js +34 -9
  38. package/dist/config/config.d.ts +1 -0
  39. package/dist/config/config.js +52 -6
  40. package/dist/drivers/herdr.d.ts +1 -0
  41. package/dist/drivers/herdr.js +11 -1
  42. package/dist/drivers/index.d.ts +6 -0
  43. package/dist/drivers/index.js +19 -4
  44. package/dist/drivers/orca.d.ts +35 -1
  45. package/dist/drivers/orca.js +260 -20
  46. package/dist/drivers/subprocess.d.ts +3 -3
  47. package/dist/drivers/subprocess.js +16 -9
  48. package/dist/drivers/types.d.ts +12 -0
  49. package/dist/gates/baseline.d.ts +2 -0
  50. package/dist/gates/baseline.js +47 -11
  51. package/dist/gates/llm.d.ts +6 -0
  52. package/dist/gates/llm.js +25 -9
  53. package/dist/gates/review.d.ts +7 -3
  54. package/dist/gates/review.js +61 -22
  55. package/dist/gates/run-gates.d.ts +5 -2
  56. package/dist/gates/run-gates.js +50 -26
  57. package/dist/gates/verdict-cause.d.ts +6 -2
  58. package/dist/gates/verdict-cause.js +8 -4
  59. package/dist/route/preference.d.ts +4 -0
  60. package/dist/route/preference.js +40 -0
  61. package/dist/route/router.js +15 -2
  62. package/dist/run/consult.d.ts +1 -0
  63. package/dist/run/consult.js +39 -8
  64. package/dist/run/daemon.d.ts +16 -0
  65. package/dist/run/daemon.js +345 -74
  66. package/dist/run/git.d.ts +3 -0
  67. package/dist/run/git.js +40 -5
  68. package/dist/run/journal.d.ts +15 -2
  69. package/dist/run/journal.js +73 -12
  70. package/dist/run/supervision.d.ts +6 -0
  71. package/dist/run/supervision.js +29 -1
  72. package/dist/tui/ink/fleet-app.d.ts +4 -0
  73. package/dist/tui/ink/fleet-app.js +45 -16
  74. package/dist/tui/ink/init-app.js +4 -4
  75. package/package.json +59 -1
  76. package/skills/tickmarkr-overseer/SKILL.md +77 -18
  77. package/skills/tickmarkr-overseer/scripts/seat-send.sh +88 -18
  78. package/skills/tickmarkr-overseer/scripts/watch-artifacts.sh +36 -2
  79. package/skills/tickmarkr-overseer/scripts/watch-contamination.sh +36 -15
  80. package/skills/tickmarkr-overseer/scripts/watch-context.sh +33 -7
  81. package/skills/tickmarkr-overseer/scripts/watch-pending-input.sh +32 -8
package/dist/run/git.d.ts CHANGED
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { ROUTING_ENV_SEAMS } from "../route/router.js";
3
3
  export { ROUTING_ENV_SEAMS };
4
4
  export declare const FORK_CAP_ENV = "VITEST_MAX_FORKS";
5
+ export declare const SUITE_PARENT_ENV = "TICKMARKR_SUITE_PARENT";
5
6
  export declare const DEFAULT_FORK_CAP = "6";
6
7
  /**
7
8
  * OBS-618: how many PROCESSES one vitest fork can hold at its peak — the fork, a daemon it spawns,
@@ -89,6 +90,8 @@ export interface ShResult {
89
90
  stdout: string;
90
91
  stderr: string;
91
92
  timedOut?: boolean;
93
+ /** The shell exited, but descendants still held its process group and pipes past the grace. */
94
+ reapedGroup?: boolean;
92
95
  durationMs?: number;
93
96
  /** T7: the capacity THIS child ran under, stamped where its environment was built (see `shell`). */
94
97
  capacity?: RunCapacity;
package/dist/run/git.js CHANGED
@@ -13,6 +13,7 @@ export { ROUTING_ENV_SEAMS };
13
13
  // Default to a modest cap through the environment so vitest honors it natively; never pass it
14
14
  // as argv (OBS-55) so child test oracles stay intact. The operator's own export wins.
15
15
  export const FORK_CAP_ENV = "VITEST_MAX_FORKS";
16
+ export const SUITE_PARENT_ENV = "TICKMARKR_SUITE_PARENT";
16
17
  export const DEFAULT_FORK_CAP = "6";
17
18
  /**
18
19
  * The fork budget belongs to ONE run: how many gate suites can be in flight at once is exactly that
@@ -137,6 +138,9 @@ export const resetSpawnForTests = () => { spawnChild = undefined; };
137
138
  const RETRYABLE_SPAWN_CODE = "EAGAIN";
138
139
  export const SPAWN_ATTEMPT_LIMIT = 4;
139
140
  export const SPAWN_RETRY_BACKOFF_MS = 50;
141
+ // RULING-P99-28: SHIP — every gate user can hit the inherited-pipe hang at this product seam.
142
+ /** Give a normally-exited shell's descendants this long to close their inherited pipes themselves. */
143
+ const SHELL_REAP_GRACE_MS = 2000;
140
144
  // stdin "ignore": same class as HARD-05 / SubprocessDriver — never leave an open pipe a child can block on
141
145
  // (pi -p / codex exec wait for stdin EOF). timedOut distinguishes SIGKILL-timeout from a real nonzero exit.
142
146
  function shell(cmd, cwd, timeoutMs, login) {
@@ -150,6 +154,9 @@ function shell(cmd, cwd, timeoutMs, login) {
150
154
  // OBS-110: apply the run's own fork cap only when the operator has not already set one.
151
155
  if (!(FORK_CAP_ENV in env))
152
156
  env[FORK_CAP_ENV] = resolvedForkCap();
157
+ // OBS-854: descendants can leave the checkout (nested fixture suites do), so cwd alone cannot
158
+ // attribute them. Every daemon shell exports the daemon pid as their durable parentage marker.
159
+ env[SUITE_PARENT_ENV] = String(process.pid);
153
160
  // T7: the capacity every result of this shell carries, read HERE — off the environment the child
154
161
  // is about to receive, after the precedence above has settled. An operator export is already in
155
162
  // `env`, so what gets recorded is the operator's number, which is the case a release was re-taken
@@ -166,15 +173,25 @@ function shell(cmd, cwd, timeoutMs, login) {
166
173
  let stdout = "", stderr = "";
167
174
  const stdoutDecoder = new StringDecoder("utf8");
168
175
  const stderrDecoder = new StringDecoder("utf8");
169
- let timedOut = false, done = false, started = false, outputSeen = false;
176
+ let timedOut = false, reapedGroup = false, done = false, started = false, outputSeen = false;
177
+ let reapTimer;
170
178
  const finish = (code, err) => {
171
179
  if (done)
172
180
  return;
173
181
  done = true;
174
182
  clearTimeout(timer);
183
+ clearTimeout(reapTimer);
175
184
  stdout += stdoutDecoder.end();
176
185
  stderr += stderrDecoder.end();
177
- resolve({ code, stdout, stderr: err ?? stderr, timedOut, durationMs: Date.now() - startedAt, capacity });
186
+ resolve({
187
+ code,
188
+ stdout,
189
+ stderr: err ?? stderr,
190
+ timedOut,
191
+ durationMs: Date.now() - startedAt,
192
+ capacity,
193
+ ...(reapedGroup ? { reapedGroup: true } : {}),
194
+ });
178
195
  };
179
196
  const timer = setTimeout(() => {
180
197
  timedOut = true;
@@ -211,9 +228,27 @@ function shell(cmd, cwd, timeoutMs, login) {
211
228
  finish(127, String(e));
212
229
  });
213
230
  p.on("close", (code) => finish(code ?? 1));
214
- // "close" waits for stdio to drain; a surviving pipe-holder must not outlive the timeout
215
- p.on("exit", (code) => { if (timedOut)
216
- finish(code ?? 1); });
231
+ // "close" waits for stdio to drain. Once bash exits normally, give descendants a bounded grace
232
+ // to exit with it; a survivor still in bash's detached group is then reaped so its inherited pipe
233
+ // cannot hold this promise until the command ceiling. A real timeout wins first and is never
234
+ // reclassified as a grace reap.
235
+ p.on("exit", (code) => {
236
+ if (timedOut) {
237
+ finish(code ?? 1);
238
+ return;
239
+ }
240
+ reapTimer = setTimeout(() => {
241
+ if (done)
242
+ return;
243
+ try {
244
+ process.kill(-p.pid, "SIGKILL");
245
+ reapedGroup = true;
246
+ }
247
+ catch {
248
+ // The group closed between the shell's exit and the grace boundary; "close" owns finish.
249
+ }
250
+ }, SHELL_REAP_GRACE_MS);
251
+ });
217
252
  });
218
253
  return (async () => {
219
254
  const startedAt = Date.now();
@@ -116,8 +116,21 @@ export declare function formatPriorFindingEvidence(evidence: PriorFindingEvidenc
116
116
  export declare function normalizeGateFailure(details: string): string;
117
117
  export declare const GATE_FINGERPRINT_CAP = 2;
118
118
  export declare function identicalGateFailures(events: JournalEvent[], taskId: string, gate: string, normalized: string): number;
119
- /** Repair attempts this engagement has already funded — journal-derived, so a resume inherits it. */
119
+ /** What each funded repair's next battery actually reached. A repair spends its budget only
120
+ * when that battery reaches one of the gates it was funded to fix; an earlier red is evidence that
121
+ * this repair never got its funded turn, not a charge against the repair ladder. */
122
+ export interface RepairReach {
123
+ repair: number;
124
+ funded: string[];
125
+ reached: string[];
126
+ diedAt?: string;
127
+ charged: boolean;
128
+ }
129
+ export declare function repairReachSinceApproval(events: JournalEvent[], taskId: string): RepairReach[];
130
+ /** Repair slots actually spent this engagement — journal-derived, so a resume inherits it. */
120
131
  export declare function repairsSinceApproval(events: JournalEvent[], taskId: string): number;
132
+ /** Recheck releases not yet enacted by a battery (or by a legacy worker launch). */
133
+ export declare function pendingRechecks(events: JournalEvent[]): Set<string>;
121
134
  /**
122
135
  * Why the last attempt failed, one row per journaled cause, in the daemon's own `source: details`
123
136
  * shape. The daemon builds that brief in a loop-local variable, which dies with the process: a resumed
@@ -242,9 +255,9 @@ export declare const TelemetryRowSchema: z.ZodObject<{
242
255
  quotaFailover: z.ZodOptional<z.ZodLiteral<true>>;
243
256
  overrun: z.ZodOptional<z.ZodLiteral<true>>;
244
257
  retryMode: z.ZodOptional<z.ZodEnum<{
258
+ repair: "repair";
245
259
  resume: "resume";
246
260
  fresh: "fresh";
247
- repair: "repair";
248
261
  }>>;
249
262
  signalQuality: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<0>, z.ZodLiteral<0.25>, z.ZodLiteral<0.5>, z.ZodLiteral<0.75>, z.ZodLiteral<1>]>>;
250
263
  signalBasis: z.ZodOptional<z.ZodEnum<{
@@ -5,6 +5,7 @@ import { z } from "zod";
5
5
  import { channelKey, shq, TokenUsageSchema } from "../adapters/types.js";
6
6
  import { stateDirName, taskContentDigest, tickmarkrDir } from "../graph/graph.js";
7
7
  import { GATE_NAMES, TIERS } from "../graph/schema.js";
8
+ import { channelRouteIdentity } from "../route/preference.js";
8
9
  import { buildProfile, classify } from "../route/profile.js";
9
10
  import { DecisionEventSchema, trackJournalRows, } from "./protocol.js";
10
11
  import { normalizeGateOutcome } from "./outcome.js";
@@ -72,8 +73,8 @@ export const REVIEW_UPHELD_RELEASE = "review-upheld";
72
73
  // OBS-203: the third decision a gate-fail park needs. Plain approve WAIVES the failed gate (and every
73
74
  // gate before it — daemon.ts remainingGates), which is wrong when the gate failed against a stale task
74
75
  // DECLARATION rather than a bad diff: amend the spec's files[], recompile, and the gate now passes
75
- // honestly. This release re-dispatches with the whole gate suite intact and no gate marked satisfied,
76
- // so the corrected declaration is the thing that earns the green. Budget semantics match attempt-cap
76
+ // honestly. This release runs the whole gate suite over the parked commit before any worker and
77
+ // marks no gate satisfied: green merges directly, red returns to the ladder. Budget semantics match attempt-cap
77
78
  // (fresh attempts, tried survives) because the park cost the task its remaining budget.
78
79
  export const RECHECK_RELEASE = "recheck";
79
80
  // OBS-738: one authority for every recovery surface. The ref is accepted only from the row that
@@ -547,18 +548,65 @@ export function identicalGateFailures(events, taskId, gate, normalized) {
547
548
  }
548
549
  return n;
549
550
  }
550
- /** Repair attempts this engagement has already funded — journal-derived, so a resume inherits it. */
551
- export function repairsSinceApproval(events, taskId) {
552
- let n = 0;
551
+ export function repairReachSinceApproval(events, taskId) {
552
+ const repairs = [];
553
553
  for (const e of events) {
554
554
  if (e.taskId !== taskId)
555
555
  continue;
556
- if (e.event === "task-approved")
557
- n = 0;
558
- else if (e.event === "repair-attempt")
559
- n++;
556
+ if (e.event === "task-approved") {
557
+ repairs.length = 0;
558
+ }
559
+ else if (e.event === "repair-attempt") {
560
+ const funded = Array.isArray(e.data.gates)
561
+ ? e.data.gates.filter((gate) => typeof gate === "string")
562
+ : [];
563
+ repairs.push({
564
+ repair: typeof e.data.repair === "number" ? e.data.repair : repairs.length + 1,
565
+ funded,
566
+ reached: [],
567
+ charged: false,
568
+ launched: false,
569
+ closed: false,
570
+ });
571
+ }
572
+ else {
573
+ const repair = repairs.at(-1);
574
+ if (!repair || repair.closed)
575
+ continue;
576
+ if (e.event === "worker-launch") {
577
+ if (repair.launched)
578
+ repair.closed = true;
579
+ else
580
+ repair.launched = true;
581
+ }
582
+ else if (repair.launched && e.event === "gate-result" && typeof e.data.gate === "string") {
583
+ if (!repair.reached.includes(e.data.gate))
584
+ repair.reached.push(e.data.gate);
585
+ if (e.data.pass === false && repair.diedAt === undefined)
586
+ repair.diedAt = e.data.gate;
587
+ if (repair.funded.includes(e.data.gate))
588
+ repair.charged = true;
589
+ }
590
+ }
560
591
  }
561
- return n;
592
+ return repairs.map(({ launched: _launched, closed: _closed, ...repair }) => repair);
593
+ }
594
+ /** Repair slots actually spent this engagement — journal-derived, so a resume inherits it. */
595
+ export function repairsSinceApproval(events, taskId) {
596
+ return repairReachSinceApproval(events, taskId).filter((repair) => repair.charged).length;
597
+ }
598
+ /** Recheck releases not yet enacted by a battery (or by a legacy worker launch). */
599
+ export function pendingRechecks(events) {
600
+ const pending = new Set();
601
+ for (const e of events) {
602
+ if (!e.taskId)
603
+ continue;
604
+ if (e.event === "task-approved" && e.data.release === RECHECK_RELEASE)
605
+ pending.add(e.taskId);
606
+ else if (e.event === "recheck-battery" || e.event === "worker-launch")
607
+ pending.delete(e.taskId);
608
+ }
609
+ return pending;
562
610
  }
563
611
  // Both retry decisions below govern exactly ONE dispatch: the next one. So both are read back from the
564
612
  // journal at the moment that dispatch is built, never carried in a process variable — a stop between
@@ -695,8 +743,21 @@ export function pendingRepairFindings(events, taskId) {
695
743
  * a channel it is no longer using, and a later unrelated failure is not parked under a stale reason.
696
744
  */
697
745
  export function activeRetryBan(events, taskId, channel) {
698
- const e = decisionForNextDispatch(events, taskId, "gate-fingerprint-cap");
699
- return e && e.data.channel === channel && typeof e.data.gate === "string" ? e.data.gate : undefined;
746
+ let pending;
747
+ for (const e of events) {
748
+ if (e.taskId !== taskId)
749
+ continue;
750
+ if (e.event === "gate-fingerprint-cap")
751
+ pending = e;
752
+ else if (e.event === DECISION_SPENT
753
+ || (e.event === "task-approved" && e.data.release === RECHECK_RELEASE))
754
+ pending = undefined;
755
+ }
756
+ return pending && typeof pending.data.channel === "string"
757
+ && channelRouteIdentity(pending.data.channel) === channelRouteIdentity(channel)
758
+ && typeof pending.data.gate === "string"
759
+ ? pending.data.gate
760
+ : undefined;
700
761
  }
701
762
  // Fail-closed shape for a dispatched assignment (journal.ts:75-90 posture): a malformed assignment in
702
763
  // one dispatch degrades that single task toward today's behavior — counts toward attempts, contributes
@@ -7,6 +7,12 @@ export declare const SUPERVISION_FUTURE_GRACE_MS = 1000;
7
7
  export declare const SUPERVISION_DEFAULT_THRESHOLD_PCT = 75;
8
8
  export declare const SUPERVISION_TIERS: readonly ["orchestrator", "orchestrator-context", "overseer", "overseer-context", "watch"];
9
9
  export type SupervisionTier = (typeof SUPERVISION_TIERS)[number];
10
+ /**
11
+ * Resolve a one-shot supervision writer to the repository state it is claiming to supervise.
12
+ * The nearest state directory wins; reaching a git root without one is a refusal, not permission
13
+ * to create fresh state there or to wander into an enclosing repository. This function is read-only.
14
+ */
15
+ export declare function resolveSupervisionRoot(cwd: string): string;
10
16
  export declare const SUPERVISION_SEAT_TIERS: readonly ["orchestrator", "orchestrator-context", "overseer", "overseer-context"];
11
17
  export type SeatTier = (typeof SUPERVISION_SEAT_TIERS)[number];
12
18
  /** Does this tier's record have to name the seat it speaks for? */
@@ -1,6 +1,6 @@
1
1
  import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
2
2
  import { randomUUID } from "node:crypto";
3
- import { dirname, join } from "node:path";
3
+ import { dirname, join, resolve } from "node:path";
4
4
  import { stateDirName, tickmarkrDir } from "../graph/graph.js";
5
5
  // SUP-01: supervision liveness as FILE STATE, not as a report — lock.ts's proven shape, one file per
6
6
  // tier. A watcher beats its OWN file; every other seat derives that tier's state by stat()ing it. So
@@ -41,6 +41,34 @@ export const SUPERVISION_DEFAULT_THRESHOLD_PCT = 75;
41
41
  export const SUPERVISION_TIERS = [
42
42
  "orchestrator", "orchestrator-context", "overseer", "overseer-context", "watch",
43
43
  ];
44
+ const pathKind = (path) => {
45
+ try {
46
+ return statSync(path).isDirectory() ? "directory" : "present";
47
+ }
48
+ catch {
49
+ return "absent";
50
+ }
51
+ };
52
+ /**
53
+ * Resolve a one-shot supervision writer to the repository state it is claiming to supervise.
54
+ * The nearest state directory wins; reaching a git root without one is a refusal, not permission
55
+ * to create fresh state there or to wander into an enclosing repository. This function is read-only.
56
+ */
57
+ export function resolveSupervisionRoot(cwd) {
58
+ let current = resolve(cwd);
59
+ for (;;) {
60
+ if (pathKind(join(current, stateDirName(current))) === "directory")
61
+ return current;
62
+ if (pathKind(join(current, ".git")) !== "absent") {
63
+ throw new Error(`missing ${stateDirName(current)}/ state dir at repository root ${current}`);
64
+ }
65
+ const parent = dirname(current);
66
+ if (parent === current) {
67
+ throw new Error(`missing ${stateDirName(current)}/ state dir — no tickmarkr repository found from ${resolve(cwd)}`);
68
+ }
69
+ current = parent;
70
+ }
71
+ }
44
72
  // The tiers whose records must NAME the seat behind them. A one-shot `tickmarkr beat` records a pid
45
73
  // that has already exited by the time anyone reads it, and an instant — nothing a reader can attribute
46
74
  // to a seat. Measured 2026-08-26: a consult seat of another tier ran the documented beat loop and the
@@ -68,6 +68,10 @@ export type FleetModelGroup = {
68
68
  detectedAt?: string;
69
69
  suggestion?: FleetModelSuggestion;
70
70
  evidence?: FleetModelEvidence;
71
+ classifyModel?: string;
72
+ variants?: string[];
73
+ foldedModels?: string[];
74
+ score?: number;
71
75
  }>;
72
76
  };
73
77
  export type FleetClassification = {
@@ -96,7 +96,8 @@ export function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels
96
96
  };
97
97
  const groupRows = (group) => {
98
98
  const rows = group.rows.map((row) => {
99
- const staged = ui.classifications.find((classification) => classification.adapter === group.adapter && classification.model === row.model);
99
+ const staged = ui.classifications.find((classification) => classification.adapter === group.adapter
100
+ && (classification.model === row.model || classification.model === row.classifyModel));
100
101
  return {
101
102
  adapter: group.adapter,
102
103
  model: row.model,
@@ -105,10 +106,14 @@ export function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels
105
106
  suggestion: row.suggestion,
106
107
  evidence: row.evidence,
107
108
  channel: group.channel,
109
+ classifyModel: row.classifyModel,
110
+ variants: row.variants,
111
+ foldedModels: row.foldedModels,
112
+ score: row.score,
108
113
  denied: ui.denyModels.has(`${group.adapter}:${row.model}`),
109
114
  };
110
115
  });
111
- const known = new Set(rows.map((row) => row.model));
116
+ const known = new Set(rows.flatMap((row) => row.classifyModel ? [row.model, row.classifyModel] : [row.model]));
112
117
  for (const staged of ui.classifications) {
113
118
  if (staged.adapter === group.adapter && !known.has(staged.model)) {
114
119
  rows.push({
@@ -125,10 +130,21 @@ export function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels
125
130
  const matches = (label, f) => f === "" || label.toLowerCase().includes(f.toLowerCase());
126
131
  // Operator directive 2026-08-13: retired shapes (dated snapshots, previews, non-worker SKUs,
127
132
  // legacy families) hide by DEFAULT. `a` reveals them; a CLASSIFIED row is never hidden.
128
- const modelRows = (f = ui.filter) => scopedGroups().flatMap(groupRows).filter((row) => matches(`${row.adapter}/${row.model}`, f)
129
- && (ui.showAll || row.tier !== undefined || retiredModelReason(row.model) === null));
130
- const hiddenModelCount = (f = ui.filter) => scopedGroups().flatMap(groupRows).filter((row) => matches(`${row.adapter}/${row.model}`, f)).length
133
+ const modelRows = (f = ui.filter) => {
134
+ const rows = scopedGroups().flatMap(groupRows).filter((row) => matches(`${row.adapter}/${row.model} ${(row.foldedModels ?? []).join(" ")}`, f)
135
+ && (ui.showAll || row.tier !== undefined || retiredModelReason(row.model) === null));
136
+ const classified = rows.filter((row) => row.tier !== undefined);
137
+ const unclassified = rows.filter((row) => row.tier === undefined).sort((a, b) => Number(!!b.suggestion) - Number(!!a.suggestion)
138
+ || (b.detectedAt ?? "").localeCompare(a.detectedAt ?? "")
139
+ || (b.score ?? Number.NEGATIVE_INFINITY) - (a.score ?? Number.NEGATIVE_INFINITY)
140
+ || `${a.adapter}/${a.model}`.localeCompare(`${b.adapter}/${b.model}`));
141
+ return [...classified, ...unclassified];
142
+ };
143
+ const hiddenModelCount = (f = ui.filter) => scopedGroups().flatMap(groupRows)
144
+ .filter((row) => matches(`${row.adapter}/${row.model} ${(row.foldedModels ?? []).join(" ")}`, f)).length
131
145
  - modelRows(f).length;
146
+ const foldedModelCount = (f = ui.filter) => modelRows(f)
147
+ .reduce((count, row) => count + Math.max((row.foldedModels?.length ?? 1) - 1, 0), 0);
132
148
  /** rail counts mirror the list's retired-hide (never the text filter) so numbers agree on screen */
133
149
  const visibleCount = (scope) => {
134
150
  const groups = scope === -1 ? enabledGroups() : (modelGroups[scope] ? [modelGroups[scope]] : []);
@@ -232,14 +248,17 @@ export function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels
232
248
  return;
233
249
  // OBS-508: catalog evidence prefills the flow — the tier cursor lands on the suggested band and
234
250
  // (when the operator keeps that band) the provenance note arrives pre-typed. Free to override.
235
- const suggestion = group.rows.find((row) => row.model === model)?.suggestion ?? null;
251
+ const source = group.rows.find((row) => row.model === model);
252
+ const suggestion = source?.suggestion ?? null;
253
+ const classifyModel = source?.classifyModel ?? model;
236
254
  const tierAt = suggestion ? Math.max(TIERS.indexOf(suggestion.tier), 0) : 0;
237
255
  const firstTouch = !group.rows.some((row) => row.tier !== undefined)
238
256
  && !ui.classifications.some((c) => c.adapter === adapter);
239
257
  const base = {
240
258
  kind: "classify",
241
259
  adapter,
242
- model,
260
+ model: classifyModel,
261
+ ...(classifyModel !== model ? { displayModel: model } : {}),
243
262
  channelAt: 0,
244
263
  tierAt,
245
264
  note: "",
@@ -966,7 +985,7 @@ export function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels
966
985
  const group = modelGroups[ui.adapterAt];
967
986
  const suggested = rows
968
987
  .filter((candidate) => candidate.adapter === group.adapter && !candidate.tier && candidate.suggestion !== undefined)
969
- .map((candidate) => ({ model: candidate.model, suggestion: candidate.suggestion }));
988
+ .map((candidate) => ({ model: candidate.classifyModel ?? candidate.model, suggestion: candidate.suggestion }));
970
989
  if (!suggested.length) {
971
990
  ui.notice = "no catalog tier suggestions among the visible unclassified models — evidence comes from the cached catalogs (AA index / API pricing)";
972
991
  bump();
@@ -1101,6 +1120,9 @@ export function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels
1101
1120
  const parts = [
1102
1121
  `${row.adapter}:${row.model}`,
1103
1122
  row.tier ?? "unclassified",
1123
+ row.variants?.length ? `variants ${row.variants.join(", ")}` : "",
1124
+ row.classifyModel ? `classify writes ${row.classifyModel}` : "",
1125
+ row.foldedModels?.length ? `${row.foldedModels.length} gateway ids folded: ${row.foldedModels.join(", ")}` : "",
1104
1126
  row.evidence?.unauthed !== undefined ? `UNAUTHED — ${row.evidence.unauthed}` : "",
1105
1127
  row.evidence?.contextWindow !== undefined ? `${fmtCtx(row.evidence.contextWindow)} ctx` : "",
1106
1128
  row.evidence?.outputWindow !== undefined ? `${fmtCtx(row.evidence.outputWindow)} out` : "",
@@ -1120,7 +1142,8 @@ export function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels
1120
1142
  const nameW = bodyW - 4 - 11 - 6 - (showPrice ? 12 : 0) - (showProbe ? 7 : 0);
1121
1143
  // OBS-531: deep router ids (omp/prime-agent) clip tail-preserving — the LAST segment is the
1122
1144
  // distinguishing half; end-clipping rendered ten identical "prime-agent/anthropic/claude-…" rows.
1123
- const name = clipPathTail(`${row.adapter}/${row.model}`, nameW);
1145
+ const folded = row.foldedModels?.length ?? 1;
1146
+ const name = clipPathTail(`${row.adapter}/${row.model}${folded > 1 ? ` ×${folded}` : ""}`, nameW);
1124
1147
  const prefixLen = Math.min(name.length, row.adapter.length + 1);
1125
1148
  return (_jsxs(Text, { wrap: "truncate", children: [_jsx(Pointer, { on: selected }), row.denied
1126
1149
  ? _jsx(Glyph, { kind: "off" })
@@ -1174,7 +1197,9 @@ export function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels
1174
1197
  if (overlay.kind === "classify") {
1175
1198
  const subject = overlay.bulk
1176
1199
  ? `${overlay.adapter} · ${overlay.bulk.rows.length} suggested models`
1177
- : `${overlay.adapter}:${overlay.model}`;
1200
+ : overlay.displayModel
1201
+ ? `${overlay.adapter}:${overlay.displayModel} · writes ${overlay.model}`
1202
+ : `${overlay.adapter}:${overlay.model}`;
1178
1203
  return (_jsxs(OverlayPanel, { title: `classify · ${subject}`, width: bodyW, children: [overlay.stage === "channel" && (_jsxs(_Fragment, { children: [_jsxs(Text, { dimColor: true, children: ["first touch for ", overlay.adapter, " \u2014 how is this CLI billed?"] }), _jsx(Text, { children: " " }), CHANNELS.map((channel, index) => (_jsxs(Text, { children: [_jsx(Pointer, { on: overlay.channelAt === index }), _jsx(Text, { bold: overlay.channelAt === index, children: padCell(channel, 5) }), _jsx(Text, { dimColor: true, children: channel === "sub" ? "flat-rate subscription quota" : "metered API billing" })] }, channel)))] })), overlay.stage === "tier" && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: overlay.suggestion
1179
1204
  ? `catalog suggests ${overlay.suggestion.tier} — keep it and the provenance note arrives pre-typed`
1180
1205
  : "pick the capability band this model routes as" }), _jsx(Text, { children: " " }), TIERS.map((tier, index) => (_jsxs(Text, { children: [_jsx(Pointer, { on: overlay.tierAt === index }), overlay.suggestion?.tier === tier ? _jsx(Glyph, { kind: "on" }) : _jsx(Text, { children: " " }), _jsx(Text, { bold: overlay.tierAt === index, children: ` ${padCell(tier, 9)}` }), _jsx(Text, { dimColor: true, children: tier === "frontier" ? "strongest band — integrity shapes" : tier === "mid" ? "capable daily driver" : "fast + cheap" })] }, tier)))] })), overlay.stage === "note" && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: "benchmark provenance (required) \u2014 where does this tier claim come from?" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: INK.brand, children: "> " }), _jsx(Text, { children: clip(overlay.note, bodyW - 6) || "" }), _jsx(Text, { color: INK.brand, children: "\u2588" })] })] }))] }));
@@ -1220,6 +1245,15 @@ export function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels
1220
1245
  const selectedSeat = ui.judgeSeat ?? judgeKeepRow;
1221
1246
  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" })] }));
1222
1247
  })();
1248
+ const modelVisibilityLine = () => {
1249
+ const hidden = hiddenModelCount();
1250
+ const folded = foldedModelCount();
1251
+ return [
1252
+ ...(folded ? [`${folded} same-model gateway id${folded === 1 ? "" : "s"} folded`] : []),
1253
+ ...(hidden ? [`${hidden} retired/preview/non-worker hidden — a shows all`] : []),
1254
+ ...(ui.showAll ? ["showing retired models — a hides them again"] : []),
1255
+ ].join(" · ");
1256
+ };
1223
1257
  const listNode = (() => {
1224
1258
  if (overlayNode)
1225
1259
  return overlayNode;
@@ -1230,7 +1264,7 @@ export function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels
1230
1264
  ? "All models"
1231
1265
  : modelGroups[ui.adapterAt]?.adapter ?? "";
1232
1266
  const scopeDenied = ui.adapterAt !== -1 && ui.deny.has(modelGroups[ui.adapterAt]?.adapter ?? "");
1233
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: scopeLabel }), _jsx(Text, { dimColor: true, children: ` ${rows.length}` })] }), _jsx(SearchRow, { filter: ui.filter, active: ui.searching, hint: "/ to search" }), scopeDenied && _jsx(Text, { color: INK.warn, children: `${modelGroups[ui.adapterAt]?.adapter} is out of the fleet — Space on its rail row adds it back` }), above > 0 && _jsx(ElisionMark, { count: above, side: "above" }), visible.map((row, index) => renderModelRow(row, ui.focus === "list" && start + index === ui.listAt)), below > 0 && _jsx(ElisionMark, { count: below, side: "below", hint: "/ to search" }), rows.length === 0 && !scopeDenied && _jsx(Text, { dimColor: true, children: " no models match" })] }));
1267
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: scopeLabel }), _jsx(Text, { dimColor: true, children: ` ${rows.length}` })] }), _jsx(SearchRow, { filter: ui.filter, active: ui.searching, hint: "/ to search" }), modelVisibilityLine() && _jsx(Text, { dimColor: true, children: ` ${modelVisibilityLine()}` }), scopeDenied && _jsx(Text, { color: INK.warn, children: `${modelGroups[ui.adapterAt]?.adapter} is out of the fleet — Space on its rail row adds it back` }), above > 0 && _jsx(ElisionMark, { count: above, side: "above" }), visible.map((row, index) => renderModelRow(row, ui.focus === "list" && start + index === ui.listAt)), below > 0 && _jsx(ElisionMark, { count: below, side: "below", hint: "/ to search" }), rows.length === 0 && !scopeDenied && _jsx(Text, { dimColor: true, children: " no models match" })] }));
1234
1268
  }
1235
1269
  if (ui.view === "shapes") {
1236
1270
  const rows = shapeList();
@@ -1266,11 +1300,6 @@ export function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels
1266
1300
  : "unclassified — Space/Enter classifies; unclassified models are never routed");
1267
1301
  }
1268
1302
  }
1269
- const hidden = hiddenModelCount();
1270
- if (hidden > 0 && lines.length < 2)
1271
- lines.push(`${hidden} retired/preview/non-worker hidden — a shows all`);
1272
- if (ui.showAll && lines.length < 2)
1273
- lines.push("showing retired models — a hides them again");
1274
1303
  return lines;
1275
1304
  }
1276
1305
  if (ui.view === "shapes") {
@@ -5,13 +5,13 @@ import { ToggleMark } from "./components.js";
5
5
  import { clip, INK, inkInput, inkOutput, KeyBar, padCell, Pointer } from "./frame.js";
6
6
  const DRIVERS = ["auto", "herdr", "subprocess", "orca"];
7
7
  // One line per driver, shown for the value currently selected: four environments no longer fit on
8
- // a single description row at 80 columns (the shipped three already clipped), and orca is an
9
- // EXPLICIT choice — `auto` still means herdr-else-subprocess and never reaches for it.
8
+ // a single description row at 80 columns (the shipped three already clipped). Auto identifies its
9
+ // innermost host from environment markers only; outside an Orca terminal, Orca is named explicitly.
10
10
  const DRIVER_DESC = {
11
- auto: "auto: herdr when HERDR_ENV=1, else subprocess — never orca",
11
+ auto: "auto: herdr when HERDR_ENV=1, else orca inside an Orca terminal, else subprocess",
12
12
  herdr: "herdr: every worker runs in a visible pane you can watch and unblock",
13
13
  subprocess: "subprocess: headless child processes — no cockpit, same fail-closed gates",
14
- orca: "orca: visible terminals in the Orca app — an explicit choice, never auto's",
14
+ orca: "orca: visible terminals in the Orca app — picked by auto inside one; name it elsewhere",
15
15
  };
16
16
  const VISIBILITY = ["pane", "headless"];
17
17
  // Three fields, one toggle, one action — a descriptor array, deliberately not a forms framework.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "2.2.1",
3
+ "version": "2.4.0",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -57,5 +57,63 @@
57
57
  "tsx": "^4.19.0",
58
58
  "typescript": "^5.6.0",
59
59
  "vitest": "^3.0.0"
60
+ },
61
+ "tickmarkrExport": {
62
+ "publicPaths": {
63
+ "exact": [
64
+ ".gitignore",
65
+ ".oxlintrc.json",
66
+ "CHANGELOG.md",
67
+ "CODE_OF_CONDUCT.md",
68
+ "CONTRIBUTING.md",
69
+ "FLEET.md",
70
+ "LICENSE",
71
+ "README.md",
72
+ "RELEASING.md",
73
+ "SECURITY.md",
74
+ "package-lock.json",
75
+ "package.json",
76
+ "tickmarkr.spec.md",
77
+ "tsconfig.json",
78
+ "vitest.config.ts",
79
+ ".github/pull_request_template.md",
80
+ "scripts/assert-test-file-count.sh",
81
+ "scripts/emit-schema.ts",
82
+ "scripts/probe-rig.mjs",
83
+ "specs/export-selftest.spec.md"
84
+ ],
85
+ "prefixes": [
86
+ ".github/ISSUE_TEMPLATE/",
87
+ ".github/workflows/",
88
+ "assets/",
89
+ "docs/codebase/",
90
+ "fixtures/",
91
+ "schema/",
92
+ "skills/tickmarkr-auto/",
93
+ "skills/tickmarkr-loop/",
94
+ "skills/tickmarkr-overseer/",
95
+ "src/",
96
+ "tests/"
97
+ ]
98
+ },
99
+ "excludedPaths": [
100
+ ".planning",
101
+ "specs",
102
+ ".tickmarkr",
103
+ ".overseer",
104
+ ".claude",
105
+ "docs",
106
+ "ASSESSMENT-*.md",
107
+ ".gitignore",
108
+ "scripts/measure-trailer-width.mjs",
109
+ "scripts/scan-scope-corpus.mjs",
110
+ "scripts/repro-obs96.mjs",
111
+ "CLAUDE.md",
112
+ "scripts/export-public.sh",
113
+ "scripts/verify-export.sh",
114
+ "tests/scripts/verify-export.test.ts",
115
+ ".github/workflows/ci.yml",
116
+ "**/*.local.*"
117
+ ]
60
118
  }
61
119
  }