tickmarkr 2.5.7 → 2.5.9
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/dist/adapters/qwen.js +30 -3
- package/dist/cli/commands/approve.js +23 -4
- package/dist/cli/commands/beat.d.ts +2 -0
- package/dist/cli/commands/beat.js +28 -29
- package/dist/cli/commands/compile.js +18 -0
- package/dist/cli/commands/fleet.js +61 -53
- package/dist/cli/commands/plan.d.ts +5 -0
- package/dist/cli/commands/plan.js +28 -23
- package/dist/cli/commands/resume.js +1 -1
- package/dist/cli/commands/run.js +1 -1
- package/dist/cli/commands/verify.d.ts +4 -1
- package/dist/cli/commands/verify.js +16 -5
- package/dist/cli/help.d.ts +2 -0
- package/dist/cli/help.js +6 -4
- package/dist/compile/native.js +68 -7
- package/dist/compile/retired-literals.d.ts +22 -0
- package/dist/compile/retired-literals.js +271 -0
- package/dist/config/config.d.ts +41 -3
- package/dist/config/config.js +48 -17
- package/dist/config/fleet-overlay.js +47 -62
- package/dist/drivers/index.d.ts +4 -2
- package/dist/drivers/index.js +54 -6
- package/dist/gates/baseline.d.ts +65 -0
- package/dist/gates/baseline.js +163 -9
- package/dist/gates/review.d.ts +6 -4
- package/dist/gates/review.js +62 -21
- package/dist/gates/run-gates.d.ts +6 -1
- package/dist/gates/run-gates.js +25 -13
- package/dist/gates/test-manifest.d.ts +20 -1
- package/dist/gates/test-manifest.js +50 -22
- package/dist/gates/test-reporter.js +22 -1
- package/dist/graph/schema.d.ts +28 -0
- package/dist/graph/schema.js +13 -1
- package/dist/run/daemon.d.ts +19 -0
- package/dist/run/daemon.js +363 -118
- package/dist/run/journal.d.ts +54 -3
- package/dist/run/journal.js +142 -11
- package/dist/run/merge.d.ts +15 -2
- package/dist/run/merge.js +74 -11
- package/dist/run/protocol.d.ts +82 -0
- package/dist/run/protocol.js +35 -0
- package/dist/run/receipt-resolver.d.ts +18 -0
- package/dist/run/receipt-resolver.js +132 -0
- package/dist/run/repair-disposition.d.ts +41 -0
- package/dist/run/repair-disposition.js +77 -0
- package/dist/run/supervision.d.ts +14 -1
- package/dist/run/supervision.js +122 -24
- package/dist/tui/cockpit/evidence-view.d.ts +10 -1
- package/dist/tui/cockpit/evidence-view.js +37 -5
- package/dist/tui/cockpit/home-view.js +45 -30
- package/dist/tui/cockpit/live-store.d.ts +18 -0
- package/dist/tui/ink/fleet-app.d.ts +12 -22
- package/dist/tui/ink/fleet-app.js +520 -131
- package/package.json +1 -1
- package/schema/rungraph.schema.json +54 -0
- package/skills/tickmarkr-overseer/SKILL.md +168 -36
- package/skills/tickmarkr-overseer/scripts/watch-context.sh +14 -2
|
@@ -3,6 +3,7 @@ import { Box, render, Text, useApp, useInput } from "ink";
|
|
|
3
3
|
import { useRef, useState } from "react";
|
|
4
4
|
import { MODEL_ID_RE } from "../../adapters/types.js";
|
|
5
5
|
import { retiredModelReason } from "../../adapters/model-lints.js";
|
|
6
|
+
import { DENY_SCOPES, denyBlockFrom, denyEntriesAt, initialDenyPropOf, stagedDenyKeyOf } from "../../config/config.js";
|
|
6
7
|
import { fleetFirstTouchProvenance } from "../../config/fleet-overlay.js";
|
|
7
8
|
import { exclusionReason } from "../../config/fleet-why.js";
|
|
8
9
|
import { entryMatchesChannel, exclusionCollector } from "../../route/preference.js";
|
|
@@ -10,6 +11,11 @@ import { TIERS } from "../../graph/schema.js";
|
|
|
10
11
|
import { windowRows } from "./components.js";
|
|
11
12
|
import { clip, clipPathTail, ElisionMark, enterAltScreen, fmtCtx, fmtMs, fmtUsdPair, Glyph, INK, inkInput, inkOutput, KeyBar, OverlayPanel, padCell, padCellStart, Pointer, SearchRow, } from "./frame.js";
|
|
12
13
|
const STEERING_KEYS = ["review", "consult"];
|
|
14
|
+
/** every scope's staged list, empty — the "every policy scope open" probe the picker lifts against */
|
|
15
|
+
export const openStagedDeny = () => ({
|
|
16
|
+
...Object.fromEntries(DENY_SCOPES.map((scope) => [stagedDenyKeyOf(scope), []])),
|
|
17
|
+
allowOut: [],
|
|
18
|
+
});
|
|
13
19
|
const CHANNELS = ["sub", "api"];
|
|
14
20
|
export function formatDoctorAge(ageMs) {
|
|
15
21
|
if (ageMs === null)
|
|
@@ -25,9 +31,17 @@ function splitSeat(seat) {
|
|
|
25
31
|
const at = seat.indexOf(":");
|
|
26
32
|
return { adapter: seat.slice(0, at), model: seat.slice(at + 1) };
|
|
27
33
|
}
|
|
28
|
-
|
|
34
|
+
const REACH_CHOICES = [
|
|
35
|
+
{ id: "in", label: "in", gloss: "every seat may route here" },
|
|
36
|
+
{ id: "out-workers", label: "out · workers", gloss: "workers skip it; judge/review/consult unaffected" },
|
|
37
|
+
{ id: "out-all", label: "out · all seats", gloss: "no seat routes here" },
|
|
38
|
+
];
|
|
39
|
+
export function FleetApp({ ageMs, agents, initialAllowOut = [], modelGroups, initialMode, modeOptions, initialMap, modePreview, shapeRows, candidatesForShape, preferOptionsForShape, initialSteering, steeringOptionsFor, reviewOverlay, reloadGuard, stagedRouting, entry = "probe", initialJudge = "", judgeSeats = [], viewRows = Number.POSITIVE_INFINITY, frameColumns = 100, frameRows, ...initialDenyProps }) {
|
|
29
40
|
const { exit } = useApp();
|
|
30
41
|
const [, setRevision] = useState(0);
|
|
42
|
+
// the props are the fleet command's contract, one per schema-enumerated scope (initialDeny…),
|
|
43
|
+
// read by a loop so a scope the schema gains seeds its set without a hand field (OBS-1099 add.1)
|
|
44
|
+
const initialDeny = Object.fromEntries(DENY_SCOPES.map((scope) => [scope.key, initialDenyProps[initialDenyPropOf(scope)] ?? []]));
|
|
31
45
|
const uiRef = useRef(null);
|
|
32
46
|
uiRef.current ??= {
|
|
33
47
|
focus: "list",
|
|
@@ -43,11 +57,8 @@ export function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels
|
|
|
43
57
|
presetsSeen: false,
|
|
44
58
|
notice: "",
|
|
45
59
|
lastEdit: null,
|
|
46
|
-
deny: new Set(initialDenyAdapters),
|
|
47
|
-
denyModels: new Set(initialDenyModels),
|
|
48
60
|
allowOut: new Set(initialAllowOut),
|
|
49
|
-
|
|
50
|
-
denyWorkersModels: new Set(initialDenyWorkersModels),
|
|
61
|
+
...Object.fromEntries(DENY_SCOPES.map((scope) => [scope.key, new Set(initialDeny[scope.key])])),
|
|
51
62
|
classifications: [],
|
|
52
63
|
selectedMode: initialMode,
|
|
53
64
|
map: structuredClone(initialMap),
|
|
@@ -63,42 +74,23 @@ export function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels
|
|
|
63
74
|
const bump = () => setRevision((revision) => revision + 1);
|
|
64
75
|
// OBS-521/OBS-522: one truth for "is there staged work" — the Esc/q loss guard, the header
|
|
65
76
|
// chip, and the empty-`w` notice all read it. Counts staged EDITS, not diff hunks.
|
|
66
|
-
const initialDenySet = new Set(initialDenyAdapters);
|
|
67
|
-
const initialDenyModelSet = new Set(initialDenyModels);
|
|
68
77
|
const initialAllowOutSet = new Set(initialAllowOut);
|
|
69
|
-
const initialDenyWorkersAdapterSet = new Set(initialDenyWorkersAdapters);
|
|
70
|
-
const initialDenyWorkersModelSet = new Set(initialDenyWorkersModels);
|
|
71
78
|
const stagedCount = () => {
|
|
72
79
|
let n = ui.classifications.length
|
|
73
80
|
+ (ui.selectedMode !== initialMode ? 1 : 0)
|
|
74
81
|
+ (ui.judgeSeat !== null ? 1 : 0);
|
|
75
|
-
for (const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
for (const model of initialDenyModelSet)
|
|
85
|
-
if (!ui.denyModels.has(model))
|
|
86
|
-
n += 1;
|
|
82
|
+
for (const scope of DENY_SCOPES) {
|
|
83
|
+
const before = new Set(initialDeny[scope.key]);
|
|
84
|
+
for (const entry of ui[scope.key])
|
|
85
|
+
if (!before.has(entry))
|
|
86
|
+
n += 1;
|
|
87
|
+
for (const entry of before)
|
|
88
|
+
if (!ui[scope.key].has(entry))
|
|
89
|
+
n += 1;
|
|
90
|
+
}
|
|
87
91
|
for (const key of initialAllowOutSet)
|
|
88
92
|
if (!ui.allowOut.has(key))
|
|
89
93
|
n += 1;
|
|
90
|
-
for (const adapter of ui.denyWorkersAdapters)
|
|
91
|
-
if (!initialDenyWorkersAdapterSet.has(adapter))
|
|
92
|
-
n += 1;
|
|
93
|
-
for (const adapter of initialDenyWorkersAdapterSet)
|
|
94
|
-
if (!ui.denyWorkersAdapters.has(adapter))
|
|
95
|
-
n += 1;
|
|
96
|
-
for (const model of ui.denyWorkersModels)
|
|
97
|
-
if (!initialDenyWorkersModelSet.has(model))
|
|
98
|
-
n += 1;
|
|
99
|
-
for (const model of initialDenyWorkersModelSet)
|
|
100
|
-
if (!ui.denyWorkersModels.has(model))
|
|
101
|
-
n += 1;
|
|
102
94
|
for (const shape of new Set([...Object.keys(initialMap), ...Object.keys(ui.map)])) {
|
|
103
95
|
// {} ≡ absent: pin-then-auto leaves an empty entry that writes nothing — not staged work
|
|
104
96
|
if (JSON.stringify(initialMap[shape] ?? {}) !== JSON.stringify(ui.map[shape] ?? {}))
|
|
@@ -133,18 +125,28 @@ export function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels
|
|
|
133
125
|
key,
|
|
134
126
|
policy: stagedRouting
|
|
135
127
|
? stagedRouting(deny)
|
|
136
|
-
: { ok: true, routing: { deny:
|
|
128
|
+
: { ok: true, routing: { deny: denyBlockFrom(denyLists()) } },
|
|
137
129
|
};
|
|
138
130
|
}
|
|
139
131
|
return routingMemo.policy;
|
|
140
132
|
};
|
|
133
|
+
// OBS-1099 add.1: the browser's deny discovery ranges over the schema-enumerated scopes (the
|
|
134
|
+
// route collector names its four paths by hand, so a scope the schema gains would never render
|
|
135
|
+
// or lift here). A flat scope reaches every seat; a nested one only the seat its block names
|
|
136
|
+
// (workers → worker). The allow exclusion (by absence, never lifted) stays the collector's.
|
|
137
|
+
const scopeReaches = (scope, role) => scope.path.length === 3 || scope.path[2] === `${role}s`;
|
|
138
|
+
const exclusionsOf = (channel, routing, role) => [
|
|
139
|
+
...DENY_SCOPES.filter((scope) => scopeReaches(scope, role)).flatMap((scope) => (denyEntriesAt(routing, scope) ?? []).filter((entry) => entryMatchesChannel(entry, channel, true))
|
|
140
|
+
.map((entry) => ({ scope: scope.dotted, path: scope.dotted, configPath: scope.dotted, entry, by: "deny" }))),
|
|
141
|
+
...exclusionCollector(channel, routing, role).filter((found) => found.by === "allow"),
|
|
142
|
+
];
|
|
141
143
|
const reachFor = (adapter, model, identity) => {
|
|
142
144
|
const policy = stagedPolicy();
|
|
143
145
|
if (!policy.ok)
|
|
144
146
|
return { reach: "unknown", reasons: [`preview unavailable (${policy.error})`], uncoveredAlias: false };
|
|
145
147
|
const channel = { adapter, model, ...(identity !== undefined ? { identity } : {}) };
|
|
146
|
-
const worker =
|
|
147
|
-
const allSeats =
|
|
148
|
+
const worker = exclusionsOf(channel, policy.routing, "worker");
|
|
149
|
+
const allSeats = exclusionsOf(channel, policy.routing, "judge");
|
|
148
150
|
const reach = allSeats.some((scope) => scope.by === "deny") ? "out-all"
|
|
149
151
|
: allSeats.length ? "out-allow"
|
|
150
152
|
: worker.length ? "out-workers"
|
|
@@ -155,6 +157,109 @@ export function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels
|
|
|
155
157
|
uncoveredAlias: identity !== undefined && !worker.some((scope) => scope.by === "deny"),
|
|
156
158
|
};
|
|
157
159
|
};
|
|
160
|
+
// OBS-1099 review: a displayed row may fold several discovered gateway ids (foldedModels);
|
|
161
|
+
// each folded id is a real channel the policy matches on its own, so every one but a row's
|
|
162
|
+
// own id counts as a sibling — an entry covering a folded id is shared, never row-owned
|
|
163
|
+
const displayedChannels = () => modelGroups.flatMap((group) => group.rows
|
|
164
|
+
.flatMap((other) => (other.foldedModels?.length ? other.foldedModels : [other.model])
|
|
165
|
+
.map((model) => ({ adapter: group.adapter, model, identity: other.evidence?.identity }))));
|
|
166
|
+
const siblingsOf = (self) => displayedChannels()
|
|
167
|
+
.filter((other) => other.adapter !== self.adapter || other.model !== self.model);
|
|
168
|
+
// Judge c3 (R107): exactly ONE selected entry per act — the channel's adapter:model key when it
|
|
169
|
+
// is a candidate, else the first candidate in sorted order; only an entry no sibling matches
|
|
170
|
+
const selectOwn = (entries, self) => {
|
|
171
|
+
const others = siblingsOf(self);
|
|
172
|
+
const candidates = [...entries].filter((entry) => entryMatchesChannel(entry, self, true)
|
|
173
|
+
&& !others.some((other) => entryMatchesChannel(entry, other, true)));
|
|
174
|
+
const id = `${self.adapter}:${self.model}`;
|
|
175
|
+
return candidates.includes(id) ? id : candidates.sort()[0];
|
|
176
|
+
};
|
|
177
|
+
// OBS-1099 add.1: the scope order every row edit ranks by — shallow scopes first, the models
|
|
178
|
+
// list before its adapters sibling (the row-edit order every prior round used) — derived from
|
|
179
|
+
// the schema enumeration, never listed by hand
|
|
180
|
+
const SCOPED = [...DENY_SCOPES].sort((a, b) => a.path.length - b.path.length
|
|
181
|
+
|| Number(b.path[b.path.length - 1] === "models") - Number(a.path[a.path.length - 1] === "models"));
|
|
182
|
+
const scopeOf = (dotted) => DENY_SCOPES.find((scope) => scope.dotted === dotted);
|
|
183
|
+
const denySets = () => SCOPED.map((scope) => [ui[scope.key], scope.dotted]);
|
|
184
|
+
// D-233: adapter-wideness is a property of the ENTRY's grammar (a bare adapter id), never of
|
|
185
|
+
// the config PATH — both .adapters lists accept adapter, model and adapter:model entries alike
|
|
186
|
+
const isAdapterWide = (entry, self) => !entry.includes(":") && entry === self.adapter;
|
|
187
|
+
/** the one row-owned deny entry a picker lift may take — never an allow exclusion, and never an
|
|
188
|
+
* adapter-wide entry (review round 2: an adapter with one displayed channel would otherwise
|
|
189
|
+
* count as owning its whole adapter entry) */
|
|
190
|
+
const ownDeny = (self) => {
|
|
191
|
+
for (const [set, scope] of denySets()) {
|
|
192
|
+
const entry = selectOwn(new Set([...set].filter((e) => !isAdapterWide(e, self))), self);
|
|
193
|
+
if (entry !== undefined)
|
|
194
|
+
return { entry, scope };
|
|
195
|
+
}
|
|
196
|
+
return undefined;
|
|
197
|
+
};
|
|
198
|
+
const liftEntry = ({ entry, scope }) => {
|
|
199
|
+
const found = scopeOf(scope);
|
|
200
|
+
if (found)
|
|
201
|
+
ui[found.key] = new Set([...ui[found.key]].filter((staged) => staged !== entry));
|
|
202
|
+
};
|
|
203
|
+
// Review round 10: the ONE ordered selector both the detail line and the reach edit read — every
|
|
204
|
+
// deny entry this row owns alone (never adapter-wide, never one a displayed sibling matches),
|
|
205
|
+
// the row's own key first (judge c3), then scope order models → adapters → workers.models →
|
|
206
|
+
// workers.adapters, alphabetical within a scope (the row-edit order every prior round used).
|
|
207
|
+
const SCOPE_ORDER = SCOPED.map((scope) => scope.dotted);
|
|
208
|
+
// Read over the RAW staged sets, never the validated policy: the writer restates a flat deny
|
|
209
|
+
// in the allow form, and a row must still find the entry it authored a press ago.
|
|
210
|
+
const ownedDenies = (self) => {
|
|
211
|
+
const own = `${self.adapter}:${self.model}`;
|
|
212
|
+
const others = siblingsOf(self);
|
|
213
|
+
const routing = { deny: denyBlockFrom(denyLists()) };
|
|
214
|
+
return exclusionsOf(self, routing, "worker")
|
|
215
|
+
.filter((found) => found.by === "deny" && !isAdapterWide(found.entry, self)
|
|
216
|
+
&& !others.some((other) => entryMatchesChannel(found.entry, other, true)))
|
|
217
|
+
.map((found) => ({ entry: found.entry, scope: found.configPath }))
|
|
218
|
+
.sort((a, b) => Number(b.entry === own) - Number(a.entry === own)
|
|
219
|
+
|| SCOPE_ORDER.indexOf(a.scope) - SCOPE_ORDER.indexOf(b.scope) || a.entry.localeCompare(b.entry));
|
|
220
|
+
};
|
|
221
|
+
// a nested scope (routing.deny.workers.*) denies workers only; a flat one denies every seat
|
|
222
|
+
const WORKERS_SCOPES = SCOPED.filter((scope) => scope.path.length > 3).map((scope) => scope.dotted);
|
|
223
|
+
const FLAT_SCOPES = SCOPED.filter((scope) => scope.path.length === 3).map((scope) => scope.dotted);
|
|
224
|
+
// the adapter rail edits only the `adapters` leaves — its own adapter-id entries (D-233 grammar)
|
|
225
|
+
const FLAT_ADAPTER_SCOPES = SCOPED.filter((scope) => scope.path.length === 3 && scope.path[scope.path.length - 1] === "adapters");
|
|
226
|
+
const NESTED_ADAPTER_SCOPES = SCOPED.filter((scope) => scope.path.length > 3 && scope.path[scope.path.length - 1] === "adapters");
|
|
227
|
+
// review round 12: per reach choice, the entry the Space handler edits — in clears the head,
|
|
228
|
+
// out · workers demotes the first flat entry, out · all promotes the first workers entry
|
|
229
|
+
const reachEdits = (owned) => ({
|
|
230
|
+
clear: owned[0],
|
|
231
|
+
demote: owned.find((f) => FLAT_SCOPES.includes(f.scope)),
|
|
232
|
+
promote: owned.find((f) => WORKERS_SCOPES.includes(f.scope)),
|
|
233
|
+
});
|
|
234
|
+
// OBS-1099 item 2: the entry that covers a row OTHER than its own key (an explicit id reached
|
|
235
|
+
// through the doctor-recorded identity, a bare model, an adapter-wide entry) — named, with every
|
|
236
|
+
// displayed channel it covers. ponytail: O(rows²) over a fleet of dozens; index if it grows.
|
|
237
|
+
const coveringFor = (self) => {
|
|
238
|
+
const policy = stagedPolicy();
|
|
239
|
+
if (!policy.ok)
|
|
240
|
+
return undefined;
|
|
241
|
+
// review round 3: the row's own key is dropped only when it covers no displayed sibling —
|
|
242
|
+
// an explicit id whose alias folds into this row is one shared entry, so it stays here
|
|
243
|
+
// (Space already refuses it as shared) and the l control owns it
|
|
244
|
+
const own = `${self.adapter}:${self.model}`;
|
|
245
|
+
const ownShared = siblingsOf(self).some((other) => entryMatchesChannel(own, other, true));
|
|
246
|
+
// D-245 (review round 11): the named entry is the FIRST ownedDenies record other than the own
|
|
247
|
+
// key (or the own key itself when shared) — the very record Space edits, scope included, never
|
|
248
|
+
// re-ranked through the collector; only a row that owns nothing falls back to the collector's
|
|
249
|
+
// shared entries, adapter-wide last
|
|
250
|
+
const owned = ownedDenies(self);
|
|
251
|
+
const ownedPick = owned.find((f) => f.entry !== own || ownShared);
|
|
252
|
+
const scope = ownedPick ? { entry: ownedPick.entry, configPath: ownedPick.scope }
|
|
253
|
+
: exclusionsOf(self, policy.routing, "worker")
|
|
254
|
+
.filter((found) => found.by === "deny" && (found.entry !== own || ownShared))
|
|
255
|
+
.sort((a, b) => Number(isAdapterWide(a.entry, self)) - Number(isAdapterWide(b.entry, self)))[0];
|
|
256
|
+
if (!scope)
|
|
257
|
+
return undefined;
|
|
258
|
+
const covers = displayedChannels().filter((channel) => entryMatchesChannel(scope.entry, channel, true))
|
|
259
|
+
.map((channel) => `${channel.adapter}:${channel.model}${channel.identity ? ` (${channel.identity})` : ""}`);
|
|
260
|
+
const shared = isAdapterWide(scope.entry, self) || covers.length > 1;
|
|
261
|
+
return { entry: scope.entry, scope: scope.configPath, covers, shared, edits: reachEdits(owned) };
|
|
262
|
+
};
|
|
158
263
|
const groupRows = (group) => {
|
|
159
264
|
const rows = group.rows.map((row) => {
|
|
160
265
|
const staged = ui.classifications.find((classification) => classification.adapter === group.adapter
|
|
@@ -174,6 +279,10 @@ export function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels
|
|
|
174
279
|
score: row.score,
|
|
175
280
|
denied: reach.reach !== "in",
|
|
176
281
|
...reach,
|
|
282
|
+
// OBS-1099 item 2 (review round 2): every row is covered the same way — an alias through
|
|
283
|
+
// its identity, a plain row by a bare-model or adapter entry — so a shared bare-model
|
|
284
|
+
// entry across adapters has the one owning control too
|
|
285
|
+
covering: coveringFor({ adapter: group.adapter, model: row.model, identity: row.evidence?.identity }),
|
|
177
286
|
};
|
|
178
287
|
});
|
|
179
288
|
const known = new Set(rows.flatMap((row) => row.classifyModel ? [row.model, row.classifyModel] : [row.model]));
|
|
@@ -218,11 +327,9 @@ export function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels
|
|
|
218
327
|
};
|
|
219
328
|
// three preview callbacks share it in lockstep — the staged deny truth every preview ranks under.
|
|
220
329
|
// LEG2-T3 finding 2: all four scopes travel distinct; the preview loads them as written.
|
|
330
|
+
const denyLists = () => Object.fromEntries(DENY_SCOPES.map((scope) => [scope.key, [...ui[scope.key]].sort()]));
|
|
221
331
|
const stagedDeny = () => ({
|
|
222
|
-
|
|
223
|
-
models: [...ui.denyModels].sort(),
|
|
224
|
-
workersAdapters: [...ui.denyWorkersAdapters].sort(),
|
|
225
|
-
workersModels: [...ui.denyWorkersModels].sort(),
|
|
332
|
+
...Object.fromEntries(DENY_SCOPES.map((scope) => [stagedDenyKeyOf(scope), [...ui[scope.key]].sort()])),
|
|
226
333
|
allowOut: [...ui.allowOut].sort(),
|
|
227
334
|
});
|
|
228
335
|
const shapeList = () => shapeRows(ui.selectedMode, ui.map, stagedDeny());
|
|
@@ -249,11 +356,8 @@ export function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels
|
|
|
249
356
|
];
|
|
250
357
|
// ── state transitions ──────────────────────────────────────────────────────
|
|
251
358
|
const editorState = () => ({
|
|
252
|
-
|
|
253
|
-
denyModels: [...ui.denyModels].sort(),
|
|
359
|
+
...denyLists(),
|
|
254
360
|
allowOut: [...ui.allowOut].sort(),
|
|
255
|
-
denyWorkersAdapters: [...ui.denyWorkersAdapters].sort(),
|
|
256
|
-
denyWorkersModels: [...ui.denyWorkersModels].sort(),
|
|
257
361
|
classifications: ui.classifications.map((classification) => classification.vendor && classification.channel
|
|
258
362
|
? {
|
|
259
363
|
...classification,
|
|
@@ -382,6 +486,266 @@ export function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels
|
|
|
382
486
|
const capacity = Number.isFinite(viewRows) ? Math.max(4, viewRows) : Number.POSITIVE_INFINITY;
|
|
383
487
|
/** review diff rows: overlay chrome is one row shorter than the browser's (no scope header) */
|
|
384
488
|
const reviewCapacity = () => Number.isFinite(capacity) ? Math.min(Math.max(capacity + 1, 4), 400) : 400;
|
|
489
|
+
// OBS-1099 item 4 (D-199, D-205): an adapter row's reach and remaining reasons come from the
|
|
490
|
+
// exclusion collector over the STAGED policy for EVERY channel it serves — displayed rows and the
|
|
491
|
+
// gateway ids folded into them — never literal set membership, which misses model-scope entries
|
|
492
|
+
// that cover the whole adapter (a bare adapter id in routing.deny.models: preference.ts).
|
|
493
|
+
// D-205: reach is the MOST-excluded channel's reach — in only when every channel is in, out-all
|
|
494
|
+
// when any channel is out for all seats, else out-workers — and `partial` says the channels
|
|
495
|
+
// disagree, so a mixed rail is never shown or guarded as an adapter-wide selected state.
|
|
496
|
+
// Reasons are the UNION over the channels: one every channel shares is named once, a
|
|
497
|
+
// channel-specific one carries its channel, so a partial exclusion is never intersected away.
|
|
498
|
+
const adapterReach = (id) => {
|
|
499
|
+
const group = modelGroups.find((candidate) => candidate.adapter === id);
|
|
500
|
+
const channels = (group?.rows ?? []).flatMap((row) => (row.foldedModels?.length ? row.foldedModels : [row.model])
|
|
501
|
+
.map((model) => ({ model, ...reachFor(id, model, row.evidence?.identity) })));
|
|
502
|
+
if (channels.length === 0) {
|
|
503
|
+
// ponytail: a rail entry with no probed channels has only its own literal entries to show
|
|
504
|
+
const reasons = [
|
|
505
|
+
...FLAT_ADAPTER_SCOPES.filter((scope) => ui[scope.key].has(id)).map((scope) => `${scope.dotted} (${id})`),
|
|
506
|
+
ui.allowOut.has(id) ? "routing.allow (not admitted)" : "",
|
|
507
|
+
...NESTED_ADAPTER_SCOPES.filter((scope) => ui[scope.key].has(id)).map((scope) => `${scope.dotted} (${id})`),
|
|
508
|
+
].filter(Boolean);
|
|
509
|
+
const reach = FLAT_ADAPTER_SCOPES.some((scope) => ui[scope.key].has(id)) ? "out-all"
|
|
510
|
+
: ui.allowOut.has(id) ? "out-allow"
|
|
511
|
+
: NESTED_ADAPTER_SCOPES.some((scope) => ui[scope.key].has(id)) ? "out-workers" : "in";
|
|
512
|
+
return { reach, reasons, partial: false };
|
|
513
|
+
}
|
|
514
|
+
const unknown = channels.find((channel) => channel.reach === "unknown");
|
|
515
|
+
if (unknown)
|
|
516
|
+
return { reach: "unknown", reasons: unknown.reasons, partial: false };
|
|
517
|
+
// OBS-1099 review round 5: out-allow and out-all are ONE picker choice (out · all seats), so a
|
|
518
|
+
// deny-excluded channel beside an allow-excluded one is uniformly out-all, never partial
|
|
519
|
+
const picked = (reach) => reach === "out-allow" ? "out-all" : reach;
|
|
520
|
+
const rank = { unknown: -1, in: 0, "out-workers": 1, "out-allow": 2, "out-all": 2 };
|
|
521
|
+
const reach = channels.reduce((most, channel) => rank[channel.reach] > rank[most] ? picked(channel.reach) : most, picked(channels[0].reach));
|
|
522
|
+
const partial = channels.some((channel) => picked(channel.reach) !== reach);
|
|
523
|
+
const shared = channels[0].reasons.filter((reason) => channels.every((channel) => channel.reasons.includes(reason)));
|
|
524
|
+
const own = channels.flatMap((channel) => channel.reasons
|
|
525
|
+
.filter((reason) => !shared.includes(reason))
|
|
526
|
+
.map((reason) => `${channel.model}: ${reason}`));
|
|
527
|
+
return { reach, reasons: [...shared, ...own], partial };
|
|
528
|
+
};
|
|
529
|
+
const describeReach = (state) => state.partial ? `${state.reach} (partial: not every channel)` : state.reach;
|
|
530
|
+
// OBS-1099 items 1 and 4: one picker choice changes exactly ONE selected entry in ONE scope —
|
|
531
|
+
// added, cleared, or re-scoped between the workers list and the flat list — then the row's
|
|
532
|
+
// ACTUAL reach is recomputed and the remaining reasons named. The one-entry-per-press law
|
|
533
|
+
// (R107, LEG2-T3) stays: a shared or adapter-wide entry covering sibling channels is never
|
|
534
|
+
// edited from a channel row; the refusal names it. A row holding two reasons is never promised
|
|
535
|
+
// in after one act.
|
|
536
|
+
const applyReach = (target, choice) => {
|
|
537
|
+
const without = (set, entry) => new Set([...set].filter((staged) => staged !== entry));
|
|
538
|
+
const withEntry = (set, entry) => new Set([...set, entry]);
|
|
539
|
+
const label = REACH_CHOICES.find((option) => option.id === choice).label;
|
|
540
|
+
// the allow form is how the writer spells an all-seats exclusion — it IS out · all seats
|
|
541
|
+
const reached = (reach, wanted) => reach === wanted || (wanted === "out-all" && reach === "out-allow");
|
|
542
|
+
if (target.model === undefined) {
|
|
543
|
+
const id = target.adapter;
|
|
544
|
+
const { reach, reasons, partial } = adapterReach(id);
|
|
545
|
+
// the rail edits exactly ONE adapter-id entry: the authored deny before the allow complement
|
|
546
|
+
// before the workers deny (OBS-1046). Reach that comes only from channel-scope entries is not
|
|
547
|
+
// the rail's to clear — the refusal names those reasons.
|
|
548
|
+
// OBS-1099 add.1: the held entry is looked up over the schema-enumerated adapter scopes,
|
|
549
|
+
// flat before the allow complement before nested
|
|
550
|
+
const heldScope = FLAT_ADAPTER_SCOPES.find((scope) => ui[scope.key].has(id))
|
|
551
|
+
?? (ui.allowOut.has(id) ? undefined : NESTED_ADAPTER_SCOPES.find((scope) => ui[scope.key].has(id)));
|
|
552
|
+
const held = heldScope?.dotted ?? (ui.allowOut.has(id) ? "routing.allow" : null);
|
|
553
|
+
const take = () => {
|
|
554
|
+
if (heldScope)
|
|
555
|
+
ui[heldScope.key] = without(ui[heldScope.key], id);
|
|
556
|
+
else
|
|
557
|
+
ui.allowOut = without(ui.allowOut, id);
|
|
558
|
+
};
|
|
559
|
+
let edit = "";
|
|
560
|
+
// OBS-1099 review: transitions are judged on ACTUAL reach, not only on the held entry — an
|
|
561
|
+
// adapter that is out only through channel-scope entries (a bare adapter id in
|
|
562
|
+
// routing.deny.models) has nothing the rail may move: a choice it already reaches says so,
|
|
563
|
+
// and a choice that would leave it out-all while staging a second reason is refused by name.
|
|
564
|
+
// D-205: a PARTIAL rail (channels disagree) is never "already" anything — the choice that
|
|
565
|
+
// widens every channel to the picked reach is one act, the others are refused by name
|
|
566
|
+
if (held === null && !partial && reached(reach, choice)) {
|
|
567
|
+
ui.notice = `${id} is already ${label}${reasons.length ? ` — ${reasons.join("; ")}` : ""}`;
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
if (held === null && (choice === "in" || (choice === "out-workers" && reached(reach, "out-all")))) {
|
|
571
|
+
ui.notice = `${id} stays ${describeReach({ reach, partial })} — ${reasons.join("; ")} — the rail edits only ${id}'s own adapter entries; set reach on the channel rows`;
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
if (choice === "in") {
|
|
575
|
+
take();
|
|
576
|
+
edit = `cleared ${id} from ${held}`;
|
|
577
|
+
}
|
|
578
|
+
else if (choice === "out-workers") {
|
|
579
|
+
if (held === "routing.deny.workers.adapters") {
|
|
580
|
+
// OBS-1099 review round 3: the held workers entry is already the rail's whole say —
|
|
581
|
+
// when actual reach is still out-all, the remaining all-seat reasons live on the
|
|
582
|
+
// channel rows, and this choice cannot reach workers-only until they clear
|
|
583
|
+
if (!partial && reached(reach, choice)) {
|
|
584
|
+
ui.notice = `${id} is already ${label}`;
|
|
585
|
+
}
|
|
586
|
+
else {
|
|
587
|
+
const remaining = reasons.filter((reason) => reason !== `routing.deny.workers.adapters (${id})`);
|
|
588
|
+
ui.notice = `${id} stays ${describeReach({ reach, partial })} — ${remaining.join("; ")} — ${id} is already in routing.deny.workers.adapters; workers-only reach needs those all-seat reasons cleared on the channel rows`;
|
|
589
|
+
}
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
if (held !== null)
|
|
593
|
+
take();
|
|
594
|
+
ui.denyWorkersAdapters = withEntry(ui.denyWorkersAdapters, id);
|
|
595
|
+
edit = `${held === null ? "added" : "moved"} ${id} to routing.deny.workers.adapters`;
|
|
596
|
+
}
|
|
597
|
+
else {
|
|
598
|
+
if (held === "routing.deny.adapters" || held === "routing.allow") {
|
|
599
|
+
ui.notice = `${id} is already ${label}`;
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
if (held !== null)
|
|
603
|
+
take();
|
|
604
|
+
ui.denyAdapters = withEntry(ui.denyAdapters, id);
|
|
605
|
+
edit = `${held === null ? "added" : "moved"} ${id} to routing.deny.adapters`;
|
|
606
|
+
}
|
|
607
|
+
const after = adapterReach(id);
|
|
608
|
+
// a partially excluded adapter (one channel still out by its own entry) names what remains
|
|
609
|
+
const still = choice === "in" ? after.reasons.length > 0 : after.partial || !reached(after.reach, choice);
|
|
610
|
+
ui.notice = `space: ${edit}${still ? ` — still out: ${after.reasons.join("; ")}` : ""}`;
|
|
611
|
+
clampList();
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
const row = modelRows().find((candidate) => candidate.adapter === target.adapter && candidate.model === target.model);
|
|
615
|
+
if (!row)
|
|
616
|
+
return;
|
|
617
|
+
const id = `${row.adapter}:${row.model}`;
|
|
618
|
+
const self = { adapter: row.adapter, model: row.model, identity: row.evidence?.identity };
|
|
619
|
+
// LEG2-T3 round 2 finding 1 / D-233 / D-235 (review round 10): ONE ordered selector —
|
|
620
|
+
// ownedDenies — feeds the detail line AND every Space edit, so the entry named is the entry
|
|
621
|
+
// edited: a clear takes its head (own key first, then collector order); a demotion takes its
|
|
622
|
+
// first flat entry, a promotion its first workers entry; a bare adapter id or a sibling's
|
|
623
|
+
// entry is never in it. OBS-1046: an allow exclusion is the LAST reason taken, never together
|
|
624
|
+
// with an authored deny.
|
|
625
|
+
const edits = reachEdits(ownedDenies(self));
|
|
626
|
+
const ownAllow = () => {
|
|
627
|
+
const fromAllow = selectOwn(ui.allowOut, self);
|
|
628
|
+
return fromAllow === undefined ? undefined : { entry: fromAllow, scope: "routing.allow" };
|
|
629
|
+
};
|
|
630
|
+
const ownFlat = () => edits.demote ?? ownAllow();
|
|
631
|
+
const ownWorkers = () => edits.promote;
|
|
632
|
+
const takeFlat = (found) => {
|
|
633
|
+
const scope = scopeOf(found.scope);
|
|
634
|
+
if (scope)
|
|
635
|
+
ui[scope.key] = without(ui[scope.key], found.entry);
|
|
636
|
+
else
|
|
637
|
+
ui.allowOut = without(ui.allowOut, found.entry);
|
|
638
|
+
};
|
|
639
|
+
const refuse = (reachLabel) => {
|
|
640
|
+
// OBS-1099 item 2: a covered row names the covering entry, every identity it covers, and
|
|
641
|
+
// the ONE control that lifts it — the T1 refusal wording stays in front of it
|
|
642
|
+
if (row.covering?.shared) {
|
|
643
|
+
// the notice clips at the browser width; the detail line and the l overlay list every identity
|
|
644
|
+
ui.notice = `${id} stays ${reachLabel} — ${row.covering.scope} (${row.covering.entry}) covers ${row.covering.covers.length} channels — Space edits only this channel's own entries; l lists and lifts it`;
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
const shared = ui.denyAdapters.has(row.adapter) || ui.allowOut.has(row.adapter) || ui.denyWorkersAdapters.has(row.adapter)
|
|
648
|
+
? `every ${row.adapter} channel is out together — set the adapter's reach on the rail`
|
|
649
|
+
: "a shared entry covers other channels too";
|
|
650
|
+
ui.notice = `${id} stays ${reachLabel} — ${row.reasons.join("; ")} — Space edits only this channel's own entries; ${shared}`;
|
|
651
|
+
};
|
|
652
|
+
const reach = row.reach;
|
|
653
|
+
const outAll = reach === "out-all" || reach === "out-allow";
|
|
654
|
+
let edit = "";
|
|
655
|
+
if (choice === "out-workers" && reach === "in") {
|
|
656
|
+
ui.denyWorkersModels = withEntry(ui.denyWorkersModels, id);
|
|
657
|
+
edit = `added ${id} to routing.deny.workers.models`;
|
|
658
|
+
}
|
|
659
|
+
else if (choice === "out-workers" && outAll) {
|
|
660
|
+
const found = ownFlat();
|
|
661
|
+
if (found === undefined)
|
|
662
|
+
return refuse("out");
|
|
663
|
+
takeFlat(found);
|
|
664
|
+
ui.denyWorkersModels = withEntry(ui.denyWorkersModels, found.entry);
|
|
665
|
+
edit = `moved ${found.entry} to routing.deny.workers.models`;
|
|
666
|
+
}
|
|
667
|
+
else if (choice === "out-all" && reach === "in") {
|
|
668
|
+
ui.denyModels = withEntry(ui.denyModels, id);
|
|
669
|
+
edit = `added ${id} to routing.deny.models`;
|
|
670
|
+
}
|
|
671
|
+
else if (choice === "out-all" && reach === "out-workers") {
|
|
672
|
+
// OBS-1099 review: a promotion, like a clear, takes only an entry no sibling row matches —
|
|
673
|
+
// a shared workers entry (adapter-wide or a bare id another row serves) is refused by name,
|
|
674
|
+
// never moved for its siblings and never doubled by a fresh flat deny
|
|
675
|
+
const found = ownWorkers();
|
|
676
|
+
if (found === undefined)
|
|
677
|
+
return refuse("out · workers");
|
|
678
|
+
takeFlat(found);
|
|
679
|
+
ui.denyModels = withEntry(ui.denyModels, found.entry);
|
|
680
|
+
edit = `moved ${found.entry} to routing.deny.models`;
|
|
681
|
+
}
|
|
682
|
+
else if (choice === "in" && reach !== "in") {
|
|
683
|
+
// D-205: in clears ONE row-owned reason from either the flat scopes or the workers scope —
|
|
684
|
+
// a shared flat reason (routing.deny.adapters covering every channel) never blocks clearing
|
|
685
|
+
// this row's own workers entry; the recomputed reach then names the shared reason that remains
|
|
686
|
+
const found = edits.clear ?? (outAll ? ownAllow() : undefined);
|
|
687
|
+
if (found !== undefined) {
|
|
688
|
+
takeFlat(found);
|
|
689
|
+
edit = `cleared ${found.entry} from ${found.scope}`;
|
|
690
|
+
}
|
|
691
|
+
else {
|
|
692
|
+
return refuse(outAll ? "out" : "out · workers");
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
else {
|
|
696
|
+
ui.notice = `${id} is already ${label}`;
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
const after = reachFor(row.adapter, row.model, row.evidence?.identity);
|
|
700
|
+
const still = reached(after.reach, choice) ? "" : ` — still out: ${after.reasons.join("; ")}`;
|
|
701
|
+
ui.lastEdit = { id, text: `space: ${edit}${still}` };
|
|
702
|
+
};
|
|
703
|
+
// OBS-1065: Enter on a greyed picker row lifts exactly its row-owned covering entry, then the
|
|
704
|
+
// same picker reopens over the recomputed policy — a shared or adapter-wide entry is refused by
|
|
705
|
+
// name and the row stays greyed
|
|
706
|
+
const liftFromPicker = (overlay, line) => {
|
|
707
|
+
const head = line.split(" — ")[0];
|
|
708
|
+
const slash = head.indexOf("/");
|
|
709
|
+
const self = slash === -1 ? undefined
|
|
710
|
+
: displayedChannels().find((channel) => channel.adapter === head.slice(0, slash) && channel.model === head.slice(slash + 1));
|
|
711
|
+
if (!self) {
|
|
712
|
+
ui.notice = `nothing to lift on this row — ${line}`;
|
|
713
|
+
bump();
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
const id = `${self.adapter}:${self.model}`;
|
|
717
|
+
// review round 2/4: only a channel that is otherwise eligible (tier floor, auth, pin/pool) may
|
|
718
|
+
// be lifted. Eligibility is read from the picker source itself, never the ledger strings: the
|
|
719
|
+
// channel must be OFFERED at or above the floor once every policy scope is open — a failed
|
|
720
|
+
// model probe (dropped by discovery), a pin or a pool keep it out of that list and a floor
|
|
721
|
+
// marks it belowFloor, so nothing here lifts it.
|
|
722
|
+
const eligible = candidatesForShape(overlay.shape, ui.selectedMode, ui.map, openStagedDeny()).rows
|
|
723
|
+
.some((row) => row.id === id && !row.belowFloor);
|
|
724
|
+
if (!eligible) {
|
|
725
|
+
// the ledger line spells its reasons; drop the row label and the reach segment for the notice
|
|
726
|
+
const reasons = line.split(" — ").slice(1).filter((segment) => !segment.startsWith("reach: "));
|
|
727
|
+
ui.notice = `${id} stays out — ${reasons.join(" — ")} — nothing here to lift`;
|
|
728
|
+
bump();
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
const found = ownDeny(self);
|
|
732
|
+
if (found === undefined) {
|
|
733
|
+
const covering = coveringFor(self);
|
|
734
|
+
ui.notice = covering
|
|
735
|
+
? `${id} stays out — ${covering.scope} (${covering.entry}) covers ${covering.covers.join(", ")} — l on its models row lifts that one entry`
|
|
736
|
+
: `${id} stays out — ${reachFor(self.adapter, self.model, self.identity).reasons.join("; ") || "not a deny entry"} — nothing here to lift`;
|
|
737
|
+
bump();
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
liftEntry(found);
|
|
741
|
+
const after = reachFor(self.adapter, self.model, self.identity);
|
|
742
|
+
const text = `lift: cleared ${found.entry} from ${found.scope}${after.reach === "in" ? "" : ` — still out: ${after.reasons.join("; ")}`}`;
|
|
743
|
+
ui.lastEdit = { id, text };
|
|
744
|
+
ui.notice = text;
|
|
745
|
+
const picked = candidatesForShape(overlay.shape, ui.selectedMode, ui.map, stagedDeny());
|
|
746
|
+
ui.overlay = { ...overlay, rows: picked.rows, excludedNote: picked.excludedNote, ledger: picked.ledger ?? [], at: 0 };
|
|
747
|
+
bump();
|
|
748
|
+
};
|
|
385
749
|
const reviewWindow = (overlay) => {
|
|
386
750
|
const lines = overlay.review.diff.split("\n");
|
|
387
751
|
const cap = reviewCapacity();
|
|
@@ -706,6 +1070,11 @@ export function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels
|
|
|
706
1070
|
return;
|
|
707
1071
|
}
|
|
708
1072
|
if (key.return) {
|
|
1073
|
+
const greyed = ledger[overlay.at - rows.length];
|
|
1074
|
+
if (!rows[overlay.at] && greyed !== undefined) {
|
|
1075
|
+
liftFromPicker(overlay, greyed);
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
709
1078
|
if (overlay.chain.length) {
|
|
710
1079
|
// a selection is a POOL, and its mode is a decision, not a default — the tiny
|
|
711
1080
|
// poolmode overlay asks it; Esc there restores this picker with the chain intact
|
|
@@ -804,6 +1173,42 @@ export function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels
|
|
|
804
1173
|
});
|
|
805
1174
|
return;
|
|
806
1175
|
}
|
|
1176
|
+
if (overlay.kind === "reach") {
|
|
1177
|
+
if (key.escape) {
|
|
1178
|
+
setOverlay(null);
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
if (key.downArrow || input === "j") {
|
|
1182
|
+
overlay.at = Math.min(overlay.at + 1, REACH_CHOICES.length - 1);
|
|
1183
|
+
bump();
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
if (key.upArrow || input === "k") {
|
|
1187
|
+
overlay.at = Math.max(overlay.at - 1, 0);
|
|
1188
|
+
bump();
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
if (key.return) {
|
|
1192
|
+
const choice = REACH_CHOICES[overlay.at].id;
|
|
1193
|
+
setOverlay(null);
|
|
1194
|
+
applyReach(overlay.target, choice);
|
|
1195
|
+
bump();
|
|
1196
|
+
}
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
if (overlay.kind === "lift") {
|
|
1200
|
+
if (key.escape) {
|
|
1201
|
+
setOverlay(null);
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
if (key.return) {
|
|
1205
|
+
liftEntry(overlay.covering);
|
|
1206
|
+
ui.lastEdit = { id: overlay.id, text: `lift: cleared ${overlay.covering.entry} from ${overlay.covering.scope} (covered ${overlay.covering.covers.join(", ")})` };
|
|
1207
|
+
setOverlay(null);
|
|
1208
|
+
bump();
|
|
1209
|
+
}
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
807
1212
|
// judge overlay
|
|
808
1213
|
const rows = [judgeKeepRow, ...judgeSeats].filter((row) => matches(row, ui.filter));
|
|
809
1214
|
if (key.escape) {
|
|
@@ -944,22 +1349,12 @@ export function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels
|
|
|
944
1349
|
const id = modelGroups[row.index].adapter;
|
|
945
1350
|
// membership can't fix auth — say so, or the ✗ reads as a toggle that refuses to move
|
|
946
1351
|
// (operator field report, 2026-08-16: expired kimi token)
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
// OBS-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
}
|
|
954
|
-
else {
|
|
955
|
-
const next = new Set(ui.deny);
|
|
956
|
-
if (next.has(id))
|
|
957
|
-
next.delete(id);
|
|
958
|
-
else
|
|
959
|
-
next.add(id);
|
|
960
|
-
ui.deny = next;
|
|
961
|
-
}
|
|
962
|
-
clampList();
|
|
1352
|
+
const unauthed = agents.find((agent) => agent.id === id)?.authed === false
|
|
1353
|
+
? `${id} is not authed — Space only sets fleet reach; re-auth the ${id} CLI, then run tickmarkr doctor`
|
|
1354
|
+
: "";
|
|
1355
|
+
// OBS-1099 item 4: an adapter row gets the same three-way reach picker as a model row
|
|
1356
|
+
setOverlay({ kind: "reach", target: { adapter: id }, at: 0 });
|
|
1357
|
+
ui.notice = unauthed;
|
|
963
1358
|
bump();
|
|
964
1359
|
return;
|
|
965
1360
|
}
|
|
@@ -1021,70 +1416,13 @@ export function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels
|
|
|
1021
1416
|
bump();
|
|
1022
1417
|
return;
|
|
1023
1418
|
}
|
|
1024
|
-
const id = `${row.adapter}:${row.model}`;
|
|
1025
|
-
// OBS-994/FL-1, LEG2-T3 finding 3: Space cycles in → out(workers) → out(all) → in by editing
|
|
1026
|
-
// only THIS channel's own entries in the per-model scopes — its adapter:model key, or an
|
|
1027
|
-
// identity/bare-model entry no other browser row matches. An adapter-wide or shared entry
|
|
1028
|
-
// is never promoted or cleared from a channel row: that would move sibling channels and
|
|
1029
|
-
// wipe reasons that are not this row's to clear. The row's detail line names the entry each
|
|
1030
|
-
// press edited; a refused press names the shared entry in the notice instead.
|
|
1031
|
-
const self = { adapter: row.adapter, model: row.model, identity: row.evidence?.identity };
|
|
1032
|
-
const others = modelGroups.flatMap((group) => group.rows
|
|
1033
|
-
.filter((other) => group.adapter !== row.adapter || other.model !== row.model)
|
|
1034
|
-
.map((other) => ({ adapter: group.adapter, model: other.model, identity: other.evidence?.identity })));
|
|
1035
|
-
// Judge c3 (R107): exactly ONE selected entry per press — the channel's adapter:model key when
|
|
1036
|
-
// it is a candidate, else the first candidate in sorted order. The cycle is three states over
|
|
1037
|
-
// two scopes, so the out-workers → out-all press re-scopes that one entry from
|
|
1038
|
-
// routing.deny.workers.models to routing.deny.models; every other press adds or clears one
|
|
1039
|
-
// entry in one scope. The channel's other entries keep their reasons for later presses.
|
|
1040
|
-
// LEG2-T3 round 2 finding 1: promotion widens the selected ban as spelled — a bare model id
|
|
1041
|
-
// then excludes every seat serving that model — while a CLEAR only takes an entry no other
|
|
1042
|
-
// row matches, so no sibling is ever admitted by a channel toggle.
|
|
1043
|
-
const select = (entries, ownOnly) => {
|
|
1044
|
-
const candidates = [...entries].filter((entry) => entryMatchesChannel(entry, self, true)
|
|
1045
|
-
&& (!ownOnly || !others.some((other) => entryMatchesChannel(entry, other, true))));
|
|
1046
|
-
return candidates.includes(id) ? id : candidates.sort()[0];
|
|
1047
|
-
};
|
|
1048
1419
|
if (row.reach === "unknown") {
|
|
1049
|
-
ui.notice = `${
|
|
1420
|
+
ui.notice = `${row.adapter}:${row.model} reach is unknown — Space does not toggle it until the staged overlay loads: ${row.reasons.join("; ")}`;
|
|
1050
1421
|
bump();
|
|
1051
1422
|
return;
|
|
1052
1423
|
}
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
ui.lastEdit = { id, text: `space: added ${id} to routing.deny.workers.models` };
|
|
1056
|
-
}
|
|
1057
|
-
else if (row.reach === "out-workers") {
|
|
1058
|
-
const entry = select(ui.denyWorkersModels, false) ?? id;
|
|
1059
|
-
ui.denyWorkersModels = new Set([...ui.denyWorkersModels].filter((staged) => staged !== entry));
|
|
1060
|
-
ui.denyModels = new Set([...ui.denyModels, entry]);
|
|
1061
|
-
ui.lastEdit = { id, text: `space: moved ${entry} to routing.deny.models` };
|
|
1062
|
-
}
|
|
1063
|
-
else {
|
|
1064
|
-
// round 2 finding 2: either flat list may carry this channel's own reason; OBS-1046: an
|
|
1065
|
-
// allow exclusion is the LAST reason cleared, and never together with an authored deny
|
|
1066
|
-
const fromModels = select(ui.denyModels, true);
|
|
1067
|
-
const fromAdapters = fromModels === undefined ? select(ui.deny, true) : undefined;
|
|
1068
|
-
const fromAllow = fromModels === undefined && fromAdapters === undefined ? select(ui.allowOut, true) : undefined;
|
|
1069
|
-
const entry = fromModels ?? fromAdapters ?? fromAllow;
|
|
1070
|
-
if (entry === undefined) {
|
|
1071
|
-
const shared = ui.deny.has(row.adapter) || ui.allowOut.has(row.adapter)
|
|
1072
|
-
? `every ${row.adapter} channel is out together — take the adapter back in on the rail`
|
|
1073
|
-
: "a shared entry covers other channels too";
|
|
1074
|
-
ui.notice = `${id} stays out — ${row.reasons.join("; ")} — Space edits only this channel's own entries; ${shared}`;
|
|
1075
|
-
bump();
|
|
1076
|
-
return;
|
|
1077
|
-
}
|
|
1078
|
-
if (fromModels !== undefined)
|
|
1079
|
-
ui.denyModels = new Set([...ui.denyModels].filter((staged) => staged !== entry));
|
|
1080
|
-
else if (fromAdapters !== undefined)
|
|
1081
|
-
ui.deny = new Set([...ui.deny].filter((staged) => staged !== entry));
|
|
1082
|
-
else
|
|
1083
|
-
ui.allowOut = new Set([...ui.allowOut].filter((staged) => staged !== entry));
|
|
1084
|
-
const scope = fromModels !== undefined ? "routing.deny.models" : fromAdapters !== undefined ? "routing.deny.adapters" : "routing.allow";
|
|
1085
|
-
ui.lastEdit = { id, text: `space: cleared ${entry} from ${scope}` };
|
|
1086
|
-
}
|
|
1087
|
-
bump();
|
|
1424
|
+
// OBS-1099 item 1: Space opens the reach picker; applyReach changes ONE selected reason
|
|
1425
|
+
setOverlay({ kind: "reach", target: { adapter: row.adapter, model: row.model }, at: 0 });
|
|
1088
1426
|
return;
|
|
1089
1427
|
}
|
|
1090
1428
|
if (key.return && row) {
|
|
@@ -1092,7 +1430,7 @@ export function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels
|
|
|
1092
1430
|
beginClassification(row.adapter, row.model);
|
|
1093
1431
|
return;
|
|
1094
1432
|
}
|
|
1095
|
-
if (row.denied || ui.
|
|
1433
|
+
if (row.denied || ui.denyAdapters.has(row.adapter)) {
|
|
1096
1434
|
ui.notice = `${row.adapter}:${row.model} is out of the fleet — Space adds it before assigning`;
|
|
1097
1435
|
bump();
|
|
1098
1436
|
return;
|
|
@@ -1101,6 +1439,15 @@ export function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels
|
|
|
1101
1439
|
setOverlay({ kind: "assign", adapter: row.adapter, model: row.model, at: 0 });
|
|
1102
1440
|
return;
|
|
1103
1441
|
}
|
|
1442
|
+
if (hotkey === "l" && row) {
|
|
1443
|
+
if (!row.covering) {
|
|
1444
|
+
ui.notice = `${row.adapter}:${row.model} — no covering entry to lift${row.reasons.length ? ` — ${row.reasons.join("; ")} — Space edits this row's own entries` : ""}`;
|
|
1445
|
+
bump();
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
setOverlay({ kind: "lift", id: `${row.adapter}:${row.model}`, covering: row.covering });
|
|
1449
|
+
return;
|
|
1450
|
+
}
|
|
1104
1451
|
if (hotkey === "t" && row) {
|
|
1105
1452
|
if (row.tier) {
|
|
1106
1453
|
ui.notice = "tier reassignment on classified models is not supported in v1 — edit config directly";
|
|
@@ -1270,14 +1617,14 @@ export function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels
|
|
|
1270
1617
|
return "unauthed — re-probe with tickmarkr doctor (Space does not toggle reach)";
|
|
1271
1618
|
const reasons = row.reasons.join("; ");
|
|
1272
1619
|
if (row.reach === "out-all")
|
|
1273
|
-
return `reach: out · all seats — ${reasons}`;
|
|
1620
|
+
return `reach: out · all seats — ${reasons} — Space picks in or out · workers`;
|
|
1274
1621
|
if (row.reach === "out-allow")
|
|
1275
|
-
return `reach: out · all seats · allow — ${reasons}`;
|
|
1622
|
+
return `reach: out · all seats · allow — ${reasons} — Space picks in or out · workers`;
|
|
1276
1623
|
if (row.reach === "out-workers")
|
|
1277
|
-
return `reach: out · workers only (judge/review/consult unaffected) — ${reasons}`;
|
|
1624
|
+
return `reach: out · workers only (judge/review/consult unaffected) — ${reasons} — Space picks in or out · all seats`;
|
|
1278
1625
|
if (row.reach === "unknown")
|
|
1279
1626
|
return `reach: unknown — ${reasons}`;
|
|
1280
|
-
return "reach: in — Space
|
|
1627
|
+
return "reach: in — Space picks out · workers or out · all seats";
|
|
1281
1628
|
};
|
|
1282
1629
|
const reachCell = (row) => row.reach === "in" || row.reach === "unknown" ? row.reach : `out ${row.reach === "out-workers" ? "workers" : row.reach.slice(4)}`;
|
|
1283
1630
|
const identityDetail = (row) => {
|
|
@@ -1287,6 +1634,25 @@ export function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels
|
|
|
1287
1634
|
? `resolved identity ${row.evidence.identity} — no deny entry covers it`
|
|
1288
1635
|
: `resolved identity ${row.evidence.identity}`;
|
|
1289
1636
|
};
|
|
1637
|
+
// OBS-1099 item 2: a second detail line names the entry that covers this row other than by its
|
|
1638
|
+
// own key, its scope, and which control edits it — the ONE lift control when it is shared
|
|
1639
|
+
const coveringDetail = (row) => {
|
|
1640
|
+
const covering = row.covering;
|
|
1641
|
+
if (covering.shared)
|
|
1642
|
+
return `covered by ${covering.scope} (${covering.entry}) — shared: covers ${covering.covers.join(", ")} — l lifts that one entry`;
|
|
1643
|
+
// review round 12: name the entry each APPLICABLE reach choice edits, from the selector the
|
|
1644
|
+
// handler reads; when every one is the covering entry itself the short form stands
|
|
1645
|
+
const outAll = row.reach === "out-all" || row.reach === "out-allow";
|
|
1646
|
+
const applicable = [
|
|
1647
|
+
["in clears", covering.edits.clear],
|
|
1648
|
+
...(outAll ? [["out · workers demotes", covering.edits.demote]] : []),
|
|
1649
|
+
...(row.reach === "out-workers" ? [["out · all promotes", covering.edits.promote]] : []),
|
|
1650
|
+
];
|
|
1651
|
+
const named = applicable.filter((pair) => pair[1] !== undefined);
|
|
1652
|
+
const same = named.every(([, o]) => o.entry === covering.entry && o.scope === covering.scope);
|
|
1653
|
+
return `covered by ${covering.scope} (${covering.entry}) — ${same ? "Space edits that entry"
|
|
1654
|
+
: `Space: ${named.map(([verb, o]) => `${verb} ${o.entry} (${o.scope})`).join("; ")}`}`;
|
|
1655
|
+
};
|
|
1290
1656
|
const modelDetail = (row) => {
|
|
1291
1657
|
const parts = [
|
|
1292
1658
|
`${row.adapter}:${row.model}`,
|
|
@@ -1351,7 +1717,7 @@ export function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels
|
|
|
1351
1717
|
const railAdapter = (index, railIndex) => {
|
|
1352
1718
|
const group = modelGroups[index];
|
|
1353
1719
|
const agent = agents.find((candidate) => candidate.id === group.adapter);
|
|
1354
|
-
const denied = ui.
|
|
1720
|
+
const denied = ui.denyAdapters.has(group.adapter) || ui.allowOut.has(group.adapter);
|
|
1355
1721
|
const active = ui.view === "models" && ui.adapterAt === index;
|
|
1356
1722
|
const selected = ui.focus === "rail" && ui.railAt === railIndex;
|
|
1357
1723
|
return (_jsxs(Text, { children: [selected ? _jsx(Text, { color: INK.brand, children: "❯ " }) : _jsx(Text, { children: " " }), denied ? _jsx(Glyph, { kind: "off" }) : agent?.authed === false ? _jsx(Glyph, { kind: "fail" }) : _jsx(Glyph, { kind: "on" }), _jsx(Text, { children: " " }), _jsx(Text, { bold: active, color: active ? INK.brand : undefined, dimColor: denied, children: padCell(group.adapter, 12) }), _jsx(Text, { dimColor: true, children: padCellStart(String(visibleCount(index)), 4) })] }, group.adapter));
|
|
@@ -1427,6 +1793,20 @@ export function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels
|
|
|
1427
1793
|
return (_jsxs(Text, { children: [_jsx(Pointer, { on: selected }), at === -1 ? _jsx(Text, { dimColor: true, children: "· " }) : _jsx(Text, { color: INK.brand, children: `${at + 1} ` }), _jsx(Text, { bold: selected, children: clip(option, bodyW - 10) })] }, option));
|
|
1428
1794
|
}), below > 0 && _jsx(ElisionMark, { count: below, side: "below" })] }));
|
|
1429
1795
|
}
|
|
1796
|
+
if (overlay.kind === "lift") {
|
|
1797
|
+
return (_jsxs(OverlayPanel, { title: `lift · ${overlay.covering.scope} (${overlay.covering.entry})`, width: bodyW, children: [_jsx(Text, { dimColor: true, children: clip(`from ${overlay.id} — Enter lifts this ONE entry for every channel it covers · Esc keeps it`, bodyW - 4) }), _jsx(Text, { children: " " }), overlay.covering.covers.map((covered) => (_jsx(Text, { wrap: "truncate", children: clip(` ${covered}`, bodyW - 4) }, covered)))] }));
|
|
1798
|
+
}
|
|
1799
|
+
if (overlay.kind === "reach") {
|
|
1800
|
+
const id = overlay.target.model === undefined ? overlay.target.adapter : `${overlay.target.adapter}:${overlay.target.model}`;
|
|
1801
|
+
const current = overlay.target.model === undefined
|
|
1802
|
+
? adapterReach(overlay.target.adapter)
|
|
1803
|
+
: modelRows().find((row) => row.adapter === overlay.target.adapter && row.model === overlay.target.model) ?? { reach: "unknown", reasons: [] };
|
|
1804
|
+
const partial = "partial" in current && current.partial;
|
|
1805
|
+
// D-205: a partial rail marks no choice as selected — its channels disagree
|
|
1806
|
+
const on = partial || current.reach === "unknown" ? null
|
|
1807
|
+
: current.reach === "out-all" || current.reach === "out-allow" ? "out-all" : current.reach;
|
|
1808
|
+
return (_jsxs(OverlayPanel, { title: `reach · ${id}`, width: bodyW, children: [_jsx(Text, { dimColor: true, children: clip(`now: ${describeReach({ reach: current.reach, partial })}${current.reasons.length ? ` — ${current.reasons.join("; ")}` : ""}`, bodyW - 4) }), _jsx(Text, { dimColor: true, children: "one choice edits one reason; a row with more reasons keeps the rest" }), _jsx(Text, { children: " " }), REACH_CHOICES.map((choice, index) => (_jsxs(Text, { children: [_jsx(Pointer, { on: overlay.at === index }), choice.id === on ? _jsx(Glyph, { kind: "on" }) : _jsx(Text, { children: " " }), _jsx(Text, { bold: overlay.at === index, children: ` ${padCell(choice.label, 16)}` }), _jsx(Text, { dimColor: true, children: clip(`— ${choice.gloss}`, bodyW - 24) })] }, choice.id)))] }));
|
|
1809
|
+
}
|
|
1430
1810
|
// judge
|
|
1431
1811
|
const rows = [judgeKeepRow, ...judgeSeats].filter((row) => matches(row, ui.filter));
|
|
1432
1812
|
const { visible, start, above, below } = windowRows(rows, overlay.at, capacity);
|
|
@@ -1485,6 +1865,8 @@ export function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels
|
|
|
1485
1865
|
const lines = [];
|
|
1486
1866
|
if (row) {
|
|
1487
1867
|
lines.push(modelDetail(row));
|
|
1868
|
+
if (row.covering)
|
|
1869
|
+
lines.push(coveringDetail(row));
|
|
1488
1870
|
if (!row.tier) {
|
|
1489
1871
|
lines.push(row.suggestion
|
|
1490
1872
|
? `catalog suggests ${row.suggestion.tier} — Space/Enter classifies with the note pre-typed · s stages every visible suggestion`
|
|
@@ -1528,12 +1910,18 @@ export function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels
|
|
|
1528
1910
|
}
|
|
1529
1911
|
if (overlay.kind === "candidates") {
|
|
1530
1912
|
return [
|
|
1531
|
-
{ key: "Enter", label: overlay.chain.length ? "apply pool" : "pin" },
|
|
1913
|
+
{ key: "Enter", label: overlay.chain.length ? "apply pool" : "pin · lift greyed" },
|
|
1532
1914
|
{ key: "Space", label: "pool in order" },
|
|
1533
1915
|
{ key: "type", label: "search" },
|
|
1534
1916
|
{ key: "Esc", label: "cancel" },
|
|
1535
1917
|
];
|
|
1536
1918
|
}
|
|
1919
|
+
if (overlay.kind === "reach") {
|
|
1920
|
+
return [{ key: "Enter", label: "set reach" }, { key: "↑↓", label: "move" }, { key: "Esc", label: "cancel" }];
|
|
1921
|
+
}
|
|
1922
|
+
if (overlay.kind === "lift") {
|
|
1923
|
+
return [{ key: "Enter", label: "lift entry" }, { key: "Esc", label: "keep" }];
|
|
1924
|
+
}
|
|
1537
1925
|
if (overlay.kind === "poolmode") {
|
|
1538
1926
|
return [
|
|
1539
1927
|
{ key: "Enter", label: "apply pool" },
|
|
@@ -1556,7 +1944,7 @@ export function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels
|
|
|
1556
1944
|
return [
|
|
1557
1945
|
{ key: "Enter", label: "open" },
|
|
1558
1946
|
{ key: "↑↓", label: "move" },
|
|
1559
|
-
{ key: "Space", label: "
|
|
1947
|
+
{ key: "Space", label: "reach picker (CLI)" },
|
|
1560
1948
|
{ key: "→", label: "list" },
|
|
1561
1949
|
{ key: "w", label: "review + write" },
|
|
1562
1950
|
{ key: "Esc", label: escLabel },
|
|
@@ -1565,7 +1953,8 @@ export function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels
|
|
|
1565
1953
|
if (ui.view === "models") {
|
|
1566
1954
|
return [
|
|
1567
1955
|
{ key: "Enter", label: "assign/classify" },
|
|
1568
|
-
{ key: "Space", label: "
|
|
1956
|
+
{ key: "Space", label: "reach picker" },
|
|
1957
|
+
{ key: "l", label: "lift entry" },
|
|
1569
1958
|
{ key: "m", label: "presets" },
|
|
1570
1959
|
{ key: "w", label: "review + write" },
|
|
1571
1960
|
{ key: "←", label: "rail" },
|
|
@@ -1596,7 +1985,7 @@ export function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels
|
|
|
1596
1985
|
? _jsx(Text, { color: INK.warn, children: ` ! ${clip(ui.notice, width - 4)}` })
|
|
1597
1986
|
: details.map((line) => _jsx(Text, { dimColor: true, children: ` ${clip(line, width - 2)}` }, line)), _jsx(KeyBar, { binds: keyBinds() })] }));
|
|
1598
1987
|
}
|
|
1599
|
-
export async function runFleetInkEditor({ ageMs, adapters, health,
|
|
1988
|
+
export async function runFleetInkEditor({ ageMs, adapters, health, initialAllowOut = [], modelGroups, initialMode, modeOptions, initialMap, modePreview, shapeRows, candidatesForShape, preferOptionsForShape, initialSteering, steeringOptionsFor, reviewOverlay, reloadGuard, stagedRouting, entry = "probe", initialJudge = "", judgeSeats = [], initialInput = [], input, output, debug = false, ...initialDenyProps }) {
|
|
1600
1989
|
const agents = adapters.flatMap((adapter) => {
|
|
1601
1990
|
const state = health[adapter.id];
|
|
1602
1991
|
if (!state?.installed)
|
|
@@ -1620,7 +2009,7 @@ export async function runFleetInkEditor({ ageMs, adapters, health, initialDenyAd
|
|
|
1620
2009
|
// rebuilt from output.columns/rows so a pane resize re-renders at the new size. Same root
|
|
1621
2010
|
// element type on every rerender ⇒ React keeps the component instance, so staged edits,
|
|
1622
2011
|
// cursor, and overlays all survive the resize.
|
|
1623
|
-
const editorElement = () => (_jsx(FleetApp, { ageMs: ageMs, agents: agents,
|
|
2012
|
+
const editorElement = () => (_jsx(FleetApp, { ageMs: ageMs, agents: agents, ...initialDenyProps, initialAllowOut: initialAllowOut, modelGroups: declaredModelGroups, initialMode: initialMode, modeOptions: modeOptions, initialMap: initialMap, modePreview: modePreview, shapeRows: shapeRows, candidatesForShape: candidatesForShape, preferOptionsForShape: preferOptionsForShape, initialSteering: initialSteering, steeringOptionsFor: steeringOptionsFor, reviewOverlay: reviewOverlay, reloadGuard: reloadGuard, stagedRouting: stagedRouting, entry: entry, initialJudge: initialJudge, judgeSeats: judgeSeats,
|
|
1624
2013
|
// v1.90.9: cap every list to the terminal minus chrome so the cursor and header can
|
|
1625
2014
|
// never scroll off-screen (omp's 218-model list did exactly that pre-cap).
|
|
1626
2015
|
// OBS-523: chrome is ≤12 rows worst case (header, borders, padding, scope+search,
|