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.
- package/.ma-agents.json +10 -0
- package/AGENTS.md +97 -0
- package/MANIFEST.yaml +3 -0
- package/README.md +17 -0
- package/_bmad-output/implementation-artifacts/21-10-profile-reconfigure.md +30 -6
- package/_bmad-output/implementation-artifacts/21-11-profile-uninstall.md +2 -1
- package/_bmad-output/implementation-artifacts/21-2-universal-instruction-block-expansion.md +217 -62
- package/_bmad-output/implementation-artifacts/21-3-roomodes-template-bmad-modes.md +196 -73
- package/_bmad-output/implementation-artifacts/21-4-agents-md-template-opencode.md +242 -53
- package/_bmad-output/implementation-artifacts/21-5-clinerules-template-extension.md +180 -41
- package/_bmad-output/implementation-artifacts/21-6-onprem-layered-guardrails.md +250 -75
- package/_bmad-output/implementation-artifacts/21-7-bmad-persona-phase-prefix.md +221 -89
- package/_bmad-output/implementation-artifacts/21-8-vllm-reference-doc-readme.md +121 -63
- package/_bmad-output/implementation-artifacts/21-9-tests-validation.md +332 -61
- package/_bmad-output/implementation-artifacts/bug-bmad-recompile-fails-on-airgapped-network.md +112 -0
- package/_bmad-output/implementation-artifacts/sprint-status.yaml +3 -2
- package/bin/cli.js +59 -0
- package/docs/deployment/vllm-nemotron.md +130 -0
- package/lib/agents.js +17 -2
- package/lib/bmad-customize/bmm-analyst.customize.yaml +8 -0
- package/lib/bmad-customize/bmm-architect.customize.yaml +2 -0
- package/lib/bmad-customize/bmm-dev.customize.yaml +2 -0
- package/lib/bmad-customize/bmm-pm.customize.yaml +2 -0
- package/lib/bmad-customize/bmm-qa.customize.yaml +2 -0
- package/lib/bmad-customize/bmm-quick-flow-solo-dev.customize.yaml +8 -0
- package/lib/bmad-customize/bmm-sm.customize.yaml +2 -0
- package/lib/bmad-customize/bmm-tech-writer.customize.yaml +2 -0
- package/lib/bmad-customize/bmm-ux-designer.customize.yaml +2 -0
- package/lib/bmad.js +293 -1
- package/lib/installer.js +617 -43
- package/lib/merge/roomodes.js +125 -0
- package/lib/profile.js +25 -2
- package/lib/reconfigure.js +334 -0
- package/lib/templates/agents-md.template.md +67 -0
- package/lib/templates/clinerules.template.md +13 -0
- package/lib/templates/instruction-block-onprem.template.md +86 -0
- package/lib/templates/instruction-block-universal.template.md +29 -0
- package/lib/templates/roomodes.template.yaml +96 -0
- package/lib/uninstall.js +314 -0
- package/package.json +4 -3
- package/test/agents-md.test.js +398 -0
- package/test/bmad-extension.test.js +2 -2
- package/test/bmad-persona-phase-prefix.test.js +271 -0
- package/test/clinerules.test.js +339 -0
- package/test/instruction-block.test.js +388 -0
- package/test/integration-verification.test.js +2 -2
- package/test/migration-validation.test.js +2 -2
- package/test/offline-recompile.test.js +237 -0
- package/test/onprem-injection.test.js +425 -32
- package/test/onprem-layer.test.js +419 -0
- package/test/reconfigure.test.js +436 -0
- package/test/roomodes.test.js +343 -0
- package/test/uninstall.test.js +402 -0
- package/_bmad-output/methodology/BMAD_AI_Development_Training.pptx +0 -0
- package/_bmad-output/methodology/version.json +0 -7
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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) +
|
|
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 =
|
|
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
|
};
|