mandrel 1.58.0 → 1.59.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 (129) hide show
  1. package/.agents/README.md +89 -87
  2. package/.agents/docs/SDLC.md +11 -7
  3. package/.agents/docs/workflows.md +2 -1
  4. package/.agents/schemas/audit-rules.json +20 -0
  5. package/.agents/scripts/acceptance-eval.js +20 -3
  6. package/.agents/scripts/assert-branch.js +1 -3
  7. package/.agents/scripts/bootstrap.js +1 -1
  8. package/.agents/scripts/check-arch-cycles.js +360 -0
  9. package/.agents/scripts/coverage-capture.js +24 -3
  10. package/.agents/scripts/epic-deliver-preflight.js +5 -3
  11. package/.agents/scripts/epic-deliver-prepare.js +12 -4
  12. package/.agents/scripts/epic-execute-record-wave.js +1 -1
  13. package/.agents/scripts/evidence-gate.js +1 -1
  14. package/.agents/scripts/git-rebase-and-resolve.js +1 -1
  15. package/.agents/scripts/hierarchy-gate.js +34 -14
  16. package/.agents/scripts/lib/baselines/kinds/coverage.js +33 -149
  17. package/.agents/scripts/lib/baselines/kinds/duplication.js +27 -116
  18. package/.agents/scripts/lib/baselines/kinds/kind-factory.js +192 -0
  19. package/.agents/scripts/lib/baselines/kinds/lighthouse.js +34 -133
  20. package/.agents/scripts/lib/baselines/kinds/maintainability.js +31 -124
  21. package/.agents/scripts/lib/baselines/kinds/mutation.js +25 -111
  22. package/.agents/scripts/lib/baselines/maintainability-baseline-io.js +59 -0
  23. package/.agents/scripts/lib/baselines/maintainability-baseline-save.js +37 -0
  24. package/.agents/scripts/lib/baselines/writer.js +1 -1
  25. package/.agents/scripts/lib/close-validation/commands.js +188 -0
  26. package/.agents/scripts/lib/close-validation/gates.js +235 -0
  27. package/.agents/scripts/lib/close-validation/process.js +101 -0
  28. package/.agents/scripts/lib/close-validation/projections/maintainability.js +1 -1
  29. package/.agents/scripts/lib/close-validation/runner.js +325 -0
  30. package/.agents/scripts/lib/close-validation/telemetry.js +70 -0
  31. package/.agents/scripts/lib/config/quality.js +6 -6
  32. package/.agents/scripts/lib/config-resolver.js +2 -5
  33. package/.agents/scripts/lib/coverage-capture.js +147 -4
  34. package/.agents/scripts/lib/cpu-pool.js +14 -0
  35. package/.agents/scripts/lib/crap-utils.js +6 -11
  36. package/.agents/scripts/lib/dynamic-workflow/documentation-report-contract.js +87 -0
  37. package/.agents/scripts/lib/git-utils.js +24 -22
  38. package/.agents/scripts/lib/maintainability-engine.js +1 -1
  39. package/.agents/scripts/lib/maintainability-utils.js +4 -187
  40. package/.agents/scripts/lib/observability/perf-report-readers.js +32 -23
  41. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +80 -6
  42. package/.agents/scripts/lib/orchestration/code-review.js +90 -77
  43. package/.agents/scripts/lib/orchestration/dispatch-pipeline.js +5 -12
  44. package/.agents/scripts/lib/orchestration/epic-deliver-lease-guard.js +14 -14
  45. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/planning-artifacts.js +2 -2
  46. package/.agents/scripts/lib/orchestration/epic-plan-lease-guard.js +184 -49
  47. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/drain.js +1 -1
  48. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/plan-epic.js +26 -2
  49. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +26 -6
  50. package/.agents/scripts/lib/orchestration/epic-runner/phases/build-wave-dag.js +7 -20
  51. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +1 -2
  52. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/signals.js +0 -6
  53. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/component-drift.js +103 -0
  54. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/crap-drift.js +22 -64
  55. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js +38 -76
  56. package/.agents/scripts/lib/orchestration/epic-runner/story-run-progress-writer.js +2 -2
  57. package/.agents/scripts/lib/orchestration/epic-runner/sub-agent-return.js +4 -16
  58. package/.agents/scripts/lib/orchestration/file-assumptions.js +4 -3
  59. package/.agents/scripts/lib/orchestration/lease-guard-shared.js +144 -0
  60. package/.agents/scripts/lib/orchestration/lifecycle/emit-story-heartbeat.js +2 -2
  61. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +7 -7
  62. package/.agents/scripts/lib/orchestration/post-merge/phases/notification.js +3 -3
  63. package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +7 -7
  64. package/.agents/scripts/lib/orchestration/preflight-cache.js +35 -12
  65. package/.agents/scripts/lib/orchestration/review-providers/codex.js +5 -60
  66. package/.agents/scripts/lib/orchestration/review-providers/native.js +7 -6
  67. package/.agents/scripts/lib/orchestration/review-providers/parse-findings.js +105 -0
  68. package/.agents/scripts/lib/orchestration/review-providers/security-review.js +7 -59
  69. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +2 -4
  70. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +1 -1
  71. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +2 -4
  72. package/.agents/scripts/lib/orchestration/single-story-lease-guard.js +32 -35
  73. package/.agents/scripts/lib/orchestration/skill-capsule-loader.js +1 -2
  74. package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +451 -503
  75. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/pre-merge-attribution.js +8 -2
  76. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/refresh-commit.js +47 -2
  77. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/regression-projection.js +2 -2
  78. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +358 -54
  79. package/.agents/scripts/lib/orchestration/story-close/phases/close.js +1 -1
  80. package/.agents/scripts/lib/orchestration/story-close/phases/gates.js +3 -2
  81. package/.agents/scripts/lib/orchestration/story-close/phases/locked-pipeline.js +30 -3
  82. package/.agents/scripts/lib/orchestration/story-close/post-merge-close.js +5 -18
  83. package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +3 -3
  84. package/.agents/scripts/lib/orchestration/story-close-recovery.js +33 -16
  85. package/.agents/scripts/lib/orchestration/story-reachability.js +47 -0
  86. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +2 -33
  87. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +42 -64
  88. package/.agents/scripts/lib/orchestration/ticketing/reads.js +9 -0
  89. package/.agents/scripts/lib/orchestration/ticketing/state.js +50 -436
  90. package/.agents/scripts/lib/orchestration/ticketing/transition.js +471 -0
  91. package/.agents/scripts/lib/orchestration/ticketing.js +0 -1
  92. package/.agents/scripts/lib/orchestration/wave-record-notifications.js +1 -1
  93. package/.agents/scripts/lib/orchestration/wave-record-projection.js +1 -7
  94. package/.agents/scripts/lib/project-root.js +17 -0
  95. package/.agents/scripts/lib/story-adjacency.js +76 -0
  96. package/.agents/scripts/lib/story-lifecycle.js +1 -1
  97. package/.agents/scripts/lib/transpile.js +93 -0
  98. package/.agents/scripts/lib/wave-runner/tick.js +4 -153
  99. package/.agents/scripts/lib/workers/crap-worker.js +1 -1
  100. package/.agents/scripts/lib/workers/maintainability-report-worker.js +1 -1
  101. package/.agents/scripts/lib/worktree/lifecycle/creation.js +20 -2
  102. package/.agents/scripts/lib/worktree/lifecycle/force-drain.js +90 -0
  103. package/.agents/scripts/lib/worktree/lifecycle/reap.js +26 -8
  104. package/.agents/scripts/lib/worktree/node-modules-strategy.js +74 -0
  105. package/.agents/scripts/providers/github/tickets.js +110 -6
  106. package/.agents/scripts/run-lint.js +9 -0
  107. package/.agents/scripts/run-tests.js +24 -4
  108. package/.agents/scripts/stories-wave-tick.js +8 -5
  109. package/.agents/scripts/story-init.js +149 -10
  110. package/.agents/scripts/sync-branch-from-base.js +1 -1
  111. package/.agents/skills/stack/qa/lighthouse-baseline/SKILL.md +1 -1
  112. package/.agents/workflows/audit-documentation.md +226 -0
  113. package/.agents/workflows/epic-deliver.md +16 -23
  114. package/.agents/workflows/epic-plan.md +1 -1
  115. package/.agents/workflows/helpers/epic-deliver-story.md +17 -28
  116. package/.agents/workflows/helpers/single-story-deliver.md +2 -1
  117. package/.agents/workflows/onboard.md +4 -3
  118. package/.agents/workflows/story-deliver.md +1 -1
  119. package/README.md +13 -8
  120. package/lib/cli/init.js +336 -0
  121. package/package.json +2 -1
  122. package/.agents/scripts/lib/auto-refresh-baselines.js +0 -308
  123. package/.agents/scripts/lib/close-validation.js +0 -897
  124. package/.agents/scripts/lib/orchestration/cascade-grouping.js +0 -275
  125. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter.js +0 -69
  126. package/.agents/scripts/lib/orchestration/story-close/format-autofix-scoped.js +0 -221
  127. package/.agents/scripts/lib/orchestration/story-close/format-autofix-shared.js +0 -123
  128. package/.agents/scripts/lib/task-utils.js +0 -26
  129. package/.agents/scripts/story-deliver-prepare.js +0 -267
@@ -0,0 +1,471 @@
1
+ /**
2
+ * lib/orchestration/ticketing/transition.js — Single-ticket state mutators.
3
+ *
4
+ * Owns the one-ticket-at-a-time mutation surface: state-label
5
+ * transitions, tasklist checkbox toggling, and structured-comment
6
+ * posting. Extracted from `./state.js` under Story #3995 to break the
7
+ * `state.js ↔ bulk.js` import cycle: `bulk.js`'s cascade walk needs these
8
+ * single-ticket primitives, and `transitionTicketState` needs the upward
9
+ * cascade. Pulling the primitives down into this leaf lets both
10
+ * `state.js` and `bulk.js` depend **downward** on `transition.js`, so the
11
+ * dependency graph is a DAG.
12
+ *
13
+ * Cascade wiring: `transitionTicketState` fires the upward parent cascade
14
+ * (which lives in `bulk.js`) on every transition unless `cascade: false`.
15
+ * To keep this module a leaf, the cascade implementation is **injected**
16
+ * via {@link registerCascadeRunner} rather than imported. `bulk.js`
17
+ * registers the real runner at module-evaluation time; until it does,
18
+ * the cascade step is a safe no-op. Every production path that fires a
19
+ * transition reaches `bulk.js` (directly, through the `./ticketing.js`
20
+ * facade, or through `./state.js`, all of which load `bulk.js`), so the
21
+ * runner is always registered before a cascade-bearing transition runs.
22
+ *
23
+ * Story #3661 — `_columnSyncRegistry` is a module-level WeakMap that
24
+ * retains one `ColumnSync` instance per provider for the lifetime of the
25
+ * process. `ColumnSync` already caches the project metadata (projectId,
26
+ * fieldId, option ids) inside the instance after the first GraphQL fetch
27
+ * (`this._meta`), but that cache was discarded on every label transition
28
+ * because `syncProjectStatusColumn` constructed a fresh instance each
29
+ * call. The registry lets the instance — and therefore its `_meta`
30
+ * cache — survive across transitions, so the invariant metadata is
31
+ * resolved exactly once per run rather than once per label flip.
32
+ */
33
+
34
+ import { extractEpicIdFromBody } from '../../dependency-parser.js';
35
+ import { Logger } from '../../Logger.js';
36
+ import {
37
+ eventSeverity,
38
+ renderTransitionMessage,
39
+ } from '../../notifications/notifier.js';
40
+ import { ColumnSync } from '../column-sync.js';
41
+ import {
42
+ ALL_STATES,
43
+ assertValidStructuredCommentType,
44
+ invalidateRawCommentsCache,
45
+ STATE_LABELS,
46
+ } from './reads.js';
47
+
48
+ /**
49
+ * Injected upward-cascade runner. `bulk.js` registers the real
50
+ * implementation (`cascadeParentState` + `logCascadePartialFailures`) at
51
+ * module load via {@link registerCascadeRunner}; until then this no-op
52
+ * default keeps `transitionTicketState` safe to call in isolation (e.g.
53
+ * a unit test that imports only this module).
54
+ *
55
+ * @type {(provider: object, ticketId: number, opts: object) => Promise<void>}
56
+ */
57
+ let _runUpwardCascade = async () => {};
58
+
59
+ /**
60
+ * Register the upward-cascade runner. Called once by `bulk.js` at
61
+ * module-evaluation time to wire its `cascadeParentState` /
62
+ * `logCascadePartialFailures` pair into `transitionTicketState` without
63
+ * `transition.js` importing `bulk.js` (which would re-introduce the
64
+ * Story #3995 cycle).
65
+ *
66
+ * @param {(provider: object, ticketId: number, opts: object) => Promise<void>} runner
67
+ */
68
+ export function registerCascadeRunner(runner) {
69
+ if (typeof runner === 'function') {
70
+ _runUpwardCascade = runner;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * One `ColumnSync` instance per provider, retained for the process
76
+ * lifetime so the invariant project metadata (`projectId`, `fieldId`,
77
+ * option ids) is fetched exactly once per run regardless of how many
78
+ * label transitions fire. The `ColumnSync` instance already caches
79
+ * the metadata internally (`_meta`), but that cache was thrown away on
80
+ * every call to `syncProjectStatusColumn` because the function
81
+ * constructed a fresh instance each time. Story #3661.
82
+ *
83
+ * `WeakMap` keys are GC-friendly: when a provider is collected the
84
+ * entry is automatically removed without a manual eviction step.
85
+ */
86
+ const _columnSyncRegistry = new WeakMap();
87
+
88
+ /**
89
+ * Drop the cached `ColumnSync` for a given provider. Exposed as a
90
+ * named export so unit tests that swap providers between assertions can
91
+ * reset the registry without reloading the module.
92
+ *
93
+ * @param {object} provider
94
+ */
95
+ export function _resetColumnSyncCache(provider) {
96
+ _columnSyncRegistry.delete(provider);
97
+ }
98
+
99
+ /**
100
+ * Guard the inputs to {@link transitionTicketState}. Extracted from the
101
+ * outer function so that the per-method cyclomatic complexity of
102
+ * `transitionTicketState` lands below the CRAP-12 ceiling required by
103
+ * Story #1848 (was CRAP 16 prior to the split — see baselines/crap.json).
104
+ *
105
+ * Currently a single label-membership predicate, but extracting it as a
106
+ * named function lets future input guards (e.g. provider-shape checks,
107
+ * concurrency-token validation) accrete here without re-inflating the
108
+ * caller's complexity.
109
+ *
110
+ * @param {string} newState - Target `agent::*` label.
111
+ * @returns {string} The validated newState, returned for fluent reuse.
112
+ * @throws {Error} when `newState` is not a recognised state label.
113
+ */
114
+ function validateTransitionInputs(newState) {
115
+ if (!ALL_STATES.includes(newState)) {
116
+ throw new Error(`Invalid state: ${newState}`);
117
+ }
118
+ return newState;
119
+ }
120
+
121
+ /**
122
+ * Resolve the pre-transition ticket snapshot that drives the notify
123
+ * payload and the provider's label-merge path. Honors the caller-supplied
124
+ * `opts.ticketSnapshot` (Story #1795) when present; otherwise issues a
125
+ * best-effort `getTicket` and returns `null` on transient failure.
126
+ *
127
+ * @param {object} provider
128
+ * @param {{ notify?: Function, ticketSnapshot?: object|null }} opts
129
+ * @param {number} ticketId
130
+ * @returns {Promise<object|null>}
131
+ */
132
+ async function loadTicketSnapshot(provider, opts, ticketId) {
133
+ if (opts.ticketSnapshot) return opts.ticketSnapshot;
134
+ if (!opts.notify || typeof provider.getTicket !== 'function') return null;
135
+ try {
136
+ return await provider.getTicket(ticketId);
137
+ } catch (err) {
138
+ Logger.debug(
139
+ `[Ticketing] fromState lookup failed for #${ticketId}: ${err.message ?? err}`,
140
+ );
141
+ return null;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Mirror the post-flip label set onto the GitHub Projects v2 Status
147
+ * column. Story #2548 — wiring this here makes every caller of
148
+ * `transitionTicketState` (story-init, story-close, story-phase,
149
+ * the LabelTransitioner lifecycle listener, the update-ticket-state CLI,
150
+ * batch transitions) update the board automatically. Prior to #2548 the
151
+ * sync was only wired from the epic-runner against the Epic ticket, so
152
+ * Stories and Tasks never had their `agent::executing` /
153
+ * `agent::blocked` flips reflected on the board.
154
+ *
155
+ * Best-effort: a project-board misconfig, missing scope, or transient
156
+ * GraphQL failure MUST NOT block the label transition itself. Errors
157
+ * surface via `Logger.warn` and the function resolves cleanly.
158
+ *
159
+ * The `_makeColumnSync` default param is a DIP seam: production callers
160
+ * accept the default (which constructs a real `ColumnSync`); tests inject
161
+ * a factory stub that avoids the GraphQL dependency without mocking the
162
+ * module. Story #3645.
163
+ *
164
+ * Story #3661 — when the caller uses the default factory (i.e. did not
165
+ * inject a stub), the function looks up or creates a `ColumnSync`
166
+ * instance in `_columnSyncRegistry` keyed by `provider`. This keeps the
167
+ * instance — and therefore its `_meta` cache — alive across transitions
168
+ * so the invariant project metadata is fetched exactly once per run.
169
+ * Test-injected factories bypass the registry entirely; their synthetic
170
+ * stubs are never stored in `_columnSyncRegistry`.
171
+ *
172
+ * @param {object} provider
173
+ * @param {number} ticketId
174
+ * @param {string} newState
175
+ * @param {(opts: object) => { sync: (id: number, labels: string[]) => Promise<object> }} [_makeColumnSync]
176
+ */
177
+ async function syncProjectStatusColumn(
178
+ provider,
179
+ ticketId,
180
+ newState,
181
+ _makeColumnSync,
182
+ ) {
183
+ try {
184
+ let sync;
185
+ if (_makeColumnSync) {
186
+ // Test-injected factory: bypass the registry so stubs are never
187
+ // accidentally cached and reused in subsequent calls.
188
+ sync = _makeColumnSync({ provider, logger: Logger });
189
+ } else {
190
+ // Production path: look up or create the per-provider instance.
191
+ // The instance's `_meta` cache survives across label transitions
192
+ // so the invariant project metadata (projectId, fieldId, options)
193
+ // is only fetched once per process run. Story #3661.
194
+ if (!_columnSyncRegistry.has(provider)) {
195
+ _columnSyncRegistry.set(
196
+ provider,
197
+ new ColumnSync({ provider, logger: Logger }),
198
+ );
199
+ }
200
+ sync = _columnSyncRegistry.get(provider);
201
+ }
202
+ await sync.sync(ticketId, [newState]);
203
+ } catch (err) {
204
+ Logger.warn(
205
+ `[Ticketing] column sync failed for #${ticketId} → ${newState}: ${err?.message ?? err}`,
206
+ );
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Dispatch the state-transition notification once the label flip has
212
+ * landed. Pulled out of `transitionTicketState` so the outer function
213
+ * stays below the CRAP-12 ceiling: this is where the severity gating,
214
+ * the ticket-type derivation, the level mapping, and the fire-and-forget
215
+ * dispatch all live.
216
+ *
217
+ * @param {{
218
+ * notify: Function,
219
+ * ticketId: number,
220
+ * ticketSnapshot: object|null,
221
+ * fromState: string|null,
222
+ * newState: string,
223
+ * }} args
224
+ */
225
+ function dispatchTransitionNotification(args) {
226
+ const { notify, ticketId, ticketSnapshot, fromState, newState } = args;
227
+ const typeLabel =
228
+ ticketSnapshot?.labels?.find((l) => l.startsWith('type::')) ?? '';
229
+ const ticketType = typeLabel.replace(/^type::/, '') || 'ticket';
230
+ const epicId = extractEpicIdFromBody(ticketSnapshot?.body) ?? null;
231
+ const event = {
232
+ kind: 'state-transition',
233
+ ticket: {
234
+ id: ticketId,
235
+ title: ticketSnapshot?.title,
236
+ type: ticketType,
237
+ },
238
+ fromState,
239
+ toState: newState,
240
+ };
241
+ const severity = eventSeverity(event);
242
+ // Suppress the dispatch entirely for low-severity transitions (task-
243
+ // level, or non-terminal story / epic flips). Pre-migration the
244
+ // comment channel filtered these out via `commentMinLevel: medium`;
245
+ // post-migration the channel is event-allowlist gated and would
246
+ // surface every transition equally, so the noise filter moves to
247
+ // the emit point.
248
+ if (severity === 'low') return;
249
+ const message = renderTransitionMessage(event);
250
+ // Post to the epic so operators get a single timeline feed; fall back
251
+ // to the transitioned ticket itself when no epic reference is present.
252
+ // The dispatch is fire-and-forget by design (a failed notification must
253
+ // not block the state transition itself), but surfacing the failure via
254
+ // the logger preserves operator visibility — the previous empty-handler
255
+ // .catch swallowed network blips and webhook 5xxs without any signal.
256
+ const targetId = epicId ?? ticketId;
257
+ const level =
258
+ ticketType === 'epic' || ticketType === 'wave' || ticketType === 'story'
259
+ ? ticketType
260
+ : 'task';
261
+ Promise.resolve(
262
+ notify(targetId, {
263
+ severity,
264
+ message,
265
+ event: 'state-transition',
266
+ level,
267
+ epicId: epicId ?? undefined,
268
+ }),
269
+ ).catch((err) => {
270
+ Logger.warn(
271
+ `[Ticketing] notify dispatch failed for #${targetId}: ${err?.message ?? err}`,
272
+ );
273
+ });
274
+ }
275
+
276
+ /**
277
+ * Transitions a ticket's label to the new state.
278
+ * Removes other agent:: state labels.
279
+ *
280
+ * @param {import('../../ITicketingProvider.js').ITicketingProvider} provider
281
+ * @param {number} ticketId
282
+ * @param {string} newState - Must be one of STATE_LABELS.
283
+ * @param {{ notify?: Function, cascade?: boolean, ticketSnapshot?: object, _makeColumnSync?: Function }} [opts]
284
+ * Optional notify function (the exported `notify(ticketId, payload, opts)`
285
+ * from `notify.js`, or any stub matching its shape). When provided, a
286
+ * state-transition notification fires after a successful transition.
287
+ * Story/Epic → `agent::done` events are dispatched as `medium`; all other
288
+ * transitions are `low` and filtered out at the default `medium` channel
289
+ * thresholds. The dispatched payload carries the typed envelope fields
290
+ * (`event: 'state-transition'`, `level: 'task'|'story'|'wave'|'epic'`,
291
+ * `epicId`) for routable webhook subscribers.
292
+ *
293
+ * `cascade` (default `true`) controls whether a `done` transition fans the
294
+ * `cascadeCompletion` upward to parents. Per-Task closes invoked mid-Story
295
+ * from the retired per-Task progress writer (4-tier era, removed under
296
+ * #3157) passed `cascade: false` so the Story/Epic only flipped to
297
+ * `agent::done` at story-close (after the merge lands), not when the
298
+ * last Task commit landed on the still-unmerged Story branch. The
299
+ * parameter is preserved for callers that still suppress cascade
300
+ * explicitly (e.g. batch-transition helpers).
301
+ *
302
+ * `ticketSnapshot` (Story #1795 / Epic #1788) is an optional pre-fetched
303
+ * ticket object. When the caller already holds the ticket (e.g.
304
+ * `batchTransitionTickets`, which loops over a list it just hydrated),
305
+ * passing the snapshot eliminates the two `getTicket` round-trips that
306
+ * `transitionTicketState` would otherwise issue — one for the notify
307
+ * `fromState` lookup and one inside `provider.updateTicket`'s label
308
+ * merge path. Backwards compatible: when omitted, behaviour is unchanged.
309
+ *
310
+ * `_makeColumnSync` (Story #3645 DIP seam) — factory for the board-sync
311
+ * object. Production callers omit it (the default constructs a real
312
+ * `ColumnSync`); tests inject a stub to avoid GraphQL calls without
313
+ * module-level mocking.
314
+ */
315
+ export async function transitionTicketState(
316
+ provider,
317
+ ticketId,
318
+ newState,
319
+ opts = {},
320
+ ) {
321
+ validateTransitionInputs(newState);
322
+
323
+ const toRemove = ALL_STATES.filter((state) => state !== newState);
324
+
325
+ // Snapshot prior state for the notification payload (best-effort; skip on
326
+ // error). A transient read failure MUST NOT block a label transition —
327
+ // the transition itself is idempotent and `fromState: null` is a valid
328
+ // payload value.
329
+ //
330
+ // Story #1795 — when the caller threads `opts.ticketSnapshot` we reuse
331
+ // it as the notify snapshot without issuing a fresh `getTicket`. The
332
+ // snapshot is also forwarded to `provider.updateTicket` so the label
333
+ // merge path skips its own `getTicket` call (the second of the two
334
+ // round-trips this seam eliminates).
335
+ const ticketSnapshot = await loadTicketSnapshot(provider, opts, ticketId);
336
+ const fromState =
337
+ ticketSnapshot?.labels?.find((l) => ALL_STATES.includes(l)) ?? null;
338
+
339
+ // Closing/reopening mirrors the label state so GitHub shows the correct
340
+ // issue state without requiring a separate manual close step.
341
+ const isDone = newState === STATE_LABELS.DONE;
342
+
343
+ await provider.updateTicket(ticketId, {
344
+ labels: {
345
+ add: [newState],
346
+ remove: toRemove,
347
+ },
348
+ state: isDone ? 'closed' : 'open',
349
+ state_reason: isDone ? 'completed' : null,
350
+ // Internal-only escape hatch threaded through `provider.updateTicket`
351
+ // to `_applyLabelMutations`. Honored by `providers/github.js`; ignored
352
+ // by providers that don't recognise it. Underscore-prefixed to mark
353
+ // it as a provider-internal contract rather than part of the public
354
+ // `mutations` shape.
355
+ _ticketSnapshot: ticketSnapshot,
356
+ });
357
+
358
+ // Story #2548 — mirror the new state onto the Projects v2 Status
359
+ // column. Best-effort; never blocks the transition.
360
+ // Story #3645 — thread the DIP seam so callers can inject a stub.
361
+ await syncProjectStatusColumn(
362
+ provider,
363
+ ticketId,
364
+ newState,
365
+ opts._makeColumnSync,
366
+ );
367
+
368
+ // Automatically trigger upward cascade on every transition (Story
369
+ // #2676). The unified entry point is `cascadeParentState`, which:
370
+ // - delegates `agent::done` transitions to the legacy
371
+ // `cascadeCompletion` (preserving tasklist-checkbox toggling, the
372
+ // "All child tickets completed" progress comment, and the Epic
373
+ // close-exclusion);
374
+ // - for every other `agent::*` transition (`executing`, `blocked`,
375
+ // `closing`, …) walks the parent chain and updates each parent to
376
+ // the state derived from its children's current composition. This
377
+ // keeps the GitHub Project board accurate when work begins on a
378
+ // Task ("In Progress" surfaces up to the Story and Epic) or when a
379
+ // child enters the HITL pause state.
380
+ //
381
+ // The cascade implementation lives in `bulk.js` and is injected via
382
+ // `registerCascadeRunner` (Story #3995) so this module stays a leaf in
383
+ // the import graph. Callers that intentionally suppress propagation
384
+ // (historically the per-Task progress writer, which closed Tasks at
385
+ // commit-time but deferred the Story flip to story-close after the
386
+ // branch was merged) opt out by passing `cascade: false`.
387
+ if (opts.cascade !== false) {
388
+ await _runUpwardCascade(provider, ticketId, {
389
+ notify: opts.notify,
390
+ });
391
+ }
392
+
393
+ // Fire the state-transition notification (fire-and-forget).
394
+ if (typeof opts.notify === 'function') {
395
+ dispatchTransitionNotification({
396
+ notify: opts.notify,
397
+ ticketId,
398
+ ticketSnapshot,
399
+ fromState,
400
+ newState,
401
+ });
402
+ }
403
+ }
404
+
405
+ /**
406
+ * Mutates the tasklist checkbox in the parent's body.
407
+ * E.g., `- [ ] #123` to `- [x] #123`
408
+ *
409
+ * Story #3645 — positional `checked` boolean replaced with a named
410
+ * `{ checked }` options bag to eliminate the boolean-trap smell (SRP /
411
+ * naming clarity audit finding). All call sites updated in the same PR
412
+ * per the No-Shim cutover rule.
413
+ *
414
+ * @param {import('../../ITicketingProvider.js').ITicketingProvider} provider
415
+ * @param {number} ticketId - ID of parent ticket
416
+ * @param {number} subIssueId - ID of child ticket
417
+ * @param {{ checked: boolean }} opts
418
+ */
419
+ export async function toggleTasklistCheckbox(
420
+ provider,
421
+ ticketId,
422
+ subIssueId,
423
+ { checked },
424
+ ) {
425
+ const ticket = await provider.getTicket(ticketId);
426
+ const body = ticket.body || '';
427
+
428
+ if (!body.includes(`#${subIssueId}`)) {
429
+ return; // sub-issue not directly referenced in body
430
+ }
431
+
432
+ const targetBox = checked ? '- [x]' : '- [ ]';
433
+
434
+ let newBody = body;
435
+
436
+ if (checked) {
437
+ // replace `- [ ] #123` or `- [] #123` with `- [x] #123`
438
+ const re = new RegExp(`-\\s*\\[\\s*\\]\\s+#${subIssueId}\\b`, 'g');
439
+ newBody = newBody.replace(re, `${targetBox} #${subIssueId}`);
440
+ } else {
441
+ // replace `- [x] #123` or `- [X] #123` with `- [ ] #123`
442
+ const re = new RegExp(`-\\s*\\[[xX]\\]\\s+#${subIssueId}\\b`, 'g');
443
+ newBody = newBody.replace(re, `${targetBox} #${subIssueId}`);
444
+ }
445
+
446
+ if (newBody !== body) {
447
+ await provider.updateTicket(ticketId, {
448
+ body: newBody,
449
+ });
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Post a structured comment to a ticket.
455
+ *
456
+ * @param {import('../../ITicketingProvider.js').ITicketingProvider} provider
457
+ * @param {number} ticketId
458
+ * @param {'progress'|'friction'|'notification'} type
459
+ * @param {string} payload
460
+ */
461
+ export async function postStructuredComment(provider, ticketId, type, payload) {
462
+ assertValidStructuredCommentType(type);
463
+ await provider.postComment(ticketId, {
464
+ type,
465
+ body: payload,
466
+ });
467
+ // Story #2465 — evict the raw-comments cache entry so the next
468
+ // `findStructuredComment` against this ticket re-fetches and sees the
469
+ // freshly-posted comment.
470
+ invalidateRawCommentsCache(provider, ticketId);
471
+ }
@@ -27,7 +27,6 @@ export {
27
27
  __resetParentCascadeLocks,
28
28
  __setCascadeRetryDelays,
29
29
  cascadeCompletion,
30
- groupByAncestor,
31
30
  logCascadePartialFailures,
32
31
  } from './ticketing/bulk.js';
33
32
  // Re-export the read surface.
@@ -21,7 +21,7 @@ import {
21
21
  emitEpicProgress,
22
22
  emitEpicStarted,
23
23
  emitEpicUnblocked,
24
- } from './epic-runner/progress-reporter.js';
24
+ } from './epic-runner/progress-reporter/transport.js';
25
25
  import { countDoneStories } from './wave-record-projection.js';
26
26
 
27
27
  /**
@@ -77,8 +77,6 @@ export function validateResults(raw) {
77
77
  }
78
78
  const out = { storyId, status };
79
79
  if (typeof entry.phase === 'string') out.phase = entry.phase;
80
- if (Number.isInteger(entry.tasksDone)) out.tasksDone = entry.tasksDone;
81
- if (Number.isInteger(entry.tasksTotal)) out.tasksTotal = entry.tasksTotal;
82
80
  if (entry.blockerCommentId != null) {
83
81
  out.blockerCommentId = String(entry.blockerCommentId);
84
82
  }
@@ -185,8 +183,7 @@ export function classifyWaveOutcome({ resultStatus, currentWave, totalWaves }) {
185
183
 
186
184
  /**
187
185
  * Build the rollup-row shape the unified `epic-run-progress` writer
188
- * consumes. Returns `{ id, title, state, tasksDone?, tasksTotal?,
189
- * blockerCommentId? }`.
186
+ * consumes. Returns `{ id, title, state, blockerCommentId? }`.
190
187
  */
191
188
  export function toRollupRow(verified, titleById) {
192
189
  const row = {
@@ -194,9 +191,6 @@ export function toRollupRow(verified, titleById) {
194
191
  title: titleById.get(verified.storyId) ?? '',
195
192
  state: STORY_STATUS_TO_ROW_STATE[verified.status] ?? 'unknown',
196
193
  };
197
- if (Number.isInteger(verified.tasksDone)) row.tasksDone = verified.tasksDone;
198
- if (Number.isInteger(verified.tasksTotal))
199
- row.tasksTotal = verified.tasksTotal;
200
194
  if (verified.status === 'blocked' && verified.blockerCommentId != null) {
201
195
  row.blockerCommentId = String(verified.blockerCommentId);
202
196
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * lib/project-root.js — side-effect-free leaf module for the repository
3
+ * root path.
4
+ *
5
+ * Extracted from `config-resolver.js` (Story #3993) so the ~10 importers
6
+ * that need only the path constant no longer transitively load the
7
+ * stateful config subsystem (module-global caches + `.env` load side
8
+ * effect). `config-resolver.js` re-exports this constant, so its barrel
9
+ * surface is unchanged.
10
+ */
11
+
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
+ // scripts/lib/ → scripts/ → .agents/ → project root
17
+ export const PROJECT_ROOT = path.resolve(__dirname, '../../..');
@@ -0,0 +1,76 @@
1
+ /**
2
+ * lib/story-adjacency.js — the single story-level adjacency builder.
3
+ *
4
+ * All three wave-computation wrappers bottom out in the shared
5
+ * `lib/Graph.js` kernel (`detectCycle` / `assignLayers` /
6
+ * `computeWaves`), but each historically re-implemented the step that
7
+ * turns a list of Story records into the `Map<storyId, number[]>`
8
+ * adjacency the kernel consumes. This module is now the one home for
9
+ * that step; the consumers are:
10
+ *
11
+ * - `lib/orchestration/epic-runner/phases/build-wave-dag.js`
12
+ * (`buildStoryDag` → `computeWaves`)
13
+ * - `lib/orchestration/dispatch-pipeline.js`
14
+ * (`buildStoryDispatchGraph` → `computeStoryWaves`)
15
+ * - `stories-wave-tick.js` (`buildAdjacency` → `assignLayers`)
16
+ *
17
+ * Dependency source order (must stay aligned with manifest-builder.js so
18
+ * the dispatch manifest and runtime wave scheduling never disagree):
19
+ * 1. Canonical: `blocked by #NNN` / `depends on #NNN` parsed from the
20
+ * Story body via `parseBlockedBy` (the same parser the dispatcher
21
+ * uses).
22
+ * 2. Fallback: an explicit `dependencies` (ticket shape) or
23
+ * `dependsOn` (operator-DAG shape) array on the Story record.
24
+ *
25
+ * @module lib/story-adjacency
26
+ */
27
+
28
+ import { parseBlockedBy } from './dependency-parser.js';
29
+
30
+ /**
31
+ * Build a story-level adjacency map (`Map<storyId, dependencyIds[]>`)
32
+ * from an ordered list of Story records.
33
+ *
34
+ * Each record contributes one adjacency entry keyed by
35
+ * `Number(record.id ?? record.number)`. Dependencies are the deduped
36
+ * union of body-parsed `blocked by` references and the record's
37
+ * explicit `dependencies` / `dependsOn` array, with self-edges and
38
+ * non-integer ids always dropped.
39
+ *
40
+ * @param {Array<{id?: number|string, number?: number, body?: string,
41
+ * dependencies?: Array<number|string>, dependsOn?: Array<number|string>}>} stories
42
+ * Story records (live ticket payloads, fixture tickets, or operator
43
+ * DAG nodes).
44
+ * @param {object} [opts]
45
+ * @param {boolean} [opts.dropForeign=true] When true (the default,
46
+ * matching the Epic-scoped wrappers), edges pointing at ids outside
47
+ * the supplied story set are dropped so the DAG stays closed over the
48
+ * scheduled set. `stories-wave-tick.js` passes `false` to preserve its
49
+ * historical operator-DAG contract, where a dependency on an id absent
50
+ * from the input still deepens the dependent's layer.
51
+ * @returns {Map<number, number[]>}
52
+ */
53
+ export function buildStoryAdjacency(stories, { dropForeign = true } = {}) {
54
+ const records = Array.isArray(stories) ? stories : [];
55
+ const storyIds = new Set(records.map((s) => Number(s?.id ?? s?.number)));
56
+ const adjacency = new Map();
57
+ for (const s of records) {
58
+ const id = Number(s?.id ?? s?.number);
59
+ const fromBody = parseBlockedBy(s?.body ?? '');
60
+ const fromField = Array.isArray(s?.dependencies)
61
+ ? s.dependencies.map(Number)
62
+ : Array.isArray(s?.dependsOn)
63
+ ? s.dependsOn.map(Number)
64
+ : [];
65
+ const merged = [...new Set([...fromBody, ...fromField])]
66
+ .map(Number)
67
+ .filter(
68
+ (dep) =>
69
+ Number.isInteger(dep) &&
70
+ dep !== id &&
71
+ (!dropForeign || storyIds.has(dep)),
72
+ );
73
+ adjacency.set(id, merged);
74
+ }
75
+ return adjacency;
76
+ }
@@ -45,7 +45,7 @@ export function resolveStoryHierarchy(body) {
45
45
  * Story issues. For those Stories this helper resolves to an empty
46
46
  * array because `provider.getSubTickets(storyId)` itself returns no
47
47
  * rows. The helper is retained as a thin pass-through so the three
48
- * orchestration callers (`story-deliver-prepare`, `task-graph-builder`,
48
+ * orchestration callers (`story-init` prepare, `task-graph-builder`,
49
49
  * and `locked-pipeline`) keep a single, named seam for sub-ticket
50
50
  * hydration that is easy to mock in tests and to instrument in the
51
51
  * provider layer.