@event4u/agent-config 15.0.0 → 16.1.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 (283) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +301 -347
  5. package/CONTRIBUTING.md +9 -0
  6. package/README.md +2 -2
  7. package/dist/agent-src/commands/analyze/inbox.md +26 -0
  8. package/dist/agent-src/commands/analyze/roadmap-repos.md +27 -0
  9. package/dist/agent-src/commands/challenge-me/closure.md +178 -0
  10. package/dist/agent-src/commands/challenge-me.md +9 -2
  11. package/dist/agent-src/commands/council/default.md +25 -5
  12. package/dist/agent-src/commands/feature/plan.md +28 -1
  13. package/dist/agent-src/commands/feature/roadmap.md +28 -1
  14. package/dist/agent-src/commands/implement-ticket.md +27 -0
  15. package/dist/agent-src/commands/jira-ticket.md +27 -0
  16. package/dist/agent-src/commands/pr/create.md +29 -0
  17. package/dist/agent-src/commands/pr/merge.md +60 -9
  18. package/dist/agent-src/commands/roadmap/ai-council.md +7 -1
  19. package/dist/agent-src/commands/roadmap/create.md +77 -29
  20. package/dist/agent-src/commands/roadmap/materialize.md +27 -0
  21. package/dist/agent-src/commands/roadmap/process-full.md +123 -18
  22. package/dist/agent-src/commands/team.md +22 -0
  23. package/dist/agent-src/contexts/authority/scope-growth-ownership.md +80 -0
  24. package/dist/agent-src/contexts/communication/rules-auto/user-interaction-mechanics.md +97 -0
  25. package/dist/agent-src/contexts/execution/auto-orchestration-activation.md +44 -3
  26. package/dist/agent-src/contexts/execution/autonomy-mechanics.md +15 -15
  27. package/dist/agent-src/contexts/execution/decision-closure-execution.md +93 -0
  28. package/dist/agent-src/contexts/execution/fix-loop-ladder.md +108 -0
  29. package/dist/agent-src/contexts/execution/plan-confidence-gate.md +4 -2
  30. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +75 -6
  31. package/dist/agent-src/guidelines/agent-infra/active-remediation-mechanics.md +57 -0
  32. package/dist/agent-src/guidelines/agent-infra/evaluator-independence-mechanics.md +102 -0
  33. package/dist/agent-src/guidelines/design-fidelity-routing.md +29 -0
  34. package/dist/agent-src/rules/autonomous-execution.md +15 -8
  35. package/dist/agent-src/rules/delegation-policy.md +23 -45
  36. package/dist/agent-src/rules/design-fidelity.md +32 -37
  37. package/dist/agent-src/rules/evaluator-independence.md +43 -49
  38. package/dist/agent-src/rules/test-first.md +41 -0
  39. package/dist/agent-src/rules/user-interaction.md +31 -30
  40. package/dist/agent-src/skills/accessibility-auditor/data/manifest.json +3 -3
  41. package/dist/agent-src/skills/ai-council/SKILL.md +11 -3
  42. package/dist/agent-src/skills/api-design/data/manifest.json +3 -3
  43. package/dist/agent-src/skills/database/data/manifest.json +4 -4
  44. package/dist/agent-src/skills/database/data/query-tuning.csv +3 -3
  45. package/dist/agent-src/skills/design-review/SKILL.md +50 -0
  46. package/dist/agent-src/skills/error-handling-patterns/SKILL.md +9 -0
  47. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +14 -4
  48. package/dist/agent-src/skills/experiment-loop/SKILL.md +31 -1
  49. package/dist/agent-src/skills/roadmap-writing/SKILL.md +1 -1
  50. package/dist/agent-src/skills/storybook-workshop/SKILL.md +42 -3
  51. package/dist/agent-src/skills/threat-modeling/SKILL.md +9 -4
  52. package/dist/agent-src/skills/threat-modeling/data/manifest.json +4 -4
  53. package/dist/agent-src/skills/verify-repair-loop/SKILL.md +37 -3
  54. package/dist/agent-src/templates/agent-settings.md +1 -1
  55. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  56. package/dist/agent-src/templates/roadmaps.md +188 -0
  57. package/dist/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +62 -2
  58. package/dist/agent-src/templates/scripts/work_engine/directives/backend/test.ts +1 -0
  59. package/dist/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +1 -0
  60. package/dist/cli-delegate/check_update_banner.js +40 -2
  61. package/dist/cli-delegate/chunk-4E2W63DA.js +335 -0
  62. package/dist/cli-delegate/chunk-5R7HOLAF.js +99 -0
  63. package/dist/cli-delegate/{chunk-IVBPRCQO.js → chunk-6KFEC3G7.js} +1 -1
  64. package/dist/cli-delegate/{chunk-2TAQ2K4V.js → chunk-6KM6ORRY.js} +55 -386
  65. package/dist/cli-delegate/{chunk-2XY7ILBT.js → chunk-FC75PGJE.js} +42 -2
  66. package/dist/cli-delegate/{chunk-GYC3VRYN.js → chunk-FXYUYU4N.js} +57 -13
  67. package/dist/cli-delegate/{chunk-55ACLDUE.js → chunk-GJCBB2UG.js} +168 -10
  68. package/dist/cli-delegate/{chunk-AK6HODNQ.js → chunk-HDVVGPQW.js} +873 -539
  69. package/dist/cli-delegate/chunk-NUHLI2RX.js +80 -0
  70. package/dist/cli-delegate/{chunk-HZ52BRGG.js → chunk-P6ZHP5W2.js} +15 -90
  71. package/dist/cli-delegate/{chunk-TYSY33MD.js → chunk-QJWAUMJW.js} +12 -2
  72. package/dist/cli-delegate/{chunk-57PN4GYJ.js → chunk-RGNIWTEA.js} +1 -1
  73. package/dist/cli-delegate/chunk-ROYYTEZU.js +39 -0
  74. package/dist/cli-delegate/{chunk-2KKXJW25.js → chunk-S3VFXMTM.js} +1 -1
  75. package/dist/cli-delegate/{chunk-3FKR64R5.js → chunk-UNCRJBT3.js} +31 -7
  76. package/dist/cli-delegate/{chunk-2DK2VJWG.js → chunk-WGXTVKCQ.js} +20 -6
  77. package/dist/cli-delegate/chunk-YDPILCOL.js +152 -0
  78. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  79. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  80. package/dist/cli-delegate/cmd_conformance.js +918 -325
  81. package/dist/cli-delegate/cmd_doctor.js +7 -5
  82. package/dist/cli-delegate/cmd_explain.js +5 -3
  83. package/dist/cli-delegate/cmd_export.js +9 -7
  84. package/dist/cli-delegate/cmd_fleet.js +4 -2
  85. package/dist/cli-delegate/cmd_handoff.js +6 -6
  86. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  87. package/dist/cli-delegate/cmd_migrate.js +2 -2
  88. package/dist/cli-delegate/cmd_packs_active.js +3 -2
  89. package/dist/cli-delegate/cmd_preflight.js +4 -2
  90. package/dist/cli-delegate/cmd_prune.js +10 -8
  91. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  92. package/dist/cli-delegate/cmd_route_explain.js +1 -1
  93. package/dist/cli-delegate/cmd_session_recycle.js +4 -4
  94. package/dist/cli-delegate/cmd_settings_get.js +28 -2
  95. package/dist/cli-delegate/cmd_settings_migrate.js +9 -7
  96. package/dist/cli-delegate/cmd_settings_set.js +14 -1
  97. package/dist/cli-delegate/cmd_sync.js +10 -8
  98. package/dist/cli-delegate/cmd_uninstall.js +16 -13
  99. package/dist/cli-delegate/cmd_update.js +1 -1
  100. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  101. package/dist/cli-delegate/cmd_validate.js +12 -10
  102. package/dist/cli-delegate/cmd_versions.js +1 -1
  103. package/dist/discovery/deprecation-report.md +1 -1
  104. package/dist/discovery/discovery-manifest.json +87 -40
  105. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  106. package/dist/discovery/discovery-manifest.summary.md +4 -4
  107. package/dist/discovery/dormancy-report.md +1 -1
  108. package/dist/discovery/orphan-report.md +1 -1
  109. package/dist/discovery/packs.json +10 -8
  110. package/dist/discovery/trust-report.md +3 -3
  111. package/dist/discovery/workspaces.json +8 -6
  112. package/dist/hooks/dispatch.js +2601 -1073
  113. package/dist/install/claudeRuleRewrite.js +14 -3
  114. package/dist/install/conflict.js +60 -10
  115. package/dist/install/conflictTracker.js +218 -0
  116. package/dist/install/fsPrimitives.js +54 -0
  117. package/dist/install/install.mjs +2008 -1481
  118. package/dist/install/manifestFiles.js +259 -0
  119. package/dist/install/preflight.js +14 -2
  120. package/dist/install/preserve.js +157 -0
  121. package/dist/install/recordedOwnership.js +132 -0
  122. package/dist/install/txlog.js +11 -2
  123. package/dist/mcp/registry-manifest.json +3 -3
  124. package/dist/mcp/server.json +2 -2
  125. package/dist/mcp/server.mjs +1612 -667
  126. package/dist/router.json +1 -1
  127. package/dist/scripts/_lib/agent_settings.js +3 -2
  128. package/dist/scripts/_lib/fs_atomic.js +167 -0
  129. package/dist/scripts/_lib/installed_lock.js +1 -1
  130. package/dist/scripts/_lib/installed_tools.js +460 -0
  131. package/dist/scripts/_lib/lean_projection_mode.js +3 -2
  132. package/dist/scripts/_lib/settings_renamed_keys.js +78 -0
  133. package/dist/server/io/yamlIO.js +50 -0
  134. package/dist/server/routes/install.js +8 -0
  135. package/dist/server/routes/wizard.js +9 -3
  136. package/dist/server/schemas/settings.js +17 -5
  137. package/dist/shared/missionExecution.js +97 -0
  138. package/dist/ui/assets/{index-D0jaCRPM.js → index-DH1xc62v.js} +17 -17
  139. package/dist/ui/index.html +1 -1
  140. package/docs/MIGRATION.md +103 -0
  141. package/docs/contracts/settings-classes.md +26 -7
  142. package/docs/guidelines/agent-infra/active-remediation-mechanics.md +57 -0
  143. package/docs/guidelines/agent-infra/evaluator-independence-mechanics.md +102 -0
  144. package/docs/guidelines/design-fidelity-routing.md +29 -0
  145. package/package.json +1 -1
  146. package/src/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +62 -2
  147. package/src/agent-src/templates/scripts/work_engine/directives/backend/test.ts +1 -0
  148. package/src/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +1 -0
  149. package/src/config/agent-settings.template.yml +139 -27
  150. package/src/config/auto-rule-budget.json +16 -0
  151. package/src/config/ci-local-parity.yml +18 -5
  152. package/src/config/continuity-surface.json +28 -0
  153. package/src/config/gate-coverage.yml +146 -13
  154. package/src/config/gate-violation-baselines.json +6 -6
  155. package/src/config/hook-token-budget.json +2 -0
  156. package/src/config/host-injection-effect.json +1 -1
  157. package/src/config/loop-surfaces.yaml +215 -0
  158. package/src/config/platform-anchor.json +3 -2
  159. package/src/config/preamble-payload-budget.json +12 -7
  160. package/src/config/preamble-payload-exceptions.json +28 -0
  161. package/src/config/release-gate-locality.yml +11 -0
  162. package/src/config/routing-coverage-seed.json +18 -4
  163. package/src/config/rule-enforcement-baseline.json +0 -1
  164. package/src/install/claudeRuleRewrite.ts +16 -2
  165. package/src/install/conflict.ts +65 -9
  166. package/src/install/conflictTracker.ts +299 -0
  167. package/src/install/fsPrimitives.ts +54 -0
  168. package/src/install/manifestFiles.ts +312 -0
  169. package/src/install/preflight.ts +16 -2
  170. package/src/install/preserve.ts +205 -0
  171. package/src/install/recordedOwnership.ts +149 -0
  172. package/src/install/txlog.ts +11 -2
  173. package/src/install/types.ts +19 -4
  174. package/src/scripts/_cli/cmd_conformance.ts +88 -15
  175. package/src/scripts/_cli/cmd_doctor.ts +4 -3
  176. package/src/scripts/_cli/cmd_route_explain.ts +9 -2
  177. package/src/scripts/_cli/cmd_settings_get.ts +44 -0
  178. package/src/scripts/_cli/doctor_execution.ts +90 -0
  179. package/src/scripts/_dispatch.bash +3 -1
  180. package/src/scripts/_lib/activation_payload.ts +247 -0
  181. package/src/scripts/_lib/agent_settings.ts +4 -2
  182. package/src/scripts/_lib/authority_path.ts +102 -0
  183. package/src/scripts/_lib/base_ref_payload.ts +315 -0
  184. package/src/scripts/_lib/cascade_base.ts +94 -0
  185. package/src/scripts/_lib/changelog_eras.ts +7 -2
  186. package/src/scripts/_lib/continuation_ladder.ts +149 -2
  187. package/src/scripts/_lib/council_transport.ts +129 -0
  188. package/src/scripts/_lib/delivery_ready.ts +112 -0
  189. package/src/scripts/_lib/dropped_decision.ts +142 -0
  190. package/src/scripts/_lib/forge_protection.ts +267 -0
  191. package/src/scripts/_lib/hook_platform_keys.ts +27 -0
  192. package/src/scripts/_lib/installed_lock.ts +1 -1
  193. package/src/scripts/_lib/json_python_parity.ts +127 -0
  194. package/src/scripts/_lib/lean_projection_mode.ts +14 -3
  195. package/src/scripts/_lib/loop_surfaces.ts +382 -0
  196. package/src/scripts/_lib/measured_payload_ceiling.ts +424 -0
  197. package/src/scripts/_lib/mission_record.ts +190 -0
  198. package/src/scripts/_lib/obligation_frequency.ts +217 -0
  199. package/src/scripts/_lib/obligations.ts +402 -0
  200. package/src/scripts/_lib/orchestration_record.ts +11 -20
  201. package/src/scripts/_lib/payload_catalogue_completeness.ts +211 -0
  202. package/src/scripts/_lib/release_findings_ingest.ts +324 -0
  203. package/src/scripts/_lib/release_highlights.ts +199 -10
  204. package/src/scripts/_lib/release_holds.ts +493 -0
  205. package/src/scripts/_lib/release_migration_gate.ts +184 -0
  206. package/src/scripts/_lib/release_tag_dispatch.ts +63 -0
  207. package/src/scripts/_lib/review_baseline.ts +254 -0
  208. package/src/scripts/_lib/review_skipped_record.ts +61 -1
  209. package/src/scripts/_lib/rides_along.ts +102 -0
  210. package/src/scripts/_lib/settings_renamed_keys.ts +82 -0
  211. package/src/scripts/_lib/standing_bound_ratchet.ts +216 -103
  212. package/src/scripts/_lib/test_delta.ts +166 -0
  213. package/src/scripts/_lib/test_provenance.ts +171 -0
  214. package/src/scripts/_lib/turn_end_refusals.ts +51 -4
  215. package/src/scripts/_lib/typed_op_grant.ts +246 -0
  216. package/src/scripts/_lib/typed_op_watch.ts +185 -0
  217. package/src/scripts/ai_council/config.ts +8 -11
  218. package/src/scripts/ai_council/fallback_config.ts +5 -3
  219. package/src/scripts/ai_council/ownership_classes.ts +118 -0
  220. package/src/scripts/ask_block_census.ts +285 -15
  221. package/src/scripts/check_beta_review_markers.ts +35 -4
  222. package/src/scripts/check_enforcement_matrix.ts +624 -0
  223. package/src/scripts/check_finding_dispositions.ts +167 -16
  224. package/src/scripts/check_gate_reachability.ts +173 -10
  225. package/src/scripts/check_generator_sync.ts +954 -0
  226. package/src/scripts/check_held_object_arrivals.ts +381 -0
  227. package/src/scripts/check_preamble_payload_budget.ts +474 -124
  228. package/src/scripts/check_release_highlights.ts +33 -1
  229. package/src/scripts/check_release_holds.ts +567 -0
  230. package/src/scripts/check_release_published.ts +1 -1
  231. package/src/scripts/check_reply_consistency.ts +86 -17
  232. package/src/scripts/check_rule_projection_integrity.ts +22 -12
  233. package/src/scripts/check_test_delta.ts +294 -0
  234. package/src/scripts/check_test_weakening.ts +299 -0
  235. package/src/scripts/closure_scan.ts +333 -0
  236. package/src/scripts/compile_hook_manifest.ts +43 -3
  237. package/src/scripts/council_record_shape.ts +209 -0
  238. package/src/scripts/hook_manifest.json +1 -1
  239. package/src/scripts/hook_manifest.yaml +127 -12
  240. package/src/scripts/hooks/block_kernel_rule_writes.ts +12 -2
  241. package/src/scripts/hooks/concern_registry.ts +6 -0
  242. package/src/scripts/hooks/design_pass_hook.ts +128 -3
  243. package/src/scripts/hooks/end_review_nudge_hook.ts +101 -21
  244. package/src/scripts/hooks/evidence_independence.ts +96 -0
  245. package/src/scripts/hooks/memory_recall_hook.ts +540 -0
  246. package/src/scripts/hooks/obligation_settle_hook.ts +182 -0
  247. package/src/scripts/hooks/review_baseline_hook.ts +130 -0
  248. package/src/scripts/hooks/rule_inject_hook.ts +49 -1
  249. package/src/scripts/hooks/run_continuation_hook.ts +17 -20
  250. package/src/scripts/hooks/turn_end_gate_hook.ts +232 -14
  251. package/src/scripts/hooks_doctor.ts +89 -1
  252. package/src/scripts/hooks_status.ts +19 -0
  253. package/src/scripts/install.ts +107 -207
  254. package/src/scripts/interruption_report.ts +8 -2
  255. package/src/scripts/janitor.ts +15 -1
  256. package/src/scripts/lint_decision_classes.ts +464 -0
  257. package/src/scripts/lint_hook_manifest.ts +11 -0
  258. package/src/scripts/lint_major_migration_sections.ts +450 -0
  259. package/src/scripts/lint_plan_risk_register.ts +47 -3
  260. package/src/scripts/lint_roadmap_blockers.ts +61 -0
  261. package/src/scripts/lint_roadmap_producers.ts +202 -0
  262. package/src/scripts/measure_rule_budget.ts +101 -5
  263. package/src/scripts/measure_turn_end_gate.ts +302 -5
  264. package/src/scripts/new_roadmap.ts +37 -0
  265. package/src/scripts/orchestration_record.ts +0 -5
  266. package/src/scripts/release.ts +66 -162
  267. package/src/scripts/release_drill.ts +460 -15
  268. package/src/scripts/release_env.ts +1 -1
  269. package/src/scripts/release_publication.ts +52 -1
  270. package/src/scripts/report_enforcement_drift.ts +566 -0
  271. package/src/scripts/report_held_object_arrivals.ts +456 -0
  272. package/src/scripts/report_host_injection_effect.ts +3 -11
  273. package/src/scripts/report_standing_payload_by_host.ts +26 -152
  274. package/src/scripts/schemas/agent-settings.schema.json +23 -0
  275. package/src/scripts/schemas/command.schema.json +4 -0
  276. package/src/scripts/scope_growth.ts +219 -0
  277. package/src/scripts/sync_agent_settings.ts +140 -18
  278. package/src/scripts/trigger_eval_grandfather.json +0 -1
  279. package/src/scripts/ui_conformance_probe.ts +622 -0
  280. package/src/server/io/yamlIO.ts +48 -0
  281. package/src/server/schemas/settings.ts +31 -7
  282. package/src/shared/missionExecution.ts +126 -0
  283. package/dist/cli-delegate/chunk-523HCFYO.js +0 -90
@@ -49,13 +49,20 @@ import * as path from 'node:path';
49
49
  import { fileURLToPath, pathToFileURL } from 'node:url';
50
50
 
51
51
  import {
52
- loadRouter,
53
- matchTierRules,
54
- pathOnlyRuleIds,
55
- selectForInjection,
56
- } from './_lib/rule_injection.js';
52
+ corpusShapeProblems,
53
+ slotFireSizes as sampleSlotFireSizes,
54
+ type SlotFireSizes,
55
+ } from './_lib/activation_payload.js';
56
+ import { loadRouter, pathOnlyRuleIds } from './_lib/rule_injection.js';
57
57
  import { CAP_BYTES } from './hooks/rule_inject_hook.js';
58
58
 
59
+ // Re-exported rather than redefined: this module was their only home until the
60
+ // payload gate needed the same sample, and two copies is how two numbers start
61
+ // disagreeing. `corpusShapeProblems` and `SlotFireSizes` are imported above
62
+ // because this module's own code uses them; these two it only passes through.
63
+ export { percentile, readCorpusPositives } from './_lib/activation_payload.js';
64
+ export { corpusShapeProblems, type SlotFireSizes };
65
+
59
66
  const _HERE = fileURLToPath(import.meta.url);
60
67
  export const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
61
68
  export const ARTIFACT_REL = path.join(
@@ -149,8 +156,13 @@ export const HOST_SURFACES: readonly HostSurface[] = [
149
156
  },
150
157
  {
151
158
  host: 'codex', surface: '.codex/agent-config.md', perRuleTree: false,
152
- writer: 'src/scripts/install.ts:1541',
153
- anchor: '\'agent-config.md\'',
159
+ writer: 'src/scripts/install.ts:1432',
160
+ // Scoped to `.codex` rather than the bare filename: nine other lines in
161
+ // `install.ts` write an `agent-config.md` for some other host, so the
162
+ // unscoped anchor would have accepted a drift onto any of them — the
163
+ // bounds-test-wearing-a-provenance-test's-name failure this field exists
164
+ // to close, one level down.
165
+ anchor: '\'.codex\', \'agent-config.md\'',
154
166
  note: 'written by the installer, not by `condense`; absent in this checkout',
155
167
  },
156
168
  ];
@@ -431,155 +443,17 @@ export function renderArtifact(root: string, pin: string): string {
431
443
  return L.join('\n');
432
444
  }
433
445
 
434
- /** One positive prompt of the frozen corpus, with the open files it declares. */
435
- interface CorpusPrompt {
436
- prompt: string;
437
- openFiles: string[] | null;
438
- }
439
-
440
446
  /**
441
- * Every YAML shape inside `positives:` this hand parser cannot read (R2 finding 10).
447
+ * The activation-charge sample at the concern's own cap.
442
448
  *
443
- * `readCorpusPositives` accepts only `- prompt: "…"` with a double-quoted
444
- * same-line scalar and an inline `open_files: [...]`. Any other legal shape —
445
- * single quotes, an unquoted scalar, a folded block, a block sequence for
446
- * `open_files` — was SILENTLY SKIPPED, which drops fires out of the
447
- * distribution the 16,384-byte `user_prompt_submit` row is derived from. A
448
- * measurement that governs a budget must fail loudly on a shape it cannot read
449
- * rather than under-count, so the CLI refuses on a non-empty result here.
450
- *
451
- * Reported as a shape problem rather than fixed by widening the parser: this
452
- * script must not grow a second YAML implementation, and the corpus is frozen,
453
- * so the honest answer to an unreadable shape is to say which file carries it.
454
- */
455
- export function corpusShapeProblems(root: string): string[] {
456
- const dir = path.join(root, 'tests', 'eval', 'routing-matrix');
457
- const problems: string[] = [];
458
- if (!fs.existsSync(dir)) return problems;
459
- for (const f of fs.readdirSync(dir).filter((n) => n.endsWith('.yaml')).sort()) {
460
- let inPositives = false;
461
- let lineNo = 0;
462
- for (const raw of fs.readFileSync(path.join(dir, f), 'utf-8').split('\n')) {
463
- lineNo += 1;
464
- if (/^positives:/u.test(raw)) { inPositives = true; continue; }
465
- if (/^near_misses:/u.test(raw)) { inPositives = false; continue; }
466
- if (!inPositives) continue;
467
- if (/^\s*-\s*prompt:/u.test(raw) && !/^\s*-\s*prompt:\s*"(.*)"\s*$/u.test(raw)) {
468
- problems.push(
469
- `${f}:${String(lineNo)}: a \`- prompt:\` this parser cannot read — only a ` +
470
- `double-quoted same-line scalar is accepted: ${JSON.stringify(raw.trim())}`,
471
- );
472
- continue;
473
- }
474
- if (/^\s*-\s*/u.test(raw) && !/^\s*-\s*(prompt|command|open_files):/u.test(raw)
475
- && raw.trim() !== '-' && raw.trim() !== '') {
476
- // A bare sequence item under `positives:` is a shape with no
477
- // `prompt:` key on its first line — folded or nested.
478
- if (!/^\s{4,}/u.test(raw)) {
479
- problems.push(
480
- `${f}:${String(lineNo)}: a positives entry whose first line carries no ` +
481
- `\`prompt:\` key: ${JSON.stringify(raw.trim())}`,
482
- );
483
- }
484
- }
485
- if (/^\s*open_files:\s*$/u.test(raw)) {
486
- problems.push(
487
- `${f}:${String(lineNo)}: a BLOCK-sequence \`open_files:\` — only the inline ` +
488
- '`[...]` form is read, so this entry\'s files would be dropped',
489
- );
490
- }
491
- }
492
- }
493
- return problems;
494
- }
495
-
496
- /** Every positive prompt in the frozen routing-matrix corpus. Near-misses are not fires. */
497
- export function readCorpusPositives(root: string): CorpusPrompt[] {
498
- const dir = path.join(root, 'tests', 'eval', 'routing-matrix');
499
- const out: CorpusPrompt[] = [];
500
- if (!fs.existsSync(dir)) return out;
501
- for (const f of fs.readdirSync(dir).filter((n) => n.endsWith('.yaml')).sort()) {
502
- let inPositives = false;
503
- let cur: CorpusPrompt | null = null;
504
- for (const raw of fs.readFileSync(path.join(dir, f), 'utf-8').split('\n')) {
505
- if (/^positives:/u.test(raw)) { inPositives = true; continue; }
506
- if (/^near_misses:/u.test(raw)) { inPositives = false; continue; }
507
- if (!inPositives) continue;
508
- const p = /^\s*-\s*prompt:\s*"(.*)"\s*$/u.exec(raw);
509
- if (p !== null) {
510
- cur = { prompt: p[1] as string, openFiles: null };
511
- out.push(cur);
512
- continue;
513
- }
514
- const of = /^\s*open_files:\s*\[(.*)\]\s*$/u.exec(raw);
515
- if (of !== null && cur !== null) {
516
- cur.openFiles = (of[1] as string)
517
- .split(',')
518
- .map((x) => x.trim().replace(/^["']|["']$/gu, ''))
519
- .filter((x) => x !== '');
520
- }
521
- }
522
- }
523
- return out;
524
- }
525
-
526
- /** Nearest-rank percentile over an unsorted sample. Empty sample → 0. */
527
- export function percentile(xs: readonly number[], q: number): number {
528
- if (xs.length === 0) return 0;
529
- const s = [...xs].sort((a, b) => a - b);
530
- return s[Math.min(s.length - 1, Math.floor(q * (s.length - 1)))] as number;
531
- }
532
-
533
- export interface SlotFireSizes {
534
- readonly slot: string;
535
- readonly fires: number;
536
- readonly p50: number;
537
- readonly p90: number;
538
- readonly max: number;
539
- }
540
-
541
- /**
542
- * Gate-open per-fire emission sizes for the delivery concern, per slot
543
- * (road-to-delivery-for-every-host 3.1).
544
- *
545
- * Measured through the SAME selection the runtime concern uses —
546
- * `matchTierRules` + `selectForInjection` at the concern's own `CAP_BYTES` —
547
- * rather than through a second model of it. An offline figure computed by a
548
- * different matcher would price a set the concern does not deliver.
549
- *
550
- * `pre_compact` is 0 by construction rather than by measurement: that branch of
551
- * `rule_inject_hook` clears the seen-set and returns allow without writing to
552
- * stdout, so there is no emission to sample.
449
+ * The sampling itself moved to `_lib/activation_payload.ts` when the payload
450
+ * gate needed the same figure (`road-to-the-delivery-flip-that-tells-the-truth`
451
+ * 3.1). This wrapper supplies `CAP_BYTES` so the report's own callers keep the
452
+ * one-argument form, and the helpers below are re-exported so there is still
453
+ * exactly one implementation of each.
553
454
  */
554
455
  export function slotFireSizes(root: string): SlotFireSizes[] {
555
- const router = loadRouter(root);
556
- const prompts = readCorpusPositives(root);
557
- const sample = (mode: 'prompt' | 'file'): number[] => {
558
- const out: number[] = [];
559
- for (const r of prompts) {
560
- if (mode === 'file' && (r.openFiles === null || r.openFiles.length === 0)) continue;
561
- const matches =
562
- mode === 'prompt'
563
- ? matchTierRules(router, r.prompt, null, null)
564
- : matchTierRules(router, '', r.openFiles, null);
565
- if (matches.length === 0) continue;
566
- const sel = selectForInjection(root, matches, CAP_BYTES);
567
- if (sel.selected.length > 0) out.push(sel.bytes);
568
- }
569
- return out;
570
- };
571
- const row = (slot: string, xs: number[]): SlotFireSizes => ({
572
- slot,
573
- fires: xs.length,
574
- p50: percentile(xs, 0.5),
575
- p90: percentile(xs, 0.9),
576
- max: xs.length === 0 ? 0 : Math.max(...xs),
577
- });
578
- return [
579
- row('user_prompt_submit', sample('prompt')),
580
- row('pre_tool_use', sample('file')),
581
- row('pre_compact', []),
582
- ];
456
+ return sampleSlotFireSizes(root, CAP_BYTES);
583
457
  }
584
458
 
585
459
  function headSha(root: string): string {
@@ -101,6 +101,29 @@
101
101
  }
102
102
  }
103
103
  },
104
+ "delivery": {
105
+ "type": "object",
106
+ "additionalProperties": true,
107
+ "properties": {
108
+ "merge": {
109
+ "type": "string",
110
+ "enum": ["off", "on-green"],
111
+ "default": "off",
112
+ "description": "Whether a run may merge the pull request it produced. off (default) = the run ends at mergeable-green-and-open and says so. on-green = merge is permitted, and only when an object-bound grant covering {op: prod_merge, target: <base>} exists and the final head is required-check-green, target-current and tamper-checked (ADR-268 section 3). A precondition, never the authorisation by itself; forge auto-merge is the mechanism, so branch protection stays the gate."
113
+ },
114
+ "wait_for_ci": {
115
+ "type": "boolean",
116
+ "default": true,
117
+ "description": "Whether the run stays alive until the required checks settle. true (default) = the run drives CI and reports the settled verdict. false = the run ends once the branch is pushed and the PR is open. Independent of merge: a run may wait for CI without being allowed to merge."
118
+ },
119
+ "pr_topology": {
120
+ "type": "string",
121
+ "enum": ["single", "stacked"],
122
+ "default": "single",
123
+ "description": "How a mission's work is shaped into pull requests. single (default) = one branch, one pull request. stacked = a dependent series. stacked is never chosen by the agent and never asked about at roadmap creation — the owner plans it in roadmap frontmatter or it does not happen."
124
+ }
125
+ }
126
+ },
104
127
  "screenshots": {
105
128
  "type": "object",
106
129
  "additionalProperties": true,
@@ -103,6 +103,10 @@
103
103
  "pattern": "^[a-z][a-z0-9-]*$",
104
104
  "description": "Sub-command identifier within the cluster (e.g. `ci` for `/fix ci`)."
105
105
  },
106
+ "produces_roadmap": {
107
+ "type": "boolean",
108
+ "description": "This command produces a roadmap, so it ends in the plan-closure pass (`/challenge-me closure`). The declaration replaces the hard-coded entrance list that lived inside three command bodies and left the other five producers uncovered with nothing saying so. `lint_roadmap_producers` reds a command that declares it and carries no reference to the closure pass. Omit the key for commands that produce no plan."
109
+ },
106
110
  "superseded_by": {
107
111
  "type": "string",
108
112
  "pattern": "^[a-z][a-z0-9-]*( (--[a-z][a-z0-9-]*|[a-z][a-z0-9-]*))?$",
@@ -0,0 +1,219 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Who owns a piece of scope growth discovered mid-mission.
4
+ *
5
+ * The size question — *is this small and aligned enough to do now* — already
6
+ * has an answer in the remediation ladder. This is the other axis, and the two
7
+ * are independent: something small and council-owned is still not the agent's
8
+ * to decide, and something agent-owned and large is still bounded by the
9
+ * smallest-diff rule.
10
+ *
11
+ * Four verdicts, and the last one is the important one:
12
+ *
13
+ * `agent` done inline, recorded as a scope delta in the PR body, never
14
+ * asked about.
15
+ * `council` routed through the ownership ladder; the verdict becomes a
16
+ * `## Decisions` row. It does NOT reach the owner, however
17
+ * consequential — a technical decision does not become
18
+ * owner-owned because it is hard.
19
+ * `owner` the work changes what the product or the business does, or it
20
+ * needs a typed op for which no grant exists. Two rows, closed.
21
+ * `follow-up` not scope growth at all. A larger unrelated opportunity
22
+ * becomes its own artifact and the mission does not expand to
23
+ * hold it — the failure that looks like diligence is a mission
24
+ * that absorbs every good idea it meets and delivers none.
25
+ *
26
+ * `unknown` is a real verdict and is deliberately NOT folded into `agent`:
27
+ * growth nothing classifies is growth nobody decided, and defaulting it to the
28
+ * cheapest rung is how an owner question becomes an agent one silently.
29
+ *
30
+ * Exit codes: 0 classified · 1 unknown · 2 bad argv.
31
+ */
32
+
33
+ import * as fs from 'node:fs';
34
+ import * as path from 'node:path';
35
+ import process from 'node:process';
36
+ import { fileURLToPath } from 'node:url';
37
+
38
+ const _HERE = fileURLToPath(import.meta.url);
39
+ const _PROG = 'scope_growth';
40
+
41
+ export type Verdict = 'agent' | 'council' | 'owner' | 'follow-up' | 'unknown';
42
+
43
+ export interface Rule {
44
+ readonly verdict: Verdict;
45
+ readonly kind: string;
46
+ readonly re: RegExp;
47
+ }
48
+
49
+ /**
50
+ * Ordered, and the order is the contract: `owner` and `follow-up` are tested
51
+ * BEFORE `agent`, so a row that matches both ("a missing test — in a subsystem
52
+ * this mission never touched") resolves to the more conservative rung rather
53
+ * than to the first cheap match.
54
+ */
55
+ export const RULES: readonly Rule[] = [
56
+ // --- owner: two rows, closed ------------------------------------------
57
+ {
58
+ verdict: 'owner',
59
+ kind: 'product-or-business-change',
60
+ re: /\b(?:new user-visible|changes what the product|changes what the business|a price|pricing|a public commitment|a policy change)\b/i,
61
+ },
62
+ {
63
+ verdict: 'owner',
64
+ kind: 'typed-op-without-grant',
65
+ re: /\b(?:needs a typed op|no grant exists|merge to main|force-push|deploy to prod(?:uction)?|rotate the secret)\b/i,
66
+ },
67
+ // --- follow-up: unrelated, however good -------------------------------
68
+ {
69
+ verdict: 'follow-up',
70
+ kind: 'larger-unrelated',
71
+ re: /\b(?:unrelated|another subsystem|a subsystem this .{0,30}never touched|out of scope|deserves its own)\b/i,
72
+ },
73
+ // --- council: technical, contested ------------------------------------
74
+ {
75
+ verdict: 'council',
76
+ kind: 'larger-internal-re-cut',
77
+ re: /\b(?:re-?cut|restructure the module|reshape the boundary)\b/i,
78
+ },
79
+ {
80
+ verdict: 'council',
81
+ kind: 'two-equal-strategies',
82
+ re: /\b(?:two equal|two valid|either strategy|no evidence between them)\b/i,
83
+ },
84
+ {
85
+ verdict: 'council',
86
+ kind: 'risky-compatibility-design',
87
+ re: /\b(?:compatibility risk|backwards[- ]incompat|breaking change)\b/i,
88
+ },
89
+ {
90
+ verdict: 'council',
91
+ kind: 'unclear-boundary',
92
+ re: /\b(?:unclear boundary|where the boundary)\b/i,
93
+ },
94
+ // --- agent: six rows, closed ------------------------------------------
95
+ {
96
+ verdict: 'agent',
97
+ kind: 'necessary-internal-refactor',
98
+ re: /\b(?:necessary (?:internal )?refactor|cannot land (?:correctly )?without)\b/i,
99
+ },
100
+ { verdict: 'agent', kind: 'missing-test', re: /\bmissing test\b/i },
101
+ {
102
+ verdict: 'agent',
103
+ kind: 'regression-on-touched-path',
104
+ re: /\bregression\b[^.]{0,60}\btouched\b|\btouched\b[^.]{0,60}\bregression\b/i,
105
+ },
106
+ {
107
+ verdict: 'agent',
108
+ kind: 'small-dependency-adjustment',
109
+ re: /\b(?:small dependency adjustment|patch bump|minor bump)\b/i,
110
+ },
111
+ {
112
+ verdict: 'agent',
113
+ kind: 'local-api-inside-defined-semantics',
114
+ re: /\b(?:inside (?:the )?(?:already-)?defined semantics|the contract does not move)\b/i,
115
+ },
116
+ {
117
+ verdict: 'agent',
118
+ kind: 'boy-scout-cleanup',
119
+ re: /\bboy[- ]scout\b/i,
120
+ },
121
+ ];
122
+
123
+ export interface Classification {
124
+ readonly verdict: Verdict;
125
+ readonly kind: string;
126
+ /** True when the verdict is done inline and recorded rather than asked. */
127
+ readonly records_scope_delta: boolean;
128
+ /** True when a person is reached at all. */
129
+ readonly asks: boolean;
130
+ }
131
+
132
+ export function classifyGrowth(text: string): Classification {
133
+ for (const r of RULES) {
134
+ if (r.re.test(text)) {
135
+ return {
136
+ verdict: r.verdict,
137
+ kind: r.kind,
138
+ records_scope_delta: r.verdict === 'agent',
139
+ asks: r.verdict === 'owner',
140
+ };
141
+ }
142
+ }
143
+ return { verdict: 'unknown', kind: 'unclassified', records_scope_delta: false, asks: false };
144
+ }
145
+
146
+ function _usage(): string {
147
+ return `usage: ${_PROG} [-h] [--self-test] [--json] <description...>\n`;
148
+ }
149
+
150
+ export function main(argv?: readonly string[]): number {
151
+ const args = argv ?? process.argv.slice(2);
152
+ let json = false;
153
+ const words: string[] = [];
154
+ for (const a of args) {
155
+ if (a === '-h' || a === '--help') {
156
+ process.stdout.write(_usage());
157
+ return 0;
158
+ }
159
+ if (a === '--self-test') return _selfTest();
160
+ if (a === '--json') {
161
+ json = true;
162
+ continue;
163
+ }
164
+ words.push(a);
165
+ }
166
+ if (!words.length) {
167
+ process.stderr.write(`${_usage()}${_PROG}: error: a description is required\n`);
168
+ return 2;
169
+ }
170
+ const c = classifyGrowth(words.join(' '));
171
+ if (json) {
172
+ process.stdout.write(JSON.stringify(c, null, 2) + '\n');
173
+ } else {
174
+ process.stdout.write(
175
+ `${c.verdict} (${c.kind}) scope-delta=${String(c.records_scope_delta)} asks=${String(c.asks)}\n`,
176
+ );
177
+ }
178
+ return c.verdict === 'unknown' ? 1 : 0;
179
+ }
180
+
181
+ function _selfTest(): number {
182
+ const cases: ReadonlyArray<readonly [string, Verdict]> = [
183
+ ['a missing test on a path this change touched', 'agent'],
184
+ ['a boy-scout cleanup, small and local', 'agent'],
185
+ ['two equal technical strategies with no evidence between them', 'council'],
186
+ ['this introduces a compatibility risk', 'council'],
187
+ ['it needs a typed op and no grant exists', 'owner'],
188
+ ['this adds new user-visible semantics', 'owner'],
189
+ ['an unrelated refactor that deserves its own roadmap', 'follow-up'],
190
+ ['something nobody wrote a rule for', 'unknown'],
191
+ // The ordering case: matches `missing test` AND `unrelated`, and the
192
+ // conservative rung must win or a follow-up silently becomes agent work.
193
+ ['a missing test in an unrelated subsystem', 'follow-up'],
194
+ ];
195
+ const failures: string[] = [];
196
+ for (const [text, want] of cases) {
197
+ const got = classifyGrowth(text).verdict;
198
+ if (got !== want) failures.push(`"${text}" → ${got}, wanted ${want}`);
199
+ }
200
+ if (failures.length) {
201
+ for (const f of failures) process.stderr.write(`❌ ${_PROG} --self-test: ${f}\n`);
202
+ return 1;
203
+ }
204
+ process.stdout.write(`✅ ${_PROG} --self-test: ${String(cases.length)} case(s) behaved\n`);
205
+ return 0;
206
+ }
207
+
208
+ function _isCliEntry(): boolean {
209
+ if (!process.argv[1]) return false;
210
+ try {
211
+ return fs.realpathSync(_HERE) === fs.realpathSync(path.resolve(process.argv[1]));
212
+ } catch {
213
+ return false;
214
+ }
215
+ }
216
+
217
+ if (_isCliEntry() || process.argv[1] === _HERE) {
218
+ process.exitCode = main();
219
+ }
@@ -24,13 +24,22 @@
24
24
  * Usage:
25
25
  * sync_agent_settings # write (default)
26
26
  * sync_agent_settings --dry-run # show diff, no write
27
- * sync_agent_settings --check # exit 2 on drift (for CI)
27
+ * sync_agent_settings --check # exit 2 if a sync is needed (CI)
28
28
  * sync_agent_settings --profile balanced # use a specific profile
29
29
  * sync_agent_settings --path path/to/.agent-settings.yml
30
30
  *
31
31
  * Exit codes:
32
32
  * 0 — already in sync, or changes applied (or --dry-run ran cleanly)
33
- * 2 — drift detected under --check, or invalid arguments / missing files
33
+ * 2 — under --check, synchronization requires intervention; also invalid
34
+ * arguments, missing files, and a duplicate-key repair this tool
35
+ * refuses to perform.
36
+ *
37
+ * `--check` has two diagnostic cases and its stderr line already separates
38
+ * them, because the remedies differ: template drift ("drift detected"), and a
39
+ * duplicate-key repair with no drift ("duplicate keys need collapsing"). Both
40
+ * exit 2 — the target needs a write either way, and a green `--check` over a
41
+ * file the strict reader cannot parse would hide the breakage. A caller that
42
+ * needs the distinction reads the message, not the code.
34
43
  *
35
44
  * No behaviour changes vs. the retired Python implementation — historical quirks preserved (consumers pin the exact behaviour).
36
45
  */
@@ -441,6 +450,52 @@ function loadTemplate(p: string, profileValues: Record<string, string>): string
441
450
  return renderTemplate(fs.readFileSync(p, 'utf-8'), profileValues);
442
451
  }
443
452
 
453
+ /**
454
+ * The comment `mergeIntoTemplate` writes above its appended flat-key block.
455
+ *
456
+ * Duplicated here rather than imported: the writer holds it as a literal in
457
+ * `src/server/io/yamlIO.ts` and exports no constant, and that module belongs
458
+ * to the server tree this CLI does not otherwise depend on. Keep the two in
459
+ * step — a drift makes the sweep below silently stop matching.
460
+ */
461
+ const WIZARD_BLOCK_HEADER = '# Wizard-added keys (no template entry)';
462
+
463
+ /**
464
+ * Drop a wizard-block header the collapse emptied.
465
+ *
466
+ * The non-idempotent writer appended the header AND its keys on every save, so
467
+ * a collapse that removes the later block's key lines leaves that block's
468
+ * header behind with nothing under it — and the next append puts a fresh
469
+ * header below the orphan, one more per save. A header names the keys beneath
470
+ * it; with none beneath it, it names nothing and misleads the next reader.
471
+ *
472
+ * A header counts as emptied only when no key line stands between it and the
473
+ * next header or EOF. Blanks and further comments do not count: a user comment
474
+ * written between the header and its first key is part of the block, not a
475
+ * replacement for it.
476
+ */
477
+ function dropOrphanedWizardHeaders(lines: readonly string[]): string[] {
478
+ const drop = new Set<number>();
479
+ for (let i = 0; i < lines.length; i++) {
480
+ if (lines[i]?.trim() !== WIZARD_BLOCK_HEADER) continue;
481
+ let hasKey = false;
482
+ for (let j = i + 1; j < lines.length; j++) {
483
+ const l = lines[j];
484
+ if (l === undefined) continue;
485
+ if (l.trim() === WIZARD_BLOCK_HEADER) break;
486
+ if (l.trim() === '' || l.trimStart().startsWith('#')) continue;
487
+ hasKey = true;
488
+ break;
489
+ }
490
+ if (hasKey) continue;
491
+ drop.add(i);
492
+ // The writer emits the blank line above the header as part of the block,
493
+ // so removing the header alone widens the gap by one line per repair.
494
+ if (i > 0 && lines[i - 1]?.trim() === '') drop.add(i - 1);
495
+ }
496
+ return drop.size === 0 ? [...lines] : lines.filter((_, i) => !drop.has(i));
497
+ }
498
+
444
499
  export interface DuplicateRepair {
445
500
  /** The document with every safely-collapsible duplicate run reduced to one. */
446
501
  text: string;
@@ -482,6 +537,9 @@ export interface DuplicateRepair {
482
537
  * A multi-document stream (`---` / `...`) is not repaired either. The reader
483
538
  * takes a single document and rejects such a file whatever this pass does, so
484
539
  * collapsing across a boundary could only merge two documents' keys into one.
540
+ *
541
+ * When — and only when — something did collapse, a wizard-block header the
542
+ * collapse emptied is removed with it; see `dropOrphanedWizardHeaders`.
485
543
  */
486
544
  export function collapseDuplicateFlatKeys(text: string): DuplicateRepair {
487
545
  // Line endings are preserved: splitting on `\n` alone leaves a trailing
@@ -630,10 +688,53 @@ export function collapseDuplicateFlatKeys(text: string): DuplicateRepair {
630
688
  if (collapsed.length === 0) {
631
689
  return { text, collapsed, unsafe };
632
690
  }
633
- const kept = lines.filter((_, i) => !drop.has(i));
691
+ const kept = dropOrphanedWizardHeaders(lines.filter((_, i) => !drop.has(i)));
634
692
  return { text: kept.join(eol), collapsed, unsafe };
635
693
  }
636
694
 
695
+ /**
696
+ * The parse error an input still carries once its duplicate keys are excused,
697
+ * or null when the duplicates were its only problem.
698
+ *
699
+ * `uniqueKeys: false` is what excuses them: the duplicate-key rejection is the
700
+ * single error the collapse has a mandate to remove, so anything the parser
701
+ * still raises is an error the collapse was never licensed to touch.
702
+ */
703
+ function residualParseError(text: string): Error | null {
704
+ try {
705
+ parseYaml(text, { version: '1.1', uniqueKeys: false });
706
+ return null;
707
+ } catch (err) {
708
+ return err instanceof Error ? err : new Error(String(err));
709
+ }
710
+ }
711
+
712
+ /**
713
+ * What the parser reliably exposes about a failure, as one indented block.
714
+ *
715
+ * Available on every `YAMLParseError` observed from this parser: `message`,
716
+ * whose first line already carries `at line L, column C` plus the detail, and
717
+ * `code`, a stable identifier. `linePos` is populated too, but only while
718
+ * `prettyErrors` stays on (the library's own condition), so it is printed when
719
+ * present and never relied on. Nothing here is asserted beyond the parser's
720
+ * own first message line, which is derived in the test rather than hardcoded —
721
+ * pinning a format this module does not own would make a safety test fail on a
722
+ * dependency's wording change.
723
+ */
724
+ function formatParserDetail(err: Error): string {
725
+ const first = err.message.split('\n')[0] ?? err.message;
726
+ const parts: string[] = [` ${first}`];
727
+ const code = (err as { code?: unknown }).code;
728
+ const linePos = (err as { linePos?: { line: number; col: number }[] }).linePos;
729
+ const at = linePos?.[0];
730
+ if (typeof code === 'string' && at !== undefined) {
731
+ parts.push(` (${code} at line ${at.line}, column ${at.col})`);
732
+ } else if (typeof code === 'string') {
733
+ parts.push(` (${code})`);
734
+ }
735
+ return parts.join('\n') + '\n';
736
+ }
737
+
637
738
  function loadUserText(raw: string): Record<string, unknown> {
638
739
  const data = parseYaml(raw, { version: '1.1' });
639
740
  if (data === null || data === undefined) {
@@ -863,6 +964,42 @@ export function main(argv: readonly string[] = process.argv.slice(2)): number {
863
964
  return 2;
864
965
  }
865
966
  if (repair.collapsed.length > 0) {
967
+ // Refuse before anything else in this branch, including the notice below:
968
+ // in write mode that notice is past tense ("collapsed …"), so announcing
969
+ // it and then refusing describes a write that never happened.
970
+ //
971
+ // The collapse's safety rests on preserving the original's last-wins
972
+ // reading, and a document that is invalid for some FURTHER reason has no
973
+ // reading to preserve — the pass can then emit valid YAML carrying
974
+ // structure nobody authored, which every downstream check accepts. A
975
+ // stderr warning (what this branch used to do) is a weak control against a
976
+ // silent outcome; a refusal is loud and the operator can still recover.
977
+ // AI council, 2026-09-11, 2 of 2 seats, converged — including a rejection
978
+ // of any `--repair-anyway` escape: an operator who understands the file
979
+ // well enough to use it understands it well enough to fix the error first,
980
+ // and "read the diff" is not a trustworthy oracle for YAML semantics.
981
+ //
982
+ // Exit 2, per the taxonomy in this file's header: 2 is "this tool cannot
983
+ // proceed with this input", which is also what the sibling refusal above
984
+ // (duplicates it will not collapse) returns. 1 is spoken for — it is the
985
+ // mirrored `install.fail` exit and means a broken install asset.
986
+ //
987
+ // Nothing durable precedes this point: the only write in `main` is the
988
+ // single `writeFileSync` at the end, there is no temporary file and no
989
+ // rename, so the refusal leaves the target byte-for-byte unchanged.
990
+ const residual = residualParseError(rawText);
991
+ if (residual !== null) {
992
+ process.stderr.write(
993
+ `❌ ${target}: refusing the automatic duplicate repair — the file has ` +
994
+ `another YAML error besides its duplicate keys.\n` +
995
+ formatParserDetail(residual) +
996
+ ` Collapsing changes only WHETHER a file parses, never what it parses as, ` +
997
+ `and that promise is empty over a document with no valid reading.\n` +
998
+ ` Fix the syntax error by hand, then re-run to apply the duplicate repair.\n`,
999
+ );
1000
+ return 2;
1001
+ }
1002
+
866
1003
  // NOT gated on --quiet, and not on stdout. Both callers that matter run
867
1004
  // quiet — the release's `sync-agent-settings` step and the pre-release
868
1005
  // probe — so gating this would let a release rewrite the operator's own
@@ -878,21 +1015,6 @@ export function main(argv: readonly string[] = process.argv.slice(2)): number {
878
1015
  ? `collapsed ${repair.collapsed.length} duplicate key(s), last value kept`
879
1016
  : `would collapse ${repair.collapsed.length} duplicate key(s), keeping the last value (no write in this mode)`;
880
1017
  process.stderr.write(`🔧 ${target}: ${verb} — ${repair.collapsed.join(', ')}\n`);
881
-
882
- // The collapse promises to change only WHETHER the file parses, never what
883
- // it parses as — and that promise is only meaningful over an input that had
884
- // a reading to preserve. If the original was unparseable for some further
885
- // reason (an unterminated quote, say), the repaired file may parse to
886
- // structure no human wrote, and the next `git diff` shows keys nobody
887
- // added. Cheap to say, and the operator cannot recover it from the output.
888
- try {
889
- parseYaml(rawText, { version: '1.1', uniqueKeys: false });
890
- } catch {
891
- process.stderr.write(
892
- `⚠️ ${target}: the original had a syntax error beyond the duplicate keys, so the ` +
893
- `result may differ in structure, not only in duplicates — read the diff before trusting it.\n`,
894
- );
895
- }
896
1018
  }
897
1019
  const sourceText = repair.text;
898
1020