mandrel 1.57.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 (131) 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/normalize-pr-title.js +241 -0
  71. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +1 -1
  72. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +16 -3
  73. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +2 -4
  74. package/.agents/scripts/lib/orchestration/single-story-lease-guard.js +32 -35
  75. package/.agents/scripts/lib/orchestration/skill-capsule-loader.js +1 -2
  76. package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +451 -503
  77. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/pre-merge-attribution.js +8 -2
  78. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/refresh-commit.js +47 -2
  79. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/regression-projection.js +2 -2
  80. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +358 -54
  81. package/.agents/scripts/lib/orchestration/story-close/phases/close.js +1 -1
  82. package/.agents/scripts/lib/orchestration/story-close/phases/gates.js +3 -2
  83. package/.agents/scripts/lib/orchestration/story-close/phases/locked-pipeline.js +30 -3
  84. package/.agents/scripts/lib/orchestration/story-close/post-merge-close.js +5 -18
  85. package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +3 -3
  86. package/.agents/scripts/lib/orchestration/story-close-recovery.js +33 -16
  87. package/.agents/scripts/lib/orchestration/story-reachability.js +47 -0
  88. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +2 -33
  89. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +42 -64
  90. package/.agents/scripts/lib/orchestration/ticketing/reads.js +9 -0
  91. package/.agents/scripts/lib/orchestration/ticketing/state.js +50 -436
  92. package/.agents/scripts/lib/orchestration/ticketing/transition.js +471 -0
  93. package/.agents/scripts/lib/orchestration/ticketing.js +0 -1
  94. package/.agents/scripts/lib/orchestration/wave-record-notifications.js +1 -1
  95. package/.agents/scripts/lib/orchestration/wave-record-projection.js +1 -7
  96. package/.agents/scripts/lib/project-root.js +17 -0
  97. package/.agents/scripts/lib/story-adjacency.js +76 -0
  98. package/.agents/scripts/lib/story-lifecycle.js +1 -1
  99. package/.agents/scripts/lib/transpile.js +93 -0
  100. package/.agents/scripts/lib/wave-runner/tick.js +4 -153
  101. package/.agents/scripts/lib/workers/crap-worker.js +1 -1
  102. package/.agents/scripts/lib/workers/maintainability-report-worker.js +1 -1
  103. package/.agents/scripts/lib/worktree/lifecycle/creation.js +20 -2
  104. package/.agents/scripts/lib/worktree/lifecycle/force-drain.js +90 -0
  105. package/.agents/scripts/lib/worktree/lifecycle/reap.js +26 -8
  106. package/.agents/scripts/lib/worktree/node-modules-strategy.js +74 -0
  107. package/.agents/scripts/providers/github/tickets.js +110 -6
  108. package/.agents/scripts/run-lint.js +9 -0
  109. package/.agents/scripts/run-tests.js +24 -4
  110. package/.agents/scripts/stories-wave-tick.js +8 -5
  111. package/.agents/scripts/story-init.js +149 -10
  112. package/.agents/scripts/sync-branch-from-base.js +1 -1
  113. package/.agents/skills/stack/qa/lighthouse-baseline/SKILL.md +1 -1
  114. package/.agents/workflows/audit-documentation.md +226 -0
  115. package/.agents/workflows/epic-deliver.md +16 -23
  116. package/.agents/workflows/epic-plan.md +1 -1
  117. package/.agents/workflows/helpers/epic-deliver-story.md +17 -28
  118. package/.agents/workflows/helpers/single-story-deliver.md +2 -1
  119. package/.agents/workflows/onboard.md +4 -3
  120. package/.agents/workflows/story-deliver.md +1 -1
  121. package/README.md +21 -8
  122. package/lib/cli/init.js +336 -0
  123. package/package.json +2 -1
  124. package/.agents/scripts/lib/auto-refresh-baselines.js +0 -308
  125. package/.agents/scripts/lib/close-validation.js +0 -897
  126. package/.agents/scripts/lib/orchestration/cascade-grouping.js +0 -275
  127. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter.js +0 -69
  128. package/.agents/scripts/lib/orchestration/story-close/format-autofix-scoped.js +0 -221
  129. package/.agents/scripts/lib/orchestration/story-close/format-autofix-shared.js +0 -123
  130. package/.agents/scripts/lib/task-utils.js +0 -26
  131. package/.agents/scripts/story-deliver-prepare.js +0 -267
@@ -25,8 +25,9 @@
25
25
  * therefore validates each Story against the **simulated post-predecessor
26
26
  * tree** — base-branch existence overlaid with the create/delete delta of
27
27
  * the Story's transitive `depends_on` predecessors (the same reachability
28
- * walk the conflict gate uses, imported from `ticket-validator-conflicts.js`
29
- * rather than re-derived). Two extra wave-aware rules layer on top of the
28
+ * walk the conflict gate uses, imported from the shared
29
+ * `story-reachability.js` leaf rather than re-derived). Two extra
30
+ * wave-aware rules layer on top of the
30
31
  * base-branch rules:
31
32
  * - `creates` + path created by a **predecessor** → mismatch
32
33
  * (`expected: 'refactors-existing'`) telling the planner to declare
@@ -48,8 +49,8 @@
48
49
  import { gitSpawn } from '../git-utils.js';
49
50
  import { parse as parseStoryBody } from '../story-body/story-body.js';
50
51
  import { FILE_ASSUMPTION_VALUES } from './file-assumption-enum.js';
52
+ import { computeStoryReachability } from './story-reachability.js';
51
53
  import { isObjectPathEntry } from './task-body-validator.js';
52
- import { computeStoryReachability } from './ticket-validator-conflicts.js';
53
54
 
54
55
  /**
55
56
  * Default git probe — returns `true` when `path` exists at
@@ -0,0 +1,144 @@
1
+ /**
2
+ * lease-guard-shared.js — Story #3992: single-source the lease-acquisition
3
+ * kernel shared by the three per-surface lease guards.
4
+ *
5
+ * `epic-deliver-lease-guard.js`, `epic-plan-lease-guard.js`, and
6
+ * `single-story-lease-guard.js` historically each carried their own copy of
7
+ * the operator-handle resolution and the fail-closed acquire wrapper around
8
+ * `ticket-lease.acquireLease` (anchor `heartbeatAt` to `now` so a foreign
9
+ * assignee always reads as a live claim, then throw an operator-facing
10
+ * refusal naming the current owner unless `--steal`). The three copies had
11
+ * already diverged — different `resolveOperator` signatures and different
12
+ * missing-handle behaviour (`null` vs `throw`) — and were synchronised only
13
+ * by docstring promise ("This mirrors the sibling lease guards…").
14
+ *
15
+ * This module is the single home for the kernel, modelled on the
16
+ * shared plumbing inside `story-close/format-autofix.js` (Story #3332,
17
+ * consolidated by Story #4017). The
18
+ * per-surface guards now differ only in injected **policy**:
19
+ *
20
+ * - **Operator candidates** — each surface supplies its own ordered
21
+ * candidate list (e.g. `--as` flag → `github.operatorHandle` →
22
+ * `git user.email` for `/epic-deliver`; bare `operatorHandle` for the
23
+ * plan/standalone paths).
24
+ * - **Missing-handle behaviour** — `'null'` (return null; the caller fails
25
+ * closed at acquire time) vs `'throw'` (refuse immediately with surface
26
+ * wording). The divergence between the plan path (`null`) and the
27
+ * standalone path (`throw`) is intentional: the plan path also calls
28
+ * `resolveOperator` on its best-effort release leg, where a missing
29
+ * handle must degrade to a `no-operator` no-op rather than throw.
30
+ * - **Refusal wording** — each surface renders its own operator-facing
31
+ * message via `renderRefusal(result, ticketId)`.
32
+ * - **Liveness anchoring** — the plan/standalone paths have no heartbeat
33
+ * ledger, so they anchor `heartbeatAt` to `now` (fail-closed: every
34
+ * foreign claim reads live). `/epic-deliver` threads a real
35
+ * `heartbeatAt` through from the lifecycle ledger, so it opts out of
36
+ * anchoring.
37
+ *
38
+ * The unclaimed / already-held / foreign-claim decision table itself (and
39
+ * the steal transfer) lives in `ticket-lease.acquireLease`; this kernel owns
40
+ * the fail-closed parameterisation and the refuse-by-throw boundary that the
41
+ * three guards previously each re-implemented.
42
+ *
43
+ * Per `.agents/rules/orchestration-error-handling.md`, failures surface via
44
+ * `throw new Error(...)`, never `Logger.fatal`.
45
+ */
46
+
47
+ import { acquireLease, normalizeOperatorHandle } from './ticket-lease.js';
48
+
49
+ /**
50
+ * Resolve the operator handle from an ordered candidate list, applying the
51
+ * surface's missing-handle policy.
52
+ *
53
+ * Each candidate is passed through the shared `normalizeOperatorHandle` so a
54
+ * leading `@` is stripped (the assignees API expects bare logins, not
55
+ * `@`-prefixed mentions) and the shipped `@[USERNAME]` placeholder maps to
56
+ * `null` — otherwise the assignee PATCH is rejected (HTTP 422) and the
57
+ * self-held-claim comparison (`owner === operator`) never matches. The first
58
+ * candidate that normalises to a non-null handle wins.
59
+ *
60
+ * @param {object} opts
61
+ * @param {Array<string|null|undefined>} opts.candidates Ordered raw handles.
62
+ * @param {'null'|'throw'} [opts.missingHandleBehavior='null'] What to do
63
+ * when no candidate resolves: return `null`, or throw with the surface's
64
+ * configured wording.
65
+ * @param {string} [opts.missingHandleMessage] Error message used when
66
+ * `missingHandleBehavior` is `'throw'`.
67
+ * @returns {string|null} Bare operator handle, or `null` (policy `'null'`).
68
+ * @throws {Error} When no candidate resolves and the policy is `'throw'`.
69
+ */
70
+ export function resolveOperatorFromCandidates({
71
+ candidates,
72
+ missingHandleBehavior = 'null',
73
+ missingHandleMessage,
74
+ } = {}) {
75
+ for (const raw of candidates ?? []) {
76
+ const normalized = normalizeOperatorHandle(raw);
77
+ if (normalized !== null) return normalized;
78
+ }
79
+ if (missingHandleBehavior === 'throw') {
80
+ throw new Error(missingHandleMessage);
81
+ }
82
+ return null;
83
+ }
84
+
85
+ /**
86
+ * Acquire a ticket lease, failing closed by throwing the surface's refusal
87
+ * message when the claim is refused (live foreign owner, no `steal`).
88
+ *
89
+ * When `anchorHeartbeatToNow` is set (the plan/standalone paths, which have
90
+ * no heartbeat ledger), `heartbeatAt` and `now` are both anchored to the
91
+ * same resolved clock value so `isClaimLive` returns true for ANY foreign
92
+ * owner — `acquireLease` then refuses a foreign assignee unless `steal` is
93
+ * set, while unclaimed and self-held tickets still proceed without a write.
94
+ * When unset (`/epic-deliver`), the caller-supplied `heartbeatAt` / `now`
95
+ * pass through untouched.
96
+ *
97
+ * @param {object} opts
98
+ * @param {object} opts.provider Ticketing provider.
99
+ * @param {number} opts.ticketId Ticket to claim.
100
+ * @param {string} opts.operator Resolved operator handle.
101
+ * @param {number|null} [opts.heartbeatAt=null] Current owner's last
102
+ * heartbeat (epoch ms). Ignored when `anchorHeartbeatToNow` is set.
103
+ * @param {boolean} [opts.steal=false] Forcibly transfer a foreign claim.
104
+ * @param {object} [opts.config] Resolved config (TTL default).
105
+ * @param {number} [opts.now] Injectable clock (epoch ms; tests).
106
+ * @param {boolean} [opts.anchorHeartbeatToNow=false] Fail-closed liveness
107
+ * anchoring for surfaces with no heartbeat source.
108
+ * @param {(result: object, ticketId: number) => string} opts.renderRefusal
109
+ * Renders the operator-facing refusal message for a refused claim.
110
+ * @returns {Promise<{ acquired: boolean, owner: string, previousOwner: string|null, reason: string }>}
111
+ * @throws {Error} When the claim is refused (`result.acquired === false`).
112
+ */
113
+ export async function acquireLeaseFailClosed({
114
+ provider,
115
+ ticketId,
116
+ operator,
117
+ heartbeatAt = null,
118
+ steal = false,
119
+ config,
120
+ now,
121
+ anchorHeartbeatToNow = false,
122
+ renderRefusal,
123
+ }) {
124
+ let resolvedHeartbeatAt = heartbeatAt;
125
+ let resolvedNow = now;
126
+ if (anchorHeartbeatToNow) {
127
+ resolvedNow =
128
+ typeof now === 'number' && Number.isFinite(now) ? now : Date.now();
129
+ resolvedHeartbeatAt = resolvedNow;
130
+ }
131
+ const result = await acquireLease({
132
+ provider,
133
+ ticketId,
134
+ operator,
135
+ heartbeatAt: resolvedHeartbeatAt,
136
+ steal,
137
+ config,
138
+ now: resolvedNow,
139
+ });
140
+ if (!result.acquired) {
141
+ throw new Error(renderRefusal(result, ticketId));
142
+ }
143
+ return result;
144
+ }
@@ -23,8 +23,8 @@
23
23
  *
24
24
  * The schema declares `additionalProperties: false`, so this emitter's
25
25
  * signature is deliberately narrow: only the schema-allowed fields are
26
- * accepted. Earlier per-child counters (`taskId`, `tasksDone`,
27
- * `tasksTotal`, `currentTaskId`) were dropped under Epic #3078's
26
+ * accepted. The earlier per-child Task id and progress counters
27
+ * were dropped under Epic #3078's
28
28
  * 3-tier hard cutover — they would fail strict validation and have no
29
29
  * meaning now that the Story is the leaf execution unit with no child
30
30
  * tickets. The optional `operator` field (Story #3480) records the handle
@@ -109,7 +109,7 @@ export const extractPrNumber = parsePrNumberFromUrl;
109
109
  *
110
110
  * Exported so tests can stub.
111
111
  */
112
- export function ghPrChecks({ prUrl, cwd, spawnFn = spawnSync }) {
112
+ function ghPrChecks({ prUrl, cwd, spawnFn = spawnSync }) {
113
113
  const result = spawnFn(
114
114
  'gh',
115
115
  [
@@ -134,7 +134,7 @@ export function ghPrChecks({ prUrl, cwd, spawnFn = spawnSync }) {
134
134
  * can detect the BEHIND condition (PR head is behind its base branch)
135
135
  * AFTER every required check is green. Exported so tests can stub.
136
136
  */
137
- export function ghPrView({ prUrl, cwd, spawnFn = spawnSync }) {
137
+ function ghPrView({ prUrl, cwd, spawnFn = spawnSync }) {
138
138
  const result = spawnFn(
139
139
  'gh',
140
140
  ['pr', 'view', prUrl, '--json', 'mergeStateStatus'],
@@ -154,7 +154,7 @@ export function ghPrView({ prUrl, cwd, spawnFn = spawnSync }) {
154
154
  * BEHIND" (the conservative recovery branch). Pure — exported for
155
155
  * tests.
156
156
  */
157
- export function parseMergeStateStatus(stdout) {
157
+ function parseMergeStateStatus(stdout) {
158
158
  const trimmed = String(stdout ?? '').trim();
159
159
  if (trimmed.length === 0) return '';
160
160
  try {
@@ -172,7 +172,7 @@ export function parseMergeStateStatus(stdout) {
172
172
  * loop to fast-forward the PR head with its base branch. Exported so
173
173
  * tests can stub and assert call counts.
174
174
  */
175
- export function ghPrUpdateBranch({ prUrl, cwd, spawnFn = spawnSync }) {
175
+ function ghPrUpdateBranch({ prUrl, cwd, spawnFn = spawnSync }) {
176
176
  const result = spawnFn('gh', ['pr', 'update-branch', prUrl], {
177
177
  cwd,
178
178
  encoding: 'utf-8',
@@ -192,7 +192,7 @@ export function ghPrUpdateBranch({ prUrl, cwd, spawnFn = spawnSync }) {
192
192
  * the same definition as the downstream predicate. Pure — exported
193
193
  * for tests.
194
194
  */
195
- export const GREEN_CHECK_OUTCOMES = Object.freeze(
195
+ const GREEN_CHECK_OUTCOMES = Object.freeze(
196
196
  new Set(['success', 'neutral', 'skipped']),
197
197
  );
198
198
 
@@ -202,7 +202,7 @@ export const GREEN_CHECK_OUTCOMES = Object.freeze(
202
202
  * regardless of mergeStateStatus, so we never auto-recover into a
203
203
  * failing PR.
204
204
  */
205
- export function allGreen(outcomes) {
205
+ function allGreen(outcomes) {
206
206
  const values = Object.values(outcomes);
207
207
  if (values.length === 0) return false;
208
208
  for (const v of values) {
@@ -271,7 +271,7 @@ export function allTerminal(outcomes) {
271
271
  * behaviour is reviewable. Called only when the poll loop exits via
272
272
  * the iteration cap.
273
273
  */
274
- export function promotePendingToTimedOut(outcomes) {
274
+ function promotePendingToTimedOut(outcomes) {
275
275
  const out = {};
276
276
  for (const [k, v] of Object.entries(outcomes)) {
277
277
  out[k] = v === 'pending' ? 'timed_out' : v;
@@ -21,7 +21,7 @@ export async function notificationPhase(ctx, state) {
21
21
  storyId,
22
22
  story,
23
23
  epicBranch,
24
- orchestration,
24
+ config,
25
25
  progress,
26
26
  provider,
27
27
  notifyFn = notify,
@@ -39,7 +39,7 @@ export async function notificationPhase(ctx, state) {
39
39
  level: 'story',
40
40
  epicId,
41
41
  },
42
- { orchestration },
42
+ { config },
43
43
  );
44
44
  // Fire a rolled-up `epic-progress` webhook so operators see the Epic's
45
45
  // overall stories-done count tick up at each story-close, without
@@ -66,7 +66,7 @@ export async function notificationPhase(ctx, state) {
66
66
  level: 'epic',
67
67
  epicId,
68
68
  },
69
- { orchestration, skipComment: true },
69
+ { config, skipComment: true },
70
70
  );
71
71
  } catch (err) {
72
72
  logger?.warn?.(
@@ -54,9 +54,9 @@ function findStillRegisteredEntry(entries, storyId) {
54
54
  });
55
55
  }
56
56
 
57
- function resolveWorktreeRoot(repoRoot, orchestration) {
57
+ function resolveWorktreeRoot(repoRoot, delivery) {
58
58
  if (!repoRoot) return null;
59
- const configuredRoot = orchestration?.worktreeIsolation?.root ?? '.worktrees';
59
+ const configuredRoot = delivery?.worktreeIsolation?.root ?? '.worktrees';
60
60
  return path.join(repoRoot, configuredRoot);
61
61
  }
62
62
 
@@ -263,7 +263,7 @@ function logStaleRegistryEntry({ state, stillRegistered, logger }) {
263
263
 
264
264
  export async function worktreeReapPhase(ctx) {
265
265
  const {
266
- orchestration,
266
+ delivery,
267
267
  storyId,
268
268
  epicId,
269
269
  epicBranch,
@@ -276,7 +276,7 @@ export async function worktreeReapPhase(ctx) {
276
276
  recordPendingCleanupFn = recordPendingCleanup,
277
277
  pathExistsFn = fs.existsSync,
278
278
  } = ctx;
279
- const wtConfig = orchestration?.worktreeIsolation;
279
+ const wtConfig = delivery?.worktreeIsolation;
280
280
  const log = reapPhaseLogger(progress);
281
281
  const skipState = resolveSkipState(wtConfig, log);
282
282
  if (skipState) return skipState;
@@ -309,7 +309,7 @@ export async function worktreeReapPhase(ctx) {
309
309
  stillRegistered,
310
310
  reapResult,
311
311
  storyId,
312
- orchestration,
312
+ delivery,
313
313
  repoRoot,
314
314
  logger,
315
315
  recordPendingCleanupFn,
@@ -352,7 +352,7 @@ function applyStillRegisteredState({
352
352
  stillRegistered,
353
353
  reapResult,
354
354
  storyId,
355
- orchestration,
355
+ delivery,
356
356
  repoRoot,
357
357
  logger,
358
358
  recordPendingCleanupFn,
@@ -370,7 +370,7 @@ function applyStillRegisteredState({
370
370
  reason: 'still-registered-after-reap',
371
371
  };
372
372
  }
373
- const worktreeRoot = resolveWorktreeRoot(repoRoot, orchestration);
373
+ const worktreeRoot = resolveWorktreeRoot(repoRoot, delivery);
374
374
  let manifestEntry = null;
375
375
  if (worktreeRoot) {
376
376
  try {
@@ -53,28 +53,51 @@ export function preflightCachePath({ epicId, cwd }) {
53
53
  }
54
54
 
55
55
  /**
56
- * Stable string fingerprint of an Epic snapshot. The hash is keyed on the
57
- * exact fields `runSnapshotPhase` reads (`getTicket(epicId)` return value)
58
- * so that any drift the snapshot phase would observe forces a cache miss.
56
+ * Per-ticket fingerprint fields shared by the Epic and each Story in the
57
+ * cache key: id, body, sorted labels, and updatedAt. Story bodies carry
58
+ * the dependency edges, so hashing them means a Story-dependency edit
59
+ * forces a cache miss (Story #4019 — the previous Epic-only key let
60
+ * dependency edits slip through unnoticed).
61
+ *
62
+ * @param {{ id?: number|string, number?: number|string, body?: string, labels?: string[], updatedAt?: string }} ticket
63
+ * @returns {{ id: number|string|null, body: string, labels: string[], updatedAt: string|null }}
64
+ */
65
+ function ticketFingerprint(ticket) {
66
+ const t = ticket && typeof ticket === 'object' ? ticket : {};
67
+ return {
68
+ id: t.id ?? t.number ?? null,
69
+ body: typeof t.body === 'string' ? t.body : '',
70
+ labels: Array.isArray(t.labels) ? [...t.labels].map(String).sort() : [],
71
+ updatedAt: typeof t.updatedAt === 'string' ? t.updatedAt : null,
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Stable string fingerprint of an Epic snapshot **plus its Story
77
+ * dependency state**. The hash is keyed on the exact fields
78
+ * `runSnapshotPhase` reads (`getTicket(epicId)` return value) and on each
79
+ * Story's id/body/labels/updatedAt, so that any drift the snapshot or
80
+ * wave-DAG phases would observe — including a Story-dependency edit —
81
+ * forces a cache miss (Story #4019).
59
82
  *
60
83
  * Labels are sorted to absorb GitHub's non-deterministic label order
61
- * across responses. The hash is sha256; we return the full hex digest so
84
+ * across responses; stories are sorted by id so enumeration order never
85
+ * perturbs the key. The hash is sha256; we return the full hex digest so
62
86
  * the cache key is collision-resistant for the lifetime of a delivery.
63
87
  *
64
88
  * @param {{ id?: number|string, number?: number|string, body?: string, labels?: string[], updatedAt?: string }} epic
89
+ * @param {Array<{ id?: number|string, number?: number|string, body?: string, labels?: string[], updatedAt?: string }>} [stories]
65
90
  * @returns {string}
66
91
  */
67
- export function computeBaseSha(epic) {
92
+ export function computeBaseSha(epic, stories = []) {
68
93
  if (!epic || typeof epic !== 'object') {
69
94
  throw new TypeError('computeBaseSha: epic snapshot must be an object');
70
95
  }
71
- const id = epic.id ?? epic.number ?? null;
72
- const body = typeof epic.body === 'string' ? epic.body : '';
73
- const labels = Array.isArray(epic.labels)
74
- ? [...epic.labels].map(String).sort()
75
- : [];
76
- const updatedAt = typeof epic.updatedAt === 'string' ? epic.updatedAt : null;
77
- const payload = JSON.stringify({ id, body, labels, updatedAt });
96
+ const epicPrint = ticketFingerprint(epic);
97
+ const storyPrints = (Array.isArray(stories) ? stories : [])
98
+ .map(ticketFingerprint)
99
+ .sort((a, b) => Number(a.id ?? 0) - Number(b.id ?? 0));
100
+ const payload = JSON.stringify({ ...epicPrint, stories: storyPrints });
78
101
  return createHash('sha256').update(payload).digest('hex');
79
102
  }
80
103
 
@@ -31,6 +31,7 @@ import { spawnSync } from 'node:child_process';
31
31
  import fs from 'node:fs';
32
32
  import os from 'node:os';
33
33
  import path from 'node:path';
34
+ import { parseProviderFindings } from './parse-findings.js';
34
35
  import { renderDepthDirective } from './review-depth.js';
35
36
 
36
37
  /**
@@ -173,66 +174,10 @@ export function mapCodexSeverity(raw) {
173
174
  * @throws {Error} when stdout is not parseable JSON.
174
175
  */
175
176
  export function parseCodexFindings(rawStdout) {
176
- const text = (rawStdout ?? '').trim();
177
- if (text.length === 0) return [];
178
-
179
- let parsed;
180
- try {
181
- parsed = JSON.parse(text);
182
- } catch (err) {
183
- throw new Error(
184
- `[codex-review] Failed to parse /codex:review stdout as JSON: ${
185
- err?.message ?? err
186
- }`,
187
- );
188
- }
189
-
190
- // Unwrap a single layer of envelope when present.
191
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
192
- if (Array.isArray(parsed.findings)) parsed = parsed.findings;
193
- else if (parsed.result !== undefined) parsed = parsed.result;
194
- else if (parsed.data !== undefined) parsed = parsed.data;
195
- }
196
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
197
- if (Array.isArray(parsed.findings)) parsed = parsed.findings;
198
- }
199
-
200
- if (!Array.isArray(parsed)) return [];
201
-
202
- /** @type {Finding[]} */
203
- const findings = [];
204
- for (const entry of parsed) {
205
- if (!entry || typeof entry !== 'object') continue;
206
- const title =
207
- typeof entry.title === 'string' && entry.title.trim().length > 0
208
- ? entry.title.trim()
209
- : null;
210
- const body =
211
- typeof entry.body === 'string' && entry.body.trim().length > 0
212
- ? entry.body
213
- : typeof entry.message === 'string' && entry.message.trim().length > 0
214
- ? entry.message
215
- : null;
216
- if (!title || !body) continue;
217
-
218
- /** @type {Finding} */
219
- const finding = {
220
- severity: mapCodexSeverity(entry.severity),
221
- title,
222
- body,
223
- };
224
- if (typeof entry.file === 'string' && entry.file.length > 0) {
225
- finding.file = entry.file;
226
- }
227
- if (Number.isInteger(entry.line) && entry.line > 0) {
228
- finding.line = entry.line;
229
- }
230
- if (typeof entry.category === 'string' && entry.category.length > 0) {
231
- finding.category = entry.category;
232
- }
233
- findings.push(finding);
234
- }
235
- return findings;
177
+ return parseProviderFindings(rawStdout, {
178
+ errorPrefix: '[codex-review] Failed to parse /codex:review stdout as JSON',
179
+ mapSeverity: mapCodexSeverity,
180
+ });
236
181
  }
237
182
 
238
183
  /**
@@ -42,14 +42,14 @@
42
42
 
43
43
  import { spawnSync } from 'node:child_process';
44
44
  import path from 'node:path';
45
- import { PROJECT_ROOT } from '../../config-resolver.js';
46
- import { runOnPool } from '../../cpu-pool.js';
45
+ import { POOL_SERIAL_THRESHOLD, runOnPool } from '../../cpu-pool.js';
47
46
  import { gitSpawn } from '../../git-utils.js';
48
47
  import {
49
48
  calculateReport,
50
49
  classifyReport,
51
50
  } from '../../maintainability-engine.js';
52
- import { transpileIfNeeded } from '../../maintainability-utils.js';
51
+ import { PROJECT_ROOT } from '../../project-root.js';
52
+ import { transpileIfNeeded } from '../../transpile.js';
53
53
  import {
54
54
  hashCommandConfig,
55
55
  recordPass,
@@ -67,10 +67,11 @@ const MAINTAINABILITY_REPORT_WORKER_URL = new URL(
67
67
  * `analyzeChangedFiles` scores in-process (the pre-pool serial path). At or
68
68
  * above it, per-file `calculateReportForFile` scoring is offloaded to the
69
69
  * shared worker pool so the event loop is not blocked during epic-scoped
70
- * reviews (f-performance). Tuned to match the `SERIAL_THRESHOLD` used by the
71
- * maintainability baseline scan in `maintainability-utils.js`.
70
+ * reviews (f-performance). Single-sourced in `cpu-pool.js` (see the
71
+ * `POOL_SERIAL_THRESHOLD` docstring for the tuning rationale); the
72
+ * `SERIAL_THRESHOLD` export name is preserved as this module's public API.
72
73
  */
73
- export const SERIAL_THRESHOLD = 8;
74
+ export const SERIAL_THRESHOLD = POOL_SERIAL_THRESHOLD;
74
75
 
75
76
  const JS_MAINTAINABILITY_EXTS = new Set(['.js', '.mjs', '.cjs']);
76
77
 
@@ -0,0 +1,105 @@
1
+ /**
2
+ * review-providers/parse-findings.js — shared JSON-findings parser.
3
+ *
4
+ * Story #3981 — extracts the verbatim-duplicated parsing logic from
5
+ * `parseCodexFindings` (codex.js) and `parseSecurityReviewFindings`
6
+ * (security-review.js) into one templated parser. Both adapters emit
7
+ * JSON; the parser is liberal in what it accepts:
8
+ * - A bare array of finding objects.
9
+ * - An object with a `findings` array.
10
+ * - Either shape wrapped in an outer envelope with a `result` or
11
+ * `data` key (covers minor wire-format drift across versions
12
+ * without re-shimming).
13
+ *
14
+ * Each entry's severity is funnelled through the caller-supplied
15
+ * `mapSeverity` so the canonical enum is the only thing that reaches
16
+ * the renderer. Entries without a `title` or `body` are skipped — the
17
+ * orchestrator cannot post an empty finding, and silently dropping the
18
+ * entry is safer than fabricating one.
19
+ *
20
+ * Per-provider deltas ride in as options:
21
+ * - `errorPrefix` — prefix for the JSON-parse failure message.
22
+ * - `mapSeverity` — provider severity vocabulary → canonical enum.
23
+ * - `defaultCategory` — when set, entries missing a `category` get
24
+ * this value (security-review defaults to `'security'`); when
25
+ * omitted, `category` is only set when present (codex behavior).
26
+ *
27
+ * @typedef {import('./types.js').Finding} Finding
28
+ * @typedef {import('./types.js').Severity} Severity
29
+ */
30
+
31
+ /**
32
+ * Parse a provider's raw stdout into `Finding[]`.
33
+ *
34
+ * @param {string} rawStdout
35
+ * @param {{
36
+ * errorPrefix: string,
37
+ * mapSeverity: (raw: unknown) => Severity,
38
+ * defaultCategory?: string,
39
+ * }} options
40
+ * @returns {Finding[]}
41
+ * @throws {Error} when stdout is not parseable JSON.
42
+ */
43
+ export function parseProviderFindings(rawStdout, options) {
44
+ const { errorPrefix, mapSeverity, defaultCategory } = options;
45
+ const text = (rawStdout ?? '').trim();
46
+ if (text.length === 0) return [];
47
+
48
+ let parsed;
49
+ try {
50
+ parsed = JSON.parse(text);
51
+ } catch (err) {
52
+ throw new Error(`${errorPrefix}: ${err?.message ?? err}`);
53
+ }
54
+
55
+ // Unwrap a single layer of envelope when present.
56
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
57
+ if (Array.isArray(parsed.findings)) parsed = parsed.findings;
58
+ else if (parsed.result !== undefined) parsed = parsed.result;
59
+ else if (parsed.data !== undefined) parsed = parsed.data;
60
+ }
61
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
62
+ if (Array.isArray(parsed.findings)) parsed = parsed.findings;
63
+ }
64
+
65
+ if (!Array.isArray(parsed)) return [];
66
+
67
+ /** @type {Finding[]} */
68
+ const findings = [];
69
+ for (const entry of parsed) {
70
+ if (!entry || typeof entry !== 'object') continue;
71
+ const title =
72
+ typeof entry.title === 'string' && entry.title.trim().length > 0
73
+ ? entry.title.trim()
74
+ : null;
75
+ const body =
76
+ typeof entry.body === 'string' && entry.body.trim().length > 0
77
+ ? entry.body
78
+ : typeof entry.message === 'string' && entry.message.trim().length > 0
79
+ ? entry.message
80
+ : null;
81
+ if (!title || !body) continue;
82
+
83
+ /** @type {Finding} */
84
+ const finding = {
85
+ severity: mapSeverity(entry.severity),
86
+ title,
87
+ body,
88
+ };
89
+ const category =
90
+ typeof entry.category === 'string' && entry.category.length > 0
91
+ ? entry.category
92
+ : defaultCategory;
93
+ if (category !== undefined) {
94
+ finding.category = category;
95
+ }
96
+ if (typeof entry.file === 'string' && entry.file.length > 0) {
97
+ finding.file = entry.file;
98
+ }
99
+ if (Number.isInteger(entry.line) && entry.line > 0) {
100
+ finding.line = entry.line;
101
+ }
102
+ findings.push(finding);
103
+ }
104
+ return findings;
105
+ }