mandrel 1.88.0 → 1.90.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 (145) hide show
  1. package/.agents/README.md +18 -13
  2. package/.agents/audit-checklists/architecture.md +24 -0
  3. package/.agents/audit-checklists/clean-code.md +24 -0
  4. package/.agents/audit-checklists/dependencies.md +14 -0
  5. package/.agents/audit-checklists/devops.md +17 -0
  6. package/.agents/audit-checklists/documentation.md +22 -0
  7. package/.agents/audit-checklists/lighthouse.md +15 -0
  8. package/.agents/audit-checklists/navigability.md +14 -0
  9. package/.agents/audit-checklists/performance.md +22 -0
  10. package/.agents/audit-checklists/privacy.md +21 -0
  11. package/.agents/audit-checklists/quality.md +18 -0
  12. package/.agents/audit-checklists/security.md +22 -0
  13. package/.agents/audit-checklists/seo.md +16 -0
  14. package/.agents/audit-checklists/sre.md +24 -0
  15. package/.agents/audit-checklists/ux-ui.md +21 -0
  16. package/.agents/docs/SDLC.md +62 -27
  17. package/.agents/docs/configuration.md +5 -4
  18. package/.agents/instructions.md +51 -21
  19. package/.agents/personas/architect.md +10 -7
  20. package/.agents/personas/engineer.md +4 -3
  21. package/.agents/personas/project-manager.md +5 -2
  22. package/.agents/personas/refactorer.md +5 -3
  23. package/.agents/rules/git-conventions.md +77 -0
  24. package/.agents/schemas/agentrc.schema.json +10 -6
  25. package/.agents/schemas/audit-rules.json +16 -2
  26. package/.agents/schemas/audit-rules.schema.json +7 -6
  27. package/.agents/schemas/lifecycle/epic.blocked.schema.json +1 -1
  28. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +39 -0
  29. package/.agents/schemas/signal-event.schema.json +28 -13
  30. package/.agents/scripts/acceptance-spec-reconciler.js +6 -4
  31. package/.agents/scripts/check-context-budget.js +320 -0
  32. package/.agents/scripts/coverage-capture.js +17 -0
  33. package/.agents/scripts/diagnose-friction.js +4 -4
  34. package/.agents/scripts/epic-audit-prepare.js +30 -2
  35. package/.agents/scripts/epic-audit-recheck.js +46 -13
  36. package/.agents/scripts/epic-deliver-prepare.js +80 -8
  37. package/.agents/scripts/epic-plan-spec.js +4 -8
  38. package/.agents/scripts/generate-lens-checklists.js +180 -0
  39. package/.agents/scripts/lib/audit-suite/checklist-threading.js +300 -0
  40. package/.agents/scripts/lib/audit-suite/findings.js +27 -0
  41. package/.agents/scripts/lib/audit-suite/index.js +9 -0
  42. package/.agents/scripts/lib/audit-suite/lens-checklist.js +212 -0
  43. package/.agents/scripts/lib/audit-suite/selector.js +136 -5
  44. package/.agents/scripts/lib/checks/loop-health.js +340 -0
  45. package/.agents/scripts/lib/cli-args.js +8 -0
  46. package/.agents/scripts/lib/close-validation/gates.js +64 -24
  47. package/.agents/scripts/lib/config/ci.js +12 -1
  48. package/.agents/scripts/lib/config/runners.js +13 -5
  49. package/.agents/scripts/lib/config/temp-paths.js +24 -0
  50. package/.agents/scripts/lib/config-settings-schema-delivery.js +28 -8
  51. package/.agents/scripts/lib/doc-tiers.js +291 -0
  52. package/.agents/scripts/lib/epic-body-sections.js +5 -2
  53. package/.agents/scripts/lib/epic-merge-lock.js +83 -0
  54. package/.agents/scripts/lib/epic-plan-clarity.js +3 -1
  55. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +47 -15
  56. package/.agents/scripts/lib/feedback-loop/graduator-core.js +395 -86
  57. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +299 -72
  58. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +438 -0
  59. package/.agents/scripts/lib/gates/friction.js +15 -5
  60. package/.agents/scripts/lib/npm-scripts.js +55 -0
  61. package/.agents/scripts/lib/observability/perf-aggregator.js +30 -104
  62. package/.agents/scripts/lib/observability/perf-report-readers.js +1 -1
  63. package/.agents/scripts/lib/observability/signal-validator.js +204 -0
  64. package/.agents/scripts/lib/observability/signals-writer.js +157 -54
  65. package/.agents/scripts/lib/observability/tool-trace-hook.js +42 -4
  66. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +1 -1
  67. package/.agents/scripts/lib/orchestration/code-review.js +74 -4
  68. package/.agents/scripts/lib/orchestration/consolidation-precondition.js +213 -0
  69. package/.agents/scripts/lib/orchestration/doc-reader.js +4 -96
  70. package/.agents/scripts/lib/orchestration/docs-digest.js +34 -0
  71. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +56 -19
  72. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +22 -0
  73. package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +193 -0
  74. package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +6 -0
  75. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +248 -13
  76. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +109 -12
  77. package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +47 -61
  78. package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +46 -4
  79. package/.agents/scripts/lib/orchestration/lifecycle/listeners/label-transitioner.js +144 -0
  80. package/.agents/scripts/lib/orchestration/lifecycle/listeners/merge-watcher.js +258 -14
  81. package/.agents/scripts/lib/orchestration/lifecycle/listeners/notify-dispatcher.js +6 -0
  82. package/.agents/scripts/lib/orchestration/merge-block-class.js +246 -0
  83. package/.agents/scripts/lib/orchestration/plan-review-routing.js +1 -1
  84. package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +3 -3
  85. package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +63 -34
  86. package/.agents/scripts/lib/orchestration/retro/phases/gather-signals.js +167 -52
  87. package/.agents/scripts/lib/orchestration/retro/phases/post-and-mirror.js +49 -2
  88. package/.agents/scripts/lib/orchestration/retro-proposals.js +12 -55
  89. package/.agents/scripts/lib/orchestration/retro-runner.js +9 -0
  90. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +5 -1
  91. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -0
  92. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +419 -0
  93. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +35 -2
  94. package/.agents/scripts/lib/orchestration/single-story-close/phases/wrong-tree-guard.js +353 -69
  95. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +66 -4
  96. package/.agents/scripts/lib/orchestration/spec-section-validator.js +60 -9
  97. package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +7 -5
  98. package/.agents/scripts/lib/orchestration/story-close/merge-runner.js +24 -2
  99. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +167 -8
  100. package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +8 -1
  101. package/.agents/scripts/lib/orchestration/story-close/shared-checkout-guard.js +163 -0
  102. package/.agents/scripts/lib/orchestration/ticketing/reads.js +20 -9
  103. package/.agents/scripts/lib/planning-corpus.js +306 -0
  104. package/.agents/scripts/lib/signals/detectors/common.js +10 -10
  105. package/.agents/scripts/lib/signals/detectors/index.js +4 -4
  106. package/.agents/scripts/lib/signals/detectors/retry.js +19 -18
  107. package/.agents/scripts/lib/signals/detectors/rework.js +1 -1
  108. package/.agents/scripts/lib/signals/schema.js +56 -81
  109. package/.agents/scripts/lib/signals/span-tree.js +6 -5
  110. package/.agents/scripts/lib/story-plan.js +3 -0
  111. package/.agents/scripts/lib/wave-runner/tick.js +10 -2
  112. package/.agents/scripts/lifecycle-emit.js +39 -8
  113. package/.agents/scripts/providers/github/issues.js +12 -1
  114. package/.agents/scripts/resolve-doc-tiers.js +83 -0
  115. package/.agents/scripts/retro-run.js +51 -0
  116. package/.agents/scripts/signals-view.js +1 -1
  117. package/.agents/scripts/single-story-close.js +20 -1
  118. package/.agents/scripts/standalone-feedback-rollup.js +188 -0
  119. package/.agents/scripts/story-close.js +48 -0
  120. package/.agents/scripts/story-plan.js +51 -12
  121. package/.agents/scripts/validate-docs-freshness.js +69 -15
  122. package/.agents/skills/core/documentation-and-adrs/SKILL.md +58 -0
  123. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
  124. package/.agents/skills/core/epic-plan-spec-author/SKILL.md +20 -7
  125. package/.agents/skills/core/scope-triage/SKILL.md +61 -0
  126. package/.agents/skills/skills.index.json +3 -3
  127. package/.agents/workflows/audit-documentation.md +82 -2
  128. package/.agents/workflows/helpers/code-review.md +116 -43
  129. package/.agents/workflows/helpers/deliver-epic.md +123 -54
  130. package/.agents/workflows/helpers/deliver-stories.md +26 -0
  131. package/.agents/workflows/helpers/epic-audit.md +116 -366
  132. package/.agents/workflows/helpers/epic-deliver-story.md +14 -0
  133. package/.agents/workflows/helpers/epic-plan-decompose.md +18 -200
  134. package/.agents/workflows/helpers/epic-plan-spec.md +18 -180
  135. package/.agents/workflows/helpers/plan-epic.md +141 -105
  136. package/.agents/workflows/helpers/plan-story.md +32 -0
  137. package/.agents/workflows/helpers/single-story-deliver.md +43 -0
  138. package/.agents/workflows/loops/nightly-audit.md +9 -7
  139. package/docs/CHANGELOG.md +29 -0
  140. package/lib/cli/doctor.js +44 -0
  141. package/package.json +4 -3
  142. package/.agents/scripts/epic-plan-spec-validate.js +0 -111
  143. package/.agents/scripts/lib/feedback-loop/code-review-graduator.js +0 -224
  144. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +0 -58
  145. package/.agents/scripts/lib/signals/detectors/hotspot.js +0 -292
@@ -15,6 +15,7 @@ import { runAutoMergePhase } from './phases/auto-merge.js';
15
15
  import { runBaseSyncPhase } from './phases/base-sync.js';
16
16
  import { runCloseValidationPhase } from './phases/close-validation.js';
17
17
  import { parsePrNumber, runStoryScopeReview } from './phases/code-review.js';
18
+ import { runConfirmMergePhase } from './phases/confirm-merge.js';
18
19
  import { parseCloseOptions } from './phases/options.js';
19
20
  import { ensurePullRequestWith } from './phases/pull-request.js';
20
21
  import { pushStoryBranch } from './phases/push.js';
@@ -57,6 +58,7 @@ async function runPrePushPhases({
57
58
  await runWrongTreeGuardPhase({
58
59
  cwd,
59
60
  worktreePath,
61
+ baseBranch,
60
62
  storyId,
61
63
  provider,
62
64
  progress,
@@ -204,6 +206,8 @@ function closeResult({
204
206
  autoMergeReason,
205
207
  worktreeReaped,
206
208
  leaseReleased,
209
+ waitedForMerge = false,
210
+ merged = false,
207
211
  }) {
208
212
  return {
209
213
  storyId,
@@ -217,9 +221,13 @@ function closeResult({
217
221
  autoMergeReason,
218
222
  worktreeReaped,
219
223
  leaseReleased,
220
- note: autoMergeEnabled
221
- ? 'PR open against baseBranch with auto-merge enabled. Story rests at agent::closing (issue stays OPEN). GitHub will squash-merge when required checks pass; run single-story-confirm-merge.js after the merge confirms to flip agent::done and close the issue (the Closes #<id> footer also auto-closes it).'
222
- : 'PR open against baseBranch. Story rests at agent::closing (issue stays OPEN). Operator merges via GitHub UI; run single-story-confirm-merge.js after the merge confirms to flip agent::done (the Closes #<id> footer also auto-closes the issue).',
224
+ waitedForMerge,
225
+ merged,
226
+ note: waitedForMerge
227
+ ? 'Headless must-land: PR merge confirmed. Story flipped agent::closing → agent::done and the issue closed (confirmStoryMerged).'
228
+ : autoMergeEnabled
229
+ ? 'PR open against baseBranch with auto-merge enabled. Story rests at agent::closing (issue stays OPEN). GitHub will squash-merge when required checks pass; run single-story-confirm-merge.js after the merge confirms to flip agent::done and close the issue (the Closes #<id> footer also auto-closes it).'
230
+ : 'PR open against baseBranch. Story rests at agent::closing (issue stays OPEN). Operator merges via GitHub UI; run single-story-confirm-merge.js after the merge confirms to flip agent::done (the Closes #<id> footer also auto-closes the issue).',
223
231
  };
224
232
  }
225
233
 
@@ -230,6 +238,8 @@ export async function runSingleStoryClose({
230
238
  skipSync: skipSyncParam,
231
239
  noAutoMerge: noAutoMergeParam,
232
240
  noFullScopeCrap: noFullScopeCrapParam,
241
+ waitForMerge: waitForMergeParam,
242
+ noWaitForMerge: noWaitForMergeParam,
233
243
  injectedProvider,
234
244
  injectedConfig,
235
245
  injectedNotify,
@@ -246,10 +256,12 @@ export async function runSingleStoryClose({
246
256
  skipSyncParam,
247
257
  noAutoMergeParam,
248
258
  noFullScopeCrapParam,
259
+ waitForMergeParam,
260
+ noWaitForMergeParam,
249
261
  });
250
262
  if (!options.storyId) {
251
263
  throw new Error(
252
- 'Usage: node single-story-close.js --story <STORY_ID> [--cwd <main-repo>] [--skip-validation] [--skip-sync] [--no-auto-merge] [--no-full-scope-crap]',
264
+ 'Usage: node single-story-close.js --story <STORY_ID> [--cwd <main-repo>] [--skip-validation] [--skip-sync] [--no-auto-merge] [--no-full-scope-crap] [--wait-merge|--no-wait-merge]',
253
265
  );
254
266
  }
255
267
 
@@ -341,6 +353,56 @@ export async function runSingleStoryClose({
341
353
  WorktreeManager,
342
354
  });
343
355
  const leaseReleased = await releaseLease(leaseArgs);
356
+
357
+ // Story #4428 — headless must-land: `--wait-merge` polls the just-armed
358
+ // PR to merge confirmation (or an explicit `agent::blocked` +
359
+ // `merge.unlanded`) instead of resting at `agent::closing`. Attended
360
+ // (non-headless) runs never set this flag, so the exit shape below is
361
+ // unreachable and the pre-existing early-return is byte-identical.
362
+ if (options.waitForMerge) {
363
+ const waitOutcome = await runConfirmMergePhase({
364
+ cwd: options.cwd,
365
+ storyId: options.storyId,
366
+ prNumber,
367
+ prUrl,
368
+ autoMergeEnabled,
369
+ autoMergeReason,
370
+ provider,
371
+ config,
372
+ progress,
373
+ injectedGh,
374
+ injectedNotify,
375
+ });
376
+ if (!waitOutcome.confirmed) {
377
+ throw new Error(
378
+ `[single-story-close] Headless must-land: PR ${prUrl} did not reach a confirmed merge ` +
379
+ `(blockClass=${waitOutcome.blockClass}). Story #${options.storyId} was transitioned to ` +
380
+ `agent::blocked with a merge.unlanded lifecycle event. Reason: ${waitOutcome.reason}`,
381
+ );
382
+ }
383
+ const result = closeResult({
384
+ storyId: options.storyId,
385
+ storyBranch,
386
+ baseBranch,
387
+ prUrl,
388
+ prNumber,
389
+ autoMergeEnabled,
390
+ autoMergeReason,
391
+ worktreeReaped,
392
+ leaseReleased,
393
+ waitedForMerge: true,
394
+ merged: true,
395
+ });
396
+ Logger.info(
397
+ `\n--- STORY CLOSE RESULT ---\n${JSON.stringify(result, null, 2)}\n--- END RESULT ---\n`,
398
+ );
399
+ progress(
400
+ 'DONE',
401
+ `✅ Standalone Story #${options.storyId}: PR merged → ${prUrl}`,
402
+ );
403
+ return { success: true, result };
404
+ }
405
+
344
406
  const result = closeResult({
345
407
  storyId: options.storyId,
346
408
  storyBranch,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * spec-section-validator.js — Phase 7.5 Tech Spec post-authoring section gate.
2
+ * spec-section-validator.js — Tech Spec post-authoring section gate.
3
3
  *
4
4
  * `/plan` Phase 7 authors a Tech Spec from documentation and the Epic body.
5
5
  * Phase 8.3 (Holistic Consolidation) then reconciles the draft ticket array
@@ -12,19 +12,29 @@
12
12
  *
13
13
  * `validateSpecSections` parses a Tech Spec body for the required
14
14
  * `## Delivery Slicing` heading (accepting the casing/wording variants
15
- * below) and returns a deterministic `{ ok, missing[] }` envelope. The
16
- * caller (`epic-plan-spec-validate.js`) maps a non-empty `missing[]` to a
17
- * non-zero exit so Phase 8 decomposition cannot proceed against an
18
- * un-anchored spec.
15
+ * below) and returns a deterministic `{ ok, missing[] }` envelope.
19
16
  *
20
- * This is the Phase 8-side counterpart to the Phase 6 Epic Clarity Gate
17
+ * Story #4403 (Finding 3): this gate used to be run by a standalone
18
+ * `epic-plan-spec-validate.js` CLI as a separate Phase 7.5 workflow step —
19
+ * but by the time the documented ordering ran it, the Phase 7 persist path's
20
+ * `cleanupPhaseTempFiles` had already deleted the temp `techspec.md` file the
21
+ * gate read, so the "blocking gate" could never actually block. The CLI is
22
+ * retired; `runSpecPhase` (`phases/run-spec-phase.js`) now calls
23
+ * `validateSpecSections` directly against the in-memory authored content as
24
+ * part of its input validation, before any GitHub mutation. This is the
25
+ * Phase 8-side counterpart to the Phase 6 Epic Clarity Gate
21
26
  * ({@link ../epic-plan-clarity.js#scoreEpicBody}) — same detect-then-prompt
22
- * pattern, one phase later, but a hard gate (exit non-zero) rather than an
27
+ * pattern, one phase later, but a hard gate (fail closed) rather than an
23
28
  * advisory rubric.
24
29
  *
25
- * Pure ESM, no I/O.
30
+ * `validateSpecSections` and `formatMissingSectionMessage` are pure, I/O-free
31
+ * helpers. `validateSpecFile` is a thin disk-reading convenience for callers
32
+ * (tests, ad-hoc tooling) that hold a path rather than in-memory content.
26
33
  */
27
34
 
35
+ import { readFile } from 'node:fs/promises';
36
+ import { DELIVERY_SLICING_RE } from '../epic-body-sections.js';
37
+
28
38
  /**
29
39
  * The single required Tech Spec section, with the heading variants the
30
40
  * Architect persona may emit. Matched case-insensitively against any
@@ -39,7 +49,7 @@ const REQUIRED_SECTIONS = Object.freeze([
39
49
  {
40
50
  name: 'Delivery Slicing',
41
51
  /** Level-2 heading, allowing the `Delivery ` qualifier to be optional. */
42
- re: /^##\s+(?:Delivery\s+)?Slicing\s*$/im,
52
+ re: DELIVERY_SLICING_RE,
43
53
  },
44
54
  ]);
45
55
 
@@ -78,3 +88,44 @@ export function validateSpecSections({ body } = {}) {
78
88
 
79
89
  return { ok: missing.length === 0, missing, present };
80
90
  }
91
+
92
+ /**
93
+ * Validate an authored Tech Spec file for the required post-authoring
94
+ * sections. Thin wrapper around `validateSpecSections` that owns the file
95
+ * read so callers holding a path (rather than in-memory content) do not each
96
+ * re-implement the read-then-validate sequence.
97
+ *
98
+ * @param {{ techspecPath: string }} args
99
+ * @returns {Promise<{ ok: boolean, missing: string[], present: string[] }>}
100
+ */
101
+ export async function validateSpecFile({ techspecPath }) {
102
+ const body = await readFile(techspecPath, 'utf8');
103
+ return validateSpecSections({ body });
104
+ }
105
+
106
+ /**
107
+ * Build the operator-facing failure message for a missing-section result.
108
+ * Names each missing section and tells the operator whether to re-author the
109
+ * spec or add the section by hand before continuing.
110
+ *
111
+ * @param {{ techspecPath: string, missing: string[] }} args
112
+ * @returns {string}
113
+ */
114
+ export function formatMissingSectionMessage({ techspecPath, missing }) {
115
+ const list = missing.map((name) => `## ${name}`).join(', ');
116
+ return [
117
+ `[spec-section-validator] Tech Spec is missing required section(s): ${list}`,
118
+ ` Spec source: ${techspecPath}`,
119
+ '',
120
+ ` Phase 8 (decomposition) reconciles the draft ticket array against the`,
121
+ ` Tech Spec's "## Delivery Slicing" section — without it, the Phase 8.3`,
122
+ ` consolidation pass has no capability-boundary anchor and groups by`,
123
+ ` technical shape instead.`,
124
+ '',
125
+ ' To continue, do ONE of the following and re-run the Phase 7 persist step:',
126
+ ` 1. Re-author the Tech Spec (re-run the Phase 7 spec-author step) so it`,
127
+ ` emits a "## Delivery Slicing" section, OR`,
128
+ ` 2. Add a "## Delivery Slicing" section to the Tech Spec by hand,`,
129
+ ` describing the capability boundaries the work should be sliced along.`,
130
+ ].join('\n');
131
+ }
@@ -376,7 +376,7 @@ function filterToStoryDiff({ miRows, crapRows, storyDiffPaths }) {
376
376
 
377
377
  /**
378
378
  * Check whether a `baseline-refresh-regression` signal tagged with the
379
- * runner's `source.tool === 'auto-refresh-runner'` already exists in the
379
+ * runner's `emitter.tool === 'auto-refresh-runner'` already exists in the
380
380
  * Story's signals stream. Backs the AC3 idempotent-re-run contract.
381
381
  */
382
382
  async function priorRefusalSignalExists({
@@ -391,7 +391,7 @@ async function priorRefusalSignalExists({
391
391
  typeof record === 'object' &&
392
392
  record.kind === 'friction' &&
393
393
  record.category === FRICTION_CATEGORY &&
394
- record?.source?.tool === RUNNER_SOURCE_TOOL
394
+ record?.emitter?.tool === RUNNER_SOURCE_TOOL
395
395
  ) {
396
396
  found = true;
397
397
  }
@@ -413,12 +413,14 @@ function buildRefusalSignal({
413
413
  }) {
414
414
  return {
415
415
  kind: 'friction',
416
- timestamp: new Date().toISOString(),
416
+ ts: new Date().toISOString(),
417
417
  epicId,
418
418
  storyId,
419
419
  category: FRICTION_CATEGORY,
420
- source: { tool: RUNNER_SOURCE_TOOL },
421
- details: `Auto-refresh refused: ${refusalReasons.length} row(s) breach configured caps (miDropCap=${caps.miDropCap}, crapJumpCap=${caps.crapJumpCap}).`,
420
+ emitter: { tool: RUNNER_SOURCE_TOOL },
421
+ details: {
422
+ message: `Auto-refresh refused: ${refusalReasons.length} row(s) breach configured caps (miDropCap=${caps.miDropCap}, crapJumpCap=${caps.crapJumpCap}).`,
423
+ },
422
424
  refusalReasons,
423
425
  miOverCap,
424
426
  crapOverCap,
@@ -55,6 +55,10 @@ import {
55
55
  buildMergeMessageWithCap,
56
56
  loadHeaderMaxLength,
57
57
  } from './merge-subject.js';
58
+ import {
59
+ assertNoForeignEpicLock as defaultAssertNoForeignEpicLock,
60
+ assertSharedCheckoutAvailable as defaultAssertSharedCheckoutAvailable,
61
+ } from './shared-checkout-guard.js';
58
62
 
59
63
  /**
60
64
  * Render the lock-file path for a given main-repo `cwd` + `epicId`. Pure;
@@ -424,7 +428,7 @@ export async function runFinalizeMerge({
424
428
  storyBranch,
425
429
  storyTitle,
426
430
  storyId,
427
- epicId: _epicId,
431
+ epicId,
428
432
  cwd,
429
433
  config,
430
434
  bus = null,
@@ -432,6 +436,7 @@ export async function runFinalizeMerge({
432
436
  logger = DefaultLogger,
433
437
  gitSync = defaultGitSync,
434
438
  gitSpawn = defaultGitSpawn,
439
+ assertSharedCheckoutAvailable = defaultAssertSharedCheckoutAvailable,
435
440
  }) {
436
441
  rebaseStoryOnEpic({
437
442
  config,
@@ -443,6 +448,15 @@ export async function runFinalizeMerge({
443
448
  gitSpawn,
444
449
  });
445
450
 
451
+ // Story #4460 — cross-epic shared-checkout guard. Runs AFTER the
452
+ // per-Epic merge lock is already held (acquired around the whole close
453
+ // flow in story-close.js) so it composes with, rather than replaces,
454
+ // that same-epic serialization. Fails fast with an actionable
455
+ // diagnostic instead of letting a raw `git checkout` error surface
456
+ // when another epic's merge phase (or unrelated dirt) holds the
457
+ // shared checkout.
458
+ assertSharedCheckoutAvailable({ cwd, epicId, gitSpawn });
459
+
446
460
  log('GIT', `Checking out ${epicBranch}...`);
447
461
  gitSync(cwd, 'checkout', epicBranch);
448
462
  gitSpawn(cwd, 'pull', '--rebase', 'origin', epicBranch);
@@ -582,13 +596,21 @@ export async function runResumeMerge({
582
596
  storyBranch,
583
597
  storyTitle,
584
598
  storyId,
585
- epicId: _epicId,
599
+ epicId,
586
600
  config,
587
601
  bus = null,
588
602
  logger = DefaultLogger,
589
603
  log = () => {},
590
604
  gitSpawn = defaultGitSpawn,
605
+ assertNoForeignEpicLockFn = defaultAssertNoForeignEpicLock,
591
606
  }) {
607
+ // Story #4460 follow-up: the resume path re-enters the shared checkout
608
+ // just like the finalize path, so another epic's live merge phase is
609
+ // the same hazard here. Only the foreign-lock half of the guard runs —
610
+ // a resume's own partial merge legitimately leaves the tree dirty, so
611
+ // the dirty-tree probe would false-positive against our own state.
612
+ assertNoForeignEpicLockFn({ cwd, epicId });
613
+
592
614
  const resumeMergeMessage = await buildMergeMessage(storyTitle, storyId, {
593
615
  cwd,
594
616
  logger,
@@ -5,9 +5,9 @@
5
5
  * Sits between the close-validation gate chain and the merge into
6
6
  * `epic/<id>` inside `runStoryCloseLocked` (locked-pipeline.js). The
7
7
  * configured ReviewProvider runs against the
8
- * `epic/<id>`…`story-<id>` diff. The structured `code-review` comment
9
- * is posted to the Story issue (default `commentTargetId === ticketId`
10
- * inside `runCodeReview`). Outcomes:
8
+ * `epic/<id>`…`story-<id>` diff. The unified `verification-results`
9
+ * structured comment is posted to the Story issue (default
10
+ * `commentTargetId === ticketId` inside `runCodeReview`). Outcomes:
11
11
  *
12
12
  * - clean / non-critical findings → `{ blocked: null }`; the pipeline
13
13
  * proceeds to merge.
@@ -32,10 +32,141 @@
32
32
  * invocation pattern (Story #3653).
33
33
  */
34
34
 
35
+ import {
36
+ runAuditSuite,
37
+ selectLocalLenses,
38
+ } from '../../../audit-suite/index.js';
39
+ import { gitSpawn } from '../../../git-utils.js';
35
40
  import { Logger } from '../../../Logger.js';
36
41
  import { runCodeReview } from '../../code-review.js';
37
42
  import { emitBlockedCloseResult } from '../merge-runner.js';
38
43
 
44
+ /**
45
+ * The review depth the Story-scope local-lens pass runs at. Shift-left
46
+ * (Epic #4405): local concerns are cheap to decide on a single Story's diff, so
47
+ * the maker-blind Story-scope review runs its matched local lenses at `light`
48
+ * depth here rather than paying a deeper pass at Epic close. Fixed for this
49
+ * tier — it is not risk-scaled like the code-review pillar depth.
50
+ */
51
+ export const STORY_SCOPE_LENS_DEPTH = 'light';
52
+
53
+ /**
54
+ * Enumerate the files changed in the `baseRef...headRef` diff via
55
+ * `git diff --name-only`. Best-effort: returns `[]` when the diff cannot be
56
+ * enumerated (git failure, missing ref) and never throws, mirroring the
57
+ * advisory posture of the surrounding review phase. Synchronous `gitSpawn`
58
+ * (returns `{ status, stdout }`) is the same seam `code-review.js#countChangedFiles`
59
+ * uses.
60
+ *
61
+ * @param {{ baseRef: string, headRef: string, gitSpawnFn?: typeof gitSpawn }} args
62
+ * @returns {string[]} Changed file paths, or `[]` on any failure.
63
+ */
64
+ export function enumerateChangedFiles({
65
+ baseRef,
66
+ headRef,
67
+ gitSpawnFn = gitSpawn,
68
+ }) {
69
+ try {
70
+ const result = gitSpawnFn(
71
+ process.cwd(),
72
+ 'diff',
73
+ '--name-only',
74
+ `${baseRef}...${headRef}`,
75
+ );
76
+ if (!result || result.status !== 0 || typeof result.stdout !== 'string') {
77
+ return [];
78
+ }
79
+ return result.stdout
80
+ .split('\n')
81
+ .map((f) => f.trim())
82
+ .filter(Boolean);
83
+ } catch {
84
+ return [];
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Run the Story-scope local-lens pass: select the LOCAL-tier lenses whose
90
+ * `filePatterns` match the actual Story diff (`baseRef...headRef`) and
91
+ * materialize their lens-prompt bodies at `light` depth. This is the
92
+ * shift-left tier from Epic #4405 — it runs **inside** the story-close
93
+ * subprocess spine (called only from {@link runStoryReviewCore}), never in the
94
+ * delivering child's (maker's) context, so a maker never grades its own work.
95
+ *
96
+ * A diff that matches no local lens adds **no** lens work: the roster is empty
97
+ * and `runAuditSuite` is never invoked. Best-effort and total — a git or
98
+ * materialization failure degrades to `{ skipped: true, lenses: [] }` and is
99
+ * logged via `progress`, matching the advisory posture the review phase already
100
+ * takes for provider/transport failures.
101
+ *
102
+ * @param {{
103
+ * baseRef: string,
104
+ * headRef: string,
105
+ * progress: (tag: string, msg: string) => void,
106
+ * progressTag?: string,
107
+ * gitSpawnFn?: typeof gitSpawn,
108
+ * selectLocalLensesFn?: typeof selectLocalLenses,
109
+ * runAuditSuiteFn?: typeof runAuditSuite,
110
+ * }} args
111
+ * @returns {Promise<{
112
+ * depth: 'light',
113
+ * lenses: string[],
114
+ * skipped: boolean,
115
+ * materialized: object|null,
116
+ * }>}
117
+ */
118
+ export async function runLocalLensReview({
119
+ baseRef,
120
+ headRef,
121
+ progress,
122
+ progressTag = 'CODE-REVIEW',
123
+ gitSpawnFn = gitSpawn,
124
+ selectLocalLensesFn = selectLocalLenses,
125
+ runAuditSuiteFn = runAuditSuite,
126
+ }) {
127
+ const empty = {
128
+ depth: STORY_SCOPE_LENS_DEPTH,
129
+ lenses: [],
130
+ skipped: true,
131
+ materialized: null,
132
+ };
133
+ let lenses;
134
+ try {
135
+ const changedFiles = enumerateChangedFiles({
136
+ baseRef,
137
+ headRef,
138
+ gitSpawnFn,
139
+ });
140
+ lenses = selectLocalLensesFn({ changedFiles });
141
+ if (lenses.length === 0) {
142
+ progress(
143
+ progressTag,
144
+ 'No local lens matched the Story diff — skipping the lens pass.',
145
+ );
146
+ return empty;
147
+ }
148
+ const materialized = await runAuditSuiteFn({ auditWorkflows: lenses });
149
+ progress(
150
+ progressTag,
151
+ `Ran ${lenses.length} local lens(es) at ${STORY_SCOPE_LENS_DEPTH} depth: ${lenses.join(', ')}.`,
152
+ );
153
+ return {
154
+ depth: STORY_SCOPE_LENS_DEPTH,
155
+ lenses,
156
+ skipped: false,
157
+ materialized,
158
+ };
159
+ } catch (err) {
160
+ // The lens pass is advisory: a git or materialization failure must not
161
+ // fail the close. Log and degrade to a skipped envelope.
162
+ progress(
163
+ progressTag,
164
+ `⚠️ local lens pass failed (continuing without it): ${err?.message ?? err}`,
165
+ );
166
+ return empty;
167
+ }
168
+ }
169
+
39
170
  /**
40
171
  * Collect the extra fields for the code-review-critical blocked envelope.
41
172
  * Pure; used by `runStoryCodeReview` to populate the `extra` argument of
@@ -92,8 +223,13 @@ function buildCodeReviewBlockedExtra({ storyId, reviewResult }) {
92
223
  * progressTag?: string,
93
224
  * planningRisk?: { overallLevel?: ('low'|'medium'|'high'), axes?: Array<{ axis?: string, level?: string }> }|null,
94
225
  * runCodeReviewFn?: typeof runCodeReview,
226
+ * runLocalLensReviewFn?: typeof runLocalLensReview,
95
227
  * }} args
96
- * @returns {Promise<object>} Raw result envelope from `runCodeReview`.
228
+ * @returns {Promise<object>} Raw result envelope from `runCodeReview`, augmented
229
+ * with a `localLensReview` field carrying the Story-scope local-lens pass
230
+ * outcome (Epic #4405, Story #4409). Both close entry points reach the lens
231
+ * pass through this single spine, so it runs on the Epic-attached and
232
+ * standalone paths alike and always inside the close subprocess.
97
233
  */
98
234
  export async function runStoryReviewCore({
99
235
  storyId,
@@ -105,6 +241,7 @@ export async function runStoryReviewCore({
105
241
  progressTag = 'CODE-REVIEW',
106
242
  planningRisk = null,
107
243
  runCodeReviewFn = runCodeReview,
244
+ runLocalLensReviewFn = runLocalLensReview,
108
245
  }) {
109
246
  const storyIdNum = Number(storyId);
110
247
  const opts = {
@@ -127,7 +264,21 @@ export async function runStoryReviewCore({
127
264
  if (planningRisk != null) {
128
265
  opts.planningRisk = planningRisk;
129
266
  }
130
- return runCodeReviewFn(opts);
267
+
268
+ // Shift-left local-lens pass (Epic #4405). Runs matched local lenses at
269
+ // `light` depth against the actual Story diff, inside this close-subprocess
270
+ // spine so the maker never grades its own work. Advisory — it never blocks
271
+ // the close and its outcome rides on the returned envelope for downstream
272
+ // consumers.
273
+ const localLensReview = await runLocalLensReviewFn({
274
+ baseRef,
275
+ headRef,
276
+ progress,
277
+ progressTag,
278
+ });
279
+
280
+ const result = await runCodeReviewFn(opts);
281
+ return { ...result, localLensReview };
131
282
  }
132
283
 
133
284
  /**
@@ -155,8 +306,12 @@ export async function runStoryReviewCore({
155
306
  * progress: (tag: string, msg: string) => void,
156
307
  * planningRisk?: { overallLevel?: ('low'|'medium'|'high'), axes?: Array<{ axis?: string, level?: string }> }|null,
157
308
  * runCodeReviewFn?: typeof runCodeReview,
309
+ * runLocalLensReviewFn?: typeof runLocalLensReview,
158
310
  * }} args
159
- * @returns {Promise<{ blocked: object|null }>}
311
+ * @returns {Promise<{ blocked: object|null, localLensReview?: object }>}
312
+ * `localLensReview` carries the Story-scope local-lens pass outcome
313
+ * (Epic #4405, Story #4409) when the review completed; it is absent only when
314
+ * the whole review phase threw (advisory failure).
160
315
  */
161
316
  export async function runStoryCodeReview(args) {
162
317
  const {
@@ -168,6 +323,7 @@ export async function runStoryCodeReview(args) {
168
323
  progress,
169
324
  planningRisk = null,
170
325
  runCodeReviewFn = runCodeReview,
326
+ runLocalLensReviewFn = runLocalLensReview,
171
327
  } = args;
172
328
 
173
329
  const storyIdNum = Number(storyId);
@@ -186,6 +342,7 @@ export async function runStoryCodeReview(args) {
186
342
  progress,
187
343
  planningRisk,
188
344
  runCodeReviewFn,
345
+ runLocalLensReviewFn,
189
346
  });
190
347
  } catch (err) {
191
348
  // Adapter / wiring failure — log and proceed. The review is advisory
@@ -197,6 +354,8 @@ export async function runStoryCodeReview(args) {
197
354
  return { blocked: null };
198
355
  }
199
356
 
357
+ const localLensReview = reviewResult?.localLensReview;
358
+
200
359
  if (reviewResult?.halted) {
201
360
  const blocked = await emitBlockedCloseResult({
202
361
  storyId: storyIdNum,
@@ -208,7 +367,7 @@ export async function runStoryCodeReview(args) {
208
367
  blockedMessage: `Story #${storyIdNum} blocked: code-review reported ${reviewResult.severity.critical} critical blocker(s).`,
209
368
  logger: Logger,
210
369
  });
211
- return { blocked };
370
+ return { blocked, localLensReview };
212
371
  }
213
372
 
214
373
  const counts = reviewResult?.severity ?? {};
@@ -216,5 +375,5 @@ export async function runStoryCodeReview(args) {
216
375
  'CODE-REVIEW',
217
376
  `Review complete — high=${counts.high ?? 0} medium=${counts.medium ?? 0} suggestion=${counts.suggestion ?? 0} (posted=${reviewResult?.posted ?? false}).`,
218
377
  );
219
- return { blocked: null };
378
+ return { blocked: null, localLensReview };
220
379
  }
@@ -133,7 +133,14 @@ export async function runPreMergeGates({
133
133
  // `buildDefaultGates` reads the canonical resolved config directly:
134
134
  // gate commands resolve from `project.commands` and the CRAP toggle
135
135
  // from `delivery.quality.gates.crap.enabled`.
136
- const gates = buildDefaultGates({ config, epicBranch });
136
+ // Probe the coverage script from the gate execution directory (the Story
137
+ // worktree when present) so coverage-capture is only registered when the
138
+ // consumer ships `test:coverage` (#4473).
139
+ const gates = buildDefaultGates({
140
+ config,
141
+ epicBranch,
142
+ cwd: worktreePath || cwd,
143
+ });
137
144
  const gateCount = Array.isArray(gates) ? gates.length : 0;
138
145
  // Story #2250 — emit `close-validate.start` only when both an epicId
139
146
  // and a storyId are present; the schema requires both, and unit