@event4u/agent-config 9.12.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 (146) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +110 -238
  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/rules/active-remediation.md +0 -2
  7. package/dist/agent-src/rules/architecture.md +0 -1
  8. package/dist/agent-src/rules/artifact-drafting-protocol.md +0 -4
  9. package/dist/agent-src/rules/autonomous-execution.md +1 -2
  10. package/dist/agent-src/rules/broken-access-control.md +0 -2
  11. package/dist/agent-src/rules/code-comment-discipline.md +0 -3
  12. package/dist/agent-src/rules/code-provenance.md +0 -3
  13. package/dist/agent-src/rules/communication-through-line.md +2 -3
  14. package/dist/agent-src/rules/content-quoting-floor.md +0 -2
  15. package/dist/agent-src/rules/context-hygiene.md +1 -3
  16. package/dist/agent-src/rules/decision-revisit-gate.md +0 -2
  17. package/dist/agent-src/rules/design-fidelity.md +29 -0
  18. package/dist/agent-src/rules/domain-adoption-policy.md +0 -3
  19. package/dist/agent-src/rules/downstream-changes.md +10 -1
  20. package/dist/agent-src/rules/external-reference-deep-dive.md +0 -7
  21. package/dist/agent-src/rules/fast-path-marker-visibility.md +0 -1
  22. package/dist/agent-src/rules/git-history-discipline.md +0 -7
  23. package/dist/agent-src/rules/guidelines.md +0 -2
  24. package/dist/agent-src/rules/improve-before-implement.md +4 -2
  25. package/dist/agent-src/rules/invite-challenge.md +0 -4
  26. package/dist/agent-src/rules/linked-projects-onboarding-gate.md +0 -2
  27. package/dist/agent-src/rules/markdown-safe-codeblocks.md +0 -1
  28. package/dist/agent-src/rules/minimal-safe-diff.md +2 -2
  29. package/dist/agent-src/rules/missing-tool-handling.md +0 -1
  30. package/dist/agent-src/rules/no-attribution-footers.md +0 -3
  31. package/dist/agent-src/rules/no-decorative-emojis-in-git-surfaces.md +8 -5
  32. package/dist/agent-src/rules/no-pr-progress-comments.md +1 -3
  33. package/dist/agent-src/rules/no-roadmap-references.md +0 -2
  34. package/dist/agent-src/rules/output-discipline.md +0 -1
  35. package/dist/agent-src/rules/prefer-enums-over-literals.md +0 -2
  36. package/dist/agent-src/rules/preservation-guard.md +0 -3
  37. package/dist/agent-src/rules/role-mode-adherence.md +0 -1
  38. package/dist/agent-src/rules/secret-vcs-guard.md +0 -2
  39. package/dist/agent-src/rules/senior-engineering-discipline.md +0 -2
  40. package/dist/agent-src/rules/session-canary.md +0 -2
  41. package/dist/agent-src/rules/size-enforcement.md +0 -5
  42. package/dist/agent-src/rules/source-confidentiality.md +0 -3
  43. package/dist/agent-src/rules/source-discovery-gate.md +0 -2
  44. package/dist/agent-src/rules/think-before-action.md +3 -3
  45. package/dist/agent-src/rules/token-budget-discipline.md +0 -1
  46. package/dist/agent-src/rules/token-efficiency.md +1 -2
  47. package/dist/agent-src/rules/tool-safety.md +0 -1
  48. package/dist/agent-src/rules/user-interaction.md +0 -3
  49. package/dist/agent-src/rules/user-interrupt-priority.md +0 -1
  50. package/dist/agent-src/skills/agent-docs-writing/SKILL.md +8 -0
  51. package/dist/agent-src/skills/analysis-skill-router/SKILL.md +55 -86
  52. package/dist/agent-src/skills/character-consistency/SKILL.md +7 -4
  53. package/dist/agent-src/skills/decision-record/SKILL.md +8 -0
  54. package/dist/agent-src/skills/design-review/SKILL.md +11 -0
  55. package/dist/agent-src/skills/design-system-capture/reference/design-system-json.md +8 -0
  56. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +34 -3
  57. package/dist/agent-src/skills/fe-design/SKILL.md +10 -0
  58. package/dist/agent-src/skills/motion-choreographer/SKILL.md +7 -4
  59. package/dist/agent-src/skills/overbuild-review-lens/SKILL.md +201 -0
  60. package/dist/agent-src/skills/performance/SKILL.md +14 -0
  61. package/dist/agent-src/skills/pixar-storyteller/SKILL.md +8 -5
  62. package/dist/agent-src/skills/scene-expander/SKILL.md +9 -6
  63. package/dist/agent-src/skills/supply-chain-intake/SKILL.md +10 -0
  64. package/dist/agent-src/skills/universal-project-analysis/SKILL.md +14 -37
  65. package/dist/agent-src/skills/video-director/SKILL.md +9 -6
  66. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  67. package/dist/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +154 -13
  68. package/dist/agent-src/templates/scripts/work_engine/directives/ui/design.ts +118 -0
  69. package/dist/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +40 -0
  70. package/dist/agent-src/templates/scripts/work_engine/state.ts +55 -0
  71. package/dist/cli-delegate/{chunk-QFKQLQ7X.js → chunk-GCVFLBP2.js} +399 -365
  72. package/dist/cli-delegate/cmd_export.js +1 -1
  73. package/dist/cli-delegate/cmd_prune.js +1 -1
  74. package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
  75. package/dist/cli-delegate/cmd_sync.js +1 -1
  76. package/dist/cli-delegate/cmd_uninstall.js +1 -1
  77. package/dist/cli-delegate/cmd_validate.js +1 -1
  78. package/dist/discovery/deprecation-report.md +1 -1
  79. package/dist/discovery/discovery-manifest.json +123 -73
  80. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  81. package/dist/discovery/discovery-manifest.summary.md +4 -4
  82. package/dist/discovery/orphan-report.md +1 -1
  83. package/dist/discovery/packs.json +10 -8
  84. package/dist/discovery/trust-report.md +3 -3
  85. package/dist/discovery/workspaces.json +8 -6
  86. package/dist/hooks/dispatch.js +174 -13
  87. package/dist/install/install.mjs +648 -614
  88. package/dist/mcp/registry-manifest.json +3 -3
  89. package/dist/mcp/server.json +2 -2
  90. package/dist/mcp/server.mjs +783 -507
  91. package/dist/router.json +1 -1
  92. package/dist/scripts/condense.js +2 -2
  93. package/docs/MIGRATION.md +1 -1
  94. package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +71 -0
  95. package/docs/guidelines/agent-infra/minimal-safe-diff-mechanics.md +24 -1
  96. package/docs/guidelines/code-clarity.md +9 -2
  97. package/docs/guidelines/design-fidelity-mechanics.md +44 -0
  98. package/llms.txt +63 -27
  99. package/package.json +4 -4
  100. package/src/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +154 -13
  101. package/src/agent-src/templates/scripts/work_engine/directives/ui/design.ts +118 -0
  102. package/src/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +40 -0
  103. package/src/agent-src/templates/scripts/work_engine/state.ts +55 -0
  104. package/src/config/agent-settings.template.yml +8 -0
  105. package/src/scripts/_lib/counted_probe.ts +142 -0
  106. package/src/scripts/_lib/exec_evidence.ts +5 -0
  107. package/src/scripts/_lib/overbuild_lens_contract.ts +196 -0
  108. package/src/scripts/_lib/release_scope.ts +207 -0
  109. package/src/scripts/_lib/scoped_projection.ts +212 -0
  110. package/src/scripts/ai_council/stance_tally.ts +37 -2
  111. package/src/scripts/audit_auto_rules.ts +9 -9
  112. package/src/scripts/audit_likelihood.ts +3 -3
  113. package/src/scripts/audit_overlap.ts +2 -2
  114. package/src/scripts/audit_skill_overlap.ts +233 -95
  115. package/src/scripts/audit_skill_overlap_allowlist.json +4 -0
  116. package/src/scripts/build_proof.ts +15 -1
  117. package/src/scripts/check_artefact_count_messaging.ts +128 -15
  118. package/src/scripts/check_dependency_floors.ts +16 -6
  119. package/src/scripts/check_gate_coverage.ts +6 -6
  120. package/src/scripts/check_kernel_rule_bundle.ts +5 -4
  121. package/src/scripts/check_token_quality_golden.ts +0 -18
  122. package/src/scripts/code_graph/loader.ts +35 -6
  123. package/src/scripts/compile_router.ts +3 -8
  124. package/src/scripts/condense.ts +2 -2
  125. package/src/scripts/count_scoped_projection.ts +104 -0
  126. package/src/scripts/explain_run.ts +0 -1
  127. package/src/scripts/generate_pack_manifests.ts +3 -20
  128. package/src/scripts/hooks/block_kernel_rule_writes.ts +145 -10
  129. package/src/scripts/hooks/block_no_verify.ts +101 -0
  130. package/src/scripts/install.ts +13 -90
  131. package/src/scripts/lint_agent_security.ts +5 -4
  132. package/src/scripts/lint_framework_leakage_allowlist.json +2 -2
  133. package/src/scripts/lint_regression.ts +92 -1
  134. package/src/scripts/lint_skill_descriptions.ts +93 -5
  135. package/src/scripts/project_thin_rules.ts +2 -2
  136. package/src/scripts/release.ts +107 -3
  137. package/src/scripts/resolve_lint_scope.ts +53 -0
  138. package/src/scripts/router_telemetry.ts +0 -5
  139. package/src/scripts/runtime_dispatcher.ts +40 -0
  140. package/src/scripts/schemas/pack.schema.json +1 -5
  141. package/src/scripts/schemas/rule.schema.json +1 -2
  142. package/src/scripts/self_review_gate.ts +15 -73
  143. package/src/scripts/skill_linter.ts +160 -11
  144. package/src/scripts/sweep_dead_scan_roots.ts +550 -0
  145. package/src/scripts/trigger_coverage.ts +11 -37
  146. package/src/scripts/update_counts.ts +90 -11
@@ -3,35 +3,51 @@
3
3
  * Skill-family content-overlap analysis (6.0.0-C Phase 4 Step 8).
4
4
  *
5
5
  * Ported from the retired Python `src/scripts/audit_skill_overlap.py` (ADR-200 —
6
- * Python→TS migration, Phase 8 / Wave 8c). Mirrors the Python CLI
7
- * contract EXACTLY — the `--threshold` / `--quiet` flags, exit codes
8
- * (0 ok / 3 no skills), the stdout/stderr split, byte-identical stdout
9
- * messages, and byte-identical written artefacts (`json.dumps(...,
10
- * indent=2)` for the JSON + the exact Markdown renderer).
6
+ * Python→TS migration, Phase 8 / Wave 8c). The numeric contract — keyword
7
+ * vector, cosine, banker's rounding, pair ordering, `json.dumps(..., indent=2)`
8
+ * byte shape, and the Markdown renderer — is preserved so historical reports
9
+ * stay comparable. The CLI grew a `--root` flag; the exit codes are unchanged
10
+ * (0 ok / 3 dead scan scope).
11
11
  *
12
- * No `_lib` imports — the retired Python implementation has none; it carries its own
13
- * `_skill_roots()` (which references the `.agent-src.uncondensed/skills`
14
- * layout literally — this twin reproduces that literal faithfully, since
15
- * the .py original carries it).
12
+ * SCAN ROOT (repaired 2026-08-02, road-to-overlap-truth-and-skill-cut Phase 1):
13
+ * the ported `_skill_roots()` resolved the pre-ADR-051 source containers, both
14
+ * of which were deleted when `src/skills` became the source of truth. The tool
15
+ * therefore walked a directory that does not exist and could never report an
16
+ * overlap for the 287-skill corpus. That resolution is GONE, not kept as a
17
+ * fallback branch: the default root is the shared `SRC_SKILLS()` resolver, and
18
+ * an empty scan is a hard failure via {@link assertScanned} rather than an
19
+ * empty result.
16
20
  *
17
21
  * Walks every `SKILL.md`, builds a keyword vector from the body
18
22
  * (frontmatter stripped), and flags pairs whose content cosine-similarity
19
23
  * is >= OVERLAP_THRESHOLD. Same-domain (shared `packs:`) >= threshold pairs
20
24
  * are the merge candidates. THIS SCRIPT MERGES NOTHING.
21
25
  *
22
- * Historical quirks are preserved deliberately — tests and downstream consumers pin the exact behaviour.
26
+ * `--strict` makes a same-pack pair at or above threshold a BUILD FAILURE
27
+ * (exit 1) unless the pair carries a reviewed justification in
28
+ * `audit_skill_overlap_allowlist.json`, capped at 20 entries. The cap is the
29
+ * point: it forces periodic re-litigation instead of an allowlist that grows
30
+ * quietly until the gate means nothing. Cross-pack pairs never block — a
31
+ * cross-pack merge changes install shape and is a different decision.
23
32
  */
24
33
  import * as fs from 'node:fs';
25
34
  import * as path from 'node:path';
26
35
  import { fileURLToPath, pathToFileURL } from 'node:url';
27
36
  import { parse as parseYaml } from 'yaml';
28
37
 
38
+ import { SRC_SKILLS } from './_lib/agent_src.js';
39
+ import { DeadScopeError, assertScanned } from './_lib/scan_scope.js';
40
+
29
41
  const _HERE = fileURLToPath(import.meta.url);
30
42
  // src/scripts/audit_skill_overlap.py → parent.parent.parent == repo root.
31
43
  export const ROOT = path.resolve(path.dirname(_HERE), '..', '..');
32
44
  export const REPORT_DIR = path.join(ROOT, 'agents', 'reports');
33
- export const OUT_JSON = path.join(REPORT_DIR, 'skill-overlap.json');
34
- export const OUT_MD = path.join(REPORT_DIR, 'skill-overlap.md');
45
+ export const OUT_JSON_NAME = 'skill-overlap.json';
46
+ export const OUT_MD_NAME = 'skill-overlap.md';
47
+ export const OUT_JSON = path.join(REPORT_DIR, OUT_JSON_NAME);
48
+ export const OUT_MD = path.join(REPORT_DIR, OUT_MD_NAME);
49
+ export const ALLOWLIST = path.join(ROOT, 'src', 'scripts', 'audit_skill_overlap_allowlist.json');
50
+ export const ALLOWLIST_CAP = 20;
35
51
  // re.compile(r"^---\n(.*?)\n---", re.DOTALL) — used with .search (anywhere).
36
52
  const FM_RE = /^---\n([\s\S]*?)\n---/m;
37
53
  export const OVERLAP_THRESHOLD = 0.7;
@@ -70,49 +86,16 @@ function _isFile(p: string): boolean {
70
86
  return false;
71
87
  }
72
88
  }
73
-
74
- /** Sorted immediate child entries (mirrors `sorted(p.iterdir())` / `p.glob`). */
75
- function _iterdirSorted(p: string): string[] {
76
- let names: string[];
77
- try {
78
- names = fs.readdirSync(p);
79
- } catch {
80
- return [];
81
- }
82
- names.sort();
83
- return names.map((n) => path.join(p, n));
84
- }
85
-
86
89
  /**
87
- * Mirror `_skill_roots`:
88
- * roots = [d for d in (ROOT/"packages").glob("*\/.agent-src.uncondensed/skills")
89
- * if d.is_dir()] if (ROOT/"packages").is_dir() else []
90
- * legacy = ROOT/".agent-src.uncondensed"/"skills"
91
- * if not roots and legacy.is_dir(): roots = [legacy]
90
+ * The one scan root: `src/skills`, via the shared ADR-051 resolver.
92
91
  *
93
- * `packages.glob("*\/.agent-src.uncondensed/skills")` iterates packages
94
- * children sorted (pathlib glob over a single level is unsorted, and the
95
- * retired Python implementation did NOT sort here — it relied on `collect()`'s `seen`
96
- * dedup keyed on dir name. To stay deterministic we sort the package
97
- * children; the resulting root order only affects first-win dedup, and no
98
- * skill dir name collides across packages in practice.)
92
+ * There is deliberately no fallback. A second, silently-tried root is how the
93
+ * previous resolution stayed green while reading nothing — the caller passes an
94
+ * explicit `--root` when it wants a different tree (fixtures, tests), and any
95
+ * root that yields zero skills fails loudly in {@link main}.
99
96
  */
100
- export function _skill_roots(): string[] {
101
- const pkgs = path.join(ROOT, 'packages');
102
- let roots: string[] = [];
103
- if (_isDir(pkgs)) {
104
- for (const child of _iterdirSorted(pkgs)) {
105
- const cand = path.join(child, '.agent-src.uncondensed', 'skills');
106
- if (_isDir(cand)) {
107
- roots.push(cand);
108
- }
109
- }
110
- }
111
- const legacy = path.join(ROOT, '.agent-src.uncondensed', 'skills');
112
- if (roots.length === 0 && _isDir(legacy)) {
113
- roots = [legacy];
114
- }
115
- return roots;
97
+ export function _default_skill_root(): string {
98
+ return SRC_SKILLS();
116
99
  }
117
100
 
118
101
  /**
@@ -162,8 +145,19 @@ export function _cosine(a: Map<string, number>, b: Map<string, number>): number
162
145
  return da && db ? num / (da * db) : 0.0;
163
146
  }
164
147
 
148
+ /**
149
+ * Which text the similarity vector is built from.
150
+ *
151
+ * `body` is the CANONICAL metric — the one every historical report, the 0.70
152
+ * threshold, and the merge decisions are calibrated against. `description` is
153
+ * an explicitly non-canonical second view: routing happens on descriptions, and
154
+ * body similarity does not measure it, so the two answer different questions
155
+ * and their scores are never mixed or compared against the same threshold.
156
+ */
157
+ export type Field = 'body' | 'description';
158
+
165
159
  /** Mirror `_parse`: yaml.safe_load frontmatter, body after the FM match. */
166
- export function _parse(md: string): Skill {
160
+ export function _parse(md: string, field: Field = 'body'): Skill {
167
161
  const text = fs.readFileSync(md, 'utf-8');
168
162
  let fm: Json = {};
169
163
  let body = text;
@@ -179,11 +173,13 @@ export function _parse(md: string): Skill {
179
173
  const isObj = fm !== null && typeof fm === 'object' && !Array.isArray(fm);
180
174
  const name = isObj ? (fm as Record<string, Json>)['name'] : null;
181
175
  const packsVal = isObj ? (fm as Record<string, Json>)['packs'] : null;
176
+ const descVal = isObj ? (fm as Record<string, Json>)['description'] : null;
177
+ const source = field === 'description' ? (descVal ? String(descVal) : '') : body;
182
178
  return {
183
179
  name: name ? String(name) : path.basename(path.dirname(md)),
184
180
  relpath: _relPosix(md, ROOT),
185
181
  packs: Array.isArray(packsVal) ? new Set(packsVal.map((x) => String(x))) : new Set(),
186
- vector: _keyword_vector(body),
182
+ vector: _keyword_vector(source),
187
183
  };
188
184
  }
189
185
 
@@ -247,22 +243,20 @@ function _pathlibCompare(a: string, b: string): number {
247
243
  return pa.length - pb.length;
248
244
  }
249
245
 
250
- export function collect(): Skill[] {
246
+ export function collect(root: string = _default_skill_root(), field: Field = 'body'): Skill[] {
251
247
  const skills: Skill[] = [];
252
248
  const seen = new Set<string>();
253
- for (const root of _skill_roots()) {
254
- for (const md of _rglobSkillMd(root)) {
255
- // if "_archive" in md.parts: continue
256
- if (md.split(path.sep).includes('_archive')) {
257
- continue;
258
- }
259
- const key = path.basename(path.dirname(md));
260
- if (seen.has(key)) {
261
- continue;
262
- }
263
- seen.add(key);
264
- skills.push(_parse(md));
249
+ for (const md of _rglobSkillMd(root)) {
250
+ // if "_archive" in md.parts: continue
251
+ if (md.split(path.sep).includes('_archive')) {
252
+ continue;
265
253
  }
254
+ const key = path.basename(path.dirname(md));
255
+ if (seen.has(key)) {
256
+ continue;
257
+ }
258
+ seen.add(key);
259
+ skills.push(_parse(md, field));
266
260
  }
267
261
  return skills;
268
262
  }
@@ -354,17 +348,30 @@ function _pct(x: number): string {
354
348
  return `${pct}%`;
355
349
  }
356
350
 
357
- export function render_md(skills: Skill[], pairs: Pair[], threshold: number): string {
351
+ export function render_md(
352
+ skills: Skill[],
353
+ pairs: Pair[],
354
+ threshold: number,
355
+ field: Field = 'body',
356
+ ): string {
358
357
  const merge = pairs.filter((p) => p.same_domain);
359
358
  const cross = pairs.filter((p) => !p.same_domain);
360
359
  const L: string[] = [
361
360
  '# Skill-family overlap report (6.0.0-C Phase 4 Step 8)\n',
362
- `> Content cosine-similarity over ${skills.length} skills; pairs at ` +
363
- `≥ ${_pct(threshold)}. **Same-domain pairs (shared \`packs:\`) are the ` +
364
- `merge candidates** consumed by ` +
365
- `[\`evidence-based-pruning.md\`](../../docs/contracts/evidence-based-pruning.md). ` +
366
- `This report merges NOTHING — it is input to a future, human-driven ` +
367
- `consolidation roadmap.\n`,
361
+ field === 'description'
362
+ ? `> **NON-CANONICAL measurement.** Cosine over the \`description:\` ` +
363
+ `frontmatter only, across ${skills.length} skills, at ≥ ${_pct(threshold)}. ` +
364
+ `Routing happens on descriptions, so this answers a question the ` +
365
+ `canonical body metric does not — but its scores are NOT comparable ` +
366
+ `to the canonical 0.70 merge threshold and must never be mixed into ` +
367
+ `a merge decision. The canonical run is the one without ` +
368
+ `\`--descriptions\`.\n`
369
+ : `> Content cosine-similarity over ${skills.length} skills; pairs at ` +
370
+ `≥ ${_pct(threshold)}. **Same-domain pairs (shared \`packs:\`) are the ` +
371
+ `merge candidates** consumed by ` +
372
+ `[\`evidence-based-pruning.md\`](../../docs/contracts/evidence-based-pruning.md). ` +
373
+ `This report merges NOTHING — it is input to a future, human-driven ` +
374
+ `consolidation roadmap.\n`,
368
375
  `\n- Skills scanned: **${skills.length}**`,
369
376
  `\n- Overlap pairs ≥ ${_pct(threshold)}: **${pairs.length}** ` +
370
377
  `(${merge.length} same-domain merge candidates, ${cross.length} cross-domain)\n`,
@@ -476,30 +483,126 @@ class FloatTag {
476
483
 
477
484
  export function main(argv: string[] | null = null): number {
478
485
  const args = parse_args(argv ?? process.argv.slice(2));
479
- const skills = collect();
480
- if (skills.length === 0) {
481
- process.stderr.write('❌ No skills found under the package skill roots.\n');
486
+ const root = args.root ?? _default_skill_root();
487
+ const skills = collect(root, args.field);
488
+ try {
489
+ assertScanned({
490
+ gate: 'audit_skill_overlap',
491
+ scanned: skills.length,
492
+ units: 'skill(s)',
493
+ roots: [_relPosix(root, ROOT)],
494
+ });
495
+ } catch (exc) {
496
+ if (!(exc instanceof DeadScopeError)) {
497
+ throw exc;
498
+ }
499
+ process.stderr.write(`❌ ${exc.message}\n`);
482
500
  return 3;
483
501
  }
484
502
  const pairs = find_pairs(skills, args.threshold);
485
- fs.mkdirSync(REPORT_DIR, { recursive: true });
503
+ const outDir = args.outDir ?? REPORT_DIR;
504
+ const outJson = path.join(outDir, OUT_JSON_NAME);
505
+ const outMd = path.join(outDir, OUT_MD_NAME);
506
+ fs.mkdirSync(outDir, { recursive: true });
486
507
  const jsonObj = {
487
508
  threshold: new FloatTag(args.threshold),
488
509
  skills_scanned: skills.length,
489
510
  pairs: pairs.map(_pairToJson),
490
511
  };
491
- fs.writeFileSync(OUT_JSON, _dumpsWithFloats(jsonObj), 'utf-8');
492
- fs.writeFileSync(OUT_MD, render_md(skills, pairs, args.threshold), 'utf-8');
512
+ fs.writeFileSync(outJson, _dumpsWithFloats(jsonObj), 'utf-8');
513
+ fs.writeFileSync(outMd, render_md(skills, pairs, args.threshold, args.field), 'utf-8');
514
+ const merge = pairs.filter((p) => p.same_domain);
493
515
  if (!args.quiet) {
494
- const merge = pairs.filter((p) => p.same_domain).length;
495
516
  process.stdout.write(
496
517
  `✅ Skill overlap: ${skills.length} skills, ${pairs.length} pair(s) ` +
497
- `≥ ${_pct(args.threshold)} (${merge} same-domain merge candidate(s)).\n`,
518
+ `≥ ${_pct(args.threshold)} (${merge.length} same-domain merge candidate(s)).\n`,
519
+ );
520
+ process.stdout.write(` JSON: ${_relPosix(outJson, ROOT)}\n`);
521
+ process.stdout.write(` MD: ${_relPosix(outMd, ROOT)}\n`);
522
+ }
523
+ // Deliberately NOT gated on --quiet: an advisory CI step whose only output
524
+ // is suppressed by the pipeline's default --quiet is a report nobody reads,
525
+ // which is one step away from the silent-green defect this tool just came
526
+ // back from. The counts line always ships when the table is requested.
527
+ if (args.printTable) {
528
+ process.stdout.write(
529
+ `ℹ️ skill overlap${args.strict ? '' : ' (advisory)'}: ${skills.length} skills scanned, ` +
530
+ `${merge.length} same-domain pair(s) ≥ ${_pct(args.threshold)}\n`,
531
+ );
532
+ for (const p of merge) {
533
+ process.stdout.write(
534
+ ` ${p.similarity.toFixed(3)} ${p.a} ↔ ${p.b} [${p.shared_packs.join(', ')}]\n`,
535
+ );
536
+ }
537
+ }
538
+
539
+ if (!args.strict) {
540
+ return 0;
541
+ }
542
+ let allow: Set<string>;
543
+ try {
544
+ allow = _loadAllowlist(args.allowlist ?? ALLOWLIST);
545
+ } catch (exc) {
546
+ process.stderr.write(`❌ ${exc instanceof Error ? exc.message : String(exc)}\n`);
547
+ return 2;
548
+ }
549
+ const blocking = merge.filter((p) => !allow.has(_pairKey(p.a, p.b)));
550
+ if (blocking.length === 0) {
551
+ return 0;
552
+ }
553
+ process.stderr.write(
554
+ `❌ audit_skill_overlap: ${blocking.length} same-pack pair(s) at or above ` +
555
+ `${_pct(args.threshold)} with no reviewed justification:\n`,
556
+ );
557
+ for (const p of blocking) {
558
+ process.stderr.write(
559
+ ` ${p.similarity.toFixed(3)} ${p.a} ↔ ${p.b} [${p.shared_packs.join(', ')}]\n`,
560
+ );
561
+ }
562
+ process.stderr.write(
563
+ ` Merge the pair, or add {"pair": "${_pairKey(blocking[0]!.a, blocking[0]!.b)}", "reason": "…"} ` +
564
+ `to ${_relPosix(args.allowlist ?? ALLOWLIST, ROOT)} (cap ${ALLOWLIST_CAP}).\n`,
565
+ );
566
+ return 1;
567
+ }
568
+
569
+ /** Order-independent key for an unordered pair. */
570
+ export function _pairKey(a: string, b: string): string {
571
+ return a < b ? `${a}::${b}` : `${b}::${a}`;
572
+ }
573
+
574
+ /**
575
+ * Reviewed same-pack overlaps, capped. Over-cap throws rather than warning:
576
+ * per the autonomous-execution allowlist-growth antipattern, an allowlist that
577
+ * outgrows its cap means the threshold is wrong, not that the corpus needs
578
+ * twenty-one exceptions.
579
+ */
580
+ export function _loadAllowlist(file: string): Set<string> {
581
+ if (!_isFile(file)) {
582
+ return new Set();
583
+ }
584
+ const data = JSON.parse(fs.readFileSync(file, 'utf-8')) as {
585
+ entries?: Array<{ pair: string; reason: string }>;
586
+ };
587
+ const entries = Array.isArray(data.entries) ? data.entries : [];
588
+ if (entries.length > ALLOWLIST_CAP) {
589
+ throw new Error(
590
+ `audit_skill_overlap: allowlist has ${entries.length} entries (> ${ALLOWLIST_CAP}). ` +
591
+ 'Per the autonomous-execution allowlist-growth antipattern, the threshold is wrong, ' +
592
+ 'not the corpus — re-litigate the bar, do not grow the list.',
498
593
  );
499
- process.stdout.write(` JSON: ${_relPosix(OUT_JSON, ROOT)}\n`);
500
- process.stdout.write(` MD: ${_relPosix(OUT_MD, ROOT)}\n`);
501
594
  }
502
- return 0;
595
+ const out = new Set<string>();
596
+ for (const e of entries) {
597
+ const [a, b] = String(e.pair).split('::');
598
+ if (a === undefined || b === undefined || (e.reason ?? '').trim() === '') {
599
+ throw new Error(
600
+ `audit_skill_overlap: allowlist entry ${JSON.stringify(e)} needs "pair": "a::b" and a non-empty "reason".`,
601
+ );
602
+ }
603
+ out.add(_pairKey(a, b));
604
+ }
605
+ return out;
503
606
  }
504
607
 
505
608
  /** json.dumps(indent=2) with FloatTag → Python-float repr. */
@@ -545,23 +648,58 @@ function _dumpsWithFloats(obj: Json, level = 0): string {
545
648
  interface Args {
546
649
  threshold: number;
547
650
  quiet: boolean;
651
+ /** Absolute scan root; `undefined` = the `src/skills` default. */
652
+ root?: string;
653
+ /** Absolute report directory; `undefined` = `agents/reports`. */
654
+ outDir?: string;
655
+ /** Print the same-domain pair table to stdout even under `--quiet`. */
656
+ printTable: boolean;
657
+ /** Fail the build on an unjustified same-pack pair at or above threshold. */
658
+ strict: boolean;
659
+ /** Allowlist path override (tests); `undefined` = the shipped file. */
660
+ allowlist?: string;
661
+ /** Which text the vector is built from. `description` is NON-canonical. */
662
+ field: Field;
548
663
  }
549
664
 
550
665
  export function parse_args(argv: string[]): Args {
551
- const args: Args = { threshold: OVERLAP_THRESHOLD, quiet: false };
666
+ const args: Args = {
667
+ threshold: OVERLAP_THRESHOLD,
668
+ quiet: false,
669
+ printTable: false,
670
+ strict: false,
671
+ field: 'body',
672
+ };
673
+ const takeValue = (flag: string, inline: string | undefined, next: () => string | undefined): string => {
674
+ const v = inline ?? next();
675
+ if (v === undefined) {
676
+ process.stderr.write(`argument ${flag}: expected one argument\n`);
677
+ process.exit(2);
678
+ }
679
+ return v;
680
+ };
552
681
  for (let i = 0; i < argv.length; i++) {
553
682
  const a = argv[i] as string;
554
683
  if (a === '--quiet') {
555
684
  args.quiet = true;
556
- } else if (a === '--threshold') {
557
- const v = argv[++i];
558
- if (v === undefined) {
559
- process.stderr.write('argument --threshold: expected one argument\n');
560
- process.exit(2);
561
- }
562
- args.threshold = Number(v);
563
- } else if (a.startsWith('--threshold=')) {
564
- args.threshold = Number(a.slice('--threshold='.length));
685
+ } else if (a === '--strict') {
686
+ args.strict = true;
687
+ } else if (a === '--allowlist' || a.startsWith('--allowlist=')) {
688
+ const inline = a.startsWith('--allowlist=') ? a.slice('--allowlist='.length) : undefined;
689
+ args.allowlist = path.resolve(ROOT, takeValue('--allowlist', inline, () => argv[++i]));
690
+ } else if (a === '--descriptions') {
691
+ args.field = 'description';
692
+ } else if (a === '--print-table') {
693
+ args.printTable = true;
694
+ } else if (a === '--threshold' || a.startsWith('--threshold=')) {
695
+ const inline = a.startsWith('--threshold=') ? a.slice('--threshold='.length) : undefined;
696
+ args.threshold = Number(takeValue('--threshold', inline, () => argv[++i]));
697
+ } else if (a === '--root' || a.startsWith('--root=')) {
698
+ const inline = a.startsWith('--root=') ? a.slice('--root='.length) : undefined;
699
+ args.root = path.resolve(ROOT, takeValue('--root', inline, () => argv[++i]));
700
+ } else if (a === '--out-dir' || a.startsWith('--out-dir=')) {
701
+ const inline = a.startsWith('--out-dir=') ? a.slice('--out-dir='.length) : undefined;
702
+ args.outDir = path.resolve(ROOT, takeValue('--out-dir', inline, () => argv[++i]));
565
703
  } else {
566
704
  process.stderr.write(`unrecognized arguments: ${a}\n`);
567
705
  process.exit(2);
@@ -0,0 +1,4 @@
1
+ {
2
+ "_comment": "Reviewed same-pack overlaps at or above the canonical 0.70 threshold. Each entry needs a real reason — a pair is here because a human read both skills and decided the overlap is structural, not because the gate was noisy. Capped at 20 by audit_skill_overlap.ts: crossing the cap means the threshold is wrong, not that the corpus needs a twenty-first exception. Empty is the healthy state.",
3
+ "entries": []
4
+ }
@@ -146,6 +146,11 @@ function render(): string {
146
146
  const entries = [...ledger.values()].sort((a, b) => a.id.localeCompare(b.id));
147
147
  const backed = entries.filter((e) => e.status === 'backed');
148
148
  const unbacked = entries.filter((e) => e.status === 'unbacked');
149
+ // `resolved-null` is neither backed nor debt: the question was asked, the
150
+ // answer came back null, and the entry is closed. Counting it as debt
151
+ // would inflate the inventory with items that can never shrink; counting
152
+ // it as backed would claim a win that was never measured.
153
+ const resolvedNull = entries.filter((e) => e.status === 'resolved-null');
149
154
 
150
155
  const L: string[] = [];
151
156
  L.push('<!-- GENERATED by src/scripts/build_proof.ts — do NOT hand-edit.');
@@ -232,6 +237,13 @@ function render(): string {
232
237
  L.push('allowed to carry a marker in public prose. Hiding them would be the');
233
238
  L.push('opposite of the point.');
234
239
  }
240
+ if (resolvedNull.length > 0) {
241
+ L.push('');
242
+ L.push(`And our **nulls**: ${resolvedNull.length} claim(s) are \`resolved-null\` —`);
243
+ L.push('measured, the threshold was missed, and the entry is closed rather than');
244
+ L.push('left open forever. A null that stays filed as pending debt is a claim');
245
+ L.push('quietly waiting to be re-argued.');
246
+ }
235
247
  L.push('');
236
248
  L.push('## 2. We publish honest nulls');
237
249
  L.push('');
@@ -419,9 +431,11 @@ function render(): string {
419
431
  {
420
432
  const ledger = [...load_ledger().values()].sort((a, b) => a.id.localeCompare(b.id));
421
433
  const backed = ledger.filter((e) => e.status === 'backed').length;
434
+ const nulls = ledger.filter((e) => e.status === 'resolved-null').length;
422
435
  L.push(
423
436
  `**Axis 2 — evidence form per public claim.** ${ledger.length} ledger entries · ` +
424
- `${backed} backed · ${ledger.length - backed} unbacked inventory.`,
437
+ `${backed} backed · ${ledger.length - backed - nulls} unbacked inventory · ` +
438
+ `${nulls} resolved-null.`,
425
439
  );
426
440
  L.push('');
427
441
  L.push('| Claim id | Kind | Status | Evidence pointer |');
@@ -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