@event4u/agent-config 9.16.0 → 9.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 (135) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/AGENTS.md +1 -1
  3. package/CHANGELOG.md +53 -0
  4. package/README.md +1 -1
  5. package/dist/agent-src/commands/agent-handoff.md +100 -48
  6. package/dist/agent-src/commands/commit.md +1 -1
  7. package/dist/agent-src/commands/condense.md +19 -64
  8. package/dist/agent-src/commands/evals/agent-handoff.json +13 -1
  9. package/dist/agent-src/commands/memory/promote.md +14 -1
  10. package/dist/agent-src/commands/mode.md +9 -5
  11. package/dist/agent-src/commands/optimize/deep.md +72 -11
  12. package/dist/agent-src/contexts/authority/commit-mechanics.md +5 -5
  13. package/dist/agent-src/contexts/communication/rules-auto/guidelines-mechanics.md +1 -0
  14. package/dist/agent-src/contexts/communication/rules-auto/skill-quality-mechanics.md +1 -1
  15. package/dist/agent-src/contexts/communication/rules-auto/think-before-action-mechanics.md +6 -1
  16. package/dist/agent-src/contexts/communication/rules-auto/token-efficiency-mechanics.md +4 -1
  17. package/dist/agent-src/contexts/execution/autonomy-mechanics.md +27 -1
  18. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +5 -2
  19. package/dist/agent-src/profiles/agency.yml +3 -3
  20. package/dist/agent-src/profiles/content_creator.yml +2 -2
  21. package/dist/agent-src/profiles/developer.yml +2 -2
  22. package/dist/agent-src/profiles/finance.yml +1 -1
  23. package/dist/agent-src/profiles/founder.yml +2 -2
  24. package/dist/agent-src/profiles/ops.yml +2 -2
  25. package/dist/agent-src/rules/architecture.md +2 -2
  26. package/dist/agent-src/rules/autonomous-execution.md +1 -1
  27. package/dist/agent-src/rules/context-hygiene.md +3 -2
  28. package/dist/agent-src/rules/domain-safety-pii.md +1 -1
  29. package/dist/agent-src/rules/output-discipline.md +9 -2
  30. package/dist/agent-src/rules/token-budget-discipline.md +2 -2
  31. package/dist/agent-src/rules/user-interaction.md +3 -4
  32. package/dist/agent-src/skills/build-buy-partner/SKILL.md +1 -1
  33. package/dist/agent-src/skills/copilot-agents-optimization/SKILL.md +4 -4
  34. package/dist/agent-src/skills/copilot-config/SKILL.md +1 -1
  35. package/dist/agent-src/skills/deep-reading-analyst/SKILL.md +7 -4
  36. package/dist/agent-src/skills/description-assist/SKILL.md +1 -1
  37. package/dist/agent-src/skills/fe-design/SKILL.md +1 -1
  38. package/dist/agent-src/skills/market-entry-analysis/SKILL.md +1 -1
  39. package/dist/agent-src/skills/persona-writing/SKILL.md +2 -2
  40. package/dist/agent-src/skills/readme-reviewer/SKILL.md +2 -1
  41. package/dist/agent-src/skills/rule-writing/SKILL.md +4 -2
  42. package/dist/agent-src/skills/skill-reviewer/SKILL.md +1 -1
  43. package/dist/agent-src/skills/skill-writing/SKILL.md +2 -2
  44. package/dist/agent-src/skills/tailwind-engineer/SKILL.md +3 -1
  45. package/dist/agent-src/skills/token-optimizer/SKILL.md +2 -2
  46. package/dist/agent-src/skills/ui-component-architect/SKILL.md +6 -3
  47. package/dist/agent-src/templates/agent-settings.md +13 -15
  48. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +6 -2
  49. package/dist/agent-src/templates/persona.md +2 -2
  50. package/dist/agent-src/templates/roadmaps.md +1 -1
  51. package/dist/agent-src/templates/rule.md +1 -1
  52. package/dist/agent-src/templates/skill.md +1 -1
  53. package/dist/cli/registry.js +1 -0
  54. package/dist/cli/registry.js.map +1 -1
  55. package/dist/cli-delegate/{chunk-ZXXJLL5K.js → chunk-BSLUHV2F.js} +127 -1487
  56. package/dist/cli-delegate/{chunk-H574KOUH.js → chunk-IMSXPRVH.js} +24 -1
  57. package/dist/cli-delegate/chunk-VSYT7OHO.js +1382 -0
  58. package/dist/cli-delegate/chunk-XGA2NTU4.js +3727 -0
  59. package/dist/cli-delegate/cmd_conformance.js +4 -3
  60. package/dist/cli-delegate/cmd_converge.js +3 -3
  61. package/dist/cli-delegate/cmd_doctor.js +3 -2
  62. package/dist/cli-delegate/cmd_export.js +4 -4
  63. package/dist/cli-delegate/cmd_handoff.js +3194 -0
  64. package/dist/cli-delegate/cmd_memory_get.js +35 -3754
  65. package/dist/cli-delegate/cmd_migrate.js +2 -2
  66. package/dist/cli-delegate/cmd_prune.js +4 -4
  67. package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
  68. package/dist/cli-delegate/cmd_sync.js +4 -4
  69. package/dist/cli-delegate/cmd_uninstall.js +4 -4
  70. package/dist/cli-delegate/cmd_update.js +3 -3
  71. package/dist/cli-delegate/cmd_upgrade.js +3 -3
  72. package/dist/cli-delegate/cmd_validate.js +4 -4
  73. package/dist/cli-delegate/cmd_versions.js +3 -3
  74. package/dist/discovery/deprecation-report.md +1 -1
  75. package/dist/discovery/discovery-manifest.json +31 -31
  76. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  77. package/dist/discovery/discovery-manifest.summary.md +1 -1
  78. package/dist/discovery/orphan-report.md +1 -1
  79. package/dist/discovery/packs.json +2 -2
  80. package/dist/discovery/trust-report.md +1 -1
  81. package/dist/discovery/workspaces.json +2 -2
  82. package/dist/hooks/dispatch.js +602 -507
  83. package/dist/install/install.mjs +23 -0
  84. package/dist/mcp/awesome-mcp-servers.row.md +1 -1
  85. package/dist/mcp/mcp-cloudflare-catalogue.json +2 -2
  86. package/dist/mcp/registry-manifest.json +4 -4
  87. package/dist/mcp/server.json +2 -2
  88. package/dist/mcp/server.mjs +25 -3
  89. package/dist/scripts/_lib/agent_src.js +39 -6
  90. package/dist/scripts/_lib/agent_src.js.map +1 -1
  91. package/dist/server/schemas/settings.js +13 -0
  92. package/dist/server/schemas/settings.js.map +1 -1
  93. package/docs/guidelines/abstraction-thresholds.md +53 -0
  94. package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +1 -1
  95. package/docs/guidelines/agent-infra/asking-and-brevity-examples.md +7 -0
  96. package/docs/guidelines/agent-infra/context-hygiene-mechanics.md +2 -2
  97. package/docs/guidelines/agent-infra/minimal-safe-diff-mechanics.md +5 -3
  98. package/docs/guidelines/agent-infra/size-and-scope.md +8 -2
  99. package/docs/guidelines/component-oriented-and-oop-development.md +11 -6
  100. package/docs/guidelines/docs/readme-size-and-splitting.md +5 -2
  101. package/llms.txt +1 -0
  102. package/package.json +1 -1
  103. package/src/config/agent-settings.template.yml +22 -0
  104. package/src/config/evaluator-budgets.json +8 -11
  105. package/src/config/gate-coverage.yml +30 -0
  106. package/src/scripts/_cli/cmd_handoff.ts +405 -0
  107. package/src/scripts/_cli/handoff_generate.ts +453 -0
  108. package/src/scripts/_cli/handoff_sessions.ts +435 -0
  109. package/src/scripts/_dispatch.bash +9 -0
  110. package/src/scripts/_lib/agent_src.ts +36 -6
  111. package/src/scripts/_lib/release_material.ts +156 -0
  112. package/src/scripts/check_condensation.ts +2 -1
  113. package/src/scripts/check_finding_dispositions.ts +278 -0
  114. package/src/scripts/check_pack_size.ts +4 -3
  115. package/src/scripts/check_references.ts +1 -0
  116. package/src/scripts/check_release_highlights.ts +300 -0
  117. package/src/scripts/check_release_pr_shape.ts +4 -0
  118. package/src/scripts/check_release_surface_equality.ts +132 -0
  119. package/src/scripts/check_reply_consistency.ts +187 -138
  120. package/src/scripts/handoff_context_hook.ts +148 -0
  121. package/src/scripts/hook_manifest.yaml +17 -7
  122. package/src/scripts/hooks/concern_registry.ts +2 -0
  123. package/src/scripts/lint_abstraction_thresholds.ts +325 -0
  124. package/src/scripts/lint_framework_leakage_allowlist.json +2 -2
  125. package/src/scripts/lint_profile_personas.ts +153 -0
  126. package/src/scripts/lint_token_budget_discipline.ts +148 -0
  127. package/src/scripts/release.ts +156 -32
  128. package/src/scripts/release_drill.ts +491 -0
  129. package/src/scripts/schemas/command.schema.json +33 -0
  130. package/src/scripts/self_review_gate.ts +41 -3
  131. package/src/scripts/skill_linter.ts +3 -1
  132. package/src/scripts/smoke/router.sh +1 -1
  133. package/src/scripts/update_counts.ts +60 -12
  134. package/src/server/schemas/settings.ts +23 -0
  135. package/dist/cli-delegate/{chunk-XX6TOPD7.js → chunk-WR6GTRHJ.js} +3 -3
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Profile → persona id resolution gate.
4
+ *
5
+ * ## The defect this catches
6
+ *
7
+ * Every seed profile under `src/agent-src/profiles/*.yml` pre-selects persona
8
+ * ids in `defaults.personas`. Persona ids are the filenames under
9
+ * `src/agent-src/personas/` (plus `personas/advisors/`), and nothing else
10
+ * historically checked the join — all six seed profiles shipped ids
11
+ * (`reviewer`, `security`, `editor`, …) that resolved to no persona file at
12
+ * all, so the pre-selection was silently dead. This gate makes a broken id a
13
+ * CI failure with a nearest-match hint.
14
+ *
15
+ * Contract (src/config/gate-coverage.yml): emits exactly one `scanned: <N>`
16
+ * line, N = profiles scanned.
17
+ *
18
+ * Exit: 0 clean · 1 usage/IO error · 2 findings.
19
+ */
20
+ import * as fs from 'node:fs';
21
+ import * as path from 'node:path';
22
+ import { fileURLToPath, pathToFileURL } from 'node:url';
23
+ import { parse as parseYaml } from 'yaml';
24
+
25
+ const _HERE = fileURLToPath(import.meta.url);
26
+ const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
27
+
28
+ export interface Finding {
29
+ file: string;
30
+ id: string;
31
+ hint: string;
32
+ }
33
+
34
+ /** Persona ids = `.md` basenames under personas/ and personas/advisors/. */
35
+ export function known_persona_ids(repoRoot: string): Set<string> {
36
+ const ids = new Set<string>();
37
+ const roots = [
38
+ path.join(repoRoot, 'src', 'agent-src', 'personas'),
39
+ path.join(repoRoot, 'src', 'agent-src', 'personas', 'advisors'),
40
+ ];
41
+ for (const dir of roots) {
42
+ if (!fs.existsSync(dir)) continue;
43
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
44
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
45
+ if (entry.name === 'README.md') continue;
46
+ ids.add(entry.name.slice(0, -'.md'.length));
47
+ }
48
+ }
49
+ return ids;
50
+ }
51
+
52
+ function levenshtein(a: string, b: string): number {
53
+ const prev: number[] = Array.from({ length: b.length + 1 }, (_, i) => i);
54
+ for (let i = 1; i <= a.length; i++) {
55
+ let diag = prev[0]!;
56
+ prev[0] = i;
57
+ for (let j = 1; j <= b.length; j++) {
58
+ const cur = prev[j]!;
59
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
60
+ prev[j] = Math.min(cur + 1, prev[j - 1]! + 1, diag + cost);
61
+ diag = cur;
62
+ }
63
+ }
64
+ return prev[b.length]!;
65
+ }
66
+
67
+ /** Closest known id by substring containment first, edit distance second. */
68
+ export function nearest_match(id: string, known: ReadonlySet<string>): string {
69
+ let best = '';
70
+ let bestScore = Number.POSITIVE_INFINITY;
71
+ for (const candidate of known) {
72
+ const contains = candidate.includes(id) || id.includes(candidate);
73
+ const score = levenshtein(id, candidate) - (contains ? 100 : 0);
74
+ if (score < bestScore) {
75
+ bestScore = score;
76
+ best = candidate;
77
+ }
78
+ }
79
+ return best;
80
+ }
81
+
82
+ /**
83
+ * Scan every `profiles/*.yml` under `repoRoot`'s profile dir and resolve
84
+ * `defaults.personas` against the known persona ids.
85
+ */
86
+ export function scan_profiles(repoRoot: string): { findings: Finding[]; scanned: number } {
87
+ const profileDir = path.join(repoRoot, 'src', 'agent-src', 'profiles');
88
+ if (!fs.existsSync(profileDir)) {
89
+ throw new Error(`profile directory not found: ${profileDir}`);
90
+ }
91
+ const known = known_persona_ids(repoRoot);
92
+ const findings: Finding[] = [];
93
+ let scanned = 0;
94
+ const files = fs
95
+ .readdirSync(profileDir)
96
+ .filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'))
97
+ .sort();
98
+ for (const file of files) {
99
+ const full = path.join(profileDir, file);
100
+ const rel = path.relative(repoRoot, full);
101
+ const parsed = parseYaml(fs.readFileSync(full, 'utf-8')) as Record<string, unknown> | null;
102
+ scanned += 1;
103
+ const profile = (parsed?.['profile'] ?? {}) as Record<string, unknown>;
104
+ const defaults = (profile['defaults'] ?? {}) as Record<string, unknown>;
105
+ const personas = defaults['personas'];
106
+ if (!Array.isArray(personas)) continue;
107
+ for (const raw of personas) {
108
+ const id = String(raw);
109
+ if (known.has(id)) continue;
110
+ const hint = known.size > 0 ? `did you mean \`${nearest_match(id, known)}\`?` : 'no personas found';
111
+ findings.push({ file: rel, id, hint });
112
+ }
113
+ }
114
+ return { findings, scanned };
115
+ }
116
+
117
+ export function main(argv?: readonly string[]): number {
118
+ let quiet = false;
119
+ for (const arg of argv ?? process.argv.slice(2)) {
120
+ if (arg === '--quiet') {
121
+ quiet = true;
122
+ } else {
123
+ process.stderr.write(`usage: lint_profile_personas [--quiet]\n`);
124
+ return 1;
125
+ }
126
+ }
127
+
128
+ let result: { findings: Finding[]; scanned: number };
129
+ try {
130
+ result = scan_profiles(REPO_ROOT);
131
+ } catch (e) {
132
+ process.stderr.write(`error: ${String(e)}\n`);
133
+ return 1;
134
+ }
135
+
136
+ for (const f of result.findings) {
137
+ process.stdout.write(
138
+ `❌ ${f.file} defaults.personas id \`${f.id}\` resolves to no file under src/agent-src/personas/ — ${f.hint}\n`,
139
+ );
140
+ }
141
+ if (result.findings.length === 0 && !quiet) {
142
+ process.stdout.write(
143
+ `✅ profile personas: every defaults.personas id resolves to a persona file\n`,
144
+ );
145
+ }
146
+ // gate-coverage contract (src/config/gate-coverage.yml): profiles inspected.
147
+ process.stdout.write(`scanned: ${String(result.scanned)}\n`);
148
+ return result.findings.length > 0 ? 2 : 0;
149
+ }
150
+
151
+ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
152
+ process.exit(main());
153
+ }
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Token-budget-discipline gate (src/rules/token-budget-discipline.md).
4
+ *
5
+ * The rule names this linter as its CI enforcer for two obligations on
6
+ * `token_budget_class: rich` skills:
7
+ *
8
+ * 1. CAP — at most 15 % of the suite's skills may claim `rich`. Exceeding
9
+ * the ratio is a hard fail (both numbers are printed).
10
+ * 2. JUSTIFICATION — every `rich`-tagged skill carries a
11
+ * `## Why this skill is rich` section explaining the irreducible
12
+ * complexity.
13
+ *
14
+ * Additionally, any `token_budget_class` value outside
15
+ * `lean | standard | rich` is a finding (schema drift).
16
+ *
17
+ * Exit: 0 clean · 1 usage/IO error · 2 findings.
18
+ */
19
+ import * as fs from 'node:fs';
20
+ import * as path from 'node:path';
21
+ import { fileURLToPath, pathToFileURL } from 'node:url';
22
+
23
+ const _HERE = fileURLToPath(import.meta.url);
24
+ const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
25
+
26
+ export const SKILLS_ROOT = 'src/skills';
27
+
28
+ /** The rule's cap: rich-tagged skills / total skills must stay <= 15 %. */
29
+ export const RICH_RATIO_CAP = 0.15;
30
+
31
+ export const VALID_CLASSES = ['lean', 'standard', 'rich'] as const;
32
+
33
+ export const RICH_SECTION_HEADING = '## Why this skill is rich';
34
+
35
+ export interface Finding {
36
+ /** repo-relative path */
37
+ file: string;
38
+ message: string;
39
+ }
40
+
41
+ export interface ScanResult {
42
+ findings: Finding[];
43
+ /** SKILL.md files scanned. */
44
+ scanned: number;
45
+ /** Skills declaring token_budget_class: rich. */
46
+ richCount: number;
47
+ }
48
+
49
+ /** `token_budget_class` value from a SKILL.md's YAML frontmatter, or null. */
50
+ export function frontmatter_class(content: string): string | null {
51
+ if (!content.startsWith('---')) {
52
+ return null;
53
+ }
54
+ const end = content.indexOf('\n---', 3);
55
+ if (end === -1) {
56
+ return null;
57
+ }
58
+ const frontmatter = content.slice(0, end);
59
+ const m = /^token_budget_class:\s*['"]?([^'"\s#]+)/m.exec(frontmatter);
60
+ return m ? m[1]! : null;
61
+ }
62
+
63
+ /** Scan one skills root (directory of `<skill>/SKILL.md`). */
64
+ export function scan_skills(skillsRoot: string): ScanResult {
65
+ const findings: Finding[] = [];
66
+ let scanned = 0;
67
+ let richCount = 0;
68
+ const entries = fs
69
+ .readdirSync(skillsRoot, { withFileTypes: true })
70
+ .filter((e) => e.isDirectory())
71
+ .map((e) => e.name)
72
+ .sort();
73
+ for (const name of entries) {
74
+ const skillFile = path.join(skillsRoot, name, 'SKILL.md');
75
+ if (!fs.existsSync(skillFile)) {
76
+ continue;
77
+ }
78
+ scanned += 1;
79
+ const rel = path.relative(REPO_ROOT, skillFile).split(path.sep).join('/');
80
+ const content = fs.readFileSync(skillFile, 'utf-8');
81
+ const cls = frontmatter_class(content);
82
+ if (cls === null) {
83
+ continue; // no key = lean by default (rule § the three classes)
84
+ }
85
+ if (!(VALID_CLASSES as readonly string[]).includes(cls)) {
86
+ findings.push({
87
+ file: rel,
88
+ message: `invalid token_budget_class '${cls}' — must be one of ${VALID_CLASSES.join(' | ')}`,
89
+ });
90
+ continue;
91
+ }
92
+ if (cls === 'rich') {
93
+ richCount += 1;
94
+ if (!content.includes(RICH_SECTION_HEADING)) {
95
+ findings.push({
96
+ file: rel,
97
+ message: `rich-tagged skill missing the '${RICH_SECTION_HEADING}' justification section`,
98
+ });
99
+ }
100
+ }
101
+ }
102
+ if (scanned > 0 && richCount > scanned * RICH_RATIO_CAP) {
103
+ findings.push({
104
+ file: SKILLS_ROOT,
105
+ message:
106
+ `rich-skill cap exceeded: ${String(richCount)} rich of ${String(scanned)} skills ` +
107
+ `(${((richCount / scanned) * 100).toFixed(1)} % > ${String(RICH_RATIO_CAP * 100)} % cap, ` +
108
+ `max ${String(Math.floor(scanned * RICH_RATIO_CAP))})`,
109
+ });
110
+ }
111
+ return { findings, scanned, richCount };
112
+ }
113
+
114
+ export function main(argv?: readonly string[]): number {
115
+ let quiet = false;
116
+ for (const arg of argv ?? process.argv.slice(2)) {
117
+ if (arg === '--quiet') {
118
+ quiet = true;
119
+ } else {
120
+ process.stderr.write('usage: lint_token_budget_discipline [--quiet]\n');
121
+ return 1;
122
+ }
123
+ }
124
+
125
+ let result: ScanResult;
126
+ try {
127
+ result = scan_skills(path.join(REPO_ROOT, SKILLS_ROOT));
128
+ } catch (e) {
129
+ process.stderr.write(`error: ${String(e)}\n`);
130
+ return 1;
131
+ }
132
+
133
+ for (const f of result.findings) {
134
+ process.stdout.write(`❌ ${f.file} ${f.message}\n`);
135
+ }
136
+ if (result.findings.length === 0 && !quiet) {
137
+ process.stdout.write(
138
+ `✅ token-budget discipline: ${String(result.richCount)} rich of ${String(result.scanned)} skills within the ${String(RICH_RATIO_CAP * 100)} % cap; every rich skill justifies itself\n`,
139
+ );
140
+ }
141
+ // gate-coverage contract (src/config/gate-coverage.yml): files inspected.
142
+ process.stdout.write(`scanned: ${String(result.scanned)}\n`);
143
+ return result.findings.length > 0 ? 2 : 0;
144
+ }
145
+
146
+ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
147
+ process.exit(main());
148
+ }
@@ -118,6 +118,12 @@ import {
118
118
  perform_split,
119
119
  plan_split,
120
120
  } from './_lib/changelog_eras.js';
121
+ import {
122
+ extract_changelog_section,
123
+ pr_body_from_section,
124
+ release_notes_from_section,
125
+ tag_message_from_section,
126
+ } from './_lib/release_material.js';
121
127
 
122
128
  // `__doc__.splitlines()[0]` in `_parse_args` — the argparse description. Kept
123
129
  // as a referenceable constant so the first docstring line is preserved exactly.
@@ -517,6 +523,22 @@ interface RunResult {
517
523
  * stderr so release preflight failures are diagnosable without re-running with
518
524
  * a debugger.
519
525
  */
526
+ /**
527
+ * Drill/test seam — when set, every external command run() would spawn is
528
+ * answered by this function instead. The check/die/CalledProcessError
529
+ * semantics of run() still apply to the simulated result, so the drill
530
+ * exercises the REAL error paths of the orchestration (release_drill.ts).
531
+ */
532
+ type ExecOverride = (args: readonly string[]) => {
533
+ status: number;
534
+ stdout: string;
535
+ stderr: string;
536
+ };
537
+ let _exec_override: ExecOverride | null = null;
538
+ function _set_exec_override(fn: ExecOverride | null): void {
539
+ _exec_override = fn;
540
+ }
541
+
520
542
  function run(
521
543
  args: readonly string[],
522
544
  opts: { check?: boolean; capture?: boolean; cwd?: string | null } = {},
@@ -526,13 +548,15 @@ function run(
526
548
  const cwd = opts.cwd ?? REPO_ROOT;
527
549
 
528
550
  const [cmd, ...rest] = args;
529
- const res = spawnSync(cmd as string, rest, {
530
- cwd,
531
- encoding: 'utf-8',
532
- // capture_output=True → pipe; else inherit so child writes straight to
533
- // this process's stdout/stderr (text mode, matching subprocess text=True).
534
- stdio: capture ? ['ignore', 'pipe', 'pipe'] : ['inherit', 'inherit', 'inherit'],
535
- });
551
+ const res = _exec_override
552
+ ? { ..._exec_override(args), error: undefined as Error | undefined }
553
+ : spawnSync(cmd as string, rest, {
554
+ cwd,
555
+ encoding: 'utf-8',
556
+ // capture_output=True → pipe; else inherit so child writes straight to
557
+ // this process's stdout/stderr (text mode, matching subprocess text=True).
558
+ stdio: capture ? ['ignore', 'pipe', 'pipe'] : ['inherit', 'inherit', 'inherit'],
559
+ });
536
560
 
537
561
  if (res.error) {
538
562
  // FileNotFoundError analogue (ENOENT) and other spawn failures — Python
@@ -619,6 +643,31 @@ export function _failed_checks_report(names: readonly string[]): string {
619
643
  'empty (docs/contracts/release-pr-gating.md § Mid-release fixes).',
620
644
  );
621
645
  }
646
+ if (names.includes('PR body equals CHANGELOG entry')) {
647
+ lines.push(
648
+ '',
649
+ 'Surface equality: the PR body drifted from the CHANGELOG entry. A',
650
+ 'resume run refreshes it from the branch head (release-truth Phase 1);',
651
+ 'never hand-edit the PR body away from the changelog section.',
652
+ );
653
+ }
654
+ if (names.includes('Curated highlights plausible against the span')) {
655
+ lines.push(
656
+ '',
657
+ 'Highlights: a curated `_none_` field contradicts the release span.',
658
+ 'Fill the curated head in CHANGELOG.md on the release branch — the',
659
+ 'gate output lists the deriving commits (release-truth Phase 2).',
660
+ );
661
+ }
662
+ if (names.includes('Blocking review findings dispositioned')) {
663
+ lines.push(
664
+ '',
665
+ 'Dispositions: a blocking/high self-review finding has no committed',
666
+ 'disposition. Ingest + adjudicate it in',
667
+ 'agents/evidence/release-findings/<version>.json (release-truth Phase 3):',
668
+ ' ./scripts-run src/scripts/check_finding_dispositions --ingest <findings.json>',
669
+ );
670
+ }
622
671
  lines.push('', 'After fixing, resume with: task release -- --resume --yes', '');
623
672
  return lines.join('\n');
624
673
  }
@@ -635,24 +684,24 @@ export function _failed_checks_report(names: readonly string[]): string {
635
684
  * freshly-pushed branch.
636
685
  */
637
686
  function watch_pr_checks(branch: string): void {
638
- // time.sleep(5) — blocking grace period. Never reached on any test path.
639
- const until = Date.now() + 5000;
640
- while (Date.now() < until) {
641
- // busy-wait stand-in for time.sleep(5) without an event-loop yield.
687
+ if (_exec_override === null) {
688
+ // time.sleep(5) — blocking grace period for GitHub to register runs
689
+ // on a freshly-pushed branch. Skipped under the drill seam: there is
690
+ // no GitHub to wait for, and 5s per simulated wait adds up.
691
+ const until = Date.now() + 5000;
692
+ while (Date.now() < until) {
693
+ // busy-wait stand-in for time.sleep(5) without an event-loop yield.
694
+ }
642
695
  }
643
696
  // The branch is passed explicitly — `gh` inferring the PR from HEAD
644
697
  // resolved to `main` mid-run once (2026-08-03, 9.16.0 resume) and the
645
698
  // whole release died on "no pull requests found for branch main".
646
- const proc = spawnSync('gh', ['pr', 'checks', branch, '--watch'], {
647
- cwd: REPO_ROOT,
648
- encoding: 'utf-8',
649
- stdio: ['ignore', 'pipe', 'pipe'],
699
+ const proc = run(['gh', 'pr', 'checks', branch, '--watch'], {
700
+ check: false,
701
+ capture: true,
650
702
  });
651
- if (proc.error) {
652
- throw proc.error;
653
- }
654
703
  const output = ((proc.stdout || '') + (proc.stderr || '')).trim();
655
- const returncode = proc.status ?? 0;
704
+ const returncode = proc.returncode;
656
705
  if (returncode === 0) {
657
706
  if (output) {
658
707
  process.stdout.write(output + '\n');
@@ -670,10 +719,9 @@ function watch_pr_checks(branch: string): void {
670
719
  if (output) {
671
720
  process.stderr.write(output + '\n');
672
721
  }
673
- const summary = spawnSync('gh', ['pr', 'checks', branch, '--json', 'name,bucket'], {
674
- cwd: REPO_ROOT,
675
- encoding: 'utf-8',
676
- stdio: ['ignore', 'pipe', 'pipe'],
722
+ const summary = run(['gh', 'pr', 'checks', branch, '--json', 'name,bucket'], {
723
+ check: false,
724
+ capture: true,
677
725
  });
678
726
  const report = _failed_checks_report(_failed_check_names(summary.stdout ?? ''));
679
727
  if (report) {
@@ -734,11 +782,45 @@ const _MERGE_UPDATE_ROUNDS = 3;
734
782
  * version-bump allowlist (docs/contracts/release-pr-gating.md § Mid-release
735
783
  * fixes) — do it here instead of aborting the run.
736
784
  */
785
+ /**
786
+ * Re-derive the release-PR body from the CHANGELOG section at the current
787
+ * release-branch head and push it to the PR (release-truth Phase 1: one
788
+ * final source — late commits and maintainer edits on the branch must reach
789
+ * the PR body instead of leaving it frozen at plan time). Best-effort sync
790
+ * of the local branch first; a no-op when the derived body is unchanged.
791
+ */
792
+ function _target_from_branch(branch: string): string {
793
+ return branch.replace(/^release\//u, '');
794
+ }
795
+
796
+ function _refresh_pr_body_from_head(branch: string, target: string): void {
797
+ run(['git', 'pull', '--ff-only', REMOTE, branch], { check: false });
798
+ const changelog = fs.readFileSync(CHANGELOG, 'utf-8');
799
+ const section = extract_changelog_section(changelog, target);
800
+ if (!section) {
801
+ die(`CHANGELOG.md at ${branch} head carries no section for ${target} — cannot derive PR body`);
802
+ }
803
+ const capped = _cap_body(section!.body, GH_PR_BODY_LIMIT - 200, '`CHANGELOG.md` in this PR');
804
+ const body = pr_body_from_section(capped, target);
805
+ const live = run(['gh', 'pr', 'view', branch, '--json', 'body', '-q', '.body'], {
806
+ check: false,
807
+ capture: true,
808
+ });
809
+ if (live.returncode === 0 && live.stdout.replace(/\r\n/gu, '\n').trim() === body.trim()) {
810
+ return;
811
+ }
812
+ run(['gh', 'pr', 'edit', branch, '--body', body]);
813
+ }
814
+
737
815
  function merge_release_pr(branch: string, wait_for_checks: boolean): void {
738
816
  if (git(['rev-parse', '--abbrev-ref', 'HEAD'], { capture: true }) !== branch) {
739
817
  run(['git', 'checkout', branch]);
740
818
  }
741
819
  for (let round = 0; round <= _MERGE_UPDATE_ROUNDS; round++) {
820
+ // One final generation step at the final head: every merge attempt
821
+ // first re-derives the PR body from the branch-head CHANGELOG, so
822
+ // update rounds and maintainer edits can never desynchronize it.
823
+ _refresh_pr_body_from_head(branch, _target_from_branch(branch));
742
824
  if (_pr_merge_state(branch) !== 'BEHIND') {
743
825
  const merged = run(['gh', 'pr', 'merge', branch, '--merge', '--delete-branch'], {
744
826
  check: false,
@@ -1708,18 +1790,27 @@ function execute(
1708
1790
  if (pr_merged) {
1709
1791
  _step(5, total, `PR #${pr_info!['number']} already merged — skip`);
1710
1792
  } else if (resume && pr_state === 'OPEN') {
1711
- _step(5, total, `PR already open: ${pr_info!['url']}`);
1793
+ // One final source: a resumed run re-derives the body from the
1794
+ // branch-head CHANGELOG instead of leaving the plan-time snapshot —
1795
+ // this was the 9.14.0 test-count desync (resume re-rendered from a
1796
+ // later HEAD while the changelog kept the branch's entry).
1797
+ _step(5, total, `PR already open: ${pr_info!['url']} — refresh body from branch head`);
1798
+ _refresh_pr_body_from_head(branch, plan.target);
1712
1799
  } else {
1713
1800
  _step(5, total, 'Open pull request');
1801
+ // Derive the PR body from the CHANGELOG section step 2 just wrote —
1802
+ // the file is the single source; the in-memory plan is only its
1803
+ // author (release-truth Phase 1).
1804
+ const section = extract_changelog_section(fs.readFileSync(CHANGELOG, 'utf-8'), plan.target);
1805
+ if (!section) {
1806
+ die(`CHANGELOG.md carries no section for ${plan.target} — cannot derive PR body`);
1807
+ }
1714
1808
  const pr_changelog = _cap_body(
1715
- plan.changelog_body,
1809
+ section!.body,
1716
1810
  GH_PR_BODY_LIMIT - 200, // leave room for the prefix + footer
1717
1811
  '`CHANGELOG.md` in this PR',
1718
1812
  );
1719
- const pr_body =
1720
- `Release ${plan.target}.\n\n` +
1721
- `${pr_changelog}\n\n` +
1722
- 'Created by `scripts/release.py`.';
1813
+ const pr_body = pr_body_from_section(pr_changelog, plan.target);
1723
1814
  run([
1724
1815
  'gh',
1725
1816
  'pr',
@@ -1770,8 +1861,21 @@ function execute(
1770
1861
  run(['git', 'push', REMOTE, plan.target]);
1771
1862
  }
1772
1863
  } else {
1773
- _step(8, total, `Tag merge commit and push ${plan.target}`);
1774
- run(['git', 'tag', plan.target]);
1864
+ // Sequencing is load-bearing (release-truth Phase 1, council
1865
+ // 2026-08-03): merge FIRST (step 7), pull main (above), THEN derive
1866
+ // the tag message from the MERGED changelog — tagging before the
1867
+ // merge would read a section that does not exist yet. The annotated
1868
+ // tag replaces the previous lightweight one so tag metadata is a
1869
+ // fourth surface carrying the same single-source content.
1870
+ _step(8, total, `Tag merge commit (annotated, from merged CHANGELOG) and push ${plan.target}`);
1871
+ const merged = extract_changelog_section(fs.readFileSync(CHANGELOG, 'utf-8'), plan.target);
1872
+ if (!merged) {
1873
+ die(
1874
+ `CHANGELOG.md on ${MAIN_BRANCH} carries no section for ${plan.target} — ` +
1875
+ 'refusing to tag a release whose changelog entry is missing',
1876
+ );
1877
+ }
1878
+ run(['git', 'tag', '-a', plan.target, '-m', tag_message_from_section(merged!.body, plan.target)]);
1775
1879
  run(['git', 'push', REMOTE, plan.target]);
1776
1880
  }
1777
1881
 
@@ -1786,8 +1890,19 @@ function execute(
1786
1890
  ? 'Create GitHub Release (tag push under GITHUB_TOKEN triggers nothing — dispatching next)'
1787
1891
  : 'Create GitHub Release (triggers publish-npm on the tag)',
1788
1892
  );
1893
+ // One final source: the notes come from the CHANGELOG as recorded AT
1894
+ // THE TAG — never from the plan-time render (whose scope can differ
1895
+ // from the merged content; that was the 9.14.0 desync).
1896
+ const tagged_changelog = git(['show', `${plan.target}:CHANGELOG.md`], { capture: true });
1897
+ const tagged_section = extract_changelog_section(tagged_changelog, plan.target);
1898
+ if (!tagged_section) {
1899
+ die(
1900
+ `CHANGELOG.md at tag ${plan.target} carries no section for ${plan.target} — ` +
1901
+ 'refusing to publish release notes from a different source',
1902
+ );
1903
+ }
1789
1904
  const notes = _cap_body(
1790
- plan.changelog_body || `Release ${plan.target}`,
1905
+ release_notes_from_section(tagged_section!.body, plan.target),
1791
1906
  GH_RELEASE_NOTES_LIMIT,
1792
1907
  '`CHANGELOG.md`',
1793
1908
  );
@@ -2363,5 +2478,14 @@ export {
2363
2478
  SEMVER_RE,
2364
2479
  _RELEASE_BRANCH_RE,
2365
2480
  MODULE_DOC_FIRST_LINE,
2481
+ // Drill seam (release_drill.ts) — the step machinery under a simulated
2482
+ // git/gh world, so orchestration bugs surface in vitest, not mid-release.
2483
+ execute,
2484
+ _set_exec_override,
2485
+ push_release_branch,
2486
+ merge_release_pr,
2487
+ watch_pr_checks,
2488
+ _pr_merge_state,
2489
+ _MERGE_UPDATE_ROUNDS,
2366
2490
  };
2367
2491
  export type { Args };