@event4u/agent-config 9.12.0 → 9.13.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 (130) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +80 -256
  3. package/README.md +1 -1
  4. package/dist/agent-src/rules/active-remediation.md +0 -2
  5. package/dist/agent-src/rules/architecture.md +0 -1
  6. package/dist/agent-src/rules/artifact-drafting-protocol.md +0 -4
  7. package/dist/agent-src/rules/autonomous-execution.md +1 -2
  8. package/dist/agent-src/rules/broken-access-control.md +0 -2
  9. package/dist/agent-src/rules/code-comment-discipline.md +0 -3
  10. package/dist/agent-src/rules/code-provenance.md +0 -3
  11. package/dist/agent-src/rules/communication-through-line.md +2 -3
  12. package/dist/agent-src/rules/content-quoting-floor.md +0 -2
  13. package/dist/agent-src/rules/context-hygiene.md +1 -3
  14. package/dist/agent-src/rules/decision-revisit-gate.md +0 -2
  15. package/dist/agent-src/rules/design-fidelity.md +29 -0
  16. package/dist/agent-src/rules/domain-adoption-policy.md +0 -3
  17. package/dist/agent-src/rules/downstream-changes.md +10 -1
  18. package/dist/agent-src/rules/external-reference-deep-dive.md +0 -7
  19. package/dist/agent-src/rules/fast-path-marker-visibility.md +0 -1
  20. package/dist/agent-src/rules/git-history-discipline.md +0 -7
  21. package/dist/agent-src/rules/guidelines.md +0 -2
  22. package/dist/agent-src/rules/improve-before-implement.md +4 -2
  23. package/dist/agent-src/rules/invite-challenge.md +0 -4
  24. package/dist/agent-src/rules/linked-projects-onboarding-gate.md +0 -2
  25. package/dist/agent-src/rules/markdown-safe-codeblocks.md +0 -1
  26. package/dist/agent-src/rules/minimal-safe-diff.md +2 -2
  27. package/dist/agent-src/rules/missing-tool-handling.md +0 -1
  28. package/dist/agent-src/rules/no-attribution-footers.md +0 -3
  29. package/dist/agent-src/rules/no-decorative-emojis-in-git-surfaces.md +8 -5
  30. package/dist/agent-src/rules/no-pr-progress-comments.md +1 -3
  31. package/dist/agent-src/rules/no-roadmap-references.md +0 -2
  32. package/dist/agent-src/rules/output-discipline.md +0 -1
  33. package/dist/agent-src/rules/prefer-enums-over-literals.md +0 -2
  34. package/dist/agent-src/rules/preservation-guard.md +0 -3
  35. package/dist/agent-src/rules/role-mode-adherence.md +0 -1
  36. package/dist/agent-src/rules/secret-vcs-guard.md +0 -2
  37. package/dist/agent-src/rules/senior-engineering-discipline.md +0 -2
  38. package/dist/agent-src/rules/session-canary.md +0 -2
  39. package/dist/agent-src/rules/size-enforcement.md +0 -5
  40. package/dist/agent-src/rules/source-confidentiality.md +0 -3
  41. package/dist/agent-src/rules/source-discovery-gate.md +0 -2
  42. package/dist/agent-src/rules/think-before-action.md +3 -3
  43. package/dist/agent-src/rules/token-budget-discipline.md +0 -1
  44. package/dist/agent-src/rules/token-efficiency.md +1 -2
  45. package/dist/agent-src/rules/tool-safety.md +0 -1
  46. package/dist/agent-src/rules/user-interaction.md +0 -3
  47. package/dist/agent-src/rules/user-interrupt-priority.md +0 -1
  48. package/dist/agent-src/skills/agent-docs-writing/SKILL.md +8 -0
  49. package/dist/agent-src/skills/analysis-skill-router/SKILL.md +55 -86
  50. package/dist/agent-src/skills/character-consistency/SKILL.md +7 -4
  51. package/dist/agent-src/skills/decision-record/SKILL.md +8 -0
  52. package/dist/agent-src/skills/design-review/SKILL.md +11 -0
  53. package/dist/agent-src/skills/design-system-capture/reference/design-system-json.md +8 -0
  54. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +34 -3
  55. package/dist/agent-src/skills/fe-design/SKILL.md +10 -0
  56. package/dist/agent-src/skills/motion-choreographer/SKILL.md +7 -4
  57. package/dist/agent-src/skills/overbuild-review-lens/SKILL.md +201 -0
  58. package/dist/agent-src/skills/performance/SKILL.md +14 -0
  59. package/dist/agent-src/skills/pixar-storyteller/SKILL.md +8 -5
  60. package/dist/agent-src/skills/scene-expander/SKILL.md +9 -6
  61. package/dist/agent-src/skills/supply-chain-intake/SKILL.md +10 -0
  62. package/dist/agent-src/skills/universal-project-analysis/SKILL.md +14 -37
  63. package/dist/agent-src/skills/video-director/SKILL.md +9 -6
  64. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  65. package/dist/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +154 -13
  66. package/dist/agent-src/templates/scripts/work_engine/directives/ui/design.ts +118 -0
  67. package/dist/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +40 -0
  68. package/dist/agent-src/templates/scripts/work_engine/state.ts +55 -0
  69. package/dist/discovery/deprecation-report.md +1 -1
  70. package/dist/discovery/discovery-manifest.json +89 -67
  71. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  72. package/dist/discovery/discovery-manifest.summary.md +3 -3
  73. package/dist/discovery/orphan-report.md +1 -1
  74. package/dist/discovery/packs.json +6 -5
  75. package/dist/discovery/trust-report.md +2 -2
  76. package/dist/discovery/workspaces.json +5 -4
  77. package/dist/hooks/dispatch.js +174 -13
  78. package/dist/mcp/registry-manifest.json +3 -3
  79. package/dist/mcp/server.json +2 -2
  80. package/dist/mcp/server.mjs +750 -507
  81. package/dist/router.json +1 -1
  82. package/dist/scripts/condense.js +2 -2
  83. package/docs/MIGRATION.md +1 -1
  84. package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +71 -0
  85. package/docs/guidelines/agent-infra/minimal-safe-diff-mechanics.md +24 -1
  86. package/docs/guidelines/code-clarity.md +9 -2
  87. package/docs/guidelines/design-fidelity-mechanics.md +44 -0
  88. package/llms.txt +63 -27
  89. package/package.json +4 -4
  90. package/src/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +154 -13
  91. package/src/agent-src/templates/scripts/work_engine/directives/ui/design.ts +118 -0
  92. package/src/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +40 -0
  93. package/src/agent-src/templates/scripts/work_engine/state.ts +55 -0
  94. package/src/config/agent-settings.template.yml +8 -0
  95. package/src/scripts/_lib/counted_probe.ts +142 -0
  96. package/src/scripts/_lib/overbuild_lens_contract.ts +196 -0
  97. package/src/scripts/_lib/release_scope.ts +207 -0
  98. package/src/scripts/ai_council/stance_tally.ts +37 -2
  99. package/src/scripts/audit_auto_rules.ts +9 -9
  100. package/src/scripts/audit_likelihood.ts +3 -3
  101. package/src/scripts/audit_overlap.ts +2 -2
  102. package/src/scripts/audit_skill_overlap.ts +233 -95
  103. package/src/scripts/audit_skill_overlap_allowlist.json +4 -0
  104. package/src/scripts/build_proof.ts +15 -1
  105. package/src/scripts/check_dependency_floors.ts +16 -6
  106. package/src/scripts/check_gate_coverage.ts +6 -6
  107. package/src/scripts/check_kernel_rule_bundle.ts +5 -4
  108. package/src/scripts/check_token_quality_golden.ts +0 -18
  109. package/src/scripts/code_graph/loader.ts +35 -6
  110. package/src/scripts/compile_router.ts +3 -8
  111. package/src/scripts/condense.ts +2 -2
  112. package/src/scripts/explain_run.ts +0 -1
  113. package/src/scripts/generate_pack_manifests.ts +3 -20
  114. package/src/scripts/hooks/block_kernel_rule_writes.ts +145 -10
  115. package/src/scripts/hooks/block_no_verify.ts +101 -0
  116. package/src/scripts/lint_agent_security.ts +5 -4
  117. package/src/scripts/lint_framework_leakage_allowlist.json +2 -2
  118. package/src/scripts/lint_regression.ts +92 -1
  119. package/src/scripts/lint_skill_descriptions.ts +93 -5
  120. package/src/scripts/project_thin_rules.ts +2 -2
  121. package/src/scripts/release.ts +107 -3
  122. package/src/scripts/resolve_lint_scope.ts +53 -0
  123. package/src/scripts/router_telemetry.ts +0 -5
  124. package/src/scripts/runtime_dispatcher.ts +40 -0
  125. package/src/scripts/schemas/pack.schema.json +1 -5
  126. package/src/scripts/schemas/rule.schema.json +1 -2
  127. package/src/scripts/self_review_gate.ts +15 -73
  128. package/src/scripts/skill_linter.ts +93 -11
  129. package/src/scripts/sweep_dead_scan_roots.ts +550 -0
  130. package/src/scripts/trigger_coverage.ts +11 -37
@@ -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 |');
@@ -44,11 +44,14 @@ const REPO_ROOT = path.resolve(fileURLToPath(import.meta.url), '..', '..', '..')
44
44
  * exact pin cannot suffer the freshest-patch failure (the version is either
45
45
  * cached or it is not), but it also forfeits patch fixes — so each one is
46
46
  * named here rather than silently tolerated.
47
+ *
48
+ * Empty since the code-graph parser pair (`web-tree-sitter` /
49
+ * `tree-sitter-wasms`) left `dependencies` — it was the only entry. The pair's
50
+ * ABI pin now lives in `code_graph/loader.ts`'s install hint, because that is
51
+ * the only place a re-enabler reads it; this gate scans `dependencies` and so
52
+ * no longer sees the pair at all.
47
53
  */
48
- export const EXACT_PIN_EXCEPTIONS: Readonly<Record<string, string>> = {
49
- 'web-tree-sitter': 'ABI-locked against tree-sitter-wasms; see code-graph engine pin',
50
- 'tree-sitter-wasms': 'ABI-locked against web-tree-sitter; see code-graph engine pin',
51
- };
54
+ export const EXACT_PIN_EXCEPTIONS: Readonly<Record<string, string>> = {};
52
55
 
53
56
  /**
54
57
  * Floors that are non-settled ON PURPOSE, because every version below them is
@@ -74,8 +77,15 @@ const EXACT_VERSION = /^\d+\.\d+\.\d+$/;
74
77
  /**
75
78
  * Every violation in `dependencies`, as human-readable lines. Empty array
76
79
  * means the manifest cannot produce a freshest-patch `ETARGET` for a consumer.
80
+ *
81
+ * `exactPinExceptions` defaults to the module's list and is injectable so the
82
+ * allow-an-exact-pin branch stays covered now that the real list is empty —
83
+ * otherwise the only way to test it would be to add a fake production entry.
77
84
  */
78
- export function evaluate(dependencies: Readonly<Record<string, string>>): string[] {
85
+ export function evaluate(
86
+ dependencies: Readonly<Record<string, string>>,
87
+ exactPinExceptions: Readonly<Record<string, string>> = EXACT_PIN_EXCEPTIONS,
88
+ ): string[] {
79
89
  const errors: string[] = [];
80
90
  for (const [name, range] of Object.entries(dependencies)) {
81
91
  const security = SECURITY_FLOOR_EXCEPTIONS[name];
@@ -89,7 +99,7 @@ export function evaluate(dependencies: Readonly<Record<string, string>>): string
89
99
  continue;
90
100
  }
91
101
  if (EXACT_VERSION.test(range)) {
92
- if (!(name in EXACT_PIN_EXCEPTIONS)) {
102
+ if (!(name in exactPinExceptions)) {
93
103
  errors.push(
94
104
  `${name}@${range}: exact pin without an entry in EXACT_PIN_EXCEPTIONS — ` +
95
105
  'add the reason there, or use a settled caret floor (^X.Y.0)',
@@ -31,13 +31,13 @@
31
31
  * Exit codes: 0 all enforced gates cleared their floor · 1 a gate is blind,
32
32
  * collapsed, or silent · 2 the manifest itself is missing/empty/malformed.
33
33
  */
34
- import { spawnSync } from 'node:child_process';
35
34
  import * as fs from 'node:fs';
36
35
  import * as path from 'node:path';
37
36
  import process from 'node:process';
38
37
  import { fileURLToPath, pathToFileURL } from 'node:url';
39
38
 
40
39
  import yaml from 'js-yaml';
40
+ import { runCountedProbe } from './_lib/counted_probe.js';
41
41
 
42
42
  const _HERE = fileURLToPath(import.meta.url);
43
43
  const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
@@ -184,11 +184,11 @@ export function classify(
184
184
 
185
185
  function run_gate(spec: GateSpec): { scanned: number | null; crashed: boolean; exit_code: number | null } {
186
186
  const runner = path.join(REPO_ROOT, 'scripts-run');
187
- const r = spawnSync(runner, [`src/scripts/${spec.id}`, ...spec.argv], {
188
- cwd: REPO_ROOT,
189
- encoding: 'utf8',
190
- });
191
- if (r.error !== undefined || r.status === null) return { scanned: null, crashed: true, exit_code: null };
187
+ // Bounded read: a gate whose output overflowed would lose its `scanned:`
188
+ // line and be classified `silent` — the scan-scope guard reporting a dead
189
+ // scope that is merely truncated. `runCountedProbe` throws instead.
190
+ const r = runCountedProbe(runner, [`src/scripts/${spec.id}`, ...spec.argv], { cwd: REPO_ROOT });
191
+ if (r.failure !== null && r.status === null) return { scanned: null, crashed: true, exit_code: null };
192
192
  // A gate may legitimately exit non-zero (it found real violations) and still
193
193
  // have scanned plenty — coverage and verdict are different questions.
194
194
  return { scanned: parse_scanned(`${r.stdout}\n${r.stderr}`), crashed: false, exit_code: r.status };
@@ -36,6 +36,7 @@ import * as fs from 'node:fs';
36
36
  import * as path from 'node:path';
37
37
  import { fileURLToPath, pathToFileURL } from 'node:url';
38
38
  import { KERNEL_RULE_FILENAMES } from './_lib/kernel_rules.js';
39
+ import { runCountedProbe } from './_lib/counted_probe.js';
39
40
 
40
41
  const KERNEL_RULES: ReadonlySet<string> = KERNEL_RULE_FILENAMES;
41
42
 
@@ -56,13 +57,13 @@ const KERNEL_DIR = 'src/rules';
56
57
  const DEFAULT_LABEL = 'bundled-always-rules-acknowledged';
57
58
 
58
59
  function _git_changed_files(base_ref: string): string[] {
59
- const res = spawnSync('git', ['diff', '--name-only', `${base_ref}...HEAD`], {
60
- encoding: 'utf8',
61
- });
60
+ // Bounded read: a truncated diff would shrink the change set and let the
61
+ // bundle guard pass on files it never saw.
62
+ const res = runCountedProbe('git', ['diff', '--name-only', `${base_ref}...HEAD`]);
62
63
  // Python uses check_output(stderr=STDOUT, text=True): on non-zero it raises
63
64
  // CalledProcessError and the handler prints the combined output. spawnSync
64
65
  // does not raise; mirror by checking status and combining stdout+stderr.
65
- if (res.status !== 0) {
66
+ if (!res.ok) {
66
67
  const combined = `${res.stdout ?? ''}${res.stderr ?? ''}`;
67
68
  process.stderr.write(`❌ git diff failed: ${combined.trim()}\n`);
68
69
  return [];
@@ -113,20 +113,6 @@ export function router_triggers(router: Json): Map<string, Array<Record<string,
113
113
  return map;
114
114
  }
115
115
 
116
- // trigger_coverage.ts word semantics: lowercase alpha-led tokens, len > 2.
117
- const _WORD = /[a-z][a-z0-9_]+/g;
118
-
119
- function _tokens(text: string): Set<string> {
120
- const out = new Set<string>();
121
- const low = text.toLowerCase();
122
- _WORD.lastIndex = 0;
123
- let m: RegExpExecArray | null;
124
- while ((m = _WORD.exec(low)) !== null) {
125
- if (m[0].length > 2) out.add(m[0]);
126
- }
127
- return out;
128
- }
129
-
130
116
  /**
131
117
  * Whether a task exercises ≥1 trigger of the rule (prompt↔trigger
132
118
  * falsifiability, Phase 3). Kernel rules always fire.
@@ -140,16 +126,12 @@ export function task_fires_rule(
140
126
  if (kernel.has(ruleId)) return true;
141
127
  const triggers = router_triggers(router).get(ruleId) ?? [];
142
128
  const low = task.prompt.toLowerCase();
143
- const toks = _tokens(task.prompt);
144
129
  const files = Array.isArray(task.context_files) ? task.context_files : [];
145
130
  const cmd = typeof task.command === 'string' ? task.command : '';
146
131
  for (const trig of triggers) {
147
132
  if ('keyword' in trig || 'phrase' in trig) {
148
133
  const needle = String(trig.keyword ?? trig.phrase).toLowerCase();
149
134
  if (needle && low.includes(needle)) return true;
150
- } else if ('intent' in trig) {
151
- const words = _tokens(String(trig.intent));
152
- if (words.size > 0 && [...words].every((w) => toks.has(w))) return true;
153
135
  } else if ('path_prefix' in trig) {
154
136
  const prefix = String(trig.path_prefix);
155
137
  if (files.some((f) => f.startsWith(prefix))) return true;