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.
Files changed (44) hide show
  1. package/.agents/scripts/agents-bootstrap-github.js +40 -48
  2. package/.agents/scripts/bootstrap.js +74 -60
  3. package/.agents/scripts/check-action-pinning.js +260 -0
  4. package/.agents/scripts/check-arch-cycles.js +38 -14
  5. package/.agents/scripts/epic-deliver-prepare.js +149 -104
  6. package/.agents/scripts/lib/baseline-snapshot.js +245 -141
  7. package/.agents/scripts/lib/bootstrap/branch-protection.js +8 -8
  8. package/.agents/scripts/lib/bootstrap/gh-preflight.js +3 -3
  9. package/.agents/scripts/lib/bootstrap/hitl-confirm.js +2 -2
  10. package/.agents/scripts/lib/bootstrap/merge-methods.js +7 -7
  11. package/.agents/scripts/lib/bootstrap/preflight.js +18 -15
  12. package/.agents/scripts/lib/bootstrap/project-bootstrap.js +5 -5
  13. package/.agents/scripts/lib/bootstrap/prompt.js +5 -1
  14. package/.agents/scripts/lib/detect-package-manager.js +2 -2
  15. package/.agents/scripts/lib/feedback-loop/graduator-core.js +171 -137
  16. package/.agents/scripts/lib/onboard/init-tail.js +60 -69
  17. package/.agents/scripts/lib/orchestration/code-review.js +206 -168
  18. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/creation.js +71 -5
  19. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist.js +16 -2
  20. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/component-drift.js +101 -1
  21. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/crap-drift.js +20 -42
  22. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js +12 -32
  23. package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +97 -60
  24. package/.agents/scripts/lib/orchestration/model-attribution.js +73 -45
  25. package/.agents/scripts/lib/orchestration/review-providers/parse-findings.js +97 -49
  26. package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +73 -69
  27. package/.agents/scripts/lib/orchestration/story-close-recovery.js +109 -79
  28. package/.agents/scripts/lib/signals/detectors/common.js +107 -0
  29. package/.agents/scripts/lib/signals/detectors/hotspot.js +12 -18
  30. package/.agents/scripts/lib/signals/detectors/retry.js +3 -40
  31. package/.agents/scripts/lib/signals/detectors/rework.js +3 -40
  32. package/.agents/scripts/lib/story-body/story-body.js +102 -76
  33. package/.agents/scripts/providers/github/blocked-by-add.js +252 -0
  34. package/.agents/scripts/providers/github/tickets.js +1 -1
  35. package/.agents/scripts/single-story-init.js +16 -3
  36. package/.agents/workflows/audit-architecture.md +9 -0
  37. package/.agents/workflows/helpers/deliver-stories.md +24 -2
  38. package/.agents/workflows/helpers/single-story-deliver.md +84 -1
  39. package/README.md +1 -1
  40. package/docs/CHANGELOG.md +43 -0
  41. package/lib/cli/init.js +66 -21
  42. package/lib/cli/sync.js +3 -3
  43. package/package.json +1 -1
  44. 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
- // ── maintainability ──────────────────────────────────────────────────────
480
- const miPath = baselines?.maintainability?.path;
481
- const miTargetDirs = quality?.maintainability?.targetDirs ?? [];
482
- const miIgnoreGlobs = quality?.maintainability?.ignoreGlobs ?? [];
483
- // Hoisted so the CRAP pass can reuse it when targetDirs match — avoids a
484
- // second full-tree walk over the same directories (Story #3663).
485
- let miSourceList = null;
486
- if (typeof miPath === 'string' && miPath.length > 0) {
487
- const miAbs = path.isAbsolute(miPath) ? miPath : path.resolve(cwd, miPath);
488
- miSourceList = [];
489
- for (const dir of miTargetDirs) {
490
- const abs = path.isAbsolute(dir) ? dir : path.resolve(cwd, dir);
491
- scanDirectoryFn(abs, miSourceList, { cwd, ignoreGlobs: miIgnoreGlobs });
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
- // ── crap ─────────────────────────────────────────────────────────────────
535
- const crapPath = baselines?.crap?.path;
536
- const crapCfg = quality?.crap ?? {};
537
- const crapTargetDirs = Array.isArray(crapCfg.targetDirs)
538
- ? crapCfg.targetDirs
539
- : [];
540
- const crapIgnoreGlobs = Array.isArray(crapCfg.ignoreGlobs)
541
- ? crapCfg.ignoreGlobs
542
- : [];
543
- const requireCoverage = crapCfg.requireCoverage !== false;
544
- const coveragePath = crapCfg.coveragePath ?? 'coverage/coverage-final.json';
545
- if (typeof crapPath === 'string' && crapPath.length > 0) {
546
- const crapAbs = path.isAbsolute(crapPath)
547
- ? crapPath
548
- : path.resolve(cwd, crapPath);
549
- const coverageAbs = path.isAbsolute(coveragePath)
550
- ? coveragePath
551
- : path.resolve(cwd, coveragePath);
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
- `[bootstrap] Branch protection on '${baseBranch}': skipped (github.branchProtection.enforce=false).`,
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
- `[bootstrap] Branch protection on '${baseBranch}': skipped (no github.branchProtection.requiredChecks configured).`,
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
- `[bootstrap] Branch protection on '${baseBranch}': existence probe failed — ${err.message}. Proceeding with the write attempt.`,
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
- `[bootstrap] Branch protection on '${baseBranch}': skipped (base branch does not exist on the remote — push an initial commit first).`,
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
- `[bootstrap] Branch protection on '${baseBranch}': read failed — ${err.message}. Proceeding as if no rule exists.`,
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
- `[bootstrap] Branch protection on '${baseBranch}': diverges from framework stance; HITL declined / non-TTY — leaving the operator's rule untouched.`,
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
- `[bootstrap] Branch protection on '${baseBranch}': ${verb} rule${addedSuffix}.`,
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
- `[bootstrap] Branch protection on '${baseBranch}': failed — ${err.message}. Proceeding without it.`,
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: 'gh-project-scope',
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: 'gh-project-scope', ok: true };
262
+ return { name: 'GitHub Projects V2 access', ok: true };
263
263
  }
264
264
  return {
265
- name: 'gh-project-scope',
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
- '[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)';
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(`\n[bootstrap] HITL confirm: ${summary}\n`);
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(`[bootstrap] Merge methods: read failed — ${err.message}.`);
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('[bootstrap] Merge methods: already at target stance (no-op).');
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
- '[bootstrap] Merge methods: HITL declined — leaving operator settings ' +
98
- 'untouched. Note: auto-merge will remain disabled until the merge-method ' +
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
- '[bootstrap] Merge methods: non-TTY — applying framework stance automatically ' +
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(`[bootstrap] Merge methods: patched (${result.patched.join(', ')}).`);
117
+ log(`[Bootstrap] Merge methods: patched (${result.patched.join(', ')}).`);
118
118
  return { status: 'patched', ...result, diff };
119
119
  } catch (err) {
120
- log(`[bootstrap] Merge methods: PATCH failed — ${err.message}.`);
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: 'node', ok: true };
63
- return { name: 'node', ok: false, remedy: NODE_REMEDY(result) };
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: 'git', ok: false, remedy: GIT_INSTALL_HINT };
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: 'git',
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: 'git', ok: true };
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-work-tree', ok: true };
102
+ return { name: 'Local git initialized', ok: true };
103
103
  }
104
- return { name: 'git-work-tree', ok: false, remedy: GIT_WORKTREE_HINT };
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: 'gh', ok: true };
122
+ return { name: 'GitHub CLI', ok: true };
119
123
  } catch (err) {
120
- return { name: 'gh', ok: false, remedy: err.message };
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 — `bootstrap-new.js` can run pre-init and
174
- // collect the remote/branch/owner in later steps.
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-work-tree',
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
- `[bootstrap] ${manager} install failed (exit ${result.status}). Resolve the install error and re-run.`,
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
- `[bootstrap] sync-claude-commands.js failed (exit ${result.status}): ${(
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
- : `[bootstrap] Node ${result.version} is below required ${result.required}. Upgrade Node and re-run.`;
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
- : `[bootstrap] .agentrc.json failed schema validation: ${JSON.stringify(
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
- : `[bootstrap] Parity check failed — workflows missing commands: ${
632
+ : `[Bootstrap] Parity check failed — workflows missing commands: ${
633
633
  result.missingCommand.join(', ') || '(none)'
634
634
  }; orphan commands: ${result.orphanCommand.join(', ') || '(none)'}`;
635
635