mandrel 1.58.0 → 1.60.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 (319) hide show
  1. package/.agents/README.md +100 -98
  2. package/.agents/docs/SDLC.md +140 -141
  3. package/.agents/docs/configuration.md +16 -16
  4. package/.agents/docs/workflows.md +7 -8
  5. package/.agents/instructions.md +12 -11
  6. package/.agents/personas/architect.md +1 -1
  7. package/.agents/personas/product.md +1 -1
  8. package/.agents/personas/project-manager.md +14 -14
  9. package/.agents/personas/technical-writer.md +1 -1
  10. package/.agents/rules/changelog-style.md +5 -5
  11. package/.agents/rules/git-conventions.md +3 -3
  12. package/.agents/schemas/agentrc.schema.json +3 -3
  13. package/.agents/schemas/audit-rules.json +20 -0
  14. package/.agents/schemas/dispatch-manifest.json +4 -4
  15. package/.agents/schemas/epic-spec.schema.json +15 -45
  16. package/.agents/schemas/lifecycle/README.md +1 -1
  17. package/.agents/schemas/lifecycle/story.dispatch.end.schema.json +1 -1
  18. package/.agents/schemas/lifecycle/story.dispatch.start.schema.json +1 -1
  19. package/.agents/schemas/lifecycle/story.heartbeat.schema.json +1 -1
  20. package/.agents/schemas/validation-evidence.schema.json +1 -1
  21. package/.agents/scripts/README.md +1 -1
  22. package/.agents/scripts/acceptance-eval.js +21 -4
  23. package/.agents/scripts/acceptance-spec-reconciler.js +2 -2
  24. package/.agents/scripts/analyze-execution.js +2 -2
  25. package/.agents/scripts/assert-branch.js +1 -3
  26. package/.agents/scripts/audit-to-stories.js +1 -1
  27. package/.agents/scripts/bootstrap.js +1 -1
  28. package/.agents/scripts/check-arch-cycles.js +360 -0
  29. package/.agents/scripts/check-doc-links.js +2 -3
  30. package/.agents/scripts/coverage-capture.js +24 -3
  31. package/.agents/scripts/diagnose-friction.js +1 -1
  32. package/.agents/scripts/dispatcher.js +2 -2
  33. package/.agents/scripts/drain-pending-cleanup.js +1 -1
  34. package/.agents/scripts/epic-audit-prepare.js +3 -3
  35. package/.agents/scripts/epic-deliver-note-intervention.js +2 -2
  36. package/.agents/scripts/epic-deliver-preflight.js +11 -9
  37. package/.agents/scripts/epic-deliver-prepare.js +13 -5
  38. package/.agents/scripts/epic-execute-record-wave.js +5 -5
  39. package/.agents/scripts/epic-plan-healthcheck.js +6 -10
  40. package/.agents/scripts/epic-plan-spec-validate.js +1 -1
  41. package/.agents/scripts/epic-reconcile.js +11 -29
  42. package/.agents/scripts/evidence-gate.js +2 -2
  43. package/.agents/scripts/generate-workflows-doc.js +1 -1
  44. package/.agents/scripts/git-rebase-and-resolve.js +1 -1
  45. package/.agents/scripts/hierarchy-gate.js +40 -24
  46. package/.agents/scripts/lib/ITicketingProvider.js +1 -1
  47. package/.agents/scripts/lib/audit-suite/selector.js +1 -1
  48. package/.agents/scripts/lib/audit-to-stories/seed-epic-from-findings.js +2 -2
  49. package/.agents/scripts/lib/baseline-snapshot.js +7 -7
  50. package/.agents/scripts/lib/baselines/kinds/coverage.js +33 -149
  51. package/.agents/scripts/lib/baselines/kinds/duplication.js +27 -116
  52. package/.agents/scripts/lib/baselines/kinds/kind-factory.js +192 -0
  53. package/.agents/scripts/lib/baselines/kinds/lighthouse.js +34 -133
  54. package/.agents/scripts/lib/baselines/kinds/maintainability.js +31 -124
  55. package/.agents/scripts/lib/baselines/kinds/mutation.js +25 -111
  56. package/.agents/scripts/lib/baselines/maintainability-baseline-io.js +59 -0
  57. package/.agents/scripts/lib/baselines/maintainability-baseline-save.js +37 -0
  58. package/.agents/scripts/lib/baselines/writer.js +1 -1
  59. package/.agents/scripts/lib/bdd-runner-detect.js +1 -1
  60. package/.agents/scripts/lib/bdd-scenario-scanner.js +3 -3
  61. package/.agents/scripts/lib/bootstrap/baselines-layout-migration.js +1 -1
  62. package/.agents/scripts/lib/bootstrap/branch-protection.js +1 -1
  63. package/.agents/scripts/lib/bootstrap/ci-workflow-template.js +1 -1
  64. package/.agents/scripts/lib/bootstrap/commit-push.js +2 -2
  65. package/.agents/scripts/lib/close-validation/commands.js +188 -0
  66. package/.agents/scripts/lib/close-validation/gates.js +235 -0
  67. package/.agents/scripts/lib/close-validation/process.js +101 -0
  68. package/.agents/scripts/lib/close-validation/projections/maintainability.js +1 -1
  69. package/.agents/scripts/lib/close-validation/runner.js +325 -0
  70. package/.agents/scripts/lib/close-validation/telemetry.js +70 -0
  71. package/.agents/scripts/lib/codebase-snapshot.js +1 -1
  72. package/.agents/scripts/lib/config/explain.js +1 -1
  73. package/.agents/scripts/lib/config/quality.js +6 -6
  74. package/.agents/scripts/lib/config/runners.js +2 -2
  75. package/.agents/scripts/lib/config/runtime.js +1 -1
  76. package/.agents/scripts/lib/config/temp-paths.js +2 -2
  77. package/.agents/scripts/lib/config-resolver.js +2 -5
  78. package/.agents/scripts/lib/config-settings-schema-delivery.js +2 -2
  79. package/.agents/scripts/lib/config-settings-schema-quality.js +1 -1
  80. package/.agents/scripts/lib/config-settings-schema.js +3 -3
  81. package/.agents/scripts/lib/coverage-capture.js +147 -4
  82. package/.agents/scripts/lib/cpu-pool.js +14 -0
  83. package/.agents/scripts/lib/crap-utils.js +6 -11
  84. package/.agents/scripts/lib/duplicate-search.js +1 -1
  85. package/.agents/scripts/lib/dynamic-workflow/capability.js +1 -1
  86. package/.agents/scripts/lib/dynamic-workflow/documentation-report-contract.js +87 -0
  87. package/.agents/scripts/lib/epic-plan-clarity.js +1 -1
  88. package/.agents/scripts/lib/epic-plan-ideation.js +1 -1
  89. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +1 -1
  90. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +1 -1
  91. package/.agents/scripts/lib/findings/classify-finding.js +1 -1
  92. package/.agents/scripts/lib/findings/promote-finding.js +10 -10
  93. package/.agents/scripts/lib/git-utils.js +24 -22
  94. package/.agents/scripts/lib/label-constants.js +3 -4
  95. package/.agents/scripts/lib/label-taxonomy.js +3 -8
  96. package/.agents/scripts/lib/maintainability-engine.js +1 -1
  97. package/.agents/scripts/lib/maintainability-utils.js +4 -187
  98. package/.agents/scripts/lib/observability/perf-report-readers.js +32 -23
  99. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +81 -7
  100. package/.agents/scripts/lib/orchestration/code-review.js +95 -82
  101. package/.agents/scripts/lib/orchestration/context-hydration-engine.js +8 -9
  102. package/.agents/scripts/lib/orchestration/dependency-analyzer.js +3 -3
  103. package/.agents/scripts/lib/orchestration/detectors-phase.js +2 -2
  104. package/.agents/scripts/lib/orchestration/dispatch-engine.js +30 -38
  105. package/.agents/scripts/lib/orchestration/dispatch-pipeline.js +14 -37
  106. package/.agents/scripts/lib/orchestration/epic-cleanup.js +1 -1
  107. package/.agents/scripts/lib/orchestration/epic-deliver-lease-guard.js +22 -22
  108. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/creation.js +1 -1
  109. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/dag.js +7 -21
  110. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/diagnostics.js +3 -3
  111. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/planning-artifacts.js +2 -2
  112. package/.agents/scripts/lib/orchestration/epic-plan-lease-guard.js +206 -58
  113. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/drain.js +1 -1
  114. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/plan-epic.js +27 -3
  115. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +1 -1
  116. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +28 -8
  117. package/.agents/scripts/lib/orchestration/epic-plan-state-store.js +1 -1
  118. package/.agents/scripts/lib/orchestration/epic-run-state-store.js +3 -3
  119. package/.agents/scripts/lib/orchestration/epic-runner/concurrency-gate.js +4 -4
  120. package/.agents/scripts/lib/orchestration/epic-runner/deliver-phases.js +3 -3
  121. package/.agents/scripts/lib/orchestration/epic-runner/phases/build-wave-dag.js +13 -41
  122. package/.agents/scripts/lib/orchestration/epic-runner/phases/snapshot.js +7 -7
  123. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +2 -3
  124. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/signals.js +2 -8
  125. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/transport.js +4 -4
  126. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/component-drift.js +103 -0
  127. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/crap-drift.js +22 -64
  128. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js +38 -76
  129. package/.agents/scripts/lib/orchestration/epic-runner/story-launcher.js +4 -4
  130. package/.agents/scripts/lib/orchestration/epic-runner/story-run-progress-writer.js +10 -10
  131. package/.agents/scripts/lib/orchestration/epic-runner/sub-agent-return.js +8 -20
  132. package/.agents/scripts/lib/orchestration/epic-spec-reconciler-apply.js +7 -15
  133. package/.agents/scripts/lib/orchestration/epic-spec-reconciler-diff.js +72 -41
  134. package/.agents/scripts/lib/orchestration/epic-spec-reconciler-ops.js +2 -4
  135. package/.agents/scripts/lib/orchestration/file-assumptions.js +6 -5
  136. package/.agents/scripts/lib/orchestration/finalize/close-planning-tickets.js +1 -1
  137. package/.agents/scripts/lib/orchestration/finalize/open-or-locate-pr.js +2 -2
  138. package/.agents/scripts/lib/orchestration/finalize/sanitize-skip-ci.js +1 -1
  139. package/.agents/scripts/lib/orchestration/lease-guard-shared.js +144 -0
  140. package/.agents/scripts/lib/orchestration/lifecycle/emit-story-dispatch-end.js +1 -1
  141. package/.agents/scripts/lib/orchestration/lifecycle/emit-story-heartbeat.js +3 -3
  142. package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +1 -1
  143. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +1 -1
  144. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +1 -1
  145. package/.agents/scripts/lib/orchestration/lifecycle/listeners/branch-cleaner.js +1 -1
  146. package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +1 -1
  147. package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +1 -1
  148. package/.agents/scripts/lib/orchestration/lifecycle/listeners/merge-watcher.js +1 -1
  149. package/.agents/scripts/lib/orchestration/lifecycle/listeners/notify-dispatcher.js +1 -1
  150. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +8 -8
  151. package/.agents/scripts/lib/orchestration/manifest-builder.js +5 -5
  152. package/.agents/scripts/lib/orchestration/parked-follow-ons.js +2 -2
  153. package/.agents/scripts/lib/orchestration/plan-runner/plan-router.js +5 -5
  154. package/.agents/scripts/lib/orchestration/post-merge/phases/notification.js +3 -3
  155. package/.agents/scripts/lib/orchestration/post-merge/phases/ticket-closure.js +3 -3
  156. package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +7 -7
  157. package/.agents/scripts/lib/orchestration/preflight-cache.js +36 -13
  158. package/.agents/scripts/lib/orchestration/recurring-failure-detector.js +1 -1
  159. package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +1 -1
  160. package/.agents/scripts/lib/orchestration/retro/phases/gather-signals.js +2 -2
  161. package/.agents/scripts/lib/orchestration/retro-runner.js +3 -3
  162. package/.agents/scripts/lib/orchestration/review-depth.js +1 -1
  163. package/.agents/scripts/lib/orchestration/review-providers/codex.js +5 -60
  164. package/.agents/scripts/lib/orchestration/review-providers/native.js +7 -6
  165. package/.agents/scripts/lib/orchestration/review-providers/parse-findings.js +105 -0
  166. package/.agents/scripts/lib/orchestration/review-providers/security-review.js +7 -59
  167. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +2 -4
  168. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +1 -1
  169. package/.agents/scripts/lib/orchestration/single-story-close/phases/wrong-tree-guard.js +1 -1
  170. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +2 -4
  171. package/.agents/scripts/lib/orchestration/single-story-lease-guard.js +32 -35
  172. package/.agents/scripts/lib/orchestration/skill-capsule-loader.js +1 -2
  173. package/.agents/scripts/lib/orchestration/spec-freshness.js +1 -1
  174. package/.agents/scripts/lib/orchestration/spec-renderer.js +36 -73
  175. package/.agents/scripts/lib/orchestration/spec-section-validator.js +1 -1
  176. package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +451 -503
  177. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/pre-merge-attribution.js +8 -2
  178. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/refresh-commit.js +47 -2
  179. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/regression-projection.js +2 -2
  180. package/.agents/scripts/lib/orchestration/story-close/baseline-friction-body.js +1 -1
  181. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +358 -54
  182. package/.agents/scripts/lib/orchestration/story-close/phases/close.js +1 -1
  183. package/.agents/scripts/lib/orchestration/story-close/phases/gates.js +3 -2
  184. package/.agents/scripts/lib/orchestration/story-close/phases/locked-pipeline.js +32 -5
  185. package/.agents/scripts/lib/orchestration/story-close/post-merge-close.js +5 -18
  186. package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +3 -3
  187. package/.agents/scripts/lib/orchestration/story-close-recovery.js +33 -16
  188. package/.agents/scripts/lib/orchestration/story-reachability.js +47 -0
  189. package/.agents/scripts/lib/orchestration/task-body-validator.js +6 -6
  190. package/.agents/scripts/lib/orchestration/ticket-lease.js +1 -1
  191. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +4 -35
  192. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +1 -10
  193. package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -70
  194. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +44 -73
  195. package/.agents/scripts/lib/orchestration/ticketing/reads.js +16 -7
  196. package/.agents/scripts/lib/orchestration/ticketing/state.js +53 -439
  197. package/.agents/scripts/lib/orchestration/ticketing/transition.js +471 -0
  198. package/.agents/scripts/lib/orchestration/ticketing.js +0 -1
  199. package/.agents/scripts/lib/orchestration/wave-record-notifications.js +3 -3
  200. package/.agents/scripts/lib/orchestration/wave-record-projection.js +2 -8
  201. package/.agents/scripts/lib/plan-phase-cleanup.js +1 -1
  202. package/.agents/scripts/lib/preflight-runner.js +1 -1
  203. package/.agents/scripts/lib/presentation/dispatch-manifest-render.js +4 -5
  204. package/.agents/scripts/lib/presentation/manifest-builder.js +28 -34
  205. package/.agents/scripts/lib/presentation/manifest-formatter.js +3 -4
  206. package/.agents/scripts/lib/presentation/manifest-helpers.js +1 -1
  207. package/.agents/scripts/lib/presentation/manifest-procedures.js +4 -4
  208. package/.agents/scripts/lib/presentation/manifest-render-waves.js +4 -23
  209. package/.agents/scripts/lib/presentation/manifest-renderer.js +1 -1
  210. package/.agents/scripts/lib/presentation/manifest-story-views.js +2 -11
  211. package/.agents/scripts/lib/project-root.js +17 -0
  212. package/.agents/scripts/lib/signals/schema.js +1 -1
  213. package/.agents/scripts/lib/spec/index.js +1 -1
  214. package/.agents/scripts/lib/spec/loader.js +2 -2
  215. package/.agents/scripts/lib/spec/state.js +7 -16
  216. package/.agents/scripts/lib/story-adjacency.js +76 -0
  217. package/.agents/scripts/lib/story-init/context-resolver.js +3 -3
  218. package/.agents/scripts/lib/story-init/state-transitioner.js +2 -2
  219. package/.agents/scripts/lib/story-init/task-graph-builder.js +7 -7
  220. package/.agents/scripts/lib/story-lifecycle.js +9 -9
  221. package/.agents/scripts/lib/story-plan.js +1 -1
  222. package/.agents/scripts/lib/templates/decomposer-prompts.js +59 -52
  223. package/.agents/scripts/lib/transpile.js +93 -0
  224. package/.agents/scripts/lib/wave-runner/tick.js +4 -153
  225. package/.agents/scripts/lib/workers/crap-worker.js +1 -1
  226. package/.agents/scripts/lib/workers/maintainability-report-worker.js +1 -1
  227. package/.agents/scripts/lib/worktree/lifecycle/creation.js +20 -2
  228. package/.agents/scripts/lib/worktree/lifecycle/force-drain.js +90 -0
  229. package/.agents/scripts/lib/worktree/lifecycle/reap.js +26 -8
  230. package/.agents/scripts/lib/worktree/node-modules-strategy.js +74 -0
  231. package/.agents/scripts/lifecycle-emit-story-dispatch.js +1 -1
  232. package/.agents/scripts/lifecycle-emit.js +1 -1
  233. package/.agents/scripts/providers/github/board-add.js +1 -1
  234. package/.agents/scripts/providers/github/errors.js +1 -1
  235. package/.agents/scripts/providers/github/mappers.js +2 -2
  236. package/.agents/scripts/providers/github/tickets.js +114 -10
  237. package/.agents/scripts/resync-status-column.js +1 -1
  238. package/.agents/scripts/retro-run.js +2 -2
  239. package/.agents/scripts/run-lint.js +10 -1
  240. package/.agents/scripts/run-tests.js +24 -4
  241. package/.agents/scripts/single-story-init.js +1 -1
  242. package/.agents/scripts/stories-wave-tick.js +13 -10
  243. package/.agents/scripts/story-close.js +1 -1
  244. package/.agents/scripts/story-init.js +162 -26
  245. package/.agents/scripts/story-phase.js +5 -5
  246. package/.agents/scripts/story-plan.js +3 -3
  247. package/.agents/scripts/sync-branch-from-base.js +2 -2
  248. package/.agents/scripts/validate-docs-freshness.js +1 -1
  249. package/.agents/scripts/wave-tick.js +1 -1
  250. package/.agents/skills/core/analyze-execution/SKILL.md +2 -2
  251. package/.agents/skills/core/epic-plan-consolidate/SKILL.md +21 -26
  252. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +23 -56
  253. package/.agents/skills/core/epic-plan-spec-author/SKILL.md +4 -4
  254. package/.agents/skills/core/hydrate-context/SKILL.md +2 -2
  255. package/.agents/skills/core/idea-refinement/SKILL.md +4 -4
  256. package/.agents/skills/core/knowledge-transfer/SKILL.md +2 -2
  257. package/.agents/skills/core/planning-and-task-breakdown/SKILL.md +1 -1
  258. package/.agents/skills/core/scope-triage/SKILL.md +9 -10
  259. package/.agents/skills/core/using-agent-skills/SKILL.md +1 -1
  260. package/.agents/skills/skills.index.json +7 -7
  261. package/.agents/skills/stack/qa/lighthouse-baseline/SKILL.md +1 -1
  262. package/.agents/templates/agent-protocol.md +2 -2
  263. package/.agents/workflows/agents-update.md +2 -2
  264. package/.agents/workflows/audit-architecture.md +2 -2
  265. package/.agents/workflows/audit-clean-code.md +2 -2
  266. package/.agents/workflows/audit-dependencies.md +1 -1
  267. package/.agents/workflows/audit-devops.md +1 -1
  268. package/.agents/workflows/audit-documentation.md +226 -0
  269. package/.agents/workflows/audit-lighthouse.md +1 -1
  270. package/.agents/workflows/audit-performance.md +2 -2
  271. package/.agents/workflows/audit-privacy.md +1 -1
  272. package/.agents/workflows/audit-quality.md +2 -2
  273. package/.agents/workflows/audit-security.md +2 -2
  274. package/.agents/workflows/audit-seo.md +1 -1
  275. package/.agents/workflows/audit-sre.md +1 -1
  276. package/.agents/workflows/audit-to-stories.md +10 -10
  277. package/.agents/workflows/audit-ux-ui.md +1 -1
  278. package/.agents/workflows/deliver.md +85 -0
  279. package/.agents/workflows/explain.md +3 -3
  280. package/.agents/workflows/git-merge-pr.md +1 -1
  281. package/.agents/workflows/git-pr-all.md +13 -10
  282. package/.agents/workflows/git-push.md +6 -3
  283. package/.agents/workflows/helpers/_merge-conflict-template.md +1 -1
  284. package/.agents/workflows/helpers/acceptance-self-eval.md +1 -1
  285. package/.agents/workflows/helpers/code-review.md +5 -5
  286. package/.agents/workflows/{epic-deliver.md → helpers/deliver-epic.md} +59 -66
  287. package/.agents/workflows/{story-deliver.md → helpers/deliver-stories.md} +25 -25
  288. package/.agents/workflows/helpers/diagnose.md +1 -1
  289. package/.agents/workflows/helpers/epic-audit.md +6 -6
  290. package/.agents/workflows/helpers/epic-deliver-story.md +28 -39
  291. package/.agents/workflows/helpers/epic-plan-decompose.md +23 -23
  292. package/.agents/workflows/helpers/epic-plan-spec.md +6 -6
  293. package/.agents/workflows/helpers/epic-testing.md +3 -3
  294. package/.agents/workflows/helpers/parallel-tooling.md +1 -1
  295. package/.agents/workflows/{epic-plan.md → helpers/plan-epic.md} +84 -84
  296. package/.agents/workflows/{story-plan.md → helpers/plan-story.md} +43 -43
  297. package/.agents/workflows/helpers/signals.md +1 -1
  298. package/.agents/workflows/helpers/single-story-deliver.md +12 -11
  299. package/.agents/workflows/helpers/worktree-lifecycle.md +18 -18
  300. package/.agents/workflows/onboard.md +21 -20
  301. package/.agents/workflows/plan.md +89 -0
  302. package/.agents/workflows/qa-explore.md +1 -1
  303. package/.agents/workflows/qa-run-harness.md +1 -1
  304. package/README.md +17 -20
  305. package/docs/CHANGELOG.md +1149 -0
  306. package/lib/cli/__tests__/update-changelog-surface.test.js +357 -0
  307. package/lib/cli/__tests__/update-reexec.test.js +513 -0
  308. package/lib/cli/init.js +338 -0
  309. package/lib/cli/update.js +413 -52
  310. package/package.json +3 -1
  311. package/.agents/scripts/lib/auto-refresh-baselines.js +0 -308
  312. package/.agents/scripts/lib/close-validation.js +0 -897
  313. package/.agents/scripts/lib/orchestration/cascade-grouping.js +0 -275
  314. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter.js +0 -69
  315. package/.agents/scripts/lib/orchestration/reconciler.js +0 -137
  316. package/.agents/scripts/lib/orchestration/story-close/format-autofix-scoped.js +0 -221
  317. package/.agents/scripts/lib/orchestration/story-close/format-autofix-shared.js +0 -123
  318. package/.agents/scripts/lib/task-utils.js +0 -26
  319. package/.agents/scripts/story-deliver-prepare.js +0 -267
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * auto-refresh-runner.js — bounded baseline auto-refresh at story-close
3
3
  * (Story #1398, Epic #1386; rerouted to `refreshBaseline()` by Story
4
- * #2205, Epic #2173).
4
+ * #2205; collapsed onto the single `runRefreshCommit` funnel by Story
5
+ * #4017).
5
6
  *
6
7
  * Runs *after* `runPreMergeGatesWithAttribution` returns `{ status: 'ok' }`
7
8
  * and *before* the merge into `epic/<id>`. For each baseline kind
@@ -9,64 +10,52 @@
9
10
  *
10
11
  * 1. Snapshots the prior on-disk envelope (so cap evaluation can compare
11
12
  * regenerated rows against the pre-refresh baseline).
12
- * 2. Calls `refreshBaseline({ kind, baseRef, headRef, fullScope: false,
13
- * ... })` — the unified service walks the story-diff scope, scores
14
- * the in-scope files, scope-merges with out-of-scope prior rows, and
15
- * writes the envelope atomically.
16
- * 3. Re-reads the (now scope-merged) envelope and evaluates the rows
17
- * against the configured caps via `evaluateAutoRefresh`.
13
+ * 2. Delegates the refresh stage commit sequence to
14
+ * `runRefreshCommit()` (`baseline-attribution/phases/refresh-commit.js`)
15
+ * the **single** story-close refresh funnel injecting a `capCheck`
16
+ * that re-reads the refreshed envelope and evaluates the row deltas
17
+ * against the configured caps via {@link evaluateAutoRefresh}.
18
18
  *
19
- * - **Under-cap path** — stages the baseline file, runs
20
- * `git diff --cached --exit-code`, and either:
21
- * · empty diff logs "no baseline drift to fold in" and skips the
22
- * commit entirely; OR
23
- * · non-empty diff → emits one canonical commit
24
- * `chore(baselines): refresh <kind> for story-<id>`. NO `--amend`,
25
- * NO `--allow-empty`.
19
+ * - **Under-cap path** — the funnel emits one canonical commit
20
+ * `chore(baselines): refresh <kind> for story-<id>` per kind that
21
+ * actually drifted. NO `--amend`, NO `--allow-empty`.
26
22
  *
27
- * - **Over-cap path** — restores the baseline files to HEAD (the staged
28
- * drift is unstaged + working-tree-reverted) and appends a single
29
- * `baseline-refresh-regression` friction signal to the per-Story
30
- * NDJSON. The runner returns `{ status: 'refused', ... }`.
23
+ * - **Over-cap path** — the funnel restores the kind's baseline file to
24
+ * HEAD; the runner appends a single `baseline-refresh-regression`
25
+ * friction signal to the per-Story NDJSON and returns
26
+ * `{ status: 'refused', ... }`.
31
27
  *
32
- * - **Skipped paths** — `enabled: false` in `quality.autoRefresh`,
33
- * `refreshBaseline()` reports `wrote: false` for every configured
34
- * kind, or staging produced an empty diff. The runner returns
28
+ * - **Skipped paths** — `enabled: false` in `quality.autoRefresh`, or
29
+ * no configured kind produced drift. The runner returns
35
30
  * `{ status: 'skipped', reason }` without touching the branch tip.
36
31
  *
37
- * Story #2205 every baseline write goes through `refreshBaseline()` in
38
- * `.agents/scripts/lib/baselines/refresh-service.js`. The legacy
39
- * `regenerateMainFromTree` + `writeScopeMergedBaseline` +
40
- * `loadPriorEnvelope` + `amendBaselinesIntoHead` chain is gone. The
41
- * `--amend` / `--allow-empty` shortcut is gone. The commit subject is
42
- * `chore(baselines): refresh <kind> for story-<id>` per the new
43
- * commit-hygiene contract (AC-8).
32
+ * Each-kind-once contract (Story #4017): the caller threads the close
33
+ * cycle's shared `cycleState` (created in `runGatesAndRefresh`) into the
34
+ * funnel, so a kind already refreshed by the gate-failure attribution
35
+ * retry (`gate-failure.js` `runRefreshCommit`) is **not re-scored or
36
+ * re-committed** here the funnel short-circuits on the idempotency
37
+ * token. A clean close therefore computes each baseline kind exactly once
38
+ * and emits at most one `chore(baselines): refresh` subject per kind.
44
39
  *
45
40
  * Dedup contract (AC3 — idempotent re-run):
46
41
  * On re-entry after an over-cap refusal, the runner scans the per-Story
47
42
  * `signals.ndjson` for any prior `baseline-refresh-regression` signal
48
43
  * tagged `source.tool === 'auto-refresh-runner'` and skips the append if
49
- * one exists. The runner does not edit the on-disk file it just doesn't
50
- * write a duplicate. Two scenarios produce identical on-disk state:
51
- *
52
- * - First run, over-cap → friction signal appended.
53
- * - Second run, same caps + same diff → friction signal NOT re-appended.
54
- *
55
- * The on-disk friction-signal file therefore carries one row per
56
- * (story, refusal-cause) regardless of how many times story-close runs.
44
+ * one exists. The on-disk friction-signal file therefore carries one row
45
+ * per (story, refusal-cause) regardless of how many times story-close
46
+ * runs.
57
47
  *
58
48
  * The runner is dependency-injection-friendly: every git invocation, every
59
49
  * fs touch, the refresh-service handle, the evaluator, and the signal
60
50
  * writer are injectable seams. Production callers omit the seams; tests
61
51
  * inject mocks.
62
52
  *
63
- * @see .agents/scripts/lib/baselines/refresh-service.js (the unified write funnel)
64
- * @see .agents/scripts/lib/auto-refresh-baselines.js (evaluator)
53
+ * @see .agents/scripts/lib/baselines/refresh-service.js (the unified write path)
54
+ * @see ./baseline-attribution/phases/refresh-commit.js (the commit funnel)
65
55
  */
66
56
 
67
57
  import fs from 'node:fs';
68
58
  import path from 'node:path';
69
- import { evaluateAutoRefresh as defaultEvaluateAutoRefresh } from '../../auto-refresh-baselines.js';
70
59
  import { loadFile as defaultReaderLoadFile } from '../../baselines/reader.js';
71
60
  import { refreshBaseline as defaultRefreshBaseline } from '../../baselines/refresh-service.js';
72
61
  import {
@@ -82,12 +71,261 @@ import {
82
71
  import {
83
72
  buildKindScorer,
84
73
  computeStoryDiffPaths,
85
- stageAndCheckBaselineDrift,
74
+ runRefreshCommit as defaultRunRefreshCommit,
86
75
  } from './baseline-attribution-wiring.js';
87
76
 
88
77
  const RUNNER_SOURCE_TOOL = 'auto-refresh-runner';
89
78
  const FRICTION_CATEGORY = 'baseline-refresh-regression';
90
79
 
80
+ // ---------------------------------------------------------------------------
81
+ // Pure delta-cap evaluator (Story #1398; folded in from the deleted
82
+ // standalone evaluator module by Story #4017).
83
+ // ---------------------------------------------------------------------------
84
+
85
+ /**
86
+ * Numeric guard — accepts finite numbers only. Strings, NaN, Infinity, null,
87
+ * undefined all fail. The evaluator runs against scored rows produced by the
88
+ * MI / CRAP scanners (which always emit numeric scores) and baseline rows
89
+ * loaded from the on-disk JSON (which JSON-parses numeric fields), so a
90
+ * non-finite value here signals upstream corruption — we exclude the row
91
+ * conservatively rather than coercing.
92
+ */
93
+ function isFiniteNumber(value) {
94
+ return typeof value === 'number' && Number.isFinite(value);
95
+ }
96
+
97
+ /**
98
+ * Index `baseline.mi` rows by `path` for O(1) lookup. Bad rows (missing
99
+ * `path`, non-string `path`, non-finite `mi`) are skipped — their absence
100
+ * causes the matching scored row to be treated as "new", which never blocks
101
+ * auto-refresh.
102
+ */
103
+ function indexMiBaseline(rows) {
104
+ const byPath = new Map();
105
+ if (!Array.isArray(rows)) return byPath;
106
+ for (const row of rows) {
107
+ if (!row || typeof row.path !== 'string' || row.path.length === 0) continue;
108
+ if (!isFiniteNumber(row.mi)) continue;
109
+ byPath.set(row.path, row);
110
+ }
111
+ return byPath;
112
+ }
113
+
114
+ /**
115
+ * Index `baseline.crap` rows by `${file}::${method}` for O(1) lookup.
116
+ * `startLine` is *not* part of the key — the scored row may have shifted
117
+ * lines vs the baseline (legitimate refactor), and we want the closest match
118
+ * by method name. When the same method appears multiple times in the same
119
+ * file (e.g. nested helpers), we pick the closest startLine at lookup time.
120
+ *
121
+ * Bad rows (missing `file`/`method`, non-finite `crap`) are skipped — their
122
+ * absence causes the matching scored row to be treated as "new".
123
+ */
124
+ function indexCrapBaseline(rows) {
125
+ const byMethod = new Map();
126
+ if (!Array.isArray(rows)) return byMethod;
127
+ for (const row of rows) {
128
+ if (!row || typeof row.file !== 'string' || row.file.length === 0) {
129
+ continue;
130
+ }
131
+ if (typeof row.method !== 'string' || row.method.length === 0) continue;
132
+ if (!isFiniteNumber(row.crap)) continue;
133
+ const key = `${row.file}::${row.method}`;
134
+ if (!byMethod.has(key)) byMethod.set(key, []);
135
+ byMethod.get(key).push(row);
136
+ }
137
+ return byMethod;
138
+ }
139
+
140
+ /**
141
+ * Pick the closest baseline candidate by `startLine` distance. When the
142
+ * scored row's `startLine` is missing or all candidates have missing line
143
+ * info, returns the first candidate — matches `baseline-attribution-wiring`'s
144
+ * `diffCrapBaselines` resolution policy.
145
+ */
146
+ function pickClosestBaseline(candidates, scoredStartLine) {
147
+ if (!Array.isArray(candidates) || candidates.length === 0) return null;
148
+ if (candidates.length === 1) return candidates[0];
149
+ const target = isFiniteNumber(scoredStartLine) ? scoredStartLine : 0;
150
+ let best = candidates[0];
151
+ let bestDist = Math.abs((best.startLine ?? 0) - target);
152
+ for (let i = 1; i < candidates.length; i += 1) {
153
+ const c = candidates[i];
154
+ const dist = Math.abs((c?.startLine ?? 0) - target);
155
+ if (dist < bestDist) {
156
+ bestDist = dist;
157
+ best = c;
158
+ }
159
+ }
160
+ return best;
161
+ }
162
+
163
+ /**
164
+ * Evaluate every MI scored row against the MI cap. Returns the over-cap
165
+ * subset; rows under the cap (or new) are simply omitted from the result.
166
+ *
167
+ * MI is higher-is-better, so drift = baseline.mi − scored.mi. A positive
168
+ * drift is a regression; a drift greater than `miDropCap` breaches the cap.
169
+ */
170
+ function evaluateMiRows({ scoredRows, baselineIndex, miDropCap }) {
171
+ const overCap = [];
172
+ if (!Array.isArray(scoredRows)) return overCap;
173
+ for (const row of scoredRows) {
174
+ if (!row || typeof row.path !== 'string' || row.path.length === 0) {
175
+ continue;
176
+ }
177
+ if (!isFiniteNumber(row.mi)) continue;
178
+ const baselineRow = baselineIndex.get(row.path);
179
+ if (!baselineRow) continue; // new path — never breaches
180
+ const drop = baselineRow.mi - row.mi;
181
+ if (drop > miDropCap) {
182
+ overCap.push({
183
+ path: row.path,
184
+ baseline: baselineRow.mi,
185
+ scored: row.mi,
186
+ delta: drop,
187
+ });
188
+ }
189
+ }
190
+ return overCap;
191
+ }
192
+
193
+ /**
194
+ * Evaluate every CRAP scored row against the CRAP cap. Returns the over-cap
195
+ * subset; rows under the cap (or new) are simply omitted from the result.
196
+ *
197
+ * CRAP is lower-is-better, so jump = scored.crap − baseline.crap. A positive
198
+ * jump is a regression; a jump greater than `crapJumpCap` breaches the cap.
199
+ */
200
+ function evaluateCrapRows({ scoredRows, baselineIndex, crapJumpCap }) {
201
+ const overCap = [];
202
+ if (!Array.isArray(scoredRows)) return overCap;
203
+ for (const row of scoredRows) {
204
+ if (!row || typeof row.file !== 'string' || row.file.length === 0) {
205
+ continue;
206
+ }
207
+ if (typeof row.method !== 'string' || row.method.length === 0) continue;
208
+ if (!isFiniteNumber(row.crap)) continue;
209
+ const candidates = baselineIndex.get(`${row.file}::${row.method}`);
210
+ const baselineRow = pickClosestBaseline(candidates, row.startLine);
211
+ if (!baselineRow) continue; // new method — never breaches
212
+ const jump = row.crap - baselineRow.crap;
213
+ if (jump > crapJumpCap) {
214
+ overCap.push({
215
+ file: row.file,
216
+ method: row.method,
217
+ startLine: row.startLine,
218
+ baseline: baselineRow.crap,
219
+ scored: row.crap,
220
+ delta: jump,
221
+ });
222
+ }
223
+ }
224
+ return overCap;
225
+ }
226
+
227
+ /**
228
+ * Build the human-readable refusal reasons array. Stable formatting so the
229
+ * friction-signal renderer (and unit tests) can pin the strings exactly.
230
+ *
231
+ * Each reason names the kind, the file/path/method, and the absolute delta
232
+ * vs the cap. Numbers are formatted to 3 decimal places to match the
233
+ * baseline JSON's float precision without trailing-zero noise.
234
+ */
235
+ function buildRefusalReasons({ miOverCap, crapOverCap, caps }) {
236
+ const reasons = [];
237
+ for (const r of miOverCap) {
238
+ reasons.push(
239
+ `MI drop ${r.delta.toFixed(3)} > cap ${caps.miDropCap} on ${r.path} (baseline ${r.baseline.toFixed(3)} → scored ${r.scored.toFixed(3)})`,
240
+ );
241
+ }
242
+ for (const r of crapOverCap) {
243
+ reasons.push(
244
+ `CRAP jump ${r.delta.toFixed(3)} > cap ${caps.crapJumpCap} on ${r.file}::${r.method} (baseline ${r.baseline.toFixed(3)} → scored ${r.scored.toFixed(3)})`,
245
+ );
246
+ }
247
+ return reasons;
248
+ }
249
+
250
+ /**
251
+ * Pure delta-cap evaluator. Decides whether the regenerated rows can be
252
+ * silently committed (under-cap) or whether the close must refuse the
253
+ * refresh and surface a `baseline-refresh-regression` friction signal
254
+ * (over-cap).
255
+ *
256
+ * Cap semantics:
257
+ *
258
+ * - MI is "higher is better". A *drop* (baseline.mi − scored.mi) greater
259
+ * than `miDropCap` breaches the cap. Improvements never breach.
260
+ * - CRAP is "lower is better". A *jump* (scored.crap − baseline.crap)
261
+ * greater than `crapJumpCap` breaches the cap. Improvements never
262
+ * breach.
263
+ * - Equality at the cap (delta === cap) is *under* the cap — the cap is
264
+ * the maximum allowed delta, not the strict maximum.
265
+ * - Missing baseline rows (path/method new in the scored set) never push
266
+ * `canAutoRefresh` to `false` and are not surfaced in the over-cap
267
+ * arrays.
268
+ *
269
+ * @param {object} input
270
+ * @param {{
271
+ * mi?: Array<{ path: string, mi: number }>,
272
+ * crap?: Array<{ file: string, method: string, startLine?: number, crap: number }>,
273
+ * }} input.scoredRows Just-regenerated rows for the Story diff.
274
+ * @param {{
275
+ * mi?: Array<{ path: string, mi: number }>,
276
+ * crap?: Array<{ file: string, method: string, startLine?: number, crap: number }>,
277
+ * }} input.baseline Previously committed rows.
278
+ * @param {{ miDropCap: number, crapJumpCap: number }} input.caps
279
+ * Bounded delta caps (defaults: miDropCap=1.5, crapJumpCap=5 — see
280
+ * `.agents/docs/agentrc-reference.json` under `delivery.quality.autoRefresh`).
281
+ * @returns {{
282
+ * canAutoRefresh: boolean,
283
+ * miOverCap: Array<{ path: string, baseline: number, scored: number, delta: number }>,
284
+ * crapOverCap: Array<{ file: string, method: string, startLine?: number, baseline: number, scored: number, delta: number }>,
285
+ * refusalReasons: string[],
286
+ * }}
287
+ */
288
+ export function evaluateAutoRefresh({
289
+ scoredRows = {},
290
+ baseline = {},
291
+ caps,
292
+ } = {}) {
293
+ if (
294
+ !caps ||
295
+ !isFiniteNumber(caps.miDropCap) ||
296
+ !isFiniteNumber(caps.crapJumpCap)
297
+ ) {
298
+ throw new TypeError(
299
+ 'evaluateAutoRefresh: caps.{miDropCap,crapJumpCap} must be finite numbers',
300
+ );
301
+ }
302
+
303
+ const miBaselineIdx = indexMiBaseline(baseline?.mi);
304
+ const crapBaselineIdx = indexCrapBaseline(baseline?.crap);
305
+
306
+ const miOverCap = evaluateMiRows({
307
+ scoredRows: scoredRows?.mi,
308
+ baselineIndex: miBaselineIdx,
309
+ miDropCap: caps.miDropCap,
310
+ });
311
+ const crapOverCap = evaluateCrapRows({
312
+ scoredRows: scoredRows?.crap,
313
+ baselineIndex: crapBaselineIdx,
314
+ crapJumpCap: caps.crapJumpCap,
315
+ });
316
+
317
+ const canAutoRefresh = miOverCap.length === 0 && crapOverCap.length === 0;
318
+ const refusalReasons = canAutoRefresh
319
+ ? []
320
+ : buildRefusalReasons({ miOverCap, crapOverCap, caps });
321
+
322
+ return { canAutoRefresh, miOverCap, crapOverCap, refusalReasons };
323
+ }
324
+
325
+ // ---------------------------------------------------------------------------
326
+ // Runner plumbing
327
+ // ---------------------------------------------------------------------------
328
+
91
329
  /**
92
330
  * Load + parse the baseline envelope at `absPath` via the injected
93
331
  * reader. Returns `null` when the file is missing, unreadable, or fails
@@ -99,13 +337,7 @@ function readEnvelope({ absPath, kind, readerLoadFile }) {
99
337
  try {
100
338
  const parsed = readerLoadFile(absPath, { kind });
101
339
  if (!parsed || !Array.isArray(parsed.rows)) return null;
102
- return {
103
- $schema: `.agents/schemas/baselines/${kind}.schema.json`,
104
- kernelVersion: parsed.kernelVersion,
105
- generatedAt: parsed.generatedAt,
106
- rollup: parsed.rollup,
107
- rows: parsed.rows,
108
- };
340
+ return { rows: parsed.rows };
109
341
  } catch {
110
342
  return null;
111
343
  }
@@ -142,25 +374,6 @@ function filterToStoryDiff({ miRows, crapRows, storyDiffPaths }) {
142
374
  return { mi, crap };
143
375
  }
144
376
 
145
- function normalizeTargetDir(dir) {
146
- if (typeof dir !== 'string' || dir.length === 0) return null;
147
- return dir.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
148
- }
149
-
150
- function buildRequiredScopeFilePredicate({
151
- kind,
152
- config,
153
- getQuality = defaultGetQuality,
154
- }) {
155
- const quality = getQuality(config) ?? {};
156
- const targetDirs = Array.isArray(quality?.[kind]?.targetDirs)
157
- ? quality[kind].targetDirs.map(normalizeTargetDir).filter(Boolean)
158
- : [];
159
- if (targetDirs.length === 0) return () => true;
160
- return (file) =>
161
- targetDirs.some((dir) => file === dir || file.startsWith(`${dir}/`));
162
- }
163
-
164
377
  /**
165
378
  * Check whether a `baseline-refresh-regression` signal tagged with the
166
379
  * runner's `source.tool === 'auto-refresh-runner'` already exists in the
@@ -218,14 +431,6 @@ function resolveBaselineAbs(cwd, p) {
218
431
  return path.isAbsolute(p) ? p : path.resolve(cwd, p);
219
432
  }
220
433
 
221
- function resolveBaselineAbsPaths({ cwd, config, getBaselines }) {
222
- const baselines = getBaselines(config);
223
- return {
224
- miAbs: resolveBaselineAbs(cwd, baselines?.maintainability?.path),
225
- crapAbs: resolveBaselineAbs(cwd, baselines?.crap?.path),
226
- };
227
- }
228
-
229
434
  async function probeDedup({ epicId, storyId, forEachLine, logger }) {
230
435
  try {
231
436
  return await priorRefusalSignalExists({ epicId, storyId, forEachLine });
@@ -266,160 +471,15 @@ async function maybeAppendRefusalSignal({
266
471
  }
267
472
  }
268
473
 
269
- /**
270
- * Restore the baseline files to HEAD's content. Used on over-cap
271
- * refusal to drop the refresh's write so the merge consumes the
272
- * pre-refresh baseline unchanged.
273
- */
274
- function rollbackBaselineFiles({ cwd, baselineFiles, gitRunner, logger }) {
275
- for (const filePath of baselineFiles) {
276
- const rel = path.isAbsolute(filePath)
277
- ? path.relative(cwd, filePath)
278
- : filePath;
279
- const posixRel = rel.split(path.sep).join('/');
280
- const res = gitRunner.gitSpawn(cwd, 'checkout', 'HEAD', '--', posixRel);
281
- if (res.status !== 0) {
282
- logger.warn?.(
283
- `[auto-refresh-runner] failed to restore ${rel} after refusal: ${res.stderr || res.stdout}`,
284
- );
285
- }
286
- }
287
- }
288
-
289
- async function handleRefusal({
290
- verdict,
291
- caps,
292
- epicId,
293
- storyId,
294
- cwd,
295
- baselineFiles,
296
- gitRunner,
297
- appendSignal,
298
- forEachLine,
299
- config,
300
- logger,
301
- }) {
302
- const dedup = await probeDedup({ epicId, storyId, forEachLine, logger });
303
- const signalAppended = await maybeAppendRefusalSignal({
304
- dedup,
305
- epicId,
306
- storyId,
307
- verdict,
308
- caps,
309
- appendSignal,
310
- config,
311
- logger,
312
- });
313
- rollbackBaselineFiles({ cwd, baselineFiles, gitRunner, logger });
314
- logger.info?.(
315
- `[auto-refresh-runner] refused — ${verdict.refusalReasons.length} cap breach(es); friction signal ${dedup ? 'already present (dedup)' : signalAppended ? 'appended' : 'append failed'}.`,
316
- );
317
- return {
318
- status: 'refused',
319
- refusalReasons: verdict.refusalReasons,
320
- signalAppended,
321
- dedup,
322
- miOverCap: verdict.miOverCap,
323
- crapOverCap: verdict.crapOverCap,
324
- };
325
- }
326
-
327
- /**
328
- * Run `refreshBaseline()` for a single kind. Returns the resolved write
329
- * path and a flag noting whether the service actually persisted bytes.
330
- * Throws on a service error (the caller surfaces it as a `failed` status).
331
- */
332
- async function runRefreshForKind({
333
- kind,
334
- cwd,
335
- epicBranch,
336
- storyBranch,
337
- writePath,
338
- config,
339
- getQuality,
340
- refreshBaseline,
341
- scorer,
342
- fsImpl,
343
- }) {
344
- if (!writePath) return { writePath: null, wrote: false };
345
- const baseRef = epicBranch ? `origin/${epicBranch}` : 'origin/main';
346
- const headRef = storyBranch ?? 'HEAD';
347
- const result = await refreshBaseline({
348
- kind,
349
- baseRef,
350
- headRef,
351
- scopeFiles: null,
352
- fullScope: false,
353
- writePath,
354
- scorer,
355
- fs: fsImpl,
356
- cwd,
357
- requireRowsForScopeFiles: true,
358
- requiredScopeFilePredicate: buildRequiredScopeFilePredicate({
359
- kind,
360
- config,
361
- getQuality,
362
- }),
363
- });
364
- return { writePath, wrote: result?.wrote === true };
365
- }
366
-
367
- /**
368
- * Commit hygiene (AC-8): stage every refreshed baseline file, ask
369
- * `git diff --cached --exit-code` whether any drift survived. Drift →
370
- * emit one canonical commit per kind. No drift → log + skip. No
371
- * `--amend`, no `--allow-empty`.
372
- */
373
- function commitRefreshedBaselines({
374
- cwd,
375
- storyId,
376
- refreshed,
377
- gitRunner,
378
- logger,
379
- }) {
380
- const committed = [];
381
- let lastSha = '';
382
- for (const { kind, writePath } of refreshed) {
383
- if (!writePath) continue;
384
- const drift = stageAndCheckBaselineDrift({
385
- cwd,
386
- baselineFile: writePath,
387
- gitRunner,
388
- });
389
- if (drift.error) {
390
- return { ok: false, error: drift.error };
391
- }
392
- if (!drift.hasDrift) {
393
- logger?.info?.(
394
- `[auto-refresh-runner] no baseline drift to fold in for kind=${kind} (story-${storyId}).`,
395
- );
396
- continue;
397
- }
398
- const subject = `chore(baselines): refresh ${kind} for story-${storyId}`;
399
- const commitRes = gitRunner.gitSpawn(cwd, 'commit', '-m', subject);
400
- if (commitRes.status !== 0) {
401
- return {
402
- ok: false,
403
- error: `git commit failed for kind=${kind}: ${commitRes.stderr || commitRes.stdout}`,
404
- };
405
- }
406
- const headRes = gitRunner.gitSpawn(cwd, 'rev-parse', '--short', 'HEAD');
407
- const sha = headRes.status === 0 ? (headRes.stdout || '').trim() : '';
408
- lastSha = sha;
409
- committed.push({ kind, sha });
410
- logger?.info?.(`[auto-refresh-runner] committed ${subject} (${sha}).`);
411
- }
412
- return { ok: true, committed, lastSha };
413
- }
414
-
415
474
  function resolveAutoRefreshDeps(deps) {
416
475
  return {
417
476
  logger: deps.logger ?? DefaultLogger,
418
477
  getQuality: deps.getQuality ?? defaultGetQuality,
419
478
  getBaselines: deps.getBaselines ?? defaultGetBaselines,
420
- evaluateAutoRefresh: deps.evaluateAutoRefresh ?? defaultEvaluateAutoRefresh,
479
+ evaluateAutoRefresh: deps.evaluateAutoRefresh ?? evaluateAutoRefresh,
421
480
  refreshBaseline: deps.refreshBaseline ?? defaultRefreshBaseline,
422
481
  scorerBuilder: deps.scorerBuilder ?? buildKindScorer,
482
+ runRefreshCommit: deps.runRefreshCommit ?? defaultRunRefreshCommit,
423
483
  gitRunner: deps.gitRunner ?? { gitSpawn: defaultGitSpawn },
424
484
  fsImpl: deps.fsImpl ?? fs,
425
485
  appendSignal: deps.appendSignal ?? defaultAppendSignal,
@@ -430,274 +490,134 @@ function resolveAutoRefreshDeps(deps) {
430
490
  }
431
491
 
432
492
  /**
433
- * Step 1 of the four-step pipeline: snapshot the prior on-disk envelopes
434
- * and dispatch one `refreshBaseline()` call per configured baseline kind.
435
- *
436
- * Returns an opaque "stage" object the next steps consume. The shape is
437
- * intentionally minimal it carries the resolved write paths, the
438
- * snapshot envelopes, and the per-kind refresh result records. Callers
439
- * never inspect the shape directly; they pass it through to `validate`
440
- * and `commit`.
441
- *
442
- * Failure mode: a thrown `refreshBaseline` propagates here as a
443
- * `{ ok: false, status: 'failed', reason: 'refresh-service-threw' }`
444
- * envelope so the caller can short-circuit without try/catching at the
445
- * top of `runAutoRefresh`.
493
+ * Build the per-kind `capCheck` closure the funnel invokes after drift is
494
+ * staged and before the commit lands. Re-reads the refreshed envelope,
495
+ * narrows to the Story diff (unless `quality.autoRefresh.scope === 'full'`),
496
+ * and evaluates the single kind's rows against the configured caps with the
497
+ * prior (pre-refresh) snapshot as the baseline.
446
498
  */
447
- async function stageRefreshArtifacts({
499
+ function buildCapCheck({
500
+ kind,
501
+ priorEnv,
502
+ autoRefresh,
503
+ caps,
448
504
  cwd,
449
505
  epicBranch,
450
506
  storyBranch,
451
- config,
452
- getQuality,
453
- getBaselines,
454
- refreshBaseline,
455
- scorerBuilder,
456
- fsImpl,
507
+ evaluate,
508
+ gitRunner,
509
+ computeDiffPaths,
457
510
  readerLoadFile,
458
511
  }) {
459
- const { miAbs, crapAbs } = resolveBaselineAbsPaths({
460
- cwd,
461
- config,
462
- getBaselines,
463
- });
464
-
465
- // Snapshot the prior envelopes BEFORE refreshBaseline overwrites them.
466
- // Reader-routed: every read goes through `reader.loadFile`, which schema-
467
- // validates against the per-kind envelope.
468
- const priorMiEnv = miAbs
469
- ? readEnvelope({
470
- absPath: miAbs,
471
- kind: 'maintainability',
472
- readerLoadFile,
473
- })
474
- : null;
475
- const priorCrapEnv = crapAbs
476
- ? readEnvelope({ absPath: crapAbs, kind: 'crap', readerLoadFile })
477
- : null;
478
-
479
- // Dispatch one refreshBaseline() call per configured kind. The service
480
- // handles diff-scope derivation, scope-merge with out-of-scope prior
481
- // rows (Task #2209), and atomic envelope persistence.
482
- let miRefreshed;
483
- let crapRefreshed;
484
- try {
485
- if (miAbs) {
486
- const scorer = scorerBuilder({
487
- kind: 'maintainability',
488
- cwd,
489
- config,
490
- });
491
- miRefreshed = await runRefreshForKind({
492
- kind: 'maintainability',
512
+ return ({ writePath }) => {
513
+ const finalEnv = readEnvelope({ absPath: writePath, kind, readerLoadFile });
514
+ const isMi = kind === 'maintainability';
515
+ const finalRows = isMi
516
+ ? (finalEnv?.rows ?? [])
517
+ : adaptCrapRowsForEvaluator(finalEnv?.rows ?? []);
518
+ const priorRows = isMi
519
+ ? (priorEnv?.rows ?? [])
520
+ : adaptCrapRowsForEvaluator(priorEnv?.rows ?? []);
521
+
522
+ let scoped;
523
+ if ((autoRefresh.scope ?? 'diff') === 'full') {
524
+ scoped = isMi ? { mi: finalRows } : { crap: finalRows };
525
+ } else {
526
+ const storyDiffPaths = computeDiffPaths({
493
527
  cwd,
494
528
  epicBranch,
495
529
  storyBranch,
496
- writePath: miAbs,
497
- config,
498
- getQuality,
499
- refreshBaseline,
500
- scorer,
501
- fsImpl,
530
+ gitRunner,
502
531
  });
503
- }
504
- if (crapAbs) {
505
- const scorer = scorerBuilder({ kind: 'crap', cwd, config });
506
- crapRefreshed = await runRefreshForKind({
507
- kind: 'crap',
508
- cwd,
509
- epicBranch,
510
- storyBranch,
511
- writePath: crapAbs,
512
- config,
513
- getQuality,
514
- refreshBaseline,
515
- scorer,
516
- fsImpl,
532
+ const filtered = filterToStoryDiff({
533
+ miRows: isMi ? finalRows : [],
534
+ crapRows: isMi ? [] : finalRows,
535
+ storyDiffPaths,
517
536
  });
537
+ scoped = isMi ? { mi: filtered.mi } : { crap: filtered.crap };
518
538
  }
519
- } catch (err) {
520
- return {
521
- ok: false,
522
- status: 'failed',
523
- reason: 'refresh-service-threw',
524
- detail: err?.message ?? String(err),
525
- };
526
- }
527
539
 
528
- return {
529
- ok: true,
530
- miAbs,
531
- crapAbs,
532
- priorMiEnv,
533
- priorCrapEnv,
534
- miRefreshed,
535
- crapRefreshed,
536
- };
537
- }
538
-
539
- /**
540
- * Step 2 of the four-step pipeline: re-read the (scope-merged) envelopes
541
- * the refresh service just wrote and evaluate whether the row deltas sit
542
- * at or below the configured caps. Returns `{ accepted, verdict, baselineFiles }`:
543
- *
544
- * - `accepted: true` → caps are satisfied; commitRefresh writes one
545
- * canonical commit per kind that actually drifted.
546
- * - `accepted: false` → at least one row breaches a cap; pushRefresh
547
- * rolls back the working-tree edits + appends a friction signal.
548
- *
549
- * The function also folds in the early-exit when no kind wrote — when
550
- * every `refreshBaseline()` reports `wrote:false` there's nothing to
551
- * validate, the caller short-circuits via the `noDrift: true` flag.
552
- */
553
- function validateRefreshAccepted({
554
- stage,
555
- autoRefresh,
556
- caps,
557
- cwd,
558
- epicBranch,
559
- storyBranch,
560
- evaluateAutoRefresh,
561
- gitRunner,
562
- computeDiffPaths,
563
- readerLoadFile,
564
- }) {
565
- const {
566
- miAbs,
567
- crapAbs,
568
- priorMiEnv,
569
- priorCrapEnv,
570
- miRefreshed,
571
- crapRefreshed,
572
- } = stage;
573
-
574
- const anyWrote = miRefreshed?.wrote === true || crapRefreshed?.wrote === true;
575
- if (!anyWrote) return { noDrift: true };
576
-
577
- // Re-read the (scope-merged) envelopes for verdict evaluation.
578
- const finalMiEnv = miAbs
579
- ? readEnvelope({
580
- absPath: miAbs,
581
- kind: 'maintainability',
582
- readerLoadFile,
583
- })
584
- : null;
585
- const finalCrapEnv = crapAbs
586
- ? readEnvelope({ absPath: crapAbs, kind: 'crap', readerLoadFile })
587
- : null;
588
-
589
- const finalMiRows = finalMiEnv?.rows ?? [];
590
- const finalCrapRows = adaptCrapRowsForEvaluator(finalCrapEnv?.rows ?? []);
591
- const priorMiRows = priorMiEnv?.rows ?? [];
592
- const priorCrapRows = adaptCrapRowsForEvaluator(priorCrapEnv?.rows ?? []);
593
-
594
- let scoped;
595
- if ((autoRefresh.scope ?? 'diff') === 'full') {
596
- scoped = { mi: finalMiRows, crap: finalCrapRows };
597
- } else {
598
- const storyDiffPaths = computeDiffPaths({
599
- cwd,
600
- epicBranch,
601
- storyBranch,
602
- gitRunner,
603
- });
604
- scoped = filterToStoryDiff({
605
- miRows: finalMiRows,
606
- crapRows: finalCrapRows,
607
- storyDiffPaths,
540
+ return evaluate({
541
+ scoredRows: scoped,
542
+ baseline: isMi ? { mi: priorRows } : { crap: priorRows },
543
+ caps,
608
544
  });
609
- }
610
-
611
- const verdict = evaluateAutoRefresh({
612
- scoredRows: scoped,
613
- baseline: { mi: priorMiRows, crap: priorCrapRows },
614
- caps,
615
- });
616
-
617
- return {
618
- noDrift: false,
619
- accepted: verdict.canAutoRefresh === true,
620
- verdict,
621
- baselineFiles: [miAbs, crapAbs].filter(Boolean),
622
545
  };
623
546
  }
624
547
 
625
548
  /**
626
- * Step 3a of the four-step pipeline (accepted path): emit one canonical
627
- * `chore(baselines): refresh <kind> for story-<id>` commit per kind that
628
- * actually drifted (AC-8 commit hygiene). Empty diff → no commit. No
629
- * `--amend`, no `--allow-empty`.
630
- *
631
- * Returns the canonical close-result envelope `runAutoRefresh` returns
632
- * to its caller — `committed` / `failed` / `skipped` — so the pipeline
633
- * top stays at one level of abstraction.
549
+ * Map a funnel failure string back onto the runner's historical failure
550
+ * vocabulary so `phases/refresh.js` keeps logging the same reason labels.
634
551
  */
635
- function commitRefresh({ stage, cwd, storyId, gitRunner, logger }) {
636
- const { miAbs, crapAbs, miRefreshed, crapRefreshed } = stage;
637
- const refreshed = [
638
- miRefreshed?.wrote === true
639
- ? { kind: 'maintainability', writePath: miAbs }
640
- : null,
641
- crapRefreshed?.wrote === true ? { kind: 'crap', writePath: crapAbs } : null,
642
- ].filter(Boolean);
643
- const commit = commitRefreshedBaselines({
644
- cwd,
645
- storyId,
646
- refreshed,
647
- gitRunner,
648
- logger,
649
- });
650
- if (!commit.ok) {
651
- return { status: 'failed', reason: 'commit-failed', detail: commit.error };
652
- }
653
- if (commit.committed.length === 0) {
654
- return { status: 'skipped', reason: 'no-baseline-drift' };
655
- }
656
- return {
657
- status: 'committed',
658
- sha: commit.lastSha,
659
- files: [miAbs, crapAbs].filter(Boolean),
660
- committed: commit.committed,
661
- };
552
+ function classifyFunnelError(error) {
553
+ return typeof error === 'string' && error.startsWith('refreshBaseline(')
554
+ ? 'refresh-service-threw'
555
+ : 'commit-failed';
662
556
  }
663
557
 
664
558
  /**
665
- * Step 3b of the four-step pipeline (refused path): roll back the baseline
666
- * working-tree edits the refresh service just wrote and push a single
667
- * `baseline-refresh-regression` friction signal onto the Story's NDJSON
668
- * stream (dedup-aware AC3 idempotent re-run contract). The "push" here
669
- * is the friction-signal write + the rollback that publishes the refusal
670
- * outcome past the in-process pipeline boundary.
671
- *
672
- * Returns the canonical `{ status: 'refused', ... }` envelope.
559
+ * Aggregate per-kind refused verdicts into the single refusal envelope the
560
+ * close pipeline consumes, appending the friction signal (dedup-aware,
561
+ * AC3) as the publish step. The funnel already rolled the refused kinds'
562
+ * files back to HEAD.
673
563
  */
674
- async function pushRefresh({
675
- validation,
564
+ async function publishRefusal({
565
+ refusedVerdicts,
676
566
  caps,
677
567
  epicId,
678
568
  storyId,
679
- cwd,
680
- gitRunner,
681
569
  appendSignal,
682
570
  forEachLine,
683
571
  config,
684
572
  logger,
685
573
  }) {
686
- return handleRefusal({
687
- verdict: validation.verdict,
688
- caps,
574
+ const verdict = {
575
+ miOverCap: refusedVerdicts.flatMap((v) => v.miOverCap ?? []),
576
+ crapOverCap: refusedVerdicts.flatMap((v) => v.crapOverCap ?? []),
577
+ refusalReasons: refusedVerdicts.flatMap((v) => v.refusalReasons ?? []),
578
+ };
579
+ const dedup = await probeDedup({ epicId, storyId, forEachLine, logger });
580
+ const signalAppended = await maybeAppendRefusalSignal({
581
+ dedup,
689
582
  epicId,
690
583
  storyId,
691
- cwd,
692
- baselineFiles: validation.baselineFiles,
693
- gitRunner,
584
+ verdict,
585
+ caps,
694
586
  appendSignal,
695
- forEachLine,
696
587
  config,
697
588
  logger,
698
589
  });
590
+ logger.info?.(
591
+ `[auto-refresh-runner] refused — ${verdict.refusalReasons.length} cap breach(es); friction signal ${dedup ? 'already present (dedup)' : signalAppended ? 'appended' : 'append failed'}.`,
592
+ );
593
+ return {
594
+ status: 'refused',
595
+ refusalReasons: verdict.refusalReasons,
596
+ signalAppended,
597
+ dedup,
598
+ miOverCap: verdict.miOverCap,
599
+ crapOverCap: verdict.crapOverCap,
600
+ };
699
601
  }
700
602
 
603
+ /**
604
+ * Bounded baseline auto-refresh. Delegates the per-kind refresh → stage →
605
+ * commit mechanics to the single `runRefreshCommit` funnel; this function
606
+ * owns only the config gating, the prior-envelope snapshot, the cap-check
607
+ * closure, and the refusal publication.
608
+ *
609
+ * @param {object} args
610
+ * @param {{ refreshedKinds?: Set<string>, lastRefreshSha?: string|null } | null} [args.cycleState]
611
+ * The close cycle's shared idempotency token (Story #4017). When the
612
+ * gate-failure attribution retry already refreshed a kind this cycle,
613
+ * the funnel short-circuits and the kind is not re-scored here.
614
+ * @returns {Promise<
615
+ * | { status: 'committed', sha: string, files: string[], committed: Array<{ kind: string, sha: string }> }
616
+ * | { status: 'refused', refusalReasons: string[], signalAppended: boolean, dedup: boolean, miOverCap: Array, crapOverCap: Array }
617
+ * | { status: 'skipped', reason: string }
618
+ * | { status: 'failed', reason: string, detail?: string }
619
+ * >}
620
+ */
701
621
  export async function runAutoRefresh({
702
622
  storyId,
703
623
  epicId,
@@ -705,22 +625,25 @@ export async function runAutoRefresh({
705
625
  epicBranch,
706
626
  storyBranch,
707
627
  config,
628
+ cycleState = null,
708
629
  deps = {},
709
630
  } = {}) {
631
+ const resolved = resolveAutoRefreshDeps(deps);
710
632
  const {
711
633
  logger,
712
634
  getQuality,
713
635
  getBaselines,
714
- evaluateAutoRefresh,
636
+ evaluateAutoRefresh: evaluate,
715
637
  refreshBaseline,
716
638
  scorerBuilder,
639
+ runRefreshCommit,
717
640
  gitRunner,
718
641
  fsImpl,
719
642
  appendSignal,
720
643
  forEachLine,
721
644
  computeDiffPaths,
722
645
  readerLoadFile,
723
- } = resolveAutoRefreshDeps(deps);
646
+ } = resolved;
724
647
 
725
648
  const autoRefresh = getQuality(config)?.autoRefresh;
726
649
  if (!autoRefresh || autoRefresh.enabled === false) {
@@ -731,67 +654,92 @@ export async function runAutoRefresh({
731
654
  crapJumpCap: autoRefresh.crapJumpCap,
732
655
  };
733
656
 
734
- // Step 1 — stage refresh artifacts (snapshot + refreshBaseline per kind).
735
- const stage = await stageRefreshArtifacts({
736
- cwd,
737
- epicBranch,
738
- storyBranch,
739
- config,
740
- getQuality,
741
- getBaselines,
742
- refreshBaseline,
743
- scorerBuilder,
744
- fsImpl,
745
- readerLoadFile,
746
- });
747
- if (stage.ok !== true) {
748
- return {
749
- status: stage.status,
750
- reason: stage.reason,
751
- detail: stage.detail,
752
- };
753
- }
657
+ const baselines = getBaselines(config);
658
+ const kinds = [
659
+ {
660
+ kind: 'maintainability',
661
+ abs: resolveBaselineAbs(cwd, baselines?.maintainability?.path),
662
+ },
663
+ { kind: 'crap', abs: resolveBaselineAbs(cwd, baselines?.crap?.path) },
664
+ ].filter((k) => k.abs);
754
665
 
755
- // Step 2 — validate that the refreshed envelopes satisfy the configured
756
- // caps (and short-circuit when no kind wrote).
757
- const validation = validateRefreshAccepted({
758
- stage,
759
- autoRefresh,
760
- caps,
761
- cwd,
762
- epicBranch,
763
- storyBranch,
764
- evaluateAutoRefresh,
765
- gitRunner,
766
- computeDiffPaths,
767
- readerLoadFile,
768
- });
769
- if (validation.noDrift) {
770
- return { status: 'skipped', reason: 'no-baseline-drift' };
666
+ const committed = [];
667
+ const refusedVerdicts = [];
668
+ let lastSha = '';
669
+
670
+ for (const { kind, abs } of kinds) {
671
+ // Snapshot the prior envelope BEFORE the funnel's refreshBaseline()
672
+ // overwrites it — the cap evaluator compares against the pre-refresh
673
+ // rows. Reader-routed: schema-validated via `reader.loadFile`.
674
+ const priorEnv = readEnvelope({ absPath: abs, kind, readerLoadFile });
675
+ const capCheck = buildCapCheck({
676
+ kind,
677
+ priorEnv,
678
+ autoRefresh,
679
+ caps,
680
+ cwd,
681
+ epicBranch,
682
+ storyBranch,
683
+ evaluate,
684
+ gitRunner,
685
+ computeDiffPaths,
686
+ readerLoadFile,
687
+ });
688
+
689
+ const res = await runRefreshCommit({
690
+ cwd,
691
+ kind,
692
+ storyId,
693
+ epicBranch,
694
+ storyBranch,
695
+ config,
696
+ cycleState,
697
+ capCheck,
698
+ refreshBaseline,
699
+ scorerBuilder,
700
+ getBaselines,
701
+ getQuality,
702
+ fsImpl,
703
+ gitRunner,
704
+ logger,
705
+ });
706
+
707
+ if (res.ok !== true) {
708
+ return {
709
+ status: 'failed',
710
+ reason: classifyFunnelError(res.error),
711
+ detail: res.error,
712
+ };
713
+ }
714
+ if (res.refused) {
715
+ refusedVerdicts.push(res.verdict);
716
+ continue;
717
+ }
718
+ if (!res.skipped && res.sha) {
719
+ committed.push({ kind, sha: res.sha });
720
+ lastSha = res.sha;
721
+ }
771
722
  }
772
723
 
773
- // Step 3 — fan out to the accepted (commit) or refused (push) terminal
774
- // step. The pipeline-top stays at one level of abstraction.
775
- if (!validation.accepted) {
776
- return pushRefresh({
777
- validation,
724
+ if (refusedVerdicts.length > 0) {
725
+ return publishRefusal({
726
+ refusedVerdicts,
778
727
  caps,
779
728
  epicId,
780
729
  storyId,
781
- cwd,
782
- gitRunner,
783
730
  appendSignal,
784
731
  forEachLine,
785
732
  config,
786
733
  logger,
787
734
  });
788
735
  }
789
- return commitRefresh({ stage, cwd, storyId, gitRunner, logger });
736
+ if (committed.length === 0) {
737
+ return { status: 'skipped', reason: 'no-baseline-drift' };
738
+ }
739
+ return {
740
+ status: 'committed',
741
+ sha: lastSha,
742
+ files: kinds.map((k) => k.abs),
743
+ committed,
744
+ };
790
745
  }
791
-
792
- export {
793
- commitRefresh,
794
- pushRefresh,
795
- stageRefreshArtifacts,
796
- validateRefreshAccepted,
797
- };