@event4u/agent-config 16.1.0 → 16.3.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 (467) 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 +98 -257
  5. package/README.md +14 -12
  6. package/dist/agent-src/commands/analyze/inbox.md +1 -1
  7. package/dist/agent-src/commands/analyze/roadmap-repos.md +1 -1
  8. package/dist/agent-src/commands/bug/fix.md +17 -2
  9. package/dist/agent-src/commands/feature/plan.md +1 -1
  10. package/dist/agent-src/commands/feature/roadmap.md +1 -1
  11. package/dist/agent-src/commands/fix/ci.md +1 -1
  12. package/dist/agent-src/commands/implement-ticket.md +1 -1
  13. package/dist/agent-src/commands/jira-ticket.md +1 -1
  14. package/dist/agent-src/commands/roadmap/create.md +1 -1
  15. package/dist/agent-src/commands/roadmap/materialize.md +1 -1
  16. package/dist/agent-src/commands/roadmap/process-full.md +5 -5
  17. package/dist/agent-src/commands/roadmap/resolve-blockers.md +361 -0
  18. package/dist/agent-src/commands/roadmap/triage-parked.md +325 -0
  19. package/dist/agent-src/commands/roadmap.md +18 -4
  20. package/dist/agent-src/commands/tests/create.md +17 -3
  21. package/dist/agent-src/commands/tests/execute.md +22 -5
  22. package/dist/agent-src/commands/work.md +4 -0
  23. package/dist/agent-src/contexts/authority/decision-revisit-gate-enforcement.md +43 -0
  24. package/dist/agent-src/contexts/communication/rules-auto/design-fidelity-enforcement.md +24 -0
  25. package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +6 -6
  26. package/dist/agent-src/contexts/execution/autonomy-mechanics.md +4 -8
  27. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +32 -2
  28. package/dist/agent-src/contexts/execution/session-canary-enforcement-history.md +67 -0
  29. package/dist/agent-src/contexts/execution/toolchain-resolver.md +101 -7
  30. package/dist/agent-src/rules/active-remediation.md +1 -1
  31. package/dist/agent-src/rules/autonomous-execution.md +1 -0
  32. package/dist/agent-src/rules/broken-access-control.md +1 -0
  33. package/dist/agent-src/rules/command-suggestion-policy.md +1 -0
  34. package/dist/agent-src/rules/context-hygiene.md +1 -1
  35. package/dist/agent-src/rules/decision-revisit-gate.md +16 -33
  36. package/dist/agent-src/rules/design-fidelity.md +13 -16
  37. package/dist/agent-src/rules/doc-screenshot-hygiene.md +1 -0
  38. package/dist/agent-src/rules/domain-safety-disclaimer.md +1 -0
  39. package/dist/agent-src/rules/domain-safety-pii.md +1 -0
  40. package/dist/agent-src/rules/domain-safety-retention.md +1 -0
  41. package/dist/agent-src/rules/engineering-safety-floor.md +1 -0
  42. package/dist/agent-src/rules/evaluator-independence.md +3 -2
  43. package/dist/agent-src/rules/fast-path-marker-visibility.md +1 -0
  44. package/dist/agent-src/rules/finance-safety-floor.md +1 -0
  45. package/dist/agent-src/rules/git-history-discipline.md +1 -0
  46. package/dist/agent-src/rules/image-likeness-and-rights.md +1 -0
  47. package/dist/agent-src/rules/legal-safety-floor.md +3 -2
  48. package/dist/agent-src/rules/lethal-trifecta-guard.md +1 -0
  49. package/dist/agent-src/rules/low-impact-corpus-privacy-floor.md +1 -0
  50. package/dist/agent-src/rules/media-governance-routing.md +14 -17
  51. package/dist/agent-src/rules/persona-governance.md +2 -2
  52. package/dist/agent-src/rules/provider-lifecycle-discipline.md +1 -1
  53. package/dist/agent-src/rules/question-not-instruction.md +1 -0
  54. package/dist/agent-src/rules/roadmap-progress-sync.md +1 -1
  55. package/dist/agent-src/rules/runtime-safety.md +1 -0
  56. package/dist/agent-src/rules/secret-vcs-guard.md +1 -0
  57. package/dist/agent-src/rules/security-sensitive-stop.md +3 -2
  58. package/dist/agent-src/rules/session-canary.md +8 -62
  59. package/dist/agent-src/rules/settings-ask-protocol.md +1 -0
  60. package/dist/agent-src/rules/source-confidentiality.md +1 -0
  61. package/dist/agent-src/rules/strategy-safety-floor.md +1 -0
  62. package/dist/agent-src/rules/tool-safety.md +1 -0
  63. package/dist/agent-src/rules/ui-audit-gate.md +1 -2
  64. package/dist/agent-src/rules/untrusted-input-defense.md +1 -0
  65. package/dist/agent-src/rules/user-interrupt-priority.md +1 -0
  66. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +115 -20
  67. package/dist/agent-src/scripts/deferral_carry.ts +339 -0
  68. package/dist/agent-src/scripts/update_roadmap_progress.ts +4 -1
  69. package/dist/agent-src/skills/accessibility-auditor/data/aria-patterns.csv +1 -1
  70. package/dist/agent-src/skills/accessibility-auditor/data/manifest.json +2 -2
  71. package/dist/agent-src/skills/api-design/data/manifest.json +2 -2
  72. package/dist/agent-src/skills/api-testing/SKILL.md +17 -0
  73. package/dist/agent-src/skills/code-intelligence/SKILL.md +14 -2
  74. package/dist/agent-src/skills/design-review/SKILL.md +30 -4
  75. package/dist/agent-src/skills/design-review/references/verification-automation.md +32 -0
  76. package/dist/agent-src/skills/design-system-capture/references/import-procedure.md +1 -1
  77. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +18 -3
  78. package/dist/agent-src/skills/existing-ui-audit/references/component-taxonomy-detection.md +56 -0
  79. package/dist/agent-src/skills/fe-design/SKILL.md +1 -1
  80. package/dist/agent-src/skills/iconography/SKILL.md +1 -1
  81. package/dist/agent-src/skills/js-library-packaging/SKILL.md +2 -2
  82. package/dist/agent-src/skills/markitdown/SKILL.md +4 -0
  83. package/dist/agent-src/skills/monorepo-workspace/SKILL.md +1 -1
  84. package/dist/agent-src/skills/playwright-testing/SKILL.md +30 -6
  85. package/dist/agent-src/skills/quality-tools/SKILL.md +11 -1
  86. package/dist/agent-src/skills/quality-tools/references/go-tools.md +76 -0
  87. package/dist/agent-src/skills/quality-tools/references/python-tools.md +104 -0
  88. package/dist/agent-src/skills/review-routing/SKILL.md +2 -2
  89. package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +1 -1
  90. package/dist/agent-src/skills/storybook-workshop/SKILL.md +3 -3
  91. package/dist/agent-src/skills/tailwind-engineer/SKILL.md +1 -1
  92. package/dist/agent-src/skills/test-case-discovery/SKILL.md +7 -0
  93. package/dist/agent-src/skills/test-driven-development/SKILL.md +17 -0
  94. package/dist/agent-src/skills/test-performance/SKILL.md +19 -1
  95. package/dist/agent-src/skills/ui-component-architect/SKILL.md +25 -2
  96. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  97. package/dist/agent-src/templates/roadmaps.md +30 -0
  98. package/dist/agent-src/templates/scripts/work_engine/directives/ui/app_spec.ts +10 -4
  99. package/dist/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +302 -21
  100. package/dist/agent-src/templates/scripts/work_engine/directives/ui/audit.ts +22 -3
  101. package/dist/agent-src/templates/scripts/work_engine/directives/ui/scaffold.ts +6 -1
  102. package/dist/agent-src/templates/scripts/work_engine/stack/runner.ts +774 -109
  103. package/dist/agent-src/templates/scripts/work_engine/state.ts +57 -7
  104. package/dist/agent-src/templates/scripts/work_engine/taxonomy/detect.ts +500 -0
  105. package/dist/agent-src/templates/scripts/work_engine/taxonomy/place.ts +183 -0
  106. package/dist/cli/python/knowledge_ingest.js +1 -1
  107. package/dist/cli/python/workspace_drive.js +1 -1
  108. package/dist/cli/registry.js +1 -1
  109. package/dist/cli-delegate/archive_completed_roadmaps.js +92 -12
  110. package/dist/cli-delegate/check_update_banner.js +12 -1
  111. package/dist/cli-delegate/chunk-523HCFYO.js +90 -0
  112. package/dist/cli-delegate/{chunk-M2DPGFZ4.js → chunk-6V6TA25H.js} +22 -152
  113. package/dist/cli-delegate/chunk-7IQE3D5S.js +191 -0
  114. package/dist/cli-delegate/{chunk-6KFEC3G7.js → chunk-CQWX336L.js} +1 -1
  115. package/dist/cli-delegate/chunk-DEIV7XP7.js +108 -0
  116. package/dist/cli-delegate/chunk-EEHDXSYD.js +2040 -0
  117. package/dist/cli-delegate/{chunk-P6ZHP5W2.js → chunk-GKE7ZVFA.js} +4 -4
  118. package/dist/cli-delegate/chunk-JQOM7TTM.js +1201 -0
  119. package/dist/cli-delegate/{chunk-UNCRJBT3.js → chunk-JQVYKIZA.js} +41 -0
  120. package/dist/cli-delegate/{chunk-FC75PGJE.js → chunk-JY2I2DXV.js} +12 -1
  121. package/dist/cli-delegate/chunk-KB5FZ655.js +208 -0
  122. package/dist/cli-delegate/{chunk-YUM5KURG.js → chunk-KR6EM23A.js} +235 -1455
  123. package/dist/cli-delegate/{chunk-LTYVIY76.js → chunk-LIYTLH6O.js} +1 -1
  124. package/dist/cli-delegate/chunk-NQ7WT43P.js +2729 -0
  125. package/dist/cli-delegate/chunk-NXEILF2K.js +2160 -0
  126. package/dist/cli-delegate/{chunk-5R7HOLAF.js → chunk-NYX3L2PZ.js} +1 -1
  127. package/dist/cli-delegate/{chunk-4E2W63DA.js → chunk-OKJUIAPS.js} +62 -1
  128. package/dist/cli-delegate/{chunk-QAJS5YWY.js → chunk-PHD7OOLE.js} +2 -1
  129. package/dist/cli-delegate/{chunk-RGNIWTEA.js → chunk-QLDHIPWR.js} +1 -1
  130. package/dist/cli-delegate/{chunk-HDVVGPQW.js → chunk-RQNNW7IF.js} +1405 -567
  131. package/dist/cli-delegate/{chunk-S3VFXMTM.js → chunk-S4UMZNES.js} +1 -1
  132. package/dist/cli-delegate/{chunk-6KM6ORRY.js → chunk-S4YWNRKO.js} +24 -8
  133. package/dist/cli-delegate/{chunk-GJCBB2UG.js → chunk-TRI6DQZ6.js} +3054 -325
  134. package/dist/cli-delegate/chunk-ULQRUR23.js +208 -0
  135. package/dist/cli-delegate/{chunk-NUHLI2RX.js → chunk-YCU3TIXH.js} +2 -0
  136. package/dist/cli-delegate/chunk-ZBZV2G5R.js +210 -0
  137. package/dist/cli-delegate/{chunk-WGXTVKCQ.js → chunk-ZUZKZYCN.js} +5 -3
  138. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  139. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  140. package/dist/cli-delegate/cmd_conformance.js +1862 -809
  141. package/dist/cli-delegate/cmd_doctor.js +13 -6
  142. package/dist/cli-delegate/cmd_doctor_neighbours.js +23 -0
  143. package/dist/cli-delegate/cmd_explain.js +5 -5
  144. package/dist/cli-delegate/cmd_export.js +11 -8
  145. package/dist/cli-delegate/cmd_fleet.js +3 -3
  146. package/dist/cli-delegate/cmd_handoff.js +10 -7
  147. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  148. package/dist/cli-delegate/cmd_memory_get.js +3 -1
  149. package/dist/cli-delegate/cmd_migrate.js +2 -2
  150. package/dist/cli-delegate/cmd_packs_active.js +3 -3
  151. package/dist/cli-delegate/cmd_preflight.js +6 -6
  152. package/dist/cli-delegate/cmd_prune.js +11 -8
  153. package/dist/cli-delegate/cmd_refresh.js +2 -1
  154. package/dist/cli-delegate/cmd_route_audit.js +8 -6
  155. package/dist/cli-delegate/cmd_route_explain.js +4 -4
  156. package/dist/cli-delegate/cmd_session_recycle.js +4 -3
  157. package/dist/cli-delegate/cmd_settings_get.js +1 -1
  158. package/dist/cli-delegate/cmd_settings_migrate.js +11 -8
  159. package/dist/cli-delegate/cmd_settings_set.js +4 -4
  160. package/dist/cli-delegate/cmd_sync.js +11 -8
  161. package/dist/cli-delegate/cmd_uninstall.js +11 -8
  162. package/dist/cli-delegate/cmd_update.js +1 -1
  163. package/dist/cli-delegate/cmd_upgrade.js +3 -2
  164. package/dist/cli-delegate/cmd_validate.js +11 -8
  165. package/dist/cli-delegate/cmd_versions.js +1 -1
  166. package/dist/cli-delegate/deferral_carry.js +16 -0
  167. package/dist/cli-delegate/gate_execute.js +2 -2
  168. package/dist/cli-delegate/roadmap_gates.js +5 -5
  169. package/dist/cli-delegate/update_roadmap_progress.js +1 -1
  170. package/dist/discovery/deprecation-report.md +1 -1
  171. package/dist/discovery/discovery-manifest.json +132 -80
  172. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  173. package/dist/discovery/discovery-manifest.summary.md +3 -3
  174. package/dist/discovery/dormancy-report.md +1 -1
  175. package/dist/discovery/orphan-report.md +1 -1
  176. package/dist/discovery/packs.json +7 -5
  177. package/dist/discovery/trust-report.md +2 -2
  178. package/dist/discovery/workspaces.json +6 -4
  179. package/dist/hooks/dispatch.js +7972 -5186
  180. package/dist/hooks/dispatch.sha256 +1 -0
  181. package/dist/install/claudePathsPlan.js +47 -6
  182. package/dist/install/claudeRuleRewrite.js +7 -0
  183. package/dist/install/emit_host_rules_cli.js +39 -36
  184. package/dist/install/globalRuleScope.js +87 -0
  185. package/dist/install/host_rule_ownership.js +160 -0
  186. package/dist/install/install.mjs +2226 -1055
  187. package/dist/install/installThinLayer.js +261 -0
  188. package/dist/install/installedRuleLinks.js +176 -0
  189. package/dist/install/wizard-plan.js +24 -0
  190. package/dist/mcp/registry-manifest.json +3 -3
  191. package/dist/mcp/server.json +2 -2
  192. package/dist/mcp/server.mjs +40913 -34865
  193. package/dist/scripts/_lib/agent_settings.js +11 -0
  194. package/dist/scripts/_lib/claude_settings_hooks.js +10 -8
  195. package/dist/scripts/_lib/installed_layer.js +378 -0
  196. package/dist/scripts/_lib/lean_projection_mode.js +218 -15
  197. package/dist/scripts/_lib/rule_consequence_class.js +45 -0
  198. package/dist/scripts/_lib/rule_injection.js +67 -10
  199. package/dist/scripts/_lib/rule_law_section.js +122 -0
  200. package/dist/scripts/_lib/thin_rules.js +458 -0
  201. package/dist/scripts/_lib/windsurf_render.js +60 -0
  202. package/dist/scripts/condense.js +14 -26
  203. package/dist/scripts/project_thin_rules.js +14 -294
  204. package/dist/server/schemas/settings.js +29 -0
  205. package/dist/shared/skillRanking.js +74 -13
  206. package/dist/ui/assets/index-Cwq4d0hX.js +23 -0
  207. package/dist/ui/assets/index-Cyjy8bWO.css +1 -0
  208. package/dist/ui/index.html +2 -2
  209. package/docs/contracts/settings-classes.md +10 -6
  210. package/package.json +4 -4
  211. package/src/agent-src/scripts/archive_completed_roadmaps.ts +115 -20
  212. package/src/agent-src/scripts/deferral_carry.ts +339 -0
  213. package/src/agent-src/scripts/update_roadmap_progress.ts +4 -1
  214. package/src/agent-src/templates/scripts/work_engine/directives/ui/app_spec.ts +10 -4
  215. package/src/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +302 -21
  216. package/src/agent-src/templates/scripts/work_engine/directives/ui/audit.ts +22 -3
  217. package/src/agent-src/templates/scripts/work_engine/directives/ui/scaffold.ts +6 -1
  218. package/src/agent-src/templates/scripts/work_engine/stack/runner.ts +774 -109
  219. package/src/agent-src/templates/scripts/work_engine/state.ts +57 -7
  220. package/src/agent-src/templates/scripts/work_engine/taxonomy/detect.ts +500 -0
  221. package/src/agent-src/templates/scripts/work_engine/taxonomy/place.ts +183 -0
  222. package/src/config/agent-settings.template.yml +91 -22
  223. package/src/config/ci-local-parity.yml +18 -0
  224. package/src/config/continuity-surface.json +9 -2
  225. package/src/config/evaluator-budgets.json +4 -4
  226. package/src/config/gate-coverage.yml +327 -3
  227. package/src/config/gate-violation-baselines.json +21 -9
  228. package/src/config/hook-bundle-budget.json +25 -0
  229. package/src/config/hook-latency-budget.json +21 -0
  230. package/src/config/hook-token-budget.json +8 -3
  231. package/src/config/host-instruction-limits.json +33 -0
  232. package/src/config/metric-registry.yml +114 -0
  233. package/src/config/placeholder-drift-budget.json +10 -0
  234. package/src/config/routing-coverage-seed.json +38 -3
  235. package/src/config/rule-activation-census.json +7 -3
  236. package/src/config/rule-consequence-class.json +161 -0
  237. package/src/config/rule-law-ceilings.json +99 -0
  238. package/src/install/claudePathsPlan.ts +46 -4
  239. package/src/install/claudeRuleRewrite.ts +10 -1
  240. package/src/install/emit_host_rules_cli.ts +43 -34
  241. package/src/install/globalRuleScope.ts +109 -0
  242. package/src/install/host_rule_ownership.ts +170 -0
  243. package/src/install/installThinLayer.ts +312 -0
  244. package/src/install/installedRuleLinks.ts +220 -0
  245. package/src/install/wizard-plan.ts +24 -0
  246. package/src/scripts/_cli/cmd_doctor.ts +28 -90
  247. package/src/scripts/_cli/cmd_doctor_neighbours.ts +293 -0
  248. package/src/scripts/_cli/doctor_execution.ts +100 -17
  249. package/src/scripts/_cli/doctor_network_posture.ts +226 -0
  250. package/src/scripts/_dispatch.bash +16 -2
  251. package/src/scripts/_lib/agent_settings.ts +11 -0
  252. package/src/scripts/_lib/body_portable.ts +102 -0
  253. package/src/scripts/_lib/cc_transcript.ts +132 -1
  254. package/src/scripts/_lib/claude_settings_hooks.ts +10 -8
  255. package/src/scripts/_lib/concern_sla_window.ts +200 -0
  256. package/src/scripts/_lib/estate_offsets.ts +241 -0
  257. package/src/scripts/_lib/exemption_shape.ts +143 -0
  258. package/src/scripts/_lib/fnv.ts +23 -0
  259. package/src/scripts/_lib/foreign_scan.ts +106 -0
  260. package/src/scripts/_lib/forge_protection.ts +114 -0
  261. package/src/scripts/_lib/forge_reader.ts +558 -0
  262. package/src/scripts/_lib/gitignore_block.ts +314 -0
  263. package/src/scripts/_lib/global_deploy_inventory.ts +32 -0
  264. package/src/scripts/_lib/graph_feeder_record.ts +214 -0
  265. package/src/scripts/_lib/hook_settings.ts +73 -0
  266. package/src/scripts/_lib/host_capability.ts +40 -19
  267. package/src/scripts/_lib/host_env_write.ts +111 -0
  268. package/src/scripts/_lib/host_hook_merge.ts +165 -0
  269. package/src/scripts/_lib/installed_layer.ts +538 -0
  270. package/src/scripts/_lib/json_merge.ts +82 -0
  271. package/src/scripts/_lib/json_pointers.ts +93 -11
  272. package/src/scripts/_lib/lean_projection_mode.ts +275 -15
  273. package/src/scripts/_lib/llm_proposer_transport.ts +14 -4
  274. package/src/scripts/_lib/machine_wake.ts +16 -3
  275. package/src/scripts/_lib/neighbour_census.ts +760 -0
  276. package/src/scripts/_lib/neighbour_scan.ts +122 -0
  277. package/src/scripts/_lib/neighbour_tool_use.ts +189 -0
  278. package/src/scripts/_lib/obligations.ts +119 -8
  279. package/src/scripts/_lib/probe_inputs.ts +271 -0
  280. package/src/scripts/_lib/prompt_shape.ts +46 -0
  281. package/src/scripts/_lib/read_surface_scan.ts +322 -0
  282. package/src/scripts/_lib/release_highlights.ts +2 -1
  283. package/src/scripts/_lib/release_migration_gate.ts +19 -3
  284. package/src/scripts/_lib/reserved_name_sweep.ts +73 -0
  285. package/src/scripts/_lib/retrieval_sanitize.ts +77 -16
  286. package/src/scripts/_lib/review_coverage.ts +134 -0
  287. package/src/scripts/_lib/rule_consequence_class.ts +71 -0
  288. package/src/scripts/_lib/rule_injection.ts +130 -10
  289. package/src/scripts/_lib/rule_law_section.ts +147 -0
  290. package/src/scripts/_lib/rule_layer_overlap.ts +58 -0
  291. package/src/scripts/_lib/shell_write_shapes.ts +108 -0
  292. package/src/scripts/_lib/skill_origin.ts +254 -0
  293. package/src/scripts/_lib/structural_hiding.ts +622 -0
  294. package/src/scripts/_lib/structured_ask.ts +64 -21
  295. package/src/scripts/_lib/thin_rules.ts +540 -0
  296. package/src/scripts/_lib/touched_file_quality.ts +434 -0
  297. package/src/scripts/_lib/turn_end_refusals.ts +600 -4
  298. package/src/scripts/_lib/turn_end_transcript.ts +216 -0
  299. package/src/scripts/_lib/verification_command.ts +324 -0
  300. package/src/scripts/_lib/verification_evidence.ts +647 -0
  301. package/src/scripts/_lib/verify_clause.ts +280 -0
  302. package/src/scripts/_lib/windsurf_render.ts +63 -0
  303. package/src/scripts/ai_council/cli_call_budget.ts +13 -5
  304. package/src/scripts/ai_council/clients.ts +26 -57
  305. package/src/scripts/ai_council/config.ts +1 -1
  306. package/src/scripts/ai_council/vendor_defaults.ts +45 -0
  307. package/src/scripts/ai_team/team_dispatch.ts +4 -0
  308. package/src/scripts/audit_skill_overlap.ts +261 -0
  309. package/src/scripts/before_complete_hook.ts +573 -25
  310. package/src/scripts/bench_hook_latency.ts +412 -5
  311. package/src/scripts/bench_touched_file_quality.ts +238 -0
  312. package/src/scripts/cache_realization_report.ts +61 -1
  313. package/src/scripts/check_argument_hint.ts +287 -0
  314. package/src/scripts/check_claims.ts +125 -1
  315. package/src/scripts/check_enforcement_coverage.ts +38 -9
  316. package/src/scripts/check_enforcement_matrix.ts +621 -22
  317. package/src/scripts/check_estate_count.ts +130 -153
  318. package/src/scripts/check_finding_dispositions.ts +138 -7
  319. package/src/scripts/check_generator_sync.ts +71 -0
  320. package/src/scripts/check_hook_bundle_composition.ts +370 -0
  321. package/src/scripts/check_host_docs_digest.ts +786 -0
  322. package/src/scripts/check_host_format_column.ts +559 -0
  323. package/src/scripts/check_input_references.ts +228 -0
  324. package/src/scripts/check_inputs_parity.ts +235 -0
  325. package/src/scripts/check_kernel_edit_ratified.ts +89 -9
  326. package/src/scripts/check_kill_switch_table.ts +294 -0
  327. package/src/scripts/check_memory.ts +7 -1
  328. package/src/scripts/check_placeholder_drift.ts +221 -0
  329. package/src/scripts/check_read_surface_coverage.ts +515 -0
  330. package/src/scripts/check_release_highlights.ts +10 -0
  331. package/src/scripts/check_release_holds.ts +2 -1
  332. package/src/scripts/check_release_pr_shape.ts +38 -15
  333. package/src/scripts/check_routing_coverage.ts +378 -11
  334. package/src/scripts/check_standing_rule_delivery.ts +33 -13
  335. package/src/scripts/check_static_parity.ts +17 -0
  336. package/src/scripts/check_verify_expectation_delta.ts +521 -0
  337. package/src/scripts/ci_settle.ts +145 -19
  338. package/src/scripts/closure_scan.ts +115 -1
  339. package/src/scripts/code_graph/detect.ts +127 -7
  340. package/src/scripts/code_graph/foreign.ts +194 -0
  341. package/src/scripts/code_graph/query.ts +217 -4
  342. package/src/scripts/code_graph/types.ts +26 -2
  343. package/src/scripts/code_graph/validate.ts +3 -1
  344. package/src/scripts/code_graph/verbs.ts +103 -0
  345. package/src/scripts/compile_hook_manifest.ts +90 -14
  346. package/src/scripts/condense.ts +14 -27
  347. package/src/scripts/estate_exemption_shape.ts +202 -0
  348. package/src/scripts/external_sources_denylist.json +29 -4
  349. package/src/scripts/generate_host_cost_table.ts +1 -1
  350. package/src/scripts/hook_manifest.json +1 -1
  351. package/src/scripts/hook_manifest.yaml +192 -14
  352. package/src/scripts/hooks/block_config_weakening.ts +294 -4
  353. package/src/scripts/hooks/block_kernel_rule_writes.ts +9 -78
  354. package/src/scripts/hooks/block_plumbing_writes.ts +342 -0
  355. package/src/scripts/hooks/bundle_integrity.ts +291 -0
  356. package/src/scripts/hooks/chain_nudge_hook.ts +1 -2
  357. package/src/scripts/hooks/code_graph_context_hook.ts +253 -25
  358. package/src/scripts/hooks/comment_discipline_hook.ts +1 -3
  359. package/src/scripts/hooks/concern_failure_policy.ts +280 -0
  360. package/src/scripts/hooks/concern_registry.ts +2 -0
  361. package/src/scripts/hooks/concern_timings.ts +94 -0
  362. package/src/scripts/hooks/delegation_nudge_hook.ts +1 -2
  363. package/src/scripts/hooks/design_pass_hook.ts +100 -9
  364. package/src/scripts/hooks/design_slop_hook.ts +1 -2
  365. package/src/scripts/hooks/dispatch_hook.ts +77 -71
  366. package/src/scripts/hooks/edit_shape_hook.ts +1 -2
  367. package/src/scripts/hooks/end_review_nudge_hook.ts +1 -1
  368. package/src/scripts/hooks/evidence_independence.ts +1 -2
  369. package/src/scripts/hooks/exit_codes.ts +129 -0
  370. package/src/scripts/hooks/host_lowering.json +1 -0
  371. package/src/scripts/hooks/host_lowering.ts +146 -3
  372. package/src/scripts/hooks/host_lowering.yaml +262 -46
  373. package/src/scripts/hooks/host_semantics.ts +30 -2
  374. package/src/scripts/hooks/injection_budget.ts +9 -2
  375. package/src/scripts/hooks/interruption_ledger_hook.ts +1 -2
  376. package/src/scripts/hooks/journal_record_hook.ts +1 -2
  377. package/src/scripts/hooks/memory_recall_hook.ts +1 -2
  378. package/src/scripts/hooks/obligation_settle_hook.ts +151 -5
  379. package/src/scripts/hooks/one_question_per_ask_hook.ts +43 -6
  380. package/src/scripts/hooks/orchestration_record_hook.ts +1 -2
  381. package/src/scripts/hooks/payload_stub.ts +10 -1
  382. package/src/scripts/hooks/permission_gate.ts +1 -2
  383. package/src/scripts/hooks/push_settle_hook.ts +1 -3
  384. package/src/scripts/hooks/replay_hook.ts +2 -1
  385. package/src/scripts/hooks/reread_guard_hook.ts +1 -2
  386. package/src/scripts/hooks/review_baseline_hook.ts +1 -2
  387. package/src/scripts/hooks/rtk_wrap_hook.ts +1 -2
  388. package/src/scripts/hooks/rule_inject_hook.ts +765 -68
  389. package/src/scripts/hooks/run_continuation_hook.ts +13 -15
  390. package/src/scripts/hooks/skill_route_hook.ts +1 -2
  391. package/src/scripts/hooks/source_first_gate_hook.ts +1 -2
  392. package/src/scripts/hooks/spawn_guard_shadow_hook.ts +1 -2
  393. package/src/scripts/hooks/subagent_ledger_hook.ts +1 -2
  394. package/src/scripts/hooks/suggestion_capture_hook.ts +1 -1
  395. package/src/scripts/hooks/table_fingerprint.ts +70 -0
  396. package/src/scripts/hooks/telemetry_flush_hook.ts +1 -2
  397. package/src/scripts/hooks/telemetry_usage_hook.ts +65 -10
  398. package/src/scripts/hooks/tool_result_bytes_hook.ts +93 -9
  399. package/src/scripts/hooks/turn_end_gate_hook.ts +421 -324
  400. package/src/scripts/hooks/ui_route_nudge_hook.ts +1 -2
  401. package/src/scripts/hooks_status.ts +112 -0
  402. package/src/scripts/injection_scan_hook.ts +7 -0
  403. package/src/scripts/install.ts +75 -189
  404. package/src/scripts/installed_layer_report.ts +103 -0
  405. package/src/scripts/language_mirror_hook.ts +15 -6
  406. package/src/scripts/learning_sidecar.ts +119 -8
  407. package/src/scripts/lint_deny_text.ts +419 -0
  408. package/src/scripts/lint_exit_codes.ts +262 -0
  409. package/src/scripts/lint_hook_manifest.ts +108 -0
  410. package/src/scripts/lint_major_migration_sections.ts +156 -26
  411. package/src/scripts/lint_pack_boundaries.ts +63 -5
  412. package/src/scripts/lint_roadmap_blockers.ts +78 -12
  413. package/src/scripts/lint_rule_law_section.ts +517 -0
  414. package/src/scripts/lint_skill_descriptions.ts +1 -1
  415. package/src/scripts/lint_skill_scripts_readonly.ts +1 -1
  416. package/src/scripts/lint_workflow_security.ts +161 -5
  417. package/src/scripts/mcp_server/consumer_tool_catalog.json +53 -0
  418. package/src/scripts/mcp_server/graph_tools.ts +72 -3
  419. package/src/scripts/mcp_server/prompts.ts +142 -1
  420. package/src/scripts/measure_routing_signal.ts +54 -16
  421. package/src/scripts/measure_skill_ranker_baseline.ts +515 -53
  422. package/src/scripts/measure_turn_end_gate.ts +193 -0
  423. package/src/scripts/memory_learn_hook.ts +91 -6
  424. package/src/scripts/memory_lookup.ts +1 -1
  425. package/src/scripts/model_rule_injection.ts +52 -5
  426. package/src/scripts/prepack-check.mjs +53 -0
  427. package/src/scripts/project_thin_rules.ts +34 -327
  428. package/src/scripts/release_publication.ts +13 -1
  429. package/src/scripts/report_evidence_temperature.ts +657 -0
  430. package/src/scripts/report_installed_rule_links.ts +157 -0
  431. package/src/scripts/report_invocation_surface.ts +383 -0
  432. package/src/scripts/report_label_agreement.ts +233 -0
  433. package/src/scripts/report_obligation_carriers.ts +699 -0
  434. package/src/scripts/report_obligation_mechanism.ts +230 -0
  435. package/src/scripts/report_obligation_writer_split.ts +236 -0
  436. package/src/scripts/report_single_token_triggers.ts +277 -0
  437. package/src/scripts/report_skill_menu_census.ts +208 -0
  438. package/src/scripts/report_skill_ranker_confusion.ts +442 -0
  439. package/src/scripts/report_standing_payload_by_host.ts +188 -15
  440. package/src/scripts/report_verification_record_coverage.ts +306 -0
  441. package/src/scripts/roadmap_verify_share.ts +213 -0
  442. package/src/scripts/rule_activation_census.ts +5 -2
  443. package/src/scripts/rule_trigger_eval.ts +9 -9
  444. package/src/scripts/schemas/command.schema.json +44 -0
  445. package/src/scripts/schemas/review-findings.schema.json +7 -2
  446. package/src/scripts/schemas/rule.schema.json +5 -0
  447. package/src/scripts/schemas/skill.schema.json +44 -0
  448. package/src/scripts/self_review_gate.ts +421 -17
  449. package/src/scripts/semantic_parity_census.ts +314 -0
  450. package/src/scripts/session_eol_report.ts +54 -1
  451. package/src/scripts/skill_tools/score_skill_relevance.ts +290 -25
  452. package/src/scripts/sweep_dead_scan_roots.ts +1 -1
  453. package/src/scripts/sweep_skill_ranker_signals.ts +129 -0
  454. package/src/scripts/sync_gitignore.ts +29 -235
  455. package/src/scripts/trigger_eval_grandfather.json +0 -7
  456. package/src/scripts/trigger_eval_rotation.ts +122 -27
  457. package/src/scripts/ui_conformance_probe.ts +156 -4
  458. package/src/scripts/update_prices.ts +85 -3
  459. package/src/scripts/write_bundle_digest.mjs +74 -0
  460. package/src/server/schemas/settings.ts +41 -0
  461. package/src/shared/skillRanking.ts +117 -11
  462. package/src/templates/consumer-settings/ONBOARDING.md +1 -0
  463. package/dist/cli-delegate/chunk-FXYUYU4N.js +0 -4416
  464. package/dist/cli-delegate/chunk-QJWAUMJW.js +0 -1072
  465. package/dist/cli-delegate/chunk-YDPILCOL.js +0 -152
  466. package/dist/ui/assets/index-DH1xc62v.js +0 -29
  467. package/dist/ui/assets/index-jSfMW8_t.css +0 -1
@@ -1,38 +1,22 @@
1
1
  /**
2
2
  * Toolchain resolution — pick the right test/quality runner per stack.
3
3
  *
4
- * TypeScript twin of `work_engine/stack/runner.py` (ADR-200 py2ts). Leaf
5
- * module — stdlib only, NO intra-`work_engine` imports — so the public API
6
- * names stay snake_case to mirror the Python module 1:1 (per ADR-200: Python
7
- * style is part of the contract).
4
+ * **The contract lives in `contexts/execution/toolchain-resolver.md`**: what
5
+ * each axis answers, detection order, confidence tiers, the refusal doctrine,
6
+ * the cache key and what resolution costs. This header carries only what a
7
+ * reader OF THIS FILE needs and what the contract page cannot enforce.
8
8
  *
9
- * Sibling of {@link "./detect"} (which labels the *frontend* stack). This
10
- * module answers a different question: *given a project root, which test
11
- * runner and quality tools does this stack actually use, and what is the
12
- * exact command to invoke them?* It is the engine behind 6.1.0 Step 6 — the
13
- * toolchain resolver that lets one set of commands (`/tests execute`,
14
- * `/tests create`, `/fix quality`, `/review changes`, `/work`) adapt to
15
- * phpunit / pest / vitest / jest / playwright / pytest / go / cargo instead
16
- * of exploding into per-stack command variants.
9
+ * Originally the TypeScript twin of `work_engine/stack/runner.py` (ADR-200
10
+ * py2ts). Leaf module — stdlib only, NO intra-`work_engine` imports — and the
11
+ * public API names stay snake_case, because that style IS the contract. The
12
+ * 1:1 parity claim no longer holds: the behavior-runner axis and the rspec /
13
+ * junit / dotnet-test labels are TypeScript-only, and the parity test was
14
+ * renamed rather than quietly widened. Sibling of {@link "./detect"}, which
15
+ * labels the *frontend* stack.
17
16
  *
18
- * Detection is filesystem-cheap and **never crashes**: a malformed manifest,
19
- * a missing file, or an unknown stack degrades to a LOW-confidence empty
20
- * result rather than raising — a wrong toolchain label is recoverable (the
21
- * agent can ask), a crash mid-run is not. This mirrors the recoverable-error
22
- * contract in {@link "./detect"}.
23
- *
24
- * Three opt-in flags shape the *selected* command set (the monorepo guard):
25
- *
26
- * - `include_e2e` — by default e2e suites (playwright / cypress) are
27
- * excluded; fast unit tests run first. Pass to add them.
28
- * - `include_slow` — by default a script tagged `test:slow` /
29
- * `test:integration` is excluded. Pass to add it.
30
- * - `php_only` — keep only the PHP ecosystem (the `--php` narrowing the
31
- * roadmap calls for; "only genuine PHP-space commands stay PHP-locked").
32
- *
33
- * The full per-stack inventory is always returned (`runners`); the
34
- * `selected` tuple is what a command should actually run after applying
35
- * the flags + the fast-by-default monorepo guard.
17
+ * Detection **never crashes** — a malformed manifest, a missing file or an
18
+ * unknown stack degrades to a LOW-confidence empty result, and EVERY
19
+ * filesystem helper here is guarded, which is what makes that true.
36
20
  */
37
21
  import * as fs from 'node:fs';
38
22
  import * as path from 'node:path';
@@ -40,8 +24,10 @@ import * as path from 'node:path';
40
24
  /**
41
25
  * Every test-runner label the resolver can emit.
42
26
  *
43
- * Single source of truth so the state schema, fixtures, and tests validate
44
- * against one set without re-deriving it. Mirrors Python's `frozenset`.
27
+ * Single source of truth: the fixtures validate against this set without
28
+ * re-deriving it, and a membership test binds it to what the resolver emits —
29
+ * without that binding it would assert set-equality with itself. Mirrors
30
+ * Python's `frozenset`.
45
31
  */
46
32
  export const KNOWN_RUNNERS: ReadonlySet<string> = new Set([
47
33
  'pest',
@@ -53,6 +39,31 @@ export const KNOWN_RUNNERS: ReadonlySet<string> = new Set([
53
39
  'pytest',
54
40
  'go-test',
55
41
  'cargo-test',
42
+ 'rspec',
43
+ 'junit',
44
+ 'dotnet-test',
45
+ ]);
46
+
47
+ /**
48
+ * Every behavior-runner label the resolver can emit, plus the refusal.
49
+ *
50
+ * A **separate** set from {@link KNOWN_RUNNERS}: the two answer different
51
+ * questions — *what runs the tests* versus *does this repository already own a
52
+ * tool that reads a specification* — and merging them would put a suite nobody
53
+ * asked to run into `selected`. {@link BEHAVIOR_UNKNOWN} is the refusal label.
54
+ */
55
+ export const BEHAVIOR_UNKNOWN = 'unknown';
56
+
57
+ export const KNOWN_BEHAVIOR_RUNNERS: ReadonlySet<string> = new Set([
58
+ 'behat',
59
+ 'cucumber-js',
60
+ 'cucumber-ruby',
61
+ 'cucumber-jvm',
62
+ 'behave',
63
+ 'pytest-bdd',
64
+ 'reqnroll',
65
+ 'specflow',
66
+ BEHAVIOR_UNKNOWN,
56
67
  ]);
57
68
 
58
69
  // Speed buckets. `fast` runs by default; `slow` needs `include_slow`;
@@ -77,6 +88,84 @@ const _MANIFESTS = [
77
88
  'Makefile',
78
89
  'Taskfile.yml',
79
90
  'Taskfile.yaml',
91
+ 'Gemfile',
92
+ 'pom.xml',
93
+ 'build.gradle',
94
+ 'build.gradle.kts',
95
+ // `settings.gradle[.kts]` is a Gradle signal `_jvm_build` acts on, so it
96
+ // must be in the key: a settings-only multi-project root emits a junit row
97
+ // and would otherwise report the greenfield `mtime: 0` forever.
98
+ 'settings.gradle',
99
+ 'settings.gradle.kts',
100
+ 'global.json',
101
+ 'Directory.Build.props',
102
+ ];
103
+
104
+ /**
105
+ * .NET project / solution extensions, matched by a root listing.
106
+ *
107
+ * .NET is the one ecosystem whose marker has no fixed FILENAME (the project
108
+ * file is `<whatever>.csproj`), so this is an extension list over the root.
109
+ *
110
+ * **Residual cache gap.** {@link latest_manifest_mtime} stats fixed NAMES, so
111
+ * a signal it does not list is invisible to the key: a project-only .NET
112
+ * scope, `features/support/env.rb`, `spec/spec_helper.rb`, `setup.cfg`,
113
+ * `pytest.ini` and the `mvnw` / `gradlew` wrappers. Known, not closed.
114
+ */
115
+ const _DOTNET_PROJECT_EXTS = ['.sln', '.slnx', '.csproj', '.fsproj', '.vbproj'];
116
+
117
+ /** The packages that make a .NET project a TEST project, for the HIGH tier. */
118
+ const _DOTNET_TEST_STACK = /Microsoft\.NET\.Test\.Sdk|xunit|nunit|mstest/i;
119
+
120
+ /**
121
+ * How deep `_dotnet_project_text` looks. The conventional layout keeps the
122
+ * solution at the root and projects at `src/<Name>/<Name>.csproj`, which a
123
+ * root-only scan misses entirely. Depth 2 reaches it; a flat root still costs
124
+ * one listing, since the walk descends only when the scope holds no project.
125
+ */
126
+ const _DOTNET_SCAN_DEPTH = 2;
127
+
128
+ /** Ceiling on project files read per scope, once the descent is entered at all. */
129
+ const _DOTNET_MAX_PROJECTS = 50;
130
+
131
+ /** Solution extensions — named once, so the descent and its hit test agree. */
132
+ const _DOTNET_SOLUTION_EXTS = ['.sln', '.slnx'];
133
+
134
+ /** Does this scope hold a solution file? The precondition for descending. */
135
+ function _has_dotnet_solution(dir: string): boolean {
136
+ try {
137
+ // `withFileTypes`: an extension list over bare NAMES counts a
138
+ // DIRECTORY called `x.sln` as a target. The walk already filters.
139
+ return fs
140
+ .readdirSync(dir, { withFileTypes: true })
141
+ .some((e) => e.isFile() && _DOTNET_SOLUTION_EXTS.includes(path.extname(e.name).toLowerCase()));
142
+ } catch {
143
+ return false;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Fixed-name files the BEHAVIOR axis reads that `_MANIFESTS` does not cover.
149
+ *
150
+ * **The split is historical and no rule separates them.** `_MANIFESTS` was
151
+ * once "what selects an ecosystem", but selection happens through individual
152
+ * `_is_file` / `_read_text` calls, and `.rspec` is a native signal in THIS
153
+ * list. Both are read in one place — so **a new fixed name goes in either;
154
+ * what matters is ONE.** What escapes: {@link _DOTNET_PROJECT_EXTS}.
155
+ */
156
+ const _BEHAVIOR_MARKERS = [
157
+ 'pnpm-workspace.yaml',
158
+ 'requirements.txt',
159
+ 'gems.rb',
160
+ '.rspec',
161
+ 'behat.yml',
162
+ 'behat.yaml',
163
+ 'behat.yml.dist',
164
+ 'behat.dist.yml',
165
+ 'cucumber.js',
166
+ 'cucumber.cjs',
167
+ 'cucumber.mjs',
168
+ 'cucumber.json',
80
169
  ];
81
170
 
82
171
  /** A heterogeneous JSON object, mirroring a Python `dict[str, object]`. */
@@ -88,16 +177,10 @@ type Wrappers = { [role: string]: string };
88
177
  /**
89
178
  * One detected test runner for one ecosystem.
90
179
  *
91
- * `command` is the exact invocation to run the suite (a task-runner
92
- * wrapper like `make test` when one exists, otherwise the direct tool).
93
- * `speed` is one of {@link SPEED_FAST} / {@link SPEED_SLOW} /
94
- * {@link SPEED_E2E}; the monorepo guard filters on it. `basis` names the
95
- * concrete signal that matched (dependency, binary, marker file) so the
96
- * routing decision is auditable, exactly like the non-interactive-contract's
97
- * `basis` column.
98
- *
99
- * Mirrors the Python `@dataclass(frozen=True)` with positional construction
100
- * and the documented field defaults.
180
+ * `command` is the exact invocation (a wrapper like `make test` when one
181
+ * exists, else the direct tool) and is ROOT-relative. `speed` is
182
+ * {@link SPEED_FAST} / {@link SPEED_SLOW} / {@link SPEED_E2E}; `basis` names
183
+ * the signal that matched.
101
184
  */
102
185
  export class RunnerResult {
103
186
  readonly ecosystem: string;
@@ -124,16 +207,41 @@ export class RunnerResult {
124
207
  }
125
208
  }
126
209
 
210
+ /**
211
+ * One behavior runner found in one scope — DETECTION ONLY.
212
+ *
213
+ * **`command` is relative to `scope_root`, not the project root** — built from
214
+ * files inside the scope (`vendor/bin/behat`, `./gradlew test`), so a consumer
215
+ * runs it WITH `scope_root` as the working directory. The native axis is
216
+ * root-relative; this one is the exception.
217
+ *
218
+ * {@link RunnerResult}'s fields plus `scope_root`, because the axis is
219
+ * **per-scope, not a repository-wide scalar**: one answer would erase which
220
+ * package owns which runner, and would break the tie the refusal exists to
221
+ * refuse. `scope_root` is POSIX-relative, `'.'` for the root; `conflict` is
222
+ * non-empty only on a refusal row. **It never instructs adoption.**
223
+ */
224
+ export class BehaviorRunnerResult {
225
+ constructor(
226
+ readonly ecosystem: string,
227
+ readonly runner: string,
228
+ readonly command: string,
229
+ readonly scope_root: string,
230
+ readonly confidence: string = HIGH,
231
+ readonly basis = '',
232
+ readonly conflict: readonly string[] = [],
233
+ ) {}
234
+ }
235
+
127
236
  /**
128
237
  * Outcome of one toolchain-resolution pass over a project root.
129
238
  *
130
- * `runners` is the full inventory (every ecosystem detected, every speed
131
- * bucket). `selected` is what a command should actually run after the flags
132
- * + fast-by-default guard. `quality` is the ordered list of quality/lint
133
- * commands per detected ecosystem. `confidence` is the overall tier (HIGH
134
- * when ≥1 runner matched deterministically and no cross-ecosystem conflict;
135
- * LOW when nothing matched). `mtime` is the latest manifest mtime, used for
136
- * cache invalidation just like {@link "./detect".StackResult}.
239
+ * `runners` is the full inventory; `selected` what a command runs after the
240
+ * flags + guard; `quality` the ordered lint commands per ecosystem;
241
+ * `confidence` the overall tier, about the RUNNER and never about `quality`
242
+ * (ruby, jvm and dotnet contribute a runner and no quality commands, so an
243
+ * empty `quality` means none was RESOLVED, not that none is needed — contract
244
+ * page § 3); `mtime` the cache key, as in {@link "./detect".StackResult}.
137
245
  */
138
246
  export class ToolchainResult {
139
247
  readonly ecosystems: readonly string[];
@@ -142,6 +250,22 @@ export class ToolchainResult {
142
250
  readonly quality: readonly string[];
143
251
  readonly confidence: string;
144
252
  readonly mtime: number;
253
+ /**
254
+ * Per-scope behavior inventory — detection only, deliberately NOT merged
255
+ * into `runners` nor reachable from `selected`, so nothing is ever run
256
+ * BECAUSE this axis detected it.
257
+ *
258
+ * That is a claim about reachability, not about execution, and the two
259
+ * came apart in review: for four of the eight labels the behavior command
260
+ * is byte-identical to a native command already in `selected` —
261
+ * `pytest-bdd`/`pytest`, `cucumber-jvm`/`jvm.command`, and both
262
+ * `reqnroll` and `specflow`/`dotnet test`. Those native rows are
263
+ * SPEED_FAST, so the suite does run by default; it runs as the native
264
+ * runner's work, which this axis neither caused nor can prevent. Stating
265
+ * it as "reported, never run" overreached in exactly the half a reader
266
+ * would act on.
267
+ */
268
+ readonly behavior_runners: readonly BehaviorRunnerResult[];
145
269
 
146
270
  constructor(args: {
147
271
  ecosystems: readonly string[];
@@ -150,6 +274,7 @@ export class ToolchainResult {
150
274
  quality: readonly string[];
151
275
  confidence: string;
152
276
  mtime: number;
277
+ behavior_runners?: readonly BehaviorRunnerResult[];
153
278
  }) {
154
279
  this.ecosystems = args.ecosystems;
155
280
  this.runners = args.runners;
@@ -157,6 +282,7 @@ export class ToolchainResult {
157
282
  this.quality = args.quality;
158
283
  this.confidence = args.confidence;
159
284
  this.mtime = args.mtime;
285
+ this.behavior_runners = args.behavior_runners ?? [];
160
286
  }
161
287
 
162
288
  /**
@@ -183,6 +309,15 @@ export class ToolchainResult {
183
309
  })),
184
310
  selected: this.selected.map((r) => r.command),
185
311
  quality: [...this.quality],
312
+ behavior_runners: this.behavior_runners.map((r) => ({
313
+ ecosystem: r.ecosystem,
314
+ runner: r.runner,
315
+ command: r.command,
316
+ scope_root: r.scope_root,
317
+ confidence: r.confidence,
318
+ basis: r.basis,
319
+ conflict: [...r.conflict],
320
+ })),
186
321
  };
187
322
  }
188
323
  }
@@ -190,20 +325,14 @@ export class ToolchainResult {
190
325
  /**
191
326
  * Inspect `project_root` and resolve its test/quality toolchain.
192
327
  *
193
- * @param project_root
194
- * Directory carrying the manifests (`composer.json` / `package.json` /
195
- * `pyproject.toml` / `go.mod` / `Cargo.toml`). The resolver does not walk
196
- * upwards — the caller picks the scope, matching `detect.detect_stack`.
197
- * @param opts.include_slow @param opts.include_e2e
198
- * Monorepo guard. Off by default → `selected` carries only fast unit
199
- * suites. Turn on to add the slow / e2e buckets.
200
- * @param opts.php_only
201
- * The `--php` narrowing — keep only the PHP ecosystem in `selected` (the
202
- * full inventory is still returned in `runners`).
203
- * @returns
204
- * A {@link ToolchainResult}. Never raises. No manifest / unknown stack →
205
- * an empty result with `confidence == LOW` so the caller can fall back to
206
- * asking.
328
+ * @param project_root Manifest directory; no upward walk, the caller picks
329
+ * the scope, matching `detect.detect_stack`.
330
+ * @param opts.include_slow @param opts.include_e2e Monorepo guard, off by
331
+ * default: `selected` carries only fast unit suites.
332
+ * @param opts.php_only The `--php` narrowing — PHP only in `selected`; the
333
+ * full inventory stays in `runners`.
334
+ * @returns A {@link ToolchainResult}. Never raises; no manifest or unknown
335
+ * stack → empty at LOW confidence, so the caller can ask.
207
336
  */
208
337
  export function resolve_toolchain(
209
338
  project_root: string,
@@ -228,6 +357,9 @@ export function resolve_toolchain(
228
357
  _is_file(path.join(project_root, 'pytest.ini'));
229
358
  const has_go = _is_file(path.join(project_root, 'go.mod'));
230
359
  const has_cargo = _is_file(path.join(project_root, 'Cargo.toml'));
360
+ const gemfile_text = _ruby_gemfile_text(project_root);
361
+ const jvm = _jvm_build(project_root);
362
+ const dotnet_basis = _dotnet_basis(project_root);
231
363
 
232
364
  const wrappers = _task_runner_wrappers(project_root, pkg);
233
365
 
@@ -255,10 +387,18 @@ export function resolve_toolchain(
255
387
  );
256
388
  quality.push('cargo clippy');
257
389
  }
390
+ runners.push(..._ruby_runners(project_root, gemfile_text));
391
+ if (jvm !== null) {
392
+ runners.push(..._jvm_runners(jvm));
393
+ }
394
+ if (dotnet_basis !== null) {
395
+ runners.push(..._dotnet_runners(dotnet_basis));
396
+ }
258
397
 
259
398
  const ecosystems = _dictFromKeys(runners.map((r) => r.ecosystem));
260
399
  const selected = _apply_guard(runners, { include_slow, include_e2e, php_only });
261
400
  const confidence = _overall_confidence(runners);
401
+ const scopes = _behavior_scopes(project_root);
262
402
 
263
403
  return new ToolchainResult({
264
404
  ecosystems,
@@ -266,7 +406,11 @@ export function resolve_toolchain(
266
406
  selected: [...selected],
267
407
  quality: _dictFromKeys(quality),
268
408
  confidence,
269
- mtime: latest_manifest_mtime(project_root),
409
+ // Computed ONCE and threaded into both consumers: workspace discovery
410
+ // reads a manifest and lists per glob parent, and both the cache probe
411
+ // and the behavior axis need the same list.
412
+ mtime: latest_manifest_mtime(project_root, scopes),
413
+ behavior_runners: resolve_behavior_runners(project_root, scopes),
270
414
  });
271
415
  }
272
416
 
@@ -300,20 +444,31 @@ export function write_config(project_root: string, result: ToolchainResult): str
300
444
  * when no manifest exists (greenfield) — a stable sentinel, not a
301
445
  * missing-file error.
302
446
  */
303
- export function latest_manifest_mtime(project_root: string): number {
447
+ export function latest_manifest_mtime(
448
+ project_root: string,
449
+ scopes?: readonly string[],
450
+ ): number {
304
451
  const mtimes: number[] = [];
305
- for (const name of _MANIFESTS) {
306
- const p = path.join(project_root, name);
307
- if (_is_file(p)) {
308
- mtimes.push(_stat_mtime(p));
452
+ const names = [..._MANIFESTS, ..._BEHAVIOR_MARKERS];
453
+ // Every scope, not only the root: `behavior_runners` is cached per scope,
454
+ // so a root-only key cannot see Behat arriving in a package — stale forever
455
+ // in the common case, and widening only invalidates more often. Cost as a
456
+ // SHAPE, since the arithmetic was published wrong once: both name lists
457
+ // stat-ed per scope, bounded by _MAX_BEHAVIOR_SCOPES, plus
458
+ // `_behavior_scopes` when `scopes` is omitted.
459
+ for (const scope of scopes ?? _behavior_scopes(project_root)) {
460
+ const dir = scope === '.' ? project_root : path.join(project_root, scope);
461
+ for (const name of names) {
462
+ const p = path.join(dir, name);
463
+ if (_is_file(p)) {
464
+ mtimes.push(_stat_mtime(p));
465
+ }
309
466
  }
310
467
  }
311
468
  return mtimes.length > 0 ? Math.max(...mtimes) : 0.0;
312
469
  }
313
470
 
314
- // --------------------------------------------------------------------------
315
471
  // Ecosystem resolvers
316
- // --------------------------------------------------------------------------
317
472
 
318
473
  function _php_runners(root: string, composer: Manifest, wrappers: Wrappers): RunnerResult[] {
319
474
  const deps = _all_dependencies(composer, 'require', 'require-dev');
@@ -422,6 +577,533 @@ function _python_runners(pyproject_text: string): RunnerResult[] {
422
577
  ];
423
578
  }
424
579
 
580
+ /**
581
+ * Ruby — `rspec` ONLY on an explicit signal, never a default: Ruby ships
582
+ * minitest in the stdlib, so a Gemfile with no rspec signal is probably
583
+ * minitest, and a MEDIUM `rspec` would be a guess wearing a label.
584
+ */
585
+ /**
586
+ * The Ruby dependency declaration, under either name Bundler accepts —
587
+ * reading only `Gemfile` made a `gems.rb` project invisible to both branches.
588
+ */
589
+ interface Gemfile {
590
+ text: string;
591
+ file: string;
592
+ /** The FILE exists — `bundle exec` keys on this, never on the text parsing. */
593
+ present: boolean;
594
+ }
595
+
596
+ function _ruby_gemfile_text(dir: string): Gemfile {
597
+ let present = false;
598
+ for (const name of ['Gemfile', 'gems.rb']) {
599
+ const full = path.join(dir, name);
600
+ // Same presence-not-content split the Gradle branch makes.
601
+ present = present || _is_file(full);
602
+ const text = _read_text(full);
603
+ if (text !== '') {
604
+ // The NAME travels with the text: the basis is serialised and
605
+ // names the file that matched, so a `gems.rb` project is not told
606
+ // its signal came from a `Gemfile` it does not have.
607
+ return { text, file: name, present: true };
608
+ }
609
+ }
610
+ return { text: '', file: '', present };
611
+ }
612
+
613
+ /** `gem 'rspec'` / `gem "cucumber"` — a DECLARATION, not the word anywhere. */
614
+ function _gem_declared(text: string, gem: string): boolean {
615
+ // Same discipline as `_PY_BEHAVE`: a bare `\brspec\b` fires on a
616
+ // commented-out gem, a changelog line, or any name containing the word. A
617
+ // hyphenated SUFFIX is allowed (`rspec-rails`, `cucumber-rails`) but a
618
+ // suffix ONLY, so `cucumber` matches no gem merely ending in it.
619
+ return new RegExp(`^[ \\t]*gem[ \\t]+["']${gem}(-[A-Za-z0-9_-]+)?["']`, 'm').test(text);
620
+ }
621
+
622
+ function _ruby_runners(root: string, gemfile: Gemfile): RunnerResult[] {
623
+ const dot_rspec = _is_file(path.join(root, '.rspec'));
624
+ const spec_helper = _is_file(path.join(root, 'spec', 'spec_helper.rb'));
625
+ const in_gemfile = _gem_declared(gemfile.text, 'rspec');
626
+ // No `Gemfile` precondition: each of the three signals stands on its own,
627
+ // as the contract table says. Gating on the Gemfile made the two marker
628
+ // bases unreachable in the projects that have only markers.
629
+ if (!in_gemfile && !dot_rspec && !spec_helper) {
630
+ return [];
631
+ }
632
+ const basis = in_gemfile
633
+ ? `rspec in ${gemfile.file}`
634
+ : dot_rspec
635
+ ? '.rspec present'
636
+ : 'spec/spec_helper.rb present';
637
+ // `bundle exec` ABORTS without a Gemfile, and the marker signals exist
638
+ // precisely for projects that have none — so the prefix keys on the file
639
+ // that makes it work, not on the signal that found the suite.
640
+ const cmd = gemfile.present ? 'bundle exec rspec' : 'rspec';
641
+ return [new RunnerResult('ruby', 'rspec', cmd, SPEED_FAST, HIGH, basis)];
642
+ }
643
+
644
+ /** Which JVM build system answered, its text, and the command prefix to use. */
645
+ interface JvmBuild {
646
+ tool: 'maven' | 'gradle';
647
+ text: string;
648
+ command: string;
649
+ }
650
+
651
+ /**
652
+ * Locate the JVM build file and pick the invocation.
653
+ *
654
+ * Wrapper-first, like `_task_runner_wrappers`: `./gradlew` / `./mvnw` pin the
655
+ * build-tool version, so the ambient `gradle` / `mvn` can fail on one the
656
+ * project never used.
657
+ */
658
+ function _jvm_build(root: string): JvmBuild | null {
659
+ const pom = _read_text(path.join(root, 'pom.xml'));
660
+ if (pom !== '') {
661
+ const wrapper = _is_file(path.join(root, 'mvnw'));
662
+ return { tool: 'maven', text: pom, command: wrapper ? './mvnw test' : 'mvn test' };
663
+ }
664
+ // PRESENCE, not content: a Gradle multi-project root routinely has an
665
+ // empty `build.gradle`, or none with `settings.gradle[.kts]` declaring
666
+ // the modules, and both looked like "no Gradle here". Maven above still
667
+ // gates on content — recorded in the roadmap residue.
668
+ const gradleFile = ['build.gradle', 'build.gradle.kts', 'settings.gradle', 'settings.gradle.kts'].find(
669
+ (n) => _is_file(path.join(root, n)),
670
+ );
671
+ if (gradleFile !== undefined) {
672
+ const wrapper = _is_file(path.join(root, 'gradlew'));
673
+ return {
674
+ tool: 'gradle',
675
+ text:
676
+ _read_text(path.join(root, 'build.gradle')) +
677
+ '\n' +
678
+ _read_text(path.join(root, 'build.gradle.kts')),
679
+ command: wrapper ? './gradlew test' : 'gradle test',
680
+ };
681
+ }
682
+ return null;
683
+ }
684
+
685
+ function _jvm_runners(build: JvmBuild): RunnerResult[] {
686
+ const named = /\bjunit\b/i.test(build.text);
687
+ const conf = named ? HIGH : MEDIUM;
688
+ const basis = named
689
+ ? `junit in the ${build.tool} build file`
690
+ : `${build.tool} build file present, no explicit runner`;
691
+ return [new RunnerResult('jvm', 'junit', build.command, SPEED_FAST, conf, basis)];
692
+ }
693
+
694
+ /**
695
+ * .NET — the basis string, or `null` when nothing marks a .NET project.
696
+ *
697
+ * Mirrors the PHP gradation, awarding HIGH only for a NAMED runner: HIGH
698
+ * needs a PROJECT file naming a TEST stack (`Microsoft.NET.Test.Sdk`, xunit,
699
+ * nunit, mstest); a bare project or solution is MEDIUM, the analogue of the
700
+ * bare manifest. That stops a bare non-test project raising the WHOLE
701
+ * repository to HIGH, but does NOT keep the row out of `selected` —
702
+ * `_apply_guard` has never read `confidence` — which is why a marker with no
703
+ * project anywhere emits no row.
704
+ */
705
+ function _dotnet_basis(root: string): { basis: string; confidence: string } | null {
706
+ let names: string[];
707
+ try {
708
+ // SORTED: the basis names the matched file and is serialised, so an
709
+ // unsorted listing churns a stable config on rename. FILES only — a
710
+ // directory named `x.csproj` is no target, and this row reaches
711
+ // `selected`.
712
+ names = fs
713
+ .readdirSync(root, { withFileTypes: true })
714
+ .filter((e) => e.isFile())
715
+ .map((e) => e.name)
716
+ .sort();
717
+ } catch {
718
+ names = [];
719
+ }
720
+ // A ROOT TARGET, not a target anywhere. `dotnet test` is NOT recursive:
721
+ // with no project or solution in the working directory it fails MSB1003,
722
+ // whatever sits below. So the row is gated on this listing, and a scope
723
+ // whose only .NET evidence is an SDK pin gets NO row — confidence does not
724
+ // gate `selected`, so a row here would be a command that cannot run.
725
+ const target = names.find((n) => _DOTNET_PROJECT_EXTS.includes(path.extname(n).toLowerCase()));
726
+ if (target === undefined) {
727
+ return null;
728
+ }
729
+ // The DESCENT still matters for the tier: a root `.sln` is a valid target
730
+ // whose own body names no package, so the projects it references decide
731
+ // HIGH vs MEDIUM.
732
+ return _DOTNET_TEST_STACK.test(_dotnet_project_text(root))
733
+ ? { basis: 'test stack named in a .NET project file', confidence: HIGH }
734
+ : { basis: `${target} present, no test stack named`, confidence: MEDIUM };
735
+ }
736
+
737
+ function _dotnet_runners(found: { basis: string; confidence: string }): RunnerResult[] {
738
+ return [
739
+ new RunnerResult(
740
+ 'dotnet',
741
+ 'dotnet-test',
742
+ 'dotnet test',
743
+ SPEED_FAST,
744
+ found.confidence,
745
+ found.basis,
746
+ ),
747
+ ];
748
+ }
749
+
750
+ // --- Behavior-runner axis — per scope, list-shaped, detection only -------
751
+
752
+ /**
753
+ * Resolve every behavior runner this repository carries, one row per scope.
754
+ *
755
+ * Scopes are the root plus each declared workspace package: one row cannot
756
+ * represent the ordinary monorepo, a behavior runner in one package and plain
757
+ * unit tests in another. Two runners of ONE ecosystem in one scope refuse,
758
+ * naming both, never a pick. Never raises — an unreadable manifest yields
759
+ * fewer rows.
760
+ */
761
+ export function resolve_behavior_runners(
762
+ project_root: string,
763
+ scopes?: readonly string[],
764
+ ): BehaviorRunnerResult[] {
765
+ const out: BehaviorRunnerResult[] = [];
766
+ for (const scope of scopes ?? _behavior_scopes(project_root)) {
767
+ const dir = scope === '.' ? project_root : path.join(project_root, scope);
768
+ // Grouped by ECOSYSTEM: two ecosystems' behavior runners are a
769
+ // polyglot repository, not a conflict — a PHP app with a JS frontend
770
+ // is the commonest shape there is, and `unknown` throws both answers
771
+ // away. Only two runners in ONE ecosystem are mutually exclusive.
772
+ const rows = _behavior_runners_in_scope(dir, scope);
773
+ for (const ecosystem of _dictFromKeys(rows.map((r) => r.ecosystem))) {
774
+ const found = rows.filter((r) => r.ecosystem === ecosystem);
775
+ const names = [...new Set(found.map((r) => r.runner))].sort();
776
+ if (names.length <= 1) {
777
+ // One answer per ecosystem, however many signals produced it.
778
+ // The `>1 row, 1 name` case is unreachable today; without it a
779
+ // future branch emitting a label twice refuses it against itself.
780
+ out.push(found[0] as BehaviorRunnerResult);
781
+ continue;
782
+ }
783
+ out.push(
784
+ new BehaviorRunnerResult(
785
+ ecosystem,
786
+ BEHAVIOR_UNKNOWN,
787
+ '',
788
+ scope,
789
+ LOW,
790
+ `two ${ecosystem} behavior runners in one scope ` +
791
+ `(${names.join(', ')}) — refusing to choose, as the frontend ` +
792
+ 'detector refuses between mutually exclusive workspaces',
793
+ names,
794
+ ),
795
+ );
796
+ }
797
+ }
798
+ return out;
799
+ }
800
+
801
+ /** Behavior runners inside ONE scope directory, before conflict handling. */
802
+ function _behavior_runners_in_scope(dir: string, scope: string): BehaviorRunnerResult[] {
803
+ const out: BehaviorRunnerResult[] = [];
804
+ const at = (n: string): string => path.join(dir, n);
805
+ const row = (ecosystem: string, runner: string, command: string, basis: string): void => {
806
+ out.push(new BehaviorRunnerResult(ecosystem, runner, command, scope, HIGH, basis));
807
+ };
808
+
809
+ const php_deps = _all_dependencies(_read_json(at('composer.json')), 'require', 'require-dev');
810
+ const behat_config = ['behat.yml', 'behat.yaml', 'behat.yml.dist', 'behat.dist.yml'].find((n) =>
811
+ _is_file(at(n)),
812
+ );
813
+ if ('behat/behat' in php_deps || behat_config !== undefined) {
814
+ // "in the composer manifest", NOT "in composer require": the literal
815
+ // reads as an install instruction to a grep, and this axis may never be
816
+ // mistaken for one. And `vendor/bin/behat` exists only after a composer
817
+ // install, so a config-only scope gets the bare binary.
818
+ const declared = 'behat/behat' in php_deps;
819
+ const basis = declared
820
+ ? 'behat/behat in the composer manifest'
821
+ : `${behat_config} present`;
822
+ row('php', 'behat', declared ? 'vendor/bin/behat' : 'behat', basis);
823
+ }
824
+
825
+ // OWN dependencies only, unlike the native axis: a peer or optional
826
+ // declaration says a CONSUMER must supply cucumber — which is exactly how a
827
+ // formatter or preset declares it. On an axis whose doctrine is no guessing,
828
+ // reading one as ownership is the guess.
829
+ const js_deps = _all_dependencies(
830
+ _read_json(at('package.json')),
831
+ 'dependencies',
832
+ 'devDependencies',
833
+ );
834
+ const cucumber_config = ['cucumber.js', 'cucumber.cjs', 'cucumber.mjs', 'cucumber.json'].find(
835
+ (n) => _is_file(at(n)),
836
+ );
837
+ if ('@cucumber/cucumber' in js_deps || 'cucumber' in js_deps || cucumber_config !== undefined) {
838
+ const dep = '@cucumber/cucumber' in js_deps ? '@cucumber/cucumber' : 'cucumber';
839
+ // `npx` FETCHES when the package is absent, so on the config-only
840
+ // path it would let a detection-only axis install. Degrades like the
841
+ // behat and ruby rows.
842
+ const declared = dep in js_deps;
843
+ const basis = declared ? `${dep} in package deps` : `${cucumber_config} present`;
844
+ row('js', 'cucumber-js', declared ? 'npx cucumber-js' : 'cucumber-js', basis);
845
+ }
846
+
847
+ // Joined with a newline, like `_dotnet_project_text`: concatenated bare, a
848
+ // `pyproject.toml` with no trailing newline fuses its last token to the
849
+ // first of `requirements.txt`, making a token present in neither.
850
+ const py_text = [_read_text(at('pyproject.toml')), _read_text(at('requirements.txt'))].join('\n');
851
+ if (_PY_BEHAVE.test(py_text)) {
852
+ row('python', 'behave', 'behave', 'behave declared as a python dependency');
853
+ }
854
+ // Anchored like `_PY_BEHAVE`, for a sharper reason than symmetry: an
855
+ // unanchored match on a commented-out `# pytest-bdd` beside a real
856
+ // `behave` declaration made TWO python rows, which the grouping turned
857
+ // into a FALSE refusal. A false positive here deletes a true row.
858
+ if (_PY_BDD.test(py_text)) {
859
+ row('python', 'pytest-bdd', 'pytest', 'pytest-bdd declared as a python dependency');
860
+ }
861
+
862
+ const gemfile = _ruby_gemfile_text(dir);
863
+ const cucumber_gem = _gem_declared(gemfile.text, 'cucumber');
864
+ if (cucumber_gem || _is_file(path.join(dir, 'features', 'support', 'env.rb'))) {
865
+ // `bundle exec` ABORTS without a Gemfile and `env.rb` is the signal
866
+ // for scopes that have none — same guard as `_ruby_runners`.
867
+ const basis = cucumber_gem
868
+ ? `cucumber in ${gemfile.file}`
869
+ : 'features/support/env.rb present';
870
+ const cmd = gemfile.present ? 'bundle exec cucumber' : 'cucumber';
871
+ row('ruby', 'cucumber-ruby', cmd, basis);
872
+ }
873
+
874
+ // NOTE: `jvm.text` is the ROOT build files only, so a Gradle multi-project
875
+ // root whose modules declare cucumber is missed — recorded in the roadmap
876
+ // residue rather than fixed, because reading the included modules is new
877
+ // detection work rather than a correction.
878
+ const jvm = _jvm_build(dir);
879
+ if (jvm !== null && /io\.cucumber|cucumber-java|cucumber-junit/i.test(jvm.text)) {
880
+ row('jvm', 'cucumber-jvm', jvm.command, `cucumber in the ${jvm.tool} build file`);
881
+ }
882
+
883
+ // Two rows, not an `else if`. The chain was a silent pick: this reads the
884
+ // CONCATENATED text of every project, so a Reqnroll project beside a
885
+ // SpecFlow one collapsed into one HIGH row — the guess the refusal exists
886
+ // to prevent. Two rows let the grouping refuse, as it does for python.
887
+ const dotnet_text = _dotnet_project_text(dir);
888
+ if (/\bReqnroll\b/i.test(dotnet_text)) {
889
+ row('dotnet', 'reqnroll', 'dotnet test', 'Reqnroll in a project file');
890
+ }
891
+ if (/\bSpecFlow\b/i.test(dotnet_text)) {
892
+ row('dotnet', 'specflow', 'dotnet test', 'SpecFlow in a project file');
893
+ }
894
+
895
+ return out;
896
+ }
897
+
898
+ /**
899
+ * Concatenated text of the .NET project files belonging to this scope.
900
+ *
901
+ * Scans the scope and, holding no project file of its own, descends to
902
+ * {@link _DOTNET_SCAN_DEPTH}: the `root/src/<Name>/<Name>.csproj` layout,
903
+ * where the root carries only a `.sln` and a `.sln` names no package. Sorted
904
+ * at every level, so deterministic. Depth is bounded, and files READ are
905
+ * bounded — directories LISTED are not, and the max-projects exits fire only
906
+ * once a project was found.
907
+ */
908
+ function _dotnet_project_text(dir: string): string {
909
+ const texts: string[] = [];
910
+ // Gated on a solution. The gate suppresses the DESCENT only: this
911
+ // directory is listed here and again by the walk, in every scope of every
912
+ // repository. NOT pruned at scope boundaries either, so a root solution
913
+ // plus package scopes can still double-attribute — both residue.
914
+ const solution = _has_dotnet_solution(dir);
915
+ const walk = (at: string, depth: number): void => {
916
+ if (texts.length >= _DOTNET_MAX_PROJECTS) {
917
+ return;
918
+ }
919
+ let entries: fs.Dirent[];
920
+ try {
921
+ entries = fs.readdirSync(at, { withFileTypes: true });
922
+ } catch {
923
+ return;
924
+ }
925
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
926
+ let hit = false;
927
+ for (const e of entries) {
928
+ if (!e.isFile() || !_DOTNET_PROJECT_EXTS.includes(path.extname(e.name).toLowerCase())) {
929
+ continue;
930
+ }
931
+ // A SOLUTION's body is project NAMES, never packages — neither
932
+ // read as project text nor a reason to stop descending. Read,
933
+ // `MyApp.SpecFlow.Tests` emits a HIGH `specflow` row no project
934
+ // supports, and a legacy SpecFlow name beside a Reqnroll one makes
935
+ // two dotnet rows the grouping REFUSES — a false positive deleting
936
+ // a true row. Presence is still a descent signal.
937
+ if (_DOTNET_SOLUTION_EXTS.includes(path.extname(e.name).toLowerCase())) {
938
+ continue;
939
+ }
940
+ if (texts.length >= _DOTNET_MAX_PROJECTS) {
941
+ return;
942
+ }
943
+ texts.push(_read_text(path.join(at, e.name)));
944
+ hit = true;
945
+ }
946
+ if (hit || !solution || depth >= _DOTNET_SCAN_DEPTH) {
947
+ return;
948
+ }
949
+ for (const e of entries) {
950
+ if (e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules') {
951
+ walk(path.join(at, e.name), depth + 1);
952
+ }
953
+ }
954
+ };
955
+ walk(dir, 0);
956
+ return texts.join('\n');
957
+ }
958
+
959
+ /** Hard ceiling on scopes scanned — a pathological glob cannot stall a turn. */
960
+ const _MAX_BEHAVIOR_SCOPES = 200;
961
+
962
+ /**
963
+ * `behave` DECLARED AS A DEPENDENCY, never merely mentioned.
964
+ *
965
+ * `behave` is an ordinary English verb, unlike every sibling token here, so a
966
+ * bare `\bbehave\b` fires HIGH on a description or changelog line. Anchored to
967
+ * a line-start declaration, terminated by a version specifier, a separator or
968
+ * a `#` comment. Misses the SINGLE-LINE array form only — the multi-line
969
+ * array and the poetry inline table both match — a missed row either way,
970
+ * which is the cheap direction here.
971
+ */
972
+ const _PY_BEHAVE = /^[ \t]*["']?behave["']?[ \t]*(?:$|[=<>~!,;[#])/m;
973
+
974
+ /** `pytest-bdd` declared as a dependency — same anchoring, same reason. */
975
+ const _PY_BDD = /^[ \t]*["']?pytest-bdd["']?[ \t]*(?:$|[=<>~!,;[#])/m;
976
+
977
+ /**
978
+ * The `packages:` sequence of a `pnpm-workspace.yaml`, and ONLY that key.
979
+ *
980
+ * Matching every `- item` line is wrong in a way that reads as working: a
981
+ * real workspace file carries sibling sequences (`onlyBuiltDependencies`,
982
+ * `patchedDependencies`), so `- esbuild` under one became the scope
983
+ * `esbuild`. A line-state reader, not a YAML parse, because this module is
984
+ * leaf by contract: track whether the current top-level key is `packages`
985
+ * and accept items only while it is; any column-0 key ends the section.
986
+ *
987
+ * KNOWN GAP: a MULTI-LINE flow sequence (`packages: [` then item lines) is
988
+ * dropped whole — the single-line flow branch consumes the opener. Residue.
989
+ */
990
+ export function _pnpm_packages(text: string): string[] {
991
+ const out: string[] = [];
992
+ let inPackages = false;
993
+ const push = (raw: string): void => {
994
+ // A trailing `# comment` is legal YAML; strip before unquoting.
995
+ const v = raw.replace(/\s+#.*$/, '').trim().replace(/^['"]|['"]$/g, '').trim();
996
+ if (v !== '') out.push(v);
997
+ };
998
+ // `\r?\n`, not `\n`: `.` never matches `\r`, so on a CRLF file the key
999
+ // regex below matched NOTHING and the whole workspace silently collapsed
1000
+ // to the root scope — the same silent-drop class as the flow gap above.
1001
+ for (const line of text.split(/\r?\n/)) {
1002
+ if (line.trim() === '' || /^\s*#/.test(line)) {
1003
+ continue;
1004
+ }
1005
+ const key = /^([A-Za-z0-9_-]+)\s*:(.*)$/.exec(line);
1006
+ if (key) {
1007
+ inPackages = key[1] === 'packages';
1008
+ // A FLOW sequence on the key line is the same declaration
1009
+ // inline; consuming the line discarded the whole workspace.
1010
+ const rest = (key[2] ?? '').trim();
1011
+ if (inPackages && rest.startsWith('[')) {
1012
+ for (const part of rest.replace(/^\[|\]$/g, '').split(',')) {
1013
+ push(part);
1014
+ }
1015
+ inPackages = false;
1016
+ }
1017
+ continue;
1018
+ }
1019
+ if (!inPackages) {
1020
+ continue;
1021
+ }
1022
+ // `^\s*-`, not `^\s+-`: a zero-indent block sequence is valid YAML.
1023
+ const item = /^\s*-\s*(.+?)\s*$/.exec(line);
1024
+ if (item && item[1] !== undefined) {
1025
+ push(item[1]);
1026
+ }
1027
+ }
1028
+ return out;
1029
+ }
1030
+
1031
+ /**
1032
+ * The scopes to scan: the root, plus each declared workspace package.
1033
+ *
1034
+ * A LOCAL re-read, not an import of the frontend detector: this module is
1035
+ * leaf by contract. Only the two forms that declare package locations are
1036
+ * read; a `turbo.json` / `nx.json` root sits beside one in practice.
1037
+ */
1038
+ /** A segment no workspace package lives in — `node_modules`, dot-dirs. */
1039
+ function _excluded_scope(name: string): boolean {
1040
+ return name.split('/').some((seg) => seg.startsWith('.') || seg === 'node_modules');
1041
+ }
1042
+
1043
+ export function _behavior_scopes(project_root: string): string[] {
1044
+ const globs: string[] = [];
1045
+ const pkg = _read_json(path.join(project_root, 'package.json'));
1046
+ const ws = pkg['workspaces'];
1047
+ if (Array.isArray(ws)) {
1048
+ globs.push(...ws.filter((g): g is string => typeof g === 'string'));
1049
+ } else if (_isDict(ws) && Array.isArray(ws['packages'])) {
1050
+ globs.push(...(ws['packages'] as unknown[]).filter((g): g is string => typeof g === 'string'));
1051
+ }
1052
+ globs.push(..._pnpm_packages(_read_text(path.join(project_root, 'pnpm-workspace.yaml'))));
1053
+
1054
+ const scopes = ['.'];
1055
+ for (const raw of globs) {
1056
+ // Checked DURING expansion, not sliced off the result, which bounds
1057
+ // only after the listing is paid. Non-resolvers still cost a syscall.
1058
+ if (scopes.length >= _MAX_BEHAVIOR_SCOPES) {
1059
+ break;
1060
+ }
1061
+ const g = raw.replace(/^\.\//, '').replace(/\/+$/, '');
1062
+ if (g === '' || g.startsWith('!') || g.startsWith('/') || g.includes('..')) {
1063
+ continue;
1064
+ }
1065
+ // One predicate over the WHOLE pattern, before the fork: checking only
1066
+ // the glob child let `node_modules/*` admit what the literal refuses.
1067
+ if (_excluded_scope(g)) {
1068
+ continue;
1069
+ }
1070
+ if (g.endsWith('/*')) {
1071
+ const parent = g.slice(0, -2);
1072
+ let children: fs.Dirent[];
1073
+ try {
1074
+ children = fs.readdirSync(path.join(project_root, parent), { withFileTypes: true });
1075
+ } catch {
1076
+ continue;
1077
+ }
1078
+ // SORTED: scope order becomes serialised row order.
1079
+ children.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
1080
+ for (const c of children) {
1081
+ if (scopes.length >= _MAX_BEHAVIOR_SCOPES) {
1082
+ break;
1083
+ }
1084
+ if (c.isDirectory() && !_excluded_scope(c.name)) {
1085
+ scopes.push(`${parent}/${c.name}`);
1086
+ }
1087
+ }
1088
+ } else if (!g.includes('*')) {
1089
+ // Existence-checked; the exclusion already ran above the fork.
1090
+ try {
1091
+ // `lstatSync`, matching the glob branch's `Dirent.isDirectory()`:
1092
+ // `statSync` follows a symlink, so one package was a scope when
1093
+ // declared literally and skipped when matched by a glob.
1094
+ if (fs.lstatSync(path.join(project_root, g)).isDirectory()) {
1095
+ scopes.push(g);
1096
+ }
1097
+ } catch {
1098
+ /* declared but absent — not a scope */
1099
+ }
1100
+ }
1101
+ // A deeper or mid-path glob (`a/*/b`, `**`) is skipped, not
1102
+ // half-expanded: a scope that does not exist is worse than one fewer.
1103
+ }
1104
+ return _dictFromKeys(scopes);
1105
+ }
1106
+
425
1107
  function _php_quality(root: string, composer: Manifest, _wrappers: Wrappers): string[] {
426
1108
  const deps = _all_dependencies(composer, 'require', 'require-dev');
427
1109
  const out: string[] = [];
@@ -463,9 +1145,7 @@ function _python_quality(pyproject_text: string): string[] {
463
1145
  return out;
464
1146
  }
465
1147
 
466
- // --------------------------------------------------------------------------
467
1148
  // Task-runner wrappers (Makefile / Taskfile / package scripts)
468
- // --------------------------------------------------------------------------
469
1149
 
470
1150
  /**
471
1151
  * Map logical roles to a wrapper command when one exists.
@@ -531,16 +1211,12 @@ export interface PackageManagerResolution {
531
1211
  /**
532
1212
  * Resolve the package manager per `monorepo-workspace/SKILL.md` § 1.
533
1213
  *
534
- * Two behaviours the previous three-branch version did not have, and both are
535
- * in the skill rather than invented here:
536
- *
537
- * - `packageManager` in the root `package.json` is the DECLARATION and wins
538
- * when present, because it is what Corepack enforces. A lockfile is an
539
- * inference; a declaration is not.
540
- * - **Two lockfiles is a finding, not a tie to break.** The old code returned
541
- * `pnpm` for a repository carrying both `pnpm-lock.yaml` and `yarn.lock`,
542
- * silently picking the first branch it tested. That is a guess presented as
543
- * an answer, and the skill says to report both and stop.
1214
+ * Two behaviors the previous three-branch version lacked, both from the skill
1215
+ * rather than invented here: `packageManager` in the root `package.json` is
1216
+ * the DECLARATION and wins when present, because Corepack enforces it and a
1217
+ * lockfile is only an inference; and **two lockfiles is a finding, not a tie
1218
+ * to break** — the old code returned `pnpm` for a repository carrying both
1219
+ * `pnpm-lock.yaml` and `yarn.lock`, a guess presented as an answer.
544
1220
  */
545
1221
  export function _resolve_package_manager(
546
1222
  root: string,
@@ -583,9 +1259,7 @@ function _package_manager(root: string, pkg?: Manifest): string {
583
1259
  return _resolve_package_manager(root, pkg).manager ?? 'npm';
584
1260
  }
585
1261
 
586
- // --------------------------------------------------------------------------
587
1262
  // Monorepo guard + confidence
588
- // --------------------------------------------------------------------------
589
1263
 
590
1264
  function _apply_guard(
591
1265
  runners: RunnerResult[],
@@ -617,9 +1291,7 @@ function _overall_confidence(runners: RunnerResult[]): string {
617
1291
  return MEDIUM;
618
1292
  }
619
1293
 
620
- // --------------------------------------------------------------------------
621
1294
  // Shared readers (mirror detect.py's recoverable-error contract)
622
- // --------------------------------------------------------------------------
623
1295
 
624
1296
  function _read_json(p: string): Manifest {
625
1297
  if (!_is_file(p)) {
@@ -677,9 +1349,7 @@ function _script_command(scripts: Manifest, names: string[], manager: string): s
677
1349
  return '';
678
1350
  }
679
1351
 
680
- // --------------------------------------------------------------------------
681
1352
  // stdlib parity helpers
682
- // --------------------------------------------------------------------------
683
1353
 
684
1354
  /** Python `Path.is_file()` — true only for a regular file (follows symlinks). */
685
1355
  function _is_file(p: string): boolean {
@@ -696,8 +1366,15 @@ function _is_file(p: string): boolean {
696
1366
  * the parity tests treat the serialized `mtime` as non-deterministic.
697
1367
  */
698
1368
  function _stat_mtime(p: string): number {
699
- const st = fs.statSync(p, { bigint: true });
700
- return Number(st.mtimeNs) / 1e9;
1369
+ // GUARDED, like every sibling helper. `latest_manifest_mtime` stats after
1370
+ // a SEPARATE `_is_file`, so a file deleted between the two threw out of
1371
+ // `resolve_toolchain` and broke its never-raises contract. A missing file
1372
+ // contributes no mtime, exactly as one that was never there.
1373
+ try {
1374
+ return Number(fs.statSync(p, { bigint: true }).mtimeNs) / 1e9;
1375
+ } catch {
1376
+ return 0;
1377
+ }
701
1378
  }
702
1379
 
703
1380
  /** Python `isinstance(x, dict)` — a plain (non-array, non-null) object. */
@@ -723,30 +1400,18 @@ function _dictFromKeys(seq: string[]): string[] {
723
1400
  return [...new Set(seq)];
724
1401
  }
725
1402
 
726
- // --------------------------------------------------------------------------
727
1403
  // JSON serialisation — byte-parity with `json.dumps(..., indent=2,
728
1404
  // sort_keys=True)` for `write_config`.
729
- // --------------------------------------------------------------------------
730
1405
 
731
1406
  /**
732
1407
  * Mirror Python `json.dumps(obj, indent=2, sort_keys=True)` byte-for-byte.
733
1408
  *
734
- * Two CPython behaviours that `JSON.stringify` does NOT reproduce on its own
735
- * and are reproduced here:
736
- *
737
- * - `sort_keys=True` — object keys emitted in code-point ascending order.
738
- * `JSON.stringify` preserves insertion order; we pre-sort recursively.
739
- * - integer-valued floats render as `N.0` (e.g. `1767222000.0`). JSON has no
740
- * float/int tag, so a JS `number` that is integral renders without `.0`.
741
- * In this module the only float is `mtime`; the parity tests normalize it
742
- * (its exact byte-repr is not reproducible across CPython/V8), but the
743
- * serialiser still applies the `N.0` rule via a tagged float wrapper so the
744
- * *shape* matches. `mtime` is the sole value passed through that wrapper.
745
- *
746
- * 2-space indent, `": "` key separator, `","` item separator with the
747
- * indent-driven newline, `{}` / `[]` for empties, non-ASCII verbatim
748
- * (`ensure_ascii` defaults to `True` in CPython, but `to_config` carries no
749
- * non-ASCII, so the distinction never surfaces).
1409
+ * Two CPython behaviors `JSON.stringify` does NOT reproduce, done here:
1410
+ * `sort_keys=True` (code-point key order — pre-sorted recursively) and
1411
+ * integer-valued floats rendering `N.0`, which JSON cannot tag. The only
1412
+ * float is `mtime`; the parity tests normalize its value (not reproducible
1413
+ * across CPython/V8) while the tagged wrapper keeps the *shape*. Also:
1414
+ * 2-space indent, `": "` / `","` separators, `{}` / `[]` for empties.
750
1415
  */
751
1416
  function jsonDumps(obj: unknown, opts: { indent: number; sort_keys: boolean }): string {
752
1417
  return _encode(obj, opts, 0);