@event4u/agent-config 12.1.0 → 13.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 (105) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +151 -0
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/cost/report.md +23 -0
  5. package/dist/agent-src/contexts/execution/subagent-routing.md +148 -33
  6. package/dist/agent-src/contexts/subagent-configuration.md +69 -7
  7. package/dist/agent-src/rules/autonomous-execution.md +1 -1
  8. package/dist/agent-src/rules/design-fidelity.md +1 -1
  9. package/dist/agent-src/rules/design-review-after-ui-write.md +10 -5
  10. package/dist/agent-src/rules/evaluator-independence.md +27 -5
  11. package/dist/agent-src/rules/git-history-discipline.md +5 -1
  12. package/dist/agent-src/rules/missing-skill-recovery.md +89 -0
  13. package/dist/agent-src/rules/ui-audit-gate.md +9 -4
  14. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
  15. package/dist/agent-src/scripts/update_roadmap_progress.ts +17 -2
  16. package/dist/agent-src/skills/authz-review/SKILL.md +4 -0
  17. package/dist/agent-src/skills/design-system-capture/SKILL.md +4 -0
  18. package/dist/agent-src/skills/design-system-capture/references/snapshot-preference-order.md +77 -0
  19. package/dist/agent-src/skills/merge-conflicts/SKILL.md +4 -0
  20. package/dist/agent-src/skills/prompt-optimizer/SKILL.md +17 -1
  21. package/dist/agent-src/skills/roadmap-writing/SKILL.md +4 -4
  22. package/dist/agent-src/skills/systematic-debugging/SKILL.md +4 -0
  23. package/dist/agent-src/skills/threat-modeling/SKILL.md +3 -0
  24. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  25. package/dist/cli-delegate/{chunk-O52B4F5S.js → chunk-3B72LSZL.js} +1 -1
  26. package/dist/cli-delegate/{chunk-55WZN5VF.js → chunk-EVTY7ISR.js} +2 -2
  27. package/dist/cli-delegate/{chunk-K34QTSD5.js → chunk-N7FEST6D.js} +17 -17
  28. package/dist/cli-delegate/{chunk-LKZ5DOYW.js → chunk-XURJ4AQC.js} +11 -3
  29. package/dist/cli-delegate/cmd_conformance.js +2 -2
  30. package/dist/cli-delegate/cmd_doctor.js +2 -2
  31. package/dist/cli-delegate/cmd_export.js +2 -2
  32. package/dist/cli-delegate/cmd_handoff.js +1 -1
  33. package/dist/cli-delegate/cmd_prune.js +2 -2
  34. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  35. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  36. package/dist/cli-delegate/cmd_sync.js +2 -2
  37. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  38. package/dist/cli-delegate/cmd_validate.js +2 -2
  39. package/dist/discovery/deprecation-report.md +1 -1
  40. package/dist/discovery/discovery-manifest.json +43 -22
  41. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  42. package/dist/discovery/discovery-manifest.summary.md +3 -3
  43. package/dist/discovery/dormancy-report.md +1 -1
  44. package/dist/discovery/orphan-report.md +1 -1
  45. package/dist/discovery/packs.json +6 -5
  46. package/dist/discovery/trust-report.md +2 -2
  47. package/dist/discovery/workspaces.json +5 -4
  48. package/dist/hooks/dispatch.js +936 -346
  49. package/dist/install/install.mjs +12 -4
  50. package/dist/mcp/registry-manifest.json +3 -3
  51. package/dist/mcp/server.json +2 -2
  52. package/dist/mcp/server.mjs +1192 -160
  53. package/dist/router.json +1 -1
  54. package/dist/server/schemas/settings.js +2 -2
  55. package/dist/server/schemas/settings.js.map +1 -1
  56. package/docs/guidelines/design-fidelity-mechanics.md +21 -0
  57. package/docs/guidelines/design-handover-extraction.md +87 -0
  58. package/docs/guidelines/prompt-templates.md +24 -1
  59. package/package.json +1 -1
  60. package/src/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
  61. package/src/agent-src/scripts/update_roadmap_progress.ts +17 -2
  62. package/src/config/evaluator-budgets.json +5 -4
  63. package/src/config/gate-coverage.yml +33 -11
  64. package/src/config/gate-violation-baselines.json +9 -3
  65. package/src/config/hook-token-budget.json +8 -1
  66. package/src/scripts/_lib/bench_ab_complexity.ts +439 -0
  67. package/src/scripts/_lib/bench_ab_safety_tier.ts +142 -0
  68. package/src/scripts/_lib/bench_ab_scoring_v2.ts +28 -3
  69. package/src/scripts/_lib/bench_ab_search_adherence.ts +423 -0
  70. package/src/scripts/_lib/bench_ab_size_claim.ts +206 -0
  71. package/src/scripts/_lib/bench_ab_workspace.ts +111 -0
  72. package/src/scripts/_lib/skill_catalogue.ts +258 -5
  73. package/src/scripts/_lib/tier_budget_routing.ts +31 -331
  74. package/src/scripts/ai_council/clients.ts +21 -21
  75. package/src/scripts/bench_ab_v2_complexity.ts +252 -0
  76. package/src/scripts/bench_ab_v2_run.ts +3 -42
  77. package/src/scripts/bench_ab_v2_safety.ts +296 -0
  78. package/src/scripts/bench_ab_v2_search.ts +343 -0
  79. package/src/scripts/bench_ab_v2_stats.ts +191 -0
  80. package/src/scripts/build_archive_index.ts +383 -0
  81. package/src/scripts/capture_skill_catalogue.ts +107 -2
  82. package/src/scripts/check_source_size_budget.ts +428 -0
  83. package/src/scripts/check_suppression_hygiene.ts +5 -1
  84. package/src/scripts/cost/backfill_rates.mjs +298 -0
  85. package/src/scripts/cost/budget.mjs +12 -32
  86. package/src/scripts/cost_summary.ts +25 -0
  87. package/src/scripts/external_sources_denylist.json +1 -0
  88. package/src/scripts/hook_manifest.yaml +28 -2
  89. package/src/scripts/hooks/concern_registry.ts +2 -0
  90. package/src/scripts/hooks/skill_route_hook.ts +226 -0
  91. package/src/scripts/hooks/ui_route_nudge_hook.ts +6 -3
  92. package/src/scripts/lint_design_antipattern_parity.ts +40 -6
  93. package/src/scripts/lint_harvest_provenance.ts +35 -1
  94. package/src/scripts/lint_rule_skill_pack_reach.ts +43 -0
  95. package/src/scripts/lint_skill_router_head.ts +21 -0
  96. package/src/scripts/mcp_server/consumer_tool_catalog.json +26 -3
  97. package/src/scripts/mcp_server/tool_catalog_source.ts +6 -24
  98. package/src/scripts/mcp_server/tools.ts +87 -0
  99. package/src/scripts/release.ts +5 -5
  100. package/src/scripts/routing_doctor.ts +50 -13
  101. package/src/scripts/schemas/command.schema.json +1 -1
  102. package/src/scripts/schemas/skill.schema.json +18 -1
  103. package/src/scripts/trigger_coverage.ts +224 -3
  104. package/src/server/schemas/settings.ts +2 -2
  105. package/src/config/budget-routing.json +0 -7
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Per-trial workspace lifecycle — the clone, and the transcript beside it.
3
+ *
4
+ * The two belong together: `reset_fixture` re-creates the clone AND drops the
5
+ * sibling transcript, and both are the delta-#7 evidence an offline re-scorer
6
+ * reads. They moved out of `bench_ab_v2_run.ts` because the source-size ratchet
7
+ * is shrink-only and extraction is its intended answer — raising the baseline
8
+ * is a defect, and trimming the prose out of load-bearing comments is worse.
9
+ *
10
+ * ## Transcript preservation — delta #7's sibling, for T5.
11
+ *
12
+ * T5 scores what a run did BEFORE it wrote, which leaves no residue in the
13
+ * final tree: the transcript is the only evidence, and the report discards it.
14
+ * These two functions are what make a completed sweep re-scorable for search
15
+ * adherence instead of needing a re-run.
16
+ *
17
+ * BESIDE the clone, never inside it. `changed_files` diffs the clone against
18
+ * the pristine fixture, so a transcript written into the workspace would appear
19
+ * as a file the run created: T5's evidence would corrupt T1's, and the size
20
+ * endpoint would gain one added file per trial for a reason no reader could see.
21
+ *
22
+ * A file rather than a field on the trial record because transcripts are the
23
+ * largest thing a sweep produces, and inlining them would multiply a pinned
24
+ * report's size for data only the T5 re-scorer ever reads. Truncating instead
25
+ * was the other option and is worse: a rubric scored on a clipped transcript
26
+ * measures the clip.
27
+ *
28
+ * KNOWN COVERAGE GAP, stated rather than silently carried: the
29
+ * `package-recursive` arm records neither `workspace` nor `transcript_path`, so
30
+ * T1/T2/T4 and now T5 all skip it. The workspace half predates this module and
31
+ * closing it would alter what the existing endpoints cover, which is a
32
+ * different decision from adding one — so it is named here and in the T5
33
+ * re-scorer's "no transcript recorded" reason instead of being half-fixed.
34
+ */
35
+ import * as fs from 'node:fs';
36
+ import * as os from 'node:os';
37
+ import * as path from 'node:path';
38
+ import { fileURLToPath } from 'node:url';
39
+
40
+ const _HERE = fileURLToPath(import.meta.url);
41
+ const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..', '..');
42
+ const FIXTURES_ROOT = path.join(REPO_ROOT, 'internal', 'bench', 'ab');
43
+ const WORK_ROOT = path.join(os.tmpdir(), 'agent-config-bench-v2-clones');
44
+
45
+ type Dict = Record<string, unknown>;
46
+
47
+ /** Where this trial's transcript is preserved — a sibling of the clone. */
48
+ export function transcript_path_for(clone: string): string {
49
+ return `${clone}.transcript.txt`;
50
+ }
51
+
52
+ /**
53
+ * Write the transcript beside the clone; return the path, or `null` when there
54
+ * was nothing to preserve or the write failed.
55
+ *
56
+ * A null propagates onto the trial as `transcript_path: null`, which the T5
57
+ * re-scorer reads as "not measured on this trial" — never as a zero score. A
58
+ * failed write must not be able to look like a run that searched for nothing.
59
+ */
60
+ export function preserve_transcript(clone: string, transcript: string): string | null {
61
+ if (!transcript) return null;
62
+ const dest = transcript_path_for(clone);
63
+ try {
64
+ fs.writeFileSync(dest, transcript, { encoding: 'utf-8' });
65
+ } catch {
66
+ return null;
67
+ }
68
+ return dest;
69
+ }
70
+
71
+
72
+ /**
73
+ * Workspace directory for one trial — delta #7 of the S0.3 spike.
74
+ *
75
+ * Keyed by `task|arm|seed`, not by task alone. The old task-only key meant every
76
+ * arm and every seed of a task reused ONE directory that the next trial deleted,
77
+ * so at the end of a sweep exactly one workspace survived per task — the last one
78
+ * written, with no record of which arm or seed it belonged to.
79
+ *
80
+ * That is not a tidiness problem. Phase 3's anti-golfing gate is specified as
81
+ * retro-fittable onto already-completed runs by offline re-scoring, and the
82
+ * roadmap calls that gate cheap *because* the workspaces are preserved. Under the
83
+ * old key there was nothing to re-score, so the claim was false. A distinct
84
+ * directory per trial is what makes it true.
85
+ *
86
+ * `arm` is sanitised because arm names are used verbatim as a path segment and
87
+ * one of them would otherwise be free to escape the root.
88
+ */
89
+ export function workspace_dir(task_id: string, arm: string, seed: number): string {
90
+ const safe_arm = arm.replace(/[^A-Za-z0-9._-]/g, '_');
91
+ return path.join(WORK_ROOT, `${task_id}__${safe_arm}__seed${seed}`);
92
+ }
93
+
94
+ /**
95
+ * Copy the task's pristine fixture into this trial's own working clone.
96
+ *
97
+ * The clone is re-created from the fixture on every call, so a resumed or
98
+ * repeated trial still starts pristine — the per-trial key changes *which*
99
+ * directory that is, never whether it is clean.
100
+ */
101
+ export function reset_fixture(task: Dict, arm: string, seed: number): [string, string] {
102
+ const fixture = path.join(FIXTURES_ROOT, String(task['fixture']));
103
+ const dest = workspace_dir(String(task['id']), arm, seed);
104
+ if (fs.existsSync(dest)) {
105
+ fs.rmSync(dest, { recursive: true, force: true });
106
+ }
107
+ fs.rmSync(transcript_path_for(dest), { force: true });
108
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
109
+ fs.cpSync(fixture, dest, { recursive: true });
110
+ return [dest, fixture];
111
+ }
@@ -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`
@@ -507,6 +552,7 @@ export function buildHostEventRecord(
507
552
  entriesOffered: number,
508
553
  event: HostBudgetEvent,
509
554
  projectedSkillCount?: number,
555
+ projectionMode?: ProjectionMode,
510
556
  ): ObservationRecord {
511
557
  return {
512
558
  schema: 1,
@@ -533,6 +579,55 @@ export function buildHostEventRecord(
533
579
  observation_source: 'host-event',
534
580
  dropped_count: event.droppedCount,
535
581
  ...(projectedSkillCount === undefined ? {} : { projected_skill_count: projectedSkillCount }),
582
+ ...(projectionMode === undefined ? {} : { projection_mode: projectionMode }),
583
+ };
584
+ }
585
+
586
+ /**
587
+ * A run on which the host published NO budget event and the caller asserted
588
+ * that this is a genuine no-truncation observation.
589
+ *
590
+ * The assertion is the caller's and the record says so by carrying
591
+ * `observation_source: 'host-event'` together with `truncation_mode: 'none'`:
592
+ * the host's channel was read, and what it contained was nothing. That pairing
593
+ * is what distinguishes this from a broken parser, which the CLI refuses to
594
+ * record at all without the explicit flag.
595
+ *
596
+ * `dropped_count: 0` is written rather than omitted, and it is safe: every
597
+ * consumer of a dropped count filters on `budget-strip-and-drop`, so a `none`
598
+ * record can never become a measured limit or a deploy-time warning.
599
+ */
600
+ export function buildNoTruncationRecord(
601
+ host: string,
602
+ observedAt: string,
603
+ entriesOffered: number,
604
+ projectedSkillCount?: number,
605
+ projectionMode?: ProjectionMode,
606
+ ): ObservationRecord {
607
+ return {
608
+ schema: 1,
609
+ observed_at: observedAt,
610
+ host,
611
+ entries_total: entriesOffered,
612
+ // BOTH counts stay 0, and neither is a claim that nothing arrived
613
+ // described. The host published no per-entry breakdown at all — it
614
+ // published nothing, which is what `truncation_mode: 'none'` records.
615
+ // Writing `described_count: entriesOffered` would look like a
616
+ // measurement and be an inference; 0 here means "not counted", exactly
617
+ // as it does on the strip-and-drop record beside it.
618
+ bare_count: 0,
619
+ described_count: 0,
620
+ bare_names: [],
621
+ // Nothing arrived bare, so no property separated bare from described —
622
+ // which is what `no-selector` states. Unlike the strip-and-drop case,
623
+ // here the label is literally true rather than merely available.
624
+ verdict: 'no-selector',
625
+ separating_candidates: [],
626
+ truncation_mode: 'none',
627
+ observation_source: 'host-event',
628
+ dropped_count: 0,
629
+ ...(projectedSkillCount === undefined ? {} : { projected_skill_count: projectedSkillCount }),
630
+ ...(projectionMode === undefined ? {} : { projection_mode: projectionMode }),
536
631
  };
537
632
  }
538
633
 
@@ -588,6 +683,119 @@ export function measureCatalogueVolume(host: string, root: string): CatalogueVol
588
683
  };
589
684
  }
590
685
 
686
+ /* ------------------------------------------------------------------ *
687
+ * Projection modes — what a host would be offered under `scoped`
688
+ * versus under `legacy-all`, side by side.
689
+ * ------------------------------------------------------------------ */
690
+
691
+ /**
692
+ * The two SKILL counts a package projects, plus the pack closure behind the
693
+ * smaller one.
694
+ *
695
+ * SKILLS, not artefacts, and the distinction is load-bearing rather than
696
+ * pedantic: a controlled probe on 2026-08-15 moved the measured host's dropped
697
+ * count by 0 when 60 commands were added and by +53 when 60 skills were, so a
698
+ * threshold read off artefact totals would fire on growth that causes no
699
+ * truncation at all.
700
+ *
701
+ * Both numbers come from ONE walk in the caller, partitioned by the
702
+ * installer's own prune predicate, so `scoped + prunedUnderScoped ===
703
+ * legacyAll` holds by construction rather than by two counters agreeing.
704
+ */
705
+ export interface ProjectionModeCounts {
706
+ /** Skills a default `projection.mode: scoped` install deploys. */
707
+ scoped: number;
708
+ /** Every skill in the catalogue — what `legacy-all` deploys. */
709
+ legacyAll: number;
710
+ /** `legacyAll - scoped`; the reduction `scoped` buys. */
711
+ prunedUnderScoped: number;
712
+ /** Pack ids active by default under `scoped`, sorted. */
713
+ activePacks: string[];
714
+ }
715
+
716
+ /**
717
+ * Which mode an installed host root is consistent with.
718
+ *
719
+ * `indeterminate` is a real answer and the common one. A host root is not
720
+ * required to hold exactly what this package projects — another suite, a
721
+ * plugin, or a stale install all put skills there — so a count matching
722
+ * neither number says the root was not installed from this tree at this
723
+ * revision, never that the install is broken. And when the two modes yield the
724
+ * same number (no pack-tagged skills), NO root can discriminate them, so every
725
+ * row is `indeterminate` by construction rather than by measurement.
726
+ */
727
+ export type ProjectionModeMatch = 'scoped' | 'legacy-all' | 'indeterminate';
728
+
729
+ /** One host's installed skill count, read against the two mode counts. */
730
+ export interface HostProjectionRow {
731
+ host: string;
732
+ root: string;
733
+ /** Skills present in the host root right now — file-measured. */
734
+ installedSkills: number;
735
+ matches: ProjectionModeMatch;
736
+ }
737
+
738
+ /**
739
+ * Classify one measured host root against the package's two mode counts.
740
+ *
741
+ * Deliberately an equality test and nothing cleverer. A nearest-neighbour rule
742
+ * would label every root with whichever number happens to be closer, which
743
+ * reports a mode for a root that carries neither — the confident-number
744
+ * failure this module's header forbids twice over.
745
+ */
746
+ export function classifyHostProjection(
747
+ volume: CatalogueVolume,
748
+ counts: ProjectionModeCounts,
749
+ ): HostProjectionRow {
750
+ const ambiguous = counts.scoped === counts.legacyAll;
751
+ let matches: ProjectionModeMatch = 'indeterminate';
752
+ if (!ambiguous) {
753
+ if (volume.skillEntries === counts.scoped) matches = 'scoped';
754
+ else if (volume.skillEntries === counts.legacyAll) matches = 'legacy-all';
755
+ }
756
+ return {
757
+ host: volume.host,
758
+ root: volume.root,
759
+ installedSkills: volume.skillEntries,
760
+ matches,
761
+ };
762
+ }
763
+
764
+ /** The side-by-side report. Measurement only — it flips no default. */
765
+ export function formatProjectionModes(
766
+ counts: ProjectionModeCounts,
767
+ rows: readonly HostProjectionRow[],
768
+ ): string {
769
+ const lines = [
770
+ 'projected SKILL counts by projection mode (package side, one walk):',
771
+ ` scoped: ${counts.scoped}`,
772
+ ` legacy-all: ${counts.legacyAll}`,
773
+ ` pruned under scoped: ${counts.prunedUnderScoped}`,
774
+ ` active packs under scoped: ${counts.activePacks.length}`,
775
+ '',
776
+ ];
777
+ if (rows.length === 0) {
778
+ lines.push(
779
+ 'No host root measured. Pass --host-root <dir> (repeatable) to read what',
780
+ 'each host currently holds against the two numbers above.',
781
+ );
782
+ return lines.join('\n');
783
+ }
784
+ lines.push('installed host roots:');
785
+ for (const row of [...rows].sort((a, b) => a.host.localeCompare(b.host))) {
786
+ lines.push(` ${row.host}: ${row.installedSkills} skills — consistent with ${row.matches}`);
787
+ }
788
+ lines.push(
789
+ '',
790
+ 'This is the PROJECTION half only, and it is a measurement: no default was',
791
+ 'read, changed, or recommended here. `indeterminate` means the root carries',
792
+ 'neither count — another suite, a plugin, or a stale install all produce it.',
793
+ 'No host limit is extrapolated from another host, and no delivered or',
794
+ 'survivor count is computed anywhere.',
795
+ );
796
+ return lines.join('\n');
797
+ }
798
+
591
799
  /* ------------------------------------------------------------------ *
592
800
  * Known limits — read off recorded observations, never invented.
593
801
  * ------------------------------------------------------------------ */
@@ -623,15 +831,54 @@ export interface KnownHostLimit {
623
831
  observedAt: string;
624
832
  }
625
833
 
834
+ /**
835
+ * Is `candidate` the record a host's headline should quote, given `incumbent`?
836
+ *
837
+ * Shared by `knownHostLimits` and `formatPerHostVerdicts` because they used to
838
+ * disagree, and the disagreement was visible in one run: two arms of the same
839
+ * experiment recorded on the same date made `--limits` print `dropped 402` in
840
+ * the verdict block and `dropped 330` in the measured-truncations block, for
841
+ * one host on one day. Two reducers over one log must not break a tie in
842
+ * opposite directions.
843
+ *
844
+ * Later date always wins. On an EQUAL date the larger drop wins, which is also
845
+ * the conservative direction this module already takes elsewhere: over-warning
846
+ * about truncation is safe, under-warning is the failure. It is also what keeps
847
+ * a `scoped` arm from being quoted at a `legacy-all` install — the scoped arm
848
+ * is by construction the smaller number.
849
+ */
850
+ function _supersedes(candidate: ObservationRecord, incumbent: ObservationRecord): boolean {
851
+ if (candidate.observed_at !== incumbent.observed_at) {
852
+ return candidate.observed_at > incumbent.observed_at;
853
+ }
854
+ return (candidate.dropped_count ?? 0) > (incumbent.dropped_count ?? 0);
855
+ }
856
+
857
+ /** The record whose truncation a host's headline quotes, per `_supersedes`. */
858
+ export function headlineRecordPerHost(
859
+ records: readonly ObservationRecord[],
860
+ ): Map<string, ObservationRecord> {
861
+ const out = new Map<string, ObservationRecord>();
862
+ for (const record of records) {
863
+ const previous = out.get(record.host);
864
+ if (previous === undefined || _supersedes(record, previous)) {
865
+ out.set(record.host, record);
866
+ }
867
+ }
868
+ return out;
869
+ }
870
+
626
871
  /** The most recent observed truncation per host. */
627
872
  export function knownHostLimits(records: readonly ObservationRecord[]): Map<string, KnownHostLimit> {
628
873
  const out = new Map<string, KnownHostLimit>();
874
+ const chosen = new Map<string, ObservationRecord>();
629
875
  for (const record of records) {
630
876
  if (truncationModeOf(record) !== 'budget-strip-and-drop') continue;
631
877
  if (typeof record.dropped_count !== 'number') continue;
632
878
  if (record.dropped_count <= 0) continue;
633
- const previous = out.get(record.host);
634
- if (previous !== undefined && previous.observedAt > record.observed_at) continue;
879
+ const previous = chosen.get(record.host);
880
+ if (previous !== undefined && !_supersedes(record, previous)) continue;
881
+ chosen.set(record.host, record);
635
882
  out.set(record.host, {
636
883
  host: record.host,
637
884
  droppedEntries: record.dropped_count,
@@ -799,10 +1046,16 @@ export function formatPerHostVerdicts(records: readonly ObservationRecord[]): st
799
1046
  byHost.set(record.host, bucket);
800
1047
  }
801
1048
 
1049
+ const headline = headlineRecordPerHost(records);
1050
+
802
1051
  const lines: string[] = [];
803
1052
  for (const host of [...byHost.keys()].sort()) {
804
1053
  const bucket = byHost.get(host)!;
805
- const latest = bucket.reduce((a, b) => (a.observed_at >= b.observed_at ? a : b));
1054
+ // One selector for both reducers — see `_supersedes`. The previous
1055
+ // `>=` reduce here kept the FIRST of two same-date rows while
1056
+ // `knownHostLimits` kept the LAST, so one run printed two different
1057
+ // drop counts for one host on one day.
1058
+ const latest = headline.get(host)!;
806
1059
  const mode = truncationModeOf(latest);
807
1060
  lines.push(
808
1061
  `${host}: ${bucket.length} observation(s) · mode ${mode} · ` +
@@ -816,7 +1069,7 @@ export function formatPerHostVerdicts(records: readonly ObservationRecord[]): st
816
1069
  );
817
1070
  }
818
1071
 
819
- const modes = new Set([...byHost.values()].map((b) => truncationModeOf(b[b.length - 1]!)));
1072
+ const modes = new Set([...headline.values()].map((r) => truncationModeOf(r)));
820
1073
  lines.push('');
821
1074
  lines.push(
822
1075
  modes.size > 1