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 +4 -4
- package/dist/cli/commands/status.js +9 -2
- package/dist/compile/native.d.ts +1 -0
- package/dist/compile/native.js +32 -0
- package/dist/config/config.d.ts +2 -27
- package/dist/config/config.js +4 -246
- package/dist/config/fleet-overlay.d.ts +27 -0
- package/dist/config/fleet-overlay.js +249 -0
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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");
|
package/dist/compile/native.d.ts
CHANGED
package/dist/compile/native.js
CHANGED
|
@@ -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);
|
package/dist/config/config.d.ts
CHANGED
|
@@ -235,8 +235,8 @@ export type InitConfigOverlay = {
|
|
|
235
235
|
};
|
|
236
236
|
};
|
|
237
237
|
export declare function configTemplate(overlay?: InitConfigOverlay): string;
|
|
238
|
-
|
|
239
|
-
export
|
|
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 {};
|
package/dist/config/config.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
537
|
-
|
|
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
|
+
}
|