tickmarkr 2.5.8 → 2.6.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 (63) hide show
  1. package/dist/adapters/claude-code.js +19 -6
  2. package/dist/adapters/prompt.js +1 -1
  3. package/dist/adapters/qwen.js +30 -3
  4. package/dist/adapters/types.d.ts +3 -0
  5. package/dist/cli/commands/approve.js +15 -3
  6. package/dist/cli/commands/beat.d.ts +2 -0
  7. package/dist/cli/commands/beat.js +28 -29
  8. package/dist/cli/commands/compile.js +11 -0
  9. package/dist/cli/commands/fleet.js +61 -53
  10. package/dist/cli/commands/init.js +1 -1
  11. package/dist/cli/commands/report.js +11 -1
  12. package/dist/cli/commands/verify.d.ts +4 -1
  13. package/dist/cli/commands/verify.js +18 -5
  14. package/dist/cli/help.d.ts +2 -0
  15. package/dist/cli/help.js +6 -4
  16. package/dist/compile/native.js +39 -4
  17. package/dist/config/config.d.ts +41 -3
  18. package/dist/config/config.js +48 -17
  19. package/dist/config/fleet-overlay.js +47 -62
  20. package/dist/drivers/orca.d.ts +1 -1
  21. package/dist/drivers/orca.js +21 -4
  22. package/dist/gates/baseline.d.ts +67 -0
  23. package/dist/gates/baseline.js +172 -10
  24. package/dist/gates/cache.d.ts +8 -0
  25. package/dist/gates/cache.js +26 -13
  26. package/dist/gates/review.d.ts +8 -7
  27. package/dist/gates/review.js +83 -45
  28. package/dist/gates/run-gates.d.ts +4 -1
  29. package/dist/gates/run-gates.js +32 -12
  30. package/dist/gates/test-manifest.d.ts +16 -1
  31. package/dist/gates/test-manifest.js +127 -27
  32. package/dist/gates/test-reporter.js +4 -0
  33. package/dist/graph/graph.d.ts +1 -1
  34. package/dist/graph/graph.js +6 -1
  35. package/dist/graph/schema.d.ts +2 -0
  36. package/dist/graph/schema.js +2 -0
  37. package/dist/route/preference.d.ts +1 -1
  38. package/dist/route/preference.js +10 -39
  39. package/dist/run/daemon.d.ts +7 -1
  40. package/dist/run/daemon.js +237 -53
  41. package/dist/run/git.d.ts +10 -0
  42. package/dist/run/git.js +49 -1
  43. package/dist/run/journal.d.ts +18 -3
  44. package/dist/run/journal.js +141 -17
  45. package/dist/run/merge.d.ts +13 -2
  46. package/dist/run/merge.js +98 -12
  47. package/dist/run/protocol.d.ts +82 -0
  48. package/dist/run/protocol.js +35 -0
  49. package/dist/run/receipt-resolver.d.ts +18 -0
  50. package/dist/run/receipt-resolver.js +132 -0
  51. package/dist/run/supervision.d.ts +14 -1
  52. package/dist/run/supervision.js +122 -24
  53. package/dist/tui/cockpit/evidence-view.d.ts +10 -1
  54. package/dist/tui/cockpit/evidence-view.js +37 -5
  55. package/dist/tui/cockpit/live-runtime.d.ts +4 -0
  56. package/dist/tui/cockpit/live-runtime.js +37 -3
  57. package/dist/tui/cockpit/live-store.d.ts +2 -0
  58. package/dist/tui/ink/fleet-app.d.ts +12 -22
  59. package/dist/tui/ink/fleet-app.js +520 -131
  60. package/package.json +1 -1
  61. package/schema/rungraph.schema.json +7 -0
  62. package/skills/tickmarkr-overseer/SKILL.md +91 -41
  63. 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
- export function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels, initialAllowOut = [], initialDenyWorkersAdapters = [], initialDenyWorkersModels = [], modelGroups, initialMode, modeOptions, initialMap, modePreview, shapeRows, candidatesForShape, preferOptionsForShape, initialSteering, steeringOptionsFor, reviewOverlay, reloadGuard, stagedRouting, entry = "probe", initialJudge = "", judgeSeats = [], viewRows = Number.POSITIVE_INFINITY, frameColumns = 100, frameRows, }) {
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
- denyWorkersAdapters: new Set(initialDenyWorkersAdapters),
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 adapter of ui.deny)
76
- if (!initialDenySet.has(adapter))
77
- n += 1;
78
- for (const adapter of initialDenySet)
79
- if (!ui.deny.has(adapter))
80
- n += 1;
81
- for (const model of ui.denyModels)
82
- if (!initialDenyModelSet.has(model))
83
- n += 1;
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: { adapters: deny.adapters, models: deny.models, workers: { adapters: deny.workersAdapters, models: deny.workersModels } } } },
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 = exclusionCollector(channel, policy.routing, "worker");
147
- const allSeats = exclusionCollector(channel, policy.routing, "judge");
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
- adapters: [...ui.deny].sort(),
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
- denyAdapters: [...ui.deny].sort(),
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
- if (agents.find((agent) => agent.id === id)?.authed === false) {
948
- ui.notice = `${id} is not authed — Space only toggles fleet membership; re-auth the ${id} CLI, then run tickmarkr doctor`;
949
- }
950
- // OBS-1046: an adapter routing.allow leaves out comes back in by clearing THAT reason
951
- if (ui.allowOut.has(id)) {
952
- ui.allowOut = new Set([...ui.allowOut].filter((key) => key !== id));
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 = `${id} reach is unknown — Space does not toggle it until the staged overlay loads: ${row.reasons.join("; ")}`;
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
- if (row.reach === "in") {
1054
- ui.denyWorkersModels = new Set([...ui.denyWorkersModels, id]);
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.deny.has(row.adapter)) {
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 cycles to out · workers";
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.deny.has(group.adapter) || ui.allowOut.has(group.adapter);
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: "fleet in/out CLI" },
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: "fleet in/out" },
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, initialDenyAdapters, initialDenyModels, initialAllowOut = [], initialDenyWorkersAdapters = [], initialDenyWorkersModels = [], modelGroups, initialMode, modeOptions, initialMap, modePreview, shapeRows, candidatesForShape, preferOptionsForShape, initialSteering, steeringOptionsFor, reviewOverlay, reloadGuard, stagedRouting, entry = "probe", initialJudge = "", judgeSeats = [], initialInput = [], input, output, debug = false, }) {
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, initialDenyAdapters: initialDenyAdapters, initialDenyModels: initialDenyModels, initialAllowOut: initialAllowOut, initialDenyWorkersAdapters: initialDenyWorkersAdapters, initialDenyWorkersModels: initialDenyWorkersModels, 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,
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,