@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +151 -0
- package/README.md +1 -1
- package/dist/agent-src/commands/cost/report.md +23 -0
- package/dist/agent-src/contexts/execution/subagent-routing.md +148 -33
- package/dist/agent-src/contexts/subagent-configuration.md +69 -7
- package/dist/agent-src/rules/autonomous-execution.md +1 -1
- package/dist/agent-src/rules/design-fidelity.md +1 -1
- package/dist/agent-src/rules/design-review-after-ui-write.md +10 -5
- package/dist/agent-src/rules/evaluator-independence.md +27 -5
- package/dist/agent-src/rules/git-history-discipline.md +5 -1
- package/dist/agent-src/rules/missing-skill-recovery.md +89 -0
- package/dist/agent-src/rules/ui-audit-gate.md +9 -4
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
- package/dist/agent-src/scripts/update_roadmap_progress.ts +17 -2
- package/dist/agent-src/skills/authz-review/SKILL.md +4 -0
- package/dist/agent-src/skills/design-system-capture/SKILL.md +4 -0
- package/dist/agent-src/skills/design-system-capture/references/snapshot-preference-order.md +77 -0
- package/dist/agent-src/skills/merge-conflicts/SKILL.md +4 -0
- package/dist/agent-src/skills/prompt-optimizer/SKILL.md +17 -1
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +4 -4
- package/dist/agent-src/skills/systematic-debugging/SKILL.md +4 -0
- package/dist/agent-src/skills/threat-modeling/SKILL.md +3 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli-delegate/{chunk-O52B4F5S.js → chunk-3B72LSZL.js} +1 -1
- package/dist/cli-delegate/{chunk-55WZN5VF.js → chunk-EVTY7ISR.js} +2 -2
- package/dist/cli-delegate/{chunk-K34QTSD5.js → chunk-N7FEST6D.js} +17 -17
- package/dist/cli-delegate/{chunk-LKZ5DOYW.js → chunk-XURJ4AQC.js} +11 -3
- package/dist/cli-delegate/cmd_conformance.js +2 -2
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_export.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +2 -2
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +2 -2
- package/dist/cli-delegate/cmd_uninstall.js +2 -2
- package/dist/cli-delegate/cmd_validate.js +2 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +43 -22
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +6 -5
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +5 -4
- package/dist/hooks/dispatch.js +936 -346
- package/dist/install/install.mjs +12 -4
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1192 -160
- package/dist/router.json +1 -1
- package/dist/server/schemas/settings.js +2 -2
- package/dist/server/schemas/settings.js.map +1 -1
- package/docs/guidelines/design-fidelity-mechanics.md +21 -0
- package/docs/guidelines/design-handover-extraction.md +87 -0
- package/docs/guidelines/prompt-templates.md +24 -1
- package/package.json +1 -1
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
- package/src/agent-src/scripts/update_roadmap_progress.ts +17 -2
- package/src/config/evaluator-budgets.json +5 -4
- package/src/config/gate-coverage.yml +33 -11
- package/src/config/gate-violation-baselines.json +9 -3
- package/src/config/hook-token-budget.json +8 -1
- package/src/scripts/_lib/bench_ab_complexity.ts +439 -0
- package/src/scripts/_lib/bench_ab_safety_tier.ts +142 -0
- package/src/scripts/_lib/bench_ab_scoring_v2.ts +28 -3
- package/src/scripts/_lib/bench_ab_search_adherence.ts +423 -0
- package/src/scripts/_lib/bench_ab_size_claim.ts +206 -0
- package/src/scripts/_lib/bench_ab_workspace.ts +111 -0
- package/src/scripts/_lib/skill_catalogue.ts +258 -5
- package/src/scripts/_lib/tier_budget_routing.ts +31 -331
- package/src/scripts/ai_council/clients.ts +21 -21
- package/src/scripts/bench_ab_v2_complexity.ts +252 -0
- package/src/scripts/bench_ab_v2_run.ts +3 -42
- package/src/scripts/bench_ab_v2_safety.ts +296 -0
- package/src/scripts/bench_ab_v2_search.ts +343 -0
- package/src/scripts/bench_ab_v2_stats.ts +191 -0
- package/src/scripts/build_archive_index.ts +383 -0
- package/src/scripts/capture_skill_catalogue.ts +107 -2
- package/src/scripts/check_source_size_budget.ts +428 -0
- package/src/scripts/check_suppression_hygiene.ts +5 -1
- package/src/scripts/cost/backfill_rates.mjs +298 -0
- package/src/scripts/cost/budget.mjs +12 -32
- package/src/scripts/cost_summary.ts +25 -0
- package/src/scripts/external_sources_denylist.json +1 -0
- package/src/scripts/hook_manifest.yaml +28 -2
- package/src/scripts/hooks/concern_registry.ts +2 -0
- package/src/scripts/hooks/skill_route_hook.ts +226 -0
- package/src/scripts/hooks/ui_route_nudge_hook.ts +6 -3
- package/src/scripts/lint_design_antipattern_parity.ts +40 -6
- package/src/scripts/lint_harvest_provenance.ts +35 -1
- package/src/scripts/lint_rule_skill_pack_reach.ts +43 -0
- package/src/scripts/lint_skill_router_head.ts +21 -0
- package/src/scripts/mcp_server/consumer_tool_catalog.json +26 -3
- package/src/scripts/mcp_server/tool_catalog_source.ts +6 -24
- package/src/scripts/mcp_server/tools.ts +87 -0
- package/src/scripts/release.ts +5 -5
- package/src/scripts/routing_doctor.ts +50 -13
- package/src/scripts/schemas/command.schema.json +1 -1
- package/src/scripts/schemas/skill.schema.json +18 -1
- package/src/scripts/trigger_coverage.ts +224 -3
- package/src/server/schemas/settings.ts +2 -2
- 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 =
|
|
634
|
-
if (previous !== undefined && previous
|
|
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
|
-
|
|
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([...
|
|
1072
|
+
const modes = new Set([...headline.values()].map((r) => truncationModeOf(r)));
|
|
820
1073
|
lines.push('');
|
|
821
1074
|
lines.push(
|
|
822
1075
|
modes.size > 1
|