@event4u/agent-config 9.11.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 (183) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +80 -204
  3. package/README.md +8 -1
  4. package/dist/agent-src/commands/fix/ci.md +29 -0
  5. package/dist/agent-src/commands/pr/create.md +60 -0
  6. package/dist/agent-src/contexts/authority/kernel-rule-edits.md +18 -2
  7. package/dist/agent-src/rules/active-remediation.md +0 -2
  8. package/dist/agent-src/rules/architecture.md +0 -1
  9. package/dist/agent-src/rules/artifact-drafting-protocol.md +0 -4
  10. package/dist/agent-src/rules/autonomous-execution.md +1 -2
  11. package/dist/agent-src/rules/broken-access-control.md +0 -2
  12. package/dist/agent-src/rules/code-comment-discipline.md +0 -3
  13. package/dist/agent-src/rules/code-provenance.md +0 -3
  14. package/dist/agent-src/rules/communication-through-line.md +2 -3
  15. package/dist/agent-src/rules/content-quoting-floor.md +0 -2
  16. package/dist/agent-src/rules/context-hygiene.md +1 -3
  17. package/dist/agent-src/rules/decision-revisit-gate.md +0 -2
  18. package/dist/agent-src/rules/design-fidelity.md +30 -1
  19. package/dist/agent-src/rules/domain-adoption-policy.md +0 -3
  20. package/dist/agent-src/rules/downstream-changes.md +10 -1
  21. package/dist/agent-src/rules/external-reference-deep-dive.md +0 -7
  22. package/dist/agent-src/rules/fast-path-marker-visibility.md +0 -1
  23. package/dist/agent-src/rules/git-history-discipline.md +0 -7
  24. package/dist/agent-src/rules/guidelines.md +0 -2
  25. package/dist/agent-src/rules/improve-before-implement.md +4 -2
  26. package/dist/agent-src/rules/invite-challenge.md +0 -4
  27. package/dist/agent-src/rules/linked-projects-onboarding-gate.md +0 -2
  28. package/dist/agent-src/rules/markdown-safe-codeblocks.md +0 -1
  29. package/dist/agent-src/rules/minimal-safe-diff.md +2 -2
  30. package/dist/agent-src/rules/missing-tool-handling.md +0 -1
  31. package/dist/agent-src/rules/no-attribution-footers.md +0 -3
  32. package/dist/agent-src/rules/no-decorative-emojis-in-git-surfaces.md +8 -5
  33. package/dist/agent-src/rules/no-pr-progress-comments.md +1 -3
  34. package/dist/agent-src/rules/no-roadmap-references.md +0 -2
  35. package/dist/agent-src/rules/output-discipline.md +0 -1
  36. package/dist/agent-src/rules/prefer-enums-over-literals.md +0 -2
  37. package/dist/agent-src/rules/preservation-guard.md +0 -3
  38. package/dist/agent-src/rules/role-mode-adherence.md +0 -1
  39. package/dist/agent-src/rules/secret-vcs-guard.md +0 -2
  40. package/dist/agent-src/rules/senior-engineering-discipline.md +0 -2
  41. package/dist/agent-src/rules/session-canary.md +0 -2
  42. package/dist/agent-src/rules/size-enforcement.md +0 -5
  43. package/dist/agent-src/rules/source-confidentiality.md +0 -3
  44. package/dist/agent-src/rules/source-discovery-gate.md +0 -2
  45. package/dist/agent-src/rules/think-before-action.md +3 -3
  46. package/dist/agent-src/rules/token-budget-discipline.md +0 -1
  47. package/dist/agent-src/rules/token-efficiency.md +1 -2
  48. package/dist/agent-src/rules/tool-safety.md +0 -1
  49. package/dist/agent-src/rules/user-interaction.md +0 -3
  50. package/dist/agent-src/rules/user-interrupt-priority.md +0 -1
  51. package/dist/agent-src/skills/agent-docs-writing/SKILL.md +8 -0
  52. package/dist/agent-src/skills/ai-code-blindspots/SKILL.md +1 -0
  53. package/dist/agent-src/skills/analysis-skill-router/SKILL.md +55 -86
  54. package/dist/agent-src/skills/character-consistency/SKILL.md +7 -4
  55. package/dist/agent-src/skills/decision-record/SKILL.md +8 -0
  56. package/dist/agent-src/skills/design-intelligence/SKILL.md +27 -1
  57. package/dist/agent-src/skills/design-intelligence/data/font-pairings-reference.csv +74 -74
  58. package/dist/agent-src/skills/design-intelligence/data/manifest.json +2 -0
  59. package/dist/agent-src/skills/design-review/SKILL.md +11 -0
  60. package/dist/agent-src/skills/design-system-capture/reference/design-system-json.md +8 -0
  61. package/dist/agent-src/skills/design-tokens/scripts/tokens.ts +0 -7
  62. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +34 -3
  63. package/dist/agent-src/skills/fe-design/SKILL.md +10 -0
  64. package/dist/agent-src/skills/motion-choreographer/SKILL.md +7 -4
  65. package/dist/agent-src/skills/overbuild-review-lens/SKILL.md +201 -0
  66. package/dist/agent-src/skills/performance/SKILL.md +14 -0
  67. package/dist/agent-src/skills/pixar-storyteller/SKILL.md +8 -5
  68. package/dist/agent-src/skills/scene-expander/SKILL.md +9 -6
  69. package/dist/agent-src/skills/supply-chain-intake/SKILL.md +10 -0
  70. package/dist/agent-src/skills/typography-system/SKILL.md +55 -11
  71. package/dist/agent-src/skills/ui-apply-generic/SKILL.md +180 -0
  72. package/dist/agent-src/skills/universal-project-analysis/SKILL.md +14 -37
  73. package/dist/agent-src/skills/video-director/SKILL.md +9 -6
  74. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  75. package/dist/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +162 -17
  76. package/dist/agent-src/templates/scripts/work_engine/directives/ui/design.ts +118 -0
  77. package/dist/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +49 -6
  78. package/dist/agent-src/templates/scripts/work_engine/directives/ui/review.ts +9 -5
  79. package/dist/agent-src/templates/scripts/work_engine/directives/ui/stack_bundles.ts +182 -29
  80. package/dist/agent-src/templates/scripts/work_engine/stack/detect.ts +311 -24
  81. package/dist/agent-src/templates/scripts/work_engine/state.ts +55 -0
  82. package/dist/cli-delegate/chunk-523HCFYO.js +90 -0
  83. package/dist/cli-delegate/{chunk-IR7P7JWA.js → chunk-A3HJ6NTI.js} +271 -59
  84. package/dist/cli-delegate/chunk-EJYIGZ3T.js +125 -0
  85. package/dist/cli-delegate/{chunk-RDGRHAAB.js → chunk-HYWSRNUX.js} +6 -86
  86. package/dist/cli-delegate/{chunk-YMFO6FDN.js → chunk-QFKQLQ7X.js} +313 -81
  87. package/dist/cli-delegate/{chunk-4SV3XGFA.js → chunk-TEGJU2JX.js} +7 -9
  88. package/dist/cli-delegate/chunk-TJIAM5FW.js +133 -0
  89. package/dist/cli-delegate/cmd_conformance.js +4 -7
  90. package/dist/cli-delegate/cmd_doctor.js +3 -5
  91. package/dist/cli-delegate/cmd_explain.js +153 -85
  92. package/dist/cli-delegate/cmd_export.js +5 -3
  93. package/dist/cli-delegate/cmd_fleet.js +2 -1
  94. package/dist/cli-delegate/cmd_preflight.js +6 -123
  95. package/dist/cli-delegate/cmd_prune.js +5 -3
  96. package/dist/cli-delegate/cmd_settings_migrate.js +5 -3
  97. package/dist/cli-delegate/cmd_sync.js +5 -3
  98. package/dist/cli-delegate/cmd_uninstall.js +6 -5
  99. package/dist/cli-delegate/cmd_validate.js +5 -3
  100. package/dist/discovery/deprecation-report.md +1 -1
  101. package/dist/discovery/discovery-manifest.json +116 -72
  102. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  103. package/dist/discovery/discovery-manifest.summary.md +3 -3
  104. package/dist/discovery/orphan-report.md +1 -1
  105. package/dist/discovery/packs.json +7 -5
  106. package/dist/discovery/trust-report.md +2 -2
  107. package/dist/discovery/workspaces.json +6 -4
  108. package/dist/hooks/dispatch.js +174 -13
  109. package/dist/install/install.mjs +605 -412
  110. package/dist/install/ruleInScope.js +123 -0
  111. package/dist/install/ruleInScope.js.map +1 -0
  112. package/dist/install/rule_scope.js +2 -2
  113. package/dist/install/rule_scope.js.map +1 -1
  114. package/dist/install/wizard-plan.js +10 -1
  115. package/dist/install/wizard-plan.js.map +1 -1
  116. package/dist/mcp/registry-manifest.json +3 -3
  117. package/dist/mcp/server.json +2 -2
  118. package/dist/mcp/server.mjs +710 -2491
  119. package/dist/router.json +1 -1
  120. package/dist/scripts/condense.js +8 -24
  121. package/dist/scripts/condense.js.map +1 -1
  122. package/docs/MIGRATION.md +1 -1
  123. package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +71 -0
  124. package/docs/guidelines/agent-infra/minimal-safe-diff-mechanics.md +24 -1
  125. package/docs/guidelines/code-clarity.md +9 -2
  126. package/docs/guidelines/design-fidelity-mechanics.md +58 -0
  127. package/llms.txt +63 -27
  128. package/package.json +4 -4
  129. package/src/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +162 -17
  130. package/src/agent-src/templates/scripts/work_engine/directives/ui/design.ts +118 -0
  131. package/src/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +49 -6
  132. package/src/agent-src/templates/scripts/work_engine/directives/ui/review.ts +9 -5
  133. package/src/agent-src/templates/scripts/work_engine/directives/ui/stack_bundles.ts +182 -29
  134. package/src/agent-src/templates/scripts/work_engine/stack/detect.ts +311 -24
  135. package/src/agent-src/templates/scripts/work_engine/state.ts +55 -0
  136. package/src/config/agent-settings.template.yml +8 -0
  137. package/src/install/ruleInScope.ts +133 -0
  138. package/src/install/rule_scope.ts +2 -2
  139. package/src/install/wizard-plan.ts +11 -1
  140. package/src/scripts/_lib/counted_probe.ts +142 -0
  141. package/src/scripts/_lib/global_deploy_inventory.ts +38 -2
  142. package/src/scripts/_lib/overbuild_lens_contract.ts +196 -0
  143. package/src/scripts/_lib/release_scope.ts +207 -0
  144. package/src/scripts/ai_council/stance_tally.ts +37 -2
  145. package/src/scripts/audit_auto_rules.ts +9 -9
  146. package/src/scripts/audit_likelihood.ts +3 -3
  147. package/src/scripts/audit_overlap.ts +2 -2
  148. package/src/scripts/audit_skill_overlap.ts +233 -95
  149. package/src/scripts/audit_skill_overlap_allowlist.json +4 -0
  150. package/src/scripts/build_proof.ts +15 -1
  151. package/src/scripts/check_dependency_floors.ts +16 -6
  152. package/src/scripts/check_gate_coverage.ts +6 -6
  153. package/src/scripts/check_installer_import_purity.ts +295 -0
  154. package/src/scripts/check_kernel_rule_bundle.ts +5 -4
  155. package/src/scripts/check_token_quality_golden.ts +0 -18
  156. package/src/scripts/code_graph/loader.ts +35 -6
  157. package/src/scripts/compile_router.ts +3 -8
  158. package/src/scripts/condense.ts +9 -30
  159. package/src/scripts/explain_run.ts +0 -1
  160. package/src/scripts/generate_pack_manifests.ts +3 -20
  161. package/src/scripts/hooks/block_kernel_rule_writes.ts +145 -10
  162. package/src/scripts/hooks/block_no_verify.ts +101 -0
  163. package/src/scripts/install-hooks.sh +36 -4
  164. package/src/scripts/install.ts +210 -5
  165. package/src/scripts/lint_agent_security.ts +5 -4
  166. package/src/scripts/lint_framework_leakage_allowlist.json +2 -2
  167. package/src/scripts/lint_regression.ts +92 -1
  168. package/src/scripts/lint_skill_descriptions.ts +93 -5
  169. package/src/scripts/lint_ui_stack_bundles.ts +31 -0
  170. package/src/scripts/project_thin_rules.ts +2 -2
  171. package/src/scripts/release.ts +107 -3
  172. package/src/scripts/resolve_lint_scope.ts +53 -0
  173. package/src/scripts/router_telemetry.ts +0 -5
  174. package/src/scripts/runtime_dispatcher.ts +40 -0
  175. package/src/scripts/schemas/pack.schema.json +1 -5
  176. package/src/scripts/schemas/rule.schema.json +1 -2
  177. package/src/scripts/self_review_gate.ts +15 -73
  178. package/src/scripts/skill_linter.ts +93 -11
  179. package/src/scripts/sweep_dead_scan_roots.ts +550 -0
  180. package/src/scripts/trigger_coverage.ts +11 -37
  181. package/dist/cli-delegate/chunk-7DSAMZFU.js +0 -356
  182. package/dist/cli-delegate/chunk-BJGXZNPM.js +0 -266
  183. package/dist/cli-delegate/chunk-TFG57JJM.js +0 -1969
@@ -51,7 +51,14 @@ export interface StanceTallyResult {
51
51
  consensus: OptionTally | null; // the option clearing the threshold, or null
52
52
  split: boolean; // true when no option clears the threshold
53
53
  abstain_count: number;
54
- needs_repair: string[]; // members whose stance line was missing/unparseable
54
+ /**
55
+ * Members whose stance line was missing or unparseable. They COUNT toward
56
+ * `w_total` (they responded) and back no option — see the
57
+ * refusal-preservation invariant in `tally_stances`. Surfaced by
58
+ * `render_vote_tally` so a shrunken-signal quorum is visible rather than
59
+ * merely computed.
60
+ */
61
+ needs_repair: string[];
55
62
  }
56
63
 
57
64
  // Tolerant of whitespace and case; requires the three explicit fields in order.
@@ -150,7 +157,23 @@ export function tally_stances(
150
157
  parsed.push({ member, ...p });
151
158
  }
152
159
 
153
- const w_total = parsed.length; // base weight 1.0 each, abstain included
160
+ // ── Refusal-preservation invariant (road-to-governance-invariants P2) ──
161
+ //
162
+ // Selection may never rank an option higher *because* a member refused
163
+ // less. A safety refusal is not a scored-down property, and — measured by
164
+ // spike S0.1 — the way this engine could be steered was not a weight but a
165
+ // DENOMINATOR: `w_total` once counted only members whose stance line
166
+ // parsed, so a refusal phrased as prose ("I will not answer this") vanished
167
+ // from the quorum and made consensus EASIER. Same two backers, margin
168
+ // −0.25 and no consensus when the refusal parsed as an abstention;
169
+ // +0.4167 and consensus when it did not parse. Δ 0.6667, outcome flipped.
170
+ //
171
+ // The fix is structural rather than a new gate: a member who RESPONDED
172
+ // counts toward the quorum whether or not its stance line parsed. It backs
173
+ // no option either way, so an unparseable response now behaves exactly like
174
+ // an abstention — the fail-safe direction. Members who never responded are
175
+ // dropped upstream (`!r.error`) and are correctly absent from `members`.
176
+ const w_total = parsed.length + needs_repair.length; // responded, abstain + unparseable included
154
177
  const threshold = CONSENSUS_FRACTION * w_total;
155
178
  let abstain_count = 0;
156
179
 
@@ -208,6 +231,18 @@ export function render_vote_tally(result: StanceTallyResult): string {
208
231
  if (result.abstain_count > 0) {
209
232
  lines.push(`- abstain — ${result.abstain_count} member(s) (raises the bar)`);
210
233
  }
234
+ // Refusal divergence, as an OBSERVATION and never a selection input
235
+ // (road-to-governance-invariants P2). A member whose stance did not parse
236
+ // still counts toward the quorum; saying so here is what makes a shrunken
237
+ // signal visible to a reader instead of merely present in a struct field
238
+ // no caller reads. Nothing in the tally consumes this string.
239
+ if (result.needs_repair.length > 0) {
240
+ lines.push(
241
+ `- unparsed — ${result.needs_repair.length} member(s) responded without a ` +
242
+ `readable stance (${result.needs_repair.join(', ')}); counted in the ` +
243
+ 'quorum, backing nothing',
244
+ );
245
+ }
211
246
  lines.push(`Threshold: ⅔ × ${result.w_total} = ${result.threshold.toFixed(2)}`);
212
247
  lines.push(
213
248
  result.consensus !== null
@@ -136,14 +136,14 @@ export function _split_frontmatter(text: string): [Record<string, Json>, string]
136
136
  interface TriggerSummary {
137
137
  path_prefixes: string[];
138
138
  keywords: string[];
139
- intents: string[];
139
+ phrases: string[];
140
140
  }
141
141
 
142
142
  /** Mirror `_trigger_summary`. */
143
143
  export function _trigger_summary(triggers: Json): TriggerSummary {
144
144
  const paths: string[] = [];
145
145
  const keywords: string[] = [];
146
- const intents: string[] = [];
146
+ const phrases: string[] = [];
147
147
  const list: Json[] = Array.isArray(triggers) ? triggers : [];
148
148
  for (const entry of list) {
149
149
  if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
@@ -156,11 +156,11 @@ export function _trigger_summary(triggers: Json): TriggerSummary {
156
156
  if ('keyword' in e) {
157
157
  keywords.push(String(e['keyword']));
158
158
  }
159
- if ('intent' in e) {
160
- intents.push(String(e['intent']));
159
+ if ('phrase' in e) {
160
+ phrases.push(String(e['phrase']));
161
161
  }
162
162
  }
163
- return { path_prefixes: paths, keywords, intents };
163
+ return { path_prefixes: paths, keywords, phrases };
164
164
  }
165
165
 
166
166
  interface RuleEntry {
@@ -208,7 +208,7 @@ export function collect(): RuleEntry[] {
208
208
  description_chars: pyLen(desc),
209
209
  triggers,
210
210
  trigger_count:
211
- triggers.path_prefixes.length + triggers.keywords.length + triggers.intents.length,
211
+ triggers.path_prefixes.length + triggers.keywords.length + triggers.phrases.length,
212
212
  routes_to,
213
213
  body_chars: pyLen(body),
214
214
  file_chars: pyLen(text),
@@ -292,7 +292,7 @@ export function render_markdown(rules: RuleEntry[]): string {
292
292
  const triggers =
293
293
  `${r.triggers.path_prefixes.length}p / ` +
294
294
  `${r.triggers.keywords.length}k / ` +
295
- `${r.triggers.intents.length}i`;
295
+ `${r.triggers.phrases.length}ph`;
296
296
  const routes = r.routes_to.map((x) => String(x)).join(', ') || '—';
297
297
  lines.push(
298
298
  `| ${i} | \`${r.name}\` | ${_pyTruthy(r.tier) ? String(r.tier) : '—'} | ` +
@@ -302,7 +302,7 @@ export function render_markdown(rules: RuleEntry[]): string {
302
302
  i++;
303
303
  }
304
304
  lines.push('');
305
- lines.push('Trigger key: `Np` = path-prefix, `Nk` = keyword, `Ni` = intent.');
305
+ lines.push('Trigger key: `Np` = path-prefix, `Nk` = keyword, `Nph` = phrase.');
306
306
  lines.push('');
307
307
  return lines.join('\n');
308
308
  }
@@ -393,7 +393,7 @@ function _ruleToJson(r: RuleEntry): Record<string, Json> {
393
393
  triggers: {
394
394
  path_prefixes: r.triggers.path_prefixes,
395
395
  keywords: r.triggers.keywords,
396
- intents: r.triggers.intents,
396
+ phrases: r.triggers.phrases,
397
397
  },
398
398
  trigger_count: r.trigger_count,
399
399
  routes_to: r.routes_to,
@@ -16,7 +16,7 @@
16
16
  * For every auto-rule from `agents/reports/auto-rules-audit.json`:
17
17
  *
18
18
  * 1. Build a token set from `description`, `triggers[].keyword`,
19
- * `triggers[].intent`, and the rule name itself.
19
+ * `triggers[].phrase`, and the rule name itself.
20
20
  * 2. Index a corpus of skills (`SKILL.md`), contexts, guidelines, and
21
21
  * command files.
22
22
  * 3. Score `corpus_hits = sum(1 for token in tokens if token in corpus)`.
@@ -197,9 +197,9 @@ function _ruleTokens(rule: Json): Set<string> {
197
197
  add(tokens(String(rule['name'] ?? '').replaceAll('-', ' ')));
198
198
  const trig = rule['triggers'] ?? {};
199
199
  const kw: Json[] = Array.isArray(trig['keywords']) ? trig['keywords'] : [];
200
- const intents: Json[] = Array.isArray(trig['intents']) ? trig['intents'] : [];
200
+ const phrases: Json[] = Array.isArray(trig['phrases']) ? trig['phrases'] : [];
201
201
  add(tokens(kw.map((x) => String(x)).join(' ')));
202
- add(tokens(intents.map((x) => String(x)).join(' ')));
202
+ add(tokens(phrases.map((x) => String(x)).join(' ')));
203
203
  return out;
204
204
  }
205
205
 
@@ -54,7 +54,7 @@ const _ALL_DIGITS = /^[0-9]+$/;
54
54
  interface RuleTriggers {
55
55
  path_prefixes: string[];
56
56
  keywords: string[];
57
- intents: string[];
57
+ phrases: string[];
58
58
  }
59
59
 
60
60
  interface RuleEntry {
@@ -194,7 +194,7 @@ function analyse(rules: RuleEntry[]): Pair[] {
194
194
  for (const x of keyword_set(r.triggers.keywords.join(' '))) {
195
195
  kw.add(x);
196
196
  }
197
- for (const x of keyword_set(r.triggers.intents.join(' '))) {
197
+ for (const x of keyword_set(r.triggers.phrases.join(' '))) {
198
198
  kw.add(x);
199
199
  }
200
200
  keywords.push(kw);
@@ -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 |');