@event4u/agent-config 11.0.0 → 12.1.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 +173 -229
- package/agents/templates/.ai-council.yml.example +46 -3
- package/dist/agent-src/commands/agent-handoff.md +0 -1
- package/dist/agent-src/commands/agent-status.md +0 -1
- package/dist/agent-src/commands/agents/audit.md +0 -1
- package/dist/agent-src/commands/agents/init.md +0 -1
- package/dist/agent-src/commands/agents/optimize.md +0 -1
- package/dist/agent-src/commands/agents/user/accept.md +0 -1
- package/dist/agent-src/commands/agents/user/delete.md +0 -1
- package/dist/agent-src/commands/agents/user/init.md +0 -1
- package/dist/agent-src/commands/agents/user/review.md +0 -1
- package/dist/agent-src/commands/agents/user/show.md +0 -1
- package/dist/agent-src/commands/agents/user/update.md +0 -1
- package/dist/agent-src/commands/agents/user.md +0 -1
- package/dist/agent-src/commands/agents.md +0 -1
- package/dist/agent-src/commands/analytics/prune.md +0 -1
- package/dist/agent-src/commands/analytics/show.md +0 -1
- package/dist/agent-src/commands/analytics.md +0 -1
- package/dist/agent-src/commands/analyze/conformance.md +0 -1
- package/dist/agent-src/commands/analyze/decision.md +0 -1
- package/dist/agent-src/commands/analyze/inbox.md +0 -1
- package/dist/agent-src/commands/analyze/incident.md +0 -1
- package/dist/agent-src/commands/analyze/near-miss.md +0 -1
- package/dist/agent-src/commands/analyze/postmortem.md +0 -1
- package/dist/agent-src/commands/analyze/premortem.md +0 -1
- package/dist/agent-src/commands/analyze/reference-repo.md +0 -1
- package/dist/agent-src/commands/analyze.md +0 -1
- package/dist/agent-src/commands/brand/identity.md +0 -1
- package/dist/agent-src/commands/brand/review.md +0 -1
- package/dist/agent-src/commands/brand/strategy.md +0 -1
- package/dist/agent-src/commands/brand/tokens.md +0 -1
- package/dist/agent-src/commands/brand/voice.md +0 -1
- package/dist/agent-src/commands/brand.md +0 -1
- package/dist/agent-src/commands/bug/fix.md +0 -1
- package/dist/agent-src/commands/bug/investigate.md +0 -1
- package/dist/agent-src/commands/bug.md +0 -1
- package/dist/agent-src/commands/challenge-me/vision.md +0 -1
- package/dist/agent-src/commands/challenge-me/with-docs.md +0 -1
- package/dist/agent-src/commands/challenge-me.md +0 -1
- package/dist/agent-src/commands/chat-history/import.md +0 -1
- package/dist/agent-src/commands/chat-history.md +0 -1
- package/dist/agent-src/commands/check-current-md.md +0 -1
- package/dist/agent-src/commands/commit/in-chunks.md +0 -1
- package/dist/agent-src/commands/commit.md +0 -1
- package/dist/agent-src/commands/condense.md +0 -1
- package/dist/agent-src/commands/context/create.md +0 -1
- package/dist/agent-src/commands/context/refactor.md +0 -1
- package/dist/agent-src/commands/context.md +0 -1
- package/dist/agent-src/commands/contribution-precheck.md +0 -1
- package/dist/agent-src/commands/cost/profile.md +0 -1
- package/dist/agent-src/commands/cost/report.md +0 -1
- package/dist/agent-src/commands/cost.md +0 -1
- package/dist/agent-src/commands/council/analysis.md +0 -1
- package/dist/agent-src/commands/council/debate.md +0 -1
- package/dist/agent-src/commands/council/default.md +0 -1
- package/dist/agent-src/commands/council/design.md +0 -1
- package/dist/agent-src/commands/council/optimize.md +0 -1
- package/dist/agent-src/commands/council/pr.md +0 -1
- package/dist/agent-src/commands/council.md +0 -1
- package/dist/agent-src/commands/design-system/capture.md +0 -1
- package/dist/agent-src/commands/design-system/generate.md +0 -1
- package/dist/agent-src/commands/design-system/import.md +0 -1
- package/dist/agent-src/commands/design-system.md +0 -1
- package/dist/agent-src/commands/estimate-ticket.md +0 -1
- package/dist/agent-src/commands/explain-run.md +0 -1
- package/dist/agent-src/commands/feature/dev.md +0 -1
- package/dist/agent-src/commands/feature/explore.md +0 -1
- package/dist/agent-src/commands/feature/plan.md +0 -1
- package/dist/agent-src/commands/feature/refactor.md +0 -1
- package/dist/agent-src/commands/feature/roadmap.md +0 -1
- package/dist/agent-src/commands/feature.md +0 -1
- package/dist/agent-src/commands/fix/ci.md +0 -1
- package/dist/agent-src/commands/fix/comments.md +0 -1
- package/dist/agent-src/commands/fix/portability.md +0 -1
- package/dist/agent-src/commands/fix/pr-comments-loop.md +0 -1
- package/dist/agent-src/commands/fix/pr-comments.md +0 -1
- package/dist/agent-src/commands/fix/quality.md +0 -1
- package/dist/agent-src/commands/fix/refs.md +0 -1
- package/dist/agent-src/commands/fix/route.md +0 -1
- package/dist/agent-src/commands/fix/seeder.md +0 -1
- package/dist/agent-src/commands/fix.md +0 -1
- package/dist/agent-src/commands/ghostwriter/delete.md +0 -1
- package/dist/agent-src/commands/ghostwriter/fetch.md +0 -1
- package/dist/agent-src/commands/ghostwriter/list.md +0 -1
- package/dist/agent-src/commands/ghostwriter/show.md +0 -1
- package/dist/agent-src/commands/ghostwriter/write.md +0 -1
- package/dist/agent-src/commands/ghostwriter.md +0 -1
- package/dist/agent-src/commands/grill-me.md +0 -1
- package/dist/agent-src/commands/humanize.md +0 -1
- package/dist/agent-src/commands/image/analyse.md +0 -1
- package/dist/agent-src/commands/image/create.md +0 -1
- package/dist/agent-src/commands/image/verify.md +0 -1
- package/dist/agent-src/commands/image.md +0 -1
- package/dist/agent-src/commands/implement-ticket.md +0 -1
- package/dist/agent-src/commands/jira-ticket.md +0 -1
- package/dist/agent-src/commands/judge/on-diff.md +0 -1
- package/dist/agent-src/commands/judge/solo.md +0 -1
- package/dist/agent-src/commands/judge/steps.md +0 -1
- package/dist/agent-src/commands/judge.md +0 -1
- package/dist/agent-src/commands/knowledge/cross-repo.md +0 -1
- package/dist/agent-src/commands/knowledge/forget.md +0 -1
- package/dist/agent-src/commands/knowledge/ingest.md +0 -1
- package/dist/agent-src/commands/knowledge/list.md +0 -1
- package/dist/agent-src/commands/knowledge.md +0 -1
- package/dist/agent-src/commands/memory/add.md +0 -1
- package/dist/agent-src/commands/memory/learn-low-impact.md +0 -1
- package/dist/agent-src/commands/memory/load.md +0 -1
- package/dist/agent-src/commands/memory/mine-session.md +0 -1
- package/dist/agent-src/commands/memory/promote.md +0 -1
- package/dist/agent-src/commands/memory/propose.md +0 -1
- package/dist/agent-src/commands/memory.md +0 -1
- package/dist/agent-src/commands/mission/upgrade.md +0 -1
- package/dist/agent-src/commands/mode.md +0 -1
- package/dist/agent-src/commands/module/create.md +0 -1
- package/dist/agent-src/commands/module/explore.md +0 -1
- package/dist/agent-src/commands/module.md +0 -1
- package/dist/agent-src/commands/optimize/agents-dir.md +0 -1
- package/dist/agent-src/commands/optimize/augmentignore.md +0 -1
- package/dist/agent-src/commands/optimize/deep.md +0 -1
- package/dist/agent-src/commands/optimize/project.md +0 -1
- package/dist/agent-src/commands/optimize/prompt.md +0 -1
- package/dist/agent-src/commands/optimize/rtk.md +0 -1
- package/dist/agent-src/commands/optimize/skills.md +0 -1
- package/dist/agent-src/commands/optimize.md +0 -1
- package/dist/agent-src/commands/orchestrate.md +0 -1
- package/dist/agent-src/commands/override/create.md +0 -1
- package/dist/agent-src/commands/override/manage.md +0 -1
- package/dist/agent-src/commands/override.md +0 -1
- package/dist/agent-src/commands/package/reset.md +0 -1
- package/dist/agent-src/commands/package/test.md +0 -1
- package/dist/agent-src/commands/package.md +0 -1
- package/dist/agent-src/commands/post-as/ghostwriter.md +0 -1
- package/dist/agent-src/commands/post-as/me.md +0 -1
- package/dist/agent-src/commands/post-as.md +0 -1
- package/dist/agent-src/commands/pr/create/description-only.md +0 -1
- package/dist/agent-src/commands/pr/create.md +23 -9
- package/dist/agent-src/commands/prediction-pool.md +0 -1
- package/dist/agent-src/commands/prepare-for-review.md +0 -1
- package/dist/agent-src/commands/profile/activate.md +0 -1
- package/dist/agent-src/commands/profile/deactivate.md +0 -1
- package/dist/agent-src/commands/profile/show.md +0 -1
- package/dist/agent-src/commands/profile.md +0 -1
- package/dist/agent-src/commands/project/analyze.md +0 -1
- package/dist/agent-src/commands/project/health.md +0 -1
- package/dist/agent-src/commands/project.md +0 -1
- package/dist/agent-src/commands/refine-ticket.md +0 -1
- package/dist/agent-src/commands/research/deep.md +0 -1
- package/dist/agent-src/commands/research/report.md +0 -1
- package/dist/agent-src/commands/research.md +0 -1
- package/dist/agent-src/commands/review/changes.md +0 -1
- package/dist/agent-src/commands/review/routing.md +0 -1
- package/dist/agent-src/commands/review.md +0 -1
- package/dist/agent-src/commands/roadmap/ai-council.md +0 -1
- package/dist/agent-src/commands/roadmap/create.md +0 -1
- package/dist/agent-src/commands/roadmap/materialize.md +0 -1
- package/dist/agent-src/commands/roadmap/next.md +0 -1
- package/dist/agent-src/commands/roadmap/process-full.md +0 -1
- package/dist/agent-src/commands/roadmap/process-phase.md +0 -1
- package/dist/agent-src/commands/roadmap/process-step.md +0 -1
- package/dist/agent-src/commands/roadmap.md +0 -1
- package/dist/agent-src/commands/rule-compliance-audit.md +0 -1
- package/dist/agent-src/commands/security-audit-config.md +0 -1
- package/dist/agent-src/commands/skill/preview.md +0 -1
- package/dist/agent-src/commands/skill.md +0 -1
- package/dist/agent-src/commands/skills/discover.md +0 -1
- package/dist/agent-src/commands/skills.md +0 -1
- package/dist/agent-src/commands/sync/agent-settings.md +0 -1
- package/dist/agent-src/commands/sync/gitignore/fix.md +0 -1
- package/dist/agent-src/commands/sync/gitignore.md +0 -1
- package/dist/agent-src/commands/sync.md +0 -1
- package/dist/agent-src/commands/tdd/green.md +0 -1
- package/dist/agent-src/commands/tdd/red.md +0 -1
- package/dist/agent-src/commands/tdd/refactor.md +0 -1
- package/dist/agent-src/commands/tdd.md +0 -1
- package/dist/agent-src/commands/team/adversarial.md +0 -1
- package/dist/agent-src/commands/team/delegate.md +0 -1
- package/dist/agent-src/commands/team/review.md +0 -1
- package/dist/agent-src/commands/team/status.md +0 -1
- package/dist/agent-src/commands/team-knowledge/bootstrap.md +0 -1
- package/dist/agent-src/commands/team-knowledge/consolidate.md +0 -1
- package/dist/agent-src/commands/team-knowledge.md +0 -1
- package/dist/agent-src/commands/team.md +0 -1
- package/dist/agent-src/commands/tests/create.md +0 -1
- package/dist/agent-src/commands/tests/e2e-heal.md +0 -1
- package/dist/agent-src/commands/tests/e2e-plan.md +0 -1
- package/dist/agent-src/commands/tests/execute.md +0 -1
- package/dist/agent-src/commands/tests.md +0 -1
- package/dist/agent-src/commands/threat-model.md +0 -1
- package/dist/agent-src/commands/update-form-request-messages.md +0 -1
- package/dist/agent-src/commands/upstream-contribute.md +0 -1
- package/dist/agent-src/commands/video/from-script.md +0 -1
- package/dist/agent-src/commands/video/from-song.md +0 -1
- package/dist/agent-src/commands/video/scene.md +0 -1
- package/dist/agent-src/commands/video/stitch.md +0 -1
- package/dist/agent-src/commands/video/storyboard.md +0 -1
- package/dist/agent-src/commands/video.md +0 -1
- package/dist/agent-src/commands/work.md +0 -1
- package/dist/agent-src/commands/worktree/cleanup.md +0 -1
- package/dist/agent-src/commands/worktree/create.md +0 -1
- package/dist/agent-src/commands/worktree/status.md +0 -1
- package/dist/agent-src/commands/worktree/verify.md +0 -1
- package/dist/agent-src/commands/worktree.md +0 -1
- package/dist/agent-src/contexts/model-recommendations.md +6 -4
- package/dist/agent-src/rules/low-impact-corpus-privacy-floor.md +1 -0
- package/dist/agent-src/scripts/roadmap_gates.ts +30 -2
- package/dist/agent-src/scripts/update_roadmap_progress.ts +32 -1
- package/dist/agent-src/skills/docx-authoring/SKILL.md +1 -0
- package/dist/agent-src/skills/git-workflow/SKILL.md +9 -1
- package/dist/agent-src/skills/pdf-tools/SKILL.md +1 -0
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +7 -7
- package/dist/agent-src/skills/skill-writing/SKILL.md +35 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +41 -3
- package/dist/cli/commands/commands.candidates.test.js +1 -1
- package/dist/cli/commands/commands.candidates.test.js.map +1 -1
- package/dist/cli/commands/commands.js +9 -15
- package/dist/cli/commands/commands.js.map +1 -1
- package/dist/cli/commands/profiles.test.js +7 -7
- package/dist/cli/commands/profiles.test.js.map +1 -1
- package/dist/cli/discovery/loadManifest.js.map +1 -1
- package/dist/cli/main.js +2 -2
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/chunk-K34QTSD5.js +3895 -0
- package/dist/cli-delegate/{chunk-L565YFXH.js → chunk-LKZ5DOYW.js} +557 -297
- package/dist/cli-delegate/{chunk-NN4KW4HZ.js → chunk-O52B4F5S.js} +123 -2338
- package/dist/cli-delegate/cmd_conformance.js +18 -6
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_export.js +1 -1
- package/dist/cli-delegate/cmd_handoff.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +1 -1
- package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +1 -1
- package/dist/cli-delegate/cmd_uninstall.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +213 -412
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +2 -2
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +3 -3
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +3 -3
- package/dist/hooks/dispatch.js +2588 -1082
- package/dist/install/install.mjs +744 -482
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +159 -15
- package/dist/router.json +1 -1
- package/dist/scripts/_lib/model_tier.js +9 -0
- package/dist/scripts/_lib/model_tier.js.map +1 -1
- package/dist/scripts/condense.js +50 -0
- package/dist/scripts/condense.js.map +1 -1
- package/docs/MIGRATION.md +21 -1
- package/docs/contracts/settings-classes.md +41 -0
- package/package.json +1 -1
- package/src/agent-src/scripts/roadmap_gates.ts +30 -2
- package/src/agent-src/scripts/update_roadmap_progress.ts +32 -1
- package/src/config/agent-settings.template.yml +34 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +39 -9
- package/src/config/gate-violation-baselines.json +9 -3
- package/src/config/hook-token-budget.json +7 -1
- package/src/config/preamble-payload-budget.json +28 -0
- package/src/scripts/_dispatch.bash +52 -5
- package/src/scripts/_lib/git_common_dir.ts +173 -0
- package/src/scripts/_lib/model_tier.ts +9 -0
- package/src/scripts/_lib/release_highlights.ts +108 -7
- package/src/scripts/_lib/skill_catalogue.ts +862 -0
- package/src/scripts/ai_council/clients.ts +182 -3
- package/src/scripts/ai_council/config.ts +16 -1
- package/src/scripts/ai_council/low_impact.ts +14 -1
- package/src/scripts/ai_council/orchestrator.ts +14 -1
- package/src/scripts/ai_council/transport_resolver.ts +32 -2
- package/src/scripts/audit_command_surface.ts +21 -15
- package/src/scripts/build_catalog_index.ts +1 -1
- package/src/scripts/build_discovery_manifest.ts +30 -34
- package/src/scripts/capture_skill_catalogue.ts +185 -328
- package/src/scripts/check_branch_freshness.ts +217 -62
- package/src/scripts/check_release_published.ts +6 -2
- package/src/scripts/check_release_trunk_sync.ts +18 -2
- package/src/scripts/condense.ts +50 -1
- package/src/scripts/dispatch_r2_reviewer.ts +15 -5
- package/src/scripts/evidence_report.ts +15 -4
- package/src/scripts/gen_discovery_baseline.ts +3 -3
- package/src/scripts/hook_manifest.yaml +67 -13
- package/src/scripts/hooks/concern_registry.ts +2 -0
- package/src/scripts/hooks/orchestration_record_hook.ts +21 -0
- package/src/scripts/hooks/subagent_ledger_hook.ts +17 -4
- package/src/scripts/hooks/tool_result_bytes_hook.ts +219 -0
- package/src/scripts/hot_context_hook.ts +13 -1
- package/src/scripts/install.ts +129 -0
- package/src/scripts/janitor.ts +77 -2
- package/src/scripts/lint_command_tiers.ts +29 -75
- package/src/scripts/lint_originality.ts +101 -14
- package/src/scripts/lint_plan_risk_register.ts +22 -9
- package/src/scripts/lint_roadmap_blockers.ts +109 -6
- package/src/scripts/lint_scheduled_deprecations.ts +738 -0
- package/src/scripts/migration_status.ts +17 -1
- package/src/scripts/probe_review_binding_drift.ts +731 -0
- package/src/scripts/release.ts +95 -1
- package/src/scripts/schemas/command.schema.json +6 -11
- package/src/scripts/schemas/skill.schema.json +12 -2
- package/src/scripts/schemas/subagent.schema.json +2 -2
- package/src/scripts/session_eol_report.ts +81 -0
- package/src/scripts/skill_linter.ts +82 -2
- package/src/scripts/workspace_doctor.ts +464 -0
- package/src/scripts/worktree_cleanup_check.ts +113 -8
- package/dist/cli-delegate/chunk-AYKQGIBG.js +0 -1594
|
@@ -0,0 +1,862 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* skill_catalogue — the pure measurement library behind `capture_skill_catalogue`.
|
|
3
|
+
*
|
|
4
|
+
* Split out of the CLI for one concrete reason: `install.ts` needs the
|
|
5
|
+
* deploy-time truncation warning, `install.ts` is an esbuild bundle entry, and
|
|
6
|
+
* a bundled module carrying a top-level `process.exit()` runs that exit when a
|
|
7
|
+
* CONSUMER loads the installer — the entry guard cannot tell it apart, because
|
|
8
|
+
* inside a bundle the bundle IS `process.argv[1]`. So everything importable
|
|
9
|
+
* lives here, with no CLI entry and no top-level exit, and the CLI re-exports
|
|
10
|
+
* it.
|
|
11
|
+
*
|
|
12
|
+
* capture_skill_catalogue — make the skill-catalogue delivery defect countable.
|
|
13
|
+
*
|
|
14
|
+
* THE HOST BOUNDARY, STATED FIRST BECAUSE IT DECIDES THE SHAPE OF THIS TOOL.
|
|
15
|
+
* The round-6 census asked for a `session_start` concern that logs the injected
|
|
16
|
+
* catalogue block. A concern cannot do that. The dispatcher envelope carries
|
|
17
|
+
* `session_id`, `source`, `cwd`, `transcript_path` — the injected catalogue is
|
|
18
|
+
* part of the request's system payload, and `preamble_byte_census.ts` already
|
|
19
|
+
* verified independently that no local transcript or file carries it. A hook
|
|
20
|
+
* that "captures the catalogue" would therefore be capturing something else and
|
|
21
|
+
* reporting it under that name, which is worse than not measuring.
|
|
22
|
+
*
|
|
23
|
+
* So the capture is deliberately two-sided, and each side is labelled with what
|
|
24
|
+
* it actually is:
|
|
25
|
+
*
|
|
26
|
+
* PROJECTION (deterministic) — what the projected skill tree offers the host:
|
|
27
|
+
* per entry, whether a `description:` exists, how long it is, which
|
|
28
|
+
* frontmatter keys it carries, and its position in the sorted catalogue. This
|
|
29
|
+
* is the host's INPUT and it is fully file-measurable.
|
|
30
|
+
*
|
|
31
|
+
* OBSERVATION — which entries actually arrived, and how many did not. This
|
|
32
|
+
* side has TWO sources and every record says which one it came from:
|
|
33
|
+
*
|
|
34
|
+
* `self-report` — an agent reading its own context lists the bare names.
|
|
35
|
+
* Only the agent can see this. It is not enforcement and nothing here
|
|
36
|
+
* pretends otherwise. This is the claude-shaped path.
|
|
37
|
+
*
|
|
38
|
+
* `host-event` — the host publishes its own truncation on a machine
|
|
39
|
+
* channel. `codex exec --json` emits an `item.completed` error event whose
|
|
40
|
+
* message states how many entries it dropped, so the count is READ, never
|
|
41
|
+
* transcribed. This is the codex-shaped path and it is deterministic.
|
|
42
|
+
*
|
|
43
|
+
* Hosts do not truncate the same way, so a record also carries a
|
|
44
|
+
* `truncation_mode`: `per-entry` (some entries arrive bare, others full —
|
|
45
|
+
* claude) versus `budget-strip-and-drop` (every description stripped, then N
|
|
46
|
+
* entries dropped wholesale — codex). Pooling the two into one verdict would
|
|
47
|
+
* average two different mechanisms into a number describing neither, so the
|
|
48
|
+
* report is always per host.
|
|
49
|
+
*
|
|
50
|
+
* The join of the two answers the question the census could not: not "how many
|
|
51
|
+
* are bare" but WHICH PROPERTY separates bare from described. That is the
|
|
52
|
+
* selector, and it is what a delivery fix has to act on. A positional
|
|
53
|
+
* head-N hypothesis is one candidate among several and this tool is built to
|
|
54
|
+
* falsify it rather than assume it — the first observation recorded against
|
|
55
|
+
* this repo showed entries far past position 40 carrying full descriptions
|
|
56
|
+
* while an earlier one was bare, which no head-N budget explains.
|
|
57
|
+
*
|
|
58
|
+
* PRIVACY BY CONSTRUCTION. An observation record carries skill names, integer
|
|
59
|
+
* counts, a host label and two closed enums. The record type has no field able
|
|
60
|
+
* to hold prompt text, file bodies, paths outside the catalogue, or user
|
|
61
|
+
* content — the same shape `domain-safety-pii` § Surface 2 requires of a log
|
|
62
|
+
* event. The codex path reads a host message and keeps only the integer in it;
|
|
63
|
+
* the message text itself never reaches a record. Do not widen it with a
|
|
64
|
+
* free-form field.
|
|
65
|
+
*
|
|
66
|
+
* A ZERO IS NEVER INFERRED FROM SILENCE. If the host's budget event is absent
|
|
67
|
+
* or unparseable — reworded upstream, or removed — the capture fails loudly
|
|
68
|
+
* rather than recording `dropped: 0`, because a broken parser and a fixed
|
|
69
|
+
* defect would otherwise look identical. Recording a genuine no-truncation run
|
|
70
|
+
* takes an explicit `--assert-no-truncation`.
|
|
71
|
+
*
|
|
72
|
+
* Exit codes: 0 report produced · 1 usage/IO error, an empty catalogue root
|
|
73
|
+
* (a catalogue scan that found nothing must never read as a clean result), or
|
|
74
|
+
* an unreadable host observation.
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
import * as fs from 'node:fs';
|
|
78
|
+
import * as path from 'node:path';
|
|
79
|
+
|
|
80
|
+
/** Catalogue roots tried in order; the first that exists wins. */
|
|
81
|
+
export const DEFAULT_CATALOGUE_ROOTS = ['.claude/skills', 'src/skills'] as const;
|
|
82
|
+
|
|
83
|
+
/** Where observations accumulate. Append-only, one JSON object per line. */
|
|
84
|
+
export const OBSERVATION_LOG = path.join(
|
|
85
|
+
'agents',
|
|
86
|
+
'evidence',
|
|
87
|
+
'metrics',
|
|
88
|
+
'skill-catalogue.jsonl',
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
export interface CatalogueEntry {
|
|
92
|
+
/** Skill directory name — the identifier the host lists. */
|
|
93
|
+
name: string;
|
|
94
|
+
/** 1-based position in the alphabetically sorted catalogue. */
|
|
95
|
+
position: number;
|
|
96
|
+
/** Whether the projected SKILL.md declares a `description:`. */
|
|
97
|
+
hasDescription: boolean;
|
|
98
|
+
/** Character length of the declared description; 0 when absent. */
|
|
99
|
+
descriptionLength: number;
|
|
100
|
+
/**
|
|
101
|
+
* The declared description itself, folded. In-memory only — this is the
|
|
102
|
+
* projection half, which is file content the host is handed, and it is
|
|
103
|
+
* what makes a byte-accurate volume measurement possible. It is NEVER
|
|
104
|
+
* copied into an `ObservationRecord`; see the privacy paragraph.
|
|
105
|
+
*/
|
|
106
|
+
description: string;
|
|
107
|
+
/** Top-level frontmatter keys the entry declares. */
|
|
108
|
+
frontmatterKeys: string[];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface SelectorCandidate {
|
|
112
|
+
/** Stable identifier for the property under test. */
|
|
113
|
+
id: string;
|
|
114
|
+
/** Human-readable statement of what the property is. */
|
|
115
|
+
describes: string;
|
|
116
|
+
/** True when the property's values are disjoint across the two groups. */
|
|
117
|
+
separates: boolean;
|
|
118
|
+
/** Why it does or does not separate, in one line. */
|
|
119
|
+
detail: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface SelectorReport {
|
|
123
|
+
catalogueRoot: string;
|
|
124
|
+
entriesTotal: number;
|
|
125
|
+
observedBare: string[];
|
|
126
|
+
observedDescribed: string[];
|
|
127
|
+
/** Observed names that the projection does not know. */
|
|
128
|
+
unknownObserved: string[];
|
|
129
|
+
candidates: SelectorCandidate[];
|
|
130
|
+
verdict: 'selector-found' | 'no-selector' | 'insufficient-observation';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Frontmatter block between the leading `---` fences, or "" when absent. */
|
|
134
|
+
function frontmatterOf(content: string): string {
|
|
135
|
+
if (!content.startsWith('---')) return '';
|
|
136
|
+
const end = content.indexOf('\n---', 3);
|
|
137
|
+
if (end === -1) return '';
|
|
138
|
+
return content.slice(3, end);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Top-level keys of a frontmatter block. Deliberately shallow: a nested key is
|
|
143
|
+
* not a property the host's catalogue selector could plausibly read, and a full
|
|
144
|
+
* YAML parse would drag a dependency into a measurement tool.
|
|
145
|
+
*/
|
|
146
|
+
function topLevelKeys(frontmatter: string): string[] {
|
|
147
|
+
const keys: string[] = [];
|
|
148
|
+
for (const line of frontmatter.split('\n')) {
|
|
149
|
+
const match = /^([A-Za-z_][A-Za-z0-9_-]*):/.exec(line);
|
|
150
|
+
if (match) keys.push(match[1]!);
|
|
151
|
+
}
|
|
152
|
+
return keys;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The declared description with surrounding quotes stripped, or "".
|
|
157
|
+
*
|
|
158
|
+
* A block scalar (`description: >-` / `|`) puts the text on the FOLLOWING
|
|
159
|
+
* lines, so taking the remainder of the header line would yield the
|
|
160
|
+
* two-character indicator and report `descriptionLength: 2`. That would feed
|
|
161
|
+
* the length-based selector candidate noise, and a length threshold could be
|
|
162
|
+
* reported or refuted on it. Block scalars are therefore folded properly.
|
|
163
|
+
*/
|
|
164
|
+
function descriptionOf(frontmatter: string): string {
|
|
165
|
+
const lines = frontmatter.split('\n');
|
|
166
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
167
|
+
const match = /^description:\s*(.*)$/.exec(lines[i]!);
|
|
168
|
+
if (!match) continue;
|
|
169
|
+
const rest = match[1]!.trim();
|
|
170
|
+
|
|
171
|
+
if (/^[|>][-+]?\d*$/.test(rest)) {
|
|
172
|
+
const body: string[] = [];
|
|
173
|
+
for (let j = i + 1; j < lines.length; j += 1) {
|
|
174
|
+
const line = lines[j]!;
|
|
175
|
+
if (line.trim() === '') {
|
|
176
|
+
body.push('');
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (!/^\s/.test(line)) break; // dedented, so the block ended
|
|
180
|
+
body.push(line.trim());
|
|
181
|
+
}
|
|
182
|
+
return body.join(' ').trim();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return rest.replace(/^["']|["']$/g, '');
|
|
186
|
+
}
|
|
187
|
+
return '';
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Read every `<root>/<name>/SKILL.md` into a catalogue entry, sorted by name —
|
|
192
|
+
* the order a host listing follows and therefore the order a positional
|
|
193
|
+
* hypothesis is measured against.
|
|
194
|
+
*/
|
|
195
|
+
export function readProjectedCatalogue(root: string): CatalogueEntry[] {
|
|
196
|
+
// Membership is decided by "does <name>/SKILL.md resolve", never by
|
|
197
|
+
// `Dirent.isDirectory()`. The host-facing projection is a tree of SYMLINKS
|
|
198
|
+
// into `dist/agent-src/skills/`, and a Dirent for a symlink reports
|
|
199
|
+
// `isDirectory() === false` — that filter silently read 47 of 289 entries
|
|
200
|
+
// and reported the result as a complete catalogue. `existsSync` follows
|
|
201
|
+
// the link, so the scan sees what the host sees.
|
|
202
|
+
const names = fs
|
|
203
|
+
.readdirSync(root)
|
|
204
|
+
.filter((n) => fs.existsSync(path.join(root, n, 'SKILL.md')))
|
|
205
|
+
.sort();
|
|
206
|
+
|
|
207
|
+
return names.map((name, index) => {
|
|
208
|
+
const content = fs.readFileSync(path.join(root, name, 'SKILL.md'), 'utf-8');
|
|
209
|
+
const frontmatter = frontmatterOf(content);
|
|
210
|
+
const description = descriptionOf(frontmatter);
|
|
211
|
+
return {
|
|
212
|
+
name,
|
|
213
|
+
position: index + 1,
|
|
214
|
+
hasDescription: description.length > 0,
|
|
215
|
+
descriptionLength: description.length,
|
|
216
|
+
description,
|
|
217
|
+
frontmatterKeys: topLevelKeys(frontmatter),
|
|
218
|
+
};
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** True when two number sets occupy disjoint ranges in either direction. */
|
|
223
|
+
function rangesAreDisjoint(a: number[], b: number[]): boolean {
|
|
224
|
+
if (a.length === 0 || b.length === 0) return false;
|
|
225
|
+
const maxA = Math.max(...a);
|
|
226
|
+
const minA = Math.min(...a);
|
|
227
|
+
const maxB = Math.max(...b);
|
|
228
|
+
const minB = Math.min(...b);
|
|
229
|
+
return maxA < minB || maxB < minA;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Join the projection against an observation and report which property, if
|
|
234
|
+
* any, separates the bare entries from the described ones.
|
|
235
|
+
*
|
|
236
|
+
* `observedBare` is the list of entries a live session saw without a
|
|
237
|
+
* description. Everything else in the projection is treated as observed
|
|
238
|
+
* described, which is the honest reading of a whole-catalogue observation —
|
|
239
|
+
* a partial observation would need its own described list, so the caller
|
|
240
|
+
* passes one when it has it.
|
|
241
|
+
*/
|
|
242
|
+
export function analyzeSelector(
|
|
243
|
+
projected: CatalogueEntry[],
|
|
244
|
+
observedBare: readonly string[],
|
|
245
|
+
observedDescribed?: readonly string[],
|
|
246
|
+
): Omit<SelectorReport, 'catalogueRoot'> {
|
|
247
|
+
const known = new Map(projected.map((e) => [e.name, e]));
|
|
248
|
+
const bare = observedBare.filter((n) => known.has(n));
|
|
249
|
+
const describedInput =
|
|
250
|
+
observedDescribed ?? projected.map((e) => e.name).filter((n) => !bare.includes(n));
|
|
251
|
+
const described = describedInput.filter((n) => known.has(n));
|
|
252
|
+
|
|
253
|
+
// Both self-reported lists are validated, not only the bare one: a typo in
|
|
254
|
+
// --described silently shrank the described set and weakened the analysis,
|
|
255
|
+
// while the same typo in --observed was surfaced.
|
|
256
|
+
const unknownObserved = [
|
|
257
|
+
...new Set([
|
|
258
|
+
...observedBare.filter((n) => !known.has(n)),
|
|
259
|
+
...(observedDescribed ?? []).filter((n) => !known.has(n)),
|
|
260
|
+
]),
|
|
261
|
+
].sort();
|
|
262
|
+
|
|
263
|
+
const base = {
|
|
264
|
+
entriesTotal: projected.length,
|
|
265
|
+
observedBare: [...bare].sort(),
|
|
266
|
+
observedDescribed: [...described].sort(),
|
|
267
|
+
unknownObserved,
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
if (bare.length === 0 || described.length === 0) {
|
|
271
|
+
return {
|
|
272
|
+
...base,
|
|
273
|
+
candidates: [],
|
|
274
|
+
verdict: 'insufficient-observation' as const,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const bareEntries = bare.map((n) => known.get(n)!);
|
|
279
|
+
const describedEntries = described.map((n) => known.get(n)!);
|
|
280
|
+
const candidates: SelectorCandidate[] = [];
|
|
281
|
+
|
|
282
|
+
// 1. The projection itself — does the entry even declare a description?
|
|
283
|
+
const bareWithDesc = bareEntries.filter((e) => e.hasDescription).length;
|
|
284
|
+
const describedWithoutDesc = describedEntries.filter((e) => !e.hasDescription).length;
|
|
285
|
+
candidates.push({
|
|
286
|
+
id: 'declares-description',
|
|
287
|
+
describes: 'the projected SKILL.md declares a `description:`',
|
|
288
|
+
separates: bareWithDesc === 0 && describedWithoutDesc === 0,
|
|
289
|
+
detail: `${bareWithDesc}/${bareEntries.length} bare entries declare one; ${describedWithoutDesc}/${describedEntries.length} described entries do not`,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// 2. The head-N hypothesis the census proposed — falsifiable here.
|
|
293
|
+
const barePositions = bareEntries.map((e) => e.position);
|
|
294
|
+
const describedPositions = describedEntries.map((e) => e.position);
|
|
295
|
+
const positional = Math.max(...describedPositions) < Math.min(...barePositions);
|
|
296
|
+
candidates.push({
|
|
297
|
+
id: 'positional-head',
|
|
298
|
+
describes: 'every described entry sorts before every bare entry (a head-N budget)',
|
|
299
|
+
separates: positional,
|
|
300
|
+
detail: positional
|
|
301
|
+
? `described top out at #${Math.max(...describedPositions)}, bare start at #${Math.min(...barePositions)}`
|
|
302
|
+
: `described reach #${Math.max(...describedPositions)} while bare start at #${Math.min(...barePositions)} — the ranges overlap, so no head-N cut explains this`,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// 3. Description length as a budget proxy.
|
|
306
|
+
const lengthSeparates = rangesAreDisjoint(
|
|
307
|
+
bareEntries.map((e) => e.descriptionLength),
|
|
308
|
+
describedEntries.map((e) => e.descriptionLength),
|
|
309
|
+
);
|
|
310
|
+
candidates.push({
|
|
311
|
+
id: 'description-length',
|
|
312
|
+
describes: 'description length ranges do not overlap between the groups',
|
|
313
|
+
separates: lengthSeparates,
|
|
314
|
+
detail: lengthSeparates
|
|
315
|
+
? 'a length threshold separates the two groups'
|
|
316
|
+
: 'length ranges overlap',
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// 4. Any frontmatter key whose presence tracks the split exactly.
|
|
320
|
+
const allKeys = new Set<string>();
|
|
321
|
+
for (const entry of projected) for (const key of entry.frontmatterKeys) allKeys.add(key);
|
|
322
|
+
for (const key of [...allKeys].sort()) {
|
|
323
|
+
const bareHas = bareEntries.filter((e) => e.frontmatterKeys.includes(key)).length;
|
|
324
|
+
const describedHas = describedEntries.filter((e) =>
|
|
325
|
+
e.frontmatterKeys.includes(key),
|
|
326
|
+
).length;
|
|
327
|
+
const separates =
|
|
328
|
+
(bareHas === 0 && describedHas === describedEntries.length) ||
|
|
329
|
+
(describedHas === 0 && bareHas === bareEntries.length);
|
|
330
|
+
if (!separates) continue;
|
|
331
|
+
candidates.push({
|
|
332
|
+
id: `frontmatter:${key}`,
|
|
333
|
+
describes: `presence of the \`${key}:\` frontmatter key`,
|
|
334
|
+
separates: true,
|
|
335
|
+
detail: `bare ${bareHas}/${bareEntries.length}, described ${describedHas}/${describedEntries.length}`,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
...base,
|
|
341
|
+
candidates,
|
|
342
|
+
verdict: candidates.some((c) => c.separates)
|
|
343
|
+
? ('selector-found' as const)
|
|
344
|
+
: ('no-selector' as const),
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* How a host truncates. The two known mechanisms are not variants of one
|
|
350
|
+
* number, so a record that does not say which one it measured cannot be
|
|
351
|
+
* compared against one that measured the other.
|
|
352
|
+
*
|
|
353
|
+
* `per-entry` — some entries arrive with their description, others arrive as
|
|
354
|
+
* bare names. Which is which is the selector question `analyzeSelector` asks.
|
|
355
|
+
*
|
|
356
|
+
* `budget-strip-and-drop` — the host strips EVERY description, then drops N
|
|
357
|
+
* entries from the list entirely. There is no per-entry selector to find; the
|
|
358
|
+
* quantity that matters is N.
|
|
359
|
+
*/
|
|
360
|
+
export type TruncationMode = 'per-entry' | 'budget-strip-and-drop';
|
|
361
|
+
|
|
362
|
+
/** Where the observation half came from. See the header. */
|
|
363
|
+
export type ObservationSource = 'self-report' | 'host-event';
|
|
364
|
+
|
|
365
|
+
/** One append-only observation record. No field can hold free-form content. */
|
|
366
|
+
export interface ObservationRecord {
|
|
367
|
+
schema: 1;
|
|
368
|
+
observed_at: string;
|
|
369
|
+
host: string;
|
|
370
|
+
entries_total: number;
|
|
371
|
+
bare_count: number;
|
|
372
|
+
described_count: number;
|
|
373
|
+
bare_names: string[];
|
|
374
|
+
verdict: SelectorReport['verdict'];
|
|
375
|
+
separating_candidates: string[];
|
|
376
|
+
/**
|
|
377
|
+
* Absent on a schema-1 record written before hosts were distinguished.
|
|
378
|
+
* Absent reads as `per-entry`, which is what those records measured — see
|
|
379
|
+
* `truncationModeOf`. Never default it to the codex mode: that would
|
|
380
|
+
* retroactively relabel an observation nobody took.
|
|
381
|
+
*/
|
|
382
|
+
truncation_mode?: TruncationMode;
|
|
383
|
+
/** Absent on records written before the two sources were distinguished. */
|
|
384
|
+
observation_source?: ObservationSource;
|
|
385
|
+
/**
|
|
386
|
+
* Skills projected at this observation — the ONLY population the drop
|
|
387
|
+
* tracks. `entries_total` counts every artefact, and a controlled probe
|
|
388
|
+
* (2026-08-15) moved the host's dropped count by 0 when 60 commands were
|
|
389
|
+
* added and by +53 when 60 skills were. Comparing artefact totals would
|
|
390
|
+
* fire on command growth that causes no truncation at all.
|
|
391
|
+
*/
|
|
392
|
+
projected_skill_count?: number;
|
|
393
|
+
/**
|
|
394
|
+
* Entries the host dropped wholesale, read from the host's own event.
|
|
395
|
+
* Only ever present on a `budget-strip-and-drop` record — a `per-entry`
|
|
396
|
+
* host publishes no such count, and inventing one is the failure the
|
|
397
|
+
* header's zero-is-never-inferred paragraph forbids.
|
|
398
|
+
*/
|
|
399
|
+
dropped_count?: number;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** The mode a record measured; absent reads as the pre-codex `per-entry`. */
|
|
403
|
+
export function truncationModeOf(record: ObservationRecord): TruncationMode {
|
|
404
|
+
return record.truncation_mode ?? 'per-entry';
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** The source a record came from; absent reads as the pre-codex self-report. */
|
|
408
|
+
export function observationSourceOf(record: ObservationRecord): ObservationSource {
|
|
409
|
+
return record.observation_source ?? 'self-report';
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export function buildObservationRecord(
|
|
413
|
+
report: SelectorReport,
|
|
414
|
+
host: string,
|
|
415
|
+
observedAt: string,
|
|
416
|
+
): ObservationRecord {
|
|
417
|
+
return {
|
|
418
|
+
schema: 1,
|
|
419
|
+
observed_at: observedAt,
|
|
420
|
+
host,
|
|
421
|
+
entries_total: report.entriesTotal,
|
|
422
|
+
bare_count: report.observedBare.length,
|
|
423
|
+
described_count: report.observedDescribed.length,
|
|
424
|
+
bare_names: report.observedBare,
|
|
425
|
+
verdict: report.verdict,
|
|
426
|
+
separating_candidates: report.candidates.filter((c) => c.separates).map((c) => c.id),
|
|
427
|
+
truncation_mode: 'per-entry',
|
|
428
|
+
observation_source: 'self-report',
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/* ------------------------------------------------------------------ *
|
|
433
|
+
* The host-event path — codex publishes its own truncation.
|
|
434
|
+
* ------------------------------------------------------------------ */
|
|
435
|
+
|
|
436
|
+
/** What a host's own budget event states. Integers only, by construction. */
|
|
437
|
+
export interface HostBudgetEvent {
|
|
438
|
+
/** Entries the host dropped from the model-visible list. */
|
|
439
|
+
droppedCount: number;
|
|
440
|
+
/** Whether the host also stripped descriptions from what survived. */
|
|
441
|
+
descriptionsStripped: boolean;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Read a host budget event out of a `codex exec --json` stream.
|
|
446
|
+
*
|
|
447
|
+
* Parses the STRUCTURED event, never the human-readable warning line: the
|
|
448
|
+
* pretty line is presentation and may be reworded or dropped without notice,
|
|
449
|
+
* while the JSON channel is the host's machine contract. Observed shape
|
|
450
|
+
* (2026-08-15, codex CLI):
|
|
451
|
+
*
|
|
452
|
+
* {"type":"item.completed","item":{"id":"item_0","type":"error",
|
|
453
|
+
* "message":"Exceeded skills context budget. All skill descriptions were
|
|
454
|
+
* removed and 393 additional skills were not included in the
|
|
455
|
+
* model-visible skills list."}}
|
|
456
|
+
*
|
|
457
|
+
* Returns `null` when no such event is present. `null` means UNKNOWN, never
|
|
458
|
+
* zero — the caller decides whether the run genuinely did not truncate, and
|
|
459
|
+
* says so explicitly. A line that is not JSON is skipped rather than fatal:
|
|
460
|
+
* the stream is interleaved with the CLI's own plain-text chatter.
|
|
461
|
+
*/
|
|
462
|
+
export function parseHostBudgetEvent(stream: string): HostBudgetEvent | null {
|
|
463
|
+
for (const line of stream.split('\n')) {
|
|
464
|
+
const trimmed = line.trim();
|
|
465
|
+
if (!trimmed.startsWith('{')) continue;
|
|
466
|
+
|
|
467
|
+
let parsed: unknown;
|
|
468
|
+
try {
|
|
469
|
+
parsed = JSON.parse(trimmed);
|
|
470
|
+
} catch {
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const item = (parsed as { item?: { type?: unknown; message?: unknown } }).item;
|
|
475
|
+
if (item === undefined || item === null || item.type !== 'error') continue;
|
|
476
|
+
const message = typeof item.message === 'string' ? item.message : '';
|
|
477
|
+
if (!/skills context budget/i.test(message)) continue;
|
|
478
|
+
|
|
479
|
+
const dropped = /(\d+)\s+additional skills were not included/i.exec(message);
|
|
480
|
+
if (!dropped) {
|
|
481
|
+
// The event fired and its shape changed. Reporting this as "no
|
|
482
|
+
// truncation" would turn an upstream rewording into a clean bill
|
|
483
|
+
// of health, so it is an unknown like any other.
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return {
|
|
488
|
+
droppedCount: Number.parseInt(dropped[1]!, 10),
|
|
489
|
+
descriptionsStripped: /descriptions were removed/i.test(message),
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* An observation built from the host's own event rather than from a self-report.
|
|
497
|
+
*
|
|
498
|
+
* `entriesOffered` is the deterministic projection half — how many artefacts
|
|
499
|
+
* the host was given. `bare_count` is the whole surviving set when the host
|
|
500
|
+
* stripped every description, which is exactly what the codex message states;
|
|
501
|
+
* there is no per-entry list to record, so `bare_names` stays empty rather
|
|
502
|
+
* than being filled with a guess.
|
|
503
|
+
*/
|
|
504
|
+
export function buildHostEventRecord(
|
|
505
|
+
host: string,
|
|
506
|
+
observedAt: string,
|
|
507
|
+
entriesOffered: number,
|
|
508
|
+
event: HostBudgetEvent,
|
|
509
|
+
projectedSkillCount?: number,
|
|
510
|
+
): ObservationRecord {
|
|
511
|
+
return {
|
|
512
|
+
schema: 1,
|
|
513
|
+
observed_at: observedAt,
|
|
514
|
+
host,
|
|
515
|
+
// OUR projection, not the host's denominator. Measured 2026-08-15 they
|
|
516
|
+
// are not the same number and there is no way to read the host's:
|
|
517
|
+
// `~/.codex` projects 297 skills + 200 commands = 497 by this tool's
|
|
518
|
+
// count, while the host reported dropping 393 — and a controlled +60
|
|
519
|
+
// commands moved that by ZERO. Whatever codex counts, it is not this.
|
|
520
|
+
// So `entries_total - dropped_count` is NOT a delivered count, and
|
|
521
|
+
// nothing here computes one: a subtraction across two different
|
|
522
|
+
// denominators produces a confident number describing neither.
|
|
523
|
+
entries_total: entriesOffered,
|
|
524
|
+
bare_count: 0,
|
|
525
|
+
described_count: 0,
|
|
526
|
+
bare_names: [],
|
|
527
|
+
// There is nothing to separate: the host did not choose per entry, it
|
|
528
|
+
// stripped all of them. `no-selector` would read as "we looked and
|
|
529
|
+
// found none", which is not what happened.
|
|
530
|
+
verdict: 'insufficient-observation',
|
|
531
|
+
separating_candidates: [],
|
|
532
|
+
truncation_mode: 'budget-strip-and-drop',
|
|
533
|
+
observation_source: 'host-event',
|
|
534
|
+
dropped_count: event.droppedCount,
|
|
535
|
+
...(projectedSkillCount === undefined ? {} : { projected_skill_count: projectedSkillCount }),
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/* ------------------------------------------------------------------ *
|
|
540
|
+
* The projection half, per host — volume, not just entry count.
|
|
541
|
+
* ------------------------------------------------------------------ */
|
|
542
|
+
|
|
543
|
+
/** What one host root is offered. Every number is file-measured. */
|
|
544
|
+
export interface CatalogueVolume {
|
|
545
|
+
host: string;
|
|
546
|
+
root: string;
|
|
547
|
+
skillEntries: number;
|
|
548
|
+
commandEntries: number;
|
|
549
|
+
/** Everything the host lists — the number its budget is spent on. */
|
|
550
|
+
artefacts: number;
|
|
551
|
+
/** Bytes of declared `description:` text across the skill entries. */
|
|
552
|
+
descriptionBytes: number;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Every `*.md` under a root, at any depth. */
|
|
556
|
+
function markdownFilesUnder(root: string): string[] {
|
|
557
|
+
if (!fs.existsSync(root)) return [];
|
|
558
|
+
const out: string[] = [];
|
|
559
|
+
const walk = (dir: string): void => {
|
|
560
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
561
|
+
const full = path.join(dir, entry.name);
|
|
562
|
+
if (fs.statSync(full).isDirectory()) walk(full);
|
|
563
|
+
else if (entry.name.endsWith('.md')) out.push(full);
|
|
564
|
+
}
|
|
565
|
+
};
|
|
566
|
+
walk(root);
|
|
567
|
+
return out;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Measure what a host root actually offers.
|
|
572
|
+
*
|
|
573
|
+
* `root` is the host's own config directory (`~/.codex`, `~/.claude`), not a
|
|
574
|
+
* skills directory — the budget is spent on the whole catalogue, and counting
|
|
575
|
+
* skills alone would understate it by the command set.
|
|
576
|
+
*/
|
|
577
|
+
export function measureCatalogueVolume(host: string, root: string): CatalogueVolume {
|
|
578
|
+
const skillsRoot = path.join(root, 'skills');
|
|
579
|
+
const skills = fs.existsSync(skillsRoot) ? readProjectedCatalogue(skillsRoot) : [];
|
|
580
|
+
const commandEntries = markdownFilesUnder(path.join(root, 'commands')).length;
|
|
581
|
+
return {
|
|
582
|
+
host,
|
|
583
|
+
root,
|
|
584
|
+
skillEntries: skills.length,
|
|
585
|
+
commandEntries,
|
|
586
|
+
artefacts: skills.length + commandEntries,
|
|
587
|
+
descriptionBytes: skills.reduce((sum, e) => sum + Buffer.byteLength(e.description), 0),
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/* ------------------------------------------------------------------ *
|
|
592
|
+
* Known limits — read off recorded observations, never invented.
|
|
593
|
+
* ------------------------------------------------------------------ */
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* A truncation this estate has actually OBSERVED for one host.
|
|
597
|
+
*
|
|
598
|
+
* Deliberately not "the host's limit". Two of its three numbers would have to
|
|
599
|
+
* come from the host to call it that, and only one does. What is recorded is
|
|
600
|
+
* the honest pair: at a projection of `projectedVolume`, the host said it
|
|
601
|
+
* dropped `droppedEntries`. No delivered count is derived — see
|
|
602
|
+
* `buildHostEventRecord` for why the subtraction is invalid.
|
|
603
|
+
*
|
|
604
|
+
* Built only from a `budget-strip-and-drop` observation, because only that
|
|
605
|
+
* mode publishes a count. A `per-entry` host reports which entries arrived
|
|
606
|
+
* bare, which is a selector fact and not a truncation quantity, so such a host
|
|
607
|
+
* yields nothing here and produces no warning. That asymmetry is the point:
|
|
608
|
+
* an unmeasured host gets no invented number.
|
|
609
|
+
*/
|
|
610
|
+
export interface KnownHostLimit {
|
|
611
|
+
host: string;
|
|
612
|
+
/** Entries the host itself said it dropped. */
|
|
613
|
+
droppedEntries: number;
|
|
614
|
+
/** What THIS tool projected for that host at that observation. */
|
|
615
|
+
projectedVolume: number;
|
|
616
|
+
/**
|
|
617
|
+
* SKILLS projected at that observation — the comparable quantity.
|
|
618
|
+
* Absent on a record written before the skill-only count existed, and
|
|
619
|
+
* absence is load-bearing: without it the observation is not comparable
|
|
620
|
+
* and yields no eligibility (see `migrationEligibility`).
|
|
621
|
+
*/
|
|
622
|
+
projectedSkills: number | null;
|
|
623
|
+
observedAt: string;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** The most recent observed truncation per host. */
|
|
627
|
+
export function knownHostLimits(records: readonly ObservationRecord[]): Map<string, KnownHostLimit> {
|
|
628
|
+
const out = new Map<string, KnownHostLimit>();
|
|
629
|
+
for (const record of records) {
|
|
630
|
+
if (truncationModeOf(record) !== 'budget-strip-and-drop') continue;
|
|
631
|
+
if (typeof record.dropped_count !== 'number') continue;
|
|
632
|
+
if (record.dropped_count <= 0) continue;
|
|
633
|
+
const previous = out.get(record.host);
|
|
634
|
+
if (previous !== undefined && previous.observedAt > record.observed_at) continue;
|
|
635
|
+
out.set(record.host, {
|
|
636
|
+
host: record.host,
|
|
637
|
+
droppedEntries: record.dropped_count,
|
|
638
|
+
projectedVolume: record.entries_total,
|
|
639
|
+
projectedSkills: record.projected_skill_count ?? null,
|
|
640
|
+
observedAt: record.observed_at,
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
return out;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* The deploy-time warning, or `null` when this deploy is smaller than the one
|
|
648
|
+
* that was measured truncating — or when nothing has been measured at all.
|
|
649
|
+
*
|
|
650
|
+
* Warns, never blocks: over-shipping is the safe direction and a consumer who
|
|
651
|
+
* accepts truncation is making a legitimate choice. Naming the command is the
|
|
652
|
+
* point — a number with no way to reproduce it is noise.
|
|
653
|
+
*/
|
|
654
|
+
export function catalogueLimitWarning(
|
|
655
|
+
volume: CatalogueVolume,
|
|
656
|
+
limit: KnownHostLimit | undefined,
|
|
657
|
+
): string | null {
|
|
658
|
+
if (limit === undefined) return null;
|
|
659
|
+
// SKILLS, not artefacts. The council's correction, and it rests on this
|
|
660
|
+
// estate's own probe: +60 commands moved the host's dropped count by 0.
|
|
661
|
+
// An artefact-total comparison would warn on command growth that truncates
|
|
662
|
+
// nothing. An observation with no skill count is not comparable, so it
|
|
663
|
+
// yields no warning rather than a guess.
|
|
664
|
+
if (limit.projectedSkills === null) return null;
|
|
665
|
+
if (volume.skillEntries < limit.projectedSkills) return null;
|
|
666
|
+
return (
|
|
667
|
+
`${volume.host}: deploying ${volume.skillEntries} skills. ` +
|
|
668
|
+
`This host reported dropping ${limit.droppedEntries} entries from the ` +
|
|
669
|
+
`model-visible list when last measured (${limit.observedAt}, at ` +
|
|
670
|
+
`${limit.projectedSkills} skills) — that much never reaches the model. ` +
|
|
671
|
+
'Explain: `agent-config exec capture_skill_catalogue --limits`'
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/* ------------------------------------------------------------------ *
|
|
676
|
+
* Migration eligibility — AI council, 2026-08-15, 2/2 converged.
|
|
677
|
+
* ------------------------------------------------------------------ */
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Why an install is (not) eligible to be ASKED about scoped projection.
|
|
681
|
+
*
|
|
682
|
+
* Eligibility is deliberately separate from whether a prompt can be shown.
|
|
683
|
+
* The council's refinement: an install can qualify while the session is
|
|
684
|
+
* non-interactive, and collapsing the two would make "not asked because CI"
|
|
685
|
+
* indistinguishable from "not asked because it does not qualify".
|
|
686
|
+
*/
|
|
687
|
+
export interface MigrationEligibility {
|
|
688
|
+
eligible: boolean;
|
|
689
|
+
/** Machine-readable reason, always set — including on the eligible path. */
|
|
690
|
+
reason:
|
|
691
|
+
| 'eligible'
|
|
692
|
+
| 'no-observation-for-host'
|
|
693
|
+
| 'observation-not-comparable'
|
|
694
|
+
| 'no-truncation-observed'
|
|
695
|
+
| 'already-scoped'
|
|
696
|
+
| 'below-observed-skill-volume';
|
|
697
|
+
/** Populated only when eligible, for the message. */
|
|
698
|
+
droppedEntries?: number;
|
|
699
|
+
observedAt?: string;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Is this install worth asking about?
|
|
704
|
+
*
|
|
705
|
+
* `currentSkillCount` is the SKILL population only, and that is the whole
|
|
706
|
+
* correction the council made to the original predicate. A controlled probe
|
|
707
|
+
* (2026-08-15) moved the host's dropped count by 0 when 60 command files were
|
|
708
|
+
* added and by +53 when 60 skills were, so a comparison over total artefacts
|
|
709
|
+
* would fire on command growth that truncates nothing.
|
|
710
|
+
*
|
|
711
|
+
* An observation with no recorded skill count is NOT comparable — the two
|
|
712
|
+
* numbers would be counted by different rules — and returns
|
|
713
|
+
* `observation-not-comparable` rather than being coerced into a verdict.
|
|
714
|
+
* Never extrapolate from another host.
|
|
715
|
+
*/
|
|
716
|
+
export function migrationEligibility(
|
|
717
|
+
host: string,
|
|
718
|
+
resolvedMode: 'scoped' | 'legacy-all',
|
|
719
|
+
currentSkillCount: number,
|
|
720
|
+
limits: ReadonlyMap<string, KnownHostLimit>,
|
|
721
|
+
): MigrationEligibility {
|
|
722
|
+
if (resolvedMode === 'scoped') return { eligible: false, reason: 'already-scoped' };
|
|
723
|
+
const limit = limits.get(host);
|
|
724
|
+
if (limit === undefined) return { eligible: false, reason: 'no-observation-for-host' };
|
|
725
|
+
if (limit.projectedSkills === null) return { eligible: false, reason: 'observation-not-comparable' };
|
|
726
|
+
if (limit.droppedEntries <= 0) return { eligible: false, reason: 'no-truncation-observed' };
|
|
727
|
+
if (currentSkillCount < limit.projectedSkills) {
|
|
728
|
+
return { eligible: false, reason: 'below-observed-skill-volume' };
|
|
729
|
+
}
|
|
730
|
+
return {
|
|
731
|
+
eligible: true,
|
|
732
|
+
reason: 'eligible',
|
|
733
|
+
droppedEntries: limit.droppedEntries,
|
|
734
|
+
observedAt: limit.observedAt,
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* The prompt body. Returns lines; WRITES NOTHING, by contract.
|
|
740
|
+
*
|
|
741
|
+
* `projection.mode` is settings class C, so `settings:set` refuses it by
|
|
742
|
+
* construction and only a human edit or the GUI write route may set it. The
|
|
743
|
+
* message therefore never offers a CLI write — suggesting one would send the
|
|
744
|
+
* reader to a command guaranteed to reject them, which the council caught in
|
|
745
|
+
* the first draft of this text.
|
|
746
|
+
*/
|
|
747
|
+
export function migrationPromptLines(
|
|
748
|
+
host: string,
|
|
749
|
+
eligibility: MigrationEligibility,
|
|
750
|
+
settingsPath: string,
|
|
751
|
+
): string[] {
|
|
752
|
+
return [
|
|
753
|
+
`${host}: ${eligibility.droppedEntries} catalogue entries never reach the model.`,
|
|
754
|
+
`Measured on this machine (${eligibility.observedAt}). This install is on \`legacy-all\`,`,
|
|
755
|
+
'which ships everything and lets the host drop what does not fit — silently.',
|
|
756
|
+
'',
|
|
757
|
+
'Scoping the projection to your active packs is the alternative. Nothing here',
|
|
758
|
+
'changes it for you: `projection.mode` is yours to set, by hand or in the GUI.',
|
|
759
|
+
'',
|
|
760
|
+
` edit ${settingsPath}`,
|
|
761
|
+
' projection:',
|
|
762
|
+
' mode: scoped',
|
|
763
|
+
'',
|
|
764
|
+
' or agent-config config (Settings → projection)',
|
|
765
|
+
'',
|
|
766
|
+
'Keeping `legacy-all` is a legitimate choice — this is a notification, not a gate.',
|
|
767
|
+
];
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/** Read the append-only observation log; a missing or broken line is skipped. */
|
|
771
|
+
export function readObservationLog(logPath: string): ObservationRecord[] {
|
|
772
|
+
if (!fs.existsSync(logPath)) return [];
|
|
773
|
+
const out: ObservationRecord[] = [];
|
|
774
|
+
for (const line of fs.readFileSync(logPath, 'utf-8').split('\n')) {
|
|
775
|
+
const trimmed = line.trim();
|
|
776
|
+
if (trimmed.length === 0) continue;
|
|
777
|
+
try {
|
|
778
|
+
out.push(JSON.parse(trimmed) as ObservationRecord);
|
|
779
|
+
} catch {
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
return out;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Per-host verdicts — never one pooled verdict.
|
|
788
|
+
*
|
|
789
|
+
* Pooling a `per-entry` observation with a `budget-strip-and-drop` one would
|
|
790
|
+
* report a selector verdict over a host that has no per-entry selector, which
|
|
791
|
+
* is Risk 1 of the roadmap that asked for this field.
|
|
792
|
+
*/
|
|
793
|
+
export function formatPerHostVerdicts(records: readonly ObservationRecord[]): string {
|
|
794
|
+
if (records.length === 0) return 'no observations recorded yet.';
|
|
795
|
+
const byHost = new Map<string, ObservationRecord[]>();
|
|
796
|
+
for (const record of records) {
|
|
797
|
+
const bucket = byHost.get(record.host) ?? [];
|
|
798
|
+
bucket.push(record);
|
|
799
|
+
byHost.set(record.host, bucket);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const lines: string[] = [];
|
|
803
|
+
for (const host of [...byHost.keys()].sort()) {
|
|
804
|
+
const bucket = byHost.get(host)!;
|
|
805
|
+
const latest = bucket.reduce((a, b) => (a.observed_at >= b.observed_at ? a : b));
|
|
806
|
+
const mode = truncationModeOf(latest);
|
|
807
|
+
lines.push(
|
|
808
|
+
`${host}: ${bucket.length} observation(s) · mode ${mode} · ` +
|
|
809
|
+
`source ${observationSourceOf(latest)} · latest ${latest.observed_at}`,
|
|
810
|
+
);
|
|
811
|
+
lines.push(
|
|
812
|
+
mode === 'budget-strip-and-drop'
|
|
813
|
+
? ` offered ${latest.entries_total}, dropped ${latest.dropped_count ?? 'unknown'} — ` +
|
|
814
|
+
'no per-entry selector exists on this host; the quantity is the budget.'
|
|
815
|
+
: ` verdict ${latest.verdict} · ${latest.bare_count} bare of ${latest.entries_total}`,
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
const modes = new Set([...byHost.values()].map((b) => truncationModeOf(b[b.length - 1]!)));
|
|
820
|
+
lines.push('');
|
|
821
|
+
lines.push(
|
|
822
|
+
modes.size > 1
|
|
823
|
+
? `hosts: ${byHost.size} · truncation modes DIFFER (${[...modes].sort().join(', ')}) — ` +
|
|
824
|
+
'a pooled verdict across them would describe neither host.'
|
|
825
|
+
: `hosts: ${byHost.size} · all observed hosts truncate the same way (${[...modes][0] ?? 'none'}).`,
|
|
826
|
+
);
|
|
827
|
+
return lines.join('\n');
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
export function formatReport(report: SelectorReport): string {
|
|
831
|
+
const lines: string[] = [];
|
|
832
|
+
lines.push(`catalogue root: ${report.catalogueRoot}`);
|
|
833
|
+
lines.push(`entries: ${report.entriesTotal}`);
|
|
834
|
+
lines.push(
|
|
835
|
+
`observed: ${report.observedBare.length} bare · ${report.observedDescribed.length} described (self-reported)`,
|
|
836
|
+
);
|
|
837
|
+
if (report.unknownObserved.length > 0) {
|
|
838
|
+
lines.push(
|
|
839
|
+
`⚠️ ${report.unknownObserved.length} observed name(s) absent from the projection: ${report.unknownObserved.join(', ')}`,
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
if (report.verdict === 'insufficient-observation') {
|
|
843
|
+
lines.push('');
|
|
844
|
+
lines.push(
|
|
845
|
+
'verdict: insufficient-observation — an observation needs at least one bare AND one described entry to separate anything.',
|
|
846
|
+
);
|
|
847
|
+
return lines.join('\n');
|
|
848
|
+
}
|
|
849
|
+
lines.push('');
|
|
850
|
+
lines.push('selector candidates:');
|
|
851
|
+
for (const candidate of report.candidates) {
|
|
852
|
+
lines.push(` ${candidate.separates ? '✅' : '❌'} ${candidate.id} — ${candidate.describes}`);
|
|
853
|
+
lines.push(` ${candidate.detail}`);
|
|
854
|
+
}
|
|
855
|
+
lines.push('');
|
|
856
|
+
lines.push(
|
|
857
|
+
report.verdict === 'selector-found'
|
|
858
|
+
? 'verdict: selector-found — a delivery fix can act on the separating property above.'
|
|
859
|
+
: 'verdict: no-selector — no measured property separates the groups; the selector is host-internal on this evidence. Publish the null rather than guessing a fix.',
|
|
860
|
+
);
|
|
861
|
+
return lines.join('\n');
|
|
862
|
+
}
|