mandrel 1.62.0 → 1.64.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/agents-bootstrap-github.js +40 -48
- package/.agents/scripts/bootstrap.js +74 -60
- 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/bootstrap/branch-protection.js +8 -8
- package/.agents/scripts/lib/bootstrap/gh-preflight.js +3 -3
- package/.agents/scripts/lib/bootstrap/hitl-confirm.js +2 -2
- package/.agents/scripts/lib/bootstrap/merge-methods.js +7 -7
- package/.agents/scripts/lib/bootstrap/preflight.js +18 -15
- package/.agents/scripts/lib/bootstrap/project-bootstrap.js +5 -5
- package/.agents/scripts/lib/bootstrap/prompt.js +5 -1
- package/.agents/scripts/lib/detect-package-manager.js +2 -2
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +171 -137
- package/.agents/scripts/lib/onboard/init-tail.js +60 -69
- 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/providers/github/tickets.js +1 -1
- package/.agents/scripts/single-story-init.js +16 -3
- package/.agents/workflows/audit-architecture.md +9 -0
- package/.agents/workflows/helpers/deliver-stories.md +24 -2
- package/.agents/workflows/helpers/single-story-deliver.md +84 -1
- package/README.md +1 -1
- package/docs/CHANGELOG.md +43 -0
- package/lib/cli/init.js +66 -21
- package/lib/cli/sync.js +3 -3
- package/package.json +1 -1
- package/.agents/scripts/lib/onboard/detect-stack.js +0 -300
|
@@ -452,6 +452,219 @@ export function commitSnapshotsToEpicBranch({
|
|
|
452
452
|
* files: Array<{ kind: 'maintainability'|'crap', path: string, didChange: boolean, reason?: 'no-coverage'|'unchanged'|'updated' }>,
|
|
453
453
|
* }>}
|
|
454
454
|
*/
|
|
455
|
+
/**
|
|
456
|
+
* Build the `files[]` entry for a baseline write, choosing between the
|
|
457
|
+
* structural-equality short-circuit (no write, `reason: 'unchanged'`) and
|
|
458
|
+
* the stamp-and-write path (`reason: 'updated'`). Story #4075 — collapses
|
|
459
|
+
* the duplicated short-circuit branch shared by the MI and CRAP passes.
|
|
460
|
+
*
|
|
461
|
+
* @returns {{ entry: object, wrote: boolean }}
|
|
462
|
+
*/
|
|
463
|
+
function commitBaselineEnvelope({
|
|
464
|
+
kind,
|
|
465
|
+
abs,
|
|
466
|
+
envelope,
|
|
467
|
+
priorEnvelope,
|
|
468
|
+
writeFileFn,
|
|
469
|
+
fsImpl,
|
|
470
|
+
}) {
|
|
471
|
+
if (priorEnvelope && envelope === priorEnvelope) {
|
|
472
|
+
return {
|
|
473
|
+
entry: { kind, path: abs, didChange: false, reason: 'unchanged' },
|
|
474
|
+
wrote: false,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
writeFileFn(abs, envelope, { fsImpl });
|
|
478
|
+
return {
|
|
479
|
+
entry: { kind, path: abs, didChange: true, reason: 'updated' },
|
|
480
|
+
wrote: true,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Regenerate the maintainability baseline from a fresh tree scan. Returns
|
|
486
|
+
* `null` when no maintainability baseline path is configured. The scanned
|
|
487
|
+
* source list is returned so the CRAP pass can reuse it when the two passes
|
|
488
|
+
* target the same dirs (Story #3663). Story #4075 — extracted from
|
|
489
|
+
* `regenerateMainFromTree`.
|
|
490
|
+
*/
|
|
491
|
+
async function regenerateMaintainability({
|
|
492
|
+
cwd,
|
|
493
|
+
baselines,
|
|
494
|
+
quality,
|
|
495
|
+
scanDirectoryFn,
|
|
496
|
+
calculateAllFn,
|
|
497
|
+
writeFn,
|
|
498
|
+
writeFileFn,
|
|
499
|
+
loadPriorFn,
|
|
500
|
+
fsImpl,
|
|
501
|
+
}) {
|
|
502
|
+
const miPath = baselines?.maintainability?.path;
|
|
503
|
+
if (typeof miPath !== 'string' || miPath.length === 0) return null;
|
|
504
|
+
|
|
505
|
+
const miTargetDirs = quality?.maintainability?.targetDirs ?? [];
|
|
506
|
+
const miIgnoreGlobs = quality?.maintainability?.ignoreGlobs ?? [];
|
|
507
|
+
const miAbs = path.isAbsolute(miPath) ? miPath : path.resolve(cwd, miPath);
|
|
508
|
+
const miSourceList = [];
|
|
509
|
+
for (const dir of miTargetDirs) {
|
|
510
|
+
const abs = path.isAbsolute(dir) ? dir : path.resolve(cwd, dir);
|
|
511
|
+
scanDirectoryFn(abs, miSourceList, { cwd, ignoreGlobs: miIgnoreGlobs });
|
|
512
|
+
}
|
|
513
|
+
const scores = await calculateAllFn(miSourceList);
|
|
514
|
+
|
|
515
|
+
// Project the scoring helper's `{path: mi}` map onto the writer's
|
|
516
|
+
// canonical row shape. Story #2079 path-canon defence stays in place —
|
|
517
|
+
// the writer would canonicalise again, but doing it here keeps any
|
|
518
|
+
// pre-canonicalised comparison inside the function meaningful.
|
|
519
|
+
const miRows = filterExcludedRows(
|
|
520
|
+
Object.entries(scores).map(([key, mi]) => {
|
|
521
|
+
const rel = path.isAbsolute(key) ? path.relative(cwd, key) : key;
|
|
522
|
+
const posixRel = rel.split(path.sep).join('/');
|
|
523
|
+
return { path: canonicalisePath(posixRel), mi };
|
|
524
|
+
}),
|
|
525
|
+
);
|
|
526
|
+
|
|
527
|
+
const priorMi = loadPriorFn(miAbs, 'maintainability');
|
|
528
|
+
const envelope = writeFn({
|
|
529
|
+
kind: 'maintainability',
|
|
530
|
+
rows: miRows,
|
|
531
|
+
priorEnvelope: priorMi,
|
|
532
|
+
});
|
|
533
|
+
const { entry, wrote } = commitBaselineEnvelope({
|
|
534
|
+
kind: 'maintainability',
|
|
535
|
+
abs: miAbs,
|
|
536
|
+
envelope,
|
|
537
|
+
priorEnvelope: priorMi,
|
|
538
|
+
writeFileFn,
|
|
539
|
+
fsImpl,
|
|
540
|
+
});
|
|
541
|
+
return { entry, wrote, miSourceList, miTargetDirs, miIgnoreGlobs };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Decide whether the CRAP pass can reuse the MI scan's file list — true only
|
|
546
|
+
* when both passes target the same dirs with the same ignore globs
|
|
547
|
+
* (Story #3663).
|
|
548
|
+
*/
|
|
549
|
+
function crapDirsMatchMi({
|
|
550
|
+
miSourceList,
|
|
551
|
+
crapTargetDirs,
|
|
552
|
+
crapIgnoreGlobs,
|
|
553
|
+
miTargetDirs,
|
|
554
|
+
miIgnoreGlobs,
|
|
555
|
+
}) {
|
|
556
|
+
return (
|
|
557
|
+
miSourceList !== null &&
|
|
558
|
+
crapTargetDirs.length === miTargetDirs.length &&
|
|
559
|
+
crapTargetDirs.every((d, i) => d === miTargetDirs[i]) &&
|
|
560
|
+
crapIgnoreGlobs.length === miIgnoreGlobs.length &&
|
|
561
|
+
crapIgnoreGlobs.every((g, i) => g === miIgnoreGlobs[i])
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Regenerate the CRAP baseline from a fresh tree scan + coverage map.
|
|
567
|
+
* Returns `null` when no CRAP baseline path is configured. Story #4075 —
|
|
568
|
+
* extracted from `regenerateMainFromTree`.
|
|
569
|
+
*/
|
|
570
|
+
async function regenerateCrap({
|
|
571
|
+
cwd,
|
|
572
|
+
baselines,
|
|
573
|
+
quality,
|
|
574
|
+
logger,
|
|
575
|
+
miScan,
|
|
576
|
+
scanAndScoreFn,
|
|
577
|
+
loadCoverageFn,
|
|
578
|
+
resolveEscomplexVersionFn,
|
|
579
|
+
resolveTsTranspilerVersionFn,
|
|
580
|
+
writeFn,
|
|
581
|
+
writeFileFn,
|
|
582
|
+
loadPriorFn,
|
|
583
|
+
fsImpl,
|
|
584
|
+
}) {
|
|
585
|
+
const crapPath = baselines?.crap?.path;
|
|
586
|
+
if (typeof crapPath !== 'string' || crapPath.length === 0) return null;
|
|
587
|
+
|
|
588
|
+
const crapCfg = quality?.crap ?? {};
|
|
589
|
+
const crapTargetDirs = Array.isArray(crapCfg.targetDirs)
|
|
590
|
+
? crapCfg.targetDirs
|
|
591
|
+
: [];
|
|
592
|
+
const crapIgnoreGlobs = Array.isArray(crapCfg.ignoreGlobs)
|
|
593
|
+
? crapCfg.ignoreGlobs
|
|
594
|
+
: [];
|
|
595
|
+
const requireCoverage = crapCfg.requireCoverage !== false;
|
|
596
|
+
const coveragePath = crapCfg.coveragePath ?? 'coverage/coverage-final.json';
|
|
597
|
+
const crapAbs = path.isAbsolute(crapPath)
|
|
598
|
+
? crapPath
|
|
599
|
+
: path.resolve(cwd, crapPath);
|
|
600
|
+
const coverageAbs = path.isAbsolute(coveragePath)
|
|
601
|
+
? coveragePath
|
|
602
|
+
: path.resolve(cwd, coveragePath);
|
|
603
|
+
const coverage = loadCoverageFn(coverageAbs);
|
|
604
|
+
|
|
605
|
+
if (!coverage && requireCoverage) {
|
|
606
|
+
logger.warn?.(
|
|
607
|
+
`[baseline-snapshot] ⚠ no coverage at ${coveragePath} — skipping crap regeneration (refresh stays clean for this file).`,
|
|
608
|
+
);
|
|
609
|
+
return {
|
|
610
|
+
entry: {
|
|
611
|
+
kind: 'crap',
|
|
612
|
+
path: crapAbs,
|
|
613
|
+
didChange: false,
|
|
614
|
+
reason: 'no-coverage',
|
|
615
|
+
},
|
|
616
|
+
wrote: false,
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const reusePreScan = crapDirsMatchMi({
|
|
621
|
+
miSourceList: miScan?.miSourceList ?? null,
|
|
622
|
+
crapTargetDirs,
|
|
623
|
+
crapIgnoreGlobs,
|
|
624
|
+
miTargetDirs: miScan?.miTargetDirs ?? [],
|
|
625
|
+
miIgnoreGlobs: miScan?.miIgnoreGlobs ?? [],
|
|
626
|
+
});
|
|
627
|
+
const { rows } = await scanAndScoreFn({
|
|
628
|
+
targetDirs: crapTargetDirs,
|
|
629
|
+
coverage,
|
|
630
|
+
requireCoverage,
|
|
631
|
+
cwd,
|
|
632
|
+
ignoreGlobs: crapIgnoreGlobs,
|
|
633
|
+
...(reusePreScan && { preScannedFiles: miScan.miSourceList }),
|
|
634
|
+
});
|
|
635
|
+
// scanAndScore yields rows keyed by `file:`; the per-kind crap module's
|
|
636
|
+
// `projectRow` handles `path ?? file`, so the writer takes either.
|
|
637
|
+
// Filter to actually-scored rows here (crap is nullable for trivial
|
|
638
|
+
// methods); the writer's `assertEnvelope` would reject otherwise.
|
|
639
|
+
const crapRows = (rows ?? []).filter(
|
|
640
|
+
(r) => typeof r?.crap === 'number' && Number.isFinite(r.crap),
|
|
641
|
+
);
|
|
642
|
+
|
|
643
|
+
// CRAP gates need the running scorer's versions present on the
|
|
644
|
+
// envelope-adjacent shape; the V2 envelope itself only carries
|
|
645
|
+
// `kernelVersion`, so we stamp escomplex/tsTranspiler via the writer's
|
|
646
|
+
// `kernelVersion` override and let the existing per-kind module resolve
|
|
647
|
+
// the rest. We also resolve them eagerly so a test stub can pin them
|
|
648
|
+
// deterministically.
|
|
649
|
+
resolveEscomplexVersionFn(cwd);
|
|
650
|
+
resolveTsTranspilerVersionFn();
|
|
651
|
+
|
|
652
|
+
const priorCrap = loadPriorFn(crapAbs, 'crap');
|
|
653
|
+
const envelope = writeFn({
|
|
654
|
+
kind: 'crap',
|
|
655
|
+
rows: crapRows,
|
|
656
|
+
priorEnvelope: priorCrap,
|
|
657
|
+
});
|
|
658
|
+
return commitBaselineEnvelope({
|
|
659
|
+
kind: 'crap',
|
|
660
|
+
abs: crapAbs,
|
|
661
|
+
envelope,
|
|
662
|
+
priorEnvelope: priorCrap,
|
|
663
|
+
writeFileFn,
|
|
664
|
+
fsImpl,
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
|
|
455
668
|
export async function regenerateMainFromTree({
|
|
456
669
|
cwd = process.cwd(),
|
|
457
670
|
resolveConfig = defaultResolveConfig,
|
|
@@ -476,149 +689,40 @@ export async function regenerateMainFromTree({
|
|
|
476
689
|
const files = [];
|
|
477
690
|
let didChange = false;
|
|
478
691
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
const scores = await calculateAllFn(miSourceList);
|
|
494
|
-
|
|
495
|
-
// Project the scoring helper's `{path: mi}` map onto the writer's
|
|
496
|
-
// canonical row shape. Story #2079 path-canon defence stays in place —
|
|
497
|
-
// the writer would canonicalise again, but doing it here keeps any
|
|
498
|
-
// pre-canonicalised comparison inside the function meaningful.
|
|
499
|
-
const miRows = filterExcludedRows(
|
|
500
|
-
Object.entries(scores).map(([key, mi]) => {
|
|
501
|
-
const rel = path.isAbsolute(key) ? path.relative(cwd, key) : key;
|
|
502
|
-
const posixRel = rel.split(path.sep).join('/');
|
|
503
|
-
return { path: canonicalisePath(posixRel), mi };
|
|
504
|
-
}),
|
|
505
|
-
);
|
|
506
|
-
|
|
507
|
-
const priorMi = loadPriorFn(miAbs, 'maintainability');
|
|
508
|
-
const envelope = writeFn({
|
|
509
|
-
kind: 'maintainability',
|
|
510
|
-
rows: miRows,
|
|
511
|
-
priorEnvelope: priorMi,
|
|
512
|
-
});
|
|
513
|
-
if (priorMi && envelope === priorMi) {
|
|
514
|
-
// Structural-equality short-circuit fired — on-disk bytes are
|
|
515
|
-
// guaranteed identical, no writeFile invocation needed.
|
|
516
|
-
files.push({
|
|
517
|
-
kind: 'maintainability',
|
|
518
|
-
path: miAbs,
|
|
519
|
-
didChange: false,
|
|
520
|
-
reason: 'unchanged',
|
|
521
|
-
});
|
|
522
|
-
} else {
|
|
523
|
-
writeFileFn(miAbs, envelope, { fsImpl });
|
|
524
|
-
didChange = true;
|
|
525
|
-
files.push({
|
|
526
|
-
kind: 'maintainability',
|
|
527
|
-
path: miAbs,
|
|
528
|
-
didChange: true,
|
|
529
|
-
reason: 'updated',
|
|
530
|
-
});
|
|
531
|
-
}
|
|
692
|
+
const miScan = await regenerateMaintainability({
|
|
693
|
+
cwd,
|
|
694
|
+
baselines,
|
|
695
|
+
quality,
|
|
696
|
+
scanDirectoryFn,
|
|
697
|
+
calculateAllFn,
|
|
698
|
+
writeFn,
|
|
699
|
+
writeFileFn,
|
|
700
|
+
loadPriorFn,
|
|
701
|
+
fsImpl,
|
|
702
|
+
});
|
|
703
|
+
if (miScan) {
|
|
704
|
+
files.push(miScan.entry);
|
|
705
|
+
didChange = didChange || miScan.wrote;
|
|
532
706
|
}
|
|
533
707
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
const coverage = loadCoverageFn(coverageAbs);
|
|
553
|
-
if (!coverage && requireCoverage) {
|
|
554
|
-
logger.warn?.(
|
|
555
|
-
`[baseline-snapshot] ⚠ no coverage at ${coveragePath} — skipping crap regeneration (refresh stays clean for this file).`,
|
|
556
|
-
);
|
|
557
|
-
files.push({
|
|
558
|
-
kind: 'crap',
|
|
559
|
-
path: crapAbs,
|
|
560
|
-
didChange: false,
|
|
561
|
-
reason: 'no-coverage',
|
|
562
|
-
});
|
|
563
|
-
} else {
|
|
564
|
-
// Reuse the MI scan's file list when CRAP and MI target the same
|
|
565
|
-
// directories with the same ignore globs — avoids a second full-tree
|
|
566
|
-
// walk over identical source trees (Story #3663).
|
|
567
|
-
const crapDirsMatchMi =
|
|
568
|
-
miSourceList !== null &&
|
|
569
|
-
crapTargetDirs.length === miTargetDirs.length &&
|
|
570
|
-
crapTargetDirs.every((d, i) => d === miTargetDirs[i]) &&
|
|
571
|
-
crapIgnoreGlobs.length === miIgnoreGlobs.length &&
|
|
572
|
-
crapIgnoreGlobs.every((g, i) => g === miIgnoreGlobs[i]);
|
|
573
|
-
const { rows } = await scanAndScoreFn({
|
|
574
|
-
targetDirs: crapTargetDirs,
|
|
575
|
-
coverage,
|
|
576
|
-
requireCoverage,
|
|
577
|
-
cwd,
|
|
578
|
-
ignoreGlobs: crapIgnoreGlobs,
|
|
579
|
-
...(crapDirsMatchMi && { preScannedFiles: miSourceList }),
|
|
580
|
-
});
|
|
581
|
-
// scanAndScore yields rows keyed by `file:`; the per-kind crap module's
|
|
582
|
-
// `projectRow` handles `path ?? file`, so the writer takes either.
|
|
583
|
-
// Filter to actually-scored rows here (crap is nullable for trivial
|
|
584
|
-
// methods); the writer's `assertEnvelope` would reject otherwise.
|
|
585
|
-
const crapRows = (rows ?? []).filter(
|
|
586
|
-
(r) => typeof r?.crap === 'number' && Number.isFinite(r.crap),
|
|
587
|
-
);
|
|
588
|
-
|
|
589
|
-
// CRAP gates need the running scorer's versions present on the
|
|
590
|
-
// envelope-adjacent shape; the V2 envelope itself only carries
|
|
591
|
-
// `kernelVersion`, so we stamp escomplex/tsTranspiler via the writer's
|
|
592
|
-
// `kernelVersion` override and let the existing per-kind module
|
|
593
|
-
// resolve the rest. We also resolve them eagerly so a test stub can
|
|
594
|
-
// pin them deterministically.
|
|
595
|
-
resolveEscomplexVersionFn(cwd);
|
|
596
|
-
resolveTsTranspilerVersionFn();
|
|
597
|
-
|
|
598
|
-
const priorCrap = loadPriorFn(crapAbs, 'crap');
|
|
599
|
-
const envelope = writeFn({
|
|
600
|
-
kind: 'crap',
|
|
601
|
-
rows: crapRows,
|
|
602
|
-
priorEnvelope: priorCrap,
|
|
603
|
-
});
|
|
604
|
-
if (priorCrap && envelope === priorCrap) {
|
|
605
|
-
files.push({
|
|
606
|
-
kind: 'crap',
|
|
607
|
-
path: crapAbs,
|
|
608
|
-
didChange: false,
|
|
609
|
-
reason: 'unchanged',
|
|
610
|
-
});
|
|
611
|
-
} else {
|
|
612
|
-
writeFileFn(crapAbs, envelope, { fsImpl });
|
|
613
|
-
didChange = true;
|
|
614
|
-
files.push({
|
|
615
|
-
kind: 'crap',
|
|
616
|
-
path: crapAbs,
|
|
617
|
-
didChange: true,
|
|
618
|
-
reason: 'updated',
|
|
619
|
-
});
|
|
620
|
-
}
|
|
621
|
-
}
|
|
708
|
+
const crapResult = await regenerateCrap({
|
|
709
|
+
cwd,
|
|
710
|
+
baselines,
|
|
711
|
+
quality,
|
|
712
|
+
logger,
|
|
713
|
+
miScan,
|
|
714
|
+
scanAndScoreFn,
|
|
715
|
+
loadCoverageFn,
|
|
716
|
+
resolveEscomplexVersionFn,
|
|
717
|
+
resolveTsTranspilerVersionFn,
|
|
718
|
+
writeFn,
|
|
719
|
+
writeFileFn,
|
|
720
|
+
loadPriorFn,
|
|
721
|
+
fsImpl,
|
|
722
|
+
});
|
|
723
|
+
if (crapResult) {
|
|
724
|
+
files.push(crapResult.entry);
|
|
725
|
+
didChange = didChange || crapResult.wrote;
|
|
622
726
|
}
|
|
623
727
|
|
|
624
728
|
return { didChange, files };
|
|
@@ -126,7 +126,7 @@ export async function applyBranchProtection({
|
|
|
126
126
|
|
|
127
127
|
if (enforce === false) {
|
|
128
128
|
log(
|
|
129
|
-
`[
|
|
129
|
+
`[Bootstrap] Branch protection on '${baseBranch}': skipped (github.branchProtection.enforce=false).`,
|
|
130
130
|
);
|
|
131
131
|
return { status: 'skipped', reason: 'opt-out' };
|
|
132
132
|
}
|
|
@@ -136,7 +136,7 @@ export async function applyBranchProtection({
|
|
|
136
136
|
.filter((n) => typeof n === 'string' && n.length > 0);
|
|
137
137
|
if (checkNames.length === 0) {
|
|
138
138
|
log(
|
|
139
|
-
`[
|
|
139
|
+
`[Bootstrap] Branch protection on '${baseBranch}': skipped (no github.branchProtection.requiredChecks configured).`,
|
|
140
140
|
);
|
|
141
141
|
return { status: 'skipped', reason: 'no-checks' };
|
|
142
142
|
}
|
|
@@ -152,12 +152,12 @@ export async function applyBranchProtection({
|
|
|
152
152
|
exists = await provider.branchExists(baseBranch);
|
|
153
153
|
} catch (err) {
|
|
154
154
|
log(
|
|
155
|
-
`[
|
|
155
|
+
`[Bootstrap] Branch protection on '${baseBranch}': existence probe failed — ${err.message}. Proceeding with the write attempt.`,
|
|
156
156
|
);
|
|
157
157
|
}
|
|
158
158
|
if (!exists) {
|
|
159
159
|
log(
|
|
160
|
-
`[
|
|
160
|
+
`[Bootstrap] Branch protection on '${baseBranch}': skipped (base branch does not exist on the remote — push an initial commit first).`,
|
|
161
161
|
);
|
|
162
162
|
return { status: 'skipped', reason: 'no-base-branch' };
|
|
163
163
|
}
|
|
@@ -169,7 +169,7 @@ export async function applyBranchProtection({
|
|
|
169
169
|
current = probe?.enabled ? (probe.raw ?? null) : null;
|
|
170
170
|
} catch (err) {
|
|
171
171
|
log(
|
|
172
|
-
`[
|
|
172
|
+
`[Bootstrap] Branch protection on '${baseBranch}': read failed — ${err.message}. Proceeding as if no rule exists.`,
|
|
173
173
|
);
|
|
174
174
|
}
|
|
175
175
|
|
|
@@ -193,7 +193,7 @@ export async function applyBranchProtection({
|
|
|
193
193
|
: false;
|
|
194
194
|
if (!approved) {
|
|
195
195
|
log(
|
|
196
|
-
`[
|
|
196
|
+
`[Bootstrap] Branch protection on '${baseBranch}': diverges from framework stance; HITL declined / non-TTY — leaving the operator's rule untouched.`,
|
|
197
197
|
);
|
|
198
198
|
return { status: 'skipped', reason: 'hitl-declined', diff };
|
|
199
199
|
}
|
|
@@ -210,12 +210,12 @@ export async function applyBranchProtection({
|
|
|
210
210
|
? ` (added: ${result.added.join(', ')})`
|
|
211
211
|
: ' (all required checks already present)';
|
|
212
212
|
log(
|
|
213
|
-
`[
|
|
213
|
+
`[Bootstrap] Branch protection on '${baseBranch}': ${verb} rule${addedSuffix}.`,
|
|
214
214
|
);
|
|
215
215
|
return { status: result.created ? 'created' : 'merged', ...result };
|
|
216
216
|
} catch (err) {
|
|
217
217
|
log(
|
|
218
|
-
`[
|
|
218
|
+
`[Bootstrap] Branch protection on '${baseBranch}': failed — ${err.message}. Proceeding without it.`,
|
|
219
219
|
);
|
|
220
220
|
return { status: 'failed', reason: err.message };
|
|
221
221
|
}
|
|
@@ -253,16 +253,16 @@ export async function checkProjectScopes(opts = {}) {
|
|
|
253
253
|
function classifyProjectScopes(scopeLine) {
|
|
254
254
|
if (!scopeLine) {
|
|
255
255
|
return {
|
|
256
|
-
name: '
|
|
256
|
+
name: 'GitHub Projects V2 access',
|
|
257
257
|
ok: true,
|
|
258
258
|
detail: GH_SCOPES_UNREADABLE_NOTE,
|
|
259
259
|
};
|
|
260
260
|
}
|
|
261
261
|
if (/\bproject\b/i.test(scopeLine[1])) {
|
|
262
|
-
return { name: '
|
|
262
|
+
return { name: 'GitHub Projects V2 access', ok: true };
|
|
263
263
|
}
|
|
264
264
|
return {
|
|
265
|
-
name: '
|
|
265
|
+
name: 'GitHub Projects V2 access',
|
|
266
266
|
ok: true,
|
|
267
267
|
detail: GH_PROJECT_SCOPE_NOTE,
|
|
268
268
|
};
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
import { createInterface } from 'node:readline';
|
|
27
27
|
|
|
28
28
|
const ABORT_MESSAGE =
|
|
29
|
-
'[
|
|
29
|
+
'[Bootstrap] aborting: no TTY available for HITL confirm (opt in with --approve-github-admin for GitHub-admin mutations, or --assume-yes to accept every phase group)';
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
32
|
* @param {object} args
|
|
@@ -61,7 +61,7 @@ export async function confirm({ summary, current, proposed }, opts = {}) {
|
|
|
61
61
|
// Render the diff. Single-line summary, then a JSON block so the
|
|
62
62
|
// operator can pipe the prompt to a logger and still recover the
|
|
63
63
|
// structured shape.
|
|
64
|
-
stdout.write(`\
|
|
64
|
+
stdout.write(`\nHITL confirm: ${summary}\n`);
|
|
65
65
|
stdout.write(
|
|
66
66
|
` current: ${JSON.stringify(current ?? null, null, 2)
|
|
67
67
|
.split('\n')
|
|
@@ -74,13 +74,13 @@ export async function applyMergeMethods({
|
|
|
74
74
|
try {
|
|
75
75
|
current = (await provider.getMergeMethods()) ?? {};
|
|
76
76
|
} catch (err) {
|
|
77
|
-
log(`[
|
|
77
|
+
log(`[Bootstrap] Merge methods: read failed — ${err.message}.`);
|
|
78
78
|
return { status: 'failed', reason: err.message };
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
const diff = diffMergeMethods(current, target);
|
|
82
82
|
if (!diff) {
|
|
83
|
-
log('[
|
|
83
|
+
log('[Bootstrap] Merge methods: already at target stance (no-op).');
|
|
84
84
|
return { status: 'unchanged' };
|
|
85
85
|
}
|
|
86
86
|
|
|
@@ -94,8 +94,8 @@ export async function applyMergeMethods({
|
|
|
94
94
|
});
|
|
95
95
|
if (!approved) {
|
|
96
96
|
log(
|
|
97
|
-
'[
|
|
98
|
-
'
|
|
97
|
+
'[Bootstrap] Merge methods: HITL declined — leaving operator settings untouched\n\n' +
|
|
98
|
+
'Note: auto-merge will remain disabled until the merge-method ' +
|
|
99
99
|
'settings match the framework stance (allow_squash_merge: true, ' +
|
|
100
100
|
'allow_auto_merge: true, delete_branch_on_merge: true).',
|
|
101
101
|
);
|
|
@@ -105,7 +105,7 @@ export async function applyMergeMethods({
|
|
|
105
105
|
// Non-TTY: no operator present to confirm. Default-apply the framework
|
|
106
106
|
// stance and log explicitly so the consequence is never silent.
|
|
107
107
|
log(
|
|
108
|
-
'[
|
|
108
|
+
'[Bootstrap] Merge methods: non-TTY — applying framework stance automatically ' +
|
|
109
109
|
'(allow_squash_merge, allow_auto_merge, delete_branch_on_merge). ' +
|
|
110
110
|
'To opt out, pass a hitlConfirm gate or set github.mergeMethods overrides in .agentrc.json.',
|
|
111
111
|
);
|
|
@@ -114,10 +114,10 @@ export async function applyMergeMethods({
|
|
|
114
114
|
|
|
115
115
|
try {
|
|
116
116
|
const result = await provider.setMergeMethods(target);
|
|
117
|
-
log(`[
|
|
117
|
+
log(`[Bootstrap] Merge methods: patched (${result.patched.join(', ')}).`);
|
|
118
118
|
return { status: 'patched', ...result, diff };
|
|
119
119
|
} catch (err) {
|
|
120
|
-
log(`[
|
|
120
|
+
log(`[Bootstrap] Merge methods: PATCH failed — ${err.message}.`);
|
|
121
121
|
return { status: 'failed', reason: err.message };
|
|
122
122
|
}
|
|
123
123
|
}
|
|
@@ -59,8 +59,8 @@ function defaultGitRunner(args) {
|
|
|
59
59
|
*/
|
|
60
60
|
function checkNode(nodeCheck) {
|
|
61
61
|
const result = nodeCheck();
|
|
62
|
-
if (result.ok) return { name: '
|
|
63
|
-
return { name: '
|
|
62
|
+
if (result.ok) return { name: 'Node version', ok: true };
|
|
63
|
+
return { name: 'Node version', ok: false, remedy: NODE_REMEDY(result) };
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
/**
|
|
@@ -73,19 +73,19 @@ function checkNode(nodeCheck) {
|
|
|
73
73
|
function checkGitAvailable(gitRunner) {
|
|
74
74
|
const result = gitRunner(['--version']);
|
|
75
75
|
if (result.error?.code === 'ENOENT') {
|
|
76
|
-
return { name: '
|
|
76
|
+
return { name: 'Git installed', ok: false, remedy: GIT_INSTALL_HINT };
|
|
77
77
|
}
|
|
78
78
|
if (result.status !== 0) {
|
|
79
79
|
const snippet = (result.stderr || '').trim().slice(0, 200);
|
|
80
80
|
return {
|
|
81
|
-
name: '
|
|
81
|
+
name: 'Git installed',
|
|
82
82
|
ok: false,
|
|
83
83
|
remedy: `git --version failed (exit ${result.status})${
|
|
84
84
|
snippet ? `: ${snippet}` : ''
|
|
85
85
|
}. ${GIT_INSTALL_HINT}`,
|
|
86
86
|
};
|
|
87
87
|
}
|
|
88
|
-
return { name: '
|
|
88
|
+
return { name: 'Git installed', ok: true };
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
/**
|
|
@@ -99,9 +99,13 @@ function checkGitAvailable(gitRunner) {
|
|
|
99
99
|
function checkInsideWorkTree(gitRunner) {
|
|
100
100
|
const result = gitRunner(['rev-parse', '--is-inside-work-tree']);
|
|
101
101
|
if (result.status === 0 && result.stdout.trim() === 'true') {
|
|
102
|
-
return { name: 'git
|
|
102
|
+
return { name: 'Local git initialized', ok: true };
|
|
103
103
|
}
|
|
104
|
-
return {
|
|
104
|
+
return {
|
|
105
|
+
name: 'Local git initialized',
|
|
106
|
+
ok: false,
|
|
107
|
+
remedy: GIT_WORKTREE_HINT,
|
|
108
|
+
};
|
|
105
109
|
}
|
|
106
110
|
|
|
107
111
|
/**
|
|
@@ -115,9 +119,9 @@ function checkInsideWorkTree(gitRunner) {
|
|
|
115
119
|
async function checkGh(gh) {
|
|
116
120
|
try {
|
|
117
121
|
await gh();
|
|
118
|
-
return { name: '
|
|
122
|
+
return { name: 'GitHub CLI', ok: true };
|
|
119
123
|
} catch (err) {
|
|
120
|
-
return { name: '
|
|
124
|
+
return { name: 'GitHub CLI', ok: false, remedy: err.message };
|
|
121
125
|
}
|
|
122
126
|
}
|
|
123
127
|
|
|
@@ -170,15 +174,14 @@ export async function runPreflight(opts = {}) {
|
|
|
170
174
|
checks.push(workTree);
|
|
171
175
|
} else {
|
|
172
176
|
// Non-fatal detection: record whether we are inside a git repo but
|
|
173
|
-
// never fail the gate on it —
|
|
174
|
-
//
|
|
177
|
+
// never fail the gate on it — bootstrap initialises git in a later
|
|
178
|
+
// phase. `ok` stays true so the gate passes; the rendered glyph is
|
|
179
|
+
// driven off `gitInitialized` (✓ when a repo exists, ✗ when not) so the
|
|
180
|
+
// operator sees the real state without the run aborting.
|
|
175
181
|
checks.push({
|
|
176
|
-
name: 'git
|
|
182
|
+
name: 'Local git initialized',
|
|
177
183
|
ok: true,
|
|
178
184
|
gitInitialized,
|
|
179
|
-
detail: gitInitialized
|
|
180
|
-
? 'inside a git work tree'
|
|
181
|
-
: 'not a git repo yet — will be collected in later steps',
|
|
182
185
|
});
|
|
183
186
|
}
|
|
184
187
|
}
|
|
@@ -277,7 +277,7 @@ export function ensureDependenciesInstalled(ctx) {
|
|
|
277
277
|
});
|
|
278
278
|
if (result.status !== 0) {
|
|
279
279
|
throw new Error(
|
|
280
|
-
`[
|
|
280
|
+
`[Bootstrap] ${manager} install failed (exit ${result.status}). Resolve the install error and re-run.`,
|
|
281
281
|
);
|
|
282
282
|
}
|
|
283
283
|
return { ran: true, manager, skipped: false };
|
|
@@ -462,7 +462,7 @@ export function runSyncCommands(ctx) {
|
|
|
462
462
|
});
|
|
463
463
|
if (result.status !== 0) {
|
|
464
464
|
throw new Error(
|
|
465
|
-
`[
|
|
465
|
+
`[Bootstrap] sync-claude-commands.js failed (exit ${result.status}): ${(
|
|
466
466
|
result.stderr ?? ''
|
|
467
467
|
)
|
|
468
468
|
.trim()
|
|
@@ -615,12 +615,12 @@ export function checkWindowsGitPerf(ctx) {
|
|
|
615
615
|
const fatalNodeCheck = (result) =>
|
|
616
616
|
result.ok
|
|
617
617
|
? null
|
|
618
|
-
: `[
|
|
618
|
+
: `[Bootstrap] Node ${result.version} is below required ${result.required}. Upgrade Node and re-run.`;
|
|
619
619
|
|
|
620
620
|
const fatalValidation = (result) =>
|
|
621
621
|
result.ok
|
|
622
622
|
? null
|
|
623
|
-
: `[
|
|
623
|
+
: `[Bootstrap] .agentrc.json failed schema validation: ${JSON.stringify(
|
|
624
624
|
result.errors,
|
|
625
625
|
null,
|
|
626
626
|
2,
|
|
@@ -629,7 +629,7 @@ const fatalValidation = (result) =>
|
|
|
629
629
|
const fatalParity = (result) =>
|
|
630
630
|
result.ok
|
|
631
631
|
? null
|
|
632
|
-
: `[
|
|
632
|
+
: `[Bootstrap] Parity check failed — workflows missing commands: ${
|
|
633
633
|
result.missingCommand.join(', ') || '(none)'
|
|
634
634
|
}; orphan commands: ${result.orphanCommand.join(', ') || '(none)'}`;
|
|
635
635
|
|