devrites 3.0.6 → 3.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 (472) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +67 -45
  3. package/SECURITY.md +31 -29
  4. package/docs/adr/0001-go-engine-as-control-plane.md +1 -1
  5. package/docs/adr/0002-dual-host-harness.md +1 -1
  6. package/docs/adr/0004-state-schema-phases-sections.md +1 -2
  7. package/docs/adr/0006-clock-seam-and-engine-ci-gates.md +3 -4
  8. package/docs/adr/0009-prebuild-decision-coverage-and-readiness.md +67 -0
  9. package/docs/adr/0010-agent-first-fresh-context-orchestration.md +71 -0
  10. package/docs/adr/0011-define-plan-transition-rights.md +28 -0
  11. package/docs/adr/README.md +3 -0
  12. package/docs/agents/triage-labels.md +7 -7
  13. package/docs/architecture.md +157 -140
  14. package/docs/cli.md +41 -12
  15. package/docs/command-map.md +49 -35
  16. package/docs/engine/agent-contract.md +92 -15
  17. package/docs/engine/commands.md +242 -72
  18. package/docs/engine/state-schema.md +52 -23
  19. package/docs/engine/workspace-schema.md +94 -15
  20. package/docs/extensions.md +1 -1
  21. package/docs/flow.md +80 -50
  22. package/docs/harness-compliance.md +29 -5
  23. package/docs/orchestration.md +107 -79
  24. package/docs/quick-reference.md +7 -3
  25. package/docs/release.md +4 -3
  26. package/docs/skills.md +64 -38
  27. package/docs/usage.md +57 -38
  28. package/engine/cmd/releasepack/main.go +219 -0
  29. package/engine/cmd/releasepack/main_test.go +170 -0
  30. package/engine/commands.go +170 -23
  31. package/engine/git_guard.go +187 -0
  32. package/engine/git_guard_test.go +283 -0
  33. package/engine/hookpolicy.go +53 -55
  34. package/engine/hookpolicy_test.go +91 -1
  35. package/engine/hooks.go +296 -75
  36. package/engine/hooks_events_test.go +276 -6
  37. package/engine/hooks_workspace.go +640 -159
  38. package/engine/internal/devritespaths/paths.go +65 -10
  39. package/engine/internal/devritespaths/paths_test.go +110 -0
  40. package/engine/internal/doctor/doctor.go +153 -23
  41. package/engine/internal/doctor/doctor_test.go +74 -0
  42. package/engine/internal/forge/forge.go +940 -0
  43. package/engine/internal/forge/forge_test.go +576 -0
  44. package/engine/internal/forge/git.go +245 -0
  45. package/engine/internal/forge/liveness_unix.go +48 -0
  46. package/engine/internal/forge/liveness_windows.go +67 -0
  47. package/engine/internal/forge/manifest.go +402 -0
  48. package/engine/internal/gate/gate.go +90 -71
  49. package/engine/internal/gate/gate_test.go +71 -2
  50. package/engine/internal/harness/compliance.go +18 -24
  51. package/engine/internal/harness/harness.go +37 -44
  52. package/engine/internal/harness/harness_test.go +21 -5
  53. package/engine/internal/install/install.go +575 -58
  54. package/engine/internal/install/install_test.go +490 -9
  55. package/engine/internal/iohooks/iohooks.go +350 -59
  56. package/engine/internal/iohooks/iohooks_test.go +421 -1
  57. package/engine/internal/lib/buildreadiness.go +35 -18
  58. package/engine/internal/lib/clarifyreturn.go +91 -0
  59. package/engine/internal/lib/clarifyreturn_test.go +123 -0
  60. package/engine/internal/lib/context.go +54 -15
  61. package/engine/internal/lib/cursor_compat_test.go +8 -0
  62. package/engine/internal/lib/extensions.go +2 -3
  63. package/engine/internal/lib/gitauthority.go +601 -0
  64. package/engine/internal/lib/gitauthority_test.go +346 -0
  65. package/engine/internal/lib/jsonout.go +25 -11
  66. package/engine/internal/lib/jsonout_test.go +16 -0
  67. package/engine/internal/lib/lanes.go +14 -8
  68. package/engine/internal/lib/observability_test.go +358 -0
  69. package/engine/internal/lib/packageexistence.go +151 -35
  70. package/engine/internal/lib/packageexistence_test.go +163 -0
  71. package/engine/internal/lib/progress.go +11 -9
  72. package/engine/internal/lib/provenance.go +462 -0
  73. package/engine/internal/lib/provenance_test.go +154 -0
  74. package/engine/internal/lib/readiness_contract.json +111 -0
  75. package/engine/internal/lib/readinessartifact.go +510 -0
  76. package/engine/internal/lib/readinessartifact_test.go +321 -0
  77. package/engine/internal/lib/reconcile.go +712 -88
  78. package/engine/internal/lib/reconcile_test.go +335 -16
  79. package/engine/internal/lib/recoveryattempts.go +298 -0
  80. package/engine/internal/lib/recoveryattempts_test.go +215 -0
  81. package/engine/internal/lib/resolve.go +23 -19
  82. package/engine/internal/lib/runbook_context_test.go +20 -0
  83. package/engine/internal/lib/session.go +701 -9
  84. package/engine/internal/lib/session_test.go +80 -13
  85. package/engine/internal/lib/testintegrity.go +33 -37
  86. package/engine/internal/lib/testintegrity_test.go +63 -1
  87. package/engine/internal/migrate/migrate.go +81 -1
  88. package/engine/internal/migrate/migrate_test.go +22 -1
  89. package/engine/internal/reason/reason.go +180 -0
  90. package/engine/internal/reason/reason_test.go +35 -0
  91. package/engine/internal/rootfacts/facts.go +466 -0
  92. package/engine/internal/rootfacts/facts_test.go +306 -0
  93. package/engine/internal/safepath/safepath.go +55 -0
  94. package/engine/internal/safepath/safepath_test.go +69 -0
  95. package/engine/internal/safepath/safepath_windows_test.go +26 -0
  96. package/engine/internal/state/clarify_transition.go +165 -0
  97. package/engine/internal/state/clarify_transition_test.go +130 -0
  98. package/engine/internal/state/cmd/workflowmanifest/main.go +12 -8
  99. package/engine/internal/state/cursor.go +58 -0
  100. package/engine/internal/state/cursor_test.go +42 -1
  101. package/engine/internal/state/feature.go +35 -48
  102. package/engine/internal/state/schema.go +90 -37
  103. package/engine/internal/state/snapshot.go +18 -2
  104. package/engine/internal/state/state_test.go +187 -7
  105. package/engine/internal/state/status.go +48 -11
  106. package/engine/internal/state/workflow_manifest.json +231 -50
  107. package/engine/internal/toolpolicy/classifier.go +533 -0
  108. package/engine/internal/toolpolicy/classifier_test.go +424 -0
  109. package/engine/internal/toolpolicy/git.go +616 -0
  110. package/engine/internal/toolpolicy/scanner.go +382 -0
  111. package/engine/main.go +160 -77
  112. package/engine/observability_cli_test.go +52 -0
  113. package/engine/root_routing_test.go +277 -0
  114. package/engine/testdata/golden/TestParityBuildReadiness/arg=approved.golden +1 -1
  115. package/engine/testdata/golden/TestParityBuildReadiness/arg=emptystatus.golden +1 -1
  116. package/engine/testdata/golden/TestParityBuildReadiness/arg=noclarify.golden +1 -0
  117. package/engine/testdata/golden/TestParityBuildReadiness/arg=novet.golden +1 -0
  118. package/engine/testdata/golden/TestParityBuildReadiness/arg=stalevet.golden +1 -0
  119. package/engine/testdata/golden/TestParityBuildReadiness/arg=trailhash.golden +1 -1
  120. package/engine/testdata/golden/TestParityBuildReadiness/arg=trailpipe.golden +1 -1
  121. package/engine/testdata/golden/TestParityBuildReadiness/arg=vetnotready.golden +1 -0
  122. package/engine/testdata/golden/TestParityProgress/arg=allbuilt.golden +1 -1
  123. package/engine/testdata/golden/TestParityProgress/arg=done.golden +1 -1
  124. package/engine/testdata/golden/TestParityProgress/arg=mid.golden +1 -1
  125. package/engine/testdata/golden/TestParityProgress/arg=nophase.golden +1 -1
  126. package/engine/testdata/golden/TestParityProgress/arg=noslice.golden +1 -1
  127. package/engine/testdata/golden/TestParityProgress/arg=plan.golden +1 -1
  128. package/engine/testdata/golden/TestParityProgress/arg=seal.golden +1 -1
  129. package/engine/testdata/golden/TestParityReconcile/check-clean.golden +1 -1
  130. package/engine/testdata/golden/TestParityReconcile/inline-fallback.golden +1 -0
  131. package/engine/testdata/golden/TestParityReconcile/snapshot-no-allowlist.golden +1 -0
  132. package/engine/testdata/golden/TestParityWrightScope/devrites-edit-denied.golden +2 -0
  133. package/engine/testdata/golden/TestParityWrightScope/out-of-scope-enforce-denies.golden +1 -1
  134. package/engine/tests/adr_0011_define_plan_test.go +30 -0
  135. package/engine/tests/budget_test.go +19 -8
  136. package/engine/tests/concurrency_cli_test.go +7 -1
  137. package/engine/tests/doctor_cli_test.go +148 -2
  138. package/engine/tests/forge_cli_test.go +463 -0
  139. package/engine/tests/gate_test.go +63 -8
  140. package/engine/tests/hook_test.go +260 -8
  141. package/engine/tests/hooks_io_test.go +94 -3
  142. package/engine/tests/json_contract_test.go +127 -2
  143. package/engine/tests/migrate_cli_test.go +2 -2
  144. package/engine/tests/parity_buildreadiness_test.go +141 -10
  145. package/engine/tests/parity_learnings_test.go +16 -17
  146. package/engine/tests/parity_reconcile_test.go +20 -17
  147. package/engine/tests/parity_resolve_test.go +12 -11
  148. package/engine/tests/parity_test.go +21 -17
  149. package/pack/.claude/agents/devrites-code-reviewer.md +62 -48
  150. package/pack/.claude/agents/devrites-devex-reviewer.md +69 -53
  151. package/pack/.claude/agents/devrites-doubt-reviewer.md +29 -22
  152. package/pack/.claude/agents/devrites-evidence-scout.md +69 -0
  153. package/pack/.claude/agents/devrites-forge-judge.md +74 -61
  154. package/pack/.claude/agents/devrites-frontend-reviewer.md +48 -39
  155. package/pack/.claude/agents/devrites-performance-reviewer.md +49 -40
  156. package/pack/.claude/agents/devrites-plan-drafter.md +71 -0
  157. package/pack/.claude/agents/devrites-plan-reviewer.md +80 -47
  158. package/pack/.claude/agents/devrites-proof-runner.md +74 -0
  159. package/pack/.claude/agents/devrites-retrospector.md +48 -45
  160. package/pack/.claude/agents/devrites-security-auditor.md +46 -36
  161. package/pack/.claude/agents/devrites-simplifier-reviewer.md +50 -46
  162. package/pack/.claude/agents/devrites-slice-wright.md +153 -165
  163. package/pack/.claude/agents/devrites-spec-reviewer.md +34 -32
  164. package/pack/.claude/agents/devrites-strategy-reviewer.md +63 -34
  165. package/pack/.claude/agents/devrites-test-analyst.md +40 -30
  166. package/pack/.claude/settings.json +2 -1
  167. package/pack/.claude/skills/devrites-audit/SKILL.md +40 -61
  168. package/pack/.claude/skills/devrites-debug-recovery/SKILL.md +24 -13
  169. package/pack/.claude/skills/devrites-debug-recovery/reference/build-the-loop.md +24 -21
  170. package/pack/.claude/skills/devrites-debug-recovery/reference/cleanup-and-classify.md +34 -6
  171. package/pack/.claude/skills/devrites-debug-recovery/reference/instrumentation.md +2 -2
  172. package/pack/.claude/skills/devrites-doubt/SKILL.md +25 -17
  173. package/pack/.claude/skills/devrites-interview/SKILL.md +53 -52
  174. package/pack/.claude/skills/devrites-lib/SKILL.md +11 -9
  175. package/pack/.claude/skills/devrites-lib/reference/intent-map.md +3 -2
  176. package/pack/.claude/skills/devrites-lib/reference/parallel-dispatch.md +82 -137
  177. package/pack/.claude/skills/devrites-lib/reference/reply-contract.md +8 -1
  178. package/pack/.claude/skills/devrites-lib/reference/standards/README.md +38 -55
  179. package/pack/.claude/skills/devrites-lib/reference/standards/afk-hitl.md +53 -27
  180. package/pack/.claude/skills/devrites-lib/reference/standards/agents.md +198 -190
  181. package/pack/.claude/skills/devrites-lib/reference/standards/anti-patterns.md +5 -15
  182. package/pack/.claude/skills/devrites-lib/reference/standards/ci-cd.md +27 -58
  183. package/pack/.claude/skills/devrites-lib/reference/standards/code-review.md +16 -52
  184. package/pack/.claude/skills/devrites-lib/reference/standards/coding-style.md +2 -10
  185. package/pack/.claude/skills/devrites-lib/reference/standards/context-hygiene.md +18 -61
  186. package/pack/.claude/skills/devrites-lib/reference/standards/core.md +23 -18
  187. package/pack/.claude/skills/devrites-lib/reference/standards/deprecation.md +17 -57
  188. package/pack/.claude/skills/devrites-lib/reference/standards/development-workflow.md +4 -33
  189. package/pack/.claude/skills/devrites-lib/reference/standards/git-workflow.md +4 -20
  190. package/pack/.claude/skills/devrites-lib/reference/standards/hooks.md +3 -14
  191. package/pack/.claude/skills/devrites-lib/reference/standards/patterns.md +9 -25
  192. package/pack/.claude/skills/devrites-lib/reference/standards/performance.md +0 -9
  193. package/pack/.claude/skills/devrites-lib/reference/standards/principles.md +1 -3
  194. package/pack/.claude/skills/devrites-lib/reference/standards/security.md +17 -7
  195. package/pack/.claude/skills/devrites-lib/reference/standards/testing.md +1 -27
  196. package/pack/.claude/skills/devrites-lib/reference/standards/tooling.md +46 -50
  197. package/pack/.claude/skills/devrites-lib/reference/workspace-artifact-schema.md +23 -10
  198. package/pack/.claude/skills/devrites-source-driven/SKILL.md +23 -22
  199. package/pack/.claude/skills/rite/SKILL.md +8 -6
  200. package/pack/.claude/skills/rite/reference/menu.md +8 -6
  201. package/pack/.claude/skills/rite-adopt/SKILL.md +33 -37
  202. package/pack/.claude/skills/rite-autocomplete/SKILL.md +33 -28
  203. package/pack/.claude/skills/rite-autocomplete/reference/loop.md +21 -21
  204. package/pack/.claude/skills/rite-autocomplete/reference/stop-conditions.md +15 -6
  205. package/pack/.claude/skills/rite-build/SKILL.md +35 -30
  206. package/pack/.claude/skills/rite-build/reference/afk-discipline.md +38 -38
  207. package/pack/.claude/skills/rite-build/reference/evidence-standard.md +10 -0
  208. package/pack/.claude/skills/rite-build/reference/forge.md +186 -156
  209. package/pack/.claude/skills/rite-build/reference/one-slice-cycle.md +4 -3
  210. package/pack/.claude/skills/rite-build/reference/phase-contract.md +96 -175
  211. package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +129 -134
  212. package/pack/.claude/skills/rite-clarify/SKILL.md +89 -0
  213. package/pack/.claude/skills/rite-clarify/reference/decision-coverage.md +59 -0
  214. package/pack/.claude/skills/rite-converge/SKILL.md +35 -25
  215. package/pack/.claude/skills/rite-define/SKILL.md +57 -32
  216. package/pack/.claude/skills/rite-define/reference/gates.md +16 -15
  217. package/pack/.claude/skills/rite-define/reference/plan-template.md +16 -8
  218. package/pack/.claude/skills/rite-frame/reference/failure-modes.md +22 -24
  219. package/pack/.claude/skills/rite-plan/SKILL.md +44 -16
  220. package/pack/.claude/skills/rite-plan/reference/task-breakdown.md +4 -0
  221. package/pack/.claude/skills/rite-polish/SKILL.md +27 -23
  222. package/pack/.claude/skills/rite-prototype/SKILL.md +25 -26
  223. package/pack/.claude/skills/rite-prove/SKILL.md +27 -17
  224. package/pack/.claude/skills/rite-resolve/SKILL.md +12 -11
  225. package/pack/.claude/skills/rite-resolve/reference/answer-protocol.md +3 -0
  226. package/pack/.claude/skills/rite-review/SKILL.md +37 -28
  227. package/pack/.claude/skills/rite-seal/reference/phase-contract.md +27 -92
  228. package/pack/.claude/skills/rite-seal/reference/risk-and-rollback.md +13 -0
  229. package/pack/.claude/skills/rite-ship/reference/design-memory.md +31 -36
  230. package/pack/.claude/skills/rite-spec/SKILL.md +84 -135
  231. package/pack/.claude/skills/rite-spec/reference/investigation.md +37 -33
  232. package/pack/.claude/skills/rite-spec/reference/question-protocol.md +6 -2
  233. package/pack/.claude/skills/rite-spec/reference/spec-checklists.md +25 -24
  234. package/pack/.claude/skills/rite-spec/reference/spec-template.md +9 -2
  235. package/pack/.claude/skills/rite-spec/reference/state-workspace.md +13 -3
  236. package/pack/.claude/skills/rite-temper/SKILL.md +62 -47
  237. package/pack/.claude/skills/rite-temper/reference/review-dimensions.md +24 -22
  238. package/pack/.claude/skills/rite-vet/SKILL.md +110 -113
  239. package/pack/.claude/skills/rite-vet/reference/artifacts.md +42 -9
  240. package/pack/.claude/skills/rite-vet/reference/review-axes.md +39 -37
  241. package/pack/generated/claude/agents/devrites-code-reviewer.md +62 -48
  242. package/pack/generated/claude/agents/devrites-devex-reviewer.md +69 -53
  243. package/pack/generated/claude/agents/devrites-doubt-reviewer.md +29 -22
  244. package/pack/generated/claude/agents/devrites-evidence-scout.md +69 -0
  245. package/pack/generated/claude/agents/devrites-forge-judge.md +74 -61
  246. package/pack/generated/claude/agents/devrites-frontend-reviewer.md +48 -39
  247. package/pack/generated/claude/agents/devrites-performance-reviewer.md +49 -40
  248. package/pack/generated/claude/agents/devrites-plan-drafter.md +71 -0
  249. package/pack/generated/claude/agents/devrites-plan-reviewer.md +80 -47
  250. package/pack/generated/claude/agents/devrites-proof-runner.md +74 -0
  251. package/pack/generated/claude/agents/devrites-retrospector.md +48 -45
  252. package/pack/generated/claude/agents/devrites-security-auditor.md +46 -36
  253. package/pack/generated/claude/agents/devrites-simplifier-reviewer.md +50 -46
  254. package/pack/generated/claude/agents/devrites-slice-wright.md +153 -165
  255. package/pack/generated/claude/agents/devrites-spec-reviewer.md +34 -32
  256. package/pack/generated/claude/agents/devrites-strategy-reviewer.md +63 -34
  257. package/pack/generated/claude/agents/devrites-test-analyst.md +40 -30
  258. package/pack/generated/claude/settings.json +2 -1
  259. package/pack/generated/claude/skills/devrites-audit/SKILL.md +40 -61
  260. package/pack/generated/claude/skills/devrites-debug-recovery/SKILL.md +24 -13
  261. package/pack/generated/claude/skills/devrites-debug-recovery/reference/build-the-loop.md +24 -21
  262. package/pack/generated/claude/skills/devrites-debug-recovery/reference/cleanup-and-classify.md +34 -6
  263. package/pack/generated/claude/skills/devrites-debug-recovery/reference/instrumentation.md +2 -2
  264. package/pack/generated/claude/skills/devrites-doubt/SKILL.md +25 -17
  265. package/pack/generated/claude/skills/devrites-interview/SKILL.md +53 -52
  266. package/pack/generated/claude/skills/devrites-lib/SKILL.md +11 -9
  267. package/pack/generated/claude/skills/devrites-lib/reference/intent-map.md +3 -2
  268. package/pack/generated/claude/skills/devrites-lib/reference/parallel-dispatch.md +82 -137
  269. package/pack/generated/claude/skills/devrites-lib/reference/reply-contract.md +8 -1
  270. package/pack/generated/claude/skills/devrites-lib/reference/standards/README.md +38 -55
  271. package/pack/generated/claude/skills/devrites-lib/reference/standards/afk-hitl.md +53 -27
  272. package/pack/generated/claude/skills/devrites-lib/reference/standards/agents.md +198 -190
  273. package/pack/generated/claude/skills/devrites-lib/reference/standards/anti-patterns.md +5 -15
  274. package/pack/generated/claude/skills/devrites-lib/reference/standards/ci-cd.md +27 -58
  275. package/pack/generated/claude/skills/devrites-lib/reference/standards/code-review.md +16 -52
  276. package/pack/generated/claude/skills/devrites-lib/reference/standards/coding-style.md +2 -10
  277. package/pack/generated/claude/skills/devrites-lib/reference/standards/context-hygiene.md +18 -61
  278. package/pack/generated/claude/skills/devrites-lib/reference/standards/core.md +23 -18
  279. package/pack/generated/claude/skills/devrites-lib/reference/standards/deprecation.md +17 -57
  280. package/pack/generated/claude/skills/devrites-lib/reference/standards/development-workflow.md +4 -33
  281. package/pack/generated/claude/skills/devrites-lib/reference/standards/git-workflow.md +4 -20
  282. package/pack/generated/claude/skills/devrites-lib/reference/standards/hooks.md +3 -14
  283. package/pack/generated/claude/skills/devrites-lib/reference/standards/patterns.md +9 -25
  284. package/pack/generated/claude/skills/devrites-lib/reference/standards/performance.md +0 -9
  285. package/pack/generated/claude/skills/devrites-lib/reference/standards/principles.md +1 -3
  286. package/pack/generated/claude/skills/devrites-lib/reference/standards/security.md +17 -7
  287. package/pack/generated/claude/skills/devrites-lib/reference/standards/testing.md +1 -27
  288. package/pack/generated/claude/skills/devrites-lib/reference/standards/tooling.md +46 -50
  289. package/pack/generated/claude/skills/devrites-lib/reference/workspace-artifact-schema.md +23 -10
  290. package/pack/generated/claude/skills/devrites-source-driven/SKILL.md +23 -22
  291. package/pack/generated/claude/skills/rite/SKILL.md +8 -6
  292. package/pack/generated/claude/skills/rite/reference/menu.md +8 -6
  293. package/pack/generated/claude/skills/rite-adopt/SKILL.md +33 -37
  294. package/pack/generated/claude/skills/rite-autocomplete/SKILL.md +33 -28
  295. package/pack/generated/claude/skills/rite-autocomplete/reference/loop.md +21 -21
  296. package/pack/generated/claude/skills/rite-autocomplete/reference/stop-conditions.md +15 -6
  297. package/pack/generated/claude/skills/rite-build/SKILL.md +35 -30
  298. package/pack/generated/claude/skills/rite-build/reference/afk-discipline.md +38 -38
  299. package/pack/generated/claude/skills/rite-build/reference/evidence-standard.md +10 -0
  300. package/pack/generated/claude/skills/rite-build/reference/forge.md +186 -156
  301. package/pack/generated/claude/skills/rite-build/reference/one-slice-cycle.md +4 -3
  302. package/pack/generated/claude/skills/rite-build/reference/phase-contract.md +96 -175
  303. package/pack/generated/claude/skills/rite-build/reference/wright-dispatch.md +129 -134
  304. package/pack/generated/claude/skills/rite-clarify/SKILL.md +89 -0
  305. package/pack/generated/claude/skills/rite-clarify/reference/decision-coverage.md +59 -0
  306. package/pack/generated/claude/skills/rite-converge/SKILL.md +35 -25
  307. package/pack/generated/claude/skills/rite-define/SKILL.md +57 -32
  308. package/pack/generated/claude/skills/rite-define/reference/gates.md +16 -15
  309. package/pack/generated/claude/skills/rite-define/reference/plan-template.md +16 -8
  310. package/pack/generated/claude/skills/rite-frame/reference/failure-modes.md +22 -24
  311. package/pack/generated/claude/skills/rite-plan/SKILL.md +44 -16
  312. package/pack/generated/claude/skills/rite-plan/reference/task-breakdown.md +4 -0
  313. package/pack/generated/claude/skills/rite-polish/SKILL.md +27 -23
  314. package/pack/generated/claude/skills/rite-prototype/SKILL.md +25 -26
  315. package/pack/generated/claude/skills/rite-prove/SKILL.md +27 -17
  316. package/pack/generated/claude/skills/rite-resolve/SKILL.md +12 -11
  317. package/pack/generated/claude/skills/rite-resolve/reference/answer-protocol.md +3 -0
  318. package/pack/generated/claude/skills/rite-review/SKILL.md +37 -28
  319. package/pack/generated/claude/skills/rite-seal/reference/phase-contract.md +27 -92
  320. package/pack/generated/claude/skills/rite-seal/reference/risk-and-rollback.md +13 -0
  321. package/pack/generated/claude/skills/rite-ship/reference/design-memory.md +31 -36
  322. package/pack/generated/claude/skills/rite-spec/SKILL.md +84 -135
  323. package/pack/generated/claude/skills/rite-spec/reference/investigation.md +37 -33
  324. package/pack/generated/claude/skills/rite-spec/reference/question-protocol.md +6 -2
  325. package/pack/generated/claude/skills/rite-spec/reference/spec-checklists.md +25 -24
  326. package/pack/generated/claude/skills/rite-spec/reference/spec-template.md +9 -2
  327. package/pack/generated/claude/skills/rite-spec/reference/state-workspace.md +13 -3
  328. package/pack/generated/claude/skills/rite-temper/SKILL.md +62 -47
  329. package/pack/generated/claude/skills/rite-temper/reference/review-dimensions.md +24 -22
  330. package/pack/generated/claude/skills/rite-vet/SKILL.md +110 -113
  331. package/pack/generated/claude/skills/rite-vet/reference/artifacts.md +42 -9
  332. package/pack/generated/claude/skills/rite-vet/reference/review-axes.md +39 -37
  333. package/pack/generated/codex/AGENTS.md +5 -2
  334. package/pack/generated/codex/agents/devrites-code-reviewer.toml +69 -47
  335. package/pack/generated/codex/agents/devrites-devex-reviewer.toml +75 -51
  336. package/pack/generated/codex/agents/devrites-doubt-reviewer.toml +35 -20
  337. package/pack/generated/codex/agents/devrites-evidence-scout.toml +75 -0
  338. package/pack/generated/codex/agents/devrites-forge-judge.toml +80 -59
  339. package/pack/generated/codex/agents/devrites-frontend-reviewer.toml +54 -37
  340. package/pack/generated/codex/agents/devrites-performance-reviewer.toml +55 -38
  341. package/pack/generated/codex/agents/devrites-plan-drafter.toml +77 -0
  342. package/pack/generated/codex/agents/devrites-plan-reviewer.toml +82 -47
  343. package/pack/generated/codex/agents/devrites-proof-runner.toml +80 -0
  344. package/pack/generated/codex/agents/devrites-retrospector.toml +54 -43
  345. package/pack/generated/codex/agents/devrites-security-auditor.toml +53 -35
  346. package/pack/generated/codex/agents/devrites-simplifier-reviewer.toml +56 -44
  347. package/pack/generated/codex/agents/devrites-slice-wright.toml +159 -163
  348. package/pack/generated/codex/agents/devrites-spec-reviewer.toml +40 -30
  349. package/pack/generated/codex/agents/devrites-strategy-reviewer.toml +65 -34
  350. package/pack/generated/codex/agents/devrites-test-analyst.toml +46 -28
  351. package/pack/generated/codex/hooks.json +4 -14
  352. package/pack/generated/codex/skills/devrites-api-interface/SKILL.md +7 -3
  353. package/pack/generated/codex/skills/devrites-audit/SKILL.md +47 -64
  354. package/pack/generated/codex/skills/devrites-browser-proof/SKILL.md +7 -3
  355. package/pack/generated/codex/skills/devrites-debug-recovery/SKILL.md +31 -16
  356. package/pack/generated/codex/skills/devrites-debug-recovery/reference/build-the-loop.md +24 -21
  357. package/pack/generated/codex/skills/devrites-debug-recovery/reference/cleanup-and-classify.md +34 -6
  358. package/pack/generated/codex/skills/devrites-debug-recovery/reference/instrumentation.md +2 -2
  359. package/pack/generated/codex/skills/devrites-doubt/SKILL.md +32 -20
  360. package/pack/generated/codex/skills/devrites-frontend-craft/SKILL.md +7 -3
  361. package/pack/generated/codex/skills/devrites-interview/SKILL.md +60 -55
  362. package/pack/generated/codex/skills/devrites-lib/SKILL.md +18 -12
  363. package/pack/generated/codex/skills/devrites-lib/reference/intent-map.md +3 -2
  364. package/pack/generated/codex/skills/devrites-lib/reference/parallel-dispatch.md +82 -137
  365. package/pack/generated/codex/skills/devrites-lib/reference/reply-contract.md +8 -1
  366. package/pack/generated/codex/skills/devrites-lib/reference/standards/README.md +38 -55
  367. package/pack/generated/codex/skills/devrites-lib/reference/standards/afk-hitl.md +53 -27
  368. package/pack/generated/codex/skills/devrites-lib/reference/standards/agents.md +198 -190
  369. package/pack/generated/codex/skills/devrites-lib/reference/standards/anti-patterns.md +5 -15
  370. package/pack/generated/codex/skills/devrites-lib/reference/standards/ci-cd.md +27 -58
  371. package/pack/generated/codex/skills/devrites-lib/reference/standards/code-review.md +16 -52
  372. package/pack/generated/codex/skills/devrites-lib/reference/standards/coding-style.md +2 -10
  373. package/pack/generated/codex/skills/devrites-lib/reference/standards/context-hygiene.md +18 -61
  374. package/pack/generated/codex/skills/devrites-lib/reference/standards/core.md +23 -18
  375. package/pack/generated/codex/skills/devrites-lib/reference/standards/deprecation.md +17 -57
  376. package/pack/generated/codex/skills/devrites-lib/reference/standards/development-workflow.md +4 -33
  377. package/pack/generated/codex/skills/devrites-lib/reference/standards/git-workflow.md +4 -20
  378. package/pack/generated/codex/skills/devrites-lib/reference/standards/hooks.md +3 -14
  379. package/pack/generated/codex/skills/devrites-lib/reference/standards/patterns.md +9 -25
  380. package/pack/generated/codex/skills/devrites-lib/reference/standards/performance.md +0 -9
  381. package/pack/generated/codex/skills/devrites-lib/reference/standards/principles.md +1 -3
  382. package/pack/generated/codex/skills/devrites-lib/reference/standards/security.md +17 -7
  383. package/pack/generated/codex/skills/devrites-lib/reference/standards/testing.md +1 -27
  384. package/pack/generated/codex/skills/devrites-lib/reference/standards/tooling.md +46 -50
  385. package/pack/generated/codex/skills/devrites-lib/reference/workspace-artifact-schema.md +23 -10
  386. package/pack/generated/codex/skills/devrites-prose-craft/SKILL.md +7 -3
  387. package/pack/generated/codex/skills/devrites-refresh-indexes/SKILL.md +7 -3
  388. package/pack/generated/codex/skills/devrites-source-driven/SKILL.md +29 -24
  389. package/pack/generated/codex/skills/devrites-ux-shape/SKILL.md +7 -3
  390. package/pack/generated/codex/skills/rite/SKILL.md +19 -13
  391. package/pack/generated/codex/skills/rite/reference/menu.md +8 -6
  392. package/pack/generated/codex/skills/rite-adopt/SKILL.md +40 -40
  393. package/pack/generated/codex/skills/rite-autocomplete/SKILL.md +40 -31
  394. package/pack/generated/codex/skills/rite-autocomplete/reference/loop.md +21 -21
  395. package/pack/generated/codex/skills/rite-autocomplete/reference/stop-conditions.md +15 -6
  396. package/pack/generated/codex/skills/rite-build/SKILL.md +42 -33
  397. package/pack/generated/codex/skills/rite-build/reference/afk-discipline.md +38 -38
  398. package/pack/generated/codex/skills/rite-build/reference/evidence-standard.md +10 -0
  399. package/pack/generated/codex/skills/rite-build/reference/forge.md +186 -156
  400. package/pack/generated/codex/skills/rite-build/reference/one-slice-cycle.md +4 -3
  401. package/pack/generated/codex/skills/rite-build/reference/phase-contract.md +96 -175
  402. package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +129 -134
  403. package/pack/generated/codex/skills/rite-clarify/SKILL.md +105 -0
  404. package/pack/generated/codex/skills/rite-clarify/reference/decision-coverage.md +59 -0
  405. package/pack/generated/codex/skills/rite-converge/SKILL.md +42 -28
  406. package/pack/generated/codex/skills/rite-customize/SKILL.md +7 -3
  407. package/pack/generated/codex/skills/rite-define/SKILL.md +64 -35
  408. package/pack/generated/codex/skills/rite-define/reference/gates.md +16 -15
  409. package/pack/generated/codex/skills/rite-define/reference/plan-template.md +16 -8
  410. package/pack/generated/codex/skills/rite-doctor/SKILL.md +7 -3
  411. package/pack/generated/codex/skills/rite-dogfood/SKILL.md +7 -3
  412. package/pack/generated/codex/skills/rite-explain/SKILL.md +7 -3
  413. package/pack/generated/codex/skills/rite-frame/SKILL.md +7 -3
  414. package/pack/generated/codex/skills/rite-frame/reference/failure-modes.md +22 -24
  415. package/pack/generated/codex/skills/rite-handoff/SKILL.md +7 -3
  416. package/pack/generated/codex/skills/rite-learn/SKILL.md +7 -3
  417. package/pack/generated/codex/skills/rite-plan/SKILL.md +51 -19
  418. package/pack/generated/codex/skills/rite-plan/reference/task-breakdown.md +5 -1
  419. package/pack/generated/codex/skills/rite-polish/SKILL.md +34 -26
  420. package/pack/generated/codex/skills/rite-pov/SKILL.md +7 -3
  421. package/pack/generated/codex/skills/rite-pr-feedback/SKILL.md +7 -3
  422. package/pack/generated/codex/skills/rite-pressure-test/SKILL.md +7 -3
  423. package/pack/generated/codex/skills/rite-prototype/SKILL.md +32 -29
  424. package/pack/generated/codex/skills/rite-prove/SKILL.md +34 -20
  425. package/pack/generated/codex/skills/rite-quick/SKILL.md +7 -3
  426. package/pack/generated/codex/skills/rite-resolve/SKILL.md +19 -14
  427. package/pack/generated/codex/skills/rite-resolve/reference/answer-protocol.md +3 -0
  428. package/pack/generated/codex/skills/rite-review/SKILL.md +44 -31
  429. package/pack/generated/codex/skills/rite-seal/SKILL.md +7 -3
  430. package/pack/generated/codex/skills/rite-seal/reference/phase-contract.md +27 -92
  431. package/pack/generated/codex/skills/rite-seal/reference/risk-and-rollback.md +13 -0
  432. package/pack/generated/codex/skills/rite-ship/SKILL.md +7 -3
  433. package/pack/generated/codex/skills/rite-ship/reference/design-memory.md +31 -36
  434. package/pack/generated/codex/skills/rite-spec/SKILL.md +91 -138
  435. package/pack/generated/codex/skills/rite-spec/reference/investigation.md +37 -33
  436. package/pack/generated/codex/skills/rite-spec/reference/question-protocol.md +6 -2
  437. package/pack/generated/codex/skills/rite-spec/reference/spec-checklists.md +25 -24
  438. package/pack/generated/codex/skills/rite-spec/reference/spec-template.md +9 -2
  439. package/pack/generated/codex/skills/rite-spec/reference/state-workspace.md +13 -3
  440. package/pack/generated/codex/skills/rite-status/SKILL.md +7 -3
  441. package/pack/generated/codex/skills/rite-temper/SKILL.md +69 -50
  442. package/pack/generated/codex/skills/rite-temper/reference/review-dimensions.md +24 -22
  443. package/pack/generated/codex/skills/rite-vet/SKILL.md +117 -116
  444. package/pack/generated/codex/skills/rite-vet/reference/artifacts.md +42 -9
  445. package/pack/generated/codex/skills/rite-vet/reference/review-axes.md +40 -38
  446. package/pack/generated/codex/skills/rite-zoom-out/SKILL.md +7 -3
  447. package/package.json +1 -1
  448. package/scripts/build-release-tarball.sh +32 -15
  449. package/scripts/check-authority-drift.py +125 -0
  450. package/scripts/check-instruction-size-baseline.mjs +19 -11
  451. package/scripts/check-invocation-integrity.py +2 -0
  452. package/scripts/codex-generate.sh +69 -33
  453. package/scripts/grade-feature.sh +121 -40
  454. package/scripts/live-hosts/agent-result.schema.json +230 -0
  455. package/scripts/live-hosts/claude.sh +87 -0
  456. package/scripts/live-hosts/codex.sh +81 -0
  457. package/scripts/live-hosts/common.sh +113 -0
  458. package/scripts/live-hosts/fake-host.py +264 -0
  459. package/scripts/live-hosts/host-transport.py +287 -0
  460. package/scripts/release-check.sh +5 -1
  461. package/scripts/run-agent-contract-evals.py +1380 -0
  462. package/scripts/run-behavioral-evals.sh +24 -30
  463. package/scripts/run-evals.sh +1 -5
  464. package/scripts/run-live-behavioral-evals.py +1274 -144
  465. package/scripts/run-outcome-evals.sh +414 -88
  466. package/scripts/run-tests.mjs +30 -2
  467. package/scripts/skills-inventory.mjs +1 -1
  468. package/scripts/validate-workflow-security.py +39 -20
  469. package/scripts/validate-workspace-schema.py +362 -10
  470. package/scripts/validate.sh +21 -15
  471. package/engine/testdata/golden/TestParityWrightScope/devrites-edit-allowed.golden +0 -1
  472. /package/engine/testdata/golden/{TestParityReconcile/check-no-claimed.golden → TestParityBuildReadiness/arg=clarifyopen.golden} +0 -0
@@ -1,9 +1,9 @@
1
1
  package main
2
2
 
3
- // Workspace hooks read a feature's runtime state (state.md fields, questions.md,
4
- // .red) as sidecar files in the feature directory. work/ is canonical; features/
5
- // remains readable as a compatibility alias. Every hook is read-only and
6
- // fail-open, staying silent + exit 0 outside a DevRites workspace.
3
+ // Workspace hooks read state.md, questions.md, and other runtime files from the
4
+ // active feature directory. work is canonical, while features remains readable
5
+ // for compatibility. These hooks are read only and stay silent outside a
6
+ // DevRites workspace.
7
7
 
8
8
  import (
9
9
  "bytes"
@@ -11,14 +11,20 @@ import (
11
11
  "fmt"
12
12
  "io"
13
13
  "os"
14
+ "os/exec"
14
15
  "path/filepath"
15
16
  "regexp"
16
17
  "strconv"
17
18
  "strings"
18
19
  "time"
19
20
 
21
+ "github.com/devrites/devrites/internal/forge"
20
22
  "github.com/devrites/devrites/internal/harness"
23
+ "github.com/devrites/devrites/internal/lib"
21
24
  "github.com/devrites/devrites/internal/orient"
25
+ drvreason "github.com/devrites/devrites/internal/reason"
26
+ "github.com/devrites/devrites/internal/rootfacts"
27
+ "github.com/devrites/devrites/internal/safepath"
22
28
  "github.com/devrites/devrites/internal/state"
23
29
  )
24
30
 
@@ -26,8 +32,8 @@ var (
26
32
  openStatusRe = regexp.MustCompile(`(?i)^[[:space:]]*status:[[:space:]]*open`)
27
33
  )
28
34
 
29
- // resolveWorkspaceDir resolves the active feature's directory. New writes default
30
- // to work/; existing features/ workspaces remain readable.
35
+ // resolveWorkspaceDir finds the active feature directory. New workspaces use
36
+ // work, while existing features workspaces remain readable.
31
37
  func resolveWorkspaceDir(root, slug string) string {
32
38
  work := filepath.Join(root, "work", slug)
33
39
  if wsIsDir(work) {
@@ -41,8 +47,8 @@ func resolveWorkspaceDir(root, slug string) string {
41
47
  }
42
48
 
43
49
  // resolveWorkspace resolves the active feature's directory.
44
- // ok is false (a silent no-op) when there is no .devrites root or no active
45
- // feature, keeping the hooks fail-open outside a DevRites workspace.
50
+ // ok is false when no .devrites root or active feature exists. Callers use that
51
+ // result to return silently.
46
52
  func resolveWorkspace() (root, slug, dir string, ok bool) {
47
53
  root, err := orient.ResolveRoot(os.Getenv("DEVRITES_ROOT"))
48
54
  if err != nil {
@@ -55,31 +61,28 @@ func resolveWorkspace() (root, slug, dir string, ok bool) {
55
61
  return root, slug, resolveWorkspaceDir(root, slug), true
56
62
  }
57
63
 
58
- type hookEventJSON struct {
59
- TS string `json:"ts"`
60
- Event string `json:"event"`
61
- Slug string `json:"slug,omitempty"`
62
- Note string `json:"note,omitempty"`
63
- }
64
-
65
- type auqEventJSON struct {
66
- TS string `json:"ts"`
67
- Event string `json:"event"`
68
- Slug string `json:"slug,omitempty"`
69
- Question string `json:"question"`
70
- Answer string `json:"answer,omitempty"`
64
+ func recordHookGuard(h harness.Harness, hook string, reasonID drvreason.ID, strength lib.GuardStrength, outcome lib.EventOutcome, evidenceFiles ...string) {
65
+ root, slug, _, ok := resolveWorkspace()
66
+ if !ok {
67
+ return
68
+ }
69
+ ev := lib.NewEventV1(lib.BoundaryHookGuard, hook, reasonID)
70
+ ev.GuardStrength = strength
71
+ ev.Outcome = outcome
72
+ ev.Host = lib.EventHost(h)
73
+ if ev.Host != lib.HostClaude && ev.Host != lib.HostCodex {
74
+ ev.Host = lib.HostEngine
75
+ }
76
+ ev.EvidencePaths = lib.WorkspaceEvidencePaths(root, slug, evidenceFiles...)
77
+ if err := lib.BindEventWorkspace(root, slug, &ev); err != nil {
78
+ return
79
+ }
80
+ _ = lib.AppendEventV1(root, ev)
71
81
  }
72
82
 
73
- // auqFieldMax caps each captured question/answer so a verbose exchange never
74
- // bloats the timeline.
75
- const auqFieldMax = 300
76
-
77
- // hookAUQ deterministically captures an AskUserQuestion exchange: every
78
- // question with the user's chosen answer: into the session trace, so HITL
79
- // decisions are recorded at the substrate rather than trusted to the model's
80
- // bookkeeping. Capture only: it never tunes, blocks, or replies. Fail-open like
81
- // every workspace hook: unparseable payload, unknown response shape, or no
82
- // active workspace all exit 0 silently.
83
+ // hookAUQ records one metadata-only v1 event for each AskUserQuestion item after
84
+ // the tool returns. Prompts and answers remain in questions.md and are not
85
+ // copied into telemetry.
83
86
  func hookAUQ(stdin io.Reader, stdout, stderr io.Writer) int {
84
87
  data, err := io.ReadAll(io.LimitReader(stdin, 1<<20))
85
88
  if err != nil {
@@ -91,72 +94,43 @@ func hookAUQ(stdin io.Reader, stdout, stderr io.Writer) int {
91
94
  Question string `json:"question"`
92
95
  } `json:"questions"`
93
96
  } `json:"tool_input"`
94
- ToolResponse json.RawMessage `json:"tool_response"`
95
97
  }
96
98
  if json.Unmarshal(data, &payload) != nil || len(payload.ToolInput.Questions) == 0 {
97
99
  return exitOK
98
100
  }
99
- answers := auqAnswers(payload.ToolResponse)
100
- root, slug, dir, ok := resolveWorkspace()
101
+ root, slug, _, ok := resolveWorkspace()
101
102
  if !ok {
102
103
  return exitOK
103
104
  }
104
- ts := time.Now().UTC().Format(time.RFC3339)
105
- for _, q := range payload.ToolInput.Questions {
106
- question := truncateField(strings.TrimSpace(q.Question), auqFieldMax)
107
- if question == "" {
108
- continue
105
+ const maxObservedQuestions = 64
106
+ for i, q := range payload.ToolInput.Questions {
107
+ if i == maxObservedQuestions {
108
+ break
109
109
  }
110
- rec := auqEventJSON{
111
- TS: ts,
112
- Event: "auq",
113
- Slug: slug,
114
- Question: question,
115
- Answer: truncateField(answers[q.Question], auqFieldMax),
110
+ if strings.TrimSpace(q.Question) == "" {
111
+ continue
116
112
  }
117
- line, err := json.Marshal(rec)
118
- if err != nil {
113
+ event := lib.NewEventV1(
114
+ lib.BoundaryHookGuard,
115
+ "human-wait-resumed",
116
+ drvreason.HookStopUnsurfacedHumanGate,
117
+ )
118
+ event.GuardStrength = lib.GuardObserved
119
+ event.Outcome = lib.OutcomeObserved
120
+ event.Host = lib.HostClaude
121
+ if err := lib.BindEventWorkspace(root, slug, &event); err != nil {
119
122
  continue
120
123
  }
121
- _ = state.AppendLog(filepath.Join(root, "timeline.jsonl"), string(line))
122
- _ = state.AppendLog(filepath.Join(dir, "events.jsonl"), string(line))
124
+ _ = lib.AppendEventV1(root, event)
123
125
  }
124
126
  return exitOK
125
127
  }
126
128
 
127
- // auqAnswers extracts the question→answer map from the tool response,
128
- // tolerating the shapes the harness emits: an object with an "answers"
129
- // string map, a bare string map, or anything else (→ no answers, questions
130
- // still recorded).
131
- func auqAnswers(raw json.RawMessage) map[string]string {
132
- if len(raw) == 0 {
133
- return nil
134
- }
135
- var wrapped struct {
136
- Answers map[string]string `json:"answers"`
137
- }
138
- if json.Unmarshal(raw, &wrapped) == nil && len(wrapped.Answers) > 0 {
139
- return wrapped.Answers
140
- }
141
- var flat map[string]string
142
- if json.Unmarshal(raw, &flat) == nil {
143
- return flat
144
- }
145
- return nil
146
- }
147
-
148
- func truncateField(s string, max int) string {
149
- if len(s) <= max {
150
- return s
151
- }
152
- return s[:max] + "…"
153
- }
154
-
155
- // hookEvent records hook/session observability without changing flow. It is
156
- // fail-open: outside an active workspace it stays silent.
129
+ // hookEvent records host lifecycle notifications as metadata-only v1 events. It
130
+ // does not change workflow state.
157
131
  func hookEvent(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
158
132
  _, _ = io.Copy(io.Discard, stdin)
159
- event := "hook"
133
+ event := ""
160
134
  for _, arg := range args {
161
135
  if strings.HasPrefix(arg, "--") {
162
136
  continue
@@ -164,22 +138,31 @@ func hookEvent(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
164
138
  event = arg
165
139
  break
166
140
  }
167
- root, slug, dir, ok := resolveWorkspace()
141
+ switch event {
142
+ case "session-end":
143
+ event = "session-ended"
144
+ case "subagent-stop":
145
+ event = "agent-finished"
146
+ default:
147
+ return exitOK
148
+ }
149
+ root, slug, _, ok := resolveWorkspace()
168
150
  if !ok {
169
151
  return exitOK
170
152
  }
171
- rec := hookEventJSON{TS: time.Now().UTC().Format(time.RFC3339), Event: event, Slug: slug}
172
- line, err := json.Marshal(rec)
173
- if err != nil {
153
+ observation := lib.NewEventV1(lib.BoundaryAgentDispatch, event, drvreason.RootSelected)
154
+ observation.GuardStrength = lib.GuardObserved
155
+ observation.Outcome = lib.OutcomeObserved
156
+ observation.Host = lib.HostClaude
157
+ if err := lib.BindEventWorkspace(root, slug, &observation); err != nil {
174
158
  return exitOK
175
159
  }
176
- _ = state.AppendLog(filepath.Join(root, "timeline.jsonl"), string(line))
177
- _ = state.AppendLog(filepath.Join(dir, "events.jsonl"), string(line))
160
+ _ = lib.AppendEventV1(root, observation)
178
161
  return exitOK
179
162
  }
180
163
 
181
- // hookHandoffSnapshot preserves a compact resume note before context compaction.
182
- // It appends; it never rewrites the canonical workspace artifacts.
164
+ // hookHandoffSnapshot appends a short resume note before context compaction. It
165
+ // does not rewrite canonical workspace artifacts.
183
166
  func hookHandoffSnapshot(stdin io.Reader, stdout, stderr io.Writer) int {
184
167
  _, _ = io.Copy(io.Discard, stdin)
185
168
  _, slug, dir, ok := resolveWorkspace()
@@ -209,10 +192,9 @@ func hookHandoffSnapshot(stdin io.Reader, stdout, stderr io.Writer) int {
209
192
  return exitOK
210
193
  }
211
194
 
212
- // hookCursor re-injects the active feature's cursor each turn (UserPromptSubmit),
213
- // so the load-bearing next-action sits at the high-attention end of context. Ported
214
- // from devrites-cursor.sh onto the new schema: plain-text output, silent + exit 0
215
- // on a non-DevRites project.
195
+ // hookCursor adds the active feature cursor to each UserPromptSubmit event so the
196
+ // next action appears near the end of the current context. It keeps the plain
197
+ // text output and silent nonproject behavior of devrites-cursor.sh.
216
198
  func hookCursor(stdin io.Reader, stdout, stderr io.Writer) int {
217
199
  _, _ = io.Copy(io.Discard, stdin)
218
200
  root, slug, dir, ok := resolveWorkspace()
@@ -246,9 +228,9 @@ func hookCursor(stdin io.Reader, stdout, stderr io.Writer) int {
246
228
  return exitOK
247
229
  }
248
230
 
249
- // hookStatusline is the one-line workspace HUD (settings.json statusLine). Ported
250
- // from devrites-statusline.sh onto the new schema: reads the session JSON on stdin
251
- // (ignored), silent + exit 0 when there is no active workspace.
231
+ // hookStatusline prints the one-line workspace status configured by settings.json.
232
+ // It reads and ignores the session JSON on stdin, matching
233
+ // devrites-statusline.sh, and stays silent without an active workspace.
252
234
  func hookStatusline(stdin io.Reader, stdout, stderr io.Writer) int {
253
235
  _, _ = io.Copy(io.Discard, stdin)
254
236
  root, slug, dir, ok := resolveWorkspace()
@@ -268,12 +250,16 @@ func hookStatusline(stdin io.Reader, stdout, stderr io.Writer) int {
268
250
  if wsIsFile(filepath.Join(dir, ".red")) {
269
251
  red = " · RED"
270
252
  }
271
- fmt.Fprintf(stdout, "DevRites: %s · %s · gates:%s · %s%s\n", slug, phase, gates, mode, red)
253
+ obs := lib.ObservabilityStatus(root, slug)
254
+ if obs != "" {
255
+ obs = " · " + obs
256
+ }
257
+ fmt.Fprintf(stdout, "DevRites: %s · %s · gates:%s · %s%s%s\n", slug, phase, gates, mode, red, obs)
272
258
  return exitOK
273
259
  }
274
260
 
275
- // redwatch detection patterns, ported from devrites-redwatch.sh (grep -qiE, so
276
- // case-insensitive; the FAIL/PASS scans are line-oriented, hence (?m)).
261
+ // redwatch detection patterns match devrites-redwatch.sh. They are case
262
+ // insensitive, and FAIL/PASS scans are line oriented.
277
263
  // pack-scan-ignore: these are the hook's own red/green heuristics, not a payload.
278
264
  var (
279
265
  redTestCmdRe = regexp.MustCompile(`(?i)(npm|pnpm|yarn|bun)([[:space:]]+run)?[[:space:]]+(test|build|lint|typecheck|check)|jest|vitest|pytest|go[[:space:]]+test|cargo[[:space:]]+(test|build|clippy)|\bmvn\b|gradle|eslint|ruff|mypy|\btsc\b|\bmake[[:space:]]+(test|build|check)`)
@@ -283,10 +269,10 @@ var (
283
269
 
284
270
  const redReasonFmt = "DevRites: tests/build are RED (%s). Fix to green or record the failure + next step in state.md before stopping: the Stop gate blocks an end-of-turn while .red is set."
285
271
 
286
- // hookRedwatch is the fail-on-red sentinel (PostToolUse Bash). After a test / build
287
- // / lint command it sets or clears <featureDir>/.red so the Stop gate can refuse to
288
- // end a turn while the suite is red. Ported from devrites-redwatch.sh onto the new
289
- // schema; heuristic on the command + its output, fail-open and silent on any doubt.
272
+ // hookRedwatch handles PostToolUse Bash events for test, build, lint, and type
273
+ // checking commands. It sets or clears <featureDir>/.red so the Stop gate can
274
+ // catch a known failing suite. Its command and output checks match
275
+ // devrites-redwatch.sh, and uncertain input leaves state unchanged.
290
276
  func hookRedwatch(h harness.Harness, stdin io.Reader, stdout, stderr io.Writer) int {
291
277
  data, err := io.ReadAll(stdin)
292
278
  if err != nil {
@@ -303,7 +289,7 @@ func hookRedwatch(h harness.Harness, stdin io.Reader, stdout, stderr io.Writer)
303
289
  if in.ToolName != "Bash" {
304
290
  return exitOK
305
291
  }
306
- // Only react to test/build/lint/typecheck commands.
292
+ // Ignore commands unrelated to tests, builds, linting, or type checking.
307
293
  if !redTestCmdRe.MatchString(in.Command) {
308
294
  return exitOK
309
295
  }
@@ -339,11 +325,13 @@ func redwatchSafe(cmd string) string {
339
325
  // node regex the shell guards use.
340
326
  var patchPathRe = regexp.MustCompile(`(?m)^\*\*\* (?:(?:Add|Update|Delete) File|Move to): (.+)$`)
341
327
 
342
- // isEditTool reports whether a tool can write source: the Edit family plus
343
- // Codex's apply_patch.
328
+ // isEditTool reports whether a tool can write source. toolBaseName also handles
329
+ // host-qualified names such as functions.apply_patch and MCP symbol editors.
344
330
  func isEditTool(tool string) bool {
345
- switch tool {
346
- case "Edit", "Write", "MultiEdit", "NotebookEdit", "apply_patch":
331
+ switch toolBaseName(tool) {
332
+ case "edit", "write", "multiedit", "multi_edit", "notebookedit", "notebook_edit",
333
+ "apply_patch", "applypatch", "edit_file", "write_file", "create_file", "delete_file",
334
+ "replace_symbol_body", "insert_before_symbol", "insert_after_symbol", "safe_delete_symbol":
347
335
  return true
348
336
  }
349
337
  return false
@@ -353,24 +341,118 @@ func isEditTool(tool string) bool {
353
341
  func patchPaths(command string) []string {
354
342
  var paths []string
355
343
  for _, m := range patchPathRe.FindAllStringSubmatch(command, -1) {
356
- paths = append(paths, m[1])
344
+ paths = append(paths, strings.TrimSpace(m[1]))
357
345
  }
358
346
  return paths
359
347
  }
360
348
 
361
- // underDevrites reports whether an absolute path is inside the .devrites root
362
- // (the orchestrator's own bookkeeping, always allowed to be edited).
349
+ // shellOutputRedirections removes harmless fd redirects and returns every file
350
+ // target of >, >>, or >|. Dynamic targets are opaque and therefore unsafe.
351
+ func shellOutputRedirections(command string) (sanitized string, paths []string, opaque bool) {
352
+ buf := []byte(command)
353
+ sanitizedBytes := append([]byte(nil), buf...)
354
+ var quote byte
355
+ for i := 0; i < len(buf); i++ {
356
+ switch {
357
+ case quote != 0:
358
+ if buf[i] == '\\' && quote == '"' {
359
+ i++
360
+ } else if buf[i] == quote {
361
+ quote = 0
362
+ }
363
+ continue
364
+ case buf[i] == '\'' || buf[i] == '"':
365
+ quote = buf[i]
366
+ continue
367
+ case buf[i] != '>':
368
+ continue
369
+ }
370
+
371
+ start := i
372
+ for start > 0 && buf[start-1] >= '0' && buf[start-1] <= '9' {
373
+ start--
374
+ }
375
+ i++
376
+ if i < len(buf) && (buf[i] == '>' || buf[i] == '|') {
377
+ i++
378
+ }
379
+ for i < len(buf) && (buf[i] == ' ' || buf[i] == '\t') {
380
+ i++
381
+ }
382
+ if i < len(buf) && buf[i] == '&' {
383
+ i++
384
+ for i < len(buf) && buf[i] >= '0' && buf[i] <= '9' {
385
+ i++
386
+ }
387
+ for j := start; j < i; j++ {
388
+ sanitizedBytes[j] = ' '
389
+ }
390
+ i--
391
+ continue
392
+ }
393
+ if i >= len(buf) || buf[i] == '(' {
394
+ return "", paths, true
395
+ }
396
+
397
+ wordStart := i
398
+ var wordQuote byte
399
+ for i < len(buf) {
400
+ c := buf[i]
401
+ if wordQuote != 0 {
402
+ if c == '\\' && wordQuote == '"' {
403
+ i += 2
404
+ continue
405
+ }
406
+ if c == wordQuote {
407
+ wordQuote = 0
408
+ }
409
+ i++
410
+ continue
411
+ }
412
+ if c == '\'' || c == '"' {
413
+ wordQuote = c
414
+ i++
415
+ continue
416
+ }
417
+ if c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == ';' || c == '|' || c == '&' {
418
+ break
419
+ }
420
+ i++
421
+ }
422
+ if wordQuote != 0 {
423
+ return "", paths, true
424
+ }
425
+ target := strings.Trim(string(buf[wordStart:i]), `"'`)
426
+ if target == "" || strings.ContainsAny(target, "$`*?[]{}()") {
427
+ return "", paths, true
428
+ }
429
+ if target != "/dev/null" {
430
+ paths = append(paths, target)
431
+ }
432
+ for j := start; j < i; j++ {
433
+ sanitizedBytes[j] = ' '
434
+ }
435
+ i--
436
+ }
437
+ return string(sanitizedBytes), paths, false
438
+ }
439
+
440
+ // underDevrites reports whether an absolute path is inside the .devrites root.
363
441
  func underDevrites(abs, root string) bool {
364
- return strings.HasPrefix(abs, root+string(filepath.Separator))
442
+ return pathWithin(abs, root)
443
+ }
444
+
445
+ func pathWithin(path, parent string) bool {
446
+ path = filepath.Clean(path)
447
+ parent = filepath.Clean(parent)
448
+ return path == parent || strings.HasPrefix(path, parent+string(filepath.Separator))
365
449
  }
366
450
 
367
451
  const a1DenyReason = "DevRites A1: the orchestrator must not edit source mid-build: the slice-wright is the only writer. Re-dispatch the wright (continue it once) or stop + escalate; do not patch the code yourself. (devrites-a1-guard)"
368
452
 
369
- // hookA1Guard keeps the /rite-build orchestrator from editing source while a slice
370
- // is mid-build: the slice-wright (a subagent) is the only sanctioned writer of
371
- // code. Ported from devrites-a1-guard.sh onto the new schema: FAIL-OPEN, OBSERVE by
372
- // default (logs to .a1-guard.log), blocking only when the build window is open, the
373
- // edit is source from the MAIN thread, and enforce mode is on.
453
+ // hookA1Guard prevents /rite-build from editing source while slice-wright owns an
454
+ // open build window. It matches devrites-a1-guard.sh by logging observations to
455
+ // .a1-guard.log unless enforcement is enabled.
374
456
  func hookA1Guard(h harness.Harness, stdin io.Reader, stdout, stderr io.Writer) int {
375
457
  data, err := io.ReadAll(stdin)
376
458
  if err != nil {
@@ -383,8 +465,8 @@ func hookA1Guard(h harness.Harness, stdin io.Reader, stdout, stderr io.Writer) i
383
465
  if !ok {
384
466
  return exitOK
385
467
  }
386
- // Only armed between a reconcile snapshot and its check; the inline fallback
387
- // means the orchestrator is the legitimate writer.
468
+ // The guard is active between a reconcile snapshot and its check. Inline
469
+ // fallback makes the orchestrator the expected writer.
388
470
  if !wsIsFile(filepath.Join(dir, ".reconcile-base")) {
389
471
  return exitOK
390
472
  }
@@ -396,7 +478,7 @@ func hookA1Guard(h harness.Harness, stdin io.Reader, stdout, stderr io.Writer) i
396
478
  if !isEditTool(in.ToolName) {
397
479
  return exitOK
398
480
  }
399
- // A subagent caller (the wright) is allowed. Claude sends agent_id; Codex agent_type.
481
+ // The wright may edit. Claude sends agent_id; Codex sends agent_type.
400
482
  if in.AgentID != "" || in.AgentType != "" {
401
483
  return exitOK
402
484
  }
@@ -445,18 +527,32 @@ func hookA1Guard(h harness.Harness, stdin io.Reader, stdout, stderr io.Writer) i
445
527
  return exitOK
446
528
  }
447
529
  fmt.Fprintln(stdout, out)
530
+ recordHookGuard(h, "a1-guard", drvreason.HookA1Denied, lib.GuardEnforced, lib.OutcomeDenied, ".reconcile-base")
448
531
  return exitOK
449
532
  }
450
533
  _ = state.AppendLog(filepath.Join(dir, ".a1-guard.log"), "WOULD-BLOCK\t"+in.ToolName+"\t"+target)
534
+ recordHookGuard(h, "a1-guard", drvreason.HookA1Observed, lib.GuardObserved, lib.OutcomeObserved, ".reconcile-base")
451
535
  return exitOK
452
536
  }
453
537
 
454
- const wrightDenyReason = "DevRites scope: this path is not in touched-files.md. Build only the slice contract; if the slice genuinely needs this file, return an Escalation so the orchestrator routes it through the Spec Drift Guard: do not widen scope yourself. (devrites-wright-scope)"
538
+ const (
539
+ wrightDenyReason = "DevRites scope: this write is not in the orchestrator-provided exact wright allowlist, or it targets .devrites. Build only the slice contract; return the blocked path to the orchestrator instead of widening scope. (devrites-wright-scope)"
540
+ wrightForbiddenReason = "DevRites scope: slice-wrights cannot dispatch nested agents, commit/push, install dependencies, run live migrations/deployments, or execute an uninspectable write path. (devrites-wright-scope)"
541
+ forgeWrightDenyReason = "DevRites Forge scope: this candidate does not match its root-owned manifest, physical worktree, Git identity, branch, worker identity, or live process token. Return to the orchestrator; never widen or guess Forge ownership. (devrites-forge-wright-scope)"
542
+ wrightAllowlistEnvName = "DEVRITES_WRIGHT_ALLOWLIST_FILE"
543
+ forgeRunEnvName = "DEVRITES_FORGE_RUN_ID"
544
+ forgeCandidateEnvName = "DEVRITES_FORGE_CANDIDATE"
545
+ forgeWorkerEnvName = "DEVRITES_FORGE_WORKER_ID"
546
+ forgePIDEnvName = "DEVRITES_FORGE_WORKER_PID"
547
+ forgeProcessEnvName = "DEVRITES_FORGE_PROCESS_START"
548
+ )
549
+
550
+ var wrightForbiddenShellRe = regexp.MustCompile(`(?im)\bgit[[:space:]]+(add|commit|push|reset|checkout|restore|clean|rm|mv|stash|tag|apply|merge|rebase|cherry-pick|am)\b|\b(npm|pnpm|yarn)[[:space:]]+(install|i|ci|add|publish)\b|\b(pip|pip3)[[:space:]]+install\b|\b(poetry|uv)[[:space:]]+(add|install)\b|\b(bundle|composer)[[:space:]]+(install|update)\b|\bgo[[:space:]]+(get|install|generate)\b|\bcargo[[:space:]]+(add|install|publish)\b|\b(devrites-engine[[:space:]]+migrate|alembic[[:space:]]+(upgrade|downgrade)|prisma[[:space:]]+migrate|flyway[[:space:]]+migrate|liquibase[[:space:]]+update|manage\.py[[:space:]]+migrate|(rails|rake)[[:space:]]+db:(migrate|rollback|seed)|sequelize[[:space:]]+db:migrate|knex[[:space:]]+migrate:latest)\b|\b(terraform[[:space:]]+(apply|destroy)|kubectl[[:space:]]+(apply|create|delete|patch|replace)|helm[[:space:]]+(install|upgrade|uninstall)|docker[[:space:]]+push)\b|\b(curl|wget|scp|ssh|nc|socat|telnet)\b|\b(codex|claude)[[:space:]]+(exec|agent)\b`)
551
+
552
+ var opaqueInterpreterRe = regexp.MustCompile(`(?im)(^|[;&|[:space:]])(python[0-9.]*[[:space:]]+(-c|-)|node[[:space:]]+(-e|--)|ruby[[:space:]]+-e|perl[[:space:]]+(-e|-p|-i)|[.][/][^[:space:]]+\.(sh|py|js|rb))([;&|[:space:]]|$)`)
455
553
 
456
- // hookWrightScope fences the slice-wright to its slice: it denies an Edit/Write to a
457
- // path not listed in the feature's touched-files.md. Ported from
458
- // devrites-wright-scope.sh onto the new schema: FAIL-OPEN, OBSERVE by default (logs
459
- // to .wright-scope.log), enforced with DEVRITES_WRIGHT_SCOPE=enforce.
554
+ // hookWrightScope restricts the write-capable leaf to the orchestrator's exact
555
+ // path allowlist. It always enforces active DevRites runs.
460
556
  func hookWrightScope(h harness.Harness, stdin io.Reader, stdout, stderr io.Writer) int {
461
557
  data, err := io.ReadAll(stdin)
462
558
  if err != nil {
@@ -465,68 +561,453 @@ func hookWrightScope(h harness.Harness, stdin io.Reader, stdout, stderr io.Write
465
561
  if !bytes.Contains(data, []byte(`"tool_name"`)) {
466
562
  return exitOK
467
563
  }
468
- root, _, dir, ok := resolveWorkspace()
469
- if !ok {
564
+ in := h.ParseGuardInput(bytes.NewReader(data))
565
+ kind := devritesAgent(in.AgentType)
566
+ forgeDeclared := forgeWrightDeclared()
567
+ active := kind != devritesAgentNone
568
+ if forgeDeclared && kind != devritesAgentWright {
569
+ return denyWright(h, stdout, stderr, forgeWrightDenyReason, drvreason.HookForgeBindingDenied)
570
+ }
571
+ if kind == devritesAgentNone && (in.AgentType != "" || os.Getenv("DEVRITES_WRIGHT_AGENT_REQUIRED") == "1") {
470
572
  return exitOK
471
573
  }
472
- tfData, err := os.ReadFile(filepath.Join(dir, "touched-files.md"))
473
- if err != nil {
474
- return exitOK // no touched-files.md → not fencing
574
+ if kind == devritesAgentReadonly {
575
+ return exitOK // reviewer-readonly owns every non-wright leaf
576
+ }
577
+ if kind == devritesAgentInvalid {
578
+ if !isEditTool(in.ToolName) && !isShellTool(in.ToolName) &&
579
+ !isOpaqueExecutionTool(in.ToolName) && !isAgentDispatchTool(in.ToolName) {
580
+ return exitOK
581
+ }
582
+ return denyWright(h, stdout, stderr, reviewerReadonlyDenyReason, drvreason.HookReviewerReadonlyDenied)
475
583
  }
476
- touched := string(tfData)
477
584
 
478
- in := h.ParseGuardInput(bytes.NewReader(data))
479
- if os.Getenv("DEVRITES_WRIGHT_AGENT_REQUIRED") == "1" && in.AgentType != "devrites-slice-wright" {
585
+ var targets []string
586
+ forbidden := isAgentDispatchTool(in.ToolName) || isOpaqueExecutionTool(in.ToolName)
587
+ switch {
588
+ case forbidden:
589
+ case isEditTool(in.ToolName):
590
+ targets = mutationToolPaths(data, in)
591
+ if len(targets) == 0 {
592
+ forbidden = true
593
+ }
594
+ case isShellTool(in.ToolName):
595
+ targets, forbidden = wrightShellWritePaths(in.Command)
596
+ if !forbidden && len(targets) == 0 {
597
+ return exitOK
598
+ }
599
+ default:
480
600
  return exitOK
481
601
  }
482
- if !isEditTool(in.ToolName) {
602
+
603
+ root, slug, dir, ok := resolveWorkspace()
604
+ if !ok {
605
+ if kind == devritesAgentWright {
606
+ return denyWright(h, stdout, stderr, wrightForbiddenReason, drvreason.HookWrightForbiddenDenied)
607
+ }
483
608
  return exitOK
484
609
  }
485
610
 
611
+ if wrightForbiddenShellRe.MatchString(in.Command) ||
612
+ (opaqueInterpreterRe.MatchString(in.Command) && !safeReadonlyShellCommand(in.Command)) {
613
+ forbidden = true
614
+ }
615
+ if forbidden {
616
+ return denyOrObserveWright(h, stdout, stderr, dir, active, wrightForbiddenReason, drvreason.HookWrightForbiddenDenied, []string{"opaque/forbidden operation"})
617
+ }
618
+
486
619
  projectDir := filepath.Dir(root)
620
+ if !forgeDeclared && kind == devritesAgentWright && insideForgeStaging(projectDir) {
621
+ return denyOrObserveWright(h, stdout, stderr, dir, true, forgeWrightDenyReason, drvreason.HookForgeBindingDenied, []string{"missing Forge binding"})
622
+ }
623
+ if forgeDeclared {
624
+ projectDir, err = forgeWrightProjectDir(projectDir, slug, in)
625
+ if err != nil {
626
+ debugf(stderr, "wright-scope Forge binding: %v", err)
627
+ return denyOrObserveWright(h, stdout, stderr, dir, true, forgeWrightDenyReason, drvreason.HookForgeBindingDenied, []string{"invalid Forge binding"})
628
+ }
629
+ }
630
+ allow, err := readWrightAllowlist(projectDir, root, dir)
631
+ if err != nil {
632
+ debugf(stderr, "wright-scope allowlist: %v", err)
633
+ return denyOrObserveWright(h, stdout, stderr, dir, active, wrightDenyReason, drvreason.HookWrightScopeDenied, []string{"invalid/missing allowlist"})
634
+ }
487
635
  var bad []string
488
- if in.ToolName == "apply_patch" {
489
- for _, p := range patchPaths(in.Command) {
490
- if strings.HasPrefix(p, ".devrites/") || strings.Contains(p, "/.devrites/") {
491
- continue
636
+ for _, target := range targets {
637
+ rel, err := normalizeProjectPath(projectDir, root, target, false)
638
+ if err != nil {
639
+ bad = append(bad, target)
640
+ continue
641
+ }
642
+ if _, ok := allow[rel]; !ok {
643
+ bad = append(bad, target)
644
+ }
645
+ }
646
+ if len(bad) == 0 {
647
+ return exitOK
648
+ }
649
+ return denyOrObserveWright(h, stdout, stderr, dir, active, wrightDenyReason, drvreason.HookWrightScopeDenied, bad)
650
+ }
651
+
652
+ func forgeWrightDeclared() bool {
653
+ for _, name := range []string{
654
+ forgeRunEnvName,
655
+ forgeCandidateEnvName,
656
+ forgeWorkerEnvName,
657
+ forgePIDEnvName,
658
+ forgeProcessEnvName,
659
+ } {
660
+ if os.Getenv(name) != "" {
661
+ return true
662
+ }
663
+ }
664
+ return false
665
+ }
666
+
667
+ func insideForgeStaging(primaryProject string) bool {
668
+ cwd, err := physicalWorkingDir()
669
+ if err != nil {
670
+ return false
671
+ }
672
+ primary, err := filepath.Abs(primaryProject)
673
+ if err != nil {
674
+ return false
675
+ }
676
+ primary, err = filepath.EvalSymlinks(primary)
677
+ if err != nil {
678
+ return false
679
+ }
680
+ staging := filepath.Join(filepath.Dir(primary), "."+filepath.Base(primary)+".devrites-forge")
681
+ rel, err := filepath.Rel(staging, cwd)
682
+ return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
683
+ }
684
+
685
+ func forgeWrightProjectDir(primaryProject, activeSlug string, in harness.GuardInput) (string, error) {
686
+ values := map[string]string{
687
+ forgeRunEnvName: os.Getenv(forgeRunEnvName),
688
+ forgeCandidateEnvName: os.Getenv(forgeCandidateEnvName),
689
+ forgeWorkerEnvName: os.Getenv(forgeWorkerEnvName),
690
+ forgePIDEnvName: os.Getenv(forgePIDEnvName),
691
+ forgeProcessEnvName: os.Getenv(forgeProcessEnvName),
692
+ }
693
+ for name, value := range values {
694
+ if value == "" || strings.TrimSpace(value) != value {
695
+ return "", fmt.Errorf("%s must be present and exact", name)
696
+ }
697
+ }
698
+
699
+ manifest, _, err := forge.Load(primaryProject, values[forgeRunEnvName])
700
+ if err != nil {
701
+ return "", err
702
+ }
703
+ if manifest.FeatureSlug != activeSlug {
704
+ return "", fmt.Errorf("forge run belongs to feature %q, not active feature %q", manifest.FeatureSlug, activeSlug)
705
+ }
706
+ candidate, err := manifest.Candidate(forge.CandidateID(values[forgeCandidateEnvName]))
707
+ if err != nil {
708
+ return "", err
709
+ }
710
+ if candidate.State != forge.StateRunning {
711
+ return "", fmt.Errorf("forge candidate %s is %s, not running", candidate.ID, candidate.State)
712
+ }
713
+ pid, err := strconv.Atoi(values[forgePIDEnvName])
714
+ if err != nil || strconv.Itoa(pid) != values[forgePIDEnvName] {
715
+ return "", fmt.Errorf("%s must be a canonical positive PID", forgePIDEnvName)
716
+ }
717
+ if candidate.Worker.ID != values[forgeWorkerEnvName] ||
718
+ candidate.Worker.PID != pid ||
719
+ candidate.Worker.ProcessStart != values[forgeProcessEnvName] {
720
+ return "", fmt.Errorf("forge worker identity does not match the manifest")
721
+ }
722
+ if in.AgentID != "" && in.AgentID != candidate.Worker.ID {
723
+ return "", fmt.Errorf("hook agent identity does not match Forge worker %q", candidate.Worker.ID)
724
+ }
725
+ liveToken, err := forge.ProcessStartToken(candidate.Worker.PID)
726
+ if err != nil || liveToken != candidate.Worker.ProcessStart {
727
+ return "", fmt.Errorf("forge worker liveness token is no longer valid")
728
+ }
729
+
730
+ cwd, err := physicalWorkingDir()
731
+ if err != nil {
732
+ return "", err
733
+ }
734
+ if filepath.Clean(cwd) != filepath.Clean(candidate.Worktree) {
735
+ return "", fmt.Errorf("physical cwd %q is not candidate %s worktree %q", cwd, candidate.ID, candidate.Worktree)
736
+ }
737
+ facts, _ := rootfacts.ResolveFrom(cwd, "")
738
+ if facts.Git.TopLevel != candidate.Worktree || facts.Git.CommonDir != manifest.GitCommonDir {
739
+ return "", fmt.Errorf("forge candidate Git identity does not match the manifest")
740
+ }
741
+ cmd := exec.Command("git", "-C", cwd, "symbolic-ref", "--quiet", "--short", "HEAD")
742
+ cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "LC_ALL=C")
743
+ branch, err := cmd.Output()
744
+ if err != nil || strings.TrimSpace(string(branch)) != candidate.Branch {
745
+ return "", fmt.Errorf("forge candidate branch does not match the manifest")
746
+ }
747
+ return candidate.Worktree, nil
748
+ }
749
+
750
+ func physicalWorkingDir() (string, error) {
751
+ cwd, err := os.Getwd()
752
+ if err != nil {
753
+ return "", err
754
+ }
755
+ cwd, err = filepath.Abs(cwd)
756
+ if err != nil {
757
+ return "", err
758
+ }
759
+ return filepath.EvalSymlinks(cwd)
760
+ }
761
+
762
+ func mutationToolPaths(data []byte, in harness.GuardInput) []string {
763
+ var envelope struct {
764
+ ToolInput any `json:"tool_input"`
765
+ }
766
+ if json.Unmarshal(data, &envelope) != nil {
767
+ return nil
768
+ }
769
+ var paths []string
770
+ if toolBaseName(in.ToolName) == "apply_patch" || toolBaseName(in.ToolName) == "applypatch" {
771
+ collectPatchMutationPaths(envelope.ToolInput, &paths)
772
+ paths = append(paths, patchPaths(in.Command)...)
773
+ return uniqueStrings(paths)
774
+ }
775
+ collectMutationPaths(envelope.ToolInput, &paths)
776
+ if len(paths) == 0 && in.FilePath != "" {
777
+ paths = append(paths, in.FilePath)
778
+ }
779
+ return uniqueStrings(paths)
780
+ }
781
+
782
+ func collectPatchMutationPaths(value any, paths *[]string) {
783
+ switch value := value.(type) {
784
+ case string:
785
+ *paths = append(*paths, patchPaths(value)...)
786
+ case map[string]any:
787
+ for _, child := range value {
788
+ collectPatchMutationPaths(child, paths)
789
+ }
790
+ case []any:
791
+ for _, child := range value {
792
+ collectPatchMutationPaths(child, paths)
793
+ }
794
+ }
795
+ }
796
+
797
+ func collectMutationPaths(value any, paths *[]string) {
798
+ switch value := value.(type) {
799
+ case map[string]any:
800
+ for key, child := range value {
801
+ normalizedKey := strings.ReplaceAll(strings.ToLower(key), "-", "_")
802
+ switch normalizedKey {
803
+ case "file_path", "filepath", "path", "relative_path", "notebook_path", "new_path", "target_path":
804
+ if path, ok := child.(string); ok && strings.TrimSpace(path) != "" {
805
+ *paths = append(*paths, strings.TrimSpace(path))
806
+ continue
807
+ }
808
+ }
809
+ collectMutationPaths(child, paths)
810
+ }
811
+ case []any:
812
+ for _, child := range value {
813
+ collectMutationPaths(child, paths)
814
+ }
815
+ }
816
+ }
817
+
818
+ func uniqueStrings(values []string) []string {
819
+ seen := make(map[string]bool, len(values))
820
+ out := make([]string, 0, len(values))
821
+ for _, value := range values {
822
+ if !seen[value] {
823
+ seen[value] = true
824
+ out = append(out, value)
825
+ }
826
+ }
827
+ return out
828
+ }
829
+
830
+ func wrightShellWritePaths(command string) ([]string, bool) {
831
+ if strings.TrimSpace(command) == "" || wrightForbiddenShellRe.MatchString(command) {
832
+ return nil, true
833
+ }
834
+ sanitized, paths, opaque := shellOutputRedirections(command)
835
+ if opaque {
836
+ return nil, true
837
+ }
838
+
839
+ patches := patchPaths(command)
840
+ if strings.Contains(command, "apply_patch") {
841
+ if len(patches) == 0 {
842
+ return nil, true
843
+ }
844
+ paths = append(paths, patches...)
845
+ sanitized = strings.ReplaceAll(sanitized, "apply_patch", "true")
846
+ }
847
+
848
+ for _, segment := range shellSegmentRe.Split(sanitized, -1) {
849
+ if safeReadonlyShellSegment(segment) {
850
+ continue
851
+ }
852
+ fields := strings.Fields(strings.TrimSpace(segment))
853
+ if len(fields) == 0 {
854
+ continue
855
+ }
856
+ for len(fields) > 0 && filepath.Base(fields[0]) == "rtk" {
857
+ fields = fields[1:]
858
+ }
859
+ if len(fields) == 0 {
860
+ return nil, true
861
+ }
862
+ base := strings.ToLower(filepath.Base(strings.Trim(fields[0], `"'`)))
863
+ args := fields[1:]
864
+ var found []string
865
+ switch base {
866
+ case "gofmt", "goimports", "shfmt":
867
+ if !hasAnyArg(args, "-w") {
868
+ return nil, true
869
+ }
870
+ found = nonFlagArgs(args)
871
+ case "prettier":
872
+ if !hasAnyArg(args, "--write") {
873
+ return nil, true
492
874
  }
493
- rel := strings.TrimPrefix(p, projectDir+string(filepath.Separator))
494
- if !strings.Contains(touched, rel) && !strings.Contains(touched, p) {
495
- bad = append(bad, p)
875
+ found = nonFlagArgs(args)
876
+ case "ruff":
877
+ if len(args) == 0 || args[0] != "format" || hasAnyArg(args, "--check") {
878
+ return nil, true
496
879
  }
880
+ found = nonFlagArgs(args[1:])
881
+ case "black", "rustfmt":
882
+ found = nonFlagArgs(args)
883
+ case "rm", "unlink", "touch", "mkdir", "rmdir", "truncate":
884
+ found = nonFlagArgs(args)
885
+ default:
886
+ return nil, true
497
887
  }
498
- } else {
499
- if in.FilePath == "" {
500
- return exitOK
888
+ if len(found) == 0 {
889
+ return nil, true
501
890
  }
502
- if strings.Contains(in.FilePath, ".devrites/") {
503
- return exitOK
891
+ for _, path := range found {
892
+ if strings.ContainsAny(path, "$`*?[]{}()") {
893
+ return nil, true
894
+ }
504
895
  }
505
- rel := strings.TrimPrefix(in.FilePath, projectDir+string(filepath.Separator))
506
- if !strings.Contains(touched, rel) && !strings.Contains(touched, in.FilePath) {
507
- bad = append(bad, in.FilePath)
896
+ paths = append(paths, found...)
897
+ }
898
+
899
+ if len(paths) == 0 {
900
+ if safeReadonlyShellCommand(command) {
901
+ return nil, false
508
902
  }
903
+ return nil, true
509
904
  }
510
- if len(bad) == 0 {
511
- return exitOK
905
+ return uniqueStrings(paths), false
906
+ }
907
+
908
+ func nonFlagArgs(args []string) []string {
909
+ var out []string
910
+ for _, arg := range args {
911
+ arg = strings.Trim(arg, `"'`)
912
+ if arg == "" || strings.HasPrefix(arg, "-") {
913
+ continue
914
+ }
915
+ out = append(out, arg)
512
916
  }
917
+ return out
918
+ }
513
919
 
514
- if hookEnforce("DEVRITES_WRIGHT_SCOPE") {
515
- out, err := h.PreToolDeny(wrightDenyReason)
920
+ func readWrightAllowlist(projectDir, devritesRoot, workspaceDir string) (map[string]struct{}, error) {
921
+ path := strings.TrimSpace(os.Getenv(wrightAllowlistEnvName))
922
+ if path == "" {
923
+ path = filepath.Join(workspaceDir, ".wright-allowlist")
924
+ }
925
+ if !filepath.IsAbs(path) {
926
+ path = filepath.Join(filepath.Dir(devritesRoot), path)
927
+ }
928
+ path = filepath.Clean(path)
929
+ if !safepath.WithinResolved(path, workspaceDir) {
930
+ return nil, fmt.Errorf("allowlist file is outside the active workspace")
931
+ }
932
+ data, err := os.ReadFile(path)
933
+ if err != nil {
934
+ return nil, err
935
+ }
936
+ allow := make(map[string]struct{})
937
+ for _, line := range strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") {
938
+ if line == "" {
939
+ continue
940
+ }
941
+ if strings.TrimSpace(line) != line || strings.HasPrefix(line, "#") {
942
+ return nil, fmt.Errorf("allowlist entry is not an exact normalized path: %q", line)
943
+ }
944
+ rel, err := normalizeProjectPath(projectDir, devritesRoot, line, true)
516
945
  if err != nil {
517
- debugf(stderr, "wright-scope: %v", err)
518
- return exitOK
946
+ return nil, err
519
947
  }
520
- fmt.Fprintln(stdout, out)
521
- return exitOK
948
+ if _, duplicate := allow[rel]; duplicate {
949
+ return nil, fmt.Errorf("duplicate allowlist path: %q", line)
950
+ }
951
+ if info, statErr := os.Stat(filepath.Join(projectDir, filepath.FromSlash(rel))); statErr == nil && info.IsDir() {
952
+ return nil, fmt.Errorf("allowlist path names a directory: %q", line)
953
+ } else if statErr != nil && !os.IsNotExist(statErr) {
954
+ return nil, statErr
955
+ }
956
+ allow[rel] = struct{}{}
957
+ }
958
+ return allow, nil
959
+ }
960
+
961
+ func normalizeProjectPath(projectDir, devritesRoot, raw string, requireNormalized bool) (string, error) {
962
+ if raw == "" || strings.ContainsRune(raw, 0) || strings.ContainsRune(raw, '\\') {
963
+ return "", fmt.Errorf("invalid project path %q", raw)
964
+ }
965
+ var abs string
966
+ if filepath.IsAbs(raw) {
967
+ if requireNormalized {
968
+ return "", fmt.Errorf("allowlist path must be project-relative: %q", raw)
969
+ }
970
+ abs = filepath.Clean(raw)
971
+ } else {
972
+ clean := filepath.Clean(filepath.FromSlash(raw))
973
+ if requireNormalized && filepath.ToSlash(clean) != raw {
974
+ return "", fmt.Errorf("allowlist path is not normalized: %q", raw)
975
+ }
976
+ abs = filepath.Join(projectDir, clean)
977
+ }
978
+ rel, err := filepath.Rel(projectDir, abs)
979
+ if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
980
+ return "", fmt.Errorf("path is outside the project: %q", raw)
981
+ }
982
+ if !safepath.WithinResolved(abs, projectDir) || safepath.WithinResolved(abs, devritesRoot) {
983
+ return "", fmt.Errorf("path escapes the project or targets .devrites: %q", raw)
984
+ }
985
+ return filepath.ToSlash(rel), nil
986
+ }
987
+
988
+ func denyOrObserveWright(h harness.Harness, stdout, stderr io.Writer, dir string, active bool, message string, reasonID drvreason.ID, bad []string) int {
989
+ if active || hookEnforce("DEVRITES_WRIGHT_SCOPE") {
990
+ return denyWright(h, stdout, stderr, message, reasonID)
522
991
  }
523
992
  _ = state.AppendLog(filepath.Join(dir, ".wright-scope.log"), "WOULD-BLOCK\t"+strings.Join(bad, ", "))
993
+ recordHookGuard(h, "wright-scope", drvreason.HookWrightScopeObserved, lib.GuardObserved, lib.OutcomeObserved)
994
+ return exitOK
995
+ }
996
+
997
+ func denyWright(h harness.Harness, stdout, stderr io.Writer, message string, reasonID drvreason.ID) int {
998
+ out, err := h.PreToolDeny(message)
999
+ if err != nil {
1000
+ debugf(stderr, "wright-scope: %v", err)
1001
+ return exitOK
1002
+ }
1003
+ fmt.Fprintln(stdout, out)
1004
+ recordHookGuard(h, "wright-scope", reasonID, lib.GuardEnforced, lib.OutcomeDenied)
524
1005
  return exitOK
525
1006
  }
526
1007
 
527
- // wsGateCount mirrors `grep -ciE '^\s*status:\s*open' questions.md || echo 0`,
528
- // INCLUDING grep's quirk: on a present file with zero matches grep prints "0" and
529
- // exits 1, so `|| echo 0` appends a second "0" (the captured value is "0\n0").
1008
+ // wsGateCount mirrors `grep -ciE '^\s*status:\s*open' questions.md || echo 0`.
1009
+ // On a present file with no matches, grep prints "0" and exits 1, so the fallback
1010
+ // appends another "0". The captured value is therefore "0\n0".
530
1011
  func wsGateCount(qPath string) string {
531
1012
  data, err := os.ReadFile(qPath)
532
1013
  if err != nil {