@event4u/agent-config 14.17.0 → 14.18.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 (74) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +170 -79
  5. package/README.md +1 -1
  6. package/dist/agent-src/commands/pr/merge.md +35 -44
  7. package/dist/agent-src/skills/accessibility-auditor/SKILL.md +2 -0
  8. package/dist/agent-src/skills/ai-code-blindspots/SKILL.md +27 -1
  9. package/dist/agent-src/skills/code-intelligence/SKILL.md +60 -12
  10. package/dist/agent-src/skills/complexity-first-planning/SKILL.md +27 -0
  11. package/dist/agent-src/skills/design-review/SKILL.md +2 -0
  12. package/dist/agent-src/skills/feature-planning/SKILL.md +34 -0
  13. package/dist/agent-src/skills/git-workflow/SKILL.md +37 -12
  14. package/dist/agent-src/skills/git-workflow/references/push-closes-its-loop.md +176 -0
  15. package/dist/agent-src/skills/laravel-mail/SKILL.md +44 -0
  16. package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +40 -0
  17. package/dist/agent-src/skills/using-git-worktrees/SKILL.md +12 -0
  18. package/dist/agent-src/skills/verify-repair-loop/SKILL.md +53 -8
  19. package/dist/agent-src/skills/worktree-lifecycle/SKILL.md +3 -1
  20. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  21. package/dist/cli-delegate/cmd_conformance.js +335 -659
  22. package/dist/discovery/deprecation-report.md +1 -1
  23. package/dist/discovery/discovery-manifest.json +15 -15
  24. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  25. package/dist/discovery/discovery-manifest.summary.md +1 -1
  26. package/dist/discovery/dormancy-report.md +1 -1
  27. package/dist/discovery/orphan-report.md +1 -1
  28. package/dist/discovery/packs.json +2 -2
  29. package/dist/discovery/trust-report.md +1 -1
  30. package/dist/discovery/workspaces.json +2 -2
  31. package/dist/hooks/dispatch.js +959 -1304
  32. package/dist/mcp/registry-manifest.json +2 -2
  33. package/dist/mcp/server.json +2 -2
  34. package/dist/mcp/server.mjs +671 -1002
  35. package/dist/shared/settingsConsent.js +1 -1
  36. package/package.json +1 -1
  37. package/src/config/conformance-claim-baseline.json +9 -0
  38. package/src/config/gate-violation-baselines.json +3 -3
  39. package/src/config/release-gate-locality.yml +129 -0
  40. package/src/scripts/_lib/release_highlights.ts +172 -1
  41. package/src/scripts/_lib/release_material.ts +66 -0
  42. package/src/scripts/_lib/repeated_run.ts +77 -0
  43. package/src/scripts/_lib/tamper_vocabulary.ts +242 -0
  44. package/src/scripts/_lib/unified_diff.ts +121 -0
  45. package/src/scripts/check_finding_dispositions.ts +179 -3
  46. package/src/scripts/check_installed_hooks_fresh.ts +381 -0
  47. package/src/scripts/check_release_highlights.ts +25 -13
  48. package/src/scripts/check_suppression_hygiene.ts +11 -0
  49. package/src/scripts/code_graph/build.ts +230 -33
  50. package/src/scripts/code_graph/extract.ts +168 -33
  51. package/src/scripts/code_graph/types.ts +37 -2
  52. package/src/scripts/code_graph/validate.ts +12 -1
  53. package/src/scripts/conformance_scan.ts +2 -2
  54. package/src/scripts/detect_silent_catch.ts +403 -0
  55. package/src/scripts/detect_verification_tampering.ts +538 -0
  56. package/src/scripts/git_authorization_hook.ts +153 -17
  57. package/src/scripts/hook_manifest.json +1 -1
  58. package/src/scripts/hook_manifest.yaml +33 -19
  59. package/src/scripts/hooks/block_no_verify.ts +3 -3
  60. package/src/scripts/hooks/concern_registry.ts +2 -2
  61. package/src/scripts/hooks/evidence_independence.ts +21 -2
  62. package/src/scripts/hooks/{block_unauthorized_git.ts → git_command_classifier.ts} +25 -369
  63. package/src/scripts/hooks/push_settle_hook.ts +250 -0
  64. package/src/scripts/hooks/ship_diff_volume_hook.ts +1 -1
  65. package/src/scripts/install-hooks.sh +134 -1
  66. package/src/scripts/language_mirror_hook.ts +27 -5
  67. package/src/scripts/release.ts +31 -143
  68. package/src/scripts/release_drill.ts +71 -1
  69. package/src/scripts/release_publication.ts +243 -3
  70. package/src/scripts/release_verify.ts +220 -0
  71. package/src/scripts/schemas/review-findings.schema.json +1 -1
  72. package/src/scripts/schemas/skill.schema.json +11 -1
  73. package/src/scripts/self_review_gate.ts +10 -1
  74. package/src/shared/settingsConsent.ts +1 -1
@@ -16,12 +16,20 @@
16
16
  * import path changes.
17
17
  */
18
18
  import { spawnSync } from 'node:child_process';
19
- import { DERIVED_MARKER, publication_blockers } from './_lib/release_highlights.js';
19
+ import {
20
+ DERIVED_MARKER,
21
+ type MixObligation,
22
+ previous_release_tag,
23
+ publication_blockers,
24
+ section_publication_blockers,
25
+ } from './_lib/release_highlights.js';
26
+ import { loadTaxonomy, measureRange } from './measure_release_mix.js';
20
27
  import * as fs from 'node:fs';
21
28
  import process from 'node:process';
22
29
 
23
30
  import { gh_argv_label, gh_retry } from './_lib/gh_transient.js';
24
31
  import {
32
+ NEXT_SECTION_RE,
25
33
  extract_changelog_section,
26
34
  tag_message_from_section,
27
35
  pr_body_from_section,
@@ -37,6 +45,7 @@ import {
37
45
  SystemExitError,
38
46
  _cap_body,
39
47
  read_changelog_text,
48
+ reEscape,
40
49
  } from './release_env.js';
41
50
 
42
51
  function die(msg: string, code = 2): never {
@@ -422,6 +431,71 @@ export function _is_non_fast_forward(stderr: string, stdout: string): boolean {
422
431
  return /(non-fast-forward|fetch first|behind its remote counterpart)/i.test(text);
423
432
  }
424
433
 
434
+ /**
435
+ * The pre-push local gate run, as an argv the caller hands to `run()`.
436
+ *
437
+ * Every release-PR assertion that can run locally, run BEFORE the branch leaves
438
+ * the machine. `guard_release_branch_push` below covers the changelog-section
439
+ * obligations; this covers the rest of `release-validation.yml` — PR shape,
440
+ * template pin — from `src/config/release-gate-locality.yml`, the registry
441
+ * `tests/scripts/release_gate_locality.test.ts` keeps in step with the
442
+ * workflow. `--cheap` drops the one row that leaves the machine, so a registry
443
+ * outage cannot read as a release defect.
444
+ *
445
+ * An argv rather than a call, so the pipeline issues it through `run()` and
446
+ * `release_drill` intercepts it exactly as it intercepts `task
447
+ * release-prepare`. A real subprocess inside the simulated world would run
448
+ * gates against the live tree.
449
+ *
450
+ * Lives here rather than in `release.ts` for the reason
451
+ * `_refuse_unpublishable` already states: that file is 2000+ lines and every
452
+ * line above 1500 is charged by the source-size growth ratchet.
453
+ */
454
+ export function local_release_gate_argv(): string[] {
455
+ return ['task', 'release:verify', '--', '--cheap'];
456
+ }
457
+
458
+ /**
459
+ * Measure the governance-versus-product level for the span under release.
460
+ *
461
+ * The push guard's fourth site reads the SAME obligation the release PR reads,
462
+ * which until 2026-09-05 it did not: `publication_blockers` knew two sentinels
463
+ * and not this one, so the earliest refusal was the pull request. That gap was
464
+ * documented in `docs/contracts/CHANGELOG-conventions.md` and it is what turned
465
+ * 14.17.0 red after a branch, a PR and a CI run had already been spent.
466
+ *
467
+ * Returns `null` on any measurement failure — a shallow clone or a missing tag
468
+ * is an environment fact, and the contract is explicit that it degrades to a
469
+ * warning rather than to a refusal. `null` produces no blocker downstream.
470
+ */
471
+ export function measure_mix_obligation(
472
+ version: string,
473
+ fromRef?: string | null,
474
+ ): MixObligation | null {
475
+ try {
476
+ const from = fromRef ?? previous_release_tag('HEAD', REPO_ROOT);
477
+ if (!from) return null;
478
+ const reading = measureRange(from, 'HEAD', loadTaxonomy(), version, REPO_ROOT);
479
+ const o = reading.response_obligation;
480
+ return {
481
+ triggered: o.triggered,
482
+ level:
483
+ `governance-only ${String(o.governance_only)} vs consumer-only ` +
484
+ `${String(o.consumer_only)} (taxonomy ${reading.taxonomy_version})`,
485
+ };
486
+ } catch (err) {
487
+ // The CI side prints `⚠️ governance mix not measured`; swallowing it
488
+ // here made a broken taxonomy file disable BOTH local guards in
489
+ // silence while CI kept refusing — the exact asymmetry this change
490
+ // exists to remove, restored one layer down.
491
+ process.stderr.write(
492
+ `⚠️ governance mix not measured for ${version}: ${(err as Error).message}\n` +
493
+ ' Both local guards are inert for this obligation; the release PR still refuses.\n',
494
+ );
495
+ return null;
496
+ }
497
+ }
498
+
425
499
  /**
426
500
  * Refuse the FIRST remote state of a release whose section is not publishable.
427
501
  *
@@ -465,7 +539,12 @@ export function guard_release_branch_push(branch: string): void {
465
539
  if (!section) {
466
540
  return;
467
541
  }
468
- const blockers = publication_blockers(section.body, version, `\`${branch}\``);
542
+ const blockers = section_publication_blockers(
543
+ section.body,
544
+ version,
545
+ `\`${branch}\``,
546
+ measure_mix_obligation(version),
547
+ );
469
548
  if (blockers.length === 0) {
470
549
  return;
471
550
  }
@@ -614,7 +693,28 @@ export function guard_release_curation(version: string, prMerged = false): void
614
693
  if (!section) {
615
694
  return;
616
695
  }
617
- const blockers = publication_blockers(section.body, version, `\`release/${version}\``);
696
+ // The head-level blockers plus the mix obligation — deliberately NOT the
697
+ // tests footer.
698
+ //
699
+ // This site runs BEFORE `prepend_changelog`'s output is committed, and
700
+ // `--resume` skips the whole bump block once `package.json` already carries
701
+ // the target version. So a refusal here for a missing footer is a trap: the
702
+ // writer never gets a second chance to render it, the message forbids
703
+ // hand-writing it, and the operator is left rolling `package.json` back by
704
+ // hand. The footer is a MEASUREMENT the writer produces, not prose the
705
+ // operator can supply, which is exactly why it does not belong at the site
706
+ // that fires before the artefact exists in git.
707
+ //
708
+ // `guard_release_branch_push` carries it instead: by then the section is
709
+ // committed and editable on the branch, and nothing has left the machine.
710
+ const blockers = [
711
+ ...publication_blockers(
712
+ section.body,
713
+ version,
714
+ `\`release/${version}\``,
715
+ measure_mix_obligation(version),
716
+ ),
717
+ ];
618
718
  if (blockers.length === 0) {
619
719
  return;
620
720
  }
@@ -1017,3 +1117,143 @@ export function create_and_push_annotated_tag(version: string): void {
1017
1117
  run(['git', 'tag', '-a', version, '-m', tag_message_from_section(merged.body, version)]);
1018
1118
  _push_tag(version);
1019
1119
  }
1120
+
1121
+ // Relocated from `release.ts` on 2026-09-05, for the reason
1122
+ // `_refuse_unpublishable` above already states: that file is 2000+ lines and
1123
+ // every line above 1500 is charged by the source-size growth ratchet. The move
1124
+ // is not incidental to this change — the `Tests: N` footer became a
1125
+ // section-level publication obligation in the same diff
1126
+ // (`section_publication_blockers`), so the writer that emits it and the guard
1127
+ // that refuses its absence now sit in one module. `release.ts` re-exports all
1128
+ // four names, so every existing importer is unaffected.
1129
+
1130
+ const _TEST_COUNT_LINE_RE = /^Tests:\s+(\d+)/m;
1131
+
1132
+ /**
1133
+ * Buffer ceiling for the `vitest list` probe, in bytes.
1134
+ *
1135
+ * `spawnSync` buffers the child's whole stdout in memory and fails with
1136
+ * ENOBUFS past `maxBuffer`, whose default is 1 MiB. The probe emits one line
1137
+ * per test case, so its output grows with the suite: at 9470 cases the listing
1138
+ * is ~1.25 MB, i.e. already past the default. That is what silently dropped
1139
+ * the footer from the 9.10.0 notes and turned the `changelog-entry` gate red.
1140
+ * 64 MiB is ~50× the current listing — headroom for a suite many times this
1141
+ * size, at no cost when the output is small.
1142
+ */
1143
+ export const _TEST_LIST_MAX_BUFFER = 64 * 1024 * 1024;
1144
+
1145
+ /**
1146
+ * Count test cases from a finished `vitest list` spawn result, or return null
1147
+ * when the probe did not produce a usable listing.
1148
+ *
1149
+ * Split out from `_count_tests_current` so the failure modes are testable
1150
+ * without spawning the real (~14s, >1 MB) collection. `warn` is injected for
1151
+ * the same reason; it defaults to stderr.
1152
+ */
1153
+ export function _count_from_list_result(
1154
+ res: {
1155
+ error?: (Error & { code?: string }) | undefined;
1156
+ status: number | null;
1157
+ stdout: string | null;
1158
+ },
1159
+ warn: (msg: string) => void = (msg) => void process.stderr.write(msg),
1160
+ ): number | null {
1161
+ // The trend line is informational and never blocks a release — but a
1162
+ // SILENT drop is what shipped 9.8.0 and 9.10.0 without a footer, so every
1163
+ // degradation says why. The `changelog-entry` CI gate treats a missing
1164
+ // footer as fatal, so this warning is the difference between fixing the
1165
+ // probe now and finding out from a red release PR.
1166
+ if (res.error) {
1167
+ // ENOENT (no npx) · ETIMEDOUT · ENOBUFS (listing past maxBuffer).
1168
+ const code = res.error.code ?? res.error.message;
1169
+ warn(
1170
+ `⚠️ test-count probe failed (${code}) — the \`Tests:\` footer will ` +
1171
+ `be omitted from the release notes, which fails the ` +
1172
+ `\`CHANGELOG entry exists for head version\` gate.\n`,
1173
+ );
1174
+ return null;
1175
+ }
1176
+ if ((res.status ?? 1) !== 0) {
1177
+ warn(
1178
+ `⚠️ test-count probe exited ${res.status ?? '(null)'} — the ` +
1179
+ `\`Tests:\` footer will be omitted from the release notes.\n`,
1180
+ );
1181
+ return null;
1182
+ }
1183
+ const lines = (res.stdout ?? '').split('\n').filter((l) => l.trim().length > 0);
1184
+ if (lines.length === 0) {
1185
+ warn('⚠️ test-count probe listed 0 cases — the `Tests:` footer will be omitted.\n');
1186
+ return null;
1187
+ }
1188
+ return lines.length;
1189
+ }
1190
+
1191
+ /**
1192
+ * Return the collected vitest test-case count on the current tree
1193
+ * (`npx vitest list`, one line per case; ~14s wall). Returns null when
1194
+ * collection fails — the trend line is informational, never a release
1195
+ * blocker, but every failure warns (see `_count_from_list_result`).
1196
+ * (Replaced the dead `pytest --collect-only` probe on 2026-07-08,
1197
+ * road-to-truth-and-reference-hygiene Phase 3: the Python suite was retired
1198
+ * with ADR-200, so the old probe always degraded to null and the `Tests:`
1199
+ * footer silently vanished from release notes.)
1200
+ */
1201
+ export function _count_tests_current(): number | null {
1202
+ // Recursion/cost guard: `npx vitest list` inside a vitest-driven release
1203
+ // test would collect the whole suite from within the suite (the child
1204
+ // inherits VITEST=… from the runner) — the exact recursion the caller
1205
+ // comment warns about. Degrade to null there, same as any other
1206
+ // collection failure.
1207
+ if (process.env['VITEST'] !== undefined) {
1208
+ return null;
1209
+ }
1210
+ const res = spawnSync('npx', ['vitest', 'list'], {
1211
+ cwd: REPO_ROOT,
1212
+ encoding: 'utf-8',
1213
+ stdio: ['ignore', 'pipe', 'pipe'],
1214
+ timeout: 180_000,
1215
+ maxBuffer: _TEST_LIST_MAX_BUFFER,
1216
+ });
1217
+ return _count_from_list_result(res);
1218
+ }
1219
+
1220
+ /**
1221
+ * Read CHANGELOG.md and return the most recent `Tests: N` footer under the
1222
+ * `prev_tag` heading, or null when not found.
1223
+ */
1224
+ export function _previous_test_count_from_changelog(prev_tag: string | null): number | null {
1225
+ if (!prev_tag || !fs.existsSync(CHANGELOG)) {
1226
+ return null;
1227
+ }
1228
+ const text = fs.readFileSync(CHANGELOG, 'utf-8');
1229
+ const heading_re = new RegExp(`^##\\s+\\[?${reEscape(prev_tag)}\\b`, 'm');
1230
+ const m = heading_re.exec(text);
1231
+ if (!m) {
1232
+ return null;
1233
+ }
1234
+ const headEnd = m.index + m[0].length;
1235
+ const rest = text.slice(headEnd);
1236
+ const next_heading = NEXT_SECTION_RE.exec(rest);
1237
+ const sectionEnd = headEnd + (next_heading ? next_heading.index : rest.length);
1238
+ const section = text.slice(headEnd, sectionEnd);
1239
+ const count_match = _TEST_COUNT_LINE_RE.exec(section);
1240
+ return count_match ? Number.parseInt(count_match[1] as string, 10) : null;
1241
+ }
1242
+
1243
+ /**
1244
+ * Return the `Tests: N (+M since X.Y.Z)` footer line, or null when the current
1245
+ * count cannot be determined. Silent on collection errors.
1246
+ */
1247
+ export function _render_test_trend_line(prev_tag: string | null): string | null {
1248
+ const current = _count_tests_current();
1249
+ if (current === null) {
1250
+ return null;
1251
+ }
1252
+ const previous = _previous_test_count_from_changelog(prev_tag);
1253
+ if (previous === null || !prev_tag) {
1254
+ return `Tests: ${current}`;
1255
+ }
1256
+ const delta = current - previous;
1257
+ const sign = delta >= 0 ? '+' : '';
1258
+ return `Tests: ${current} (${sign}${delta} since ${prev_tag})`;
1259
+ }
@@ -0,0 +1,220 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Run every release-PR assertion that CAN run locally, before anything is
4
+ * pushed.
5
+ *
6
+ * `release-validation.yml` is the last gate a release passes and the most
7
+ * expensive one to fail: it runs after `gh pr create`, so a branch, a pull
8
+ * request and a CI run are already spent by the time it speaks. Twice in three
9
+ * releases it refused something reproducible locally in under two seconds —
10
+ * 14.14.0 (PR #1812) on the curated head, 14.17.0 (PR #1856) on the
11
+ * governance-mix response. In both cases the check existed, was runnable, and
12
+ * nothing ran it.
13
+ *
14
+ * This is the command that runs it. It is DERIVED from
15
+ * `src/config/release-gate-locality.yml` rather than from a hand-kept list, so
16
+ * a new release-validation job cannot be added without either giving this
17
+ * runner a command or declaring, with a classified reason, that it has none —
18
+ * `tests/scripts/release_gate_locality.test.ts` enforces that half.
19
+ *
20
+ * Deliberately NOT a gate script: it runs commands rather than inspecting a
21
+ * corpus, so it has no `scanned:` count to report and no place in
22
+ * `gate-coverage.yml`. Same shape as `release_drill`, which is also a runner
23
+ * with a task and a test and no coverage row.
24
+ *
25
+ * Usage:
26
+ * task release:verify # version from the branch, else package.json
27
+ * task release:verify -- --version 14.17.0
28
+ * task release:verify -- --list # print the plan, run nothing
29
+ */
30
+ import { spawnSync } from 'node:child_process';
31
+ import * as fs from 'node:fs';
32
+ import * as path from 'node:path';
33
+ import process from 'node:process';
34
+ import { fileURLToPath } from 'node:url';
35
+
36
+ import { parse as parseYaml } from 'yaml';
37
+
38
+ const _HERE = fileURLToPath(import.meta.url);
39
+ const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
40
+ const REGISTRY = path.join(REPO_ROOT, 'src', 'config', 'release-gate-locality.yml');
41
+
42
+ interface Row {
43
+ local: string | null;
44
+ reason?: string;
45
+ verify: boolean;
46
+ network?: boolean;
47
+ partial?: string;
48
+ release_branch_only?: boolean;
49
+ script: string | null;
50
+ guard?: string | null;
51
+ }
52
+
53
+ /**
54
+ * Collapse rows that reproduce to the SAME command.
55
+ *
56
+ * Two jobs legitimately share one local reproduction — `changelog-entry` and
57
+ * `highlight-plausibility` are both answered by `check_release_highlights` —
58
+ * and running it twice would double the cost while reporting a coverage the
59
+ * second run did not add. The ids stay joined so the output still names every
60
+ * job the command answers for.
61
+ */
62
+ function plan(rows: ReadonlyArray<readonly [string, Row]>, version: string): Array<[string, string]> {
63
+ const byCommand = new Map<string, string[]>();
64
+ for (const [id, r] of rows) {
65
+ const cmd = r.local!.replace('${VERSION}', version);
66
+ const label = r.partial ? `${id} (partial: ${r.partial})` : id;
67
+ byCommand.set(cmd, [...(byCommand.get(cmd) ?? []), label]);
68
+ }
69
+ return [...byCommand.entries()].map(([cmd, ids]) => [ids.join(' + '), cmd]);
70
+ }
71
+
72
+ /**
73
+ * The version under release.
74
+ *
75
+ * The branch name first, because that is what `release-validation.yml` itself
76
+ * reads (`head_ref`) — running the gates against a different version than CI
77
+ * will read would be a green run that proves nothing. `package.json` is the
78
+ * fallback for a maintainer probing before the branch exists.
79
+ */
80
+ const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u;
81
+
82
+ export function resolve_version(argv: readonly string[], cwd = REPO_ROOT): string | null {
83
+ const v = resolve_version_raw(argv, cwd);
84
+ // Every row's command is interpolated into `bash -c`, and two of the three
85
+ // sources here are strings git and npm hand back rather than values this
86
+ // file chose. `on_release_branch` already applies exactly this shape check;
87
+ // not applying it on the path that reaches a shell was the asymmetry.
88
+ return v !== null && SEMVER_RE.test(v) ? v : null;
89
+ }
90
+
91
+ function resolve_version_raw(argv: readonly string[], cwd = REPO_ROOT): string | null {
92
+ const i = argv.indexOf('--version');
93
+ if (i !== -1 && argv[i + 1]) return argv[i + 1]!;
94
+ const branch = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
95
+ cwd,
96
+ encoding: 'utf-8',
97
+ });
98
+ const name = (branch.stdout ?? '').trim();
99
+ if (name.startsWith('release/')) return name.slice('release/'.length);
100
+ try {
101
+ const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf-8')) as {
102
+ version?: string;
103
+ };
104
+ return pkg.version ?? null;
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
109
+
110
+ /** Whether HEAD is a `release/X.Y.Z` branch — decides the `release_branch_only` rows. */
111
+ export function on_release_branch(cwd = REPO_ROOT): boolean {
112
+ const r = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd, encoding: 'utf-8' });
113
+ return /^release\/\d+\.\d+\.\d+$/u.test((r.stdout ?? '').trim());
114
+ }
115
+
116
+ export function main(argv: readonly string[] = process.argv.slice(2)): number {
117
+ const version = resolve_version(argv);
118
+ if (!version) {
119
+ process.stderr.write('release_verify: could not resolve a version to check.\n');
120
+ return 2;
121
+ }
122
+ const reg = parseYaml(fs.readFileSync(REGISTRY, 'utf-8')) as { jobs: Record<string, Row> };
123
+ const cheap = argv.includes('--cheap');
124
+ const releaseHead = on_release_branch();
125
+ // Every job lands in exactly one bucket, and the buckets are built from one
126
+ // pass so they cannot fail to partition. The first version filtered `rows`
127
+ // and then derived `uncovered` from a DIFFERENT predicate, so `audit-gate`
128
+ // under `--cheap` satisfied neither and vanished from the report: the run
129
+ // printed "3 pass" and "4 not covered" over eight jobs and read as a
130
+ // clearance for a job nobody ran. `--cheap` is the variant `task release`
131
+ // fires unattended, so that was the report nobody would be reading.
132
+ const runnable: Array<[string, Row]> = [];
133
+ const skipped: Array<[string, string]> = [];
134
+ for (const [id, r] of Object.entries(reg.jobs)) {
135
+ if (!r.verify || !r.local) {
136
+ skipped.push([id, r.reason ?? 'not part of the verify set']);
137
+ } else if (cheap && r.network) {
138
+ skipped.push([id, 'skipped by --cheap — leaves the machine']);
139
+ } else if (r.release_branch_only && !releaseHead) {
140
+ skipped.push([id, 'asserts a release-PR diff, and HEAD is not a release/X.Y.Z branch']);
141
+ } else {
142
+ runnable.push([id, r]);
143
+ }
144
+ }
145
+ if (runnable.length + skipped.length !== Object.keys(reg.jobs).length) {
146
+ process.stderr.write('release_verify: internal accounting error — jobs lost.\n');
147
+ return 2;
148
+ }
149
+ const rows = runnable;
150
+ const steps = plan(rows, version);
151
+
152
+ // Where the version came from, because off a release branch it falls back
153
+ // to `package.json` — which is the version already RELEASED, not the one
154
+ // being cut. A green run against it says nothing about the next release,
155
+ // and `task release:verify` with no arguments is exactly the pre-probe the
156
+ // task description invites.
157
+ const provenance = argv.includes('--version')
158
+ ? 'from --version'
159
+ : releaseHead
160
+ ? 'from the release branch'
161
+ : 'from package.json — NOT a release branch, so this is the version already released';
162
+ process.stdout.write(
163
+ `release_verify: ${version} (${provenance}) — ${String(steps.length)} local gate command(s)` +
164
+ `${cheap ? ' (cheap set — network rows skipped)' : ''}\n\n`,
165
+ );
166
+
167
+ if (argv.includes('--list')) {
168
+ for (const [id, cmd] of steps) {
169
+ process.stdout.write(` ${id}\n ${cmd}\n`);
170
+ }
171
+ for (const [id, why] of skipped) {
172
+ process.stdout.write(` ${id} — not run\n ${why}\n`);
173
+ }
174
+ return 0;
175
+ }
176
+
177
+ const failed: string[] = [];
178
+ for (const [id, cmd] of steps) {
179
+ process.stdout.write(`─── ${id}\n ${cmd}\n`);
180
+ const res = spawnSync('bash', ['-c', cmd], { cwd: REPO_ROOT, stdio: 'inherit' });
181
+ if (res.status !== 0) {
182
+ failed.push(id);
183
+ process.stdout.write(` ❌ ${id} (exit ${String(res.status ?? -1)})\n\n`);
184
+ } else {
185
+ process.stdout.write(` ✅ ${id}\n\n`);
186
+ }
187
+ }
188
+
189
+ if (failed.length > 0) {
190
+ process.stderr.write(
191
+ `❌ ${String(failed.length)} release gate(s) would fail the release PR: ${failed.join(', ')}.\n` +
192
+ ' Nothing has left this machine yet. Fix them, then re-run.\n',
193
+ );
194
+ return 1;
195
+ }
196
+ // The runner names what it did NOT cover — a green run that silently
197
+ // omitted a job would read as a clearance it is not.
198
+ process.stdout.write(
199
+ `✅ ${String(rows.length)} of ${String(Object.keys(reg.jobs).length)} ` +
200
+ `release-validation job(s) pass for ${version} (${String(steps.length)} command(s)).\n` +
201
+ ` Not covered by this run (${String(skipped.length)}):\n` +
202
+ skipped.map(([id, why]) => ` ${id} — ${why}\n`).join('') +
203
+ ' See src/config/release-gate-locality.yml.\n',
204
+ );
205
+ return 0;
206
+ }
207
+
208
+ const _isMain = (() => {
209
+ const entry = process.argv[1];
210
+ if (!entry) return false;
211
+ try {
212
+ return fs.realpathSync(entry) === fs.realpathSync(_HERE);
213
+ } catch {
214
+ return false;
215
+ }
216
+ })();
217
+
218
+ if (_isMain) {
219
+ process.exit(main());
220
+ }
@@ -66,7 +66,7 @@
66
66
  "finding_id": {
67
67
  "type": "string",
68
68
  "pattern": "^[0-9a-f]{12}$",
69
- "description": "First 12 hex of sha256(`kind|title|file`), per findingId() in self_review_gate.ts. Stable across runs, which is what lets the disposition ledger key on it. NOT required: an in-flight finding may not have been keyed yet, and requiring it would make the schema reject the shape the model returns before the gate stamps it."
69
+ "description": "First 12 hex of sha256(`kind|title|file`), per findingId() in self_review_gate.ts. Stable across runs, which is what lets the disposition ledger key on it. NOT required: an in-flight finding may not have been keyed yet, and requiring it would make the schema reject the shape the model returns before the gate stamps it. It is a FINDING id, NOT a commit SHA — a 12-hex string looks exactly like a short SHA, and searching the commit log for it finds nothing by design; the disposition lives in agents/evidence/release-findings/<version>.json."
70
70
  },
71
71
  "severity": {
72
72
  "enum": [
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "http://json-schema.org/draft-07/schema#",
3
3
  "$id": "https://github.com/event4u-app/agent-config/scripts/schemas/skill.schema.json",
4
- "x-schemaVersion": "1.0.0",
4
+ "x-schemaVersion": "1.1.0",
5
5
  "title": "Skill frontmatter",
6
6
  "$comment": "Source: agents/reference/docs/frontmatter-contract.md#skills. Keep in sync with inventory_frontmatter.py and the linter's lint_skill().",
7
7
  "type": "object",
@@ -472,6 +472,16 @@
472
472
  ],
473
473
  "description": "Classifier signal for auto-dispatch. 'steps' = ordered multi-step plan → do-in-steps. 'files' = N independent files of the same shape → do-in-parallel. 'independent' = N independent same-shape targets (not files) → do-in-parallel. Omit for skills that are always single-pass. Read by auto-dispatch-classification.md classifier signal 1."
474
474
  },
475
+ "enforced_by": {
476
+ "type": "array",
477
+ "minItems": 1,
478
+ "uniqueItems": true,
479
+ "items": {
480
+ "type": "string",
481
+ "pattern": "^(hook:[a-z0-9_-]+|validator:[A-Za-z0-9_./-]+|test:[A-Za-z0-9_./-]+|observer:[a-z0-9 _-]+|instruction-only: *[^ ].*|none)$"
482
+ },
483
+ "description": "What resolves a CHECKABLE CLAIM this skill makes about itself \u2014 a standard-conformance claim of the form `<standard> <version> <level>` (WCAG 2.2 AA). Deliberately the SAME key and the same value vocabulary as rule.schema.json's `enforced_by`, per council 2026-09-04 (anthropic + openai, 2/2 for frontmatter over body markers; the residual split between reusing this key and minting a new `claim_resolvers:` was resolved on measurement \u2014 reuse costs ONE schema addition where a new key costs two, and adds no second vocabulary for one idea). `test:<path>` names the resolver; `instruction-only: <reason>` declares an honest gap, and the reason is MANDATORY because a bare marker is a claim with no triage record behind it. Resolved by tests/contracts/conformance_claim_declaration.test.ts. NOT read by check_enforcement_coverage.ts or lint_rule_enforcement_declaration.ts \u2014 both scan `src/rules/` only, so a skill carrying this key does not join the rule-enforcement ratchet."
484
+ },
475
485
  "gaps": {
476
486
  "type": "array",
477
487
  "description": "B6 — honest-null known limitations. Human-curated (NOT auto-discovered), not all skills. Each entry documents a real limitation and cites a witness test that reproduces it. Rendered on the proof page's 'Known Limits'; check_skill_gaps enforces every witness path exists. The offensive-transparency surface a headline-benchmark cannot ship.",
@@ -356,6 +356,15 @@ export function renderReview(findings: Finding[], enforce: boolean, escalation:
356
356
  ? `❌ ${blocking.length} merge-blocking finding(s) (security/claim × high+).`
357
357
  : `⚠️ ${blocking.length} finding(s) WOULD block merge under an enforced gate (advisory now).`
358
358
  : '✅ No merge-blocking findings (style/correctness advise only).';
359
+ // Three reviewers in the 2026-09 round searched the commit log for a finding
360
+ // id's first eight characters, found nothing, and reported "no fix found". A
361
+ // 12-hex id looks exactly like a short SHA, so the rendered comment now says
362
+ // what it is and where its disposition lives. This is the only place a reader
363
+ // of the comment can learn it without opening the source.
364
+ const idNote =
365
+ '\n\n`id` above is a FINDING id — the first 12 hex of sha256(kind|title|file), '
366
+ + '**not a commit SHA**. Searching the git log for it finds nothing, by design. '
367
+ + 'Its disposition is recorded in `agents/evidence/release-findings/<version>.json`.';
359
368
  // Machine-readable block (release-truth Phase 3): invisible in the rendered
360
369
  // comment; `check_finding_dispositions --pr` reads it as the TRIGGER that
361
370
  // demands committed dispositions. The comment is transport, never the
@@ -363,7 +372,7 @@ export function renderReview(findings: Finding[], enforce: boolean, escalation:
363
372
  const machine = `\n\n<!-- release-findings-json: ${JSON.stringify(
364
373
  findings.map((f) => ({ finding_id: findingId(f), severity: f.severity, kind: f.kind, title: f.title, file: f.file ?? null })),
365
374
  )} -->`;
366
- return `${banner}\n\n${verdictLine}\n\n| id | severity | kind | file | finding |\n|---|---|---|---|---|\n${rows}${esc}${machine}`;
375
+ return `${banner}\n\n${verdictLine}\n\n| id | severity | kind | file | finding |\n|---|---|---|---|---|\n${rows}${idNote}${esc}${machine}`;
367
376
  }
368
377
 
369
378
  function postReview(body: string): void {
@@ -15,7 +15,7 @@
15
15
  * would mean the agent could arrive at its own permission by observing the
16
16
  * world, which is the confused-deputy shape in miniature.
17
17
  *
18
- * Modelled on `src/scripts/hooks/block_unauthorized_git.ts`: answer from a
18
+ * Modelled on `src/scripts/hooks/git_command_classifier.ts`: answer from a
19
19
  * recorded fact rather than from the model's recollection, and treat a missing
20
20
  * record as NOT granted rather than as probably-fine.
21
21
  *