ma-agents 3.5.5 → 3.6.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 (56) hide show
  1. package/.ma-agents.json +10 -0
  2. package/AGENTS.md +97 -0
  3. package/MANIFEST.yaml +3 -0
  4. package/README.md +17 -0
  5. package/_bmad-output/implementation-artifacts/21-10-profile-reconfigure.md +30 -6
  6. package/_bmad-output/implementation-artifacts/21-11-profile-uninstall.md +2 -1
  7. package/_bmad-output/implementation-artifacts/21-2-universal-instruction-block-expansion.md +217 -62
  8. package/_bmad-output/implementation-artifacts/21-3-roomodes-template-bmad-modes.md +196 -73
  9. package/_bmad-output/implementation-artifacts/21-4-agents-md-template-opencode.md +242 -53
  10. package/_bmad-output/implementation-artifacts/21-5-clinerules-template-extension.md +180 -41
  11. package/_bmad-output/implementation-artifacts/21-6-onprem-layered-guardrails.md +250 -75
  12. package/_bmad-output/implementation-artifacts/21-7-bmad-persona-phase-prefix.md +221 -89
  13. package/_bmad-output/implementation-artifacts/21-8-vllm-reference-doc-readme.md +121 -63
  14. package/_bmad-output/implementation-artifacts/21-9-tests-validation.md +332 -61
  15. package/_bmad-output/implementation-artifacts/bug-bmad-recompile-fails-on-airgapped-network.md +112 -0
  16. package/_bmad-output/implementation-artifacts/sprint-status.yaml +3 -2
  17. package/bin/cli.js +59 -0
  18. package/docs/deployment/vllm-nemotron.md +130 -0
  19. package/lib/agents.js +17 -2
  20. package/lib/bmad-customize/bmm-analyst.customize.yaml +8 -0
  21. package/lib/bmad-customize/bmm-architect.customize.yaml +2 -0
  22. package/lib/bmad-customize/bmm-dev.customize.yaml +2 -0
  23. package/lib/bmad-customize/bmm-pm.customize.yaml +2 -0
  24. package/lib/bmad-customize/bmm-qa.customize.yaml +2 -0
  25. package/lib/bmad-customize/bmm-quick-flow-solo-dev.customize.yaml +8 -0
  26. package/lib/bmad-customize/bmm-sm.customize.yaml +2 -0
  27. package/lib/bmad-customize/bmm-tech-writer.customize.yaml +2 -0
  28. package/lib/bmad-customize/bmm-ux-designer.customize.yaml +2 -0
  29. package/lib/bmad.js +293 -1
  30. package/lib/installer.js +617 -43
  31. package/lib/merge/roomodes.js +125 -0
  32. package/lib/profile.js +25 -2
  33. package/lib/reconfigure.js +334 -0
  34. package/lib/templates/agents-md.template.md +67 -0
  35. package/lib/templates/clinerules.template.md +13 -0
  36. package/lib/templates/instruction-block-onprem.template.md +86 -0
  37. package/lib/templates/instruction-block-universal.template.md +29 -0
  38. package/lib/templates/roomodes.template.yaml +96 -0
  39. package/lib/uninstall.js +314 -0
  40. package/package.json +4 -3
  41. package/test/agents-md.test.js +398 -0
  42. package/test/bmad-extension.test.js +2 -2
  43. package/test/bmad-persona-phase-prefix.test.js +271 -0
  44. package/test/clinerules.test.js +339 -0
  45. package/test/instruction-block.test.js +388 -0
  46. package/test/integration-verification.test.js +2 -2
  47. package/test/migration-validation.test.js +2 -2
  48. package/test/offline-recompile.test.js +237 -0
  49. package/test/onprem-injection.test.js +425 -32
  50. package/test/onprem-layer.test.js +419 -0
  51. package/test/reconfigure.test.js +436 -0
  52. package/test/roomodes.test.js +343 -0
  53. package/test/uninstall.test.js +402 -0
  54. package/_bmad-output/methodology/BMAD_AI_Development_Training.pptx +0 -0
  55. package/_bmad-output/methodology/version.json +0 -7
  56. package/docs/BMAD_AI_Development_Training.pptx +0 -0
package/lib/installer.js CHANGED
@@ -8,6 +8,205 @@ const MANIFEST_FILE = '.ma-agents.json';
8
8
  const MANIFEST_VERSION = '1.2.0';
9
9
  const MA_AGENTS_SOURCE = 'ma-agents';
10
10
  const TEMPLATE_PATH = path.join(__dirname, 'templates', 'project-context.template.md');
11
+ const UNIVERSAL_INSTRUCTION_TEMPLATE_PATH = path.join(__dirname, 'templates', 'instruction-block-universal.template.md');
12
+ const ONPREM_INSTRUCTION_TEMPLATE_PATH = path.join(__dirname, 'templates', 'instruction-block-onprem.template.md');
13
+ const CLINERULES_TEMPLATE_PATH = path.join(__dirname, 'templates', 'clinerules.template.md');
14
+ const EXTRA_TEMPLATE_DIR = path.join(__dirname, 'templates');
15
+
16
+ /**
17
+ * Story 21.5 AC #6 — Dual-file drift detection error.
18
+ *
19
+ * Thrown by the installer when the in-marker contents of `.cline/clinerules.md`
20
+ * and `.clinerules` diverge (non-whitespace diff). `--yes` does NOT bypass this
21
+ * check — reconciliation between the two Cline rule files is user work, and
22
+ * silently picking a "winner" could discard intentional edits.
23
+ *
24
+ * Follows the error-class naming pattern introduced by Story 21.3/21.10's
25
+ * RoomodesSlugDivergenceError.
26
+ */
27
+ class ClinerulesDualFileDriftError extends Error {
28
+ constructor({ fileA, fileB, diff }) {
29
+ const header = `Cline dual-file drift detected between ${fileA} and ${fileB}.`;
30
+ const guidance = 'Reconcile the two files manually (copy the correct marker-block content into both) before re-running install. `--yes` does NOT bypass this check.';
31
+ super(`${header}\n${guidance}\n\n--- diff (${fileA} vs. ${fileB}) ---\n${diff}`);
32
+ this.name = 'ClinerulesDualFileDriftError';
33
+ this.fileA = fileA;
34
+ this.fileB = fileB;
35
+ this.diff = diff;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Extract the content between MA-AGENTS markers in a file, without the marker
41
+ * lines themselves. Returns null when the file does not exist OR has no marker
42
+ * pair. Whitespace-only leading/trailing runs inside the block are preserved —
43
+ * caller decides whether to normalize before comparing.
44
+ */
45
+ function _extractMarkerBlockInner(filePath) {
46
+ if (!fs.existsSync(filePath)) return null;
47
+ const content = fs.readFileSync(filePath, 'utf-8');
48
+ const m = content.match(/<!-- MA-AGENTS-START -->([\s\S]*?)<!-- MA-AGENTS-END -->/);
49
+ if (!m) return null;
50
+ return m[1];
51
+ }
52
+
53
+ /**
54
+ * Story 21.5 AC #6 — Compare in-marker content of `.cline/clinerules.md` and
55
+ * `.clinerules` (when both exist). Non-whitespace divergence throws
56
+ * ClinerulesDualFileDriftError. If only one file exists, drift detection is
57
+ * skipped (AC #6, Task 3.4 "render once, write twice" invariant).
58
+ *
59
+ * Exposed for tests via module.exports; called internally by
60
+ * updateAgentInstructions on the Cline agent path.
61
+ */
62
+ function checkClinerulesDualFileDrift(projectRoot) {
63
+ const pathA = path.join(projectRoot, '.cline', 'clinerules.md');
64
+ const pathB = path.join(projectRoot, '.clinerules');
65
+ const innerA = _extractMarkerBlockInner(pathA);
66
+ const innerB = _extractMarkerBlockInner(pathB);
67
+ if (innerA == null || innerB == null) return; // one (or both) absent — skip
68
+ const normalize = (s) => s.replace(/\s+/g, ' ').trim();
69
+ if (normalize(innerA) === normalize(innerB)) return;
70
+ // Build a minimal unified diff (line-level) for the message.
71
+ const linesA = innerA.split('\n');
72
+ const linesB = innerB.split('\n');
73
+ const diffLines = [];
74
+ const maxLen = Math.max(linesA.length, linesB.length);
75
+ for (let i = 0; i < maxLen; i++) {
76
+ const a = linesA[i];
77
+ const b = linesB[i];
78
+ if (a === b) continue;
79
+ if (a !== undefined) diffLines.push(`- ${a}`);
80
+ if (b !== undefined) diffLines.push(`+ ${b}`);
81
+ }
82
+ throw new ClinerulesDualFileDriftError({
83
+ fileA: path.relative(projectRoot, pathA).replace(/\\/g, '/'),
84
+ fileB: path.relative(projectRoot, pathB).replace(/\\/g, '/'),
85
+ diff: diffLines.join('\n')
86
+ });
87
+ }
88
+
89
+ // Story 21.4 — memoize resolved BMAD-output dirs per projectRoot so the
90
+ // install loop resolves once (AC #10c) and logs once.
91
+ const _bmadOutputDirsCache = new Map();
92
+ const _bmadOutputDirsLogged = new Set();
93
+
94
+ /**
95
+ * Story 21.2 — Universal per-tool instruction block composer.
96
+ *
97
+ * Reads lib/templates/instruction-block-universal.template.md (always).
98
+ * If profile === 'on-prem', appends lib/templates/instruction-block-onprem.template.md
99
+ * separated by a single blank line. If the on-prem template is missing when required,
100
+ * THROWS — there is no silent fallback (Decision A, AC #3).
101
+ *
102
+ * Templates contain NO substitution of {{...}} placeholders inside this function; any
103
+ * substitution (e.g., {{MANIFEST_PATH}}) is the caller's responsibility, applied AFTER
104
+ * composition. This keeps the composer a single-owner entry point that downstream
105
+ * stories 21.3/21.4/21.5/21.6 consume without duplication.
106
+ *
107
+ * @param {{profile: string|undefined, projectRoot: string}} args
108
+ * @returns {string} composed template content (with placeholders intact)
109
+ */
110
+ function composeInstructionBlock({ profile, projectRoot } = {}) {
111
+ // projectRoot is accepted for API-stability with downstream stories even though
112
+ // the current implementation does not read from it (templates ship with the
113
+ // package). Keeping the parameter documented avoids signature churn in 21.6.
114
+ void projectRoot;
115
+
116
+ let universal;
117
+ try {
118
+ universal = fs.readFileSync(UNIVERSAL_INSTRUCTION_TEMPLATE_PATH, 'utf-8');
119
+ } catch (err) {
120
+ throw new Error(
121
+ `universal instruction template not found at ${UNIVERSAL_INSTRUCTION_TEMPLATE_PATH} — ma-agents installation may be corrupted: ${err.message}`
122
+ );
123
+ }
124
+
125
+ if (!universal.includes('{{MANIFEST_PATH}}')) {
126
+ throw new Error(
127
+ `universal instruction template at ${UNIVERSAL_INSTRUCTION_TEMPLATE_PATH} is missing the required {{MANIFEST_PATH}} placeholder`
128
+ );
129
+ }
130
+
131
+ if (profile === 'on-prem') {
132
+ if (!fs.existsSync(ONPREM_INSTRUCTION_TEMPLATE_PATH)) {
133
+ throw new Error('on-prem profile selected but instruction-block-onprem.template.md is missing');
134
+ }
135
+ const onprem = fs.readFileSync(ONPREM_INSTRUCTION_TEMPLATE_PATH, 'utf-8');
136
+ // Normalize both pieces so the concatenation has exactly one blank line between them
137
+ // and no trailing whitespace — feeds NFR46 byte-identity.
138
+ return universal.replace(/\s+$/, '') + '\n\n' + onprem.replace(/\s+$/, '') + '\n';
139
+ }
140
+
141
+ // Normalize trailing whitespace so first-insert and in-place-replace both
142
+ // produce byte-identical content inside the markers (NFR46, AC #6).
143
+ return universal.replace(/\s+$/, '') + '\n';
144
+ }
145
+
146
+ /**
147
+ * Story 21.4 AC #10 — resolve BMAD output directories once per install.
148
+ *
149
+ * Precedence:
150
+ * a) If `_bmad/bmm/config.yaml` exists AND has explicit `planning_artifacts`,
151
+ * `architecture_artifacts`, and `implementation_artifacts`, use those.
152
+ * b) Otherwise, fall back to the documented defaults:
153
+ * planning: _bmad-output/planning-artifacts
154
+ * architecture: _bmad-output/planning-artifacts (co-located default)
155
+ * stories: _bmad-output/implementation-artifacts
156
+ *
157
+ * YAML parsing is intentionally minimal — we only need a handful of
158
+ * top-level scalar keys. Adding a full YAML dependency just for this is
159
+ * overkill and would widen the supply chain for one helper.
160
+ *
161
+ * @param {string} projectRoot
162
+ * @returns {{planning: string, architecture: string, stories: string}}
163
+ */
164
+ function resolveBmadOutputDirs(projectRoot) {
165
+ if (_bmadOutputDirsCache.has(projectRoot)) {
166
+ return _bmadOutputDirsCache.get(projectRoot);
167
+ }
168
+
169
+ const defaults = {
170
+ planning: '_bmad-output/planning-artifacts',
171
+ architecture: '_bmad-output/planning-artifacts',
172
+ stories: '_bmad-output/implementation-artifacts'
173
+ };
174
+
175
+ const configPath = path.join(projectRoot, '_bmad', 'bmm', 'config.yaml');
176
+ const dirs = { ...defaults };
177
+
178
+ if (fs.existsSync(configPath)) {
179
+ try {
180
+ const yamlText = fs.readFileSync(configPath, 'utf-8');
181
+ const scanScalar = (key) => {
182
+ // Match `key: "value"` or `key: value` at line start (indent tolerated),
183
+ // ignoring commented lines. Value is the quoted string or the bare token.
184
+ const re = new RegExp(`^[\\t ]*${key}\\s*:\\s*(?:"([^"]*)"|'([^']*)'|([^#\\n\\r]+?))\\s*(?:#.*)?$`, 'm');
185
+ const m = yamlText.match(re);
186
+ if (!m) return null;
187
+ const raw = m[1] || m[2] || m[3] || '';
188
+ return raw.trim().replace(/\\/g, '/');
189
+ };
190
+ const planning = scanScalar('planning_artifacts');
191
+ const architecture = scanScalar('architecture_artifacts');
192
+ const stories = scanScalar('implementation_artifacts');
193
+ if (planning) dirs.planning = planning;
194
+ if (architecture) dirs.architecture = architecture;
195
+ if (stories) dirs.stories = stories;
196
+ } catch {
197
+ // Malformed or unreadable config — fall back silently to defaults.
198
+ }
199
+ }
200
+
201
+ _bmadOutputDirsCache.set(projectRoot, dirs);
202
+ if (!_bmadOutputDirsLogged.has(projectRoot)) {
203
+ _bmadOutputDirsLogged.add(projectRoot);
204
+ console.log(
205
+ `Resolved BMAD output dirs: planning=${dirs.planning}, architecture=${dirs.architecture}, stories=${dirs.stories}`
206
+ );
207
+ }
208
+ return dirs;
209
+ }
11
210
 
12
211
  // Claude Code hook configuration for MANIFEST verification
13
212
  const CLAUDE_CODE_HOOK_ID = 'ma-agents-verify-manifest';
@@ -355,9 +554,279 @@ function findInsertionPoint(content, skipPatterns) {
355
554
  return idx;
356
555
  }
357
556
 
358
- async function updateAgentInstructions(agent, projectRoot) {
557
+ /**
558
+ * Story 21.2 AC #10 — canonical backup filename format.
559
+ * Format: <target>.backup-<ISO-8601-timestamp> with colons replaced by hyphens
560
+ * for Windows filename safety (e.g., .claude/CLAUDE.md.backup-2026-04-15T12-30-00Z).
561
+ * Story 21.2 OWNS this format; stories 21.10 (reconfigure) and 21.11 (uninstall)
562
+ * consume it. The date source is injectable to keep tests deterministic.
563
+ */
564
+ function formatBackupTimestamp(date = new Date()) {
565
+ // ISO 8601 with hyphens instead of colons and no milliseconds:
566
+ // 2026-04-15T12-30-00Z
567
+ return date.toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/:/g, '-');
568
+ }
569
+
570
+ function buildBackupFilename(targetPath, date = new Date()) {
571
+ const base = `${targetPath}.backup-${formatBackupTimestamp(date)}`;
572
+ // Guard against sub-second re-runs clobbering a prior backup. If the canonical
573
+ // name already exists on disk, append a ".N" suffix until we find a free slot.
574
+ if (!fs.existsSync(base)) return base;
575
+ let i = 1;
576
+ while (fs.existsSync(`${base}.${i}`)) i++;
577
+ return `${base}.${i}`;
578
+ }
579
+
580
+ /**
581
+ * Story 21.2 AC #10 — marker-block drift handler.
582
+ *
583
+ * Called when the existing in-marker content differs from what
584
+ * composeInstructionBlock would produce for the current profile. In interactive
585
+ * mode (no --yes), prompts the user for confirmation. With --yes or when stdin
586
+ * is not a TTY, emits the pinned WARNING line and proceeds. In all drift cases
587
+ * where we proceed with the overwrite, a backup sibling file is written
588
+ * containing ONLY the marker-block region (markers included).
589
+ *
590
+ * Throws if the user declines the interactive prompt, short-circuiting the
591
+ * write in the caller.
592
+ */
593
+ async function handleMarkerBlockDrift({ filePath, existingBlock, expectedBlock, yesMode }) {
594
+ const backupPath = buildBackupFilename(filePath);
595
+
596
+ // In non-interactive mode (yes mode or non-TTY), emit pinned warning and proceed.
597
+ // In interactive mode, show diff preview and prompt for confirmation.
598
+ const interactive = !yesMode && process.stdin.isTTY;
599
+
600
+ if (interactive) {
601
+ // Show a compact diff-style preview. We intentionally do not require a diff
602
+ // library — the on-screen diff is informational only.
603
+ console.log(chalk.yellow(`\nma-agents marker-block in ${filePath} was modified since last install.`));
604
+ console.log(chalk.gray('--- current on-disk (inside markers) ---'));
605
+ console.log(existingBlock);
606
+ console.log(chalk.gray('--- expected (ma-agents) ---'));
607
+ console.log(expectedBlock);
608
+ const { proceed } = await prompts({
609
+ type: 'confirm',
610
+ name: 'proceed',
611
+ message: `Overwrite and back up previous content to ${backupPath}?`,
612
+ initial: false
613
+ });
614
+ if (!proceed) {
615
+ throw new Error(`User declined to overwrite ma-agents marker block in ${filePath}`);
616
+ }
617
+ } else {
618
+ // AC #10 pinned WARNING line (verbatim).
619
+ console.log(
620
+ `WARNING: ma-agents marker-block content modified since last install — overwriting. Previous content backed up to ${backupPath}`
621
+ );
622
+ }
623
+
624
+ // Backup contains only the marker-block region (markers included).
625
+ await fs.outputFile(backupPath, existingBlock, 'utf-8');
626
+ }
627
+
628
+ /**
629
+ * Story 21.4 — markdown-markers merger for extraInstructionTemplates.
630
+ *
631
+ * Writes a marker-wrapped instruction block into a markdown target file. This
632
+ * is the same marker-wrap contract used by updateAgentInstructions for
633
+ * single-instructionFile agents (AC #5), lifted into a reusable helper so the
634
+ * extraInstructionTemplates processor can dispatch on merger = 'markdown-markers'.
635
+ *
636
+ * Behavior (AC #5):
637
+ * - If target file does not exist: create it by writing the full template
638
+ * contents (including the leading "Generated by ma-agents" comment and
639
+ * the markers), with `composedBlock` placed between the MA-AGENTS markers.
640
+ * - If target exists with markers: replace in-marker content only; content
641
+ * outside markers is preserved byte-for-byte. Hand-edit drift detection
642
+ * (AC #11) uses the same handleMarkerBlockDrift helper as Story 21.2.
643
+ * - If target exists WITHOUT markers: append the marker block at EOF
644
+ * separated by one blank line. Existing content preserved.
645
+ *
646
+ * @param {string} targetPath - absolute path to the target file
647
+ * @param {string} templateBody - static template text (from lib/templates/)
648
+ * @param {string} composedBlock - the output of composeInstructionBlock(...)
649
+ * @param {{ yesMode?: boolean }} opts
650
+ * @returns {Promise<'created'|'updated'|'appended'|'skipped'>}
651
+ */
652
+ async function markdownMarkersMerger(targetPath, templateBody, composedBlock, opts = {}) {
653
+ const markerStart = '<!-- MA-AGENTS-START -->';
654
+ const markerEnd = '<!-- MA-AGENTS-END -->';
655
+ const yesMode = !!(opts.yesMode || process.env.MA_AGENTS_YES === '1');
656
+
657
+ // Normalize composedBlock trailing whitespace once so first-insert and
658
+ // in-place-replace both produce byte-identical in-marker content (NFR46).
659
+ const normalized = composedBlock.replace(/\s+$/, '') + '\n';
660
+ const wrappedBlock = `${markerStart}\n${normalized}${markerEnd}`;
661
+
662
+ if (!fs.existsSync(targetPath)) {
663
+ // AC #5 first bullet: fresh create — write leading comment + template, with
664
+ // markers replaced to carry the composed content.
665
+ const leadingComment = '<!-- Generated by ma-agents. Edit outside the MA-AGENTS-START/END markers to preserve your changes. -->\n';
666
+ // The template already contains placeholder empty markers ("<!-- MA-AGENTS-START -->\n<!-- MA-AGENTS-END -->").
667
+ // Replace the first such pair with the wrapped block. If the template has no markers,
668
+ // the block is appended at EOF separated by one blank line (defensive — template ships with markers).
669
+ const emptyMarkerPair = new RegExp(`${markerStart}\\s*\\n\\s*${markerEnd}`);
670
+ let body;
671
+ if (emptyMarkerPair.test(templateBody)) {
672
+ body = templateBody.replace(emptyMarkerPair, wrappedBlock);
673
+ } else {
674
+ const trimmed = templateBody.replace(/\s+$/, '');
675
+ body = trimmed + '\n\n' + wrappedBlock + '\n';
676
+ }
677
+ // Ensure single trailing newline.
678
+ const finalBody = body.replace(/\s+$/, '') + '\n';
679
+ await fs.outputFile(targetPath, leadingComment + finalBody, 'utf-8');
680
+ console.log(chalk.cyan(` + Created ${path.relative(path.dirname(targetPath), targetPath) === path.basename(targetPath) ? path.basename(targetPath) : targetPath}`));
681
+ return 'created';
682
+ }
683
+
684
+ const content = await fs.readFile(targetPath, 'utf-8');
685
+ const regex = new RegExp(`${markerStart}[\\s\\S]*?${markerEnd}`);
686
+ const existingMatch = content.match(regex);
687
+
688
+ if (existingMatch) {
689
+ // AC #5 second bullet + AC #11 drift detection.
690
+ const existingBlock = existingMatch[0];
691
+ if (existingBlock === wrappedBlock) {
692
+ // Byte-identical — idempotent no-op write to preserve mtime semantics would be
693
+ // wasteful; just return. Outside-markers content is unchanged.
694
+ return 'skipped';
695
+ }
696
+ // Drift: previous content differs from expected.
697
+ try {
698
+ await handleMarkerBlockDrift({
699
+ filePath: targetPath,
700
+ existingBlock,
701
+ expectedBlock: wrappedBlock,
702
+ yesMode
703
+ });
704
+ } catch (declineErr) {
705
+ console.log(chalk.gray(` Skipped ${path.basename(targetPath)} (user declined marker-block overwrite)`));
706
+ return 'skipped';
707
+ }
708
+ const replaced = content.replace(regex, wrappedBlock);
709
+ await fs.writeFile(targetPath, replaced, 'utf-8');
710
+ console.log(chalk.cyan(` + Updated ${path.basename(targetPath)}`));
711
+ return 'updated';
712
+ }
713
+
714
+ // AC #5 third bullet: existing file, no markers — append marker block at EOF
715
+ // separated by one blank line.
716
+ const base = content.replace(/\s+$/, '');
717
+ const appended = (base.length ? base + '\n\n' : '') + wrappedBlock + '\n';
718
+ await fs.writeFile(targetPath, appended, 'utf-8');
719
+ console.log(chalk.cyan(` + Appended marker block to ${path.basename(targetPath)}`));
720
+ return 'appended';
721
+ }
722
+
723
+ /**
724
+ * Story 21.4 — process extraInstructionTemplates entries on an agent.
725
+ *
726
+ * For each { template, target, merger } entry:
727
+ * 1. Resolve the source template file under lib/templates/.
728
+ * 2. Call composeInstructionBlock({ profile, projectRoot }) exactly once per
729
+ * entry (canonical composer contract — Story 21.2, decision A).
730
+ * 3. Dispatch on `merger`:
731
+ * - 'markdown-markers' → markdownMarkersMerger (this story)
732
+ * - 'yaml-customModes' → reserved for Story 21.3 (.roomodes)
733
+ * 4. Per-entry MANIFEST_PATH substitution is applied to the composed string
734
+ * BEFORE the merger receives it (caller-owned per Story 21.2 AC #3).
735
+ *
736
+ * @param {object} agent - lib/agents.js entry
737
+ * @param {string} projectRoot
738
+ * @param {{ yesMode?: boolean }} opts
739
+ */
740
+ async function stampExtraInstructionTemplates(agent, projectRoot, opts = {}) {
741
+ if (!Array.isArray(agent.extraInstructionTemplates) || agent.extraInstructionTemplates.length === 0) {
742
+ return;
743
+ }
744
+
745
+ // Resolve BMAD output dirs once per projectRoot (memoized + logged once).
746
+ resolveBmadOutputDirs(projectRoot);
747
+
748
+ const { getProfile } = require('./profile');
749
+ const resolvedProfile = getProfile(projectRoot) || 'standard';
750
+ const agentProjectPath = agent.getProjectPath();
751
+ const relManifestPath = path.relative(projectRoot, path.join(agentProjectPath, 'MANIFEST.yaml')).replace(/\\/g, '/');
752
+
753
+ for (const entry of agent.extraInstructionTemplates) {
754
+ if (!entry || !entry.template || !entry.target || !entry.merger) {
755
+ console.log(chalk.yellow(` Warning: malformed extraInstructionTemplates entry on ${agent.id}, skipping`));
756
+ continue;
757
+ }
758
+ const templatePath = path.join(EXTRA_TEMPLATE_DIR, entry.template);
759
+ if (!fs.existsSync(templatePath)) {
760
+ console.log(chalk.yellow(` Warning: template not found at ${templatePath}, skipping`));
761
+ continue;
762
+ }
763
+ const templateBody = fs.readFileSync(templatePath, 'utf-8');
764
+
765
+ // Canonical composer contract: called exactly once per artifact.
766
+ let composed = composeInstructionBlock({ profile: resolvedProfile, projectRoot });
767
+ // Post-composition substitution is caller-owned (Story 21.2 AC #3).
768
+ composed = composed.replace(/\{\{MANIFEST_PATH\}\}/g, relManifestPath);
769
+
770
+ const targetPath = path.join(projectRoot, entry.target);
771
+
772
+ if (entry.merger === 'markdown-markers') {
773
+ await markdownMarkersMerger(targetPath, templateBody, composed, { yesMode: opts.yesMode });
774
+ } else if (entry.merger === 'yaml-customModes') {
775
+ // Story 21.3 — .roomodes YAML splice. The template contains
776
+ // {{UNIVERSAL_BLOCK}} sentinels that must be expanded with the
777
+ // composed block BEFORE the merger (caller-owned substitution, AC #2/#4).
778
+ // Indent-preserving expansion keeps each line of the multi-line block
779
+ // aligned with the YAML block-scalar indent of the sentinel.
780
+ const composedTemplate = templateBody.replace(
781
+ /^([ \t]*)\{\{UNIVERSAL_BLOCK\}\}/gm,
782
+ (_m, indent) => composed
783
+ .replace(/\s+$/, '')
784
+ .split('\n')
785
+ .map(line => indent + line)
786
+ .join('\n')
787
+ );
788
+ const { mergeRoomodes } = require('./merge/roomodes');
789
+ const existingYaml = fs.existsSync(targetPath)
790
+ ? fs.readFileSync(targetPath, 'utf-8')
791
+ : '';
792
+ const mergedContent = mergeRoomodes(existingYaml, composedTemplate);
793
+
794
+ // Backup if the target exists and content differs (Story 21.2 canonical format).
795
+ if (fs.existsSync(targetPath)) {
796
+ const existing = fs.readFileSync(targetPath, 'utf-8');
797
+ if (existing !== mergedContent) {
798
+ const backupPath = buildBackupFilename(targetPath);
799
+ await fs.outputFile(backupPath, existing, 'utf-8');
800
+ }
801
+ }
802
+
803
+ const tmpPath = targetPath + '.tmp';
804
+ await fs.outputFile(tmpPath, mergedContent, 'utf-8');
805
+ await fs.rename(tmpPath, targetPath);
806
+ console.log(chalk.cyan(` + Updated ${entry.target}`));
807
+ } else {
808
+ console.log(chalk.yellow(` Warning: unknown merger '${entry.merger}' for ${agent.id}, skipping ${entry.target}`));
809
+ }
810
+ }
811
+ }
812
+
813
+ async function updateAgentInstructions(agent, projectRoot, opts = {}) {
359
814
  if (!agent.instructionFiles || agent.instructionFiles.length === 0) return;
360
815
 
816
+ // Story 21.2 — yesMode resolution (AC #10): explicit opts.yesMode wins,
817
+ // fall back to MA_AGENTS_YES env var (used by tests and subprocess flows).
818
+ // The CLI passes yesMode via opts when --yes is set so non-interactive
819
+ // installs do not hang on the drift-confirmation prompt.
820
+ const yesMode = !!(opts.yesMode || process.env.MA_AGENTS_YES === '1');
821
+
822
+ // Story 21.2 — resolve profile once per stamped artifact; compose the universal
823
+ // (+ on-prem when applicable) block once; mergers consume the already-composed string.
824
+ // Lazy require avoids potential circular-import pitfalls at module load (profile.js
825
+ // already lazy-requires installer for the ensureManifest bootstrap path).
826
+ const { getProfile } = require('./profile');
827
+ const resolvedProfile = getProfile(projectRoot) || 'standard';
828
+ const composedTemplate = composeInstructionBlock({ profile: resolvedProfile, projectRoot });
829
+
361
830
  // JSON merge strategy (e.g., OpenCode)
362
831
  // OpenCode expects instructions to be plain strings, not objects.
363
832
  // We identify our entries by a marker prefix in the string content,
@@ -367,63 +836,103 @@ async function updateAgentInstructions(agent, projectRoot) {
367
836
  const filePath = path.join(projectRoot, agent.instructionFiles[0]);
368
837
  const agentProjectPath = agent.getProjectPath();
369
838
  const relManifestPath = path.relative(projectRoot, path.join(agentProjectPath, 'MANIFEST.yaml')).replace(/\\/g, '/');
370
- const instructionText = `[${MA_AGENTS_SOURCE}] # AI Agent Skills - Planning Instruction\n\nYou have access to a library of skills in your skills directory. Before starting any task:\n\n1. Read the skill manifest at ${relManifestPath}\n2. Based on the task description, select which skills are relevant\n3. Read only the selected skill files\n4. Then proceed with the task\n\nAlways load skills marked with always_load: true.\nDo not load skills that are not relevant to the current task.`;
839
+ // Story 21.2 substitute {{MANIFEST_PATH}} AFTER composition (caller-owned, per AC #3).
840
+ // Prefix with [ma-agents] tag so the isMaEntry filter identifies the entry on re-install.
841
+ const instructionBody = composedTemplate.replace(/\{\{MANIFEST_PATH\}\}/g, relManifestPath);
842
+ const instructionText = `[${MA_AGENTS_SOURCE}] ${instructionBody}`.replace(/\s+$/, '');
371
843
 
372
844
  const isMaEntry = (entry) =>
373
845
  (typeof entry === 'string' && entry.startsWith(`[${MA_AGENTS_SOURCE}]`)) ||
374
846
  (typeof entry === 'object' && entry != null && entry._source === MA_AGENTS_SOURCE);
375
847
 
848
+ // Story 21.4 AC #6, #7 — collect any extra path-string entries that must be
849
+ // appended to the JSON array (e.g., literal "AGENTS.md" for OpenCode). These
850
+ // entries are USER-OWNED after first install (no [ma-agents] prefix → the
851
+ // isMaEntry filter does NOT match them → never re-appended, never removed
852
+ // by subsequent installs). Dedup uses strict string equality per AC #6.
853
+ const extraJsonEntries = [];
854
+ if (Array.isArray(agent.extraInstructionTemplates)) {
855
+ for (const tpl of agent.extraInstructionTemplates) {
856
+ if (tpl && tpl.merger === 'markdown-markers' && typeof tpl.target === 'string') {
857
+ extraJsonEntries.push(tpl.target);
858
+ }
859
+ }
860
+ }
861
+
376
862
  if (!fs.existsSync(filePath)) {
377
863
  // File absent: create fresh (atomic write)
378
- const data = { [targetKey]: [instructionText] };
864
+ const data = { [targetKey]: [instructionText, ...extraJsonEntries] };
379
865
  const tmpPath = filePath + '.tmp';
380
866
  await fs.outputFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8');
381
867
  await fs.rename(tmpPath, filePath);
382
868
  console.log(chalk.cyan(` + Created ${agent.instructionFiles[0]}`));
383
- return;
384
- }
385
- // File present: read and merge
386
- try {
387
- const content = await fs.readFile(filePath, 'utf-8');
388
- const data = JSON.parse(content);
389
- if (!Array.isArray(data[targetKey])) {
390
- data[targetKey] = [];
869
+ // Continue to stamp extra templates below (e.g., AGENTS.md).
870
+ } else {
871
+ // File present: read and merge
872
+ try {
873
+ const content = await fs.readFile(filePath, 'utf-8');
874
+ const data = JSON.parse(content);
875
+ if (!Array.isArray(data[targetKey])) {
876
+ data[targetKey] = [];
877
+ }
878
+ // Filter out stale ma-agents entries (string or legacy object format), keep user entries
879
+ const userEntries = data[targetKey].filter(entry => entry != null && !isMaEntry(entry));
880
+ // Story 21.4 AC #6 — append extraJsonEntries (e.g., "AGENTS.md") using strict
881
+ // string-equality dedup against userEntries so pre-existing user additions
882
+ // are not duplicated. AC #7: entries lack the [ma-agents] prefix and are
883
+ // therefore user-owned after first install.
884
+ const missingExtras = extraJsonEntries.filter(e => !userEntries.includes(e));
885
+ data[targetKey] = [...userEntries, instructionText, ...missingExtras];
886
+ // Atomic write: temp file then rename
887
+ const tmpPath = filePath + '.tmp';
888
+ await fs.outputFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8');
889
+ await fs.rename(tmpPath, filePath);
890
+ console.log(chalk.cyan(` + Updated ${agent.instructionFiles[0]}`));
891
+ } catch (err) {
892
+ console.error(`[${MA_AGENTS_SOURCE}] ERROR: Could not parse ${filePath} — ${err.message}. File not modified.`);
893
+ return; // non-fatal: do NOT re-throw
391
894
  }
392
- // Filter out stale ma-agents entries (string or legacy object format), keep user entries
393
- const userEntries = data[targetKey].filter(entry => entry != null && !isMaEntry(entry));
394
- data[targetKey] = [...userEntries, instructionText];
395
- // Atomic write: temp file then rename
396
- const tmpPath = filePath + '.tmp';
397
- await fs.outputFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8');
398
- await fs.rename(tmpPath, filePath);
399
- console.log(chalk.cyan(` + Updated ${agent.instructionFiles[0]}`));
400
- } catch (err) {
401
- console.error(`[${MA_AGENTS_SOURCE}] ERROR: Could not parse ${filePath} — ${err.message}. File not modified.`);
402
- return; // non-fatal: do NOT re-throw
403
895
  }
896
+
897
+ // Story 21.4 — stamp any extraInstructionTemplates (e.g., AGENTS.md) after
898
+ // the JSON-merge branch. This path does not fall through to the markdown
899
+ // marker-wrap below because `instructionFiles: ['opencode.json']` is JSON.
900
+ await stampExtraInstructionTemplates(agent, projectRoot, { yesMode });
404
901
  return;
405
902
  }
406
903
 
407
904
  const agentProjectPath = agent.getProjectPath();
408
905
  const relManifestPath = path.relative(projectRoot, path.join(agentProjectPath, 'MANIFEST.yaml')).replace(/\\/g, '/');
409
906
 
410
- const planningInstruction = `
411
- # AI Agent Skills - Planning Instruction
412
-
413
- You have access to a library of skills in your skills directory. Before starting any task:
414
-
415
- 1. Read the skill manifest at ${relManifestPath}
416
- 2. Based on the task description, select which skills are relevant
417
- 3. Read only the selected skill files
418
- 4. Then proceed with the task
419
-
420
- Always load skills marked with always_load: true.
421
- Do not load skills that are not relevant to the current task.
422
- `;
907
+ // Story 21.2 — replace the previously-hardcoded planningInstruction with the
908
+ // composed block. {{MANIFEST_PATH}} substitution happens AFTER composition
909
+ // (caller-owned per AC #3). The leading "\n" preserves the historical shape
910
+ // of the wrapped instruction so existing markers keep the same layout.
911
+ const planningInstruction = '\n' + composedTemplate.replace(/\{\{MANIFEST_PATH\}\}/g, relManifestPath);
423
912
 
424
913
  const markerStart = '<!-- MA-AGENTS-START -->';
425
914
  const markerEnd = '<!-- MA-AGENTS-END -->';
426
- const wrappedInstruction = `${markerStart}${planningInstruction}${markerEnd}\n`;
915
+ // NFR46: normalize once so first-insert and in-place-replace produce
916
+ // byte-identical marker-block content. Both paths now use `.trim()` + '\n'.
917
+ const wrappedInstruction = `${markerStart}${planningInstruction}${markerEnd}`;
918
+ const wrappedInstructionWithTrailingNewline = wrappedInstruction + '\n';
919
+
920
+ // Story 21.5 AC #6 — Cline dual-file drift detection.
921
+ // Runs BEFORE the file loop so we abort cleanly without partial writes.
922
+ // `--yes` does NOT bypass (explicit documented exception, AC #6).
923
+ if (agent.id === 'cline') {
924
+ checkClinerulesDualFileDrift(projectRoot);
925
+ }
926
+
927
+ // Story 21.5 AC #1, #2 — optional framing template for fresh Cline file creation.
928
+ // The composer produces the universal body once; the framing supplies the
929
+ // Cline-flavored header paragraph + Architect-mode guidance line. Framing is
930
+ // only used when creating a NEW file — existing files go through the normal
931
+ // marker-replace path so user content outside markers is preserved (AC #4).
932
+ let frameworkTemplate = null;
933
+ if (agent.id === 'cline' && fs.existsSync(CLINERULES_TEMPLATE_PATH)) {
934
+ frameworkTemplate = fs.readFileSync(CLINERULES_TEMPLATE_PATH, 'utf-8');
935
+ }
427
936
 
428
937
  for (const fileName of agent.instructionFiles) {
429
938
  const filePath = path.join(projectRoot, fileName);
@@ -433,14 +942,33 @@ Do not load skills that are not relevant to the current task.
433
942
  content = await fs.readFile(filePath, 'utf-8');
434
943
 
435
944
  const regex = new RegExp(`${markerStart}[\\s\\S]*?${markerEnd}`, 'g');
436
- if (regex.test(content)) {
945
+ const existingMatch = content.match(regex);
946
+ if (existingMatch) {
947
+ // AC #10 — upgrade-safety: detect hand-edits to the marker block and back up.
948
+ const existingBlock = existingMatch[0];
949
+ if (existingBlock !== wrappedInstruction) {
950
+ let proceedWithOverwrite = true;
951
+ try {
952
+ await handleMarkerBlockDrift({
953
+ filePath,
954
+ existingBlock,
955
+ expectedBlock: wrappedInstruction,
956
+ yesMode
957
+ });
958
+ } catch (declineErr) {
959
+ // User declined in interactive mode — leave file unchanged for this agent file.
960
+ proceedWithOverwrite = false;
961
+ console.log(chalk.gray(` Skipped ${fileName} (user declined marker-block overwrite)`));
962
+ }
963
+ if (!proceedWithOverwrite) continue;
964
+ }
437
965
  // Replace existing block in-place (AC #2)
438
- content = content.replace(regex, wrappedInstruction.trim());
966
+ content = content.replace(regex, wrappedInstruction);
439
967
  } else {
440
968
  // Top-insert: place after skipped headers (AC #1, #3)
441
969
  const strategy = agent.injectionStrategy || { position: 'top', skipPatterns: [] };
442
970
  const insertIdx = findInsertionPoint(content, strategy.skipPatterns);
443
- content = content.slice(0, insertIdx) + wrappedInstruction + '\n' + content.slice(insertIdx);
971
+ content = content.slice(0, insertIdx) + wrappedInstructionWithTrailingNewline + '\n' + content.slice(insertIdx);
444
972
  }
445
973
  } else if (agent.category === 'bmad') {
446
974
  // BMAD agent instruction files ARE the agent definitions (persona, menu, activation).
@@ -449,16 +977,41 @@ Do not load skills that are not relevant to the current task.
449
977
  // wiping the entire agent definition.
450
978
  console.log(chalk.gray(` Skipped ${fileName} (BMAD agent file not yet deployed)`));
451
979
  continue;
980
+ } else if (frameworkTemplate) {
981
+ // Story 21.5 AC #1/#2 — fresh Cline file: wrap the composed block in
982
+ // the Cline-flavored framing template (header paragraph + Architect-mode
983
+ // guidance line). The framing contains empty MA-AGENTS markers that we
984
+ // replace with the wrapped block. Cross-file byte-identity by construction
985
+ // is preserved because both `.cline/clinerules.md` and `.clinerules`
986
+ // receive the SAME rendered string in this single loop pass.
987
+ const emptyMarkerPair = new RegExp(`${markerStart}\\s*\\n\\s*${markerEnd}`);
988
+ let framed;
989
+ if (emptyMarkerPair.test(frameworkTemplate)) {
990
+ framed = frameworkTemplate.replace(emptyMarkerPair, wrappedInstruction);
991
+ } else {
992
+ // Defensive: framing shipped without markers — append block at EOF.
993
+ const trimmed = frameworkTemplate.replace(/\s+$/, '');
994
+ framed = trimmed + '\n\n' + wrappedInstruction + '\n';
995
+ }
996
+ content = framed.replace(/\s+$/, '') + '\n';
452
997
  } else {
453
998
  // New non-BMAD file: block is sole content (AC #1, #3)
454
- content = wrappedInstruction;
999
+ content = wrappedInstructionWithTrailingNewline;
455
1000
  }
456
1001
 
457
1002
  await fs.outputFile(filePath, content, 'utf-8');
458
1003
  console.log(chalk.cyan(` + Updated ${fileName}`));
459
1004
  }
1005
+
1006
+ // Story 21.4 — stamp any extraInstructionTemplates on non-JSON-merge agents
1007
+ // (forward-compat for Story 21.3 .roomodes and Story 21.5 .clinerules).
1008
+ await stampExtraInstructionTemplates(agent, projectRoot, { yesMode });
460
1009
  }
461
1010
 
1011
+ // Story 21.3's parallel applyExtraInstructionTemplates was consolidated during
1012
+ // rebase onto Story 21.4's canonical stampExtraInstructionTemplates dispatcher
1013
+ // (see above) — the yaml-customModes merger branch lives there now.
1014
+
462
1015
  /**
463
1016
  * Compare two semver strings. Returns -1, 0, or 1.
464
1017
  */
@@ -729,7 +1282,7 @@ async function installSkill(skillId, agentIds, customPath = '', scope = 'project
729
1282
  await generateSkillsManifest(installPath);
730
1283
  if (scope === 'project') {
731
1284
  for (const entry of agentEntries) {
732
- await updateAgentInstructions(entry.agent, process.cwd());
1285
+ await updateAgentInstructions(entry.agent, process.cwd(), { yesMode: yes });
733
1286
  }
734
1287
  }
735
1288
  continue;
@@ -775,7 +1328,12 @@ async function installSkill(skillId, agentIds, customPath = '', scope = 'project
775
1328
  await generateSkillsManifest(installPath);
776
1329
  if (scope === 'project') {
777
1330
  for (const entry of agentEntries) {
778
- await updateAgentInstructions(entry.agent, process.cwd());
1331
+ await updateAgentInstructions(entry.agent, process.cwd(), { yesMode: yes });
1332
+ // Story 21.4 — updateAgentInstructions now internally invokes
1333
+ // stampExtraInstructionTemplates, which handles per-agent extra
1334
+ // templates (e.g., Roo Code .roomodes via merger 'yaml-customModes',
1335
+ // OpenCode AGENTS.md via merger 'markdown-markers'). No sibling
1336
+ // call needed here.
779
1337
  }
780
1338
  // Deploy Claude Code hook when skills are installed for claude-code
781
1339
  if (includesClaudeCode(agentEntries)) {
@@ -1067,5 +1625,21 @@ module.exports = {
1067
1625
  updateProjectContextRepoLayout,
1068
1626
  _updateProjectContextManifestPaths: updateProjectContextManifestPaths,
1069
1627
  _testUpdateAgentInstructions: updateAgentInstructions,
1070
- _MA_AGENTS_SOURCE: MA_AGENTS_SOURCE
1628
+ _MA_AGENTS_SOURCE: MA_AGENTS_SOURCE,
1629
+ // Story 21.2 — universal instruction-block composer and helpers.
1630
+ composeInstructionBlock,
1631
+ buildBackupFilename,
1632
+ formatBackupTimestamp,
1633
+ UNIVERSAL_INSTRUCTION_TEMPLATE_PATH,
1634
+ ONPREM_INSTRUCTION_TEMPLATE_PATH,
1635
+ // Story 21.4 — AGENTS.md template, markdown-markers merger, extraInstructionTemplates processor.
1636
+ // Story 21.3 (rebased) — yaml-customModes merger dispatch is integrated into stampExtraInstructionTemplates.
1637
+ resolveBmadOutputDirs,
1638
+ markdownMarkersMerger,
1639
+ stampExtraInstructionTemplates,
1640
+ EXTRA_TEMPLATE_DIR,
1641
+ // Story 21.5 — Cline dual-file drift detection + framing template path.
1642
+ CLINERULES_TEMPLATE_PATH,
1643
+ ClinerulesDualFileDriftError,
1644
+ checkClinerulesDualFileDrift
1071
1645
  };