mandrel 2.0.0 → 2.2.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 (323) hide show
  1. package/.agents/README.md +59 -28
  2. package/.agents/agents/acceptance-critic.md +20 -9
  3. package/.agents/agents/story-worker.md +45 -48
  4. package/.agents/audit-checklists/performance.md +1 -1
  5. package/.agents/docs/SDLC.md +60 -46
  6. package/.agents/docs/agentrc-reference.json +8 -13
  7. package/.agents/docs/configuration.md +33 -57
  8. package/.agents/docs/execution-reference.md +39 -10
  9. package/.agents/docs/quality-gates.md +17 -19
  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/gherkin-standards.md +10 -0
  14. package/.agents/rules/git-conventions-reference.md +42 -51
  15. package/.agents/schemas/acceptance-eval-verdict.schema.json +2 -2
  16. package/.agents/schemas/agentrc.schema.json +35 -46
  17. package/.agents/schemas/audit-rules.json +59 -1
  18. package/.agents/schemas/audit-rules.schema.json +33 -1
  19. package/.agents/schemas/lifecycle/README.md +1 -2
  20. package/.agents/schemas/lifecycle/ledger-record.schema.json +1 -1
  21. package/.agents/schemas/lifecycle/merge.flip-failed.schema.json +33 -0
  22. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +1 -0
  23. package/.agents/schemas/lifecycle/story.merged.schema.json +1 -1
  24. package/.agents/schemas/signal-event.schema.json +3 -3
  25. package/.agents/schemas/story-deliver-terminal.schema.json +152 -0
  26. package/.agents/schemas/validation-evidence.schema.json +1 -1
  27. package/.agents/scripts/acceptance-eval.js +24 -68
  28. package/.agents/scripts/agents-bootstrap-github.js +1 -1
  29. package/.agents/scripts/bootstrap.js +3 -3
  30. package/.agents/scripts/check-dead-exports.js +43 -104
  31. package/.agents/scripts/check-doc-links.js +2 -2
  32. package/.agents/scripts/check-lifecycle-lint.js +1 -1
  33. package/.agents/scripts/check-workflow-cli-lint.js +91 -0
  34. package/.agents/scripts/deliver-recover.js +122 -0
  35. package/.agents/scripts/drain-pending-cleanup.js +1 -1
  36. package/.agents/scripts/evidence-gate.js +20 -50
  37. package/.agents/scripts/generate-skills-index.js +17 -1
  38. package/.agents/scripts/generate-workflows-doc.js +4 -4
  39. package/.agents/scripts/lib/ITicketingProvider.js +1 -19
  40. package/.agents/scripts/lib/audit-suite/selector.js +323 -23
  41. package/.agents/scripts/lib/baselines/kinds/maintainability.js +0 -11
  42. package/.agents/scripts/lib/bootstrap/ci-workflow-template.js +28 -33
  43. package/.agents/scripts/lib/bootstrap/manifest.js +8 -11
  44. package/.agents/scripts/lib/bootstrap/project-bootstrap.js +30 -53
  45. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +0 -2
  46. package/.agents/scripts/lib/checks/core-bare-clean.js +4 -1
  47. package/.agents/scripts/lib/checks/index.js +1 -1
  48. package/.agents/scripts/lib/checks/loop-health.js +12 -11
  49. package/.agents/scripts/lib/checks/state.js +17 -248
  50. package/.agents/scripts/lib/checks/story-init-not-backgrounded.js +3 -3
  51. package/.agents/scripts/lib/checks/subagent-agent-tool-required.js +3 -4
  52. package/.agents/scripts/lib/checks/worktree-bootstrap-env.js +2 -2
  53. package/.agents/scripts/lib/checks/worktree-residue-biome.js +3 -3
  54. package/.agents/scripts/lib/cli-args.js +23 -2
  55. package/.agents/scripts/lib/close-validation/gates.js +13 -13
  56. package/.agents/scripts/lib/close-validation/projections/inputs.js +7 -7
  57. package/.agents/scripts/lib/close-validation/projections/maintainability.js +12 -12
  58. package/.agents/scripts/lib/close-validation/runner.js +13 -21
  59. package/.agents/scripts/lib/close-validation/telemetry.js +17 -8
  60. package/.agents/scripts/lib/config/acceptance-eval.js +2 -2
  61. package/.agents/scripts/lib/config/delivery-routing.js +7 -6
  62. package/.agents/scripts/lib/config/explain.js +10 -16
  63. package/.agents/scripts/lib/config/github.js +7 -5
  64. package/.agents/scripts/lib/config/limits.js +15 -25
  65. package/.agents/scripts/lib/config/quality.js +11 -14
  66. package/.agents/scripts/lib/config/runners.js +8 -21
  67. package/.agents/scripts/lib/config/temp-paths.js +18 -56
  68. package/.agents/scripts/lib/config-settings-schema-delivery.js +34 -16
  69. package/.agents/scripts/lib/config-settings-schema-quality.js +9 -2
  70. package/.agents/scripts/lib/config-settings-schema.js +48 -22
  71. package/.agents/scripts/lib/dead-exports-knip.js +105 -0
  72. package/.agents/scripts/lib/dead-exports-mode.js +51 -0
  73. package/.agents/scripts/lib/duplicate-search.js +38 -7
  74. package/.agents/scripts/lib/findings/promote-finding.js +23 -14
  75. package/.agents/scripts/lib/format-generated-json.js +97 -0
  76. package/.agents/scripts/lib/framework-version.js +19 -189
  77. package/.agents/scripts/lib/gh-exec.js +8 -0
  78. package/.agents/scripts/lib/git-branch-lifecycle.js +0 -158
  79. package/.agents/scripts/lib/git-utils.js +0 -14
  80. package/.agents/scripts/lib/json-utils.js +1 -2
  81. package/.agents/scripts/lib/label-constants.js +0 -15
  82. package/.agents/scripts/lib/label-taxonomy.js +1 -12
  83. package/.agents/scripts/lib/observability/active-story-env.js +42 -163
  84. package/.agents/scripts/lib/observability/runtime-friction.js +243 -0
  85. package/.agents/scripts/lib/observability/signal-validator.js +4 -4
  86. package/.agents/scripts/lib/observability/signals-writer.js +6 -82
  87. package/.agents/scripts/lib/observability/source-classifier.js +2 -2
  88. package/.agents/scripts/lib/observability/tool-trace-hook.js +2 -12
  89. package/.agents/scripts/lib/orchestration/acceptance-clusters.js +1 -1
  90. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +2 -2
  91. package/.agents/scripts/lib/orchestration/ceremony-routing.js +43 -45
  92. package/.agents/scripts/lib/orchestration/change-set.js +103 -0
  93. package/.agents/scripts/lib/orchestration/code-review.js +70 -191
  94. package/.agents/scripts/lib/orchestration/consolidation-precondition.js +3 -3
  95. package/.agents/scripts/lib/orchestration/deliver-recover.js +328 -0
  96. package/.agents/scripts/lib/orchestration/detectors-phase.js +12 -6
  97. package/.agents/scripts/lib/orchestration/git-cleanup/phases/fast-forward.js +34 -0
  98. package/.agents/scripts/lib/orchestration/lease-guard-shared.js +3 -2
  99. package/.agents/scripts/lib/orchestration/lifecycle/emit-ledger-event.js +142 -0
  100. package/.agents/scripts/lib/orchestration/lifecycle/emit-loop-tick.js +9 -11
  101. package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-flip-failed.js +86 -0
  102. package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +37 -103
  103. package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +7 -3
  104. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +50 -85
  105. package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +3 -14
  106. package/.agents/scripts/lib/orchestration/merge-block-class.js +76 -20
  107. package/.agents/scripts/lib/orchestration/merge-poll.js +104 -0
  108. package/.agents/scripts/lib/orchestration/plan-context.js +116 -33
  109. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +26 -36
  110. package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +31 -22
  111. package/.agents/scripts/lib/orchestration/plan-metrics.js +38 -6
  112. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +16 -6
  113. package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +173 -25
  114. package/.agents/scripts/lib/orchestration/plan-persist/plan-context-source.js +116 -0
  115. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +280 -100
  116. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +472 -55
  117. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +21 -16
  118. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +509 -0
  119. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +230 -0
  120. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +41 -40
  121. package/.agents/scripts/lib/orchestration/planning/decomposer-context.js +1 -2
  122. package/.agents/scripts/lib/orchestration/planning/spec-authoring-grounding.js +1 -1
  123. package/.agents/scripts/lib/orchestration/resolve-stories.js +344 -0
  124. package/.agents/scripts/lib/orchestration/retro-proposals.js +7 -7
  125. package/.agents/scripts/lib/orchestration/review-depth.js +105 -40
  126. package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +3 -13
  127. package/.agents/scripts/lib/orchestration/review-providers/native.js +1 -154
  128. package/.agents/scripts/lib/orchestration/review-providers/review-depth.js +3 -2
  129. package/.agents/scripts/lib/orchestration/review-providers/security-review.js +1 -1
  130. package/.agents/scripts/lib/orchestration/review-providers/types.js +5 -4
  131. package/.agents/scripts/lib/orchestration/review-providers/ultrareview.js +1 -1
  132. package/.agents/scripts/lib/orchestration/run-epilogue.js +374 -16
  133. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +24 -0
  134. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +11 -9
  135. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +4 -4
  136. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +4 -13
  137. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +608 -152
  138. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +72 -30
  139. package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +305 -0
  140. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +1 -1
  141. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-block.js +12 -8
  142. package/.agents/scripts/lib/orchestration/single-story-close/phases/worktree-reap.js +37 -4
  143. package/.agents/scripts/lib/orchestration/single-story-close/phases/wrong-tree-guard.js +2 -2
  144. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +264 -43
  145. package/.agents/scripts/lib/orchestration/single-story-lease-guard.js +1 -1
  146. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +10 -10
  147. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +104 -279
  148. package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +191 -0
  149. package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +120 -0
  150. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +360 -0
  151. package/.agents/scripts/lib/orchestration/story-follow-ups.js +75 -14
  152. package/.agents/scripts/lib/orchestration/story-init-remote.js +12 -8
  153. package/.agents/scripts/lib/orchestration/story-plan-state.js +14 -29
  154. package/.agents/scripts/lib/orchestration/task-body-validator.js +52 -7
  155. package/.agents/scripts/lib/orchestration/ticket-lease.js +27 -74
  156. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +119 -14
  157. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +3 -4
  158. package/.agents/scripts/lib/orchestration/ticket-validator.js +121 -18
  159. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +14 -47
  160. package/.agents/scripts/lib/orchestration/ticketing/reads.js +19 -32
  161. package/.agents/scripts/lib/orchestration/ticketing/transition.js +61 -1
  162. package/.agents/scripts/lib/orchestration/ticketing.js +0 -1
  163. package/.agents/scripts/lib/plan-phase-cleanup.js +12 -14
  164. package/.agents/scripts/lib/planning-corpus.js +12 -286
  165. package/.agents/scripts/lib/preflight-runner.js +2 -2
  166. package/.agents/scripts/lib/qa/qa-context-hydrator.js +5 -5
  167. package/.agents/scripts/lib/signals/index.js +4 -17
  168. package/.agents/scripts/lib/signals/read.js +35 -35
  169. package/.agents/scripts/lib/signals/schema.js +8 -11
  170. package/.agents/scripts/lib/signals/span-tree.js +7 -7
  171. package/.agents/scripts/lib/signals/write.js +0 -1
  172. package/.agents/scripts/lib/single-story/story-merged-notify.js +13 -2
  173. package/.agents/scripts/lib/skills/parse-skill.js +16 -3
  174. package/.agents/scripts/lib/story-adjacency.js +8 -7
  175. package/.agents/scripts/lib/story-body/story-body.js +81 -13
  176. package/.agents/scripts/lib/templates/decomposer-prompts.js +15 -16
  177. package/.agents/scripts/lib/test-env.js +14 -1
  178. package/.agents/scripts/lib/test-tiers.js +0 -3
  179. package/.agents/scripts/lib/ticket-body-sections.js +0 -14
  180. package/.agents/scripts/lib/validation-evidence.js +31 -59
  181. package/.agents/scripts/lib/wave-runner/live-probe.js +315 -0
  182. package/.agents/scripts/lib/wave-runner/ready-set.js +32 -6
  183. package/.agents/scripts/lib/worktree/lifecycle/pending-cleanup.js +1 -1
  184. package/.agents/scripts/lib/worktree/lifecycle/reap.js +68 -19
  185. package/.agents/scripts/lib/worktree/lifecycle-manager.js +1 -2
  186. package/.agents/scripts/plan-context.js +38 -7
  187. package/.agents/scripts/plan-critics.js +203 -0
  188. package/.agents/scripts/plan-persist.js +145 -35
  189. package/.agents/scripts/plan-run-epilogue.js +83 -38
  190. package/.agents/scripts/post-structured-comment.js +0 -38
  191. package/.agents/scripts/pr-watch-with-update.js +43 -22
  192. package/.agents/scripts/providers/github/compose.js +0 -1
  193. package/.agents/scripts/providers/github/errors.js +0 -19
  194. package/.agents/scripts/providers/github/issues.js +1 -11
  195. package/.agents/scripts/providers/github/mappers.js +5 -0
  196. package/.agents/scripts/providers/github/sub-issues.js +0 -47
  197. package/.agents/scripts/providers/github/tickets.js +33 -153
  198. package/.agents/scripts/providers/github.js +17 -6
  199. package/.agents/scripts/quality-preview.js +13 -6
  200. package/.agents/scripts/resolve-stories.js +236 -0
  201. package/.agents/scripts/run-coverage.js +4 -1
  202. package/.agents/scripts/run-lint.js +2 -2
  203. package/.agents/scripts/run-verify.js +31 -2
  204. package/.agents/scripts/signals-view.js +9 -10
  205. package/.agents/scripts/single-story-close.js +173 -18
  206. package/.agents/scripts/single-story-confirm-merge.js +288 -15
  207. package/.agents/scripts/single-story-init.js +6 -10
  208. package/.agents/scripts/stories-wave-tick.js +380 -53
  209. package/.agents/scripts/story-plan.js +3 -3
  210. package/.agents/scripts/update-ticket-state.js +8 -50
  211. package/.agents/skills/core/code-review-and-quality/SKILL.md +28 -450
  212. package/.agents/skills/core/code-review-and-quality/reference.md +458 -0
  213. package/.agents/skills/core/debugging-and-error-recovery/SKILL.md +22 -315
  214. package/.agents/skills/core/debugging-and-error-recovery/reference.md +323 -0
  215. package/.agents/skills/core/diagnose-friction/SKILL.md +14 -18
  216. package/.agents/skills/core/documentation-and-adrs/SKILL.md +25 -397
  217. package/.agents/skills/core/documentation-and-adrs/reference.md +403 -0
  218. package/.agents/skills/core/gates-and-baselines/SKILL.md +12 -12
  219. package/.agents/skills/core/idea-refinement/SKILL.md +3 -3
  220. package/.agents/skills/core/scope-triage/SKILL.md +3 -0
  221. package/.agents/skills/core/security-and-hardening/SKILL.md +22 -367
  222. package/.agents/skills/core/security-and-hardening/reference.md +375 -0
  223. package/.agents/skills/skills.index.json +2 -12
  224. package/.agents/skills/stack/qa/playwright-bdd/SKILL.md +2 -4
  225. package/.agents/skills/stack/qa/qa-explore-driving/SKILL.md +1 -1
  226. package/.agents/skills/stack/qa/qa-harness/SKILL.md +1 -3
  227. package/.agents/workflows/audit-architecture.md +3 -4
  228. package/.agents/workflows/audit-clean-code.md +4 -4
  229. package/.agents/workflows/audit-documentation.md +4 -5
  230. package/.agents/workflows/audit-lighthouse.md +8 -0
  231. package/.agents/workflows/audit-navigability.md +10 -0
  232. package/.agents/workflows/audit-performance.md +2 -3
  233. package/.agents/workflows/audit-quality.md +8 -9
  234. package/.agents/workflows/audit-security.md +1 -2
  235. package/.agents/workflows/audit-seo.md +10 -0
  236. package/.agents/workflows/audit-ux-ui.md +7 -0
  237. package/.agents/workflows/deliver.md +133 -45
  238. package/.agents/workflows/git-cleanup.md +2 -2
  239. package/.agents/workflows/git-deliver.md +1 -1
  240. package/.agents/workflows/helpers/acceptance-self-eval.md +34 -17
  241. package/.agents/workflows/helpers/code-quality-guardrails.md +15 -12
  242. package/.agents/workflows/helpers/code-review.md +14 -12
  243. package/.agents/workflows/helpers/deliver-story-reference.md +73 -32
  244. package/.agents/workflows/helpers/deliver-story.md +209 -118
  245. package/.agents/workflows/helpers/parallel-tooling.md +2 -2
  246. package/.agents/workflows/helpers/worktree-lifecycle.md +28 -32
  247. package/.agents/workflows/plan.md +239 -19
  248. package/.agents/workflows/qa-assist.md +6 -6
  249. package/.agents/workflows/qa-explore.md +3 -3
  250. package/.agents/workflows/qa-run.md +1 -5
  251. package/bin/mandrel.js +12 -1
  252. package/docs/CHANGELOG.md +62 -0
  253. package/lib/cli/registry.js +262 -19
  254. package/lib/cli/sync-agents.js +157 -0
  255. package/lib/cli/sync-commands.js +115 -6
  256. package/lib/cli/sync.js +168 -6
  257. package/lib/cli/update.js +105 -8
  258. package/lib/cli/version-helpers.js +131 -0
  259. package/lib/migrations/README.md +7 -5
  260. package/lib/migrations/index.js +17 -9
  261. package/lib/migrations/steps/2.1.0-retire-mi-drop-knobs.js +100 -0
  262. package/lib/migrations/steps/2.1.0-retire-verify-concurrency-cap.js +101 -0
  263. package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +154 -0
  264. package/package.json +2 -2
  265. package/.agents/schemas/epic-perf-report.schema.json +0 -89
  266. package/.agents/schemas/lifecycle/acceptance.reconcile.failed.schema.json +0 -13
  267. package/.agents/schemas/lifecycle/acceptance.reconcile.ok.schema.json +0 -13
  268. package/.agents/schemas/lifecycle/acceptance.reconcile.skipped.schema.json +0 -13
  269. package/.agents/schemas/lifecycle/acceptance.reconcile.start.schema.json +0 -12
  270. package/.agents/schemas/lifecycle/acceptance.reconcile.waived.schema.json +0 -13
  271. package/.agents/schemas/lifecycle/epic.automerge.end.schema.json +0 -15
  272. package/.agents/schemas/lifecycle/epic.automerge.start.schema.json +0 -13
  273. package/.agents/schemas/lifecycle/epic.blocked.schema.json +0 -13
  274. package/.agents/schemas/lifecycle/epic.cleanup.end.schema.json +0 -12
  275. package/.agents/schemas/lifecycle/epic.cleanup.start.schema.json +0 -12
  276. package/.agents/schemas/lifecycle/epic.close.end.schema.json +0 -12
  277. package/.agents/schemas/lifecycle/epic.complete.schema.json +0 -13
  278. package/.agents/schemas/lifecycle/epic.finalize.end.schema.json +0 -13
  279. package/.agents/schemas/lifecycle/epic.finalize.start.schema.json +0 -12
  280. package/.agents/schemas/lifecycle/epic.merge.armed.schema.json +0 -13
  281. package/.agents/schemas/lifecycle/epic.merge.blocked.schema.json +0 -14
  282. package/.agents/schemas/lifecycle/epic.merge.confirmed.schema.json +0 -17
  283. package/.agents/schemas/lifecycle/epic.merge.ready.schema.json +0 -15
  284. package/.agents/schemas/lifecycle/epic.plan.end.schema.json +0 -18
  285. package/.agents/schemas/lifecycle/epic.plan.start.schema.json +0 -12
  286. package/.agents/schemas/lifecycle/epic.snapshot.end.schema.json +0 -16
  287. package/.agents/schemas/lifecycle/epic.snapshot.start.schema.json +0 -12
  288. package/.agents/schemas/lifecycle/epic.watch.end.schema.json +0 -29
  289. package/.agents/schemas/lifecycle/epic.watch.start.schema.json +0 -16
  290. package/.agents/schemas/lifecycle/story.heartbeat.schema.json +0 -20
  291. package/.agents/schemas/risk-verdict.schema.json +0 -53
  292. package/.agents/schemas/story-perf-summary.schema.json +0 -73
  293. package/.agents/scripts/analyze-execution.js +0 -444
  294. package/.agents/scripts/check-prepush-recovery.js +0 -90
  295. package/.agents/scripts/lib/git-merge-orchestrator.js +0 -261
  296. package/.agents/scripts/lib/observability/baseline-refresh-rate.js +0 -221
  297. package/.agents/scripts/lib/observability/hook-heartbeat.js +0 -187
  298. package/.agents/scripts/lib/observability/perf-aggregator.js +0 -813
  299. package/.agents/scripts/lib/observability/perf-report-readers.js +0 -328
  300. package/.agents/scripts/lib/observability/perf-report-render.js +0 -182
  301. package/.agents/scripts/lib/orchestration/audit-lens-routing.js +0 -128
  302. package/.agents/scripts/lib/orchestration/bookkeeping-outbox.js +0 -273
  303. package/.agents/scripts/lib/orchestration/error-journal.js +0 -139
  304. package/.agents/scripts/lib/orchestration/lifecycle/emit-story-heartbeat.js +0 -155
  305. package/.agents/scripts/lib/orchestration/lifecycle/ledger-diff.js +0 -140
  306. package/.agents/scripts/lib/orchestration/lifecycle/listeners/merge-watcher.js +0 -665
  307. package/.agents/scripts/lib/orchestration/plan-review-routing.js +0 -63
  308. package/.agents/scripts/lib/orchestration/planning/risk-verdict.js +0 -104
  309. package/.agents/scripts/lib/orchestration/planning-context-budget.js +0 -213
  310. package/.agents/scripts/lib/orchestration/planning-risk.js +0 -194
  311. package/.agents/scripts/lib/orchestration/post-merge/phases/branch-cleanup.js +0 -56
  312. package/.agents/scripts/lib/orchestration/post-merge/phases/dashboard-refresh.js +0 -21
  313. package/.agents/scripts/lib/orchestration/post-merge/phases/notification.js +0 -78
  314. package/.agents/scripts/lib/orchestration/post-merge/phases/temp-cleanup.js +0 -68
  315. package/.agents/scripts/lib/orchestration/post-merge/phases/ticket-closure.js +0 -118
  316. package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +0 -397
  317. package/.agents/scripts/lib/orchestration/preflight-cache.js +0 -187
  318. package/.agents/scripts/lib/orchestration/resolve-plan-run.js +0 -155
  319. package/.agents/scripts/lib/orchestration/retro-perf-heuristics.js +0 -275
  320. package/.agents/scripts/lib/orchestration/story-progress/story-run-progress-writer.js +0 -400
  321. package/.agents/scripts/lib/single-story/confirm-merge-follow-ups.js +0 -36
  322. package/.agents/scripts/resolve-plan-run.js +0 -117
  323. package/.agents/skills/core/analyze-execution/SKILL.md +0 -101
@@ -1,813 +0,0 @@
1
- /**
2
- * Performance signal aggregator (Epic #1030 / Story #1123; rewrite under
3
- * Epic #1181 / Story #1438 / Task #1460).
4
- *
5
- * Pure functions that turn the per-Story `signals.ndjson` stream into the
6
- * structured payloads posted by `analyze-execution.js`:
7
- *
8
- * - `computeStoryPerfSummary(events, opts)` → `<!-- structured:story-perf-summary -->`
9
- * - `computeEpicPerfReport(perStorySummaries, opts)` → `<!-- structured:epic-perf-report -->`
10
- *
11
- * Both take a materialised event iterable; the caller (`analyze-execution.js`)
12
- * owns NDJSON ingestion via `lib/signals/read.js`. (The former
13
- * streaming store-reading variants were retired in the Epic #4406
14
- * signal-contract cutover — they had no callers.)
15
- *
16
- * Schemas:
17
- * - `.agents/schemas/story-perf-summary.schema.json`
18
- * - `.agents/schemas/epic-perf-report.schema.json`
19
- *
20
- * Robustness contract:
21
- * - Both helpers tolerate empty / partial input. Empty streams produce a
22
- * well-formed payload with zeroed counters and empty arrays so the
23
- * analyzer can still upsert a comment without throwing.
24
- * - Malformed events (missing `kind`, non-object payload) are silently
25
- * skipped; the caller is responsible for reading them off the wire and
26
- * deciding whether to log. The aggregator never throws on bad data.
27
- * - Numeric fields are floored to non-negative integers so the schemas
28
- * (`integer`, `minimum: 0`) hold by construction.
29
- *
30
- * NDJSON ingestion discipline (Epic #1181):
31
- * - Field-name literals for event `kind` come from
32
- * `lib/signals/schema.js` so writer ↔ reader names stay in lockstep.
33
- * - All file I/O for `signals.ndjson` goes through `lib/signals/read.js`
34
- * (no direct `readFileSync` / `createReadStream` on signals.ndjson in
35
- * this module). A grep gate in `tests/lib/checks/` enforces this on
36
- * CI.
37
- */
38
-
39
- import { isObject } from '../json-utils.js';
40
- import { EVENT_KINDS } from '../signals/schema.js';
41
-
42
- const FRICTION_KIND = EVENT_KINDS.FRICTION;
43
- const HOTSPOT_KIND = EVENT_KINDS.HOTSPOT;
44
- const REWORK_KIND = EVENT_KINDS.REWORK;
45
- const RETRY_KIND = EVENT_KINDS.RETRY;
46
- const SIGNAL_COUNT_KINDS = Object.freeze([
47
- EVENT_KINDS.FRICTION,
48
- EVENT_KINDS.HOTSPOT,
49
- EVENT_KINDS.REWORK,
50
- EVENT_KINDS.CHURN,
51
- EVENT_KINDS.IDLE,
52
- EVENT_KINDS.RETRY,
53
- ]);
54
-
55
- function nonNegativeInt(v) {
56
- const n = Number(v);
57
- if (!Number.isFinite(n) || n < 0) return 0;
58
- return Math.floor(n);
59
- }
60
-
61
- function nonNegativeNumber(v) {
62
- const n = Number(v);
63
- if (!Number.isFinite(n) || n < 0) return 0;
64
- return n;
65
- }
66
-
67
- /**
68
- * Pull friction-by-category counts off a list of NDJSON events. Keys are
69
- * the **top-level** `category` strings (Epic #4406 canonical shape); a
70
- * record with no top-level category buckets under `Unknown`. Reading the
71
- * top-level key (not `details.category`) is what un-zeroes the report —
72
- * every writer emits `category` at the envelope top level.
73
- *
74
- * @param {Iterable<object>} events
75
- * @returns {Object<string, number>}
76
- */
77
- function frictionByCategory(events) {
78
- const out = {};
79
- for (const evt of events) {
80
- if (!isObject(evt) || evt.kind !== FRICTION_KIND) continue;
81
- const category =
82
- typeof evt.category === 'string' && evt.category.length > 0
83
- ? evt.category
84
- : 'Unknown';
85
- out[category] = (out[category] ?? 0) + 1;
86
- }
87
- return out;
88
- }
89
-
90
- /**
91
- * Build the `topSlowPhasesVsBaseline` array. We accept hotspot signals
92
- * carrying `{ phase, elapsedMs, baselineP95Ms, ratio }` in `details` and
93
- * surface them sorted by ratio descending. The hotspot detector is a
94
- * future Epic-#1030 Story; until it lands the input list is empty and
95
- * this returns `[]`.
96
- *
97
- * @param {Iterable<object>} events
98
- * @param {{ limit?: number }} [opts]
99
- * @returns {Array<{phase: string, elapsedMs: number, baselineP95Ms: number, ratio: number}>}
100
- */
101
- function topSlowPhasesVsBaseline(events, opts = {}) {
102
- const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : 5;
103
- const rows = [];
104
- for (const evt of events) {
105
- if (!isObject(evt) || evt.kind !== HOTSPOT_KIND) continue;
106
- const d = isObject(evt.details) ? evt.details : {};
107
- const phase =
108
- typeof evt.phase === 'string' && evt.phase.length > 0
109
- ? evt.phase
110
- : typeof d.phase === 'string' && d.phase.length > 0
111
- ? d.phase
112
- : null;
113
- if (!phase) continue;
114
- rows.push({
115
- phase,
116
- elapsedMs: nonNegativeInt(d.elapsedMs),
117
- baselineP95Ms: nonNegativeInt(d.baselineP95Ms),
118
- ratio: nonNegativeNumber(d.ratio),
119
- });
120
- }
121
- rows.sort((a, b) => b.ratio - a.ratio);
122
- return rows.slice(0, limit);
123
- }
124
-
125
- /**
126
- * Build the `reworkScore` object: `{ filesEditedBeyondThreshold, topPath?,
127
- * topPathEdits? }`. We aggregate `kind: 'rework'` signals whose details
128
- * carry a `targetHash` and an `editCount` — the exact keys
129
- * `detectors/rework.js` emits (Epic #4406 canonical shape). `topPath` is
130
- * the offending `targetHash` (a sha256; the raw path never reaches the
131
- * aggregator by the privacy contract). When the input has no rework
132
- * signals we return the zero-shape: `{ filesEditedBeyondThreshold: 0 }`.
133
- *
134
- * @param {Iterable<object>} events
135
- * @returns {{ filesEditedBeyondThreshold: number, topPath?: string|null, topPathEdits?: number|null }}
136
- */
137
- function reworkScore(events) {
138
- const editsByPath = new Map();
139
- for (const evt of events) {
140
- if (!isObject(evt) || evt.kind !== REWORK_KIND) continue;
141
- const d = isObject(evt.details) ? evt.details : {};
142
- const p =
143
- typeof d.targetHash === 'string' && d.targetHash.length > 0
144
- ? d.targetHash
145
- : null;
146
- if (!p) continue;
147
- const edits = nonNegativeInt(d.editCount);
148
- editsByPath.set(p, Math.max(editsByPath.get(p) ?? 0, edits));
149
- }
150
- if (editsByPath.size === 0) {
151
- return { filesEditedBeyondThreshold: 0 };
152
- }
153
- let topPath = null;
154
- let topPathEdits = 0;
155
- for (const [p, n] of editsByPath) {
156
- if (n > topPathEdits) {
157
- topPath = p;
158
- topPathEdits = n;
159
- }
160
- }
161
- return {
162
- filesEditedBeyondThreshold: editsByPath.size,
163
- topPath,
164
- topPathEdits,
165
- };
166
- }
167
-
168
- /**
169
- * Build the `retryDensity` object: `{ retries, uniqueCommands }`. Sums
170
- * `kind: 'retry'` signals; `uniqueCommands` is the hash-cardinality of the
171
- * distinct `details.commandHash` values observed — the exact key
172
- * `detectors/retry.js` emits (Epic #4406 canonical shape). The raw command
173
- * never reaches the aggregator by the privacy contract, so we count
174
- * distinct hashes. Zero-shape on empty input.
175
- *
176
- * @param {Iterable<object>} events
177
- * @returns {{ retries: number, uniqueCommands: number }}
178
- */
179
- function retryDensity(events) {
180
- let retries = 0;
181
- const commandHashes = new Set();
182
- for (const evt of events) {
183
- if (!isObject(evt) || evt.kind !== RETRY_KIND) continue;
184
- const d = isObject(evt.details) ? evt.details : {};
185
- retries += 1;
186
- if (typeof d.commandHash === 'string' && d.commandHash.length > 0) {
187
- commandHashes.add(d.commandHash);
188
- }
189
- }
190
- return { retries, uniqueCommands: commandHashes.size };
191
- }
192
-
193
- /**
194
- * Convert a phase-timer summary `{ phases: [{ name, elapsedMs }, ...] }`
195
- * into the flat `{ <name>: <ms> }` map the schema wants. Last entry wins
196
- * if a phase appears twice (mark/finish boundaries).
197
- *
198
- * @param {{ phases?: Array<{ name: string, elapsedMs: number }> } | null | undefined} timing
199
- * @returns {Object<string, number>}
200
- */
201
- function phaseTimingsMs(timing) {
202
- if (!isObject(timing) || !Array.isArray(timing.phases)) return {};
203
- const out = {};
204
- for (const p of timing.phases) {
205
- if (!isObject(p)) continue;
206
- if (typeof p.name !== 'string' || p.name.length === 0) continue;
207
- out[p.name] = nonNegativeInt(p.elapsedMs);
208
- }
209
- return out;
210
- }
211
-
212
- /**
213
- * Compute the StoryPerfSummary payload from a list of NDJSON events
214
- * sampled out of `temp/epic-<eid>/stories/story-<sid>/signals.ndjson` plus an
215
- * optional phase-timer summary.
216
- *
217
- * @param {Iterable<object>} events
218
- * @param {{ storyId: number, epicId: number, closedAt?: string, phaseTiming?: object|null }} opts
219
- * @returns {object} StoryPerfSummary payload (schema: story-perf-summary)
220
- */
221
- export function computeStoryPerfSummary(events, opts) {
222
- if (!isObject(opts)) {
223
- throw new TypeError('computeStoryPerfSummary: opts is required');
224
- }
225
- const storyId = Number(opts.storyId);
226
- const epicId = Number(opts.epicId);
227
- if (!Number.isInteger(storyId) || storyId < 1) {
228
- throw new RangeError(
229
- `computeStoryPerfSummary: storyId must be a positive integer (got ${opts.storyId})`,
230
- );
231
- }
232
- if (!Number.isInteger(epicId) || epicId < 1) {
233
- throw new RangeError(
234
- `computeStoryPerfSummary: epicId must be a positive integer (got ${opts.epicId})`,
235
- );
236
- }
237
- const closedAt =
238
- typeof opts.closedAt === 'string' && opts.closedAt.length > 0
239
- ? opts.closedAt
240
- : new Date().toISOString();
241
-
242
- // Materialise the iterable so each helper can scan independently.
243
- const evtArr = [];
244
- for (const e of events ?? []) {
245
- if (isObject(e) && typeof e.kind === 'string') evtArr.push(e);
246
- }
247
-
248
- return {
249
- kind: 'story-perf-summary',
250
- storyId,
251
- epicId,
252
- closedAt,
253
- frictionByCategory: frictionByCategory(evtArr),
254
- phaseTimingsMs: phaseTimingsMs(opts.phaseTiming),
255
- topSlowPhasesVsBaseline: topSlowPhasesVsBaseline(evtArr),
256
- reworkScore: reworkScore(evtArr),
257
- retryDensity: retryDensity(evtArr),
258
- };
259
- }
260
-
261
- /**
262
- * Compute the EpicPerfReport payload from a list of per-Story summaries
263
- * (each shaped like `computeStoryPerfSummary`'s return value) plus an
264
- * optional list of raw events for signal-count rollup.
265
- *
266
- * `signalCounts` rolls up across **events**, not summaries — a Story's
267
- * `frictionByCategory` only carries friction (the named slice the schema
268
- * surfaces), but the Epic-level rollup wants every kind. When `opts.events`
269
- * is absent we fall back to summing each summary's friction count and
270
- * leave the other kinds at 0.
271
- *
272
- * @param {Iterable<object>} perStorySummaries
273
- * @param {{ epicId: number, generatedAt?: string, events?: Iterable<object>, waveParallelism?: Array<object>, topHotspots?: Array<object> }} opts
274
- * @returns {object} EpicPerfReport payload (schema: epic-perf-report)
275
- */
276
- /**
277
- * Predicate / collector: walk a `perStorySummaries` iterable and emit
278
- * only the entries whose `kind === 'story-perf-summary'`. Extracted from
279
- * `computeEpicPerfReport` so the input-validation cascade is independently
280
- * testable and the parent stays straight-line. Returns `[]` for nullish
281
- * inputs, which matches the parent's prior behaviour.
282
- *
283
- * @param {Iterable<object>|null|undefined} perStorySummaries
284
- * @returns {object[]}
285
- */
286
- export function collectValidStorySamples(perStorySummaries) {
287
- const out = [];
288
- if (!perStorySummaries) return out;
289
- for (const s of perStorySummaries) {
290
- if (isObject(s) && s.kind === 'story-perf-summary') out.push(s);
291
- }
292
- return out;
293
- }
294
-
295
- /**
296
- * Build the `signalCounts` block. When `events` is supplied we roll up
297
- * across every kind in `SIGNAL_COUNT_KINDS`; otherwise we sum the
298
- * per-Story friction counts so the legacy summary-only path keeps the
299
- * same friction total.
300
- *
301
- * @param {Iterable<object>|null|undefined} events
302
- * @param {object[]} summaries
303
- * @returns {{friction: number, hotspot: number, rework: number, churn: number, idle: number, retry: number}}
304
- */
305
- function buildSignalCounts(events, summaries) {
306
- const counts = {
307
- friction: 0,
308
- hotspot: 0,
309
- rework: 0,
310
- churn: 0,
311
- idle: 0,
312
- retry: 0,
313
- };
314
- if (events) {
315
- for (const evt of events) {
316
- if (!isObject(evt) || typeof evt.kind !== 'string') continue;
317
- if (SIGNAL_COUNT_KINDS.includes(evt.kind)) {
318
- counts[evt.kind] += 1;
319
- }
320
- }
321
- return counts;
322
- }
323
- for (const s of summaries) {
324
- if (!isObject(s.frictionByCategory)) continue;
325
- for (const v of Object.values(s.frictionByCategory)) {
326
- counts.friction += nonNegativeInt(v);
327
- }
328
- }
329
- return counts;
330
- }
331
-
332
- /**
333
- * Aggregate the `topHotspots` block from per-Story samples: group each
334
- * story's `topSlowPhasesVsBaseline` rows by phase, count occurrences,
335
- * average the ratio, then sort by `occurrences desc, avgRatio desc` and
336
- * cap at 5. Extracted from `computeEpicPerfReport` so the parent stays
337
- * straight-line; callers that pass `opts.topHotspots` skip this helper
338
- * entirely.
339
- *
340
- * @param {object[]} summaries
341
- * @returns {Array<{phase: string, occurrences: number, avgRatio: number}>}
342
- */
343
- function aggregateTopHotspots(summaries) {
344
- const acc = new Map();
345
- for (const s of summaries) {
346
- const arr = Array.isArray(s.topSlowPhasesVsBaseline)
347
- ? s.topSlowPhasesVsBaseline
348
- : [];
349
- for (const row of arr) {
350
- if (!isObject(row) || typeof row.phase !== 'string') continue;
351
- const rec = acc.get(row.phase) ?? {
352
- phase: row.phase,
353
- occurrences: 0,
354
- ratioSum: 0,
355
- };
356
- rec.occurrences += 1;
357
- rec.ratioSum += nonNegativeNumber(row.ratio);
358
- acc.set(row.phase, rec);
359
- }
360
- }
361
- return [...acc.values()]
362
- .map((r) => ({
363
- phase: r.phase,
364
- occurrences: r.occurrences,
365
- avgRatio: r.occurrences > 0 ? r.ratioSum / r.occurrences : 0,
366
- }))
367
- .sort((a, b) => b.occurrences - a.occurrences || b.avgRatio - a.avgRatio)
368
- .slice(0, 5);
369
- }
370
-
371
- /**
372
- * Default verify-concurrency cap when the caller does not override it.
373
- * Mirrors the default for `delivery.deliverRunner.verifyConcurrencyCap`
374
- * in `.agentrc.json` (Epic #3019 Tech Spec §1.4).
375
- */
376
- const DEFAULT_VERIFY_CONCURRENCY_CAP = 4;
377
-
378
- /**
379
- * Default wave-execution concurrency cap used when the caller does not
380
- * supply `concurrencyCap` to {@link computeWaveParallelismRows}. The
381
- * project default (`delivery.deliverRunner.concurrencyCap`) is 2 today;
382
- * the value here is the safe fallback for offline / test contexts.
383
- */
384
- const DEFAULT_WAVE_CONCURRENCY_CAP = 2;
385
-
386
- function tsOf(evt) {
387
- return evt?.ts ?? null;
388
- }
389
-
390
- function tsToMs(ts) {
391
- if (typeof ts !== 'string') return null;
392
- const n = Date.parse(ts);
393
- return Number.isFinite(n) ? n : null;
394
- }
395
-
396
- function storyIdOf(evt) {
397
- const n = Number(evt?.storyId);
398
- return Number.isInteger(n) && n > 0 ? n : null;
399
- }
400
-
401
- /**
402
- * Materialise an event iterable into an array of `{ evt, ms }` records,
403
- * parsing each event's timestamp **exactly once** (Story #3343). Events
404
- * with a non-string `kind` are dropped (matching the prior inline guard);
405
- * the `ms` field is `null` when the timestamp is missing or unparseable so
406
- * downstream passes can skip it without re-parsing.
407
- *
408
- * @param {Iterable<object>} events
409
- * @returns {Array<{ evt: object, ms: number|null }>}
410
- */
411
- function materialiseTimedEvents(events) {
412
- const out = [];
413
- for (const evt of events ?? []) {
414
- if (isObject(evt) && typeof evt.kind === 'string') {
415
- out.push({ evt, ms: tsToMs(tsOf(evt)) });
416
- }
417
- }
418
- return out;
419
- }
420
-
421
- /**
422
- * Index Story state-transition windows from pre-timed events: first
423
- * `agent::executing` → last terminal (`agent::done` | `agent::blocked` |
424
- * `agent::failed`). Reuses the per-event `ms` parsed by
425
- * {@link materialiseTimedEvents}. Extracted from
426
- * {@link computeWaveParallelismRows} (Story #3343).
427
- *
428
- * @param {Array<{ evt: object, ms: number|null }>} timedEvents
429
- * @returns {Map<number, { startMs: number|null, endMs: number|null }>}
430
- */
431
- function indexStoryWindows(timedEvents) {
432
- const storyWindows = new Map();
433
- for (const { evt, ms } of timedEvents) {
434
- if (evt.kind !== 'state-transition') continue;
435
- const sid = storyIdOf(evt);
436
- if (sid == null) continue;
437
- if (ms == null) continue;
438
- const to =
439
- (isObject(evt.details) && evt.details.to) ?? evt.to ?? evt.toState;
440
- const rec = storyWindows.get(sid) ?? { startMs: null, endMs: null };
441
- if (to === 'agent::executing') {
442
- if (rec.startMs == null || ms < rec.startMs) rec.startMs = ms;
443
- } else if (
444
- to === 'agent::done' ||
445
- to === 'agent::blocked' ||
446
- to === 'agent::failed'
447
- ) {
448
- if (rec.endMs == null || ms > rec.endMs) rec.endMs = ms;
449
- }
450
- storyWindows.set(sid, rec);
451
- }
452
- return storyWindows;
453
- }
454
-
455
- /**
456
- * Bucket `wave-start` / `wave-complete` events by index from pre-timed
457
- * events. Reuses the per-event `ms` parsed by
458
- * {@link materialiseTimedEvents}. Extracted from
459
- * {@link computeWaveParallelismRows} (Story #3343).
460
- *
461
- * @param {Array<{ evt: object, ms: number|null }>} timedEvents
462
- * @returns {Map<number, { startMs: number|null, endMs: number|null, stories: number[] }>}
463
- */
464
- function bucketWaves(timedEvents) {
465
- const waves = new Map();
466
- for (const { evt, ms } of timedEvents) {
467
- if (ms == null) continue;
468
- if (evt.kind === 'wave-start') {
469
- const idx = Number(evt.index);
470
- if (!Number.isInteger(idx) || idx < 0) continue;
471
- const storiesField = Array.isArray(evt.stories) ? evt.stories : [];
472
- const storyIds = storiesField
473
- .map((s) => {
474
- const n = Number(isObject(s) ? (s.id ?? s.storyId) : s);
475
- return Number.isInteger(n) && n > 0 ? n : null;
476
- })
477
- .filter((n) => n != null);
478
- const rec = waves.get(idx) ?? {
479
- startMs: null,
480
- endMs: null,
481
- stories: [],
482
- };
483
- if (rec.startMs == null || ms < rec.startMs) rec.startMs = ms;
484
- rec.stories = storyIds;
485
- waves.set(idx, rec);
486
- } else if (evt.kind === 'wave-complete') {
487
- const idx = Number(evt.index);
488
- if (!Number.isInteger(idx) || idx < 0) continue;
489
- const rec = waves.get(idx) ?? {
490
- startMs: null,
491
- endMs: null,
492
- stories: [],
493
- };
494
- if (rec.endMs == null || ms > rec.endMs) rec.endMs = ms;
495
- waves.set(idx, rec);
496
- }
497
- }
498
- return waves;
499
- }
500
-
501
- /**
502
- * Largest value `< hi` that is `>= lo` in a sorted ascending array, or
503
- * `null` when the half-open window `[lo, hi)` contains no element. Pure
504
- * binary search — used by {@link fillMissingWaveEnds} to find a wave's
505
- * fallback terminator without re-scanning the whole event array per wave.
506
- *
507
- * @param {number[]} sortedMs ascending
508
- * @param {number} lo inclusive lower bound
509
- * @param {number} hi exclusive upper bound (may be Infinity)
510
- * @returns {number|null}
511
- */
512
- function maxInWindow(sortedMs, lo, hi) {
513
- // Find the first index with value >= hi (upper bound), then step back to
514
- // the last element strictly below hi.
515
- let left = 0;
516
- let right = sortedMs.length;
517
- while (left < right) {
518
- const mid = (left + right) >> 1;
519
- if (sortedMs[mid] < hi) left = mid + 1;
520
- else right = mid;
521
- }
522
- const candidate = left - 1; // last index with value < hi
523
- if (candidate < 0) return null;
524
- const val = sortedMs[candidate];
525
- return val >= lo ? val : null;
526
- }
527
-
528
- /**
529
- * Fill the `endMs` of any wave that never observed a `wave-complete`:
530
- * each such wave's terminator is the max event timestamp in the half-open
531
- * window `[startMs, nextStartMs)`, where `nextStartMs` is the start of the
532
- * next wave by ascending index (or `Infinity` for the last wave).
533
- *
534
- * Replaces the prior O(waves × events) nested scan with a single sort of
535
- * the already-parsed timestamps plus one binary search per gap-wave
536
- * (Story #3343). Output is byte-identical to the prior implementation.
537
- *
538
- * @param {Array<[number, { startMs: number|null, endMs: number|null, stories: number[] }]>} orderedWaves sorted by index asc
539
- * @param {Array<{ evt: object, ms: number|null }>} timedEvents
540
- * @returns {void} mutates the wave records in `orderedWaves` in place
541
- */
542
- function fillMissingWaveEnds(orderedWaves, timedEvents) {
543
- const needsFill = orderedWaves.some(
544
- ([, rec]) => rec.endMs == null && rec.startMs != null,
545
- );
546
- if (!needsFill) return;
547
- const sortedMs = [];
548
- for (const { ms } of timedEvents) {
549
- if (ms != null) sortedMs.push(ms);
550
- }
551
- sortedMs.sort((a, b) => a - b);
552
- for (let i = 0; i < orderedWaves.length; i += 1) {
553
- const [, rec] = orderedWaves[i];
554
- if (rec.endMs != null) continue;
555
- const startMs = rec.startMs;
556
- if (startMs == null) continue;
557
- const nextStartMs =
558
- i + 1 < orderedWaves.length ? orderedWaves[i + 1][1].startMs : Infinity;
559
- const maxMs = maxInWindow(sortedMs, startMs, nextStartMs);
560
- rec.endMs = maxMs == null ? startMs : Math.max(startMs, maxMs);
561
- }
562
- }
563
-
564
- /**
565
- * Compute per-wave parallelism rows from a chronological iterable of
566
- * lifecycle events (Task #3028, Epic #3019 / Story #3025).
567
- *
568
- * Wave windows are bracketed by `wave-start` (carrying `index` +
569
- * `stories[]`) and the matching `wave-complete` (same `index`). When no
570
- * `wave-complete` is observed for a wave, the wave's wallClockMs falls
571
- * back to the timestamp of the last in-wave event observed (so partial
572
- * runs still emit a row). When `wave-start` is missing entirely we emit
573
- * no row for that wave.
574
- *
575
- * Per-Story durations within a wave come from `state-transition` events
576
- * (`agent::executing` → `agent::done`) for the Story IDs the
577
- * `wave-start` payload enumerated. We bracket the **first**
578
- * `executing` transition and the **last** terminal transition (`done`,
579
- * `blocked`, or `failed`) per Story; if a Story is missing one boundary
580
- * its contribution to `summedStoryMs` is 0.
581
- *
582
- * Field contract (per the extended `epic-perf-report.schema.json`):
583
- * - `waveIndex`: integer ≥ 0, from `wave-start.index`
584
- * - `storyCount`: integer ≥ 0, number of Stories in the wave (from
585
- * `wave-start.stories`). Added under Story #3850.
586
- * - `wallClockMs`: integer ≥ 0, `(waveEnd - waveStart)` in ms
587
- * - `summedStoryMs`: integer ≥ 0, Σ per-Story `(end - start)`
588
- * - `utilisation`: `summedStoryMs / (wallClockMs * effectiveCap)`,
589
- * where `effectiveCap = min(storyCount, concurrencyCap)`, clamped
590
- * to `[0, 1]`. Zero when `wallClockMs === 0` or effectiveCap is 0.
591
- * Using the effective (not raw) cap means a fully-busy 1-Story wave
592
- * scores 1.0 rather than `1/cap`, eliminating false-positive
593
- * `low-utilisation` signals on serialized/narrow waves (Story #3850).
594
- * - `capBinding`: true when `summedStoryMs / wallClockMs >=
595
- * concurrencyCap`, false otherwise (and false when wallClockMs
596
- * is 0). Still uses the raw cap so the signal fires when the
597
- * configured parallelism ceiling is actually saturated.
598
- * - `verifyConcurrencyCap`: forwarded from `opts.verifyConcurrencyCap`
599
- * (or the project default 4) so the post-merge close comment can
600
- * attribute saturation back to the cap value in force at the time.
601
- *
602
- * @param {Iterable<object>} events
603
- * @param {{
604
- * concurrencyCap?: number,
605
- * verifyConcurrencyCap?: number,
606
- * }} [opts]
607
- * @returns {Array<{
608
- * waveIndex: number,
609
- * storyCount: number,
610
- * wallClockMs: number,
611
- * summedStoryMs: number,
612
- * utilisation: number,
613
- * capBinding: boolean,
614
- * verifyConcurrencyCap: number,
615
- * }>}
616
- */
617
- export function computeWaveParallelismRows(events, opts = {}) {
618
- const concurrencyCap =
619
- Number.isInteger(opts.concurrencyCap) && opts.concurrencyCap >= 1
620
- ? opts.concurrencyCap
621
- : DEFAULT_WAVE_CONCURRENCY_CAP;
622
- const verifyConcurrencyCap =
623
- Number.isInteger(opts.verifyConcurrencyCap) &&
624
- opts.verifyConcurrencyCap >= 1
625
- ? opts.verifyConcurrencyCap
626
- : DEFAULT_VERIFY_CONCURRENCY_CAP;
627
-
628
- // Materialise the iterable once, parsing each event's timestamp a
629
- // single time so every downstream pass reuses the same `ms` value
630
- // (Story #3343). Events are typically a few thousand per Epic at most.
631
- const timedEvents = materialiseTimedEvents(events);
632
-
633
- // Index Story state-transition windows: first `agent::executing` →
634
- // last terminal (`agent::done` | `agent::blocked` | `agent::failed`).
635
- const storyWindows = indexStoryWindows(timedEvents);
636
-
637
- // Bucket wave-start / wave-complete events by index, then fill any wave
638
- // that never saw `wave-complete` via a single sorted-timestamp sweep
639
- // (replacing the prior O(waves × events) nested scan).
640
- const waves = bucketWaves(timedEvents);
641
- const orderedWaves = [...waves.entries()].sort((a, b) => a[0] - b[0]);
642
- fillMissingWaveEnds(orderedWaves, timedEvents);
643
-
644
- // Build rows.
645
- const rows = [];
646
- for (const [idx, rec] of orderedWaves) {
647
- if (rec.startMs == null) continue;
648
- const wallClockMs = Math.max(
649
- 0,
650
- Math.floor((rec.endMs ?? rec.startMs) - rec.startMs),
651
- );
652
- const storyCount = rec.stories.length;
653
- let summedStoryMs = 0;
654
- for (const sid of rec.stories) {
655
- const w = storyWindows.get(sid);
656
- if (!w || w.startMs == null || w.endMs == null) continue;
657
- const dur = w.endMs - w.startMs;
658
- if (Number.isFinite(dur) && dur > 0) summedStoryMs += Math.floor(dur);
659
- }
660
- // Use the effective cap — min(storyCount, concurrencyCap) — as the
661
- // utilisation denominator so a fully-busy 1-Story wave scores 1.0
662
- // instead of 1/cap. This eliminates false-positive `low-utilisation`
663
- // signals on serialized / narrow-wave Epics (Story #3850).
664
- // capBinding still uses the raw concurrencyCap because it signals that
665
- // the configured parallelism ceiling is genuinely saturated.
666
- const effectiveCap = Math.min(storyCount, concurrencyCap);
667
- let utilisation = 0;
668
- let capBinding = false;
669
- if (wallClockMs > 0 && effectiveCap > 0) {
670
- utilisation = clamp(summedStoryMs / (wallClockMs * effectiveCap), 0, 1);
671
- }
672
- if (wallClockMs > 0 && concurrencyCap > 0) {
673
- capBinding = summedStoryMs / wallClockMs >= concurrencyCap;
674
- }
675
- rows.push({
676
- waveIndex: idx,
677
- storyCount,
678
- wallClockMs,
679
- summedStoryMs,
680
- utilisation,
681
- capBinding,
682
- verifyConcurrencyCap,
683
- });
684
- }
685
-
686
- return rows;
687
- }
688
-
689
- /**
690
- * Clamp `n` to the inclusive range [lo, hi]. Returns `lo` for NaN /
691
- * non-finite inputs so the coercer stays well-behaved on garbage.
692
- *
693
- * @param {number} n
694
- * @param {number} lo
695
- * @param {number} hi
696
- * @returns {number}
697
- */
698
- function clamp(n, lo, hi) {
699
- if (!Number.isFinite(n)) return lo;
700
- if (n < lo) return lo;
701
- if (n > hi) return hi;
702
- return n;
703
- }
704
-
705
- /**
706
- * Coerce a single waveParallelism input row into the schema-canonical
707
- * shape `{ waveIndex, storyCount, wallClockMs, summedStoryMs, utilisation,
708
- * capBinding, verifyConcurrencyCap }` (Story #3025; storyCount added
709
- * Story #3850).
710
- *
711
- * - Numeric fields are floored to non-negative integers (or clamped
712
- * numbers for utilisation) so the JSON-schema `integer` / `minimum: 0`
713
- * constraints hold by construction.
714
- * - `utilisation` is clamped to `[0, 1]` per the Tech Spec
715
- * contract (§1.1).
716
- * - `capBinding` is coerced to boolean.
717
- * - `verifyConcurrencyCap` falls back to the project default (4) when the
718
- * caller omits it or supplies a non-positive integer; the schema
719
- * requires `minimum: 1` so 0 is not a valid carrier value.
720
- * - `storyCount` falls back to 0 when absent (older payloads predating
721
- * Story #3850 do not carry the field).
722
- *
723
- * Exported for unit-testing the coercer in isolation.
724
- *
725
- * @param {object | null | undefined} row
726
- * @returns {{ waveIndex: number, storyCount: number, wallClockMs: number, summedStoryMs: number, utilisation: number, capBinding: boolean, verifyConcurrencyCap: number }}
727
- */
728
- export function coerceWaveParallelismRow(row) {
729
- const src = isObject(row) ? row : {};
730
- const cap = Number(src.verifyConcurrencyCap);
731
- const verifyConcurrencyCap =
732
- Number.isInteger(cap) && cap >= 1 ? cap : DEFAULT_VERIFY_CONCURRENCY_CAP;
733
- return {
734
- waveIndex: nonNegativeInt(src.waveIndex),
735
- storyCount: nonNegativeInt(src.storyCount),
736
- wallClockMs: nonNegativeInt(src.wallClockMs),
737
- summedStoryMs: nonNegativeInt(src.summedStoryMs),
738
- utilisation: clamp(nonNegativeNumber(src.utilisation), 0, 1),
739
- capBinding: Boolean(src.capBinding),
740
- verifyConcurrencyCap,
741
- };
742
- }
743
-
744
- export function computeEpicPerfReport(perStorySummaries, opts) {
745
- if (!isObject(opts)) {
746
- throw new TypeError('computeEpicPerfReport: opts is required');
747
- }
748
- const epicId = Number(opts.epicId);
749
- if (!Number.isInteger(epicId) || epicId < 1) {
750
- throw new RangeError(
751
- `computeEpicPerfReport: epicId must be a positive integer (got ${opts.epicId})`,
752
- );
753
- }
754
- const generatedAt =
755
- typeof opts.generatedAt === 'string' && opts.generatedAt.length > 0
756
- ? opts.generatedAt
757
- : new Date().toISOString();
758
-
759
- const summaries = collectValidStorySamples(perStorySummaries);
760
-
761
- // signalCounts: prefer the raw-event roll-up; fall back to friction-only
762
- // when the caller did not pass events.
763
- const signalCounts = buildSignalCounts(opts.events, summaries);
764
-
765
- const topHotspots = Array.isArray(opts.topHotspots)
766
- ? opts.topHotspots
767
- : aggregateTopHotspots(summaries);
768
-
769
- // mostFrictionStories: per-Story friction count, sorted desc, capped.
770
- const mostFrictionStories = summaries
771
- .map((s) => {
772
- const counts = isObject(s.frictionByCategory)
773
- ? Object.values(s.frictionByCategory).reduce(
774
- (acc, v) => acc + nonNegativeInt(v),
775
- 0,
776
- )
777
- : 0;
778
- return {
779
- storyId: nonNegativeInt(s.storyId),
780
- frictionCount: counts,
781
- };
782
- })
783
- .filter((row) => row.storyId > 0)
784
- .sort((a, b) => b.frictionCount - a.frictionCount)
785
- .slice(0, 5);
786
-
787
- let waveParallelism;
788
- if (Array.isArray(opts.waveParallelism)) {
789
- waveParallelism = opts.waveParallelism.map((row) =>
790
- coerceWaveParallelismRow(row),
791
- );
792
- } else if (opts.events) {
793
- // Derive rows from the raw lifecycle event stream when the caller
794
- // hands us the events but no pre-computed array (Story #3025 /
795
- // Task #3028).
796
- waveParallelism = computeWaveParallelismRows(opts.events, {
797
- concurrencyCap: opts.concurrencyCap,
798
- verifyConcurrencyCap: opts.verifyConcurrencyCap,
799
- });
800
- } else {
801
- waveParallelism = [];
802
- }
803
-
804
- return {
805
- kind: 'epic-perf-report',
806
- epicId,
807
- generatedAt,
808
- signalCounts,
809
- waveParallelism,
810
- topHotspots,
811
- mostFrictionStories,
812
- };
813
- }