@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
@@ -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
  }
@@ -31,6 +31,7 @@ import { fileURLToPath } from 'node:url';
31
31
  import {
32
32
  STACK_BUNDLES,
33
33
  type StackBundle,
34
+ compose_bundle,
34
35
  } from '../agent-src/templates/scripts/work_engine/directives/ui/stack_bundles.js';
35
36
 
36
37
  const QUIET = process.argv.includes('--quiet');
@@ -47,6 +48,29 @@ const STACK_NEUTRAL_PACKS: ReadonlySet<string> = new Set(['engineering-base', 'c
47
48
 
48
49
  const findings: string[] = [];
49
50
 
51
+ /**
52
+ * Axis combinations the composition must satisfy.
53
+ *
54
+ * Not exhaustive over the cross-product — that would be thousands of
55
+ * meaningless tuples. These are the shapes a real project produces, including
56
+ * the ones no hand-written lane ever covered (Nuxt over Vue, Astro, Angular,
57
+ * Livewire without Flux).
58
+ */
59
+ const _AXIS_COMBOS: ReadonlyArray<Record<string, string>> = [
60
+ { view: 'blade', reactivity: 'livewire', component_lib: 'flux', css: 'tailwind', meta: 'none' },
61
+ { view: 'blade', reactivity: 'livewire', component_lib: 'none', css: 'tailwind', meta: 'none' },
62
+ { view: 'blade', reactivity: 'alpine', component_lib: 'none', css: 'tailwind', meta: 'none' },
63
+ { view: 'blade', reactivity: 'unknown', component_lib: 'none', css: 'none', meta: 'filament' },
64
+ { view: 'jsx', reactivity: 'react', component_lib: 'radix', css: 'tailwind', meta: 'none' },
65
+ { view: 'jsx', reactivity: 'react', component_lib: 'none', css: 'tailwind', meta: 'nextjs' },
66
+ { view: 'vue-sfc', reactivity: 'vue', component_lib: 'none', css: 'none', meta: 'nuxt' },
67
+ { view: 'svelte-sfc', reactivity: 'svelte', component_lib: 'none', css: 'none', meta: 'none' },
68
+ { view: 'astro', reactivity: 'unknown', component_lib: 'none', css: 'none', meta: 'astro' },
69
+ { view: 'angular-html', reactivity: 'angular', component_lib: 'none', css: 'none', meta: 'none' },
70
+ { view: 'unknown', reactivity: 'htmx', component_lib: 'none', css: 'none', meta: 'none' },
71
+ { view: 'none', reactivity: 'none', component_lib: 'none', css: 'tailwind', meta: 'none' },
72
+ ];
73
+
50
74
  function skill_path(name: string): string {
51
75
  return path.join(SKILLS_DIR, name, 'SKILL.md');
52
76
  }
@@ -137,6 +161,13 @@ function main(): number {
137
161
  check_lane(lane, STACK_BUNDLES[lane] as StackBundle);
138
162
  }
139
163
 
164
+ // The composed shape, not just the hand-written lanes. Dispatch derives a
165
+ // bundle from axes now, so a combination nobody wrote down by hand can
166
+ // still be produced — and it has to satisfy the same two properties.
167
+ for (const combo of _AXIS_COMBOS) {
168
+ check_lane(`composed(${JSON.stringify(combo)})`, compose_bundle(combo));
169
+ }
170
+
140
171
  // A lane usable as a fallback must be pack-agnostic, else the fallback
141
172
  // itself is the thing that fails for an unpredictable consumer.
142
173
  const plain = STACK_BUNDLES['plain'];
@@ -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());
@@ -357,6 +357,109 @@ const SECTIONS: ReadonlyArray<readonly [string, string | null, readonly string[]
357
357
  ['Chores', null, ['chore']],
358
358
  ];
359
359
 
360
+ // ---------------------------------------------------------------------------
361
+ // Curated head (road-to-release-shape-honesty Phase 2)
362
+ // ---------------------------------------------------------------------------
363
+
364
+ /**
365
+ * The five head lines, in the order an operator reads them.
366
+ *
367
+ * Reviewers of 9.9.0 and 9.10.0 repeatedly could not tell, from a generated
368
+ * commit log, which entries change consumer behaviour, which need migration,
369
+ * which are internal gate repairs, and which ended as nulls. The log is a
370
+ * faithful record of *what was committed*; it is not a statement of *what
371
+ * changed for you*. The head is that statement, and it sits above the log
372
+ * rather than replacing it.
373
+ *
374
+ * Wording is taken from `docs/RELEASE_STORY_TEMPLATE.md` rather than invented,
375
+ * so the package does not end up with two competing definitions of a curated
376
+ * head.
377
+ */
378
+ const RELEASE_HEAD_SECTIONS: ReadonlyArray<string> = [
379
+ 'Behaviour changes',
380
+ 'Default changes + migration',
381
+ 'Security and correctness',
382
+ 'Honest nulls',
383
+ 'Known limitations',
384
+ ];
385
+
386
+ /**
387
+ * Cap on the head, in rendered lines.
388
+ *
389
+ * Roughly ten operator-relevant lines: past that it stops being a summary and
390
+ * becomes a second changelog. The era budget is also finite
391
+ * (`_lib/changelog_eras.ts`, 250 lines per era), so an unbounded head would
392
+ * quietly consume it.
393
+ */
394
+ export const RELEASE_HEAD_CAP_LINES = 10;
395
+
396
+ /**
397
+ * The default value of every head line.
398
+ *
399
+ * `_none_` and not a placeholder token: for most releases it is the **true**
400
+ * answer, and a release that genuinely changed no defaults should say so
401
+ * rather than carry an unfilled marker. That also keeps the skeleton free of
402
+ * the placeholder prose `output-discipline` bans — there is nothing here that
403
+ * is wrong-if-shipped, only something that is uninformative-if-unedited.
404
+ */
405
+ const HEAD_DEFAULT = '_none_';
406
+
407
+ /**
408
+ * Render the curated head. Emitted by the generator on every release so it
409
+ * cannot be forgotten; edited by the maintainer before merge.
410
+ */
411
+ export function render_release_head(
412
+ filled: Readonly<Record<string, string>> = {},
413
+ ): string[] {
414
+ const lines: string[] = [
415
+ '### Release highlights',
416
+ '',
417
+ '<!-- Curated head: fill before merge, keep it under ' +
418
+ `${RELEASE_HEAD_CAP_LINES} lines, and leave \`${HEAD_DEFAULT}\` where it is ` +
419
+ 'genuinely the answer. The generated log below is unchanged. -->',
420
+ ];
421
+ for (const label of RELEASE_HEAD_SECTIONS) {
422
+ const value = (filled[label] ?? '').trim();
423
+ lines.push(`- **${label}:** ${value === '' ? HEAD_DEFAULT : value}`);
424
+ }
425
+ return lines;
426
+ }
427
+
428
+ /**
429
+ * Drop commits whose rendered line would be identical.
430
+ *
431
+ * The generated log is keyed by SHA, but a reader is not: a cherry-pick, a
432
+ * re-land, or one change split across branches produces the same
433
+ * `scope: subject` twice with different SHAs, and the notes then say the same
434
+ * thing twice with two links. Keyed on `type + scope + subject` — the parts
435
+ * that reach the rendered line — keeping the first occurrence, so the earliest
436
+ * SHA stays the citation.
437
+ *
438
+ * Breaking commits are never folded into a non-breaking twin: `!` changes what
439
+ * the line *means*, and collapsing them would hide a breaking change behind a
440
+ * routine one.
441
+ */
442
+ export function dedupe_commit_lines(commits: readonly Commit[]): Commit[] {
443
+ const seen = new Set<string>();
444
+ const out: Commit[] = [];
445
+ for (const c of commits) {
446
+ const key = [c.breaking ? '!' : '', c.type, c.scope ?? '', c.subject].join('\x1f');
447
+ if (seen.has(key)) {
448
+ continue;
449
+ }
450
+ seen.add(key);
451
+ out.push(c);
452
+ }
453
+ return out;
454
+ }
455
+
456
+ /** Rendered length of the head, for the cap assertion. */
457
+ export function release_head_line_count(head: readonly string[]): number {
458
+ // The HTML comment is invisible to a reader, so it does not count against
459
+ // an operator-facing cap.
460
+ return head.filter((l) => !l.trimStart().startsWith('<!--')).length;
461
+ }
462
+
360
463
  const BREAKING_RE = /^([a-z]+)(\([^)]+\))?!:/;
361
464
  const CONVENTIONAL_RE = /^(?<type>[a-z]+)(?:\((?<scope>[^)]+)\))?(?<bang>!)?: (?<subject>.+)$/;
362
465
 
@@ -687,7 +790,7 @@ function render_changelog_entry(
687
790
  prev: string | null,
688
791
  commits: readonly Commit[],
689
792
  today: string,
690
- opts: { test_trend_line?: string | null } = {},
793
+ opts: { test_trend_line?: string | null; head?: Readonly<Record<string, string>> } = {},
691
794
  ): [string, string] {
692
795
  const test_trend_line = opts.test_trend_line ?? null;
693
796
  let heading: string;
@@ -706,7 +809,7 @@ function render_changelog_entry(
706
809
  }
707
810
  grouped['BREAKING CHANGES'] = [];
708
811
  const other: Commit[] = [];
709
- for (const c of commits) {
812
+ for (const c of dedupe_commit_lines(commits)) {
710
813
  if (c.breaking) {
711
814
  grouped['BREAKING CHANGES']!.push(c);
712
815
  continue;
@@ -724,7 +827,8 @@ function render_changelog_entry(
724
827
  }
725
828
  }
726
829
 
727
- const body_lines: string[] = [];
830
+ // The curated head sits above the generated log, which stays unchanged.
831
+ const body_lines: string[] = [...render_release_head(opts.head ?? {})];
728
832
  const ordered_labels = ['BREAKING CHANGES', ...SECTIONS.map(([label]) => label)];
729
833
  for (const label of ordered_labels) {
730
834
  const bucket = grouped[label] ?? [];
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Print the diff base a content check should compare HEAD against.
4
+ *
5
+ * A resolver, not a gate — it decides nothing and blocks nothing. It exists so
6
+ * the shell-side checks in `.github/workflows/skill-lint.yml` resolve their
7
+ * scope from the same source as `skill_linter --changed`, instead of hardcoding
8
+ * `origin/${base_ref}` and going blind on a release PR (the "0 changed corpus
9
+ * files, INCONCLUSIVE" outcome).
10
+ *
11
+ * Usage:
12
+ * resolve_lint_scope [--base-ref <ref>] [--json]
13
+ *
14
+ * Prints the base ref on stdout and the reason on stderr, so a caller can do
15
+ * BASE="$(./scripts-run src/scripts/resolve_lint_scope --base-ref "origin/$X")"
16
+ * and still see the explanation in the log.
17
+ *
18
+ * Exit codes: 0 always — a resolver that cannot widen reports the ordinary
19
+ * base and says why. Failing here would turn an unresolvable tag into a red
20
+ * release for the wrong reason; the emptiness decision belongs to the gate.
21
+ */
22
+ import { resolveContentLintScope } from './_lib/release_scope.js';
23
+
24
+ export function main(argv: string[]): number {
25
+ let baseRef: string | undefined;
26
+ let asJson = false;
27
+ for (let i = 0; i < argv.length; i += 1) {
28
+ const a = argv[i] as string;
29
+ if (a === '--base-ref') {
30
+ baseRef = argv[i + 1];
31
+ i += 1;
32
+ } else if (a.startsWith('--base-ref=')) {
33
+ baseRef = a.slice('--base-ref='.length);
34
+ } else if (a === '--json') {
35
+ asJson = true;
36
+ }
37
+ }
38
+
39
+ const scope = resolveContentLintScope(baseRef === undefined ? {} : { baseRef });
40
+ if (asJson) {
41
+ process.stdout.write(`${JSON.stringify(scope)}\n`);
42
+ return 0;
43
+ }
44
+ process.stderr.write(`resolve_lint_scope: ${scope.reason}\n`);
45
+ process.stdout.write(`${scope.base}\n`);
46
+ return 0;
47
+ }
48
+
49
+ const invokedDirectly =
50
+ process.argv[1] !== undefined && /resolve_lint_scope\.ts$/.test(process.argv[1]);
51
+ if (invokedDirectly) {
52
+ process.exit(main(process.argv.slice(2)));
53
+ }
@@ -24,7 +24,6 @@
24
24
  * | `keyword` | case-insensitive substring inside the prompt text |
25
25
  * | `phrase` | case-insensitive substring (multi-word) inside the prompt text |
26
26
  * | `command` | case-sensitive prefix on `command:` field (optional context) |
27
- * | `intent` | informational only — never auto-matches; counted separately |
28
27
  * | `path_prefix` | prefix match against any path in `open_files` (optional context) |
29
28
  * | `file_pattern` | fnmatch against any path in `open_files` (optional context) |
30
29
  *
@@ -227,10 +226,6 @@ export function trigger_matches(
227
226
  }
228
227
  return false;
229
228
  }
230
- if ("intent" in trigger) {
231
- // Intent triggers are informational and never auto-match.
232
- return false;
233
- }
234
229
  return false;
235
230
  }
236
231
 
@@ -34,6 +34,7 @@ import { build_registry } from './runtime_registry.js';
34
34
  import type { ExecutionResult} from './runtime_handler.js';
35
35
  import { HandlerError, execute_shell } from './runtime_handler.js';
36
36
  import { resolve_level } from './_lib/script_output.js';
37
+ import { validate_tool_declarations } from './tool_registry.js';
37
38
 
38
39
  const _HERE = fileURLToPath(import.meta.url);
39
40
 
@@ -187,6 +188,45 @@ export function dispatch(skillName: string, registry: SkillRuntime[]): DispatchR
187
188
  warnings: [],
188
189
  });
189
190
  }
191
+ // Gate integrity — the grant resolves against TRUSTED config, never
192
+ // against the skill's own frontmatter.
193
+ //
194
+ // road-to-governance-invariants Phase 4, property (b). Every field the
195
+ // three checks above read (`execution_type`, `handler`, `safety_mode`)
196
+ // comes out of the skill file's own YAML, so a skill that writes
197
+ // `safety_mode: strict` self-certifies past all of them. `tool-safety`
198
+ // states "Allowlist only — tool names must match the tool registry",
199
+ // and `validate_tool_declarations` implemented exactly that — but
200
+ // nothing called it. Measured before this branch existed: a skill
201
+ // declaring `allowed_tools: ["NotInRegistry", "Bash(*)"]` dispatched
202
+ // `ready`.
203
+ //
204
+ // Scoped to the automated path deliberately. That is where
205
+ // `runtime-safety` puts the hard requirement, and it is the path that
206
+ // would execute with no human in the loop. Assisted skills get a
207
+ // warning instead (below): blocking them would reject the scoped-grant
208
+ // syntax (`Bash(scripts-run:*)`) that `tool-safety` itself prefers and
209
+ // that this two-entry registry does not model.
210
+ const toolCheck = validate_tool_declarations(skill.allowed_tools);
211
+ if (!toolCheck.valid) {
212
+ return new DispatchResult({
213
+ request: new ExecutionRequest({
214
+ skill_name: skill.name,
215
+ execution_type: skill.execution_type,
216
+ handler: skill.handler,
217
+ timeout_seconds: skill.timeout_seconds,
218
+ safety_mode: skill.safety_mode,
219
+ allowed_tools: skill.allowed_tools,
220
+ status: 'blocked',
221
+ reason: `Automated skill declares an untrusted tool grant — ${toolCheck.errors.join('; ')}`,
222
+ }),
223
+ warnings: [],
224
+ });
225
+ }
226
+ } else if (skill.allowed_tools.length > 0) {
227
+ // Not silently ignored: an unregistered grant on a human-confirmed
228
+ // path is surfaced as a warning rather than a block.
229
+ warnings.push(...validate_tool_declarations(skill.allowed_tools).errors);
190
230
  }
191
231
 
192
232
  // Assisted/automated skill is ready