@event4u/agent-config 9.13.0 → 9.14.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 (33) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +48 -0
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/optimize/deep.md +147 -0
  5. package/dist/agent-src/commands/optimize.md +12 -6
  6. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  7. package/dist/cli-delegate/{chunk-QFKQLQ7X.js → chunk-GCVFLBP2.js} +399 -365
  8. package/dist/cli-delegate/cmd_export.js +1 -1
  9. package/dist/cli-delegate/cmd_prune.js +1 -1
  10. package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
  11. package/dist/cli-delegate/cmd_sync.js +1 -1
  12. package/dist/cli-delegate/cmd_uninstall.js +1 -1
  13. package/dist/cli-delegate/cmd_validate.js +1 -1
  14. package/dist/discovery/deprecation-report.md +1 -1
  15. package/dist/discovery/discovery-manifest.json +39 -11
  16. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  17. package/dist/discovery/discovery-manifest.summary.md +3 -3
  18. package/dist/discovery/orphan-report.md +1 -1
  19. package/dist/discovery/packs.json +6 -5
  20. package/dist/discovery/trust-report.md +3 -3
  21. package/dist/discovery/workspaces.json +5 -4
  22. package/dist/install/install.mjs +648 -614
  23. package/dist/mcp/registry-manifest.json +2 -2
  24. package/dist/mcp/server.json +2 -2
  25. package/dist/mcp/server.mjs +46 -13
  26. package/package.json +1 -1
  27. package/src/scripts/_lib/exec_evidence.ts +5 -0
  28. package/src/scripts/_lib/scoped_projection.ts +212 -0
  29. package/src/scripts/check_artefact_count_messaging.ts +128 -15
  30. package/src/scripts/count_scoped_projection.ts +104 -0
  31. package/src/scripts/install.ts +13 -90
  32. package/src/scripts/skill_linter.ts +81 -14
  33. package/src/scripts/update_counts.ts +90 -11
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "discovery": {
3
- "artefact_count": 593,
3
+ "artefact_count": 594,
4
4
  "scanner_version": "c24312e116f8"
5
5
  },
6
6
  "generated_at": "2026-08-02",
@@ -9,7 +9,7 @@
9
9
  "homepage": "https://event4u-app.github.io/agent-config",
10
10
  "name": "@event4u/agent-config",
11
11
  "repository": "https://github.com/event4u-app/agent-config",
12
- "version": "9.13.0"
12
+ "version": "9.14.0"
13
13
  },
14
14
  "registries": [
15
15
  {
@@ -9,12 +9,12 @@
9
9
  "transport": {
10
10
  "type": "stdio"
11
11
  },
12
- "version": "9.13.0"
12
+ "version": "9.14.0"
13
13
  }
14
14
  ],
15
15
  "repository": {
16
16
  "source": "github",
17
17
  "url": "https://github.com/event4u-app/agent-config"
18
18
  },
19
- "version": "9.13.0"
19
+ "version": "9.14.0"
20
20
  }
@@ -14036,6 +14036,7 @@ __export(skill_linter_exports, {
14036
14036
  gather_all_candidate_files: () => gather_all_candidate_files,
14037
14037
  gather_candidate_files_under: () => gather_candidate_files_under,
14038
14038
  gather_changed_candidate_files: () => gather_changed_candidate_files,
14039
+ last_changed_base_diff_ok: () => last_changed_base_diff_ok,
14039
14040
  last_changed_scope_reason: () => last_changed_scope_reason,
14040
14041
  lint_command: () => lint_command,
14041
14042
  lint_file: () => lint_file,
@@ -15984,6 +15985,7 @@ function gather_candidate_files_under(srcRoot) {
15984
15985
  function gather_changed_candidate_files(root, since) {
15985
15986
  const scope = resolveContentLintScope({ cwd: root, since: since ?? null });
15986
15987
  last_changed_scope_reason = scope.reason;
15988
+ last_changed_base_diff_ok = false;
15987
15989
  const diffCommands = [
15988
15990
  ["git", "diff", "--name-only", `${scope.base}...HEAD`],
15989
15991
  ["git", "diff", "--name-only", "--cached", "HEAD"],
@@ -15991,8 +15993,13 @@ function gather_changed_candidate_files(root, since) {
15991
15993
  ];
15992
15994
  try {
15993
15995
  let rawLines = [];
15996
+ let first = true;
15994
15997
  for (const cmd of diffCommands) {
15995
15998
  const result = runCountedProbe(cmd[0], cmd.slice(1), { cwd: root });
15999
+ if (first) {
16000
+ last_changed_base_diff_ok = result.ok;
16001
+ first = false;
16002
+ }
15996
16003
  if (result.ok && result.stdout.trim()) {
15997
16004
  rawLines = splitlines(result.stdout);
15998
16005
  break;
@@ -17398,6 +17405,25 @@ function _release_scope_requires_findings(root, since) {
17398
17405
  const scope = resolveContentLintScope({ cwd: root, since: since ?? null });
17399
17406
  return scope.isRelease && scope.previousTag !== null;
17400
17407
  }
17408
+ function _changed_filter_matches_head(root) {
17409
+ const result = runCountedProbe("git", ["ls-files", "--", "dist/agent-src"], { cwd: root });
17410
+ if (!result.ok) {
17411
+ return false;
17412
+ }
17413
+ for (const raw of splitlines(result.stdout)) {
17414
+ const norm = raw.trim().replace(/\\/g, "/");
17415
+ if (!norm || norm.includes("/evals/")) {
17416
+ continue;
17417
+ }
17418
+ if (!norm.startsWith("dist/agent-src/") && !norm.includes("/dist/agent-src/")) {
17419
+ continue;
17420
+ }
17421
+ if (norm.endsWith("/SKILL.md") || norm.endsWith(".md") && (norm.includes("/rules/") || norm.includes("/commands/"))) {
17422
+ return true;
17423
+ }
17424
+ }
17425
+ return false;
17426
+ }
17401
17427
  function main7(argv) {
17402
17428
  let args;
17403
17429
  try {
@@ -17442,20 +17468,26 @@ function main7(argv) {
17442
17468
  `);
17443
17469
  }
17444
17470
  if (args.changed && _release_scope_requires_findings(root, args.since)) {
17445
- try {
17446
- assertScanned({
17447
- gate: "skill_linter --changed (release PR)",
17448
- scanned: 0,
17449
- units: "skill/rule files",
17450
- roots: [last_changed_scope_reason]
17451
- });
17452
- } catch (exc) {
17453
- if (exc instanceof DeadScopeError) {
17454
- process7.stderr.write(`\u274C ${exc.message}
17471
+ if (last_changed_base_diff_ok && _changed_filter_matches_head(root)) {
17472
+ process7.stderr.write(
17473
+ "\u2705 verified-empty release scope: the base-span diff ran cleanly and the corpus filter still matches tracked files at HEAD \u2014 this release genuinely changes no skill/rule file. The gate is alive, not blind.\n"
17474
+ );
17475
+ } else {
17476
+ try {
17477
+ assertScanned({
17478
+ gate: "skill_linter --changed (release PR)",
17479
+ scanned: 0,
17480
+ units: "skill/rule files",
17481
+ roots: [last_changed_scope_reason]
17482
+ });
17483
+ } catch (exc) {
17484
+ if (exc instanceof DeadScopeError) {
17485
+ process7.stderr.write(`\u274C ${exc.message}
17455
17486
  `);
17456
- return 2;
17487
+ return 2;
17488
+ }
17489
+ throw exc;
17457
17490
  }
17458
- throw exc;
17459
17491
  }
17460
17492
  }
17461
17493
  process7.stderr.write("No matching skill/rule files found.\n");
@@ -17635,7 +17667,7 @@ function _isCliEntry7() {
17635
17667
  return false;
17636
17668
  }
17637
17669
  }
17638
- var _HERE4, REQUIRED_PERSONA_SECTIONS_CORE, REQUIRED_PERSONA_SECTIONS_SPECIALIST, VALID_PERSONA_TIERS, PERSONA_LINE_BUDGETS, REQUIRED_USERTYPE_SECTIONS, USERTYPE_LINE_BUDGET, VALID_PERSONA_WINGS, PERSONA_LINE_BUDGETS_BY_WING, REQUIRED_SKILL_SECTIONS, SECTION_ALIASES, RECOMMENDED_SKILL_SECTIONS, RULE_BAD_SIGNS, FRUGALITY_WRITER_SKILLS, FRUGALITY_CHARTER_RELPATH, FRUGALITY_CHARTER_INDEX_RULES, VAGUE_VALIDATION_PATTERNS, TRIGGER_WARNING_PATTERNS, ORDERED_STEP_PATTERN, SECTION_PATTERN, FRONTMATTER_PATTERN, DESCRIPTION_PATTERN, STATUS_PATTERN, REPLACED_BY_PATTERN, TIER_PATTERN, TYPE_PATTERN, SOURCE_PATTERN, SENIOR_RELATED_SKILLS_PATTERN, SENIOR_RELATED_WHEN_PATTERN, SENIOR_RELATED_WHEN_NOT_PATTERN, SENIOR_PROACTIVE_PATTERN, SENIOR_OUTPUT_PATTERN, H1_PATTERN, DOUBLE_BLANK_PATTERN, VALID_RULE_TYPES, VALID_RULE_SOURCES, ROUTER_ALLOWED_TRIGGER_KEYS, KERNEL_RULE_IDS, VALID_EXECUTION_TYPES, VALID_EXECUTION_HANDLERS, VALID_EXECUTION_SAFETY_MODES, VALID_EXECUTION_FIELDS, WING3_SPINE_SLOTS, CONTEXT_SPINE_INLINE_PATTERN, WING3_SAAS_URL_PATTERN, WING3_VENDOR_BLACKLIST, WING3_STACK_LOCKED_PATTERN, WING3_CHANNEL_TACTIC_PATTERN, WING4_SPINE_SLOTS, WING4_SAAS_URL_PATTERN, WING4_VENDOR_BLACKLIST, WING4_STAGE_AGNOSTIC_PATTERN, Issue, LintResult, _ROLE_CONTRACT_CANDIDATES, _ROLE_CONTRACT_SLUGS_CACHE, _ROLE_CONTRACT_REF_PATTERN, PROCEDURE_HEADING_PATTERN, COMMAND_FRONTMATTER_DELEGATION_KEYS, MD_LINK_PATTERN_G, NAME_PATTERN, DISABLE_MODEL_PATTERN, _INSPECT_VERB_PATTERN, last_changed_scope_reason, _INTERACTION_NAME_PATTERNS, _INTERACTION_CONTENT_KEYWORDS, _EXEC_FILE_SIGNALS, _EXEC_CONTENT_SIGNALS, _TASK_TYPE_SIGNALS, _VERIFICATION_TOOLS, _FRUGALITY_STANDARDS_PATTERN, _FRUGALITY_CHARTER_LINK_PATTERN, _MD_LINK_PATTERN, _MALICE_CRED_EXFIL, _MALICE_REMOTE_EXEC, _MALICE_FORCE_PUSH, _MALICE_CHMOD_SECRETS, _MALICE_SHELL_INJECT, _MALICE_PATTERNS, _NAG_UPDATE_CHECK, _NAG_STAY_CURRENT, _NAG_SELF_PROMO, _NAG_BEST_SKILL, _NAG_MUST_USE, _NAG_ALWAYS_USE, _NAG_PATTERNS, _OUTPUT_SCHEMA_KEY_PATTERN, _SCHEMA_ARTEFACT_TYPES, ArgError, isCliEntry2;
17670
+ var _HERE4, REQUIRED_PERSONA_SECTIONS_CORE, REQUIRED_PERSONA_SECTIONS_SPECIALIST, VALID_PERSONA_TIERS, PERSONA_LINE_BUDGETS, REQUIRED_USERTYPE_SECTIONS, USERTYPE_LINE_BUDGET, VALID_PERSONA_WINGS, PERSONA_LINE_BUDGETS_BY_WING, REQUIRED_SKILL_SECTIONS, SECTION_ALIASES, RECOMMENDED_SKILL_SECTIONS, RULE_BAD_SIGNS, FRUGALITY_WRITER_SKILLS, FRUGALITY_CHARTER_RELPATH, FRUGALITY_CHARTER_INDEX_RULES, VAGUE_VALIDATION_PATTERNS, TRIGGER_WARNING_PATTERNS, ORDERED_STEP_PATTERN, SECTION_PATTERN, FRONTMATTER_PATTERN, DESCRIPTION_PATTERN, STATUS_PATTERN, REPLACED_BY_PATTERN, TIER_PATTERN, TYPE_PATTERN, SOURCE_PATTERN, SENIOR_RELATED_SKILLS_PATTERN, SENIOR_RELATED_WHEN_PATTERN, SENIOR_RELATED_WHEN_NOT_PATTERN, SENIOR_PROACTIVE_PATTERN, SENIOR_OUTPUT_PATTERN, H1_PATTERN, DOUBLE_BLANK_PATTERN, VALID_RULE_TYPES, VALID_RULE_SOURCES, ROUTER_ALLOWED_TRIGGER_KEYS, KERNEL_RULE_IDS, VALID_EXECUTION_TYPES, VALID_EXECUTION_HANDLERS, VALID_EXECUTION_SAFETY_MODES, VALID_EXECUTION_FIELDS, WING3_SPINE_SLOTS, CONTEXT_SPINE_INLINE_PATTERN, WING3_SAAS_URL_PATTERN, WING3_VENDOR_BLACKLIST, WING3_STACK_LOCKED_PATTERN, WING3_CHANNEL_TACTIC_PATTERN, WING4_SPINE_SLOTS, WING4_SAAS_URL_PATTERN, WING4_VENDOR_BLACKLIST, WING4_STAGE_AGNOSTIC_PATTERN, Issue, LintResult, _ROLE_CONTRACT_CANDIDATES, _ROLE_CONTRACT_SLUGS_CACHE, _ROLE_CONTRACT_REF_PATTERN, PROCEDURE_HEADING_PATTERN, COMMAND_FRONTMATTER_DELEGATION_KEYS, MD_LINK_PATTERN_G, NAME_PATTERN, DISABLE_MODEL_PATTERN, _INSPECT_VERB_PATTERN, last_changed_scope_reason, last_changed_base_diff_ok, _INTERACTION_NAME_PATTERNS, _INTERACTION_CONTENT_KEYWORDS, _EXEC_FILE_SIGNALS, _EXEC_CONTENT_SIGNALS, _TASK_TYPE_SIGNALS, _VERIFICATION_TOOLS, _FRUGALITY_STANDARDS_PATTERN, _FRUGALITY_CHARTER_LINK_PATTERN, _MD_LINK_PATTERN, _MALICE_CRED_EXFIL, _MALICE_REMOTE_EXEC, _MALICE_FORCE_PUSH, _MALICE_CHMOD_SECRETS, _MALICE_SHELL_INJECT, _MALICE_PATTERNS, _NAG_UPDATE_CHECK, _NAG_STAY_CURRENT, _NAG_SELF_PROMO, _NAG_BEST_SKILL, _NAG_MUST_USE, _NAG_ALWAYS_USE, _NAG_PATTERNS, _OUTPUT_SCHEMA_KEY_PATTERN, _SCHEMA_ARTEFACT_TYPES, ArgError, isCliEntry2;
17639
17671
  var init_skill_linter = __esm({
17640
17672
  "src/scripts/skill_linter.ts"() {
17641
17673
  "use strict";
@@ -17811,6 +17843,7 @@ var init_skill_linter = __esm({
17811
17843
  DISABLE_MODEL_PATTERN = /^disable-model-invocation:\s*"?(true|false)"?\s*$/m;
17812
17844
  _INSPECT_VERB_PATTERN = /\b(?:inspect|examine|audit|survey|read|look\s+at|check|review|understand|identify|analyze|analyse|detect|gather|discover)\b/i;
17813
17845
  last_changed_scope_reason = "";
17846
+ last_changed_base_diff_ok = false;
17814
17847
  _INTERACTION_NAME_PATTERNS = /skill-router|handoff|analysis-skill|skill-writing|skill-reviewer|model-recommendation|developer-like-execution|universal-project-analysis|interaction|autonomous-mode|feature-planning/i;
17815
17848
  _INTERACTION_CONTENT_KEYWORDS = [
17816
17849
  "handoff",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@event4u/agent-config",
3
3
  "mcpName": "io.github.event4u-app/agent-config",
4
- "version": "9.13.0",
4
+ "version": "9.14.0",
5
5
  "description": "Governed skills, rules and commands for AI coding tools (Claude Code, Cursor, Windsurf, Copilot) — every claim machine-checked, including \"zero runtime daemon\".",
6
6
  "license": "MIT",
7
7
  "private": false,
@@ -47,6 +47,11 @@ export const ALLOWLIST_PREFIXES: readonly (readonly string[])[] = [
47
47
  ['skill_eval_coverage'],
48
48
  ['domain_soundness_status'],
49
49
  ['check_artefact_count_messaging'],
50
+ // `update_counts --check` re-derives every anchored number in the docs and
51
+ // exits non-zero on any disagreement — so its exit code IS the verdict for
52
+ // a published count, not merely evidence that some report exists. Read-only
53
+ // under `--check`; same deterministic, model-free class as its neighbours.
54
+ ['update_counts'],
50
55
  ['check_enforcement_coverage'],
51
56
  ['check_token_regression'],
52
57
  ['lint_agent_security'],
@@ -0,0 +1,212 @@
1
+ /**
2
+ * The scoped-projection predicate — ONE implementation, two callers.
3
+ *
4
+ * `projection.mode: scoped` narrows a GLOBAL deploy to the active profile's
5
+ * packs. Two places need to know exactly which skills survive that prune:
6
+ *
7
+ * 1. `install.ts` — which artefacts it actually writes to the user's tree.
8
+ * 2. `count_scoped_projection.ts` — the number the claims ledger publishes.
9
+ *
10
+ * Before this module those were the same rule written once and *typed* a
11
+ * second time into `docs/CLAIMS.md`. The published pair drifted from the
12
+ * benchmark doc it cited as its own method (217/288 vs 215/286) and grew by
13
+ * one on every skill added, because nothing could compare them. The fix is
14
+ * not a second counter — a second counting path that disagrees with the
15
+ * first is the failure this replaces. It is this module: the installer and
16
+ * the counter call the same functions, so a divergence is impossible rather
17
+ * than merely unlikely.
18
+ *
19
+ * Consumers: `install.ts` (the prune itself) and `count_scoped_projection.ts`
20
+ * (the published number).
21
+ */
22
+
23
+ import * as fs from 'node:fs';
24
+ import { createRequire } from 'node:module';
25
+ import * as path from 'node:path';
26
+ import type * as YamlModule from 'yaml';
27
+
28
+ import * as surface_tiers from './surface_tiers.js';
29
+
30
+ /**
31
+ * ESM-safe `require`. A bare `require('yaml')` is `undefined` under tsx (the
32
+ * runner every `./scripts-run` gate uses), so the older lazy-require pattern
33
+ * elsewhere in this tree degrades to "no yaml" silently. `createRequire`
34
+ * keeps the load lazy — `yaml` stays out of the installer bundle's static
35
+ * graph — while actually resolving.
36
+ */
37
+ const _require = createRequire(import.meta.url);
38
+
39
+ /** `workspaces` values whose packs are ALWAYS active under `scoped` mode. */
40
+ export const SCOPED_ACTIVE_WORKSPACES: ReadonlySet<string> = new Set([
41
+ 'engineering',
42
+ 'agent-config-maintainer',
43
+ ]);
44
+
45
+ export interface PackRecord {
46
+ id: string;
47
+ workspaces: string[];
48
+ requires: string[];
49
+ }
50
+
51
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
52
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
53
+ }
54
+
55
+ /** Lazy YAML safe_load mirroring PyYAML (version 1.1); `null` when absent. */
56
+ function yamlSafeLoad(text: string): unknown {
57
+ let YAML: typeof YamlModule;
58
+ try {
59
+ YAML = _require('yaml') as typeof YamlModule;
60
+ } catch {
61
+ return null;
62
+ }
63
+ try {
64
+ return YAML.parse(text, { version: '1.1' }) ?? null;
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ /** Repo-relative location of the pack vocabulary. */
71
+ export const PACKS_YML_REL = path.join('src', 'config', 'discovery', 'packs.yml');
72
+
73
+ /**
74
+ * Parse `src/config/discovery/packs.yml` into `{id, workspaces, requires}`
75
+ * records. `requires` falls back to the legacy `requires_hint` field name
76
+ * when `requires` is absent.
77
+ *
78
+ * Deliberately THROWS on a missing file or a parse result that is not a
79
+ * list — an empty active-pack set would be the LEAST safe fallback here
80
+ * (it would prune every tagged artefact, the opposite of the fail-safe
81
+ * contract). Callers wrap this in a try/catch that restores the full tree
82
+ * on any error instead.
83
+ */
84
+ export function load_packs_registry(package_root: string): PackRecord[] {
85
+ const vocab_path = path.join(package_root, PACKS_YML_REL);
86
+ const data = yamlSafeLoad(fs.readFileSync(vocab_path, 'utf-8'));
87
+ if (!Array.isArray(data)) {
88
+ throw new Error(`packs.yml did not parse to a list: ${vocab_path}`);
89
+ }
90
+ const out: PackRecord[] = [];
91
+ for (const entry of data) {
92
+ if (!isPlainObject(entry)) continue;
93
+ const id = entry['id'];
94
+ if (typeof id !== 'string' || id === '') continue;
95
+ const workspaces_raw = entry['workspaces'];
96
+ const workspaces = Array.isArray(workspaces_raw)
97
+ ? workspaces_raw.filter((w): w is string => typeof w === 'string')
98
+ : [];
99
+ const requires_raw = entry['requires'] ?? entry['requires_hint'];
100
+ const requires = Array.isArray(requires_raw)
101
+ ? requires_raw.filter((r): r is string => typeof r === 'string')
102
+ : [];
103
+ out.push({ id, workspaces, requires });
104
+ }
105
+ return out;
106
+ }
107
+
108
+ /**
109
+ * Active pack id set for `scoped` mode: every pack whose `workspaces`
110
+ * intersects `SCOPED_ACTIVE_WORKSPACES`, unioned with `runtime_active_packs`,
111
+ * expanded over the `requires` graph (transitive closure).
112
+ */
113
+ export function compute_active_pack_ids(
114
+ packs: readonly PackRecord[],
115
+ runtime_active_packs: readonly string[],
116
+ ): Set<string> {
117
+ const by_id = new Map<string, PackRecord>();
118
+ for (const p of packs) by_id.set(p.id, p);
119
+
120
+ const active = new Set<string>();
121
+ for (const p of packs) {
122
+ if (p.workspaces.some((w) => SCOPED_ACTIVE_WORKSPACES.has(w))) {
123
+ active.add(p.id);
124
+ }
125
+ }
126
+ for (const id of runtime_active_packs) {
127
+ active.add(id);
128
+ }
129
+
130
+ let frontier = [...active];
131
+ while (frontier.length > 0) {
132
+ const next: string[] = [];
133
+ for (const id of frontier) {
134
+ const rec = by_id.get(id);
135
+ if (rec === undefined) continue;
136
+ for (const dep of rec.requires) {
137
+ if (!active.has(dep)) {
138
+ active.add(dep);
139
+ next.push(dep);
140
+ }
141
+ }
142
+ }
143
+ frontier = next;
144
+ }
145
+ return active;
146
+ }
147
+
148
+ /**
149
+ * The prune predicate itself: `true` when this artefact is DROPPED under
150
+ * `scoped`. Untagged artefacts (empty `packs:` frontmatter) are core/shared
151
+ * and always kept; a tagged artefact survives iff its tags intersect the
152
+ * active set.
153
+ */
154
+ export function is_pruned_under_scoped(md_path: string, active_ids: ReadonlySet<string>): boolean {
155
+ const packs = surface_tiers.frontmatter_packs(md_path);
156
+ if (packs.size === 0) return false;
157
+ for (const id of packs) {
158
+ if (active_ids.has(id)) return false;
159
+ }
160
+ return true;
161
+ }
162
+
163
+ export interface ScopedProjectionStats {
164
+ /** Skills a default `projection.mode: scoped` install deploys. */
165
+ projected: number;
166
+ /** Every skill in the catalog — identical to canonical `count('skills')`. */
167
+ total: number;
168
+ /** `total - projected`; the reduction the claim reports. */
169
+ pruned: number;
170
+ /** Pack ids active by default, sorted — the requires-closure result. */
171
+ active_packs: string[];
172
+ /** The workspaces whose packs are always on under `scoped`. */
173
+ workspaces: string[];
174
+ }
175
+
176
+ /**
177
+ * Partition a skill catalog with the installer's own predicate.
178
+ *
179
+ * The caller supplies the file set — in practice always
180
+ * `update_counts.iter_skills()`, the walk behind the canonical
181
+ * `count('skills')`, so `projected + pruned === total` holds by
182
+ * construction. Taking it as a parameter (rather than importing
183
+ * `update_counts` here) keeps this module free of a cycle: `update_counts`
184
+ * imports it, not the other way round.
185
+ *
186
+ * `runtime_active_packs` defaults to empty: the published claim is about the
187
+ * DEFAULT for a new install, and a fresh machine has no overlay.
188
+ */
189
+ export function scoped_projection_stats(
190
+ package_root: string,
191
+ skills: Iterable<string>,
192
+ runtime_active_packs: readonly string[] = [],
193
+ ): ScopedProjectionStats {
194
+ const active = compute_active_pack_ids(load_packs_registry(package_root), runtime_active_packs);
195
+
196
+ let projected = 0;
197
+ let total = 0;
198
+ for (const skill of skills) {
199
+ total += 1;
200
+ if (!is_pruned_under_scoped(skill, active)) {
201
+ projected += 1;
202
+ }
203
+ }
204
+
205
+ return {
206
+ projected,
207
+ total,
208
+ pruned: total - projected,
209
+ active_packs: [...active].sort(),
210
+ workspaces: [...SCOPED_ACTIVE_WORKSPACES].sort(),
211
+ };
212
+ }
@@ -33,7 +33,7 @@ import * as path from 'node:path';
33
33
  import { fileURLToPath, pathToFileURL } from 'node:url';
34
34
 
35
35
  import { canonical_counts } from './check_command_count_messaging.js';
36
- import { count } from './update_counts.js';
36
+ import { count, TARGETS } from './update_counts.js';
37
37
 
38
38
  const _HERE = fileURLToPath(import.meta.url);
39
39
  export const ROOT = path.resolve(path.dirname(_HERE), '..', '..');
@@ -164,6 +164,93 @@ export function scan_structured(
164
164
  return { findings, seen };
165
165
  }
166
166
 
167
+ // --- Anchor-coverage pass (road-to-reproducible-artefact-counts) ----------
168
+ //
169
+ // The two gates used to overlap only by accident. This scanner flags any
170
+ // count-shaped prose; `update_counts` rewrites a hand-written list of
171
+ // anchors. Nothing asserted the second list covered the first, so positions
172
+ // existed that the scanner could turn red and the generator could not fix —
173
+ // the README Commands badge and the getting-started browse line drifted to
174
+ // 191 against a canonical 192 exactly there, each explicitly left unanchored
175
+ // to "avoid double-ownership". The reconciliation was a human's job, and the
176
+ // gate's own advice said so ("run update_counts … OR correct the prose").
177
+ //
178
+ // This pass removes the human: every position this scanner can flag must be
179
+ // rewritable by the generator, or be a generated file whose source is. A new
180
+ // count-shaped sentence with no anchor now fails CI as a coverage gap.
181
+
182
+ /**
183
+ * Scanner kind → the generator kinds that legitimately write it.
184
+ *
185
+ * One prose kind can have several canonical numbers and they are NOT
186
+ * interchangeable: "N commands" in prose means ACTIVE commands (total minus
187
+ * deprecation shims), and a scoped-projection sentence carries the projected
188
+ * skill count beside the catalog total. Anchoring an active-count position
189
+ * to the raw `commands` total would silently write the wrong number the day
190
+ * a command is superseded — which is why this is a mapping and not equality.
191
+ */
192
+ const ANCHOR_KINDS: Readonly<Record<string, readonly string[]>> = {
193
+ skills: ['skills', 'skills_scoped'],
194
+ commands: ['commands', 'commands_active'],
195
+ rules: ['rules'],
196
+ guidelines: ['guidelines'],
197
+ personas: ['personas'],
198
+ };
199
+
200
+ /**
201
+ * Surfaces regenerated from an anchored source — exempt because fixing the
202
+ * source fixes them. Each entry names the generator, so the exemption stays
203
+ * falsifiable rather than becoming a place to hide drift.
204
+ */
205
+ export const GENERATED_DOWNSTREAM: Readonly<Record<string, string>> = {
206
+ 'docs/proof.md': 'generated from docs/CLAIMS.md by src/scripts/build_proof.ts',
207
+ };
208
+
209
+ export interface CoverageGap {
210
+ file: string;
211
+ line: number;
212
+ kind: string;
213
+ text: string;
214
+ }
215
+
216
+ /**
217
+ * Positions this scanner can flag that `update_counts` cannot rewrite.
218
+ *
219
+ * A position counts as anchored when some `TARGETS` pattern for the same
220
+ * file, bound to a compatible kind, matches the same line.
221
+ */
222
+ export function anchor_coverage_gaps(root: string = ROOT): CoverageGap[] {
223
+ const anchorsByFile = new Map<string, ReadonlyArray<[string, string]>>();
224
+ for (const [rel, patterns] of TARGETS) {
225
+ anchorsByFile.set(rel, patterns);
226
+ }
227
+
228
+ const gaps: CoverageGap[] = [];
229
+ for (const rel of SURFACES) {
230
+ if (rel in GENERATED_DOWNSTREAM) continue;
231
+ const p = path.join(root, rel);
232
+ if (!fs.existsSync(p)) continue;
233
+ const lines = fs.readFileSync(p, 'utf-8').split('\n');
234
+ const anchors = anchorsByFile.get(rel) ?? [];
235
+ for (const [kind, pattern] of KIND_PATTERNS) {
236
+ const allowed = ANCHOR_KINDS[kind] ?? [kind];
237
+ for (let i = 0; i < lines.length; i++) {
238
+ const line = lines[i]!;
239
+ const re = new RegExp(pattern.source, pattern.flags);
240
+ if (!re.test(line)) continue;
241
+ const anchored = anchors.some(
242
+ ([raw, anchorKind]) =>
243
+ allowed.includes(anchorKind) && new RegExp(raw).test(line),
244
+ );
245
+ if (!anchored) {
246
+ gaps.push({ file: rel, line: i + 1, kind, text: line.trim().slice(0, 100) });
247
+ }
248
+ }
249
+ }
250
+ }
251
+ return gaps;
252
+ }
253
+
167
254
  export function main(argv: readonly string[] = process.argv.slice(2)): number {
168
255
  const QUIET = argv.includes('--quiet');
169
256
  const expected: Record<string, number> = {};
@@ -213,29 +300,55 @@ export function main(argv: readonly string[] = process.argv.slice(2)): number {
213
300
  }
214
301
  }
215
302
 
216
- if (allFindings.length === 0 && inconsistent.length === 0) {
303
+ // Anchor coverage — every position this gate can flag must be a position
304
+ // `update_counts` can rewrite, so the two never need a human to reconcile.
305
+ const gaps = anchor_coverage_gaps();
306
+
307
+ if (allFindings.length === 0 && inconsistent.length === 0 && gaps.length === 0) {
217
308
  if (!QUIET) {
218
- process.stdout.write('✅ All artefact-count prose in sync with source.\n');
309
+ process.stdout.write(
310
+ '✅ All artefact-count prose in sync with source, every position anchored.\n',
311
+ );
219
312
  }
220
313
  return 0;
221
314
  }
222
315
 
223
- process.stdout.write(
224
- `❌ Artefact-count messaging drift — ${allFindings.length} mismatch(es):\n`,
225
- );
226
- for (const f of allFindings) {
227
- const tag = f.approx ? ' (approximation "~" not allowed on flagship surfaces)' : '';
316
+ if (allFindings.length > 0 || inconsistent.length > 0) {
317
+ process.stdout.write(
318
+ `❌ Artefact-count messaging drift — ${allFindings.length} mismatch(es):\n`,
319
+ );
320
+ for (const f of allFindings) {
321
+ const tag = f.approx ? ' (approximation "~" not allowed on flagship surfaces)' : '';
322
+ process.stdout.write(
323
+ ` ${f.file}:${f.line}: ${f.kind} says ${f.found}, expected ${f.expected}${tag}\n`,
324
+ );
325
+ }
326
+ for (const line of inconsistent) {
327
+ process.stdout.write(` internal inconsistency — ${line}\n`);
328
+ }
228
329
  process.stdout.write(
229
- ` ${f.file}:${f.line}: ${f.kind} says ${f.found}, expected ${f.expected}${tag}\n`,
330
+ '\nFix: run `./scripts-run src/scripts/update_counts` — every checked position\n' +
331
+ 'is anchored, so the generator can write it. Never hand-type a count.\n',
230
332
  );
231
333
  }
232
- for (const line of inconsistent) {
233
- process.stdout.write(` internal inconsistency — ${line}\n`);
334
+
335
+ if (gaps.length > 0) {
336
+ process.stdout.write(
337
+ `❌ Anchor-coverage gap — ${gaps.length} position(s) this gate checks but\n` +
338
+ ' `update_counts` cannot rewrite:\n',
339
+ );
340
+ for (const g of gaps) {
341
+ process.stdout.write(` ${g.file}:${g.line}: ${g.kind} — ${g.text}\n`);
342
+ }
343
+ process.stdout.write(
344
+ '\nFix: add an anchor for each position to `TARGETS` in\n' +
345
+ '`src/scripts/update_counts.ts`, bound to the kind that position\n' +
346
+ 'actually carries (`commands_active` for active-command mentions,\n' +
347
+ '`skills_scoped` for the projected-skill figure). If the file is\n' +
348
+ 'generated from an anchored source, register it in\n' +
349
+ '`GENERATED_DOWNSTREAM` with the generator that produces it.\n',
350
+ );
234
351
  }
235
- process.stdout.write(
236
- '\nFix: run `./scripts-run src/scripts/update_counts` for anchored positions,\n' +
237
- 'or correct the prose to the canonical number (never hand-type a new one).\n',
238
- );
239
352
  return 1;
240
353
  }
241
354
 
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Emit the scoped-projection skill count — the number `docs/CLAIMS.md`
4
+ * publishes for the default install
5
+ * (road-to-reproducible-artefact-counts).
6
+ *
7
+ * The claim used to read "217 of 288 skills … with the counting method
8
+ * pinned in the benchmark doc", while that benchmark doc said 215 of 286.
9
+ * Both numbers were hand-typed, only one of them tracked the catalog, and
10
+ * the gap grew by one on every skill added because no gate could compare
11
+ * them. This script is the single derivation both now come from.
12
+ *
13
+ * It adds no counting mechanism of its own — that would be a second path
14
+ * free to disagree with the first, which is the defect, not the fix:
15
+ *
16
+ * - the file set is `update_counts.iter_skills()`, the exact walk behind
17
+ * the canonical `count('skills')`, so `projected + pruned === total`
18
+ * holds by construction;
19
+ * - the in/out predicate is `_lib/scoped_projection.is_pruned_under_scoped`,
20
+ * the exact function `install.ts` applies when it prunes a real user's
21
+ * tree, so the published number cannot describe a projection the
22
+ * installer does not perform.
23
+ *
24
+ * `runtime.active_packs` is deliberately empty here: the claim is about the
25
+ * DEFAULT for a new install, and a fresh machine has no overlay. An operator
26
+ * who activates extra packs gets more skills than the claim states — the
27
+ * claim is a floor, and `--json` exposes `active_packs` so a reader can see
28
+ * the assumption rather than infer it.
29
+ *
30
+ * Usage:
31
+ * ./scripts-run src/scripts/count_scoped_projection # "217 of 288"
32
+ * ./scripts-run src/scripts/count_scoped_projection --json
33
+ * ./scripts-run src/scripts/count_scoped_projection --projected
34
+ *
35
+ * Exit codes: 0 ok · 1 the pack registry could not be read.
36
+ */
37
+
38
+ import * as fs from 'node:fs';
39
+ import * as path from 'node:path';
40
+ import { fileURLToPath, pathToFileURL } from 'node:url';
41
+
42
+ import {
43
+ scoped_projection_stats,
44
+ type ScopedProjectionStats,
45
+ } from './_lib/scoped_projection.js';
46
+ import { iter_skills, REPO_ROOT } from './update_counts.js';
47
+
48
+ const _HERE = fileURLToPath(import.meta.url);
49
+
50
+ export type { ScopedProjectionStats };
51
+
52
+ /** Partition the canonical skill catalog with the installer's own predicate. */
53
+ export function stats(package_root: string = REPO_ROOT): ScopedProjectionStats {
54
+ return scoped_projection_stats(package_root, iter_skills());
55
+ }
56
+
57
+ export function main(argv: readonly string[] = process.argv.slice(2)): number {
58
+ let s: ScopedProjectionStats;
59
+ try {
60
+ s = stats();
61
+ } catch (err) {
62
+ process.stderr.write(
63
+ `❌ scoped-projection count unavailable: ${(err as Error).message}\n` +
64
+ ' (src/config/discovery/packs.yml must exist and parse to a list)\n',
65
+ );
66
+ return 1;
67
+ }
68
+
69
+ if (argv.includes('--json')) {
70
+ process.stdout.write(`${JSON.stringify(s, null, 2)}\n`);
71
+ return 0;
72
+ }
73
+ if (argv.includes('--projected')) {
74
+ process.stdout.write(`${s.projected}\n`);
75
+ return 0;
76
+ }
77
+ if (argv.includes('--total')) {
78
+ process.stdout.write(`${s.total}\n`);
79
+ return 0;
80
+ }
81
+ process.stdout.write(`${s.projected} of ${s.total}\n`);
82
+ return 0;
83
+ }
84
+
85
+ function _isCliEntry(): boolean {
86
+ if (process.argv[1] === undefined) {
87
+ return false;
88
+ }
89
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
90
+ if (import.meta.url === argvUrl) {
91
+ return true;
92
+ }
93
+ try {
94
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
95
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
96
+ return here === argv;
97
+ } catch {
98
+ return false;
99
+ }
100
+ }
101
+
102
+ if (_isCliEntry() || process.argv[1] === _HERE) {
103
+ process.exit(main(process.argv.slice(2)));
104
+ }