@event4u/agent-config 14.15.0 → 14.17.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 (58) 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 +84 -153
  5. package/README.md +1 -1
  6. package/dist/agent-src/commands/analyze/inbox.md +172 -6
  7. package/dist/agent-src/commands/fix/commit-messages.md +336 -0
  8. package/dist/agent-src/commands/fix.md +5 -3
  9. package/dist/agent-src/rules/commit-conventions.md +1 -1
  10. package/dist/agent-src/rules/security-sensitive-stop.md +7 -5
  11. package/dist/agent-src/skills/accessibility-auditor/SKILL.md +20 -2
  12. package/dist/agent-src/skills/aws-infrastructure/SKILL.md +41 -0
  13. package/dist/agent-src/skills/conventional-commits-writing/SKILL.md +234 -4
  14. package/dist/agent-src/skills/design-intelligence/data/ux-guidelines.csv +99 -99
  15. package/dist/agent-src/skills/fe-design/SKILL.md +6 -3
  16. package/dist/agent-src/skills/fe-design/references/design-patterns.md +28 -0
  17. package/dist/agent-src/skills/iconography/SKILL.md +37 -6
  18. package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +1 -1
  19. package/dist/agent-src/skills/terraform/SKILL.md +29 -0
  20. package/dist/agent-src/skills/threat-modeling/data/reasoning.csv +1 -0
  21. package/dist/agent-src/skills/threat-modeling/data/surfaces.csv +1 -0
  22. package/dist/agent-src/skills/threat-modeling/data/threats.csv +5 -0
  23. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  24. package/dist/cli-delegate/cmd_conformance.js +158 -33
  25. package/dist/cli-delegate/cmd_explain.js +24 -112
  26. package/dist/discovery/deprecation-report.md +1 -1
  27. package/dist/discovery/discovery-manifest.json +47 -21
  28. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  29. package/dist/discovery/discovery-manifest.summary.md +3 -3
  30. package/dist/discovery/dormancy-report.md +1 -1
  31. package/dist/discovery/orphan-report.md +1 -1
  32. package/dist/discovery/packs.json +6 -5
  33. package/dist/discovery/trust-report.md +2 -2
  34. package/dist/discovery/workspaces.json +5 -4
  35. package/dist/hooks/dispatch.js +169 -41
  36. package/dist/mcp/registry-manifest.json +3 -3
  37. package/dist/mcp/server.json +2 -2
  38. package/dist/mcp/server.mjs +160 -35
  39. package/dist/router.json +1 -1
  40. package/docs/guidelines/design-antipatterns.md +10 -2
  41. package/package.json +1 -1
  42. package/src/config/gate-violation-baselines.json +2 -2
  43. package/src/config/web-launch-readiness.json +1 -1
  44. package/src/scripts/_cli/explain_last/index.ts +1 -1
  45. package/src/scripts/_cli/explain_last/memory.ts +8 -104
  46. package/src/scripts/check_artefact_count_messaging.ts +9 -0
  47. package/src/scripts/check_release_highlights.ts +77 -4
  48. package/src/scripts/check_web_launch_readiness.ts +295 -15
  49. package/src/scripts/design_slop_rules.ts +19 -9
  50. package/src/scripts/git_authorization_hook.ts +257 -35
  51. package/src/scripts/hooks/block_unauthorized_git.ts +16 -4
  52. package/src/scripts/hooks/merge_impact.ts +68 -0
  53. package/src/scripts/measure_release_mix.ts +384 -0
  54. package/src/scripts/release.ts +4 -17
  55. package/src/scripts/release_drill.ts +154 -1
  56. package/src/scripts/release_mix_taxonomy.json +172 -0
  57. package/src/scripts/release_publication.ts +91 -0
  58. package/src/scripts/attest_artifact.ts +0 -193
@@ -0,0 +1,384 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Release governance-vs-product mix — measured from changed paths, published as
4
+ * two views, with no threshold.
5
+ *
6
+ * Why this exists, and what it deliberately is not.
7
+ *
8
+ * An external reviewer asked twice for a per-PR rule: "no meta/governance
9
+ * feature without a user-facing artifact in the same PR". The AI council
10
+ * DECLINED that proposal (2026-09-04, anthropic/claude-sonnet-4-5 +
11
+ * openai/codex-default, 2 rounds, quorum 2/2, $0.00 — both seats
12
+ * subscription-authed) on two grounds: a same-PR gate measures packaging rather
13
+ * than progress, since one token consumer-file edit legitimises an otherwise
14
+ * governance-only pull request; and it rejects legitimate work by construction,
15
+ * since a CI fix, a dependency bump and an analysis round producing only
16
+ * roadmaps are all real work with no consumer surface. The decline is recorded
17
+ * in `docs/decisions/ADR-253-per-pr-user-artifact-gate-declined.md`.
18
+ *
19
+ * This script is the replacement both seats specified: classify at RELEASE
20
+ * level, publish two views, attach a response obligation, and refuse to pick a
21
+ * threshold until at least two readings exist. It therefore **enforces
22
+ * nothing about the ratio**. It measures and it publishes. The one enforced
23
+ * consequence lives in `check_release_highlights.ts` and is about the
24
+ * completeness of the written response, never about the number.
25
+ *
26
+ * The two views.
27
+ *
28
+ * - **Commit view** — exclusive counts per category, plus `mixed`, plus
29
+ * `unclassified`. A commit is classified from the files it touches.
30
+ * - **Diff view** — added/deleted lines per category over the whole range.
31
+ *
32
+ * Generated projections and lockfiles (`dist/`, `.augment/`, `.claude/`,
33
+ * `package-lock.json`, …) are removed from BOTH views by name. In this
34
+ * repository `dist/` is a byte-exact projection of `src/`, so leaving it in
35
+ * would let a mechanical regeneration dominate every reading. A commit left
36
+ * with no path after that exclusion lands in the `generated_only` diagnostic
37
+ * rather than in maintenance — otherwise the ratio would depend on whether the
38
+ * regeneration was committed separately from the source edit that caused it.
39
+ *
40
+ * Classification reads paths, never subjects.
41
+ *
42
+ * Commit subjects are mutable, inconsistently formatted, and can contradict the
43
+ * files a commit touches. Nothing in this file reads `%s`, `%b` or any other
44
+ * message field; the only git output consumed is a name list and a numstat.
45
+ * `tests/scripts/measure_release_mix.test.ts` pins that by rewriting a
46
+ * subject with `git commit --amend` and asserting the reading is byte-identical.
47
+ *
48
+ * `mixed` is never collapsed into a category.
49
+ *
50
+ * Collapsing `mixed` into product would recreate the exact gaming the declined
51
+ * rule permits. A commit touching two classified categories is `mixed` and is
52
+ * reported as its own bucket in both views.
53
+ *
54
+ * `unclassified` is separate from `mixed` and does not cause it. A commit whose
55
+ * paths are all unmatched is `unclassified`; a commit with one classified
56
+ * category plus unmatched paths keeps that category and carries
57
+ * `has_unclassified`. Conflating taxonomy uncertainty with genuine
58
+ * cross-category work would hide both.
59
+ *
60
+ * Usage.
61
+ *
62
+ * measure_release_mix --from <ref> --to <ref> [--label <tag>] [--json <path>] [--quiet]
63
+ * measure_release_mix --audit # every top-level tracked path resolves
64
+ *
65
+ * Exit codes: 0 clean · 1 an unresolved path in `--audit` · 2 usage or git error.
66
+ */
67
+ import { spawnSync } from 'node:child_process';
68
+ import * as fs from 'node:fs';
69
+ import * as path from 'node:path';
70
+ import process from 'node:process';
71
+ import { fileURLToPath, pathToFileURL } from 'node:url';
72
+
73
+ const _HERE = path.resolve(fileURLToPath(import.meta.url));
74
+ export const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
75
+ const TAXONOMY_PATH = path.join(_HERE, '..', 'release_mix_taxonomy.json');
76
+
77
+ export type Category = 'consumer' | 'governance' | 'maintenance' | 'unclassified';
78
+ export const CLASSIFIED: readonly Category[] = ['consumer', 'governance', 'maintenance'];
79
+
80
+ export interface Taxonomy {
81
+ taxonomy_version: string;
82
+ excluded_generated: string[];
83
+ rules: { prefix: string; category: Category }[];
84
+ governance_contracts: string[];
85
+ }
86
+
87
+ export function loadTaxonomy(file = TAXONOMY_PATH): Taxonomy {
88
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8')) as Taxonomy;
89
+ if (!raw.taxonomy_version) throw new Error('taxonomy carries no taxonomy_version');
90
+ return raw;
91
+ }
92
+
93
+ /** True when the path is a generated projection or a lockfile — dropped from both views. */
94
+ export function isExcluded(p: string, tax: Taxonomy): boolean {
95
+ return tax.excluded_generated.some((pre) => (pre.endsWith('/') ? p.startsWith(pre) : p === pre));
96
+ }
97
+
98
+ /**
99
+ * Longest-prefix-wins. A path matching no rule is `unclassified` — a reported
100
+ * state, never a silent default into some category.
101
+ */
102
+ export function classifyPath(p: string, tax: Taxonomy): Category {
103
+ return classifyPathDetailed(p, tax).category;
104
+ }
105
+
106
+ /**
107
+ * The same decision, with the rule that made it. `matched: false` is the
108
+ * failure `--audit` looks for: a path that fell through every rule, which is
109
+ * not the same thing as a path the taxonomy deliberately calls unclassified.
110
+ */
111
+ export function classifyPathDetailed(
112
+ p: string,
113
+ tax: Taxonomy,
114
+ ): { category: Category; matched: boolean; rule: string } {
115
+ if (tax.governance_contracts.includes(p)) {
116
+ return { category: 'governance', matched: true, rule: `governance_contracts:${p}` };
117
+ }
118
+ let best: { len: number; category: Category; rule: string } | null = null;
119
+ for (const rule of tax.rules) {
120
+ if (!p.startsWith(rule.prefix)) continue;
121
+ if (best === null || rule.prefix.length > best.len) {
122
+ best = { len: rule.prefix.length, category: rule.category, rule: rule.prefix };
123
+ }
124
+ }
125
+ return best === null
126
+ ? { category: 'unclassified', matched: false, rule: '' }
127
+ : { category: best.category, matched: true, rule: best.rule };
128
+ }
129
+
130
+ export interface CommitVerdict {
131
+ /** The bucket the commit lands in. `generated_only` is a diagnostic, not a view bucket. */
132
+ bucket: Category | 'mixed' | 'generated_only';
133
+ categories: Category[];
134
+ has_unclassified: boolean;
135
+ }
136
+
137
+ /** Classify ONE commit from its changed paths. Nothing here reads the subject. */
138
+ export function classifyCommit(paths: readonly string[], tax: Taxonomy): CommitVerdict {
139
+ const kept = paths.filter((p) => !isExcluded(p, tax));
140
+ if (kept.length === 0) {
141
+ return { bucket: 'generated_only', categories: [], has_unclassified: false };
142
+ }
143
+ const seen = new Set<Category>();
144
+ for (const p of kept) seen.add(classifyPath(p, tax));
145
+ const has_unclassified = seen.has('unclassified');
146
+ const classified = [...seen].filter((c) => c !== 'unclassified').sort();
147
+ if (classified.length === 0) return { bucket: 'unclassified', categories: [], has_unclassified: true };
148
+ if (classified.length === 1) return { bucket: classified[0]!, categories: classified, has_unclassified };
149
+ return { bucket: 'mixed', categories: classified, has_unclassified };
150
+ }
151
+
152
+ function git(args: string[], cwd = REPO_ROOT): string {
153
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
154
+ if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${(r.stderr || '').trim()}`);
155
+ return r.stdout;
156
+ }
157
+
158
+ export interface Reading {
159
+ schema_version: 1;
160
+ taxonomy_version: string;
161
+ classifier_version: string;
162
+ label: string;
163
+ range: { from: string; to: string; from_sha: string; to_sha: string };
164
+ merge_handling: 'no-merges';
165
+ rename_handling: 'git-default';
166
+ commit_view: Record<string, number>;
167
+ diff_view: Record<string, { added: number; deleted: number }>;
168
+ diagnostics: {
169
+ generated_only_commits: number;
170
+ commits_with_unclassified: number;
171
+ unclassified_paths: string[];
172
+ mixed_combinations: Record<string, number>;
173
+ largest_contributors: Record<string, [string, number][]>;
174
+ };
175
+ response_obligation: { triggered: boolean; rule: string; governance_only: number; consumer_only: number };
176
+ }
177
+
178
+ /** The classifier's own version, bumped independently of the taxonomy's. */
179
+ export const CLASSIFIER_VERSION = '1.0.0';
180
+
181
+ /**
182
+ * The response obligation's trigger, stated once so the gate and the report
183
+ * cannot drift: governance-only commits STRICTLY outnumber consumer-only
184
+ * commits over the span. Strict inequality is the literal reading of the
185
+ * roadmap's own wording and both council seats named it explicitly.
186
+ */
187
+ export const OBLIGATION_RULE = 'governance-only commits > consumer-only commits';
188
+
189
+ export function measureRange(from: string, to: string, tax: Taxonomy, label = '', cwd = REPO_ROOT): Reading {
190
+ const shas = git(['log', '--no-merges', '--format=%H', `${from}..${to}`], cwd)
191
+ .split('\n')
192
+ .map((s) => s.trim())
193
+ .filter(Boolean);
194
+
195
+ const commit_view: Record<string, number> = {
196
+ consumer: 0, governance: 0, maintenance: 0, unclassified: 0, mixed: 0,
197
+ };
198
+ const mixed_combinations: Record<string, number> = {};
199
+ const unclassified_paths = new Set<string>();
200
+ let generated_only_commits = 0;
201
+ let commits_with_unclassified = 0;
202
+
203
+ for (const sha of shas) {
204
+ const paths = git(['show', '--name-only', '--format=', sha], cwd)
205
+ .split('\n').map((s) => s.trim()).filter(Boolean);
206
+ const v = classifyCommit(paths, tax);
207
+ if (v.bucket === 'generated_only') { generated_only_commits += 1; continue; }
208
+ commit_view[v.bucket] = (commit_view[v.bucket] ?? 0) + 1;
209
+ if (v.has_unclassified) commits_with_unclassified += 1;
210
+ if (v.bucket === 'mixed') {
211
+ const key = v.categories.join('+');
212
+ mixed_combinations[key] = (mixed_combinations[key] ?? 0) + 1;
213
+ }
214
+ for (const p of paths) {
215
+ if (!isExcluded(p, tax) && classifyPath(p, tax) === 'unclassified') unclassified_paths.add(p);
216
+ }
217
+ }
218
+
219
+ const diff_view: Record<string, { added: number; deleted: number }> = {
220
+ consumer: { added: 0, deleted: 0 },
221
+ governance: { added: 0, deleted: 0 },
222
+ maintenance: { added: 0, deleted: 0 },
223
+ unclassified: { added: 0, deleted: 0 },
224
+ };
225
+ const touchCount: Record<string, Record<string, number>> = {
226
+ consumer: {}, governance: {}, maintenance: {}, unclassified: {},
227
+ };
228
+
229
+ for (const line of git(['diff', '--numstat', `${from}..${to}`], cwd).split('\n')) {
230
+ const parts = line.split('\t');
231
+ if (parts.length < 3) continue;
232
+ const [addRaw, delRaw, pathRaw] = parts as [string, string, string];
233
+ // `git diff --numstat` renders a rename as `old => new`; take the new path.
234
+ const p = pathRaw.includes('=>') ? pathRaw.replace(/^.*=>\s*/, '').replace(/[{}]/g, '') : pathRaw;
235
+ if (isExcluded(p, tax)) continue;
236
+ const cat = classifyPath(p, tax);
237
+ // A binary file renders as `-`; it contributes a touch, not a line count.
238
+ diff_view[cat]!.added += addRaw === '-' ? 0 : Number(addRaw);
239
+ diff_view[cat]!.deleted += delRaw === '-' ? 0 : Number(delRaw);
240
+ const top = p.split('/').slice(0, 2).join('/');
241
+ touchCount[cat]![top] = (touchCount[cat]![top] ?? 0) + 1;
242
+ }
243
+
244
+ const largest_contributors: Record<string, [string, number][]> = {};
245
+ for (const cat of Object.keys(touchCount)) {
246
+ largest_contributors[cat] = Object.entries(touchCount[cat]!)
247
+ .sort((a, b) => b[1] - a[1])
248
+ .slice(0, 5) as [string, number][];
249
+ }
250
+
251
+ const governance_only = commit_view.governance ?? 0;
252
+ const consumer_only = commit_view.consumer ?? 0;
253
+
254
+ return {
255
+ schema_version: 1,
256
+ taxonomy_version: tax.taxonomy_version,
257
+ classifier_version: CLASSIFIER_VERSION,
258
+ label: label || `${from}..${to}`,
259
+ range: {
260
+ from, to,
261
+ from_sha: git(['rev-parse', from], cwd).trim(),
262
+ to_sha: git(['rev-parse', to], cwd).trim(),
263
+ },
264
+ merge_handling: 'no-merges',
265
+ rename_handling: 'git-default',
266
+ commit_view,
267
+ diff_view,
268
+ diagnostics: {
269
+ generated_only_commits,
270
+ commits_with_unclassified,
271
+ unclassified_paths: [...unclassified_paths].sort().slice(0, 50),
272
+ mixed_combinations,
273
+ largest_contributors,
274
+ },
275
+ response_obligation: {
276
+ triggered: governance_only > consumer_only,
277
+ rule: OBLIGATION_RULE,
278
+ governance_only,
279
+ consumer_only,
280
+ },
281
+ };
282
+ }
283
+
284
+ export function renderReading(r: Reading): string {
285
+ const cv = r.commit_view;
286
+ const lines: string[] = [];
287
+ lines.push(`release-mix · ${r.label} · taxonomy ${r.taxonomy_version} · classifier ${r.classifier_version}`);
288
+ lines.push(` range ${r.range.from_sha.slice(0, 9)}..${r.range.to_sha.slice(0, 9)} (${r.merge_handling})`);
289
+ lines.push(` commit view consumer ${cv.consumer} · governance ${cv.governance} · maintenance ${cv.maintenance} · mixed ${cv.mixed} · unclassified ${cv.unclassified}`);
290
+ for (const cat of ['consumer', 'governance', 'maintenance', 'unclassified'] as const) {
291
+ const d = r.diff_view[cat]!;
292
+ lines.push(` diff view ${cat.padEnd(13)} +${d.added} / -${d.deleted}`);
293
+ }
294
+ lines.push(` diagnostics generated-only commits ${r.diagnostics.generated_only_commits} · commits carrying unclassified paths ${r.diagnostics.commits_with_unclassified}`);
295
+ lines.push(` response owed ${r.response_obligation.triggered ? 'YES' : 'no'} (${r.response_obligation.rule}: ${r.response_obligation.governance_only} vs ${r.response_obligation.consumer_only})`);
296
+ lines.push(' NOTE a level, not a verdict — no threshold is committed to this repository.');
297
+ return lines.join('\n');
298
+ }
299
+
300
+ /**
301
+ * AC-2 — every tracked path resolves to exactly one category, or to a rule that
302
+ * says `unclassified` on purpose. A path that matches NO rule is the failure:
303
+ * it would otherwise be counted as taxonomy uncertainty when it is really a
304
+ * taxonomy hole. Reported grouped by the level at which the taxonomy actually
305
+ * decides, so `src/` shows its subdirectories rather than one aggregate row.
306
+ */
307
+ export function auditTree(tax: Taxonomy, cwd = REPO_ROOT): {
308
+ rows: { unit: string; category: Category; files: number }[];
309
+ unmatched: string[];
310
+ } {
311
+ const byUnit = new Map<string, { category: Category; files: number }>();
312
+ const unmatched: string[] = [];
313
+ for (const f of git(['ls-files'], cwd).split('\n')) {
314
+ const file = f.trim();
315
+ if (!file || isExcluded(file, tax)) continue;
316
+ const d = classifyPathDetailed(file, tax);
317
+ if (!d.matched) {
318
+ unmatched.push(file);
319
+ continue;
320
+ }
321
+ const unit = d.rule.startsWith('governance_contracts:') ? 'docs/contracts/ (exception list)' : d.rule;
322
+ const cur = byUnit.get(unit);
323
+ if (cur) cur.files += 1;
324
+ else byUnit.set(unit, { category: d.category, files: 1 });
325
+ }
326
+ const rows = [...byUnit.entries()]
327
+ .map(([unit, v]) => ({ unit, category: v.category, files: v.files }))
328
+ .sort((a, b) => a.unit.localeCompare(b.unit));
329
+ return { rows, unmatched };
330
+ }
331
+
332
+ function usage(msg: string): never {
333
+ process.stderr.write(`measure_release_mix: ${msg}\n`);
334
+ process.stderr.write('usage: measure_release_mix --from <ref> --to <ref> [--label <tag>] [--json <path>] [--quiet]\n');
335
+ process.stderr.write(' measure_release_mix --audit\n');
336
+ process.exit(2);
337
+ }
338
+
339
+ export function main(argv: readonly string[]): number {
340
+ const args = [...argv];
341
+ const flag = (name: string): string | undefined => {
342
+ const i = args.indexOf(name);
343
+ return i === -1 ? undefined : args[i + 1];
344
+ };
345
+ const tax = loadTaxonomy();
346
+
347
+ if (args.includes('--audit')) {
348
+ const { rows, unmatched } = auditTree(tax);
349
+ for (const r of rows) {
350
+ process.stdout.write(` ${r.category.padEnd(13)} ${String(r.files).padStart(5)} ${r.unit}\n`);
351
+ }
352
+ if (unmatched.length > 0) {
353
+ process.stderr.write(`\nmeasure_release_mix: ${unmatched.length} tracked path(s) match no rule:\n`);
354
+ for (const u of unmatched.slice(0, 20)) process.stderr.write(` ${u}\n`);
355
+ return 1;
356
+ }
357
+ const files = rows.reduce((n, r) => n + r.files, 0);
358
+ process.stdout.write(
359
+ `\nOK — ${files} tracked paths over ${rows.length} classification units, all resolved (taxonomy ${tax.taxonomy_version}).\n`,
360
+ );
361
+ return 0;
362
+ }
363
+
364
+ const from = flag('--from');
365
+ const to = flag('--to');
366
+ if (!from || !to) usage('--from and --to are required');
367
+ const reading = measureRange(from, to, tax, flag('--label') ?? '');
368
+ const jsonPath = flag('--json');
369
+ if (jsonPath) {
370
+ fs.mkdirSync(path.dirname(path.resolve(jsonPath)), { recursive: true });
371
+ fs.writeFileSync(path.resolve(jsonPath), `${JSON.stringify(reading, null, 2)}\n`, 'utf8');
372
+ }
373
+ if (!args.includes('--quiet')) process.stdout.write(`${renderReading(reading)}\n`);
374
+ return 0;
375
+ }
376
+
377
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
378
+ try {
379
+ process.exit(main(process.argv.slice(2)));
380
+ } catch (e) {
381
+ process.stderr.write(`measure_release_mix: ${(e as Error).message}\n`);
382
+ process.exit(2);
383
+ }
384
+ }
@@ -194,6 +194,7 @@ import {
194
194
  _target_from_branch,
195
195
  create_and_push_annotated_tag,
196
196
  guard_publication,
197
+ checkout_release_branch,
197
198
  guard_release_curation,
198
199
  die,
199
200
  gh,
@@ -1177,23 +1178,9 @@ function execute(
1177
1178
  const pr_merged = pr_state === 'MERGED';
1178
1179
 
1179
1180
  // ─── 1. branch ──────────────────────────────────────────────────────────
1180
- if (pr_merged) {
1181
- _step(1, total, `PR for ${branch} already merged — staying on ${MAIN_BRANCH}`);
1182
- if (git(['rev-parse', '--abbrev-ref', 'HEAD'], { capture: true }) !== MAIN_BRANCH) {
1183
- run(['git', 'checkout', MAIN_BRANCH]);
1184
- }
1185
- run(['git', 'pull', '--ff-only', REMOTE, MAIN_BRANCH]);
1186
- } else if (resume && _branch_exists_local(branch)) {
1187
- _step(1, total, `Branch ${branch} exists locally — checkout`);
1188
- run(['git', 'checkout', branch]);
1189
- } else if (resume && _branch_exists_remote(branch)) {
1190
- _step(1, total, `Branch ${branch} exists on ${REMOTE} — fetch + checkout`);
1191
- run(['git', 'fetch', REMOTE, branch]);
1192
- run(['git', 'checkout', '-b', branch, `${REMOTE}/${branch}`]);
1193
- } else {
1194
- _step(1, total, `Create branch ${branch}`);
1195
- run(['git', 'checkout', '-b', branch]);
1196
- }
1181
+ checkout_release_branch(branch, pr_merged, (m) => {
1182
+ _step(1, total, m);
1183
+ });
1197
1184
 
1198
1185
  // ─── 1b. era split (optional, separate commit) ─────────────────────────
1199
1186
  // Lands as `chore(changelog): split era ...` BEFORE the release commit
@@ -38,6 +38,58 @@ import {
38
38
  SystemExitError,
39
39
  } from './release.js';
40
40
  import { DERIVED_MARKER } from './_lib/release_highlights.js';
41
+ import {
42
+ AUGMENT_MARKETPLACE_JSON,
43
+ AUGMENT_PLUGIN_JSON,
44
+ CHANGELOG,
45
+ MARKETPLACE_JSON,
46
+ PACKAGE_JSON,
47
+ PACKAGE_LOCK_JSON,
48
+ PROJECT_TEMPLATE,
49
+ } from './release_env.js';
50
+
51
+ /**
52
+ * The files step 2 mutates on disk, snapshotted around every scenario.
53
+ *
54
+ * The FakeWorld intercepts COMMANDS, not `fs` writes, and step 2's bump is
55
+ * seven direct writes. Every scenario before 2026-09-03 passed `--resume` with
56
+ * `package.json` already at the target, so step 2 short-circuited and nothing
57
+ * was ever written — the drill looked side-effect-free because no scenario had
58
+ * reached the writing arm. The first `resume: false` scenario appended twelve
59
+ * `## 14.15.0` headings to the REAL `CHANGELOG.md`.
60
+ *
61
+ * Snapshot-and-restore rather than a writer seam: a seam means threading an
62
+ * injection point through seven call sites in `release.ts`, which sits past the
63
+ * 1500-line cap where every line is charged. This is contained to the runner
64
+ * and covers any future scenario without further thought.
65
+ */
66
+ const _MUTATED_BY_STEP_2: readonly string[] = [
67
+ CHANGELOG,
68
+ PACKAGE_JSON,
69
+ PACKAGE_LOCK_JSON,
70
+ MARKETPLACE_JSON,
71
+ AUGMENT_PLUGIN_JSON,
72
+ AUGMENT_MARKETPLACE_JSON,
73
+ PROJECT_TEMPLATE,
74
+ ];
75
+
76
+ function _snapshot_step_2_files(): Map<string, string> {
77
+ const snap = new Map<string, string>();
78
+ for (const f of _MUTATED_BY_STEP_2) {
79
+ if (fs.existsSync(f)) {
80
+ snap.set(f, fs.readFileSync(f, 'utf-8'));
81
+ }
82
+ }
83
+ return snap;
84
+ }
85
+
86
+ function _restore_step_2_files(snap: ReadonlyMap<string, string>): void {
87
+ for (const [f, content] of snap) {
88
+ if (fs.readFileSync(f, 'utf-8') !== content) {
89
+ fs.writeFileSync(f, content);
90
+ }
91
+ }
92
+ }
41
93
 
42
94
  const _HERE = fileURLToPath(import.meta.url);
43
95
  const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
@@ -64,6 +116,27 @@ interface WorldConfig {
64
116
  push_rejections?: number;
65
117
  /** mergeStateStatus reports BEHIND this many probes in a row (9.16.0). */
66
118
  behind_probes?: number;
119
+ /**
120
+ * Commits the release branch is behind `origin/main` at step 1.
121
+ *
122
+ * Distinct from `behind_probes`, which fakes the PR's own
123
+ * `mergeStateStatus` at step 6. This one fakes `git rev-list --count
124
+ * HEAD..origin/main`, the probe `integrate_main_if_behind` uses — the
125
+ * defect it covers surfaced at the PUSH, six steps before that PR ever
126
+ * existed.
127
+ */
128
+ branch_behind_main?: number;
129
+ /**
130
+ * Whether `execute()` is invoked with `--resume`. Defaults to TRUE so every
131
+ * pre-existing scenario keeps the shape it was written against.
132
+ *
133
+ * Configurable since 2026-09-03 because the drill could not otherwise
134
+ * reach the defect: branch reuse used to be gated on `resume`, so a plain
135
+ * `task release` over an existing branch died at `git checkout -b` with
136
+ * exit 128 — and every scenario here passed `resume: true`, which is
137
+ * exactly the arm that worked.
138
+ */
139
+ resume?: boolean;
67
140
  /** `gh pr merge` fails once with the not-up-to-date error (the race). */
68
141
  merge_races_once?: boolean;
69
142
  /** `gh pr merge` fails hard with an unrelated error (must surface). */
@@ -199,6 +272,7 @@ class FakeWorld {
199
272
 
200
273
  private push_rejections: number;
201
274
  private behind_probes: number;
275
+ private branch_behind_main: number;
202
276
  private merge_races_once: boolean;
203
277
  private merge_fails_hard: boolean;
204
278
  private checks_fail: boolean;
@@ -217,6 +291,7 @@ class FakeWorld {
217
291
  this.tag_local = (cfg.tag_created_unpushed ?? false) || (cfg.tag_already_remote ?? false);
218
292
  this.tag_remote = cfg.tag_already_remote ?? false;
219
293
  this.behind_probes = cfg.behind_probes ?? 0;
294
+ this.branch_behind_main = cfg.branch_behind_main ?? 0;
220
295
  this.merge_races_once = cfg.merge_races_once ?? false;
221
296
  this.merge_fails_hard = cfg.merge_fails_hard ?? false;
222
297
  this.checks_fail = cfg.checks_fail ?? false;
@@ -235,6 +310,15 @@ class FakeWorld {
235
310
  this.head = args[2] === '-b' ? (args[3] as string) : (args[2] as string);
236
311
  return OK;
237
312
  }
313
+ if (cmd === 'git rev-list --count HEAD..origin/main') {
314
+ return { ...OK, stdout: `${String(this.branch_behind_main)}\n` };
315
+ }
316
+ if (cmd === 'git merge origin/main --no-edit') {
317
+ // A real merge closes the gap, so a second probe reads 0. Without
318
+ // this the scenario could not tell one merge from a loop.
319
+ this.branch_behind_main = 0;
320
+ return OK;
321
+ }
238
322
  if (cmd === `git rev-parse --verify --quiet refs/heads/${this.branch}`) {
239
323
  return this.pr_merged ? { ...OK, status: 1 } : OK;
240
324
  }
@@ -345,6 +429,15 @@ class FakeWorld {
345
429
  // live body before deciding whether to edit.
346
430
  return { ...OK, stdout: this.pr_body };
347
431
  }
432
+ if (args[0] === 'gh' && args[1] === 'pr' && args[2] === 'create') {
433
+ // Only reachable on the NON-resume path: a resumed run refreshes an
434
+ // existing PR body instead (step 5's other arm), which is why every
435
+ // scenario before 2026-09-03 could leave this unscripted.
436
+ const bodyIdx = args.indexOf('--body');
437
+ this.pr_body = bodyIdx >= 0 ? String(args[bodyIdx + 1] ?? '') : '';
438
+ this.branch_exists_remote = true;
439
+ return { ...OK, stdout: `https://github.com/event4u-app/agent-config/pull/999\n` };
440
+ }
348
441
  if (args[0] === 'gh' && args[1] === 'pr' && args[2] === 'edit' && args[3] === this.branch) {
349
442
  const bodyIdx = args.indexOf('--body');
350
443
  this.pr_body = bodyIdx >= 0 ? String(args[bodyIdx + 1] ?? '') : this.pr_body;
@@ -390,6 +483,64 @@ function _count(world: FakeWorld, needle: string): number {
390
483
  }
391
484
 
392
485
  const SCENARIOS: Record<string, Scenario> = {
486
+ 'plain-run-reuses-an-existing-branch': {
487
+ summary:
488
+ 'step 1: a PLAIN run (no --resume) over an existing release branch checks it out instead of dying at `git checkout -b`',
489
+ // The defect, measured on 14.15.0: branch reuse read
490
+ // `resume && _branch_exists_local(branch)`, so without --resume the
491
+ // run fell through to the create arm and died with exit 128. That is
492
+ // precisely the state `guard_release_curation` leaves behind — it stops
493
+ // BEFORE the commit, on a branch step 1 already created — while its own
494
+ // message says to re-run `task release`. The code refused the remedy its
495
+ // own message prescribed.
496
+ config: { resume: false },
497
+ expect_success: true,
498
+ verify: (w) => {
499
+ const f: string[] = [];
500
+ _expect(
501
+ w.calls.includes(`git checkout ${w.branch}`),
502
+ 'the existing branch was not checked out',
503
+ f,
504
+ );
505
+ _expect(
506
+ !w.calls.includes(`git checkout -b ${w.branch}`),
507
+ 'took the create arm over an existing branch — this is the exit-128 path',
508
+ f,
509
+ );
510
+ return f;
511
+ },
512
+ },
513
+ 'stale-branch-merges-main-at-step-1': {
514
+ summary:
515
+ 'step 1: a branch behind origin/main merges it in there — NOT at the push, where the preflight used to be the first to notice',
516
+ // Second half of the same defect. On 14.15.0 the branch had been cut
517
+ // from an older main during an earlier aborted run; steps 2 and 3
518
+ // bumped and committed, and only the pre-push preflight said `branch is
519
+ // BEHIND origin/main`. The cheapest moment to integrate main is the
520
+ // moment the branch is checked out.
521
+ config: { resume: false, branch_behind_main: 3 },
522
+ expect_success: true,
523
+ verify: (w) => {
524
+ const f: string[] = [];
525
+ const merge = w.calls.indexOf('git merge origin/main --no-edit');
526
+ const push = w.calls.indexOf(`git push -u origin ${w.branch}`);
527
+ _expect(merge >= 0, 'main was never merged into the stale branch', f);
528
+ _expect(push >= 0, 'the branch was never pushed', f);
529
+ // Ordering is the whole point: a merge AFTER the push would mean the
530
+ // preflight had already refused.
531
+ _expect(
532
+ merge >= 0 && push >= 0 && merge < push,
533
+ 'main was merged after the push, not before it',
534
+ f,
535
+ );
536
+ _expect(
537
+ _count(w, 'git merge origin/main --no-edit') === 1,
538
+ 'merged main more than once — the probe is not being re-read after the merge',
539
+ f,
540
+ );
541
+ return f;
542
+ },
543
+ },
393
544
  'marker-refuses-before-commit': {
394
545
  summary:
395
546
  'step 3: a draft marker refuses BEFORE the release commit — no local release state, and therefore no remote state either',
@@ -685,12 +836,13 @@ function run_scenario(name: string): ScenarioOutcome {
685
836
  captured.push(String(s));
686
837
  return true;
687
838
  };
839
+ const tree_snapshot = _snapshot_step_2_files();
688
840
  _set_exec_override((args) => world.exec(args));
689
841
  // The working-tree read, faked for the same reason the command layer is.
690
842
  // Without this the drill's step 8 reads the repository's real CHANGELOG.md.
691
843
  _set_changelog_reader(() => world.changelog_file);
692
844
  try {
693
- execute(plan, { wait_for_checks: true, dry_run: false, resume: true });
845
+ execute(plan, { wait_for_checks: true, dry_run: false, resume: scenario.config.resume ?? true });
694
846
  } catch (err) {
695
847
  if (err instanceof SystemExitError) {
696
848
  error = `SystemExit(${err.code}): ${captured.join('')}`;
@@ -702,6 +854,7 @@ function run_scenario(name: string): ScenarioOutcome {
702
854
  _set_changelog_reader(null);
703
855
  process.stdout.write = orig_out;
704
856
  process.stderr.write = orig_err;
857
+ _restore_step_2_files(tree_snapshot);
705
858
  }
706
859
 
707
860
  const failures: string[] = [];