@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
@@ -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' });
@@ -30,7 +30,6 @@
30
30
  * 2 = errors
31
31
  * 3 = internal error / structural malice
32
32
  */
33
- import { spawnSync } from 'node:child_process';
34
33
  import * as fs from 'node:fs';
35
34
  import * as path from 'node:path';
36
35
  import process from 'node:process';
@@ -44,6 +43,9 @@ import {
44
43
  type YamlValue,
45
44
  } from './validate_frontmatter.js';
46
45
  import { artefact_roots, resolve_logical } from './_lib/agent_src.js';
46
+ import { ProbeOverflowError, runCountedProbe } from './_lib/counted_probe.js';
47
+ import { resolveContentLintScope } from './_lib/release_scope.js';
48
+ import { DeadScopeError, assertScanned } from './_lib/scan_scope.js';
47
49
 
48
50
  // __file__ equivalent for repo-root derivation (mirrors Python
49
51
  // `Path(__file__).resolve().parent.parent.parent` = <repo>/src/scripts → repo).
@@ -166,10 +168,10 @@ const DOUBLE_BLANK_PATTERN = /\n{3,}/;
166
168
  const VALID_RULE_TYPES = new Set(['always', 'auto', 'manual']);
167
169
  const VALID_RULE_SOURCES = new Set(['package', 'project']);
168
170
 
171
+ // `intent` was removed — see compile_router.ts's copy of this allowlist.
169
172
  const ROUTER_ALLOWED_TRIGGER_KEYS = new Set([
170
173
  'keyword',
171
174
  'phrase',
172
- 'intent',
173
175
  'file_pattern',
174
176
  'path_prefix',
175
177
  'command',
@@ -2428,20 +2430,58 @@ export function gather_candidate_files_under(srcRoot: string): string[] {
2428
2430
  return sortedUnique(out);
2429
2431
  }
2430
2432
 
2431
- export function gather_changed_candidate_files(root: string): string[] {
2433
+ /**
2434
+ * The scope `--changed` resolved, for the caller to report.
2435
+ *
2436
+ * Exported alongside the file list because a release PR silently widening its
2437
+ * own scope would be as opaque as the blindness it fixes — the caller prints
2438
+ * this line.
2439
+ */
2440
+ export let last_changed_scope_reason = '';
2441
+
2442
+ /**
2443
+ * Whether the base-span diff (`base...HEAD`) itself ran cleanly.
2444
+ *
2445
+ * The verified-empty release outcome (see `main`) may only rest on an empty
2446
+ * change set that was actually COMPUTED — a base diff that errored (missing
2447
+ * tag object, corrupt ref) degrades to the worktree fallbacks and would
2448
+ * otherwise be indistinguishable from a genuinely empty span.
2449
+ */
2450
+ export let last_changed_base_diff_ok = false;
2451
+
2452
+ /**
2453
+ * Candidate files changed against the resolved base.
2454
+ *
2455
+ * The base is release-aware (`_lib/release_scope.ts`): on a release PR it
2456
+ * widens to `previous_release_tag...HEAD`, because a release PR's own diff is
2457
+ * the version bump and the changelog — everything substantive landed in the
2458
+ * commits before the cut. Scoped to the PR's own file list this returned
2459
+ * nothing on a 179-commit release, and the gate reported
2460
+ * "0 skills checked, INCONCLUSIVE" and passed.
2461
+ *
2462
+ * `since` pins the base explicitly and wins over detection.
2463
+ */
2464
+ export function gather_changed_candidate_files(root: string, since?: string | null): string[] {
2465
+ const scope = resolveContentLintScope({ cwd: root, since: since ?? null });
2466
+ last_changed_scope_reason = scope.reason;
2467
+ last_changed_base_diff_ok = false;
2432
2468
  const diffCommands = [
2433
- ['git', 'diff', '--name-only', 'origin/main...HEAD'],
2469
+ ['git', 'diff', '--name-only', `${scope.base}...HEAD`],
2434
2470
  ['git', 'diff', '--name-only', '--cached', 'HEAD'],
2435
2471
  ['git', 'diff', '--name-only', 'HEAD'],
2436
2472
  ];
2437
2473
  try {
2438
2474
  let rawLines: string[] = [];
2475
+ let first = true;
2439
2476
  for (const cmd of diffCommands) {
2440
- const result = spawnSync(cmd[0] as string, cmd.slice(1), {
2441
- cwd: root,
2442
- encoding: 'utf-8',
2443
- });
2444
- if (result.status === 0 && (result.stdout ?? '').trim()) {
2477
+ // A truncated diff would silently shrink the change set — the same
2478
+ // degrade-to-zero shape this roadmap closes elsewhere.
2479
+ const result = runCountedProbe(cmd[0] as string, cmd.slice(1), { cwd: root });
2480
+ if (first) {
2481
+ last_changed_base_diff_ok = result.ok;
2482
+ first = false;
2483
+ }
2484
+ if (result.ok && result.stdout.trim()) {
2445
2485
  rawLines = splitlines(result.stdout);
2446
2486
  break;
2447
2487
  }
@@ -2479,7 +2519,15 @@ export function gather_changed_candidate_files(root: string): string[] {
2479
2519
  }
2480
2520
  }
2481
2521
  return sortedUnique(files);
2482
- } catch {
2522
+ } catch (exc) {
2523
+ // An overflowed diff is not an empty diff. Everything else (a shallow
2524
+ // clone with no `origin/main`, a detached worktree) legitimately has no
2525
+ // resolvable change set and keeps the historical empty-list degrade.
2526
+ if (exc instanceof ProbeOverflowError) {
2527
+ throw exc;
2528
+ }
2529
+ last_changed_scope_reason =
2530
+ `change set unresolvable (${exc instanceof Error ? exc.message : String(exc)})`;
2483
2531
  return [];
2484
2532
  }
2485
2533
  }
@@ -4082,6 +4130,8 @@ interface Args {
4082
4130
  report: boolean;
4083
4131
  repoRoot: string;
4084
4132
  quiet: boolean;
4133
+ /** Explicit diff base for `--changed`; wins over release detection. */
4134
+ since: string | null;
4085
4135
  }
4086
4136
 
4087
4137
  export function parse_args(argv: string[]): Args {
@@ -4095,6 +4145,7 @@ export function parse_args(argv: string[]): Args {
4095
4145
  condensationQuality: false,
4096
4146
  strictWarnings: false,
4097
4147
  report: false,
4148
+ since: null,
4098
4149
  repoRoot: '.',
4099
4150
  quiet: false,
4100
4151
  };
@@ -4104,6 +4155,15 @@ export function parse_args(argv: string[]): Args {
4104
4155
  args.all = true;
4105
4156
  } else if (a === '--changed') {
4106
4157
  args.changed = true;
4158
+ } else if (a === '--since') {
4159
+ const v = argv[i + 1];
4160
+ i += 1;
4161
+ if (v === undefined || v.startsWith('--')) {
4162
+ throw new ArgError('argument --since: expected a git ref');
4163
+ }
4164
+ args.since = v;
4165
+ } else if (a.startsWith('--since=')) {
4166
+ args.since = a.slice('--since='.length);
4107
4167
  } else if (a === '--format') {
4108
4168
  const v = argv[i + 1];
4109
4169
  i += 1;
@@ -4143,6 +4203,58 @@ export function parse_args(argv: string[]): Args {
4143
4203
 
4144
4204
  class ArgError extends Error {}
4145
4205
 
4206
+ /**
4207
+ * True when an empty change set is a defect rather than a legitimate scope.
4208
+ *
4209
+ * Only on a release PR whose scope actually widened to a tag span: a release
4210
+ * that changed no skill and no rule is possible in principle, but a *resolved*
4211
+ * span that matched nothing is the "0 skills checked, INCONCLUSIVE" shape this
4212
+ * gate exists to stop. A release whose previous tag could not be resolved is
4213
+ * excluded — that scope never widened, so failing it would punish a shallow
4214
+ * clone for the wrong reason.
4215
+ */
4216
+ function _release_scope_requires_findings(root: string, since?: string | null): boolean {
4217
+ const scope = resolveContentLintScope({ cwd: root, since: since ?? null });
4218
+ return scope.isRelease && scope.previousTag !== null;
4219
+ }
4220
+
4221
+ /**
4222
+ * True when the `--changed` corpus filter still matches tracked files at HEAD.
4223
+ *
4224
+ * The discriminator between a DEAD scope and a VERIFIED-EMPTY release span.
4225
+ * A release that touches no skill/rule file is a legitimate outcome — but only
4226
+ * provably so when the exact predicate `gather_changed_candidate_files`
4227
+ * applies to diff lines demonstrably still sees the corpus. Probing
4228
+ * `git ls-files` with that same predicate means a moved projection root
4229
+ * (the ADR-051 blindness class) fails this probe exactly as it would blind
4230
+ * the diff — so "empty because the root moved" cannot masquerade as
4231
+ * "empty because nothing changed".
4232
+ */
4233
+ function _changed_filter_matches_head(root: string): boolean {
4234
+ // Probes the canonical projection root only (ADR-051): a corpus that
4235
+ // exists solely under a legacy path counts as dead, not alive.
4236
+ const result = runCountedProbe('git', ['ls-files', '--', 'dist/agent-src'], { cwd: root });
4237
+ if (!result.ok) {
4238
+ return false;
4239
+ }
4240
+ for (const raw of splitlines(result.stdout)) {
4241
+ const norm = raw.trim().replace(/\\/g, '/');
4242
+ if (!norm || norm.includes('/evals/')) {
4243
+ continue;
4244
+ }
4245
+ if (!norm.startsWith('dist/agent-src/') && !norm.includes('/dist/agent-src/')) {
4246
+ continue;
4247
+ }
4248
+ if (
4249
+ norm.endsWith('/SKILL.md') ||
4250
+ (norm.endsWith('.md') && (norm.includes('/rules/') || norm.includes('/commands/')))
4251
+ ) {
4252
+ return true;
4253
+ }
4254
+ }
4255
+ return false;
4256
+ }
4257
+
4146
4258
  export function main(argv: string[]): number {
4147
4259
  let args: Args;
4148
4260
  try {
@@ -4162,7 +4274,8 @@ export function main(argv: string[]): number {
4162
4274
  paths.push(...gather_all_candidate_files(root));
4163
4275
  }
4164
4276
  if (args.changed) {
4165
- paths.push(...gather_changed_candidate_files(root));
4277
+ paths.push(...gather_changed_candidate_files(root, args.since));
4278
+ process.stderr.write(`skill_linter --changed: ${last_changed_scope_reason}\n`);
4166
4279
  }
4167
4280
  for (const raw of args.paths) {
4168
4281
  const p = path.isAbsolute(raw) ? raw : path.resolve(root, raw);
@@ -4183,6 +4296,42 @@ export function main(argv: string[]): number {
4183
4296
  } else if (args.format === 'json') {
4184
4297
  process.stdout.write(`${format_json([])}\n`);
4185
4298
  }
4299
+ // A release PR that resolved a real tag span and still matched
4300
+ // nothing needs a discriminator: DEAD scope (the diff errored, or
4301
+ // the corpus filter no longer matches anything at HEAD — the
4302
+ // ADR-051 moved-root blindness) fails; VERIFIED-EMPTY (the base
4303
+ // diff ran cleanly AND the filter provably still sees tracked
4304
+ // corpus files) is a legitimate release that simply changed no
4305
+ // skill/rule — failing it would red every scripts-/docs-only
4306
+ // release forever. Mirrors the scan-scope regime
4307
+ // (`_lib/scan_scope.ts`; exit 2, as `check_iron_law_prominence`).
4308
+ // Ordinary PRs keep the historical exit 0: a change set with no
4309
+ // skill or rule in it is a legitimate empty scope, not a dead one.
4310
+ if (args.changed && _release_scope_requires_findings(root, args.since)) {
4311
+ if (last_changed_base_diff_ok && _changed_filter_matches_head(root)) {
4312
+ process.stderr.write(
4313
+ '✅ verified-empty release scope: the base-span diff ran cleanly and ' +
4314
+ 'the corpus filter still matches tracked files at HEAD — this ' +
4315
+ 'release genuinely changes no skill/rule file. The gate is alive, ' +
4316
+ 'not blind.\n',
4317
+ );
4318
+ } else {
4319
+ try {
4320
+ assertScanned({
4321
+ gate: 'skill_linter --changed (release PR)',
4322
+ scanned: 0,
4323
+ units: 'skill/rule files',
4324
+ roots: [last_changed_scope_reason],
4325
+ });
4326
+ } catch (exc) {
4327
+ if (exc instanceof DeadScopeError) {
4328
+ process.stderr.write(`❌ ${exc.message}\n`);
4329
+ return 2;
4330
+ }
4331
+ throw exc;
4332
+ }
4333
+ }
4334
+ }
4186
4335
  process.stderr.write('No matching skill/rule files found.\n');
4187
4336
  return 0;
4188
4337
  }