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.
- package/.agents/scripts/check-action-pinning.js +260 -0
- package/.agents/scripts/check-arch-cycles.js +38 -14
- package/.agents/scripts/epic-deliver-prepare.js +149 -104
- package/.agents/scripts/lib/baseline-snapshot.js +245 -141
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +171 -137
- package/.agents/scripts/lib/orchestration/code-review.js +206 -168
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/creation.js +71 -5
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist.js +16 -2
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/component-drift.js +101 -1
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/crap-drift.js +20 -42
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js +12 -32
- package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +97 -60
- package/.agents/scripts/lib/orchestration/model-attribution.js +73 -45
- package/.agents/scripts/lib/orchestration/review-providers/parse-findings.js +97 -49
- package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +73 -69
- package/.agents/scripts/lib/orchestration/story-close-recovery.js +109 -79
- package/.agents/scripts/lib/signals/detectors/common.js +107 -0
- package/.agents/scripts/lib/signals/detectors/hotspot.js +12 -18
- package/.agents/scripts/lib/signals/detectors/retry.js +3 -40
- package/.agents/scripts/lib/signals/detectors/rework.js +3 -40
- package/.agents/scripts/lib/story-body/story-body.js +102 -76
- package/.agents/scripts/providers/github/blocked-by-add.js +252 -0
- package/.agents/scripts/single-story-init.js +16 -3
- package/.agents/workflows/audit-architecture.md +9 -0
- package/README.md +1 -1
- package/docs/CHANGELOG.md +28 -0
- package/package.json +1 -1
|
@@ -463,203 +463,241 @@ function resolveScopeEnvelope(opts, config) {
|
|
|
463
463
|
* blockerReason: string|null,
|
|
464
464
|
* }>}
|
|
465
465
|
*/
|
|
466
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
501
|
-
|
|
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
|
-
|
|
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,
|
|
3
|
-
* the advisory ticket-cap warning
|
|
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`, `
|
|
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
|
-
|
|
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
|
+
}
|