tickmarkr 1.60.0 → 1.61.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");
@@ -116,6 +119,35 @@ export function compileNative(file) {
116
119
  `Add to each section in ${file}:\n- acceptance:\n - <observable outcome>`);
117
120
  }
118
121
  const csv = (value) => (value && value.toLowerCase() !== "none" ? value.split(",").map((item) => item.trim()).filter(Boolean) : []);
122
+ // OBS-97: a typed test: oracle needs a collectable home. vitest only collects COLLECTABLE_TESTS
123
+ // paths, so a task whose non-empty files[] cannot host one makes scope-green and acceptance-green
124
+ // mutually exclusive by construction — run-20260719-210434 burned two dispatch attempts before a
125
+ // consult diagnosed exactly this. Empty files[] stays exempt: no file scope means unrestricted
126
+ // (src/gates/scope.ts). An entry hosts a collectable path iff its glob can produce one — probed by
127
+ // substituting each wildcard run with a test-shaped segment (also covers literal test-file paths).
128
+ const collectable = picomatch(COLLECTABLE_TESTS, { dot: true });
129
+ // Single-token substitution is not a true glob-overlap test (tests/**/*.ts needs "probe.test",
130
+ // a bare ** needs the whole collectable path) — probe with several test-shaped tokens and accept
131
+ // if ANY candidate satisfies both globs. Scopes that truly cannot host one still fail every probe.
132
+ const PROBE_TOKENS = ["probe.test.ts", "probe.test", "tests/probe.test.ts"];
133
+ const canHostTest = (entry) => {
134
+ const self = picomatch(entry, { dot: true });
135
+ return PROBE_TOKENS.some((token) => {
136
+ const probe = entry.replace(/\*+/g, token);
137
+ return self(probe) && collectable(probe);
138
+ });
139
+ };
140
+ const homeless = drafts
141
+ .filter((draft) => {
142
+ const files = csv(draft.fields.files);
143
+ return files.length > 0 && !files.some(canHostTest)
144
+ && draft.acceptance.some((item) => typeof item !== "string" && item.oracle === "test");
145
+ })
146
+ .map((draft) => draft.id);
147
+ if (homeless.length) {
148
+ 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` +
149
+ `Add a ${COLLECTABLE_TESTS} entry to files[] in ${file}, or replace the test: oracle with command:/judge:.`);
150
+ }
119
151
  const tasks = drafts.map((draft) => {
120
152
  const { fields } = draft;
121
153
  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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "1.60.0",
3
+ "version": "1.61.0",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",