tickmarkr 1.85.0 → 1.87.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 -2
- package/dist/adapters/catalog-remote.d.ts +64 -0
- package/dist/adapters/catalog-remote.js +287 -0
- package/dist/adapters/catalog.d.ts +108 -0
- package/dist/adapters/catalog.js +189 -0
- package/dist/adapters/claude-code.js +5 -3
- package/dist/adapters/fake.js +33 -4
- package/dist/adapters/model-lints.d.ts +25 -5
- package/dist/adapters/model-lints.js +184 -50
- package/dist/adapters/model-windows.d.ts +31 -0
- package/dist/adapters/model-windows.js +69 -0
- package/dist/adapters/prompt.d.ts +5 -1
- package/dist/adapters/prompt.js +13 -4
- package/dist/adapters/registry.d.ts +34 -27
- package/dist/adapters/registry.js +215 -112
- package/dist/adapters/types.js +15 -3
- package/dist/brand.d.ts +5 -1
- package/dist/brand.js +18 -2
- package/dist/cli/commands/doctor.d.ts +3 -0
- package/dist/cli/commands/doctor.js +43 -21
- package/dist/cli/commands/fleet.d.ts +7 -0
- package/dist/cli/commands/fleet.js +94 -74
- package/dist/cli/commands/init.js +118 -5
- package/dist/cli/commands/plan.js +11 -1
- package/dist/cli/commands/resume.js +7 -1
- package/dist/cli/commands/status.js +44 -18
- package/dist/compile/gsd.d.ts +2 -1
- package/dist/compile/gsd.js +68 -2
- package/dist/compile/native.d.ts +14 -0
- package/dist/compile/native.js +168 -12
- package/dist/config/config.d.ts +20 -5
- package/dist/config/config.js +96 -64
- package/dist/config/fleet-overlay.d.ts +25 -20
- package/dist/config/fleet-overlay.js +195 -77
- package/dist/config/fleet-why.d.ts +23 -0
- package/dist/config/fleet-why.js +42 -0
- package/dist/drivers/herdr.d.ts +1 -0
- package/dist/drivers/herdr.js +70 -19
- package/dist/gates/acceptance.js +7 -2
- package/dist/gates/llm.d.ts +0 -1
- package/dist/gates/llm.js +5 -30
- package/dist/gates/review.d.ts +2 -1
- package/dist/gates/review.js +9 -7
- package/dist/gates/run-gates.d.ts +1 -0
- package/dist/gates/run-gates.js +21 -2
- package/dist/gates/verdict-cause.d.ts +4 -0
- package/dist/gates/verdict-cause.js +63 -0
- package/dist/graph/schema.d.ts +6 -0
- package/dist/graph/schema.js +8 -5
- package/dist/route/preference.d.ts +1 -1
- package/dist/route/preference.js +8 -1
- package/dist/route/router.d.ts +0 -5
- package/dist/route/router.js +16 -20
- package/dist/run/consult.d.ts +6 -0
- package/dist/run/consult.js +49 -26
- package/dist/run/daemon.js +81 -20
- package/dist/run/journal.js +87 -7
- package/dist/tui/cockpit/capture.d.ts +12 -0
- package/dist/tui/cockpit/capture.js +37 -1
- package/dist/tui/cockpit/components.js +8 -8
- package/dist/tui/cockpit/theme.d.ts +32 -26
- package/dist/tui/cockpit/theme.js +11 -5
- package/dist/tui/ink/components.d.ts +0 -15
- package/dist/tui/ink/components.js +0 -17
- package/dist/tui/ink/fleet-app.d.ts +4 -1
- package/dist/tui/ink/fleet-app.js +134 -13
- package/fixtures/gateway-models.json +1 -0
- package/fixtures/sample.native.md +1 -1
- package/package.json +1 -1
- package/skills/tickmarkr-overseer/SKILL.md +464 -34
- package/skills/tickmarkr-overseer/scripts/watch-artifacts.sh +86 -0
- package/skills/tickmarkr-overseer/scripts/watch-panes.sh +1 -1
- package/dist/tui/ink/studio-app.d.ts +0 -59
- package/dist/tui/ink/studio-app.js +0 -320
- package/dist/tui/save.d.ts +0 -38
- package/dist/tui/save.js +0 -96
- package/dist/tui/staging.d.ts +0 -29
- package/dist/tui/staging.js +0 -78
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
// Fleet-overlay
|
|
2
|
-
// for the `tickmarkr fleet` write path. Pure move out of config.ts — prior import paths preserved
|
|
3
|
-
// via re-exports there.
|
|
1
|
+
// Fleet-overlay mutation, serialization, and diff rendering for the `tickmarkr fleet` write path.
|
|
4
2
|
import { isMap, isScalar, isSeq, parseDocument, stringify } from "yaml";
|
|
5
3
|
/** Fleet-owned overlay keys — the only config surface `tickmarkr fleet` may write. */
|
|
6
4
|
export const FLEET_OVERLAY_KEYS = ["routing", "tiers"];
|
|
@@ -15,49 +13,186 @@ function fleetSubset(obj) {
|
|
|
15
13
|
function sortedUnique(xs) {
|
|
16
14
|
return [...new Set(xs)].sort();
|
|
17
15
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
export function
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
16
|
+
// fleet.ts deliberately remains the sole overlay builder and writer. Its established classification
|
|
17
|
+
// seam copies only `tier` and `note` into FleetEditable, so first-touch entry metadata rides inside a
|
|
18
|
+
// private provenance envelope until this module writes the YAML. The envelope never reaches disk.
|
|
19
|
+
const FIRST_TOUCH_OPEN = "\uE000tickmarkr-fleet-first-touch:";
|
|
20
|
+
const FIRST_TOUCH_CLOSE = "\uE001";
|
|
21
|
+
export function fleetFirstTouchProvenance(note, firstTouch) {
|
|
22
|
+
return `${FIRST_TOUCH_OPEN}${encodeURIComponent(firstTouch.vendor)}:${firstTouch.channel}${FIRST_TOUCH_CLOSE}${note}`;
|
|
23
|
+
}
|
|
24
|
+
function unpackFleetProvenance(provenance) {
|
|
25
|
+
if (!provenance?.startsWith(FIRST_TOUCH_OPEN))
|
|
26
|
+
return { provenance };
|
|
27
|
+
const close = provenance.indexOf(FIRST_TOUCH_CLOSE, FIRST_TOUCH_OPEN.length);
|
|
28
|
+
if (close === -1)
|
|
29
|
+
return { provenance };
|
|
30
|
+
const metadata = provenance.slice(FIRST_TOUCH_OPEN.length, close);
|
|
31
|
+
const colon = metadata.lastIndexOf(":");
|
|
32
|
+
if (colon === -1)
|
|
33
|
+
return { provenance };
|
|
34
|
+
const channel = metadata.slice(colon + 1);
|
|
35
|
+
if (channel !== "sub" && channel !== "api")
|
|
36
|
+
return { provenance };
|
|
37
|
+
try {
|
|
38
|
+
return {
|
|
39
|
+
provenance: provenance.slice(close + FIRST_TOUCH_CLOSE.length),
|
|
40
|
+
firstTouch: { vendor: decodeURIComponent(metadata.slice(0, colon)), channel },
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return { provenance };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function setScalarPreservingComment(doc, path, value, authoredComment) {
|
|
48
|
+
const existing = doc.getIn(path, true);
|
|
49
|
+
if (isScalar(existing))
|
|
50
|
+
existing.value = value;
|
|
51
|
+
else
|
|
52
|
+
doc.setIn(path, doc.createNode(value));
|
|
53
|
+
if (authoredComment !== undefined) {
|
|
54
|
+
const written = doc.getIn(path, true);
|
|
55
|
+
if (isScalar(written))
|
|
56
|
+
written.comment = ` ${authoredComment}`;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function setStringSequencePreservingComments(doc, path, values) {
|
|
60
|
+
const existing = doc.getIn(path, true);
|
|
61
|
+
if (values === null) {
|
|
62
|
+
const tombstone = doc.createNode(null);
|
|
63
|
+
if (isScalar(tombstone) && existing && typeof existing === "object") {
|
|
64
|
+
// A block sequence's key-line note is stored as commentBefore; on a scalar it must be
|
|
65
|
+
// inline comment content or YAML expands `key: null` into a nested null value.
|
|
66
|
+
const comments = [];
|
|
67
|
+
if ("commentBefore" in existing && typeof existing.commentBefore === "string") {
|
|
68
|
+
comments.push(existing.commentBefore);
|
|
69
|
+
}
|
|
70
|
+
if ("comment" in existing && typeof existing.comment === "string") {
|
|
71
|
+
comments.push(existing.comment);
|
|
44
72
|
}
|
|
73
|
+
if (comments.length)
|
|
74
|
+
tombstone.comment = comments.join("\n");
|
|
45
75
|
}
|
|
76
|
+
doc.setIn(path, tombstone);
|
|
77
|
+
return;
|
|
46
78
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
79
|
+
if (!isSeq(existing)) {
|
|
80
|
+
doc.setIn(path, doc.createNode(values));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const available = [...existing.items];
|
|
84
|
+
existing.items = values.map((value) => {
|
|
85
|
+
const at = available.findIndex((item) => isScalar(item) && String(item.value) === value);
|
|
86
|
+
if (at >= 0)
|
|
87
|
+
return available.splice(at, 1)[0];
|
|
88
|
+
return doc.createNode(value);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
function deleteEmptyMap(doc, path) {
|
|
92
|
+
const node = doc.getIn(path, true);
|
|
93
|
+
if (isMap(node) && node.items.length === 0)
|
|
94
|
+
doc.deleteIn(path);
|
|
95
|
+
}
|
|
96
|
+
/** Apply only fields fleet authored to the parsed YAML document. Untouched nodes retain their
|
|
97
|
+
* keys, ordering, scalar style, and comments; fresh provenance is written directly on its tier node. */
|
|
98
|
+
export function renderFleetOverlayWrite(priorBytes, write) {
|
|
99
|
+
const doc = parseDocument(priorBytes);
|
|
100
|
+
if (doc.errors.length)
|
|
101
|
+
throw doc.errors[0];
|
|
102
|
+
const { initial, edited } = write;
|
|
103
|
+
const denyChanged = sortedUnique(initial.denyAdapters).join() !== sortedUnique(edited.denyAdapters).join()
|
|
104
|
+
|| sortedUnique(initial.denyModels).join() !== sortedUnique(edited.denyModels).join();
|
|
105
|
+
if (denyChanged) {
|
|
106
|
+
setStringSequencePreservingComments(doc, ["routing", "deny", "adapters"], edited.denyAdapters.length ? sortedUnique(edited.denyAdapters) : null);
|
|
107
|
+
setStringSequencePreservingComments(doc, ["routing", "deny", "models"], edited.denyModels.length ? sortedUnique(edited.denyModels) : null);
|
|
108
|
+
}
|
|
109
|
+
for (const shape of new Set([...Object.keys(initial.map), ...Object.keys(edited.map)])) {
|
|
110
|
+
const before = initial.map[shape];
|
|
111
|
+
const after = edited.map[shape];
|
|
112
|
+
if (JSON.stringify(before?.pin) !== JSON.stringify(after?.pin)) {
|
|
113
|
+
if (after?.pin === undefined)
|
|
114
|
+
doc.deleteIn(["routing", "map", shape, "pin"]);
|
|
115
|
+
else
|
|
116
|
+
doc.setIn(["routing", "map", shape, "pin"], doc.createNode(after.pin));
|
|
117
|
+
}
|
|
118
|
+
if (JSON.stringify(before?.prefer) !== JSON.stringify(after?.prefer)) {
|
|
119
|
+
// Clearing a resolved list must mask the lower layer with [], not delete the key and inherit it again.
|
|
120
|
+
setStringSequencePreservingComments(doc, ["routing", "map", shape, "prefer"], after?.prefer ?? []);
|
|
121
|
+
}
|
|
122
|
+
if (before?.escalate !== after?.escalate) {
|
|
123
|
+
if (after?.escalate === undefined)
|
|
124
|
+
doc.deleteIn(["routing", "map", shape, "escalate"]);
|
|
125
|
+
else
|
|
126
|
+
setScalarPreservingComment(doc, ["routing", "map", shape, "escalate"], after.escalate);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
for (const shape of new Set([...Object.keys(initial.floors), ...Object.keys(edited.floors)])) {
|
|
130
|
+
if (initial.floors[shape] === edited.floors[shape])
|
|
50
131
|
continue;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
132
|
+
const tier = edited.floors[shape];
|
|
133
|
+
if (tier === undefined)
|
|
134
|
+
doc.deleteIn(["routing", "floors", shape]);
|
|
135
|
+
else
|
|
136
|
+
setScalarPreservingComment(doc, ["routing", "floors", shape], tier);
|
|
137
|
+
}
|
|
138
|
+
for (const adapter of new Set([...Object.keys(initial.tiers), ...Object.keys(edited.tiers)])) {
|
|
139
|
+
const beforeModels = initial.tiers[adapter] ?? {};
|
|
140
|
+
const afterModels = edited.tiers[adapter] ?? {};
|
|
141
|
+
let firstTouch;
|
|
142
|
+
for (const model of new Set([...Object.keys(beforeModels), ...Object.keys(afterModels)])) {
|
|
143
|
+
const before = beforeModels[model];
|
|
144
|
+
const after = afterModels[model];
|
|
145
|
+
if (JSON.stringify(before) === JSON.stringify(after) || after === null || after === undefined)
|
|
146
|
+
continue;
|
|
147
|
+
firstTouch ??= unpackFleetProvenance(after.provenance).firstTouch;
|
|
148
|
+
}
|
|
149
|
+
const ft = firstTouch;
|
|
150
|
+
if (ft) {
|
|
151
|
+
if (doc.getIn(["tiers", adapter, "vendor"]) === undefined) {
|
|
152
|
+
setScalarPreservingComment(doc, ["tiers", adapter, "vendor"], ft.vendor);
|
|
153
|
+
}
|
|
154
|
+
if (doc.getIn(["tiers", adapter, "channel"]) === undefined) {
|
|
155
|
+
setScalarPreservingComment(doc, ["tiers", adapter, "channel"], ft.channel);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
for (const model of new Set([...Object.keys(beforeModels), ...Object.keys(afterModels)])) {
|
|
159
|
+
const before = beforeModels[model];
|
|
160
|
+
const after = afterModels[model];
|
|
161
|
+
if (JSON.stringify(before) === JSON.stringify(after))
|
|
162
|
+
continue;
|
|
163
|
+
const path = ["tiers", adapter, "models", model];
|
|
164
|
+
if (after === null || after === undefined)
|
|
165
|
+
setScalarPreservingComment(doc, path, null);
|
|
166
|
+
else
|
|
167
|
+
setScalarPreservingComment(doc, path, after.tier, unpackFleetProvenance(after.provenance).provenance);
|
|
55
168
|
}
|
|
56
169
|
}
|
|
57
|
-
|
|
170
|
+
if (write.mode !== undefined) {
|
|
171
|
+
setScalarPreservingComment(doc, ["routing", "mode"], write.mode);
|
|
172
|
+
}
|
|
173
|
+
if (write.steering) {
|
|
174
|
+
for (const key of ["review", "consult"]) {
|
|
175
|
+
const before = write.steering.initial[key];
|
|
176
|
+
const after = write.steering.edited[key];
|
|
177
|
+
if (JSON.stringify(before) === JSON.stringify(after))
|
|
178
|
+
continue;
|
|
179
|
+
if (after === undefined) {
|
|
180
|
+
doc.deleteIn([key, "prefer"]);
|
|
181
|
+
deleteEmptyMap(doc, [key]);
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
setStringSequencePreservingComments(doc, [key, "prefer"], after);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// Fleet's established note style uses two spaces before `#`; the Document remains the sole
|
|
189
|
+
// comment writer while preserving that byte-level convention for old and newly-authored notes.
|
|
190
|
+
return doc.toString({
|
|
191
|
+
commentString: (comment) => comment.replace(/^(?!$)(?: $)?/gm, " #"),
|
|
192
|
+
});
|
|
58
193
|
}
|
|
59
194
|
/** Build the repo overlay fragment fleet would write for edits since session start. */
|
|
60
|
-
export function fleetRepoOverlayFromDelta(initial, edited, existingRepo = {}) {
|
|
195
|
+
export function fleetRepoOverlayFromDelta(initial, edited, existingRepo = {}, firstTouches = {}) {
|
|
61
196
|
if (fleetEditableEquals(initial, edited))
|
|
62
197
|
return {};
|
|
63
198
|
const out = structuredClone(existingRepo);
|
|
@@ -74,8 +209,13 @@ export function fleetRepoOverlayFromDelta(initial, edited, existingRepo = {}) {
|
|
|
74
209
|
}
|
|
75
210
|
const mapDelta = {};
|
|
76
211
|
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
|
-
|
|
212
|
+
if (JSON.stringify(initial.map[shape]) !== JSON.stringify(edited.map[shape])) {
|
|
213
|
+
const next = { ...edited.map[shape] };
|
|
214
|
+
if (initial.map[shape]?.prefer !== undefined && edited.map[shape]?.prefer === undefined) {
|
|
215
|
+
next.prefer = [];
|
|
216
|
+
}
|
|
217
|
+
mapDelta[shape] = next;
|
|
218
|
+
}
|
|
79
219
|
}
|
|
80
220
|
if (Object.keys(mapDelta).length) {
|
|
81
221
|
routing.map = { ...routing.map, ...mapDelta };
|
|
@@ -111,9 +251,17 @@ export function fleetRepoOverlayFromDelta(initial, edited, existingRepo = {}) {
|
|
|
111
251
|
}
|
|
112
252
|
}
|
|
113
253
|
if (Object.keys(modelDelta).length) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
254
|
+
const existingEntry = tiersOut[adapter] ?? {};
|
|
255
|
+
const firstTouch = firstTouches[adapter];
|
|
256
|
+
// Spread the whole entry rather than a known-key projection: vendor/channel/windows and sibling
|
|
257
|
+
// keys introduced by newer schemas all survive. A genuinely new entry receives only facts the
|
|
258
|
+
// adapter/operator declared; channel is never inferred from the binary.
|
|
259
|
+
tiersOut[adapter] = {
|
|
260
|
+
...existingEntry,
|
|
261
|
+
...(existingEntry.vendor === undefined && firstTouch ? { vendor: firstTouch.vendor } : {}),
|
|
262
|
+
...(existingEntry.channel === undefined && firstTouch ? { channel: firstTouch.channel } : {}),
|
|
263
|
+
models: { ...existingEntry.models, ...modelDelta },
|
|
264
|
+
};
|
|
117
265
|
tiersTouched = true;
|
|
118
266
|
}
|
|
119
267
|
}
|
|
@@ -121,11 +269,11 @@ export function fleetRepoOverlayFromDelta(initial, edited, existingRepo = {}) {
|
|
|
121
269
|
out.tiers = tiersOut;
|
|
122
270
|
return out;
|
|
123
271
|
}
|
|
124
|
-
export function repoOverlayYaml(overlay
|
|
272
|
+
export function repoOverlayYaml(overlay) {
|
|
125
273
|
if (!Object.keys(overlay).length)
|
|
126
274
|
return "";
|
|
127
275
|
const fleet = fleetSubset(overlay);
|
|
128
|
-
const fleetBody = serializeFleetOverlay(fleet
|
|
276
|
+
const fleetBody = serializeFleetOverlay(fleet);
|
|
129
277
|
const rest = { ...overlay };
|
|
130
278
|
for (const k of FLEET_OVERLAY_KEYS)
|
|
131
279
|
delete rest[k];
|
|
@@ -134,7 +282,7 @@ export function repoOverlayYaml(overlay, provenance = {}, denyNotes = {}) {
|
|
|
134
282
|
const head = stringify(rest).trimEnd();
|
|
135
283
|
return fleetBody ? `${head}\n${fleetBody}` : `${head}\n`;
|
|
136
284
|
}
|
|
137
|
-
export function serializeFleetOverlay(overlay
|
|
285
|
+
export function serializeFleetOverlay(overlay) {
|
|
138
286
|
if (!Object.keys(overlay).length)
|
|
139
287
|
return "";
|
|
140
288
|
const lines = [];
|
|
@@ -142,8 +290,6 @@ export function serializeFleetOverlay(overlay, provenance = {}, denyNotes = {})
|
|
|
142
290
|
// re-indent the whole emitted block, so sequences/nested maps nest correctly and null
|
|
143
291
|
// tombstones/empty collections survive the serialize→parse round-trip.
|
|
144
292
|
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
293
|
const denySeq = (key, v) => {
|
|
148
294
|
if (v === undefined)
|
|
149
295
|
return [];
|
|
@@ -152,9 +298,6 @@ export function serializeFleetOverlay(overlay, provenance = {}, denyNotes = {})
|
|
|
152
298
|
const out = [` ${key}:`];
|
|
153
299
|
for (const item of v) {
|
|
154
300
|
const emitted = stringify([item]).trimEnd().split("\n");
|
|
155
|
-
const n = denyNotes[key]?.[item];
|
|
156
|
-
if (n && emitted.length === 1)
|
|
157
|
-
emitted[0] += ` # ${n}`;
|
|
158
301
|
out.push(...emitted.map((l) => ` ${l}`));
|
|
159
302
|
}
|
|
160
303
|
return out;
|
|
@@ -177,32 +320,7 @@ export function serializeFleetOverlay(overlay, provenance = {}, denyNotes = {})
|
|
|
177
320
|
if (tiers && Object.keys(tiers).length) {
|
|
178
321
|
lines.push("tiers:");
|
|
179
322
|
for (const [adapter, entry] of Object.entries(tiers)) {
|
|
180
|
-
|
|
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}: {}`);
|
|
323
|
+
lines.push(...block({ [adapter]: entry }, " "));
|
|
206
324
|
}
|
|
207
325
|
}
|
|
208
326
|
return `${lines.join("\n")}\n`;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type FleetWhySource = "repo-overlay" | "global-config" | "seed-default" | "operator-pinned" | "defaulted";
|
|
2
|
+
export type FleetWhyValue<Id extends string = string> = {
|
|
3
|
+
id: Id;
|
|
4
|
+
effective: string;
|
|
5
|
+
/** Dotted config path whose winning declaration produced this value. */
|
|
6
|
+
declaredAt?: string;
|
|
7
|
+
/** A live Shapes-screen pin outranks every persisted declaration. */
|
|
8
|
+
operatorPinned?: boolean;
|
|
9
|
+
/** Paste-ready only: projection and renderers never execute setup actions. */
|
|
10
|
+
setupCommand?: string;
|
|
11
|
+
};
|
|
12
|
+
export type FleetWhyRow<Id extends string = string> = FleetWhyValue<Id> & {
|
|
13
|
+
source: FleetWhySource;
|
|
14
|
+
label: string;
|
|
15
|
+
};
|
|
16
|
+
export type FleetWhyOptions = {
|
|
17
|
+
repoRoot: string;
|
|
18
|
+
globalDir?: string;
|
|
19
|
+
};
|
|
20
|
+
/** The single fleet projection: every consumer receives the same value/source/setup label. */
|
|
21
|
+
export declare function projectFleetWhy<Id extends string>(values: readonly FleetWhyValue<Id>[], options: FleetWhyOptions): FleetWhyRow<Id>[];
|
|
22
|
+
/** Plain line-mode twin of the Shapes rows; labels are projected, never reconstructed here. */
|
|
23
|
+
export declare function renderFleetWhy(rows: readonly FleetWhyRow[]): string;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { DEFAULT_CONFIG, fleetKeyLayer } from "./config.js";
|
|
2
|
+
function valueAt(root, dotted) {
|
|
3
|
+
let value = root;
|
|
4
|
+
for (const part of dotted.split(".")) {
|
|
5
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
6
|
+
return undefined;
|
|
7
|
+
value = value[part];
|
|
8
|
+
}
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
function sourceOf(value, options) {
|
|
12
|
+
if (value.operatorPinned)
|
|
13
|
+
return "operator-pinned";
|
|
14
|
+
if (!value.declaredAt)
|
|
15
|
+
return "defaulted";
|
|
16
|
+
const layer = fleetKeyLayer(options.repoRoot, value.declaredAt, {
|
|
17
|
+
globalDir: options.globalDir,
|
|
18
|
+
});
|
|
19
|
+
if (layer === "repo")
|
|
20
|
+
return "repo-overlay";
|
|
21
|
+
if (layer === "global")
|
|
22
|
+
return "global-config";
|
|
23
|
+
return valueAt(DEFAULT_CONFIG, value.declaredAt) === undefined
|
|
24
|
+
? "defaulted"
|
|
25
|
+
: "seed-default";
|
|
26
|
+
}
|
|
27
|
+
/** The single fleet projection: every consumer receives the same value/source/setup label. */
|
|
28
|
+
export function projectFleetWhy(values, options) {
|
|
29
|
+
return values.map((value) => {
|
|
30
|
+
const source = sourceOf(value, options);
|
|
31
|
+
const setup = value.setupCommand ? `\n setup: ${value.setupCommand}` : "";
|
|
32
|
+
return {
|
|
33
|
+
...value,
|
|
34
|
+
source,
|
|
35
|
+
label: `${value.id} → ${value.effective} · source: ${source}${setup}`,
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/** Plain line-mode twin of the Shapes rows; labels are projected, never reconstructed here. */
|
|
40
|
+
export function renderFleetWhy(rows) {
|
|
41
|
+
return ["tickmarkr fleet --why — effective shape routing", ...rows.map((row) => row.label)].join("\n");
|
|
42
|
+
}
|
package/dist/drivers/herdr.d.ts
CHANGED
|
@@ -22,6 +22,7 @@ export declare class DeliveryCorruptedError extends Error {
|
|
|
22
22
|
export type DriverJournal = (event: string, slotName: string, data: Record<string, unknown>) => void;
|
|
23
23
|
/** First-generation join direction from measured trailer-safe floor (43-MEASUREMENT.md). */
|
|
24
24
|
export declare function workerSplitDirection(paneCols: number | null, safeFloor?: number, margin?: number): "right" | "down";
|
|
25
|
+
export declare function taskGroupOf(name: string): string | undefined;
|
|
25
26
|
export declare class HerdrDriver implements ExecutorDriver {
|
|
26
27
|
private bin;
|
|
27
28
|
private workersPerTab;
|
package/dist/drivers/herdr.js
CHANGED
|
@@ -3,7 +3,7 @@ import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { declaredInputBoxForWorkerName, matchesEmptyInputBox, matchesInputBox, matchesOccupiedInputBox, missingInputStateDeclarations, shq } from "../adapters/types.js";
|
|
6
|
-
import { PANE_IDENTITY_ENV, paneIdentityLine } from "../brand.js";
|
|
6
|
+
import { consumePaneLaunchIntent, PANE_IDENTITY_ENV, paneIdentityLine } from "../brand.js";
|
|
7
7
|
import { createWorktree, sh } from "../run/git.js";
|
|
8
8
|
import { Journal } from "../run/journal.js";
|
|
9
9
|
import { herdrSealShellPrefix } from "./subprocess.js";
|
|
@@ -67,6 +67,27 @@ export function workerSplitDirection(paneCols, safeFloor = TRAILER_SAFE_FLOOR_CO
|
|
|
67
67
|
return "down";
|
|
68
68
|
return paneCols / 2 >= safeFloor + margin ? "right" : "down";
|
|
69
69
|
}
|
|
70
|
+
/** The tab a slot belongs to: its TASK — worker, judge, review and consult panes for one task share it.
|
|
71
|
+
* Returns undefined for everything else, which keeps those on the dedicated-tab path.
|
|
72
|
+
*
|
|
73
|
+
* The ROLE gate is load-bearing and was added after it bit: `canonicalizeLegacyName` returns
|
|
74
|
+
* `role:"other", taskId:"<the whole name>"` for any unrecognised string, so keying on taskId alone
|
|
75
|
+
* makes EVERY one-off pane its own "task" and gives it a group tab. `watch` is excluded for the same
|
|
76
|
+
* reason in the other direction — its taskId is the literal "run", which is a board, not a task. */
|
|
77
|
+
const TASK_TAB_ROLES = new Set(["worker", "judge", "review", "consult"]);
|
|
78
|
+
export function taskGroupOf(name) {
|
|
79
|
+
const { role, taskId } = canonicalizeLegacyName(name, "");
|
|
80
|
+
if (!TASK_TAB_ROLES.has(role))
|
|
81
|
+
return undefined;
|
|
82
|
+
return taskId && taskId.trim() ? taskId : undefined;
|
|
83
|
+
}
|
|
84
|
+
/** Gate panes ride with the task they belong to and never consume the tab cap; everything else does.
|
|
85
|
+
* Scoped to the three GATE roles deliberately — an earlier cut of this said "not a worker", which let
|
|
86
|
+
* role:"other" members (any unrecognised name) bypass the cap and silently disabled overflow for
|
|
87
|
+
* explicit stage groups. The cap still governs every member it governed before. */
|
|
88
|
+
const GATE_ROLES = new Set(["judge", "review", "consult"]);
|
|
89
|
+
const ridesWithTask = (name) => GATE_ROLES.has(canonicalizeLegacyName(name, "").role);
|
|
90
|
+
const cappedMembers = (g) => g.members.filter((m) => !ridesWithTask(m.name)).length;
|
|
70
91
|
export class HerdrDriver {
|
|
71
92
|
bin;
|
|
72
93
|
workersPerTab;
|
|
@@ -250,8 +271,22 @@ export class HerdrDriver {
|
|
|
250
271
|
// reconcile.ts and this driver's own renameGroupTab/glyphFor decode role/taskId/attempt from
|
|
251
272
|
// those shapes without a call-site migration; T2 retires this branch by always passing `owned`.
|
|
252
273
|
const resolved = opts?.owned ? formatOwnedName(opts.owned) : name;
|
|
253
|
-
|
|
254
|
-
|
|
274
|
+
// ONE TAB PER TASK. The group defaults to the task id, derived from the same parser that already
|
|
275
|
+
// decodes role/taskId for tab labels — so a worker and every gate pane it earns (judge, review,
|
|
276
|
+
// consult) land in that task's tab instead of scattering. Deriving it HERE rather than at each
|
|
277
|
+
// call site is the point: the defect this replaces was three call sites of which exactly one
|
|
278
|
+
// passed a group, so judge/review/consult each opened a tab of their own. A caller may still pass
|
|
279
|
+
// `group` explicitly to override, and a name with no task id (or an explicit `label`) keeps the
|
|
280
|
+
// dedicated-tab path unchanged.
|
|
281
|
+
// PRECEDENCE: a task-bearing name ALWAYS groups by its task; an explicit `group` applies only to
|
|
282
|
+
// names with no task identity. That direction is deliberate — the invariant is "a task's panes are
|
|
283
|
+
// never scattered", and a caller passing a stage group (the daemon still passes "workers", which now
|
|
284
|
+
// serves as the fallback for task-less names) must not be able to override it by omission or habit.
|
|
285
|
+
// An explicit `label` still wins outright: that is the dedicated-role-tab path, chosen on purpose.
|
|
286
|
+
const derivedGroup = opts?.label ? undefined : taskGroupOf(resolved);
|
|
287
|
+
const group = derivedGroup ?? opts?.group;
|
|
288
|
+
const allocate = group
|
|
289
|
+
? () => this.serial(() => this.groupSlot(cwd, resolved, group))
|
|
255
290
|
: () => this.tabSlot(cwd, resolved, opts?.label);
|
|
256
291
|
// Production dispatch names are canonical even when the gate call site supplies the already-
|
|
257
292
|
// formatted name rather than SlotOpts.owned. Hold one lease across slot() → run(); legacy/manual
|
|
@@ -358,7 +393,10 @@ export class HerdrDriver {
|
|
|
358
393
|
return this.tabSlot(cwd, name); // D-09: degrade, NOT a shared-tab member
|
|
359
394
|
if (state) {
|
|
360
395
|
const latest = state.generations[state.generations.length - 1];
|
|
361
|
-
|
|
396
|
+
// A task tab's judge/review/consult panes belong with their worker and must never overflow into a
|
|
397
|
+
// `cleanup` tab — that would re-scatter exactly what the per-task grouping exists to gather. They
|
|
398
|
+
// therefore join freely and do not consume `workersPerTab`; every other member still does.
|
|
399
|
+
if (latest && (ridesWithTask(name) || cappedMembers(latest) < this.workersPerTab)) {
|
|
362
400
|
const joined = await this.joinGroup(cwd, name, group, latest);
|
|
363
401
|
if (joined)
|
|
364
402
|
return joined;
|
|
@@ -393,7 +431,11 @@ export class HerdrDriver {
|
|
|
393
431
|
const newest = [...entry.members].reverse().find((m) => canonicalizeLegacyName(m.name, "").role === "worker");
|
|
394
432
|
const token = newest ? canonicalizeLegacyName(newest.name, "").taskId : undefined;
|
|
395
433
|
const glyph = newest ? await this.glyphFor(newest) : "";
|
|
396
|
-
|
|
434
|
+
// A per-task tab is already labelled with its task id; appending the same token again reads as a
|
|
435
|
+
// duplicate rather than as state, so the glyph rides the existing label instead.
|
|
436
|
+
const label = !token ? entry.label
|
|
437
|
+
: entry.label === token ? `${token}${glyph}`
|
|
438
|
+
: `${entry.label} · ${token}${glyph}`;
|
|
397
439
|
const cmd = `tab rename ${shq(entry.tabId)} ${shq(label)}`;
|
|
398
440
|
const ok = async () => (await this.herdr(cmd)).code === 0;
|
|
399
441
|
if (await ok() || await ok())
|
|
@@ -468,12 +510,16 @@ export class HerdrDriver {
|
|
|
468
510
|
// command — earns the typed pincer. One dispatch corruption is then retried in-process against a
|
|
469
511
|
// FRESH pane before anything is reported as a failure.
|
|
470
512
|
async run(slot, cmd) {
|
|
513
|
+
// paneLaunchCommand is a linear same-call handoff: consume before the first await so concurrent
|
|
514
|
+
// task dispatches cannot exchange intent. The command remains an ordinary string for every
|
|
515
|
+
// ExecutorDriver implementation; classification never reads its bytes when the builder spoke.
|
|
516
|
+
const recordedLaunch = consumePaneLaunchIntent();
|
|
471
517
|
const lease = this.dispatchLeases.get(slot);
|
|
472
518
|
if (lease) {
|
|
473
519
|
this.dispatchLeases.delete(slot);
|
|
474
520
|
try {
|
|
475
521
|
const paneId = await this.verifyPaneIdentityBinding(slot);
|
|
476
|
-
await this.dispatchWithRetry(slot, cmd, paneId);
|
|
522
|
+
await this.dispatchWithRetry(slot, cmd, paneId, recordedLaunch);
|
|
477
523
|
}
|
|
478
524
|
finally {
|
|
479
525
|
lease.release();
|
|
@@ -481,16 +527,16 @@ export class HerdrDriver {
|
|
|
481
527
|
return;
|
|
482
528
|
}
|
|
483
529
|
return this.deliveryQueue(async () => {
|
|
484
|
-
await this.dispatchWithRetry(slot, cmd, await this.paneId(slot));
|
|
530
|
+
await this.dispatchWithRetry(slot, cmd, await this.paneId(slot), recordedLaunch);
|
|
485
531
|
});
|
|
486
532
|
}
|
|
487
533
|
// OBS-253: dispatch corruption is self-clearing — all ten recorded occurrences cleared on a fresh
|
|
488
534
|
// pane and a fresh submit, and every one of them cost an operator `resume --retry-failed`. Retry
|
|
489
535
|
// ONCE here against a pane that has never seen this delivery; the wedged pane is closed, never
|
|
490
536
|
// re-pressed. A second consecutive corruption propagates, and the daemon parks the task on it.
|
|
491
|
-
async dispatchWithRetry(slot, cmd, pane) {
|
|
537
|
+
async dispatchWithRetry(slot, cmd, pane, recordedLaunch = false) {
|
|
492
538
|
try {
|
|
493
|
-
await this.dispatch(slot, cmd, pane);
|
|
539
|
+
await this.dispatch(slot, cmd, pane, recordedLaunch);
|
|
494
540
|
this.deliveredPanes.set(slot, pane);
|
|
495
541
|
return;
|
|
496
542
|
}
|
|
@@ -502,7 +548,7 @@ export class HerdrDriver {
|
|
|
502
548
|
// this slot already delivered. Retyping a turn into a bare shell would guarantee the second
|
|
503
549
|
// failure rather than recover from the first, so without a replayable bootstrap the
|
|
504
550
|
// corruption propagates untouched.
|
|
505
|
-
const typedTurn = this.isTuiTurn(slot, cmd);
|
|
551
|
+
const typedTurn = this.isTuiTurn(slot, cmd, recordedLaunch);
|
|
506
552
|
const relaunch = typedTurn ? this.bootstraps.get(slot) : undefined;
|
|
507
553
|
if (typedTurn && relaunch === undefined)
|
|
508
554
|
throw error;
|
|
@@ -516,27 +562,32 @@ export class HerdrDriver {
|
|
|
516
562
|
throw error;
|
|
517
563
|
if (relaunch !== undefined)
|
|
518
564
|
await this.deliverAtomic(slot, relaunch, fresh);
|
|
519
|
-
await this.dispatch(slot, cmd, fresh);
|
|
565
|
+
await this.dispatch(slot, cmd, fresh, recordedLaunch);
|
|
520
566
|
this.deliveredPanes.set(slot, fresh);
|
|
521
567
|
}
|
|
522
568
|
}
|
|
523
569
|
// Is this delivery a turn TYPED into a running interface, rather than a line a shell runs? The
|
|
524
|
-
//
|
|
525
|
-
//
|
|
570
|
+
// The builder's recorded fact decides first, regardless of command bytes. For direct driver users,
|
|
571
|
+
// an adapter may declare the fresh-slot lifecycle rather than a prefix list. Legacy declarations
|
|
572
|
+
// still decide where neither stronger fact exists. With NO declaration the only evidence is history — a
|
|
526
573
|
// pane that already accepted a delivery is running whatever that delivery started, so a second
|
|
527
574
|
// delivery is a turn into it. Answering "shell" there is what would run an adapter's seed prompt
|
|
528
575
|
// as a shell command; answering "turn" routes it to deliverTyped, which refuses it by name
|
|
529
576
|
// because the adapter declared no input box (fail closed, never a guess).
|
|
530
|
-
isTuiTurn(slot, cmd) {
|
|
577
|
+
isTuiTurn(slot, cmd, recordedLaunch = false) {
|
|
578
|
+
if (recordedLaunch)
|
|
579
|
+
return false;
|
|
531
580
|
const inputBox = this.inputBoxes.get(slot);
|
|
581
|
+
if (inputBox?.firstDeliveryIsLaunch && !this.deliveredPanes.has(slot))
|
|
582
|
+
return false;
|
|
532
583
|
return inputBox ? inputBox.launchCommand?.(cmd) !== true : this.deliveredPanes.has(slot);
|
|
533
584
|
}
|
|
534
|
-
dispatch(slot, cmd, pane) {
|
|
535
|
-
if (this.isTuiTurn(slot, cmd))
|
|
585
|
+
dispatch(slot, cmd, pane, recordedLaunch = false) {
|
|
586
|
+
if (this.isTuiTurn(slot, cmd, recordedLaunch))
|
|
536
587
|
return this.deliverTyped(slot, cmd, pane);
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
if (this.inputBoxes.
|
|
588
|
+
// A classified bootstrap is the one command that can restore this slot's interface on a fresh
|
|
589
|
+
// pane, so remember the fact's command without asking its bytes to prove the classification again.
|
|
590
|
+
if (this.inputBoxes.has(slot))
|
|
540
591
|
this.bootstraps.set(slot, cmd);
|
|
541
592
|
return this.deliverAtomic(slot, cmd, pane);
|
|
542
593
|
}
|
package/dist/gates/acceptance.js
CHANGED
|
@@ -5,6 +5,7 @@ import { renderAcceptanceItem } from "../graph/schema.js";
|
|
|
5
5
|
import { sh } from "../run/git.js";
|
|
6
6
|
import { checkDiffCap, fetchTaskDiff, isProtectedEvidence, setAsideReceiptPath } from "./review.js";
|
|
7
7
|
import { appendAnchoredReview, COMPLETION_FAKING_CHECKLIST, extractVerdictJson, generateVerdictNonce, runLlm, verdictNonceLine } from "./llm.js";
|
|
8
|
+
import { classifyVerdictCause } from "./verdict-cause.js";
|
|
8
9
|
// Fable F4: acceptance judge shares review's 900s timeout — 300s default killed frontier judges on cap-sized diffs.
|
|
9
10
|
const JUDGE_TIMEOUT_MS = 900_000;
|
|
10
11
|
const CitationSchema = z.object({ path: z.string(), line: z.number().int() });
|
|
@@ -328,12 +329,16 @@ The top-level comments array is optional. Use it only for actionable line-anchor
|
|
|
328
329
|
const raw = await runLlm(judge.adapter, judge.model, prompt, worktree, via, JUDGE_TIMEOUT_MS);
|
|
329
330
|
const extracted = extractVerdictJson(raw, nonce);
|
|
330
331
|
if (!extracted) {
|
|
332
|
+
const cause = classifyVerdictCause(raw, nonce, "pass");
|
|
331
333
|
// GATE-09: structured meta names the flaked judge channel (mirrors review.ts:99 meta precedent) so
|
|
332
334
|
// run-gates can retry the judge on a failover channel without string-matching details (D-03).
|
|
333
335
|
// The parsed-verdict paths below are untouched — the flake signal is exactly extractJson→null here.
|
|
336
|
+
const failure = cause === "malformed-verdict"
|
|
337
|
+
? "judge output unparseable — failing closed"
|
|
338
|
+
: "judge dispatch failed — no structurally valid nonce-bound response; output unparseable — failing closed";
|
|
334
339
|
return { gate: "acceptance", pass: false,
|
|
335
|
-
details: warn + detBlock +
|
|
336
|
-
meta: { unparseable: true, judge: channelKey({ adapter: judge.adapter.id, model: judge.model }) } };
|
|
340
|
+
details: warn + detBlock + failure,
|
|
341
|
+
meta: { unparseable: true, cause, judge: channelKey({ adapter: judge.adapter.id, model: judge.model }) } };
|
|
337
342
|
}
|
|
338
343
|
const { verdict: v, inconsistencies } = checkJudgeVerdict(extracted, expectedIds);
|
|
339
344
|
// v1.70 / OBS-129: each citation must fall inside a changed hunk of `diff` (the exact string embedded in
|
package/dist/gates/llm.d.ts
CHANGED
|
@@ -15,7 +15,6 @@ export declare function appendAnchoredReview(prose: string, verdict: unknown): s
|
|
|
15
15
|
export declare function verdictNonceLine(nonce: string): string;
|
|
16
16
|
export declare function extractPromptNonce(prompt: string): string | null;
|
|
17
17
|
export declare function gateExitTrailer(nonce: string): string;
|
|
18
|
-
export declare function augmentFakeVerdictOutput(adapter: WorkerAdapter, out: string, nonce: string, prompt?: string): string;
|
|
19
18
|
export type GatePaneRole = "judge" | "review" | "consult";
|
|
20
19
|
/** T8: role-first pane name for fleet visibility — judge · T4, review · T3, consult · T2. */
|
|
21
20
|
export declare function gatePaneName(role: GatePaneRole, taskId: string, suffix?: string): string;
|