@event4u/agent-config 9.18.0 → 9.18.1

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.
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Bootstrap shim for the event4u agent-config suite. This plugin ships ONLY the dispatcher hooks plus one install-pointer skill — ALL content (skills, rules, commands) is installed via `npx -y @event4u/agent-config init`, which writes the file projection AND registers the same hooks in ~/.claude/settings.json. DEPRECATED as a content channel; kept so the marketplace listing stays discoverable and existing installs keep working hooks.",
9
- "version": "9.18.0",
9
+ "version": "9.18.1",
10
10
  "keywords": [
11
11
  "agent-config",
12
12
  "skills",
package/CHANGELOG.md CHANGED
@@ -82,6 +82,29 @@ Entry-shape contract: [`docs/contracts/CHANGELOG-conventions.md`](docs/contracts
82
82
 
83
83
  ### Fixed
84
84
 
85
+ - **Every release PR was red on its first run — the generator and the
86
+ highlight gate contradicted each other.** `release.ts` wrote `_none_` into
87
+ all five curated-head fields; `check_release_highlights` (added 2026-08-03)
88
+ fails a `_none_` the release span contradicts. Because every release of this
89
+ package touches `src/rules/` or `src/scripts/schemas/`, "Behaviour changes"
90
+ is *always* substantiated, so the gate reliably red-flagged the generator's
91
+ own default — 9.17.0 (run 30871194277) and 9.18.0 (run 30909511315) both
92
+ failed on that one step, and each release had to be unblocked by hand.
93
+ - The span → category classifier now lives once, in
94
+ `src/scripts/_lib/release_highlights.ts`, and is shared by the generator
95
+ and the gate; the two duplicated label lists are gone with it.
96
+ - `release.ts` **pre-fills** each substantiated label with the deriving
97
+ reason plus its citing SHAs (capped at 6, remainder stated, never silently
98
+ truncated). `_none_` is now a fallback for labels the span does not
99
+ substantiate, not a blanket default — so the tool no longer asserts five
100
+ things it never checked.
101
+ - The gate keeps full teeth for the failure it was actually built for: a
102
+ **human** editing a substantiated line back down to `_none_`, which is what
103
+ produced the false 9.13.0 and 9.14.0 heads. An unrewritten auto-derived
104
+ line is advisory (a prose gap, not a false claim) and never blocks.
105
+ - Derivation is best-effort in the generator: a git failure degrades to the
106
+ `_none_` skeleton with a warning instead of aborting a release.
107
+
85
108
  - **`/team delegate` double gate is now enforced in code, not only in prose**
86
109
  (team mode stays default-off; closing `road-to-team-mode`). The gate on the
87
110
  only write-access wrapper existed as agent-followed instructions in the
@@ -271,6 +294,37 @@ Entry-shape contract: [`docs/contracts/CHANGELOG-conventions.md`](docs/contracts
271
294
  > that forces a new era split (`# Era: 9.19.x`, etc.) — see
272
295
  > [`docs/contracts/CHANGELOG-conventions.md § Era splits`](docs/contracts/CHANGELOG-conventions.md).
273
296
 
297
+ ## [9.18.1](https://github.com/event4u-app/agent-config/compare/9.18.0...9.18.1) (2026-08-04)
298
+
299
+ ### Release highlights
300
+
301
+ <!-- Curated head: fill before merge, keep it under 10 lines, and leave `_none_` where it is genuinely the answer. The generated log below is unchanged. -->
302
+ - **Behaviour changes:** the release generator pre-fills the curated head from the release span instead of writing `_none_` into all five fields (8fe60e9, 2f23d6b) — `_none_` now means "the span substantiates nothing", so a reader can trust it; review-gate scope classification changed, with IaC and extensionless build files counting as code and a findings row required to be exactly six cells (6c5f0f5).
303
+ - **Default changes + migration:** _none_
304
+ - **Security and correctness:** two more fail-open routes in the review-gate fence parser are closed — an unterminated fence no longer swallows the rest of a findings artefact (55dc01d) and a bare fence never delimits a region (9937ad9, the fourth such route); both previously let a truncated artefact read as clean.
305
+ - **Honest nulls:** the honest-null recorded against 6c6fc15a9 in the 9.18.0 notes was **false** — the real binding review is restored (d56c0be), so treat that 9.18.0 line as withdrawn. Rounds 6 to 8 of the R2 review closed 7 findings and deferred 10, with 0 critical or high (7a0f7b3, 39f071f, 70bb89f).
306
+ - **Known limitations:** the head pre-fill matches an `honest-null` literal anywhere in a commit body, so a commit *about* the mechanism gets cited as one — it did here, for 2f23d6b, and a human dropped it. The derived line is a draft, never a claim; the gate blocks only a `_none_` the span contradicts and never judges a filled field.
307
+
308
+ ### Bug Fixes
309
+
310
+ * **release:** derive the head in --dry-run too, so the preview matches ([2f23d6b](https://github.com/event4u-app/agent-config/commit/2f23d6bb963577af5741951962b48e556e5a3e97))
311
+ * **release:** stop the generator writing a `_none_` the gate rejects ([8fe60e9](https://github.com/event4u-app/agent-config/commit/8fe60e9aba5192c07f8e33bc6ce1747a77e82922))
312
+ * **gates:** a bare fence never delimits a region — close the fourth fail-open route ([9937ad9](https://github.com/event4u-app/agent-config/commit/9937ad9b75f60bffd4ad42f2a830589bd4be7e5c))
313
+ * **gates:** an unterminated fence must not swallow the rest of a findings artefact ([55dc01d](https://github.com/event4u-app/agent-config/commit/55dc01d1fd3c8dbf44dd04fa7b3f41e3f4bc39bb))
314
+ * **gates:** IaC and extensionless build files are code; a row is exactly six cells ([6c5f0f5](https://github.com/event4u-app/agent-config/commit/6c5f0f5999b8a421cf765269a8da8b780465dc98))
315
+
316
+ ### Documentation
317
+
318
+ * **review:** close round 8 — 1 fixed, 4 deferred, 0 critical ([70bb89f](https://github.com/event4u-app/agent-config/commit/70bb89f2820f4c5a5a26cd25f5db2daa53b08e5e))
319
+ * **review:** close round 7 — 2 fixed, 3 deferred, 0 critical/high ([39f071f](https://github.com/event4u-app/agent-config/commit/39f071fad58dc2c5cb9e6ec87e8e37b79085db2f))
320
+ * **gates:** in-place re-binding is the normal fix-pass path (2.1/2.5/2.7) ([1f7d140](https://github.com/event4u-app/agent-config/commit/1f7d1404c4bc63e87d4b46f91d52f81f2ae51f7b))
321
+ * **review:** round-7 R2 findings — 2 medium, 3 low, 0 critical/high ([2bd3eda](https://github.com/event4u-app/agent-config/commit/2bd3edad82ff69700f62c4d16c93f38a484e5e08))
322
+ * **review:** close round 6 — 4 fixed, 3 deferred, 0 critical/high ([7a0f7b3](https://github.com/event4u-app/agent-config/commit/7a0f7b33aed30921f2404ae1955a8e5aa76dde9b))
323
+ * **gates:** two claims corrected to match the code (round-6 lows) ([0f5d43a](https://github.com/event4u-app/agent-config/commit/0f5d43a17be245b034287b0f486839fb156f8b86))
324
+ * **review:** restore the real binding review — 6c6fc15a9's honest-null was false ([d56c0be](https://github.com/event4u-app/agent-config/commit/d56c0be63f6782183737ab50256401ab52a1f38f))
325
+
326
+ Tests: 10963 (+19 since 9.18.0)
327
+
274
328
  ## [9.18.0](https://github.com/event4u-app/agent-config/compare/9.17.0...9.18.0) (2026-08-04)
275
329
 
276
330
  ### Release highlights
@@ -39,7 +39,7 @@ schema_version: 1
39
39
  # CI guard: a release bump of `package.json` must update this value
40
40
  # in lockstep — see scripts/check_template_pin_drift.ts (road-to-
41
41
  # portable-runtime-and-update-check P3.3).
42
- agent_config_version: "9.18.0"
42
+ agent_config_version: "9.18.1"
43
43
 
44
44
  # --- Project identity ---
45
45
  project:
@@ -1,6 +1,6 @@
1
1
  # Discovery — Deprecation Report
2
2
 
3
- - Generated: `2026-08-04T14:13:41Z`
3
+ - Generated: `2026-08-04T15:34:05Z`
4
4
  - Deprecated artefacts: **0**
5
5
 
6
6
  _None. Tree is clean._
@@ -14402,7 +14402,7 @@
14402
14402
  "reason": "scaffold template for ticket-bundle tickets (ADR-101); carries no runtime frontmatter — emit-tickets fills it in per ticket"
14403
14403
  }
14404
14404
  ],
14405
- "generated_at": "2026-08-04T14:13:41Z",
14405
+ "generated_at": "2026-08-04T15:34:05Z",
14406
14406
  "packs": [
14407
14407
  {
14408
14408
  "artefact_count": 117,
@@ -1 +1 @@
1
- 4a7dadd2d33fe1127bfd31cb3bdf58c83d3609c17b740e06e8666569b760cf65 discovery-manifest.json
1
+ 9221573649a81d2b695576370a341ecdcd2d64ba0246a685d3caaf4582a78ca8 discovery-manifest.json
@@ -1,6 +1,6 @@
1
1
  # Discovery Manifest — Summary
2
2
 
3
- - Generated: `2026-08-04T14:13:41Z`
3
+ - Generated: `2026-08-04T15:34:05Z`
4
4
  - Scanner: `c24312e116f8`
5
5
  - Artefacts: **594**
6
6
  - Unassigned: **0**
@@ -1,6 +1,6 @@
1
1
  # Discovery — Orphan Report
2
2
 
3
- - Generated: `2026-08-04T14:13:41Z`
3
+ - Generated: `2026-08-04T15:34:05Z`
4
4
  - Orphan artefacts: **0**
5
5
 
6
6
  > An orphan is an artefact whose declared pack has no other members.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "checksum": "sha256:d766f7f17140ce6b648e68821c9403c0bf478609a1ea925e85501c5994002a05",
3
- "generated_at": "2026-08-04T14:13:41Z",
3
+ "generated_at": "2026-08-04T15:34:05Z",
4
4
  "packs": [
5
5
  {
6
6
  "always_on": false,
@@ -1,6 +1,6 @@
1
1
  # Discovery — Trust Report
2
2
 
3
- - Generated: `2026-08-04T14:13:41Z`
3
+ - Generated: `2026-08-04T15:34:05Z`
4
4
  - Workspaces tracked: **10**
5
5
  - Human-review-required artefacts: **3**
6
6
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "checksum": "sha256:d766f7f17140ce6b648e68821c9403c0bf478609a1ea925e85501c5994002a05",
3
- "generated_at": "2026-08-04T14:13:41Z",
3
+ "generated_at": "2026-08-04T15:34:05Z",
4
4
  "scanner_version": "c24312e116f8",
5
5
  "workspaces": [
6
6
  {
@@ -9,7 +9,7 @@
9
9
  "homepage": "https://event4u-app.github.io/agent-config",
10
10
  "name": "@event4u/agent-config",
11
11
  "repository": "https://github.com/event4u-app/agent-config",
12
- "version": "9.18.0"
12
+ "version": "9.18.1"
13
13
  },
14
14
  "registries": [
15
15
  {
@@ -9,12 +9,12 @@
9
9
  "transport": {
10
10
  "type": "stdio"
11
11
  },
12
- "version": "9.18.0"
12
+ "version": "9.18.1"
13
13
  }
14
14
  ],
15
15
  "repository": {
16
16
  "source": "github",
17
17
  "url": "https://github.com/event4u-app/agent-config"
18
18
  },
19
- "version": "9.18.0"
19
+ "version": "9.18.1"
20
20
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@event4u/agent-config",
3
3
  "mcpName": "io.github.event4u-app/agent-config",
4
- "version": "9.18.0",
4
+ "version": "9.18.1",
5
5
  "description": "Governed skills, rules and commands for AI coding tools (Claude Code, Cursor, Windsurf, Copilot) — every claim machine-checked, including \"zero runtime daemon\".",
6
6
  "license": "MIT",
7
7
  "private": false,
@@ -329,8 +329,12 @@ gates:
329
329
  Gate R2 (docs/contracts/plan-review-gates.md § 2). HONEST LIMIT, stated
330
330
  because a floor that cannot fail is exactly the false green this file
331
331
  exists to prevent: scanned = artefacts inspected + 1 for the diff
332
- evaluation, so it is >= 1 by construction and `min_scanned: 1` can never
333
- trip. NOTHING ELSE TRIPS EITHER DURING THE ADVISORY WINDOW. The argv above
332
+ evaluation, so on any run that REACHES the count `min_scanned: 1` cannot
333
+ trip. It is not unreachable in principle — a moved reviews root exits
334
+ before the inventory and reports `scanned: 0`, which the guard does read —
335
+ but that path is the dead-scope violation below, so the floor never adds
336
+ anything the assertion does not already catch.
337
+ NOTHING TRIPS AT ALL DURING THE ADVISORY WINDOW. The argv above
334
338
  mirrors the CI invocation, and under `--advisory` every violation kind —
335
339
  dead-scan-scope included — is printed as a warning and the exit code is 0.
336
340
  So for the duration of the Stage-A window this entry proves only that the
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Shared release-highlight classifier — one definition of the curated head's
3
+ * labels and of the span → category derivation.
4
+ *
5
+ * Used by BOTH sides on purpose:
6
+ *
7
+ * - the generator (`release.ts`) pre-fills the curated head with what it can
8
+ * substantiate from the span;
9
+ * - the gate (`check_release_highlights.ts`) blocks a `_none_` the span
10
+ * contradicts.
11
+ *
12
+ * Why shared instead of duplicated. Before this module the generator emitted
13
+ * `_none_` for all five fields while the gate rejected exactly that default
14
+ * the moment the span carried evidence. Every release of this package touches
15
+ * `src/rules/` or `src/scripts/schemas/`, so "Behaviour changes" is always
16
+ * substantiated and every release PR was red on its FIRST run by construction
17
+ * — 9.17.0 (run 30871194277) and 9.18.0 (run 30909511315) both hit it, on the
18
+ * same step, for the same reason.
19
+ *
20
+ * This is not a weaker gate. The generator no longer manufactures a claim it
21
+ * cannot support, and the gate keeps full teeth for the failure it was built
22
+ * for: a human editing a substantiated line back down to `_none_`, which is
23
+ * exactly what produced the false 9.13.0 and 9.14.0 heads. Machine-written
24
+ * `_none_` was never that failure; it was noise standing in front of it.
25
+ */
26
+ import { spawnSync } from 'node:child_process';
27
+
28
+ /** The five curated labels, in the order an operator reads them. */
29
+ export const HEAD_LABELS: readonly string[] = [
30
+ 'Behaviour changes',
31
+ 'Default changes + migration',
32
+ 'Security and correctness',
33
+ 'Honest nulls',
34
+ 'Known limitations',
35
+ ];
36
+
37
+ export const HEAD_NONE = '_none_';
38
+
39
+ /**
40
+ * Prefix marking a head line the generator derived rather than a human wrote.
41
+ *
42
+ * Machine-checkable so the gate can warn when one survives to merge, and
43
+ * readable so the line still says something true to anyone who ships it
44
+ * unedited. It is not a placeholder token: the line it introduces carries real
45
+ * evidence (the reason plus the citing SHAs), so nothing here is
46
+ * wrong-if-shipped — only unpolished-if-unedited.
47
+ */
48
+ export const DERIVED_MARKER = '_auto-derived, rewrite before merge:_';
49
+
50
+ /** Cap on cited SHAs per line, so a wide release cannot unbound the head. */
51
+ export const DERIVED_SHA_CAP = 6;
52
+
53
+ export interface SpanCommit {
54
+ sha: string;
55
+ subject: string;
56
+ body: string;
57
+ /** `A` / `M` / `D` / `R…` status per touched path. */
58
+ files: ReadonlyArray<{ status: string; path: string }>;
59
+ breaking: boolean;
60
+ }
61
+
62
+ /** One derived hit: the commit that substantiates a label. */
63
+ export interface CategoryHit {
64
+ sha: string;
65
+ /** Rendered detail — the subject, plus a removed-surface note when any. */
66
+ text: string;
67
+ }
68
+
69
+ /** Public artefact trees whose deletions count as removed public surface. */
70
+ const _PUBLIC_SURFACE_PREFIXES = [
71
+ 'src/skills/',
72
+ 'src/rules/',
73
+ 'src/agent-src/commands/',
74
+ 'src/domains/',
75
+ ];
76
+
77
+ const _RULE_OR_SCHEMA_PREFIXES = ['src/rules/', 'src/scripts/schemas/'];
78
+
79
+ /**
80
+ * Why a label fired, in generator-facing prose. Keyed by label; a label absent
81
+ * here is never derived (`Known limitations` is pure prose, not checkable).
82
+ */
83
+ const _DERIVED_REASON: Readonly<Record<string, string>> = {
84
+ 'Behaviour changes': 'rule/schema diffs, breaking commits or removed public surface',
85
+ 'Default changes + migration': 'commits naming a default, migration or migrate',
86
+ 'Security and correctness': 'security-scoped commits',
87
+ 'Honest nulls': 'commits carrying an honest-null marker',
88
+ };
89
+
90
+ /**
91
+ * Derive evidence per curated label from the span. Rules per label:
92
+ *
93
+ * - Security and correctness: conventional scope matching /secur/i, or the
94
+ * whole word "security" in the subject.
95
+ * - Behaviour changes: breaking (`!` / BREAKING CHANGE) commits, diffs
96
+ * touching `src/rules/` or `src/scripts/schemas/`, and deletions under the
97
+ * public artefact trees (removed public surface).
98
+ * - Default changes + migration: subject carrying the whole word "default",
99
+ * "migration", or "migrate".
100
+ * - Honest nulls: subject or body carrying an "honest null" marker.
101
+ * - Known limitations: never derived — pure prose, not gate-checkable.
102
+ *
103
+ * Derivation is deliberately conservative: a false red makes every release
104
+ * annoying, a miss only returns the head to the pre-gate state.
105
+ */
106
+ export function derive_category_hits(
107
+ commits: readonly SpanCommit[],
108
+ ): Record<string, CategoryHit[]> {
109
+ const out: Record<string, CategoryHit[]> = {};
110
+ for (const label of HEAD_LABELS) {
111
+ out[label] = [];
112
+ }
113
+ for (const c of commits) {
114
+ const scopeMatch = /^\w+\(([^)]*)\)!?:/u.exec(c.subject);
115
+ const scope = scopeMatch ? scopeMatch[1]! : '';
116
+ if (/secur/iu.test(scope) || /\bsecurity\b/iu.test(c.subject)) {
117
+ out['Security and correctness']!.push({ sha: c.sha, text: c.subject });
118
+ }
119
+ const removedPublic = c.files.filter(
120
+ (f) =>
121
+ f.status.startsWith('D') &&
122
+ _PUBLIC_SURFACE_PREFIXES.some((p) => f.path.startsWith(p)),
123
+ );
124
+ const ruleOrSchema = c.files.some((f) =>
125
+ _RULE_OR_SCHEMA_PREFIXES.some((p) => f.path.startsWith(p)),
126
+ );
127
+ if (c.breaking || ruleOrSchema || removedPublic.length > 0) {
128
+ const text =
129
+ removedPublic.length > 0
130
+ ? `${c.subject} (removes ${removedPublic.map((f) => f.path).join(', ')})`
131
+ : c.subject;
132
+ out['Behaviour changes']!.push({ sha: c.sha, text });
133
+ }
134
+ if (/\b(default|migration|migrate)\b/iu.test(c.subject)) {
135
+ out['Default changes + migration']!.push({ sha: c.sha, text: c.subject });
136
+ }
137
+ if (/honest[ -]null/iu.test(`${c.subject}\n${c.body}`)) {
138
+ out['Honest nulls']!.push({ sha: c.sha, text: c.subject });
139
+ }
140
+ }
141
+ return out;
142
+ }
143
+
144
+ /**
145
+ * String-rendered evidence per label (`<sha7> <detail>`), the shape the gate
146
+ * reports to a human. Derived from the structured hits so the two can never
147
+ * disagree.
148
+ */
149
+ export function derive_categories(commits: readonly SpanCommit[]): Record<string, string[]> {
150
+ const hits = derive_category_hits(commits);
151
+ const out: Record<string, string[]> = {};
152
+ for (const label of HEAD_LABELS) {
153
+ out[label] = (hits[label] ?? []).map((h) => `${h.sha.slice(0, 7)} ${h.text}`);
154
+ }
155
+ return out;
156
+ }
157
+
158
+ /**
159
+ * Pre-fill values for the curated head, one line per SUBSTANTIATED label.
160
+ *
161
+ * A label with no evidence is omitted, so the caller's `_none_` default still
162
+ * applies where `_none_` is the true answer. One line per label keeps the
163
+ * rendered head inside its cap; SHA citations are capped at `DERIVED_SHA_CAP`
164
+ * with an explicit remainder count rather than a silent truncation.
165
+ */
166
+ export function render_derived_head_values(
167
+ hits: Readonly<Record<string, readonly CategoryHit[]>>,
168
+ ): Record<string, string> {
169
+ const out: Record<string, string> = {};
170
+ for (const label of HEAD_LABELS) {
171
+ const reason = _DERIVED_REASON[label];
172
+ const ev = hits[label] ?? [];
173
+ if (!reason || ev.length === 0) {
174
+ continue;
175
+ }
176
+ const shas = ev.map((h) => h.sha.slice(0, 7));
177
+ const shown = shas.slice(0, DERIVED_SHA_CAP);
178
+ const remainder = shas.length - shown.length;
179
+ const more = remainder > 0 ? ` +${remainder} more` : '';
180
+ out[label] = `${DERIVED_MARKER} ${reason} in ${shown.join(', ')}${more}.`;
181
+ }
182
+ return out;
183
+ }
184
+
185
+ /** Labels whose curated value is still the generator's unedited draft. */
186
+ export function stale_draft_labels(
187
+ curated: Readonly<Record<string, string>>,
188
+ ): string[] {
189
+ return HEAD_LABELS.filter((l) => (curated[l] ?? '').includes(DERIVED_MARKER));
190
+ }
191
+
192
+ // ─── git span collection ────────────────────────────────────────────────────
193
+
194
+ const _RECORD = '\u001e';
195
+ const _FIELD = '\u001f';
196
+
197
+ export function parse_git_log(raw: string): SpanCommit[] {
198
+ const commits: SpanCommit[] = [];
199
+ for (const record of raw.split(_RECORD)) {
200
+ if (!record.trim()) {
201
+ continue;
202
+ }
203
+ const [head, ...restLines] = record.split('\n');
204
+ const parts = (head ?? '').split(_FIELD);
205
+ if (parts.length < 3) {
206
+ continue;
207
+ }
208
+ const sha = parts[0]!.trim();
209
+ const subject = parts[1]!;
210
+ const bodyParts: string[] = [parts.slice(2).join(_FIELD)];
211
+ const files: Array<{ status: string; path: string }> = [];
212
+ for (const line of restLines) {
213
+ const m = /^([A-Z]\d*)\t(.+)$/u.exec(line);
214
+ if (m) {
215
+ // Renames carry two paths; the last tab field is the new path,
216
+ // the first the old — record both so a rename out of a public
217
+ // tree still counts its origin path.
218
+ const segs = m[2]!.split('\t');
219
+ for (const p of segs) {
220
+ files.push({ status: m[1]!, path: p });
221
+ }
222
+ } else {
223
+ // %b is multi-line — everything that is not a name-status
224
+ // line is body continuation (honest-null markers live there).
225
+ bodyParts.push(line);
226
+ }
227
+ }
228
+ const body = bodyParts.join('\n');
229
+ commits.push({
230
+ sha,
231
+ subject,
232
+ body,
233
+ files,
234
+ breaking: /^\w+(\([^)]*\))?!:/u.test(subject) || /BREAKING CHANGE/u.test(body),
235
+ });
236
+ }
237
+ return commits;
238
+ }
239
+
240
+ /** `git log` arguments for a span, shared so both callers read one format. */
241
+ export function span_log_args(from: string | null, to: string): string[] {
242
+ return [
243
+ 'log',
244
+ from ? `${from}..${to}` : to,
245
+ '--no-merges',
246
+ '--name-status',
247
+ '--format=%x1e%H%x1f%s%x1f%b',
248
+ ];
249
+ }
250
+
251
+ /**
252
+ * Collect the span. Throws on git failure so a caller can decide whether the
253
+ * derivation is load-bearing (the gate) or best-effort (the generator).
254
+ */
255
+ export function collect_span_commits(from: string | null, to: string, cwd: string): SpanCommit[] {
256
+ const r = spawnSync('git', span_log_args(from, to), {
257
+ encoding: 'utf-8',
258
+ cwd,
259
+ maxBuffer: 64 * 1024 * 1024,
260
+ });
261
+ if (r.status !== 0) {
262
+ throw new Error(`git ${span_log_args(from, to).join(' ')} failed: ${r.stderr}`);
263
+ }
264
+ return parse_git_log(r.stdout);
265
+ }
266
+
267
+ /** Latest reachable release tag before `ref`, or null when there is none. */
268
+ export function previous_release_tag(ref: string, cwd: string): string | null {
269
+ const r = spawnSync(
270
+ 'git',
271
+ ['describe', '--tags', '--abbrev=0', '--match', '[0-9]*.[0-9]*.[0-9]*', `${ref}^`],
272
+ { encoding: 'utf-8', cwd },
273
+ );
274
+ if (r.status !== 0) {
275
+ return null;
276
+ }
277
+ const tag = r.stdout.trim();
278
+ return tag === '' ? null : tag;
279
+ }
@@ -18,10 +18,15 @@
18
18
  * with the required Reason/Ref content;
19
19
  * - every table-shaped row parses into the six §2.2 cells (`malformed-row`) —
20
20
  * a short row is never silently dropped, or an `open` finding would pass;
21
+ * - ``` fences resolve (`unbalanced-fence`) — only a LABELLED, properly-closed
22
+ * fence hides content, so no arrangement of bare fences can swallow a row,
23
+ * for the same reason;
21
24
  * - severity rows are sorted descending (critical > high > medium > low);
22
25
  * - findings-before-fixes ancestry: the commit that FIRST added the
23
26
  * artifact is an ancestor of every referenced fix commit (§2.5 — the
24
- * first-add commit is what counts, so backdating is detected too).
27
+ * first-add commit is what counts, so backdating is detected too; it is the
28
+ * earliest add of the PATH, so the guarantee covers the artefact's FIRST
29
+ * round, and per-round ordering stays convention — §2.5 names that limit).
25
30
  *
26
31
  * The review binds to the review-SCOPE HASH, never to a commit sha. That
27
32
  * definition is owned by `dispatch_r2_reviewer.ts` and imported here so the
@@ -114,6 +119,16 @@ export interface ParsedArtifact {
114
119
  * defect class.
115
120
  */
116
121
  malformedRows: string[];
122
+ /**
123
+ * 1-based lines of every stray ``` fence, ascending (§2.2).
124
+ *
125
+ * A stray is a BARE ``` (which never delimits a region) or a labelled opener
126
+ * that is never closed. All of them are reported as one `unbalanced-fence`
127
+ * violation naming every line — a single number could only ever name the
128
+ * last. Stray fences skip nothing: the lines around them are parsed, because
129
+ * no fence arrangement may swallow an `open` row (see {@link scanFences}).
130
+ */
131
+ strayFenceLines: number[];
117
132
  /** The §5 context manifest, when present and parseable. */
118
133
  manifest: ParsedManifest | null;
119
134
  }
@@ -181,8 +196,46 @@ const CODE_EXTENSIONS = new Set([
181
196
  'erl',
182
197
  'dart',
183
198
  'r',
199
+ // Infrastructure-as-code and build definitions. Production behaviour lives
200
+ // here as much as in application code — this suite ships terraform /
201
+ // terragrunt / aws-infrastructure skills and an engineering-safety-floor
202
+ // that treats these files as prod surfaces, so omitting them let an
203
+ // IaC-only completion claim "no code surface" and take the skip path.
204
+ 'tf',
205
+ 'tfvars',
206
+ 'hcl',
207
+ 'gradle',
208
+ 'cmake',
209
+ 'proto',
210
+ 'bicep',
184
211
  ]);
185
212
 
213
+ /**
214
+ * Extensionless build / infra files. `isCodePath` decides by extension, so a
215
+ * `Dockerfile` or `Makefile` (no dot) fell through to "not code" — the same
216
+ * hole as a missing extension, one step further along.
217
+ */
218
+ const CODE_BASENAMES = new Set([
219
+ 'dockerfile',
220
+ 'containerfile',
221
+ 'makefile',
222
+ 'jenkinsfile',
223
+ 'vagrantfile',
224
+ 'procfile',
225
+ 'brewfile',
226
+ 'rakefile',
227
+ 'gemfile',
228
+ 'justfile',
229
+ ]);
230
+
231
+ /**
232
+ * Tails that turn a {@link CODE_BASENAMES} stem into generated or prose state
233
+ * rather than code — `Gemfile.lock`, `Makefile.md`. Kept narrow on purpose: an
234
+ * unknown tail (`Dockerfile.prod`) stays code, so the classification errs broad
235
+ * exactly as contract §2.4 promises.
236
+ */
237
+ const GENERATED_TAILS = new Set(['lock', 'md', 'txt', 'json', 'yml', 'yaml', 'bak', 'orig']);
238
+
186
239
  /**
187
240
  * Template extensions whose *inner* extension decides. `foo.blade.php` is
188
241
  * already caught by `php`; `foo.html.twig` / `foo.j2` are not, so they are
@@ -216,6 +269,18 @@ export function isCodePath(p: string): boolean {
216
269
  if (CODE_SUFFIXES.some((s) => base.endsWith(s))) {
217
270
  return true;
218
271
  }
272
+ // Extensionless build/infra files (Dockerfile, Makefile, …) plus their
273
+ // environment variants (`Dockerfile.prod`, `Makefile.local`). A generated
274
+ // sibling that merely shares the stem is NOT code — `Gemfile.lock` is
275
+ // dependency state, the same class as the `composer.json` this function
276
+ // already excludes.
277
+ const stem = base.includes('.') ? base.slice(0, base.indexOf('.')) : base;
278
+ if (CODE_BASENAMES.has(stem)) {
279
+ const tail = base.slice(stem.length + 1);
280
+ if (tail === '' || !GENERATED_TAILS.has(tail)) {
281
+ return true;
282
+ }
283
+ }
219
284
  const dot = base.lastIndexOf('.');
220
285
  if (dot <= 0) {
221
286
  return false;
@@ -301,6 +366,91 @@ function splitTableRow(line: string): string[] {
301
366
  }
302
367
 
303
368
  const FENCE_RE = /^\s*```/;
369
+ /** A ```-prefixed line split into its backtick run and its info string. */
370
+ const FENCE_PARTS_RE = /^\s*(`{3,})(.*)$/;
371
+
372
+ export interface FenceScan {
373
+ /** 0-based indices of lines inside a LABELLED, properly-closed fence — the only lines to skip. */
374
+ fenced: Set<number>;
375
+ /**
376
+ * 1-based line numbers of every stray ``` fence, ascending.
377
+ *
378
+ * A stray is a bare ``` that opens nothing (no region is ever delimited by
379
+ * one) or a labelled opener that is never closed. Empty = fences are sound.
380
+ */
381
+ strays: number[];
382
+ }
383
+
384
+ /** Backtick-run length and info string of a fence line, or null if not a fence. */
385
+ function fenceParts(line: string): { ticks: number; info: string } | null {
386
+ const m = FENCE_PARTS_RE.exec(line);
387
+ return m ? { ticks: (m[1] as string).length, info: (m[2] as string).trim() } : null;
388
+ }
389
+
390
+ /**
391
+ * Which lines are fenced — and which ``` fences are stray?
392
+ *
393
+ * Fenced regions exist for ONE reason: an *illustrative* template row (the §2.2
394
+ * `| # | Severity | …` example a doc-ish artefact quotes) must not be read as a
395
+ * live finding. Nothing else justifies making a line invisible, so the
396
+ * delimiter has to be something no author produces by accident.
397
+ *
398
+ * Two earlier shapes both failed OPEN, and they are the same defect:
399
+ * - a single `inFence` toggle made every line after an ODD ```-count invisible;
400
+ * - POSITIONAL pairing then detected parity but never MIS-pairing — two
401
+ * unpaired inner openers (two `~~~`-wrapped illustrations, each holding one
402
+ * stray ``` — exactly what `markdown-safe-codeblocks` produces, since the
403
+ * outer `~~~` is not a fence to this grammar) paired with EACH OTHER and
404
+ * swallowed every line between them, `unterminatedAt` staying `null`.
405
+ * In both, one earlier well-formed row kept `rows.length > 0`, so the
406
+ * neither-table-nor-honest-null fallback also stayed quiet and an unreviewed
407
+ * `open` row PASSED — the same fail-open the §2.2 `malformed-row` rule closes by
408
+ * a different route. Patching parity, then pairing, then the next arrangement is
409
+ * a losing game, so the discriminator is fixed instead of the arithmetic:
410
+ *
411
+ * A region is skipped ONLY when it is a properly-closed pair whose OPENING
412
+ * fence carries an info string (```` ```markdown ````). A BARE ``` never
413
+ * delimits a region: it is recorded as a stray, the lines around it are parsed
414
+ * as ordinary content, and the artefact is reported `unbalanced-fence`.
415
+ *
416
+ * So no arrangement of bare fences can hide a row — a swallowed `open` finding
417
+ * needs a deliberate label, which is the authoring act that means "illustration".
418
+ * Closing follows CommonMark (bare, and at least as many backticks as the
419
+ * opener), which also makes a ```` ````-wrapped ``` block nest correctly. An
420
+ * unterminated labelled opener skips nothing either and is itself a stray.
421
+ */
422
+ export function scanFences(lines: readonly string[]): FenceScan {
423
+ const fenced = new Set<number>();
424
+ const strays: number[] = [];
425
+ let open: { at: number; ticks: number } | null = null;
426
+ for (let i = 0; i < lines.length; i++) {
427
+ const parts = fenceParts(lines[i] as string);
428
+ if (!parts) {
429
+ continue;
430
+ }
431
+ if (open !== null) {
432
+ // CommonMark closer: bare, and at least as long as the opener.
433
+ // Anything else is content inside the open region.
434
+ if (parts.info === '' && parts.ticks >= open.ticks) {
435
+ for (let j = open.at + 1; j < i; j++) {
436
+ fenced.add(j);
437
+ }
438
+ open = null;
439
+ }
440
+ continue;
441
+ }
442
+ if (parts.info === '') {
443
+ strays.push(i + 1);
444
+ continue;
445
+ }
446
+ open = { at: i, ticks: parts.ticks };
447
+ }
448
+ if (open !== null) {
449
+ strays.push(open.at + 1);
450
+ }
451
+ strays.sort((a, b) => a - b);
452
+ return { fenced, strays };
453
+ }
304
454
 
305
455
  export function parseArtifact(text: string): ParsedArtifact {
306
456
  const out: ParsedArtifact = {
@@ -311,18 +461,20 @@ export function parseArtifact(text: string): ParsedArtifact {
311
461
  rows: [],
312
462
  malformedLines: [],
313
463
  malformedRows: [],
464
+ strayFenceLines: [],
314
465
  manifest: parseManifest(text),
315
466
  };
316
467
  const lines = text.split(/\r?\n/);
317
- let inFence = false;
468
+ const fences = scanFences(lines);
469
+ out.strayFenceLines = fences.strays;
318
470
  for (let i = 0; i < lines.length; i++) {
319
471
  const raw = lines[i] as string;
320
472
  const lineno = i + 1;
321
- if (FENCE_RE.test(raw)) {
322
- inFence = !inFence;
323
- continue;
324
- }
325
- if (inFence) {
473
+ // A fence marker line carries no grammar of its own, and a line inside a
474
+ // LABELLED closed fence is illustrative content. A stray fence (bare, or
475
+ // an unclosed opener) skips nothing (see scanFences) — the lines around
476
+ // it are parsed, and the artefact is reported.
477
+ if (FENCE_RE.test(raw) || fences.fenced.has(i)) {
326
478
  continue;
327
479
  }
328
480
  const trimmed = raw.trim();
@@ -371,7 +523,12 @@ export function parseArtifact(text: string): ParsedArtifact {
371
523
  if (first === '#' || second === 'severity') {
372
524
  continue; // header row
373
525
  }
374
- if (cells.length >= 6) {
526
+ // EXACTLY six, per contract §2.2 — not `>= 6`. An over-long row is
527
+ // as malformed as a short one and the surplus is usually the tell:
528
+ // an unescaped `|` inside a cell (the shape §2.2 requires escaping)
529
+ // splits one cell into two and shifts Status/Reason-Ref rightwards,
530
+ // so accepting it silently reads the wrong cell as the status.
531
+ if (cells.length === 6) {
375
532
  out.rows.push({
376
533
  index: cells[0] ?? '',
377
534
  severity: (cells[1] ?? '').toLowerCase(),
@@ -390,7 +547,7 @@ export function parseArtifact(text: string): ParsedArtifact {
390
547
  // §2.2 template ends in exactly that empty cell. Gate R1 already
391
548
  // reports this class — same name, `malformed_row`.
392
549
  out.malformedRows.push(
393
- `line ${lineno}: findings row has ${String(cells.length)} cell(s), expected 6 ` +
550
+ `line ${lineno}: findings row has ${String(cells.length)} cell(s), expected exactly 6 ` +
394
551
  '(`| # | Severity | File:Line | Finding | Status | Reason/Ref |` — the trailing ' +
395
552
  'Reason/Ref cell is required even when empty)',
396
553
  );
@@ -605,6 +762,24 @@ function evaluate(input: EvalInput): Violation[] {
605
762
  for (const detail of art.malformedRows) {
606
763
  violations.push({ kind: 'malformed-row', file, detail });
607
764
  }
765
+ // §2.2: stray ``` fences — a BARE fence (which delimits nothing) or a
766
+ // labelled opener left unclosed. The parser swallows no line around them
767
+ // (so an `open` row still blocks), but an artefact whose fences do not
768
+ // resolve is malformed in its own right — the reader cannot tell
769
+ // illustrative content from live rows. ONE violation naming EVERY stray:
770
+ // a single line number could only ever name the last one.
771
+ if (art.strayFenceLines.length > 0) {
772
+ const at = art.strayFenceLines.map((n) => `line ${String(n)}`).join(', ');
773
+ violations.push({
774
+ kind: 'unbalanced-fence',
775
+ file,
776
+ detail:
777
+ `${at}: stray \`\`\` fence — a bare fence delimits no region, and a labelled fence must ` +
778
+ 'be closed. An artefact whose ``` fences do not resolve is malformed (contract §2.2). ' +
779
+ 'Label the opener (```markdown) and close it, or inline-escape an illustrative ```; ' +
780
+ 'the lines around a stray fence are still parsed, so any finding row there is validated too.',
781
+ });
782
+ }
608
783
  }
609
784
 
610
785
  for (const { file, art } of relevant) {
@@ -757,6 +932,13 @@ function checkFindingsBeforeFixes(repo: string, artifactAbs: string, rows: reado
757
932
  // Earliest add commit: `git log --diff-filter=A` lists newest-first, so the
758
933
  // LAST line is the first commit that ever added the file (backdating via a
759
934
  // later amend does not move it).
935
+ //
936
+ // Scope of the guarantee, per contract §2.5: this resolves the earliest add
937
+ // of the PATH, not of the current round's artefact — a re-bound (or renamed
938
+ // and re-added) `<slug>.findings.md` keeps round 1's add. So the check proves
939
+ // "the review file existed before the fixes of its FIRST round", and
940
+ // per-round ordering stays agent-carried convention. Deliberate, and named
941
+ // as a limit in the contract rather than implied away.
760
942
  const logRes = gitTry(repo, ['log', '--diff-filter=A', '--format=%H', '--', rel]);
761
943
  const addLines = logRes.stdout
762
944
  .split('\n')
@@ -11,8 +11,14 @@
11
11
  * commit types + rule/schema diffs, honest-null markers, removed public
12
12
  * surface) and FAILS when a populated generated category meets a `_none_`
13
13
  * curated field. It blocks the contradiction only — a human still writes the
14
- * prose; nothing here auto-formulates highlights, and a filled field is
15
- * never judged for quality.
14
+ * prose, and a filled field is never judged for quality.
15
+ *
16
+ * The classifier lives in `_lib/release_highlights.ts` and is shared with the
17
+ * generator, which now pre-fills each substantiated label instead of writing
18
+ * `_none_` everywhere. That removes the guaranteed first-run red (9.17.0,
19
+ * 9.18.0) without blunting this gate: a machine-written `_none_` was never the
20
+ * failure it was built for — a HUMAN writing one was (9.13.0, 9.14.0), and that
21
+ * still fails here. An unrewritten derived line warns; it never blocks.
16
22
  *
17
23
  * Derivation is deliberately conservative (documented per-label below):
18
24
  * a false red makes every release annoying; a miss only returns the head to
@@ -25,96 +31,41 @@
25
31
  * `--from` defaults to the latest reachable release tag before `--to`
26
32
  * (default HEAD). Exit codes: 0 plausible · 1 contradiction · 2 usage error.
27
33
  */
28
- import { spawnSync } from 'node:child_process';
29
34
  import * as fs from 'node:fs';
30
35
  import * as path from 'node:path';
31
36
  import process from 'node:process';
32
37
  import { fileURLToPath } from 'node:url';
33
38
 
39
+ import {
40
+ HEAD_LABELS,
41
+ HEAD_NONE,
42
+ type SpanCommit,
43
+ collect_span_commits,
44
+ derive_categories,
45
+ parse_git_log,
46
+ previous_release_tag,
47
+ stale_draft_labels,
48
+ } from './_lib/release_highlights.js';
34
49
  import { extract_changelog_section } from './_lib/release_material.js';
35
50
 
36
51
  const _HERE = fileURLToPath(import.meta.url);
37
52
  const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
38
53
 
39
- // The five curated labels, mirrored from release.ts RELEASE_HEAD_SECTIONS
40
- // (kept literal here so this checker has no import edge into the release
41
- // pipeline it audits; the unit tests pin the two lists against each other).
42
- export const HEAD_LABELS: readonly string[] = [
43
- 'Behaviour changes',
44
- 'Default changes + migration',
45
- 'Security and correctness',
46
- 'Honest nulls',
47
- 'Known limitations',
48
- ];
49
-
50
- export const HEAD_NONE = '_none_';
51
-
52
- export interface SpanCommit {
53
- sha: string;
54
- subject: string;
55
- body: string;
56
- /** `A` / `M` / `D` / `R…` status per touched path. */
57
- files: ReadonlyArray<{ status: string; path: string }>;
58
- breaking: boolean;
59
- }
60
-
61
- /** Public artefact trees whose deletions count as removed public surface. */
62
- const _PUBLIC_SURFACE_PREFIXES = [
63
- 'src/skills/',
64
- 'src/rules/',
65
- 'src/agent-src/commands/',
66
- 'src/domains/',
67
- ];
68
-
69
- const _RULE_OR_SCHEMA_PREFIXES = ['src/rules/', 'src/scripts/schemas/'];
70
-
71
- /**
72
- * Derive evidence per curated label from the span. Rules per label:
73
- *
74
- * - Security and correctness: conventional scope matching /secur/i, or the
75
- * whole word "security" in the subject.
76
- * - Behaviour changes: breaking (`!` / BREAKING CHANGE) commits, diffs
77
- * touching `src/rules/` or `src/scripts/schemas/`, and deletions under the
78
- * public artefact trees (removed public surface).
79
- * - Default changes + migration: subject carrying the whole word "default",
80
- * "migration", or "migrate".
81
- * - Honest nulls: subject or body carrying an "honest null" marker.
82
- * - Known limitations: never derived — pure prose, not gate-checkable.
83
- */
84
- export function derive_categories(commits: readonly SpanCommit[]): Record<string, string[]> {
85
- const out: Record<string, string[]> = {};
86
- for (const label of HEAD_LABELS) {
87
- out[label] = [];
88
- }
89
- const evidence = (c: SpanCommit): string => `${c.sha.slice(0, 7)} ${c.subject}`;
90
- for (const c of commits) {
91
- const scopeMatch = /^\w+\(([^)]*)\)!?:/u.exec(c.subject);
92
- const scope = scopeMatch ? scopeMatch[1]! : '';
93
- if (/secur/iu.test(scope) || /\bsecurity\b/iu.test(c.subject)) {
94
- out['Security and correctness']!.push(evidence(c));
95
- }
96
- const removedPublic = c.files.filter(
97
- (f) => f.status.startsWith('D') && _PUBLIC_SURFACE_PREFIXES.some((p) => f.path.startsWith(p)),
98
- );
99
- const ruleOrSchema = c.files.some((f) =>
100
- _RULE_OR_SCHEMA_PREFIXES.some((p) => f.path.startsWith(p)),
101
- );
102
- if (c.breaking || ruleOrSchema || removedPublic.length > 0) {
103
- const detail =
104
- removedPublic.length > 0
105
- ? `${evidence(c)} (removes ${removedPublic.map((f) => f.path).join(', ')})`
106
- : evidence(c);
107
- out['Behaviour changes']!.push(detail);
108
- }
109
- if (/\b(default|migration|migrate)\b/iu.test(c.subject)) {
110
- out['Default changes + migration']!.push(evidence(c));
111
- }
112
- if (/honest[ -]null/iu.test(`${c.subject}\n${c.body}`)) {
113
- out['Honest nulls']!.push(evidence(c));
114
- }
115
- }
116
- return out;
117
- }
54
+ // Labels and derivation live in `_lib/release_highlights.ts` — ONE definition
55
+ // shared with the generator that pre-fills the head. They used to be duplicated
56
+ // here to keep the checker independent of the release pipeline, and that
57
+ // independence is what produced the defect this gate now guards against from
58
+ // the other side: the generator wrote `_none_` into every field while this
59
+ // checker rejected exactly that, so every release PR was red on its first run.
60
+ // Sharing the classifier does not blunt the gate — it still blocks the failure
61
+ // it was built for, a human editing a substantiated line back to `_none_`.
62
+ export {
63
+ HEAD_LABELS,
64
+ HEAD_NONE,
65
+ derive_categories,
66
+ parse_git_log as _parse_git_log,
67
+ type SpanCommit,
68
+ };
118
69
 
119
70
  /**
120
71
  * Parse the curated head from a changelog section body. Returns null when
@@ -162,74 +113,6 @@ export function highlight_contradictions(
162
113
  return out;
163
114
  }
164
115
 
165
- // ─── git span collection (CLI only — the core above is pure) ────────────────
166
-
167
- const _RECORD = '\u001e';
168
- const _FIELD = '\u001f';
169
-
170
- export function _parse_git_log(raw: string): SpanCommit[] {
171
- const commits: SpanCommit[] = [];
172
- for (const record of raw.split(_RECORD)) {
173
- if (!record.trim()) {
174
- continue;
175
- }
176
- const [head, ...restLines] = record.split('\n');
177
- const parts = (head ?? '').split(_FIELD);
178
- if (parts.length < 3) {
179
- continue;
180
- }
181
- const sha = parts[0]!.trim();
182
- const subject = parts[1]!;
183
- const bodyParts: string[] = [parts.slice(2).join(_FIELD)];
184
- const files: Array<{ status: string; path: string }> = [];
185
- for (const line of restLines) {
186
- const m = /^([A-Z]\d*)\t(.+)$/u.exec(line);
187
- if (m) {
188
- // Renames carry two paths; the last tab field is the new path,
189
- // the first the old — record both so a rename out of a public
190
- // tree still counts its origin path.
191
- const segs = m[2]!.split('\t');
192
- for (const p of segs) {
193
- files.push({ status: m[1]!, path: p });
194
- }
195
- } else {
196
- // %b is multi-line — everything that is not a name-status
197
- // line is body continuation (honest-null markers live there).
198
- bodyParts.push(line);
199
- }
200
- }
201
- const body = bodyParts.join('\n');
202
- commits.push({
203
- sha,
204
- subject,
205
- body,
206
- files,
207
- breaking: /^\w+(\([^)]*\))?!:/u.test(subject) || /BREAKING CHANGE/u.test(body),
208
- });
209
- }
210
- return commits;
211
- }
212
-
213
- function _git(args: string[]): string {
214
- const r = spawnSync('git', args, { encoding: 'utf-8', cwd: REPO_ROOT, maxBuffer: 64 * 1024 * 1024 });
215
- if (r.status !== 0) {
216
- process.stderr.write(`git ${args.join(' ')} failed: ${r.stderr}\n`);
217
- process.exit(2);
218
- }
219
- return r.stdout;
220
- }
221
-
222
- function _collect_span(from: string, to: string): SpanCommit[] {
223
- const raw = _git([
224
- 'log',
225
- `${from}..${to}`,
226
- '--no-merges',
227
- '--name-status',
228
- '--format=%x1e%H%x1f%s%x1f%b',
229
- ]);
230
- return _parse_git_log(raw);
231
- }
232
-
233
116
  function main(argv: readonly string[]): number {
234
117
  let version: string | null = null;
235
118
  let from: string | null = null;
@@ -251,7 +134,13 @@ function main(argv: readonly string[]): number {
251
134
  return 2;
252
135
  }
253
136
  if (!from) {
254
- from = _git(['describe', '--tags', '--abbrev=0', '--match', '[0-9]*.[0-9]*.[0-9]*', `${to}^`]).trim();
137
+ from = previous_release_tag(to, REPO_ROOT);
138
+ if (!from) {
139
+ process.stderr.write(
140
+ `no release tag reachable before ${to} — pass --from explicitly\n`,
141
+ );
142
+ return 2;
143
+ }
255
144
  }
256
145
  const section = extract_changelog_section(fs.readFileSync(changelogPath, 'utf-8'), version);
257
146
  if (!section) {
@@ -263,7 +152,26 @@ function main(argv: readonly string[]): number {
263
152
  process.stdout.write(`ℹ️ no curated head in the ${version} section — nothing to check\n`);
264
153
  return 0;
265
154
  }
266
- const derived = derive_categories(_collect_span(from, to));
155
+ let span: SpanCommit[];
156
+ try {
157
+ span = collect_span_commits(from, to, REPO_ROOT);
158
+ } catch (err) {
159
+ process.stderr.write(`${(err as Error).message}\n`);
160
+ return 2;
161
+ }
162
+ // A generator-derived line that nobody rewrote is a prose gap, never a
163
+ // contradiction: the line's evidence is true, it is just unpolished. Warn
164
+ // so the omission is visible, and keep the exit code owned solely by the
165
+ // `_none_` check — a warning that reds the build is the guaranteed-red
166
+ // failure mode this whole change exists to remove.
167
+ const drafts = stale_draft_labels(curated);
168
+ if (drafts.length > 0) {
169
+ process.stdout.write(
170
+ `⚠️ auto-derived head line(s) not yet rewritten for ${version}: ` +
171
+ `${drafts.join(', ')} — advisory, not blocking.\n`,
172
+ );
173
+ }
174
+ const derived = derive_categories(span);
267
175
  const contradictions = highlight_contradictions(curated, derived);
268
176
  if (contradictions.length === 0) {
269
177
  process.stdout.write(`✅ curated head plausible for ${version} (span ${from}..${to})\n`);
@@ -437,11 +437,12 @@ const CI_BRANCH_ENV_KEYS = ['GITHUB_HEAD_REF', 'GITHUB_REF_NAME'] as const;
437
437
  * uses it to decide whether a leftover artifact in the reviews directory is
438
438
  * THIS branch's (and may therefore produce violations) or a foreign one.
439
439
  *
440
- * The CI environment is consulted FIRST because on a `pull_request` checkout
441
- * `HEAD` is a detached synthetic merge commit: `rev-parse --abbrev-ref HEAD`
442
- * yields `HEAD`, the slug degrades to `detached-<sha>`, and no artefact can
443
- * ever be "own" — which inverts contract §2.6 on the layer the contract calls
444
- * authoritative (an own malformed artefact would be reported as
440
+ * Resolution order is **git first, CI environment as the fallback** — see the
441
+ * reason inline below. The env fallback exists because on a `pull_request`
442
+ * checkout `HEAD` is a detached synthetic merge commit: `rev-parse --abbrev-ref
443
+ * HEAD` yields `HEAD`, the slug would degrade to `detached-<sha>`, and no
444
+ * artefact could ever be "own" — inverting contract §2.6 on the layer the
445
+ * contract calls authoritative (an own malformed artefact would report
445
446
  * `missing-artifact` instead of the root-cause `bad-marker`, and `stale-review`
446
447
  * would never fire in CI). A `HEAD` / `detached-*` env value carries no branch
447
448
  * identity either and is ignored.
@@ -118,6 +118,12 @@ import {
118
118
  perform_split,
119
119
  plan_split,
120
120
  } from './_lib/changelog_eras.js';
121
+ import {
122
+ HEAD_LABELS,
123
+ collect_span_commits,
124
+ derive_category_hits,
125
+ render_derived_head_values,
126
+ } from './_lib/release_highlights.js';
121
127
  import {
122
128
  extract_changelog_section,
123
129
  pr_body_from_section,
@@ -383,13 +389,7 @@ const SECTIONS: ReadonlyArray<readonly [string, string | null, readonly string[]
383
389
  * so the package does not end up with two competing definitions of a curated
384
390
  * head.
385
391
  */
386
- const RELEASE_HEAD_SECTIONS: ReadonlyArray<string> = [
387
- 'Behaviour changes',
388
- 'Default changes + migration',
389
- 'Security and correctness',
390
- 'Honest nulls',
391
- 'Known limitations',
392
- ];
392
+ const RELEASE_HEAD_SECTIONS: ReadonlyArray<string> = HEAD_LABELS;
393
393
 
394
394
  /**
395
395
  * Cap on the head, in rendered lines.
@@ -402,16 +402,44 @@ const RELEASE_HEAD_SECTIONS: ReadonlyArray<string> = [
402
402
  export const RELEASE_HEAD_CAP_LINES = 10;
403
403
 
404
404
  /**
405
- * The default value of every head line.
405
+ * The fallback value of a head line the span does not substantiate.
406
+ *
407
+ * `_none_` and not a placeholder token: where nothing was derived it is the
408
+ * **true** answer, and a release that genuinely changed no defaults should say
409
+ * so rather than carry an unfilled marker.
406
410
  *
407
- * `_none_` and not a placeholder token: for most releases it is the **true**
408
- * answer, and a release that genuinely changed no defaults should say so
409
- * rather than carry an unfilled marker. That also keeps the skeleton free of
410
- * the placeholder prose `output-discipline` bans — there is nothing here that
411
- * is wrong-if-shipped, only something that is uninformative-if-unedited.
411
+ * It is a fallback and no longer a blanket default. Writing `_none_` into
412
+ * every field made the generator assert five things it had not checked, and
413
+ * `check_release_highlights` rejected exactly that assertion the moment the
414
+ * span contradicted it — which for this package is every release, because every
415
+ * release touches `src/rules/` or `src/scripts/schemas/`. Substantiated labels
416
+ * are now pre-filled from the span (`_derive_head_prefill`), so the tool states
417
+ * only what it can support and `_none_` means what it says.
412
418
  */
413
419
  const HEAD_DEFAULT = '_none_';
414
420
 
421
+ /**
422
+ * Pre-fill values for the curated head from the release span.
423
+ *
424
+ * Best-effort by design, same stance as the test-trend footer: a git failure
425
+ * degrades to the `_none_` skeleton rather than blocking a release. The gate
426
+ * that reads the result runs later, on the branch head, and derives from the
427
+ * same shared classifier — so a degraded run surfaces as the gate's ordinary
428
+ * "fill the head" message instead of a crash mid-release.
429
+ */
430
+ function _derive_head_prefill(prev: string | null): Record<string, string> {
431
+ try {
432
+ const span = collect_span_commits(prev, 'HEAD', REPO_ROOT);
433
+ return render_derived_head_values(derive_category_hits(span));
434
+ } catch (err) {
435
+ process.stderr.write(
436
+ `warning: could not derive release-head highlights (${(err as Error).message}); ` +
437
+ 'the head ships as the `_none_` skeleton\n',
438
+ );
439
+ return {};
440
+ }
441
+ }
442
+
415
443
  /**
416
444
  * Render the curated head. Emitted by the generator on every release so it
417
445
  * cannot be forgotten; edited by the maintainer before merge.
@@ -2344,8 +2372,14 @@ function main(argv: readonly string[] | null = null): number {
2344
2372
  // (~15s+), which `--dry-run exits 0 without needing …` contracts (and
2345
2373
  // the release test) assume never runs. Real releases compute it.
2346
2374
  const test_trend_line = args.dry_run ? null : _render_test_trend_line(prev);
2375
+ // Derived unconditionally, dry-run included: it is one `git log` (~25 ms),
2376
+ // not the trend line's full vitest collection. Skipping it on `--dry-run`
2377
+ // made the preview print the `_none_` skeleton while the real run wrote
2378
+ // pre-filled lines — a preview that contradicts its own output, which is
2379
+ // the exact class of surface disagreement this pre-fill exists to end.
2347
2380
  const [full, body] = render_changelog_entry(target, prev, commits, today, {
2348
2381
  test_trend_line,
2382
+ head: _derive_head_prefill(prev),
2349
2383
  });
2350
2384
 
2351
2385
  // Era-split planning — gate on the POST-release view (2026-07-07 fix).