@event4u/agent-config 14.19.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 (161) 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 +351 -156
  5. package/README.md +1 -1
  6. package/agents/templates/.ai-council.yml.example +10 -0
  7. package/dist/agent-src/commands/agent-handoff.md +42 -41
  8. package/dist/agent-src/commands/analyze/inbox.md +1 -1
  9. package/dist/agent-src/commands/analyze/{reference-repo.md → repo.md} +247 -32
  10. package/dist/agent-src/commands/analyze/roadmap-repos.md +129 -0
  11. package/dist/agent-src/commands/analyze.md +12 -7
  12. package/dist/agent-src/commands/feature/plan.md +55 -16
  13. package/dist/agent-src/commands/feature/refactor.md +8 -3
  14. package/dist/agent-src/commands/humanize.md +48 -4
  15. package/dist/agent-src/contexts/execution/contract-decision-sheet.md +22 -1
  16. package/dist/agent-src/contexts/execution/host-capability-manifest.md +11 -2
  17. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +35 -2
  18. package/dist/agent-src/contexts/execution/terminal-states.md +4 -1
  19. package/dist/agent-src/rules/evaluator-independence.md +4 -4
  20. package/dist/agent-src/rules/external-reference-deep-dive.md +2 -2
  21. package/dist/agent-src/rules/untrusted-input-defense.md +0 -2
  22. package/dist/agent-src/skills/competitive-positioning/SKILL.md +2 -2
  23. package/dist/agent-src/skills/evaluate-llm-feature/SKILL.md +1 -1
  24. package/dist/agent-src/skills/humanizer/references/anti-aiisms.md +27 -11
  25. package/dist/agent-src/skills/learning-to-rule-or-skill/SKILL.md +1 -1
  26. package/dist/agent-src/skills/markitdown/SKILL.md +2 -2
  27. package/dist/agent-src/skills/repomix-packer/SKILL.md +1 -1
  28. package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +28 -6
  29. package/dist/agent-src/skills/technical-specification/SKILL.md +4 -0
  30. package/dist/agent-src/skills/test-driven-development/SKILL.md +1 -3
  31. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  32. package/dist/agent-src/templates/features.md +5 -0
  33. package/dist/agent-src/templates/roadmaps.md +29 -1
  34. package/dist/agent-src/templates/scripts/telemetry/mcp_lite.ts +213 -0
  35. package/dist/agent-src/templates/scripts/telemetry/report_renderer.ts +16 -0
  36. package/dist/agent-src/templates/scripts/telemetry_report.ts +22 -2
  37. package/dist/cli/mcp/dispatch.js +10 -0
  38. package/dist/cli/mcp/stdio.js +33 -2
  39. package/dist/cli/mcp/telemetry.js +227 -0
  40. package/dist/cli-delegate/{chunk-KPBWFOPZ.js → chunk-2FTMENJL.js} +194 -37
  41. package/dist/cli-delegate/{chunk-PMEKGDED.js → chunk-2TAQ2K4V.js} +166 -1
  42. package/dist/cli-delegate/{chunk-GZTMRYCN.js → chunk-643Z7FIC.js} +79 -12
  43. package/dist/cli-delegate/{chunk-CYH5SP6M.js → chunk-I6RHFQEG.js} +151 -16
  44. package/dist/cli-delegate/{chunk-IDHUQIXP.js → chunk-J3RFNKG4.js} +41 -5
  45. package/dist/cli-delegate/{chunk-7PRE6ZV2.js → chunk-YS6XTLEK.js} +199 -4
  46. package/dist/cli-delegate/chunk-ZVBOHSVF.js +1109 -0
  47. package/dist/cli-delegate/cmd_conformance.js +88 -110
  48. package/dist/cli-delegate/cmd_doctor.js +3 -3
  49. package/dist/cli-delegate/cmd_export.js +2 -2
  50. package/dist/cli-delegate/cmd_handoff.js +42 -12
  51. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  52. package/dist/cli-delegate/cmd_prune.js +2 -2
  53. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  54. package/dist/cli-delegate/cmd_session_recycle.js +59 -902
  55. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  56. package/dist/cli-delegate/cmd_sync.js +2 -2
  57. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  58. package/dist/cli-delegate/cmd_validate.js +2 -2
  59. package/dist/discovery/deprecation-report.md +1 -1
  60. package/dist/discovery/discovery-manifest.json +62 -31
  61. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  62. package/dist/discovery/discovery-manifest.summary.md +3 -3
  63. package/dist/discovery/dormancy-report.md +1 -1
  64. package/dist/discovery/orphan-report.md +1 -1
  65. package/dist/discovery/packs.json +7 -6
  66. package/dist/discovery/trust-report.md +2 -2
  67. package/dist/discovery/workspaces.json +6 -5
  68. package/dist/hooks/dispatch.js +3801 -2803
  69. package/dist/install/install.mjs +396 -168
  70. package/dist/mcp/registry-manifest.json +3 -3
  71. package/dist/mcp/server.json +2 -2
  72. package/dist/mcp/server.mjs +809 -180
  73. package/dist/scripts/ai_council/content_ceiling.js +63 -0
  74. package/dist/scripts/ai_council/seat_policy.js +106 -0
  75. package/dist/scripts/ai_council/transport_resolver.js +21 -0
  76. package/dist/scripts/mcp_server/telemetry.js +148 -0
  77. package/docs/guidelines/agent-infra/naming.md +1 -1
  78. package/docs/guidelines/agent-infra/security-lint-containment.md +46 -6
  79. package/docs/guidelines/agent-infra/self-improvement-pipeline.md +4 -4
  80. package/docs/guidelines/agent-infra/untrusted-input-spotlighting.md +1 -1
  81. package/docs/guidelines/redundancy-taxonomy.md +28 -0
  82. package/llms.txt +14 -14
  83. package/package.json +1 -1
  84. package/src/agent-src/templates/scripts/telemetry/mcp_lite.ts +213 -0
  85. package/src/agent-src/templates/scripts/telemetry/report_renderer.ts +16 -0
  86. package/src/agent-src/templates/scripts/telemetry_report.ts +22 -2
  87. package/src/config/assurance-capability-registry.json +9 -0
  88. package/src/config/gate-coverage.yml +55 -0
  89. package/src/config/gate-violation-baselines.json +18 -6
  90. package/src/config/release-gate-locality.yml +4 -3
  91. package/src/scripts/_cli/cmd_session_recycle.ts +51 -3
  92. package/src/scripts/_lib/audit_field_provenance.ts +44 -3
  93. package/src/scripts/_lib/blocked_by_marker.ts +189 -0
  94. package/src/scripts/_lib/host_capability.ts +76 -1
  95. package/src/scripts/_lib/install_reach_checks.ts +33 -1
  96. package/src/scripts/_lib/mcp_bridge.ts +222 -10
  97. package/src/scripts/_lib/mcp_consent_residual.ts +173 -0
  98. package/src/scripts/_lib/orchestration_record.ts +23 -1
  99. package/src/scripts/_lib/recycle_envelope_paths.ts +190 -0
  100. package/src/scripts/_lib/release_highlights.ts +19 -211
  101. package/src/scripts/_lib/release_material.ts +27 -77
  102. package/src/scripts/_lib/release_position.ts +96 -0
  103. package/src/scripts/_lib/review_skipped_record.ts +17 -1
  104. package/src/scripts/_lib/security_lint.ts +179 -7
  105. package/src/scripts/_lib/self_repair.ts +141 -6
  106. package/src/scripts/_lib/self_repair_store.ts +273 -7
  107. package/src/scripts/_lib/structured_ask.ts +117 -0
  108. package/src/scripts/_lib/subagent_capsule.ts +155 -3
  109. package/src/scripts/_lib/tty_prompt.ts +76 -0
  110. package/src/scripts/ai_council/api_transport.ts +163 -0
  111. package/src/scripts/ai_council/clients.ts +58 -63
  112. package/src/scripts/ai_council/config.ts +13 -0
  113. package/src/scripts/ai_council/content_ceiling.ts +79 -0
  114. package/src/scripts/ai_council/jury_aggregate.ts +178 -0
  115. package/src/scripts/ai_council/seat_policy.ts +137 -0
  116. package/src/scripts/ai_council/status_surface.ts +79 -0
  117. package/src/scripts/ai_council/transport_resolver.ts +59 -1
  118. package/src/scripts/ai_tells_rules.ts +446 -5
  119. package/src/scripts/ask_block_census.ts +499 -0
  120. package/src/scripts/bench_humanizer_eval.ts +264 -40
  121. package/src/scripts/bench_prose_tells_fp.ts +198 -0
  122. package/src/scripts/build_rule_trigger_matrix.ts +5 -0
  123. package/src/scripts/check_branch_work_committed.ts +234 -0
  124. package/src/scripts/check_mcp_doc_drift.ts +217 -0
  125. package/src/scripts/check_pack_size.ts +321 -3
  126. package/src/scripts/check_release_highlights.ts +0 -157
  127. package/src/scripts/council_cli.ts +18 -27
  128. package/src/scripts/detect_ai_tells.ts +142 -20
  129. package/src/scripts/handoff_context_hook.ts +54 -7
  130. package/src/scripts/harvest_reference_tokens.ts +698 -0
  131. package/src/scripts/hook_manifest.json +1 -1
  132. package/src/scripts/hook_manifest.yaml +31 -1
  133. package/src/scripts/hooks/concern_registry.ts +2 -0
  134. package/src/scripts/hooks/delegation_nudge_hook.ts +45 -1
  135. package/src/scripts/hooks/one_question_per_ask_hook.ts +191 -0
  136. package/src/scripts/hooks/session_eol_hook.ts +18 -7
  137. package/src/scripts/install-hooks.sh +52 -0
  138. package/src/scripts/install.ts +4 -4
  139. package/src/scripts/lint_agent_security.ts +316 -25
  140. package/src/scripts/lint_confusables.ts +42 -6
  141. package/src/scripts/lint_handoffs.ts +0 -132
  142. package/src/scripts/lint_hidden_unicode.ts +49 -7
  143. package/src/scripts/lint_instruction_smuggling.ts +42 -6
  144. package/src/scripts/lint_mcp_config_security.ts +42 -6
  145. package/src/scripts/lint_roadmap_blockers.ts +51 -1
  146. package/src/scripts/lint_roadmap_later_disposition.ts +217 -27
  147. package/src/scripts/lint_skill_frontmatter_safety.ts +43 -7
  148. package/src/scripts/mcp_server/resources.ts +41 -0
  149. package/src/scripts/mcp_server/telemetry.ts +19 -4
  150. package/src/scripts/migrate_command_suggestions.ts +1 -1
  151. package/src/scripts/orchestration_record.ts +12 -1
  152. package/src/scripts/probe_unblocked_ask.ts +135 -2
  153. package/src/scripts/release.ts +50 -95
  154. package/src/scripts/release_drill.ts +6 -10
  155. package/src/scripts/release_env.ts +1 -0
  156. package/src/scripts/release_publication.ts +40 -31
  157. package/src/scripts/self_repair_cli.ts +31 -1
  158. package/src/scripts/self_review_gate.ts +409 -15
  159. package/src/scripts/skill_scout.ts +69 -0
  160. package/dist/cli-delegate/chunk-2GNWGMAL.js +0 -172
  161. package/dist/cli-delegate/chunk-LPPWO5CE.js +0 -161
@@ -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',
@@ -49,8 +49,10 @@
49
49
  * Idempotency: pass `--resume` to recover from a partial failure. Each
50
50
  * step then probes existing state (branch, commit, PR, tag, GitHub
51
51
  * Release) and skips work that is already done, instead of erroring out.
52
- * Without `--resume` the pipeline still mutates git/network state, so
53
- * re-running on a dirty tree needs `--resume` (or a manual cleanup). The
52
+ * Re-running from `release/{target}` needs neither the flag nor a clean
53
+ * tree — that position, dirty, is the state `guard_release_curation` leaves
54
+ * behind, and `preflightPosition` accepts it (a dirty `main` still refuses).
55
+ * The
54
56
  * two entry points guard against colliding with each other via these same
55
57
  * probes plus the CI workflow's concurrency group — whichever starts
56
58
  * second sees the other's in-progress state and skips or refuses cleanly.
@@ -124,6 +126,8 @@ import {
124
126
  derive_category_hits,
125
127
  render_derived_head_values,
126
128
  } from './_lib/release_highlights.js';
129
+ import { canPrompt, promptLine } from './_lib/tty_prompt.js';
130
+ import { preflightPosition } from './_lib/release_position.js';
127
131
  import {
128
132
  RELEASE_HEAD_DEFAULT,
129
133
  extract_changelog_section,
@@ -551,8 +555,8 @@ function render_changelog_entry(
551
555
 
552
556
  // The curated head sits above the generated log, which stays unchanged.
553
557
  const body_lines: string[] = [...render_release_head(opts.head ?? {})];
554
- // Under the head and outside it — rationale on `MIX_RESPONSE_PLACEHOLDER`.
555
- if (opts.mix?.triggered) {
558
+ // Under the head, outside it; any resolved reading — see `MixObligation`.
559
+ if (opts.mix) {
556
560
  body_lines.push('');
557
561
  body_lines.push(...render_mix_response(opts.mix.level));
558
562
  }
@@ -784,17 +788,17 @@ export function assert_scheduled_deprecations_clear(
784
788
  /**
785
789
  * Fail fast on conditions that would break the release mid-flight.
786
790
  *
787
- * In `--resume` mode two invariants are relaxed:
791
+ * The start position — which branch, and whether a dirty tree is tolerated —
792
+ * is `preflightPosition` in `_lib/release_position.ts`, and it is NO LONGER
793
+ * gated on `--resume`:
794
+ * `release/{target}` is where `guard_release_curation` leaves the operator, so
795
+ * refusing it there made the guard's own remedy unreachable. Read that
796
+ * function's header for the measurement.
788
797
  *
789
- * - The starting branch may be `release/{target}` in addition to `main` —
790
- * both are valid resume positions (mid-pipeline crash after step 1 leaves
791
- * you on the release branch).
792
- * - The target-tag-exists check is dropped — execute() probes for existing
793
- * tags/releases and skips them.
798
+ * `--resume` still relaxes ONE invariant here: the target-tag-exists check is
799
+ * dropped, because execute() probes for existing tags/releases and skips them.
794
800
  *
795
- * Tree cleanliness, gh auth, and `main` in-sync with origin are still
796
- * enforced, so resuming has the same starting posture as a fresh run; only
797
- * step-level outcomes differ.
801
+ * gh auth and `main` in-sync with origin are enforced either way.
798
802
  *
799
803
  * `opts.ci` swaps the gh-auth probe (see below) for the CI-mode variant —
800
804
  * everything else is identical between the two entry points.
@@ -832,20 +836,17 @@ function preflight(target: string, opts: { resume?: boolean; ci?: boolean } = {}
832
836
 
833
837
  const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], { capture: true });
834
838
  const release_branch = `release/${target}`;
835
- const allowed = resume ? new Set([MAIN_BRANCH, release_branch]) : new Set([MAIN_BRANCH]);
836
- if (!allowed.has(branch)) {
837
- if (resume) {
838
- die(
839
- `resume must run from '${MAIN_BRANCH}' or '${release_branch}', ` +
840
- `currently on '${branch}'`,
841
- );
842
- }
843
- die(`release must run from '${MAIN_BRANCH}', currently on '${branch}'`);
839
+ const position = preflightPosition({
840
+ branch,
841
+ mainBranch: MAIN_BRANCH,
842
+ releaseBranch: release_branch,
843
+ porcelain: git(['status', '--porcelain'], { capture: true }),
844
+ });
845
+ if (!position.proceed) {
846
+ die(position.message ?? `release cannot start from '${branch}'`);
844
847
  }
845
-
846
- const porcelain = git(['status', '--porcelain'], { capture: true });
847
- if (porcelain) {
848
- die('working tree is not clean; commit or stash first');
848
+ if (position.notice) {
849
+ process.stdout.write(`\u26a0\ufe0f ${position.notice}\n`);
849
850
  }
850
851
 
851
852
  // --force lets the remote's tag positions win over stale local tags.
@@ -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))
@@ -731,8 +736,12 @@ export function guard_release_curation(version: string, prMerged = false): void
731
736
  marked.join('\n')
732
737
  : '') +
733
738
  '\n Stopped BEFORE committing — no release commit, no branch on ' +
734
- `${REMOTE}, no pull request, no tag. Curate the ` +
735
- '`### Release highlights` head, then re-run `task release`.',
739
+ `${REMOTE}, no pull request, no tag. HEAD is on the LOCAL ` +
740
+ `\`release/${version}\` branch and the generated section is in the working ` +
741
+ 'tree: curate the `### Release highlights` head in `CHANGELOG.md` there, then ' +
742
+ 're-run `task release`. That re-run works from this position — `preflightPosition` ' +
743
+ 'accepts the release branch with the uncommitted step-2 output, which it did not ' +
744
+ 'until 2026-09-07 (both spellings of the re-run were refused before step 1).',
736
745
  );
737
746
  }
738
747
 
@@ -48,6 +48,7 @@ import {
48
48
  attachReleaseErrors,
49
49
  listRecords,
50
50
  markReleased,
51
+ writeTargetIndex,
51
52
  openRecords,
52
53
  readOverflow,
53
54
  readRecord,
@@ -503,6 +504,30 @@ function releaseCmd(root: string, argv: string[]): number {
503
504
  return EXIT_OK;
504
505
  }
505
506
 
507
+ /**
508
+ * Rebuild the per-target occurrence index and print it.
509
+ *
510
+ * A DERIVED read: the record files are the source, the index is regenerable
511
+ * and gitignored with the rest of `agents/runtime/`. It exists because
512
+ * `occurrences` counts a fingerprint (class + normalised evidence shape), so a
513
+ * rule's weakness showing up in three different shapes was three records with
514
+ * one occurrence each — and the third-recurrence escalation had nothing that
515
+ * could ever establish its own precondition.
516
+ */
517
+ function targetsCmd(root: string): number {
518
+ const counts = writeTargetIndex(root);
519
+ if (counts.length === 0) {
520
+ process.stdout.write('self-repair: no record carries a resolved target yet.\n');
521
+ return EXIT_OK;
522
+ }
523
+ for (const c of counts) {
524
+ process.stdout.write(
525
+ ` ${c.target} ${c.occurrences} occurrence(s) across ${c.records.length} record(s)\n`,
526
+ );
527
+ }
528
+ return EXIT_OK;
529
+ }
530
+
506
531
  export function main(argv: readonly string[]): number {
507
532
  const root = process.cwd();
508
533
  const sub = argv[0] ?? 'status';
@@ -512,7 +537,12 @@ export function main(argv: readonly string[]): number {
512
537
  if (sub === 'release') {
513
538
  return releaseCmd(root, [...argv.slice(1)]);
514
539
  }
515
- process.stderr.write(`self-repair: unknown subcommand '${sub}' (status | release).\n`);
540
+ if (sub === 'targets') {
541
+ return targetsCmd(root);
542
+ }
543
+ process.stderr.write(
544
+ `self-repair: unknown subcommand '${sub}' (status | release | targets).\n`,
545
+ );
516
546
  return EXIT_FAIL;
517
547
  }
518
548