@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
@@ -12,7 +12,25 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
12
12
  # (where $PROJECT_ROOT/.git is a file, not a dir, and a literal .git/hooks path
13
13
  # would break under `set -e`). --git-common-dir points at the main .git even from
14
14
  # a worktree; fall back to the literal path only outside a git repo.
15
- if COMMON_GIT_DIR="$(git -C "$PROJECT_ROOT" rev-parse --git-common-dir 2>/dev/null)"; then
15
+ #
16
+ # AGENT_CONFIG_HOOKS_DIR overrides the destination. It exists so that
17
+ # `check_installed_hooks_fresh` can render what this installer WOULD write into
18
+ # a scratch directory and byte-compare it against what is installed — the only
19
+ # way to answer "is the installed copy stale?" without re-parsing these
20
+ # heredocs, which carry an appended block (post-merge / post-checkout) that no
21
+ # heredoc slice can reproduce. It is a test/inspection seam; a normal install
22
+ # never sets it.
23
+ if [ -n "${AGENT_CONFIG_HOOKS_DIR+set}" ]; then
24
+ # SET-BUT-EMPTY is refused rather than ignored. A caller that meant to
25
+ # redirect an inspection run and passed an empty value would otherwise fall
26
+ # through to the real .git/hooks and INSTALL over it — including over a
27
+ # pre-push that is running the inspection, which truncates it silently.
28
+ if [ -z "$AGENT_CONFIG_HOOKS_DIR" ]; then
29
+ echo "❌ AGENT_CONFIG_HOOKS_DIR is set but empty — refusing to fall back to the real .git/hooks." >&2
30
+ exit 2
31
+ fi
32
+ HOOKS_DIR="$AGENT_CONFIG_HOOKS_DIR"
33
+ elif COMMON_GIT_DIR="$(git -C "$PROJECT_ROOT" rev-parse --git-common-dir 2>/dev/null)"; then
16
34
  case "$COMMON_GIT_DIR" in /*) : ;; *) COMMON_GIT_DIR="$PROJECT_ROOT/$COMMON_GIT_DIR" ;; esac
17
35
  HOOKS_DIR="$COMMON_GIT_DIR/hooks"
18
36
  else
@@ -20,6 +38,12 @@ else
20
38
  fi
21
39
 
22
40
  mkdir -p "$HOOKS_DIR"
41
+ # Name the destination. AGENT_CONFIG_HOOKS_DIR is a global override, and a
42
+ # stray non-empty value would otherwise redirect a real `task install-hooks`
43
+ # while it printed six "hook installed" lines that name no path at all — the
44
+ # freshness check would then report every hook missing with nothing pointing at
45
+ # the cause.
46
+ echo "→ installing git hooks into $HOOKS_DIR" >&2
23
47
 
24
48
  cat > "$HOOKS_DIR/pre-push" << 'EOF'
25
49
  #!/usr/bin/env bash
@@ -85,6 +109,96 @@ fi
85
109
 
86
110
  fail=0
87
111
 
112
+ # BASE FRESHNESS — first, and it EXITS rather than setting fail=1.
113
+ #
114
+ # The header above has promised "when this hook refuses for staleness" since the
115
+ # hook was written, and nothing below ever asked. Measured over the 50 PRs
116
+ # preceding 2026-09-04: 25 of them carried at least one `Merge branch 'main'
117
+ # into <branch>` commit (52 such commits in total), and 17 of the 36 PRs whose
118
+ # workflow runs were still readable had at least one FAILED run — the three
119
+ # workflows that failed most (Tests 18, Consistency 16, Rule Backstops 13) are
120
+ # exactly the base-relative ones: ratchets, budgets and generated counts that
121
+ # were green against the base the branch forked from and red against the base it
122
+ # merges into. A branch pushed behind its base buys a CONFLICTING PR, a red run,
123
+ # or both, and the contributor pays for it after the work looks finished.
124
+ #
125
+ # It runs FIRST because everything after it is invalidated by the merge that has
126
+ # to happen anyway: `task consistency` regenerates derived files against the
127
+ # stale base, the merge then re-dirties them, and the ~10s is spent twice. And
128
+ # it EXITS instead of accumulating into `fail`, for the same reason — there is
129
+ # no useful second finding to collect from a tree that is about to move.
130
+ #
131
+ # It never merges. `check_branch_freshness` asks the REMOTE (one `ls-remote`,
132
+ # measured 4.5s) and returns 1 only on a VERIFIED behind state; offline, in CI,
133
+ # on a detached HEAD, or standing on the base itself it returns 0 and says so.
134
+ # So an unreachable network cannot block a push, and a stale local tracking ref
135
+ # cannot fake a green.
136
+ echo "🔍 Base freshness — is this branch behind the base it will merge into?"
137
+ if [ "${AGENT_CONFIG_SKIP_PREPUSH_FRESHNESS:-}" = "1" ]; then
138
+ echo "⏭️ skipped via AGENT_CONFIG_SKIP_PREPUSH_FRESHNESS=1"
139
+ elif [ ! -x ./scripts-run ]; then
140
+ echo "⚠️ ./scripts-run not found — skipping the freshness check for this push."
141
+ elif ! ./scripts-run src/scripts/check_branch_freshness --quiet; then
142
+ echo ""
143
+ echo " Push blocked — the branch is behind its base, and the gates you just"
144
+ echo " passed were answered against a base that no longer exists."
145
+ echo ""
146
+ echo " task push-ready # fetch → integrate the base SET → regenerate"
147
+ echo " # → verify → re-check freshness, then push"
148
+ echo " task push-ready DRY=1 # the same steps, read-only"
149
+ echo ""
150
+ echo " This hook refuses; it never merges. Bypass a genuine WIP push with"
151
+ echo " AGENT_CONFIG_SKIP_PREPUSH_FRESHNESS=1."
152
+ exit 1
153
+ fi
154
+
155
+ # INSTALLED-HOOK FRESHNESS — ADVISORY, and deliberately AFTER base freshness.
156
+ #
157
+ # What it answers: are the hooks in .git/hooks still what THIS checkout's
158
+ # install-hooks.sh would write? Measured 2026-09-05: the installed pre-push here
159
+ # was 146 lines against a 189-line source body, missing the entire base-freshness
160
+ # gate above, merged five days earlier. It read as live in the source, in CI and
161
+ # in the skill documenting it, and did not run here.
162
+ #
163
+ # WHY IT DOES NOT REFUSE. The predicate is "installed == what this checkout
164
+ # renders", and .git/hooks is SHARED by every linked worktree through the common
165
+ # dir. With two worktrees whose installer bodies differ, at most one can satisfy
166
+ # it at a time and each re-install flips which — so a refusal would fire on
167
+ # ordinary parallel work, and the documented skip variable would become routine,
168
+ # which retires the gate rather than enforcing it. An AI council (claude-sonnet-4-5
169
+ # + codex-default, 2026-09-05, 2 of 2 seats) chose advisory unanimously after an
170
+ # independent review raised exactly that. Blocking returns only with a
171
+ # single-referent predicate — per-worktree hook isolation via core.hooksPath, or a
172
+ # branch-independent dispatcher installed once in the common dir.
173
+ #
174
+ # WHY AFTER BASE FRESHNESS. Being behind a base that moved the installer is the
175
+ # most common cause of a mismatch, and the repair for THAT is to merge, not to
176
+ # re-install: re-installing from a behind-base checkout writes the OLDER hook set
177
+ # over the shared directory. Base freshness exits first, so that contributor is
178
+ # told the correct thing and never reaches this notice.
179
+ echo "🔍 Installed-hook freshness — do .git/hooks still match this checkout?"
180
+ if [ "${AGENT_CONFIG_SKIP_PREPUSH_HOOKFRESH:-}" = "1" ]; then
181
+ echo "⏭️ skipped via AGENT_CONFIG_SKIP_PREPUSH_HOOKFRESH=1"
182
+ elif [ ! -x ./scripts-run ] || [ ! -d node_modules ] \
183
+ || [ ! -f src/scripts/check_installed_hooks_fresh.ts ]; then
184
+ # Three guards, and each has been a real miss. `-d node_modules`: without
185
+ # dependencies ./scripts-run cannot start tsx and the non-zero exit would
186
+ # read as staleness. `-f`: .git/hooks is shared, so this file also runs in a
187
+ # worktree standing on a branch that predates the gate, and calling a script
188
+ # that is not on that checkout would report the wrong thing.
189
+ echo "⏭️ check_installed_hooks_fresh not runnable on this checkout — skipping."
190
+ else
191
+ ./scripts-run src/scripts/check_installed_hooks_fresh --quiet
192
+ hookfresh_rc=$?
193
+ # The gate's three exit codes mean three different things, and collapsing
194
+ # them reports a false cause: 0 = match or nothing installed, 1 = mismatch,
195
+ # 2 = it could not render (installer failed, unwritable TMPDIR, usage error).
196
+ if [ "$hookfresh_rc" = "2" ]; then
197
+ echo "⚠️ could not compare the installed hooks (see above) — not a staleness verdict."
198
+ fi
199
+ # Advisory in both cases: nothing here sets fail=1 or exits.
200
+ fi
201
+
88
202
  if ! command -v task >/dev/null 2>&1; then
89
203
  echo "⚠️ 'task' (go-task) not found — cannot run the consistency gate locally."
90
204
  echo " Install it (https://taskfile.dev) so pre-push can mirror the CI check;"
@@ -381,6 +495,25 @@ if [ -n "\$prev" ] && [ -n "\$new" ] && [ "\$prev" != "\$new" ]; then
381
495
  if command -v npm >/dev/null 2>&1 && [ -d node_modules ]; then
382
496
  npm run build:cli >/dev/null 2>&1 || true
383
497
  fi
498
+ # Installed-hook staleness — REPORT it here, never repair it here.
499
+ #
500
+ # This is the event that CAUSES the staleness, so it is where the news
501
+ # belongs. It is NOT where the repair belongs: linked worktrees SHARE
502
+ # .git/hooks through the common dir, so re-running the installer from a
503
+ # checkout would let one worktree silently redefine the gates every
504
+ # other worktree runs, and an older branch would reinstall older hooks
505
+ # over newer ones. An AI council (claude-sonnet-4-5 + codex-default,
506
+ # 2026-09-05, 2 of 2 seats) refused that mutation unanimously.
507
+ #
508
+ # Output goes to stderr and is NOT swallowed: the rest of this block is
509
+ # "|| true" so a pull is never blocked, and a repair notice nobody can
510
+ # see is the same silence this check exists to remove.
511
+ # The -f guard is not belt-and-braces: this hook is shared across
512
+ # linked worktrees, so it also runs on checkouts that predate the gate.
513
+ if [ -x ./scripts-run ] && [ -d node_modules ] \
514
+ && [ -f src/scripts/check_installed_hooks_fresh.ts ]; then
515
+ ./scripts-run src/scripts/check_installed_hooks_fresh --quiet >&2 || true
516
+ fi
384
517
  fi
385
518
  fi
386
519
  EOF
@@ -630,9 +630,17 @@ export function _setPinLost(consumer_root: string, session_id: string): void {
630
630
  _migrateLegacyMarker(consumer_root);
631
631
  try {
632
632
  atomic_write_text(path.join(consumer_root, pinLostMarker(session_id)), new Date().toISOString());
633
- } catch {
634
- // Observability only — a failed marker write costs one un-restored pin,
635
- // never a broken turn.
633
+ } catch (err) {
634
+ // The COST is unchanged and still bounded — one un-restored pin, never a
635
+ // broken turn. What changed on 2026-09-04 is that it stops being silent.
636
+ // The legacy-marker migration above documents exactly how this catch can
637
+ // become PERMANENT for a root (`EEXIST` then `ENOTDIR`, reproduced against
638
+ // Node), and a permanently dead mechanism that never says so is the failure
639
+ // the sibling swallows in `git_authorization_hook` were repaired for.
640
+ process.stderr.write(
641
+ `language-mirror: pin-lost marker write failed (${_errText(err)}) — ` +
642
+ "the pin will NOT be re-emitted after this compaction. Turn outcome unchanged.\n",
643
+ );
636
644
  }
637
645
  }
638
646
 
@@ -1124,8 +1132,16 @@ export function run(
1124
1132
  // per tool call — and never before the write it is cleaning up after.
1125
1133
  _pruneLegacyState(options.consumer_root);
1126
1134
  _pruneStaleSessions(options.consumer_root, Date.now());
1127
- } catch {
1128
- // Observability only — a failed state write never blocks the turn.
1135
+ } catch (err) {
1136
+ // Direction unchanged: the turn is never blocked. But the pin lives ONLY
1137
+ // in this file, so a failed write silently reverts the next turn to
1138
+ // re-derivation — the user re-states a language they already pinned and
1139
+ // has no way to know why. Named rather than swallowed.
1140
+ process.stderr.write(
1141
+ `language-mirror: pin-state write failed (${_errText(err)}) — ` +
1142
+ "the language pin did NOT persist and the next turn re-derives it. " +
1143
+ "This turn's notice is unchanged.\n",
1144
+ );
1129
1145
  }
1130
1146
  }
1131
1147
 
@@ -1146,6 +1162,12 @@ export function run(
1146
1162
  */
1147
1163
  export { isSyntheticPrompt };
1148
1164
 
1165
+ /** One-line, bounded rendering of a caught value for a stderr diagnostic. */
1166
+ function _errText(err: unknown): string {
1167
+ const t = err instanceof Error ? err.message : String(err);
1168
+ return t.replace(/\s+/g, " ").trim().slice(0, 200);
1169
+ }
1170
+
1149
1171
  export function main(argv?: string[]): number {
1150
1172
  const args = argv ?? process.argv.slice(2);
1151
1173
  let consumer_root = process.cwd();
@@ -102,7 +102,6 @@
102
102
  * - `_lib.changelog_eras` imports resolve to the `.ts` twin, never a `.py`.
103
103
  */
104
104
 
105
- import { spawnSync } from 'node:child_process';
106
105
  import * as fs from 'node:fs';
107
106
  import * as path from 'node:path';
108
107
  import process from 'node:process';
@@ -120,16 +119,17 @@ import {
120
119
  } from './_lib/changelog_eras.js';
121
120
  import {
122
121
  HEAD_LABELS,
122
+ type MixObligation,
123
123
  collect_span_commits,
124
124
  derive_category_hits,
125
125
  render_derived_head_values,
126
126
  } from './_lib/release_highlights.js';
127
127
  import {
128
- NEXT_SECTION_RE,
129
128
  RELEASE_HEAD_DEFAULT,
130
129
  extract_changelog_section,
131
130
  pr_body_from_section,
132
131
  release_notes_from_section,
132
+ render_mix_response,
133
133
  } from './_lib/release_material.js';
134
134
 
135
135
  // `__doc__.splitlines()[0]` in `_parse_args` — the argparse description. Kept
@@ -164,11 +164,16 @@ import {
164
164
  _splitlines,
165
165
  } from './release_env.js';
166
166
  import type { RunResult } from './release_publication.js';
167
- // Re-export surface for the six names tests import from `release.js` and that
168
- // moved into the publication unit. `export ... from` rather than a bare import,
167
+ // Re-export surface for the names tests import from `release.js` and that moved
168
+ // into the publication unit — eleven since the test-count-trend unit followed. `export ... from` rather than a bare import,
169
169
  // because these are not USED here — an unused import would be dropped and the
170
170
  // test import path would break silently.
171
171
  export {
172
+ _TEST_LIST_MAX_BUFFER,
173
+ _count_from_list_result,
174
+ _count_tests_current,
175
+ _previous_test_count_from_changelog,
176
+ _render_test_trend_line,
172
177
  _failed_check_names,
173
178
  _failed_checks_report,
174
179
  _is_non_fast_forward,
@@ -176,7 +181,7 @@ export {
176
181
  _no_checks_action,
177
182
  _required_contexts_from_rules,
178
183
  } from './release_publication.js';
179
- import { _cap_body, jsonDumpsIndent, reEscape } from './release_env.js';
184
+ import { _cap_body, jsonDumpsIndent } from './release_env.js';
180
185
  import {
181
186
  _MERGE_UPDATE_ROUNDS,
182
187
  _branch_exists_local,
@@ -195,7 +200,10 @@ import {
195
200
  create_and_push_annotated_tag,
196
201
  guard_publication,
197
202
  checkout_release_branch,
203
+ _render_test_trend_line,
198
204
  guard_release_curation,
205
+ local_release_gate_argv,
206
+ measure_mix_obligation,
199
207
  die,
200
208
  gh,
201
209
  git,
@@ -500,7 +508,11 @@ function render_changelog_entry(
500
508
  prev: string | null,
501
509
  commits: readonly Commit[],
502
510
  today: string,
503
- opts: { test_trend_line?: string | null; head?: Readonly<Record<string, string>> } = {},
511
+ opts: {
512
+ test_trend_line?: string | null;
513
+ head?: Readonly<Record<string, string>>;
514
+ mix?: MixObligation | null;
515
+ } = {},
504
516
  ): [string, string] {
505
517
  const test_trend_line = opts.test_trend_line ?? null;
506
518
  let heading: string;
@@ -539,6 +551,11 @@ function render_changelog_entry(
539
551
 
540
552
  // The curated head sits above the generated log, which stays unchanged.
541
553
  const body_lines: string[] = [...render_release_head(opts.head ?? {})];
554
+ // Under the head and outside it — rationale on `MIX_RESPONSE_PLACEHOLDER`.
555
+ if (opts.mix?.triggered) {
556
+ body_lines.push('');
557
+ body_lines.push(...render_mix_response(opts.mix.level));
558
+ }
542
559
  const ordered_labels = ['BREAKING CHANGES', ...SECTIONS.map(([label]) => label)];
543
560
  for (const label of ordered_labels) {
544
561
  const bucket = grouped[label] ?? [];
@@ -581,139 +598,6 @@ function _changelog_line(c: Commit): string {
581
598
  return `* ${scope}${c.subject} ([${short}](${link}))`;
582
599
  }
583
600
 
584
- // ─── test-count trend (road-to-feedback-followups P3.2) ───────────────────────
585
-
586
- const _TEST_COUNT_LINE_RE = /^Tests:\s+(\d+)/m;
587
-
588
- /**
589
- * Buffer ceiling for the `vitest list` probe, in bytes.
590
- *
591
- * `spawnSync` buffers the child's whole stdout in memory and fails with
592
- * ENOBUFS past `maxBuffer`, whose default is 1 MiB. The probe emits one line
593
- * per test case, so its output grows with the suite: at 9470 cases the listing
594
- * is ~1.25 MB, i.e. already past the default. That is what silently dropped
595
- * the footer from the 9.10.0 notes and turned the `changelog-entry` gate red.
596
- * 64 MiB is ~50× the current listing — headroom for a suite many times this
597
- * size, at no cost when the output is small.
598
- */
599
- const _TEST_LIST_MAX_BUFFER = 64 * 1024 * 1024;
600
-
601
- /**
602
- * Count test cases from a finished `vitest list` spawn result, or return null
603
- * when the probe did not produce a usable listing.
604
- *
605
- * Split out from `_count_tests_current` so the failure modes are testable
606
- * without spawning the real (~14s, >1 MB) collection. `warn` is injected for
607
- * the same reason; it defaults to stderr.
608
- */
609
- function _count_from_list_result(
610
- res: {
611
- error?: (Error & { code?: string }) | undefined;
612
- status: number | null;
613
- stdout: string | null;
614
- },
615
- warn: (msg: string) => void = (msg) => void process.stderr.write(msg),
616
- ): number | null {
617
- // The trend line is informational and never blocks a release — but a
618
- // SILENT drop is what shipped 9.8.0 and 9.10.0 without a footer, so every
619
- // degradation says why. The `changelog-entry` CI gate treats a missing
620
- // footer as fatal, so this warning is the difference between fixing the
621
- // probe now and finding out from a red release PR.
622
- if (res.error) {
623
- // ENOENT (no npx) · ETIMEDOUT · ENOBUFS (listing past maxBuffer).
624
- const code = res.error.code ?? res.error.message;
625
- warn(
626
- `⚠️ test-count probe failed (${code}) — the \`Tests:\` footer will ` +
627
- `be omitted from the release notes, which fails the ` +
628
- `\`CHANGELOG entry exists for head version\` gate.\n`,
629
- );
630
- return null;
631
- }
632
- if ((res.status ?? 1) !== 0) {
633
- warn(
634
- `⚠️ test-count probe exited ${res.status ?? '(null)'} — the ` +
635
- `\`Tests:\` footer will be omitted from the release notes.\n`,
636
- );
637
- return null;
638
- }
639
- const lines = (res.stdout ?? '').split('\n').filter((l) => l.trim().length > 0);
640
- if (lines.length === 0) {
641
- warn('⚠️ test-count probe listed 0 cases — the `Tests:` footer will be omitted.\n');
642
- return null;
643
- }
644
- return lines.length;
645
- }
646
-
647
- /**
648
- * Return the collected vitest test-case count on the current tree
649
- * (`npx vitest list`, one line per case; ~14s wall). Returns null when
650
- * collection fails — the trend line is informational, never a release
651
- * blocker, but every failure warns (see `_count_from_list_result`).
652
- * (Replaced the dead `pytest --collect-only` probe on 2026-07-08,
653
- * road-to-truth-and-reference-hygiene Phase 3: the Python suite was retired
654
- * with ADR-200, so the old probe always degraded to null and the `Tests:`
655
- * footer silently vanished from release notes.)
656
- */
657
- function _count_tests_current(): number | null {
658
- // Recursion/cost guard: `npx vitest list` inside a vitest-driven release
659
- // test would collect the whole suite from within the suite (the child
660
- // inherits VITEST=… from the runner) — the exact recursion the caller
661
- // comment warns about. Degrade to null there, same as any other
662
- // collection failure.
663
- if (process.env['VITEST'] !== undefined) {
664
- return null;
665
- }
666
- const res = spawnSync('npx', ['vitest', 'list'], {
667
- cwd: REPO_ROOT,
668
- encoding: 'utf-8',
669
- stdio: ['ignore', 'pipe', 'pipe'],
670
- timeout: 180_000,
671
- maxBuffer: _TEST_LIST_MAX_BUFFER,
672
- });
673
- return _count_from_list_result(res);
674
- }
675
-
676
- /**
677
- * Read CHANGELOG.md and return the most recent `Tests: N` footer under the
678
- * `prev_tag` heading, or null when not found.
679
- */
680
- function _previous_test_count_from_changelog(prev_tag: string | null): number | null {
681
- if (!prev_tag || !fs.existsSync(CHANGELOG)) {
682
- return null;
683
- }
684
- const text = fs.readFileSync(CHANGELOG, 'utf-8');
685
- const heading_re = new RegExp(`^##\\s+\\[?${reEscape(prev_tag)}\\b`, 'm');
686
- const m = heading_re.exec(text);
687
- if (!m) {
688
- return null;
689
- }
690
- const headEnd = m.index + m[0].length;
691
- const rest = text.slice(headEnd);
692
- const next_heading = NEXT_SECTION_RE.exec(rest);
693
- const sectionEnd = headEnd + (next_heading ? next_heading.index : rest.length);
694
- const section = text.slice(headEnd, sectionEnd);
695
- const count_match = _TEST_COUNT_LINE_RE.exec(section);
696
- return count_match ? Number.parseInt(count_match[1] as string, 10) : null;
697
- }
698
-
699
- /**
700
- * Return the `Tests: N (+M since X.Y.Z)` footer line, or null when the current
701
- * count cannot be determined. Silent on collection errors.
702
- */
703
- function _render_test_trend_line(prev_tag: string | null): string | null {
704
- const current = _count_tests_current();
705
- if (current === null) {
706
- return null;
707
- }
708
- const previous = _previous_test_count_from_changelog(prev_tag);
709
- if (previous === null || !prev_tag) {
710
- return `Tests: ${current}`;
711
- }
712
- const delta = current - previous;
713
- const sign = delta >= 0 ? '+' : '';
714
- return `Tests: ${current} (${sign}${delta} since ${prev_tag})`;
715
- }
716
-
717
601
  /**
718
602
  * Insert `entry` inside the current era block.
719
603
  *
@@ -1287,6 +1171,10 @@ function execute(
1287
1171
  // `git push -u` is naturally idempotent — it prints "Everything
1288
1172
  // up-to-date" when remote already matches. push_release_branch
1289
1173
  // additionally absorbs a remote that moved under us.
1174
+ // Not a `_step`: a second `[4/10]` makes the cited evidence anchors ambiguous.
1175
+ process.stdout.write(' · verifying release gates locally (`task release:verify -- --cheap`)\n');
1176
+ run(local_release_gate_argv());
1177
+
1290
1178
  _step(4, total, `Push ${branch} to ${REMOTE}`);
1291
1179
  push_release_branch(branch);
1292
1180
  }
@@ -1834,9 +1722,13 @@ function main(argv: readonly string[] | null = null): number {
1834
1722
  // made the preview print the `_none_` skeleton while the real run wrote
1835
1723
  // pre-filled lines — a preview that contradicts its own output, which is
1836
1724
  // the exact class of surface disagreement this pre-fill exists to end.
1725
+ // Unconditional for the same preview-parity reason as `_derive_head_prefill`,
1726
+ // at a different cost: one `git show` per commit — measured 3.2 s over a
1727
+ // 120-commit span, paid 3-4× per release. Not the ~25 ms of the line above.
1837
1728
  const [full, body] = render_changelog_entry(target, prev, commits, today, {
1838
1729
  test_trend_line,
1839
1730
  head: _derive_head_prefill(prev),
1731
+ mix: measure_mix_obligation(target, prev),
1840
1732
  });
1841
1733
 
1842
1734
  // Era-split planning — gate on the POST-release view (2026-07-07 fix).
@@ -1954,10 +1846,6 @@ export {
1954
1846
  _detect_in_flight_target,
1955
1847
  print_preview,
1956
1848
  latest_tag,
1957
- _render_test_trend_line,
1958
- _previous_test_count_from_changelog,
1959
- _count_from_list_result,
1960
- _TEST_LIST_MAX_BUFFER,
1961
1849
  Commit,
1962
1850
  Plan,
1963
1851
  SystemExitError,
@@ -209,6 +209,18 @@ interface WorldConfig {
209
209
  * it fakes is one every resumed release actually passes through.
210
210
  */
211
211
  pr_already_merged?: boolean;
212
+
213
+ /**
214
+ * Make `task release:verify` fail, as a red local release gate would.
215
+ *
216
+ * Without this the drill returns OK for EVERY `task` invocation, so the
217
+ * pre-push gate run added 2026-09-05 would be exercised only on its happy
218
+ * path — wired but never proven to stop anything. The whole point of that
219
+ * step is that a red local gate costs one edit instead of a branch, a PR
220
+ * and a CI run, and that property is only real if a scenario watches it
221
+ * hold.
222
+ */
223
+ release_verify_fails?: boolean;
212
224
  }
213
225
 
214
226
  /**
@@ -232,6 +244,20 @@ export function defaultChangelogFixture(target: string): string {
232
244
  '',
233
245
  '- fixture entry for the release drill',
234
246
  '',
247
+ // Both lines are section-level publication obligations
248
+ // (`section_publication_blockers`), so a fixture without them refuses
249
+ // at the curation guard and every sequencing scenario dies at step 2 —
250
+ // the same "fixture trips the guard the drill exists to sequence"
251
+ // failure this builder's docstring already warns about, for the two
252
+ // obligations added 2026-09-05. The mix answer is written OUT rather
253
+ // than measured: measuring would couple every scenario to the live
254
+ // repository's commit span, which is the coupling `changelog` was
255
+ // introduced to remove.
256
+ '> **Governance mix:** governance-only 2 vs consumer-only 1 (taxonomy 1.0.0).',
257
+ '> Next cycle ships the fixture consumer work, tracked in the drill.',
258
+ '',
259
+ 'Tests: 1000 (+0 since 0.0.0)',
260
+ '',
235
261
  '## [0.0.0]',
236
262
  '',
237
263
  '- earlier release, present so the section boundary is exercised',
@@ -267,6 +293,8 @@ class FakeWorld {
267
293
  pr_merged = false;
268
294
  release_created = false;
269
295
  branch_exists_remote = true;
296
+ /** Whether the simulated `task release:verify` reports a red local gate. */
297
+ private readonly release_verify_fails: boolean;
270
298
  /** Live PR body (release-truth Phase 1: refreshed from the changelog at head). */
271
299
  pr_body = '';
272
300
 
@@ -296,6 +324,7 @@ class FakeWorld {
296
324
  this.merge_fails_hard = cfg.merge_fails_hard ?? false;
297
325
  this.checks_fail = cfg.checks_fail ?? false;
298
326
  this.pr_merged = cfg.pr_already_merged ?? false;
327
+ this.release_verify_fails = cfg.release_verify_fails ?? false;
299
328
  }
300
329
 
301
330
  exec(args: readonly string[]): ExecResult {
@@ -374,7 +403,14 @@ class FakeWorld {
374
403
 
375
404
  // ── task ─────────────────────────────────────────────────────────
376
405
  if (args[0] === 'task') {
377
- return OK; // release-prepare — regeneration is out of drill scope
406
+ // `release-prepare` — regeneration is out of drill scope.
407
+ // `release:verify` — the pre-push local gate run; simulated
408
+ // red only when a scenario asks for it, so the step's stopping
409
+ // power is exercised rather than assumed.
410
+ if (args[1] === 'release:verify' && this.release_verify_fails) {
411
+ return { status: 1, stdout: '', stderr: '❌ 1 release gate(s) would fail the release PR: release-shape.\n' };
412
+ }
413
+ return OK;
378
414
  }
379
415
 
380
416
  // ── gh ───────────────────────────────────────────────────────────
@@ -541,6 +577,40 @@ const SCENARIOS: Record<string, Scenario> = {
541
577
  return f;
542
578
  },
543
579
  },
580
+ 'local-gates-refuse-before-push': {
581
+ summary:
582
+ 'step 4: a red local release gate stops the run BEFORE the branch is pushed — no branch on origin, no PR, no CI',
583
+ config: { release_verify_fails: true },
584
+ expect_success: false,
585
+ verify: (w, err) => {
586
+ const f: string[] = [];
587
+ _expect(
588
+ (err ?? '').includes('release:verify'),
589
+ `died for the wrong reason: ${err ?? '(no error)'}`,
590
+ f,
591
+ );
592
+ // The three remote side effects the 14.17.0 run actually produced
593
+ // before `check_release_highlights` refused it on the PR. Each is
594
+ // a separate assertion because each was a separate cost.
595
+ _expect(
596
+ !w.calls.some((c) => c.startsWith('git push')),
597
+ 'the release branch was pushed after the local gates refused',
598
+ f,
599
+ );
600
+ _expect(
601
+ !w.calls.some((c) => c.startsWith('gh pr create')),
602
+ 'a pull request was opened after the local gates refused',
603
+ f,
604
+ );
605
+ _expect(
606
+ !w.calls.some((c) => c.includes('pr checks')),
607
+ 'CI was watched after the local gates refused',
608
+ f,
609
+ );
610
+ return f;
611
+ },
612
+ },
613
+
544
614
  'marker-refuses-before-commit': {
545
615
  summary:
546
616
  'step 3: a draft marker refuses BEFORE the release commit — no local release state, and therefore no remote state either',