tickmarkr 1.85.0 → 1.86.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.
Files changed (71) hide show
  1. package/README.md +4 -2
  2. package/dist/adapters/catalog-remote.d.ts +64 -0
  3. package/dist/adapters/catalog-remote.js +287 -0
  4. package/dist/adapters/catalog.d.ts +96 -0
  5. package/dist/adapters/catalog.js +176 -0
  6. package/dist/adapters/claude-code.js +5 -3
  7. package/dist/adapters/fake.js +33 -4
  8. package/dist/adapters/model-lints.d.ts +25 -5
  9. package/dist/adapters/model-lints.js +184 -50
  10. package/dist/adapters/model-windows.d.ts +31 -0
  11. package/dist/adapters/model-windows.js +69 -0
  12. package/dist/adapters/prompt.d.ts +5 -1
  13. package/dist/adapters/prompt.js +13 -4
  14. package/dist/adapters/registry.d.ts +25 -26
  15. package/dist/adapters/registry.js +173 -110
  16. package/dist/adapters/types.js +15 -3
  17. package/dist/brand.d.ts +5 -1
  18. package/dist/brand.js +18 -2
  19. package/dist/cli/commands/doctor.d.ts +3 -0
  20. package/dist/cli/commands/doctor.js +43 -21
  21. package/dist/cli/commands/fleet.d.ts +7 -0
  22. package/dist/cli/commands/fleet.js +94 -74
  23. package/dist/cli/commands/init.js +118 -5
  24. package/dist/cli/commands/status.js +44 -18
  25. package/dist/compile/gsd.d.ts +2 -1
  26. package/dist/compile/gsd.js +68 -2
  27. package/dist/compile/native.d.ts +14 -0
  28. package/dist/compile/native.js +161 -12
  29. package/dist/config/config.d.ts +20 -5
  30. package/dist/config/config.js +96 -64
  31. package/dist/config/fleet-overlay.d.ts +25 -20
  32. package/dist/config/fleet-overlay.js +195 -77
  33. package/dist/config/fleet-why.d.ts +23 -0
  34. package/dist/config/fleet-why.js +42 -0
  35. package/dist/drivers/herdr.d.ts +1 -0
  36. package/dist/drivers/herdr.js +70 -19
  37. package/dist/gates/acceptance.js +7 -2
  38. package/dist/gates/llm.d.ts +0 -1
  39. package/dist/gates/llm.js +5 -30
  40. package/dist/gates/review.d.ts +2 -1
  41. package/dist/gates/review.js +9 -7
  42. package/dist/gates/verdict-cause.d.ts +4 -0
  43. package/dist/gates/verdict-cause.js +63 -0
  44. package/dist/graph/schema.d.ts +6 -0
  45. package/dist/graph/schema.js +8 -5
  46. package/dist/route/router.d.ts +0 -5
  47. package/dist/route/router.js +16 -20
  48. package/dist/run/consult.d.ts +6 -0
  49. package/dist/run/consult.js +35 -25
  50. package/dist/run/daemon.js +71 -15
  51. package/dist/run/journal.js +87 -7
  52. package/dist/tui/cockpit/capture.d.ts +12 -0
  53. package/dist/tui/cockpit/capture.js +37 -1
  54. package/dist/tui/cockpit/components.js +8 -8
  55. package/dist/tui/cockpit/theme.d.ts +32 -26
  56. package/dist/tui/cockpit/theme.js +11 -5
  57. package/dist/tui/ink/components.d.ts +0 -15
  58. package/dist/tui/ink/components.js +0 -17
  59. package/dist/tui/ink/fleet-app.d.ts +4 -1
  60. package/dist/tui/ink/fleet-app.js +134 -13
  61. package/fixtures/sample.native.md +1 -1
  62. package/package.json +1 -1
  63. package/skills/tickmarkr-overseer/SKILL.md +354 -34
  64. package/skills/tickmarkr-overseer/scripts/watch-artifacts.sh +70 -0
  65. package/skills/tickmarkr-overseer/scripts/watch-panes.sh +1 -1
  66. package/dist/tui/ink/studio-app.d.ts +0 -59
  67. package/dist/tui/ink/studio-app.js +0 -320
  68. package/dist/tui/save.d.ts +0 -38
  69. package/dist/tui/save.js +0 -96
  70. package/dist/tui/staging.d.ts +0 -29
  71. package/dist/tui/staging.js +0 -78
@@ -1,6 +1,4 @@
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.
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
- /** 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;
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
- for (const [key, dest] of [["adapters", out.denyAdapters], ["models", out.denyModels]]) {
48
- const seq = doc.getIn(["routing", "deny", key]);
49
- if (!isSeq(seq))
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
- for (const item of seq.items) {
52
- const n = note(item);
53
- if (isScalar(item) && n)
54
- dest[String(item.value)] = n;
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
- return out;
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
- mapDelta[shape] = edited.map[shape];
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
- // 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 } };
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, provenance = {}, denyNotes = {}) {
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, provenance, denyNotes);
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, provenance = {}, denyNotes = {}) {
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
- 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}: {}`);
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
+ }
@@ -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;
@@ -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
- const allocate = opts?.group
254
- ? () => this.serial(() => this.groupSlot(cwd, resolved, opts.group))
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
- if (latest && latest.members.length < this.workersPerTab) {
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
- const label = token ? `${entry.label} · ${token}${glyph}` : entry.label;
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
- // adapter's declaration decides it wherever there is one: `launchCommand` names the bootstrap,
525
- // everything else is a turn. With NO declaration the only evidence is the slot's own history — a
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
- // An adapter-declared bootstrap is the one command that can restore this slot's interface on a
538
- // fresh pane, so remember it for the recovery above.
539
- if (this.inputBoxes.get(slot)?.launchCommand?.(cmd) === true)
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
  }
@@ -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 + "judge output unparseable — failing closed",
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
@@ -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;