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
@@ -195,13 +195,13 @@ function runGit(args, cwd) {
195
195
  * `GhExecError` — so a bare "gh exited with code 1" is actually diagnosable.
196
196
  */
197
197
  function logGhError(label, err) {
198
- Logger.error(`[bootstrap] ${label} failed: ${err.message}`);
198
+ Logger.error(`[Bootstrap] ${label} failed: ${err.message}`);
199
199
  if (err.stderr)
200
- Logger.error(`[bootstrap] gh stderr: ${String(err.stderr).trim()}`);
200
+ Logger.error(`[Bootstrap] gh stderr: ${String(err.stderr).trim()}`);
201
201
  if (err.stdout)
202
- Logger.error(`[bootstrap] gh stdout: ${String(err.stdout).trim()}`);
202
+ Logger.error(`[Bootstrap] gh stdout: ${String(err.stdout).trim()}`);
203
203
  if (Array.isArray(err.args)) {
204
- Logger.error(`[bootstrap] gh args: ${err.args.join(' ')}`);
204
+ Logger.error(`[Bootstrap] gh args: ${err.args.join(' ')}`);
205
205
  }
206
206
  }
207
207
 
@@ -249,7 +249,7 @@ function ensureGitInitialized(state) {
249
249
  initialized = true;
250
250
  state.gitInitialized = true;
251
251
  Logger.info(
252
- `[bootstrap] Initialized git repo (branch ${branch}) at ${cwd}.`,
252
+ `[Bootstrap] Initialized git repo (branch ${branch}) at ${cwd}.`,
253
253
  );
254
254
  }
255
255
 
@@ -281,7 +281,7 @@ function ensureGitInitialized(state) {
281
281
  return { ok: false, error: commit.stderr || 'git commit failed' };
282
282
  }
283
283
  committed = true;
284
- Logger.info('[bootstrap] Created initial commit.');
284
+ Logger.info('[Bootstrap] Created initial commit.');
285
285
  }
286
286
  return { ok: true, initialized, committed };
287
287
  }
@@ -303,21 +303,21 @@ async function ensureGitRemote(state, execImpl = exec) {
303
303
  if (runGit(['remote', 'get-url', 'origin'], cwd).ok) return;
304
304
  if (!(await repoExists(owner, repo, execImpl))) {
305
305
  Logger.warn(
306
- `[bootstrap] No 'origin' remote and ${owner}/${repo} does not exist on GitHub — skipping remote wiring.`,
306
+ `[Bootstrap] No 'origin' remote and ${owner}/${repo} does not exist on GitHub — skipping remote wiring.`,
307
307
  );
308
308
  return;
309
309
  }
310
310
  const url = `https://github.com/${owner}/${repo}.git`;
311
311
  const add = runGit(['remote', 'add', 'origin', url], cwd);
312
312
  if (!add.ok) {
313
- Logger.warn(`[bootstrap] Could not add 'origin' remote: ${add.stderr}`);
313
+ Logger.warn(`[Bootstrap] Could not add 'origin' remote: ${add.stderr}`);
314
314
  return;
315
315
  }
316
- Logger.info(`[bootstrap] Wired 'origin' → ${url}.`);
316
+ Logger.info(`[Bootstrap] Wired 'origin' → ${url}.`);
317
317
  const push = runGit(['push', '-u', 'origin', branch], cwd);
318
318
  if (!push.ok) {
319
319
  Logger.warn(
320
- `[bootstrap] 'origin' is set but push of '${branch}' failed (resolve manually, e.g. \`git pull --rebase origin ${branch}\`): ${push.stderr}`,
320
+ `[Bootstrap] 'origin' is set but push of '${branch}' failed (resolve manually, e.g. \`git pull --rebase origin ${branch}\`): ${push.stderr}`,
321
321
  );
322
322
  }
323
323
  }
@@ -359,11 +359,11 @@ async function ensureProjectLinked(state, execImpl = exec) {
359
359
  args: ['project', 'link', pn, '--owner', owner, '--repo', repo],
360
360
  });
361
361
  Logger.info(
362
- `[bootstrap] Linked repo ${owner}/${repo} to Project V2 #${pn}.`,
362
+ `[Bootstrap] Linked repo ${owner}/${repo} to Project V2 #${pn}.`,
363
363
  );
364
364
  } catch (err) {
365
365
  Logger.warn(
366
- `[bootstrap] Could not link repo ${owner}/${repo} to Project V2 #${pn} (continuing): ${err.message}`,
366
+ `[Bootstrap] Could not link repo ${owner}/${repo} to Project V2 #${pn} (continuing): ${err.message}`,
367
367
  );
368
368
  }
369
369
  }
@@ -413,7 +413,7 @@ async function createGithubRepo(state, execImpl = exec) {
413
413
  ],
414
414
  });
415
415
  Logger.info(
416
- `[bootstrap] Created GitHub repo ${slug} (${visibility}) and pushed.`,
416
+ `[Bootstrap] Created GitHub repo ${slug} (${visibility}) and pushed.`,
417
417
  );
418
418
  }
419
419
 
@@ -480,7 +480,7 @@ async function createGithubProject(state, execImpl = exec) {
480
480
  if (Number.isInteger(existing)) {
481
481
  state.answers.projectNumber = String(existing);
482
482
  Logger.info(
483
- `[bootstrap] Reusing existing GitHub Project V2 "${title}" (#${existing}) — no duplicate created.`,
483
+ `[Bootstrap] Reusing existing GitHub Project V2 "${title}" (#${existing}) — no duplicate created.`,
484
484
  );
485
485
  return existing;
486
486
  }
@@ -510,7 +510,7 @@ async function createGithubProject(state, execImpl = exec) {
510
510
  );
511
511
  }
512
512
  state.answers.projectNumber = String(number);
513
- Logger.info(`[bootstrap] Created GitHub Project V2 "${title}" (#${number}).`);
513
+ Logger.info(`[Bootstrap] Created GitHub Project V2 "${title}" (#${number}).`);
514
514
  return number;
515
515
  }
516
516
 
@@ -539,7 +539,7 @@ export function buildQuestions(defaults, flags, env = process.env, lists = {}) {
539
539
  key: 'owner',
540
540
  flag: 'owner',
541
541
  env: 'GH_OWNER',
542
- message: 'Github repo owner',
542
+ message: '\n\nGitHub repo owner',
543
543
  default: defaults.owner,
544
544
  required: true,
545
545
  validate: (v) =>
@@ -549,7 +549,8 @@ export function buildQuestions(defaults, flags, env = process.env, lists = {}) {
549
549
  key: 'operatorHandle',
550
550
  flag: 'operator-handle',
551
551
  env: 'GH_OPERATOR_HANDLE',
552
- message: 'Github username/handle (without the @)',
552
+ message:
553
+ 'GitHub username/handle without preceding@ (default: same as owner)',
553
554
  // Default tracks the repo owner; resolved post-collect if left blank.
554
555
  default: defaults.owner,
555
556
  required: false,
@@ -562,8 +563,9 @@ export function buildQuestions(defaults, flags, env = process.env, lists = {}) {
562
563
  key: 'repo',
563
564
  flag: 'repo',
564
565
  env: 'GH_REPO',
565
- message:
566
- 'Github repo name - Select from the list or enter a new name to create one',
566
+ message: 'New GitHub repo name',
567
+ pickerMessage:
568
+ 'GitHub repo name - Select existing or press ENTER to create',
567
569
  default: defaults.repo,
568
570
  required: true,
569
571
  picker: {
@@ -590,8 +592,9 @@ export function buildQuestions(defaults, flags, env = process.env, lists = {}) {
590
592
  key: 'projectNumber',
591
593
  flag: 'project-number',
592
594
  env: 'GH_PROJECT_NUMBER',
593
- message:
594
- 'Github Project V2 name - Select from the list or enter a new name to create one',
595
+ message: 'New GitHub Project V2 name',
596
+ pickerMessage:
597
+ 'GitHub Project V2 name - Select existing or press ENTER to create',
595
598
  // Prefer the already-stored numeric project number (an
596
599
  // already-provisioned project on a re-run) so `--assume-yes` resolves a
597
600
  // numeric answer that `detectCreation` treats as existing — never a
@@ -750,7 +753,7 @@ export function parseAndValidate(argv, opts = {}) {
750
753
  // `--assume-yes` path did.)
751
754
  if (!interactive && !assumeYes && !approveGithubAdmin) {
752
755
  Logger.error(
753
- '[bootstrap] non-TTY run requires --assume-yes or --approve-github-admin ' +
756
+ '[Bootstrap] non-TTY run requires --assume-yes or --approve-github-admin ' +
754
757
  '(no operator is present to confirm the GitHub-admin mutations).',
755
758
  );
756
759
  return { ok: false, exit: 1 };
@@ -760,7 +763,7 @@ export function parseAndValidate(argv, opts = {}) {
760
763
  const githubAdminApproved = interactive || assumeYes || approveGithubAdmin;
761
764
  if (resolveRepoVisibility(flags) === null) {
762
765
  Logger.error(
763
- `[bootstrap] invalid --visibility "${flags.visibility}". ` +
766
+ `[Bootstrap] invalid --visibility "${flags.visibility}". ` +
764
767
  `Expected one of: ${REPO_VISIBILITIES.join(', ')}.`,
765
768
  );
766
769
  return { ok: false, exit: 1 };
@@ -783,11 +786,12 @@ export function prepareContext(state, opts = {}) {
783
786
  const defaults = inferDefaults(projectRoot);
784
787
  const silentAccept = resolveSilentAccept(defaults, state.flags);
785
788
 
786
- Logger.info('[bootstrap] Detected from local git:');
787
- Logger.info(` owner ${defaults.owner ?? '(none)'}`);
788
- Logger.info(` repo ${defaults.repo ?? '(none)'}`);
789
- Logger.info(` base branch ${defaults.baseBranch ?? '(none)'}`);
790
- Logger.info(` username ${defaults.operatorHandle ?? '(none)'}`);
789
+ Logger.info('[\n');
790
+ Logger.info('[Bootstrap] Checking existing GitHub values:');
791
+ Logger.info(` GitHub Repo Owner ${defaults.owner ?? '(unknown)'}`);
792
+ Logger.info(` GitHub Repo Name ${defaults.repo ?? '(unknown)'}`);
793
+ Logger.info(` Base Branch ${defaults.baseBranch ?? '(unknown)'}`);
794
+ Logger.info(` GitHub Username ${defaults.operatorHandle ?? '(unknown)'}`);
791
795
 
792
796
  return {
793
797
  ok: true,
@@ -811,24 +815,34 @@ export async function runPreflightPhase(state, opts = {}) {
811
815
 
812
816
  for (const check of result.checks) {
813
817
  if (check.ok) {
818
+ // A non-fatal informational check (it carries `gitInitialized`) shows a
819
+ // glyph reflecting the real state rather than its always-true gate pass:
820
+ // ✓ when the git repo exists, ✗ when it does not (bootstrap initialises
821
+ // it in a later phase regardless — the ✗ never aborts the run).
822
+ const glyph =
823
+ typeof check.gitInitialized === 'boolean' && !check.gitInitialized
824
+ ? '✗'
825
+ : '✓';
814
826
  Logger.info(
815
- `[bootstrap] ${check.name}${check.detail ? ` — ${check.detail}` : ''}`,
827
+ `[Bootstrap] ${glyph} ${check.name}${check.detail ? ` — ${check.detail}` : ''}`,
816
828
  );
817
829
  } else {
818
- Logger.error(`[bootstrap] ✗ ${check.name}: ${check.remedy}`);
830
+ Logger.error(`[Bootstrap] ✗ ${check.name}: ${check.remedy}`);
819
831
  }
820
832
  }
821
833
 
822
834
  if (!result.ok) {
823
835
  Logger.error(
824
- '[bootstrap] Preflight failed. Resolve the issues above and re-run.',
836
+ '[Bootstrap] Preflight failed. Resolve the issues above and re-run.',
825
837
  );
826
838
  return { ok: false, exit: 1 };
827
839
  }
828
840
 
829
- Logger.info(
830
- `[bootstrap] git initialized: ${result.gitInitialized ? 'yes' : 'no'}`,
831
- );
841
+ // The git-repo state is already reported by the (non-fatal) "Local git
842
+ // initialized" check above both derive from the same
843
+ // `git rev-parse --is-inside-work-tree` probe — so there is no separate
844
+ // "git initialized" line here (it would duplicate, and contradict, that
845
+ // check). The boolean is still threaded through the payload for later phases.
832
846
  return {
833
847
  ok: true,
834
848
  payload: { preflight: result, gitInitialized: result.gitInitialized },
@@ -844,15 +858,15 @@ function renderAnswerSummary(
844
858
  visibility,
845
859
  ) {
846
860
  const newRepoNote = creation.newRepo
847
- ? ` (NEW — will be created, ${visibility})`
861
+ ? ` will be created as ${visibility}`
848
862
  : '';
849
863
  const lines = [
850
- '\n=== Review your answers ===',
864
+ '=== Review choices ===',
851
865
  ` Repo owner ${answers.owner}`,
852
866
  ` Username/handle ${answers.operatorHandle || '(none)'}`,
853
867
  ` Repo name ${answers.repo}${newRepoNote}`,
854
868
  ` Base branch ${answers.baseBranch}`,
855
- ` Project V2 name ${project.name}${creation.newProject ? ' (NEW — will be created)' : ''}`,
869
+ ` Project V2 name ${project.name}${creation.newProject ? ' will be created' : ''}`,
856
870
  ` Project V2 # ${project.number}`,
857
871
  ` Local git ${gitInitialized ? 'initialized' : 'will be initialized'}`,
858
872
  ];
@@ -943,7 +957,7 @@ export async function collectAndConfirm(state) {
943
957
  });
944
958
  if (missing.length > 0) {
945
959
  Logger.error(
946
- `[bootstrap] missing required answers: ${missing.join(', ')}`,
960
+ `[Bootstrap] missing required answers: ${missing.join(', ')}`,
947
961
  );
948
962
  return { ok: false, exit: 1 };
949
963
  }
@@ -968,7 +982,7 @@ export async function collectAndConfirm(state) {
968
982
  );
969
983
  const correct = await confirmYesNo('Is this correct?', state.interactive);
970
984
  if (!correct) {
971
- Logger.info('[bootstrap] Okay — let’s try again.');
985
+ Logger.info('[Bootstrap] Okay — let’s try again.');
972
986
  // Re-prompt everything on the next pass (drop silent-accept).
973
987
  silentAccept = [];
974
988
  continue;
@@ -982,7 +996,7 @@ export async function collectAndConfirm(state) {
982
996
  );
983
997
  if (!approved) {
984
998
  Logger.error(
985
- '[bootstrap] Creation declined — cannot continue without the repo/project. Exiting.',
999
+ '[Bootstrap] Creation declined — cannot continue without the repo/project. Exiting.',
986
1000
  );
987
1001
  return { ok: false, exit: 1 };
988
1002
  }
@@ -1025,7 +1039,7 @@ function renderDryRunPlan(state) {
1025
1039
  export function dryRunPlan(state) {
1026
1040
  if (!state.flags['dry-run']) return { ok: true, payload: {} };
1027
1041
  Logger.info(
1028
- '[bootstrap] --dry-run: no files, GitHub settings, or labels will be changed.',
1042
+ '[Bootstrap] --dry-run: no files, GitHub settings, or labels will be changed.',
1029
1043
  );
1030
1044
  Logger.info(renderDryRunPlan(state));
1031
1045
  return { ok: false, exit: 0 };
@@ -1062,18 +1076,18 @@ export async function provisionResources(state, deps = {}) {
1062
1076
  // 1. Local git — initialize + first commit when missing (idempotent).
1063
1077
  const git = ensureGitInitialized(state);
1064
1078
  if (!git.ok) {
1065
- Logger.error(`[bootstrap] git initialization failed: ${git.error}`);
1079
+ Logger.error(`[Bootstrap] git initialization failed: ${git.error}`);
1066
1080
  return { ok: false, exit: 1 };
1067
1081
  }
1068
1082
  if (!git.initialized && !git.committed) {
1069
- Logger.info('[bootstrap] git already initialized — leaving as-is.');
1083
+ Logger.info('[Bootstrap] git already initialized — leaving as-is.');
1070
1084
  }
1071
1085
 
1072
1086
  const { newRepo, newProject } = state.creation;
1073
1087
  if (skipGithub) {
1074
1088
  if (newRepo || newProject) {
1075
1089
  Logger.info(
1076
- '[bootstrap] --skip-github set; not creating the GitHub repo/project.',
1090
+ '[Bootstrap] --skip-github set; not creating the GitHub repo/project.',
1077
1091
  );
1078
1092
  }
1079
1093
  return { ok: true, payload: {} };
@@ -1108,7 +1122,7 @@ export async function provisionResources(state, deps = {}) {
1108
1122
  }
1109
1123
 
1110
1124
  if (!newRepo && !newProject) {
1111
- Logger.info('[bootstrap] No new GitHub resources needed.');
1125
+ Logger.info('[Bootstrap] No new GitHub resources needed.');
1112
1126
  }
1113
1127
 
1114
1128
  // 4. Link the repo to the project board so issues/PRs surface on it
@@ -1125,7 +1139,7 @@ export async function provisionResources(state, deps = {}) {
1125
1139
  */
1126
1140
  export async function executeBootstrap(state) {
1127
1141
  Logger.info(
1128
- `[bootstrap] Starting project bootstrap at ${state.projectRoot} (owner=${state.answers.owner} repo=${state.answers.repo} base=${state.answers.baseBranch})`,
1142
+ `[Bootstrap] Starting project bootstrap at ${state.projectRoot} (owner=${state.answers.owner} repo=${state.answers.repo} base=${state.answers.baseBranch})`,
1129
1143
  );
1130
1144
  const approvedGroups = new Set(Object.values(PHASE_GROUPS));
1131
1145
  const report = await applyProjectBootstrap({
@@ -1158,7 +1172,7 @@ export function persistProjectNumber(state) {
1158
1172
  config = JSON.parse(fs.readFileSync(target, 'utf8'));
1159
1173
  } catch (err) {
1160
1174
  Logger.error(
1161
- `[bootstrap] Could not read ${target} to store projectNumber: ${err.message}`,
1175
+ `[Bootstrap] Could not read ${target} to store projectNumber: ${err.message}`,
1162
1176
  );
1163
1177
  return { ok: true, payload: {} };
1164
1178
  }
@@ -1172,14 +1186,14 @@ export function persistProjectNumber(state) {
1172
1186
  }
1173
1187
  config.github.projectNumber = Number(pn);
1174
1188
  fs.writeFileSync(target, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
1175
- Logger.info(`[bootstrap] Stored github.projectNumber=${pn} in .agentrc.json`);
1189
+ Logger.info(`[Bootstrap] Stored github.projectNumber=${pn} in .agentrc.json`);
1176
1190
  return { ok: true, payload: {} };
1177
1191
  }
1178
1192
 
1179
1193
  /** Step 6b — GitHub-side bootstrap. Honours `--skip-github`. */
1180
1194
  export async function executeGithubBootstrap(state) {
1181
1195
  if (state.flags['skip-github']) {
1182
- Logger.info('[bootstrap] --skip-github set; skipping GitHub bootstrap.');
1196
+ Logger.info('[Bootstrap] --skip-github set; skipping GitHub bootstrap.');
1183
1197
  return { ok: true, payload: {} };
1184
1198
  }
1185
1199
  try {
@@ -1274,7 +1288,7 @@ export async function offerCommitPush(state, deps = {}) {
1274
1288
  // Non-interactive (--assume-yes / no TTY): never push unprompted. Print the
1275
1289
  // exact commands and leave the working tree untouched.
1276
1290
  if (!state.interactive) {
1277
- Logger.info(`\n[bootstrap] ${instructions}`);
1291
+ Logger.info(`\n[Bootstrap] ${instructions}`);
1278
1292
  return { ok: true, payload: { commitPush: { action: 'instructed' } } };
1279
1293
  }
1280
1294
 
@@ -1283,14 +1297,14 @@ export async function offerCommitPush(state, deps = {}) {
1283
1297
  state.interactive,
1284
1298
  );
1285
1299
  if (!accepted) {
1286
- Logger.info(`\n[bootstrap] ${instructions}`);
1300
+ Logger.info(`\n[Bootstrap] ${instructions}`);
1287
1301
  return { ok: true, payload: { commitPush: { action: 'declined' } } };
1288
1302
  }
1289
1303
 
1290
1304
  const staged = stageBootstrapFiles({ projectRoot: cwd, runGit: runGitImpl });
1291
1305
  if (!staged.ok) {
1292
- Logger.warn(`[bootstrap] Could not stage the wiring: ${staged.error}`);
1293
- Logger.info(`\n[bootstrap] ${instructions}`);
1306
+ Logger.warn(`[Bootstrap] Could not stage the wiring: ${staged.error}`);
1307
+ Logger.info(`\n[Bootstrap] ${instructions}`);
1294
1308
  return { ok: true, payload: { commitPush: { action: 'stage-failed' } } };
1295
1309
  }
1296
1310
  const commit = runGitImpl(
@@ -1300,20 +1314,20 @@ export async function offerCommitPush(state, deps = {}) {
1300
1314
  if (!commit.ok) {
1301
1315
  // A "nothing to commit" exit is benign — the wiring is already committed.
1302
1316
  Logger.warn(
1303
- `[bootstrap] git commit did not create a commit (already committed?): ${commit.stderr || commit.stdout}`,
1317
+ `[Bootstrap] git commit did not create a commit (already committed?): ${commit.stderr || commit.stdout}`,
1304
1318
  );
1305
- Logger.info(`\n[bootstrap] ${instructions}`);
1319
+ Logger.info(`\n[Bootstrap] ${instructions}`);
1306
1320
  return { ok: true, payload: { commitPush: { action: 'commit-skipped' } } };
1307
1321
  }
1308
- Logger.info('[bootstrap] Committed the Mandrel wiring.');
1322
+ Logger.info('[Bootstrap] Committed the Mandrel wiring.');
1309
1323
  const push = runGitImpl(['push', '-u', 'origin', branch], cwd);
1310
1324
  if (!push.ok) {
1311
1325
  Logger.warn(
1312
- `[bootstrap] Commit landed but push of '${branch}' failed (push it manually with \`git push -u origin ${branch}\`): ${push.stderr}`,
1326
+ `[Bootstrap] Commit landed but push of '${branch}' failed (push it manually with \`git push -u origin ${branch}\`): ${push.stderr}`,
1313
1327
  );
1314
1328
  return { ok: true, payload: { commitPush: { action: 'push-failed' } } };
1315
1329
  }
1316
- Logger.info(`[bootstrap] Pushed '${branch}' to origin.`);
1330
+ Logger.info(`[Bootstrap] Pushed '${branch}' to origin.`);
1317
1331
  return { ok: true, payload: { commitPush: { action: 'committed-pushed' } } };
1318
1332
  }
1319
1333
 
@@ -1357,7 +1371,7 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
1357
1371
  const githubError = result.state?.report?.github?.error;
1358
1372
  if (githubError) {
1359
1373
  Logger.error(
1360
- `\n[bootstrap] GitHub bootstrap failed: ${githubError}. ` +
1374
+ `\n[Bootstrap] GitHub bootstrap failed: ${githubError}. ` +
1361
1375
  'Project-side setup (labels are GitHub-side; the local .agentrc.json / ' +
1362
1376
  'quality-gate / workflow files that were applied are recorded in the ' +
1363
1377
  'install ledger) completed, but the GitHub label/board/views/protection ' +
@@ -1368,7 +1382,7 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
1368
1382
  return 1;
1369
1383
  }
1370
1384
 
1371
- Logger.info('\n[bootstrap] Done.');
1385
+ Logger.info('\n[Bootstrap] Done.');
1372
1386
  return 0;
1373
1387
  }
1374
1388
 
@@ -0,0 +1,260 @@
1
+ /**
2
+ * CLI: third-party GitHub Action pinning gate.
3
+ *
4
+ * Story #4079 (audit::devops). Closes a supply-chain regression window the
5
+ * `ci.yml` / `release-please.yml` comments *claimed* was guarded by a
6
+ * nonexistent `npm run audit-security` gate. There is no such npm script;
7
+ * `audit-security` is only a manual `/audit-security` slash-command lens.
8
+ * Nothing actually enforced that third-party `uses:` refs stay SHA-pinned,
9
+ * so a future edit reverting `trufflehog@<sha>` to `@main` would pass CI
10
+ * silently.
11
+ *
12
+ * This script scans `.github/workflows/*.yml` (and `*.yaml`), extracts every
13
+ * `uses:` ref, and fails the build when a **third-party** action (anything
14
+ * not under the first-party `actions/*` org) is pinned to a floating ref
15
+ * instead of a full 40-char commit SHA. First-party `actions/*` refs are
16
+ * allowed on major-version tags (`@v4`) — Dependabot's `github-actions`
17
+ * ecosystem bumps those in-place, matching the rationale in the workflow
18
+ * file headers.
19
+ *
20
+ * A "floating ref" is any of:
21
+ * - a branch head: `@main`, `@master`
22
+ * - a tag / partial-SHA that is not a full 40-hex-char commit SHA
23
+ * (`@v5`, `@v3.95.3`, `@release`, a 7-char short SHA, …)
24
+ *
25
+ * Contract:
26
+ * - Scans the workflows directory (default `.github/workflows`, override
27
+ * with `--dir <path>`).
28
+ * - Prints `<file>:<lineNo> <ref> — <reason>` for each violation, then a
29
+ * one-line summary even on a clean scan so operators see the "ok" signal.
30
+ * - With `--json`: writes a structured envelope to stdout and skips the
31
+ * human summary.
32
+ * - Exit codes: 0 = no violations; 1 = at least one floating third-party
33
+ * ref. A missing / empty workflows directory exits 0 (nothing to gate).
34
+ */
35
+
36
+ import fs from 'node:fs';
37
+ import path from 'node:path';
38
+ import process from 'node:process';
39
+ import { runAsCli } from './lib/cli-utils.js';
40
+
41
+ /**
42
+ * Parse argv for `--dir <path>` and `--json`. Exported so unit tests can pin
43
+ * the parser.
44
+ *
45
+ * @param {string[]} argv
46
+ * @returns {{ dir: string | null, json: boolean }}
47
+ */
48
+ export function parseArgv(argv = []) {
49
+ let dir = null;
50
+ let json = false;
51
+ for (let i = 0; i < argv.length; i += 1) {
52
+ const a = argv[i];
53
+ if (a === '--dir') {
54
+ const next = argv[i + 1];
55
+ if (next && !next.startsWith('--')) {
56
+ dir = next;
57
+ i += 1;
58
+ }
59
+ } else if (a === '--json') {
60
+ json = true;
61
+ }
62
+ }
63
+ return { dir, json };
64
+ }
65
+
66
+ /**
67
+ * Is the given ref suffix a full 40-char hex commit SHA?
68
+ *
69
+ * @param {string} ref The portion after the `@` in a `uses:` value.
70
+ * @returns {boolean}
71
+ */
72
+ export function isFullSha(ref) {
73
+ return /^[0-9a-f]{40}$/i.test(ref);
74
+ }
75
+
76
+ /**
77
+ * Is the action a first-party `actions/*` action (e.g. `actions/checkout`)?
78
+ * First-party refs are allowed to float on major-version tags because
79
+ * Dependabot's `github-actions` ecosystem bumps them in-place.
80
+ *
81
+ * Local (`./…`) and reusable-workflow (`owner/repo/.github/workflows/x.yml`)
82
+ * refs and Docker refs (`docker://…`) are out of scope for the SHA-pin gate;
83
+ * `isFirstParty` only matters for `owner/repo[@ref]` registry actions.
84
+ *
85
+ * @param {string} action The portion before the `@` in a `uses:` value.
86
+ * @returns {boolean}
87
+ */
88
+ export function isFirstParty(action) {
89
+ return /^actions\//.test(action);
90
+ }
91
+
92
+ /**
93
+ * Pure helper: scan a single workflow file's text for `uses:` refs and return
94
+ * the violations. A violation is a third-party `owner/repo@ref` where `ref`
95
+ * is not a full 40-char SHA.
96
+ *
97
+ * Skips:
98
+ * - local actions (`uses: ./path`)
99
+ * - Docker refs (`uses: docker://…`)
100
+ * - first-party `actions/*` refs (allowed on major-version tags)
101
+ * - refs with no `@` (pinned by default branch implicitly — flagged as a
102
+ * violation: an unpinned third-party ref floats on the default branch)
103
+ *
104
+ * @param {string} file Relative file label used in violation rows.
105
+ * @param {string} text The file contents.
106
+ * @returns {Array<{ file: string, line: number, action: string, ref: string | null, reason: string }>}
107
+ */
108
+ export function scanWorkflowText(file, text) {
109
+ const violations = [];
110
+ const lines = text.split(/\r?\n/);
111
+ // Match `uses:` values, optionally quoted. The value runs until whitespace
112
+ // or a `#` comment. Capture the raw value for downstream parsing.
113
+ const usesRe = /^\s*(?:-\s*)?uses:\s*['"]?([^'"#\s]+)['"]?/;
114
+ for (let i = 0; i < lines.length; i += 1) {
115
+ const m = usesRe.exec(lines[i]);
116
+ if (!m) continue;
117
+ const value = m[1];
118
+ const lineNo = i + 1;
119
+ // Local actions and Docker refs are out of scope for the SHA-pin gate.
120
+ if (value.startsWith('./') || value.startsWith('docker://')) continue;
121
+ const atIndex = value.indexOf('@');
122
+ const action = atIndex === -1 ? value : value.slice(0, atIndex);
123
+ const ref = atIndex === -1 ? null : value.slice(atIndex + 1);
124
+ // First-party actions/* may float on major-version tags.
125
+ if (isFirstParty(action)) continue;
126
+ if (ref === null) {
127
+ violations.push({
128
+ file,
129
+ line: lineNo,
130
+ action,
131
+ ref: null,
132
+ reason: 'third-party action with no ref floats on the default branch',
133
+ });
134
+ continue;
135
+ }
136
+ if (!isFullSha(ref)) {
137
+ const floating = ref === 'main' || ref === 'master';
138
+ violations.push({
139
+ file,
140
+ line: lineNo,
141
+ action,
142
+ ref,
143
+ reason: floating
144
+ ? `third-party action pinned to branch head @${ref} (CWE-1357)`
145
+ : `third-party action @${ref} is not a full 40-char commit SHA`,
146
+ });
147
+ }
148
+ }
149
+ return violations;
150
+ }
151
+
152
+ /**
153
+ * Enumerate workflow files (`*.yml` / `*.yaml`) directly under `dir`.
154
+ * Returns absolute paths sorted for deterministic output. A missing directory
155
+ * yields an empty list.
156
+ *
157
+ * @param {string} dir Absolute workflows directory.
158
+ * @returns {string[]}
159
+ */
160
+ export function listWorkflowFiles(dir) {
161
+ let entries;
162
+ try {
163
+ entries = fs.readdirSync(dir, { withFileTypes: true });
164
+ } catch {
165
+ return [];
166
+ }
167
+ return entries
168
+ .filter((e) => e.isFile() && /\.ya?ml$/i.test(e.name))
169
+ .map((e) => path.join(dir, e.name))
170
+ .sort();
171
+ }
172
+
173
+ /**
174
+ * Pure helper: render the human-readable report. One line per violation
175
+ * followed by a one-line summary. The summary carries a `(gate fail)` /
176
+ * `(ok)` marker so the result is visible in CI output.
177
+ *
178
+ * @param {Array<{ file: string, line: number, action: string, ref: string | null, reason: string }>} violations
179
+ * @returns {string}
180
+ */
181
+ export function renderReport(violations) {
182
+ const lines = [];
183
+ for (const v of violations) {
184
+ const refLabel = v.ref === null ? '(no ref)' : `@${v.ref}`;
185
+ lines.push(`${v.file}:${v.line} ${v.action}${refLabel} — ${v.reason}`);
186
+ }
187
+ const tag = violations.length > 0 ? '(gate fail)' : '(ok)';
188
+ lines.push(`[action-pinning] violations=${violations.length} ${tag}`);
189
+ return lines.join('\n');
190
+ }
191
+
192
+ /**
193
+ * Top-level CLI entry. Exported so tests can drive the full pipeline against a
194
+ * fixture workflows directory without touching the repo's real workflows.
195
+ *
196
+ * @param {{
197
+ * argv?: string[],
198
+ * cwd?: string,
199
+ * stdout?: { write: (s: string) => void },
200
+ * stderr?: { write: (s: string) => void },
201
+ * }} [opts]
202
+ * @returns {Promise<number>} exit code: 0 = clean; 1 = floating third-party ref
203
+ */
204
+ export async function runCli({
205
+ argv = process.argv.slice(2),
206
+ cwd = process.cwd(),
207
+ stdout = process.stdout,
208
+ stderr = process.stderr,
209
+ } = {}) {
210
+ const { dir, json } = parseArgv(argv);
211
+ const resolvedDir = path.resolve(
212
+ cwd,
213
+ dir ?? path.join('.github', 'workflows'),
214
+ );
215
+
216
+ const files = listWorkflowFiles(resolvedDir);
217
+ const violations = [];
218
+ for (const file of files) {
219
+ let text;
220
+ try {
221
+ text = fs.readFileSync(file, 'utf-8');
222
+ } catch {
223
+ continue;
224
+ }
225
+ violations.push(...scanWorkflowText(path.relative(cwd, file), text));
226
+ }
227
+
228
+ const exitCode = violations.length > 0 ? 1 : 0;
229
+
230
+ if (json) {
231
+ const envelope = {
232
+ kind: 'action-pinning-report',
233
+ dir: resolvedDir,
234
+ filesScanned: files.length,
235
+ violations,
236
+ exitCode,
237
+ };
238
+ stdout.write(`${JSON.stringify(envelope, null, 2)}\n`);
239
+ } else {
240
+ if (files.length === 0) {
241
+ stderr.write(
242
+ `[action-pinning] ⚠ no workflow files found under ${resolvedDir}\n`,
243
+ );
244
+ }
245
+ stdout.write(`\n--- action-pinning scan ---\n`);
246
+ stdout.write(`${renderReport(violations)}\n`);
247
+ }
248
+
249
+ return exitCode;
250
+ }
251
+
252
+ async function main() {
253
+ return runCli();
254
+ }
255
+
256
+ runAsCli(import.meta.url, main, {
257
+ source: 'action-pinning',
258
+ propagateExitCode: true,
259
+ errorPrefix: '[action-pinning] ❌ Fatal error',
260
+ });