tickmarkr 2.6.3 → 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.
@@ -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));
@@ -394,19 +394,23 @@ export type ModeResolution = {
394
394
  export declare function loadConfigWithMode(repoRoot: string, opts?: {
395
395
  globalDir?: string;
396
396
  repoOverlayText?: string;
397
+ userOverlayText?: string;
397
398
  }): {
398
399
  cfg: TickmarkrConfig;
399
400
  mode: ModeResolution;
400
401
  };
401
- /** OBS-1182: tiers.<adapter>.modelOverrides as the layers under the repo overlay resolve them. */
402
+ /** OBS-1182: tiers.<adapter>.modelOverrides as the layers under the written overlay resolve them. */
402
403
  export type LowerLayerModelOverrides = Record<string, Record<string, Record<string, unknown>>>;
403
- /** OBS-1182: tiers.<adapter>.modelOverrides as the layers under the repo overlay (defaults + global)
404
- * merge them. Read raw, never schema-validated: a lower layer may only validate beside repo fields
405
- * (global `vendor: null` completed by a repo vendor) and its override metadata is still inherited.
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.
406
409
  * OBS-1188: an unparseable global file, or a non-map where this path expects a map, is an error —
407
410
  * never an empty lower layer, which would silently drop the metadata a write must re-mask. */
408
411
  export declare function lowerLayerModelOverrides(opts?: {
409
412
  globalDir?: string;
413
+ below?: "repo" | "user";
410
414
  }): {
411
415
  ok: true;
412
416
  overrides: LowerLayerModelOverrides;
@@ -417,11 +421,14 @@ export declare function lowerLayerModelOverrides(opts?: {
417
421
  export declare function loadConfig(repoRoot: string, opts?: {
418
422
  globalDir?: string;
419
423
  }): TickmarkrConfig;
420
- /** v1.52 T2 write-time reload guard: run candidate repo-overlay bytes through the SAME production
421
- * loader path every later command uses (parse → merge → schema → mode resolution). Returns null
422
- * 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. */
423
428
  export declare function overlayBytesLoadError(repoRoot: string, bytes: string, opts?: {
424
429
  globalDir?: string;
430
+ layer?: "repo" | "user";
431
+ repoOverlayText?: string;
425
432
  }): string | null;
426
433
  export type InitConfigOverlay = {
427
434
  concurrency?: number;
@@ -474,6 +481,17 @@ export declare function fleetEditableFromConfig(cfg: TickmarkrConfig, universe?:
474
481
  export declare function fleetKeyLayer(repoRoot: string, dotted: string, opts?: {
475
482
  globalDir?: string;
476
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
+ }>;
477
495
  /** Non-interactive fleet state for CI drift checks (`tickmarkr fleet --print`). */
478
496
  export declare function formatFleetPrint(repoRoot: string, opts?: {
479
497
  globalDir?: string;
@@ -545,10 +545,27 @@ export const DEFAULT_CONFIG = {
545
545
  // retired id. Installed codex 0.144.1 lists it (models_cache.json client_version 0.144.0, fetched
546
546
  // 2026-07-10) AND a live runtime probe (`codex exec --model gpt-5.6-sol` → "OK", 2026-07-10) confirmed
547
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.
548
559
  codex: {
549
560
  vendor: "openai", channel: "sub",
550
- models: { "gpt-5.6-sol": "frontier", "gpt-5.5": "frontier", "gpt-5.6-terra": "mid", "gpt-5.6-luna": "cheap" },
551
- 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
+ },
552
569
  },
553
570
  // grok-4.5 (xAI, released 2026-07-08) → mid: AA Intelligence 54 (#4), Terminal-Bench 2.1 83.3%
554
571
  // (≈ GPT-5.5 83.4, Fable 5 84.3), SWE-bench Pro 64.7%, ~4.2× more token-efficient than Opus 4.8
@@ -809,7 +826,11 @@ function resolveRoutingMode(cfg, layers) {
809
826
  /** loadConfig plus the mode-resolution record (floor provenance + plan lints). The resolved floors are
810
827
  * already applied to cfg.routing.floors — route() consumes floors only and never sees the mode. */
811
828
  export function loadConfigWithMode(repoRoot, opts = {}) {
812
- 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);
813
834
  // v1.52 T2: repoOverlayText substitutes candidate bytes for the on-disk repo layer — the fleet
814
835
  // write guard validates EXACTLY what it is about to write through this one loader path.
815
836
  const repoCfg = opts.repoOverlayText === undefined
@@ -824,9 +845,11 @@ export function loadConfigWithMode(repoRoot, opts = {}) {
824
845
  assertSeededModelWindows();
825
846
  return { cfg: r.data, mode: resolveRoutingMode(r.data, [globalCfg, repoCfg]) };
826
847
  }
827
- /** OBS-1182: tiers.<adapter>.modelOverrides as the layers under the repo overlay (defaults + global)
828
- * merge them. Read raw, never schema-validated: a lower layer may only validate beside repo fields
829
- * (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.
830
853
  * OBS-1188: an unparseable global file, or a non-map where this path expects a map, is an error —
831
854
  * never an empty lower layer, which would silently drop the metadata a write must re-mask. */
832
855
  export function lowerLayerModelOverrides(opts = {}) {
@@ -834,7 +857,7 @@ export function lowerLayerModelOverrides(opts = {}) {
834
857
  const path = join(opts.globalDir ?? globalConfigDir(), "config.yaml");
835
858
  let global;
836
859
  try {
837
- global = readYaml(path);
860
+ global = opts.below === "user" ? undefined : readYaml(path);
838
861
  }
839
862
  catch (error) {
840
863
  return { ok: false, error: `lower config layer ${path} is malformed YAML: ${error.message.replace(/\s+/g, " ").trim()}` };
@@ -865,12 +888,17 @@ export function lowerLayerModelOverrides(opts = {}) {
865
888
  export function loadConfig(repoRoot, opts = {}) {
866
889
  return loadConfigWithMode(repoRoot, opts).cfg;
867
890
  }
868
- /** v1.52 T2 write-time reload guard: run candidate repo-overlay bytes through the SAME production
869
- * loader path every later command uses (parse → merge → schema → mode resolution). Returns null
870
- * 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. */
871
895
  export function overlayBytesLoadError(repoRoot, bytes, opts = {}) {
872
896
  try {
873
- 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
+ });
874
902
  return null;
875
903
  }
876
904
  catch (e) {
@@ -1085,6 +1113,59 @@ export function fleetKeyLayer(repoRoot, dotted, opts = {}) {
1085
1113
  return "global";
1086
1114
  return "defaults";
1087
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
+ }
1088
1169
  /** Non-interactive fleet state for CI drift checks (`tickmarkr fleet --print`). */
1089
1170
  export function formatFleetPrint(repoRoot, opts = {}) {
1090
1171
  const gdir = opts.globalDir ?? globalConfigDir();
@@ -55,6 +55,17 @@ export declare const taskPool: (task: Task, cfg: TickmarkrConfig) => {
55
55
  /** Park-reason suffix naming the pool an exhausted re-dispatch stayed inside; empty when unpooled. */
56
56
  export declare const poolExhaustion: (task: Task, cfg: TickmarkrConfig) => string;
57
57
  export declare const inTaskPool: (task: Task, cfg: TickmarkrConfig, key: string) => boolean;
58
+ /**
59
+ * F (D-718): the one exclusion meaning route(), nextChannel() and climbChannel() share — explicit
60
+ * exclusions, demotions, tried and escalation history, and climb skips — exported so resume can test a
61
+ * restored assignment the same way. An excluded raw channel key always excludes itself (the fallback).
62
+ * The exclusion extends to another offered channel only when BOTH offered channels carry a probed
63
+ * identity and the two are equal. A missing identity is neutral: it never matches and never blocks.
64
+ * Residual, by ruling: an unprobed alias of an excluded model stays eligible by its raw key — this is
65
+ * not complete alias exclusion; reopen on an executed re-dispatch of a tried model through an alias
66
+ * with no probed identity. The daemon's nextChannel-null exhaustion fallback is not this policy's.
67
+ */
68
+ export declare function channelExclusion(offered: readonly BillingChannel[], excluded: Iterable<string>): (seat: ChannelSeat) => boolean;
58
69
  export declare function route(task: Task, cfg: TickmarkrConfig, channels: BillingChannel[], profile?: RoutingProfile, preferCtx?: RoutingPreferContext, exclude?: ReadonlySet<string>, exploreCtx?: ExploreContext): Route;
59
70
  export declare function nextChannel(current: Assignment, task: Task, cfg: TickmarkrConfig, channels: BillingChannel[], tried: string[], profile?: RoutingProfile, exclude?: ReadonlySet<string>): Assignment | null;
60
71
  export type ClimbPick = Assignment & {
@@ -1,6 +1,6 @@
1
1
  import { channelKey, channelsFromConfig } from "../adapters/types.js";
2
2
  import { TIER_RANK } from "../config/config.js";
3
- import { channelRouteIdentity, disallowedBy, modelRouteIdentity, routingModelProvider } from "./preference.js";
3
+ import { disallowedBy, routingModelProvider } from "./preference.js";
4
4
  import { cellOf, EXPLORE_CAP, explorationBonus, learnedScore, MIN_SAMPLES } from "./profile.js";
5
5
  export const NO_EXPLORE_ENV = "TICKMARKR_NO_EXPLORE";
6
6
  // OBS-89 (v1.60): the TICKMARKR_QUALITY variable is RETIRED — nothing in src reads it anymore and
@@ -124,11 +124,33 @@ function spreadFrontierTies(sorted, task, prefer, tied) {
124
124
  }
125
125
  return out;
126
126
  }
127
+ /**
128
+ * F (D-718): the one exclusion meaning route(), nextChannel() and climbChannel() share — explicit
129
+ * exclusions, demotions, tried and escalation history, and climb skips — exported so resume can test a
130
+ * restored assignment the same way. An excluded raw channel key always excludes itself (the fallback).
131
+ * The exclusion extends to another offered channel only when BOTH offered channels carry a probed
132
+ * identity and the two are equal. A missing identity is neutral: it never matches and never blocks.
133
+ * Residual, by ruling: an unprobed alias of an excluded model stays eligible by its raw key — this is
134
+ * not complete alias exclusion; reopen on an executed re-dispatch of a tried model through an alias
135
+ * with no probed identity. The daemon's nextChannel-null exhaustion fallback is not this policy's.
136
+ */
137
+ export function channelExclusion(offered, excluded) {
138
+ const keys = new Set(excluded);
139
+ const identityOf = new Map(offered.flatMap((c) => (c.identity ? [[channelKey(c), c.identity]] : [])));
140
+ const identities = new Set([...keys].flatMap((k) => identityOf.get(k) ?? []));
141
+ return (seat) => {
142
+ const key = channelKey(seat);
143
+ const identity = identityOf.get(key);
144
+ return keys.has(key) || (identity !== undefined && identities.has(identity));
145
+ };
146
+ }
127
147
  function withoutExcluded(channels, exclude) {
128
148
  if (!exclude?.size)
129
149
  return channels;
130
150
  // OBS-57: in-run demotion after consecutive no-trailer windows — route around poisoned channels for the rest of the run.
131
- return channels.filter((c) => !exclude.has(channelKey(c)));
151
+ // D-718: a demoted channel's probed-identical alias is routed around too; an unprobed one is not.
152
+ const excluded = channelExclusion(channels, exclude);
153
+ return channels.filter((c) => !excluded(c));
132
154
  }
133
155
  // Pools spread providers before tier, independently within each marginal-cost run. Stable
134
156
  // cost sorting preserves declaration order when forming groups; gateways share the served
@@ -394,19 +416,18 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
394
416
  return { assignment: toAssignment(eligible[0]), ladder: ladderFor(task, entry), lints, provenance: `${degraded}${bound}, marginal-cost auto (${chosenBy})`, ...(deviation ? { deviation } : {}) };
395
417
  }
396
418
  function candidatePool(current, task, cfg, channels, tried, tierPredicate, exclude) {
397
- channels = withoutExcluded(channels, exclude).filter((c) => inTaskPool(task, cfg, channelKey(c)));
419
+ // D-718: tried/escalated/demoted keys share channelExclusion's meaning, resolved against every offered
420
+ // channel; the provider-outage rule below still reads the raw-key remainder exactly as before.
421
+ const excluded = channelExclusion(channels, [...(exclude ?? []), ...tried]);
422
+ channels = channels.filter((c) => !exclude?.has(channelKey(c)) && inTaskPool(task, cfg, channelKey(c)));
398
423
  const triedKeys = new Set(tried);
399
- const triedIdentities = new Set(tried.map((key) => {
400
- const channel = channels.find((c) => channelKey(c) === key);
401
- return channel ? modelRouteIdentity(channel.model, channel.vendor) : channelRouteIdentity(key);
402
- }));
403
424
  // excludeAdapter is expanded by the daemon into every channel key of the failed adapter. Once that
404
425
  // complete set is present, the outage follows the current served provider across gateway aliases.
405
426
  const currentAdapterExcluded = channels.some((c) => c.adapter === current.adapter)
406
427
  && channels.filter((c) => c.adapter === current.adapter).every((c) => triedKeys.has(channelKey(c)));
407
428
  const currentChannel = channels.find((c) => c.adapter === current.adapter && c.model === current.model);
408
429
  const excludedProvider = currentAdapterExcluded ? routingModelProvider(current.model, currentChannel?.vendor) : undefined;
409
- return channels.filter((c) => !triedIdentities.has(modelRouteIdentity(c.model, c.vendor))
430
+ return channels.filter((c) => !excluded(c)
410
431
  && (!excludedProvider || routingModelProvider(c.model, c.vendor) !== excludedProvider)
411
432
  && tierPredicate(c.tier));
412
433
  }
@@ -39,7 +39,7 @@ import { applyScopeAmendments, activeRetryBan, interruptedAttempt, RESUME_HARVES
39
39
  import { gateReviewerFloor, isDiffCapPark, isGarbageReview, pickReviewer } from "../gates/review.js";
40
40
  import { acquireApprovalSerialization, acquireRunLock, isPidLive, releaseRunLock } from "./lock.js";
41
41
  import { ensureIntegration, integrationBranch, integrationHead, mergeTask, reusedTipEvidence, verifyIntegrationTip } from "./merge.js";
42
- import { climbChannel, inTaskPool, marginalCostRank, nextChannel, poolExhaustion, route, taskPool } from "../route/router.js";
42
+ import { channelExclusion, climbChannel, inTaskPool, marginalCostRank, nextChannel, poolExhaustion, route, taskPool } from "../route/router.js";
43
43
  import { desiredPanes } from "./reconcile.js";
44
44
  import { readTierLiveness, readWatchBoard, supervisionPresencePath } from "./supervision.js";
45
45
  import { harvestCpuFlatWindowMs, NUDGEABLE_ADAPTERS, PANE_READ_ROWS, StallProgressTracker, stallSnapshotBannerRows, WorkerTreeCpuAccountant, reapOwnedProcessGroup, readOwnedProcessGroup, } from "./stall.js";
@@ -3215,10 +3215,12 @@ export async function runDaemon(repoRoot, opts = {}) {
3215
3215
  // and a lastAssignment at zero attempts is a first dispatch whose capacity requeue was taken back:
3216
3216
  // the seat is still in force, so restore it instead of failing over its own tried[] entry early.
3217
3217
  // OBS-1187: a restored seat must still be a member of the task's pool (the operator may have closed it).
3218
+ // F/W (D-718): and not excluded under the router's one meaning — its raw key, or a probed identity
3219
+ // it shares with an excluded channel; an unprobed seat is excluded by its raw key alone.
3218
3220
  if (!hintsChanged && rs?.lastAssignment
3219
3221
  && channels.some((c) => channelKey(c) === channelKey(rs.lastAssignment))
3220
3222
  && inTaskPool(t, cfg, channelKey(rs.lastAssignment))
3221
- && !demotedChannels.has(channelKey(rs.lastAssignment))) {
3223
+ && !channelExclusion(channels, demotedChannels)(rs.lastAssignment)) {
3222
3224
  assignment = rs.lastAssignment; // restore the consult-chosen assignment (bypasses route()'s static re-pick)
3223
3225
  }
3224
3226
  else if (!hintsChanged && rs && rs.tried.length && !t.routingHints?.pin) {
@@ -4016,7 +4018,10 @@ export async function runDaemon(repoRoot, opts = {}) {
4016
4018
  const followsApproved = GATE_NAMES.indexOf(gate) > satisfiedIndex;
4017
4019
  const otherRed = prior?.pass === false;
4018
4020
  const needsFullSuite = gate === "test" && prior?.fullSuite !== true;
4019
- return followsApproved || otherRed || needsFullSuite;
4021
+ // W: a red evidence/scope screen ends the round before the battery, so a gate that PRECEDES
4022
+ // the waived one may never have run: no prior-round row is no verdict — run it as declared.
4023
+ const unobserved = prior === undefined;
4024
+ return followsApproved || otherRed || needsFullSuite || unobserved;
4020
4025
  });
4021
4026
  }
4022
4027
  else {
@@ -435,8 +435,8 @@ export declare const TelemetryRowSchema: z.ZodObject<{
435
435
  quotaFailover: z.ZodOptional<z.ZodLiteral<true>>;
436
436
  overrun: z.ZodOptional<z.ZodLiteral<true>>;
437
437
  retryMode: z.ZodOptional<z.ZodEnum<{
438
- repair: "repair";
439
438
  resume: "resume";
439
+ repair: "repair";
440
440
  fresh: "fresh";
441
441
  }>>;
442
442
  signalQuality: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<0>, z.ZodLiteral<0.25>, z.ZodLiteral<0.5>, z.ZodLiteral<0.75>, z.ZodLiteral<1>]>>;
@@ -593,4 +593,100 @@ export declare class Journal {
593
593
  readTelemetry(): TelemetryRow[];
594
594
  readJudgeTelemetry(): TelemetryRow[];
595
595
  }
596
+ export declare const OWED_CHECK_VERSION: 1;
597
+ export declare const OWED_DISCHARGE_EVENT: "owed-check-discharged";
598
+ export interface OwedCheck {
599
+ version: typeof OWED_CHECK_VERSION;
600
+ id: string;
601
+ runId: string;
602
+ taskId: string;
603
+ gate: GateName;
604
+ /** The immutable range the waived gate measured, and its canonical subject (daemon gateCommitSubject). */
605
+ base: string;
606
+ head: string;
607
+ subject: string;
608
+ /** Verbatim (whitespace-sensitive) patch identity of the whole range and of each commit, in order — the integration mapping key. */
609
+ patch: string;
610
+ patches: string[];
611
+ criteria: string;
612
+ /** The task's file scope at waive time: a discharge gates exactly this allowlist, never a wider one. */
613
+ files: string[];
614
+ authors: string[];
615
+ /** The channels this repository's adapters declared under its config at waive time (key → vendor):
616
+ * the only seats a review discharge's reviewer and author channels may resolve to. */
617
+ declared: ChannelFact[];
618
+ cause: string;
619
+ evidence: string;
620
+ disposition: "accepted-risk";
621
+ known: boolean;
622
+ unknownReason?: string;
623
+ }
624
+ export interface OwedFold {
625
+ known: boolean;
626
+ debt: number | "unknown";
627
+ outstanding: OwedCheck[];
628
+ acceptedRisk: OwedCheck[];
629
+ discharged: string[];
630
+ unknown: Array<{
631
+ taskId?: string;
632
+ gate?: string;
633
+ reason: string;
634
+ }>;
635
+ }
636
+ export declare const owedCriteria: (acceptance: unknown) => string;
637
+ /** The daemon's canonical gate subject for base..head (daemon.ts gateCommitSubject), byte for byte. */
638
+ export declare const owedSubject: (cwd: string, base: string, head: string) => string;
639
+ /** Ordered verbatim patch ids of the commits in base..head; an empty commit authored no patch. */
640
+ export declare const rangePatches: (cwd: string, base: string, head: string) => string[];
641
+ /** Verbatim patch identity of the whole change base..head introduces. */
642
+ export declare const rangePatch: (cwd: string, base: string, head: string) => string;
643
+ /**
644
+ * `head` is the task's own recorded integration merge of the obligation's range — exactly. Its first
645
+ * parent is `mergeBase`; its second parent replays the waived history source tree for source tree from
646
+ * a base with the waived base's tree (a carry may mint new commit objects, never new content — and
647
+ * patch ids alone ignore where a hunk lands); its tree is git's own clean merge of the two (no
648
+ * resolution, no amend); and it introduced the same ordered verbatim patch series and total patch.
649
+ * Squashed, reordered, relocated, re-indented, reverted or conflict-resolved history fails one of
650
+ * these and is refused as ambiguous; containment never counts.
651
+ * Both verify (before discharging) and the fold (re-reading every discharge) decide through here.
652
+ */
653
+ export declare function integrationMapped(cwd: string, events: readonly JournalEvent[], o: OwedCheck, mergeBase: unknown, head: unknown): boolean;
654
+ /**
655
+ * Lifetime patch authors of base..head, read from the run: the daemon's subjectAuthors fold
656
+ * (daemon.ts, not exported and not this task's to edit) restated synchronously so the waive row is
657
+ * written without yielding. Recreation rows name SOURCE hashes, so ownership joins by stable patch
658
+ * identity across changed commit objects; a gate-only restore (resume-restore's seat, a recreation
659
+ * after work started) never becomes an author. Unreadable history names an unknown author.
660
+ */
661
+ export declare function owedAuthors(events: readonly JournalEvent[], taskId: string, cwd: string, base: string, head: string): string[];
662
+ /**
663
+ * The obligation a waive of `gate` leaves on the task branch's current range. Synchronous on purpose:
664
+ * approve writes it into the waive row under the approval serialization without yielding, so a live
665
+ * daemon's boundary sees the same single append it always did. Anything not bound exactly — the
666
+ * waived row measured another subject, unreadable refs, unresolved authors, unreadable criteria — is
667
+ * recorded, never guessed, as known: false.
668
+ */
669
+ export declare function captureOwedCheck(input: {
670
+ cwd: string;
671
+ runId: string;
672
+ taskId: string;
673
+ gate: GateName;
674
+ cause: string;
675
+ acceptance: unknown;
676
+ files: unknown;
677
+ declared: readonly ChannelFact[] | undefined;
678
+ taskRef: string;
679
+ integrationRef: string;
680
+ events: readonly JournalEvent[];
681
+ }): OwedCheck;
682
+ export type ChannelFact = {
683
+ key: string;
684
+ vendor: string;
685
+ };
686
+ /**
687
+ * The CURRENT owed-check fold: accepted-risk history, outstanding debt, and whether it is known.
688
+ * `cwd` is any checkout of the run's repository (integration discharges are re-proved from git).
689
+ * It never throws: a fold that cannot be computed is unknown debt, not a crash and never zero.
690
+ */
691
+ export declare function foldOwedChecks(events: readonly JournalEvent[], cwd: string): OwedFold;
596
692
  export {};