@event4u/agent-config 9.12.0 → 9.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +80 -256
  3. package/README.md +1 -1
  4. package/dist/agent-src/rules/active-remediation.md +0 -2
  5. package/dist/agent-src/rules/architecture.md +0 -1
  6. package/dist/agent-src/rules/artifact-drafting-protocol.md +0 -4
  7. package/dist/agent-src/rules/autonomous-execution.md +1 -2
  8. package/dist/agent-src/rules/broken-access-control.md +0 -2
  9. package/dist/agent-src/rules/code-comment-discipline.md +0 -3
  10. package/dist/agent-src/rules/code-provenance.md +0 -3
  11. package/dist/agent-src/rules/communication-through-line.md +2 -3
  12. package/dist/agent-src/rules/content-quoting-floor.md +0 -2
  13. package/dist/agent-src/rules/context-hygiene.md +1 -3
  14. package/dist/agent-src/rules/decision-revisit-gate.md +0 -2
  15. package/dist/agent-src/rules/design-fidelity.md +29 -0
  16. package/dist/agent-src/rules/domain-adoption-policy.md +0 -3
  17. package/dist/agent-src/rules/downstream-changes.md +10 -1
  18. package/dist/agent-src/rules/external-reference-deep-dive.md +0 -7
  19. package/dist/agent-src/rules/fast-path-marker-visibility.md +0 -1
  20. package/dist/agent-src/rules/git-history-discipline.md +0 -7
  21. package/dist/agent-src/rules/guidelines.md +0 -2
  22. package/dist/agent-src/rules/improve-before-implement.md +4 -2
  23. package/dist/agent-src/rules/invite-challenge.md +0 -4
  24. package/dist/agent-src/rules/linked-projects-onboarding-gate.md +0 -2
  25. package/dist/agent-src/rules/markdown-safe-codeblocks.md +0 -1
  26. package/dist/agent-src/rules/minimal-safe-diff.md +2 -2
  27. package/dist/agent-src/rules/missing-tool-handling.md +0 -1
  28. package/dist/agent-src/rules/no-attribution-footers.md +0 -3
  29. package/dist/agent-src/rules/no-decorative-emojis-in-git-surfaces.md +8 -5
  30. package/dist/agent-src/rules/no-pr-progress-comments.md +1 -3
  31. package/dist/agent-src/rules/no-roadmap-references.md +0 -2
  32. package/dist/agent-src/rules/output-discipline.md +0 -1
  33. package/dist/agent-src/rules/prefer-enums-over-literals.md +0 -2
  34. package/dist/agent-src/rules/preservation-guard.md +0 -3
  35. package/dist/agent-src/rules/role-mode-adherence.md +0 -1
  36. package/dist/agent-src/rules/secret-vcs-guard.md +0 -2
  37. package/dist/agent-src/rules/senior-engineering-discipline.md +0 -2
  38. package/dist/agent-src/rules/session-canary.md +0 -2
  39. package/dist/agent-src/rules/size-enforcement.md +0 -5
  40. package/dist/agent-src/rules/source-confidentiality.md +0 -3
  41. package/dist/agent-src/rules/source-discovery-gate.md +0 -2
  42. package/dist/agent-src/rules/think-before-action.md +3 -3
  43. package/dist/agent-src/rules/token-budget-discipline.md +0 -1
  44. package/dist/agent-src/rules/token-efficiency.md +1 -2
  45. package/dist/agent-src/rules/tool-safety.md +0 -1
  46. package/dist/agent-src/rules/user-interaction.md +0 -3
  47. package/dist/agent-src/rules/user-interrupt-priority.md +0 -1
  48. package/dist/agent-src/skills/agent-docs-writing/SKILL.md +8 -0
  49. package/dist/agent-src/skills/analysis-skill-router/SKILL.md +55 -86
  50. package/dist/agent-src/skills/character-consistency/SKILL.md +7 -4
  51. package/dist/agent-src/skills/decision-record/SKILL.md +8 -0
  52. package/dist/agent-src/skills/design-review/SKILL.md +11 -0
  53. package/dist/agent-src/skills/design-system-capture/reference/design-system-json.md +8 -0
  54. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +34 -3
  55. package/dist/agent-src/skills/fe-design/SKILL.md +10 -0
  56. package/dist/agent-src/skills/motion-choreographer/SKILL.md +7 -4
  57. package/dist/agent-src/skills/overbuild-review-lens/SKILL.md +201 -0
  58. package/dist/agent-src/skills/performance/SKILL.md +14 -0
  59. package/dist/agent-src/skills/pixar-storyteller/SKILL.md +8 -5
  60. package/dist/agent-src/skills/scene-expander/SKILL.md +9 -6
  61. package/dist/agent-src/skills/supply-chain-intake/SKILL.md +10 -0
  62. package/dist/agent-src/skills/universal-project-analysis/SKILL.md +14 -37
  63. package/dist/agent-src/skills/video-director/SKILL.md +9 -6
  64. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  65. package/dist/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +154 -13
  66. package/dist/agent-src/templates/scripts/work_engine/directives/ui/design.ts +118 -0
  67. package/dist/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +40 -0
  68. package/dist/agent-src/templates/scripts/work_engine/state.ts +55 -0
  69. package/dist/discovery/deprecation-report.md +1 -1
  70. package/dist/discovery/discovery-manifest.json +89 -67
  71. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  72. package/dist/discovery/discovery-manifest.summary.md +3 -3
  73. package/dist/discovery/orphan-report.md +1 -1
  74. package/dist/discovery/packs.json +6 -5
  75. package/dist/discovery/trust-report.md +2 -2
  76. package/dist/discovery/workspaces.json +5 -4
  77. package/dist/hooks/dispatch.js +174 -13
  78. package/dist/mcp/registry-manifest.json +3 -3
  79. package/dist/mcp/server.json +2 -2
  80. package/dist/mcp/server.mjs +750 -507
  81. package/dist/router.json +1 -1
  82. package/dist/scripts/condense.js +2 -2
  83. package/docs/MIGRATION.md +1 -1
  84. package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +71 -0
  85. package/docs/guidelines/agent-infra/minimal-safe-diff-mechanics.md +24 -1
  86. package/docs/guidelines/code-clarity.md +9 -2
  87. package/docs/guidelines/design-fidelity-mechanics.md +44 -0
  88. package/llms.txt +63 -27
  89. package/package.json +4 -4
  90. package/src/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +154 -13
  91. package/src/agent-src/templates/scripts/work_engine/directives/ui/design.ts +118 -0
  92. package/src/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +40 -0
  93. package/src/agent-src/templates/scripts/work_engine/state.ts +55 -0
  94. package/src/config/agent-settings.template.yml +8 -0
  95. package/src/scripts/_lib/counted_probe.ts +142 -0
  96. package/src/scripts/_lib/overbuild_lens_contract.ts +196 -0
  97. package/src/scripts/_lib/release_scope.ts +207 -0
  98. package/src/scripts/ai_council/stance_tally.ts +37 -2
  99. package/src/scripts/audit_auto_rules.ts +9 -9
  100. package/src/scripts/audit_likelihood.ts +3 -3
  101. package/src/scripts/audit_overlap.ts +2 -2
  102. package/src/scripts/audit_skill_overlap.ts +233 -95
  103. package/src/scripts/audit_skill_overlap_allowlist.json +4 -0
  104. package/src/scripts/build_proof.ts +15 -1
  105. package/src/scripts/check_dependency_floors.ts +16 -6
  106. package/src/scripts/check_gate_coverage.ts +6 -6
  107. package/src/scripts/check_kernel_rule_bundle.ts +5 -4
  108. package/src/scripts/check_token_quality_golden.ts +0 -18
  109. package/src/scripts/code_graph/loader.ts +35 -6
  110. package/src/scripts/compile_router.ts +3 -8
  111. package/src/scripts/condense.ts +2 -2
  112. package/src/scripts/explain_run.ts +0 -1
  113. package/src/scripts/generate_pack_manifests.ts +3 -20
  114. package/src/scripts/hooks/block_kernel_rule_writes.ts +145 -10
  115. package/src/scripts/hooks/block_no_verify.ts +101 -0
  116. package/src/scripts/lint_agent_security.ts +5 -4
  117. package/src/scripts/lint_framework_leakage_allowlist.json +2 -2
  118. package/src/scripts/lint_regression.ts +92 -1
  119. package/src/scripts/lint_skill_descriptions.ts +93 -5
  120. package/src/scripts/project_thin_rules.ts +2 -2
  121. package/src/scripts/release.ts +107 -3
  122. package/src/scripts/resolve_lint_scope.ts +53 -0
  123. package/src/scripts/router_telemetry.ts +0 -5
  124. package/src/scripts/runtime_dispatcher.ts +40 -0
  125. package/src/scripts/schemas/pack.schema.json +1 -5
  126. package/src/scripts/schemas/rule.schema.json +1 -2
  127. package/src/scripts/self_review_gate.ts +15 -73
  128. package/src/scripts/skill_linter.ts +93 -11
  129. package/src/scripts/sweep_dead_scan_roots.ts +550 -0
  130. package/src/scripts/trigger_coverage.ts +11 -37
@@ -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());
@@ -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
@@ -6,7 +6,7 @@
6
6
  "$comment": "Generated by scripts/generate_pack_manifests.py and validated on load by scripts/validate_pack_yaml.py. Schema introduced in road-to-6.0.0-D Phase 0 Step 2. `dependencies` is the artefact-level include graph (skills/rules the pack's commands reference); `requires` is the pack-level hard-dependency graph (must be acyclic — enforced by scripts/lint_pack_dependencies.py).",
7
7
  "type": "object",
8
8
  "additionalProperties": false,
9
- "required": ["id", "version", "artefact_count"],
9
+ "required": ["id", "artefact_count"],
10
10
  "properties": {
11
11
  "id": {
12
12
  "type": "string",
@@ -58,10 +58,6 @@
58
58
  "trust_level_default": {
59
59
  "type": "string"
60
60
  },
61
- "version": {
62
- "type": "string",
63
- "minLength": 1
64
- },
65
61
  "artefact_count": {
66
62
  "type": "integer",
67
63
  "minimum": 0
@@ -63,14 +63,13 @@
63
63
  "properties": {
64
64
  "keyword": {"type": "string"},
65
65
  "phrase": {"type": "string"},
66
- "intent": {"type": "string"},
67
66
  "file_pattern": {"type": "string"},
68
67
  "path_prefix": {"type": "string", "description": "Literal path-prefix match pattern the host evaluates against the file the agent is editing — NOT a file reference. The rewriter leaves it verbatim. Source-of-truth rules that must fire on edits under `.agent-src.uncondensed/` keep that prefix here (the prefix ban applies only to `load_context:` and body links — see road-to-path-fixes.md P2.2 / AI-Council 2026-05-06)."},
69
68
  "command": {"type": "string"},
70
69
  "reason": {"type": "string"}
71
70
  }
72
71
  },
73
- "description": "Router activation triggers (Phase 3 of road-to-kernel-and-router.md). Forbidden on kernel rules, required on non-kernel rules. Schema: docs/contracts/rule-router.md."
72
+ "description": "Router activation triggers (Phase 3 of road-to-kernel-and-router.md). Forbidden on kernel rules, required on non-kernel rules. Five match keys: keyword, phrase, file_pattern, path_prefix, command. `intent` was removed — it never auto-matched at runtime and only gave rule authors false confidence that an activation path existed; additionalProperties:false now rejects it. Schema: docs/contracts/rule-router.md."
74
73
  },
75
74
  "routes_to": {
76
75
  "type": "array",
@@ -125,79 +125,21 @@ function changedLineCount(baseRef: string, files: string[], cwd: string = REPO_R
125
125
  }
126
126
 
127
127
  // ── Release-PR detection (road-to-feedback-9.2.0-followups Phase 3) ────
128
- /**
129
- * A release PR is detected from its own packaging diff (baseRef...HEAD,
130
- * scoped to CHANGELOG.md + package.json): an added changelog heading and an
131
- * added package.json version bump, agreeing on the same version. Pure over
132
- * the patch text — no git call — so it is unit-testable with synthetic
133
- * patches. See docs/design/release-pr-review-mode.md.
134
- */
135
- export function detectReleaseVersion(patchText: string): string | null {
136
- const addedLines = patchText
137
- .split('\n')
138
- .filter((l) => l.startsWith('+') && !l.startsWith('+++'))
139
- .map((l) => l.slice(1));
140
-
141
- let changelogVersion: string | null = null;
142
- let packageVersion: string | null = null;
143
- for (const line of addedLines) {
144
- const changelogMatch = /^##\s*\[(\d+\.\d+\.\d+)\]/.exec(line);
145
- if (changelogMatch) changelogVersion = changelogMatch[1] ?? null;
146
- const packageMatch = /^\s*"version"\s*:\s*"(\d+\.\d+\.\d+)"/.exec(line);
147
- if (packageMatch) packageVersion = packageMatch[1] ?? null;
148
- }
149
- if (changelogVersion && packageVersion && changelogVersion === packageVersion) {
150
- return changelogVersion;
151
- }
152
- return null;
153
- }
154
-
155
- function releaseDetectionPatch(baseRef: string, cwd: string): string {
156
- const r = spawnSync('git', ['diff', `${baseRef}...HEAD`, '--', 'CHANGELOG.md', 'package.json'], {
157
- cwd,
158
- encoding: 'utf8',
159
- maxBuffer: 8 * 1024 * 1024,
160
- });
161
- return (r.stdout ?? '').toString();
162
- }
163
-
164
- /** Impure wrapper: collects the packaging patch via git, then detects. */
165
- export function detectReleaseVersionFromGit(baseRef: string, cwd: string = REPO_ROOT): string | null {
166
- return detectReleaseVersion(releaseDetectionPatch(baseRef, cwd));
167
- }
168
-
169
- /** `major.minor.patch`, tolerating an optional `v` prefix; null if not semver-shaped. */
170
- function parseSemver(raw: string): [number, number, number] | null {
171
- const m = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(raw.trim());
172
- if (!m) return null;
173
- return [Number(m[1]), Number(m[2]), Number(m[3])];
174
- }
175
-
176
- function semverLessThan(a: [number, number, number], b: [number, number, number]): boolean {
177
- const [aMajor, aMinor, aPatch] = a;
178
- const [bMajor, bMinor, bPatch] = b;
179
- if (aMajor !== bMajor) return aMajor < bMajor;
180
- if (aMinor !== bMinor) return aMinor < bMinor;
181
- return aPatch < bPatch;
182
- }
183
-
184
- /**
185
- * Highest semver-shaped tag strictly below `version`, or null if none. Pure
186
- * over the supplied tag list — no git call. Non-semver tags (branch-backup
187
- * names etc.) are ignored rather than throwing.
188
- */
189
- export function pickPreviousTag(version: string, tags: readonly string[]): string | null {
190
- const target = parseSemver(version);
191
- if (!target) return null;
192
- let best: { raw: string; parsed: [number, number, number] } | null = null;
193
- for (const raw of tags) {
194
- const parsed = parseSemver(raw);
195
- if (!parsed) continue;
196
- if (!semverLessThan(parsed, target)) continue;
197
- if (!best || semverLessThan(best.parsed, parsed)) best = { raw, parsed };
198
- }
199
- return best ? best.raw : null;
200
- }
128
+ //
129
+ // Moved to `_lib/release_scope.ts` so the release-aware content checks reuse
130
+ // this detector instead of re-deriving it (road-to-release-shape-honesty
131
+ // Phase 1). Imported for local use AND re-exported — a bare `export … from`
132
+ // would not bind the names in this module's scope. Public surface unchanged.
133
+ import {
134
+ detectReleaseVersionFromGit,
135
+ pickPreviousTag,
136
+ } from './_lib/release_scope.js';
137
+
138
+ export {
139
+ detectReleaseVersion,
140
+ detectReleaseVersionFromGit,
141
+ pickPreviousTag,
142
+ } from './_lib/release_scope.js';
201
143
 
202
144
  function listGitTags(cwd: string): string[] {
203
145
  const r = spawnSync('git', ['tag'], { cwd, encoding: 'utf8' });