mandrel 2.31.0 → 2.33.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 (267) hide show
  1. package/.agents/README.md +13 -17
  2. package/.agents/agents/acceptance-critic.md +1 -2
  3. package/.agents/docs/SDLC.md +12 -9
  4. package/.agents/docs/agentrc-reference.json +63 -58
  5. package/.agents/docs/configuration.md +275 -227
  6. package/.agents/docs/execution-reference.md +13 -14
  7. package/.agents/docs/quality-gates.md +195 -23
  8. package/.agents/instructions.md +2 -5
  9. package/.agents/rules/git-conventions-reference.md +27 -27
  10. package/.agents/rules/git-conventions.md +4 -2
  11. package/.agents/rules/known-tooling-behavior.md +66 -30
  12. package/.agents/rules/testing-standards.md +35 -71
  13. package/.agents/runtime-deps.json +2 -2
  14. package/.agents/schemas/agentrc.schema.json +1955 -1410
  15. package/.agents/schemas/lifecycle/README.md +21 -14
  16. package/.agents/schemas/lifecycle/ledger-record.schema.json +76 -22
  17. package/.agents/schemas/story-deliver-terminal.schema.json +2 -2
  18. package/.agents/scripts/README.md +16 -29
  19. package/.agents/scripts/apply-quality-bootstrap.js +27 -34
  20. package/.agents/scripts/audit-to-stories.js +160 -41
  21. package/.agents/scripts/bootstrap.js +28 -26
  22. package/.agents/scripts/check-baseline-drift.js +73 -13
  23. package/.agents/scripts/check-baseline-scope.js +362 -0
  24. package/.agents/scripts/check-dead-exports.js +9 -1
  25. package/.agents/scripts/check-gherkin-corpus.js +508 -0
  26. package/.agents/scripts/check-knip-entries.js +159 -0
  27. package/.agents/scripts/check-lifecycle-lint.js +108 -124
  28. package/.agents/scripts/check-schema-references.js +1 -1
  29. package/.agents/scripts/diagnose-friction.js +7 -4
  30. package/.agents/scripts/generate-config-docs.js +263 -171
  31. package/.agents/scripts/install-matrix-assert.js +0 -1
  32. package/.agents/scripts/lib/ITicketingProvider.js +0 -58
  33. package/.agents/scripts/lib/audit-baselines/staleness.js +6 -6
  34. package/.agents/scripts/lib/audit-baselines/trend.js +7 -8
  35. package/.agents/scripts/lib/audit-baselines/weights.js +4 -5
  36. package/.agents/scripts/lib/audit-suite/checklist-threading.js +1 -1
  37. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +81 -35
  38. package/.agents/scripts/lib/audit-to-stories/wire-dependencies.js +185 -0
  39. package/.agents/scripts/lib/baselines/envelope.js +41 -60
  40. package/.agents/scripts/lib/baselines/git-base.js +30 -37
  41. package/.agents/scripts/lib/baselines/kinds/_crap-new-method-gate.js +103 -0
  42. package/.agents/scripts/lib/baselines/kinds/_crap-read.js +150 -0
  43. package/.agents/scripts/lib/baselines/kinds/crap.js +25 -65
  44. package/.agents/scripts/lib/baselines/orphan-pruner.js +233 -0
  45. package/.agents/scripts/lib/baselines/refresh-service.js +6 -8
  46. package/.agents/scripts/lib/baselines/scope-assert.js +223 -0
  47. package/.agents/scripts/lib/baselines/scope-inventory.js +314 -0
  48. package/.agents/scripts/lib/bdd-step-index.js +326 -0
  49. package/.agents/scripts/lib/bootstrap/install-ledger.js +5 -3
  50. package/.agents/scripts/lib/bootstrap/issue-forms-template.js +4 -6
  51. package/.agents/scripts/lib/bootstrap/manifest.js +17 -40
  52. package/.agents/scripts/lib/bootstrap/project-bootstrap.js +12 -59
  53. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +62 -2
  54. package/.agents/scripts/lib/checks/loop-health.js +9 -37
  55. package/.agents/scripts/lib/child-exec.js +193 -0
  56. package/.agents/scripts/lib/cli/standard-args.js +1 -1
  57. package/.agents/scripts/lib/cli-args.js +64 -0
  58. package/.agents/scripts/lib/close-validation/gates.js +2 -2
  59. package/.agents/scripts/lib/close-validation/runner.js +3 -3
  60. package/.agents/scripts/lib/config/acceptance-eval.js +5 -52
  61. package/.agents/scripts/lib/config/commands.js +3 -5
  62. package/.agents/scripts/lib/config/explain.js +5 -7
  63. package/.agents/scripts/lib/config/gates/bundle-size.schema.js +32 -6
  64. package/.agents/scripts/lib/config/gates/coverage.schema.js +25 -5
  65. package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +12 -2
  66. package/.agents/scripts/lib/config/gates/crap.schema.js +68 -23
  67. package/.agents/scripts/lib/config/gates/duplication.schema.js +29 -17
  68. package/.agents/scripts/lib/config/gates/index.js +5 -2
  69. package/.agents/scripts/lib/config/gates/lighthouse.schema.js +34 -6
  70. package/.agents/scripts/lib/config/gates/lint.schema.js +11 -2
  71. package/.agents/scripts/lib/config/gates/maintainability.schema.js +37 -15
  72. package/.agents/scripts/lib/config/gates/mutation.schema.js +15 -3
  73. package/.agents/scripts/lib/config/gates/shared.js +58 -9
  74. package/.agents/scripts/lib/config/github.js +0 -1
  75. package/.agents/scripts/lib/config/limits.js +3 -48
  76. package/.agents/scripts/lib/config/qa.js +105 -0
  77. package/.agents/scripts/lib/config/runners.js +38 -16
  78. package/.agents/scripts/lib/config/temp-paths.js +6 -5
  79. package/.agents/scripts/lib/config-settings-schema-delivery.js +245 -56
  80. package/.agents/scripts/lib/config-settings-schema-quality.js +209 -29
  81. package/.agents/scripts/lib/config-settings-schema.js +386 -39
  82. package/.agents/scripts/lib/crap-baseline-join.js +126 -9
  83. package/.agents/scripts/lib/crap-utils.js +84 -520
  84. package/.agents/scripts/lib/dead-exports-knip.js +79 -10
  85. package/.agents/scripts/lib/degraded-mode.js +2 -2
  86. package/.agents/scripts/lib/dependency-parser.js +20 -7
  87. package/.agents/scripts/lib/doc-tiers.js +3 -3
  88. package/.agents/scripts/lib/feedback-loop/graduator-core.js +46 -104
  89. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +10 -8
  90. package/.agents/scripts/lib/findings/provenance-field.js +135 -0
  91. package/.agents/scripts/lib/findings/route-finding.js +57 -8
  92. package/.agents/scripts/lib/fs-walk.js +52 -0
  93. package/.agents/scripts/lib/git-branch-lifecycle.js +2 -2
  94. package/.agents/scripts/lib/git-utils.js +16 -36
  95. package/.agents/scripts/lib/knip-config-resolver.js +181 -0
  96. package/.agents/scripts/lib/knip-entry-sync.js +508 -0
  97. package/.agents/scripts/lib/observability/metrics-ledger.js +1 -1
  98. package/.agents/scripts/lib/observability/runtime-friction.js +10 -0
  99. package/.agents/scripts/lib/observability/signal-validator.js +5 -85
  100. package/.agents/scripts/lib/observability/signals-writer.js +19 -62
  101. package/.agents/scripts/lib/observability/source-classifier.js +5 -7
  102. package/.agents/scripts/lib/observability/terse-result.js +3 -3
  103. package/.agents/scripts/lib/orchestration/behind-recovery.js +114 -0
  104. package/.agents/scripts/lib/orchestration/ceremony-routing.js +7 -8
  105. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +34 -33
  106. package/.agents/scripts/lib/orchestration/code-review.js +2 -2
  107. package/.agents/scripts/lib/orchestration/complexity-gate.js +43 -161
  108. package/.agents/scripts/lib/orchestration/diff-magnitude.js +4 -4
  109. package/.agents/scripts/lib/orchestration/label-transitions.js +3 -2
  110. package/.agents/scripts/lib/orchestration/lease-guard-shared.js +12 -38
  111. package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +5 -6
  112. package/.agents/scripts/lib/orchestration/plan-metrics.js +2 -3
  113. package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +7 -26
  114. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +69 -5
  115. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +69 -13
  116. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +49 -0
  117. package/.agents/scripts/lib/orchestration/{lifecycle/listeners/watcher.js → pr-watch.js} +58 -208
  118. package/.agents/scripts/lib/orchestration/resolve-stories.js +77 -50
  119. package/.agents/scripts/lib/orchestration/review-providers/codex.js +1 -1
  120. package/.agents/scripts/lib/orchestration/review-providers/mi-exemptions.js +130 -0
  121. package/.agents/scripts/lib/orchestration/review-providers/native.js +30 -16
  122. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +1 -1
  123. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +37 -26
  124. package/.agents/scripts/lib/orchestration/single-story-close/phases/conventional-subject.js +376 -0
  125. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +161 -151
  126. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +15 -3
  127. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +10 -15
  128. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-block.js +5 -0
  129. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-override.js +157 -0
  130. package/.agents/scripts/lib/orchestration/single-story-close/phases/worktree-reap.js +0 -14
  131. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +59 -25
  132. package/.agents/scripts/lib/orchestration/single-story-lease-guard.js +20 -31
  133. package/.agents/scripts/lib/orchestration/spec-spill.js +17 -3
  134. package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +7 -6
  135. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +2 -1
  136. package/.agents/scripts/lib/orchestration/task-body-validator.js +4 -1
  137. package/.agents/scripts/lib/orchestration/ticket-lease.js +28 -127
  138. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +116 -1
  139. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +1 -1
  140. package/.agents/scripts/lib/orchestration/ticket-validator.js +38 -0
  141. package/.agents/scripts/lib/orchestration/ticketing/reads.js +5 -5
  142. package/.agents/scripts/lib/orchestration/ticketing/transition.js +5 -4
  143. package/.agents/scripts/lib/orchestration/verify-tier-repair.js +107 -0
  144. package/.agents/scripts/lib/qa/coverage-verdict.js +5 -87
  145. package/.agents/scripts/lib/signals/detectors/common.js +1 -1
  146. package/.agents/scripts/lib/signals/index.js +8 -6
  147. package/.agents/scripts/lib/signals/schema.js +20 -25
  148. package/.agents/scripts/lib/signals/write.js +8 -8
  149. package/.agents/scripts/lib/story-body/footer-block.js +97 -0
  150. package/.agents/scripts/lib/story-body/story-body.js +18 -81
  151. package/.agents/scripts/lib/temp-retention.js +1 -1
  152. package/.agents/scripts/lib/templates/decomposer-prompts.js +16 -14
  153. package/.agents/scripts/lib/ticket-body-sections.js +4 -5
  154. package/.agents/scripts/lib/wave-runner/footprint.js +306 -0
  155. package/.agents/scripts/lib/wave-runner/ready-set.js +198 -181
  156. package/.agents/scripts/lib/worktree/lifecycle/merge-reachability.js +13 -45
  157. package/.agents/scripts/lib/worktree/lifecycle/reap.js +4 -5
  158. package/.agents/scripts/lib/worktree-manager.js +2 -3
  159. package/.agents/scripts/lint-label-vocabulary.js +2 -24
  160. package/.agents/scripts/pr-watch-with-update.js +7 -5
  161. package/.agents/scripts/providers/github/blocked-by-add.js +25 -10
  162. package/.agents/scripts/providers/github/cache.js +2 -2
  163. package/.agents/scripts/providers/github/comments.js +6 -28
  164. package/.agents/scripts/providers/github/compose.js +0 -15
  165. package/.agents/scripts/providers/github/errors.js +10 -27
  166. package/.agents/scripts/providers/github/request-helpers.js +1 -2
  167. package/.agents/scripts/providers/github/sub-issues.js +10 -218
  168. package/.agents/scripts/providers/github.js +4 -7
  169. package/.agents/scripts/prune-baseline-orphans.js +181 -0
  170. package/.agents/scripts/resolve-stories.js +21 -7
  171. package/.agents/scripts/run-lint.js +61 -61
  172. package/.agents/scripts/run-test-profile.js +6 -6
  173. package/.agents/scripts/run-verify.js +48 -30
  174. package/.agents/scripts/single-story-close.js +20 -0
  175. package/.agents/scripts/single-story-init.js +12 -35
  176. package/.agents/scripts/stories-wave-tick.js +192 -9
  177. package/.agents/scripts/update-dead-exports-baseline.js +321 -0
  178. package/.agents/skills/core/gates-and-baselines/SKILL.md +2 -2
  179. package/.agents/skills/skills.index.json +1 -11
  180. package/.agents/workflows/audit-documentation.md +5 -6
  181. package/.agents/workflows/audit-to-stories.md +28 -2
  182. package/.agents/workflows/helpers/audit-lens-core.md +11 -12
  183. package/.agents/workflows/helpers/code-quality-guardrails.md +15 -14
  184. package/.agents/workflows/helpers/code-review.md +3 -8
  185. package/.agents/workflows/helpers/deliver-reference.md +30 -2
  186. package/.agents/workflows/helpers/deliver-story-reference.md +84 -16
  187. package/.agents/workflows/helpers/plan-reference.md +76 -0
  188. package/.agents/workflows/helpers/worktree-lifecycle.md +1 -2
  189. package/.agents/workflows/mandrel-update.md +10 -10
  190. package/.agents/workflows/qa-assist.md +15 -20
  191. package/.agents/workflows/qa-explore.md +9 -8
  192. package/README.md +1 -1
  193. package/docs/CHANGELOG.md +58 -0
  194. package/lib/migrations/index.js +2 -0
  195. package/lib/migrations/steps/2.32.0-retire-lint-baseline-command.js +127 -0
  196. package/package.json +14 -5
  197. package/.agents/schemas/lifecycle/checkpoint.written.schema.json +0 -13
  198. package/.agents/schemas/lifecycle/close-validate.end.schema.json +0 -18
  199. package/.agents/schemas/lifecycle/close-validate.start.schema.json +0 -13
  200. package/.agents/schemas/lifecycle/code-review.end.schema.json +0 -30
  201. package/.agents/schemas/lifecycle/code-review.start.schema.json +0 -12
  202. package/.agents/schemas/lifecycle/intervention.recorded.schema.json +0 -15
  203. package/.agents/schemas/lifecycle/loop.tick.schema.json +0 -20
  204. package/.agents/schemas/lifecycle/notification.emitted.schema.json +0 -18
  205. package/.agents/schemas/lifecycle/pr.created.schema.json +0 -14
  206. package/.agents/schemas/lifecycle/retro.end.schema.json +0 -16
  207. package/.agents/schemas/lifecycle/retro.start.schema.json +0 -12
  208. package/.agents/schemas/lifecycle/story.blocked.schema.json +0 -13
  209. package/.agents/schemas/lifecycle/story.dispatch.end.schema.json +0 -17
  210. package/.agents/schemas/lifecycle/story.dispatch.start.schema.json +0 -15
  211. package/.agents/schemas/lifecycle/story.merged.schema.json +0 -13
  212. package/.agents/scripts/check-gherkin-placeholders.js +0 -663
  213. package/.agents/scripts/check-lifecycle-doc-drift.js +0 -411
  214. package/.agents/scripts/lib/audit-suite/cli.js +0 -64
  215. package/.agents/scripts/lib/bootstrap/baselines-layout-migration.js +0 -202
  216. package/.agents/scripts/lib/bootstrap/ci-workflow-template.js +0 -212
  217. package/.agents/scripts/lib/checks/baseline-drift-main-checkout.js +0 -104
  218. package/.agents/scripts/lib/checks/push-hook-parity.js +0 -106
  219. package/.agents/scripts/lib/checks/windows-coverage-noise-floor.js +0 -92
  220. package/.agents/scripts/lib/checks/worktree-bootstrap-env.js +0 -81
  221. package/.agents/scripts/lib/checks/worktree-residue-biome.js +0 -55
  222. package/.agents/scripts/lib/crap-baseline-index.js +0 -46
  223. package/.agents/scripts/lib/crap-utils-incremental.js +0 -113
  224. package/.agents/scripts/lib/dynamic-workflow/capability.js +0 -396
  225. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -335
  226. package/.agents/scripts/lib/mutation/baseline-snapshot.js +0 -239
  227. package/.agents/scripts/lib/mutation/config-detector.js +0 -119
  228. package/.agents/scripts/lib/mutation/stryker-runner.js +0 -306
  229. package/.agents/scripts/lib/mutation/survivor-report.js +0 -160
  230. package/.agents/scripts/lib/observability/active-story-env.js +0 -170
  231. package/.agents/scripts/lib/observability/tool-trace-hook.js +0 -456
  232. package/.agents/scripts/lib/orchestration/acceptance-clusters.js +0 -111
  233. package/.agents/scripts/lib/orchestration/context-envelope.js +0 -277
  234. package/.agents/scripts/lib/orchestration/detectors-phase.js +0 -194
  235. package/.agents/scripts/lib/orchestration/lifecycle/bus.js +0 -309
  236. package/.agents/scripts/lib/orchestration/lifecycle/emit-loop-tick.js +0 -181
  237. package/.agents/scripts/lib/orchestration/lifecycle/ledger-writer.js +0 -229
  238. package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +0 -54
  239. package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +0 -344
  240. package/.agents/scripts/lib/orchestration/lint-baseline-service.js +0 -114
  241. package/.agents/scripts/lib/orchestration/pr-base-guard.js +0 -37
  242. package/.agents/scripts/lib/orchestration/resolves-token.js +0 -127
  243. package/.agents/scripts/lib/orchestration/spec-section-validator.js +0 -130
  244. package/.agents/scripts/lib/orchestration/story-close/emit-blocked.js +0 -55
  245. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +0 -211
  246. package/.agents/scripts/lib/planning-corpus.js +0 -37
  247. package/.agents/scripts/lib/qa/coverage-report.js +0 -181
  248. package/.agents/scripts/lib/qa/propose-missing-test.js +0 -95
  249. package/.agents/scripts/lib/qa/qa-context-hydrator.js +0 -217
  250. package/.agents/scripts/lib/signals/detectors/index.js +0 -14
  251. package/.agents/scripts/lib/signals/detectors/retry.js +0 -253
  252. package/.agents/scripts/lib/signals/detectors/rework.js +0 -167
  253. package/.agents/scripts/lib/signals/read.js +0 -268
  254. package/.agents/scripts/lib/signals/span-tree.js +0 -291
  255. package/.agents/scripts/lib/story-lifecycle.js +0 -194
  256. package/.agents/scripts/lib/story-plan.js +0 -379
  257. package/.agents/scripts/lib/util/phase-timer-state.js +0 -72
  258. package/.agents/scripts/lib/util/phase-timer.js +0 -163
  259. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +0 -169
  260. package/.agents/scripts/lint-baseline.js +0 -507
  261. package/.agents/scripts/providers/github/prs.js +0 -103
  262. package/.agents/scripts/signals-view.js +0 -309
  263. package/.agents/scripts/story-plan.js +0 -370
  264. package/.agents/scripts/sync-branch-from-base.js +0 -149
  265. package/.agents/scripts/validate-docs-freshness.js +0 -314
  266. package/.agents/skills/core/diagnose-friction/SKILL.md +0 -78
  267. package/.agents/workflows/helpers/signals.md +0 -112
@@ -61,6 +61,35 @@
61
61
 
62
62
  import { AGENT_LABELS } from '../label-constants.js';
63
63
  import { buildStoryAdjacency } from '../story-adjacency.js';
64
+ import { detectCollision } from './footprint.js';
65
+
66
+ /**
67
+ * How a footprint collision affects dispatch — the `footprintGuard` config
68
+ * knob (`delivery.deliverRunner.footprintGuard`, Story #5044).
69
+ *
70
+ * `enforce` is the default and encodes delivery-time-only knowledge the
71
+ * planner cannot have: which implementation windows are open right now, which
72
+ * Stories a foreign lease holds, how far the ground has moved since the plan
73
+ * was authored. It is never demoted automatically.
74
+ *
75
+ * `advisory` is an explicit operator trade for throughput on a run whose
76
+ * `depends_on` edges are known to be complete. Detection still runs; only the
77
+ * withholding stops.
78
+ */
79
+ export const GUARD_MODES = Object.freeze({
80
+ ENFORCE: 'enforce',
81
+ ADVISORY: 'advisory',
82
+ });
83
+
84
+ /**
85
+ * Which guard produced a withhold: a peer admitted **this beat**, or a Story
86
+ * still in flight from an **earlier** one. The two clear on different events,
87
+ * so an operator reading the report needs them apart.
88
+ */
89
+ export const WITHHOLD_SCOPES = Object.freeze({
90
+ BEAT: 'beat',
91
+ IN_FLIGHT: 'in-flight',
92
+ });
64
93
 
65
94
  /**
66
95
  * @typedef {object} StoryRecord
@@ -135,128 +164,6 @@ export function classifyStory(story) {
135
164
  return 'ready';
136
165
  }
137
166
 
138
- /**
139
- * Extract a Story's declared file footprint as a normalized set of path
140
- * strings. Accepts the three footprint shapes a Story record can carry:
141
- *
142
- * - `files: string[]` — explicit footprint.
143
- * - `changes: string[]` — string-array sketch.
144
- * - `changeset: Array<{ path }>` / — object-array sketch (the
145
- * `changes: Array<{ path }>` `{ path, assumption }`
146
- * shape from a Story body).
147
- *
148
- * Paths are trimmed; empty / non-string entries are dropped. A Story with
149
- * no declared footprint yields an empty set, which (by `storiesOverlap`'s
150
- * contract) means it overlaps with nothing and is never withheld by the
151
- * co-dispatch guard.
152
- *
153
- * @param {StoryRecord} story
154
- * @returns {Set<string>}
155
- */
156
- export function storyFootprint(story) {
157
- const out = new Set();
158
- const push = (entry) => {
159
- const path =
160
- typeof entry === 'string'
161
- ? entry
162
- : typeof entry?.path === 'string'
163
- ? entry.path
164
- : null;
165
- if (!path) return;
166
- const trimmed = path.trim();
167
- if (trimmed) out.add(trimmed);
168
- };
169
- if (Array.isArray(story?.files)) for (const e of story.files) push(e);
170
- if (Array.isArray(story?.changes)) for (const e of story.changes) push(e);
171
- if (Array.isArray(story?.changeset)) for (const e of story.changeset) push(e);
172
- return out;
173
- }
174
-
175
- /**
176
- * Does a declared path contain a glob metacharacter? Mirrors the detection
177
- * in `story-body.js#extractChangePaths`, whose `isGlob` flag documents an
178
- * "unknown-width footprint" policy that was never implemented downstream.
179
- *
180
- * @param {string} path
181
- * @returns {boolean}
182
- */
183
- function isGlobPath(path) {
184
- return path.includes('*') || path.includes('?') || path.includes('{');
185
- }
186
-
187
- /**
188
- * Repo-relative file paths as they appear in Story prose: at least one `/`
189
- * separator and a short file extension. Deliberately narrow — a token has to
190
- * look like a real path before it can widen a footprint and withhold a Story.
191
- */
192
- const PROSE_PATH_RE = /(?:[\w.@~-]+\/)+[\w.@-]+\.[A-Za-z0-9]{1,6}/g;
193
-
194
- /**
195
- * Scrape file paths a Story's **text** mentions but its `changes[]` never
196
- * declared (Story #4875).
197
- *
198
- * The declared footprint is a planner's *prediction*, and it is systematically
199
- * a lower bound: a Story's `## Spec` names the module it must also touch, its
200
- * acceptance criteria name the caller that must be updated, and none of that
201
- * reaches `changes[]`. The overlap guard exists to stop two Stories racing the
202
- * same file, so trusting the declaration outright means the guard is blind to
203
- * precisely the collisions nobody predicted.
204
- *
205
- * Evidence is only ever **added** — nothing here can shrink a declared
206
- * footprint, so widening can withhold a Story for a beat but can never
207
- * co-dispatch one the declared comparison would have caught.
208
- *
209
- * @param {StoryRecord} story
210
- * @returns {Set<string>}
211
- */
212
- function storyEvidencePaths(story) {
213
- const out = new Set();
214
- for (const field of [story?.title, story?.body, story?.spec]) {
215
- if (typeof field !== 'string' || field === '') continue;
216
- for (const match of field.matchAll(PROSE_PATH_RE)) {
217
- const trimmed = match[0].trim();
218
- if (trimmed) out.add(trimmed);
219
- }
220
- }
221
- return out;
222
- }
223
-
224
- /**
225
- * A Story's footprint **widened from observable evidence** — the set the
226
- * co-dispatch guard actually compares (Story #4875).
227
- *
228
- * `declared ∪ scraped-from-prose`. See {@link storyEvidencePaths} for why the
229
- * declaration is treated as a lower bound rather than the answer.
230
- *
231
- * @param {StoryRecord} story
232
- * @returns {Set<string>}
233
- */
234
- function storyWidenedFootprint(story) {
235
- const out = storyFootprint(story);
236
- for (const path of storyEvidencePaths(story)) out.add(path);
237
- return out;
238
- }
239
-
240
- /**
241
- * Both Stories' widened footprints, or `null` when either is empty.
242
- *
243
- * **An empty footprint means "no known overlap"**, so both guards below
244
- * short-circuit to `false` on one. This is permissive by necessity: a Story
245
- * with no declared footprint and no path evidence in its text carries no
246
- * information, and withholding on absence would serialize every run.
247
- *
248
- * @param {StoryRecord} a
249
- * @param {StoryRecord} b
250
- * @returns {[Set<string>, Set<string>]|null}
251
- */
252
- function widenedFootprintPair(a, b) {
253
- const fa = storyWidenedFootprint(a);
254
- if (fa.size === 0) return null;
255
- const fb = storyWidenedFootprint(b);
256
- if (fb.size === 0) return null;
257
- return [fa, fb];
258
- }
259
-
260
167
  /**
261
168
  * **Beat-local** file-overlap co-dispatch guard. Returns `true` when two
262
169
  * Stories' file footprints intersect — meaning they would race the same file
@@ -282,17 +189,8 @@ function widenedFootprintPair(a, b) {
282
189
  * @param {StoryRecord} b
283
190
  * @returns {boolean}
284
191
  */
285
- export function storiesOverlap(a, b) {
286
- const pair = widenedFootprintPair(a, b);
287
- if (pair === null) return false;
288
- const [fa, fb] = pair;
289
- for (const path of fa) {
290
- if (isGlobPath(path) || fb.has(path)) return true;
291
- }
292
- for (const path of fb) {
293
- if (isGlobPath(path)) return true;
294
- }
295
- return false;
192
+ export function storiesOverlap(a, b, options = {}) {
193
+ return detectCollision(a, b, options) !== null;
296
194
  }
297
195
 
298
196
  /**
@@ -319,16 +217,11 @@ export function storiesOverlap(a, b) {
319
217
  *
320
218
  * @param {StoryRecord} held The in-flight Story holding the reservation.
321
219
  * @param {StoryRecord} candidate The Story being considered for admission.
322
- * @returns {boolean}
220
+ * @param {object} [options] Evidence-scrape options (see {@link storyEvidencePaths}).
221
+ * @returns {{ paths: string[], source: string }|null}
323
222
  */
324
- function reservesConcretePath(held, candidate) {
325
- const pair = widenedFootprintPair(held, candidate);
326
- if (pair === null) return false;
327
- const [fa, fb] = pair;
328
- for (const path of fa) {
329
- if (!isGlobPath(path) && fb.has(path)) return true;
330
- }
331
- return false;
223
+ function reservesConcretePath(held, candidate, options = {}) {
224
+ return detectCollision(held, candidate, { ...options, concreteOnly: true });
332
225
  }
333
226
 
334
227
  /**
@@ -396,11 +289,28 @@ function reservesConcretePath(held, candidate) {
396
289
  * footprint reserves nothing (Story #4960). Callers that hold only ids (the
397
290
  * `--dag`/`--in-flight` flag mode) pass nothing and get the pre-#4950
398
291
  * same-beat-only behaviour.
399
- * @returns {{ selected: StoryRecord[], withheldByInFlight: Array<{id: number, blockedBy: number}> }}
292
+ * @param {'enforce'|'advisory'} [args.footprintGuard='enforce'] Whether a
293
+ * footprint collision **withholds** a Story (`enforce`, the default and
294
+ * today's behaviour) or merely **reports** one while dispatch follows the
295
+ * declared `depends_on` edges alone (`advisory`). Advisory never changes what
296
+ * the guard *detects* — every would-be withhold is still computed and
297
+ * returned in `footprintWithholds` with `enforced: false` — so turning it on
298
+ * trades serialization for throughput without going blind (Story #5044).
299
+ * @param {string} [args.tempRoot] Resolved `project.paths.tempRoot`, threaded
300
+ * so the evidence scrape can ignore gitignored scratch paths.
301
+ * @returns {{
302
+ * selected: StoryRecord[],
303
+ * withheldByInFlight: Array<{id: number, blockedBy: number}>,
304
+ * footprintWithholds: Array<{id: number, blockedBy: number, scope: string, source: string, paths: string[], enforced: boolean}>,
305
+ * guardMode: 'enforce'|'advisory'
306
+ * }}
400
307
  * `selected` is the dispatch set: a subset of `stories`, ascending by id,
401
308
  * overlap-free, length ≤ `globalCap − inFlight`. `withheldByInFlight`
402
309
  * names each eligible Story a reservation held back and the in-flight
403
- * Story that holds it.
310
+ * Story that holds it. `footprintWithholds` is the **complete** ledger —
311
+ * beat-local skips as well as cross-beat reservations, each with the
312
+ * colliding paths and its `declared-overlap` / `scraped-overlap` source — so
313
+ * no withheld dispatch is unexplained (Story #5044).
404
314
  */
405
315
  export function planReadySet({
406
316
  stories,
@@ -409,14 +319,25 @@ export function planReadySet({
409
319
  globalCap,
410
320
  dropForeign = false,
411
321
  inFlightRecords = [],
322
+ footprintGuard = GUARD_MODES.ENFORCE,
323
+ tempRoot,
412
324
  } = {}) {
413
325
  const records = Array.isArray(stories) ? stories : [];
326
+ const guardMode =
327
+ footprintGuard === GUARD_MODES.ADVISORY
328
+ ? GUARD_MODES.ADVISORY
329
+ : GUARD_MODES.ENFORCE;
414
330
  const cap = Number.isInteger(globalCap) ? globalCap : 0;
415
331
  const inFlightCount =
416
332
  Number.isInteger(inFlight) && inFlight > 0 ? inFlight : 0;
417
333
  const slots = Math.max(0, cap - inFlightCount);
418
334
  if (slots <= 0 || records.length === 0) {
419
- return { selected: [], withheldByInFlight: [] };
335
+ return {
336
+ selected: [],
337
+ withheldByInFlight: [],
338
+ footprintWithholds: [],
339
+ guardMode,
340
+ };
420
341
  }
421
342
 
422
343
  // Step 1 — adjacency keyed by id. The `dropForeign` policy decides whether
@@ -425,7 +346,47 @@ export function planReadySet({
425
346
  // rationale.
426
347
  const adjacency = buildStoryAdjacency(records, { dropForeign });
427
348
 
428
- // Step 2 — done set = caller-supplied ids records that classify done.
349
+ // Steps 2 + 3 who is eligible at all, before any footprint reasoning.
350
+ const { eligibleIds, byId } = resolveEligibility({
351
+ records,
352
+ doneIds,
353
+ adjacency,
354
+ });
355
+
356
+ // Steps 4 + 5 — greedily admit up to `slots`, skipping file-overlap
357
+ // collisions against the already-admitted set AND against the footprints
358
+ // reserved by Stories still in flight from an earlier beat.
359
+ return admitStories({
360
+ eligibleIds,
361
+ byId,
362
+ slots,
363
+ reserved: Array.isArray(inFlightRecords) ? inFlightRecords : [],
364
+ guardMode,
365
+ evidence: { tempRoot },
366
+ });
367
+ }
368
+
369
+ /**
370
+ * Resolve which Stories are eligible to dispatch on dependency grounds alone —
371
+ * `agent::ready` with every declared blocker done — plus the id→record index
372
+ * the admission loop reads.
373
+ *
374
+ * Separated from {@link planReadySet} because it answers a different question:
375
+ * this is the *declared graph* half of the decision (edges and lifecycle
376
+ * state), while everything after it reasons about footprints. Keeping the two
377
+ * apart is also what holds `planReadySet` under the cyclomatic ceiling ratchet.
378
+ *
379
+ * The done set is the union of two sources: ids the caller resolved from live
380
+ * state, and records in this batch that classify done — a Story can be both,
381
+ * and neither alone is complete.
382
+ *
383
+ * @param {object} args
384
+ * @param {StoryRecord[]} args.records
385
+ * @param {number[]|Set<number>} args.doneIds
386
+ * @param {Map<number, number[]>} args.adjacency
387
+ * @returns {{ eligibleIds: number[], byId: Map<number, StoryRecord> }}
388
+ */
389
+ function resolveEligibility({ records, doneIds, adjacency }) {
429
390
  const done = new Set();
430
391
  for (const raw of doneIds instanceof Set ? doneIds : (doneIds ?? [])) {
431
392
  const id = Number(raw);
@@ -439,25 +400,14 @@ export function planReadySet({
439
400
  if (classifyStory(rec) === 'done') done.add(id);
440
401
  }
441
402
 
442
- // Step 3 eligible: ready AND all dependencies done. Ascending id for
443
- // deterministic admission order.
403
+ // Ascending id for deterministic admission order.
444
404
  const eligibleIds = [];
445
405
  for (const id of [...byId.keys()].sort((a, b) => a - b)) {
446
- const rec = byId.get(id);
447
- if (classifyStory(rec) !== 'ready') continue;
406
+ if (classifyStory(byId.get(id)) !== 'ready') continue;
448
407
  const deps = adjacency.get(id) ?? [];
449
408
  if (deps.every((dep) => done.has(dep))) eligibleIds.push(id);
450
409
  }
451
-
452
- // Steps 4 + 5 — greedily admit up to `slots`, skipping file-overlap
453
- // collisions against the already-admitted set AND against the footprints
454
- // reserved by Stories still in flight from an earlier beat.
455
- return admitStories({
456
- eligibleIds,
457
- byId,
458
- slots,
459
- reserved: Array.isArray(inFlightRecords) ? inFlightRecords : [],
460
- });
410
+ return { eligibleIds, byId };
461
411
  }
462
412
 
463
413
  /**
@@ -469,37 +419,102 @@ export function planReadySet({
469
419
  * ({@link reservesConcretePath}), while the same-beat guard also serializes
470
420
  * unknown-width footprints ({@link storiesOverlap}). See both for why.
471
421
  *
472
- * The reservation check runs **first**, so a Story racing both an in-flight
473
- * Story and a same-beat peer is reported against the in-flight one: that is
474
- * the longer-lived and more informative blocker (a Story that has been
475
- * implementing for beats, not one merely admitted a moment ago), and checking
476
- * it first is what makes the report complete — every withheld-by-reservation
477
- * Story appears in it. Ordering cannot change `selected`: a candidate either
478
- * rule rejects is skipped whichever runs first; only which list it is
479
- * reported in depends on the order.
422
+ * Which of the two a candidate is reported against is {@link blockingCollision}'s
423
+ * decision, not this loop's.
424
+ *
425
+ * Under `footprintGuard: 'advisory'` neither rule withholds: dispatch follows
426
+ * the declared `depends_on` edges alone. Detection is unchanged — every hit is
427
+ * still computed and recorded with `enforced: false` so advisory mode is a
428
+ * deliberate throughput trade an operator can read the cost of, not a blind
429
+ * spot (Story #5044).
480
430
  *
481
431
  * @param {object} args
482
432
  * @param {number[]} args.eligibleIds Ascending eligible Story ids.
483
433
  * @param {Map<number, StoryRecord>} args.byId
484
434
  * @param {number} args.slots Remaining dispatch capacity.
485
435
  * @param {StoryRecord[]} args.reserved In-flight Story records.
486
- * @returns {{ selected: StoryRecord[], withheldByInFlight: Array<{id: number, blockedBy: number}> }}
436
+ * @param {'enforce'|'advisory'} args.guardMode
437
+ * @param {object} args.evidence Evidence-scrape options.
438
+ * @returns {{ selected: StoryRecord[], withheldByInFlight: Array<{id: number, blockedBy: number}>, footprintWithholds: object[], guardMode: string }}
487
439
  */
488
- function admitStories({ eligibleIds, byId, slots, reserved }) {
440
+ function admitStories({
441
+ eligibleIds,
442
+ byId,
443
+ slots,
444
+ reserved,
445
+ guardMode,
446
+ evidence,
447
+ }) {
448
+ const enforced = guardMode !== GUARD_MODES.ADVISORY;
489
449
  const selected = [];
490
- const withheldByInFlight = [];
450
+ const footprintWithholds = [];
491
451
  for (const id of eligibleIds) {
492
452
  if (selected.length >= slots) break;
493
453
  const rec = byId.get(id);
494
- const blockedBy = findInFlightBlocker(rec, id, reserved);
495
- if (blockedBy !== null) {
496
- withheldByInFlight.push({ id, blockedBy });
497
- continue;
498
- }
499
- if (selected.some((picked) => storiesOverlap(picked, rec))) continue;
454
+ const hit = blockingCollision({ rec, id, selected, reserved, evidence });
455
+ if (hit) footprintWithholds.push({ id, ...hit, enforced });
456
+ if (hit && enforced) continue;
500
457
  selected.push(rec);
501
458
  }
502
- return { selected, withheldByInFlight };
459
+ return {
460
+ selected,
461
+ // The legacy cross-beat projection, kept at its original `{ id, blockedBy }`
462
+ // shape: it is `stories-wave-tick.js`'s long-standing reservation input and
463
+ // narrowing the scrape must not reshape it.
464
+ withheldByInFlight: footprintWithholds
465
+ .filter((w) => w.enforced && w.scope === WITHHOLD_SCOPES.IN_FLIGHT)
466
+ .map(({ id, blockedBy }) => ({ id, blockedBy })),
467
+ footprintWithholds,
468
+ guardMode,
469
+ };
470
+ }
471
+
472
+ /**
473
+ * The one footprint collision withholding this candidate, or `null`.
474
+ *
475
+ * The in-flight reservation is checked **first**, so a Story racing both an
476
+ * in-flight Story and a same-beat peer is reported against the in-flight one:
477
+ * that is the longer-lived and more informative blocker (a Story that has been
478
+ * implementing for beats, not one merely admitted a moment ago), and checking it
479
+ * first is what makes the reservation report complete. Order cannot change
480
+ * `selected` — a candidate either rule rejects is skipped whichever runs first.
481
+ *
482
+ * @param {object} args
483
+ * @param {StoryRecord} args.rec
484
+ * @param {number} args.id
485
+ * @param {StoryRecord[]} args.selected Peers already admitted this beat.
486
+ * @param {StoryRecord[]} args.reserved In-flight Story records.
487
+ * @param {object} args.evidence
488
+ * @returns {{ blockedBy: number, scope: string, paths: string[], source: string }|null}
489
+ */
490
+ function blockingCollision({ rec, id, selected, reserved, evidence }) {
491
+ const held = findInFlightBlocker(rec, id, reserved, evidence);
492
+ if (held) return { ...held, scope: WITHHOLD_SCOPES.IN_FLIGHT };
493
+ const peer = findBeatBlocker(rec, selected, evidence);
494
+ return peer ? { ...peer, scope: WITHHOLD_SCOPES.BEAT } : null;
495
+ }
496
+
497
+ /**
498
+ * The **already-admitted peer** whose footprint this candidate would race on
499
+ * this beat, with the colliding paths — or `null` when none does.
500
+ *
501
+ * Until Story #5044 this was an anonymous `continue`: the candidate was
502
+ * silently dropped from the beat and nothing in any envelope said why. An
503
+ * unfilled slot with no explanation is indistinguishable from a cap that was
504
+ * simply not reached, which is what let a footprint-widening artifact
505
+ * serialize a whole audit-derived plan without leaving a trace to notice.
506
+ *
507
+ * @param {StoryRecord} candidate
508
+ * @param {StoryRecord[]} selected Stories already admitted this beat.
509
+ * @param {object} [options]
510
+ * @returns {{ blockedBy: number, paths: string[], source: string }|null}
511
+ */
512
+ function findBeatBlocker(candidate, selected, options = {}) {
513
+ for (const picked of selected) {
514
+ const collision = detectCollision(picked, candidate, options);
515
+ if (collision) return { blockedBy: storyIdOf(picked), ...collision };
516
+ }
517
+ return null;
503
518
  }
504
519
 
505
520
  /**
@@ -520,13 +535,15 @@ function admitStories({ eligibleIds, byId, slots, reserved }) {
520
535
  * @param {StoryRecord} candidate
521
536
  * @param {number} candidateId
522
537
  * @param {StoryRecord[]} reserved
523
- * @returns {number|null}
538
+ * @param {object} [options]
539
+ * @returns {{ blockedBy: number, paths: string[], source: string }|null}
524
540
  */
525
- function findInFlightBlocker(candidate, candidateId, reserved) {
541
+ function findInFlightBlocker(candidate, candidateId, reserved, options = {}) {
526
542
  for (const held of reserved) {
527
543
  const heldId = storyIdOf(held);
528
544
  if (heldId === null || heldId === candidateId) continue;
529
- if (reservesConcretePath(held, candidate)) return heldId;
545
+ const collision = reservesConcretePath(held, candidate, options);
546
+ if (collision) return { blockedBy: heldId, ...collision };
530
547
  }
531
548
  return null;
532
549
  }
@@ -3,23 +3,25 @@
3
3
  *
4
4
  * The "is the worktree's work integrated upstream?" half of
5
5
  * `isSafeToRemove`. Runs the two-phase reachability gate the parent
6
- * documents: primary `merge-base --is-ancestor HEAD epicRef`, and a
7
- * fallback `git log --grep=resolves #<storyId>` against the Epic ref when
8
- * the ancestry check returns "not an ancestor".
6
+ * documents: primary `merge-base --is-ancestor HEAD baseRef`, and a
7
+ * `git cherry` patch-equivalence fallback when the ancestry check returns
8
+ * "not an ancestor".
9
9
  *
10
10
  * The fallback exists because a post-merge rebase or force-push can drop
11
- * the local branch ref off the merged tip the `(resolves #N)` token on
12
- * the Epic's `--no-ff` merge commit (emitted by
13
- * `story-close/merge-runner.js`) is the durable proof the Story was
14
- * integrated.
11
+ * the local branch ref off the merged tip, so SHA reachability alone
12
+ * under-reports integration.
13
+ *
14
+ * A third phase used to sit between the two: a `git log --merges
15
+ * --grep=resolves #<storyId>` probe for the `(resolves #N)` token on the
16
+ * Epic's `--no-ff` merge commit. Story #5006 removed it — v2 lands every
17
+ * Story as a **squash** merge onto `main`, which never writes that token,
18
+ * and the `--no-ff` emitter (`story-close/merge-runner.js`) went with the
19
+ * Epic pipeline. The probe could only ever return `false`.
15
20
  *
16
21
  * Pure with respect to the supplied `ctx` bag; the only side effects are
17
22
  * the `gitSpawn` calls.
18
23
  */
19
24
 
20
- import { parseStoryBranch } from '../../git-utils.js';
21
- import { resolvesGrepArgs } from '../../orchestration/resolves-token.js';
22
-
23
25
  /**
24
26
  * Resolve a worktree's `HEAD` to a full commit SHA via
25
27
  * `git rev-parse HEAD` (run inside the worktree). Returns
@@ -70,37 +72,6 @@ export function checkHeadAncestor(ctx, headSha, epicRef) {
70
72
  };
71
73
  }
72
74
 
73
- /**
74
- * Predicate: did the Epic ref accumulate a `--no-ff` merge commit whose
75
- * subject names this Story (e.g. `... (resolves #1851)`)? Returns `true`
76
- * when the grep finds at least one matching merge commit, `false` when it
77
- * returns empty or fails.
78
- *
79
- * Returns `false` for branches that do not match the canonical
80
- * `story-<id>` shape — the merge-commit subject contract is only
81
- * guaranteed for story branches.
82
- *
83
- * @param {object} ctx
84
- * @param {string} branch Worktree branch (e.g. `story-1851`).
85
- * @param {string} epicRef Epic branch ref (e.g. `epic/1831`).
86
- * @returns {boolean}
87
- */
88
- export function hasMergeCommitForStory(ctx, branch, epicRef) {
89
- const storyId = parseStoryBranch(branch);
90
- if (storyId === null) return false;
91
- const grep = ctx.git.gitSpawn(
92
- ctx.repoRoot,
93
- 'log',
94
- epicRef,
95
- '--merges',
96
- '-n',
97
- '1',
98
- '--pretty=%H',
99
- ...resolvesGrepArgs(storyId),
100
- );
101
- return grep.status === 0 && grep.stdout.trim().length > 0;
102
- }
103
-
104
75
  /**
105
76
  * Predicate: are every commit on `branch` patch-equivalent to a commit
106
77
  * already on `epicRef`? Runs `git cherry <epicRef> <branch>` and returns
@@ -140,7 +111,7 @@ export function hasRebasedEquivalents(ctx, branch, epicRef) {
140
111
  }
141
112
 
142
113
  /**
143
- * Run the full three-phase merge-reachability gate. Returns the same
114
+ * Run the full two-phase merge-reachability gate. Returns the same
144
115
  * `{ safe, reason }` envelope `isSafeToRemove` does, so callers can chain
145
116
  * the verdict directly into the parent return value.
146
117
  *
@@ -165,9 +136,6 @@ export async function checkMergeReachability(ctx, wtPath, branch, epicRef) {
165
136
  };
166
137
  }
167
138
 
168
- if (hasMergeCommitForStory(ctx, branch, epicRef)) {
169
- return { safe: true, reason: 'merge-commit-reachable' };
170
- }
171
139
  if (hasRebasedEquivalents(ctx, branch, epicRef)) {
172
140
  return { safe: true, reason: 'rebased-equivalents' };
173
141
  }
@@ -47,11 +47,10 @@ const WINDOWS_CWD_RE =
47
47
  * (branch advanced to a SHA still reachable from the Epic merge commit).
48
48
  *
49
49
  * When HEAD is no longer an ancestor (force-push that drops or rewrites
50
- * the merged tip), the function falls back to a `merge-commit-reachable`
51
- * check: search the Epic ref for a `--no-ff` merge commit whose subject
52
- * carries this Story's `(resolves #<id>)` token. Such a merge commit
53
- * proves the Story branch was integrated even though the current HEAD
54
- * has diverged, so the worktree is still safe to reap.
50
+ * the merged tip), the function falls back to a `rebased-equivalents`
51
+ * check: `git cherry` proves every commit on the branch is already
52
+ * upstream by patch-id even though the current HEAD has diverged, so the
53
+ * worktree is still safe to reap.
55
54
  *
56
55
  * `opts.epicBranch` is the integration / base ref the Story must already
57
56
  * be merged into (e.g. `main` or a plan-run branch).
@@ -54,9 +54,8 @@ export class WorktreeManager {
54
54
  * @param {(phase: 'worktree-create'|'bootstrap'|'install') => void} [opts.onPhase]
55
55
  * Optional phase-boundary callback fired from `ensure()` just before each
56
56
  * internal phase (git worktree add, bootstrap-file copy, dependency
57
- * install). Consumed by `story-init` to drive `phase-timer.mark()`
58
- * so `[phase-timing]` log lines attribute wall-clock correctly to the
59
- * sub-phases of worktree provisioning.
57
+ * install), so a caller can attribute wall-clock to the sub-phases of
58
+ * worktree provisioning.
60
59
  */
61
60
  constructor({
62
61
  repoRoot,
@@ -38,8 +38,8 @@
38
38
  import { readdirSync, readFileSync } from 'node:fs';
39
39
  import path from 'node:path';
40
40
  import { fileURLToPath } from 'node:url';
41
-
42
41
  import { runAsCli } from './lib/cli-utils.js';
42
+ import { walkFilesByExtension } from './lib/fs-walk.js';
43
43
 
44
44
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
45
45
  const REPO_ROOT = path.resolve(__dirname, '..', '..');
@@ -71,28 +71,6 @@ export const KNOWN_AXES = Object.freeze([
71
71
  'planning',
72
72
  ]);
73
73
 
74
- /**
75
- * Walk a directory tree synchronously, yielding absolute paths of files
76
- * matching `.md`. Mirrors the walker shape in `check-lifecycle-lint.js`.
77
- */
78
- function* walkMd(dir) {
79
- let entries;
80
- try {
81
- entries = readdirSync(dir, { withFileTypes: true });
82
- } catch (err) {
83
- if (err.code === 'ENOENT') return;
84
- throw err;
85
- }
86
- for (const entry of entries) {
87
- const p = path.join(dir, entry.name);
88
- if (entry.isDirectory()) {
89
- yield* walkMd(p);
90
- } else if (entry.isFile() && entry.name.endsWith('.md')) {
91
- yield p;
92
- }
93
- }
94
- }
95
-
96
74
  /**
97
75
  * Resolve a mixed list of file/directory paths into the flat set of
98
76
  * markdown files to scan. Missing paths are silently skipped (the
@@ -116,7 +94,7 @@ function* resolveTargets(targets) {
116
94
  for (const entry of stat) {
117
95
  const p = path.join(target, entry.name);
118
96
  if (entry.isDirectory()) {
119
- yield* walkMd(p);
97
+ yield* walkFilesByExtension(p, '.md');
120
98
  } else if (entry.isFile() && entry.name.endsWith('.md')) {
121
99
  yield p;
122
100
  }
@@ -6,10 +6,12 @@
6
6
  *
7
7
  * Polls the PR's required checks to a terminal state and auto-recovers
8
8
  * from `mergeStateStatus: BEHIND` (via bounded `gh pr update-branch`
9
- * calls) by delegating to the shared `watchPrToTerminal` primitive in
10
- * the lifecycle `Watcher` the SAME loop the listener runs, so the CLI
11
- * and the bus path are byte-for-byte equivalent. No lifecycle bus is
12
- * created; this is a direct, synchronous watch with a real exit code.
9
+ * calls) by delegating to the `watchPrToTerminal` primitive in
10
+ * `lib/orchestration/pr-watch.js`. That primitive was shared with the
11
+ * `Watcher` bus listener until Story #5006 deleted it (nothing emitted at
12
+ * it); this CLI is now its only caller. Story #5024 retired the bus
13
+ * outright, so there is no bus to create — this is a direct, synchronous
14
+ * watch with a real exit code.
13
15
  *
14
16
  * Slow-vs-failed semantics (Story #4358):
15
17
  * - GREEN — every required check terminal + green → exit 0, unless the
@@ -100,7 +102,7 @@ import {
100
102
  retireCiDigest,
101
103
  writeCiDigest,
102
104
  } from './lib/orchestration/ci-rerun-guard.js';
103
- import { watchPrToTerminal } from './lib/orchestration/lifecycle/listeners/watcher.js';
105
+ import { watchPrToTerminal } from './lib/orchestration/pr-watch.js';
104
106
  import { enableAutoMergeWith } from './lib/orchestration/single-story-close/phases/auto-merge.js';
105
107
  import { sleep as defaultSleep } from './lib/util/poll-loop.js';
106
108