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 +4 -4
- package/dist/cli/commands/status.js +9 -2
- package/dist/compile/native.d.ts +1 -0
- package/dist/compile/native.js +98 -1
- 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/dist/drivers/herdr.d.ts +1 -0
- package/dist/drivers/herdr.js +37 -3
- package/dist/run/daemon.js +16 -7
- 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");
|
|
@@ -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
|
-
|
|
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);
|
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
|
+
}
|
package/dist/drivers/herdr.d.ts
CHANGED
|
@@ -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 {
|
package/dist/drivers/herdr.js
CHANGED
|
@@ -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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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())
|
package/dist/run/daemon.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|