mandrel 2.64.0 → 2.66.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 (105) hide show
  1. package/.agents/agents/acceptance-critic.md +8 -7
  2. package/.agents/agents/auditor.md +20 -20
  3. package/.agents/agents/plan-critic.md +8 -7
  4. package/.agents/agents/story-worker.md +7 -7
  5. package/.agents/audit-checklists/quality.md +3 -0
  6. package/.agents/docs/agentrc-reference.json +1 -9
  7. package/.agents/docs/configuration.md +8 -7
  8. package/.agents/docs/execution-reference.md +27 -5
  9. package/.agents/instructions.md +10 -12
  10. package/.agents/rules/ci-remediation.md +3 -3
  11. package/.agents/rules/gherkin-standards.md +3 -2
  12. package/.agents/rules/git-conventions-reference.md +12 -3
  13. package/.agents/rules/git-conventions.md +9 -7
  14. package/.agents/rules/testing-standards.md +8 -7
  15. package/.agents/runtime-deps.json +1 -1
  16. package/.agents/schemas/agentrc.schema.json +6 -13
  17. package/.agents/schemas/audit-rules.schema.json +1 -1
  18. package/.agents/schemas/story-deliver-terminal.schema.json +5 -0
  19. package/.agents/scripts/bootstrap.js +102 -91
  20. package/.agents/scripts/check-context-budget.js +1 -1
  21. package/.agents/scripts/lib/ITicketingProvider.js +1 -3
  22. package/.agents/scripts/lib/audit-suite/findings.js +1 -17
  23. package/.agents/scripts/lib/audit-suite/frontmatter.js +0 -28
  24. package/.agents/scripts/lib/audit-suite/index.js +0 -6
  25. package/.agents/scripts/lib/audit-suite/selector.js +0 -31
  26. package/.agents/scripts/lib/baselines/duplication-scanner.js +17 -7
  27. package/.agents/scripts/lib/bootstrap/agents-md-fold.js +156 -0
  28. package/.agents/scripts/lib/bootstrap/commit-push.js +1 -1
  29. package/.agents/scripts/lib/bootstrap/manifest.js +2 -2
  30. package/.agents/scripts/lib/bootstrap/project-bootstrap.js +91 -107
  31. package/.agents/scripts/lib/cli/standard-args.js +60 -76
  32. package/.agents/scripts/lib/cli-args.js +26 -0
  33. package/.agents/scripts/lib/config/gates/shared.js +3 -3
  34. package/.agents/scripts/lib/config/review-chain-default.js +13 -0
  35. package/.agents/scripts/lib/config-settings-schema-delivery.js +2 -2
  36. package/.agents/scripts/lib/config-settings-schema-quality.js +11 -13
  37. package/.agents/scripts/lib/doc-tiers.js +25 -6
  38. package/.agents/scripts/lib/feedback-loop/graduate-steps.js +205 -0
  39. package/.agents/scripts/lib/feedback-loop/graduator-core.js +47 -782
  40. package/.agents/scripts/lib/feedback-loop/graduator-gh.js +449 -0
  41. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  42. package/.agents/scripts/lib/observability/close-telemetry.js +330 -0
  43. package/.agents/scripts/lib/observability/metrics-ledger.js +0 -72
  44. package/.agents/scripts/lib/observability/runtime-friction.js +2 -0
  45. package/.agents/scripts/lib/observability/signal-validator.js +17 -5
  46. package/.agents/scripts/lib/orchestration/code-review.js +33 -6
  47. package/.agents/scripts/lib/orchestration/epic-rollup.js +29 -12
  48. package/.agents/scripts/lib/orchestration/merge-block-class.js +20 -4
  49. package/.agents/scripts/lib/orchestration/merge-poll.js +41 -22
  50. package/.agents/scripts/lib/orchestration/plan-metrics.js +76 -63
  51. package/.agents/scripts/lib/orchestration/required-checks.js +147 -0
  52. package/.agents/scripts/lib/orchestration/review-providers/code-review.js +203 -0
  53. package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +29 -4
  54. package/.agents/scripts/lib/orchestration/review-providers/security-review.js +3 -2
  55. package/.agents/scripts/lib/orchestration/run-epilogue.js +6 -0
  56. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +1 -0
  57. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +2 -12
  58. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +370 -268
  59. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +21 -7
  60. package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +112 -82
  61. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-override.js +4 -0
  62. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +393 -313
  63. package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +12 -87
  64. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +3 -0
  65. package/.agents/scripts/lib/orchestration/ticket-validator.js +19 -36
  66. package/.agents/scripts/lib/signals/detectors/common.js +63 -51
  67. package/.agents/scripts/lib/templates/decomposer-prompts.js +5 -24
  68. package/.agents/scripts/lib/transpile.js +28 -3
  69. package/.agents/scripts/providers/github/issues.js +14 -23
  70. package/.agents/scripts/single-story-close.js +10 -2
  71. package/.agents/scripts/single-story-confirm-merge.js +267 -238
  72. package/.agents/scripts/sync-claude-agents.js +1 -1
  73. package/.agents/skills/core/idea-refinement/SKILL.md +6 -6
  74. package/.agents/skills/stack/qa/qa-harness/SKILL.md +1 -2
  75. package/.agents/workflows/audit-architecture.md +5 -4
  76. package/.agents/workflows/audit-documentation.md +5 -5
  77. package/.agents/workflows/audit-performance.md +10 -10
  78. package/.agents/workflows/audit-quality.md +42 -7
  79. package/.agents/workflows/helpers/acceptance-self-eval.md +9 -9
  80. package/.agents/workflows/helpers/audit-lens-core.md +30 -57
  81. package/.agents/workflows/helpers/code-review.md +15 -38
  82. package/.agents/workflows/helpers/deliver-digest.md +2 -2
  83. package/.agents/workflows/helpers/deliver-reference.md +7 -3
  84. package/.agents/workflows/helpers/deliver-story.md +9 -1
  85. package/.agents/workflows/helpers/parallel-tooling.md +16 -18
  86. package/.agents/workflows/helpers/plan-reference.md +9 -8
  87. package/.agents/workflows/mandrel-deliver.md +3 -2
  88. package/.agents/workflows/mandrel-plan.md +11 -7
  89. package/.agents/workflows/mandrel-update.md +5 -3
  90. package/docs/CHANGELOG.md +57 -0
  91. package/lib/cli/claude-code-version.js +73 -0
  92. package/lib/cli/doctor.js +2 -2
  93. package/lib/cli/guarded-sync.js +87 -0
  94. package/lib/cli/registry.js +9 -0
  95. package/lib/cli/sync-agents.js +9 -92
  96. package/lib/cli/sync-commands.js +9 -101
  97. package/lib/cli/uninstall.js +37 -9
  98. package/lib/migrations/index.js +2 -0
  99. package/lib/migrations/steps/2.65.0-fold-claude-md-into-agents-md.js +38 -0
  100. package/package.json +3 -2
  101. package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +0 -99
  102. package/.agents/scripts/lib/audit-suite/runner.js +0 -205
  103. package/.agents/scripts/lib/audit-suite/substitutions.js +0 -96
  104. package/.agents/scripts/lib/audit-suite/workflow-loader.js +0 -37
  105. package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +0 -234
@@ -10,7 +10,7 @@ import fs from 'node:fs';
10
10
  import path from 'node:path';
11
11
  import readline from 'node:readline/promises';
12
12
  import { fileURLToPath } from 'node:url';
13
-
13
+ import { stageLegacyEntryDocRemoval } from './lib/bootstrap/agents-md-fold.js';
14
14
  import {
15
15
  buildManualInstructions,
16
16
  COMMIT_SUBJECT,
@@ -716,6 +716,13 @@ async function detectCreation(answers, skipGithub) {
716
716
  return creation;
717
717
  }
718
718
 
719
+ function projectNameFor(pn, projects) {
720
+ const match = projects.find((p) => p.value === pn);
721
+ if (!match) return '(unknown)';
722
+ const m = /^(.*)\s+\(#\d+\)$/.exec(match.label);
723
+ return m ? m[1] : match.label;
724
+ }
725
+
719
726
  /**
720
727
  * `{ name, number }` for the summary; the picker stores only the number, so
721
728
  * an existing project's name is looked up.
@@ -723,20 +730,78 @@ async function detectCreation(answers, skipGithub) {
723
730
  function resolveProjectDisplay(answers, skipGithub, projectsList) {
724
731
  const pn = answers.projectNumber;
725
732
  if (!pn) return { name: '(skip)', number: '(skip)' };
726
- if (/^\d+$/.test(pn)) {
727
- let name = '(unknown)';
728
- if (!skipGithub) {
729
- const projects =
730
- projectsList ?? safeList(() => listProjects({ owner: answers.owner }));
731
- const match = projects.find((p) => p.value === pn);
732
- if (match) {
733
- const m = /^(.*)\s+\(#\d+\)$/.exec(match.label);
734
- name = m ? m[1] : match.label;
735
- }
733
+ if (!/^\d+$/.test(pn)) return { name: pn, number: '(new)' };
734
+ if (skipGithub) return { name: '(unknown)', number: pn };
735
+ const projects =
736
+ projectsList ?? safeList(() => listProjects({ owner: answers.owner }));
737
+ return { name: projectNameFor(pn, projects), number: pn };
738
+ }
739
+
740
+ /** Opt-ins default off; dry-run resolves them without prompting. */
741
+ const OPT_INS = Object.freeze([
742
+ {
743
+ key: 'withProjectBoard',
744
+ flag: 'with-project-board',
745
+ prompt: 'Set up project board fields (Status, custom)?',
746
+ },
747
+ {
748
+ key: 'withIssueForms',
749
+ flag: 'with-issue-forms',
750
+ prompt: 'Generate GitHub Issue Form templates?',
751
+ },
752
+ {
753
+ key: 'withQuality',
754
+ flag: 'with-quality',
755
+ prompt:
756
+ 'Install local quality gates (pre-commit hook + quality:preview/watch scripts)?',
757
+ },
758
+ ]);
759
+
760
+ async function resolveOptIns(state) {
761
+ const optIns = {};
762
+ for (const { key, flag, prompt } of OPT_INS) {
763
+ let on = Boolean(state.flags[flag]);
764
+ if (!state.flags['dry-run'] && !on) {
765
+ on = await confirmYesNo(prompt, state.interactive, false);
736
766
  }
737
- return { name, number: pn };
767
+ optIns[key] = on;
768
+ }
769
+ return optIns;
770
+ }
771
+
772
+ async function approveCreation(state, creation) {
773
+ if (state.flags['dry-run'] || !(creation.newRepo || creation.newProject)) {
774
+ return true;
738
775
  }
739
- return { name: pn, number: '(new)' };
776
+ return confirmYesNo(
777
+ 'Create the new GitHub repo/project listed above?',
778
+ state.interactive,
779
+ );
780
+ }
781
+
782
+ async function confirmSummary(state, answers, creation, projectsList) {
783
+ const skipGithub = Boolean(state.flags['skip-github']);
784
+ const project = resolveProjectDisplay(answers, skipGithub, projectsList);
785
+ Logger.info(
786
+ renderAnswerSummary(
787
+ answers,
788
+ creation,
789
+ project,
790
+ state.gitInitialized,
791
+ resolveRepoVisibility(state.flags),
792
+ ),
793
+ );
794
+ return confirmYesNo('Is this correct?', state.interactive);
795
+ }
796
+
797
+ /** Owner repo/project lists, fetched once for pickers and summary. */
798
+ function fetchPickerLists(state, skipGithub) {
799
+ const owner = resolveOwnerForPicker(state.defaults, state.flags);
800
+ if (skipGithub || !owner) return { reposList: [], projectsList: [] };
801
+ return {
802
+ reposList: safeList(() => listRepos({ owner }).map(bareRepoName)),
803
+ projectsList: safeList(() => listProjects({ owner })),
804
+ };
740
805
  }
741
806
 
742
807
  /**
@@ -745,22 +810,16 @@ function resolveProjectDisplay(answers, skipGithub, projectsList) {
745
810
  */
746
811
  export async function collectAndConfirm(state) {
747
812
  const skipGithub = Boolean(state.flags['skip-github']);
748
- const owner = resolveOwnerForPicker(state.defaults, state.flags);
749
- // Fetched once for pickers and summary, so the name never needs a second call.
750
- const reposList =
751
- !skipGithub && owner
752
- ? safeList(() => listRepos({ owner }).map(bareRepoName))
753
- : [];
754
- const projectsList =
755
- !skipGithub && owner ? safeList(() => listProjects({ owner })) : [];
756
-
813
+ const lists = fetchPickerLists(state, skipGithub);
757
814
  let silentAccept = state.silentAccept;
758
815
  for (;;) {
759
816
  const { answers, missing } = await collectAnswers({
760
- questions: buildQuestions(state.defaults, state.flags, process.env, {
761
- reposList,
762
- projectsList,
763
- }),
817
+ questions: buildQuestions(
818
+ state.defaults,
819
+ state.flags,
820
+ process.env,
821
+ lists,
822
+ ),
764
823
  flags: state.flags,
765
824
  interactive: state.interactive,
766
825
  assumeYes: state.assumeYes,
@@ -775,78 +834,24 @@ export async function collectAndConfirm(state) {
775
834
  );
776
835
  return { ok: false, exit: 1 };
777
836
  }
778
- if (!answers.operatorHandle) answers.operatorHandle = answers.owner;
779
- answers.operatorHandle = normalizeHandleAnswer(answers.operatorHandle);
837
+ answers.operatorHandle = normalizeHandleAnswer(
838
+ answers.operatorHandle || answers.owner,
839
+ );
780
840
 
781
841
  const creation = await detectCreation(answers, skipGithub);
782
- const project = resolveProjectDisplay(answers, skipGithub, projectsList);
783
- Logger.info(
784
- renderAnswerSummary(
785
- answers,
786
- creation,
787
- project,
788
- state.gitInitialized,
789
- resolveRepoVisibility(state.flags),
790
- ),
791
- );
792
- const correct = await confirmYesNo('Is this correct?', state.interactive);
793
- if (!correct) {
842
+ if (!(await confirmSummary(state, answers, creation, lists.projectsList))) {
794
843
  Logger.info('[Bootstrap] Okay — let’s try again.');
795
844
  silentAccept = [];
796
845
  continue;
797
846
  }
798
-
799
- if (!state.flags['dry-run'] && (creation.newRepo || creation.newProject)) {
800
- const approved = await confirmYesNo(
801
- 'Create the new GitHub repo/project listed above?',
802
- state.interactive,
803
- );
804
- if (!approved) {
805
- Logger.error(
806
- '[Bootstrap] Creation declined — cannot continue without the repo/project. Exiting.',
807
- );
808
- return { ok: false, exit: 1 };
809
- }
810
- }
811
-
812
- // Opt-ins default off; dry-run resolves them without prompting.
813
- let withProjectBoard = Boolean(state.flags['with-project-board']);
814
- if (!state.flags['dry-run'] && !withProjectBoard) {
815
- withProjectBoard = await confirmYesNo(
816
- 'Set up project board fields (Status, custom)?',
817
- state.interactive,
818
- false,
819
- );
820
- }
821
-
822
- let withIssueForms = Boolean(state.flags['with-issue-forms']);
823
- if (!state.flags['dry-run'] && !withIssueForms) {
824
- withIssueForms = await confirmYesNo(
825
- 'Generate GitHub Issue Form templates?',
826
- state.interactive,
827
- false,
828
- );
829
- }
830
-
831
- let withQuality = Boolean(state.flags['with-quality']);
832
- if (!state.flags['dry-run'] && !withQuality) {
833
- withQuality = await confirmYesNo(
834
- 'Install local quality gates (pre-commit hook + quality:preview/watch scripts)?',
835
- state.interactive,
836
- false,
847
+ if (!(await approveCreation(state, creation))) {
848
+ Logger.error(
849
+ '[Bootstrap] Creation declined — cannot continue without the repo/project. Exiting.',
837
850
  );
851
+ return { ok: false, exit: 1 };
838
852
  }
839
-
840
- return {
841
- ok: true,
842
- payload: {
843
- answers,
844
- creation,
845
- withProjectBoard,
846
- withIssueForms,
847
- withQuality,
848
- },
849
- };
853
+ const optIns = await resolveOptIns(state);
854
+ return { ok: true, payload: { answers, creation, ...optIns } };
850
855
  }
851
856
  }
852
857
 
@@ -1068,7 +1073,13 @@ export async function offerCommitPush(state, deps = {}) {
1068
1073
  return { ok: true, payload: { commitPush: { action: 'declined' } } };
1069
1074
  }
1070
1075
 
1071
- const staged = stageBootstrapFiles({ projectRoot: cwd, runGit: runGitImpl });
1076
+ const added = stageBootstrapFiles({ projectRoot: cwd, runGit: runGitImpl });
1077
+ // The folded-away CLAUDE.md deletion rides the same commit.
1078
+ const staged = stageLegacyEntryDocRemoval({
1079
+ projectRoot: cwd,
1080
+ runGit: runGitImpl,
1081
+ after: added,
1082
+ });
1072
1083
  if (!staged.ok) {
1073
1084
  Logger.warn(`[Bootstrap] Could not stage the wiring: ${staged.error}`);
1074
1085
  Logger.info(`\n[Bootstrap] ${instructions}`);
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * CLI: ratchet gate for documentation context bytes against
3
- * `baselines/context-budget.json`. Measures `alwaysLoaded` (the `CLAUDE.md`
3
+ * `baselines/context-budget.json`. Measures `alwaysLoaded` (the entry-doc
4
4
  * `@`-import closure), `mandatoryRead` (`project.docsContextFiles`) and
5
5
  * `workflow` (entry points + `mandatoryReads:` closure).
6
6
  *
@@ -105,9 +105,7 @@ export class ITicketingProvider {
105
105
  }
106
106
 
107
107
  /**
108
- * Resolve a container parent in one call. Returns `null` (never throws) when
109
- * there is no parent or the backend cannot answer, so a degraded lookup
110
- * never fails a lifecycle edge.
108
+ * `null` means "no parent"; throws when the lookup cannot answer.
111
109
  *
112
110
  * @param {number} _number
113
111
  * @returns {Promise<object|null>}
@@ -1,27 +1,11 @@
1
1
  /**
2
- * Findings severity histogram and per-component baseline rollup deltas.
2
+ * The surviving-Critical halting rule and per-component baseline rollup deltas.
3
3
  * Deltas are per rollup, not per row: row churn that moves no rollup is noise.
4
4
  * Pure, no I/O.
5
5
  */
6
6
 
7
7
  import { groupRows, resolveComponents } from '../baselines/components.js';
8
8
 
9
- /**
10
- * Non-standard severities are ignored.
11
- *
12
- * @param {Array<{ severity?: string }>|null|undefined} findings
13
- * @returns {{ critical: number, high: number, medium: number, low: number }}
14
- */
15
- export function aggregateSummary(findings) {
16
- const summary = { critical: 0, high: 0, medium: 0, low: 0 };
17
- for (const finding of findings ?? []) {
18
- if (Object.hasOwn(summary, finding.severity)) {
19
- summary[finding.severity] += 1;
20
- }
21
- }
22
- return summary;
23
- }
24
-
25
9
  /**
26
10
  * The single halting rule: a surviving Critical finding halts delivery. Every
27
11
  * consumer routes through this rather than re-deriving `critical > 0`.
@@ -8,8 +8,6 @@
8
8
  // biome-ignore-start lint/complexity/useRegexLiterals: typhonjs-escomplex MI workaround
9
9
  const FRONTMATTER_RE = new RegExp(String.raw`^---\r?\n([\s\S]*?)\r?\n---\r?\n`);
10
10
  const NEWLINE_SPLIT_RE = new RegExp(String.raw`\r?\n`);
11
- const PARAGRAPH_SPLIT_RE = new RegExp(String.raw`\r?\n\s*\r?\n`);
12
- const COLLAPSE_WS_RE = new RegExp(String.raw`\s+`, 'g');
13
11
  const SENTENCE_RE = new RegExp(String.raw`[^.!?\n]+[.!?]+`, 'g');
14
12
  // biome-ignore-end lint/complexity/useRegexLiterals: typhonjs-escomplex MI workaround
15
13
 
@@ -42,22 +40,6 @@ export function extractFrontmatter(content) {
42
40
  return fm;
43
41
  }
44
42
 
45
- /**
46
- * @param {string} content
47
- * @returns {string}
48
- */
49
- export function firstProseParagraph(content) {
50
- const stripped = content.replace(FRONTMATTER_RE, '');
51
- for (const block of stripped.split(PARAGRAPH_SPLIT_RE)) {
52
- const trimmed = block.trim();
53
- if (!trimmed) continue;
54
- if (trimmed.startsWith('#')) continue;
55
- if (trimmed.startsWith('---')) continue;
56
- return trimmed.replace(COLLAPSE_WS_RE, ' ');
57
- }
58
- return '';
59
- }
60
-
61
43
  /**
62
44
  * @param {string} text
63
45
  * @returns {string}
@@ -74,13 +56,3 @@ export function clampSummary(text) {
74
56
  }
75
57
  return result;
76
58
  }
77
-
78
- /**
79
- * @param {string} content
80
- * @returns {string}
81
- */
82
- export function summarizeWorkflow(content) {
83
- const fm = extractFrontmatter(content);
84
- const candidate = fm.description?.trim() || firstProseParagraph(content);
85
- return clampSummary(candidate);
86
- }
@@ -7,16 +7,10 @@ export {
7
7
  readAuditRules,
8
8
  } from './checklist-threading.js';
9
9
  export { buildDispatchChecklist } from './dispatch-checklist.js';
10
- export {
11
- countChangedLines,
12
- evaluateLensDiffFloor,
13
- } from './lens-diff-floor.js';
14
- export { runAuditSuite } from './runner.js';
15
10
  export {
16
11
  LENS_TIERS,
17
12
  matchesAnyFilePattern,
18
13
  matchesFilePattern,
19
14
  resolveLensTier,
20
15
  selectAudits,
21
- selectLocalLenses,
22
16
  } from './selector.js';
@@ -45,37 +45,6 @@ export function resolveLensTier(lens) {
45
45
  return scope;
46
46
  }
47
47
 
48
- /**
49
- * Local-tier lenses whose `filePatterns` hit the change set (not
50
- * {@link selectAudits}, which would widen it with keyword matches).
51
- *
52
- * @param {{
53
- * changedFiles?: string[],
54
- * injectedRules?: { audits?: Record<string, object> },
55
- * resolveLensTierFn?: typeof resolveLensTier,
56
- * }} [params]
57
- * @returns {string[]} The matched local-lens identifiers, in manifest order.
58
- */
59
- export function selectLocalLenses({
60
- changedFiles,
61
- injectedRules,
62
- resolveLensTierFn = resolveLensTier,
63
- } = {}) {
64
- const files = Array.isArray(changedFiles) ? changedFiles : [];
65
- if (files.length === 0) return [];
66
-
67
- const rules = injectedRules ?? readAuditRulesSync();
68
- const selected = [];
69
- for (const [lens, entry] of Object.entries(rules.audits ?? {})) {
70
- if (resolveLensTierFn(lens) !== 'local') continue;
71
- const patterns = entry?.triggers?.filePatterns ?? [];
72
- if (matchesAnyFilePattern(patterns, files)) {
73
- selected.push(lens);
74
- }
75
- }
76
- return selected;
77
- }
78
-
79
48
  /**
80
49
  * Manifest `sensitivePaths` classes the change set touches (review depth).
81
50
  *
@@ -5,24 +5,33 @@
5
5
  import { readFileSync } from 'node:fs';
6
6
  import { createRequire } from 'node:module';
7
7
  import path from 'node:path';
8
+ import { resolveDependencyVersion } from '../dependency-version.js';
8
9
 
9
10
  const DEFAULT_MIN_TOKENS = 50;
10
11
  const DEFAULT_FORMATS = Object.freeze(['javascript']);
11
12
 
12
13
  const require = createRequire(import.meta.url);
13
14
 
15
+ /** jscpd 5 is a Rust rewrite with no Node API; only major 4 exposes one. */
16
+ const SUPPORTED_JSCPD_MAJOR = '^4';
17
+
14
18
  /**
15
19
  * Lazy CJS load: jscpd's ESM entry has a broken transitive `colors/safe`
16
20
  * specifier under strict ESM resolution, and importers that never scan should
17
21
  * not pay the load.
18
22
  *
23
+ * No `detectClones` means an unsupported major, not a missing install.
24
+ *
25
+ * @param {NodeJS.Require} [requireFn] substitutes the module resolver
19
26
  * @returns {(opts: object) => Promise<Array<object>>}
20
27
  */
21
- export function resolveDetectClones() {
22
- const jscpd = require('jscpd');
23
- if (typeof jscpd.detectClones !== 'function') {
28
+ export function resolveDetectClones(requireFn = require) {
29
+ const jscpd = requireFn('jscpd');
30
+ if (typeof jscpd?.detectClones !== 'function') {
31
+ const version = resolveDependencyVersion('jscpd', requireFn) ?? 'unknown';
24
32
  throw new Error(
25
- "[Duplication] jscpd.detectClones is not available — run 'npm install'",
33
+ `[Duplication] jscpd ${version} exposes no detectClones Node API — ` +
34
+ `the duplication gate supports jscpd ${SUPPORTED_JSCPD_MAJOR}; install jscpd@${SUPPORTED_JSCPD_MAJOR}`,
26
35
  );
27
36
  }
28
37
  return jscpd.detectClones;
@@ -44,6 +53,9 @@ export function relativisePath(sourceId, cwd) {
44
53
  }
45
54
 
46
55
  /**
56
+ * A side with `end.line < start.line` counts nothing: its real span is
57
+ * unrecoverable, and widening it recorded hundreds of phantom lines.
58
+ *
47
59
  * @param {{ start?: { line?: number }, end?: { line?: number } }} dup
48
60
  * @returns {Array<number>} the 1-based line numbers the clone covers
49
61
  */
@@ -51,10 +63,8 @@ function cloneLineNumbers(dup) {
51
63
  const start = dup?.start?.line;
52
64
  const end = dup?.end?.line;
53
65
  if (!Number.isInteger(start) || !Number.isInteger(end)) return [];
54
- const lo = Math.min(start, end);
55
- const hi = Math.max(start, end);
56
66
  const lines = [];
57
- for (let n = lo; n <= hi; n += 1) lines.push(n);
67
+ for (let n = start; n <= end; n += 1) lines.push(n);
58
68
  return lines;
59
69
  }
60
70
 
@@ -0,0 +1,156 @@
1
+ /**
2
+ * CLAUDE.md → AGENTS.md fold for bootstrap and the update migration: a
3
+ * surviving CLAUDE.md would shadow AGENTS.md, since the host prefers it.
4
+ *
5
+ * @module bootstrap/agents-md-fold
6
+ */
7
+
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+
11
+ /** Entry-doc wiring keys idempotence off this exact import path. */
12
+ export const SYSTEM_PROMPT_IMPORT = '@.agents/instructions.md';
13
+
14
+ export const SYSTEM_PROMPT_BLOCK = `## System Prompt
15
+
16
+ ${SYSTEM_PROMPT_IMPORT}
17
+ `;
18
+
19
+ /** Install template for a fresh entry doc. */
20
+ export const SYSTEM_PROMPT_ENTRY_DOC = `# Agent Protocols
21
+
22
+ ${SYSTEM_PROMPT_BLOCK}`;
23
+
24
+ const ENTRY_DOC = 'AGENTS.md';
25
+ export const LEGACY_ENTRY_DOC = 'CLAUDE.md';
26
+
27
+ const SELF_IMPORT = '@AGENTS.md';
28
+
29
+ /** @type {readonly string[]} */
30
+ const BOOTSTRAP_REMOVED_PATHS = Object.freeze([LEGACY_ENTRY_DOC]);
31
+
32
+ /**
33
+ * @param {string} text
34
+ * @returns {string}
35
+ */
36
+ function withTrailingNewline(text) {
37
+ return text.length === 0 || text.endsWith('\n') ? text : `${text}\n`;
38
+ }
39
+
40
+ /**
41
+ * Keep the first system-prompt import line, drop every later one.
42
+ *
43
+ * @param {string[]} lines
44
+ * @returns {string[]}
45
+ */
46
+ function dedupeImport(lines) {
47
+ let seen = false;
48
+ return lines.filter((line) => {
49
+ if (line.trim() !== SYSTEM_PROMPT_IMPORT) return true;
50
+ if (seen) return false;
51
+ seen = true;
52
+ return true;
53
+ });
54
+ }
55
+
56
+ /**
57
+ * `agents` null means AGENTS.md is absent.
58
+ *
59
+ * @param {{ claude: string, agents?: string|null }} input
60
+ * @returns {string} the resulting AGENTS.md content
61
+ */
62
+ function foldEntryDocs({ claude, agents = null }) {
63
+ const claudeBody = claude
64
+ .split('\n')
65
+ .filter((line) => line.trim() !== SELF_IMPORT)
66
+ .join('\n');
67
+ const combined =
68
+ typeof agents === 'string'
69
+ ? `${withTrailingNewline(agents)}\n${claudeBody}`
70
+ : claudeBody;
71
+ const lines = dedupeImport(combined.split('\n'));
72
+ let result = withTrailingNewline(lines.join('\n'));
73
+ if (!lines.some((line) => line.trim() === SYSTEM_PROMPT_IMPORT)) {
74
+ result =
75
+ result.trim().length === 0
76
+ ? SYSTEM_PROMPT_ENTRY_DOC
77
+ : `${result}\n${SYSTEM_PROMPT_BLOCK}`;
78
+ }
79
+ return result;
80
+ }
81
+
82
+ /**
83
+ * Fold a root CLAUDE.md into AGENTS.md on disk and delete CLAUDE.md. No-op
84
+ * when CLAUDE.md is absent, so a second pass mutates nothing.
85
+ *
86
+ * @param {string} projectRoot
87
+ * @param {typeof fs} [fsImpl]
88
+ * @returns {{ action: 'folded'|'none', path: string }}
89
+ */
90
+ export function foldClaudeMdIntoAgentsMd(projectRoot, fsImpl = fs) {
91
+ const claudePath = path.join(projectRoot, LEGACY_ENTRY_DOC);
92
+ const agentsPath = path.join(projectRoot, ENTRY_DOC);
93
+ if (!fsImpl.existsSync(claudePath)) {
94
+ return { action: 'none', path: agentsPath };
95
+ }
96
+ const claude = fsImpl.readFileSync(claudePath, 'utf8');
97
+ const agents = fsImpl.existsSync(agentsPath)
98
+ ? fsImpl.readFileSync(agentsPath, 'utf8')
99
+ : null;
100
+ fsImpl.writeFileSync(agentsPath, foldEntryDocs({ claude, agents }), 'utf8');
101
+ fsImpl.rmSync(claudePath, { force: true });
102
+ return { action: 'folded', path: agentsPath };
103
+ }
104
+
105
+ /**
106
+ * Wire the system-prompt import into AGENTS.md, folding a CLAUDE.md in first.
107
+ * Creates, folds, appends, or no-ops.
108
+ *
109
+ * @param {string} projectRoot
110
+ * @param {typeof fs} [fsImpl]
111
+ * @returns {{ action: 'created'|'folded'|'appended'|'already-present', path: string }}
112
+ */
113
+ export function wireEntryDoc(projectRoot, fsImpl = fs) {
114
+ const folded = foldClaudeMdIntoAgentsMd(projectRoot, fsImpl);
115
+ const target = folded.path;
116
+ if (folded.action === 'folded') return folded;
117
+ if (!fsImpl.existsSync(target)) {
118
+ fsImpl.writeFileSync(target, SYSTEM_PROMPT_ENTRY_DOC, 'utf8');
119
+ return { action: 'created', path: target };
120
+ }
121
+ const existing = fsImpl.readFileSync(target, 'utf8');
122
+ if (existing.includes(SYSTEM_PROMPT_IMPORT)) {
123
+ return { action: 'already-present', path: target };
124
+ }
125
+ const separator = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
126
+ fsImpl.writeFileSync(
127
+ target,
128
+ `${existing}${separator}\n${SYSTEM_PROMPT_BLOCK}`,
129
+ 'utf8',
130
+ );
131
+ return { action: 'appended', path: target };
132
+ }
133
+
134
+ /**
135
+ * Stage removal of folded-away entry docs; a failed `after` passes through.
136
+ *
137
+ * @param {{ after?: { ok: boolean, error?: string }, projectRoot: string, runGit: (args: string[], cwd: string) => { ok: boolean, stderr?: string }, fsImpl?: typeof fs }} args
138
+ * @returns {{ ok: boolean, error?: string, removed: string[] }}
139
+ */
140
+ export function stageLegacyEntryDocRemoval({
141
+ projectRoot,
142
+ runGit,
143
+ fsImpl = fs,
144
+ after = { ok: true },
145
+ }) {
146
+ if (!after.ok) return after;
147
+ const removed = BOOTSTRAP_REMOVED_PATHS.filter(
148
+ (rel) => !fsImpl.existsSync(path.join(projectRoot, rel)),
149
+ );
150
+ if (removed.length === 0) return { ok: true, removed };
151
+ const result = runGit(
152
+ ['rm', '--cached', '--ignore-unmatch', '--quiet', '--', ...removed],
153
+ projectRoot,
154
+ );
155
+ return { ok: result.ok, error: result.stderr || 'git rm failed', removed };
156
+ }
@@ -19,7 +19,7 @@ import path from 'node:path';
19
19
  export const BOOTSTRAP_COMMIT_PATHS = Object.freeze([
20
20
  '.agents',
21
21
  '.agentrc.json',
22
- 'CLAUDE.md',
22
+ 'AGENTS.md',
23
23
  '.claude/settings.json',
24
24
  '.gitignore',
25
25
  'package.json',
@@ -62,10 +62,10 @@ export function buildMutationManifest(ctx = {}) {
62
62
  entries.push(
63
63
  {
64
64
  phaseGroup: PHASE_GROUPS.IDE_WIRING,
65
- target: rel('CLAUDE.md'),
65
+ target: rel('AGENTS.md'),
66
66
  action: 'merge',
67
67
  detail:
68
- 'Wire the @.agents/instructions.md system-prompt import so Claude Code hydrates the framework on cold start.',
68
+ 'Wire the @.agents/instructions.md system-prompt import into AGENTS.md (folding any CLAUDE.md into it) so Claude Code hydrates the framework on cold start.',
69
69
  reversible: true,
70
70
  },
71
71
  {