tickmarkr 1.86.0 → 1.89.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 (74) hide show
  1. package/dist/adapters/catalog.d.ts +30 -1
  2. package/dist/adapters/catalog.js +58 -2
  3. package/dist/adapters/fake.d.ts +2 -1
  4. package/dist/adapters/fake.js +7 -0
  5. package/dist/adapters/grok.js +11 -0
  6. package/dist/adapters/kimi.d.ts +2 -1
  7. package/dist/adapters/kimi.js +36 -0
  8. package/dist/adapters/opencode.js +17 -0
  9. package/dist/adapters/pi.js +11 -0
  10. package/dist/adapters/prompt.js +8 -1
  11. package/dist/adapters/registry.d.ts +10 -2
  12. package/dist/adapters/registry.js +126 -67
  13. package/dist/adapters/types.d.ts +34 -3
  14. package/dist/adapters/types.js +99 -1
  15. package/dist/cli/commands/approve.d.ts +2 -0
  16. package/dist/cli/commands/approve.js +104 -84
  17. package/dist/cli/commands/compile.d.ts +1 -1
  18. package/dist/cli/commands/compile.js +29 -12
  19. package/dist/cli/commands/init.js +1 -1
  20. package/dist/cli/commands/plan.d.ts +1 -1
  21. package/dist/cli/commands/plan.js +21 -2
  22. package/dist/cli/commands/report.js +49 -0
  23. package/dist/cli/commands/resume.js +7 -1
  24. package/dist/cli/commands/status.js +298 -96
  25. package/dist/cli/harness.d.ts +13 -0
  26. package/dist/cli/harness.js +50 -0
  27. package/dist/compile/collateral.js +4 -4
  28. package/dist/compile/index.d.ts +14 -3
  29. package/dist/compile/index.js +36 -10
  30. package/dist/compile/native.js +108 -25
  31. package/dist/drivers/subprocess.d.ts +6 -1
  32. package/dist/drivers/subprocess.js +9 -4
  33. package/dist/gates/acceptance.d.ts +21 -1
  34. package/dist/gates/acceptance.js +67 -22
  35. package/dist/gates/artifact-manifest.d.ts +119 -0
  36. package/dist/gates/artifact-manifest.js +357 -0
  37. package/dist/gates/baseline.d.ts +6 -0
  38. package/dist/gates/baseline.js +52 -7
  39. package/dist/gates/llm.js +37 -26
  40. package/dist/gates/review.d.ts +16 -11
  41. package/dist/gates/review.js +44 -150
  42. package/dist/gates/run-gates.d.ts +1 -0
  43. package/dist/gates/run-gates.js +145 -9
  44. package/dist/graph/schema.d.ts +3 -1
  45. package/dist/graph/schema.js +4 -1
  46. package/dist/route/preference.d.ts +1 -1
  47. package/dist/route/preference.js +8 -1
  48. package/dist/run/consult.js +14 -1
  49. package/dist/run/daemon.d.ts +42 -0
  50. package/dist/run/daemon.js +2322 -1963
  51. package/dist/run/git.d.ts +50 -0
  52. package/dist/run/git.js +113 -2
  53. package/dist/run/interactive-seed.d.ts +6 -2
  54. package/dist/run/interactive-seed.js +72 -5
  55. package/dist/run/journal.d.ts +9 -1
  56. package/dist/run/journal.js +99 -9
  57. package/dist/run/lock.d.ts +11 -0
  58. package/dist/run/lock.js +97 -6
  59. package/dist/run/outcome.d.ts +50 -0
  60. package/dist/run/outcome.js +152 -0
  61. package/dist/run/protocol.d.ts +460 -0
  62. package/dist/run/protocol.js +433 -0
  63. package/dist/run/supervision.d.ts +29 -0
  64. package/dist/run/supervision.js +189 -0
  65. package/fixtures/gateway-models.json +1 -0
  66. package/fixtures/wrapped-acceptance.native.md +29 -0
  67. package/package.json +1 -1
  68. package/schema/rungraph.schema.json +21 -2
  69. package/skills/tickmarkr-overseer/SKILL.md +366 -4
  70. package/skills/tickmarkr-overseer/scripts/watch-artifacts.sh +95 -8
  71. package/skills/tickmarkr-overseer/scripts/watch-contamination.sh +77 -0
  72. package/skills/tickmarkr-overseer/scripts/watch-context.sh +86 -0
  73. package/skills/tickmarkr-overseer/scripts/watch-parks.sh +96 -0
  74. package/skills/tickmarkr-overseer/scripts/watch-pending-input.sh +183 -0
@@ -1,9 +1,20 @@
1
1
  import { z } from "zod";
2
- import type { WorkerAdapter } from "./types.js";
2
+ import { type WorkerAdapter } from "./types.js";
3
3
  export declare const CLI_NAME_RE: RegExp;
4
+ export declare const MODEL_ID_FIELDS: readonly ["id", "selector", "model"];
5
+ export type ModelIdField = (typeof MODEL_ID_FIELDS)[number];
6
+ export declare const DRIVE_TRUST_DIALOG_MESSAGE = "drive contract must declare trustDialog \u2014 either the workspace-trust prompt this CLI renders, captured verbatim as {fingerprint, key}, or {kind: none, reason}";
4
7
  export declare const CliDriveSchema: z.ZodObject<{
5
8
  headless: z.ZodString;
6
9
  interactive: z.ZodNullable<z.ZodString>;
10
+ trustDialog: z.ZodUnion<readonly [z.ZodObject<{
11
+ kind: z.ZodOptional<z.ZodLiteral<"dialog">>;
12
+ fingerprint: z.ZodString;
13
+ key: z.ZodString;
14
+ }, z.core.$strict>, z.ZodObject<{
15
+ kind: z.ZodLiteral<"none">;
16
+ reason: z.ZodString;
17
+ }, z.core.$strict>]>;
7
18
  listModels: z.ZodOptional<z.ZodObject<{
8
19
  argv: z.ZodArray<z.ZodString>;
9
20
  parser: z.ZodEnum<{
@@ -12,6 +23,11 @@ export declare const CliDriveSchema: z.ZodObject<{
12
23
  json: "json";
13
24
  }>;
14
25
  path: z.ZodOptional<z.ZodString>;
26
+ field: z.ZodOptional<z.ZodEnum<{
27
+ id: "id";
28
+ model: "model";
29
+ selector: "selector";
30
+ }>>;
15
31
  }, z.core.$strict>>;
16
32
  }, z.core.$strict>;
17
33
  declare const CliEntryBaseSchema: z.ZodObject<{
@@ -28,6 +44,14 @@ export declare const CliEntrySchema: z.ZodObject<{
28
44
  drive: z.ZodOptional<z.ZodObject<{
29
45
  headless: z.ZodString;
30
46
  interactive: z.ZodNullable<z.ZodString>;
47
+ trustDialog: z.ZodUnion<readonly [z.ZodObject<{
48
+ kind: z.ZodOptional<z.ZodLiteral<"dialog">>;
49
+ fingerprint: z.ZodString;
50
+ key: z.ZodString;
51
+ }, z.core.$strict>, z.ZodObject<{
52
+ kind: z.ZodLiteral<"none">;
53
+ reason: z.ZodString;
54
+ }, z.core.$strict>]>;
31
55
  listModels: z.ZodOptional<z.ZodObject<{
32
56
  argv: z.ZodArray<z.ZodString>;
33
57
  parser: z.ZodEnum<{
@@ -36,6 +60,11 @@ export declare const CliEntrySchema: z.ZodObject<{
36
60
  json: "json";
37
61
  }>;
38
62
  path: z.ZodOptional<z.ZodString>;
63
+ field: z.ZodOptional<z.ZodEnum<{
64
+ id: "id";
65
+ model: "model";
66
+ selector: "selector";
67
+ }>>;
39
68
  }, z.core.$strict>>;
40
69
  }, z.core.$strict>>;
41
70
  }, z.core.$strict>;
@@ -10,15 +10,39 @@ import { grok } from "./grok.js";
10
10
  import { kimi } from "./kimi.js";
11
11
  import { opencode } from "./opencode.js";
12
12
  import { pi } from "./pi.js";
13
+ import { TRUST_DIALOG_VARIANTS, TrustDialogSchema } from "./types.js";
13
14
  export const CLI_NAME_RE = /^[a-z0-9-]+$/;
15
+ // `field` names the ONE key projected out of an array of objects — a model id and nothing else.
16
+ // `{"models":[{…}]}` is the normal shape of a modern CLI's --json output, and without a selector the
17
+ // json parser only reads the rarer array-of-bare-strings. The allowlist is the rule, not a comment:
18
+ // `cost` (prices have one authority, catalog-remote.ts) and `provider` (vendor landing is a separate
19
+ // decision) are not id keys, so no config can name them and no parser call can be typed with them —
20
+ // a schema that took any string would let either be harvested as a second source of truth.
21
+ export const MODEL_ID_FIELDS = ["id", "selector", "model"];
14
22
  const ListModelsSchema = z.object({
15
23
  argv: z.array(z.string()).min(1),
16
24
  parser: z.enum(["lines", "pi-table", "json"]),
17
25
  path: z.string().min(1).optional(),
18
- }).strict();
26
+ field: z.enum(MODEL_ID_FIELDS).optional(),
27
+ }).strict().superRefine((listModels, ctx) => {
28
+ // A selector the parser would silently ignore is the same silence this contract exists to end.
29
+ if (listModels.field !== undefined && listModels.parser !== "json") {
30
+ ctx.addIssue({ code: "custom", path: ["field"], message: "field is only read by the json parser" });
31
+ }
32
+ });
33
+ // v1.89 T1 / OBS-414: a drive contract declares its trust posture or it is not a drive contract.
34
+ // `declarativeAdapter()` builds every catalog-driven CLI, so a contract without this field would
35
+ // reintroduce the silent opt-out at the one construction site no adapter file covers. Absence gets
36
+ // its own operator sentence; a malformed declaration keeps the union's own diagnosis.
37
+ // Quote-free by construction: a zod issue reaches the operator as a JSON dump, where an embedded
38
+ // double quote is escaped and no longer reads back as the sentence that was written.
39
+ export const DRIVE_TRUST_DIALOG_MESSAGE = "drive contract must declare trustDialog — either the workspace-trust prompt this CLI renders, captured verbatim as {fingerprint, key}, or {kind: none, reason}";
19
40
  export const CliDriveSchema = z.object({
20
41
  headless: z.string().min(1),
21
42
  interactive: z.string().min(1).nullable(),
43
+ trustDialog: z.union(TRUST_DIALOG_VARIANTS, {
44
+ error: (issue) => (issue.input === undefined ? DRIVE_TRUST_DIALOG_MESSAGE : undefined),
45
+ }),
22
46
  listModels: ListModelsSchema.optional(),
23
47
  }).strict().superRefine((drive, ctx) => {
24
48
  for (const [field, template] of [["headless", drive.headless], ["interactive", drive.interactive]]) {
@@ -69,6 +93,14 @@ function validateCliEntry(entry) {
69
93
  if (drive.adapter.id !== base.id || drive.adapter.vendor !== base.vendor) {
70
94
  throw new Error(`native drive identity mismatch for ${base.id}`);
71
95
  }
96
+ // v1.89 T1 / OBS-414: native adapters carry their declaration in code, so the compiler enforces
97
+ // PRESENCE — it cannot enforce that the bytes are a real capture. The same schema the drive
98
+ // contract uses runs here, on the registry's own construction path, so a blank fingerprint or a
99
+ // prompt that is not a workspace-trust gate is unbuildable from either side of the catalog.
100
+ const trust = TrustDialogSchema.safeParse(drive.adapter.trustDialog);
101
+ if (!trust.success) {
102
+ throw new Error(`invalid trust declaration for ${base.id}: ${trust.error.issues.map((i) => i.message).join("; ")}`);
103
+ }
72
104
  return { ...base, drive };
73
105
  }
74
106
  // Package-owned, deterministic order. This is the sole shipped definition array: candidate-name
@@ -90,7 +122,31 @@ export const CLI_CATALOG = [
90
122
  native(pi, "pi"),
91
123
  native(grok, "grok"),
92
124
  native(kimi, "kimi"),
93
- "gemini", "qwen", "aider", "goose", "amp", "droid", "auggie", "crush", "omp",
125
+ "gemini", "qwen", "aider", "goose", "amp", "droid", "auggie", "crush",
126
+ {
127
+ id: "omp",
128
+ binary: "omp",
129
+ // PROBE-omp-v189.md, re-recorded by hand 2026-08-07: `omp --version` → `omp/17.2.10`.
130
+ // A prefix match, so a patch bump does not re-open the identity gate.
131
+ identity: "^omp/",
132
+ // omp is a multi-provider gateway; a selected model carries its real vendor in its own prefix.
133
+ vendor: "mixed",
134
+ drive: {
135
+ // The same probe recorded -p as the fail-closed print mode (exit 1 on an unknown model).
136
+ // interactive deliberately omits it: `interactive` is nullable and `headless` is required, so
137
+ // the invisible configuration is the easier one to write — this one is written visible.
138
+ headless: "omp -p --model {model} @{promptFile}",
139
+ interactive: "omp --model {model} @{promptFile}",
140
+ trustDialog: {
141
+ kind: "none",
142
+ reason: "omp 17.2.10 showed no workspace-trust prompt during the recorded interactive probe (PROBE-omp-v189.md, 2026-08-07)",
143
+ },
144
+ // The recorded payload is an OBJECT keyed by models: 370 entries, selector prefixed 370/370
145
+ // and the neighbouring bare `id` prefixed 0/370. Projecting `id` would return 370 plausible
146
+ // ids that route to nothing, so the selector is the only id key this contract may name.
147
+ listModels: { argv: ["models", "ls", "--json"], parser: "json", path: "models", field: "selector" },
148
+ },
149
+ },
94
150
  ].flatMap((entry) => typeof entry === "string"
95
151
  ? [{ id: entry, binary: entry, identity: ".+", vendor: null }]
96
152
  : [entry]);
@@ -1,6 +1,6 @@
1
1
  import type { TickmarkrConfig } from "../config/config.js";
2
2
  import type { Task } from "../graph/schema.js";
3
- import { type Assignment, type AuthHealth, type BillingChannel, type ContextUsage, type Invocation, type SessionRef, type TokenUsage, type WorkerAdapter, type WorkerResult } from "./types.js";
3
+ import { type Assignment, type AuthHealth, type BillingChannel, type ContextUsage, type Invocation, type SessionRef, type TokenUsage, type TrustDialog, type WorkerAdapter, type WorkerResult } from "./types.js";
4
4
  export interface FakeScript {
5
5
  tasks: Record<string, Array<{
6
6
  shell: string;
@@ -25,6 +25,7 @@ export declare class FakeAdapter implements WorkerAdapter {
25
25
  private scriptPath;
26
26
  id: string;
27
27
  vendor: string;
28
+ trustDialog: TrustDialog;
28
29
  private script;
29
30
  private attempts;
30
31
  constructor(scriptPath: string);
@@ -7,6 +7,13 @@ export class FakeAdapter {
7
7
  scriptPath;
8
8
  id = "fake";
9
9
  vendor = "fake-a";
10
+ // v1.89 T1 / OBS-414: the scripted adapter runs `bash -c` — it has no TUI, so no prompt of any
11
+ // kind can render. Daemon tests that exercise the auto-answer seam assign a captured declaration
12
+ // to this field explicitly; that assignment is the falsifier for the claim made here.
13
+ trustDialog = {
14
+ kind: "none",
15
+ reason: "the zero-token scripted adapter dispatches a bash command and renders no TUI, so no trust prompt exists to capture",
16
+ };
10
17
  script;
11
18
  attempts = new Map();
12
19
  constructor(scriptPath) {
@@ -114,6 +114,17 @@ export const grok = {
114
114
  return { command: this.headlessCommand(ctx.promptFile, a.model) };
115
115
  },
116
116
  parse: parseWorkerResult, // prompt.ts — last-valid-trailer, hard-wrap tolerant. No grok fork.
117
+ // v1.89 T1 / OBS-414: no capture exists, so none is invented. grok 0.2.93 has never been observed
118
+ // rendering a workspace/folder-trust prompt: it loads the operator's global Claude-Code-compat
119
+ // config rather than keeping a per-directory trust store, and no grok dispatch in any recorded run
120
+ // has stalled before launch (OBS-414's own sweep lists grok's gap as MCP suppression, not trust).
121
+ // FALSIFIER, and it is cheap: dispatch grok into a never-seen worktree and read the pane. One
122
+ // capture of a trust prompt replaces this with {fingerprint, key} — the capture is the only
123
+ // admissible evidence, and a guess here would press a key on whatever it happened to match.
124
+ trustDialog: {
125
+ kind: "none",
126
+ reason: "grok 0.2.93 keeps no per-directory trust store and no recorded pane shows it rendering a workspace-trust prompt; falsified by one capture from a fresh worktree",
127
+ },
117
128
  // v1.5 MODEL-01: fail OPEN to [] (advisory detection, unlike gates). Parses the model LIST only —
118
129
  // the auth banner on line 1 is ignored BY CONSTRUCTION (parseGrokModels anchors on the header).
119
130
  // Advisory/doctor-only — never in the dispatch or auth path (types.ts:56-60).
@@ -1,5 +1,5 @@
1
1
  import type { ExecutorDriver, Slot } from "../drivers/types.js";
2
- import { type Assignment, type WorkerAdapter, type WorkerResult } from "./types.js";
2
+ import { type Assignment, type TrustDialog, type WorkerAdapter, type WorkerResult } from "./types.js";
3
3
  export declare function kimiAuthed(credentialsText: string, nowMs: number): boolean;
4
4
  export declare function parseKimiModels(raw: string): string[];
5
5
  export declare function parseKimiResult(raw: string, nonce: string): WorkerResult;
@@ -32,6 +32,7 @@ export interface KimiInteractiveSeedResult {
32
32
  sessionId?: string;
33
33
  }
34
34
  export declare const KIMI_INPUT_BOX: import("./types.js").InputBox;
35
+ export declare const KIMI_TRUST_DIALOG: TrustDialog;
35
36
  export declare function runKimiInteractiveSeed(opts: {
36
37
  driver: Pick<ExecutorDriver, "run" | "waitOutput" | "read">;
37
38
  slot: Slot;
@@ -234,6 +234,34 @@ const KIMI_SEED = {
234
234
  : confirmation;
235
235
  },
236
236
  };
237
+ // v1.89 T1 / OBS-414: the dialog that cost run …215 thirty minutes. Kimi records trust PER FOLDER
238
+ // (~/.kimi-code/workspaces.json), and tickmarkr recreates a worktree on every attempt, so every kimi
239
+ // dispatch after the first meets a brand-new path and a brand-new prompt. Neither `-y/--yolo` (tool
240
+ // calls) nor `--auto` (agent questions) covers it: the gate is raised before the agent loop.
241
+ //
242
+ // VERBATIM CAPTURES, read off live panes — never composed (v1.79 law). Each is quoted from the
243
+ // record that holds the FULLEST transcription of it, named here so a reviewer reads the same bytes:
244
+ // OBS-358, pane wW:p2TB, run-20260805-121252 (.overseer/REPAIR-v186/GATE-T28-1.md — five lines;
245
+ // .planning/OBSERVATIONS.md abridges the same capture to its first three):
246
+ // Trust this folder?
247
+ // /Users/…/.tickmarkr/worktrees.noindex/tickmarkr-run-20260805-121252--T29
248
+ // ❯ Trust this folder ← highlighted ("← highlighted" is the observer's annotation)
249
+ // Enable project MCP servers. Remembered for this folder.
250
+ // Don't trust
251
+ // OBS-406, pane wW:p32A, run-20260806-121758-…214 T5:
252
+ // Kimi Code loads project-level MCP servers (.mcp.json, .kimi-code/mcp.json) only in trusted folders.
253
+ // ❯ Trust this folder / Don't trust
254
+ //
255
+ // The fingerprint carries the SELECTION CURSOR, and that is the load-bearing half: OBS-406's own
256
+ // cursor-less watcher matched bare `Trust this folder` and produced 258 false wakes in 25 minutes —
257
+ // every one of them a supervisor pane with the words on screen as prose. A live modal renders a
258
+ // cursor glyph; prose never does. Enter accepts the highlighted "Trust this folder" option, and the
259
+ // two captures above are the reason the cursor+label pair is the fingerprint rather than either
260
+ // prompt's heading: the same gate ships two different headings.
261
+ export const KIMI_TRUST_DIALOG = {
262
+ fingerprint: "❯ Trust this folder",
263
+ key: "Enter",
264
+ };
237
265
  // v1.69 T7 / v1.71 T2: thin wrapper over the daemon's generic dispatch path for kimi-only tests.
238
266
  export async function runKimiInteractiveSeed(opts) {
239
267
  const r = await runInteractiveSeed({ ...opts, adapter: kimi });
@@ -260,6 +288,14 @@ export const kimi = {
260
288
  // v1.65 T3: every flag the command builders below hardcode (-S is resumeCommand's) — verified in
261
289
  // `kimi --help` 2026-07-22.
262
290
  hardcodedFlags: { binary: "kimi", flags: ["-p", "--model", "--output-format", "-S"] },
291
+ // KNOWN GAP, stated rather than implied: this declaration is consumed by the daemon's trust loop,
292
+ // which is entered only AFTER runInteractiveSeed returns — and that seed waits for
293
+ // `readinessMatch` for the whole task timeout, which is exactly the window this prompt blocks in
294
+ // (run …215). So on a kimi startup pane the declaration is correct and still unreached: the seed
295
+ // reports `readiness pattern not seen` with zero keypresses. Answering it during the readiness
296
+ // wait is a change to src/run/interactive-seed.ts — WHEN and HOW OFTEN a declaration is answered
297
+ // is T19's half of this contract, and src/run may not appear in this task's diff.
298
+ trustDialog: KIMI_TRUST_DIALOG,
263
299
  // KIMI-02 headless. -p prompt mode + explicit model, NO permission flag: kimi 0.26.0 rejects
264
300
  // -p combined with -y/--auto at argument parse time ("Cannot combine --prompt with --yolo",
265
301
  // OBS-67 — doctor probes all failed on it), and prompt mode is already non-interactive with
@@ -28,6 +28,23 @@ export const opencode = {
28
28
  return { command: this.headlessCommand(ctx.promptFile, a.model) };
29
29
  },
30
30
  parse: parseWorkerResult,
31
+ // v1.89 T1 / OBS-414: opencode 1.17.15 renders no workspace-trust gate, and the prompt it DOES
32
+ // render must never be declared here. A prior attempt at this contract pointed opencode's
33
+ // fingerprint at its tool-permission modal — captured verbatim as the three lines
34
+ // Permission required
35
+ // Allow once
36
+ // enter confirm
37
+ // The daemon answers a match with Enter, which selects "Allow once" and silently approves the
38
+ // first arbitrary tool request in every slot: stall protection converted into auto-approval
39
+ // (.planning/RULING-v189-T1-reauthor.md:11). Tool approval and workspace trust are different
40
+ // gates, and no line of that capture — including the keybar hint that slipped past the first
41
+ // refusal list — can be declared now: the schema requires a fingerprint that names a trust gate.
42
+ // FALSIFIER: a captured opencode pane showing a folder/workspace-trust prompt — not a permission
43
+ // prompt — replaces this with {fingerprint, key}.
44
+ trustDialog: {
45
+ kind: "none",
46
+ reason: "opencode 1.17.15 renders no workspace-trust prompt; its only modal is the tool-permission prompt (Permission required / Allow once), a different gate that must never be auto-answered; falsified by a captured opencode pane showing a folder-trust prompt",
47
+ },
31
48
  // v1.5 MODEL-01: fail OPEN to [] (advisory detection, unlike gates). Plain `models` (NOT --refresh):
32
49
  // opencode reads its own cache offline; --refresh adds an avoidable network dependency (RESEARCH
33
50
  // anti-pattern). Live-verified 2026-07-10, opencode 1.17.15.
@@ -62,6 +62,17 @@ export const pi = {
62
62
  return { command: this.headlessCommand(ctx.promptFile, a.model) };
63
63
  },
64
64
  parse: parseWorkerResult,
65
+ // v1.89 T1 / OBS-414: pi HAS a per-directory trust prompt, and both command builders above already
66
+ // suppress it with --approve — a global option legal in both modes (pi --help 0.80.3), chosen for
67
+ // exactly this reason. Live-checked 2026-07-10 in a fresh worktree: no trust prompt reached the
68
+ // pane, so there is nothing captured, and a fingerprint here would be a guess about a dialog this
69
+ // adapter never lets render.
70
+ // FALSIFIER: drop --approve from interactiveCommand, dispatch into a fresh worktree, and capture
71
+ // the prompt that appears — then this becomes {fingerprint, key} rather than a suppression claim.
72
+ trustDialog: {
73
+ kind: "none",
74
+ reason: "pi 0.80.3's per-directory trust prompt is suppressed before it renders by --approve on both command builders (live-checked 2026-07-10, fresh worktree, no prompt); falsified by removing --approve and capturing the pane",
75
+ },
65
76
  // v1.5 MODEL-01: fail OPEN to [] (detection is advisory — unlike gates' fail-closed posture).
66
77
  // spawnSync mirrors probeVersion; 15s timeout. Live-verified 2026-07-10, pi 0.80.3.
67
78
  listModels: async () => {
@@ -2,6 +2,13 @@ import { mkdirSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { renderAcceptanceItem } from "../graph/schema.js";
4
4
  import { classifyVerdictCause } from "../gates/verdict-cause.js";
5
+ // v1.87 T5: the scope rule states only what the runtime enforces, and no more. What it enforces:
6
+ // `scope.allowDeviations` comes from the operator's config, is snapshotted and frozen when a gate
7
+ // round starts (run-gates.ts `allowDeviations`), and is never written back — so a worker has no
8
+ // reach into it at all, and the round that judges its work judges against one fixed list. The
9
+ // retired "unless the operator's config allowlists that path" advertised an exemption the worker
10
+ // could neither invoke nor influence. Nothing here names a capability a worker can invoke mid-run,
11
+ // and the allowlist stays exactly as immutable during a run as it was before this text changed.
5
12
  export function buildTaskPrompt(task, feedback = "", nonce = "") {
6
13
  const list = (xs) => xs.map((x) => `- ${x}`).join("\n");
7
14
  return `You are an autonomous coding worker dispatched by tickmarkr into an isolated git worktree.
@@ -15,7 +22,7 @@ ${task.files.length ? `\n## File scope — touch ONLY paths matching:\n${list(ta
15
22
  ## Rules
16
23
  - Work only inside the current directory (your isolated worktree). Never push. Never switch branches.
17
24
  - Make small atomic git commits as you go (git add + git commit, conventional messages).
18
- - Touch ONLY paths matching the file scope. Out-of-scope edits FAIL the scope gate unless the operator's config allowlists that path — declaring a deviation never passes the gate. If you cannot complete the task without an out-of-scope edit, stop and report ok:false explaining why in "summary". List any out-of-scope paths you did touch, each with a reason, in "deviations" (journaled for the operator's audit).
25
+ - Touch ONLY paths matching the file scope. Out-of-scope edits FAIL the scope gate. The operator's allowlist is fixed when the run starts and nothing you do can change it while your work is judged; declaring a deviation never passes the gate either. If you cannot complete the task without an out-of-scope edit, stop and report ok:false explaining why in "summary". List any out-of-scope paths you did touch, each with a reason, in "deviations" (journaled for the operator's audit).
19
26
  - Do not ask questions; you are unattended. Make the smallest correct change.
20
27
  ${feedback ? `\n## Previous attempt failed gates — fix these specifically\n${feedback}\n` : ""}
21
28
  When finished, end your final message with exactly one line (no code fence):
@@ -1,5 +1,6 @@
1
1
  import { type TickmarkrConfig } from "../config/config.js";
2
- import { type CatalogEntriesOptions, type CliEntry } from "./catalog.js";
2
+ import { type PreferenceRole } from "../route/preference.js";
3
+ import { type CatalogEntriesOptions, type CliEntry, type ModelIdField } from "./catalog.js";
3
4
  import { type AuthHealth, type BillingChannel, type WorkerAdapter } from "./types.js";
4
5
  export declare const CANDIDATE_CLI_CATALOG: readonly string[];
5
6
  export declare function registeredAdapterBinaries(adapters?: WorkerAdapter[]): string[];
@@ -46,6 +47,11 @@ export declare function formatCandidateCliRow({ binary, path }: CandidateCliDete
46
47
  export declare function probeHelpText(bin: string): string | undefined;
47
48
  export declare function missingDeclaredFlags(helpText: string, flags: string[]): string[];
48
49
  export declare function flagDriftWarnings(adapters: WorkerAdapter[], health: Record<string, AuthHealth>, helpOf?: (bin: string) => string | undefined): string[];
50
+ export interface DeclaredModels {
51
+ models: string[];
52
+ reason?: string;
53
+ }
54
+ export declare function parseDeclaredModels(raw: string, parser: "lines" | "pi-table" | "json", path?: string, field?: ModelIdField): DeclaredModels;
49
55
  export declare function adaptersFromCliEntries(entries: readonly CliEntry[]): WorkerAdapter[];
50
56
  export interface AllAdaptersOptions {
51
57
  fakeScriptPath?: string;
@@ -63,7 +69,9 @@ export type ProbeModelProgress = (adapter: string, model: string, status: ProbeM
63
69
  export declare function probeModels(cfg: TickmarkrConfig, repoRoot: string, adapters: WorkerAdapter[], health: Record<string, AuthHealth>, onProgress?: ProbeModelProgress): Promise<void>;
64
70
  export declare function writeDoctor(repoRoot: string, health: Record<string, AuthHealth>): void;
65
71
  export declare function readDoctor(repoRoot: string): Record<string, AuthHealth> | null;
66
- export declare function discoverChannels(cfg: TickmarkrConfig, adapters: WorkerAdapter[], health: Record<string, AuthHealth>): BillingChannel[];
72
+ export declare function discoverChannels(cfg: TickmarkrConfig, adapters: WorkerAdapter[], health: Record<string, AuthHealth>, role?: PreferenceRole): BillingChannel[];
73
+ export declare const PREFERENCE_ROLES: readonly ["worker", "judge", "review", "consult"];
74
+ export declare function rolePools(cfg: TickmarkrConfig, adapters: WorkerAdapter[], health: Record<string, AuthHealth>): Record<PreferenceRole, BillingChannel[]>;
67
75
  export declare function servableExclusions(cfg: TickmarkrConfig, adapters: WorkerAdapter[], health: Record<string, AuthHealth>): {
68
76
  key: string;
69
77
  adapter: string;
@@ -1,5 +1,5 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { existsSync, mkdtempSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { modelLints, suggestOverlay } from "./model-lints.js";
@@ -208,15 +208,34 @@ function valueAtPath(value, path) {
208
208
  return cursor[part];
209
209
  }, value);
210
210
  }
211
- function parseDeclaredModels(raw, parser, path) {
211
+ export function parseDeclaredModels(raw, parser, path, field) {
212
212
  let values;
213
213
  if (parser === "json") {
214
+ let root;
214
215
  try {
215
- const selected = valueAtPath(JSON.parse(raw), path);
216
- values = Array.isArray(selected) ? selected : [];
216
+ root = JSON.parse(raw);
217
217
  }
218
218
  catch {
219
- return [];
219
+ return { models: [], reason: "malformed JSON: the output did not parse" };
220
+ }
221
+ const selected = valueAtPath(root, path);
222
+ if (!Array.isArray(selected)) {
223
+ return { models: [], reason: `no array at ${path ? `path "${path}"` : "the JSON root"}` };
224
+ }
225
+ // One key per entry, and only the configured one — and `ModelIdField` is an id-key allowlist, so
226
+ // `cost` and `provider` are not spellable here: no price, no vendor, no whole object.
227
+ values = field === undefined
228
+ ? selected
229
+ : selected.map((entry) => entry && typeof entry === "object" && !Array.isArray(entry)
230
+ ? entry[field]
231
+ : undefined);
232
+ if (selected.length > 0 && !values.some((value) => typeof value === "string")) {
233
+ return {
234
+ models: [],
235
+ reason: field === undefined
236
+ ? `${selected.length} entries are not id strings and no field is configured`
237
+ : `no string field "${field}" on any of the ${selected.length} entries`,
238
+ };
220
239
  }
221
240
  }
222
241
  else if (parser === "pi-table") {
@@ -230,7 +249,14 @@ function parseDeclaredModels(raw, parser, path) {
230
249
  else {
231
250
  values = raw.split("\n").map((line) => line.trim());
232
251
  }
233
- return [...new Set(values.filter((value) => typeof value === "string" && MODEL_ID_RE.test(value)))];
252
+ // Blanks are structural padding in every parser (trailing newline, headerless table row), never a
253
+ // rejected id — counting them would make "all values rejected" fire on a healthy list.
254
+ const candidates = values.filter((value) => typeof value === "string" && value.length > 0);
255
+ const models = [...new Set(candidates.filter((value) => MODEL_ID_RE.test(value)))];
256
+ if (models.length === 0 && candidates.length > 0) {
257
+ return { models, reason: `all ${candidates.length} listed values were rejected by the model-id format` };
258
+ }
259
+ return { models };
234
260
  }
235
261
  function declarativeAdapter(entry) {
236
262
  const drive = entry.drive;
@@ -255,6 +281,9 @@ function declarativeAdapter(entry) {
255
281
  return health;
256
282
  },
257
283
  channels: (cfg) => channelsFromConfig(entry.id, cfg),
284
+ // v1.89 T1 / OBS-414: straight from the validated drive contract — no default and no fallback,
285
+ // which is the only way this factory cannot reintroduce the optionality the type just removed.
286
+ trustDialog: drive.trustDialog,
258
287
  headlessCommand: (promptFile, model) => renderDriveTemplate(drive.headless, promptFile, model),
259
288
  interactiveCommand: (promptFile, model) => drive.interactive === null
260
289
  ? null
@@ -270,9 +299,13 @@ function declarativeAdapter(entry) {
270
299
  return [];
271
300
  const cmd = [shq(resolved), ...drive.listModels.argv.map(shq)].join(" ");
272
301
  const result = shellSpawnSync(cmd, process.cwd(), 15000);
273
- return result.code === 0
274
- ? parseDeclaredModels(result.stdout, drive.listModels.parser, drive.listModels.path)
275
- : [];
302
+ if (result.code !== 0)
303
+ return [];
304
+ const { models, reason } = parseDeclaredModels(result.stdout, drive.listModels.parser, drive.listModels.path, drive.listModels.field);
305
+ // Listing stays advisory (fail-open to []), but never silently: an empty projection names why.
306
+ if (models.length === 0 && reason)
307
+ console.warn(`tickmarkr: ${entry.id} listed no models — ${reason}`);
308
+ return models;
276
309
  },
277
310
  } : {}),
278
311
  };
@@ -365,67 +398,83 @@ export async function probeModels(cfg, repoRoot, adapters, health, onProgress) {
365
398
  // Tests inject FakeAdapter; never let an incidental default adapter spend a real token in the suite.
366
399
  if (process.env.VITEST && SHIPPED_NATIVE_ADAPTERS.has(a))
367
400
  return;
368
- const verdicts = {};
369
401
  const priorModelAuth = priorHealth?.[a.id]?.modelAuth;
370
- const probeRoot = a.probeCwd === "neutral" ? mkdtempSync(join(tmpdir(), "tickmarkr-probe-")) : repoRoot;
371
- const store = (model, verdict) => {
372
- verdicts[model] = verdict;
373
- onProgress?.(a.id, model, probeModelStatus(verdict), verdict.durationMs);
374
- };
375
- // One bounded probe call. verdict:null = a first-pass failure that earns the one serial retry
376
- // (OBS-72: a failure inside the concurrent batch is indistinguishable from adapter self-contention
377
- // until re-probed alone); a retry attempt always returns a final verdict.
378
- const attempt = async (model, retry) => {
379
- const t0 = Date.now();
380
- const probedAt = new Date().toISOString();
381
- const v = (authed, reason) => ({ authed, ...(reason !== undefined ? { reason } : {}), probedAt, durationMs: Date.now() - t0 });
382
- try {
383
- if (typeof a.headlessCommand !== "function")
384
- return { verdict: v(false, "headless probe unavailable"), timedOut: false };
385
- const promptFile = join(mkdtempSync(join(tmpdir(), "tickmarkr-auth-")), "probe.md");
386
- writeFileSync(promptFile, MODEL_PROBE_PROMPT);
387
- const r = await sh(a.headlessCommand(promptFile, model), probeRoot, MODEL_PROBE_TIMEOUT_MS);
388
- // T2 rule unchanged: a prior doctor.json timeout skips the retry — a persistently dead
389
- // model (e.g. opencode glm-5.2) costs one attempt instead of two every run.
390
- if (r.timedOut && !retry && priorModelAuth?.[model]?.reason?.includes("timed out") === true) {
391
- return { verdict: v(false, `probe timed out (repeat — retry skipped) (${MODEL_PROBE_TIMEOUT_MS}ms)`), timedOut: true };
402
+ const runProbes = async (probeRoot) => {
403
+ const verdicts = {};
404
+ const store = (model, verdict) => {
405
+ verdicts[model] = verdict;
406
+ onProgress?.(a.id, model, probeModelStatus(verdict), verdict.durationMs);
407
+ };
408
+ // One bounded probe call. verdict:null = a first-pass failure that earns the one serial retry
409
+ // (OBS-72: a failure inside the concurrent batch is indistinguishable from adapter self-contention
410
+ // until re-probed alone); a retry attempt always returns a final verdict.
411
+ const attempt = async (model, retry) => {
412
+ const t0 = Date.now();
413
+ const probedAt = new Date().toISOString();
414
+ const v = (authed, reason) => ({ authed, ...(reason !== undefined ? { reason } : {}), probedAt, durationMs: Date.now() - t0 });
415
+ try {
416
+ if (typeof a.headlessCommand !== "function")
417
+ return { verdict: v(false, "headless probe unavailable"), timedOut: false };
418
+ const promptDir = mkdtempSync(join(tmpdir(), "tickmarkr-auth-"));
419
+ try {
420
+ const promptFile = join(promptDir, "probe.md");
421
+ writeFileSync(promptFile, MODEL_PROBE_PROMPT);
422
+ const r = await sh(a.headlessCommand(promptFile, model), probeRoot, MODEL_PROBE_TIMEOUT_MS);
423
+ // T2 rule unchanged: a prior doctor.json timeout skips the retry — a persistently dead
424
+ // model (e.g. opencode glm-5.2) costs one attempt instead of two every run.
425
+ if (r.timedOut && !retry && priorModelAuth?.[model]?.reason?.includes("timed out") === true) {
426
+ return { verdict: v(false, `probe timed out (repeat — retry skipped) (${MODEL_PROBE_TIMEOUT_MS}ms)`), timedOut: true };
427
+ }
428
+ const reason = probeFailure(r.code, r.stdout, r.stderr, r.timedOut, MODEL_PROBE_TIMEOUT_MS);
429
+ if (!reason)
430
+ return { verdict: v(true), timedOut: false };
431
+ if (!retry)
432
+ return { verdict: null, timedOut: r.timedOut === true };
433
+ return {
434
+ verdict: r.timedOut && retry.firstTimedOut ? v(false, `probe timed out twice (${MODEL_PROBE_TIMEOUT_MS}ms)`) : v(false, reason),
435
+ timedOut: r.timedOut === true,
436
+ };
437
+ }
438
+ finally {
439
+ rmSync(promptDir, { recursive: true, force: true });
440
+ }
392
441
  }
393
- const reason = probeFailure(r.code, r.stdout, r.stderr, r.timedOut, MODEL_PROBE_TIMEOUT_MS);
394
- if (!reason)
395
- return { verdict: v(true), timedOut: false };
396
- if (!retry)
397
- return { verdict: null, timedOut: r.timedOut === true };
398
- return {
399
- verdict: r.timedOut && retry.firstTimedOut ? v(false, `probe timed out twice (${MODEL_PROBE_TIMEOUT_MS}ms)`) : v(false, reason),
400
- timedOut: r.timedOut === true,
401
- };
402
- }
403
- catch (e) {
404
- return { verdict: retry ? v(false, String(e)) : null, timedOut: false };
442
+ catch (e) {
443
+ return { verdict: retry ? v(false, String(e)) : null, timedOut: false };
444
+ }
445
+ };
446
+ // models probe concurrently too (v1.33.5) — sequential chains made init wall time Σ(models×60s)
447
+ // on the slowest adapter (measured 96s); each probe is one tiny prompt, safe to overlap.
448
+ // Capped at MODEL_PROBE_CONCURRENCY per adapter unless the adapter declares its own cap
449
+ // (codex: 1 — OBS-72, its concurrent probes self-contend in one repo).
450
+ const retries = [];
451
+ await mapLimit(Object.keys(cfg.tiers[a.id]?.models ?? {}), a.probeConcurrency ?? MODEL_PROBE_CONCURRENCY, async (model) => {
452
+ const first = await attempt(model);
453
+ if (first.verdict)
454
+ store(model, first.verdict);
455
+ else
456
+ retries.push({ model, firstTimedOut: first.timedOut });
457
+ });
458
+ // OBS-72: re-probe each first-pass failure once with ONE probe in flight, only after the
459
+ // concurrent batch drained — an in-slot retry still races its concurrency partner. Success here
460
+ // was contention; a second failure is the real verdict. Successful first-pass probes stored
461
+ // above and never wait; cost is one extra call per genuinely dead model only.
462
+ for (const { model, firstTimedOut } of retries) {
463
+ const second = await attempt(model, { firstTimedOut });
464
+ if (second.verdict)
465
+ store(model, second.verdict);
405
466
  }
467
+ h.modelAuth = verdicts;
406
468
  };
407
- // models probe concurrently too (v1.33.5) — sequential chains made init wall time Σ(models×60s)
408
- // on the slowest adapter (measured 96s); each probe is one tiny prompt, safe to overlap.
409
- // Capped at MODEL_PROBE_CONCURRENCY per adapter unless the adapter declares its own cap
410
- // (codex: 1 — OBS-72, its concurrent probes self-contend in one repo).
411
- const retries = [];
412
- await mapLimit(Object.keys(cfg.tiers[a.id]?.models ?? {}), a.probeConcurrency ?? MODEL_PROBE_CONCURRENCY, async (model) => {
413
- const first = await attempt(model);
414
- if (first.verdict)
415
- store(model, first.verdict);
416
- else
417
- retries.push({ model, firstTimedOut: first.timedOut });
418
- });
419
- // OBS-72: re-probe each first-pass failure once with ONE probe in flight, only after the
420
- // concurrent batch drained — an in-slot retry still races its concurrency partner. Success here
421
- // was contention; a second failure is the real verdict. Successful first-pass probes stored
422
- // above and never wait; cost is one extra call per genuinely dead model only.
423
- for (const { model, firstTimedOut } of retries) {
424
- const second = await attempt(model, { firstTimedOut });
425
- if (second.verdict)
426
- store(model, second.verdict);
469
+ if (a.probeCwd !== "neutral")
470
+ return runProbes(repoRoot);
471
+ const probeRoot = mkdtempSync(join(tmpdir(), "tickmarkr-probe-"));
472
+ try {
473
+ await runProbes(probeRoot);
474
+ }
475
+ finally {
476
+ rmSync(probeRoot, { recursive: true, force: true });
427
477
  }
428
- h.modelAuth = verdicts;
429
478
  }));
430
479
  }
431
480
  // T2: caps concurrent probes per adapter at 2 — v1.33.5 regression, 4 concurrent codex exec in one
@@ -468,7 +517,11 @@ export function readDoctor(repoRoot) {
468
517
  const { autoPrefer: _, ...health } = raw;
469
518
  return health;
470
519
  }
471
- export function discoverChannels(cfg, adapters, health) {
520
+ // v1.87 T2: `role` is optional and defaults to "worker", so every pre-existing two/three-argument
521
+ // call keeps its exact present behaviour — the worker pool. A non-worker role reads the SAME fleet
522
+ // through its own deny scope: a worker-only deny (routing.deny.workers) benches a channel for
523
+ // dispatch while leaving it eligible to judge, review or consult the work.
524
+ export function discoverChannels(cfg, adapters, health, role = "worker") {
472
525
  const base = adapters
473
526
  .filter((a) => health[a.id]?.installed && health[a.id]?.authed)
474
527
  .flatMap((a) => {
@@ -483,7 +536,13 @@ export function discoverChannels(cfg, adapters, health) {
483
536
  });
484
537
  if (!cfg.routing.allow && !cfg.routing.deny)
485
538
  return base;
486
- return base.filter((c) => disallowedBy(c, cfg.routing) === null);
539
+ return base.filter((c) => disallowedBy(c, cfg.routing, role) === null);
540
+ }
541
+ // v1.87 T2: the role-scoped pool builder. One doctor probe, one discovery pass per role — so the
542
+ // daemon hands each seat the pool its own policy allows instead of handing everyone the worker list.
543
+ export const PREFERENCE_ROLES = ["worker", "judge", "review", "consult"];
544
+ export function rolePools(cfg, adapters, health) {
545
+ return Object.fromEntries(PREFERENCE_ROLES.map((role) => [role, discoverChannels(cfg, adapters, health, role)]));
487
546
  }
488
547
  // HYG-07(a): the channels discoverChannels silently dropped because their model isn't in the adapter's
489
548
  // served list (the HYG-05 filter above). Computed from the SAME inputs as the drop (a.channels(cfg), not