@event4u/agent-config 14.20.0 → 14.21.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 (100) 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 +243 -0
  5. package/README.md +1 -1
  6. package/agents/templates/.ai-council.yml.example +10 -0
  7. package/dist/agent-src/commands/analyze/inbox.md +1 -1
  8. package/dist/agent-src/commands/analyze/{reference-repo.md → repo.md} +247 -32
  9. package/dist/agent-src/commands/analyze/roadmap-repos.md +129 -0
  10. package/dist/agent-src/commands/analyze.md +12 -7
  11. package/dist/agent-src/commands/feature/plan.md +55 -16
  12. package/dist/agent-src/commands/feature/refactor.md +8 -3
  13. package/dist/agent-src/commands/humanize.md +48 -4
  14. package/dist/agent-src/contexts/execution/contract-decision-sheet.md +22 -1
  15. package/dist/agent-src/contexts/execution/host-capability-manifest.md +4 -1
  16. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +35 -2
  17. package/dist/agent-src/contexts/execution/terminal-states.md +4 -1
  18. package/dist/agent-src/rules/evaluator-independence.md +4 -4
  19. package/dist/agent-src/rules/external-reference-deep-dive.md +2 -2
  20. package/dist/agent-src/skills/competitive-positioning/SKILL.md +2 -2
  21. package/dist/agent-src/skills/humanizer/references/anti-aiisms.md +27 -11
  22. package/dist/agent-src/skills/learning-to-rule-or-skill/SKILL.md +1 -1
  23. package/dist/agent-src/skills/markitdown/SKILL.md +1 -1
  24. package/dist/agent-src/skills/repomix-packer/SKILL.md +1 -1
  25. package/dist/agent-src/skills/technical-specification/SKILL.md +4 -0
  26. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  27. package/dist/agent-src/templates/features.md +5 -0
  28. package/dist/agent-src/templates/roadmaps.md +15 -0
  29. package/dist/cli-delegate/{chunk-KPBWFOPZ.js → chunk-2FTMENJL.js} +194 -37
  30. package/dist/cli-delegate/{chunk-PI2GU3DP.js → chunk-2TAQ2K4V.js} +4 -1
  31. package/dist/cli-delegate/{chunk-C6IPZRDA.js → chunk-643Z7FIC.js} +1 -1
  32. package/dist/cli-delegate/{chunk-NUMQHLS5.js → chunk-J3RFNKG4.js} +21 -2
  33. package/dist/cli-delegate/cmd_conformance.js +17 -3
  34. package/dist/cli-delegate/cmd_doctor.js +3 -3
  35. package/dist/cli-delegate/cmd_export.js +2 -2
  36. package/dist/cli-delegate/cmd_handoff.js +1 -1
  37. package/dist/cli-delegate/cmd_prune.js +2 -2
  38. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  39. package/dist/cli-delegate/cmd_sync.js +2 -2
  40. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  41. package/dist/cli-delegate/cmd_validate.js +2 -2
  42. package/dist/discovery/deprecation-report.md +1 -1
  43. package/dist/discovery/discovery-manifest.json +56 -25
  44. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  45. package/dist/discovery/discovery-manifest.summary.md +3 -3
  46. package/dist/discovery/dormancy-report.md +1 -1
  47. package/dist/discovery/orphan-report.md +1 -1
  48. package/dist/discovery/packs.json +7 -6
  49. package/dist/discovery/trust-report.md +2 -2
  50. package/dist/discovery/workspaces.json +6 -5
  51. package/dist/hooks/dispatch.js +479 -241
  52. package/dist/install/install.mjs +4 -1
  53. package/dist/mcp/registry-manifest.json +2 -2
  54. package/dist/mcp/server.json +2 -2
  55. package/dist/mcp/server.mjs +313 -63
  56. package/dist/scripts/ai_council/content_ceiling.js +63 -0
  57. package/dist/scripts/ai_council/seat_policy.js +106 -0
  58. package/dist/scripts/ai_council/transport_resolver.js +21 -0
  59. package/llms.txt +14 -14
  60. package/package.json +1 -1
  61. package/src/config/gate-violation-baselines.json +3 -3
  62. package/src/config/release-gate-locality.yml +4 -3
  63. package/src/scripts/_lib/blocked_by_marker.ts +189 -0
  64. package/src/scripts/_lib/host_capability.ts +38 -1
  65. package/src/scripts/_lib/release_highlights.ts +19 -211
  66. package/src/scripts/_lib/release_material.ts +27 -77
  67. package/src/scripts/_lib/structured_ask.ts +117 -0
  68. package/src/scripts/_lib/tty_prompt.ts +76 -0
  69. package/src/scripts/ai_council/api_transport.ts +163 -0
  70. package/src/scripts/ai_council/clients.ts +58 -63
  71. package/src/scripts/ai_council/config.ts +13 -0
  72. package/src/scripts/ai_council/content_ceiling.ts +79 -0
  73. package/src/scripts/ai_council/jury_aggregate.ts +178 -0
  74. package/src/scripts/ai_council/seat_policy.ts +137 -0
  75. package/src/scripts/ai_council/status_surface.ts +79 -0
  76. package/src/scripts/ai_council/transport_resolver.ts +59 -1
  77. package/src/scripts/ai_tells_rules.ts +446 -5
  78. package/src/scripts/ask_block_census.ts +499 -0
  79. package/src/scripts/bench_humanizer_eval.ts +264 -40
  80. package/src/scripts/bench_prose_tells_fp.ts +198 -0
  81. package/src/scripts/check_branch_work_committed.ts +234 -0
  82. package/src/scripts/check_pack_size.ts +1 -1
  83. package/src/scripts/check_release_highlights.ts +0 -157
  84. package/src/scripts/council_cli.ts +18 -27
  85. package/src/scripts/detect_ai_tells.ts +142 -20
  86. package/src/scripts/harvest_reference_tokens.ts +698 -0
  87. package/src/scripts/hook_manifest.json +1 -1
  88. package/src/scripts/hook_manifest.yaml +31 -1
  89. package/src/scripts/hooks/concern_registry.ts +2 -0
  90. package/src/scripts/hooks/delegation_nudge_hook.ts +45 -1
  91. package/src/scripts/hooks/one_question_per_ask_hook.ts +191 -0
  92. package/src/scripts/install-hooks.sh +52 -0
  93. package/src/scripts/lint_roadmap_blockers.ts +51 -1
  94. package/src/scripts/migrate_command_suggestions.ts +1 -1
  95. package/src/scripts/probe_unblocked_ask.ts +135 -2
  96. package/src/scripts/release.ts +26 -71
  97. package/src/scripts/release_drill.ts +6 -10
  98. package/src/scripts/release_env.ts +1 -0
  99. package/src/scripts/release_publication.ts +34 -29
  100. package/src/scripts/self_review_gate.ts +409 -15
@@ -47,18 +47,40 @@
47
47
  * Both edges are printed. A reader who cannot see the bracket cannot judge the
48
48
  * number.
49
49
  *
50
+ * The `form` dimension (road-to-asked-not-parked 1.1)
51
+ *
52
+ * Every unblocked ask is additionally partitioned by the FORM the ask took:
53
+ *
54
+ * - `native` — the same assistant turn carries a tool call whose name is a
55
+ * structured-ask tool (`_lib/structured_ask.ts`, `isStructuredAskTool`).
56
+ * - `text` — everything else, i.e. the ask was prose.
57
+ *
58
+ * The two are exhaustive and disjoint by construction, so `text + native`
59
+ * always equals `unblockedAsks`. That identity is asserted in `--self-test`
60
+ * rather than left to a reader to check.
61
+ *
62
+ * Expect `native` to be **0** on every corpus this repo can scan today: no host
63
+ * in the capability registry carries an observed structured-ask tool, and
64
+ * `structured_ask.ts` states why matching on a name-shape pattern is not a claim
65
+ * that any host ships one. A measured zero and an uninstrumented zero look the
66
+ * same in a report and are not the same fact — this axis is what makes them
67
+ * distinguishable later.
68
+ *
50
69
  * Usage:
51
70
  * ./scripts-run src/scripts/probe_unblocked_ask [--limit N] [--store PATH] [--json]
52
71
  * ./scripts-run src/scripts/probe_unblocked_ask --self-test
72
+ * ./scripts-run src/scripts/probe_unblocked_ask --help
53
73
  *
54
74
  * Exit codes: 0 always — a measurement, not a gate. `--self-test` exits 1 when
55
75
  * the detector fails to fire on its fixtures.
56
76
  */
57
77
  import fs from 'node:fs';
78
+ import os from 'node:os';
58
79
  import path from 'node:path';
59
80
  import { pathToFileURL } from 'node:url';
60
81
 
61
82
  import { defaultStore, HARNESS_TEXT, isInjectedBody } from './conformance_scan.js';
83
+ import { isStructuredAskTool } from './_lib/structured_ask.js';
62
84
  import { isSyntheticPrompt } from './_lib/prompt_shape.js';
63
85
 
64
86
  /** A numbered-options block — the surface `check_reply_consistency` already reads. */
@@ -88,6 +110,8 @@ interface Turn {
88
110
  role: 'user' | 'assistant';
89
111
  text: string;
90
112
  at: string;
113
+ /** Tool names invoked in this turn — empty for a user turn. */
114
+ tools: string[];
91
115
  }
92
116
 
93
117
  function turns(lines: string[]): Turn[] {
@@ -116,10 +140,16 @@ function turns(lines: string[]): Turn[] {
116
140
  .map((b: { text?: string }) => b.text ?? '')
117
141
  .join('\n')
118
142
  : '';
143
+ const tools = Array.isArray(c)
144
+ ? c
145
+ .filter((b: { type?: string }) => b?.type === 'tool_use')
146
+ .map((b: { name?: string }) => String(b?.name ?? ''))
147
+ .filter((n: string) => n !== '')
148
+ : [];
119
149
  if (!text.trim()) continue;
120
150
  if (role === 'assistant' && HARNESS_TEXT.test(text.trim())) continue;
121
151
  if (role === 'user' && (isSyntheticPrompt(text) || isInjectedBody(text))) continue;
122
- out.push({ role, text, at: String(e['timestamp'] ?? '') });
152
+ out.push({ role, text, at: String(e['timestamp'] ?? ''), tools });
123
153
  }
124
154
  return out;
125
155
  }
@@ -139,16 +169,39 @@ export function classifyTail(tail: string): AskVerdict {
139
169
  return { unblockedAsk: true, malformed: !RECOMMENDATION.test(tail) };
140
170
  }
141
171
 
172
+ /** The ask FORM partition. Exhaustive and disjoint: `text + native === unblockedAsks`. */
173
+ export interface FormBreakdown {
174
+ text: number;
175
+ native: number;
176
+ }
177
+
178
+ /**
179
+ * Which FORM an ask took. `native` iff the turn carried a structured-ask tool
180
+ * call; `text` otherwise. Exported so the partition is one function rather than
181
+ * an inline condition a later edit can quietly make non-exhaustive.
182
+ */
183
+ export function askForm(tools: readonly string[]): 'text' | 'native' {
184
+ return tools.some((n) => isStructuredAskTool(n)) ? 'native' : 'text';
185
+ }
186
+
142
187
  export interface Result {
143
188
  handbacks: number;
144
189
  withBlock: number;
145
190
  unblockedAsks: number;
146
191
  malformed: number;
192
+ form: FormBreakdown;
147
193
  samples: { session: string; at: string; span: string }[];
148
194
  }
149
195
 
150
196
  export function measure(store: string, limit: number): Result {
151
- const r: Result = { handbacks: 0, withBlock: 0, unblockedAsks: 0, malformed: 0, samples: [] };
197
+ const r: Result = {
198
+ handbacks: 0,
199
+ withBlock: 0,
200
+ unblockedAsks: 0,
201
+ malformed: 0,
202
+ form: { text: 0, native: 0 },
203
+ samples: [],
204
+ };
152
205
  let files: string[];
153
206
  try {
154
207
  files = fs.readdirSync(store).filter((f) => f.endsWith('.jsonl'));
@@ -179,6 +232,7 @@ export function measure(store: string, limit: number): Result {
179
232
  const v = classifyTail(tail);
180
233
  if (!v.unblockedAsk) continue;
181
234
  r.unblockedAsks += 1;
235
+ r.form[askForm(t.tools)] += 1;
182
236
  if (v.malformed) {
183
237
  r.malformed += 1;
184
238
  if (r.samples.length < 5) {
@@ -221,6 +275,52 @@ export function selfTest(): number {
221
275
  );
222
276
  if (!ok) failed += 1;
223
277
  }
278
+ for (const [tools, want] of [
279
+ [[], 'text'],
280
+ [['Bash', 'Read'], 'text'],
281
+ [['AskUserQuestion'], 'native'],
282
+ [['ask_user_question'], 'native'],
283
+ ] as Array<[string[], 'text' | 'native']>) {
284
+ const got = askForm(tools);
285
+ const ok = got === want;
286
+ process.stdout.write(
287
+ `${ok ? '✅' : '❌'} form of [${tools.join(', ')}] — ${got}` +
288
+ (ok ? '\n' : ` (wanted ${want})\n`),
289
+ );
290
+ if (!ok) failed += 1;
291
+ }
292
+ const fx = fs.mkdtempSync(path.join(os.tmpdir(), 'probe-ask-form-'));
293
+ try {
294
+ const mk = (role: string, blocks: unknown): string =>
295
+ JSON.stringify({ type: role, timestamp: '2026-01-01T00:00:00Z', message: { role, content: blocks } });
296
+ fs.writeFileSync(
297
+ path.join(fx, 'fixture.jsonl'),
298
+ [
299
+ mk('assistant', [{ type: 'text', text: 'Done.\n\nSoll ich das so umsetzen?' }]),
300
+ mk('user', 'ja'),
301
+ mk('assistant', [
302
+ { type: 'text', text: 'Done.\n\nShall I take the second half too?' },
303
+ { type: 'tool_use', name: 'AskUserQuestion' },
304
+ ]),
305
+ mk('user', 'ja'),
306
+ ].join('\n'),
307
+ 'utf8',
308
+ );
309
+ const r = measure(fx, 10);
310
+ const sums = r.form.text + r.form.native === r.unblockedAsks;
311
+ process.stdout.write(
312
+ `${sums ? '✅' : '❌'} form partition is exhaustive — text ${String(r.form.text)} + ` +
313
+ `native ${String(r.form.native)} = unblocked ${String(r.unblockedAsks)}\n`,
314
+ );
315
+ if (!sums) failed += 1;
316
+ const split = r.form.text === 1 && r.form.native === 1;
317
+ process.stdout.write(
318
+ `${split ? '✅' : '❌'} the fixture's two asks split one per class\n`,
319
+ );
320
+ if (!split) failed += 1;
321
+ } finally {
322
+ fs.rmSync(fx, { recursive: true, force: true });
323
+ }
224
324
  const positives = cases.filter((c) => c[3]).length;
225
325
  if (positives < 3) {
226
326
  process.stderr.write('❌ probe_unblocked_ask --self-test: fewer than 3 positive cases — a truncated suite must fail.\n');
@@ -233,6 +333,32 @@ export function selfTest(): number {
233
333
  return failed > 0 ? 1 : 0;
234
334
  }
235
335
 
336
+ const HELP = `probe_unblocked_ask — the unblocked-ask rate, and the FORM each ask took.
337
+
338
+ Usage:
339
+ probe_unblocked_ask [--limit N] [--store PATH] [--json]
340
+ probe_unblocked_ask --self-test
341
+ probe_unblocked_ask --help
342
+
343
+ Dimensions reported:
344
+ hand-back turns assistant turns immediately followed by a user turn
345
+ unblocked asks …that hand a decision with no numbered-options block
346
+ malformed …of those, carrying no Recommendation:/Empfehlung: label
347
+ form the ask FORM partition of the unblocked asks:
348
+ native the turn carried a structured-ask tool call
349
+ text the ask was prose
350
+ text + native always equals the unblocked-ask total.
351
+ native is 0 wherever no host has an observed structured-ask
352
+ tool — a measured zero, not an uninstrumented one.
353
+
354
+ Options:
355
+ --limit N most recent N sessions (default 60)
356
+ --store PATH transcript store (default: the host's own)
357
+ --json emit the Result object instead of the report
358
+ --self-test run the detector fixtures, incl. the form-partition identity
359
+ --help this text
360
+ `;
361
+
236
362
  export function main(argv: string[]): number {
237
363
  let limit = 60;
238
364
  let storeArg: string | null = null;
@@ -246,6 +372,10 @@ export function main(argv: string[]): number {
246
372
  // in-process, and resolving a transcript store that may not exist here
247
373
  // would make the sensitivity proof depend on the host having one.
248
374
  else if (a === '--self-test') return selfTest();
375
+ else if (a === '--help' || a === '-h') {
376
+ process.stdout.write(HELP);
377
+ return 0;
378
+ }
249
379
  else {
250
380
  process.stderr.write(`probe_unblocked_ask: unrecognized argument: ${a}\n`);
251
381
  return 0;
@@ -264,6 +394,9 @@ export function main(argv: string[]): number {
264
394
  ` …with a numbered block ${String(r.withBlock)} (excluded — check_reply_consistency reads that surface)\n` +
265
395
  ` …unblocked asks ${String(r.unblockedAsks)} ${pct(r.unblockedAsks, r.handbacks)} of hand-backs\n` +
266
396
  ` …of those, malformed ${String(r.malformed)} ${pct(r.malformed, r.unblockedAsks)} of unblocked asks\n` +
397
+ ` form: text ${String(r.form.text)} ${pct(r.form.text, r.unblockedAsks)} of unblocked asks\n` +
398
+ ` form: native ${String(r.form.native)} ${pct(r.form.native, r.unblockedAsks)} of unblocked asks\n` +
399
+ ` form: text + native ${String(r.form.text + r.form.native)} (must equal unblocked asks: ${String(r.unblockedAsks)})\n` +
267
400
  '\n CEILING, not a point estimate: "hands a decision" is a heuristic and a\n' +
268
401
  ' rhetorical question can match it. A recommendation is matched on its LABEL,\n' +
269
402
  ' which is the rule\'s own bar but a bar on form.\n',
@@ -126,6 +126,7 @@ import {
126
126
  derive_category_hits,
127
127
  render_derived_head_values,
128
128
  } from './_lib/release_highlights.js';
129
+ import { canPrompt, promptLine } from './_lib/tty_prompt.js';
129
130
  import { preflightPosition } from './_lib/release_position.js';
130
131
  import {
131
132
  RELEASE_HEAD_DEFAULT,
@@ -554,8 +555,8 @@ function render_changelog_entry(
554
555
 
555
556
  // The curated head sits above the generated log, which stays unchanged.
556
557
  const body_lines: string[] = [...render_release_head(opts.head ?? {})];
557
- // Under the head and outside it — rationale on `MIX_RESPONSE_PLACEHOLDER`.
558
- if (opts.mix?.triggered) {
558
+ // Under the head, outside it; any resolved reading — see `MixObligation`.
559
+ if (opts.mix) {
559
560
  body_lines.push('');
560
561
  body_lines.push(...render_mix_response(opts.mix.level));
561
562
  }
@@ -933,74 +934,12 @@ function print_preview(plan: Plan): void {
933
934
  }
934
935
 
935
936
  function confirm(prompt: string): boolean | null {
936
- const ans = _input(`${prompt} [y/N] `);
937
+ const ans = promptLine(`${prompt} [y/N] `);
937
938
  if (ans === null) return null; // no usable controlling terminal
938
939
  const norm = ans.trim().toLowerCase();
939
940
  return norm === 'y' || norm === 'yes';
940
941
  }
941
942
 
942
- /** Can we prompt at all — is fd 0 a TTY, or is a controlling terminal openable? */
943
- function _canPrompt(): boolean {
944
- if (process.env.CI) return false; // CI is non-interactive by contract → require --yes
945
- if (process.stdin.isTTY) return true;
946
- try {
947
- const fd = fs.openSync('/dev/tty', 'r');
948
- fs.closeSync(fd);
949
- return true;
950
- } catch {
951
- return false;
952
- }
953
- }
954
-
955
- /**
956
- * Mirror of Python `input(prompt)` — write the prompt, read one line from the
957
- * controlling terminal. Returns `null` when no terminal is reachable.
958
- *
959
- * The answer is ALWAYS read from a freshly opened, blocking `/dev/tty`
960
- * (`openSync(..., 'rs')`), NEVER from fd 0. `task release` / `./scripts-run`
961
- * run this under go-task's `interactive: true`, which leaves fd 0 a TTY
962
- * (`process.stdin.isTTY === true`) but one Node treats as NON-blocking — so a
963
- * bare `readSync(0)` throws `EAGAIN`, the catch swallows it, and the `[y/N]`
964
- * prompt "auto-aborts" without ever waiting (the exact failure this fixes;
965
- * reproduced with `isTTY === true`). A fresh blocking `/dev/tty` descriptor
966
- * blocks for real input regardless of how the script was invoked — the proven
967
- * synchronous-prompt pattern.
968
- */
969
- function _input(prompt: string): string | null {
970
- process.stdout.write(prompt);
971
- let fd: number;
972
- try {
973
- // 'rs' → O_RDONLY | O_SYNC: a fresh, blocking descriptor on the
974
- // controlling terminal, unaffected by Node's non-blocking fd 0.
975
- fd = fs.openSync('/dev/tty', 'rs');
976
- } catch {
977
- return null; // no controlling terminal (true non-interactive)
978
- }
979
- try {
980
- const buf = Buffer.alloc(1);
981
- const chars: number[] = [];
982
- for (;;) {
983
- let bytesRead: number;
984
- try {
985
- bytesRead = fs.readSync(fd, buf, 0, 1, null);
986
- } catch (e) {
987
- const code = (e as NodeJS.ErrnoException).code;
988
- // A blocking /dev/tty should not yield these, but inherited
989
- // descriptor flags can — retry rather than abort the prompt.
990
- if (code === 'EAGAIN' || code === 'EINTR') continue;
991
- break; // EOF / EIO → EOFError analogue; return what we have.
992
- }
993
- if (bytesRead === 0) break;
994
- const b = buf[0] as number;
995
- if (b === 0x0a) break; // newline terminates the line (stripped, like input()).
996
- chars.push(b);
997
- }
998
- return Buffer.from(chars).toString('utf-8');
999
- } finally {
1000
- fs.closeSync(fd);
1001
- }
1002
- }
1003
-
1004
943
  export interface ConfirmVerdict {
1005
944
  proceed: boolean;
1006
945
  /** Message to surface when not proceeding (no trailing newline). */
@@ -1024,7 +963,7 @@ export function confirmGate(target: string, yes: boolean): ConfirmVerdict {
1024
963
  'No terminal available for the [y/N] confirmation (non-interactive shell). ' +
1025
964
  'Re-run with --yes to confirm, e.g. `task release -- --yes`.',
1026
965
  };
1027
- if (!_canPrompt()) return noTerminal;
966
+ if (!canPrompt()) return noTerminal;
1028
967
  const answer = confirm(`Proceed with release ${target}?`);
1029
968
  // null = the controlling terminal vanished between the probe and the read;
1030
969
  // surface the actionable --yes guidance, never a bare silent "aborted.".
@@ -1684,17 +1623,33 @@ function main(argv: readonly string[] | null = null): number {
1684
1623
 
1685
1624
  const bump = resolve_bump(args.bump_override, commits);
1686
1625
 
1687
- // Resume mode: prefer an existing `release/X.Y.Z` over computed bump,
1688
- // so we don't accidentally start a 1.16.0 release while 1.15.0 is
1689
- // still in flight. Explicit --version still wins.
1690
- const in_flight = args.resume ? _detect_in_flight_target() : null;
1626
+ // An in-flight release is a STATE, never a flag — so the probe runs
1627
+ // unconditionally and `--resume` no longer decides what is being released.
1628
+ //
1629
+ // The comment this replaces already stated the right intent ("so we don't
1630
+ // accidentally start a 1.16.0 release while 1.15.0 is still in flight") and
1631
+ // the `args.resume ?` gate narrowed it to resumed runs only. Measured on
1632
+ // 14.20.0: `guard_release_curation` refuses after step 2 has bumped
1633
+ // `package.json`, so a plain re-run computed the bump from the ALREADY
1634
+ // BUMPED version, landed on 14.21.0, and then refused its own branch with
1635
+ // `release must run from 'main' or 'release/14.21.0', currently on
1636
+ // 'release/14.20.0'`. The 2026-09-07 start-position fix could not help: it
1637
+ // was asked about the wrong target.
1638
+ //
1639
+ // Neither branch of `_detect_in_flight_target` needs the flag — HEAD
1640
+ // sitting on `release/X.Y.Z`, and a `package.json` version whose tag is not
1641
+ // PUBLISHED, are both facts about the repository. On a clean `main` after a
1642
+ // completed release the second branch returns null (the tag is published),
1643
+ // so a fresh release is unaffected; what changes is that an abandoned bump
1644
+ // can no longer be silently released as the NEXT version.
1645
+ const in_flight = _detect_in_flight_target();
1691
1646
  let target: string;
1692
1647
  if (args.explicit) {
1693
1648
  target = args.explicit;
1694
1649
  } else if (in_flight) {
1695
1650
  target = in_flight;
1696
1651
  process.stdout.write(
1697
- `(resume) in-flight target ${in_flight} (package.json version with no tag yet)\n`,
1652
+ `in-flight target ${in_flight} (package.json version with no published tag yet)\n`,
1698
1653
  );
1699
1654
  } else {
1700
1655
  target = bump_version(current, bump);
@@ -244,17 +244,13 @@ export function defaultChangelogFixture(target: string): string {
244
244
  '',
245
245
  '- fixture entry for the release drill',
246
246
  '',
247
- // Both lines are section-level publication obligations
248
- // (`section_publication_blockers`), so a fixture without them refuses
249
- // at the curation guard and every sequencing scenario dies at step 2 —
250
- // the same "fixture trips the guard the drill exists to sequence"
251
- // failure this builder's docstring already warns about, for the two
252
- // obligations added 2026-09-05. The mix answer is written OUT rather
253
- // than measured: measuring would couple every scenario to the live
254
- // repository's commit span, which is the coupling `changelog` was
255
- // introduced to remove.
247
+ // The mix line is written OUT rather than measured: measuring would
248
+ // couple every scenario to the live repository's commit span, which is
249
+ // the coupling `changelog` was introduced to remove. It is no longer a
250
+ // publication obligation (ADR-261 deleted the written answer it used to
251
+ // carry) — it stays because the drill exercises the shape the writer
252
+ // actually renders.
256
253
  '> **Governance mix:** governance-only 2 vs consumer-only 1 (taxonomy 1.0.0).',
257
- '> Next cycle ships the fixture consumer work, tracked in the drill.',
258
254
  '',
259
255
  'Tests: 1000 (+0 since 0.0.0)',
260
256
  '',
@@ -124,6 +124,7 @@ function _set_changelog_reader(fn: ((file: string) => string) | null): void {
124
124
  function read_changelog_text(): string {
125
125
  return _changelog_reader ? _changelog_reader(CHANGELOG) : fs.readFileSync(CHANGELOG, 'utf-8');
126
126
  }
127
+
127
128
  const MAIN_BRANCH = 'main';
128
129
  const REMOTE = 'origin';
129
130
  const REPO_SLUG = 'event4u-app/agent-config';
@@ -458,15 +458,14 @@ export function local_release_gate_argv(): string[] {
458
458
  /**
459
459
  * Measure the governance-versus-product level for the span under release.
460
460
  *
461
- * The push guard's fourth site reads the SAME obligation the release PR reads,
462
- * which until 2026-09-05 it did not: `publication_blockers` knew two sentinels
463
- * and not this one, so the earliest refusal was the pull request. That gap was
464
- * documented in `docs/contracts/CHANGELOG-conventions.md` and it is what turned
465
- * 14.17.0 red after a branch, a PR and a CI run had already been spent.
461
+ * REPORTING ONLY since ADR-261. It feeds exactly one consumer: the `>
462
+ * **Governance mix:** …` line the writer renders into the section. No guard
463
+ * reads it, nothing refuses over it, and `triggered` gates nothing — the
464
+ * written-answer obligation it used to serve is deleted.
466
465
  *
467
466
  * Returns `null` on any measurement failure — a shallow clone or a missing tag
468
- * is an environment fact, and the contract is explicit that it degrades to a
469
- * warning rather than to a refusal. `null` produces no blocker downstream.
467
+ * is an environment fact, and a release must not stop over one. `null` means
468
+ * the line is simply not rendered.
470
469
  */
471
470
  export function measure_mix_obligation(
472
471
  version: string,
@@ -478,19 +477,18 @@ export function measure_mix_obligation(
478
477
  const reading = measureRange(from, 'HEAD', loadTaxonomy(), version, REPO_ROOT);
479
478
  const o = reading.response_obligation;
480
479
  return {
481
- triggered: o.triggered,
482
480
  level:
483
481
  `governance-only ${String(o.governance_only)} vs consumer-only ` +
484
482
  `${String(o.consumer_only)} (taxonomy ${reading.taxonomy_version})`,
485
483
  };
486
484
  } catch (err) {
487
- // The CI side prints `⚠️ governance mix not measured`; swallowing it
488
- // here made a broken taxonomy file disable BOTH local guards in
489
- // silence while CI kept refusing — the exact asymmetry this change
490
- // exists to remove, restored one layer down.
485
+ // Printed rather than swallowed. Nothing refuses over the reading any
486
+ // more (ADR-261), so this is not a blocked release — it is a section
487
+ // that will publish without its `> **Governance mix:** …` line, and the
488
+ // operator is entitled to know why the line is missing.
491
489
  process.stderr.write(
492
490
  `⚠️ governance mix not measured for ${version}: ${(err as Error).message}\n` +
493
- ' Both local guards are inert for this obligation; the release PR still refuses.\n',
491
+ ' The section will publish without the measured line. Nothing is blocked.\n',
494
492
  );
495
493
  return null;
496
494
  }
@@ -539,12 +537,7 @@ export function guard_release_branch_push(branch: string): void {
539
537
  if (!section) {
540
538
  return;
541
539
  }
542
- const blockers = section_publication_blockers(
543
- section.body,
544
- version,
545
- `\`${branch}\``,
546
- measure_mix_obligation(version),
547
- );
540
+ const blockers = section_publication_blockers(section.body, version, `\`${branch}\``);
548
541
  if (blockers.length === 0) {
549
542
  return;
550
543
  }
@@ -685,6 +678,13 @@ export function integrate_main_if_behind(branch: string, say: (msg: string) => v
685
678
  run(['git', 'merge', `${REMOTE}/${MAIN_BRANCH}`, '--no-edit']);
686
679
  }
687
680
 
681
+ /** Everything this site refuses over: the head-level blockers, nothing else. */
682
+ function curation_blockers(version: string): string[] {
683
+ const section = extract_changelog_section(read_changelog_text(), version);
684
+ if (section === null) return [];
685
+ return publication_blockers(section.body, version, `\`release/${version}\``);
686
+ }
687
+
688
688
  export function guard_release_curation(version: string, prMerged = false): void {
689
689
  if (prMerged) {
690
690
  return;
@@ -693,8 +693,7 @@ export function guard_release_curation(version: string, prMerged = false): void
693
693
  if (!section) {
694
694
  return;
695
695
  }
696
- // The head-level blockers plus the mix obligation — deliberately NOT the
697
- // tests footer.
696
+ // The head-level blockers only — deliberately NOT the tests footer.
698
697
  //
699
698
  // This site runs BEFORE `prepend_changelog`'s output is committed, and
700
699
  // `--resume` skips the whole bump block once `package.json` already carries
@@ -707,17 +706,23 @@ export function guard_release_curation(version: string, prMerged = false): void
707
706
  //
708
707
  // `guard_release_branch_push` carries it instead: by then the section is
709
708
  // committed and editable on the branch, and nothing has left the machine.
710
- const blockers = [
711
- ...publication_blockers(
712
- section.body,
713
- version,
714
- `\`release/${version}\``,
715
- measure_mix_obligation(version),
716
- ),
717
- ];
709
+ //
710
+ // What is NOT here any more (ADR-261): the governance-versus-product
711
+ // written answer, the next-cycle promise, the read-back of the previous
712
+ // one, the `## [Unreleased]` staging channel that fed them and the
713
+ // interactive prompt that asked for them. This guard refuses the
714
+ // generator's own unrewritten draft — a claim no human has looked at — and
715
+ // nothing else. It never asks the operator to write prose about a future
716
+ // cycle to get a version out.
717
+ const blockers = curation_blockers(version);
718
718
  if (blockers.length === 0) {
719
719
  return;
720
720
  }
721
+ // Read once. This used to re-read the file here, because
722
+ // `consume_staged_answers` / `collect_curation_answers` could rewrite the
723
+ // section between the two reads; both write paths are gone (ADR-261) and
724
+ // `curation_blockers` is read-only, so a second read could only ever
725
+ // return the same bytes.
721
726
  const marked = section.body
722
727
  .split('\n')
723
728
  .filter((l) => l.includes(DERIVED_MARKER))