@event4u/agent-config 14.6.0 → 14.8.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 (258) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +255 -145
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/analyze/inbox.md +264 -10
  5. package/dist/agent-src/commands/analyze.md +1 -1
  6. package/dist/agent-src/commands/evals/git-pr-merge.json +13 -0
  7. package/dist/agent-src/commands/evals/roadmap.json +17 -1
  8. package/dist/agent-src/commands/pr/create.md +14 -2
  9. package/dist/agent-src/commands/pr/merge.md +379 -0
  10. package/dist/agent-src/commands/roadmap/process-full.md +272 -32
  11. package/dist/agent-src/commands/roadmap.md +1 -1
  12. package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +46 -36
  13. package/dist/agent-src/contexts/execution/autonomy-mechanics.md +37 -2
  14. package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +95 -2
  15. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +289 -106
  16. package/dist/agent-src/rules/artifact-drafting-protocol.md +5 -1
  17. package/dist/agent-src/rules/context-hygiene.md +2 -0
  18. package/dist/agent-src/rules/decision-revisit-gate.md +67 -32
  19. package/dist/agent-src/rules/fix-what-you-see.md +108 -0
  20. package/dist/agent-src/rules/no-roadmap-references.md +0 -5
  21. package/dist/agent-src/rules/recurring-criticism.md +110 -0
  22. package/dist/agent-src/rules/roadmap-progress-sync.md +20 -1
  23. package/dist/agent-src/rules/rule-type-governance.md +0 -2
  24. package/dist/agent-src/rules/self-repair-loop.md +17 -0
  25. package/dist/agent-src/rules/skill-quality.md +0 -2
  26. package/dist/agent-src/rules/source-confidentiality.md +37 -7
  27. package/dist/agent-src/scripts/archival_sweep.ts +117 -0
  28. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +26 -7
  29. package/dist/agent-src/scripts/dashboard_mode.ts +212 -0
  30. package/dist/agent-src/scripts/gate_budget.ts +282 -0
  31. package/dist/agent-src/scripts/gate_execute.ts +72 -31
  32. package/dist/agent-src/scripts/roadmap_gates.ts +74 -3
  33. package/dist/agent-src/scripts/update_roadmap_progress.ts +296 -53
  34. package/dist/agent-src/skills/adr-create/SKILL.md +124 -5
  35. package/dist/agent-src/skills/ai-council/SKILL.md +48 -807
  36. package/dist/agent-src/skills/ai-council/references/advanced-modes.md +320 -0
  37. package/dist/agent-src/skills/ai-council/references/cost-and-redaction.md +99 -0
  38. package/dist/agent-src/skills/ai-council/references/execution-modes.md +162 -0
  39. package/dist/agent-src/skills/ai-council/references/output-and-synthesis.md +125 -0
  40. package/dist/agent-src/skills/ai-council/references/procedure.md +143 -0
  41. package/dist/agent-src/skills/decision-record/SKILL.md +61 -3
  42. package/dist/agent-src/skills/decision-review/SKILL.md +65 -0
  43. package/dist/agent-src/skills/merge-conflicts/SKILL.md +26 -0
  44. package/dist/agent-src/skills/quality-tools/SKILL.md +34 -326
  45. package/dist/agent-src/skills/quality-tools/references/js-ts-tools.md +113 -0
  46. package/dist/agent-src/skills/quality-tools/references/php-tools.md +222 -0
  47. package/dist/agent-src/skills/roadmap-management/SKILL.md +15 -409
  48. package/dist/agent-src/skills/roadmap-management/references/archival.md +226 -0
  49. package/dist/agent-src/skills/roadmap-management/references/authoring.md +107 -0
  50. package/dist/agent-src/skills/roadmap-management/references/dashboard.md +47 -0
  51. package/dist/agent-src/skills/roadmap-management/references/lifecycle.md +58 -0
  52. package/dist/agent-src/skills/security/SKILL.md +8 -0
  53. package/dist/agent-src/skills/security-audit/SKILL.md +12 -1
  54. package/dist/agent-src/skills/skill-writing/SKILL.md +34 -570
  55. package/dist/agent-src/skills/skill-writing/references/procedure.md +275 -0
  56. package/dist/agent-src/skills/skill-writing/references/read-only-scripts.md +18 -0
  57. package/dist/agent-src/skills/skill-writing/references/section-patterns.md +298 -0
  58. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  59. package/dist/agent-src/templates/roadmaps.md +65 -4
  60. package/dist/agent-src/templates/scripts/telemetry/flush_sender.mjs +163 -0
  61. package/dist/agent-src/templates/scripts/telemetry/remote.ts +160 -7
  62. package/dist/agent-src/templates/scripts/telemetry/transport.ts +169 -0
  63. package/dist/cli/registry.js +2 -1
  64. package/dist/cli/registry.js.map +1 -1
  65. package/dist/cli-delegate/chunk-2GNWGMAL.js +172 -0
  66. package/dist/cli-delegate/{chunk-P6LQDIWK.js → chunk-7PRE6ZV2.js} +338 -0
  67. package/dist/cli-delegate/{chunk-BKO2AZ7Z.js → chunk-B2ERU2VA.js} +1 -1
  68. package/dist/cli-delegate/chunk-E4XIZDIB.js +402 -0
  69. package/dist/cli-delegate/{chunk-3UCJWMMP.js → chunk-EEJUMCDZ.js} +16 -16
  70. package/dist/cli-delegate/{chunk-NXFWFAYV.js → chunk-G23AT6GD.js} +1 -0
  71. package/dist/cli-delegate/{chunk-XURJ4AQC.js → chunk-H4VIVD4C.js} +385 -284
  72. package/dist/cli-delegate/chunk-I3EIN2YG.js +36 -0
  73. package/dist/cli-delegate/{chunk-RLN73PR5.js → chunk-LPPWO5CE.js} +12 -8
  74. package/dist/cli-delegate/chunk-MWRD5YOH.js +100 -0
  75. package/dist/cli-delegate/{chunk-UEZ7D35A.js → chunk-N7CDDGI3.js} +4 -23
  76. package/dist/cli-delegate/{chunk-63WSP6YZ.js → chunk-PMEKGDED.js} +74 -167
  77. package/dist/cli-delegate/{chunk-GRRPEPRN.js → chunk-T7L2QK3K.js} +24 -5
  78. package/dist/cli-delegate/{chunk-EVTY7ISR.js → chunk-YM4GZDJG.js} +9 -1
  79. package/dist/cli-delegate/cmd_adr_effective.js +1162 -0
  80. package/dist/cli-delegate/cmd_conformance.js +466 -716
  81. package/dist/cli-delegate/cmd_converge.js +3 -3
  82. package/dist/cli-delegate/cmd_doctor.js +10 -10
  83. package/dist/cli-delegate/cmd_explain.js +1 -1
  84. package/dist/cli-delegate/cmd_export.js +10 -9
  85. package/dist/cli-delegate/cmd_handoff.js +10 -9
  86. package/dist/cli-delegate/cmd_memory_get.js +2 -1
  87. package/dist/cli-delegate/cmd_migrate.js +2 -2
  88. package/dist/cli-delegate/cmd_packs_active.js +1 -1
  89. package/dist/cli-delegate/cmd_preflight.js +5 -5
  90. package/dist/cli-delegate/cmd_prune.js +11 -10
  91. package/dist/cli-delegate/cmd_route_audit.js +6 -5
  92. package/dist/cli-delegate/cmd_route_explain.js +3 -3
  93. package/dist/cli-delegate/cmd_session_recycle.js +840 -30
  94. package/dist/cli-delegate/cmd_settings_get.js +3 -3
  95. package/dist/cli-delegate/cmd_settings_migrate.js +10 -9
  96. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  97. package/dist/cli-delegate/cmd_sync.js +11 -10
  98. package/dist/cli-delegate/cmd_uninstall.js +13 -11
  99. package/dist/cli-delegate/cmd_update.js +4 -4
  100. package/dist/cli-delegate/cmd_upgrade.js +4 -4
  101. package/dist/cli-delegate/cmd_validate.js +11 -10
  102. package/dist/cli-delegate/cmd_versions.js +3 -3
  103. package/dist/discovery/deprecation-report.md +1 -1
  104. package/dist/discovery/discovery-manifest.json +111 -37
  105. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  106. package/dist/discovery/discovery-manifest.summary.md +5 -5
  107. package/dist/discovery/dormancy-report.md +1 -1
  108. package/dist/discovery/orphan-report.md +1 -1
  109. package/dist/discovery/packs.json +15 -12
  110. package/dist/discovery/trust-report.md +3 -3
  111. package/dist/discovery/workspaces.json +11 -8
  112. package/dist/hooks/dispatch.js +3165 -2192
  113. package/dist/install/claudePathsPlan.js +187 -0
  114. package/dist/install/claudePathsPlan.js.map +1 -0
  115. package/dist/install/globalRuleLayers.js +169 -0
  116. package/dist/install/globalRuleLayers.js.map +1 -0
  117. package/dist/install/hostLayerFingerprint.js +136 -0
  118. package/dist/install/hostLayerFingerprint.js.map +1 -0
  119. package/dist/install/install.mjs +553 -425
  120. package/dist/install/partitionEligibility.js +432 -0
  121. package/dist/install/partitionEligibility.js.map +1 -0
  122. package/dist/install/ruleLayerPartition.js +157 -0
  123. package/dist/install/ruleLayerPartition.js.map +1 -0
  124. package/dist/mcp/registry-manifest.json +3 -3
  125. package/dist/mcp/server.json +2 -2
  126. package/dist/mcp/server.mjs +1863 -1234
  127. package/dist/router.json +1 -1
  128. package/dist/scripts/_lib/agent_src.js +41 -0
  129. package/dist/scripts/_lib/agent_src.js.map +1 -1
  130. package/dist/scripts/_lib/install_layout.js +83 -0
  131. package/dist/scripts/_lib/install_layout.js.map +1 -0
  132. package/dist/scripts/_lib/installed_lock.js +444 -0
  133. package/dist/scripts/_lib/installed_lock.js.map +1 -0
  134. package/dist/scripts/_lib/prune_empty_dirs.js +85 -0
  135. package/dist/scripts/_lib/prune_empty_dirs.js.map +1 -0
  136. package/dist/scripts/condense.js +63 -251
  137. package/dist/scripts/condense.js.map +1 -1
  138. package/dist/server/schemas/settings.js +4 -0
  139. package/dist/server/schemas/settings.js.map +1 -1
  140. package/docs/contracts/settings-classes.md +12 -6
  141. package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +8 -3
  142. package/docs/guidelines/agent-infra/artifact-drafting-protocol-mechanics.md +45 -1
  143. package/docs/guidelines/agent-infra/context-hygiene-mechanics.md +26 -0
  144. package/docs/guidelines/agent-infra/false-green.md +47 -1
  145. package/docs/guidelines/agent-infra/memory-access.md +17 -0
  146. package/docs/guidelines/agent-infra/recurring-criticism-mechanics.md +96 -0
  147. package/package.json +1 -1
  148. package/src/agent-src/scripts/archival_sweep.ts +117 -0
  149. package/src/agent-src/scripts/archive_completed_roadmaps.ts +26 -7
  150. package/src/agent-src/scripts/dashboard_mode.ts +212 -0
  151. package/src/agent-src/scripts/gate_budget.ts +282 -0
  152. package/src/agent-src/scripts/gate_execute.ts +72 -31
  153. package/src/agent-src/scripts/roadmap_gates.ts +74 -3
  154. package/src/agent-src/scripts/update_roadmap_progress.ts +296 -53
  155. package/src/agent-src/templates/scripts/telemetry/flush_sender.mjs +163 -0
  156. package/src/agent-src/templates/scripts/telemetry/remote.ts +160 -7
  157. package/src/agent-src/templates/scripts/telemetry/transport.ts +169 -0
  158. package/src/config/agent-settings.template.yml +27 -2
  159. package/src/config/ci-local-parity.yml +39 -0
  160. package/src/config/estate-count-budget.json +612 -3
  161. package/src/config/evaluator-budgets.json +3 -3
  162. package/src/config/gate-coverage.yml +220 -12
  163. package/src/config/gate-violation-baselines.json +73 -63
  164. package/src/config/gitignore-block.txt +15 -2
  165. package/src/config/hook-latency-budget.json +5 -0
  166. package/src/config/rule-activation-census.json +14 -6
  167. package/src/config/rule-stub-ceilings.json +34 -7
  168. package/src/install/claudePathsPlan.ts +196 -0
  169. package/src/install/globalRuleLayers.ts +197 -0
  170. package/src/install/hostLayerFingerprint.ts +145 -0
  171. package/src/install/partitionEligibility.ts +502 -0
  172. package/src/install/ruleLayerPartition.ts +212 -0
  173. package/src/scripts/_cli/cmd_adr_effective.ts +484 -0
  174. package/src/scripts/_cli/cmd_session_recycle.ts +9 -1
  175. package/src/scripts/_dispatch.bash +17 -1
  176. package/src/scripts/_lib/ac_heading.ts +30 -0
  177. package/src/scripts/_lib/adr_frontmatter.test.ts +365 -0
  178. package/src/scripts/_lib/adr_frontmatter.ts +459 -0
  179. package/src/scripts/_lib/agent_src.ts +42 -0
  180. package/src/scripts/_lib/bench_ab_pinned_repo.ts +162 -0
  181. package/src/scripts/_lib/bench_ab_workspace.ts +10 -1
  182. package/src/scripts/_lib/envelope_grounding.ts +49 -9
  183. package/src/scripts/_lib/installed_lock.ts +50 -5
  184. package/src/scripts/_lib/layer_overlap_notice.ts +11 -2
  185. package/src/scripts/_lib/md_section.ts +129 -0
  186. package/src/scripts/_lib/prune_empty_dirs.ts +80 -0
  187. package/src/scripts/_lib/scope_guard.sh +26 -3
  188. package/src/scripts/_lib/self_repair.ts +19 -0
  189. package/src/scripts/_lib/self_repair_class_b.ts +171 -0
  190. package/src/scripts/adr/evidence_census.ts +899 -0
  191. package/src/scripts/adr/regenerate_index.ts +12 -32
  192. package/src/scripts/adr_cite_check.ts +540 -39
  193. package/src/scripts/ai_council/cli_help.ts +55 -0
  194. package/src/scripts/audit_adr_coverage.ts +103 -17
  195. package/src/scripts/before_complete_hook.ts +153 -28
  196. package/src/scripts/bench_ab_v2_complexity.ts +27 -7
  197. package/src/scripts/bench_hook_latency.ts +204 -0
  198. package/src/scripts/build_archive_index.ts +34 -0
  199. package/src/scripts/check_adr_frontmatter.ts +574 -39
  200. package/src/scripts/check_hook_bundle_content.ts +219 -0
  201. package/src/scripts/check_new_adr_evidence.ts +529 -0
  202. package/src/scripts/check_references.ts +78 -0
  203. package/src/scripts/check_release_published.ts +32 -11
  204. package/src/scripts/check_rule_layer_partition.ts +531 -0
  205. package/src/scripts/check_single_delivery.ts +194 -17
  206. package/src/scripts/check_standing_rule_delivery.ts +8 -2
  207. package/src/scripts/ci_settle.ts +170 -0
  208. package/src/scripts/condense.ts +79 -259
  209. package/src/scripts/council_cli.ts +3 -3
  210. package/src/scripts/dispatch_r2_reviewer.ts +6 -14
  211. package/src/scripts/external_sources_denylist.json +4 -1
  212. package/src/scripts/hook_manifest.json +1 -1
  213. package/src/scripts/hook_manifest.yaml +77 -14
  214. package/src/scripts/hooks/block_no_verify.ts +61 -10
  215. package/src/scripts/hooks/block_unauthorized_git.ts +21 -1
  216. package/src/scripts/hooks/concern_registry.ts +4 -0
  217. package/src/scripts/hooks/dispatch_hook.ts +154 -187
  218. package/src/scripts/hooks/dispatch_issues.ts +38 -19
  219. package/src/scripts/hooks/fallback_yaml.ts +66 -0
  220. package/src/scripts/hooks/host_semantics.ts +21 -0
  221. package/src/scripts/hooks/injection_budget.ts +13 -3
  222. package/src/scripts/hooks/py_json_dumps.ts +103 -0
  223. package/src/scripts/hooks/rtk_wrap_hook.ts +8 -3
  224. package/src/scripts/hooks/run_continuation_hook.ts +8 -5
  225. package/src/scripts/hooks/ship_diff_volume_hook.ts +81 -15
  226. package/src/scripts/hooks/source_first_gate_hook.ts +464 -0
  227. package/src/scripts/hooks/state_io.ts +870 -21
  228. package/src/scripts/hooks/stdin_failure_policy.ts +96 -0
  229. package/src/scripts/hooks/subagent_ledger_hook.ts +44 -15
  230. package/src/scripts/hooks/telemetry_flush_hook.ts +139 -0
  231. package/src/scripts/hooks/telemetry_self_repair.ts +128 -0
  232. package/src/scripts/hooks/telemetry_usage_hook.ts +22 -4
  233. package/src/scripts/hooks/turn_end_gate_hook.ts +81 -15
  234. package/src/scripts/injection_scan_hook.ts +137 -5
  235. package/src/scripts/install.ts +16 -11
  236. package/src/scripts/language_mirror_hook.ts +561 -25
  237. package/src/scripts/lint_framework_leakage_allowlist.json +2 -2
  238. package/src/scripts/lint_hook_manifest.ts +49 -0
  239. package/src/scripts/lint_plan_risk_register.ts +4 -3
  240. package/src/scripts/lint_provenance_vocabulary.ts +472 -4
  241. package/src/scripts/lint_skill_router_head.ts +4 -4
  242. package/src/scripts/mcp_server/tools.ts +2 -2
  243. package/src/scripts/new_roadmap.ts +136 -0
  244. package/src/scripts/pack_mcp_content.ts +20 -5
  245. package/src/scripts/release.ts +37 -38
  246. package/src/scripts/report_layer_overlap.ts +6 -0
  247. package/src/scripts/routing_doctor.ts +5 -1
  248. package/src/scripts/self_repair_hook.ts +37 -5
  249. package/src/scripts/session_register_hook.ts +11 -0
  250. package/src/scripts/skill_usage_collect.ts +11 -1
  251. package/src/scripts/skill_usage_report.ts +278 -8
  252. package/src/scripts/sync_pr_branch.ts +170 -20
  253. package/src/server/schemas/settings.ts +8 -0
  254. package/dist/cli-delegate/chunk-EJITJBJX.js +0 -172
  255. package/dist/cli-delegate/chunk-PZWRPRWP.js +0 -38
  256. package/dist/cli-delegate/{chunk-PPQO4NGR.js → chunk-6RJA2F5A.js} +3 -3
  257. package/dist/cli-delegate/{chunk-RYNADUFG.js → chunk-LWTYTX6F.js} +0 -0
  258. package/dist/cli-delegate/{chunk-FBKL6JWT.js → chunk-MMSGVZKV.js} +3 -3
@@ -41,6 +41,7 @@ import {
41
41
  atomic_write_json,
42
42
  feedback_dir,
43
43
  is_replay_mode,
44
+ update_json_under_lock,
44
45
  } from "./state_io.js";
45
46
  import { log_dispatch_issue, fix_hint } from "./dispatch_issues.js";
46
47
  import { shapeAndRecord, type ConcernMessage } from "./injection_budget.js";
@@ -48,11 +49,15 @@ import { CONCERN_REGISTRY, type ConcernMain } from "./concern_registry.js";
48
49
  import {
49
50
  setHookStdinOverride,
50
51
  clearHookStdinOverride,
51
- readFd0ToEnd,
52
52
  } from "./hook_stdin.js";
53
53
  import { emitFor, type Severity } from "./host_semantics.js";
54
54
  import { _concern_body_classes, planPayloadShapes } from "./payload_stub.js";
55
55
  import { resolveSessionRole, type SessionRole } from "../_lib/session_role.js";
56
+ import { stdinReadFailure, denyOnStdinFailure } from './stdin_failure_policy.js';
57
+ export { stdinReadFailure, denyOnStdinFailure, _is_fail_closed_blocking } from './stdin_failure_policy.js';
58
+ import { _py_json_dumps } from './py_json_dumps.js';
59
+ import { _fallback_yaml } from './fallback_yaml.js';
60
+ export { _fallback_yaml } from './fallback_yaml.js';
56
61
 
57
62
  // Free-form JSON values flow through every helper here; a documented
58
63
  // alias keeps the surface honest without `any` (ADR-200 § strict TS).
@@ -145,6 +150,16 @@ interface Args {
145
150
  dry_run: boolean;
146
151
  project_dir: string;
147
152
  min_version: number;
153
+ /**
154
+ * Measurement-only: read stdin, build the envelope, exit. See `--read-exit`.
155
+ *
156
+ * OPTIONAL, unlike `dry_run` beside it, and the asymmetry is deliberate: this
157
+ * flag exists for one bench cell, so every caller that constructs an `Args`
158
+ * by hand — eight test fixtures across three files — would otherwise have to
159
+ * name a field it has no opinion about. `_parse_args` still sets it
160
+ * explicitly, so the production path never reads an absent value.
161
+ */
162
+ read_exit?: boolean;
148
163
  }
149
164
 
150
165
  function _resolve_session_id(envelope: JsonObject): string {
@@ -234,63 +249,6 @@ export function _load_yaml(p: string): JsonObject {
234
249
  return _isObject(data) ? data : {};
235
250
  }
236
251
 
237
- /**
238
- * Indent-aware mini-parser for the manifest's flat shape only.
239
- * Handles: scalars, `key: null`, `key: true/false`, `key: [a, b]`.
240
- * Drops comments + blank lines. Two-space indent assumed.
241
- *
242
- * Ported verbatim from the Python `_fallback_yaml` so the parser unit
243
- * tests have an exact twin; runtime prefers `_load_yaml` above.
244
- */
245
- export function _fallback_yaml(text: string): JsonObject {
246
- const root: JsonObject = {};
247
- const stack: Array<[number, JsonObject]> = [[-1, root]];
248
- for (const raw of text.split("\n")) {
249
- const line = raw.split("#", 1)[0]!.replace(/\s+$/, "");
250
- if (!line.trim()) {
251
- continue;
252
- }
253
- const indent = line.length - line.replace(/^ +/, "").length;
254
- while (stack.length > 0 && stack[stack.length - 1]![0] >= indent) {
255
- stack.pop();
256
- }
257
- const parent = stack.length > 0 ? stack[stack.length - 1]![1] : root;
258
- const body = line.trim();
259
- if (!body.includes(":")) {
260
- continue;
261
- }
262
- const idx = body.indexOf(":");
263
- const key = body.slice(0, idx).trim();
264
- const val = body.slice(idx + 1).trim();
265
- if (!val) {
266
- const newObj: JsonObject = {};
267
- parent[key] = newObj;
268
- stack.push([indent, newObj]);
269
- } else {
270
- const lower = val.toLowerCase();
271
- if (lower === "null" || lower === "~" || lower === "") {
272
- parent[key] = null;
273
- } else if (lower === "true") {
274
- parent[key] = true;
275
- } else if (lower === "false") {
276
- parent[key] = false;
277
- } else if (val.startsWith("[") && val.endsWith("]")) {
278
- const inner = val.slice(1, -1).trim();
279
- parent[key] = inner
280
- ? inner
281
- .split(",")
282
- .map((s) => s.trim())
283
- .filter((s) => s)
284
- : [];
285
- } else if (/^-?\d+$/.test(val)) {
286
- parent[key] = parseInt(val, 10);
287
- } else {
288
- parent[key] = val.replace(/^['"]+|['"]+$/g, "");
289
- }
290
- }
291
- }
292
- return root;
293
- }
294
252
 
295
253
  interface ConcernDef extends JsonObject {
296
254
  name: string;
@@ -478,97 +436,6 @@ export function _resolve_concerns(
478
436
  return out;
479
437
  }
480
438
 
481
- // Python json.dumps(record, indent=2) byte-for-byte (ensure_ascii=True).
482
- function _py_json_dumps(value: unknown, indent: number): string {
483
- const pad = " ".repeat(indent);
484
- const escapeString = (s: string): string => {
485
- let out = '"';
486
- for (const ch of s) {
487
- const code = ch.codePointAt(0) as number;
488
- switch (ch) {
489
- case '"':
490
- out += '\\"';
491
- break;
492
- case "\\":
493
- out += "\\\\";
494
- break;
495
- case "\n":
496
- out += "\\n";
497
- break;
498
- case "\r":
499
- out += "\\r";
500
- break;
501
- case "\t":
502
- out += "\\t";
503
- break;
504
- case "\b":
505
- out += "\\b";
506
- break;
507
- case "\f":
508
- out += "\\f";
509
- break;
510
- default:
511
- if (code < 0x20 || code > 0x7e) {
512
- if (code > 0xffff) {
513
- const c = code - 0x10000;
514
- const hi = 0xd800 + (c >> 10);
515
- const lo = 0xdc00 + (c & 0x3ff);
516
- out +=
517
- "\\u" +
518
- hi.toString(16).padStart(4, "0") +
519
- "\\u" +
520
- lo.toString(16).padStart(4, "0");
521
- } else {
522
- out += "\\u" + code.toString(16).padStart(4, "0");
523
- }
524
- } else {
525
- out += ch;
526
- }
527
- }
528
- }
529
- return out + '"';
530
- };
531
- const render = (v: unknown, depth: number): string => {
532
- if (v === null || v === undefined) return "null";
533
- if (typeof v === "boolean") return v ? "true" : "false";
534
- if (typeof v === "number") {
535
- if (!Number.isFinite(v)) {
536
- if (Number.isNaN(v)) return "NaN";
537
- return v > 0 ? "Infinity" : "-Infinity";
538
- }
539
- return String(v);
540
- }
541
- if (typeof v === "string") return escapeString(v);
542
- const curPad = pad.repeat(depth + 1);
543
- const closePad = pad.repeat(depth);
544
- if (Array.isArray(v)) {
545
- if (v.length === 0) return "[]";
546
- return (
547
- "[\n" +
548
- v.map((item) => curPad + render(item, depth + 1)).join(",\n") +
549
- "\n" +
550
- closePad +
551
- "]"
552
- );
553
- }
554
- if (typeof v === "object") {
555
- const obj = v as Record<string, unknown>;
556
- const keys = Object.keys(obj);
557
- if (keys.length === 0) return "{}";
558
- return (
559
- "{\n" +
560
- keys
561
- .map((k) => curPad + escapeString(k) + ": " + render(obj[k], depth + 1))
562
- .join(",\n") +
563
- "\n" +
564
- closePad +
565
- "}"
566
- );
567
- }
568
- throw new TypeError(`Object of type ${typeof v} is not JSON serializable`);
569
- };
570
- return render(value, 0);
571
- }
572
439
 
573
440
  /**
574
441
  * Write the raw stdin payload to a capture directory when
@@ -1020,13 +887,54 @@ function _write_feedback(
1020
887
  }),
1021
888
  };
1022
889
  try {
1023
- atomic_write_json(path.join(fb_dir, "summary.json"), summary);
890
+ // P3 of `b-stop-async-split-prerequisites` (council 2026-08-20, option (a)).
891
+ //
892
+ // WAS: one `summary.json` per session, published by `atomic_write_json`.
893
+ // The publish is atomic and that was never the problem — the PATH is one,
894
+ // so two dispatches in the same session (parallel tool calls on this host,
895
+ // or two platforms installed into one workspace) both wrote it and the later
896
+ // rename discarded the earlier rollup entirely. Evidence loss, silent.
897
+ //
898
+ // The fix is the per-invocation discriminator the blocker names, held INSIDE
899
+ // one file rather than fanned out across per-invocation filenames. Both
900
+ // were considered and the file-per-invocation form was refused on growth:
901
+ // the feedback tree is not pruned, so a 200-tool-call session would leave
902
+ // 200 rollups per session dir, and capping THAT needs a directory scan on
903
+ // the hot path. A capped list needs one locked read-modify-write, which is
904
+ // the same primitive `rule-trips.json` now uses for the same reason.
905
+ //
906
+ // `invocation` is pid + monotonic clock: unique per dispatch without a
907
+ // shared counter, and ordered within one process.
908
+ const invocation = `${String(process.pid)}-${process.hrtime.bigint().toString()}`;
909
+ update_json_under_lock<JsonObject>(path.join(fb_dir, "summary.json"), (loaded) => {
910
+ const priorRaw = (loaded as JsonObject)["invocations"];
911
+ const prior = Array.isArray(priorRaw) ? priorRaw : [];
912
+ const next = [...prior, { invocation, ...summary }];
913
+ return {
914
+ schema_version: 2,
915
+ session_id,
916
+ // Newest last, oldest dropped — the same rotation direction
917
+ // `dispatch-issues.jsonl` uses, so a reader learns one convention.
918
+ invocations: next.slice(Math.max(0, next.length - SUMMARY_INVOCATION_CAP)),
919
+ };
920
+ });
1024
921
  } catch (exc) {
1025
922
  const msg = exc instanceof Error ? exc.message : String(exc);
1026
923
  process.stderr.write(`dispatch_hook: summary write failed: ${msg}\n`);
1027
924
  }
1028
925
  }
1029
926
 
927
+ /**
928
+ * How many invocation rollups one session's `summary.json` retains.
929
+ *
930
+ * 20, matching nothing in particular and stated as such: the file is a human
931
+ * debugging surface, twenty dispatches is more than a reader scrolls, and the
932
+ * value exists to bound growth rather than to preserve a measured window.
933
+ * `dispatch-issues.jsonl` caps at 200 because it holds FAILURES, which are rare;
934
+ * this holds every dispatch, which is not.
935
+ */
936
+ export const SUMMARY_INVOCATION_CAP = 20;
937
+
1030
938
  /**
1031
939
  * Checkable-rule trip counting (road-to-credible-install Phase 6, scoped P3
1032
940
  * cut): every concern that returns BLOCK (1) or WARN (2) — a violation the
@@ -1051,28 +959,38 @@ function _record_rule_trips(envelope: JsonObject, entries: FeedbackEntry[]): voi
1051
959
  const workspace = String(envelope["workspace_root"] || process.cwd());
1052
960
  const state_dir = path.join(workspace, "agents", "runtime", "state");
1053
961
  const target = path.join(state_dir, "rule-trips.json");
1054
- fs.mkdirSync(state_dir, { recursive: true });
1055
- let doc: JsonObject = { schema_version: 1, concerns: {} };
1056
- try {
1057
- const parsed = JSON.parse(fs.readFileSync(target, "utf-8")) as JsonObject;
1058
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) doc = parsed;
1059
- } catch {
1060
- /* absent or corrupt → fresh counter file */
1061
- }
1062
- const concerns = (doc["concerns"] ?? {}) as Record<string, JsonObject>;
1063
962
  const today = _now_iso().slice(0, 10);
1064
- for (const e of tripped) {
1065
- const name = String(e["concern"]);
1066
- const prev = (concerns[name] ?? { block: 0, warn: 0 }) as JsonObject;
1067
- concerns[name] = {
1068
- block: (Number(prev["block"]) || 0) + (e["exit_code"] === EXIT_BLOCK ? 1 : 0),
1069
- warn: (Number(prev["warn"]) || 0) + (e["exit_code"] === EXIT_WARN ? 1 : 0),
1070
- last_trip: today,
1071
- };
1072
- }
1073
- doc["schema_version"] = 1;
1074
- doc["concerns"] = concerns;
1075
- atomic_write_json(target, doc);
963
+ // P3 of `b-stop-async-split-prerequisites` (council 2026-08-20, option (a)).
964
+ // The read used to sit OUTSIDE the lock: `readFileSync` here,
965
+ // `atomic_write_json` there. `atomic_write_json` makes the PUBLISH atomic
966
+ // and says nothing about load → increment → publish, so two concurrent
967
+ // dispatchers both loaded `block: 3`, both computed 4, and one trip
968
+ // vanished. `update_json_under_lock` holds all three steps under one lock,
969
+ // which is the primitive that already exists for exactly this shape.
970
+ //
971
+ // The worse form is a lost FIELD, not a lost increment: this mutator
972
+ // republishes the whole `concerns` map, so a counter another dispatcher
973
+ // wrote in between would be reverted rather than merely missed.
974
+ update_json_under_lock<JsonObject>(target, (loaded) => {
975
+ const doc: JsonObject = { schema_version: 1, concerns: {}, ...(loaded as JsonObject) };
976
+ const concernsRaw = doc["concerns"];
977
+ const concerns = (
978
+ _isObject(concernsRaw) ? concernsRaw : {}
979
+ ) as Record<string, JsonObject>;
980
+ for (const e of tripped) {
981
+ const name = String(e["concern"]);
982
+ const prevRaw = concerns[name];
983
+ const prev = (_isObject(prevRaw) ? prevRaw : { block: 0, warn: 0 }) as JsonObject;
984
+ concerns[name] = {
985
+ block: (Number(prev["block"]) || 0) + (e["exit_code"] === EXIT_BLOCK ? 1 : 0),
986
+ warn: (Number(prev["warn"]) || 0) + (e["exit_code"] === EXIT_WARN ? 1 : 0),
987
+ last_trip: today,
988
+ };
989
+ }
990
+ doc["schema_version"] = 1;
991
+ doc["concerns"] = concerns;
992
+ return doc;
993
+ });
1076
994
  } catch {
1077
995
  /* fail-open — counters are observability, never dispatch-critical */
1078
996
  }
@@ -1087,6 +1005,7 @@ function _parse_args(argv: string[]): Args {
1087
1005
  dry_run: false,
1088
1006
  project_dir: "",
1089
1007
  min_version: 0,
1008
+ read_exit: false,
1090
1009
  };
1091
1010
  let sawPlatform = false;
1092
1011
  let sawEvent = false;
@@ -1110,6 +1029,9 @@ function _parse_args(argv: string[]): Args {
1110
1029
  case "--dry-run":
1111
1030
  args.dry_run = true;
1112
1031
  break;
1032
+ case "--read-exit":
1033
+ args.read_exit = true;
1034
+ break;
1113
1035
  case "--project-dir":
1114
1036
  args.project_dir = argv[++i] ?? "";
1115
1037
  break;
@@ -1176,6 +1098,44 @@ export function main(argv?: string[]): number {
1176
1098
  }
1177
1099
  }
1178
1100
 
1101
+ // ── `--read-exit`: the transport-isolation cell ─────────────────────────────
1102
+ //
1103
+ // `b-payload-read-parse-dominates`, option (a), council 2026-08-20 (2/2
1104
+ // quorum): "add a same-fixture dispatcher cell that reads stdin and exits
1105
+ // immediately, reporting its own latency and its share of the large-payload
1106
+ // delta".
1107
+ //
1108
+ // WHY IT LIVES IN THE DISPATCHER rather than in a standalone probe script.
1109
+ // The term being isolated is `readFd0ToEnd` + one `JSON.parse` of the same
1110
+ // payload, and it must be measured through the SAME process shape as the
1111
+ // slot it is a share of — same interpreter, same bundle, same spawn. A
1112
+ // separate probe would have to re-implement the audited retrying reader, and
1113
+ // a copy of that reader is precisely the drift `hook_stdin` was consolidated
1114
+ // to remove. Bundle load does not vary with payload size, so it cancels in
1115
+ // the large-minus-small delta, which is the number the option asks for.
1116
+ //
1117
+ // Measurement-only, and it exits BEFORE the manifest load, the concern
1118
+ // resolution and every concern — so it can neither run a guard nor suppress
1119
+ // one. Same exposure `--dry-run` already carries: reaching it requires
1120
+ // editing the installed hook command, and anyone who can do that can delete
1121
+ // the hook instead.
1122
+ if (args.read_exit === true) {
1123
+ const probe = tty.isatty(0) ? { text: "", failure: null } : stdinReadFailure();
1124
+ if (probe.failure !== null) {
1125
+ process.stderr.write(`dispatch_hook: --read-exit read failed: ${probe.failure}\n`);
1126
+ return EXIT_ALLOW;
1127
+ }
1128
+ // The parse is the half of the term under test, so it must actually happen
1129
+ // and its result must be observable — otherwise a future optimiser could
1130
+ // elide it and the cell would silently measure the read alone.
1131
+ const envelope = _build_envelope(args, probe.text);
1132
+ process.stderr.write(
1133
+ `dispatch_hook: --read-exit ok (${probe.text.length} chars, ` +
1134
+ `${Object.keys(_isObject(envelope["payload"]) ? envelope["payload"] : {}).length} payload keys)\n`,
1135
+ );
1136
+ return EXIT_ALLOW;
1137
+ }
1138
+
1179
1139
  if (!EVENT_VOCABULARY.has(args.event)) {
1180
1140
  process.stderr.write(
1181
1141
  `dispatch_hook: unknown event '${args.event}'; allowed: ` +
@@ -1208,7 +1168,9 @@ export function main(argv?: string[]): number {
1208
1168
  // below throws EAGAIN on any payload above the pipe buffer and silently
1209
1169
  // yields an EMPTY envelope. That is a measured guard bypass, not a theory —
1210
1170
  // see readFd0ToEnd's header in hooks/hook_stdin.ts.
1211
- const payload_text = tty.isatty(0) ? "" : _readStdin();
1171
+ const read = tty.isatty(0) ? { text: "", failure: null } : stdinReadFailure();
1172
+ const payload_text = read.text;
1173
+ _stdin_read_failed = read.failure;
1212
1174
  if (_stdin_read_failed !== null) {
1213
1175
  // NEVER silent: an empty envelope reached from a failed read means every
1214
1176
  // guard on this event evaluated nothing. The exit code is unchanged (see
@@ -1254,6 +1216,21 @@ export function main(argv?: string[]): number {
1254
1216
  return EXIT_ALLOW; // platform unsupported / fallback-only / empty slot
1255
1217
  }
1256
1218
 
1219
+ // `b-stdin-read-failure-policy` option (c). Placed HERE, not at the read:
1220
+ // the decision needs the resolved concern list, because the whole question is
1221
+ // whether a fail-closed blocking guard was among the ones that just ran
1222
+ // blind. Before the dry-run branch it would turn a plan printer into a
1223
+ // refusal; after the chain it would spend the chain first.
1224
+ if (_stdin_read_failed !== null) {
1225
+ const deny = denyOnStdinFailure(args.platform, args.event, concerns, _stdin_read_failed);
1226
+ if (deny !== null) {
1227
+ const emission = emitFor(args.platform, args.event, "block", [deny.reason], EXIT_BLOCK);
1228
+ if (emission.stdout) process.stdout.write(emission.stdout);
1229
+ if (emission.stderr) process.stderr.write(emission.stderr);
1230
+ return emission.exit;
1231
+ }
1232
+ }
1233
+
1257
1234
  const envelope = _build_envelope(args, payload_text);
1258
1235
  const session_id = _resolve_session_id(envelope);
1259
1236
  const started_at = _now_iso();
@@ -1447,24 +1424,14 @@ function _sortedRepr(s: ReadonlySet<string>): string {
1447
1424
  * dispatcher exits 0. For a `fail_closed: true`, `severity: blocking` guard that
1448
1425
  * is an allow, emitted silently. Found by the R2 review.
1449
1426
  *
1450
- * The failure is now LOUD (stderr + a dispatch issue) while the allow/deny
1451
- * decision is unchanged. Turning a failed read into a DENY is a policy change on
1452
- * a security surface, so it is filed as `b-stdin-read-failure-policy` rather than
1453
- * improvised here: an unreadable payload on a block-capable event arguably must
1454
- * refuse, and that call is the maintainer's.
1427
+ * The failure is LOUD (stderr + a dispatch issue) on every slot, and since
1428
+ * `b-stdin-read-failure-policy` was decided (council 2026-08-20, option (c)) it
1429
+ * also DENIES — but only where a deny is honoured and a fail-closed blocking
1430
+ * concern was among the ones that ran blind. See `denyOnStdinFailure` for the
1431
+ * policy and for why the two broader options were refused.
1455
1432
  */
1456
1433
  let _stdin_read_failed: string | null = null;
1457
1434
 
1458
- function _readStdin(): string {
1459
- try {
1460
- // Reads to EOF and retries on EAGAIN rather than treating it as empty
1461
- // input; shared with the concern-side reader so both paths cannot drift.
1462
- return readFd0ToEnd();
1463
- } catch (exc) {
1464
- _stdin_read_failed = exc instanceof Error ? exc.message : String(exc);
1465
- return "";
1466
- }
1467
- }
1468
1435
 
1469
1436
  // Bundle-safety: never auto-run when inlined into an esbuild bundle, where
1470
1437
  // every module shares the bundle's `import.meta.url` (declare at top of file).
@@ -35,6 +35,8 @@
35
35
  import fs from "node:fs";
36
36
  import path from "node:path";
37
37
 
38
+ import { update_text_under_lock } from "./state_io.js";
39
+
38
40
  export const LOG_CAP = 200;
39
41
 
40
42
  export const VALID_ISSUE: ReadonlySet<string> = new Set([
@@ -185,27 +187,44 @@ export function log_dispatch_issue(
185
187
  };
186
188
 
187
189
  try {
188
- fs.mkdirSync(path.dirname(log), { recursive: true });
189
- // Read existing lines (cheap — bounded log).
190
- let existing: string[] = [];
191
- if (fs.existsSync(log)) {
192
- try {
193
- existing = fs.readFileSync(log, "utf-8").split("\n");
194
- // Python str.splitlines() drops a trailing empty element from a
195
- // trailing newline; mirror that.
196
- if (existing.length > 0 && existing[existing.length - 1] === "") {
197
- existing.pop();
198
- }
199
- } catch {
200
- existing = [];
190
+ // P3 of `b-stop-async-split-prerequisites` (council 2026-08-20, option (a),
191
+ // "P3 before anything else"): read + append + write is one critical
192
+ // section, held under the shared dispatcher lock and published by
193
+ // tmp+rename.
194
+ //
195
+ // What it was: `readFileSync` outside any lock, then `writeFileSync`
196
+ // straight onto the target. Two failures, and the second is the one that
197
+ // made this the first P3 item — a lost line is an absent record, while a
198
+ // TRUNCATED write is a corrupt one, and this file is written exactly when
199
+ // something has already gone wrong. Two concurrent dispatchers in one
200
+ // workspace is supported (two platforms installed side by side), and a host
201
+ // that runs tool calls in parallel produces it with one platform.
202
+ //
203
+ // Still best-effort: `update_text_under_lock` returns false rather than
204
+ // throwing when the lock or the write fails, and observability never breaks
205
+ // the agent loop.
206
+ const outcome = update_text_under_lock(log, (loaded) => {
207
+ const existing = loaded === null ? [] : loaded.split("\n");
208
+ // Python str.splitlines() drops a trailing empty element from a
209
+ // trailing newline; mirror that.
210
+ if (existing.length > 0 && existing[existing.length - 1] === "") {
211
+ existing.pop();
201
212
  }
213
+ existing.push(_json_dumps_flat(entry));
214
+ // Cap rotation: drop the oldest entries.
215
+ const capped =
216
+ existing.length > LOG_CAP ? existing.slice(existing.length - LOG_CAP) : existing;
217
+ return capped.join("\n") + "\n";
218
+ });
219
+ // `!outcome` would be a silent no-op — every member of the union is a
220
+ // truthy string, which the primitive's own commit message names as the
221
+ // migration hazard. Compare the literal.
222
+ if (outcome !== "written") {
223
+ process.stderr.write(
224
+ `dispatch_issues: could not append to ${log} (${outcome}) — ` +
225
+ `the entry was dropped: ${entry.issue} on ${entry.hook}\n`,
226
+ );
202
227
  }
203
- existing.push(_json_dumps_flat(entry));
204
- // Cap rotation: drop the oldest entries.
205
- if (existing.length > LOG_CAP) {
206
- existing = existing.slice(existing.length - LOG_CAP);
207
- }
208
- fs.writeFileSync(log, existing.join("\n") + "\n", { encoding: "utf-8" });
209
228
  } catch (exc) {
210
229
  // Observability never blocks the agent.
211
230
  const msg = exc instanceof Error ? exc.message : String(exc);
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The dependency-free YAML-subset reader, extracted from `dispatch_hook.ts`.
3
+ *
4
+ * Pure and self-recursive, like the JSON writer beside it. Moved for headroom
5
+ * rather than for the ceiling itself: two extractions clear the gate but land the
6
+ * file at exactly 1500, back on the cliff where the next added line reds it.
7
+ */
8
+ import type { JsonObject } from './dispatch_hook.js';
9
+
10
+ /**
11
+ * Indent-aware mini-parser for the manifest's flat shape only.
12
+ * Handles: scalars, `key: null`, `key: true/false`, `key: [a, b]`.
13
+ * Drops comments + blank lines. Two-space indent assumed.
14
+ *
15
+ * Ported verbatim from the Python `_fallback_yaml` so the parser unit
16
+ * tests have an exact twin; runtime prefers `_load_yaml` above.
17
+ */
18
+ export function _fallback_yaml(text: string): JsonObject {
19
+ const root: JsonObject = {};
20
+ const stack: Array<[number, JsonObject]> = [[-1, root]];
21
+ for (const raw of text.split("\n")) {
22
+ const line = raw.split("#", 1)[0]!.replace(/\s+$/, "");
23
+ if (!line.trim()) {
24
+ continue;
25
+ }
26
+ const indent = line.length - line.replace(/^ +/, "").length;
27
+ while (stack.length > 0 && stack[stack.length - 1]![0] >= indent) {
28
+ stack.pop();
29
+ }
30
+ const parent = stack.length > 0 ? stack[stack.length - 1]![1] : root;
31
+ const body = line.trim();
32
+ if (!body.includes(":")) {
33
+ continue;
34
+ }
35
+ const idx = body.indexOf(":");
36
+ const key = body.slice(0, idx).trim();
37
+ const val = body.slice(idx + 1).trim();
38
+ if (!val) {
39
+ const newObj: JsonObject = {};
40
+ parent[key] = newObj;
41
+ stack.push([indent, newObj]);
42
+ } else {
43
+ const lower = val.toLowerCase();
44
+ if (lower === "null" || lower === "~" || lower === "") {
45
+ parent[key] = null;
46
+ } else if (lower === "true") {
47
+ parent[key] = true;
48
+ } else if (lower === "false") {
49
+ parent[key] = false;
50
+ } else if (val.startsWith("[") && val.endsWith("]")) {
51
+ const inner = val.slice(1, -1).trim();
52
+ parent[key] = inner
53
+ ? inner
54
+ .split(",")
55
+ .map((s) => s.trim())
56
+ .filter((s) => s)
57
+ : [];
58
+ } else if (/^-?\d+$/.test(val)) {
59
+ parent[key] = parseInt(val, 10);
60
+ } else {
61
+ parent[key] = val.replace(/^['"]+|['"]+$/g, "");
62
+ }
63
+ }
64
+ }
65
+ return root;
66
+ }
@@ -78,6 +78,27 @@ const CLAUDE_BLOCK_CAPABLE_EVENTS: ReadonlySet<string> = new Set([
78
78
  "stop",
79
79
  ]);
80
80
 
81
+ /**
82
+ * Does this (platform, event) pair let a hook actually REFUSE the action?
83
+ *
84
+ * Exported because a second consumer now needs the same fact and must not
85
+ * re-derive it: `dispatch_hook`'s stdin-read-failure policy denies only where a
86
+ * deny is honoured (`b-stdin-read-failure-policy`, option (c)). A copy of
87
+ * `CLAUDE_BLOCK_CAPABLE_EVENTS` one module away is a copy that drifts, and the
88
+ * drift direction here is the dangerous one: a stale copy would deny on a slot
89
+ * where the deny is discarded, refusing nothing while looking like enforcement.
90
+ *
91
+ * An unverified platform is NOT block-capable by this predicate, and that is
92
+ * deliberate rather than incidental: `emitFor` hands such a platform its legacy
93
+ * exit code verbatim, so this tree has no evidence its host honours a deny at
94
+ * all. Claiming capability without evidence is the over-claim the hook-coverage
95
+ * corrections in this estate exist to remove.
96
+ */
97
+ export function isBlockCapable(platform: string, event: string): boolean {
98
+ if (!VERIFIED_PLATFORMS.has(platform)) return false;
99
+ return CLAUDE_BLOCK_CAPABLE_EVENTS.has(event);
100
+ }
101
+
81
102
  function _joinReasons(reasons: readonly string[]): string {
82
103
  return reasons.map((r) => r.trim()).filter((r) => r.length > 0).join(" · ");
83
104
  }
@@ -196,9 +196,19 @@ export function evictionOrder(
196
196
  * Nudge exclusivity: keep the lowest `nudge_rank`, drop every other nudge.
197
197
  *
198
198
  * Ties break on concern name so the winner is stable; a tie is a manifest
199
- * defect rather than a runtime one, and `lint_hook_manifest` is where it should
200
- * eventually be caught — that check does not exist yet and this comment is the
201
- * honest statement of the gap, not a claim that it is covered.
199
+ * defect rather than a runtime one, and `lint_hook_manifest` is where it is
200
+ * caught — `_check_concerns` rejects a repeated non-null `nudge_rank`, naming
201
+ * the rank and every concern declaring it, and rejects a non-positive-integer
202
+ * value rather than coercing it (road-to-wiring-truth-corrections Phase 3;
203
+ * cases in `tests/scripts/lint_hook_manifest.test.ts`). The tie-break below
204
+ * therefore stays as the runtime's stable fallback for a manifest that got
205
+ * past the lint, not as the only thing standing between a collision and a
206
+ * silently non-emitting concern.
207
+ *
208
+ * The lint enforces uniqueness GLOBALLY while this selector compares within
209
+ * one dispatch, so the lint is deliberately the stricter of the two — see its
210
+ * own note for why, and narrow it rather than delete it if a design ever needs
211
+ * one rank on two disjoint events.
202
212
  */
203
213
  function _selectNudge(
204
214
  candidates: readonly EmissionCandidate[],