mandrel 2.0.0 → 2.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 (311) hide show
  1. package/.agents/README.md +59 -28
  2. package/.agents/agents/acceptance-critic.md +9 -7
  3. package/.agents/agents/story-worker.md +41 -46
  4. package/.agents/audit-checklists/performance.md +1 -1
  5. package/.agents/docs/SDLC.md +51 -44
  6. package/.agents/docs/agentrc-reference.json +8 -13
  7. package/.agents/docs/configuration.md +32 -56
  8. package/.agents/docs/execution-reference.md +39 -10
  9. package/.agents/docs/quality-gates.md +14 -16
  10. package/.agents/docs/workflows.md +6 -6
  11. package/.agents/instructions.md +64 -79
  12. package/.agents/rules/ci-remediation.md +3 -3
  13. package/.agents/rules/git-conventions-reference.md +42 -51
  14. package/.agents/schemas/agentrc.schema.json +34 -45
  15. package/.agents/schemas/audit-rules.json +59 -1
  16. package/.agents/schemas/audit-rules.schema.json +33 -1
  17. package/.agents/schemas/lifecycle/README.md +1 -2
  18. package/.agents/schemas/lifecycle/ledger-record.schema.json +1 -1
  19. package/.agents/schemas/lifecycle/merge.flip-failed.schema.json +33 -0
  20. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +1 -0
  21. package/.agents/schemas/lifecycle/story.merged.schema.json +1 -1
  22. package/.agents/schemas/signal-event.schema.json +3 -3
  23. package/.agents/schemas/story-deliver-terminal.schema.json +152 -0
  24. package/.agents/schemas/validation-evidence.schema.json +1 -1
  25. package/.agents/scripts/acceptance-eval.js +22 -66
  26. package/.agents/scripts/agents-bootstrap-github.js +1 -1
  27. package/.agents/scripts/bootstrap.js +3 -3
  28. package/.agents/scripts/check-dead-exports.js +43 -104
  29. package/.agents/scripts/check-doc-links.js +2 -2
  30. package/.agents/scripts/check-lifecycle-lint.js +1 -1
  31. package/.agents/scripts/check-workflow-cli-lint.js +91 -0
  32. package/.agents/scripts/deliver-recover.js +122 -0
  33. package/.agents/scripts/drain-pending-cleanup.js +1 -1
  34. package/.agents/scripts/evidence-gate.js +20 -50
  35. package/.agents/scripts/generate-skills-index.js +17 -1
  36. package/.agents/scripts/generate-workflows-doc.js +4 -4
  37. package/.agents/scripts/lib/ITicketingProvider.js +1 -19
  38. package/.agents/scripts/lib/audit-suite/selector.js +323 -23
  39. package/.agents/scripts/lib/baselines/kinds/maintainability.js +0 -11
  40. package/.agents/scripts/lib/bootstrap/ci-workflow-template.js +28 -33
  41. package/.agents/scripts/lib/bootstrap/manifest.js +8 -11
  42. package/.agents/scripts/lib/bootstrap/project-bootstrap.js +30 -53
  43. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +0 -2
  44. package/.agents/scripts/lib/checks/core-bare-clean.js +4 -1
  45. package/.agents/scripts/lib/checks/index.js +1 -1
  46. package/.agents/scripts/lib/checks/loop-health.js +12 -11
  47. package/.agents/scripts/lib/checks/state.js +17 -248
  48. package/.agents/scripts/lib/checks/story-init-not-backgrounded.js +3 -3
  49. package/.agents/scripts/lib/checks/subagent-agent-tool-required.js +3 -4
  50. package/.agents/scripts/lib/checks/worktree-bootstrap-env.js +2 -2
  51. package/.agents/scripts/lib/checks/worktree-residue-biome.js +3 -3
  52. package/.agents/scripts/lib/cli-args.js +23 -2
  53. package/.agents/scripts/lib/close-validation/gates.js +13 -13
  54. package/.agents/scripts/lib/close-validation/projections/inputs.js +7 -7
  55. package/.agents/scripts/lib/close-validation/projections/maintainability.js +12 -12
  56. package/.agents/scripts/lib/close-validation/runner.js +13 -21
  57. package/.agents/scripts/lib/close-validation/telemetry.js +17 -8
  58. package/.agents/scripts/lib/config/delivery-routing.js +7 -6
  59. package/.agents/scripts/lib/config/explain.js +10 -16
  60. package/.agents/scripts/lib/config/github.js +7 -5
  61. package/.agents/scripts/lib/config/limits.js +15 -25
  62. package/.agents/scripts/lib/config/quality.js +11 -14
  63. package/.agents/scripts/lib/config/runners.js +8 -21
  64. package/.agents/scripts/lib/config/temp-paths.js +18 -56
  65. package/.agents/scripts/lib/config-settings-schema-delivery.js +31 -13
  66. package/.agents/scripts/lib/config-settings-schema-quality.js +9 -2
  67. package/.agents/scripts/lib/config-settings-schema.js +48 -22
  68. package/.agents/scripts/lib/dead-exports-knip.js +105 -0
  69. package/.agents/scripts/lib/dead-exports-mode.js +51 -0
  70. package/.agents/scripts/lib/duplicate-search.js +38 -7
  71. package/.agents/scripts/lib/findings/promote-finding.js +23 -14
  72. package/.agents/scripts/lib/format-generated-json.js +97 -0
  73. package/.agents/scripts/lib/framework-version.js +19 -189
  74. package/.agents/scripts/lib/gh-exec.js +8 -0
  75. package/.agents/scripts/lib/git-branch-lifecycle.js +0 -158
  76. package/.agents/scripts/lib/git-utils.js +0 -14
  77. package/.agents/scripts/lib/json-utils.js +1 -2
  78. package/.agents/scripts/lib/label-constants.js +0 -15
  79. package/.agents/scripts/lib/label-taxonomy.js +1 -12
  80. package/.agents/scripts/lib/observability/active-story-env.js +42 -163
  81. package/.agents/scripts/lib/observability/runtime-friction.js +243 -0
  82. package/.agents/scripts/lib/observability/signal-validator.js +4 -4
  83. package/.agents/scripts/lib/observability/signals-writer.js +6 -82
  84. package/.agents/scripts/lib/observability/source-classifier.js +2 -2
  85. package/.agents/scripts/lib/observability/tool-trace-hook.js +2 -12
  86. package/.agents/scripts/lib/orchestration/acceptance-clusters.js +1 -1
  87. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +2 -2
  88. package/.agents/scripts/lib/orchestration/ceremony-routing.js +43 -45
  89. package/.agents/scripts/lib/orchestration/code-review.js +58 -168
  90. package/.agents/scripts/lib/orchestration/consolidation-precondition.js +3 -3
  91. package/.agents/scripts/lib/orchestration/deliver-recover.js +328 -0
  92. package/.agents/scripts/lib/orchestration/detectors-phase.js +12 -6
  93. package/.agents/scripts/lib/orchestration/git-cleanup/phases/fast-forward.js +34 -0
  94. package/.agents/scripts/lib/orchestration/lease-guard-shared.js +3 -2
  95. package/.agents/scripts/lib/orchestration/lifecycle/emit-ledger-event.js +142 -0
  96. package/.agents/scripts/lib/orchestration/lifecycle/emit-loop-tick.js +9 -11
  97. package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-flip-failed.js +86 -0
  98. package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +37 -103
  99. package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +7 -3
  100. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +50 -85
  101. package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +3 -14
  102. package/.agents/scripts/lib/orchestration/merge-block-class.js +76 -20
  103. package/.agents/scripts/lib/orchestration/merge-poll.js +104 -0
  104. package/.agents/scripts/lib/orchestration/plan-context.js +114 -24
  105. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +11 -22
  106. package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +3 -7
  107. package/.agents/scripts/lib/orchestration/plan-metrics.js +38 -6
  108. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +16 -6
  109. package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +173 -25
  110. package/.agents/scripts/lib/orchestration/plan-persist/plan-context-source.js +116 -0
  111. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +280 -75
  112. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +472 -55
  113. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +21 -16
  114. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +509 -0
  115. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +41 -40
  116. package/.agents/scripts/lib/orchestration/planning/spec-authoring-grounding.js +1 -1
  117. package/.agents/scripts/lib/orchestration/resolve-stories.js +344 -0
  118. package/.agents/scripts/lib/orchestration/retro-proposals.js +7 -7
  119. package/.agents/scripts/lib/orchestration/review-depth.js +105 -40
  120. package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +3 -13
  121. package/.agents/scripts/lib/orchestration/review-providers/native.js +1 -154
  122. package/.agents/scripts/lib/orchestration/review-providers/review-depth.js +3 -2
  123. package/.agents/scripts/lib/orchestration/review-providers/security-review.js +1 -1
  124. package/.agents/scripts/lib/orchestration/review-providers/types.js +5 -4
  125. package/.agents/scripts/lib/orchestration/review-providers/ultrareview.js +1 -1
  126. package/.agents/scripts/lib/orchestration/run-epilogue.js +374 -16
  127. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +24 -0
  128. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +11 -9
  129. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +4 -4
  130. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +3 -12
  131. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +608 -152
  132. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +72 -30
  133. package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +305 -0
  134. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +1 -1
  135. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-block.js +12 -8
  136. package/.agents/scripts/lib/orchestration/single-story-close/phases/worktree-reap.js +37 -4
  137. package/.agents/scripts/lib/orchestration/single-story-close/phases/wrong-tree-guard.js +2 -2
  138. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +264 -43
  139. package/.agents/scripts/lib/orchestration/single-story-lease-guard.js +1 -1
  140. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +10 -10
  141. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +15 -32
  142. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +360 -0
  143. package/.agents/scripts/lib/orchestration/story-follow-ups.js +75 -14
  144. package/.agents/scripts/lib/orchestration/story-init-remote.js +12 -8
  145. package/.agents/scripts/lib/orchestration/story-plan-state.js +14 -29
  146. package/.agents/scripts/lib/orchestration/task-body-validator.js +52 -7
  147. package/.agents/scripts/lib/orchestration/ticket-lease.js +27 -74
  148. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +119 -14
  149. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +3 -4
  150. package/.agents/scripts/lib/orchestration/ticket-validator.js +121 -18
  151. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +14 -47
  152. package/.agents/scripts/lib/orchestration/ticketing/reads.js +19 -32
  153. package/.agents/scripts/lib/orchestration/ticketing/transition.js +61 -1
  154. package/.agents/scripts/lib/orchestration/ticketing.js +0 -1
  155. package/.agents/scripts/lib/plan-phase-cleanup.js +12 -14
  156. package/.agents/scripts/lib/planning-corpus.js +12 -286
  157. package/.agents/scripts/lib/preflight-runner.js +2 -2
  158. package/.agents/scripts/lib/qa/qa-context-hydrator.js +5 -5
  159. package/.agents/scripts/lib/signals/index.js +4 -17
  160. package/.agents/scripts/lib/signals/read.js +35 -35
  161. package/.agents/scripts/lib/signals/schema.js +8 -11
  162. package/.agents/scripts/lib/signals/span-tree.js +7 -7
  163. package/.agents/scripts/lib/signals/write.js +0 -1
  164. package/.agents/scripts/lib/single-story/story-merged-notify.js +13 -2
  165. package/.agents/scripts/lib/skills/parse-skill.js +16 -3
  166. package/.agents/scripts/lib/story-adjacency.js +8 -7
  167. package/.agents/scripts/lib/story-body/story-body.js +6 -5
  168. package/.agents/scripts/lib/templates/decomposer-prompts.js +7 -3
  169. package/.agents/scripts/lib/test-env.js +14 -1
  170. package/.agents/scripts/lib/test-tiers.js +0 -3
  171. package/.agents/scripts/lib/ticket-body-sections.js +0 -14
  172. package/.agents/scripts/lib/validation-evidence.js +31 -59
  173. package/.agents/scripts/lib/wave-runner/ready-set.js +32 -6
  174. package/.agents/scripts/lib/worktree/lifecycle/pending-cleanup.js +1 -1
  175. package/.agents/scripts/lib/worktree/lifecycle/reap.js +68 -19
  176. package/.agents/scripts/lib/worktree/lifecycle-manager.js +1 -2
  177. package/.agents/scripts/plan-context.js +38 -6
  178. package/.agents/scripts/plan-persist.js +145 -35
  179. package/.agents/scripts/plan-run-epilogue.js +83 -38
  180. package/.agents/scripts/post-structured-comment.js +0 -38
  181. package/.agents/scripts/pr-watch-with-update.js +43 -22
  182. package/.agents/scripts/providers/github/compose.js +0 -1
  183. package/.agents/scripts/providers/github/errors.js +0 -19
  184. package/.agents/scripts/providers/github/issues.js +1 -11
  185. package/.agents/scripts/providers/github/mappers.js +5 -0
  186. package/.agents/scripts/providers/github/sub-issues.js +0 -47
  187. package/.agents/scripts/providers/github/tickets.js +33 -153
  188. package/.agents/scripts/providers/github.js +17 -6
  189. package/.agents/scripts/resolve-stories.js +236 -0
  190. package/.agents/scripts/run-coverage.js +4 -1
  191. package/.agents/scripts/run-lint.js +2 -2
  192. package/.agents/scripts/run-verify.js +31 -2
  193. package/.agents/scripts/signals-view.js +9 -10
  194. package/.agents/scripts/single-story-close.js +173 -18
  195. package/.agents/scripts/single-story-confirm-merge.js +288 -15
  196. package/.agents/scripts/single-story-init.js +6 -10
  197. package/.agents/scripts/stories-wave-tick.js +79 -4
  198. package/.agents/scripts/story-plan.js +3 -3
  199. package/.agents/scripts/update-ticket-state.js +8 -50
  200. package/.agents/skills/core/code-review-and-quality/SKILL.md +28 -450
  201. package/.agents/skills/core/code-review-and-quality/reference.md +458 -0
  202. package/.agents/skills/core/debugging-and-error-recovery/SKILL.md +22 -315
  203. package/.agents/skills/core/debugging-and-error-recovery/reference.md +323 -0
  204. package/.agents/skills/core/diagnose-friction/SKILL.md +14 -18
  205. package/.agents/skills/core/documentation-and-adrs/SKILL.md +25 -397
  206. package/.agents/skills/core/documentation-and-adrs/reference.md +403 -0
  207. package/.agents/skills/core/gates-and-baselines/SKILL.md +12 -12
  208. package/.agents/skills/core/idea-refinement/SKILL.md +3 -3
  209. package/.agents/skills/core/scope-triage/SKILL.md +3 -0
  210. package/.agents/skills/core/security-and-hardening/SKILL.md +22 -367
  211. package/.agents/skills/core/security-and-hardening/reference.md +375 -0
  212. package/.agents/skills/skills.index.json +2 -12
  213. package/.agents/skills/stack/qa/playwright-bdd/SKILL.md +2 -4
  214. package/.agents/skills/stack/qa/qa-explore-driving/SKILL.md +1 -1
  215. package/.agents/skills/stack/qa/qa-harness/SKILL.md +1 -3
  216. package/.agents/workflows/audit-architecture.md +3 -4
  217. package/.agents/workflows/audit-clean-code.md +4 -4
  218. package/.agents/workflows/audit-documentation.md +4 -5
  219. package/.agents/workflows/audit-lighthouse.md +8 -0
  220. package/.agents/workflows/audit-navigability.md +10 -0
  221. package/.agents/workflows/audit-performance.md +2 -3
  222. package/.agents/workflows/audit-quality.md +8 -9
  223. package/.agents/workflows/audit-security.md +1 -2
  224. package/.agents/workflows/audit-seo.md +10 -0
  225. package/.agents/workflows/audit-ux-ui.md +7 -0
  226. package/.agents/workflows/deliver.md +98 -45
  227. package/.agents/workflows/git-cleanup.md +2 -2
  228. package/.agents/workflows/git-deliver.md +1 -1
  229. package/.agents/workflows/helpers/acceptance-self-eval.md +21 -13
  230. package/.agents/workflows/helpers/code-quality-guardrails.md +7 -7
  231. package/.agents/workflows/helpers/code-review.md +12 -10
  232. package/.agents/workflows/helpers/deliver-story-reference.md +73 -32
  233. package/.agents/workflows/helpers/deliver-story.md +193 -118
  234. package/.agents/workflows/helpers/parallel-tooling.md +2 -2
  235. package/.agents/workflows/helpers/worktree-lifecycle.md +28 -32
  236. package/.agents/workflows/plan.md +184 -19
  237. package/.agents/workflows/qa-assist.md +6 -6
  238. package/.agents/workflows/qa-explore.md +3 -3
  239. package/.agents/workflows/qa-run.md +1 -5
  240. package/bin/mandrel.js +12 -1
  241. package/docs/CHANGELOG.md +40 -0
  242. package/lib/cli/registry.js +262 -19
  243. package/lib/cli/sync-agents.js +157 -0
  244. package/lib/cli/sync-commands.js +115 -6
  245. package/lib/cli/sync.js +168 -6
  246. package/lib/cli/update.js +105 -8
  247. package/lib/cli/version-helpers.js +131 -0
  248. package/lib/migrations/README.md +7 -5
  249. package/lib/migrations/index.js +12 -9
  250. package/lib/migrations/steps/2.1.0-retire-mi-drop-knobs.js +100 -0
  251. package/lib/migrations/steps/2.1.0-retire-verify-concurrency-cap.js +101 -0
  252. package/package.json +1 -1
  253. package/.agents/schemas/epic-perf-report.schema.json +0 -89
  254. package/.agents/schemas/lifecycle/acceptance.reconcile.failed.schema.json +0 -13
  255. package/.agents/schemas/lifecycle/acceptance.reconcile.ok.schema.json +0 -13
  256. package/.agents/schemas/lifecycle/acceptance.reconcile.skipped.schema.json +0 -13
  257. package/.agents/schemas/lifecycle/acceptance.reconcile.start.schema.json +0 -12
  258. package/.agents/schemas/lifecycle/acceptance.reconcile.waived.schema.json +0 -13
  259. package/.agents/schemas/lifecycle/epic.automerge.end.schema.json +0 -15
  260. package/.agents/schemas/lifecycle/epic.automerge.start.schema.json +0 -13
  261. package/.agents/schemas/lifecycle/epic.blocked.schema.json +0 -13
  262. package/.agents/schemas/lifecycle/epic.cleanup.end.schema.json +0 -12
  263. package/.agents/schemas/lifecycle/epic.cleanup.start.schema.json +0 -12
  264. package/.agents/schemas/lifecycle/epic.close.end.schema.json +0 -12
  265. package/.agents/schemas/lifecycle/epic.complete.schema.json +0 -13
  266. package/.agents/schemas/lifecycle/epic.finalize.end.schema.json +0 -13
  267. package/.agents/schemas/lifecycle/epic.finalize.start.schema.json +0 -12
  268. package/.agents/schemas/lifecycle/epic.merge.armed.schema.json +0 -13
  269. package/.agents/schemas/lifecycle/epic.merge.blocked.schema.json +0 -14
  270. package/.agents/schemas/lifecycle/epic.merge.confirmed.schema.json +0 -17
  271. package/.agents/schemas/lifecycle/epic.merge.ready.schema.json +0 -15
  272. package/.agents/schemas/lifecycle/epic.plan.end.schema.json +0 -18
  273. package/.agents/schemas/lifecycle/epic.plan.start.schema.json +0 -12
  274. package/.agents/schemas/lifecycle/epic.snapshot.end.schema.json +0 -16
  275. package/.agents/schemas/lifecycle/epic.snapshot.start.schema.json +0 -12
  276. package/.agents/schemas/lifecycle/epic.watch.end.schema.json +0 -29
  277. package/.agents/schemas/lifecycle/epic.watch.start.schema.json +0 -16
  278. package/.agents/schemas/lifecycle/story.heartbeat.schema.json +0 -20
  279. package/.agents/schemas/risk-verdict.schema.json +0 -53
  280. package/.agents/schemas/story-perf-summary.schema.json +0 -73
  281. package/.agents/scripts/analyze-execution.js +0 -444
  282. package/.agents/scripts/check-prepush-recovery.js +0 -90
  283. package/.agents/scripts/lib/git-merge-orchestrator.js +0 -261
  284. package/.agents/scripts/lib/observability/baseline-refresh-rate.js +0 -221
  285. package/.agents/scripts/lib/observability/hook-heartbeat.js +0 -187
  286. package/.agents/scripts/lib/observability/perf-aggregator.js +0 -813
  287. package/.agents/scripts/lib/observability/perf-report-readers.js +0 -328
  288. package/.agents/scripts/lib/observability/perf-report-render.js +0 -182
  289. package/.agents/scripts/lib/orchestration/audit-lens-routing.js +0 -128
  290. package/.agents/scripts/lib/orchestration/bookkeeping-outbox.js +0 -273
  291. package/.agents/scripts/lib/orchestration/error-journal.js +0 -139
  292. package/.agents/scripts/lib/orchestration/lifecycle/emit-story-heartbeat.js +0 -155
  293. package/.agents/scripts/lib/orchestration/lifecycle/ledger-diff.js +0 -140
  294. package/.agents/scripts/lib/orchestration/lifecycle/listeners/merge-watcher.js +0 -665
  295. package/.agents/scripts/lib/orchestration/plan-review-routing.js +0 -63
  296. package/.agents/scripts/lib/orchestration/planning/risk-verdict.js +0 -104
  297. package/.agents/scripts/lib/orchestration/planning-context-budget.js +0 -213
  298. package/.agents/scripts/lib/orchestration/planning-risk.js +0 -194
  299. package/.agents/scripts/lib/orchestration/post-merge/phases/branch-cleanup.js +0 -56
  300. package/.agents/scripts/lib/orchestration/post-merge/phases/dashboard-refresh.js +0 -21
  301. package/.agents/scripts/lib/orchestration/post-merge/phases/notification.js +0 -78
  302. package/.agents/scripts/lib/orchestration/post-merge/phases/temp-cleanup.js +0 -68
  303. package/.agents/scripts/lib/orchestration/post-merge/phases/ticket-closure.js +0 -118
  304. package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +0 -397
  305. package/.agents/scripts/lib/orchestration/preflight-cache.js +0 -187
  306. package/.agents/scripts/lib/orchestration/resolve-plan-run.js +0 -155
  307. package/.agents/scripts/lib/orchestration/retro-perf-heuristics.js +0 -275
  308. package/.agents/scripts/lib/orchestration/story-progress/story-run-progress-writer.js +0 -400
  309. package/.agents/scripts/lib/single-story/confirm-merge-follow-ups.js +0 -36
  310. package/.agents/scripts/resolve-plan-run.js +0 -117
  311. package/.agents/skills/core/analyze-execution/SKILL.md +0 -101
@@ -5,9 +5,19 @@
5
5
  * an exclusive, time-bounded claim on a ticket so two concurrent runs do not
6
6
  * both drive the same Story. Rather than invent a new state column, the lease
7
7
  * rides the ticket's existing **assignees** surface: the single assignee *is*
8
- * the lease owner. Liveness is decided by the owner's most-recent
9
- * `story.heartbeat` timestamp (the `operator` field added in this Story links
10
- * a heartbeat back to the claimant) compared against a configured TTL.
8
+ * the lease owner. Liveness is decided by the owner's last-heartbeat epoch-ms
9
+ * compared against a configured TTL.
10
+ *
11
+ * **There is no live heartbeat source.** The `story.heartbeat` emitter this
12
+ * module was designed against was structurally inert (it demanded an
13
+ * `epicId >= 1` that v2, which has no Epics, never supplies) and has been
14
+ * deleted. Every caller now reaches `acquireLease` through
15
+ * `lease-guard-shared.acquireLeaseFailClosed` with `anchorHeartbeatToNow`,
16
+ * which pins `heartbeatAt` to `now`: the lease **fails closed**, so any
17
+ * foreign claim reads as live and refuses. A stranded claim is cleared with
18
+ * `--steal`, never by TTL expiry. The TTL and the stale-reclaim branch below
19
+ * are therefore reachable only via an explicit caller-supplied `heartbeatAt`
20
+ * — the seam is kept, the automatic expiry is not real.
11
21
  *
12
22
  * The three exported operations are deliberately thin and provider-agnostic:
13
23
  *
@@ -31,19 +41,16 @@
31
41
  * Liveness seam: callers supply the owner's last-heartbeat epoch-ms via the
32
42
  * `heartbeatAt` option (a number, or `null`/`undefined` when no heartbeat has
33
43
  * ever been recorded for the current owner). Threading the timestamp in keeps
34
- * this module pure and trivially unit-testable — it does not read the
35
- * lifecycle ledger itself. A claim with no heartbeat is treated as stale
36
- * (reclaimable) so an abandoned assignment never wedges the ticket.
44
+ * this module pure and trivially unit-testable — it does not read any ledger
45
+ * itself. A claim with no heartbeat is treated as stale (reclaimable) by
46
+ * `isClaimLive`; note the live guards never take that branch, per the
47
+ * fail-closed anchoring described above.
37
48
  *
38
49
  * `now` is injectable (epoch ms) for deterministic tests; it defaults to
39
50
  * `Date.now()`.
40
51
  */
41
52
 
42
- import { readFileSync } from 'node:fs';
43
-
44
53
  import { resolveLeaseTtlMs } from '../config/limits.js';
45
- import { epicLedgerPath } from '../config/temp-paths.js';
46
- import { parseLedger } from './lifecycle/trace-logger.js';
47
54
 
48
55
  /**
49
56
  * The shipped, non-personal operator-identity placeholder (and its bare,
@@ -87,76 +94,22 @@ export function normalizeOperatorHandle(raw) {
87
94
  return trimmed;
88
95
  }
89
96
 
90
- /**
91
- * Read the most-recent `story.heartbeat` epoch-ms recorded for a given lease
92
- * owner from an Epic lifecycle ledger. Returns `null` when the ledger is
93
- * absent, unreadable, or carries no heartbeat for that owner — which the lease
94
- * primitive treats as a stale (reclaimable) claim.
95
- *
96
- * The ledger is NDJSON; each `story.heartbeat` record carries
97
- * `payload.operator` (Story #3480) and `payload.timestamp` (ISO-8601). A
98
- * malformed ledger downgrades to `null` rather than throwing so a corrupt
99
- * observability artifact never wedges the lease preflight.
100
- *
101
- * This is the shared liveness source the lease guards thread into
102
- * `acquireLease` via `heartbeatAt`; `/plan` and `/deliver` both
103
- * reuse it so a live foreign claim actually refuses.
104
- *
105
- * @param {object} args
106
- * @param {number} args.epicId
107
- * @param {string} args.owner Lease owner whose heartbeat to find.
108
- * @param {object} [args.config] Resolved config (for ledger path).
109
- * @param {string} [args.ledgerPath] Explicit path override (tests).
110
- * @param {(eid: number, config?: object) => string} [args.ledgerPathResolver]
111
- * Injectable resolver (tests). Defaults to `epicLedgerPath`.
112
- * @param {(p: string) => string} [args.readFile] Injectable reader (tests).
113
- * @returns {number|null}
114
- */
115
- export function latestHeartbeatForOwner({
116
- epicId,
117
- owner,
118
- config,
119
- ledgerPath,
120
- ledgerPathResolver = epicLedgerPath,
121
- readFile = (p) => readFileSync(p, 'utf8'),
122
- }) {
123
- if (typeof owner !== 'string' || owner.length === 0) return null;
124
- const resolvedPath = ledgerPath ?? ledgerPathResolver(epicId, config);
125
-
126
- let text;
127
- try {
128
- text = readFile(resolvedPath);
129
- } catch (_err) {
130
- // No ledger yet (fresh Epic) → no heartbeat → reclaimable.
131
- return null;
132
- }
133
-
134
- let records;
135
- try {
136
- records = parseLedger(text);
137
- } catch (_err) {
138
- // Corrupt ledger is an observability problem, not a coordination blocker.
139
- return null;
140
- }
141
-
142
- let latest = null;
143
- for (const record of records) {
144
- const payload = record?.payload;
145
- if (!payload || payload.event !== 'story.heartbeat') continue;
146
- if (payload.operator !== owner) continue;
147
- const ts = Date.parse(payload.timestamp ?? '');
148
- if (!Number.isFinite(ts)) continue;
149
- if (latest === null || ts > latest) latest = ts;
150
- }
151
- return latest;
152
- }
153
-
154
97
  /**
155
98
  * Decide whether a foreign claim is still "live" given the owner's last
156
99
  * heartbeat and the configured TTL. A claim is live when a heartbeat exists
157
100
  * and is no older than `ttlMs`. A missing heartbeat (`null`/`undefined`) or a
158
101
  * heartbeat older than the TTL is stale and therefore reclaimable.
159
102
  *
103
+ * The `heartbeatAt` seam is retained, but there is no longer any in-repo
104
+ * heartbeat *source*: the `story.heartbeat` emitter was structurally inert
105
+ * (it required an `epicId >= 1` that v2 never sets) and was deleted along
106
+ * with the ledger reader that scanned for it. Every live caller reaches this
107
+ * through `lease-guard-shared.acquireLeaseFailClosed` with
108
+ * `anchorHeartbeatToNow`, which pins `heartbeatAt` to `now` so ANY foreign
109
+ * claim reads live and the guard fails closed — a stranded claim is cleared
110
+ * with `--steal`, not by TTL expiry. The parameter stays because that
111
+ * anchoring is expressed through it.
112
+ *
160
113
  * @param {object} args
161
114
  * @param {number|null|undefined} args.heartbeatAt Owner's last heartbeat (epoch ms).
162
115
  * @param {number} args.ttlMs Lease TTL in milliseconds.
@@ -589,13 +589,53 @@ function registryRegistry(producers, assumptionEntries, patterns, cache) {
589
589
  }
590
590
 
591
591
  /**
592
- * Compute `fan-out-warning` findings (Story #2962).
592
+ * Normalize a fan-out probe result into `{ count, files, probe }`.
593
593
  *
594
- * For each `body.changes` entry whose `assumption` is `"deletes"` (or
595
- * `"refactors-existing"` when the planner declared a symbol replacement),
596
- * count the number of distinct files in the base branch that reference
597
- * the deleted module via its basename. When the count exceeds the
598
- * configured `largeFanOutThreshold`, emit a finding.
594
+ * The production probe reports its referencing files and the exact command
595
+ * that found them so an operator can reproduce the figure (Story #4547).
596
+ * A bare number stays valid injected test counters and any consumer
597
+ * counter written against the Story #2962 contract keep working, they just
598
+ * carry no audit trail.
599
+ */
600
+ function normalizeFanOutProbe(result) {
601
+ if (typeof result === 'number') {
602
+ return { count: result, files: [], probe: null };
603
+ }
604
+ if (result === null || typeof result !== 'object') {
605
+ return { count: 0, files: [], probe: null };
606
+ }
607
+ const files = Array.isArray(result.files) ? result.files : [];
608
+ const count = Number.isFinite(result.count) ? result.count : files.length;
609
+ return { count, files, probe: result.probe ?? null };
610
+ }
611
+
612
+ /**
613
+ * Index the basenames this spec *creates*, so a deletion that is really one
614
+ * half of a move can be told apart from a genuine wide-coupling removal.
615
+ */
616
+ function indexCreatedBasenames(assumptionEntries) {
617
+ const byBasename = new Map();
618
+ for (const entry of assumptionEntries) {
619
+ if (entry.assumption !== 'creates') continue;
620
+ const base = entry.path.slice(entry.path.lastIndexOf('/') + 1);
621
+ if (!byBasename.has(base)) byBasename.set(base, entry.path);
622
+ }
623
+ return byBasename;
624
+ }
625
+
626
+ /**
627
+ * Compute `fan-out-warning` findings (Story #2962, reworked in #4547).
628
+ *
629
+ * For each `body.changes` entry whose `assumption` is `"deletes"`, probe the
630
+ * files at the base branch that genuinely import or require the deleted
631
+ * module. When that count exceeds the configured `largeFanOutThreshold`,
632
+ * emit a finding carrying the referencing files and the probe that produced
633
+ * them.
634
+ *
635
+ * The finding also records whether the deletion is **rename-shaped** — the
636
+ * same spec creates a file with the deleted module's basename elsewhere —
637
+ * because the remedy diverges: a move wants its importers repointed in one
638
+ * Story, not a subsystem-by-subsystem migration split across several.
599
639
  *
600
640
  * The default severity is always `'soft'` — the persist gate enforces a
601
641
  * hard refusal via the `--allow-large-fan-out` operator flag, since the
@@ -613,21 +653,28 @@ function computeFanOutFindings({
613
653
  if (!Number.isFinite(threshold) || threshold < 0) return [];
614
654
  const findings = [];
615
655
  const cache = new Map();
656
+ const createdBasenames = indexCreatedBasenames(assumptionEntries);
616
657
  for (const entry of assumptionEntries) {
617
658
  if (entry.assumption !== 'deletes') continue;
618
- let count = cache.get(entry.path);
619
- if (count === undefined) {
620
- count = counter({ path: entry.path }) ?? 0;
621
- cache.set(entry.path, count);
659
+ let probed = cache.get(entry.path);
660
+ if (probed === undefined) {
661
+ probed = normalizeFanOutProbe(counter({ path: entry.path }));
662
+ cache.set(entry.path, probed);
622
663
  }
623
- if (count <= threshold) continue;
664
+ if (probed.count <= threshold) continue;
665
+ const base = entry.path.slice(entry.path.lastIndexOf('/') + 1);
666
+ const renameTarget = createdBasenames.get(base);
624
667
  findings.push({
625
668
  kind: 'fan-out-warning',
626
669
  severity,
627
670
  taskSlug: entry.taskSlug,
628
671
  storySlug: entry.storySlug,
629
672
  path: entry.path,
630
- callSiteCount: count,
673
+ callSiteCount: probed.count,
674
+ callSites: probed.files,
675
+ probe: probed.probe,
676
+ renameShaped: renameTarget !== undefined && renameTarget !== entry.path,
677
+ renameTarget: renameTarget === entry.path ? null : (renameTarget ?? null),
631
678
  threshold,
632
679
  });
633
680
  }
@@ -701,6 +748,60 @@ export function computeConflictFindings({ stories, policy } = {}) {
701
748
  ];
702
749
  }
703
750
 
751
+ /**
752
+ * Render the audit trail behind a fan-out finding's number, so an operator
753
+ * can check the figure rather than trust it (Story #4547).
754
+ *
755
+ * Every importer is named — the list is deliberately **not** truncated. A
756
+ * gate that fires at 100 importers is precisely when the operator needs the
757
+ * list, and a `…and 109 more` tail would leave the figure uncheckable in
758
+ * exactly the case the gate exists for. This message is a fail-closed stop,
759
+ * not a log line; its length is the point.
760
+ *
761
+ * The probe is reported as what it is — the *candidate* net, which each hit
762
+ * is then re-resolved against. It will report at least as many lines as the
763
+ * gate counts files, so labelling it as the thing that produced the number
764
+ * would send an operator chasing a discrepancy that is by design.
765
+ *
766
+ * Returns `''` for a bare-number counter, which carries no audit trail.
767
+ */
768
+ export function renderFanOutEvidence(finding) {
769
+ const files = Array.isArray(finding.callSites) ? finding.callSites : [];
770
+ const parts = [];
771
+ if (files.length > 0) {
772
+ parts.push(` Importers (${files.length}):`);
773
+ for (const file of files) parts.push(` ${file}`);
774
+ }
775
+ if (finding.probe) {
776
+ parts.push(
777
+ ` Candidate probe (each hit re-resolved against its importer's directory):`,
778
+ ` ${finding.probe}`,
779
+ );
780
+ }
781
+ return parts.length > 0 ? `\n${parts.join('\n')}` : '';
782
+ }
783
+
784
+ /**
785
+ * Render the remedy that actually fits the finding. A rename-shaped
786
+ * deletion has nowhere to split to — the importers just need repointing at
787
+ * the path the same plan creates — so telling the operator to split it
788
+ * across Stories leaves the override as the only exit, which is exactly the
789
+ * habit that defeats the gate (Story #4547).
790
+ */
791
+ export function renderFanOutRemedy(finding) {
792
+ if (finding.renameShaped && finding.renameTarget) {
793
+ return (
794
+ `This deletion is rename-shaped: the same plan creates "${finding.renameTarget}" under the same basename. ` +
795
+ `Repoint the importer(s) at the new path inside this Story — a move has no subsystems to split across — ` +
796
+ `then rerun --allow-large-fan-out.`
797
+ );
798
+ }
799
+ return (
800
+ `Split the deletion into a subsystem-by-subsystem migration across multiple Stories, ` +
801
+ `or rerun --allow-large-fan-out after confirming the deletion is intentional.`
802
+ );
803
+ }
804
+
704
805
  /**
705
806
  * Render a `'hard'`-severity conflict finding as a human-readable error
706
807
  * message. Used by the validator when policy flags upgrade a finding to
@@ -712,14 +813,18 @@ export function renderHardConflictError(finding) {
712
813
  return `Shared-editor conflict: "${finding.path}" is written by ${finding.storySlugs.length} concurrent Stories (${stories}). Add depends_on chains between them or split the edits into a dedicated late-wave wiring Story.`;
713
814
  }
714
815
  if (finding.kind === 'implicit-cross-story-dep') {
715
- return `Implicit cross-Story dependency: Task "${finding.consumer.taskSlug}" in Story "${finding.consumer.storySlug}" references "${finding.path}" (produced by Task "${finding.producer.taskSlug}" in Story "${finding.producer.storySlug}") via body.${finding.consumer.sourceField}, but Story "${finding.consumer.storySlug}" has no depends_on link to Story "${finding.producer.storySlug}". Add depends_on: ["${finding.producer.storySlug}"] to the consumer Story or remove the reference.`;
816
+ return `Implicit cross-Story dependency: Story "${finding.consumer.storySlug}" references "${finding.path}" (produced by Story "${finding.producer.storySlug}") via body.${finding.consumer.sourceField}, but Story "${finding.consumer.storySlug}" has no depends_on link to Story "${finding.producer.storySlug}". Add depends_on: ["${finding.producer.storySlug}"] to the consumer Story or remove the reference.`;
716
817
  }
717
818
  if (finding.kind === 'cross-cutting-registries') {
718
819
  const stories = finding.storySlugs.map((s) => `"${s}"`).join(', ');
719
820
  return `Cross-cutting registry conflict: ${finding.storySlugs.length} concurrent Stories (${stories}) edit or register into "${finding.registryPath}". Add depends_on chains between them so the registry updates serialize, or split the registration into a dedicated late-wave wiring Story.`;
720
821
  }
721
822
  if (finding.kind === 'fan-out-warning') {
722
- return `Large fan-out: Task "${finding.taskSlug}" in Story "${finding.storySlug}" deletes "${finding.path}" with ${finding.callSiteCount} call site(s) on the base branch (threshold ${finding.threshold}). Split into a subsystem-by-subsystem migration across multiple Stories, or rerun --allow-large-fan-out after confirming the deletion is intentional.`;
823
+ return (
824
+ `Large fan-out: Story "${finding.storySlug}" deletes "${finding.path}" ` +
825
+ `with ${finding.callSiteCount} importer(s) on the base branch (threshold ${finding.threshold}). ` +
826
+ `${renderFanOutRemedy(finding)}${renderFanOutEvidence(finding)}`
827
+ );
723
828
  }
724
829
  if (finding.kind === 'missing-bdd-scaffold') {
725
830
  return `Missing BDD scaffold: Story "${finding.consumer.storySlug}" verifies against "${finding.path}" (created by Story "${finding.producer.storySlug}") via body.${finding.consumer.sourceField}, but "${finding.consumer.storySlug}" has no depends_on path to "${finding.producer.storySlug}" — the .feature file is scaffolded in the same wave (or later), so verification runs before the file exists. Add depends_on: ["${finding.producer.storySlug}"] to the consumer Story so the scaffold lands in an earlier wave.`;
@@ -14,10 +14,9 @@
14
14
  *
15
15
  * `DEFAULT_MODEL_CAPACITY` is the **single source of truth** for the capacity
16
16
  * thresholds. The decomposer prompt template
17
- * (`.agents/scripts/lib/templates/decomposer-prompts.js`) and the authoring
18
- * SKILL (`.agents/skills/core/epic-plan-decompose-author/SKILL.md`) reference
19
- * these numbers rather than restating divergent ones — the prompt generates
20
- * its threshold sentence from this constant so the two surfaces cannot drift.
17
+ * (`.agents/scripts/lib/templates/decomposer-prompts.js`) generates its
18
+ * threshold sentence from this constant rather than restating divergent
19
+ * numbers, so the two surfaces cannot drift.
21
20
  *
22
21
  * Capacity model:
23
22
  * - Plan-time **session mass** = authored tokens only (`estimateTokens` of
@@ -3,7 +3,10 @@ import { detectCycle } from '../Graph.js';
3
3
  import { gitSpawn } from '../git-utils.js';
4
4
 
5
5
  import { Logger } from '../Logger.js';
6
- import { parse as parseStoryBody } from '../story-body/story-body.js';
6
+ import {
7
+ parse as parseStoryBody,
8
+ StoryBodyParseError,
9
+ } from '../story-body/story-body.js';
7
10
  import { validateStoryFileAssumptions } from './file-assumptions.js';
8
11
  import {
9
12
  computeConflictFindings,
@@ -43,6 +46,95 @@ function collectPathsFromText(text, paths) {
43
46
  }
44
47
  }
45
48
 
49
+ /**
50
+ * Parse a Story's serialized markdown body, translating a
51
+ * `StoryBodyParseError` into a `ValidationError` that names the offending
52
+ * **section** and **entry** (Story #4541).
53
+ *
54
+ * `StoryBodyParseError` already carries `field` (the section the parser was
55
+ * reading) and `raw` (the entry text that failed); this lifts both into an
56
+ * operator-legible message and a structured `violation` payload so an
57
+ * authoring loop can point at the exact bullet instead of re-deriving it
58
+ * from a downstream freshness miss.
59
+ *
60
+ * @param {object} story Story whose `body` is a non-empty markdown string.
61
+ * @returns {object} The structured body.
62
+ * @throws {ValidationError} `code: 'story-body-unparseable'`.
63
+ */
64
+ function parseStoryBodyOrThrow(story) {
65
+ try {
66
+ return parseStoryBody(story.body).body;
67
+ } catch (err) {
68
+ if (!(err instanceof StoryBodyParseError)) throw err;
69
+ const slug = story.slug ?? '<unknown>';
70
+ const section = err.field ?? 'body';
71
+ const entry = err.raw ?? null;
72
+ const entryLine = entry === null ? '' : `\n entry: ${entry}`;
73
+ const violation = { slug, section, entry, reason: err.message };
74
+ const error = new ValidationError(
75
+ `Cross-Validation Failed: Story "${slug}" has an unparseable body — ` +
76
+ `the ## ${section} section could not be read: ${err.message}` +
77
+ `${entryLine}\n\nFix the offending entry; this is a malformed body, ` +
78
+ 'not a stale path reference.',
79
+ { violations: [violation] },
80
+ );
81
+ error.code = 'story-body-unparseable';
82
+ error.violations = [violation];
83
+ throw error;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Refuse the plan when any Story's serialized body cannot be parsed, before
89
+ * either git-probe gate runs (Story #4541). Ordering matters: the freshness
90
+ * gate consults `body.changes` for its net-new whitelist, so an unparseable
91
+ * body used to reach the operator as a freshness miss naming declared paths.
92
+ *
93
+ * @param {{ tickets: object[] }} opts
94
+ * @throws {ValidationError} `code: 'story-body-unparseable'` on the first
95
+ * offending Story.
96
+ */
97
+ function assertStoryBodiesParse({ tickets }) {
98
+ for (const story of (tickets ?? []).filter((t) => t.type === 'story')) {
99
+ if (typeof story.body !== 'string' || story.body.trim().length === 0) {
100
+ continue;
101
+ }
102
+ parseStoryBodyOrThrow(story);
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Resolve every acceptance line a Story declares, across both authoring
108
+ * shapes (Story #4541).
109
+ *
110
+ * The canonical shape is a **serialized string body** with the criteria at
111
+ * the ticket's **top level** — the machine contract persist syncs into the
112
+ * body. `validateAcceptanceSubjectPrefix` used to read `body.acceptance` on
113
+ * an object body only, so on every real plan it scanned nothing and the gate
114
+ * silently passed. Union both sources (deduplicated) so the gate fires on
115
+ * whichever surface the author used.
116
+ *
117
+ * @param {object} story
118
+ * @returns {string[]}
119
+ */
120
+ function resolveAcceptanceLines(story) {
121
+ const lines = new Set();
122
+ if (Array.isArray(story?.acceptance)) {
123
+ for (const item of story.acceptance) lines.add(String(item ?? ''));
124
+ }
125
+ const body = story?.body;
126
+ let bodyAcceptance = null;
127
+ if (typeof body === 'string' && body.trim().length > 0) {
128
+ bodyAcceptance = parseStoryBodyOrThrow(story).acceptance;
129
+ } else if (body !== null && typeof body === 'object') {
130
+ bodyAcceptance = body.acceptance;
131
+ }
132
+ if (Array.isArray(bodyAcceptance)) {
133
+ for (const item of bodyAcceptance) lines.add(String(item ?? ''));
134
+ }
135
+ return [...lines];
136
+ }
137
+
46
138
  function collectTaskPathReferences(task) {
47
139
  const paths = new Set();
48
140
  const body = task.body;
@@ -82,7 +174,7 @@ function collectTaskPathReferences(task) {
82
174
  * path out of the prose.
83
175
  * 3. **Object form** — `{ path: "<path>", assumption: "creates" | ... }`,
84
176
  * introduced by Story #2636 as the canonical declaration shape and
85
- * documented in `epic-plan-decompose-author/SKILL.md`. The path is
177
+ * documented in `lib/templates/decomposer-prompts.js`. The path is
86
178
  * trusted verbatim.
87
179
  *
88
180
  * Only `body.changes` (and `body.references`) is consulted —
@@ -100,15 +192,16 @@ function collectTaskChangesPaths(task) {
100
192
  // arrays before scanning. Without this, a string body causes the
101
193
  // object-form branch below to fall through on every item, leaving the
102
194
  // freshness gate blind to declared paths.
195
+ //
196
+ // Story #4541: a parse failure is NOT swallowed here. Swallowing it
197
+ // returned an empty whitelist, so a single malformed `## Changes` entry
198
+ // surfaced downstream as "files do not exist at main" naming the very
199
+ // paths the Story *had* declared — a misdiagnosis that cost two authoring
200
+ // round-trips. `assertStoryBodiesParse` runs before the freshness gate and
201
+ // owns that failure with a named error; the throw here is the same error
202
+ // for any caller that drives `validateAcFreshness` directly.
103
203
  if (typeof body === 'string' && body.trim().length > 0) {
104
- let parsed;
105
- try {
106
- parsed = parseStoryBody(body).body;
107
- } catch {
108
- // Unparseable body — no paths to whitelist; the freshness gate will
109
- // catch any real references in the text scan below.
110
- return paths;
111
- }
204
+ const parsed = parseStoryBodyOrThrow(task);
112
205
  for (const arrName of ['changes', 'references']) {
113
206
  const arr = parsed[arrName];
114
207
  if (!Array.isArray(arr)) continue;
@@ -307,11 +400,16 @@ const SUBJECT_PREFIX_RE = /Commit subject begins with ['"`]([^'"`]+):['"`]/g;
307
400
  * the form `baseline-refresh` is rejected because no Conventional-Commits
308
401
  * type starts with that token.
309
402
  *
310
- * Only `body.acceptance[]` is scanned; `body.goal` / `body.verify` /
403
+ * Only acceptance criteria are scanned; `body.goal` / `body.verify` /
311
404
  * `body.changes` are not commit-subject prescriptions by convention and
312
405
  * scanning them would surface false positives from prose that happens to
313
406
  * quote a forbidden prefix while explaining why it's forbidden.
314
407
  *
408
+ * Both authoring shapes are covered (Story #4541): the canonical top-level
409
+ * `acceptance[]` on a serialized string body, and the pre-serialize
410
+ * `body.acceptance[]` object shape. Scanning only the latter made the gate
411
+ * inert on every real plan.
412
+ *
315
413
  * @param {object} opts
316
414
  * @param {object[]} opts.tickets - Validated ticket hierarchy.
317
415
  * @throws {ValidationError} when one or more Story acceptance items
@@ -324,11 +422,7 @@ export function validateAcceptanceSubjectPrefix({ tickets }) {
324
422
  const violations = [];
325
423
  const stories = (tickets ?? []).filter((t) => t.type === 'story');
326
424
  for (const story of stories) {
327
- const body = story.body;
328
- if (body === null || typeof body !== 'object') continue;
329
- if (!Array.isArray(body.acceptance)) continue;
330
- for (const item of body.acceptance) {
331
- const line = String(item ?? '');
425
+ for (const line of resolveAcceptanceLines(story)) {
332
426
  // Reset the global regex between iterations.
333
427
  SUBJECT_PREFIX_RE.lastIndex = 0;
334
428
  let match = SUBJECT_PREFIX_RE.exec(line);
@@ -381,8 +475,8 @@ function renderMissLine({ slug, path }) {
381
475
  * The returned tickets array carries two extra non-array properties:
382
476
  * - `findings` — structured sizing findings (hard + soft) keyed by the
383
477
  * three-layer sizing model. The bounded re-decomposition loop in
384
- * `epic-plan-decompose` reads `findings.filter(f => f.severity === 'hard')`
385
- * to decide whether to re-prompt.
478
+ * `/plan` reads `findings.filter(f => f.severity === 'hard')` to decide
479
+ * whether to re-prompt.
386
480
  * - `errors` — human-readable strings, one per hard finding. Non-empty
387
481
  * `errors[]` is the AC-visible "block normalization" signal; the legacy
388
482
  * hierarchy/cycle/freshness checks continue to throw, so callers that
@@ -540,6 +634,14 @@ export function validateAndNormalizeTickets(tickets, opts = {}) {
540
634
 
541
635
  assertAcyclic(slugAdjacency);
542
636
 
637
+ // Story #4541 — refuse an unparseable Story body up front, with a named
638
+ // error pointing at the offending section + entry. Must precede both the
639
+ // subject-prefix scan and the freshness gate: each parses the body, and
640
+ // the freshness gate's net-new whitelist comes from `body.changes`, so a
641
+ // malformed body used to surface as a stale-path miss naming the paths the
642
+ // Story had legitimately declared.
643
+ assertStoryBodiesParse({ tickets });
644
+
543
645
  // Reject any Task acceptance item that prescribes a non-Conventional-Commits
544
646
  // subject prefix (e.g. legacy "Commit subject begins with 'baseline-refresh:'"
545
647
  // from pre-Epic-#2501 planner output). Runs before the freshness gate so
@@ -648,6 +750,7 @@ export function validateAndNormalizeTickets(tickets, opts = {}) {
648
750
 
649
751
  // Internal helpers exposed for unit tests; not part of the public surface.
650
752
  export const _internal = {
753
+ assertStoryBodiesParse,
651
754
  indexTicketsBySlug,
652
755
  assertAllTicketsAreStories,
653
756
  assertEveryStoryHasInlineContract,
@@ -357,7 +357,7 @@ async function processCascadeParentLocked(
357
357
  * unset so the module-level {@link Logger} is used.
358
358
  * @returns {Promise<{ cascadedTo: number[], failed: Array<{ parentId: number, error: string }> }>}
359
359
  */
360
- export async function cascadeCompletion(provider, ticketId, opts = {}) {
360
+ async function cascadeCompletion(provider, ticketId, opts = {}) {
361
361
  const ticket = await provider.getTicket(ticketId);
362
362
 
363
363
  // Determine if this ticket is agent::done
@@ -365,43 +365,14 @@ export async function cascadeCompletion(provider, ticketId, opts = {}) {
365
365
  return { cascadedTo: [], failed: [] };
366
366
  }
367
367
 
368
+ // Story #4545 — one strategy, not three. The `parent: #N` body footer and
369
+ // the native sub-issue link were both written by `createTicket`, the
370
+ // Epic-hierarchy write surface deleted in the same Story; with no writer,
371
+ // the body regex could only ever miss and the native lookup could only
372
+ // ever spend an API call to learn the same. The operator-settable `blocks`
373
+ // annotation is the one parent edge that can still exist.
368
374
  const { blocks: parentIds } = await provider.getTicketDependencies(ticketId);
369
-
370
- // Fallback: parse `parent: #NNN` from the body when `blocks` syntax isn't used (C-5).
371
- let parsedParents = parentIds;
372
- if (!parsedParents || parsedParents.length === 0) {
373
- const parentMatch = ticket.body
374
- ? [...ticket.body.matchAll(/parent:\s*#(\d+)/gi)]
375
- : [];
376
- parsedParents = parentMatch.map((m) => Number.parseInt(m[1], 10));
377
- }
378
-
379
- // Story #2982 — third fallback: GitHub's native Sub-Issues API. The
380
- // resume reconciler can strip the `parent: #N` orchestrator footer
381
- // from a Story body (see Issue 2 in #2982); without the body marker
382
- // the cascade silently returned `{ cascadedTo: [], failed: [] }` and
383
- // left intermediate parent tickets stranded OPEN. The native link is
384
- // independent of body text, so consult it when the first two
385
- // strategies came back empty.
386
- if (
387
- parsedParents.length === 0 &&
388
- typeof provider._getNativeParent === 'function' &&
389
- ticket.nodeId
390
- ) {
391
- try {
392
- const nativeParent = await provider._getNativeParent(
393
- ticket.nodeId,
394
- ticketId,
395
- );
396
- if (typeof nativeParent === 'number') {
397
- parsedParents = [nativeParent];
398
- }
399
- } catch (err) {
400
- Logger.warn(
401
- `[cascadeCompletion] native parent lookup failed for #${ticketId}: ${err.message}`,
402
- );
403
- }
404
- }
375
+ const parsedParents = Array.isArray(parentIds) ? parentIds : [];
405
376
 
406
377
  if (parsedParents.length === 0) {
407
378
  return { cascadedTo: [], failed: [] };
@@ -537,22 +508,18 @@ export async function cascadeParentState(provider, ticketId, opts = {}) {
537
508
  }
538
509
 
539
510
  /**
540
- * Resolve the parent issue ids for a ticket: native `blocks:` dependency
541
- * annotations first, then `parent: #NNN` body references as a fallback.
542
- * Mirrors the resolution path used by {@link cascadeCompletion}.
511
+ * Resolve the parent issue ids for a ticket from its `blocks:` dependency
512
+ * annotations. Mirrors the resolution path used by {@link cascadeCompletion}
513
+ * see there for why the `parent: #NNN` body fallback is gone (Story #4545).
543
514
  *
544
515
  * @param {import('../../ITicketingProvider.js').ITicketingProvider} provider
545
- * @param {object} ticket
516
+ * @param {object} _ticket
546
517
  * @param {number} ticketId
547
518
  * @returns {Promise<number[]>}
548
519
  */
549
- async function resolveParentIds(provider, ticket, ticketId) {
520
+ async function resolveParentIds(provider, _ticket, ticketId) {
550
521
  const { blocks: parentIds } = await provider.getTicketDependencies(ticketId);
551
- if (Array.isArray(parentIds) && parentIds.length > 0) return parentIds;
552
- const parentMatch = ticket?.body
553
- ? [...ticket.body.matchAll(/parent:\s*#(\d+)/gi)]
554
- : [];
555
- return parentMatch.map((m) => Number.parseInt(m[1], 10));
522
+ return Array.isArray(parentIds) ? parentIds : [];
556
523
  }
557
524
 
558
525
  /**