mandrel 1.62.0 → 1.63.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 (27) hide show
  1. package/.agents/scripts/check-action-pinning.js +260 -0
  2. package/.agents/scripts/check-arch-cycles.js +38 -14
  3. package/.agents/scripts/epic-deliver-prepare.js +149 -104
  4. package/.agents/scripts/lib/baseline-snapshot.js +245 -141
  5. package/.agents/scripts/lib/feedback-loop/graduator-core.js +171 -137
  6. package/.agents/scripts/lib/orchestration/code-review.js +206 -168
  7. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/creation.js +71 -5
  8. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist.js +16 -2
  9. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/component-drift.js +101 -1
  10. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/crap-drift.js +20 -42
  11. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js +12 -32
  12. package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +97 -60
  13. package/.agents/scripts/lib/orchestration/model-attribution.js +73 -45
  14. package/.agents/scripts/lib/orchestration/review-providers/parse-findings.js +97 -49
  15. package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +73 -69
  16. package/.agents/scripts/lib/orchestration/story-close-recovery.js +109 -79
  17. package/.agents/scripts/lib/signals/detectors/common.js +107 -0
  18. package/.agents/scripts/lib/signals/detectors/hotspot.js +12 -18
  19. package/.agents/scripts/lib/signals/detectors/retry.js +3 -40
  20. package/.agents/scripts/lib/signals/detectors/rework.js +3 -40
  21. package/.agents/scripts/lib/story-body/story-body.js +102 -76
  22. package/.agents/scripts/providers/github/blocked-by-add.js +252 -0
  23. package/.agents/scripts/single-story-init.js +16 -3
  24. package/.agents/workflows/audit-architecture.md +9 -0
  25. package/README.md +1 -1
  26. package/docs/CHANGELOG.md +28 -0
  27. package/package.json +1 -1
@@ -463,203 +463,241 @@ function resolveScopeEnvelope(opts, config) {
463
463
  * blockerReason: string|null,
464
464
  * }>}
465
465
  */
466
- export async function runCodeReview(opts = {}) {
466
+ /**
467
+ * Resolve the human-facing provider name from the resolved code-review
468
+ * config. Chain configs render as `chain[a,b,...]`; a single-provider
469
+ * config renders its `provider` string; everything else falls back to
470
+ * `'native'`. Story #4075 — extracted from `runCodeReview`.
471
+ */
472
+ function resolveProviderName(codeReviewConfig) {
473
+ const isChainConfig =
474
+ codeReviewConfig &&
475
+ Array.isArray(codeReviewConfig.providers) &&
476
+ codeReviewConfig.providers.length > 0;
477
+ if (isChainConfig) {
478
+ return `chain[${codeReviewConfig.providers
479
+ .map((p) => p?.name ?? '?')
480
+ .join(',')}]`;
481
+ }
482
+ return (
483
+ (codeReviewConfig && typeof codeReviewConfig.provider === 'string'
484
+ ? codeReviewConfig.provider
485
+ : null) ?? 'native'
486
+ );
487
+ }
488
+
489
+ /**
490
+ * Build the provider `runReview` input, resolving the review depth from the
491
+ * judged risk envelope's `overallLevel` and the mechanical changed-file
492
+ * count of the diff under review (Story #3876 / #3938). The depth is an
493
+ * input-only signal (light → standard → deep) and never touches the output
494
+ * envelope or the posted comment. Absent risk envelope + unknown width →
495
+ * `standard`. Story #4075 — extracted from `runCodeReview`.
496
+ */
497
+ function buildReviewInput({ opts, config, scope, ticketId, baseRef, headRef }) {
498
+ const changedFileCount =
499
+ typeof opts.changedFileCount === 'number'
500
+ ? opts.changedFileCount
501
+ : countChangedFiles({ baseRef, headRef, gitSpawnFn: opts.gitSpawnFn });
502
+ const depth = resolveDepth({
503
+ overallLevel: opts.planningRisk?.overallLevel,
504
+ changedFileCount,
505
+ sizing: resolveTaskSizing(config),
506
+ });
507
+ return {
508
+ scope,
509
+ ticketId,
510
+ baseRef,
511
+ headRef,
512
+ labels: Array.isArray(opts.ticketLabels) ? opts.ticketLabels : [],
513
+ depth,
514
+ };
515
+ }
516
+
517
+ /**
518
+ * Feature-detect manual-prompt providers (Story #2871). Legacy
519
+ * single-adapter providers don't carry `getPromptMessages`, so the
520
+ * empty-array fallback keeps the old snapshot byte-stable; a throw is
521
+ * logged and degraded to empty.
522
+ */
523
+ async function resolvePromptMessages(reviewProvider, reviewInput, logger) {
524
+ if (typeof reviewProvider.getPromptMessages !== 'function') return [];
525
+ try {
526
+ const out = await reviewProvider.getPromptMessages(reviewInput);
527
+ return Array.isArray(out) ? out : [];
528
+ } catch (err) {
529
+ logger?.warn?.(
530
+ `[code-review] getPromptMessages threw; treating as empty. ${
531
+ err?.message ?? err
532
+ }`,
533
+ );
534
+ return [];
535
+ }
536
+ }
537
+
538
+ /**
539
+ * Upsert the rendered report as a structured comment. Posting failure is
540
+ * non-fatal: it is logged and surfaced via `posted: false`. Story #4075 —
541
+ * extracted from `runCodeReview`.
542
+ */
543
+ async function postReviewComment({
544
+ upsertCommentFn,
545
+ provider,
546
+ commentTargetId,
547
+ report,
548
+ logger,
549
+ }) {
550
+ try {
551
+ const postResult = await upsertCommentFn(
552
+ provider,
553
+ commentTargetId,
554
+ 'code-review',
555
+ report,
556
+ );
557
+ const postedCommentId =
558
+ typeof postResult?.commentId === 'number'
559
+ ? postResult.commentId
560
+ : typeof postResult?.id === 'number'
561
+ ? postResult.id
562
+ : null;
563
+ logger?.info?.(
564
+ `[code-review] Posted structured comment to #${commentTargetId}.`,
565
+ );
566
+ return { posted: true, postedCommentId };
567
+ } catch (err) {
568
+ logger?.warn?.(
569
+ `[code-review] Failed to upsert structured comment on #${commentTargetId}: ${err?.message ?? err}`,
570
+ );
571
+ return { posted: false, postedCommentId: null };
572
+ }
573
+ }
574
+
575
+ /**
576
+ * Run the review pipeline (resolve provider → runReview → prompt messages →
577
+ * render → post comment) and shape the `status: 'ok'` result. Pure of the
578
+ * lifecycle-boundary concern — `runCodeReview` owns the start/end emit pair.
579
+ * Story #4075 — extracted to keep both bodies below the CC must-fix band.
580
+ */
581
+ async function executeReviewPipeline({ opts, config, envelope }) {
467
582
  const {
468
583
  provider,
469
584
  logger,
470
- bus,
471
- now = Date.now,
472
585
  reviewProvider: injectedReviewProvider,
473
- resolveConfigFn = resolveConfig,
474
586
  createReviewProviderFn = createReviewProvider,
475
587
  upsertCommentFn = upsertStructuredComment,
476
588
  renderFindingsFn = renderFindings,
477
589
  } = opts;
590
+ const { scope, ticketId, baseRef, headRef, commentTargetId } = envelope;
591
+
592
+ const codeReviewConfig = config?.delivery?.codeReview ?? null;
593
+ const providerName = resolveProviderName(codeReviewConfig);
594
+ const reviewProvider =
595
+ injectedReviewProvider ?? createReviewProviderFn(codeReviewConfig);
596
+
597
+ logger?.info?.(
598
+ `[code-review] Running ${providerName} adapter for ${scope === 'epic' ? 'Epic' : 'Story'} #${ticketId} (${baseRef}...${headRef})...`,
599
+ );
600
+
601
+ const reviewInput = buildReviewInput({
602
+ opts,
603
+ config,
604
+ scope,
605
+ ticketId,
606
+ baseRef,
607
+ headRef,
608
+ });
609
+
610
+ const findings = await reviewProvider.runReview(reviewInput);
611
+ if (!Array.isArray(findings)) {
612
+ throw new TypeError(
613
+ `[code-review] Review provider "${providerName}" returned a non-array; expected Finding[].`,
614
+ );
615
+ }
616
+
617
+ const promptMessages = await resolvePromptMessages(
618
+ reviewProvider,
619
+ reviewInput,
620
+ logger,
621
+ );
622
+
623
+ const severity = countBySeverity(findings);
624
+ const halted = severity.critical > 0;
625
+ const report = renderFindingsFn({
626
+ scope,
627
+ ticketId,
628
+ baseRef,
629
+ headRef,
630
+ findings,
631
+ provider: providerName,
632
+ promptMessages,
633
+ });
634
+
635
+ const { posted, postedCommentId } = await postReviewComment({
636
+ upsertCommentFn,
637
+ provider,
638
+ commentTargetId,
639
+ report,
640
+ logger,
641
+ });
642
+
643
+ return {
644
+ status: 'ok',
645
+ severity,
646
+ report,
647
+ posted,
648
+ postedCommentId,
649
+ commentTargetId,
650
+ halted,
651
+ blockerReason: halted
652
+ ? `code-review reported ${severity.critical} critical blocker(s)`
653
+ : null,
654
+ };
655
+ }
656
+
657
+ export async function runCodeReview(opts = {}) {
658
+ const { bus, now = Date.now, resolveConfigFn = resolveConfig } = opts;
478
659
 
479
660
  const config = resolveConfigFn();
480
661
  const envelope = resolveScopeEnvelope(opts, config);
481
- const { scope, ticketId, baseRef, headRef, commentTargetId } = envelope;
662
+ const { scope } = envelope;
482
663
 
483
664
  // Epic-scope lifecycle ledger requires `bus`; Story-scope sits outside
484
665
  // the Epic lifecycle so the bus is optional there. A caller without a
485
666
  // bus on the Story path still gets the full review semantics — only the
486
667
  // `code-review.start`/`.end` events are suppressed.
487
- const requiresBus = scope === 'epic';
488
- if (requiresBus && (!bus || typeof bus.emit !== 'function')) {
668
+ if (scope === 'epic' && (!bus || typeof bus.emit !== 'function')) {
489
669
  throw new TypeError('runCodeReview: bus is required (object with emit()).');
490
670
  }
491
671
  const ledgerEnabled =
492
672
  scope === 'epic' && bus && typeof bus.emit === 'function';
493
673
 
494
674
  const startedAt = typeof now === 'function' ? now() : Date.now();
675
+ // Emit the matched `code-review.end` boundary; the ledger must always
676
+ // show a start/end pair (even on adapter throw, where `result` is the
677
+ // canonical `{ status: 'invalid' }`).
678
+ const emitEnd = async (result) => {
679
+ if (!ledgerEnabled) return;
680
+ const endedAt = typeof now === 'function' ? now() : Date.now();
681
+ await bus.emit(
682
+ 'code-review.end',
683
+ buildCodeReviewEndPayload({
684
+ epicId: envelope.epicIdForLedger,
685
+ result,
686
+ durationMs: Math.max(0, endedAt - startedAt),
687
+ }),
688
+ );
689
+ };
690
+
495
691
  if (ledgerEnabled) {
496
692
  await bus.emit('code-review.start', { epicId: envelope.epicIdForLedger });
497
693
  }
498
694
 
499
695
  try {
500
- const codeReviewConfig = config?.delivery?.codeReview ?? null;
501
- const isChainConfig =
502
- codeReviewConfig &&
503
- Array.isArray(codeReviewConfig.providers) &&
504
- codeReviewConfig.providers.length > 0;
505
- const providerName = isChainConfig
506
- ? `chain[${codeReviewConfig.providers
507
- .map((p) => p?.name ?? '?')
508
- .join(',')}]`
509
- : ((codeReviewConfig && typeof codeReviewConfig.provider === 'string'
510
- ? codeReviewConfig.provider
511
- : null) ?? 'native');
512
- const reviewProvider =
513
- injectedReviewProvider ?? createReviewProviderFn(codeReviewConfig);
514
-
515
- const scopeLabel = scope === 'epic' ? 'Epic' : 'Story';
516
- logger?.info?.(
517
- `[code-review] Running ${providerName} adapter for ${scopeLabel} #${ticketId} (${baseRef}...${headRef})...`,
518
- );
519
-
520
- const ticketLabels = Array.isArray(opts.ticketLabels)
521
- ? opts.ticketLabels
522
- : [];
523
- // Story #3876 / #3938 — resolve the review depth from BOTH the judged risk
524
- // envelope's `overallLevel` and the mechanical changed-file count of the
525
- // diff under review, then thread it into the provider's `runReview` input.
526
- // The depth is an input-only signal: it tells the provider how thorough to
527
- // be (light → standard → deep) and never touches the output envelope or the
528
- // posted structured comment. The changed-file count is enumerated from the
529
- // same `baseRef...headRef` diff the native provider reviews; a count that
530
- // cannot be determined is the neutral "width unknown" signal that neither
531
- // escalates to `deep` nor blocks `light`. The `sizing` thresholds honour
532
- // the operator's `planning.taskSizing` override. Absent risk envelope +
533
- // unknown width → `standard` (the neutral default), preserving the
534
- // pre-change behaviour for callers that pass no risk envelope.
535
- const changedFileCount =
536
- typeof opts.changedFileCount === 'number'
537
- ? opts.changedFileCount
538
- : countChangedFiles({
539
- baseRef,
540
- headRef,
541
- gitSpawnFn: opts.gitSpawnFn,
542
- });
543
- const depth = resolveDepth({
544
- overallLevel: opts.planningRisk?.overallLevel,
545
- changedFileCount,
546
- sizing: resolveTaskSizing(config),
547
- });
548
- const reviewInput = {
549
- scope,
550
- ticketId,
551
- baseRef,
552
- headRef,
553
- labels: ticketLabels,
554
- depth,
555
- };
556
-
557
- const findings = await reviewProvider.runReview(reviewInput);
558
-
559
- if (!Array.isArray(findings)) {
560
- throw new TypeError(
561
- `[code-review] Review provider "${providerName}" returned a non-array; expected Finding[].`,
562
- );
563
- }
564
-
565
- // Story #2871 — feature-detect manual-prompt providers. Legacy
566
- // single-adapter providers don't carry `getPromptMessages`, so the
567
- // empty-array fallback keeps the old snapshot byte-stable.
568
- let promptMessages = [];
569
- if (typeof reviewProvider.getPromptMessages === 'function') {
570
- try {
571
- const out = await reviewProvider.getPromptMessages(reviewInput);
572
- promptMessages = Array.isArray(out) ? out : [];
573
- } catch (err) {
574
- logger?.warn?.(
575
- `[code-review] getPromptMessages threw; treating as empty. ${
576
- err?.message ?? err
577
- }`,
578
- );
579
- promptMessages = [];
580
- }
581
- }
582
-
583
- const severity = countBySeverity(findings);
584
- const halted = severity.critical > 0;
585
- const blockerReason = halted
586
- ? `code-review reported ${severity.critical} critical blocker(s)`
587
- : null;
588
-
589
- const report = renderFindingsFn({
590
- scope,
591
- ticketId,
592
- baseRef,
593
- headRef,
594
- findings,
595
- provider: providerName,
596
- promptMessages,
597
- });
598
-
599
- let posted = false;
600
- let postedCommentId = null;
601
- try {
602
- const postResult = await upsertCommentFn(
603
- provider,
604
- commentTargetId,
605
- 'code-review',
606
- report,
607
- );
608
- posted = true;
609
- const rawId =
610
- typeof postResult?.commentId === 'number'
611
- ? postResult.commentId
612
- : typeof postResult?.id === 'number'
613
- ? postResult.id
614
- : null;
615
- postedCommentId = rawId;
616
- logger?.info?.(
617
- `[code-review] Posted structured comment to #${commentTargetId}.`,
618
- );
619
- } catch (err) {
620
- logger?.warn?.(
621
- `[code-review] Failed to upsert structured comment on #${commentTargetId}: ${err?.message ?? err}`,
622
- );
623
- posted = false;
624
- }
625
-
626
- const result = {
627
- status: 'ok',
628
- severity,
629
- report,
630
- posted,
631
- postedCommentId,
632
- commentTargetId,
633
- halted,
634
- blockerReason,
635
- };
636
- if (ledgerEnabled) {
637
- const endedAt = typeof now === 'function' ? now() : Date.now();
638
- await bus.emit(
639
- 'code-review.end',
640
- buildCodeReviewEndPayload({
641
- epicId: envelope.epicIdForLedger,
642
- result,
643
- durationMs: Math.max(0, endedAt - startedAt),
644
- }),
645
- );
646
- }
696
+ const result = await executeReviewPipeline({ opts, config, envelope });
697
+ await emitEnd(result);
647
698
  return result;
648
699
  } catch (err) {
649
- // Surface the closing boundary even on adapter throw — the ledger
650
- // must always show a matched start/end pair. `status: 'invalid'`
651
- // is the canonical "could not complete" value.
652
- if (ledgerEnabled) {
653
- const endedAt = typeof now === 'function' ? now() : Date.now();
654
- await bus.emit(
655
- 'code-review.end',
656
- buildCodeReviewEndPayload({
657
- epicId: envelope.epicIdForLedger,
658
- result: { status: 'invalid' },
659
- durationMs: Math.max(0, endedAt - startedAt),
660
- }),
661
- );
662
- }
700
+ await emitEnd({ status: 'invalid' });
663
701
  throw err;
664
702
  }
665
703
  }
@@ -1,14 +1,15 @@
1
1
  /**
2
- * creation.js — sub-issue link reconciliation, Epic label transitions, and
3
- * the advisory ticket-cap warning used by the reconciler-based persist flow
4
- * (`persist.js`).
2
+ * creation.js — sub-issue link reconciliation, Epic label transitions,
3
+ * the advisory ticket-cap warning, and the blocked-by dependency wiring
4
+ * used by the reconciler-based persist flow (`persist.js`).
5
5
  *
6
- * Exports: `reconcileSubIssueLinks`, `setEpicLabel`,
7
- * `warnTicketCapNearLimit`.
6
+ * Exports: `reconcileSubIssueLinks`, `setBlockedByDependencies`,
7
+ * `setEpicLabel`, `warnTicketCapNearLimit`.
8
8
  *
9
9
  * @module lib/orchestration/epic-plan-decompose/phases/creation
10
10
  */
11
11
 
12
+ import { applyBlockedByDependencies } from '../../../../providers/github/blocked-by-add.js';
12
13
  import { Logger } from '../../../Logger.js';
13
14
  import { AGENT_LABELS } from '../../../label-constants.js';
14
15
 
@@ -36,6 +37,71 @@ export async function reconcileSubIssueLinks(epicId, provider) {
36
37
  );
37
38
  }
38
39
 
40
+ /**
41
+ * Translate each Story's `dependsOn` slug list into native GitHub "blocked
42
+ * by" dependency edges. Best-effort and non-fatal: individual edge failures
43
+ * are logged as warnings and do not abort the decompose phase.
44
+ *
45
+ * Requires the provider to expose `owner`, `repo`, `_gh`, and `getTicket`.
46
+ * No-ops silently when any required surface is absent so callers remain
47
+ * safe across provider stubs.
48
+ *
49
+ * @param {number} epicId
50
+ * @param {import('../../../ITicketingProvider.js').ITicketingProvider} provider
51
+ * @param {object} spec — the parsed YAML spec (has `.stories[*].dependsOn`).
52
+ * @param {object} stateMapping — slug → `{ issueNumber }` from the reconciler state.
53
+ */
54
+ export async function setBlockedByDependencies(
55
+ epicId,
56
+ provider,
57
+ spec,
58
+ stateMapping,
59
+ ) {
60
+ const stories = Array.isArray(spec?.stories) ? spec.stories : [];
61
+ const hasDeps = stories.some(
62
+ (s) => Array.isArray(s.dependsOn) && s.dependsOn.length > 0,
63
+ );
64
+ if (!hasDeps) return;
65
+
66
+ if (
67
+ !provider?.owner ||
68
+ !provider?.repo ||
69
+ !provider?._gh ||
70
+ typeof provider.getTicket !== 'function'
71
+ ) {
72
+ Logger.warn(
73
+ `[Decomposer] setBlockedByDependencies: provider missing required surface (owner/repo/_gh/getTicket); skipping.`,
74
+ );
75
+ return;
76
+ }
77
+
78
+ Logger.info(
79
+ `[Decomposer] Setting native blocked-by dependency edges for Epic #${epicId}...`,
80
+ );
81
+
82
+ // Build a slug → issueNumber map from the reconciler state mapping.
83
+ const slugToIssueNumber = {};
84
+ for (const [slug, entry] of Object.entries(stateMapping ?? {})) {
85
+ if (typeof entry?.issueNumber === 'number') {
86
+ slugToIssueNumber[slug] = entry.issueNumber;
87
+ }
88
+ }
89
+
90
+ const result = await applyBlockedByDependencies({
91
+ stories,
92
+ slugToIssueNumber,
93
+ getTicket: (n) => provider.getTicket(n),
94
+ owner: provider.owner,
95
+ repo: provider.repo,
96
+ gh: provider._gh,
97
+ });
98
+
99
+ const { edgesAdded, edgesSkipped, edgesFailed, storiesProcessed } = result;
100
+ Logger.info(
101
+ `[Decomposer] blocked-by edges: ${edgesAdded} added, ${edgesSkipped} already present, ${edgesFailed} failed (${storiesProcessed} stories with deps processed).`,
102
+ );
103
+ }
104
+
39
105
  export async function setEpicLabel(provider, epicId, targetLabel) {
40
106
  const planningLabels = [AGENT_LABELS.REVIEW_SPEC, AGENT_LABELS.READY];
41
107
  await provider.updateTicket(epicId, {
@@ -29,7 +29,7 @@ import {
29
29
  PLANNING_HEALTHCHECK_WAIVED,
30
30
  } from '../../../label-constants.js';
31
31
  import { cleanupPhaseTempFiles } from '../../../plan-phase-cleanup.js';
32
- import { writeSpec } from '../../../spec/index.js';
32
+ import { loadState, writeSpec } from '../../../spec/index.js';
33
33
  import {
34
34
  assertNoOpenPlanChildren,
35
35
  releaseEpicPlanLease,
@@ -38,6 +38,7 @@ import { renderSpec } from '../../spec-renderer.js';
38
38
  import { renderHardConflictError } from '../../ticket-validator-conflicts.js';
39
39
  import {
40
40
  reconcileSubIssueLinks,
41
+ setBlockedByDependencies,
41
42
  setEpicLabel,
42
43
  warnTicketCapNearLimit,
43
44
  } from './creation.js';
@@ -59,7 +60,7 @@ import { RECONCILE_CLI, spawnReconcilerApply } from './reconcile-spawn.js';
59
60
  * @param {import('../../../ITicketingProvider.js').ITicketingProvider} provider
60
61
  * @param {{ tickets: Array<object> }} payload
61
62
  * @param {object} config
62
- * @param {{ force?: boolean, resume?: boolean, allowOverBudget?: boolean, allowLargeFanOut?: boolean, fanOutCounter?: (arg: { path: string }) => number, spawnSync?: typeof defaultSpawnSync, reconcileCli?: string, writeSpecFn?: typeof writeSpec, renderSpecFn?: typeof renderSpec, cwd?: string, runHealthcheckFn?: typeof defaultRunPlanHealthcheck, skipHealthcheck?: boolean }} [opts]
63
+ * @param {{ force?: boolean, resume?: boolean, allowOverBudget?: boolean, allowLargeFanOut?: boolean, fanOutCounter?: (arg: { path: string }) => number, spawnSync?: typeof defaultSpawnSync, reconcileCli?: string, writeSpecFn?: typeof writeSpec, renderSpecFn?: typeof renderSpec, loadStateFn?: typeof loadState, cwd?: string, runHealthcheckFn?: typeof defaultRunPlanHealthcheck, skipHealthcheck?: boolean }} [opts]
63
64
  */
64
65
  export async function runDecomposePhase(
65
66
  epicId,
@@ -76,6 +77,7 @@ export async function runDecomposePhase(
76
77
  reconcileCli = RECONCILE_CLI,
77
78
  writeSpecFn = writeSpec,
78
79
  renderSpecFn = renderSpec,
80
+ loadStateFn = loadState,
79
81
  cwd = PROJECT_ROOT,
80
82
  runHealthcheckFn = defaultRunPlanHealthcheck,
81
83
  skipHealthcheck = false,
@@ -170,6 +172,18 @@ export async function runDecomposePhase(
170
172
  // re-establish missing native links before flipping the Epic to ready.
171
173
  await reconcileSubIssueLinks(epicId, provider);
172
174
 
175
+ // Story #4067 — translate `depends_on` edges into native GitHub "blocked
176
+ // by" dependencies so maintainers see blocking relationships in the UI.
177
+ // Best-effort and non-fatal: the reconciler has already written the state
178
+ // file, so we load it here to get the authoritative slug→issueNumber map.
179
+ const postReconcileState = loadStateFn(epicId);
180
+ await setBlockedByDependencies(
181
+ epicId,
182
+ provider,
183
+ spec,
184
+ postReconcileState.mapping,
185
+ );
186
+
173
187
  const checkpoint = await recordCheckpoint(provider, epicId, tickets);
174
188
 
175
189
  // Story #2921 (Epic #2880 F7) — `agent::ready` handoff gate. The
@@ -1,3 +1,4 @@
1
+ import nodeFs from 'node:fs';
1
2
  import path from 'node:path';
2
3
  import { resolveComponents } from '../../../baselines/components.js';
3
4
  import { componentOrder } from './_bullet-format.js';
@@ -70,7 +71,7 @@ export function walkComponentRegressions(params = {}, spec) {
70
71
  * metadata?: Record<string, unknown>,
71
72
  * }} opts
72
73
  */
73
- export function createSnapshotStore({ fs, baselinePath, metadata = {} }) {
74
+ function createSnapshotStore({ fs, baselinePath, metadata = {} }) {
74
75
  return {
75
76
  persist(scores) {
76
77
  if (!fs.writeFileSync) return;
@@ -101,3 +102,102 @@ export function createSnapshotStore({ fs, baselinePath, metadata = {} }) {
101
102
  },
102
103
  };
103
104
  }
105
+
106
+ /**
107
+ * Shared per-file/per-method drift-detector skeleton for the progress-signal
108
+ * detectors (Story #4076). `crap-drift.js` and `maintainability-drift.js`
109
+ * previously duplicated the same `{ baselinePath, captureBaseline,
110
+ * loadBaseline, detect }` shape — identical snapshot-store wiring, identical
111
+ * "distinct from canonical baseline" framing, and identical
112
+ * swallow-and-warn resilience. The only divergence is the per-axis scoring
113
+ * (`scoreFile`), the snapshot value extracted from a score (`captureScore`),
114
+ * and the per-detect comparison that emits bullets (`detect`). Both detectors
115
+ * now supply those deltas to this factory, mirroring the "shared walker +
116
+ * per-axis delta" pattern already established by `walkComponentRegressions`.
117
+ *
118
+ * The factory owns:
119
+ *
120
+ * - `fs` / `cwd` / `files` resolution from `opts`.
121
+ * - The `<cwd>/<baselineDir>/<baselineFilename>` baseline path and the
122
+ * `createSnapshotStore` wiring (with caller-supplied `metadata`).
123
+ * - The in-memory `baseline` cache and the `captureBaseline` /
124
+ * `loadBaseline` lifecycle.
125
+ *
126
+ * The caller owns:
127
+ *
128
+ * - `scoreFile(relPath, ctx)` — returns the per-file score (any shape) or
129
+ * `null` when the file can't be scored. `ctx` is whatever
130
+ * `beforeScore()` returned for this pass (e.g. a coverage map), or
131
+ * `undefined` when `beforeScore` is omitted.
132
+ * - `captureScore(score)` — maps a `scoreFile` result to the value
133
+ * persisted in the snapshot (e.g. extract `crap` from each method row).
134
+ * - `detect({ baseline, scoreFile })` — async; returns the bullet list.
135
+ * Receives the loaded baseline and a `scoreFile(relPath)` bound to this
136
+ * pass's score context so the per-axis compare stays free of plumbing.
137
+ * - `beforeScore()` (optional) — runs once per `captureBaseline` / `detect`
138
+ * pass and returns the score context threaded into `scoreFile`.
139
+ *
140
+ * @param {{
141
+ * cwd?: string,
142
+ * files?: string[],
143
+ * fs?: { readFileSync?: Function, writeFileSync?: Function, mkdirSync?: Function, existsSync?: Function },
144
+ * baselineDir?: string,
145
+ * baselineFilename: string,
146
+ * metadata?: Record<string, unknown>,
147
+ * beforeScore?: () => unknown,
148
+ * scoreFile: (relPath: string, ctx: unknown) => unknown,
149
+ * captureScore: (score: unknown) => unknown,
150
+ * detect: (args: { baseline: Record<string, unknown>, scoreFile: (relPath: string) => unknown }) => Promise<string[]>,
151
+ * }} opts
152
+ */
153
+ export function createDriftDetector(opts = {}) {
154
+ const fs = opts.fs ?? nodeFs;
155
+ const cwd = opts.cwd ?? process.cwd();
156
+ const files = Array.isArray(opts.files) ? [...opts.files] : [];
157
+ const baselineDir = opts.baselineDir ?? '.agents/state';
158
+ const baselinePath = path.join(cwd, baselineDir, opts.baselineFilename);
159
+ const beforeScore = opts.beforeScore ?? (() => undefined);
160
+ const scoreFile = opts.scoreFile;
161
+ const captureScore = opts.captureScore;
162
+ const runDetect = opts.detect;
163
+ const store = createSnapshotStore({
164
+ fs,
165
+ baselinePath,
166
+ metadata: opts.metadata ?? {},
167
+ });
168
+
169
+ let baseline = null;
170
+
171
+ return {
172
+ get baselinePath() {
173
+ return baselinePath;
174
+ },
175
+
176
+ captureBaseline() {
177
+ const ctx = beforeScore();
178
+ const snapshot = {};
179
+ for (const f of files) {
180
+ const score = scoreFile(f, ctx);
181
+ if (score == null) continue;
182
+ snapshot[f] = captureScore(score);
183
+ }
184
+ baseline = snapshot;
185
+ store.persist(snapshot);
186
+ return snapshot;
187
+ },
188
+
189
+ loadBaseline() {
190
+ baseline = store.load();
191
+ return baseline;
192
+ },
193
+
194
+ async detect() {
195
+ if (!baseline) return [];
196
+ const ctx = beforeScore();
197
+ return runDetect({
198
+ baseline,
199
+ scoreFile: (relPath) => scoreFile(relPath, ctx),
200
+ });
201
+ },
202
+ };
203
+ }