tickmarkr 2.6.6 → 2.6.8

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 (51) hide show
  1. package/dist/adapters/claude-code.js +8 -2
  2. package/dist/adapters/codex-commit-check.d.ts +3 -2
  3. package/dist/adapters/codex-commit-check.js +37 -10
  4. package/dist/adapters/codex.js +10 -4
  5. package/dist/cli/commands/beat.d.ts +7 -2
  6. package/dist/cli/commands/beat.js +144 -77
  7. package/dist/cli/commands/fleet.js +66 -21
  8. package/dist/cli/commands/init.js +18 -7
  9. package/dist/cli/commands/plan.js +16 -18
  10. package/dist/cli/commands/verify.js +4 -2
  11. package/dist/cli/help.d.ts +1 -0
  12. package/dist/cli/help.js +14 -6
  13. package/dist/cli/index.d.ts +1 -1
  14. package/dist/cli/index.js +1 -1
  15. package/dist/drivers/herdr.js +2 -2
  16. package/dist/drivers/orca.d.ts +12 -4
  17. package/dist/drivers/orca.js +200 -39
  18. package/dist/gates/baseline.d.ts +9 -0
  19. package/dist/gates/baseline.js +14 -3
  20. package/dist/gates/cache.js +1 -1
  21. package/dist/gates/evidence.js +3 -3
  22. package/dist/gates/review.d.ts +1 -1
  23. package/dist/gates/review.js +77 -13
  24. package/dist/gates/run-gates.d.ts +21 -6
  25. package/dist/gates/run-gates.js +439 -224
  26. package/dist/gates/test-manifest.d.ts +42 -0
  27. package/dist/gates/test-manifest.js +203 -118
  28. package/dist/run/beat-lifecycle.d.ts +239 -0
  29. package/dist/run/beat-lifecycle.js +1782 -0
  30. package/dist/run/daemon.d.ts +4 -0
  31. package/dist/run/daemon.js +129 -40
  32. package/dist/run/git-trust.d.ts +102 -0
  33. package/dist/run/git-trust.js +334 -0
  34. package/dist/run/git.d.ts +24 -3
  35. package/dist/run/git.js +169 -30
  36. package/dist/run/journal.js +15 -3
  37. package/dist/run/lease.d.ts +23 -0
  38. package/dist/run/lease.js +232 -38
  39. package/dist/run/merge.js +4 -4
  40. package/dist/run/stall.js +20 -1
  41. package/dist/run/supervision.d.ts +8 -0
  42. package/dist/run/supervision.js +24 -4
  43. package/dist/run/verification-job.d.ts +53 -0
  44. package/dist/run/verification-job.js +159 -0
  45. package/dist/tui/ink/fleet-app.d.ts +16 -2
  46. package/dist/tui/ink/fleet-app.js +265 -141
  47. package/package.json +1 -1
  48. package/skills/tickmarkr-auto/SKILL.md +80 -27
  49. package/skills/tickmarkr-loop/SKILL.md +87 -27
  50. package/skills/tickmarkr-overseer/SKILL.md +125 -47
  51. package/skills/tickmarkr-overseer/scripts/watch-journal.sh +9 -7
@@ -49,7 +49,7 @@ const REACH_CHOICES = [
49
49
  { id: "out-workers", label: "out · workers", gloss: "workers skip it; judge/review/consult unaffected" },
50
50
  { id: "out-all", label: "out · all seats", gloss: "no seat routes here" },
51
51
  ];
52
- export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts = {}, modelGroups, initialMode, modeOptions, initialMap, modePreview, shapeRows, candidatesForShape, preferOptionsForShape, initialSteering, steeringOptionsFor, reviewAdvisories = [], reviewOverlay, reloadGuard, stagedRouting, holderOf, entry = "probe", initialJudge = "", judgeSeats = [], viewRows = Number.POSITIVE_INFINITY, frameColumns = 100, frameRows, ...initialDenyProps }) {
52
+ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts = {}, modelGroups, initialMode, modeOptions, initialMap, modePreview, shapeRows, candidatesForShape, preferOptionsForShape, initialSteering, steeringOptionsFor, reviewAdvisories = [], reviewOverlay, layerStamp, reloadGuard, stagedRouting, holderOf, entry = "probe", initialJudge = "", judgeSeats = [], viewRows = Number.POSITIVE_INFINITY, frameColumns = 100, frameRows, ...initialDenyProps }) {
53
53
  const { exit } = useApp();
54
54
  const [, setRevision] = useState(0);
55
55
  // the props are the fleet command's contract, one per schema-enumerated scope (initialDeny…),
@@ -137,21 +137,51 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
137
137
  // the STAGED policy (fleet loads the candidate bytes; see stagedRouting) and the recorded
138
138
  // identity — never literal membership of the staged sets, which missed identity, bare-model and
139
139
  // family entries and could not tell an allowlist exclusion from a deny.
140
- let routingMemo = null;
140
+ // B-KEYS (D-1338): the staged CONTENT every row and the staged policy derive from — the deny sets,
141
+ // the allow complement, classifications, efforts and seeds, read raw off ui (the provenance a
142
+ // review folds is a pure function of them). Computed per call, never per render: a handler
143
+ // mutates ui and reads rows in the same tick, before bump.
144
+ const stagedKey = () => JSON.stringify([
145
+ DENY_SCOPES.map((scope) => [...ui[scope.key]].sort()),
146
+ [...ui.allowOut].sort(),
147
+ ui.classifications,
148
+ ui.efforts,
149
+ ui.seededAllowOut,
150
+ ]);
151
+ // ponytail: both memos live in refs keyed on stagedKey — a render-local memo reset on every keypress,
152
+ // so each cursor move reloaded the policy and re-derived ~800 rows' reach (B-KEYS). A cursor move,
153
+ // a filter, showAll or an overlay never recompute a row; any staged edit changes the key and
154
+ // recomputes every group. Ceiling: one JSON.stringify of the staged sets per lookup (µs); a dirty
155
+ // counter bumped at every mutation site if a fleet ever outgrows it.
156
+ const routingMemo = useRef(null);
141
157
  const stagedPolicy = () => {
158
+ const key = memoKey();
159
+ const memo = routingMemo.current;
160
+ if (memo?.key === key)
161
+ return memo.policy;
142
162
  const deny = stagedDeny();
143
163
  const stage = stagedMetadata();
144
- const key = JSON.stringify([deny, stage]);
145
- if (routingMemo?.key !== key) {
146
- routingMemo = {
147
- key,
148
- policy: stagedRouting
149
- ? stagedRouting(deny, stage)
150
- : { ok: true, routing: { deny: denyBlockFrom(denyLists()) } },
151
- };
152
- }
153
- return routingMemo.policy;
164
+ const policy = stagedRouting
165
+ ? stagedRouting(deny, stage)
166
+ : { ok: true, routing: { deny: denyBlockFrom(denyLists()) } };
167
+ routingMemo.current = { key, policy };
168
+ return policy;
154
169
  };
170
+ const rowsMemo = useRef({ key: "", byAdapter: new Map() });
171
+ // B-KEYS r2 (D-1446): staged content alone does not identify a row — the rows also derive from the loaded
172
+ // overlay layers (stagedRouting and holderOf read both files) and the props they read. Once per render a
173
+ // generation moves when the layer bytes or one of those props changes, and both memos key on it beside the
174
+ // staged content: a layer written mid-session shows on the next frame, as on the base, while a cursor move
175
+ // over unchanged layers still derives nothing. Cost: one read of each overlay per frame (the B2 previewCfg key).
176
+ const layers = useRef({ stamp: "", inputs: [], gen: 0 });
177
+ {
178
+ const stamp = layerStamp?.() ?? "";
179
+ const inputs = [modelGroups, stagedRouting, holderOf];
180
+ if (stamp !== layers.current.stamp || inputs.some((input, at) => input !== layers.current.inputs[at])) {
181
+ layers.current = { stamp, inputs, gen: layers.current.gen + 1 };
182
+ }
183
+ }
184
+ const memoKey = () => `${layers.current.gen}\u0000${stagedKey()}`;
155
185
  // OBS-1099 add.1: the browser's deny discovery ranges over the schema-enumerated scopes (the
156
186
  // route collector names its four paths by hand, so a scope the schema gains would never render
157
187
  // or lift here). A flat scope reaches every seat; a nested one only the seat its block names
@@ -183,20 +213,31 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
183
213
  }),
184
214
  };
185
215
  };
186
- // OBS-1099 review: a displayed row may fold several discovered gateway ids (foldedModels);
187
- // each folded id is a real channel the policy matches on its own, so every one but a row's
188
- // own id counts as a sibling — an entry covering a folded id is shared, never row-owned
189
- const displayedChannels = () => modelGroups.flatMap((group) => group.rows
190
- .flatMap((other) => (other.foldedModels?.length ? other.foldedModels : [other.model])
191
- .map((model) => ({ adapter: group.adapter, model, identity: other.evidence?.identity }))));
216
+ // T5: the real channels a displayed row stands for — every collapsed variant and catalog-fold
217
+ // constituent fleet derived from the cached doctor list, each with its own identity; a row without
218
+ // them stands for its folded ids, else its reference real id (classifyModel) or its own. Display labels
219
+ // are never channels.
220
+ const membersOf = (adapter, row) => {
221
+ const listed = row.members?.length ? row.members
222
+ : (row.foldedModels?.length ? row.foldedModels : [row.classifyModel ?? row.model]).map((model) => ({ model, identity: row.evidence?.identity }));
223
+ // one channel per real id: a repeated id is one member, so a grouped act takes ONE reason from it
224
+ return listed.filter((member, at) => listed.findIndex((other) => other.model === member.model) === at)
225
+ .map((member) => ({ adapter, model: member.model, ...(member.identity !== undefined ? { identity: member.identity } : {}) }));
226
+ };
227
+ // OBS-1099 review: a displayed row may fold several discovered gateway ids; each real member is a
228
+ // channel the policy matches on its own, so every one but a channel itself counts as a sibling —
229
+ // an entry covering another member is shared, never channel-owned (grouping never privatizes it)
230
+ const displayedChannels = () => modelGroups.flatMap((group) => group.rows.flatMap((other) => membersOf(group.adapter, other)));
192
231
  const siblingsOf = (self) => displayedChannels()
193
232
  .filter((other) => other.adapter !== self.adapter || other.model !== self.model);
194
233
  // Judge c3 (R107): exactly ONE selected entry per act — the channel's adapter:model key when it
195
- // is a candidate, else the first candidate in sorted order; only an entry no sibling matches
196
- const selectOwn = (entries, self) => {
234
+ // is a candidate, else the first candidate in sorted order; only an entry no sibling matches.
235
+ // T5: matched as production matches the list — a deny entry with family matching, an allow-complement
236
+ // entry exactly as routing.allow admits (`fake:C-low` never reaches a bare `fake:C` there)
237
+ const selectOwn = (entries, self, family = true) => {
197
238
  const others = siblingsOf(self);
198
- const candidates = [...entries].filter((entry) => entryMatchesChannel(entry, self, true)
199
- && !others.some((other) => entryMatchesChannel(entry, other, true)));
239
+ const candidates = [...entries].filter((entry) => entryMatchesChannel(entry, self, family)
240
+ && !others.some((other) => entryMatchesChannel(entry, other, family)));
200
241
  const id = `${self.adapter}:${self.model}`;
201
242
  return candidates.includes(id) ? id : candidates.sort()[0];
202
243
  };
@@ -286,11 +327,48 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
286
327
  const shared = isAdapterWide(scope.entry, self) || covers.length > 1;
287
328
  return { entry: scope.entry, scope: scope.configPath, covers, shared, edits: reachEdits(owned) };
288
329
  };
330
+ // T5: a grouped row's reach over EVERY real member — the whole-group state only when every member
331
+ // holds it; otherwise the most-excluded member's reach, marked partial, each member-specific reason
332
+ // prefixed with its real id. A one-member row is exactly its channel's reach.
333
+ const rowReach = (members) => {
334
+ const each = members.map((member) => ({ model: member.model, ...reachFor(member.adapter, member.model, member.identity) }));
335
+ if (each.length === 1) {
336
+ const { model: _model, ...only } = each[0];
337
+ return { ...only, partial: false };
338
+ }
339
+ const folded = foldReach(each);
340
+ const uniform = each.every((channel) => channel.reach === each[0].reach);
341
+ return {
342
+ reach: uniform ? each[0].reach : folded.reach,
343
+ reasons: folded.reasons,
344
+ partial: folded.partial,
345
+ uncoveredAlias: each.some((channel) => channel.uncoveredAlias),
346
+ held: [...new Set(each.flatMap((channel) => channel.held))],
347
+ };
348
+ };
349
+ // T5: the covering entry a grouped row names — a shared one first, so l owns it
350
+ const rowCovering = (members) => {
351
+ const found = members.map(coveringFor).filter((covering) => covering !== undefined);
352
+ return found.find((covering) => covering.shared) ?? found[0];
353
+ };
354
+ // B-KEYS: one derivation per group per staged state — the memo above hands every later pass
355
+ // (visibleCount per rail row, modelRows, the hidden and folded counts, a handler's lookup) the
356
+ // same rows; the returned array is read, never mutated, by every caller.
289
357
  const groupRows = (group) => {
358
+ const key = memoKey();
359
+ if (rowsMemo.current.key !== key)
360
+ rowsMemo.current = { key, byAdapter: new Map() };
361
+ const memo = rowsMemo.current.byAdapter;
362
+ const rows = memo.get(group.adapter) ?? deriveGroupRows(group);
363
+ memo.set(group.adapter, rows);
364
+ return rows;
365
+ };
366
+ const deriveGroupRows = (group) => {
290
367
  const rows = group.rows.map((row) => {
368
+ const members = membersOf(group.adapter, row);
291
369
  const staged = ui.classifications.find((classification) => classification.adapter === group.adapter
292
- && (classification.model === row.model || classification.model === row.classifyModel));
293
- const reach = reachFor(group.adapter, row.model, row.evidence?.identity);
370
+ && (classification.model === row.model || members.some((member) => member.model === classification.model)));
371
+ const reach = rowReach(members);
294
372
  return {
295
373
  adapter: group.adapter,
296
374
  model: row.model,
@@ -302,16 +380,17 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
302
380
  classifyModel: row.classifyModel,
303
381
  variants: row.variants,
304
382
  foldedModels: row.foldedModels,
383
+ ...(row.members ? { members: row.members } : {}),
305
384
  score: row.score,
306
385
  denied: reach.reach !== "in",
307
386
  ...reach,
308
387
  // OBS-1099 item 2 (review round 2): every row is covered the same way — an alias through
309
388
  // its identity, a plain row by a bare-model or adapter entry — so a shared bare-model
310
389
  // entry across adapters has the one owning control too
311
- covering: coveringFor({ adapter: group.adapter, model: row.model, identity: row.evidence?.identity }),
390
+ covering: rowCovering(members),
312
391
  };
313
392
  });
314
- const known = new Set(rows.flatMap((row) => row.classifyModel ? [row.model, row.classifyModel] : [row.model]));
393
+ const known = new Set(group.rows.flatMap((row) => [row.model, ...membersOf(group.adapter, row).map((member) => member.model)]));
315
394
  for (const staged of ui.classifications) {
316
395
  if (staged.adapter === group.adapter && !known.has(staged.model)) {
317
396
  const reach = reachFor(group.adapter, staged.model);
@@ -448,27 +527,29 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
448
527
  // its classification — the ownAllow spelling Space clears — so it can be admitted in this session.
449
528
  // Re-staging a classification replaces its seed; a row the allow form admits seeds nothing. A row a
450
529
  // flat deny ALSO excludes still seeds, so successive owning lifts (deny, then allow) admit it.
451
- const seedAllow = (adapter, model, displayModel) => {
452
- // ponytail: a collapsed variant row's allow entry would name no channel (its display name is not the real
453
- // model), so it is not seeded for same-session admission — classify, save, then admit next session;
454
- // full grouped-row membership (collapse + catalog fold) is 2.6.7 (T10 owed review F1, D-1016)
455
- if (model !== displayModel)
456
- return;
457
- const identity = groupOf(adapter)?.rows.find((row) => row.model === displayModel)?.evidence?.identity;
458
- const self = { adapter, model: displayModel, ...(identity !== undefined ? { identity } : {}) };
530
+ // T5: one seed per REAL member of a grouped row (collapsed variants, catalog-fold constituents), keyed and
531
+ // spelled by its real id with its own identity — the display label never names a channel.
532
+ const seedAllow = (self, displayModel) => {
459
533
  const policy = stagedPolicy();
460
534
  if (!policy.ok || !exclusionsOf(self, policy.routing, "judge").some((scope) => scope.by === "allow"))
461
535
  return;
462
- const entry = selectOwn(ui.allowOut, self) ?? `${adapter}:${displayModel}`;
536
+ const entry = selectOwn(ui.allowOut, self, false) ?? `${self.adapter}:${self.model}`;
463
537
  ui.allowOut = new Set([...ui.allowOut, entry]);
464
538
  ui.seededAllowOut = [
465
- ...ui.seededAllowOut.filter((seed) => seed.adapter !== adapter || seed.model !== model),
466
- { adapter, model, displayModel, ...(identity !== undefined ? { identity } : {}), entry },
539
+ ...ui.seededAllowOut.filter((seed) => seed.adapter !== self.adapter || seed.model !== self.model),
540
+ { adapter: self.adapter, model: self.model, displayModel, ...(self.identity !== undefined ? { identity: self.identity } : {}), entry },
467
541
  ];
468
542
  };
543
+ // T5: the real members a classification of this browser row stages — a row the browser never listed
544
+ // (n adds one) is its own one member
545
+ const classifiedMembers = (adapter, displayModel) => {
546
+ const row = groupOf(adapter)?.rows.find((candidate) => candidate.model === displayModel);
547
+ return row ? membersOf(adapter, row) : [{ adapter, model: displayModel }];
548
+ };
469
549
  const stageSuggested = (adapter, rows, firstTouch) => {
470
- for (const row of rows)
471
- seedAllow(adapter, row.model, row.displayModel);
550
+ for (const row of rows) {
551
+ seedAllow({ adapter, model: row.model, ...(row.identity !== undefined ? { identity: row.identity } : {}) }, row.displayModel);
552
+ }
472
553
  ui.classifications = [
473
554
  ...ui.classifications,
474
555
  ...rows.map((row) => ({
@@ -521,18 +602,23 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
521
602
  }
522
603
  setOverlay({ ...base, stage: "channel", vendor: group.vendor });
523
604
  };
605
+ // T5: the picked tier and provenance land on EVERY real member of the row, each with its own seed —
606
+ // classifyModel stays the reference id the suggestion and the overlay title name
524
607
  const applyClassification = (overlay) => {
525
608
  const answered = overlay.vendor ? ui.channelByAdapter[overlay.adapter] : undefined;
526
- seedAllow(overlay.adapter, overlay.model, overlay.displayModel ?? overlay.model);
609
+ const displayModel = overlay.displayModel ?? overlay.model;
610
+ const members = classifiedMembers(overlay.adapter, displayModel);
611
+ for (const member of members)
612
+ seedAllow(member, displayModel);
527
613
  ui.classifications = [
528
614
  ...ui.classifications,
529
- {
615
+ ...members.map((member) => ({
530
616
  adapter: overlay.adapter,
531
- model: overlay.model,
617
+ model: member.model,
532
618
  tier: TIERS[overlay.tierAt],
533
619
  note: overlay.note.trim(),
534
620
  ...(overlay.vendor && answered ? { vendor: overlay.vendor, channel: answered } : {}),
535
- },
621
+ })),
536
622
  ];
537
623
  setOverlay(null);
538
624
  };
@@ -560,8 +646,9 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
560
646
  // channel-specific one carries its channel, so a partial exclusion is never intersected away.
561
647
  const adapterReach = (id) => {
562
648
  const group = modelGroups.find((candidate) => candidate.adapter === id);
563
- const channels = (group?.rows ?? []).flatMap((row) => (row.foldedModels?.length ? row.foldedModels : [row.model])
564
- .map((model) => ({ model, ...reachFor(id, model, row.evidence?.identity) })));
649
+ // T5: every real member of every row — a collapsed or folded row's display label is no channel
650
+ const channels = (group?.rows ?? []).flatMap((row) => membersOf(id, row))
651
+ .map((channel) => ({ model: channel.model, ...reachFor(id, channel.model, channel.identity) }));
565
652
  if (channels.length === 0) {
566
653
  // ponytail: a rail entry with no probed channels has only its own literal entries to show
567
654
  const reasons = [
@@ -574,6 +661,11 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
574
661
  : NESTED_ADAPTER_SCOPES.some((scope) => ui[scope.key].has(id)) ? "out-workers" : "in";
575
662
  return { reach, reasons, partial: false };
576
663
  }
664
+ return foldReach(channels);
665
+ };
666
+ // the rail's (and a grouped row's) fold over its real channels — most-excluded reach, partial when they
667
+ // disagree, shared reasons once and channel-specific ones carrying their channel
668
+ function foldReach(channels) {
577
669
  const unknown = channels.find((channel) => channel.reach === "unknown");
578
670
  if (unknown)
579
671
  return { reach: "unknown", reasons: unknown.reasons, partial: false };
@@ -588,7 +680,7 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
588
680
  .filter((reason) => !shared.includes(reason))
589
681
  .map((reason) => `${channel.model}: ${reason}`));
590
682
  return { reach, reasons: [...shared, ...own], partial };
591
- };
683
+ }
592
684
  const describeReach = (state) => state.partial ? `${state.reach} (partial: not every channel)` : state.reach;
593
685
  // OBS-1099 items 1 and 4: one picker choice changes exactly ONE selected entry in ONE scope —
594
686
  // added, cleared, or re-scoped between the workers list and the flat list — then the row's
@@ -678,90 +770,106 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
678
770
  if (!row)
679
771
  return;
680
772
  const id = `${row.adapter}:${row.model}`;
681
- const self = { adapter: row.adapter, model: row.model, identity: row.evidence?.identity };
682
- // LEG2-T3 round 2 finding 1 / D-233 / D-235 (review round 10): ONE ordered selector —
683
- // ownedDenies — feeds the detail line AND every Space edit, so the entry named is the entry
684
- // edited: a clear takes its head (own key first, then collector order); a demotion takes its
685
- // first flat entry, a promotion its first workers entry; a bare adapter id or a sibling's
686
- // entry is never in it. OBS-1046: an allow exclusion is the LAST reason taken, never together
687
- // with an authored deny.
688
- const edits = reachEdits(ownedDenies(self));
689
- const ownAllow = () => {
690
- const fromAllow = selectOwn(ui.allowOut, self);
691
- return fromAllow === undefined ? undefined : { entry: fromAllow, scope: "routing.allow" };
692
- };
693
- const ownFlat = () => edits.demote ?? ownAllow();
694
- const ownWorkers = () => edits.promote;
695
- const takeFlat = (found) => {
696
- const scope = scopeOf(found.scope);
697
- if (scope)
698
- ui[scope.key] = without(ui[scope.key], found.entry);
699
- else
700
- ui.allowOut = without(ui.allowOut, found.entry);
701
- };
702
- const refuse = (reachLabel) => {
703
- // OBS-1099 item 2: a covered row names the covering entry, every identity it covers, and
704
- // the ONE control that lifts it — the T1 refusal wording stays in front of it
705
- if (row.covering?.shared) {
706
- // the notice clips at the browser width; the detail line and the l overlay list every identity
707
- 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`;
708
- return;
773
+ // T5: a grouped row's act is the same one-reason act on EVERY real member, each decided from the reach
774
+ // it held BEFORE this act — a sibling's fresh entry that also matches a member's recorded identity never
775
+ // turns that member's own addition into "already" (review D-1081) — while every removal still takes only
776
+ // an entry the member owns alone over the staged sets; a member a shared entry keeps out is named and left
777
+ const members = membersOf(row.adapter, row);
778
+ const before = members.map((self) => (members.length === 1
779
+ ? { reach: row.reach, reasons: row.reasons, covering: row.covering }
780
+ : { ...reachFor(self.adapter, self.model, self.identity), covering: coveringFor(self) }));
781
+ const outcomes = members.map((self, at) => {
782
+ const memberId = `${self.adapter}:${self.model}`;
783
+ const { reasons, covering } = before[at];
784
+ // LEG2-T3 round 2 finding 1 / D-233 / D-235 (review round 10): ONE ordered selector —
785
+ // ownedDenies — feeds the detail line AND every Space edit, so the entry named is the entry
786
+ // edited: a clear takes its head (own key first, then collector order); a demotion takes its
787
+ // first flat entry, a promotion its first workers entry; a bare adapter id or a sibling's
788
+ // entry is never in it. OBS-1046: an allow exclusion is the LAST reason taken, never together
789
+ // with an authored deny.
790
+ const edits = reachEdits(ownedDenies(self));
791
+ // review D-1081 add.: the seed this session generated for this member is its own even when a peer
792
+ // member's recorded identity also reaches it (C-high recorded as C-low) — never authored, and lifting
793
+ // it admits only this act's members; a seed any channel outside the row matches stays shared
794
+ const ownSeed = () => ui.seededAllowOut.find((seed) => seed.adapter === self.adapter && seed.model === self.model
795
+ && ui.allowOut.has(seed.entry)
796
+ && !siblingsOf(self).some((other) => !members.some((peer) => peer.adapter === other.adapter && peer.model === other.model)
797
+ && entryMatchesChannel(seed.entry, other, false)))?.entry;
798
+ const ownAllow = () => {
799
+ const fromAllow = selectOwn(ui.allowOut, self, false) ?? ownSeed();
800
+ return fromAllow === undefined ? undefined : { entry: fromAllow, scope: "routing.allow" };
801
+ };
802
+ const ownFlat = () => edits.demote ?? ownAllow();
803
+ const ownWorkers = () => edits.promote;
804
+ const takeFlat = (found) => {
805
+ const scope = scopeOf(found.scope);
806
+ if (scope)
807
+ ui[scope.key] = without(ui[scope.key], found.entry);
808
+ else
809
+ ui.allowOut = without(ui.allowOut, found.entry);
810
+ };
811
+ const refuse = (reachLabel) => {
812
+ // OBS-1099 item 2: a covered row names the covering entry, every identity it covers, and
813
+ // the ONE control that lifts it — the T1 refusal wording stays in front of it
814
+ if (covering?.shared) {
815
+ // the notice clips at the browser width; the detail line and the l overlay list every identity
816
+ return { refused: `${memberId} stays ${reachLabel} — ${covering.scope} (${covering.entry}) covers ${covering.covers.length} channels — Space edits only this channel's own entries; l lists and lifts it` };
817
+ }
818
+ const shared = ui.denyAdapters.has(row.adapter) || ui.allowOut.has(row.adapter) || ui.denyWorkersAdapters.has(row.adapter)
819
+ ? `every ${row.adapter} channel is out together — set the adapter's reach on the rail`
820
+ : "a shared entry covers other channels too";
821
+ return { refused: `${memberId} stays ${reachLabel} — ${reasons.join("; ")} — Space edits only this channel's own entries; ${shared}` };
822
+ };
823
+ const reach = before[at].reach;
824
+ const outAll = reach === "out-all" || reach === "out-allow";
825
+ if (choice === "out-workers" && reach === "in") {
826
+ ui.denyWorkersModels = withEntry(ui.denyWorkersModels, memberId);
827
+ return { edit: `added ${memberId} to routing.deny.workers.models` };
709
828
  }
710
- const shared = ui.denyAdapters.has(row.adapter) || ui.allowOut.has(row.adapter) || ui.denyWorkersAdapters.has(row.adapter)
711
- ? `every ${row.adapter} channel is out together — set the adapter's reach on the rail`
712
- : "a shared entry covers other channels too";
713
- ui.notice = `${id} stays ${reachLabel} — ${row.reasons.join("; ")} — Space edits only this channel's own entries; ${shared}`;
714
- };
715
- const reach = row.reach;
716
- const outAll = reach === "out-all" || reach === "out-allow";
717
- let edit = "";
718
- if (choice === "out-workers" && reach === "in") {
719
- ui.denyWorkersModels = withEntry(ui.denyWorkersModels, id);
720
- edit = `added ${id} to routing.deny.workers.models`;
721
- }
722
- else if (choice === "out-workers" && outAll) {
723
- const found = ownFlat();
724
- if (found === undefined)
725
- return refuse("out");
726
- takeFlat(found);
727
- ui.denyWorkersModels = withEntry(ui.denyWorkersModels, found.entry);
728
- edit = `moved ${found.entry} to routing.deny.workers.models`;
729
- }
730
- else if (choice === "out-all" && reach === "in") {
731
- ui.denyModels = withEntry(ui.denyModels, id);
732
- edit = `added ${id} to routing.deny.models`;
733
- }
734
- else if (choice === "out-all" && reach === "out-workers") {
735
- // OBS-1099 review: a promotion, like a clear, takes only an entry no sibling row matches —
736
- // a shared workers entry (adapter-wide or a bare id another row serves) is refused by name,
737
- // never moved for its siblings and never doubled by a fresh flat deny
738
- const found = ownWorkers();
739
- if (found === undefined)
740
- return refuse("out · workers");
741
- takeFlat(found);
742
- ui.denyModels = withEntry(ui.denyModels, found.entry);
743
- edit = `moved ${found.entry} to routing.deny.models`;
744
- }
745
- else if (choice === "in" && reach !== "in") {
746
- // D-205: in clears ONE row-owned reason from either the flat scopes or the workers scope —
747
- // a shared flat reason (routing.deny.adapters covering every channel) never blocks clearing
748
- // this row's own workers entry; the recomputed reach then names the shared reason that remains
749
- const found = edits.clear ?? (outAll ? ownAllow() : undefined);
750
- if (found !== undefined) {
829
+ else if (choice === "out-workers" && outAll) {
830
+ const found = ownFlat();
831
+ if (found === undefined)
832
+ return refuse("out");
751
833
  takeFlat(found);
752
- edit = `cleared ${found.entry} from ${found.scope}`;
834
+ ui.denyWorkersModels = withEntry(ui.denyWorkersModels, found.entry);
835
+ return { edit: `moved ${found.entry} to routing.deny.workers.models` };
753
836
  }
754
- else {
755
- return refuse(outAll ? "out" : "out · workers");
837
+ else if (choice === "out-all" && reach === "in") {
838
+ ui.denyModels = withEntry(ui.denyModels, memberId);
839
+ return { edit: `added ${memberId} to routing.deny.models` };
756
840
  }
757
- }
758
- else {
759
- ui.notice = `${id} is already ${label}`;
841
+ else if (choice === "out-all" && reach === "out-workers") {
842
+ // OBS-1099 review: a promotion, like a clear, takes only an entry no sibling row matches —
843
+ // a shared workers entry (adapter-wide or a bare id another row serves) is refused by name,
844
+ // never moved for its siblings and never doubled by a fresh flat deny
845
+ const found = ownWorkers();
846
+ if (found === undefined)
847
+ return refuse("out · workers");
848
+ takeFlat(found);
849
+ ui.denyModels = withEntry(ui.denyModels, found.entry);
850
+ return { edit: `moved ${found.entry} to routing.deny.models` };
851
+ }
852
+ else if (choice === "in" && reach !== "in") {
853
+ // D-205: in clears ONE row-owned reason from either the flat scopes or the workers scope —
854
+ // a shared flat reason (routing.deny.adapters covering every channel) never blocks clearing
855
+ // this row's own workers entry; the recomputed reach then names the shared reason that remains
856
+ const found = edits.clear ?? (outAll ? ownAllow() : undefined);
857
+ if (found === undefined)
858
+ return refuse(outAll ? "out" : "out · workers");
859
+ takeFlat(found);
860
+ return { edit: `cleared ${found.entry} from ${found.scope}` };
861
+ }
862
+ return { already: true };
863
+ });
864
+ const made = outcomes.flatMap((outcome) => ("edit" in outcome ? [outcome.edit] : []));
865
+ const refused = outcomes.flatMap((outcome) => ("refused" in outcome ? [outcome.refused] : []));
866
+ if (!made.length) {
867
+ ui.notice = refused[0] ?? `${id} is already ${label}`;
760
868
  return;
761
869
  }
762
- const after = reachFor(row.adapter, row.model, row.evidence?.identity);
763
- const still = reached(after.reach, choice) ? "" : ` — still out: ${after.reasons.join("; ")}`;
764
- ui.lastEdit = { id, text: `space: ${edit}${still}` };
870
+ const after = rowReach(members);
871
+ const still = reached(after.reach, choice) && !after.partial ? "" : ` — still out: ${after.reasons.join("; ")}`;
872
+ ui.lastEdit = { id, text: `space: ${made.join("; ")}${still}` };
765
873
  };
766
874
  // OBS-1065: Enter on a greyed picker row lifts exactly its row-owned covering entry, then the
767
875
  // same picker reopens over the recomputed policy — a shared or adapter-wide entry is refused by
@@ -1588,7 +1696,13 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
1588
1696
  const group = modelGroups[ui.adapterAt];
1589
1697
  const suggested = rows
1590
1698
  .filter((candidate) => candidate.adapter === group.adapter && !candidate.tier && candidate.suggestion !== undefined)
1591
- .map((candidate) => ({ model: candidate.classifyModel ?? candidate.model, displayModel: candidate.model, suggestion: candidate.suggestion }));
1699
+ // T5: every real member of each suggested row stages, never only its reference id
1700
+ .flatMap((candidate) => membersOf(candidate.adapter, candidate).map((member) => ({
1701
+ model: member.model,
1702
+ displayModel: candidate.model,
1703
+ ...(member.identity !== undefined ? { identity: member.identity } : {}),
1704
+ suggestion: candidate.suggestion,
1705
+ })));
1592
1706
  if (!suggested.length) {
1593
1707
  ui.notice = "no catalog tier suggestions among the visible unclassified models — evidence comes from the cached catalogs (AA index / API pricing)";
1594
1708
  bump();
@@ -1722,6 +1836,9 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
1722
1836
  return "unauthed — re-probe with tickmarkr doctor (Space does not toggle reach)";
1723
1837
  // T10: the reasons, then where each scope is held — the overlay a reviewed w writes
1724
1838
  const reasons = `${row.reasons.join("; ")}${row.held.length ? ` — held: ${row.held.join("; ")}` : ""}`;
1839
+ // T5: members disagree — never a whole-group state; each member-specific reason names its real id
1840
+ if (row.partial)
1841
+ return `reach: partial — ${row.reach} for some members only — ${reasons} — Space applies one choice to every member`;
1725
1842
  if (row.reach === "out-all")
1726
1843
  return `reach: out · all seats — ${reasons} — Space picks in or out · workers`;
1727
1844
  if (row.reach === "out-allow")
@@ -1732,7 +1849,7 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
1732
1849
  return `reach: unknown — ${reasons}`;
1733
1850
  return "reach: in — Space picks out · workers or out · all seats";
1734
1851
  };
1735
- const reachCell = (row) => row.reach === "in" || row.reach === "unknown" ? row.reach : `out ${row.reach === "out-workers" ? "workers" : row.reach.slice(4)}`;
1852
+ const reachCell = (row) => row.partial ? "partial" : row.reach === "in" || row.reach === "unknown" ? row.reach : `out ${row.reach === "out-workers" ? "workers" : row.reach.slice(4)}`;
1736
1853
  const identityDetail = (row) => {
1737
1854
  if (row.evidence?.identity === undefined)
1738
1855
  return "";
@@ -1764,11 +1881,16 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
1764
1881
  `${row.adapter}:${row.model}`,
1765
1882
  row.tier ?? "unclassified",
1766
1883
  ui.efforts[row.adapter]?.[row.model] ? `effort ${ui.efforts[row.adapter][row.model]}` : "",
1884
+ // T5: a grouped row names each member doctor recorded unauthed — up front, before the line clips
1885
+ ...(row.evidence?.unauthed === undefined ? (row.members ?? []).filter((member) => member.unauthed !== undefined)
1886
+ .map((member) => `${row.adapter}:${member.model} UNAUTHED — ${member.unauthed} — re-probe with tickmarkr doctor`) : []),
1767
1887
  ui.lastEdit?.id === `${row.adapter}:${row.model}` ? ui.lastEdit.text : "",
1768
1888
  identityDetail(row),
1769
1889
  row.tier ? reachDetail(row) : "",
1770
1890
  row.variants?.length ? `variants ${row.variants.join(", ")}` : "",
1771
- row.classifyModel ? `classify writes ${row.classifyModel}` : "",
1891
+ // T5: a grouped row classifies (and edits) every real member; classifyModel is its reference id
1892
+ row.members?.length ? `classify writes ${row.members.map((member) => member.model).join(", ")}`
1893
+ : row.classifyModel ? `classify writes ${row.classifyModel}` : "",
1772
1894
  row.foldedModels?.length ? `${row.foldedModels.length} gateway ids folded: ${row.foldedModels.join(", ")}` : "",
1773
1895
  row.evidence?.unauthed !== undefined ? `UNAUTHED — ${row.evidence.unauthed}` : "",
1774
1896
  row.evidence?.contextWindow !== undefined ? `${fmtCtx(row.evidence.contextWindow)} ctx` : "",
@@ -1792,15 +1914,17 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
1792
1914
  const folded = row.foldedModels?.length ?? 1;
1793
1915
  const name = clipPathTail(`${row.adapter}/${row.model}${folded > 1 ? ` ×${folded}` : ""}`, nameW);
1794
1916
  const prefixLen = Math.min(name.length, row.adapter.length + 1);
1795
- return (_jsxs(Text, { wrap: "truncate", children: [_jsx(Pointer, { on: selected }), row.reach === "out-all" || row.reach === "out-allow"
1796
- ? _jsx(Glyph, { kind: "off" })
1797
- : row.evidence?.unauthed !== undefined || row.reach === "unknown"
1798
- ? _jsx(Glyph, { kind: "fail" })
1799
- : row.reach === "out-workers"
1800
- ? _jsx(Glyph, { kind: "warn" })
1801
- : row.tier
1802
- ? _jsx(Glyph, { kind: "on" })
1803
- : _jsx(Glyph, { kind: "unknown" }), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: row.denied, children: [_jsx(Text, { dimColor: true, children: name.slice(0, prefixLen) }), _jsx(Text, { bold: selected, children: name.slice(prefixLen).padEnd(Math.max(nameW - prefixLen, 0)) })] }), _jsx(Text, { color: tier.color, dimColor: tier.dim, children: padCellStart(tier.text, 11) }), _jsx(Text, { dimColor: row.reach === "in", children: padCellStart(row.tier ? reachCell(row) : "", 12) }), _jsx(Text, { dimColor: true, children: padCellStart(fmtCtx(row.evidence?.contextWindow), 6) }), showPrice && _jsx(Text, { dimColor: priceCell(row) === "sub", children: padCellStart(priceCell(row), 12) }), showProbe && _jsx(Text, { dimColor: true, children: padCellStart(fmtMs(row.evidence?.probeMs), 7) })] }, `${row.adapter}:${row.model}`));
1917
+ return (_jsxs(Text, { wrap: "truncate", children: [_jsx(Pointer, { on: selected }), row.partial
1918
+ ? _jsx(Glyph, { kind: "warn" })
1919
+ : row.reach === "out-all" || row.reach === "out-allow"
1920
+ ? _jsx(Glyph, { kind: "off" })
1921
+ : row.evidence?.unauthed !== undefined || row.reach === "unknown"
1922
+ ? _jsx(Glyph, { kind: "fail" })
1923
+ : row.reach === "out-workers"
1924
+ ? _jsx(Glyph, { kind: "warn" })
1925
+ : row.tier
1926
+ ? _jsx(Glyph, { kind: "on" })
1927
+ : _jsx(Glyph, { kind: "unknown" }), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: row.denied, children: [_jsx(Text, { dimColor: true, children: name.slice(0, prefixLen) }), _jsx(Text, { bold: selected, children: name.slice(prefixLen).padEnd(Math.max(nameW - prefixLen, 0)) })] }), _jsx(Text, { color: tier.color, dimColor: tier.dim, children: padCellStart(tier.text, 11) }), _jsx(Text, { dimColor: row.reach === "in", children: padCellStart(row.tier ? reachCell(row) : "", 12) }), _jsx(Text, { dimColor: true, children: padCellStart(fmtCtx(row.evidence?.contextWindow), 6) }), showPrice && _jsx(Text, { dimColor: priceCell(row) === "sub", children: padCellStart(priceCell(row), 12) }), showProbe && _jsx(Text, { dimColor: true, children: padCellStart(fmtMs(row.evidence?.probeMs), 7) })] }, `${row.adapter}:${row.model}`));
1804
1928
  };
1805
1929
  const renderPlainRow = (id, label, selected, rowW = bodyW - 2) => (_jsxs(Text, { wrap: "truncate", children: [_jsx(Pointer, { on: selected }), _jsx(Text, { bold: selected, children: clip(label, rowW) })] }, id));
1806
1930
  const renderShapeRow = (row, selected, rowW = bodyW - 2) => {
@@ -1846,8 +1970,8 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
1846
1970
  if (overlay.kind === "classify") {
1847
1971
  const subject = overlay.bulk
1848
1972
  ? `${overlay.adapter} · ${overlay.bulk.rows.length} suggested models`
1849
- : overlay.displayModel
1850
- ? `${overlay.adapter}:${overlay.displayModel} · writes ${overlay.model}`
1973
+ : overlay.displayModel || classifiedMembers(overlay.adapter, overlay.model).length > 1
1974
+ ? `${overlay.adapter}:${overlay.displayModel ?? overlay.model} · writes ${classifiedMembers(overlay.adapter, overlay.displayModel ?? overlay.model).map((member) => member.model).join(", ")}`
1851
1975
  : `${overlay.adapter}:${overlay.model}`;
1852
1976
  return (_jsxs(OverlayPanel, { title: `classify · ${subject}`, width: bodyW, children: [overlay.stage === "channel" && (_jsxs(_Fragment, { children: [_jsxs(Text, { dimColor: true, children: ["first touch for ", overlay.adapter, " \u2014 how is this CLI billed?"] }), _jsx(Text, { children: " " }), CHANNELS.map((channel, index) => (_jsxs(Text, { children: [_jsx(Pointer, { on: overlay.channelAt === index }), _jsx(Text, { bold: overlay.channelAt === index, children: padCell(channel, 5) }), _jsx(Text, { dimColor: true, children: channel === "sub" ? "flat-rate subscription quota" : "metered API billing" })] }, channel)))] })), overlay.stage === "tier" && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: overlay.suggestion
1853
1977
  ? `catalog suggests ${overlay.suggestion.tier} — keep it and the provenance note arrives pre-typed`
@@ -1911,7 +2035,7 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
1911
2035
  const current = overlay.target.model === undefined
1912
2036
  ? adapterReach(overlay.target.adapter)
1913
2037
  : modelRows().find((row) => row.adapter === overlay.target.adapter && row.model === overlay.target.model) ?? { reach: "unknown", reasons: [] };
1914
- const partial = "partial" in current && current.partial;
2038
+ const partial = "partial" in current && current.partial === true;
1915
2039
  // D-205: a partial rail marks no choice as selected — its channels disagree
1916
2040
  const on = partial || current.reach === "unknown" ? null
1917
2041
  : current.reach === "out-all" || current.reach === "out-allow" ? "out-all" : current.reach;
@@ -2098,7 +2222,7 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
2098
2222
  ? _jsx(Text, { color: INK.warn, children: ` ! ${clip(ui.notice, width - 4)}` })
2099
2223
  : details.map((line) => _jsx(Text, { dimColor: true, children: ` ${clip(line, width - 2)}` }, line)), _jsx(KeyBar, { binds: keyBinds() })] }));
2100
2224
  }
2101
- export async function runFleetInkEditor({ ageMs, adapters, health, initialAllowOut = [], initialEfforts, modelGroups, initialMode, modeOptions, initialMap, modePreview, shapeRows, candidatesForShape, preferOptionsForShape, initialSteering, steeringOptionsFor, reviewAdvisories = [], reviewOverlay, reloadGuard, stagedRouting, holderOf, entry = "probe", initialJudge = "", judgeSeats = [], initialInput = [], input, output, debug = false, ...initialDenyProps }) {
2225
+ export async function runFleetInkEditor({ ageMs, adapters, health, initialAllowOut = [], initialEfforts, modelGroups, initialMode, modeOptions, initialMap, modePreview, shapeRows, candidatesForShape, preferOptionsForShape, initialSteering, steeringOptionsFor, reviewAdvisories = [], reviewOverlay, layerStamp, reloadGuard, stagedRouting, holderOf, entry = "probe", initialJudge = "", judgeSeats = [], initialInput = [], input, output, debug = false, ...initialDenyProps }) {
2102
2226
  const agents = adapters.flatMap((adapter) => {
2103
2227
  const state = health[adapter.id];
2104
2228
  if (!state?.installed)
@@ -2122,7 +2246,7 @@ export async function runFleetInkEditor({ ageMs, adapters, health, initialAllowO
2122
2246
  // rebuilt from output.columns/rows so a pane resize re-renders at the new size. Same root
2123
2247
  // element type on every rerender ⇒ React keeps the component instance, so staged edits,
2124
2248
  // cursor, and overlays all survive the resize.
2125
- const editorElement = () => (_jsx(FleetApp, { ageMs: ageMs, agents: agents, ...initialDenyProps, initialAllowOut: initialAllowOut, initialEfforts: initialEfforts, modelGroups: declaredModelGroups, initialMode: initialMode, modeOptions: modeOptions, initialMap: initialMap, modePreview: modePreview, shapeRows: shapeRows, candidatesForShape: candidatesForShape, preferOptionsForShape: preferOptionsForShape, initialSteering: initialSteering, steeringOptionsFor: steeringOptionsFor, reviewAdvisories: reviewAdvisories, reviewOverlay: reviewOverlay, reloadGuard: reloadGuard, stagedRouting: stagedRouting, holderOf: holderOf, entry: entry, initialJudge: initialJudge, judgeSeats: judgeSeats,
2249
+ const editorElement = () => (_jsx(FleetApp, { ageMs: ageMs, agents: agents, ...initialDenyProps, initialAllowOut: initialAllowOut, initialEfforts: initialEfforts, modelGroups: declaredModelGroups, initialMode: initialMode, modeOptions: modeOptions, initialMap: initialMap, modePreview: modePreview, shapeRows: shapeRows, candidatesForShape: candidatesForShape, preferOptionsForShape: preferOptionsForShape, initialSteering: initialSteering, steeringOptionsFor: steeringOptionsFor, reviewAdvisories: reviewAdvisories, reviewOverlay: reviewOverlay, layerStamp: layerStamp, reloadGuard: reloadGuard, stagedRouting: stagedRouting, holderOf: holderOf, entry: entry, initialJudge: initialJudge, judgeSeats: judgeSeats,
2126
2250
  // v1.90.9: cap every list to the terminal minus chrome so the cursor and header can
2127
2251
  // never scroll off-screen (omp's 218-model list did exactly that pre-cap).
2128
2252
  // OBS-523: chrome is ≤12 rows worst case (header, borders, padding, scope+search,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "2.6.6",
3
+ "version": "2.6.8",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",