tickmarkr 2.6.6 → 2.6.7

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 (41) 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 +6 -1
  6. package/dist/cli/commands/beat.js +123 -76
  7. package/dist/cli/commands/fleet.js +64 -21
  8. package/dist/cli/commands/init.js +18 -7
  9. package/dist/cli/help.d.ts +1 -0
  10. package/dist/cli/help.js +10 -5
  11. package/dist/cli/index.d.ts +1 -1
  12. package/dist/cli/index.js +1 -1
  13. package/dist/gates/baseline.d.ts +9 -0
  14. package/dist/gates/baseline.js +14 -3
  15. package/dist/gates/cache.js +1 -1
  16. package/dist/gates/review.js +15 -0
  17. package/dist/gates/run-gates.d.ts +18 -6
  18. package/dist/gates/run-gates.js +324 -204
  19. package/dist/gates/test-manifest.d.ts +8 -0
  20. package/dist/gates/test-manifest.js +151 -111
  21. package/dist/run/beat-lifecycle.d.ts +154 -0
  22. package/dist/run/beat-lifecycle.js +825 -0
  23. package/dist/run/daemon.js +18 -7
  24. package/dist/run/git-trust.d.ts +27 -0
  25. package/dist/run/git-trust.js +178 -0
  26. package/dist/run/git.js +34 -14
  27. package/dist/run/journal.js +15 -3
  28. package/dist/run/lease.d.ts +23 -0
  29. package/dist/run/lease.js +232 -38
  30. package/dist/run/merge.js +2 -2
  31. package/dist/run/stall.js +20 -1
  32. package/dist/run/supervision.d.ts +8 -0
  33. package/dist/run/supervision.js +24 -4
  34. package/dist/run/verification-job.d.ts +51 -0
  35. package/dist/run/verification-job.js +137 -0
  36. package/dist/tui/ink/fleet-app.d.ts +9 -0
  37. package/dist/tui/ink/fleet-app.js +209 -127
  38. package/package.json +1 -1
  39. package/skills/tickmarkr-auto/SKILL.md +19 -10
  40. package/skills/tickmarkr-loop/SKILL.md +19 -10
  41. package/skills/tickmarkr-overseer/SKILL.md +25 -16
@@ -183,20 +183,31 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
183
183
  }),
184
184
  };
185
185
  };
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 }))));
186
+ // T5: the real channels a displayed row stands for — every collapsed variant and catalog-fold
187
+ // constituent fleet derived from the cached doctor list, each with its own identity; a row without
188
+ // them stands for its folded ids, else its reference real id (classifyModel) or its own. Display labels
189
+ // are never channels.
190
+ const membersOf = (adapter, row) => {
191
+ const listed = row.members?.length ? row.members
192
+ : (row.foldedModels?.length ? row.foldedModels : [row.classifyModel ?? row.model]).map((model) => ({ model, identity: row.evidence?.identity }));
193
+ // one channel per real id: a repeated id is one member, so a grouped act takes ONE reason from it
194
+ return listed.filter((member, at) => listed.findIndex((other) => other.model === member.model) === at)
195
+ .map((member) => ({ adapter, model: member.model, ...(member.identity !== undefined ? { identity: member.identity } : {}) }));
196
+ };
197
+ // OBS-1099 review: a displayed row may fold several discovered gateway ids; each real member is a
198
+ // channel the policy matches on its own, so every one but a channel itself counts as a sibling —
199
+ // an entry covering another member is shared, never channel-owned (grouping never privatizes it)
200
+ const displayedChannels = () => modelGroups.flatMap((group) => group.rows.flatMap((other) => membersOf(group.adapter, other)));
192
201
  const siblingsOf = (self) => displayedChannels()
193
202
  .filter((other) => other.adapter !== self.adapter || other.model !== self.model);
194
203
  // 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) => {
204
+ // is a candidate, else the first candidate in sorted order; only an entry no sibling matches.
205
+ // T5: matched as production matches the list — a deny entry with family matching, an allow-complement
206
+ // entry exactly as routing.allow admits (`fake:C-low` never reaches a bare `fake:C` there)
207
+ const selectOwn = (entries, self, family = true) => {
197
208
  const others = siblingsOf(self);
198
- const candidates = [...entries].filter((entry) => entryMatchesChannel(entry, self, true)
199
- && !others.some((other) => entryMatchesChannel(entry, other, true)));
209
+ const candidates = [...entries].filter((entry) => entryMatchesChannel(entry, self, family)
210
+ && !others.some((other) => entryMatchesChannel(entry, other, family)));
200
211
  const id = `${self.adapter}:${self.model}`;
201
212
  return candidates.includes(id) ? id : candidates.sort()[0];
202
213
  };
@@ -286,11 +297,36 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
286
297
  const shared = isAdapterWide(scope.entry, self) || covers.length > 1;
287
298
  return { entry: scope.entry, scope: scope.configPath, covers, shared, edits: reachEdits(owned) };
288
299
  };
300
+ // T5: a grouped row's reach over EVERY real member — the whole-group state only when every member
301
+ // holds it; otherwise the most-excluded member's reach, marked partial, each member-specific reason
302
+ // prefixed with its real id. A one-member row is exactly its channel's reach.
303
+ const rowReach = (members) => {
304
+ const each = members.map((member) => ({ model: member.model, ...reachFor(member.adapter, member.model, member.identity) }));
305
+ if (each.length === 1) {
306
+ const { model: _model, ...only } = each[0];
307
+ return { ...only, partial: false };
308
+ }
309
+ const folded = foldReach(each);
310
+ const uniform = each.every((channel) => channel.reach === each[0].reach);
311
+ return {
312
+ reach: uniform ? each[0].reach : folded.reach,
313
+ reasons: folded.reasons,
314
+ partial: folded.partial,
315
+ uncoveredAlias: each.some((channel) => channel.uncoveredAlias),
316
+ held: [...new Set(each.flatMap((channel) => channel.held))],
317
+ };
318
+ };
319
+ // T5: the covering entry a grouped row names — a shared one first, so l owns it
320
+ const rowCovering = (members) => {
321
+ const found = members.map(coveringFor).filter((covering) => covering !== undefined);
322
+ return found.find((covering) => covering.shared) ?? found[0];
323
+ };
289
324
  const groupRows = (group) => {
290
325
  const rows = group.rows.map((row) => {
326
+ const members = membersOf(group.adapter, row);
291
327
  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);
328
+ && (classification.model === row.model || members.some((member) => member.model === classification.model)));
329
+ const reach = rowReach(members);
294
330
  return {
295
331
  adapter: group.adapter,
296
332
  model: row.model,
@@ -302,16 +338,17 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
302
338
  classifyModel: row.classifyModel,
303
339
  variants: row.variants,
304
340
  foldedModels: row.foldedModels,
341
+ ...(row.members ? { members: row.members } : {}),
305
342
  score: row.score,
306
343
  denied: reach.reach !== "in",
307
344
  ...reach,
308
345
  // OBS-1099 item 2 (review round 2): every row is covered the same way — an alias through
309
346
  // its identity, a plain row by a bare-model or adapter entry — so a shared bare-model
310
347
  // entry across adapters has the one owning control too
311
- covering: coveringFor({ adapter: group.adapter, model: row.model, identity: row.evidence?.identity }),
348
+ covering: rowCovering(members),
312
349
  };
313
350
  });
314
- const known = new Set(rows.flatMap((row) => row.classifyModel ? [row.model, row.classifyModel] : [row.model]));
351
+ const known = new Set(group.rows.flatMap((row) => [row.model, ...membersOf(group.adapter, row).map((member) => member.model)]));
315
352
  for (const staged of ui.classifications) {
316
353
  if (staged.adapter === group.adapter && !known.has(staged.model)) {
317
354
  const reach = reachFor(group.adapter, staged.model);
@@ -448,27 +485,29 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
448
485
  // its classification — the ownAllow spelling Space clears — so it can be admitted in this session.
449
486
  // Re-staging a classification replaces its seed; a row the allow form admits seeds nothing. A row a
450
487
  // 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 } : {}) };
488
+ // T5: one seed per REAL member of a grouped row (collapsed variants, catalog-fold constituents), keyed and
489
+ // spelled by its real id with its own identity — the display label never names a channel.
490
+ const seedAllow = (self, displayModel) => {
459
491
  const policy = stagedPolicy();
460
492
  if (!policy.ok || !exclusionsOf(self, policy.routing, "judge").some((scope) => scope.by === "allow"))
461
493
  return;
462
- const entry = selectOwn(ui.allowOut, self) ?? `${adapter}:${displayModel}`;
494
+ const entry = selectOwn(ui.allowOut, self, false) ?? `${self.adapter}:${self.model}`;
463
495
  ui.allowOut = new Set([...ui.allowOut, entry]);
464
496
  ui.seededAllowOut = [
465
- ...ui.seededAllowOut.filter((seed) => seed.adapter !== adapter || seed.model !== model),
466
- { adapter, model, displayModel, ...(identity !== undefined ? { identity } : {}), entry },
497
+ ...ui.seededAllowOut.filter((seed) => seed.adapter !== self.adapter || seed.model !== self.model),
498
+ { adapter: self.adapter, model: self.model, displayModel, ...(self.identity !== undefined ? { identity: self.identity } : {}), entry },
467
499
  ];
468
500
  };
501
+ // T5: the real members a classification of this browser row stages — a row the browser never listed
502
+ // (n adds one) is its own one member
503
+ const classifiedMembers = (adapter, displayModel) => {
504
+ const row = groupOf(adapter)?.rows.find((candidate) => candidate.model === displayModel);
505
+ return row ? membersOf(adapter, row) : [{ adapter, model: displayModel }];
506
+ };
469
507
  const stageSuggested = (adapter, rows, firstTouch) => {
470
- for (const row of rows)
471
- seedAllow(adapter, row.model, row.displayModel);
508
+ for (const row of rows) {
509
+ seedAllow({ adapter, model: row.model, ...(row.identity !== undefined ? { identity: row.identity } : {}) }, row.displayModel);
510
+ }
472
511
  ui.classifications = [
473
512
  ...ui.classifications,
474
513
  ...rows.map((row) => ({
@@ -521,18 +560,23 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
521
560
  }
522
561
  setOverlay({ ...base, stage: "channel", vendor: group.vendor });
523
562
  };
563
+ // T5: the picked tier and provenance land on EVERY real member of the row, each with its own seed —
564
+ // classifyModel stays the reference id the suggestion and the overlay title name
524
565
  const applyClassification = (overlay) => {
525
566
  const answered = overlay.vendor ? ui.channelByAdapter[overlay.adapter] : undefined;
526
- seedAllow(overlay.adapter, overlay.model, overlay.displayModel ?? overlay.model);
567
+ const displayModel = overlay.displayModel ?? overlay.model;
568
+ const members = classifiedMembers(overlay.adapter, displayModel);
569
+ for (const member of members)
570
+ seedAllow(member, displayModel);
527
571
  ui.classifications = [
528
572
  ...ui.classifications,
529
- {
573
+ ...members.map((member) => ({
530
574
  adapter: overlay.adapter,
531
- model: overlay.model,
575
+ model: member.model,
532
576
  tier: TIERS[overlay.tierAt],
533
577
  note: overlay.note.trim(),
534
578
  ...(overlay.vendor && answered ? { vendor: overlay.vendor, channel: answered } : {}),
535
- },
579
+ })),
536
580
  ];
537
581
  setOverlay(null);
538
582
  };
@@ -560,8 +604,9 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
560
604
  // channel-specific one carries its channel, so a partial exclusion is never intersected away.
561
605
  const adapterReach = (id) => {
562
606
  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) })));
607
+ // T5: every real member of every row — a collapsed or folded row's display label is no channel
608
+ const channels = (group?.rows ?? []).flatMap((row) => membersOf(id, row))
609
+ .map((channel) => ({ model: channel.model, ...reachFor(id, channel.model, channel.identity) }));
565
610
  if (channels.length === 0) {
566
611
  // ponytail: a rail entry with no probed channels has only its own literal entries to show
567
612
  const reasons = [
@@ -574,6 +619,11 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
574
619
  : NESTED_ADAPTER_SCOPES.some((scope) => ui[scope.key].has(id)) ? "out-workers" : "in";
575
620
  return { reach, reasons, partial: false };
576
621
  }
622
+ return foldReach(channels);
623
+ };
624
+ // the rail's (and a grouped row's) fold over its real channels — most-excluded reach, partial when they
625
+ // disagree, shared reasons once and channel-specific ones carrying their channel
626
+ function foldReach(channels) {
577
627
  const unknown = channels.find((channel) => channel.reach === "unknown");
578
628
  if (unknown)
579
629
  return { reach: "unknown", reasons: unknown.reasons, partial: false };
@@ -588,7 +638,7 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
588
638
  .filter((reason) => !shared.includes(reason))
589
639
  .map((reason) => `${channel.model}: ${reason}`));
590
640
  return { reach, reasons: [...shared, ...own], partial };
591
- };
641
+ }
592
642
  const describeReach = (state) => state.partial ? `${state.reach} (partial: not every channel)` : state.reach;
593
643
  // OBS-1099 items 1 and 4: one picker choice changes exactly ONE selected entry in ONE scope —
594
644
  // added, cleared, or re-scoped between the workers list and the flat list — then the row's
@@ -678,90 +728,106 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
678
728
  if (!row)
679
729
  return;
680
730
  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;
731
+ // T5: a grouped row's act is the same one-reason act on EVERY real member, each decided from the reach
732
+ // it held BEFORE this act — a sibling's fresh entry that also matches a member's recorded identity never
733
+ // turns that member's own addition into "already" (review D-1081) — while every removal still takes only
734
+ // an entry the member owns alone over the staged sets; a member a shared entry keeps out is named and left
735
+ const members = membersOf(row.adapter, row);
736
+ const before = members.map((self) => (members.length === 1
737
+ ? { reach: row.reach, reasons: row.reasons, covering: row.covering }
738
+ : { ...reachFor(self.adapter, self.model, self.identity), covering: coveringFor(self) }));
739
+ const outcomes = members.map((self, at) => {
740
+ const memberId = `${self.adapter}:${self.model}`;
741
+ const { reasons, covering } = before[at];
742
+ // LEG2-T3 round 2 finding 1 / D-233 / D-235 (review round 10): ONE ordered selector —
743
+ // ownedDenies — feeds the detail line AND every Space edit, so the entry named is the entry
744
+ // edited: a clear takes its head (own key first, then collector order); a demotion takes its
745
+ // first flat entry, a promotion its first workers entry; a bare adapter id or a sibling's
746
+ // entry is never in it. OBS-1046: an allow exclusion is the LAST reason taken, never together
747
+ // with an authored deny.
748
+ const edits = reachEdits(ownedDenies(self));
749
+ // review D-1081 add.: the seed this session generated for this member is its own even when a peer
750
+ // member's recorded identity also reaches it (C-high recorded as C-low) — never authored, and lifting
751
+ // it admits only this act's members; a seed any channel outside the row matches stays shared
752
+ const ownSeed = () => ui.seededAllowOut.find((seed) => seed.adapter === self.adapter && seed.model === self.model
753
+ && ui.allowOut.has(seed.entry)
754
+ && !siblingsOf(self).some((other) => !members.some((peer) => peer.adapter === other.adapter && peer.model === other.model)
755
+ && entryMatchesChannel(seed.entry, other, false)))?.entry;
756
+ const ownAllow = () => {
757
+ const fromAllow = selectOwn(ui.allowOut, self, false) ?? ownSeed();
758
+ return fromAllow === undefined ? undefined : { entry: fromAllow, scope: "routing.allow" };
759
+ };
760
+ const ownFlat = () => edits.demote ?? ownAllow();
761
+ const ownWorkers = () => edits.promote;
762
+ const takeFlat = (found) => {
763
+ const scope = scopeOf(found.scope);
764
+ if (scope)
765
+ ui[scope.key] = without(ui[scope.key], found.entry);
766
+ else
767
+ ui.allowOut = without(ui.allowOut, found.entry);
768
+ };
769
+ const refuse = (reachLabel) => {
770
+ // OBS-1099 item 2: a covered row names the covering entry, every identity it covers, and
771
+ // the ONE control that lifts it — the T1 refusal wording stays in front of it
772
+ if (covering?.shared) {
773
+ // the notice clips at the browser width; the detail line and the l overlay list every identity
774
+ 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` };
775
+ }
776
+ const shared = ui.denyAdapters.has(row.adapter) || ui.allowOut.has(row.adapter) || ui.denyWorkersAdapters.has(row.adapter)
777
+ ? `every ${row.adapter} channel is out together — set the adapter's reach on the rail`
778
+ : "a shared entry covers other channels too";
779
+ return { refused: `${memberId} stays ${reachLabel} — ${reasons.join("; ")} — Space edits only this channel's own entries; ${shared}` };
780
+ };
781
+ const reach = before[at].reach;
782
+ const outAll = reach === "out-all" || reach === "out-allow";
783
+ if (choice === "out-workers" && reach === "in") {
784
+ ui.denyWorkersModels = withEntry(ui.denyWorkersModels, memberId);
785
+ return { edit: `added ${memberId} to routing.deny.workers.models` };
709
786
  }
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) {
787
+ else if (choice === "out-workers" && outAll) {
788
+ const found = ownFlat();
789
+ if (found === undefined)
790
+ return refuse("out");
751
791
  takeFlat(found);
752
- edit = `cleared ${found.entry} from ${found.scope}`;
792
+ ui.denyWorkersModels = withEntry(ui.denyWorkersModels, found.entry);
793
+ return { edit: `moved ${found.entry} to routing.deny.workers.models` };
753
794
  }
754
- else {
755
- return refuse(outAll ? "out" : "out · workers");
795
+ else if (choice === "out-all" && reach === "in") {
796
+ ui.denyModels = withEntry(ui.denyModels, memberId);
797
+ return { edit: `added ${memberId} to routing.deny.models` };
756
798
  }
757
- }
758
- else {
759
- ui.notice = `${id} is already ${label}`;
799
+ else if (choice === "out-all" && reach === "out-workers") {
800
+ // OBS-1099 review: a promotion, like a clear, takes only an entry no sibling row matches —
801
+ // a shared workers entry (adapter-wide or a bare id another row serves) is refused by name,
802
+ // never moved for its siblings and never doubled by a fresh flat deny
803
+ const found = ownWorkers();
804
+ if (found === undefined)
805
+ return refuse("out · workers");
806
+ takeFlat(found);
807
+ ui.denyModels = withEntry(ui.denyModels, found.entry);
808
+ return { edit: `moved ${found.entry} to routing.deny.models` };
809
+ }
810
+ else if (choice === "in" && reach !== "in") {
811
+ // D-205: in clears ONE row-owned reason from either the flat scopes or the workers scope —
812
+ // a shared flat reason (routing.deny.adapters covering every channel) never blocks clearing
813
+ // this row's own workers entry; the recomputed reach then names the shared reason that remains
814
+ const found = edits.clear ?? (outAll ? ownAllow() : undefined);
815
+ if (found === undefined)
816
+ return refuse(outAll ? "out" : "out · workers");
817
+ takeFlat(found);
818
+ return { edit: `cleared ${found.entry} from ${found.scope}` };
819
+ }
820
+ return { already: true };
821
+ });
822
+ const made = outcomes.flatMap((outcome) => ("edit" in outcome ? [outcome.edit] : []));
823
+ const refused = outcomes.flatMap((outcome) => ("refused" in outcome ? [outcome.refused] : []));
824
+ if (!made.length) {
825
+ ui.notice = refused[0] ?? `${id} is already ${label}`;
760
826
  return;
761
827
  }
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}` };
828
+ const after = rowReach(members);
829
+ const still = reached(after.reach, choice) && !after.partial ? "" : ` — still out: ${after.reasons.join("; ")}`;
830
+ ui.lastEdit = { id, text: `space: ${made.join("; ")}${still}` };
765
831
  };
766
832
  // OBS-1065: Enter on a greyed picker row lifts exactly its row-owned covering entry, then the
767
833
  // same picker reopens over the recomputed policy — a shared or adapter-wide entry is refused by
@@ -1588,7 +1654,13 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
1588
1654
  const group = modelGroups[ui.adapterAt];
1589
1655
  const suggested = rows
1590
1656
  .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 }));
1657
+ // T5: every real member of each suggested row stages, never only its reference id
1658
+ .flatMap((candidate) => membersOf(candidate.adapter, candidate).map((member) => ({
1659
+ model: member.model,
1660
+ displayModel: candidate.model,
1661
+ ...(member.identity !== undefined ? { identity: member.identity } : {}),
1662
+ suggestion: candidate.suggestion,
1663
+ })));
1592
1664
  if (!suggested.length) {
1593
1665
  ui.notice = "no catalog tier suggestions among the visible unclassified models — evidence comes from the cached catalogs (AA index / API pricing)";
1594
1666
  bump();
@@ -1722,6 +1794,9 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
1722
1794
  return "unauthed — re-probe with tickmarkr doctor (Space does not toggle reach)";
1723
1795
  // T10: the reasons, then where each scope is held — the overlay a reviewed w writes
1724
1796
  const reasons = `${row.reasons.join("; ")}${row.held.length ? ` — held: ${row.held.join("; ")}` : ""}`;
1797
+ // T5: members disagree — never a whole-group state; each member-specific reason names its real id
1798
+ if (row.partial)
1799
+ return `reach: partial — ${row.reach} for some members only — ${reasons} — Space applies one choice to every member`;
1725
1800
  if (row.reach === "out-all")
1726
1801
  return `reach: out · all seats — ${reasons} — Space picks in or out · workers`;
1727
1802
  if (row.reach === "out-allow")
@@ -1732,7 +1807,7 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
1732
1807
  return `reach: unknown — ${reasons}`;
1733
1808
  return "reach: in — Space picks out · workers or out · all seats";
1734
1809
  };
1735
- const reachCell = (row) => row.reach === "in" || row.reach === "unknown" ? row.reach : `out ${row.reach === "out-workers" ? "workers" : row.reach.slice(4)}`;
1810
+ 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
1811
  const identityDetail = (row) => {
1737
1812
  if (row.evidence?.identity === undefined)
1738
1813
  return "";
@@ -1764,11 +1839,16 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
1764
1839
  `${row.adapter}:${row.model}`,
1765
1840
  row.tier ?? "unclassified",
1766
1841
  ui.efforts[row.adapter]?.[row.model] ? `effort ${ui.efforts[row.adapter][row.model]}` : "",
1842
+ // T5: a grouped row names each member doctor recorded unauthed — up front, before the line clips
1843
+ ...(row.evidence?.unauthed === undefined ? (row.members ?? []).filter((member) => member.unauthed !== undefined)
1844
+ .map((member) => `${row.adapter}:${member.model} UNAUTHED — ${member.unauthed} — re-probe with tickmarkr doctor`) : []),
1767
1845
  ui.lastEdit?.id === `${row.adapter}:${row.model}` ? ui.lastEdit.text : "",
1768
1846
  identityDetail(row),
1769
1847
  row.tier ? reachDetail(row) : "",
1770
1848
  row.variants?.length ? `variants ${row.variants.join(", ")}` : "",
1771
- row.classifyModel ? `classify writes ${row.classifyModel}` : "",
1849
+ // T5: a grouped row classifies (and edits) every real member; classifyModel is its reference id
1850
+ row.members?.length ? `classify writes ${row.members.map((member) => member.model).join(", ")}`
1851
+ : row.classifyModel ? `classify writes ${row.classifyModel}` : "",
1772
1852
  row.foldedModels?.length ? `${row.foldedModels.length} gateway ids folded: ${row.foldedModels.join(", ")}` : "",
1773
1853
  row.evidence?.unauthed !== undefined ? `UNAUTHED — ${row.evidence.unauthed}` : "",
1774
1854
  row.evidence?.contextWindow !== undefined ? `${fmtCtx(row.evidence.contextWindow)} ctx` : "",
@@ -1792,15 +1872,17 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
1792
1872
  const folded = row.foldedModels?.length ?? 1;
1793
1873
  const name = clipPathTail(`${row.adapter}/${row.model}${folded > 1 ? ` ×${folded}` : ""}`, nameW);
1794
1874
  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}`));
1875
+ return (_jsxs(Text, { wrap: "truncate", children: [_jsx(Pointer, { on: selected }), row.partial
1876
+ ? _jsx(Glyph, { kind: "warn" })
1877
+ : row.reach === "out-all" || row.reach === "out-allow"
1878
+ ? _jsx(Glyph, { kind: "off" })
1879
+ : row.evidence?.unauthed !== undefined || row.reach === "unknown"
1880
+ ? _jsx(Glyph, { kind: "fail" })
1881
+ : row.reach === "out-workers"
1882
+ ? _jsx(Glyph, { kind: "warn" })
1883
+ : row.tier
1884
+ ? _jsx(Glyph, { kind: "on" })
1885
+ : _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
1886
  };
1805
1887
  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
1888
  const renderShapeRow = (row, selected, rowW = bodyW - 2) => {
@@ -1846,8 +1928,8 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
1846
1928
  if (overlay.kind === "classify") {
1847
1929
  const subject = overlay.bulk
1848
1930
  ? `${overlay.adapter} · ${overlay.bulk.rows.length} suggested models`
1849
- : overlay.displayModel
1850
- ? `${overlay.adapter}:${overlay.displayModel} · writes ${overlay.model}`
1931
+ : overlay.displayModel || classifiedMembers(overlay.adapter, overlay.model).length > 1
1932
+ ? `${overlay.adapter}:${overlay.displayModel ?? overlay.model} · writes ${classifiedMembers(overlay.adapter, overlay.displayModel ?? overlay.model).map((member) => member.model).join(", ")}`
1851
1933
  : `${overlay.adapter}:${overlay.model}`;
1852
1934
  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
1935
  ? `catalog suggests ${overlay.suggestion.tier} — keep it and the provenance note arrives pre-typed`
@@ -1911,7 +1993,7 @@ export function FleetApp({ ageMs, agents, initialAllowOut = [], initialEfforts =
1911
1993
  const current = overlay.target.model === undefined
1912
1994
  ? adapterReach(overlay.target.adapter)
1913
1995
  : modelRows().find((row) => row.adapter === overlay.target.adapter && row.model === overlay.target.model) ?? { reach: "unknown", reasons: [] };
1914
- const partial = "partial" in current && current.partial;
1996
+ const partial = "partial" in current && current.partial === true;
1915
1997
  // D-205: a partial rail marks no choice as selected — its channels disagree
1916
1998
  const on = partial || current.reach === "unknown" ? null
1917
1999
  : current.reach === "out-all" || current.reach === "out-allow" ? "out-all" : current.reach;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "2.6.6",
3
+ "version": "2.6.7",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -113,16 +113,25 @@ For `resume`, use the same host-owned split form with `tickmarkr resume <runId>`
113
113
  in the process table to the host PTY; verify no agent session is an ancestor. If an agent is
114
114
  an ancestor, stop that launch and relaunch through the host split before continuing.
115
115
 
116
- Start `tickmarkr beat <tier> --seat <seat> --loop` as a DETACHED background process, one per
117
- (tier, seat), from the repository root. Use setsid plus nohup (or the host's equivalent detached
118
- session facility), redirect output to a file, and record a pidfile under the repository's state
119
- dir. Verify ppid 1 and no agent-session ancestor. Never put beats in a harness background task
120
- or a visible tab. The lifecycle must be idempotent: start checks the pidfile against the live
121
- process payload and returns an "already running" no-op for the same (repo, tier, seat); status
122
- checks that payload and beat freshness; stop sends `--stand-down` for the same tier and seat,
123
- verifies the loop exits within one interval, then removes its pidfile. A stale or reused pid
124
- must never be killed without matching its live payload. Include a run-end stop item for every
125
- beat started, also on failure, park or handoff; verify DISARMED and remove retired beat files.
116
+ Detached beats are product-owned, one per (tier, seat), from the repository root, through the
117
+ shipped lifecycle verbs — never a hand-rolled setsid plus nohup wrapper, pidfile or shell loop:
118
+
119
+ ```bash
120
+ cd <repo> && tickmarkr beat start <tier> --seat <seat>
121
+ cd <repo> && tickmarkr beat status <tier> --seat <seat>
122
+ cd <repo> && tickmarkr beat stop <tier> --seat <seat>
123
+ ```
124
+
125
+ `start` launches the `--loop` child in its own session, logs to `<state-dir>/supervision/<tier>.log`,
126
+ records its pid, birth, argv and cwd, and exits 0 only after it reads back that exact child's own
127
+ advancing beat. A repeat start for the same (tier, seat) is an "already running" no-op; a stale,
128
+ dead, foreign, reused-pid, unreadable or busy tier is a nonzero refusal that changes nothing.
129
+ Verify ppid 1 is never proof on its own — an orphan of a dying session shows it too; `status`
130
+ is the check: it reads the recorded process identity and beat freshness, writes nothing, and exits
131
+ nonzero for STALE, MISMATCH or UNREADABLE. `stop` signals only that recorded pid after its
132
+ generation and identity still match, and reads back DISARMED. Never put beats in a harness
133
+ background task or a visible tab. Include a run-end `beat stop` item for every beat started, also
134
+ on failure, park or handoff, and require its DISARMED read-back.
126
135
 
127
136
  Keep only watchers that must WAKE the seat in the harness. Re-arm them on each wake and at
128
137
  their harness cap; daemon and beat lifetimes must not depend on that cap.
@@ -108,16 +108,25 @@ For `resume`, use the same host-owned split form with `tickmarkr resume <runId>`
108
108
  in the process table to the host PTY; verify no agent session is an ancestor. If an agent is
109
109
  an ancestor, stop that launch and relaunch through the host split before continuing.
110
110
 
111
- Start `tickmarkr beat <tier> --seat <seat> --loop` as a DETACHED background process, one per
112
- (tier, seat), from the repository root. Use setsid plus nohup (or the host's equivalent detached
113
- session facility), redirect output to a file, and record a pidfile under the repository's state
114
- dir. Verify ppid 1 and no agent-session ancestor. Never put beats in a harness background task
115
- or a visible tab. The lifecycle must be idempotent: start checks the pidfile against the live
116
- process payload and returns an "already running" no-op for the same (repo, tier, seat); status
117
- checks that payload and beat freshness; stop sends `--stand-down` for the same tier and seat,
118
- verifies the loop exits within one interval, then removes its pidfile. A stale or reused pid
119
- must never be killed without matching its live payload. Include a run-end stop item for every
120
- beat started, also on failure, park or handoff; verify DISARMED and remove retired beat files.
111
+ Detached beats are product-owned, one per (tier, seat), from the repository root, through the
112
+ shipped lifecycle verbs — never a hand-rolled setsid plus nohup wrapper, pidfile or shell loop:
113
+
114
+ ```bash
115
+ cd <repo> && tickmarkr beat start <tier> --seat <seat>
116
+ cd <repo> && tickmarkr beat status <tier> --seat <seat>
117
+ cd <repo> && tickmarkr beat stop <tier> --seat <seat>
118
+ ```
119
+
120
+ `start` launches the `--loop` child in its own session, logs to `<state-dir>/supervision/<tier>.log`,
121
+ records its pid, birth, argv and cwd, and exits 0 only after it reads back that exact child's own
122
+ advancing beat. A repeat start for the same (tier, seat) is an "already running" no-op; a stale,
123
+ dead, foreign, reused-pid, unreadable or busy tier is a nonzero refusal that changes nothing.
124
+ Verify ppid 1 is never proof on its own — an orphan of a dying session shows it too; `status`
125
+ is the check: it reads the recorded process identity and beat freshness, writes nothing, and exits
126
+ nonzero for STALE, MISMATCH or UNREADABLE. `stop` signals only that recorded pid after its
127
+ generation and identity still match, and reads back DISARMED. Never put beats in a harness
128
+ background task or a visible tab. Include a run-end `beat stop` item for every beat started, also
129
+ on failure, park or handoff, and require its DISARMED read-back.
121
130
 
122
131
  Keep only watchers that must WAKE the seat in the harness. Re-arm them on each wake and at
123
132
  their harness cap; daemon and beat lifetimes must not depend on that cap.