@event4u/agent-config 14.7.0 → 14.9.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 (230) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +175 -390
  3. package/README.md +1 -1
  4. package/agents/templates/.ai-council.yml.example +34 -4
  5. package/dist/agent-src/commands/evals/git-pr-merge.json +13 -0
  6. package/dist/agent-src/commands/evals/roadmap.json +17 -1
  7. package/dist/agent-src/commands/pr/create.md +14 -2
  8. package/dist/agent-src/commands/pr/merge.md +383 -0
  9. package/dist/agent-src/commands/review/changes.md +95 -14
  10. package/dist/agent-src/commands/roadmap/process-full.md +157 -3
  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/host-capability-manifest.md +77 -0
  15. package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +100 -2
  16. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +154 -2
  17. package/dist/agent-src/contexts/execution/subagent-response-contract.md +80 -0
  18. package/dist/agent-src/rules/artifact-drafting-protocol.md +5 -1
  19. package/dist/agent-src/rules/decision-revisit-gate.md +65 -33
  20. package/dist/agent-src/rules/recurring-criticism.md +1 -1
  21. package/dist/agent-src/rules/roadmap-progress-sync.md +19 -0
  22. package/dist/agent-src/rules/source-confidentiality.md +1 -1
  23. package/dist/agent-src/rules/untrusted-input-defense.md +9 -3
  24. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +7 -4
  25. package/dist/agent-src/scripts/dashboard_mode.ts +212 -0
  26. package/dist/agent-src/scripts/update_roadmap_progress.ts +47 -48
  27. package/dist/agent-src/skills/adr-create/SKILL.md +124 -5
  28. package/dist/agent-src/skills/code-review/evals/evals.json +87 -46
  29. package/dist/agent-src/skills/decision-record/SKILL.md +61 -3
  30. package/dist/agent-src/skills/decision-review/SKILL.md +65 -0
  31. package/dist/agent-src/skills/humanizer/SKILL.md +39 -0
  32. package/dist/agent-src/skills/humanizer/evals/strip_fixtures.json +57 -0
  33. package/dist/agent-src/skills/humanizer/references/fixtures.md +54 -0
  34. package/dist/agent-src/skills/judge-spec-compliance/SKILL.md +154 -0
  35. package/dist/agent-src/skills/judge-synthesis/SKILL.md +45 -0
  36. package/dist/agent-src/skills/merge-conflicts/SKILL.md +26 -0
  37. package/dist/agent-src/skills/roadmap-writing/SKILL.md +18 -18
  38. package/dist/agent-src/skills/security/SKILL.md +8 -0
  39. package/dist/agent-src/skills/security-audit/SKILL.md +12 -1
  40. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  41. package/dist/agent-src/templates/roadmaps.md +28 -2
  42. package/dist/cli/registry.js +3 -0
  43. package/dist/cli/registry.js.map +1 -1
  44. package/dist/cli-delegate/{chunk-FBKL6JWT.js → chunk-77GR3VD4.js} +68 -43
  45. package/dist/cli-delegate/{chunk-JZ4NR2TW.js → chunk-7PRE6ZV2.js} +1 -1
  46. package/dist/cli-delegate/{chunk-UEZ7D35A.js → chunk-CYH5SP6M.js} +4 -23
  47. package/dist/cli-delegate/{chunk-Q26HT4QT.js → chunk-E46KPN6K.js} +251 -150
  48. package/dist/cli-delegate/{chunk-74E3TKWV.js → chunk-H4VIVD4C.js} +5 -5
  49. package/dist/cli-delegate/chunk-I3EIN2YG.js +36 -0
  50. package/dist/cli-delegate/{chunk-4VR4ORQ6.js → chunk-LPPWO5CE.js} +4 -3
  51. package/dist/cli-delegate/{chunk-7F3VYSM2.js → chunk-PLSG6UL5.js} +14 -14
  52. package/dist/cli-delegate/cmd_adr_effective.js +1162 -0
  53. package/dist/cli-delegate/cmd_conformance.js +101 -25
  54. package/dist/cli-delegate/cmd_converge.js +3 -3
  55. package/dist/cli-delegate/cmd_doctor.js +7 -7
  56. package/dist/cli-delegate/cmd_export.js +6 -6
  57. package/dist/cli-delegate/cmd_handoff.js +7 -6
  58. package/dist/cli-delegate/cmd_memory_get.js +2 -1
  59. package/dist/cli-delegate/cmd_migrate.js +2 -2
  60. package/dist/cli-delegate/cmd_preflight.js +5 -5
  61. package/dist/cli-delegate/cmd_prune.js +6 -6
  62. package/dist/cli-delegate/cmd_route_audit.js +6 -5
  63. package/dist/cli-delegate/cmd_route_explain.js +3 -3
  64. package/dist/cli-delegate/cmd_session_recycle.js +3 -3
  65. package/dist/cli-delegate/cmd_settings_get.js +4 -4
  66. package/dist/cli-delegate/cmd_settings_migrate.js +6 -6
  67. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  68. package/dist/cli-delegate/cmd_sync.js +6 -6
  69. package/dist/cli-delegate/cmd_uninstall.js +6 -6
  70. package/dist/cli-delegate/cmd_update.js +3 -3
  71. package/dist/cli-delegate/cmd_upgrade.js +3 -3
  72. package/dist/cli-delegate/cmd_validate.js +6 -6
  73. package/dist/cli-delegate/cmd_versions.js +3 -3
  74. package/dist/discovery/deprecation-report.md +1 -1
  75. package/dist/discovery/discovery-manifest.json +83 -30
  76. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  77. package/dist/discovery/discovery-manifest.summary.md +4 -4
  78. package/dist/discovery/dormancy-report.md +1 -1
  79. package/dist/discovery/orphan-report.md +1 -1
  80. package/dist/discovery/packs.json +11 -9
  81. package/dist/discovery/trust-report.md +2 -2
  82. package/dist/discovery/workspaces.json +9 -7
  83. package/dist/hooks/dispatch.js +1295 -812
  84. package/dist/install/globalRuleLayers.js +169 -0
  85. package/dist/install/globalRuleLayers.js.map +1 -0
  86. package/dist/install/partitionEligibility.js +156 -0
  87. package/dist/install/partitionEligibility.js.map +1 -1
  88. package/dist/install/ruleLayerPartition.js +157 -0
  89. package/dist/install/ruleLayerPartition.js.map +1 -0
  90. package/dist/mcp/registry-manifest.json +3 -3
  91. package/dist/mcp/server.json +2 -2
  92. package/dist/mcp/server.mjs +1358 -805
  93. package/dist/scripts/_lib/claude_plugin.js +162 -0
  94. package/dist/scripts/_lib/claude_plugin.js.map +1 -0
  95. package/dist/scripts/_lib/environment_detector.js +397 -0
  96. package/dist/scripts/_lib/environment_detector.js.map +1 -0
  97. package/dist/scripts/_lib/prune_empty_dirs.js +85 -0
  98. package/dist/scripts/_lib/prune_empty_dirs.js.map +1 -0
  99. package/dist/scripts/_lib/spawn_env.js +129 -0
  100. package/dist/scripts/_lib/spawn_env.js.map +1 -0
  101. package/dist/scripts/ai_council/modes.js +199 -0
  102. package/dist/scripts/ai_council/modes.js.map +1 -0
  103. package/dist/scripts/ai_council/transport_resolver.js +418 -0
  104. package/dist/scripts/ai_council/transport_resolver.js.map +1 -0
  105. package/dist/scripts/condense.js +52 -89
  106. package/dist/scripts/condense.js.map +1 -1
  107. package/dist/server/routes/wizard.js +3 -2
  108. package/dist/server/routes/wizard.js.map +1 -1
  109. package/dist/ui/assets/index-Cxgy63kz.js +26 -0
  110. package/dist/ui/assets/index-Cxgy63kz.js.map +1 -0
  111. package/dist/ui/index.html +1 -1
  112. package/docs/guidelines/agent-infra/artifact-drafting-protocol-mechanics.md +45 -1
  113. package/docs/guidelines/agent-infra/false-green.md +47 -1
  114. package/docs/guidelines/agent-infra/traceability-field-mechanics.md +50 -0
  115. package/docs/guidelines/agent-infra/untrusted-input-spotlighting.md +17 -0
  116. package/package.json +7 -3
  117. package/src/agent-src/scripts/archive_completed_roadmaps.ts +7 -4
  118. package/src/agent-src/scripts/dashboard_mode.ts +212 -0
  119. package/src/agent-src/scripts/update_roadmap_progress.ts +47 -48
  120. package/src/config/ci-local-parity.yml +21 -0
  121. package/src/config/estate-count-budget.json +13 -471
  122. package/src/config/evaluator-budgets.json +4 -4
  123. package/src/config/gate-coverage.yml +458 -12
  124. package/src/config/gate-violation-baselines.json +18 -8
  125. package/src/config/gitignore-block.txt +15 -2
  126. package/src/config/hook-latency-budget.json +5 -0
  127. package/src/config/publish-surface.json +60 -0
  128. package/src/config/requirements-trace-budget.json +32 -0
  129. package/src/config/rule-stub-ceilings.json +25 -4
  130. package/src/install/globalRuleLayers.ts +197 -0
  131. package/src/install/partitionEligibility.ts +177 -0
  132. package/src/install/ruleLayerPartition.ts +212 -0
  133. package/src/scripts/_cli/cmd_adr_effective.ts +484 -0
  134. package/src/scripts/_dispatch.bash +18 -0
  135. package/src/scripts/_lib/adr_frontmatter.test.ts +365 -0
  136. package/src/scripts/_lib/adr_frontmatter.ts +459 -0
  137. package/src/scripts/_lib/base_tree.ts +175 -0
  138. package/src/scripts/_lib/billing_grant.ts +165 -0
  139. package/src/scripts/_lib/billing_grant_cli.ts +123 -0
  140. package/src/scripts/_lib/council_fallback_posture.ts +53 -6
  141. package/src/scripts/_lib/council_fallback_wiring.ts +61 -1
  142. package/src/scripts/_lib/envelope_grounding.ts +11 -1
  143. package/src/scripts/_lib/host_capability.ts +59 -5
  144. package/src/scripts/_lib/layer_overlap_notice.ts +11 -2
  145. package/src/scripts/_lib/prune_empty_dirs.ts +80 -0
  146. package/src/scripts/_lib/quota_parked.ts +133 -0
  147. package/src/scripts/_lib/retrieval_sanitize.ts +62 -0
  148. package/src/scripts/_lib/review_telemetry.ts +196 -0
  149. package/src/scripts/_lib/subagent_response.ts +92 -0
  150. package/src/scripts/adr/evidence_census.ts +899 -0
  151. package/src/scripts/adr/regenerate_index.ts +25 -38
  152. package/src/scripts/adr_cite_check.ts +540 -39
  153. package/src/scripts/ai_council/clients.ts +12 -1
  154. package/src/scripts/ai_council/config.ts +72 -115
  155. package/src/scripts/ai_council/fallback_config.ts +19 -7
  156. package/src/scripts/ai_council/mid_flight_fallback.ts +88 -7
  157. package/src/scripts/ai_council/orchestrator.ts +2 -2
  158. package/src/scripts/ai_council/py_format.ts +124 -0
  159. package/src/scripts/ai_council/transport_resolver.ts +65 -2
  160. package/src/scripts/audit_adr_coverage.ts +134 -21
  161. package/src/scripts/bench_hook_latency.ts +204 -0
  162. package/src/scripts/build_archive_index.ts +34 -0
  163. package/src/scripts/check_adr_frontmatter.ts +574 -39
  164. package/src/scripts/check_council_pin_staleness.ts +300 -0
  165. package/src/scripts/check_estate_count.ts +420 -293
  166. package/src/scripts/check_gate_coverage.ts +91 -0
  167. package/src/scripts/check_hook_bundle_content.ts +219 -0
  168. package/src/scripts/check_new_adr_evidence.ts +529 -0
  169. package/src/scripts/check_no_stub_inventory_table.ts +292 -0
  170. package/src/scripts/check_pack_size.ts +234 -1
  171. package/src/scripts/check_publish_surface.ts +297 -0
  172. package/src/scripts/check_references.ts +18 -0
  173. package/src/scripts/check_requirements_trace.ts +378 -0
  174. package/src/scripts/check_rule_layer_partition.ts +531 -0
  175. package/src/scripts/check_secret_leak.ts +166 -12
  176. package/src/scripts/check_single_delivery.ts +137 -4
  177. package/src/scripts/check_standing_rule_delivery.ts +8 -2
  178. package/src/scripts/code_graph/cli.ts +44 -5
  179. package/src/scripts/code_graph/extract.ts +87 -0
  180. package/src/scripts/condense.ts +61 -91
  181. package/src/scripts/council_cli.ts +12 -16
  182. package/src/scripts/detect_ai_tells.ts +87 -0
  183. package/src/scripts/dispatch_r2_reviewer.ts +42 -0
  184. package/src/scripts/external_sources_denylist.json +4 -1
  185. package/src/scripts/generate_capabilities_index.ts +41 -0
  186. package/src/scripts/git_authorization_hook.ts +54 -4
  187. package/src/scripts/hooks/block_unauthorized_git.ts +41 -3
  188. package/src/scripts/hooks/dispatch_hook.ts +154 -187
  189. package/src/scripts/hooks/dispatch_issues.ts +38 -19
  190. package/src/scripts/hooks/fallback_yaml.ts +66 -0
  191. package/src/scripts/hooks/host_semantics.ts +21 -0
  192. package/src/scripts/hooks/injection_budget.ts +13 -3
  193. package/src/scripts/hooks/py_json_dumps.ts +103 -0
  194. package/src/scripts/hooks/run_continuation_hook.ts +3 -1
  195. package/src/scripts/hooks/state_io.ts +88 -1
  196. package/src/scripts/hooks/stdin_failure_policy.ts +96 -0
  197. package/src/scripts/hooks/subagent_ledger_hook.ts +76 -11
  198. package/src/scripts/injection_scan_hook.ts +257 -15
  199. package/src/scripts/lint_adr_sweep_routing.ts +365 -0
  200. package/src/scripts/lint_hook_manifest.ts +49 -0
  201. package/src/scripts/lint_provenance_vocabulary.ts +472 -4
  202. package/src/scripts/lint_workflow_security.ts +53 -10
  203. package/src/scripts/lint_workflow_security_allowlist.json +2 -18
  204. package/src/scripts/report_envelope_rate.ts +166 -0
  205. package/src/scripts/review_axis_report.ts +218 -0
  206. package/src/scripts/routing_doctor.ts +5 -1
  207. package/src/scripts/run_supervise.ts +46 -1
  208. package/src/scripts/schemas/evals.schema.json +5 -0
  209. package/src/scripts/sync_pr_branch.ts +170 -20
  210. package/dist/cli/commands/commands.candidates.test.js +0 -257
  211. package/dist/cli/commands/commands.candidates.test.js.map +0 -1
  212. package/dist/cli/commands/profiles.test.js +0 -88
  213. package/dist/cli/commands/profiles.test.js.map +0 -1
  214. package/dist/cli/commands/recordTriggerEval.test.js +0 -113
  215. package/dist/cli/commands/recordTriggerEval.test.js.map +0 -1
  216. package/dist/cli/configRoot.test.js +0 -97
  217. package/dist/cli/configRoot.test.js.map +0 -1
  218. package/dist/cli/mcp/content.test.js +0 -54
  219. package/dist/cli/mcp/content.test.js.map +0 -1
  220. package/dist/cli/mcp/dispatch.test.js +0 -108
  221. package/dist/cli/mcp/dispatch.test.js.map +0 -1
  222. package/dist/server/writeRoot.test.js +0 -91
  223. package/dist/server/writeRoot.test.js.map +0 -1
  224. package/dist/shared/capabilities.test.js +0 -39
  225. package/dist/shared/capabilities.test.js.map +0 -1
  226. package/dist/ui/assets/index-C1pBRm1f.js +0 -26
  227. package/dist/ui/assets/index-C1pBRm1f.js.map +0 -1
  228. package/dist/cli-delegate/{chunk-PPQO4NGR.js → chunk-6RJA2F5A.js} +3 -3
  229. package/dist/cli-delegate/{chunk-6EOCNQAM.js → chunk-E4XIZDIB.js} +0 -0
  230. package/dist/cli-delegate/{chunk-GJ5BVHXM.js → chunk-PMEKGDED.js} +3 -3
@@ -263,7 +263,9 @@ export {
263
263
  type ClaudePathsPlan,
264
264
  } from '../install/claudePathsPlan.js';
265
265
  import { rule_in_scope } from '../install/ruleInScope.js';
266
- import { isExclusivelyPackageOnly, partitionActive, setPartitionAnnounce } from '../install/partitionEligibility.js'; // ADR-236
266
+ import { pruneEmptyDirs } from './_lib/prune_empty_dirs.js';
267
+ import { commandsWithheld, partitionActive, personaPartition, setPartitionAnnounce } from '../install/partitionEligibility.js'; // ADR-236
268
+ import { dedupableRules, partitionRulesForDir } from '../install/ruleLayerPartition.js'; // ADR-236, per-host evidence
267
269
  import { _claude_paths_plan, derive_trigger_globs } from '../install/claudePathsPlan.js';
268
270
 
269
271
  const _HERE = path.dirname(fileURLToPath(import.meta.url)); // <repo>/src/scripts
@@ -439,15 +441,6 @@ function _read_augment_rules_use_symlinks(): boolean {
439
441
  return false;
440
442
  }
441
443
 
442
- /**
443
- * Per-tool user-scope rule directory, for the scope-dedup below. Only tools
444
- * whose host reads a user-scope rules directory can have a redundant twin;
445
- * everything else has nothing to de-duplicate against.
446
- */
447
- const USER_SCOPE_RULE_DIRS: Readonly<Record<string, string>> = {
448
- '.claude/rules': path.join('.claude', 'rules'),
449
- };
450
-
451
444
  function _read_projection_scope_dedup(): boolean {
452
445
  const data = load_agent_settings({ project_path: MODULE_STATE.SETTINGS_FILE });
453
446
  const projection = data['projection'];
@@ -464,60 +457,6 @@ function _read_projection_scope_dedup(): boolean {
464
457
  return false;
465
458
  }
466
459
 
467
- /**
468
- * Rules whose user-scope twin is BYTE-IDENTICAL to the source, i.e. safe to
469
- * skip at project scope: the host still loads the same text, once instead of
470
- * twice, so nothing the model sees changes.
471
- *
472
- * Byte-identity is the whole safety argument and is not negotiable. Keying on
473
- * the filename alone would silently let a stale globally-installed copy win
474
- * whenever the two scopes hold different versions — which is the NORMAL state
475
- * while developing the package (measured: 110/110 shared filenames differing in
476
- * bytes). This is deliberately not the thin-projection mechanism: no rule
477
- * becomes trigger-gated, so the quality floor that disabled thin projection
478
- * does not apply here.
479
- */
480
- function _dedupable_rules(tool_dir: string, rules: readonly string[], userHome: string): Set<string> {
481
- const relative = USER_SCOPE_RULE_DIRS[tool_dir];
482
- if (relative === undefined) {
483
- return new Set();
484
- }
485
- // A hostile or simply absent $HOME must make the dedup inert, not
486
- // adventurous. In a container `$HOME` is often unset (so `homedir()` can
487
- // resolve to `/`) or world-writable, and this function decides which rules
488
- // to STOP emitting — reading an unexpected tree there is how a projection
489
- // silently loses a rule. Council review of PR #1055 raised exactly this.
490
- let userDirStat: fs.Stats;
491
- const userDir = path.join(userHome, relative);
492
- try {
493
- userDirStat = fs.statSync(userDir);
494
- } catch {
495
- return new Set();
496
- }
497
- if (!userDirStat.isDirectory()) {
498
- return new Set();
499
- }
500
- // World-writable user scope: anyone on the box could plant a byte-identical
501
- // twin and thereby delete a rule from the project projection. Refuse.
502
- if ((userDirStat.mode & 0o002) !== 0) {
503
- _print(` ⚠️ ${tool_dir}: user-scope rules dir is world-writable — scope-dedup skipped`);
504
- return new Set();
505
- }
506
- const skip = new Set<string>();
507
- for (const rule of rules) {
508
- const twin = path.join(userDir, rule);
509
- const source = path.join(MODULE_STATE.RULES_SOURCE, rule);
510
- try {
511
- if (!_isFile(twin) || !_isFile(source)) continue;
512
- if (fs.readFileSync(twin).equals(fs.readFileSync(source))) {
513
- skip.add(rule);
514
- }
515
- } catch {
516
- // An unreadable twin is simply not de-duplicable — emit the project copy.
517
- }
518
- }
519
- return skip;
520
- }
521
460
 
522
461
  function _lean_projection_mode(): string {
523
462
  const data = load_agent_settings({ project_path: MODULE_STATE.SETTINGS_FILE });
@@ -1132,11 +1071,22 @@ function _scoped_rule_basenames(): string[] {
1132
1071
  // is its fourth consumer, after the router compiler, the dist writer, and
1133
1072
  // check_sync. Same predicate in all four; that is the point.
1134
1073
  .filter((p) => !skip_compile_disabled_rule(`rules/${path.basename(p)}`))
1135
- .filter((p) => !partitionActive(MODULE_STATE.PROJECT_ROOT) || isExclusivelyPackageOnly(p))
1136
1074
  .map((p) => path.basename(p))
1137
1075
  .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
1138
1076
  }
1139
1077
 
1078
+ /** Adapter over {@link partitionRulesForDir}; that module carries the reasoning. */
1079
+ export function partition_rules_for_dir(
1080
+ tool_dir: string,
1081
+ rules: readonly string[],
1082
+ user_home: string = process.env['HOME'] ?? os.homedir(),
1083
+ ): string[] {
1084
+ return partitionRulesForDir({
1085
+ toolDir: tool_dir, rules, userHome: user_home, announce: _print,
1086
+ projectRoot: MODULE_STATE.PROJECT_ROOT, rulesSource: MODULE_STATE.RULES_SOURCE,
1087
+ });
1088
+ }
1089
+
1140
1090
  /**
1141
1091
  * The emit plan of {@link generate_rule_symlinks}: active tool dir → the exact
1142
1092
  * rule basenames it projects there.
@@ -1161,8 +1111,12 @@ export function projected_rule_trees(
1161
1111
  const dedup_on = _read_projection_scope_dedup();
1162
1112
  const out: Record<string, string[]> = {};
1163
1113
  for (const tool_dir of Object.keys(_filter_tool_dirs(TOOL_DIRS))) {
1164
- const skip = dedup_on ? _dedupable_rules(tool_dir, rules, user_home) : new Set<string>();
1165
- out[tool_dir] = rules.filter((r) => !skip.has(r));
1114
+ const skip = dedup_on
1115
+ ? dedupableRules({ toolDir: tool_dir, rules, userHome: user_home,
1116
+ rulesSource: MODULE_STATE.RULES_SOURCE, announce: _print })
1117
+ : new Set<string>();
1118
+ const kept = rules.filter((r) => !skip.has(r));
1119
+ out[tool_dir] = partition_rules_for_dir(tool_dir, kept, user_home);
1166
1120
  }
1167
1121
  return out;
1168
1122
  }
@@ -1281,7 +1235,10 @@ export function generate_rule_symlinks(): number {
1281
1235
  }
1282
1236
 
1283
1237
  export function generate_windsurfrules(): number {
1284
- const rules = _scoped_rule_basenames();
1238
+ // Windsurf's LEGACY single-file surface — same host, so the same evidence, keyed
1239
+ // on `.windsurf/rules`. Without this line removing the per-run filter regressed
1240
+ // it 13 → 113: the per-run filter had been covering it by accident.
1241
+ const rules = partition_rules_for_dir('.windsurf/rules', _scoped_rule_basenames());
1285
1242
  const parts = ['# Auto-generated from dist/agent-src/rules/ — do not edit directly\n'];
1286
1243
  for (const rule of rules) {
1287
1244
  const p = path.join(MODULE_STATE.RULES_SOURCE, rule);
@@ -1451,12 +1408,16 @@ function _clean_modern_dir(target_dir: string, valid_names: ReadonlySet<string>)
1451
1408
  export function generate_cursor_mdc_rules(): number {
1452
1409
  const scope = _read_rule_workspaces();
1453
1410
  const pack_scope = _read_rule_packs();
1454
- const rules = _rglobSorted(MODULE_STATE.RULES_SOURCE, '*.md')
1411
+ let rules = _rglobSorted(MODULE_STATE.RULES_SOURCE, '*.md')
1455
1412
  .filter((p) => _isFile(p))
1456
1413
  .filter((p) => rule_in_scope(p, scope, pack_scope))
1457
1414
  // ADR-004 manual rules are reference-only — never auto-injected into a
1458
1415
  // per-tool tree. Same predicate as generate_rule_symlinks.
1459
1416
  .filter((p) => !_is_manual_rule(p));
1417
+ // ADR-236 per-host partition (src/install/ruleLayerPartition.ts). Narrowing the
1418
+ // list is a REMOVAL, because `_clean_modern_dir` below sweeps to `valid`.
1419
+ const kept = new Set(partition_rules_for_dir('.cursor/rules', rules.map((r) => path.basename(r))));
1420
+ rules = rules.filter((r) => kept.has(path.basename(r)));
1460
1421
  const stems = rules.map((r) => path.basename(r, '.md'));
1461
1422
  const valid = new Set([
1462
1423
  ...stems.map((s) => `${s}.mdc`),
@@ -1473,12 +1434,15 @@ export function generate_cursor_mdc_rules(): number {
1473
1434
  export function generate_windsurf_modern_rules(): number {
1474
1435
  const scope = _read_rule_workspaces();
1475
1436
  const pack_scope = _read_rule_packs();
1476
- const rules = _rglobSorted(MODULE_STATE.RULES_SOURCE, '*.md')
1437
+ let rules = _rglobSorted(MODULE_STATE.RULES_SOURCE, '*.md')
1477
1438
  .filter((p) => _isFile(p))
1478
1439
  .filter((p) => rule_in_scope(p, scope, pack_scope))
1479
1440
  // ADR-004 manual rules are reference-only — never auto-injected into a
1480
1441
  // per-tool tree. Same predicate as generate_rule_symlinks.
1481
1442
  .filter((p) => !_is_manual_rule(p));
1443
+ // ADR-236 per-host partition — same shape as the cursor emitter above.
1444
+ const kept = new Set(partition_rules_for_dir('.windsurf/rules', rules.map((r) => path.basename(r))));
1445
+ rules = rules.filter((r) => kept.has(path.basename(r)));
1482
1446
  const valid = new Set(rules.map((r) => path.basename(r)));
1483
1447
  _clean_modern_dir(_WINDSURF_RULES_DIR(), valid);
1484
1448
  for (const rule of rules) {
@@ -1781,18 +1745,16 @@ function _nested_command_subpath(source_file: string): string | null {
1781
1745
  /**
1782
1746
  * Project-scope `.claude/commands/<cluster>/<sub>.md` — the colon form.
1783
1747
  *
1784
- * Before this, the package repo's own `.claude/` carried NO commands
1785
- * directory: every command reached Claude Code as a hyphen-named skill
1786
- * wrapper in `.claude/skills/`, while the colon form existed only in the
1787
- * user-global tree written by `install.ts`. Every clustered command was
1788
- * therefore listed TWICE for anyone with a global install
1789
- * (`roadmap-process-full` AND `roadmap:process-full`), costing 4,214 GPT tok
1790
- * of always-loaded catalog for zero added reach — measured 2026-08-02.
1748
+ * Before this, the package repo's own `.claude/` carried NO commands directory:
1749
+ * every command reached Claude Code as a hyphen-named skill wrapper in
1750
+ * `.claude/skills/`, while the colon form existed only in the user-global tree
1751
+ * written by `install.ts`. Every clustered command was therefore listed TWICE
1752
+ * for anyone with a global install (`roadmap-process-full` AND
1753
+ * `roadmap:process-full`), costing 4,214 GPT tok of catalog — measured 2026-08-02.
1791
1754
  *
1792
1755
  * Emitting the nested form here lets `generate_claude_commands` stop wrapping
1793
1756
  * the clustered commands: one listing, same reachability, and reachability no
1794
- * longer depends on a global deploy having run. Claude Code dedupes project
1795
- * and user scope by name, so the two copies of `/cluster:sub` collapse.
1757
+ * longer depends on a global deploy. Dedup AND precedence: `commandsWithheld`.
1796
1758
  *
1797
1759
  * ADR-003 (colon canonical for clusters) and ADR-044 (flat commands stay
1798
1760
  * hyphenated) both hold: flat commands are untouched and keep their wrapper.
@@ -1800,6 +1762,8 @@ function _nested_command_subpath(source_file: string): string | null {
1800
1762
  export function generate_claude_project_commands(
1801
1763
  active_command_slugs: ReadonlySet<string> | null = null,
1802
1764
  ): number {
1765
+ if (commandsWithheld(MODULE_STATE.PROJECT_ROOT)) active_command_slugs = new Set<string>();
1766
+ // Returns before the stale sweep — invariant, see `commandsWithheld`.
1803
1767
  if (!_isDir(path.join(MODULE_STATE.PROJECT_ROOT, 'src', 'domains'))) {
1804
1768
  return 0;
1805
1769
  }
@@ -1816,8 +1780,7 @@ export function generate_claude_project_commands(
1816
1780
 
1817
1781
  const target_root = _CLAUDE_COMMANDS_DIR();
1818
1782
  const valid = new Set(nested.map(([, sub]) => `${sub}.md`));
1819
- // Sweep stale links first: a renamed or deleted command must not linger as
1820
- // a dangling `/cluster:sub`.
1783
+ // Sweep stale links first — a renamed, deleted or WITHHELD command must not linger.
1821
1784
  if (_exists(target_root)) {
1822
1785
  for (const item of _rglobSorted(target_root, '*.md')) {
1823
1786
  const rel = _relativeToPosix(item, target_root);
@@ -1825,6 +1788,7 @@ export function generate_claude_project_commands(
1825
1788
  fs.unlinkSync(item);
1826
1789
  }
1827
1790
  }
1791
+ pruneEmptyDirs(target_root); // else the gate counts 40 empty cluster dirs as 40 commands
1828
1792
  }
1829
1793
 
1830
1794
  let count = 0;
@@ -2297,7 +2261,7 @@ export function generate_persona_symlinks(): number {
2297
2261
  .map((p) => path.basename(p))
2298
2262
  .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
2299
2263
  void personas;
2300
- const list = direct;
2264
+ const partition = personaPartition(MODULE_STATE.PROJECT_ROOT, direct);
2301
2265
  const tool_dirs = _filter_tool_dirs(PERSONA_TOOL_DIRS);
2302
2266
  let total = 0;
2303
2267
  for (const [tool_dir, rel_prefix] of Object.entries(tool_dirs)) {
@@ -2305,11 +2269,11 @@ export function generate_persona_symlinks(): number {
2305
2269
  _mkdirp(target_dir);
2306
2270
  for (const item of _iterdirSorted(target_dir)) {
2307
2271
  const name = path.basename(item);
2308
- if (_isSymlink(item) && !list.includes(name) && name !== 'README.md') {
2272
+ if (_isSymlink(item) && !partition.listFor(tool_dir).includes(name) && name !== 'README.md') {
2309
2273
  fs.unlinkSync(item);
2310
2274
  }
2311
2275
  }
2312
- for (const persona of list) {
2276
+ for (const persona of partition.listFor(tool_dir)) {
2313
2277
  const link = path.join(target_dir, persona);
2314
2278
  const target = path.join(rel_prefix, persona);
2315
2279
  if (_existsOrSymlink(link)) {
@@ -2319,9 +2283,8 @@ export function generate_persona_symlinks(): number {
2319
2283
  total += 1;
2320
2284
  }
2321
2285
  }
2322
- info(
2323
- ` ✅ Created ${total} persona symlinks across ${Object.keys(tool_dirs).length} tool directories (${list.length} personas each)`,
2324
- );
2286
+ const per_dir = Object.keys(tool_dirs).map((d) => `${d}=${partition.countFor(d)}`);
2287
+ info(` ✅ Created ${total} persona symlinks — ${per_dir.join(' · ')}${partition.note}`);
2325
2288
  return total;
2326
2289
  }
2327
2290
 
@@ -2521,10 +2484,17 @@ export function project_to_augment(): void {
2521
2484
  const current = new Set<string>();
2522
2485
  let written = 0;
2523
2486
  if (_exists(src_rules)) {
2524
- const rules = _iterdirSorted(src_rules)
2525
- .filter((p) => p.endsWith('.md') && _isFile(p))
2526
- .map((p) => path.basename(p))
2527
- .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
2487
+ // ADR-236 per-host partition, ahead of the `use_symlinks` branch so both
2488
+ // writers narrow; the `existing` minus `current` sweep below makes it a
2489
+ // removal. This emitter never had the scope or ADR-004 manual filters
2490
+ // either — why its count is 15 and the symlink trees' 13. Pre-existing.
2491
+ const rules = partition_rules_for_dir(
2492
+ '.augment/rules',
2493
+ _iterdirSorted(src_rules)
2494
+ .filter((p) => p.endsWith('.md') && _isFile(p))
2495
+ .map((p) => path.basename(p))
2496
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
2497
+ );
2528
2498
  for (const rule of rules) {
2529
2499
  const target = path.join(dst_rules, rule);
2530
2500
  if (_isSymlink(target) || _existsOrSymlink(target)) {
@@ -106,7 +106,8 @@ import {
106
106
  renderPostureLines,
107
107
  type FallbackPosture,
108
108
  } from './_lib/council_fallback_posture.js';
109
- import { buildFallbackOptions } from './_lib/council_fallback_wiring.js';
109
+ import { wireCouncilFallback } from './_lib/council_fallback_wiring.js';
110
+ import { BILLING_SUBCOMMANDS, billingArgSpec, handleBillingCommand, printBillingGate } from './_lib/billing_grant_cli.js';
110
111
  import {
111
112
  type ClassificationResult,
112
113
  type SizeFitVerdict,
@@ -880,19 +881,11 @@ function build_members(settings: Dict, opts: BuildMembersOptions = {}): External
880
881
  'prints the resolved path.',
881
882
  );
882
883
  }
883
- if (fallback_out !== null) {
884
- // Absent/malformed block means `false`, never a throw. Why quota
885
- // fall-through is opt-in: `FallbackPolicy.apiOnQuota`.
886
- const fallback_cfg = _isDict(ai['fallback']) ? (ai['fallback'] as Dict) : {};
887
- fallback_out.options = buildFallbackOptions({
888
- apiOnQuota: _pyBool(fallback_cfg['api_on_quota']),
889
- hasApiRung: (p) => _API_PROVIDERS.has(p),
890
- memberConfig: (p) => ((members_cfg[p] as Dict) || {}) as Dict,
891
- modelOverride: (p) => (overrides[p] as string | undefined) ?? null,
892
- constructApi: _construct_api_member,
893
- emit: appendEvent,
894
- });
895
- }
884
+ wireCouncilFallback(fallback_out, ai, {
885
+ repoRoot: REPO_ROOT, isDict: _isDict, membersCfg: members_cfg, overrides,
886
+ hasApiRung: (p) => _API_PROVIDERS.has(p),
887
+ constructApi: _construct_api_member, emit: appendEvent,
888
+ });
896
889
  return members;
897
890
  }
898
891
 
@@ -2668,6 +2661,7 @@ function cmd_run(
2668
2661
  quorum_out.result = post_run.quorum;
2669
2662
  skipped.push(...post_run.absent);
2670
2663
  recordRoundObservations(REPO_ROOT, members, responses, classifyCliFailure);
2664
+ printBillingGate(fallback_out.options?.parked ?? [], { repoRoot: REPO_ROOT, stdout: _stdout, stderr: _stderr });
2671
2665
  // Attendance telemetry for the reading that actually decided the pass.
2672
2666
  // `post_run.absent` is the mid-flight set only; the construction-time
2673
2667
  // skips already went out under `phase: 'pre_run'` from `build_members`,
@@ -3509,7 +3503,7 @@ interface Args {
3509
3503
  json: boolean;
3510
3504
  }
3511
3505
 
3512
- const _SUBCOMMANDS = ['estimate', 'run', 'debate', 'render', 'replay', 'quota', 'shadow-report', 'status'];
3506
+ const _SUBCOMMANDS = ['estimate', 'run', 'debate', 'render', 'replay', 'quota', 'shadow-report', 'status', ...BILLING_SUBCOMMANDS];
3513
3507
 
3514
3508
  const _TOP_USAGE =
3515
3509
  `usage: ${_PROG} [-h]\n` +
@@ -3698,7 +3692,7 @@ function _specsFor(cmd: string): { positionals: string[]; opts: OptSpec[]; requi
3698
3692
  requiredOpts: [],
3699
3693
  };
3700
3694
  default:
3701
- return { positionals: [], opts: [], requiredOpts: [] };
3695
+ return billingArgSpec(cmd) ?? { positionals: [], opts: [], requiredOpts: [] };
3702
3696
  }
3703
3697
  }
3704
3698
 
@@ -3902,6 +3896,8 @@ function main(argv: string[] | null = null): number {
3902
3896
  if (args.cmd === 'status') {
3903
3897
  return cmd_status(args);
3904
3898
  }
3899
+ const billing = handleBillingCommand(String(args.cmd), _getattr(args, 'run_id', null), { repoRoot: REPO_ROOT, stdout: _stdout, stderr: _stderr });
3900
+ if (billing !== null) return billing;
3905
3901
  } catch (exc) {
3906
3902
  if (exc instanceof CouncilDisabledError) {
3907
3903
  _stderr(`❌ council:${args.cmd}: ${exc.message}\n`);
@@ -111,6 +111,93 @@ export function scanHiddenUnicode(text: string): HiddenUnicodeFinding[] {
111
111
  }));
112
112
  }
113
113
 
114
+ /** One codepoint the strip considered, and what it decided. */
115
+ export interface CarrierRecord {
116
+ /** `U+XXXX` form, so a record is readable without decoding. */
117
+ codepoint: string;
118
+ /** The class `_classify` returned — never a second class list. */
119
+ cls: string;
120
+ /** Codepoint index (not UTF-16 index) in the input. */
121
+ offset: number;
122
+ disposition: "removed" | "preserved";
123
+ /** Why it was preserved. `null` when removed. */
124
+ reason: string | null;
125
+ }
126
+
127
+ export interface CarrierStripResult {
128
+ out: string;
129
+ removed: number;
130
+ preserved: number;
131
+ records: CarrierRecord[];
132
+ }
133
+
134
+ /**
135
+ * Remove hidden-Unicode carriers from OUTPUT prose — context-aware, opt-in.
136
+ *
137
+ * ## Why this is not `_sanitize`
138
+ *
139
+ * `lint_hidden_unicode._sanitize` drops every `_classify`-flagged codepoint
140
+ * unconditionally and then applies `NFKC`. That is correct for its own callers —
141
+ * it repairs a source FILE — and it is wrong for a deliverable, because
142
+ * `_ZERO_WIDTH` contains `0x200C` and `0x200D`. A blind pass therefore destroys
143
+ * emoji ZWJ sequences and complex-script joiners, which are legitimate content.
144
+ * This function keeps `_classify` as the single source of truth for *what is a
145
+ * candidate* and adds exactly one predicate for *whether to remove it*.
146
+ *
147
+ * ## The predicate
148
+ *
149
+ * A flagged codepoint is removed only when the codepoints on BOTH sides are
150
+ * ASCII (`< 0x80`) or absent. A carrier between two ASCII characters is doing
151
+ * no linguistic work; one adjacent to any non-ASCII character might be a
152
+ * joiner, so it stays.
153
+ *
154
+ * Deliberately conservative, and the asymmetry is the point: the failure mode
155
+ * is a preserved carrier, never a corrupted word. That makes this a HYGIENE
156
+ * pass and not a security control — the injection vector is covered on the
157
+ * INGESTION side by `scanHiddenUnicode`, whose contract is to warn and never
158
+ * strip, and which this function does not touch.
159
+ *
160
+ * Not a detector-evasion tool: it removes invisible characters and alters no
161
+ * visible prose. That exclusion is permanent.
162
+ */
163
+ export function stripCarrierUnicode(text: string): CarrierStripResult {
164
+ const cps = [...text].map((ch) => ch.codePointAt(0) ?? 0);
165
+ const kept: string[] = [];
166
+ const records: CarrierRecord[] = [];
167
+ let removed = 0;
168
+ let preserved = 0;
169
+
170
+ for (let i = 0; i < cps.length; i += 1) {
171
+ const cp = cps[i] as number;
172
+ const cls = _classify(cp);
173
+ if (cls === null) {
174
+ kept.push(String.fromCodePoint(cp));
175
+ continue;
176
+ }
177
+ // `undefined` at either end is "absent", which counts as ASCII-side: a
178
+ // carrier at a boundary has no neighbour to be joining.
179
+ const before = cps[i - 1];
180
+ const after = cps[i + 1];
181
+ const asciiSide = (n: number | undefined): boolean => n === undefined || n < 0x80;
182
+ const strip = asciiSide(before) && asciiSide(after);
183
+ records.push({
184
+ codepoint: `U+${cp.toString(16).toUpperCase().padStart(4, "0")}`,
185
+ cls,
186
+ offset: i,
187
+ disposition: strip ? "removed" : "preserved",
188
+ reason: strip ? null : "adjacent to a non-ASCII codepoint — may be a joiner",
189
+ });
190
+ if (strip) {
191
+ removed += 1;
192
+ } else {
193
+ preserved += 1;
194
+ kept.push(String.fromCodePoint(cp));
195
+ }
196
+ }
197
+
198
+ return { out: kept.join(""), removed, preserved, records };
199
+ }
200
+
114
201
  interface Thresholds {
115
202
  maxHard: number;
116
203
  maxScore: number;
@@ -698,6 +698,44 @@ function isoSeconds(d: Date): string {
698
698
  return d.toISOString().replace(/\.\d{3}Z$/, 'Z');
699
699
  }
700
700
 
701
+ /**
702
+ * The return-envelope POINTER — one line-shaped field, delivered on the
703
+ * dominant dispatch path.
704
+ *
705
+ * ## Why a pointer and not the contract
706
+ *
707
+ * 240 chars is not a round number: it is `MAX_RESPONSE_LINE_CHARS`
708
+ * (`_lib/subagent_response.ts`), so the pointer is one line-shaped field by the
709
+ * contract's own measure. Inlining the shape would put a fourth copy of it in
710
+ * the tree, and a fourth copy is how the contract came to have three
711
+ * inconsistent states.
712
+ *
713
+ * ## Why THIS dispatcher
714
+ *
715
+ * `road-to-subagent-envelope-adoption` step 1.1 says the dominant path, "and it
716
+ * does not fan out to both". The dominant path is **not answerable from the
717
+ * ledger** — 1,725 of 1,845 post-split stops carry a null `agent_type`, which is
718
+ * the 8 % start↔stop join rate recorded elsewhere, so no stop can be attributed
719
+ * to a dispatcher. Decided on invocation surface instead: this file is wired
720
+ * into `/create-pr` and read by three gates (`check_completion_review`,
721
+ * `lint_evidence_artifacts`, `probe_review_binding_drift`), while
722
+ * `team_dispatch` sits behind an ai_team host flag and one `/team delegate`
723
+ * command. It is also the leg with ZERO prior contract hits, so the dominant
724
+ * path and the absent one are the same file.
725
+ *
726
+ * ## What it does not change
727
+ *
728
+ * The findings table stays the artefact — the gates read the file, not the
729
+ * envelope. Per the four-boundary separation in the response contract, this
730
+ * touches the DELIVERY boundary only: what the final message is. It adds no
731
+ * lifecycle status and does not alter the body schema.
732
+ */
733
+ export const RETURN_ENVELOPE_POINTER =
734
+ 'Final message = the return envelope and nothing else: ' +
735
+ '{summary, handoff, confidence, findings, risks}. Shape + the ' +
736
+ 'write-to-disk-first rule: contexts/execution/subagent-response-contract.md. ' +
737
+ 'The findings table stays a file.';
738
+
701
739
  function reviewerPrompt(args: {
702
740
  slug: string;
703
741
  headSha: string;
@@ -781,6 +819,10 @@ function reviewerPrompt(args: {
781
819
  `**Honest-null:** 0 findings, scope ${args.scopeHash}, reviewed <YYYY-MM-DD>`,
782
820
  '```',
783
821
  '',
822
+ '## Return channel',
823
+ '',
824
+ RETURN_ENVELOPE_POINTER,
825
+ '',
784
826
  ].join('\n');
785
827
  }
786
828
 
@@ -62,7 +62,10 @@
62
62
  "\\bagent-reach\\b",
63
63
  "panniantong",
64
64
  "virgiliojr94",
65
- "book-to-skill"
65
+ "book-to-skill",
66
+ "project-codeguard",
67
+ "\\bcodeguard\\b",
68
+ "\\bcosai\\b"
66
69
  ],
67
70
  "skip_paths": [
68
71
  "src/scripts/check_no_external_sources.py",
@@ -268,6 +268,45 @@ function _getStr(d: Dict, key: string, dflt: string): string {
268
268
  return String(v);
269
269
  }
270
270
 
271
+ /**
272
+ * What the runtime hooks actually DO, in four states and no others.
273
+ *
274
+ * The four: `detects` (it looks and reports nothing a host reads), `warns` (it
275
+ * reports through the host, and the host may ignore it), `blocks` (it can
276
+ * refuse), and `not-enforceable-on-host` (the slot does not exist there).
277
+ *
278
+ * WHY THIS BLOCK EXISTS. `CAPABILITIES.yaml` carried no prompt-injection entry
279
+ * at all, and saying nothing is a different defect from saying too much: a
280
+ * reader looking for injection coverage found absence, which reads as "not
281
+ * covered" rather than as "a warn-only scanner exists and ships off". This is
282
+ * the constructive half of a source proposal whose other half — "remove claims
283
+ * that equate a warn-hook with prevention" — had nothing to remove.
284
+ *
285
+ * `blocks` is never legal for the injection scanner on ANY host, and the value
286
+ * is DERIVED from `hook_manifest.yaml` rather than typed here: `fail_closed:
287
+ * false` plus `severity: advisory` is what makes it `warns`, so a manifest edit
288
+ * that gave it teeth would have to change this row too. A hand-written `blocks`
289
+ * would be the coverage inflation `untrusted-input-defense` refuses, one file
290
+ * over.
291
+ */
292
+ function _runtime_enforcement_lines(): string[] {
293
+ return [
294
+ 'runtime_enforcement:',
295
+ ' # Four states, and no others: detects | warns | blocks | not-enforceable-on-host.',
296
+ ' # Derived from src/scripts/hook_manifest.yaml (fail_closed + severity), never',
297
+ ' # hand-written — a manifest row that gained teeth would have to change this one.',
298
+ ' - hook: injection-scan',
299
+ ' slot: post_tool_use',
300
+ ' state: warns',
301
+ ' default: off',
302
+ ' blocks_legal_on_any_host: false',
303
+ ' reason: ' +
304
+ _scalar(
305
+ 'fail_closed: false + severity: advisory in hook_manifest.yaml — it reports through the host and cannot refuse, so it warns and never blocks. Ships enabled: false; the detector was measured at 99.00% recall and a 0.85% false-positive rate over internal/bench/corpora/encoding-channels/ before any default flip is even discussable.<!-- claim:injection-scan-corpus-rates -->',
306
+ ),
307
+ ];
308
+ }
309
+
271
310
  export function build(): string {
272
311
  const packs = _load_packs();
273
312
  const skillMap = _skill_packs();
@@ -360,6 +399,8 @@ export function build(): string {
360
399
  }
361
400
  }
362
401
  lines.push('');
402
+ lines.push(..._runtime_enforcement_lines());
403
+ lines.push('');
363
404
  lines.push('gaps:');
364
405
  if (gaps.length > 0) {
365
406
  const sortedGaps = [...gaps].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
@@ -153,6 +153,19 @@ export type GitOp =
153
153
  | "branch"
154
154
  | "pr-create"
155
155
  | "pr-merge"
156
+ /**
157
+ * Enabling auto-merge. Its OWN op, not a plain merge and not nothing.
158
+ *
159
+ * Irreversible in the same sense a merge is: it commits the outcome to a
160
+ * condition the agent does not control, and the merge then happens with
161
+ * nobody in the loop. Before this split, `gh pr merge --auto` classified
162
+ * identically to a plain merge (the pattern is `\bgh\s+pr\s+merge\b`, and
163
+ * `--auto` is invisible to it), and the GraphQL mutation classified as
164
+ * NOTHING — `ghApiWrite()` needs a REST path and a write method, and
165
+ * `gh api graphql -f query='mutation{enablePullRequestAutoMerge...}'` has
166
+ * neither.
167
+ */
168
+ | "pr-merge-auto"
156
169
  | "tag"
157
170
  | "release"
158
171
  | "publish";
@@ -163,6 +176,7 @@ export const ALL_OPS: readonly GitOp[] = [
163
176
  "branch",
164
177
  "pr-create",
165
178
  "pr-merge",
179
+ "pr-merge-auto",
166
180
  "tag",
167
181
  "release",
168
182
  "publish",
@@ -185,8 +199,29 @@ const PHRASES: ReadonlyArray<{ op: GitOp; re: RegExp }> = [
185
199
  op: "pr-create",
186
200
  re: /\b(erstell(e|en)?|mach(e|en)?|leg(e|en)?\s+an|(er)?(ö|oe)ffne|open|create|raise|aufmachen)\b[^.\n]{0,30}\b(pr|pull[- ]request)\b|\b(pr|pull[- ]request)\b[^.\n]{0,20}\b(erstellen|aufmachen|anlegen|(er)?(ö|oe)ffnen)\b/i,
187
201
  },
188
- // `merge` as an ACTION, never as the noun in "merge conflict" / "merge commit".
189
- { op: "pr-merge", re: /\b(merge|merg(e|en|st|t)|zusammenf(ü|ue)hren|reinmergen)\b(?!\s*[- ]?(conflict|konflikt|commit|base|queue|state|status))/i },
202
+ // `merge` as an ACTION, never as the noun in "merge conflict" / "merge commit",
203
+ // and never under a NEGATION.
204
+ //
205
+ // The negation half closes a measured defect: probed directly against
206
+ // `classifyAuthorization`, "nicht mergen", "don't merge this" and
207
+ // "never auto-merge" each returned ["pr-merge"] — a prompt saying DO NOT
208
+ // MERGE authorized a merge. The noun-sense lookahead was never a negator.
209
+ //
210
+ // The lookBEHIND is line-scoped by `[^.!?\n]` on purpose, the same scoping
211
+ // `turn_end_gate_hook.ts` uses for its own negation exclusion (`:330`) and
212
+ // its negated-claim window (`:490-493`). A sentence boundary ends a
213
+ // negation's reach: "Do not push. Merge PR #12." is two instructions, and a
214
+ // guard that let the first suppress the second would silently stop
215
+ // authorizing merges the user DID order — the failure mode that is worse
216
+ // than the defect, because nothing happens and nothing says why.
217
+ //
218
+ // Reusing that vocabulary rather than inventing a third is deliberate: two
219
+ // negation vocabularies in one tree drift, and the drift is invisible until
220
+ // a prompt lands in the gap between them.
221
+ {
222
+ op: "pr-merge",
223
+ re: /(?<!\b(nicht|nichts|kein(e|en|em|er|es)?|niemals|nie|no|not|dont|don't|never|without|ohne)\b[^.!?\n]{0,30})\b(merge|merg(e|en|st|t)|zusammenf(ü|ue)hren|reinmergen)\b(?!\s*[- ]?(conflict|konflikt|commit|base|queue|state|status))/i,
224
+ },
190
225
  // A tag is an ACTION here — bare "Tag" is the German word for day, and
191
226
  // "Version" is an ordinary noun. Both authorized a BLOCK op before this.
192
227
  { op: "tag", re: /\b(tagge(n|st)?|tag\s+(setzen|anlegen|erstellen)|git\s+tag|--tags|--follow-tags)\b/i },
@@ -267,10 +302,20 @@ export function isAffirmative(prose: string): boolean {
267
302
  }
268
303
 
269
304
  /** Executable commands a user may paste, mapped to the op they authorize. */
270
- const PASTED_COMMANDS: ReadonlyArray<{ op: GitOp; re: RegExp }> = [
305
+ const PASTED_COMMANDS: ReadonlyArray<{ op: GitOp | null; re: RegExp }> = [
271
306
  { op: "publish", re: /\bnpm\s+publish\b/i },
272
307
  { op: "tag", re: /\bgit\s+push\s+[^\n]*--tags\b|\bgit\s+tag\s+-a\b/i },
273
308
  { op: "release", re: /\bgh\s+release\s+create\b/i },
309
+ // ORDER IS LOAD-BEARING: the auto variants precede the plain merge, because
310
+ // the first match wins and `gh pr merge 12 --auto` also matches the plain
311
+ // pattern. Same rule the block-side table states for `git push --tags`.
312
+ //
313
+ // The DE-ESCALATING forms come first of all, and they map to no op at all:
314
+ // `--disable-auto` and `disablePullRequestAutoMerge` turn the capability OFF.
315
+ // Requiring merge authorization to switch auto-merge off is a live deadlock,
316
+ // and it is why this cannot ship after the split rather than with it.
317
+ { op: null, re: /\bgh\s+pr\s+merge\b[^\n]*--disable-auto\b|\bdisablePullRequestAutoMerge\b/i },
318
+ { op: "pr-merge-auto", re: /\bgh\s+pr\s+merge\b[^\n]*--auto\b|\benablePullRequestAutoMerge\b/i },
274
319
  { op: "pr-merge", re: /\bgh\s+pr\s+merge\b/i },
275
320
  { op: "pr-create", re: /\bgh\s+pr\s+create\b/i },
276
321
  { op: "push", re: /\bgit\s+push\b/i },
@@ -355,10 +400,15 @@ export function classifyAuthorization(prompt: string): {
355
400
  }
356
401
  for (const line of _commandLines(fence)) {
357
402
  for (const { op, re } of PASTED_COMMANDS) {
358
- if (re.test(line)) {
403
+ if (!re.test(line)) continue;
404
+ // FIRST MATCH WINS, and the table is ordered most-specific-first. A
405
+ // de-escalating form carries `op: null` — it matched, so no later
406
+ // pattern may claim the line, and it authorizes nothing.
407
+ if (op !== null) {
359
408
  authorized.add(op);
360
409
  evidence[op] = `pasted command: "${line.slice(0, 80)}"`;
361
410
  }
411
+ break;
362
412
  }
363
413
  }
364
414
  }