tickmarkr 2.6.2 → 2.6.4

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 (79) hide show
  1. package/README.md +12 -2
  2. package/dist/adapters/codex.d.ts +2 -1
  3. package/dist/adapters/codex.js +26 -5
  4. package/dist/adapters/model-lints.d.ts +1 -0
  5. package/dist/adapters/model-lints.js +48 -1
  6. package/dist/adapters/model-windows.d.ts +2 -1
  7. package/dist/adapters/model-windows.js +18 -7
  8. package/dist/adapters/types.d.ts +6 -0
  9. package/dist/cli/commands/approve.d.ts +6 -3
  10. package/dist/cli/commands/approve.js +43 -6
  11. package/dist/cli/commands/doctor.d.ts +8 -2
  12. package/dist/cli/commands/doctor.js +6 -1
  13. package/dist/cli/commands/fleet.js +264 -59
  14. package/dist/cli/commands/init.js +5 -3
  15. package/dist/cli/commands/plan.js +13 -8
  16. package/dist/cli/commands/report.d.ts +40 -2
  17. package/dist/cli/commands/report.js +278 -11
  18. package/dist/cli/commands/resume.js +2 -4
  19. package/dist/cli/commands/run.d.ts +11 -0
  20. package/dist/cli/commands/run.js +33 -5
  21. package/dist/cli/commands/status.js +49 -4
  22. package/dist/cli/commands/verify.js +332 -121
  23. package/dist/compile/native.js +137 -0
  24. package/dist/config/config.d.ts +40 -9
  25. package/dist/config/config.js +133 -15
  26. package/dist/config/fleet-overlay.d.ts +13 -3
  27. package/dist/config/fleet-overlay.js +12 -8
  28. package/dist/drivers/herdr.d.ts +12 -0
  29. package/dist/drivers/herdr.js +51 -0
  30. package/dist/drivers/orca.d.ts +9 -1
  31. package/dist/drivers/orca.js +29 -7
  32. package/dist/drivers/types.d.ts +2 -0
  33. package/dist/drivers/types.js +2 -2
  34. package/dist/gates/acceptance.d.ts +7 -0
  35. package/dist/gates/acceptance.js +27 -5
  36. package/dist/gates/baseline.d.ts +20 -1
  37. package/dist/gates/baseline.js +100 -20
  38. package/dist/gates/cache.d.ts +8 -0
  39. package/dist/gates/cache.js +12 -2
  40. package/dist/gates/llm.d.ts +6 -0
  41. package/dist/gates/llm.js +27 -8
  42. package/dist/gates/review.d.ts +6 -1
  43. package/dist/gates/review.js +122 -32
  44. package/dist/gates/run-gates.d.ts +54 -3
  45. package/dist/gates/run-gates.js +331 -45
  46. package/dist/gates/test-manifest.d.ts +42 -0
  47. package/dist/gates/test-manifest.js +69 -10
  48. package/dist/route/router.d.ts +23 -1
  49. package/dist/route/router.js +54 -16
  50. package/dist/run/consult.d.ts +3 -1
  51. package/dist/run/consult.js +4 -2
  52. package/dist/run/daemon.d.ts +2 -1
  53. package/dist/run/daemon.js +349 -79
  54. package/dist/run/interactive-seed.d.ts +4 -0
  55. package/dist/run/interactive-seed.js +35 -9
  56. package/dist/run/journal.d.ts +123 -1
  57. package/dist/run/journal.js +480 -17
  58. package/dist/run/lease.d.ts +13 -0
  59. package/dist/run/lease.js +45 -0
  60. package/dist/run/protocol.d.ts +15 -0
  61. package/dist/run/protocol.js +11 -1
  62. package/dist/run/receipt-resolver.d.ts +22 -0
  63. package/dist/run/receipt-resolver.js +40 -1
  64. package/dist/run/repair-selection.d.ts +11 -1
  65. package/dist/run/repair-selection.js +17 -9
  66. package/dist/run/wall-budget.d.ts +48 -0
  67. package/dist/run/wall-budget.js +280 -0
  68. package/dist/tui/cockpit/live-store.d.ts +6 -0
  69. package/dist/tui/cockpit/live-store.js +36 -11
  70. package/dist/tui/cockpit/run-cockpit.js +2 -2
  71. package/dist/tui/cockpit/run-view.d.ts +2 -1
  72. package/dist/tui/cockpit/run-view.js +13 -9
  73. package/dist/tui/cockpit/setup-cockpit.d.ts +2 -0
  74. package/dist/tui/cockpit/setup-cockpit.js +4 -0
  75. package/package.json +2 -1
  76. package/schema/config.schema.json +8 -1
  77. package/skills/tickmarkr-auto/SKILL.md +2 -2
  78. package/skills/tickmarkr-loop/SKILL.md +17 -5
  79. package/skills/tickmarkr-overseer/scripts/classify-vitest-log.sh +4 -1
@@ -58,6 +58,121 @@ export function classifyContextPath(entry, tracked, repoDir) {
58
58
  const hits = [...tracked].filter((t) => basename(t) === base);
59
59
  return hits.length === 1 ? { kind: "missing", suggestion: hits[0] } : { kind: "missing" };
60
60
  }
61
+ // v2.6.4 T9 (E): picomatch (the files[] matcher, graph/files-glob.ts) expands a brace group only when
62
+ // it holds a top-level comma or a `..` range, which compiles to a character class — faithful only
63
+ // between single characters: `{A..z}` is `[A-z]`, but `{1..10}` is the class `[1-10]`. Any other
64
+ // unescaped group stays LITERAL braces: `docs/{a}.md` scopes a file named `docs/{a}.md`, never
65
+ // `docs/a.md` (2.6.3 T10: six scope reds). Like picomatch, a `[...]` class (opened only when a `]`
66
+ // follows; a `]` right after `[` or `[^` is a member) swallows braces and commas: `docs/{[a,b]}.md`
67
+ // stays literal, `docs/[{a}].md` holds no group. A POSIX `[:name:]` inside a class is replayed as
68
+ // picomatch's parser does it (the `:]` is consumed, the class stays open), so `docs/{[[:alpha:],]}.md`
69
+ // stays literal and `docs/[[:alpha:]{a}].md` holds no group. Outside a class, picomatch reads `"..."` as
70
+ // quoted literal text (an escape still applies inside it), so a quoted `[`, `{` or `,` opens nothing:
71
+ // `docs/"["/{a}].md` still holds the group `{a}`, `docs/"{a}".md` holds none. A class picomatch never
72
+ // closes (its only `]` is a member: `docs/[]/{a}.md`) has its opener escaped at the end, and everything
73
+ // after that opener stays literal text — no quotes, no classes, braces literal — so that tail is
74
+ // rescanned for groups with only escapes honoured: `docs/[]/{a}.md` holds `{a}`. Returns each literal
75
+ // group and the pattern with those braces dropped — the literal path its author almost certainly meant.
76
+ // A range is read with picomatch's escapes: its lexer drops a backslash before `.`, `/` or `;` (the char
77
+ // stays live, so `{1\..3}` IS the range `{1..3}`) and keeps any other `\X` as one literal character. Each
78
+ // end must be one such character, and the matcher itself must then match both ends: `{\d..z}` compiles
79
+ // to the class `[\d-z]`, which never matches "d".
80
+ const RANGE_END = String.raw `(\\[\s\S]|[^\\])`;
81
+ const RANGE = new RegExp(`^${RANGE_END}\\.\\.${RANGE_END}$`);
82
+ function supportedRange(group) {
83
+ const ends = RANGE.exec(group.replace(/\\([\s\S])/g, (pair, ch) => "./;".includes(ch) ? ch : pair));
84
+ if (!ends)
85
+ return false;
86
+ const match = filesGlob(`{${group}}`);
87
+ return ends.slice(1).every((end) => match(end.slice(-1)));
88
+ }
89
+ const POSIX_CLASS = picomatch.constants.POSIX_REGEX_SOURCE;
90
+ function singletonBraces(pattern) {
91
+ const open = [];
92
+ const groups = [];
93
+ const drop = new Set();
94
+ let cls; // the open character class's text as picomatch accumulates it, undefined outside one
95
+ let clsAt = -1; // the open class's opener
96
+ let quoted = false; // inside a "..." run outside any class
97
+ let raw = false; // past an unterminated class's opener: literal text, only escapes and braces count
98
+ for (let i = 0; i < pattern.length; i++) {
99
+ const ch = pattern[i];
100
+ if (cls !== undefined) {
101
+ const at = cls.lastIndexOf("[");
102
+ if (ch === "\\")
103
+ cls += pattern.slice(i, ++i + 1);
104
+ else if (ch === "]" && cls !== "[" && cls !== "[^")
105
+ cls = undefined;
106
+ else if (ch === ":" && at > 0 && cls.includes(":", 1) && Object.hasOwn(POSIX_CLASS, cls.slice(at + 2))) {
107
+ cls = cls.slice(0, at) + POSIX_CLASS[cls.slice(at + 2)];
108
+ i++; // picomatch consumes the char after the closing `:` (the `]` of `[:name:]`) without closing the class
109
+ }
110
+ else
111
+ cls += ch;
112
+ }
113
+ else if (ch === "\\")
114
+ i++; // escaped: `\{a\}` is a literal filename
115
+ else if (ch === '"' && !raw)
116
+ quoted = !quoted;
117
+ else if (quoted)
118
+ continue;
119
+ else if (ch === "[" && !raw && pattern.includes("]", i + 1))
120
+ [cls, clsAt] = ["[", i];
121
+ else if (ch === "{")
122
+ open.push({ at: i, comma: false });
123
+ else if (ch === "," && open.length)
124
+ open[open.length - 1].comma = true;
125
+ else if (ch === "}" && open.length) {
126
+ const { at, comma } = open.pop();
127
+ if (comma || supportedRange(pattern.slice(at + 1, i)))
128
+ continue;
129
+ groups.push(pattern.slice(at, i + 1));
130
+ drop.add(at).add(i);
131
+ }
132
+ if (cls !== undefined && i + 1 >= pattern.length)
133
+ [i, cls, raw] = [clsAt, undefined, true]; // unterminated: rescan its tail raw
134
+ }
135
+ return { groups, bare: pattern.split("").filter((_, i) => !drop.has(i)).join("") };
136
+ }
137
+ // v2.6.4 T9 (E): a files[] path the SELECTED git tree (spec base, else HEAD — what a worker's worktree
138
+ // is built from) stores as a symlink, mode 120000, scopes the link's own bytes: its stored target
139
+ // text. An edit made THROUGH the link writes the file it points to, which the scope gate refuses. The
140
+ // checkout is never the oracle — its links may be untracked or replaced. Advisory only; with no
141
+ // repository (or no files[]) there is no tree to inspect and git is never spawned.
142
+ function symlinkScopeWarnings(file, tasks, base) {
143
+ const scoped = tasks.filter((task) => task.files.length > 0)
144
+ .map((task) => ({ id: task.id, match: filesGlob(task.files.map((entry) => entry.replace(/^\.\//, ""))) }));
145
+ if (!scoped.length || !repositoryRoot(file))
146
+ return [];
147
+ const tree = base ?? "HEAD";
148
+ const git = (...args) => spawnSync("git", ["-C", dirname(file), ...args], { encoding: "utf8", maxBuffer: 1 << 28 });
149
+ const failed = (r) => r.error || r.signal || r.status !== 0 || typeof r.stdout !== "string";
150
+ const why = (r) => r.signal ? `signal ${r.signal}` : r.status === null ? "git did not run" : `git exit ${r.status}`;
151
+ const advisory = (what, r) => `tickmarkr: advisory: files[] symlink inspection could not read ${what} in the selected git tree ${JSON.stringify(tree)} (${why(r)}) — symlink scope is unverified for this compile.`;
152
+ const ls = git("ls-tree", "--full-tree", "-r", "-z", "--end-of-options", tree); // a base is never an option
153
+ if (failed(ls))
154
+ return [advisory("the tree", ls)];
155
+ const warnings = [];
156
+ for (const row of ls.stdout.split("\0")) {
157
+ const link = /^120000 blob ([0-9a-f]+)\t([\s\S]+)$/.exec(row);
158
+ if (!link)
159
+ continue;
160
+ const owners = scoped.filter((task) => task.match(link[2]));
161
+ if (!owners.length)
162
+ continue;
163
+ // ponytail: one cat-file per matched link; switch to `cat-file --batch` if a scope matches hundreds
164
+ const target = git("cat-file", "blob", link[1]);
165
+ if (failed(target)) {
166
+ warnings.push(advisory(`the stored target of ${JSON.stringify(link[2])}`, target));
167
+ continue;
168
+ }
169
+ for (const { id } of owners) {
170
+ warnings.push(`tickmarkr: task ${id} files[] path ${JSON.stringify(link[2])} is a symlink in the selected git tree ${JSON.stringify(tree)} storing target ${JSON.stringify(target.stdout)} — `
171
+ + `files[] owns only that stored target text; an edit made through the link changes the file it points to, which needs its own files[] entry.`);
172
+ }
173
+ }
174
+ return warnings;
175
+ }
61
176
  // OBS-97: mirror of the vitest.config.ts suite include — the only path class vitest collects.
62
177
  export const COLLECTABLE_TESTS = "tests/**/*.test.ts";
63
178
  export const LEGACY_PREFIX = ["dro", "vr"].join("");
@@ -611,6 +726,19 @@ export function compileNative(file, options = {}) {
611
726
  // A fixture pin's path set is split only — never annotation-stripped: "fixtures/output (old)" is a
612
727
  // filename, and csv() would silently retarget the declared obligation to "fixtures/output".
613
728
  const pinPaths = (value) => splitTop(value).map((item) => item.trim()).filter(Boolean);
729
+ // v2.6.4 T9 (E): refused BEFORE the hosting probe below, which expands every brace group — so it
730
+ // would certify `tests/{native}.test.ts` as a test home while the scope gate never matches it.
731
+ for (const draft of drafts) {
732
+ for (const entry of csv(draft.fields.files)) {
733
+ const { groups, bare } = singletonBraces(entry);
734
+ if (groups.length) {
735
+ invalid(draft.id, "files", `entry ${JSON.stringify(entry)} holds single-item brace group${groups.length === 1 ? "" : "s"} ${groups.join(", ")}: `
736
+ + `the scope gate matches ${groups.length === 1 ? "it" : "them"} as literal braces while test hosting expands ${groups.length === 1 ? "it" : "them"}, `
737
+ + `so the declared scope disagrees with itself. Write the bare path ${JSON.stringify(bare)}, list two or more alternatives as {a,b}, `
738
+ + `or escape the braces (\\{ \\}) if they are part of the filename (a range expands only between single characters, e.g. {1..3} or {a..c}).`);
739
+ }
740
+ }
741
+ }
614
742
  // OBS-97: a typed test: oracle needs a collectable home. vitest only collects COLLECTABLE_TESTS
615
743
  // paths, so a task whose non-empty files[] cannot host one makes scope-green and acceptance-green
616
744
  // mutually exclusive by construction — run-20260719-210434 burned two dispatch attempts before a
@@ -744,6 +872,8 @@ export function compileNative(file, options = {}) {
744
872
  },
745
873
  tasks,
746
874
  });
875
+ for (const warning of symlinkScopeWarnings(file, result.tasks, specBase))
876
+ console.warn(warning);
747
877
  const authoringFindings = authoringLintFindings(result.tasks, file);
748
878
  const blockingCodes = new Set(["criterion-scope", "fence-symbol-absent"]);
749
879
  const blockingFindings = authoringFindings.filter((finding) => options.strict || blockingCodes.has(finding.code));
@@ -911,6 +1041,13 @@ acceptance is required on every task (a nested list of observable outcomes).
911
1041
  - Enumerating one axis exhaustively is what hides the others. A spec that guards PARTIAL coverage
912
1042
  site-by-site, member-by-member, can be defeated wholesale by CONDITIONAL coverage, which leaves
913
1043
  every enumeration satisfied. After you enumerate, ask what a single flag would do to the whole set.
1044
+ - A GOAL STATING AN INVARIANT OVER "every", "never" OR "any" CARRIES ITS CLOSED CASE TABLE AS A
1045
+ CRITERION: the in-scope consumers, bridges, operations and event sequences it ranges over, each a
1046
+ row a test exercises. An unenumerated invariant is repaired one edge per review round while every
1047
+ round re-judges the accumulated diff (OBS-1019 add.2). The table binds author and reviewer, never a
1048
+ worker's claim: each review material binds its declared class to the goal clause or criterion it
1049
+ violates through input-to-consequence evidence marked executed, static or blocked. A worker's own
1050
+ table alone never silences a real finding, and blocked evidence is never a pass.
914
1051
  - EVERY CRITERION NAMES THE PAIR IT DISCRIMINATES: the correct case that MUST PASS, and the
915
1052
  neighbouring plausible-wrong or false-clean case that MUST FAIL. Two easy examples that both pass are
916
1053
  not discrimination — they are two ways of being green, and the wrong half is the whole point: it is
@@ -181,13 +181,13 @@ export declare function effectiveReviewPolicy(files: ReadonlyArray<string>, revi
181
181
  export declare function criticalPathHits(files: ReadonlyArray<string>, criticalPaths?: ReadonlyArray<string>): string[];
182
182
  export declare const ExecutionPolicySchema: z.ZodObject<{
183
183
  boundedInfrastructure: z.ZodDefault<z.ZodBoolean>;
184
- repairSelection: z.ZodDefault<z.ZodBoolean>;
184
+ repairSelection: z.ZodOptional<z.ZodBoolean>;
185
185
  taskExecutionLimitMs: z.ZodNumber;
186
186
  }, z.core.$strict>;
187
187
  export declare const TickmarkrConfigSchema: z.ZodObject<{
188
188
  executionPolicy: z.ZodOptional<z.ZodObject<{
189
189
  boundedInfrastructure: z.ZodDefault<z.ZodBoolean>;
190
- repairSelection: z.ZodDefault<z.ZodBoolean>;
190
+ repairSelection: z.ZodOptional<z.ZodBoolean>;
191
191
  taskExecutionLimitMs: z.ZodNumber;
192
192
  }, z.core.$strict>>;
193
193
  concurrency: z.ZodNumber;
@@ -309,6 +309,8 @@ export declare const TickmarkrConfigSchema: z.ZodObject<{
309
309
  tipTest: z.ZodOptional<z.ZodString>;
310
310
  lint: z.ZodOptional<z.ZodString>;
311
311
  diffCap: z.ZodOptional<z.ZodNumber>;
312
+ evidenceQuotaBytes: z.ZodOptional<z.ZodNumber>;
313
+ repairSelection: z.ZodOptional<z.ZodBoolean>;
312
314
  byShape: z.ZodOptional<z.ZodOptional<z.ZodRecord<z.ZodEnum<{
313
315
  plan: "plan";
314
316
  spec: "spec";
@@ -374,6 +376,7 @@ export type TickmarkrConfig = z.infer<typeof TickmarkrConfigSchema>;
374
376
  export declare class ConfigError extends Error {
375
377
  constructor(message: string);
376
378
  }
379
+ export declare const DEFAULT_EVIDENCE_QUOTA_BYTES: number;
377
380
  export declare const DEFAULT_CONFIG: TickmarkrConfig;
378
381
  export declare function globalConfigDir(): string;
379
382
  export declare function overlayPreferShapes(repoRoot: string, opts?: {
@@ -391,24 +394,41 @@ export type ModeResolution = {
391
394
  export declare function loadConfigWithMode(repoRoot: string, opts?: {
392
395
  globalDir?: string;
393
396
  repoOverlayText?: string;
397
+ userOverlayText?: string;
394
398
  }): {
395
399
  cfg: TickmarkrConfig;
396
400
  mode: ModeResolution;
397
401
  };
398
- /** OBS-1182: tiers.<adapter>.modelOverrides as the layers under the repo overlay (defaults + global)
399
- * merge them. Read raw, never schema-validated: a lower layer may only validate beside repo fields
400
- * (global `vendor: null` completed by a repo vendor) and its override metadata is still inherited. */
402
+ /** OBS-1182: tiers.<adapter>.modelOverrides as the layers under the written overlay resolve them. */
403
+ export type LowerLayerModelOverrides = Record<string, Record<string, Record<string, unknown>>>;
404
+ /** OBS-1182: tiers.<adapter>.modelOverrides as the layers under the written overlay merge them —
405
+ * defaults + global under the repo overlay; B2 `below: "user"`: the defaults alone under the USER
406
+ * overlay, which is never read as its own lower layer. Read raw, never schema-validated: a lower
407
+ * layer may only validate beside upper fields (global `vendor: null` completed by a repo vendor)
408
+ * and its override metadata is still inherited.
409
+ * OBS-1188: an unparseable global file, or a non-map where this path expects a map, is an error —
410
+ * never an empty lower layer, which would silently drop the metadata a write must re-mask. */
401
411
  export declare function lowerLayerModelOverrides(opts?: {
402
412
  globalDir?: string;
403
- }): Record<string, Record<string, Record<string, unknown>>>;
413
+ below?: "repo" | "user";
414
+ }): {
415
+ ok: true;
416
+ overrides: LowerLayerModelOverrides;
417
+ } | {
418
+ ok: false;
419
+ error: string;
420
+ };
404
421
  export declare function loadConfig(repoRoot: string, opts?: {
405
422
  globalDir?: string;
406
423
  }): TickmarkrConfig;
407
- /** v1.52 T2 write-time reload guard: run candidate repo-overlay bytes through the SAME production
408
- * loader path every later command uses (parse → merge → schema → mode resolution). Returns null
409
- * when the bytes load, else the loader's failure message — the caller must refuse the write. */
424
+ /** v1.52 T2 write-time reload guard: run candidate overlay bytes (the repo layer, or B2's USER layer)
425
+ * through the SAME production loader path every later command uses (parse → merge → schema → mode
426
+ * resolution). Returns null when the bytes load, else the loader's failure message — the caller
427
+ * must refuse the write. */
410
428
  export declare function overlayBytesLoadError(repoRoot: string, bytes: string, opts?: {
411
429
  globalDir?: string;
430
+ layer?: "repo" | "user";
431
+ repoOverlayText?: string;
412
432
  }): string | null;
413
433
  export type InitConfigOverlay = {
414
434
  concurrency?: number;
@@ -461,6 +481,17 @@ export declare function fleetEditableFromConfig(cfg: TickmarkrConfig, universe?:
461
481
  export declare function fleetKeyLayer(repoRoot: string, dotted: string, opts?: {
462
482
  globalDir?: string;
463
483
  }): "defaults" | "global" | "repo";
484
+ /** B2: the user-overlay keys a write changes that the repo overlay (merged above it) decides in this
485
+ * repository — it declares the path, or a non-map (value, list, null tombstone) at a prefix that
486
+ * replaces or deletes the user's subtree, or its merge otherwise moves the value (a repo pin/pool/
487
+ * prefer replaces the user's whole routing.map slot atomically) — each with the value that still
488
+ * applies here. Bytes the loader refuses yield none: the write guard refuses them anyway. */
489
+ export declare function repoShadowedUserKeys(repoRoot: string, beforeUser: string, afterUser: string, opts?: {
490
+ globalDir?: string;
491
+ }): Array<{
492
+ path: string;
493
+ value: unknown;
494
+ }>;
464
495
  /** Non-interactive fleet state for CI drift checks (`tickmarkr fleet --print`). */
465
496
  export declare function formatFleetPrint(repoRoot: string, opts?: {
466
497
  globalDir?: string;
@@ -325,7 +325,8 @@ const ShapeGateParticipationSchema = z
325
325
  });
326
326
  export const ExecutionPolicySchema = z.object({
327
327
  boundedInfrastructure: z.boolean().default(false),
328
- repairSelection: z.boolean().default(false),
328
+ // OBS-1199: absent inherits the effective default (on); an explicit false stays off.
329
+ repairSelection: z.boolean().optional(),
329
330
  taskExecutionLimitMs: z.number().int().positive(),
330
331
  }).strict();
331
332
  export const TickmarkrConfigSchema = z.object({
@@ -408,6 +409,12 @@ export const TickmarkrConfigSchema = z.object({
408
409
  tipTest: z.string(),
409
410
  lint: z.string(),
410
411
  diffCap: z.number().int().positive(),
412
+ // OBS-1140: per-run gate-evidence byte quota; the oldest artifacts are evicted (and tombstoned)
413
+ // past it. Zero retains zero artifact bytes. Evidence retention never changes a gate's pass bit.
414
+ evidenceQuotaBytes: z.number().int().nonnegative(),
415
+ // OBS-1199: repair rounds screen known failing files plus affected tests before the full suite
416
+ // that still decides every merge. On unless false here or in executionPolicy, on every driver.
417
+ repairSelection: z.boolean(),
411
418
  byShape: z.partialRecord(z.enum(SHAPES), ShapeGateParticipationSchema).optional(),
412
419
  }).partial(),
413
420
  scope: z.object({
@@ -488,6 +495,7 @@ function assertSeededModelWindows() {
488
495
  }
489
496
  }
490
497
  }
498
+ export const DEFAULT_EVIDENCE_QUOTA_BYTES = 8 * 1024 * 1024;
491
499
  export const DEFAULT_CONFIG = {
492
500
  concurrency: 3,
493
501
  driver: "auto",
@@ -537,10 +545,27 @@ export const DEFAULT_CONFIG = {
537
545
  // retired id. Installed codex 0.144.1 lists it (models_cache.json client_version 0.144.0, fetched
538
546
  // 2026-07-10) AND a live runtime probe (`codex exec --model gpt-5.6-sol` → "OK", 2026-07-10) confirmed
539
547
  // it runs. Reseeding away would be a regression — the CLI upgrade closed the gate. Seed stays.
548
+ // v2.6.4 T3 (B1b, D-717): gpt-6-astra / gpt-6-sol / gpt-6-luna seeded AHEAD of the retained gpt-5.x
549
+ // ids from the installed CLI's models_cache.json snapshot (codex 0.159.0, fetched 2026-09-29T16:09Z).
550
+ // Tiers are ASSUMPTIONS, not measurements — no benchmark was run or cited for any of the three:
551
+ // astra=frontier and luna=cheap follow the provider's own descriptions in the local cache (read
552
+ // 2026-09-29: "Frontier intelligence for the most demanding work" / "Fast and affordable model for
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
557
+ // with, and the authority for these seeds. The API's published 1,050,000 is a different surface and is
558
+ // not contradicted. A later cache listing more ids (gpt-6.1-sol) does not widen this sealed set.
540
559
  codex: {
541
560
  vendor: "openai", channel: "sub",
542
- models: { "gpt-5.6-sol": "frontier", "gpt-5.5": "frontier", "gpt-5.6-terra": "mid", "gpt-5.6-luna": "cheap" },
543
- windows: { "gpt-5.6-sol": 1_050_000, "gpt-5.5": 1_050_000, "gpt-5.6-terra": 1_050_000, "gpt-5.6-luna": 1_050_000 },
561
+ models: {
562
+ "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
+ },
565
+ windows: {
566
+ "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
+ },
544
569
  },
545
570
  // grok-4.5 (xAI, released 2026-07-08) → mid: AA Intelligence 54 (#4), Terminal-Bench 2.1 83.3%
546
571
  // (≈ GPT-5.5 83.4, Fable 5 84.3), SWE-bench Pro 64.7%, ~4.2× more token-efficient than Opus 4.8
@@ -660,7 +685,7 @@ export const DEFAULT_CONFIG = {
660
685
  },
661
686
  },
662
687
  pricing: { cheap: 0.1, mid: 0.5, frontier: 2.5 },
663
- gates: { diffCap: DEFAULT_DIFF_CAP },
688
+ gates: { diffCap: DEFAULT_DIFF_CAP, evidenceQuotaBytes: DEFAULT_EVIDENCE_QUOTA_BYTES },
664
689
  judge: { adapter: "claude-code", model: "fable" },
665
690
  // R3: no `policy` floor — the neutral floor leaves the compiler's per-task assignment standing, so
666
691
  // the path-keyed rule is reachable out of the box rather than raised to full by construction.
@@ -801,7 +826,11 @@ function resolveRoutingMode(cfg, layers) {
801
826
  /** loadConfig plus the mode-resolution record (floor provenance + plan lints). The resolved floors are
802
827
  * already applied to cfg.routing.floors — route() consumes floors only and never sees the mode. */
803
828
  export function loadConfigWithMode(repoRoot, opts = {}) {
804
- const globalCfg = readYaml(join(opts.globalDir ?? globalConfigDir(), "config.yaml"));
829
+ // B2: userOverlayText substitutes candidate bytes for the on-disk USER layer — the fleet's save
830
+ // destination — so a preview and the write guard load exactly the bytes a save would land.
831
+ const globalCfg = opts.userOverlayText === undefined
832
+ ? readYaml(join(opts.globalDir ?? globalConfigDir(), "config.yaml"))
833
+ : parse(opts.userOverlayText);
805
834
  // v1.52 T2: repoOverlayText substitutes candidate bytes for the on-disk repo layer — the fleet
806
835
  // write guard validates EXACTLY what it is about to write through this one loader path.
807
836
  const repoCfg = opts.repoOverlayText === undefined
@@ -816,24 +845,60 @@ export function loadConfigWithMode(repoRoot, opts = {}) {
816
845
  assertSeededModelWindows();
817
846
  return { cfg: r.data, mode: resolveRoutingMode(r.data, [globalCfg, repoCfg]) };
818
847
  }
819
- /** OBS-1182: tiers.<adapter>.modelOverrides as the layers under the repo overlay (defaults + global)
820
- * merge them. Read raw, never schema-validated: a lower layer may only validate beside repo fields
821
- * (global `vendor: null` completed by a repo vendor) and its override metadata is still inherited. */
848
+ /** OBS-1182: tiers.<adapter>.modelOverrides as the layers under the written overlay merge them —
849
+ * defaults + global under the repo overlay; B2 `below: "user"`: the defaults alone under the USER
850
+ * overlay, which is never read as its own lower layer. Read raw, never schema-validated: a lower
851
+ * layer may only validate beside upper fields (global `vendor: null` completed by a repo vendor)
852
+ * and its override metadata is still inherited.
853
+ * OBS-1188: an unparseable global file, or a non-map where this path expects a map, is an error —
854
+ * never an empty lower layer, which would silently drop the metadata a write must re-mask. */
822
855
  export function lowerLayerModelOverrides(opts = {}) {
823
856
  const isMap = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
824
- const merged = deepMerge(structuredClone(DEFAULT_CONFIG), readYaml(join(opts.globalDir ?? globalConfigDir(), "config.yaml")));
825
- const tiers = isMap(merged) && isMap(merged.tiers) ? merged.tiers : {};
826
- return Object.fromEntries(Object.entries(tiers).map(([adapter, entry]) => [adapter, Object.fromEntries(Object.entries(isMap(entry) && isMap(entry.modelOverrides) ? entry.modelOverrides : {}).filter(([, o]) => isMap(o)))]));
857
+ const path = join(opts.globalDir ?? globalConfigDir(), "config.yaml");
858
+ let global;
859
+ try {
860
+ global = opts.below === "user" ? undefined : readYaml(path);
861
+ }
862
+ catch (error) {
863
+ return { ok: false, error: `lower config layer ${path} is malformed YAML: ${error.message.replace(/\s+/g, " ").trim()}` };
864
+ }
865
+ const malformed = (at) => ({ ok: false, error: `lower config layer ${path} is malformed: ${at} is not a map` });
866
+ // deepMerge replaces a map with any non-map the global declares, so the merged shape is the global's
867
+ const merged = deepMerge(structuredClone(DEFAULT_CONFIG), global);
868
+ if (!isMap(merged))
869
+ return malformed("the document");
870
+ const tiers = merged.tiers ?? {}; // a global `tiers: null` tombstone leaves no lower tiers
871
+ if (!isMap(tiers))
872
+ return malformed("tiers");
873
+ const overrides = {};
874
+ for (const [adapter, entry] of Object.entries(tiers)) {
875
+ if (!isMap(entry))
876
+ return malformed(`tiers.${adapter}`);
877
+ const models = entry.modelOverrides ?? {};
878
+ if (!isMap(models))
879
+ return malformed(`tiers.${adapter}.modelOverrides`);
880
+ for (const [model, override] of Object.entries(models)) {
881
+ if (!isMap(override))
882
+ return malformed(`tiers.${adapter}.modelOverrides.${model}`);
883
+ }
884
+ overrides[adapter] = models;
885
+ }
886
+ return { ok: true, overrides };
827
887
  }
828
888
  export function loadConfig(repoRoot, opts = {}) {
829
889
  return loadConfigWithMode(repoRoot, opts).cfg;
830
890
  }
831
- /** v1.52 T2 write-time reload guard: run candidate repo-overlay bytes through the SAME production
832
- * loader path every later command uses (parse → merge → schema → mode resolution). Returns null
833
- * when the bytes load, else the loader's failure message — the caller must refuse the write. */
891
+ /** v1.52 T2 write-time reload guard: run candidate overlay bytes (the repo layer, or B2's USER layer)
892
+ * through the SAME production loader path every later command uses (parse → merge → schema → mode
893
+ * resolution). Returns null when the bytes load, else the loader's failure message — the caller
894
+ * must refuse the write. */
834
895
  export function overlayBytesLoadError(repoRoot, bytes, opts = {}) {
835
896
  try {
836
- loadConfigWithMode(repoRoot, { ...opts, repoOverlayText: bytes });
897
+ loadConfigWithMode(repoRoot, {
898
+ globalDir: opts.globalDir,
899
+ // B2: a user-layer check may substitute the repo layer too ("" = none: the bytes on their own)
900
+ ...(opts.layer === "user" ? { userOverlayText: bytes, repoOverlayText: opts.repoOverlayText } : { repoOverlayText: bytes }),
901
+ });
837
902
  return null;
838
903
  }
839
904
  catch (e) {
@@ -1048,6 +1113,59 @@ export function fleetKeyLayer(repoRoot, dotted, opts = {}) {
1048
1113
  return "global";
1049
1114
  return "defaults";
1050
1115
  }
1116
+ const isPlainMap = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
1117
+ /** B2: every leaf path an overlay write changes — a map on either side (the other absent) is
1118
+ * walked, anything else (scalar, list, null tombstone) is one leaf. */
1119
+ function changedLeafPaths(before, after, path = []) {
1120
+ if (JSON.stringify(before) === JSON.stringify(after))
1121
+ return [];
1122
+ const walkable = (v) => v === undefined || isPlainMap(v);
1123
+ if (!walkable(before) || !walkable(after))
1124
+ return [path];
1125
+ const b = (before ?? {});
1126
+ const a = (after ?? {});
1127
+ return [...new Set([...Object.keys(b), ...Object.keys(a)])].flatMap((key) => changedLeafPaths(b[key], a[key], [...path, key]));
1128
+ }
1129
+ /** B2: the user-overlay keys a write changes that the repo overlay (merged above it) decides in this
1130
+ * repository — it declares the path, or a non-map (value, list, null tombstone) at a prefix that
1131
+ * replaces or deletes the user's subtree, or its merge otherwise moves the value (a repo pin/pool/
1132
+ * prefer replaces the user's whole routing.map slot atomically) — each with the value that still
1133
+ * applies here. Bytes the loader refuses yield none: the write guard refuses them anyway. */
1134
+ export function repoShadowedUserKeys(repoRoot, beforeUser, afterUser, opts = {}) {
1135
+ let effective;
1136
+ let paths;
1137
+ try {
1138
+ effective = loadConfigWithMode(repoRoot, { globalDir: opts.globalDir, userOverlayText: afterUser }).cfg;
1139
+ paths = changedLeafPaths(parse(beforeUser) ?? undefined, parse(afterUser) ?? undefined);
1140
+ }
1141
+ catch {
1142
+ return [];
1143
+ }
1144
+ // the same bytes without the repo layer: any changed path whose value differs here is the repo's merge
1145
+ let alone;
1146
+ try {
1147
+ alone = loadConfigWithMode(repoRoot, { globalDir: opts.globalDir, userOverlayText: afterUser, repoOverlayText: "" }).cfg;
1148
+ }
1149
+ catch {
1150
+ alone = undefined;
1151
+ }
1152
+ const at = (cfg, path) => path.reduce((cur, key) => (isPlainMap(cur) ? cur[key] : undefined), cfg);
1153
+ const repo = readOverlayFile(repoOverlayPath(repoRoot));
1154
+ const decides = (path) => {
1155
+ if (alone !== undefined && JSON.stringify(at(alone, path)) !== JSON.stringify(at(effective, path)))
1156
+ return true;
1157
+ let cur = repo;
1158
+ for (const key of path) {
1159
+ if (!isPlainMap(cur))
1160
+ return cur !== repo;
1161
+ if (!(key in cur))
1162
+ return false;
1163
+ cur = cur[key];
1164
+ }
1165
+ return true;
1166
+ };
1167
+ return paths.filter(decides).map((path) => ({ path: path.join("."), value: at(effective, path) }));
1168
+ }
1051
1169
  /** Non-interactive fleet state for CI drift checks (`tickmarkr fleet --print`). */
1052
1170
  export function formatFleetPrint(repoRoot, opts = {}) {
1053
1171
  const gdir = opts.globalDir ?? globalConfigDir();
@@ -1,7 +1,7 @@
1
- import { type FleetEditable, type FleetUniverseRow, type RoutingMode } from "./config.js";
1
+ import { type FleetEditable, type FleetUniverseRow, type LowerLayerModelOverrides, type RoutingMode } from "./config.js";
2
2
  /** Fleet-owned overlay keys — the only config surface `tickmarkr fleet` may write. */
3
3
  export declare const FLEET_OVERLAY_KEYS: readonly ["routing", "tiers"];
4
- export type FleetOverlayWrite = {
4
+ type FleetOverlayWriteFields = {
5
5
  initial: FleetEditable;
6
6
  edited: FleetEditable;
7
7
  mode?: RoutingMode;
@@ -20,8 +20,17 @@ export type FleetOverlayWrite = {
20
20
  };
21
21
  };
22
22
  universe?: FleetUniverseRow[];
23
- lowerOverrides?: Record<string, Record<string, Record<string, unknown>>>;
24
23
  };
24
+ type EffortFree = FleetEditable & {
25
+ efforts?: undefined;
26
+ };
27
+ export type FleetOverlayWrite = FleetOverlayWriteFields & ({
28
+ lowerOverrides: LowerLayerModelOverrides;
29
+ } | {
30
+ initial: EffortFree;
31
+ edited: EffortFree;
32
+ lowerOverrides?: undefined;
33
+ });
25
34
  export type FleetFirstTouch = {
26
35
  vendor: string;
27
36
  channel: "sub" | "api";
@@ -36,3 +45,4 @@ export declare function repoOverlayYaml(overlay: Record<string, unknown>): strin
36
45
  export declare function serializeFleetOverlay(overlay: Record<string, unknown>): string;
37
46
  export declare function unifiedYamlDiff(before: string, after: string, label?: string): string;
38
47
  export declare function fleetEditableEquals(a: FleetEditable, b: FleetEditable): boolean;
48
+ export {};
@@ -368,27 +368,32 @@ export function renderFleetOverlayWrite(priorBytes, write) {
368
368
  const after = efforts(edited)[adapter]?.[model];
369
369
  if (efforts(initial)[adapter]?.[model] === after)
370
370
  continue;
371
+ // OBS-1188: the type already demands lower state here; this refuses a cast/JS caller before the
372
+ // bytes are returned, so a blind effort write never reaches disk.
373
+ const lowerAll = write.lowerOverrides;
374
+ if (!lowerAll) {
375
+ throw new Error(`fleet write: the effort edit on ${adapter}:${model} needs the lower config layers' model overrides — refusing to write it blind`);
376
+ }
371
377
  const override = ["tiers", adapter, "modelOverrides", model];
372
378
  if (after !== undefined) {
373
379
  // Setting beneath a tombstone (the override, or modelOverrides above it) lifts it, and
374
380
  // deepMerge would restore what it masked: re-mask each lower-layer key it covered — sibling
375
- // models, then this override's vendor/channel (unknown lower ⇒ both, blind).
381
+ // models, then this override's own keys.
376
382
  // ponytail: tiers.<adapter> itself tombstoned is unreachable here — its models, and so a tier, are masked too.
377
383
  const tombstoned = (depth) => {
378
384
  const node = doc.getIn(override.slice(0, depth), true);
379
385
  return node !== undefined && !isMap(node);
380
386
  };
381
- const lowerModels = write.lowerOverrides?.[adapter] ?? {};
387
+ const lowerModels = lowerAll[adapter] ?? {};
382
388
  const remask = [];
383
389
  if (tombstoned(3))
384
390
  for (const other of Object.keys(lowerModels))
385
391
  if (other !== model)
386
392
  remask.push([...override.slice(0, 3), other]);
387
393
  if (tombstoned(3) || tombstoned(4)) {
388
- for (const key of write.lowerOverrides ? Object.keys(lowerModels[model] ?? {}) : ["vendor", "channel"]) {
394
+ for (const key of Object.keys(lowerModels[model] ?? {}))
389
395
  if (key !== "effort")
390
396
  remask.push([...override, key]);
391
- }
392
397
  }
393
398
  setScalarPreservingComment(doc, [...override, "effort"], after);
394
399
  for (const path of remask)
@@ -396,11 +401,10 @@ export function renderFleetOverlayWrite(priorBytes, write) {
396
401
  continue;
397
402
  }
398
403
  // Clearing means no effort in the MERGED config: drop the repo key, then mask an effort the
399
- // lower layers still declare (unknown lower ⇒ a repo that never held the key inherited it).
400
- const held = doc.getIn([...override, "effort"]) !== undefined;
404
+ // lower layers still declare.
401
405
  deleteAt(doc, [...override, "effort"]);
402
- const lower = write.lowerOverrides?.[adapter]?.[model];
403
- const maskEffort = write.lowerOverrides ? lower?.effort !== undefined : !held;
406
+ const lower = lowerAll[adapter]?.[model];
407
+ const maskEffort = lower?.effort !== undefined;
404
408
  const repoNode = doc.getIn(override, true);
405
409
  const { effort: _masked, ...rest } = { ...lower, ...(isMap(repoNode) ? repoNode.toJSON() : {}) };
406
410
  if (Object.values(rest).some((v) => v !== null && v !== undefined)) {
@@ -62,6 +62,8 @@ export declare class HerdrDriver implements ExecutorDriver {
62
62
  private deliverySerial;
63
63
  private dispatchLeases;
64
64
  private deliveredPanes;
65
+ /** OBS-1203: slots bound by adopt() — their reads are harvest evidence, so a failed read throws. */
66
+ private adoptedSlots;
65
67
  private inputBoxes;
66
68
  private bootstraps;
67
69
  private journalRoots;
@@ -93,6 +95,16 @@ export declare class HerdrDriver implements ExecutorDriver {
93
95
  private paneId;
94
96
  private paneWidth;
95
97
  slot(cwd: string, name: string, opts?: SlotOpts): Promise<Slot>;
98
+ /**
99
+ * OBS-1203: bind THIS instance, READ-ONLY, to the pane an earlier daemon created for an owned slot,
100
+ * so a resumed daemon can read the interrupted attempt's trailer. slot() is never a substitute: it
101
+ * allocates a tab, reclaimStaleLabel closes the very pane holding the evidence, and it holds a
102
+ * dispatch lease only run() releases. Binding needs ownership evidence, never a label alone: exactly
103
+ * one pane carries the FULL owned label, herdr reports that pane's cwd as the journaled task
104
+ * checkout, and that path is a git checkout whose own top level it is. Zero, ambiguous or foreign
105
+ * candidates throw — the caller's decline. `pane list` and `git rev-parse` are the only calls made.
106
+ */
107
+ adopt(slot: Slot): Promise<Slot>;
96
108
  private tabSlot;
97
109
  private reclaimStaleLabel;
98
110
  private staleLabelPanes;