@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
@@ -33,6 +33,34 @@ const _SHELL_SEPARATORS: ReadonlySet<string> = new Set(['&&', '||', ';', '|']);
33
33
  const _NO_VERIFY_FLAGS: ReadonlySet<string> = new Set(['--no-verify']);
34
34
  const _NO_VERIFY_SHORT: ReadonlySet<string> = new Set(['-n']);
35
35
  const _HOOKS_PATH_RE = /^core\.hooksPath\s*=/i;
36
+ /** Bare key form, as `git config core.hooksPath <value>` spells it. */
37
+ const _HOOKS_PATH_KEY_RE = /^core\.hooksPath$/i;
38
+ /**
39
+ * `git config` flags that make the invocation a READ or a RESTORE rather than
40
+ * a write. `--unset` removes the override — the opposite of disabling hooks —
41
+ * so it stays allowed, or the gate would block its own remediation.
42
+ */
43
+ /** Global `git` options that consume the following token as their value. */
44
+ const _GIT_GLOBAL_OPTS_WITH_VALUE: ReadonlySet<string> = new Set([
45
+ '-C',
46
+ '-c',
47
+ '--config',
48
+ '--git-dir',
49
+ '--work-tree',
50
+ '--namespace',
51
+ '--exec-path',
52
+ '--super-prefix',
53
+ ]);
54
+ const _CONFIG_READ_OR_RESTORE: ReadonlySet<string> = new Set([
55
+ '--get',
56
+ '--get-all',
57
+ '--get-regexp',
58
+ '--get-urlmatch',
59
+ '--list',
60
+ '-l',
61
+ '--unset',
62
+ '--unset-all',
63
+ ]);
36
64
 
37
65
  type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
38
66
  type JsonObject = { [k: string]: JsonValue };
@@ -178,7 +206,80 @@ function _git_base(tokens: string[]): string[] | null {
178
206
  return null;
179
207
  }
180
208
 
209
+ /**
210
+ * `git config … core.hooksPath <value>` — the subcommand form of the hooksPath
211
+ * override, distinct from the `-c` / `--config` global-option form handled in
212
+ * `_is_blocked`.
213
+ *
214
+ * Closed by road-to-governance-invariants Phase 1 after the S0.2 spike measured
215
+ * it: `git-history-discipline` already claimed `core.hooksPath` overrides were
216
+ * "deterministically blocked", and only the inline `-c` form was. That made
217
+ * `git config core.hooksPath /dev/null` → `git commit` a two-step sequence
218
+ * whose every step this gate allowed and whose composition is exactly the
219
+ * outcome the gate exists to prevent.
220
+ *
221
+ * Deliberately narrow, per the 2026-08-02 council cut (option ii — widen only
222
+ * the exact shapes with no plausible legitimate use):
223
+ * - a READ (`--get`, `--list`, or the key with no value) is allowed;
224
+ * - `--unset` is allowed — it restores hooks, it does not disable them;
225
+ * - non-git ways of disabling a hook (`mv`/`chmod`/`rm` on `.git/hooks/*`)
226
+ * are NOT matched here. Recognising them would turn this guard into a
227
+ * shell sandbox and those verbs have ordinary legitimate uses. That gap
228
+ * is published rather than papered over — see the S0.2 spike.
229
+ */
230
+ function _is_config_hookspath_write(git_tokens: string[]): [boolean, string] {
231
+ const rest = git_tokens.slice(1);
232
+ // The subcommand is the first token that is neither a global option nor
233
+ // the VALUE of one — `git -C /repo config …` must still resolve to
234
+ // `config`, not to `/repo`.
235
+ let subIdx = -1;
236
+ for (let k = 0; k < rest.length; k += 1) {
237
+ const tok = rest[k] as string;
238
+ if (!tok.startsWith('-')) {
239
+ subIdx = k;
240
+ break;
241
+ }
242
+ if (_GIT_GLOBAL_OPTS_WITH_VALUE.has(tok)) {
243
+ k += 1; // consume its value
244
+ }
245
+ }
246
+ if (subIdx === -1 || rest[subIdx] !== 'config') {
247
+ return [false, ''];
248
+ }
249
+ const args = rest.slice(subIdx + 1);
250
+ if (args.some((t) => _CONFIG_READ_OR_RESTORE.has(t))) {
251
+ return [false, ''];
252
+ }
253
+ for (let k = 0; k < args.length; k += 1) {
254
+ const tok = args[k] as string;
255
+ // `core.hooksPath=<value>` — one token, always a write.
256
+ if (_HOOKS_PATH_RE.test(tok)) {
257
+ return [
258
+ true,
259
+ `'git config ${tok}' disables git hooks via hooksPath (git-history-discipline)`,
260
+ ];
261
+ }
262
+ if (!_HOOKS_PATH_KEY_RE.test(tok)) {
263
+ continue;
264
+ }
265
+ // Bare key: a write only when a value follows.
266
+ const next = args.slice(k + 1).find((t) => !t.startsWith('-'));
267
+ if (next !== undefined) {
268
+ return [
269
+ true,
270
+ `'git config ${tok} ${next}' disables git hooks via hooksPath (git-history-discipline)`,
271
+ ];
272
+ }
273
+ return [false, ''];
274
+ }
275
+ return [false, ''];
276
+ }
277
+
181
278
  function _is_blocked(git_tokens: string[]): [boolean, string] {
279
+ const [cfgBlocked, cfgReason] = _is_config_hookspath_write(git_tokens);
280
+ if (cfgBlocked) {
281
+ return [cfgBlocked, cfgReason];
282
+ }
182
283
  let i = 1; // skip 'git'
183
284
  while (i < git_tokens.length) {
184
285
  const tok = git_tokens[i] as string;
@@ -74,6 +74,7 @@ import type * as YamlModule from 'yaml';
74
74
  import { build_merge_entries } from './_lib/json_pointers.js';
75
75
  import { is_claude_builtin_name } from './_lib/claude_builtin_names.js';
76
76
  import * as installed_lock from './_lib/installed_lock.js';
77
+ import * as scoped_projection from './_lib/scoped_projection.js';
77
78
  import * as surface_tiers from './_lib/surface_tiers.js';
78
79
  import * as global_deploy_inventory from './_lib/global_deploy_inventory.js';
79
80
  import * as installed_tools from './_lib/installed_tools.js';
@@ -3344,89 +3345,16 @@ function _prune_lab_modules(
3344
3345
  // `requires` graph). Mirrors the core-only lab prune above — same
3345
3346
  // `_prune_modules_by` mechanics, a different predicate.
3346
3347
 
3347
- /** `workspaces` values whose packs are ALWAYS active under `scoped` mode. */
3348
- const SCOPED_ACTIVE_WORKSPACES: ReadonlySet<string> = new Set(['engineering', 'agent-config-maintainer']);
3349
-
3350
- interface PackRecord {
3351
- id: string;
3352
- workspaces: string[];
3353
- requires: string[];
3354
- }
3355
-
3356
- /**
3357
- * Parse `src/config/discovery/packs.yml` into `{id, workspaces, requires}`
3358
- * records. `requires` falls back to the legacy `requires_hint` field name
3359
- * when `requires` is absent.
3360
- *
3361
- * Deliberately THROWS on a missing file or a parse result that is not a
3362
- * list — an empty active-pack set would be the LEAST safe fallback here
3363
- * (it would prune every tagged artefact, the opposite of the fail-safe
3364
- * contract). The caller wraps this in a try/catch that restores the full
3365
- * tree on any error instead.
3366
- */
3367
- function _load_packs_registry(package_root: string): PackRecord[] {
3368
- const vocab_path = path.join(package_root, 'src', 'config', 'discovery', 'packs.yml');
3369
- const data = yamlSafeLoad(readText(vocab_path));
3370
- if (!Array.isArray(data)) {
3371
- throw new Error(`packs.yml did not parse to a list: ${vocab_path}`);
3372
- }
3373
- const out: PackRecord[] = [];
3374
- for (const entry of data) {
3375
- if (!_isPlainObject(entry)) continue;
3376
- const id = entry['id'];
3377
- if (typeof id !== 'string' || id === '') continue;
3378
- const workspaces_raw = entry['workspaces'];
3379
- const workspaces = Array.isArray(workspaces_raw)
3380
- ? workspaces_raw.filter((w): w is string => typeof w === 'string')
3381
- : [];
3382
- const requires_raw = entry['requires'] ?? entry['requires_hint'];
3383
- const requires = Array.isArray(requires_raw)
3384
- ? requires_raw.filter((r): r is string => typeof r === 'string')
3385
- : [];
3386
- out.push({ id, workspaces, requires });
3387
- }
3388
- return out;
3389
- }
3390
-
3391
- /**
3392
- * Active pack id set for `scoped` mode: every pack whose `workspaces`
3393
- * intersects `SCOPED_ACTIVE_WORKSPACES`, unioned with `runtime_active_packs`,
3394
- * expanded over the `requires` graph (transitive closure).
3348
+ /*
3349
+ * The scoped-projection predicate lives in `_lib/scoped_projection.ts` so the
3350
+ * installer and `count_scoped_projection.ts` — which produces the number the
3351
+ * claims ledger publishes — run the SAME code rather than the same rule
3352
+ * written twice. Re-exported under the existing private names to keep the
3353
+ * prune tests' import surface unchanged.
3395
3354
  */
3396
- function _compute_active_pack_ids(
3397
- packs: readonly PackRecord[],
3398
- runtime_active_packs: readonly string[],
3399
- ): Set<string> {
3400
- const by_id = new Map<string, PackRecord>();
3401
- for (const p of packs) by_id.set(p.id, p);
3402
-
3403
- const active = new Set<string>();
3404
- for (const p of packs) {
3405
- if (p.workspaces.some((w) => SCOPED_ACTIVE_WORKSPACES.has(w))) {
3406
- active.add(p.id);
3407
- }
3408
- }
3409
- for (const id of runtime_active_packs) {
3410
- active.add(id);
3411
- }
3412
-
3413
- let frontier = [...active];
3414
- while (frontier.length > 0) {
3415
- const next: string[] = [];
3416
- for (const id of frontier) {
3417
- const rec = by_id.get(id);
3418
- if (rec === undefined) continue;
3419
- for (const dep of rec.requires) {
3420
- if (!active.has(dep)) {
3421
- active.add(dep);
3422
- next.push(dep);
3423
- }
3424
- }
3425
- }
3426
- frontier = next;
3427
- }
3428
- return active;
3429
- }
3355
+ type PackRecord = scoped_projection.PackRecord;
3356
+ const _load_packs_registry = scoped_projection.load_packs_registry;
3357
+ const _compute_active_pack_ids = scoped_projection.compute_active_pack_ids;
3430
3358
 
3431
3359
  /**
3432
3360
  * Read the GLOBAL settings document that governs a global deploy, if one
@@ -3574,14 +3502,9 @@ function _prune_scoped_modules(
3574
3502
  deploy_results: Record<string, DeployResult>,
3575
3503
  active_ids: ReadonlySet<string>,
3576
3504
  ): [number, Record<string, DeployResult>] {
3577
- return _prune_modules_by(deploy_results, (p) => {
3578
- const packs = surface_tiers.frontmatter_packs(p);
3579
- if (packs.size === 0) return false;
3580
- for (const id of packs) {
3581
- if (active_ids.has(id)) return false;
3582
- }
3583
- return true;
3584
- });
3505
+ return _prune_modules_by(deploy_results, (p) =>
3506
+ scoped_projection.is_pruned_under_scoped(p, active_ids),
3507
+ );
3585
3508
  }
3586
3509
 
3587
3510
  function install_global(
@@ -23,10 +23,10 @@
23
23
  * Usage:
24
24
  * ./scripts-run src/scripts/lint_agent_security [--sarif artifacts/agent-security.sarif]
25
25
  */
26
- import { spawnSync } from "node:child_process";
27
26
  import fs from "node:fs";
28
27
  import path from "node:path";
29
28
  import { fileURLToPath, pathToFileURL } from "node:url";
29
+ import { runCountedProbe } from "./_lib/counted_probe.js";
30
30
 
31
31
  const _HERE = path.dirname(fileURLToPath(import.meta.url));
32
32
 
@@ -54,9 +54,10 @@ function _run(script: string): [number, Finding[]] {
54
54
  // (capture_output=True, text=True equivalent). Mirrors the retired Python implementation's
55
55
  // `subprocess.run([sys.executable, HERE/<child>.py, "--json"])`, now that the
56
56
  // children are TypeScript and the `.py` originals are deleted.
57
- const proc = spawnSync(tsxBin, [path.join(_HERE, script), "--json"], {
58
- encoding: "utf-8",
59
- });
57
+ // Bounded read: a truncated `--json` payload would fail to parse and be
58
+ // swallowed as zero findings — a security aggregate reporting clean because
59
+ // it lost the answer. `runCountedProbe` throws on overflow instead.
60
+ const proc = runCountedProbe(tsxBin, [path.join(_HERE, script), "--json"]);
60
61
  let findings: Finding[];
61
62
  try {
62
63
  const parsed: unknown = JSON.parse((proc.stdout ?? "") || "[]");
@@ -93,8 +93,8 @@
93
93
  {
94
94
  "file": "src/skills/supply-chain-intake/SKILL.md",
95
95
  "lines": [
96
- 70,
97
- 72
96
+ 80,
97
+ 82
98
98
  ],
99
99
  "reason": "Per-ecosystem check commands in fenced blocks; the pip/composer siblings sit farther apart than the ±10-line window — genuine multi-ecosystem documentation."
100
100
  },
@@ -64,6 +64,65 @@ const STATUS_ORDER: Record<string, number> = { pass: 0, pass_with_warnings: 1, f
64
64
  * baseline worktree-add failure (→ exit 2 in main). */
65
65
  class CalledProcessError extends Error {}
66
66
 
67
+ /** The baseline ran but produced nothing usable (→ exit 2 in main).
68
+ *
69
+ * Distinct from the two DOCUMENTED empty-baseline degradations (no linter file
70
+ * in the ref; a pre-migration `.py` baseline with no python3). Those are
71
+ * deliberate. This one is a broken run, and returning an empty baseline for it
72
+ * silently reclassifies every existing finding as a "new file" — the gate then
73
+ * fires on an unmodified tree and can never report a real regression, because a
74
+ * regression needs the file present in both maps. */
75
+ class BaselineCollectionError extends Error {}
76
+
77
+ /** Give the detached baseline worktree a resolvable module context.
78
+ *
79
+ * `git worktree add` checks out tracked files only, so the temp tree has no
80
+ * `node_modules` and the linter dies on its first bare import. Symlinking the
81
+ * repo's own install is enough — the baseline runs the ref's linter source
82
+ * against the ref's artefacts; only the dependency resolution is borrowed. */
83
+ function _link_node_modules(tmpdir: string, repo_root: string): void {
84
+ const target = path.join(repo_root, 'node_modules');
85
+ const link = path.join(tmpdir, 'node_modules');
86
+ if (fs.existsSync(link) || !fs.existsSync(target)) {
87
+ return;
88
+ }
89
+ try {
90
+ fs.symlinkSync(target, link, 'dir');
91
+ } catch {
92
+ // Non-fatal here: a failed link surfaces as a failed baseline run
93
+ // below, which raises with the real cause rather than degrading.
94
+ }
95
+ }
96
+
97
+ /** Re-root the baseline's file keys onto the repo-relative paths the working-tree
98
+ * run emits.
99
+ *
100
+ * The baseline runs with `--repo-root <tmpdir>` and reports absolute paths under
101
+ * that temp worktree; the working-tree run reports paths relative to the repo.
102
+ * Left as-is the two maps share no key, so every comparison is vacuous — the
103
+ * disjoint guard would reject the run outright once the baseline is non-empty. */
104
+ function _relativise(data: LinterJson, tmpdir: string): LinterJson {
105
+ const roots = new Set<string>([tmpdir]);
106
+ try {
107
+ roots.add(fs.realpathSync(tmpdir));
108
+ } catch {
109
+ /* the temp dir is about to be removed; the literal prefix still applies */
110
+ }
111
+ const strip = (f: string): string => {
112
+ for (const r of roots) {
113
+ const prefix = r.endsWith(path.sep) ? r : r + path.sep;
114
+ if (f.startsWith(prefix)) {
115
+ return f.slice(prefix.length);
116
+ }
117
+ }
118
+ return f;
119
+ };
120
+ return {
121
+ ...data,
122
+ results: (data.results ?? []).map((e) => ({ ...e, file: strip(e.file) })),
123
+ };
124
+ }
125
+
67
126
  /** Run the linter and return parsed JSON. If ref is null, run on working tree.
68
127
  *
69
128
  * Mirrors `run_linter_json`: for a ref, add a detached temp worktree, run the
@@ -91,6 +150,7 @@ function run_linter_json(ref: string | null, repo_root: string): LinterJson {
91
150
  );
92
151
  }
93
152
  try {
153
+ _link_node_modules(tmpdir, repo_root);
94
154
  // The baseline ref may be pre-migration (`.py`, run via python3) or
95
155
  // post-migration (`.ts`, run via tsx). Probe both extensions in both
96
156
  // locations and spawn by extension. A historical `.py` ref genuinely
@@ -139,7 +199,17 @@ function run_linter_json(ref: string | null, repo_root: string): LinterJson {
139
199
  encoding: 'utf8',
140
200
  });
141
201
  const out = result.stdout ?? '';
142
- return out.trim() ? (JSON.parse(out) as LinterJson) : { results: [], summary: {} };
202
+ if (!out.trim()) {
203
+ const why =
204
+ result.status === 0
205
+ ? 'it exited 0 but printed nothing'
206
+ : `it exited ${result.status ?? 'null'}`;
207
+ throw new BaselineCollectionError(
208
+ `the baseline linter for '${ref}' produced no output — ${why}.\n` +
209
+ `stderr: ${(result.stderr ?? '').trim().split('\n').slice(-6).join('\n')}`,
210
+ );
211
+ }
212
+ return _relativise(JSON.parse(out) as LinterJson, tmpdir);
143
213
  } finally {
144
214
  spawnSync('git', ['-C', repo_root, 'worktree', 'remove', '--force', tmpdir], {
145
215
  encoding: 'utf8',
@@ -433,6 +503,10 @@ function main(): number {
433
503
  );
434
504
  return 2;
435
505
  }
506
+ if (e instanceof BaselineCollectionError) {
507
+ process.stderr.write(`Error: ${e.message}\n`);
508
+ return 2;
509
+ }
436
510
  throw e;
437
511
  }
438
512
 
@@ -454,6 +528,22 @@ function main(): number {
454
528
  return 2;
455
529
  }
456
530
 
531
+ // The same guard's blind spot: an EMPTY baseline against a populated
532
+ // current tree is not comparable either, and it is the worse failure —
533
+ // every existing finding is then reported as a "new file", so the gate
534
+ // fires on an unmodified tree and no real regression can ever surface.
535
+ // The documented empty-baseline degradations (no linter in the ref; a
536
+ // pre-migration `.py` baseline with no python3) print their own warning
537
+ // above and land here on purpose.
538
+ if (!baseKeys.length && currKeys.length) {
539
+ process.stderr.write(
540
+ `Error: the baseline '${args.baseline}' produced no lint results while the ` +
541
+ `current tree produced ${currKeys.length} — runs are not comparable; ` +
542
+ 'refusing to report. Every finding would be misreported as new.\n',
543
+ );
544
+ return 2;
545
+ }
546
+
457
547
  const delta = compare(baseline_map, current_map);
458
548
 
459
549
  if (args.format === 'json') {
@@ -528,4 +618,5 @@ export {
528
618
  format_text,
529
619
  format_markdown,
530
620
  main,
621
+ _relativise,
531
622
  };
@@ -18,11 +18,32 @@
18
18
  * (em-dash / colon / comma list), no quoted example,
19
19
  * no imperative-verb lead. A bare topic restatement.
20
20
  *
21
- * (d) is signal-ABSENCE, not a phrase mandate: an imperative house-style
21
+ * (a)–(d) are signal-ABSENCE checks over every skill: an imperative house-style
22
22
  * description ("Writes Laravel PHP — Eloquent, Artisan …") carries routing
23
23
  * signal and passes. The farm-generator's name-echo frontmatter is the
24
24
  * must-fail specimen.
25
25
  *
26
+ * Two further checks are POSITIVE — they demand something be present — and are
27
+ * therefore scoped to CLUSTERED skills only (road-to-overlap-truth-and-skill-cut
28
+ * Phase 5):
29
+ *
30
+ * (e) clustered-no-sibling-routing — the canonical overlap tool pairs this
31
+ * skill with a same-pack sibling at >= the canonical
32
+ * threshold, and the description never names that
33
+ * sibling. Two skills the router cannot tell apart,
34
+ * neither of which says which one loses.
35
+ * (f) clustered-no-quoted-phrase — same cluster condition, and the description
36
+ * carries no quoted literal user phrase. In a cluster,
37
+ * the user's own words are the only tie-break the
38
+ * router gets.
39
+ *
40
+ * The cluster condition is what keeps these honest. A quoted-phrase mandate
41
+ * across all ~287 skills is noise, not routing — it would fire on skills with no
42
+ * neighbour to be confused with. Today the cluster set is EMPTY (the corpus has
43
+ * no same-pack pair at or above threshold), so (e) and (f) are dormant by
44
+ * construction and wake up exactly when a new skill lands close to an existing
45
+ * one. That is the point.
46
+ *
26
47
  * Deterministic, static-config only. Allowlist mirrors lint_skill_originality:
27
48
  * `lint_skill_descriptions_allowlist.json`, capped at 20 (autonomous-execution
28
49
  * allowlist-growth antipattern — over-cap means the linter is wrong).
@@ -39,6 +60,7 @@ import * as path from 'node:path';
39
60
  import process from 'node:process';
40
61
  import { fileURLToPath } from 'node:url';
41
62
 
63
+ import { OVERLAP_THRESHOLD, _cosine, collect } from './audit_skill_overlap.js';
42
64
  import { parse_frontmatter } from './skill_overlap.js';
43
65
 
44
66
  const _HERE = fileURLToPath(import.meta.url);
@@ -57,10 +79,54 @@ const IMPERATIVE_LEAD_RE =
57
79
 
58
80
  interface Violation {
59
81
  slug: string;
60
- code: 'desc-equals-name' | 'duplicated-trigger' | 'triggers-are-name' | 'no-routing-signal';
82
+ code:
83
+ | 'desc-equals-name'
84
+ | 'duplicated-trigger'
85
+ | 'triggers-are-name'
86
+ | 'no-routing-signal'
87
+ | 'clustered-no-sibling-routing'
88
+ | 'clustered-no-quoted-phrase';
61
89
  detail: string;
62
90
  }
63
91
 
92
+ /** A quoted literal phrase — `'expand this scene'` / `"film-grade scene"`. */
93
+ const QUOTED_PHRASE_RE = /['"`][^'"`]{3,}['"`]/;
94
+
95
+ /**
96
+ * Same-pack neighbours at or above the canonical overlap threshold, keyed by
97
+ * directory slug.
98
+ *
99
+ * Uses the canonical instrument, not a second implementation — one metric, one
100
+ * threshold, one answer. Cross-pack pairs are excluded for the same reason a
101
+ * cross-pack merge is out of scope: they do not compete for the same install.
102
+ */
103
+ function computeClusters(root: string, threshold: number = OVERLAP_THRESHOLD): Map<string, string[]> {
104
+ const clusters = new Map<string, string[]>();
105
+ const skills = collect(root).map((s) => ({
106
+ slug: path.basename(path.dirname(s.relpath)),
107
+ packs: s.packs,
108
+ vector: s.vector,
109
+ }));
110
+ for (let i = 0; i < skills.length; i += 1) {
111
+ for (let j = i + 1; j < skills.length; j += 1) {
112
+ const a = skills[i]!;
113
+ const b = skills[j]!;
114
+ let sharesPack = false;
115
+ for (const p of a.packs) {
116
+ if (b.packs.has(p)) {
117
+ sharesPack = true;
118
+ break;
119
+ }
120
+ }
121
+ if (!sharesPack) continue;
122
+ if (_cosine(a.vector, b.vector) < threshold) continue;
123
+ clusters.set(a.slug, [...(clusters.get(a.slug) ?? []), b.slug]);
124
+ clusters.set(b.slug, [...(clusters.get(b.slug) ?? []), a.slug]);
125
+ }
126
+ }
127
+ return clusters;
128
+ }
129
+
64
130
  /** A description carries routing signal if it names a condition, leads with an
65
131
  * imperative verb, enumerates specifics (em-dash / colon / ≥2 comma segments),
66
132
  * or embeds an example query (a quoted phrase). Absence of all four is the defect. */
@@ -89,7 +155,7 @@ function triggerPhrases(desc: string): string[] {
89
155
  .filter((p) => p.length >= 3);
90
156
  }
91
157
 
92
- function analyseSkill(slug: string, fm: Record<string, string>): Violation[] {
158
+ function analyseSkill(slug: string, fm: Record<string, string>, siblings: readonly string[] = []): Violation[] {
93
159
  const out: Violation[] = [];
94
160
  const name = fm['name'] ?? slug;
95
161
  const desc = (fm['description'] ?? '').trim();
@@ -137,6 +203,25 @@ function analyseSkill(slug: string, fm: Record<string, string>): Violation[] {
137
203
  });
138
204
  }
139
205
 
206
+ // (e) + (f) fire ONLY for skills the canonical tool put in a cluster.
207
+ if (siblings.length > 0) {
208
+ const unrouted = siblings.filter((s) => !desc.includes(s));
209
+ if (unrouted.length > 0) {
210
+ out.push({
211
+ slug,
212
+ code: 'clustered-no-sibling-routing',
213
+ detail: `overlaps ${unrouted.map((s) => `\`${s}\``).join(', ')} at or above the canonical threshold, but the description never names ${unrouted.length === 1 ? 'it' : 'them'} — add a sibling-routing clause so the router knows which one loses`,
214
+ });
215
+ }
216
+ if (!QUOTED_PHRASE_RE.test(desc)) {
217
+ out.push({
218
+ slug,
219
+ code: 'clustered-no-quoted-phrase',
220
+ detail: `clustered with ${siblings.map((s) => `\`${s}\``).join(', ')} but carries no quoted literal user phrase — in a cluster the user's own words are the tie-break`,
221
+ });
222
+ }
223
+ }
224
+
140
225
  return out;
141
226
  }
142
227
 
@@ -192,13 +277,14 @@ function parseArgs(argv: string[]): Args {
192
277
  function main(argv: string[]): number {
193
278
  const args = parseArgs(argv);
194
279
  const allow = loadAllowlist();
280
+ const clusters = computeClusters(args.root);
195
281
  const violations: Violation[] = [];
196
282
  let scanned = 0;
197
283
  for (const md of skillMds(args.root)) {
198
284
  scanned += 1;
199
285
  const slug = path.basename(path.dirname(md));
200
286
  const [fm] = parse_frontmatter(fs.readFileSync(md, 'utf-8'));
201
- for (const v of analyseSkill(slug, fm)) {
287
+ for (const v of analyseSkill(slug, fm, clusters.get(slug) ?? [])) {
202
288
  if (!allow.has(`${v.slug}::${v.code}`)) violations.push(v);
203
289
  }
204
290
  }
@@ -216,7 +302,9 @@ function main(argv: string[]): number {
216
302
  return 1;
217
303
  }
218
304
  if (!args.quiet) {
219
- process.stdout.write(`✅ lint_skill_descriptions: ${scanned} skill(s) scanned, no description defects.\n`);
305
+ process.stdout.write(
306
+ `✅ lint_skill_descriptions: ${scanned} skill(s) scanned (${clusters.size} clustered), no description defects.\n`,
307
+ );
220
308
  }
221
309
  return 0;
222
310
  }
@@ -155,8 +155,8 @@ const _TRIGGER_HINT_LIMIT = 6;
155
155
  /** A short, comma-joined sample of the rule's trigger keywords/phrases. */
156
156
  function _trigger_hint(fm: string): string {
157
157
  const hits: string[] = [];
158
- // re.finditer(r'^\s*-\s*(?:keyword|phrase|intent):\s*"?(.+?)"?\s*$', fm, re.MULTILINE)
159
- const re = /^\s*-\s*(?:keyword|phrase|intent):\s*"?(.+?)"?\s*$/gm;
158
+ // re.finditer(r'^\s*-\s*(?:keyword|phrase):\s*"?(.+?)"?\s*$', fm, re.MULTILINE)
159
+ const re = /^\s*-\s*(?:keyword|phrase):\s*"?(.+?)"?\s*$/gm;
160
160
  let m: RegExpExecArray | null;
161
161
  while ((m = re.exec(fm)) !== null) {
162
162
  hits.push((m[1] as string).trim());