@event4u/agent-config 12.1.0 → 14.0.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 (185) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +164 -186
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/cost/report.md +23 -0
  5. package/dist/agent-src/commands/evals/roadmap.json +8 -0
  6. package/dist/agent-src/commands/fix/ci.md +39 -1
  7. package/dist/agent-src/commands/pr/create.md +65 -4
  8. package/dist/agent-src/commands/roadmap/next.md +28 -5
  9. package/dist/agent-src/commands/work.md +16 -0
  10. package/dist/agent-src/contexts/contracts/command-suggestion-flow.md +47 -0
  11. package/dist/agent-src/contexts/execution/contract-decision-sheet.md +94 -0
  12. package/dist/agent-src/contexts/execution/plan-confidence-gate.md +30 -0
  13. package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +21 -8
  14. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +55 -8
  15. package/dist/agent-src/contexts/execution/subagent-response-contract.md +42 -0
  16. package/dist/agent-src/contexts/execution/subagent-routing.md +148 -33
  17. package/dist/agent-src/contexts/execution/subagent-spawn-contract.md +29 -0
  18. package/dist/agent-src/contexts/subagent-configuration.md +69 -7
  19. package/dist/agent-src/rules/autonomous-execution.md +1 -1
  20. package/dist/agent-src/rules/command-suggestion-policy.md +33 -0
  21. package/dist/agent-src/rules/design-fidelity.md +1 -1
  22. package/dist/agent-src/rules/design-review-after-ui-write.md +36 -14
  23. package/dist/agent-src/rules/evaluator-independence.md +27 -5
  24. package/dist/agent-src/rules/git-history-discipline.md +5 -1
  25. package/dist/agent-src/rules/missing-skill-recovery.md +89 -0
  26. package/dist/agent-src/rules/ui-audit-gate.md +34 -12
  27. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
  28. package/dist/agent-src/scripts/gate_execute.ts +395 -0
  29. package/dist/agent-src/scripts/resume_probe.ts +422 -0
  30. package/dist/agent-src/scripts/roadmap_gates.ts +138 -4
  31. package/dist/agent-src/scripts/update_roadmap_progress.ts +70 -4
  32. package/dist/agent-src/skills/ai-council/SKILL.md +61 -0
  33. package/dist/agent-src/skills/authz-review/SKILL.md +4 -0
  34. package/dist/agent-src/skills/design-system-capture/SKILL.md +4 -0
  35. package/dist/agent-src/skills/design-system-capture/references/snapshot-preference-order.md +77 -0
  36. package/dist/agent-src/skills/experiment-loop/SKILL.md +116 -0
  37. package/dist/agent-src/skills/experiment-loop/evals/output-schema.yml +27 -0
  38. package/dist/agent-src/skills/experiment-loop/evals/triggers.json +17 -0
  39. package/dist/agent-src/skills/experiment-loop/references/pivot-ladder.md +54 -0
  40. package/dist/agent-src/skills/experiment-loop/references/protocol.md +61 -0
  41. package/dist/agent-src/skills/experiment-loop/references/register-format.md +66 -0
  42. package/dist/agent-src/skills/merge-conflicts/SKILL.md +4 -0
  43. package/dist/agent-src/skills/prompt-optimizer/SKILL.md +17 -1
  44. package/dist/agent-src/skills/roadmap-management/SKILL.md +2 -2
  45. package/dist/agent-src/skills/roadmap-writing/SKILL.md +5 -5
  46. package/dist/agent-src/skills/subagent-orchestration/schemas/team-review-status.json +8 -3
  47. package/dist/agent-src/skills/systematic-debugging/SKILL.md +4 -0
  48. package/dist/agent-src/skills/threat-modeling/SKILL.md +3 -0
  49. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  50. package/dist/agent-src/templates/roadmaps.md +43 -3
  51. package/dist/cli/registry.js +1 -0
  52. package/dist/cli/registry.js.map +1 -1
  53. package/dist/cli-delegate/{chunk-K34QTSD5.js → chunk-7PZ4FSZB.js} +368 -191
  54. package/dist/cli-delegate/{chunk-55WZN5VF.js → chunk-EVTY7ISR.js} +2 -2
  55. package/dist/cli-delegate/{chunk-O52B4F5S.js → chunk-F3YUF7SA.js} +1 -1
  56. package/dist/cli-delegate/{chunk-LKZ5DOYW.js → chunk-XURJ4AQC.js} +11 -3
  57. package/dist/cli-delegate/cmd_conformance.js +224 -104
  58. package/dist/cli-delegate/cmd_doctor.js +2 -2
  59. package/dist/cli-delegate/cmd_export.js +2 -2
  60. package/dist/cli-delegate/cmd_handoff.js +1 -1
  61. package/dist/cli-delegate/cmd_prune.js +2 -2
  62. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  63. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  64. package/dist/cli-delegate/cmd_sync.js +2 -2
  65. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  66. package/dist/cli-delegate/cmd_validate.js +2 -2
  67. package/dist/discovery/deprecation-report.md +1 -1
  68. package/dist/discovery/discovery-manifest.json +73 -30
  69. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  70. package/dist/discovery/discovery-manifest.summary.md +3 -3
  71. package/dist/discovery/dormancy-report.md +1 -1
  72. package/dist/discovery/orphan-report.md +1 -1
  73. package/dist/discovery/packs.json +7 -5
  74. package/dist/discovery/trust-report.md +3 -3
  75. package/dist/discovery/workspaces.json +6 -4
  76. package/dist/hooks/dispatch.js +2653 -1365
  77. package/dist/install/install.mjs +12 -4
  78. package/dist/mcp/registry-manifest.json +3 -3
  79. package/dist/mcp/server.json +2 -2
  80. package/dist/mcp/server.mjs +2679 -887
  81. package/dist/router.json +1 -1
  82. package/dist/scripts/condense.js +1 -1
  83. package/dist/scripts/condense.js.map +1 -1
  84. package/dist/server/schemas/settings.js +2 -2
  85. package/dist/server/schemas/settings.js.map +1 -1
  86. package/dist/ui/assets/{index-B0aMJnuz.js → index-C1pBRm1f.js} +2 -2
  87. package/dist/ui/assets/index-C1pBRm1f.js.map +1 -0
  88. package/dist/ui/index.html +1 -1
  89. package/docs/guidelines/design-fidelity-mechanics.md +21 -0
  90. package/docs/guidelines/design-handover-extraction.md +87 -0
  91. package/docs/guidelines/prompt-templates.md +24 -1
  92. package/package.json +1 -1
  93. package/src/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
  94. package/src/agent-src/scripts/gate_execute.ts +395 -0
  95. package/src/agent-src/scripts/resume_probe.ts +422 -0
  96. package/src/agent-src/scripts/roadmap_gates.ts +138 -4
  97. package/src/agent-src/scripts/update_roadmap_progress.ts +70 -4
  98. package/src/config/ci-local-parity.yml +13 -0
  99. package/src/config/evaluator-budgets.json +9 -8
  100. package/src/config/gate-coverage.yml +140 -12
  101. package/src/config/gate-violation-baselines.json +9 -3
  102. package/src/config/hook-token-budget.json +8 -1
  103. package/src/config/rule-activation-census.json +41 -0
  104. package/src/config/rule-stub-ceilings.json +52 -0
  105. package/src/scripts/_dispatch.bash +4 -0
  106. package/src/scripts/_lib/bench_ab_complexity.ts +439 -0
  107. package/src/scripts/_lib/bench_ab_safety_tier.ts +142 -0
  108. package/src/scripts/_lib/bench_ab_scoring_v2.ts +28 -3
  109. package/src/scripts/_lib/bench_ab_search_adherence.ts +423 -0
  110. package/src/scripts/_lib/bench_ab_size_claim.ts +206 -0
  111. package/src/scripts/_lib/bench_ab_workspace.ts +111 -0
  112. package/src/scripts/_lib/evaluator_contract.ts +159 -0
  113. package/src/scripts/_lib/gh_transient.ts +80 -0
  114. package/src/scripts/_lib/release_material.ts +16 -5
  115. package/src/scripts/_lib/review_independence.ts +111 -0
  116. package/src/scripts/_lib/session_register.ts +14 -0
  117. package/src/scripts/_lib/skill_catalogue.ts +278 -5
  118. package/src/scripts/_lib/skill_catalogue_series.ts +411 -0
  119. package/src/scripts/_lib/tier_budget_routing.ts +31 -331
  120. package/src/scripts/_lib/turn_end_refusals.ts +482 -0
  121. package/src/scripts/ai_council/_py_json.ts +96 -0
  122. package/src/scripts/ai_council/cli_call_budget.ts +357 -0
  123. package/src/scripts/ai_council/clients.ts +101 -164
  124. package/src/scripts/ai_council/config.ts +25 -11
  125. package/src/scripts/ai_council/probe_store.ts +196 -0
  126. package/src/scripts/ai_council/qualification.ts +425 -0
  127. package/src/scripts/ai_council/qualification_wiring.ts +243 -0
  128. package/src/scripts/ai_council/quorum_wiring.ts +211 -0
  129. package/src/scripts/ai_team/team_dispatch.ts +40 -6
  130. package/src/scripts/bench_ab_v2_complexity.ts +252 -0
  131. package/src/scripts/bench_ab_v2_run.ts +3 -42
  132. package/src/scripts/bench_ab_v2_safety.ts +296 -0
  133. package/src/scripts/bench_ab_v2_search.ts +343 -0
  134. package/src/scripts/bench_ab_v2_stats.ts +191 -0
  135. package/src/scripts/build_archive_index.ts +383 -0
  136. package/src/scripts/capture_skill_catalogue.ts +369 -4
  137. package/src/scripts/check_evaluator_schema.ts +249 -0
  138. package/src/scripts/check_pr_ci_current.ts +409 -0
  139. package/src/scripts/check_review_schema.ts +195 -0
  140. package/src/scripts/check_rule_activation_census.ts +451 -0
  141. package/src/scripts/check_rule_stub_ceiling.ts +1128 -0
  142. package/src/scripts/check_source_size_budget.ts +428 -0
  143. package/src/scripts/check_suppression_hygiene.ts +5 -1
  144. package/src/scripts/condense.ts +1 -1
  145. package/src/scripts/cost/backfill_rates.mjs +298 -0
  146. package/src/scripts/cost/budget.mjs +12 -32
  147. package/src/scripts/cost_summary.ts +25 -0
  148. package/src/scripts/council_cli.ts +141 -191
  149. package/src/scripts/dispatch_r2_reviewer.ts +8 -1
  150. package/src/scripts/external_sources_denylist.json +1 -0
  151. package/src/scripts/hook_manifest.yaml +99 -6
  152. package/src/scripts/hooks/concern_registry.ts +6 -0
  153. package/src/scripts/hooks/dispatch_hook.ts +43 -1
  154. package/src/scripts/hooks/interruption_ledger_hook.ts +256 -0
  155. package/src/scripts/hooks/ship_diff_volume_hook.ts +180 -0
  156. package/src/scripts/hooks/skill_route_hook.ts +226 -0
  157. package/src/scripts/hooks/turn_end_gate_hook.ts +119 -26
  158. package/src/scripts/hooks/ui_route_nudge_hook.ts +6 -3
  159. package/src/scripts/hooks_doctor.ts +112 -0
  160. package/src/scripts/interruption_report.ts +443 -0
  161. package/src/scripts/lint_design_antipattern_parity.ts +40 -6
  162. package/src/scripts/lint_evidence_artifacts.ts +359 -0
  163. package/src/scripts/lint_harvest_provenance.ts +35 -1
  164. package/src/scripts/lint_roadmap_blockers.ts +65 -0
  165. package/src/scripts/lint_roadmap_complexity.ts +7 -1
  166. package/src/scripts/lint_rule_skill_pack_reach.ts +43 -0
  167. package/src/scripts/lint_skill_router_head.ts +21 -0
  168. package/src/scripts/mcp_server/consumer_tool_catalog.json +26 -3
  169. package/src/scripts/mcp_server/tool_catalog_source.ts +6 -24
  170. package/src/scripts/mcp_server/tools.ts +87 -0
  171. package/src/scripts/release.ts +43 -58
  172. package/src/scripts/routing_doctor.ts +50 -13
  173. package/src/scripts/rule_activation_census.ts +292 -0
  174. package/src/scripts/schemas/command.schema.json +1 -1
  175. package/src/scripts/schemas/evaluator-output.schema.json +60 -0
  176. package/src/scripts/schemas/review-findings.schema.json +31 -0
  177. package/src/scripts/schemas/skill.schema.json +18 -1
  178. package/src/scripts/self_review_gate.ts +6 -0
  179. package/src/scripts/session_register_hook.ts +30 -1
  180. package/src/scripts/sync_pr_branch.ts +271 -0
  181. package/src/scripts/test_council_qualification.ts +213 -0
  182. package/src/scripts/trigger_coverage.ts +224 -3
  183. package/src/server/schemas/settings.ts +2 -2
  184. package/dist/ui/assets/index-B0aMJnuz.js.map +0 -1
  185. package/src/config/budget-routing.json +0 -7
@@ -80,6 +80,27 @@ import * as path from 'node:path';
80
80
  /** Catalogue roots tried in order; the first that exists wins. */
81
81
  export const DEFAULT_CATALOGUE_ROOTS = ['.claude/skills', 'src/skills'] as const;
82
82
 
83
+ /**
84
+ * First existing catalogue root under `workspaceRoot`, or `null`.
85
+ *
86
+ * `.claude/skills` before `src/skills` because a CONSUMER install carries the
87
+ * former and only a maintainer checkout carries the latter. Shared by the
88
+ * `skill-route` concern and the `suggest_skill_for_task` MCP handler on
89
+ * purpose: two resolvers over one catalogue is how a ranker and the tool that
90
+ * exposes it start ranking different trees.
91
+ */
92
+ export function resolveSkillsRoot(workspaceRoot: string): string | null {
93
+ for (const candidate of DEFAULT_CATALOGUE_ROOTS) {
94
+ const abs = path.join(workspaceRoot, candidate);
95
+ try {
96
+ if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) return abs;
97
+ } catch {
98
+ // Unreadable candidate is not a match; try the next.
99
+ }
100
+ }
101
+ return null;
102
+ }
103
+
83
104
  /** Where observations accumulate. Append-only, one JSON object per line. */
84
105
  export const OBSERVATION_LOG = path.join(
85
106
  'agents',
@@ -356,12 +377,27 @@ export function analyzeSelector(
356
377
  * `budget-strip-and-drop` — the host strips EVERY description, then drops N
357
378
  * entries from the list entirely. There is no per-entry selector to find; the
358
379
  * quantity that matters is N.
380
+ *
381
+ * `none` — the host truncated in NO way on this run: every entry arrived and
382
+ * every description survived. It is a mode a record must be able to state
383
+ * explicitly, because the alternative is leaving the field absent, and absent
384
+ * reads as `per-entry` — a mode this run did not exercise. A `none` record is
385
+ * the only shape that can say "the same host that strips today did not strip
386
+ * here" without inventing a mechanism.
359
387
  */
360
- export type TruncationMode = 'per-entry' | 'budget-strip-and-drop';
388
+ export type TruncationMode = 'per-entry' | 'budget-strip-and-drop' | 'none';
361
389
 
362
390
  /** Where the observation half came from. See the header. */
363
391
  export type ObservationSource = 'self-report' | 'host-event';
364
392
 
393
+ /**
394
+ * The projection mode the observed install was deployed under.
395
+ *
396
+ * A closed enum, never a free-form label: the record type's privacy contract
397
+ * is that no field can hold arbitrary text, and this one is no exception.
398
+ */
399
+ export type ProjectionMode = 'scoped' | 'legacy-all';
400
+
365
401
  /** One append-only observation record. No field can hold free-form content. */
366
402
  export interface ObservationRecord {
367
403
  schema: 1;
@@ -390,6 +426,15 @@ export interface ObservationRecord {
390
426
  * fire on command growth that causes no truncation at all.
391
427
  */
392
428
  projected_skill_count?: number;
429
+ /**
430
+ * Projection mode the observed install was deployed under.
431
+ *
432
+ * Absent on every record written before the mode was captured, and absence
433
+ * is NOT `legacy-all`: those observations were taken without asking, so
434
+ * defaulting them either way would relabel a reading nobody took. A
435
+ * consumer comparing modes must skip a record that carries none.
436
+ */
437
+ projection_mode?: ProjectionMode;
393
438
  /**
394
439
  * Entries the host dropped wholesale, read from the host's own event.
395
440
  * Only ever present on a `budget-strip-and-drop` record — a `per-entry`
@@ -409,10 +454,27 @@ export function observationSourceOf(record: ObservationRecord): ObservationSourc
409
454
  return record.observation_source ?? 'self-report';
410
455
  }
411
456
 
457
+ /**
458
+ * The self-report path's record.
459
+ *
460
+ * `projectedSkillCount` and `projectionMode` are optional in the SAME shape the
461
+ * two host-event builders use — omitted rather than defaulted, because absence
462
+ * is not `legacy-all` and never was (see `ObservationRecord.projection_mode`).
463
+ *
464
+ * They were missing here until 2026-08-18, and the omission was load-bearing in
465
+ * one direction: the self-report path is the ONLY one that fills `bare_names`,
466
+ * so it is the only source the pointable-but-bare join below can read — and
467
+ * every claude observation in the corpus therefore carried no scope at all,
468
+ * while the codex records beside them did. A series that changes scope
469
+ * mid-flight without recording it is not comparable, which is exactly what
470
+ * claim 7 of the roadmap that asked for the field says.
471
+ */
412
472
  export function buildObservationRecord(
413
473
  report: SelectorReport,
414
474
  host: string,
415
475
  observedAt: string,
476
+ projectedSkillCount?: number,
477
+ projectionMode?: ProjectionMode,
416
478
  ): ObservationRecord {
417
479
  return {
418
480
  schema: 1,
@@ -426,6 +488,8 @@ export function buildObservationRecord(
426
488
  separating_candidates: report.candidates.filter((c) => c.separates).map((c) => c.id),
427
489
  truncation_mode: 'per-entry',
428
490
  observation_source: 'self-report',
491
+ ...(projectedSkillCount === undefined ? {} : { projected_skill_count: projectedSkillCount }),
492
+ ...(projectionMode === undefined ? {} : { projection_mode: projectionMode }),
429
493
  };
430
494
  }
431
495
 
@@ -507,6 +571,7 @@ export function buildHostEventRecord(
507
571
  entriesOffered: number,
508
572
  event: HostBudgetEvent,
509
573
  projectedSkillCount?: number,
574
+ projectionMode?: ProjectionMode,
510
575
  ): ObservationRecord {
511
576
  return {
512
577
  schema: 1,
@@ -533,6 +598,55 @@ export function buildHostEventRecord(
533
598
  observation_source: 'host-event',
534
599
  dropped_count: event.droppedCount,
535
600
  ...(projectedSkillCount === undefined ? {} : { projected_skill_count: projectedSkillCount }),
601
+ ...(projectionMode === undefined ? {} : { projection_mode: projectionMode }),
602
+ };
603
+ }
604
+
605
+ /**
606
+ * A run on which the host published NO budget event and the caller asserted
607
+ * that this is a genuine no-truncation observation.
608
+ *
609
+ * The assertion is the caller's and the record says so by carrying
610
+ * `observation_source: 'host-event'` together with `truncation_mode: 'none'`:
611
+ * the host's channel was read, and what it contained was nothing. That pairing
612
+ * is what distinguishes this from a broken parser, which the CLI refuses to
613
+ * record at all without the explicit flag.
614
+ *
615
+ * `dropped_count: 0` is written rather than omitted, and it is safe: every
616
+ * consumer of a dropped count filters on `budget-strip-and-drop`, so a `none`
617
+ * record can never become a measured limit or a deploy-time warning.
618
+ */
619
+ export function buildNoTruncationRecord(
620
+ host: string,
621
+ observedAt: string,
622
+ entriesOffered: number,
623
+ projectedSkillCount?: number,
624
+ projectionMode?: ProjectionMode,
625
+ ): ObservationRecord {
626
+ return {
627
+ schema: 1,
628
+ observed_at: observedAt,
629
+ host,
630
+ entries_total: entriesOffered,
631
+ // BOTH counts stay 0, and neither is a claim that nothing arrived
632
+ // described. The host published no per-entry breakdown at all — it
633
+ // published nothing, which is what `truncation_mode: 'none'` records.
634
+ // Writing `described_count: entriesOffered` would look like a
635
+ // measurement and be an inference; 0 here means "not counted", exactly
636
+ // as it does on the strip-and-drop record beside it.
637
+ bare_count: 0,
638
+ described_count: 0,
639
+ bare_names: [],
640
+ // Nothing arrived bare, so no property separated bare from described —
641
+ // which is what `no-selector` states. Unlike the strip-and-drop case,
642
+ // here the label is literally true rather than merely available.
643
+ verdict: 'no-selector',
644
+ separating_candidates: [],
645
+ truncation_mode: 'none',
646
+ observation_source: 'host-event',
647
+ dropped_count: 0,
648
+ ...(projectedSkillCount === undefined ? {} : { projected_skill_count: projectedSkillCount }),
649
+ ...(projectionMode === undefined ? {} : { projection_mode: projectionMode }),
536
650
  };
537
651
  }
538
652
 
@@ -588,6 +702,119 @@ export function measureCatalogueVolume(host: string, root: string): CatalogueVol
588
702
  };
589
703
  }
590
704
 
705
+ /* ------------------------------------------------------------------ *
706
+ * Projection modes — what a host would be offered under `scoped`
707
+ * versus under `legacy-all`, side by side.
708
+ * ------------------------------------------------------------------ */
709
+
710
+ /**
711
+ * The two SKILL counts a package projects, plus the pack closure behind the
712
+ * smaller one.
713
+ *
714
+ * SKILLS, not artefacts, and the distinction is load-bearing rather than
715
+ * pedantic: a controlled probe on 2026-08-15 moved the measured host's dropped
716
+ * count by 0 when 60 commands were added and by +53 when 60 skills were, so a
717
+ * threshold read off artefact totals would fire on growth that causes no
718
+ * truncation at all.
719
+ *
720
+ * Both numbers come from ONE walk in the caller, partitioned by the
721
+ * installer's own prune predicate, so `scoped + prunedUnderScoped ===
722
+ * legacyAll` holds by construction rather than by two counters agreeing.
723
+ */
724
+ export interface ProjectionModeCounts {
725
+ /** Skills a default `projection.mode: scoped` install deploys. */
726
+ scoped: number;
727
+ /** Every skill in the catalogue — what `legacy-all` deploys. */
728
+ legacyAll: number;
729
+ /** `legacyAll - scoped`; the reduction `scoped` buys. */
730
+ prunedUnderScoped: number;
731
+ /** Pack ids active by default under `scoped`, sorted. */
732
+ activePacks: string[];
733
+ }
734
+
735
+ /**
736
+ * Which mode an installed host root is consistent with.
737
+ *
738
+ * `indeterminate` is a real answer and the common one. A host root is not
739
+ * required to hold exactly what this package projects — another suite, a
740
+ * plugin, or a stale install all put skills there — so a count matching
741
+ * neither number says the root was not installed from this tree at this
742
+ * revision, never that the install is broken. And when the two modes yield the
743
+ * same number (no pack-tagged skills), NO root can discriminate them, so every
744
+ * row is `indeterminate` by construction rather than by measurement.
745
+ */
746
+ export type ProjectionModeMatch = 'scoped' | 'legacy-all' | 'indeterminate';
747
+
748
+ /** One host's installed skill count, read against the two mode counts. */
749
+ export interface HostProjectionRow {
750
+ host: string;
751
+ root: string;
752
+ /** Skills present in the host root right now — file-measured. */
753
+ installedSkills: number;
754
+ matches: ProjectionModeMatch;
755
+ }
756
+
757
+ /**
758
+ * Classify one measured host root against the package's two mode counts.
759
+ *
760
+ * Deliberately an equality test and nothing cleverer. A nearest-neighbour rule
761
+ * would label every root with whichever number happens to be closer, which
762
+ * reports a mode for a root that carries neither — the confident-number
763
+ * failure this module's header forbids twice over.
764
+ */
765
+ export function classifyHostProjection(
766
+ volume: CatalogueVolume,
767
+ counts: ProjectionModeCounts,
768
+ ): HostProjectionRow {
769
+ const ambiguous = counts.scoped === counts.legacyAll;
770
+ let matches: ProjectionModeMatch = 'indeterminate';
771
+ if (!ambiguous) {
772
+ if (volume.skillEntries === counts.scoped) matches = 'scoped';
773
+ else if (volume.skillEntries === counts.legacyAll) matches = 'legacy-all';
774
+ }
775
+ return {
776
+ host: volume.host,
777
+ root: volume.root,
778
+ installedSkills: volume.skillEntries,
779
+ matches,
780
+ };
781
+ }
782
+
783
+ /** The side-by-side report. Measurement only — it flips no default. */
784
+ export function formatProjectionModes(
785
+ counts: ProjectionModeCounts,
786
+ rows: readonly HostProjectionRow[],
787
+ ): string {
788
+ const lines = [
789
+ 'projected SKILL counts by projection mode (package side, one walk):',
790
+ ` scoped: ${counts.scoped}`,
791
+ ` legacy-all: ${counts.legacyAll}`,
792
+ ` pruned under scoped: ${counts.prunedUnderScoped}`,
793
+ ` active packs under scoped: ${counts.activePacks.length}`,
794
+ '',
795
+ ];
796
+ if (rows.length === 0) {
797
+ lines.push(
798
+ 'No host root measured. Pass --host-root <dir> (repeatable) to read what',
799
+ 'each host currently holds against the two numbers above.',
800
+ );
801
+ return lines.join('\n');
802
+ }
803
+ lines.push('installed host roots:');
804
+ for (const row of [...rows].sort((a, b) => a.host.localeCompare(b.host))) {
805
+ lines.push(` ${row.host}: ${row.installedSkills} skills — consistent with ${row.matches}`);
806
+ }
807
+ lines.push(
808
+ '',
809
+ 'This is the PROJECTION half only, and it is a measurement: no default was',
810
+ 'read, changed, or recommended here. `indeterminate` means the root carries',
811
+ 'neither count — another suite, a plugin, or a stale install all produce it.',
812
+ 'No host limit is extrapolated from another host, and no delivered or',
813
+ 'survivor count is computed anywhere.',
814
+ );
815
+ return lines.join('\n');
816
+ }
817
+
591
818
  /* ------------------------------------------------------------------ *
592
819
  * Known limits — read off recorded observations, never invented.
593
820
  * ------------------------------------------------------------------ */
@@ -623,15 +850,54 @@ export interface KnownHostLimit {
623
850
  observedAt: string;
624
851
  }
625
852
 
853
+ /**
854
+ * Is `candidate` the record a host's headline should quote, given `incumbent`?
855
+ *
856
+ * Shared by `knownHostLimits` and `formatPerHostVerdicts` because they used to
857
+ * disagree, and the disagreement was visible in one run: two arms of the same
858
+ * experiment recorded on the same date made `--limits` print `dropped 402` in
859
+ * the verdict block and `dropped 330` in the measured-truncations block, for
860
+ * one host on one day. Two reducers over one log must not break a tie in
861
+ * opposite directions.
862
+ *
863
+ * Later date always wins. On an EQUAL date the larger drop wins, which is also
864
+ * the conservative direction this module already takes elsewhere: over-warning
865
+ * about truncation is safe, under-warning is the failure. It is also what keeps
866
+ * a `scoped` arm from being quoted at a `legacy-all` install — the scoped arm
867
+ * is by construction the smaller number.
868
+ */
869
+ function _supersedes(candidate: ObservationRecord, incumbent: ObservationRecord): boolean {
870
+ if (candidate.observed_at !== incumbent.observed_at) {
871
+ return candidate.observed_at > incumbent.observed_at;
872
+ }
873
+ return (candidate.dropped_count ?? 0) > (incumbent.dropped_count ?? 0);
874
+ }
875
+
876
+ /** The record whose truncation a host's headline quotes, per `_supersedes`. */
877
+ export function headlineRecordPerHost(
878
+ records: readonly ObservationRecord[],
879
+ ): Map<string, ObservationRecord> {
880
+ const out = new Map<string, ObservationRecord>();
881
+ for (const record of records) {
882
+ const previous = out.get(record.host);
883
+ if (previous === undefined || _supersedes(record, previous)) {
884
+ out.set(record.host, record);
885
+ }
886
+ }
887
+ return out;
888
+ }
889
+
626
890
  /** The most recent observed truncation per host. */
627
891
  export function knownHostLimits(records: readonly ObservationRecord[]): Map<string, KnownHostLimit> {
628
892
  const out = new Map<string, KnownHostLimit>();
893
+ const chosen = new Map<string, ObservationRecord>();
629
894
  for (const record of records) {
630
895
  if (truncationModeOf(record) !== 'budget-strip-and-drop') continue;
631
896
  if (typeof record.dropped_count !== 'number') continue;
632
897
  if (record.dropped_count <= 0) continue;
633
- const previous = out.get(record.host);
634
- if (previous !== undefined && previous.observedAt > record.observed_at) continue;
898
+ const previous = chosen.get(record.host);
899
+ if (previous !== undefined && !_supersedes(record, previous)) continue;
900
+ chosen.set(record.host, record);
635
901
  out.set(record.host, {
636
902
  host: record.host,
637
903
  droppedEntries: record.dropped_count,
@@ -799,10 +1065,16 @@ export function formatPerHostVerdicts(records: readonly ObservationRecord[]): st
799
1065
  byHost.set(record.host, bucket);
800
1066
  }
801
1067
 
1068
+ const headline = headlineRecordPerHost(records);
1069
+
802
1070
  const lines: string[] = [];
803
1071
  for (const host of [...byHost.keys()].sort()) {
804
1072
  const bucket = byHost.get(host)!;
805
- const latest = bucket.reduce((a, b) => (a.observed_at >= b.observed_at ? a : b));
1073
+ // One selector for both reducers — see `_supersedes`. The previous
1074
+ // `>=` reduce here kept the FIRST of two same-date rows while
1075
+ // `knownHostLimits` kept the LAST, so one run printed two different
1076
+ // drop counts for one host on one day.
1077
+ const latest = headline.get(host)!;
806
1078
  const mode = truncationModeOf(latest);
807
1079
  lines.push(
808
1080
  `${host}: ${bucket.length} observation(s) · mode ${mode} · ` +
@@ -816,7 +1088,7 @@ export function formatPerHostVerdicts(records: readonly ObservationRecord[]): st
816
1088
  );
817
1089
  }
818
1090
 
819
- const modes = new Set([...byHost.values()].map((b) => truncationModeOf(b[b.length - 1]!)));
1091
+ const modes = new Set([...headline.values()].map((r) => truncationModeOf(r)));
820
1092
  lines.push('');
821
1093
  lines.push(
822
1094
  modes.size > 1
@@ -860,3 +1132,4 @@ export function formatReport(report: SelectorReport): string {
860
1132
  );
861
1133
  return lines.join('\n');
862
1134
  }
1135
+