tickmarkr 2.6.3 → 2.6.5

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 +10 -2
  2. package/dist/adapters/catalog-remote.d.ts +23 -0
  3. package/dist/adapters/catalog-remote.js +38 -0
  4. package/dist/adapters/claude-code.d.ts +1 -1
  5. package/dist/adapters/claude-code.js +4 -1
  6. package/dist/adapters/codex-commit-check.d.ts +25 -0
  7. package/dist/adapters/codex-commit-check.js +108 -0
  8. package/dist/adapters/codex.d.ts +2 -1
  9. package/dist/adapters/codex.js +26 -5
  10. package/dist/adapters/model-discovery-check.d.ts +33 -0
  11. package/dist/adapters/model-discovery-check.js +123 -0
  12. package/dist/adapters/model-lints.d.ts +2 -1
  13. package/dist/adapters/model-lints.js +52 -1
  14. package/dist/adapters/model-windows.d.ts +2 -1
  15. package/dist/adapters/model-windows.js +18 -7
  16. package/dist/adapters/prompt.js +3 -2
  17. package/dist/adapters/types.d.ts +13 -0
  18. package/dist/cli/commands/approve.js +37 -6
  19. package/dist/cli/commands/doctor.d.ts +4 -0
  20. package/dist/cli/commands/doctor.js +20 -3
  21. package/dist/cli/commands/fleet.d.ts +10 -1
  22. package/dist/cli/commands/fleet.js +279 -95
  23. package/dist/cli/commands/init.js +10 -4
  24. package/dist/cli/commands/plan.js +65 -4
  25. package/dist/cli/commands/report.d.ts +39 -2
  26. package/dist/cli/commands/report.js +227 -10
  27. package/dist/cli/commands/resume.js +2 -4
  28. package/dist/cli/commands/run.d.ts +11 -0
  29. package/dist/cli/commands/run.js +33 -5
  30. package/dist/cli/commands/status.js +30 -3
  31. package/dist/cli/commands/verify.d.ts +1 -1
  32. package/dist/cli/commands/verify.js +356 -123
  33. package/dist/compile/native.js +130 -0
  34. package/dist/config/config.d.ts +25 -7
  35. package/dist/config/config.js +92 -11
  36. package/dist/config/fleet-overlay.d.ts +2 -1
  37. package/dist/config/fleet-overlay.js +77 -4
  38. package/dist/drivers/orca.d.ts +19 -1
  39. package/dist/drivers/orca.js +161 -31
  40. package/dist/gates/cache.d.ts +4 -0
  41. package/dist/gates/cache.js +10 -2
  42. package/dist/gates/llm.d.ts +4 -1
  43. package/dist/gates/llm.js +6 -1
  44. package/dist/gates/review.js +8 -3
  45. package/dist/gates/run-gates.d.ts +3 -2
  46. package/dist/gates/run-gates.js +91 -50
  47. package/dist/route/router.d.ts +11 -0
  48. package/dist/route/router.js +29 -8
  49. package/dist/run/daemon.js +158 -22
  50. package/dist/run/git.js +9 -0
  51. package/dist/run/journal.d.ts +101 -1
  52. package/dist/run/journal.js +352 -2
  53. package/dist/run/launchservices-check.d.ts +56 -0
  54. package/dist/run/launchservices-check.js +117 -0
  55. package/dist/run/operator-state.d.ts +22 -2
  56. package/dist/run/operator-state.js +20 -3
  57. package/dist/run/title-environment.d.ts +45 -0
  58. package/dist/run/title-environment.js +127 -0
  59. package/dist/run/title-preload.cjs +38 -0
  60. package/dist/run/title-preload.d.cts +1 -0
  61. package/dist/tui/cockpit/board.js +12 -6
  62. package/dist/tui/cockpit/keys.d.ts +8 -0
  63. package/dist/tui/cockpit/keys.js +4 -1
  64. package/dist/tui/cockpit/layout.d.ts +2 -2
  65. package/dist/tui/cockpit/layout.js +2 -2
  66. package/dist/tui/cockpit/live-runtime.js +46 -20
  67. package/dist/tui/cockpit/live-store.d.ts +30 -1
  68. package/dist/tui/cockpit/live-store.js +134 -18
  69. package/dist/tui/cockpit/log-view.d.ts +28 -0
  70. package/dist/tui/cockpit/log-view.js +60 -0
  71. package/dist/tui/cockpit/run-view.js +4 -0
  72. package/dist/tui/cockpit/shell.d.ts +2 -1
  73. package/dist/tui/cockpit/shell.js +6 -4
  74. package/dist/tui/ink/fleet-app.d.ts +12 -6
  75. package/dist/tui/ink/fleet-app.js +17 -11
  76. package/package.json +1 -1
  77. package/skills/tickmarkr-auto/SKILL.md +33 -3
  78. package/skills/tickmarkr-loop/SKILL.md +55 -5
  79. package/skills/tickmarkr-overseer/SKILL.md +83 -8
@@ -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();
@@ -1,4 +1,4 @@
1
- import { type FleetEditable, type FleetUniverseRow, type LowerLayerModelOverrides, type RoutingMode } from "./config.js";
1
+ import { type FleetEditable, type FleetUniverseRow, type LowerLayerModelOverrides, type MapEntry, 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
4
  type FleetOverlayWriteFields = {
@@ -20,6 +20,7 @@ type FleetOverlayWriteFields = {
20
20
  };
21
21
  };
22
22
  universe?: FleetUniverseRow[];
23
+ lowerMap?: Record<string, MapEntry>;
23
24
  };
24
25
  type EffortFree = FleetEditable & {
25
26
  efforts?: undefined;
@@ -1,5 +1,5 @@
1
1
  // Fleet-overlay mutation, serialization, and diff rendering for the `tickmarkr fleet` write path.
2
- import { isMap, isScalar, isSeq, parseDocument, stringify, visit } from "yaml";
2
+ import { isAlias, isMap, isNode, isScalar, isSeq, parseDocument, stringify, visit } from "yaml";
3
3
  import { DENY_SCOPES, universeCovers, universeEntryMatches } from "./config.js";
4
4
  // OBS-1099 add.1: the writer ranges over the schema-derived scopes. The flat (all-seats) scopes
5
5
  // take part in the membership/allow-form write; every nested scope (routing.deny.workers.*) is a
@@ -161,7 +161,71 @@ function setStringSequencePreservingComments(doc, path, values) {
161
161
  // Total forms: a delete treats the masked subtree as absent (the operator's tombstone already
162
162
  // denies it, and survives untouched); a set clears the scalar and rebuilds what fleet owns —
163
163
  // the routing.allow precedent below.
164
+ // T8 repair: an alias is no tombstone — `spec: *empty` resolves to a mapping. Each alias on the path
165
+ // is replaced by an un-anchored copy of its target, so what it resolves to decides, and a write under
166
+ // it never edits the anchored node its other aliases share.
167
+ function materializeAliases(doc, path) {
168
+ for (let depth = 1; depth < path.length; depth++) {
169
+ const node = doc.getIn(path.slice(0, depth), true);
170
+ if (!isAlias(node))
171
+ continue;
172
+ const target = node.resolve(doc);
173
+ if (target === undefined)
174
+ return;
175
+ doc.setIn(path.slice(0, depth), detachedCopy(doc, node, target, new Set()));
176
+ }
177
+ }
178
+ const nodesOf = (root) => {
179
+ const out = [];
180
+ visit(root, (_key, node) => {
181
+ if (isNode(node))
182
+ out.push(node);
183
+ });
184
+ return out;
185
+ };
186
+ // D-821: a copy carries NO anchor (a nested `&empty` would rebind `plan: *empty` and every later
187
+ // alias to the edited copy), and each inner alias must still name what it named in the original —
188
+ // one whose anchor is redefined before `at` is replaced by a detached copy of its original target.
189
+ function detachedCopy(doc, at, target, expanding) {
190
+ if (expanding.has(target))
191
+ throw new Error(`fleet cannot detach the recursive YAML alias *${at.source}`);
192
+ const visible = new Map();
193
+ visit(doc, (_key, node) => {
194
+ if (node === at)
195
+ return visit.BREAK;
196
+ if (isNode(node) && !isAlias(node) && node.anchor)
197
+ visible.set(node.anchor, node);
198
+ });
199
+ const copy = target.clone();
200
+ const originals = nodesOf(target);
201
+ const replace = new Map();
202
+ nodesOf(copy).forEach((node, index) => {
203
+ if (!isAlias(node)) {
204
+ node.anchor = undefined;
205
+ return;
206
+ }
207
+ const resolved = originals[index].resolve(doc);
208
+ if (resolved !== undefined && visible.get(node.source) !== resolved) {
209
+ replace.set(node, detachedCopy(doc, at, resolved, new Set([...expanding, target])));
210
+ }
211
+ });
212
+ if (!replace.size)
213
+ return copy;
214
+ visit(copy, (_key, node) => (isNode(node) ? replace.get(node) : undefined));
215
+ return copy;
216
+ }
217
+ function underScalar(doc, path) {
218
+ for (let depth = 1; depth < path.length; depth++) {
219
+ const node = doc.getIn(path.slice(0, depth), true);
220
+ if (node === undefined)
221
+ return false;
222
+ if (!isMap(node))
223
+ return true;
224
+ }
225
+ return false;
226
+ }
164
227
  function deleteAt(doc, path) {
228
+ materializeAliases(doc, path); // an aliased mapping is no tombstone: Auto under `spec: *pinned` deletes the pin
165
229
  for (let depth = 1; depth < path.length; depth++) {
166
230
  if (!isMap(doc.getIn(path.slice(0, depth), true)))
167
231
  return;
@@ -171,6 +235,7 @@ function deleteAt(doc, path) {
171
235
  doc.deleteIn(path);
172
236
  }
173
237
  function setAt(doc, path, value) {
238
+ materializeAliases(doc, path); // edit a copy of an aliased mapping, never drop what it resolved to
174
239
  for (let depth = 1; depth < path.length; depth++) {
175
240
  const node = doc.getIn(path.slice(0, depth), true);
176
241
  if (node === undefined)
@@ -281,11 +346,19 @@ export function renderFleetOverlayWrite(priorBytes, write) {
281
346
  for (const shape of new Set([...Object.keys(initial.map), ...Object.keys(edited.map)])) {
282
347
  const before = initial.map[shape];
283
348
  const after = edited.map[shape];
284
- if (JSON.stringify(before?.pin) !== JSON.stringify(after?.pin)) {
349
+ const pinPath = ["routing", "map", shape, "pin"];
350
+ const declares = after?.pin !== undefined || after?.pool !== undefined || after?.prefer !== undefined;
351
+ if (!declares && write.lowerMap?.[shape]?.pin !== undefined && JSON.stringify(before) !== JSON.stringify(after)) {
352
+ // pin: null tombstone — masks the lower-layer pin; a scalar tombstone above it already masks the entry
353
+ materializeAliases(doc, pinPath);
354
+ if (!underScalar(doc, pinPath))
355
+ setScalarPreservingComment(doc, pinPath, null);
356
+ }
357
+ else if (JSON.stringify(before?.pin) !== JSON.stringify(after?.pin)) {
285
358
  if (after?.pin === undefined)
286
- deleteAt(doc, ["routing", "map", shape, "pin"]);
359
+ deleteAt(doc, pinPath);
287
360
  else
288
- setAt(doc, ["routing", "map", shape, "pin"], doc.createNode(after.pin));
361
+ setAt(doc, pinPath, doc.createNode(after.pin));
289
362
  }
290
363
  if (JSON.stringify(before?.pool) !== JSON.stringify(after?.pool)) {
291
364
  const path = ["routing", "map", shape, "pool"];
@@ -31,16 +31,18 @@ export declare class OrcaError extends Error {
31
31
  readonly raw: string;
32
32
  readonly code?: string;
33
33
  readonly runtimeId?: string;
34
+ readonly launchCause?: SeatLaunchCause;
34
35
  constructor(family: string, reason: string, raw: string, opts?: {
35
36
  code?: string;
36
37
  runtimeId?: string;
38
+ launchCause?: SeatLaunchCause;
37
39
  });
38
40
  }
39
41
  /** The slot cannot be addressed: dead/unknown terminal record, or a handle that cannot be recovered
40
42
  * to exactly one owned terminal in the slot's own worktree. Never a silent false or empty string. */
41
43
  export declare class OrcaUnavailableError extends OrcaError {
42
44
  readonly terminalStatus?: string | undefined;
43
- constructor(family: string, reason: string, raw: string, terminalStatus?: string | undefined);
45
+ constructor(family: string, reason: string, raw: string, terminalStatus?: string | undefined, launchCause?: SeatLaunchCause);
44
46
  }
45
47
  export interface OrcaEnvelope {
46
48
  result: Record<string, unknown>;
@@ -83,6 +85,10 @@ export declare function terminalWorktree(term: Record<string, unknown>): string
83
85
  export declare function canonicalWorktreePath(path: string): string;
84
86
  /** The proof line a worker terminal prints first: create, recovery and focus read it back. */
85
87
  export declare const CHECKOUT_MARK = "TICKMARKR_CHECKOUT";
88
+ /** E1: the one typed launch cause a driver attaches to its launch failure. `checkout-proof-timeout`
89
+ * means the terminal printed NO proof frame by the ceiling — a slow host, not a misplaced seat. A
90
+ * foreign or malformed frame is placement evidence and carries no cause (never a timeout exemption). */
91
+ export type SeatLaunchCause = "checkout-proof-timeout";
86
92
  /** The exact bytes the create command prints as its first line. */
87
93
  export declare function checkoutProofLine(checkout: string): string;
88
94
  /** Every complete frame in a scrollback, decoded and canonicalized; whether an incomplete one was seen. */
@@ -250,6 +256,18 @@ export declare class OrcaDriver implements ExecutorDriver {
250
256
  * that supplied the ownership listing — else another terminal's or another runtime's bytes were
251
257
  * answered and nothing is proven. Proven means provesCheckout: exact canonical full-path equality
252
258
  * of a complete frame, no other checkout named, no incomplete frame.
259
+ * E1: with a `deadline` (create's window) there is ONE absolute deadline: a read is issued only while
260
+ * budget is left and is handed exactly that budget, and a page that still lands past it is late —
261
+ * nothing late proves the checkout. `seen` is what the scrollback showed, for the typed timeout:
262
+ * `misplaced` — a frame naming another checkout, a cut frame or an unparseable mark in anything read
263
+ * (the anchor's tail and every page included, even when paging stopped or a later read threw);
264
+ * `absent` — ONLY when every page down to the start of the scrollback was read before the ceiling
265
+ * and none carries a trace of the mark; `unread` — every other exit: PROOF_PAGES reached, a limited
266
+ * read with no (or the same) next cursor or a truncated anchor without one, a read that threw, a read
267
+ * the ceiling cut off or never let start (the anchor included), or a page that landed late (even one
268
+ * carrying the right proof), a malformed page (its tail, cursors or paging flags), or a blind stream
269
+ * page (isBlindStreamPage). Unread output is unknown — never an empty page, never absence.
270
+ * Without a deadline (reconcile, recovery) only `proven` is consumed.
253
271
  */
254
272
  private checkoutProven;
255
273
  private recover;