tickmarkr 1.60.0 → 1.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -160,9 +160,9 @@ Nothing is written until you confirm; pressing Enter through every step leaves c
160
160
  Step 5/6 uses an arrow-driven candidate picker ranked by the production router; step 3 may ask
161
161
  for a benchmark-provenance note when you classify a new model.
162
162
 
163
- Routing-mode semantics, pin/prefer/floor precedence, review and consult steering syntax,
163
+ Routing-mode semantics, pin/floor/prefer precedence, review and consult steering syntax,
164
164
  provenance rules, and `--quality` / `--mode` flags are documented in
165
- **[FLEET.md](FLEET.md)** (advanced reference).
165
+ **[FLEET.md](https://github.com/alzahrani-khalid/tickmarkr/blob/main/FLEET.md)** (advanced reference).
166
166
 
167
167
  ## Steering
168
168
 
@@ -170,7 +170,7 @@ Fleet step 6/6 sets `review.prefer` and `consult.prefer`; routing modes (`risk-b
170
170
  `partner-led`, `staff-led`) are chosen in step 4/6. Full grammar — including when review
171
171
  prefer may name a **bare adapter** versus `adapter:model`, and why consult prefer entries
172
172
  require **`adapter:model`** form — plus `tickmarkr run --supersedes` rerun control, is in
173
- **[FLEET.md](FLEET.md)**.
173
+ **[FLEET.md](https://github.com/alzahrani-khalid/tickmarkr/blob/main/FLEET.md)**.
174
174
 
175
175
  ## Model scoping and auth detection
176
176
 
@@ -366,7 +366,7 @@ accepted contributions are credited via `Co-authored-by:` on the release commit.
366
366
 
367
367
  ## Documentation
368
368
 
369
- - **[FLEET.md](FLEET.md)** — routing modes, steering syntax, tier provenance, and run flags (advanced reference)
369
+ - **[FLEET.md](https://github.com/alzahrani-khalid/tickmarkr/blob/main/FLEET.md)** — routing modes, steering syntax, tier provenance, and run flags (advanced reference)
370
370
  - **[LICENSE](LICENSE)** — MIT license
371
371
  - **[CONTRIBUTING.md](CONTRIBUTING.md)** — development setup and contribution guidelines
372
372
  - **[SECURITY.md](SECURITY.md)** — security policy and private vulnerability reporting
@@ -67,6 +67,11 @@ const failedSuffix = (states) => {
67
67
  const f = failedGates(states);
68
68
  return f.length ? ` · ${f.join(", ")}` : "";
69
69
  };
70
+ // a designed human gate parks the task BEFORE any gate result exists (daemon.ts execTask), so the
71
+ // awaited approval is named from task state alone — never from gate results. Failed gates win the
72
+ // cell when present: a post-approval park is not awaiting the designed gate. Plain text for column
73
+ // math; rendered with a dim dot + warn words. TTY-only — the non-TTY surface stays byte-pinned.
74
+ const humanGateSuffix = (t, st, states) => st === "human" && t.humanGate && failedGates(states).length === 0 ? " · awaiting approval" : "";
70
75
  const shortGoal = (goal, max) => {
71
76
  const clause = goal.split(/[,;.?!]/, 1)[0].trim();
72
77
  if (clause.length <= max)
@@ -203,7 +208,7 @@ const renderFrame = (cwd) => {
203
208
  const gatesLegend = legend(` gates: ${GATE_NAMES.join(" · ")}`);
204
209
  const taskVerdict = (st) => st === "done" ? "pass" : st === "failed" ? "fail" : st === "human" ? "warn" : "neutral";
205
210
  const idW = Math.max(...cells.map((c) => c.t.id.length), 2);
206
- const stW = Math.max(...cells.map((c) => (String(c.st) + c.label + failedSuffix(c.states)).length));
211
+ const stW = Math.max(...cells.map((c) => (String(c.st) + c.label + failedSuffix(c.states) + humanGateSuffix(c.t, c.st, c.states)).length));
207
212
  const chainW = gateChainWidth(true);
208
213
  const assignW = Math.max(...cells.map((c) => c.assignCol.length));
209
214
  const goalW = Math.max(8, width - (5 + idW) - 2 - chainW - 2 - stW - 2 - assignW);
@@ -212,10 +217,12 @@ const renderFrame = (cwd) => {
212
217
  const stWord = st === "done" ? ok(String(st)) : st === "failed" ? fail(String(st)) : st === "human" ? warn(String(st)) : String(st);
213
218
  // a fail names its gate in words right here — the one moment gate identity is needed on a row
214
219
  const f = failedGates(states);
220
+ const human = humanGateSuffix(t, st, states);
215
221
  const statusCell = stWord +
216
222
  (label ? (label === " starved" ? fail(label) : dim(label)) : "") +
217
223
  (f.length ? dim(" · ") + fail(f.join(", ")) : "") +
218
- " ".repeat(stW - (String(st) + label + failedSuffix(states)).length);
224
+ (human ? dim(" · ") + warn("awaiting approval") : "") +
225
+ " ".repeat(stW - (String(st) + label + failedSuffix(states) + human).length);
219
226
  return ` ${statusRow(taskVerdict(st), `${t.id.padEnd(idW)} ${goal} ${gateChain(states, true)} ${statusCell} ${dim(assignCol)}`)}`;
220
227
  });
221
228
  return [header, hr, gatesLegend, ...rows].join("\n");
@@ -1,4 +1,5 @@
1
1
  import { type RunGraph } from "../graph/schema.js";
2
+ export declare const COLLECTABLE_TESTS = "tests/**/*.test.ts";
2
3
  export declare const LEGACY_PREFIX: string;
3
4
  export declare const TICKMARKR_NATIVE_MARKER: RegExp;
4
5
  export declare const NATIVE_MARKER: RegExp;
@@ -1,6 +1,9 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
+ import picomatch from "picomatch";
2
3
  import { GATE_NAMES, GRAPH_ROUTING_MODES, ORACLES, SHAPES, TIERS, validateGraph } from "../graph/schema.js";
3
4
  import { CompileError, inferShape, sha256 } from "./common.js";
5
+ // OBS-97: mirror of the vitest.config.ts suite include — the only path class vitest collects.
6
+ export const COLLECTABLE_TESTS = "tests/**/*.test.ts";
4
7
  export const LEGACY_PREFIX = ["dro", "vr"].join("");
5
8
  export const TICKMARKR_NATIVE_MARKER = /^<!--\s*tickmarkr:spec(?:\s+v1)?\s*-->\s*$/m;
6
9
  export const NATIVE_MARKER = new RegExp(`^<!--\\s*(?:tickmarkr|${LEGACY_PREFIX}):spec(?:\\s+v1)?\\s*-->\\s*$`, "m");
@@ -115,7 +118,101 @@ export function compileNative(file) {
115
118
  throw new CompileError(`acceptance criteria are required on every task. Missing on: ${missing.join(", ")}.\n` +
116
119
  `Add to each section in ${file}:\n- acceptance:\n - <observable outcome>`);
117
120
  }
118
- const csv = (value) => (value && value.toLowerCase() !== "none" ? value.split(",").map((item) => item.trim()).filter(Boolean) : []);
121
+ // v1.62 (OBS-97): commas inside {a,b} alternatives are part of one glob entry, not separators
122
+ // split only at brace depth 0 so a brace glob reaches the lint (and the scope gate) intact.
123
+ const splitTop = (value) => {
124
+ const parts = [];
125
+ let depth = 0;
126
+ let current = "";
127
+ for (const ch of value) {
128
+ if (ch === "," && depth === 0) {
129
+ parts.push(current);
130
+ current = "";
131
+ continue;
132
+ }
133
+ if (ch === "{")
134
+ depth++;
135
+ else if (ch === "}" && depth > 0)
136
+ depth--;
137
+ current += ch;
138
+ }
139
+ return [...parts, current];
140
+ };
141
+ const csv = (value) => (value && value.toLowerCase() !== "none" ? splitTop(value).map((item) => item.trim()).filter(Boolean) : []);
142
+ // OBS-97: a typed test: oracle needs a collectable home. vitest only collects COLLECTABLE_TESTS
143
+ // paths, so a task whose non-empty files[] cannot host one makes scope-green and acceptance-green
144
+ // mutually exclusive by construction — run-20260719-210434 burned two dispatch attempts before a
145
+ // consult diagnosed exactly this. Empty files[] stays exempt: no file scope means unrestricted
146
+ // (src/gates/scope.ts). An entry hosts a collectable path iff its glob can produce one — probed by
147
+ // substituting each wildcard run with a test-shaped segment (also covers literal test-file paths);
148
+ // v1.62 extends the probe to {a,b} brace alternatives and ? single-character wildcards.
149
+ const collectable = picomatch(COLLECTABLE_TESTS, { dot: true });
150
+ // Single-token substitution is not a true glob-overlap test (tests/**/*.ts needs "probe.test",
151
+ // a bare ** needs the whole collectable path) — probe with several test-shaped tokens and accept
152
+ // if ANY candidate satisfies both globs. Scopes that truly cannot host one still fail every probe.
153
+ const PROBE_TOKENS = ["probe.test.ts", "probe.test", "tests/probe.test.ts"];
154
+ // v1.62: {a,b} alternatives expand to concrete branches before probing (innermost group first, so
155
+ // nesting resolves); an unbalanced brace is literal to picomatch and stays unexpanded. Expansion is
156
+ // BOUNDED: past BRANCH_CAP branches, further alternatives are dropped — dropping candidates can only
157
+ // reject, never accept (every accept needs a concrete witness), so the cap stays fail-closed while a
158
+ // pathological {a,b}{a,b}… entry can no longer balloon compile time.
159
+ const BRANCH_CAP = 64;
160
+ const expandBraces = (glob) => {
161
+ let branches = [glob];
162
+ for (;;) {
163
+ let changed = false;
164
+ const next = [];
165
+ for (const branch of branches) {
166
+ const inner = branch.match(/\{([^{}]*)\}/);
167
+ if (!inner) {
168
+ next.push(branch);
169
+ continue;
170
+ }
171
+ changed = true;
172
+ for (const alt of inner[1].split(","))
173
+ next.push(branch.replace(inner[0], alt));
174
+ }
175
+ branches = next.slice(0, BRANCH_CAP);
176
+ if (!changed)
177
+ return branches;
178
+ }
179
+ };
180
+ // v1.62: a ? never blocks self-match (it matches any one char), so hosting hinges on the probe
181
+ // clearing the collectable literals — search ? positions over that alphabet PER POSITION (a mixed
182
+ // scope like test?/unit.tes?.ts needs different chars at each ?). Every accepted probe is a concrete
183
+ // path satisfying BOTH globs (a witness), so expansion and substitution can only lift false
184
+ // rejections — a scope with no witness still fails every probe. Bounded: entries with more than
185
+ // QMARK_CAP ?s stay fail-closed (4^4 = 256 candidates is the ceiling per probe).
186
+ const QMARK_CHARS = ["t", "e", "s", "."];
187
+ const QMARK_CAP = 4;
188
+ const qmarkVariants = (probe) => {
189
+ const qCount = (probe.match(/\?/g) ?? []).length;
190
+ if (qCount === 0 || qCount > QMARK_CAP)
191
+ return [probe];
192
+ let variants = [probe];
193
+ for (let i = 0; i < qCount; i++) {
194
+ variants = variants.flatMap((v) => QMARK_CHARS.map((ch) => v.replace("?", ch)));
195
+ }
196
+ return [probe, ...variants];
197
+ };
198
+ const canHostTest = (entry) => {
199
+ const self = picomatch(entry, { dot: true });
200
+ return expandBraces(entry)
201
+ .flatMap((branch) => PROBE_TOKENS.map((token) => branch.replace(/\*+/g, token)))
202
+ .flatMap(qmarkVariants)
203
+ .some((probe) => self(probe) && collectable(probe));
204
+ };
205
+ const homeless = drafts
206
+ .filter((draft) => {
207
+ const files = csv(draft.fields.files);
208
+ return files.length > 0 && !files.some(canHostTest)
209
+ && draft.acceptance.some((item) => typeof item !== "string" && item.oracle === "test");
210
+ })
211
+ .map((draft) => draft.id);
212
+ if (homeless.length) {
213
+ throw new CompileError(`OBS-97: task${homeless.length === 1 ? "" : "s"} ${homeless.join(", ")} carr${homeless.length === 1 ? "ies" : "y"} a test: acceptance oracle but files[] cannot host a vitest-collectable test path (${COLLECTABLE_TESTS}).\n` +
214
+ `Add a ${COLLECTABLE_TESTS} entry to files[] in ${file}, or replace the test: oracle with command:/judge:.`);
215
+ }
119
216
  const tasks = drafts.map((draft) => {
120
217
  const { fields } = draft;
121
218
  const shape = fields.shape ?? inferShape(draft.title);
@@ -235,8 +235,8 @@ export type InitConfigOverlay = {
235
235
  };
236
236
  };
237
237
  export declare function configTemplate(overlay?: InitConfigOverlay): string;
238
- /** Fleet-owned overlay keys the only config surface `tickmarkr fleet` may write. */
239
- export declare const FLEET_OVERLAY_KEYS: readonly ["routing", "tiers"];
238
+ export { FLEET_OVERLAY_KEYS, fleetEditableEquals, fleetRepoOverlayFromDelta, harvestFleetProvenance, repoOverlayYaml, serializeFleetOverlay, unifiedYamlDiff, } from "./fleet-overlay.js";
239
+ export type { FleetDenyNotes, HarvestedProvenance } from "./fleet-overlay.js";
240
240
  export type FleetTierAssignment = {
241
241
  tier: Tier;
242
242
  provenance?: string;
@@ -259,28 +259,3 @@ export declare function fleetKeyLayer(repoRoot: string, dotted: string, opts?: {
259
259
  export declare function formatFleetPrint(repoRoot: string, opts?: {
260
260
  globalDir?: string;
261
261
  }): string;
262
- /** Trailing `# note` comments on per-entry lines an operator may have hand-written or a prior
263
- * fleet write stamped: model tier lines (including null tombstones) and deny list items. */
264
- export type HarvestedProvenance = {
265
- tiers: Record<string, Record<string, string>>;
266
- denyAdapters: Record<string, string>;
267
- denyModels: Record<string, string>;
268
- };
269
- /** OBS-88: harvest existing provenance comments from raw repo-overlay bytes at fleet-session
270
- * load. yaml.parse discards comments, so before this every fleet write re-serialized the file
271
- * knowing only the current session's own notes and silently stripped all prior ones — a typed
272
- * benchmark-provenance note survived exactly one write. Fail-open to empty: an unreadable
273
- * overlay is the loader's problem to reject, never the harvester's. */
274
- export declare function harvestFleetProvenance(overlayText: string): HarvestedProvenance;
275
- /** Harvested deny-entry notes keyed by the exact entry string, re-attached at serialize time. */
276
- export type FleetDenyNotes = {
277
- adapters?: Record<string, string>;
278
- models?: Record<string, string>;
279
- };
280
- /** Build the repo overlay fragment fleet would write for edits since session start. */
281
- export declare function fleetRepoOverlayFromDelta(initial: FleetEditable, edited: FleetEditable, existingRepo?: Record<string, unknown>): Record<string, unknown>;
282
- export declare function repoOverlayYaml(overlay: Record<string, unknown>, provenance?: Record<string, Record<string, string>>, denyNotes?: FleetDenyNotes): string;
283
- export declare function serializeFleetOverlay(overlay: Record<string, unknown>, provenance?: Record<string, Record<string, string>>, denyNotes?: FleetDenyNotes): string;
284
- export declare function unifiedYamlDiff(before: string, after: string, label?: string): string;
285
- export declare function fleetEditableEquals(a: FleetEditable, b: FleetEditable): boolean;
286
- export {};
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
- import { isMap, isScalar, isSeq, parse, parseDocument, stringify } from "yaml";
4
+ import { parse, stringify } from "yaml";
5
5
  import { z } from "zod";
6
6
  import { stateDirName } from "../graph/graph.js";
7
7
  import { SHAPES, TIERS } from "../graph/schema.js";
@@ -533,8 +533,9 @@ export function configTemplate(overlay) {
533
533
  const nl = base.indexOf("\n");
534
534
  return `${base.slice(0, nl + 1)}${lines.join("\n")}\n${base.slice(nl + 1)}`;
535
535
  }
536
- /** Fleet-owned overlay keys the only config surface `tickmarkr fleet` may write. */
537
- export const FLEET_OVERLAY_KEYS = ["routing", "tiers"];
536
+ // v1.61 seed 8: the fleet-overlay provenance cluster (harvesting, serialization, diff rendering)
537
+ // moved to fleet-overlay.ts as a pure move — re-exported here so prior import paths keep working.
538
+ export { FLEET_OVERLAY_KEYS, fleetEditableEquals, fleetRepoOverlayFromDelta, harvestFleetProvenance, repoOverlayYaml, serializeFleetOverlay, unifiedYamlDiff, } from "./fleet-overlay.js";
538
539
  export function repoOverlayPath(repoRoot) {
539
540
  return join(repoRoot, stateDirName(repoRoot), "config.yaml");
540
541
  }
@@ -565,14 +566,6 @@ export function fleetEditableFromConfig(cfg, provenance = {}) {
565
566
  floors: { ...cfg.routing.floors },
566
567
  };
567
568
  }
568
- function fleetSubset(obj) {
569
- const out = {};
570
- for (const k of FLEET_OVERLAY_KEYS) {
571
- if (obj[k] !== undefined)
572
- out[k] = obj[k];
573
- }
574
- return out;
575
- }
576
569
  /** Resolve which layer last set a dotted fleet path (defaults < global < repo). */
577
570
  export function fleetKeyLayer(repoRoot, dotted, opts = {}) {
578
571
  const gdir = opts.globalDir ?? globalConfigDir();
@@ -650,238 +643,3 @@ export function formatFleetPrint(repoRoot, opts = {}) {
650
643
  }
651
644
  return `${lines.join("\n")}\n`;
652
645
  }
653
- function sortedUnique(xs) {
654
- return [...new Set(xs)].sort();
655
- }
656
- /** OBS-88: harvest existing provenance comments from raw repo-overlay bytes at fleet-session
657
- * load. yaml.parse discards comments, so before this every fleet write re-serialized the file
658
- * knowing only the current session's own notes and silently stripped all prior ones — a typed
659
- * benchmark-provenance note survived exactly one write. Fail-open to empty: an unreadable
660
- * overlay is the loader's problem to reject, never the harvester's. */
661
- export function harvestFleetProvenance(overlayText) {
662
- const out = { tiers: {}, denyAdapters: {}, denyModels: {} };
663
- if (!overlayText.trim())
664
- return out;
665
- const doc = parseDocument(overlayText);
666
- const note = (n) => {
667
- const c = isScalar(n) ? n.comment : undefined;
668
- return typeof c === "string" && c.trim() ? c.trim() : undefined;
669
- };
670
- const tiers = doc.getIn(["tiers"]);
671
- if (isMap(tiers)) {
672
- for (const ap of tiers.items) {
673
- if (!isScalar(ap.key))
674
- continue;
675
- const models = isMap(ap.value) ? ap.value.get("models") : undefined;
676
- if (!isMap(models))
677
- continue;
678
- for (const mp of models.items) {
679
- const n = note(mp.value);
680
- if (isScalar(mp.key) && n)
681
- (out.tiers[String(ap.key.value)] ??= {})[String(mp.key.value)] = n;
682
- }
683
- }
684
- }
685
- for (const [key, dest] of [["adapters", out.denyAdapters], ["models", out.denyModels]]) {
686
- const seq = doc.getIn(["routing", "deny", key]);
687
- if (!isSeq(seq))
688
- continue;
689
- for (const item of seq.items) {
690
- const n = note(item);
691
- if (isScalar(item) && n)
692
- dest[String(item.value)] = n;
693
- }
694
- }
695
- return out;
696
- }
697
- /** Build the repo overlay fragment fleet would write for edits since session start. */
698
- export function fleetRepoOverlayFromDelta(initial, edited, existingRepo = {}) {
699
- if (fleetEditableEquals(initial, edited))
700
- return {};
701
- const out = structuredClone(existingRepo);
702
- const routing = { ...out.routing };
703
- let routingTouched = false;
704
- const denyChanged = sortedUnique(initial.denyAdapters).join() !== sortedUnique(edited.denyAdapters).join()
705
- || sortedUnique(initial.denyModels).join() !== sortedUnique(edited.denyModels).join();
706
- if (denyChanged) {
707
- routing.deny = {
708
- adapters: edited.denyAdapters.length ? edited.denyAdapters : null,
709
- models: edited.denyModels.length ? edited.denyModels : null,
710
- };
711
- routingTouched = true;
712
- }
713
- const mapDelta = {};
714
- for (const shape of new Set([...Object.keys(initial.map), ...Object.keys(edited.map)])) {
715
- if (JSON.stringify(initial.map[shape]) !== JSON.stringify(edited.map[shape]))
716
- mapDelta[shape] = edited.map[shape];
717
- }
718
- if (Object.keys(mapDelta).length) {
719
- routing.map = { ...routing.map, ...mapDelta };
720
- routingTouched = true;
721
- }
722
- const floorDelta = {};
723
- for (const shape of new Set([...Object.keys(initial.floors), ...Object.keys(edited.floors)])) {
724
- if (initial.floors[shape] !== edited.floors[shape])
725
- floorDelta[shape] = edited.floors[shape];
726
- }
727
- if (Object.keys(floorDelta).length) {
728
- routing.floors = { ...routing.floors, ...floorDelta };
729
- routingTouched = true;
730
- }
731
- if (routingTouched)
732
- out.routing = routing;
733
- const tiersOut = {
734
- ...out.tiers,
735
- };
736
- let tiersTouched = false;
737
- const adapters = new Set([...Object.keys(initial.tiers), ...Object.keys(edited.tiers)]);
738
- for (const adapter of adapters) {
739
- const models = new Set([
740
- ...Object.keys(initial.tiers[adapter] ?? {}),
741
- ...Object.keys(edited.tiers[adapter] ?? {}),
742
- ]);
743
- const modelDelta = {};
744
- for (const model of models) {
745
- const a = initial.tiers[adapter]?.[model];
746
- const b = edited.tiers[adapter]?.[model];
747
- if (JSON.stringify(a) !== JSON.stringify(b)) {
748
- modelDelta[model] = b === null || b === undefined ? null : b.tier;
749
- }
750
- }
751
- if (Object.keys(modelDelta).length) {
752
- // spread the existing entry so vendor/channel/windows survive the rewrite — dropping them
753
- // makes the overlay unloadable for any adapter without a default seed (reload-guard class)
754
- tiersOut[adapter] = { ...tiersOut[adapter], models: { ...tiersOut[adapter]?.models, ...modelDelta } };
755
- tiersTouched = true;
756
- }
757
- }
758
- if (tiersTouched)
759
- out.tiers = tiersOut;
760
- return out;
761
- }
762
- export function repoOverlayYaml(overlay, provenance = {}, denyNotes = {}) {
763
- if (!Object.keys(overlay).length)
764
- return "";
765
- const fleet = fleetSubset(overlay);
766
- const fleetBody = serializeFleetOverlay(fleet, provenance, denyNotes);
767
- const rest = { ...overlay };
768
- for (const k of FLEET_OVERLAY_KEYS)
769
- delete rest[k];
770
- if (!Object.keys(rest).length)
771
- return fleetBody;
772
- const head = stringify(rest).trimEnd();
773
- return fleetBody ? `${head}\n${fleetBody}` : `${head}\n`;
774
- }
775
- export function serializeFleetOverlay(overlay, provenance = {}, denyNotes = {}) {
776
- if (!Object.keys(overlay).length)
777
- return "";
778
- const lines = [];
779
- // OBS-75: never glue stringify() output onto a key line — wrap the key into the object and
780
- // re-indent the whole emitted block, so sequences/nested maps nest correctly and null
781
- // tombstones/empty collections survive the serialize→parse round-trip.
782
- const block = (obj, pad) => stringify(obj).trimEnd().split("\n").map((l) => `${pad}${l}`);
783
- // deny lists emit item-by-item through the same stringify quoting rules as block(), so a
784
- // harvested `# reason` can re-attach to its exact entry (multi-line emissions never take one)
785
- const denySeq = (key, v) => {
786
- if (v === undefined)
787
- return [];
788
- if (v === null || !v.length)
789
- return block({ [key]: v }, " ");
790
- const out = [` ${key}:`];
791
- for (const item of v) {
792
- const emitted = stringify([item]).trimEnd().split("\n");
793
- const n = denyNotes[key]?.[item];
794
- if (n && emitted.length === 1)
795
- emitted[0] += ` # ${n}`;
796
- out.push(...emitted.map((l) => ` ${l}`));
797
- }
798
- return out;
799
- };
800
- const routing = overlay.routing;
801
- if (routing) {
802
- lines.push("routing:");
803
- const deny = routing.deny;
804
- if (deny && (deny.adapters !== undefined || deny.models !== undefined)) {
805
- lines.push(" deny:");
806
- lines.push(...denySeq("adapters", deny.adapters));
807
- lines.push(...denySeq("models", deny.models));
808
- }
809
- if (routing.map)
810
- lines.push(...block({ map: routing.map }, " "));
811
- if (routing.floors)
812
- lines.push(...block({ floors: routing.floors }, " "));
813
- }
814
- const tiers = overlay.tiers;
815
- if (tiers && Object.keys(tiers).length) {
816
- lines.push("tiers:");
817
- for (const [adapter, entry] of Object.entries(tiers)) {
818
- const body = [];
819
- if (entry.vendor)
820
- body.push(` vendor: ${entry.vendor}`);
821
- if (entry.channel)
822
- body.push(` channel: ${entry.channel}`);
823
- if (entry.windows)
824
- body.push(...block({ windows: entry.windows }, " "));
825
- const models = Object.entries(entry.models ?? {});
826
- if (models.length) {
827
- body.push(" models:");
828
- for (const [model, tier] of models) {
829
- // OBS-88: notes serialize verbatim (fresh session notes arrive pre-stamped with their
830
- // "— fleet <date>" suffix), so a harvested note round-trips byte-for-byte every write
831
- const note = provenance[adapter]?.[model];
832
- const suffix = note ? ` # ${note}` : "";
833
- body.push(` ${model}: ${tier === null ? "null" : tier}${suffix}`);
834
- }
835
- }
836
- else if (entry.models) {
837
- body.push(" models: {}"); // present-but-empty: explicit {} — a childless header parses as null and the loader rejects it
838
- }
839
- // a bare `adapter:` line parses as a null tombstone and would DELETE the adapter's default seeds on merge
840
- if (body.length)
841
- lines.push(` ${adapter}:`, ...body);
842
- else
843
- lines.push(` ${adapter}: {}`);
844
- }
845
- }
846
- return `${lines.join("\n")}\n`;
847
- }
848
- export function unifiedYamlDiff(before, after, label = "config overlay") {
849
- if (before === after)
850
- return "";
851
- const a = before.split("\n");
852
- const b = after.split("\n");
853
- // v1.60 T3: shortest-edit (LCS) matching. The old scan resynced greedily on the first mismatched
854
- // line, so one inserted line could cascade into a whole-file remove/re-add hunk on the one
855
- // confirmation surface an operator reviews before a write.
856
- // ponytail: O(n·m) table — overlays are tens of lines; Myers O(nd) if files ever grow.
857
- const lcs = Array.from({ length: a.length + 1 }, () => Array.from({ length: b.length + 1 }, () => 0));
858
- for (let i = a.length - 1; i >= 0; i--) {
859
- for (let j = b.length - 1; j >= 0; j--) {
860
- lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
861
- }
862
- }
863
- const header = [`--- ${label} (current)`, `+++ ${label} (proposed)`];
864
- const hunks = [];
865
- let i = 0;
866
- let j = 0;
867
- while (i < a.length || j < b.length) {
868
- if (i < a.length && j < b.length && a[i] === b[j]) {
869
- i++;
870
- j++;
871
- continue;
872
- }
873
- const del = [];
874
- const add = [];
875
- while ((i < a.length || j < b.length) && !(i < a.length && j < b.length && a[i] === b[j])) {
876
- if (j >= b.length || (i < a.length && lcs[i + 1][j] >= lcs[i][j + 1]))
877
- del.push(`-${a[i++]}`);
878
- else
879
- add.push(`+${b[j++]}`);
880
- }
881
- hunks.push("@@", ...del, ...add);
882
- }
883
- return `${header.join("\n")}\n${hunks.join("\n")}\n`;
884
- }
885
- export function fleetEditableEquals(a, b) {
886
- return JSON.stringify(a) === JSON.stringify(b);
887
- }
@@ -0,0 +1,27 @@
1
+ import type { FleetEditable } from "./config.js";
2
+ /** Fleet-owned overlay keys — the only config surface `tickmarkr fleet` may write. */
3
+ export declare const FLEET_OVERLAY_KEYS: readonly ["routing", "tiers"];
4
+ /** Trailing `# note` comments on per-entry lines an operator may have hand-written or a prior
5
+ * fleet write stamped: model tier lines (including null tombstones) and deny list items. */
6
+ export type HarvestedProvenance = {
7
+ tiers: Record<string, Record<string, string>>;
8
+ denyAdapters: Record<string, string>;
9
+ denyModels: Record<string, string>;
10
+ };
11
+ /** OBS-88: harvest existing provenance comments from raw repo-overlay bytes at fleet-session
12
+ * load. yaml.parse discards comments, so before this every fleet write re-serialized the file
13
+ * knowing only the current session's own notes and silently stripped all prior ones — a typed
14
+ * benchmark-provenance note survived exactly one write. Fail-open to empty: an unreadable
15
+ * overlay is the loader's problem to reject, never the harvester's. */
16
+ export declare function harvestFleetProvenance(overlayText: string): HarvestedProvenance;
17
+ /** Harvested deny-entry notes keyed by the exact entry string, re-attached at serialize time. */
18
+ export type FleetDenyNotes = {
19
+ adapters?: Record<string, string>;
20
+ models?: Record<string, string>;
21
+ };
22
+ /** Build the repo overlay fragment fleet would write for edits since session start. */
23
+ export declare function fleetRepoOverlayFromDelta(initial: FleetEditable, edited: FleetEditable, existingRepo?: Record<string, unknown>): Record<string, unknown>;
24
+ export declare function repoOverlayYaml(overlay: Record<string, unknown>, provenance?: Record<string, Record<string, string>>, denyNotes?: FleetDenyNotes): string;
25
+ export declare function serializeFleetOverlay(overlay: Record<string, unknown>, provenance?: Record<string, Record<string, string>>, denyNotes?: FleetDenyNotes): string;
26
+ export declare function unifiedYamlDiff(before: string, after: string, label?: string): string;
27
+ export declare function fleetEditableEquals(a: FleetEditable, b: FleetEditable): boolean;
@@ -0,0 +1,249 @@
1
+ // Fleet-overlay provenance cluster (v1.61 seed 8): harvesting, serialization, and diff rendering
2
+ // for the `tickmarkr fleet` write path. Pure move out of config.ts — prior import paths preserved
3
+ // via re-exports there.
4
+ import { isMap, isScalar, isSeq, parseDocument, stringify } from "yaml";
5
+ /** Fleet-owned overlay keys — the only config surface `tickmarkr fleet` may write. */
6
+ export const FLEET_OVERLAY_KEYS = ["routing", "tiers"];
7
+ function fleetSubset(obj) {
8
+ const out = {};
9
+ for (const k of FLEET_OVERLAY_KEYS) {
10
+ if (obj[k] !== undefined)
11
+ out[k] = obj[k];
12
+ }
13
+ return out;
14
+ }
15
+ function sortedUnique(xs) {
16
+ return [...new Set(xs)].sort();
17
+ }
18
+ /** OBS-88: harvest existing provenance comments from raw repo-overlay bytes at fleet-session
19
+ * load. yaml.parse discards comments, so before this every fleet write re-serialized the file
20
+ * knowing only the current session's own notes and silently stripped all prior ones — a typed
21
+ * benchmark-provenance note survived exactly one write. Fail-open to empty: an unreadable
22
+ * overlay is the loader's problem to reject, never the harvester's. */
23
+ export function harvestFleetProvenance(overlayText) {
24
+ const out = { tiers: {}, denyAdapters: {}, denyModels: {} };
25
+ if (!overlayText.trim())
26
+ return out;
27
+ const doc = parseDocument(overlayText);
28
+ const note = (n) => {
29
+ const c = isScalar(n) ? n.comment : undefined;
30
+ return typeof c === "string" && c.trim() ? c.trim() : undefined;
31
+ };
32
+ const tiers = doc.getIn(["tiers"]);
33
+ if (isMap(tiers)) {
34
+ for (const ap of tiers.items) {
35
+ if (!isScalar(ap.key))
36
+ continue;
37
+ const models = isMap(ap.value) ? ap.value.get("models") : undefined;
38
+ if (!isMap(models))
39
+ continue;
40
+ for (const mp of models.items) {
41
+ const n = note(mp.value);
42
+ if (isScalar(mp.key) && n)
43
+ (out.tiers[String(ap.key.value)] ??= {})[String(mp.key.value)] = n;
44
+ }
45
+ }
46
+ }
47
+ for (const [key, dest] of [["adapters", out.denyAdapters], ["models", out.denyModels]]) {
48
+ const seq = doc.getIn(["routing", "deny", key]);
49
+ if (!isSeq(seq))
50
+ continue;
51
+ for (const item of seq.items) {
52
+ const n = note(item);
53
+ if (isScalar(item) && n)
54
+ dest[String(item.value)] = n;
55
+ }
56
+ }
57
+ return out;
58
+ }
59
+ /** Build the repo overlay fragment fleet would write for edits since session start. */
60
+ export function fleetRepoOverlayFromDelta(initial, edited, existingRepo = {}) {
61
+ if (fleetEditableEquals(initial, edited))
62
+ return {};
63
+ const out = structuredClone(existingRepo);
64
+ const routing = { ...out.routing };
65
+ let routingTouched = false;
66
+ const denyChanged = sortedUnique(initial.denyAdapters).join() !== sortedUnique(edited.denyAdapters).join()
67
+ || sortedUnique(initial.denyModels).join() !== sortedUnique(edited.denyModels).join();
68
+ if (denyChanged) {
69
+ routing.deny = {
70
+ adapters: edited.denyAdapters.length ? edited.denyAdapters : null,
71
+ models: edited.denyModels.length ? edited.denyModels : null,
72
+ };
73
+ routingTouched = true;
74
+ }
75
+ const mapDelta = {};
76
+ for (const shape of new Set([...Object.keys(initial.map), ...Object.keys(edited.map)])) {
77
+ if (JSON.stringify(initial.map[shape]) !== JSON.stringify(edited.map[shape]))
78
+ mapDelta[shape] = edited.map[shape];
79
+ }
80
+ if (Object.keys(mapDelta).length) {
81
+ routing.map = { ...routing.map, ...mapDelta };
82
+ routingTouched = true;
83
+ }
84
+ const floorDelta = {};
85
+ for (const shape of new Set([...Object.keys(initial.floors), ...Object.keys(edited.floors)])) {
86
+ if (initial.floors[shape] !== edited.floors[shape])
87
+ floorDelta[shape] = edited.floors[shape];
88
+ }
89
+ if (Object.keys(floorDelta).length) {
90
+ routing.floors = { ...routing.floors, ...floorDelta };
91
+ routingTouched = true;
92
+ }
93
+ if (routingTouched)
94
+ out.routing = routing;
95
+ const tiersOut = {
96
+ ...out.tiers,
97
+ };
98
+ let tiersTouched = false;
99
+ const adapters = new Set([...Object.keys(initial.tiers), ...Object.keys(edited.tiers)]);
100
+ for (const adapter of adapters) {
101
+ const models = new Set([
102
+ ...Object.keys(initial.tiers[adapter] ?? {}),
103
+ ...Object.keys(edited.tiers[adapter] ?? {}),
104
+ ]);
105
+ const modelDelta = {};
106
+ for (const model of models) {
107
+ const a = initial.tiers[adapter]?.[model];
108
+ const b = edited.tiers[adapter]?.[model];
109
+ if (JSON.stringify(a) !== JSON.stringify(b)) {
110
+ modelDelta[model] = b === null || b === undefined ? null : b.tier;
111
+ }
112
+ }
113
+ if (Object.keys(modelDelta).length) {
114
+ // spread the existing entry so vendor/channel/windows survive the rewrite — dropping them
115
+ // makes the overlay unloadable for any adapter without a default seed (reload-guard class)
116
+ tiersOut[adapter] = { ...tiersOut[adapter], models: { ...tiersOut[adapter]?.models, ...modelDelta } };
117
+ tiersTouched = true;
118
+ }
119
+ }
120
+ if (tiersTouched)
121
+ out.tiers = tiersOut;
122
+ return out;
123
+ }
124
+ export function repoOverlayYaml(overlay, provenance = {}, denyNotes = {}) {
125
+ if (!Object.keys(overlay).length)
126
+ return "";
127
+ const fleet = fleetSubset(overlay);
128
+ const fleetBody = serializeFleetOverlay(fleet, provenance, denyNotes);
129
+ const rest = { ...overlay };
130
+ for (const k of FLEET_OVERLAY_KEYS)
131
+ delete rest[k];
132
+ if (!Object.keys(rest).length)
133
+ return fleetBody;
134
+ const head = stringify(rest).trimEnd();
135
+ return fleetBody ? `${head}\n${fleetBody}` : `${head}\n`;
136
+ }
137
+ export function serializeFleetOverlay(overlay, provenance = {}, denyNotes = {}) {
138
+ if (!Object.keys(overlay).length)
139
+ return "";
140
+ const lines = [];
141
+ // OBS-75: never glue stringify() output onto a key line — wrap the key into the object and
142
+ // re-indent the whole emitted block, so sequences/nested maps nest correctly and null
143
+ // tombstones/empty collections survive the serialize→parse round-trip.
144
+ const block = (obj, pad) => stringify(obj).trimEnd().split("\n").map((l) => `${pad}${l}`);
145
+ // deny lists emit item-by-item through the same stringify quoting rules as block(), so a
146
+ // harvested `# reason` can re-attach to its exact entry (multi-line emissions never take one)
147
+ const denySeq = (key, v) => {
148
+ if (v === undefined)
149
+ return [];
150
+ if (v === null || !v.length)
151
+ return block({ [key]: v }, " ");
152
+ const out = [` ${key}:`];
153
+ for (const item of v) {
154
+ const emitted = stringify([item]).trimEnd().split("\n");
155
+ const n = denyNotes[key]?.[item];
156
+ if (n && emitted.length === 1)
157
+ emitted[0] += ` # ${n}`;
158
+ out.push(...emitted.map((l) => ` ${l}`));
159
+ }
160
+ return out;
161
+ };
162
+ const routing = overlay.routing;
163
+ if (routing) {
164
+ lines.push("routing:");
165
+ const deny = routing.deny;
166
+ if (deny && (deny.adapters !== undefined || deny.models !== undefined)) {
167
+ lines.push(" deny:");
168
+ lines.push(...denySeq("adapters", deny.adapters));
169
+ lines.push(...denySeq("models", deny.models));
170
+ }
171
+ if (routing.map)
172
+ lines.push(...block({ map: routing.map }, " "));
173
+ if (routing.floors)
174
+ lines.push(...block({ floors: routing.floors }, " "));
175
+ }
176
+ const tiers = overlay.tiers;
177
+ if (tiers && Object.keys(tiers).length) {
178
+ lines.push("tiers:");
179
+ for (const [adapter, entry] of Object.entries(tiers)) {
180
+ const body = [];
181
+ if (entry.vendor)
182
+ body.push(` vendor: ${entry.vendor}`);
183
+ if (entry.channel)
184
+ body.push(` channel: ${entry.channel}`);
185
+ if (entry.windows)
186
+ body.push(...block({ windows: entry.windows }, " "));
187
+ const models = Object.entries(entry.models ?? {});
188
+ if (models.length) {
189
+ body.push(" models:");
190
+ for (const [model, tier] of models) {
191
+ // OBS-88: notes serialize verbatim (fresh session notes arrive pre-stamped with their
192
+ // "— fleet <date>" suffix), so a harvested note round-trips byte-for-byte every write
193
+ const note = provenance[adapter]?.[model];
194
+ const suffix = note ? ` # ${note}` : "";
195
+ body.push(` ${model}: ${tier === null ? "null" : tier}${suffix}`);
196
+ }
197
+ }
198
+ else if (entry.models) {
199
+ body.push(" models: {}"); // present-but-empty: explicit {} — a childless header parses as null and the loader rejects it
200
+ }
201
+ // a bare `adapter:` line parses as a null tombstone and would DELETE the adapter's default seeds on merge
202
+ if (body.length)
203
+ lines.push(` ${adapter}:`, ...body);
204
+ else
205
+ lines.push(` ${adapter}: {}`);
206
+ }
207
+ }
208
+ return `${lines.join("\n")}\n`;
209
+ }
210
+ export function unifiedYamlDiff(before, after, label = "config overlay") {
211
+ if (before === after)
212
+ return "";
213
+ const a = before.split("\n");
214
+ const b = after.split("\n");
215
+ // v1.60 T3: shortest-edit (LCS) matching. The old scan resynced greedily on the first mismatched
216
+ // line, so one inserted line could cascade into a whole-file remove/re-add hunk on the one
217
+ // confirmation surface an operator reviews before a write.
218
+ // ponytail: O(n·m) table — overlays are tens of lines; Myers O(nd) if files ever grow.
219
+ const lcs = Array.from({ length: a.length + 1 }, () => Array.from({ length: b.length + 1 }, () => 0));
220
+ for (let i = a.length - 1; i >= 0; i--) {
221
+ for (let j = b.length - 1; j >= 0; j--) {
222
+ lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
223
+ }
224
+ }
225
+ const header = [`--- ${label} (current)`, `+++ ${label} (proposed)`];
226
+ const hunks = [];
227
+ let i = 0;
228
+ let j = 0;
229
+ while (i < a.length || j < b.length) {
230
+ if (i < a.length && j < b.length && a[i] === b[j]) {
231
+ i++;
232
+ j++;
233
+ continue;
234
+ }
235
+ const del = [];
236
+ const add = [];
237
+ while ((i < a.length || j < b.length) && !(i < a.length && j < b.length && a[i] === b[j])) {
238
+ if (j >= b.length || (i < a.length && lcs[i + 1][j] >= lcs[i][j + 1]))
239
+ del.push(`-${a[i++]}`);
240
+ else
241
+ add.push(`+${b[j++]}`);
242
+ }
243
+ hunks.push("@@", ...del, ...add);
244
+ }
245
+ return `${header.join("\n")}\n${hunks.join("\n")}\n`;
246
+ }
247
+ export function fleetEditableEquals(a, b) {
248
+ return JSON.stringify(a) === JSON.stringify(b);
249
+ }
@@ -1,6 +1,7 @@
1
1
  import { type ExecutorDriver, type NotifyOpts, type Slot, type SlotOpts } from "./types.js";
2
2
  export declare const TRAILER_SAFE_FLOOR_COLS = 108;
3
3
  export declare const TRAILER_WIDTH_MARGIN = 2;
4
+ export declare const DELIVERY_ATTEMPTS = 3;
4
5
  /** First-generation join direction from measured trailer-safe floor (43-MEASUREMENT.md). */
5
6
  export declare function workerSplitDirection(paneCols: number | null, safeFloor?: number, margin?: number): "right" | "down";
6
7
  export declare class HerdrDriver implements ExecutorDriver {
@@ -6,6 +6,10 @@ import { canonicalizeLegacyName, formatOwnedName, panesToClose, parseOwnedName }
6
6
  // VIS-09 P43-03: adopted safety floor from 43-MEASUREMENT.md (narrowest safe 53 → floor 108).
7
7
  export const TRAILER_SAFE_FLOOR_COLS = 108;
8
8
  export const TRAILER_WIDTH_MARGIN = 2; // cols below (floor + margin) refuse a rightward first split
9
+ // OBS-85 verified delivery: bounded type→read-back→enter attempts before failing closed.
10
+ export const DELIVERY_ATTEMPTS = 3;
11
+ const DELIVERY_VERIFY_TIMEOUT_MS = 2000; // per attempt — a paste that hasn't rendered in 2s is retyped
12
+ const DELIVERY_READ_LINES = 80;
9
13
  /** First-generation join direction from measured trailer-safe floor (43-MEASUREMENT.md). */
10
14
  export function workerSplitDirection(paneCols, safeFloor = TRAILER_SAFE_FLOOR_COLS, margin = TRAILER_WIDTH_MARGIN) {
11
15
  if (paneCols == null || paneCols <= 0)
@@ -273,11 +277,41 @@ export class HerdrDriver {
273
277
  await this.renameGroupTab(entry);
274
278
  return { id: pane, name, cwd, tabId: entry.tabId, group };
275
279
  }
280
+ // OBS-85 verified delivery: a pane paste can interleave a long dispatch line with itself (codex
281
+ // `$(git rev-parse…)` mashed into its trailing printf — v1.58 T2 attempts 2-4, v1.61 T10). Never
282
+ // the atomic `pane run` (text+Enter in one request, uninspectable between the two): type WITHOUT
283
+ // enter, read the pane back — `wait output --match` checks the same unwrapped transcript pane
284
+ // read exposes, event-driven so wrap and render timing can't race the check — and press Enter
285
+ // only when that read-back contains the typed command. A corrupted paste is captured (pane read),
286
+ // cleared (C-u), and retyped, bounded; persistent corruption fails closed WITH the captured
287
+ // transcript — the dispatch-time pincer the ledger asks for, not post-hoc `git:` archaeology.
276
288
  async run(slot, cmd) {
277
289
  const pane = await this.paneId(slot);
278
- const r = await this.herdr(`pane run ${shq(pane)} ${shq(cmd)}`, slot.cwd);
279
- if (r.code !== 0)
280
- throw new Error(`herdr pane run failed: ${r.stderr || r.stdout}`);
290
+ let transcript = "";
291
+ for (let attempt = 0; attempt < DELIVERY_ATTEMPTS; attempt++) {
292
+ if (attempt > 0) {
293
+ // clear the corrupted input line before retyping; a failed clear must NOT be retyped onto —
294
+ // corrupt-prefix + clean-retype would concatenate and false-verify by containment
295
+ const cleared = await this.herdr(`pane send-keys ${shq(pane)} C-u`, slot.cwd);
296
+ if (cleared.code !== 0)
297
+ throw new Error(`herdr delivery clear failed — refusing to retype onto a corrupted line (OBS-85); pane transcript:\n${transcript}`);
298
+ }
299
+ const typed = await this.herdr(`pane send-text ${shq(pane)} ${shq(cmd)}`, slot.cwd);
300
+ if (typed.code !== 0)
301
+ throw new Error(`herdr pane send-text failed: ${typed.stderr || typed.stdout}`);
302
+ const back = await this.herdr(`wait output ${shq(pane)} --match ${shq(cmd)} --timeout ${DELIVERY_VERIFY_TIMEOUT_MS}`, slot.cwd, DELIVERY_VERIFY_TIMEOUT_MS + 15_000);
303
+ // ponytail: transcript containment (scrollback included); anchor to the prompt line if a
304
+ // prior echo of the same command ever false-verifies. Dead pane → error envelope → unverified.
305
+ if (this.waitOk(back.code, back.stdout)) {
306
+ const enter = await this.herdr(`pane send-keys ${shq(pane)} Enter`, slot.cwd);
307
+ if (enter.code !== 0)
308
+ throw new Error(`herdr pane send-keys Enter failed: ${enter.stderr || enter.stdout}`);
309
+ return;
310
+ }
311
+ // capture the corrupted delivery BEFORE clearing it — the OBS-85 byte-level evidence
312
+ transcript = (await this.herdr(`pane read ${shq(pane)} --source recent-unwrapped --lines ${DELIVERY_READ_LINES}`, slot.cwd)).stdout;
313
+ }
314
+ throw new Error(`herdr delivery corrupted after ${DELIVERY_ATTEMPTS} attempts — enter never pressed (OBS-85); pane transcript:\n${transcript}`);
281
315
  }
282
316
  waitOk(code, stdout) {
283
317
  if (code !== 0 || !stdout.trim())
@@ -7,7 +7,7 @@ import { stringify } from "yaml";
7
7
  import { trailerPattern, writePrompt } from "../adapters/prompt.js";
8
8
  import { allAdapters, discoverChannels, getAdapter, probeAll, readDoctor } from "../adapters/registry.js";
9
9
  import { addUsage, channelKey, matchesTrustDialog, QUOTA_RE } from "../adapters/types.js";
10
- import { bannerShell } from "../brand.js";
10
+ import { bannerShell, paneDispatchCommand } from "../brand.js";
11
11
  import { globalConfigDir, loadConfigWithMode, readOverlayFile, repoOverlayPath, } from "../config/config.js";
12
12
  import { herdrSealShellPrefix, SubprocessDriver } from "../drivers/subprocess.js";
13
13
  import { formatOwnedName } from "../drivers/types.js";
@@ -575,6 +575,19 @@ export async function runDaemon(repoRoot, opts = {}) {
575
575
  journal.append("worker-mode-fallback", t.id, { reason: driver.interactive ? "adapter" : "driver" });
576
576
  }
577
577
  const interactive = icmd !== null;
578
+ // OBS-85 (v1.62 T1): both dispatch branches deliver ONE short script invocation — banner,
579
+ // adapter command, and nonce exit marker live in a per-attempt script beside the prompt
580
+ // artifact (the same paneDispatchCommand pattern judge/review/consult dispatches use). The
581
+ // delivered pane line carries no command substitution and no trailing shell text, so paste
582
+ // timing can never interleave a `$(…)` with what follows it (the codex corruption class).
583
+ const workerCmd = interactive ? icmd : adapter.invoke(t, wt, assignment, { promptFile }).command;
584
+ const dispatchScript = promptFile.replace(/\.md$/, ".sh");
585
+ writeFileSync(dispatchScript, [
586
+ "export BASH_SILENCE_DEPRECATION_WARNING=1",
587
+ bannerShell(),
588
+ workerCmd,
589
+ exitMarkerCmd,
590
+ ].join("\n"));
578
591
  // SPEND-01: this attempt's dispatch wall-clock — the usage collect cursor. Captured once here, the
579
592
  // single site, so a test can reason about it; keep Date.now() out of profile.ts (still pure) and
580
593
  // out of adapter module scope (the cursor is a parameter, threaded from the daemon).
@@ -617,9 +630,7 @@ export async function runDaemon(repoRoot, opts = {}) {
617
630
  if (interactive) {
618
631
  // v1.2 interactive: the TUI doesn't exit on completion — the trailer is the finish line.
619
632
  // The exit wrapper still fires if the TUI dies (crash/quit): fast-fail instead of burning the timeout.
620
- // T5: the pane announces itself first — same brand header (logo + dim identity) as the
621
- // judge/review/consult dispatch scripts; one printf one-liner, dispatch mechanics unchanged.
622
- await driver.run(slot, `${bannerShell()}; ${icmd}; ${exitMarkerCmd}`);
633
+ await driver.run(slot, paneDispatchCommand(dispatchScript));
623
634
  let paged = false;
624
635
  // v1.22 T5 / OBS-19: auto-answer a fingerprint-matched trust dialog exactly once per slot.
625
636
  // Any other blocked/idle dialog still pages the operator (paged latch below).
@@ -708,9 +719,7 @@ export async function runDaemon(repoRoot, opts = {}) {
708
719
  }
709
720
  }
710
721
  else {
711
- const inv = adapter.invoke(t, wt, assignment, { promptFile });
712
- // T5: same brand header as the interactive path and the gate/consult dispatch scripts.
713
- await driver.run(slot, `${bannerShell()}; ${inv.command}; ${exitMarkerCmd}`);
722
+ await driver.run(slot, paneDispatchCommand(dispatchScript));
714
723
  // OBS-54: headless workers have the same output-inactivity budget as visible panes.
715
724
  // OBS-82: same normalized-snapshot compare as the interactive site — spinner-only repaints
716
725
  // exhaust the budget here too; harvest below still reads the raw pane.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "1.60.0",
3
+ "version": "1.62.0",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",