@soleri/forge 9.9.0 → 9.10.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/dist/agent-schema.d.ts +11 -0
- package/dist/agent-schema.js +8 -0
- package/dist/agent-schema.js.map +1 -1
- package/dist/compose-claude-md.d.ts +11 -0
- package/dist/compose-claude-md.js +104 -0
- package/dist/compose-claude-md.js.map +1 -1
- package/dist/lib.d.ts +2 -1
- package/dist/lib.js +1 -1
- package/dist/lib.js.map +1 -1
- package/dist/scaffold-filetree.js +4 -3
- package/dist/scaffold-filetree.js.map +1 -1
- package/dist/templates/inject-claude-md.js +50 -5
- package/dist/templates/inject-claude-md.js.map +1 -1
- package/dist/templates/section-parser.d.ts +33 -0
- package/dist/templates/section-parser.js +75 -0
- package/dist/templates/section-parser.js.map +1 -0
- package/dist/templates/shared-rules.d.ts +14 -0
- package/dist/templates/shared-rules.js +81 -11
- package/dist/templates/shared-rules.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/compose-claude-md.test.ts +89 -0
- package/src/__tests__/scaffold-filetree.test.ts +49 -0
- package/src/__tests__/shared-rules.test.ts +99 -1
- package/src/agent-schema.ts +8 -0
- package/src/compose-claude-md.ts +124 -0
- package/src/lib.ts +7 -1
- package/src/scaffold-filetree.ts +10 -3
- package/src/templates/inject-claude-md.ts +50 -5
- package/src/templates/section-parser.ts +97 -0
- package/src/templates/shared-rules.ts +90 -11
package/src/compose-claude-md.ts
CHANGED
|
@@ -11,6 +11,57 @@ import { parse as parseYaml } from 'yaml';
|
|
|
11
11
|
import { AgentYamlSchema, type AgentYaml } from './agent-schema.js';
|
|
12
12
|
import { ENGINE_MODULE_MANIFEST, CORE_KEY_OPS } from '@soleri/core/module-manifest';
|
|
13
13
|
|
|
14
|
+
// ─── User Custom Zone ────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
const USER_CUSTOM_OPEN = '<!-- user:custom -->';
|
|
17
|
+
const USER_CUSTOM_CLOSE = '<!-- /user:custom -->';
|
|
18
|
+
|
|
19
|
+
const DEFAULT_USER_CUSTOM_BLOCK = [
|
|
20
|
+
USER_CUSTOM_OPEN,
|
|
21
|
+
'<!-- Add your custom instructions here. This section survives regeneration. -->',
|
|
22
|
+
USER_CUSTOM_CLOSE,
|
|
23
|
+
].join('\n');
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Extract content between `<!-- user:custom -->` and `<!-- /user:custom -->` markers.
|
|
27
|
+
* Returns the full block (including markers) or null if not found.
|
|
28
|
+
*/
|
|
29
|
+
export function extractUserCustomZone(content: string): string | null {
|
|
30
|
+
const openIdx = content.indexOf(USER_CUSTOM_OPEN);
|
|
31
|
+
if (openIdx === -1) return null;
|
|
32
|
+
|
|
33
|
+
const closeIdx = content.indexOf(USER_CUSTOM_CLOSE, openIdx);
|
|
34
|
+
if (closeIdx === -1) return null;
|
|
35
|
+
|
|
36
|
+
return content.slice(openIdx, closeIdx + USER_CUSTOM_CLOSE.length);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Inject a user:custom zone into composed CLAUDE.md content.
|
|
41
|
+
* Replaces the existing user:custom block if present, otherwise inserts
|
|
42
|
+
* before the engine-rules-ref marker (or appends at the end).
|
|
43
|
+
*/
|
|
44
|
+
export function injectUserCustomZone(content: string, userZone: string): string {
|
|
45
|
+
// If the content already has a user:custom block, replace it
|
|
46
|
+
const existing = extractUserCustomZone(content);
|
|
47
|
+
if (existing) {
|
|
48
|
+
return content.replace(existing, userZone);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Insert before engine-rules-ref if present
|
|
52
|
+
const engineRefMarker = '<!-- soleri:engine-rules-ref -->';
|
|
53
|
+
const engineIdx = content.indexOf(engineRefMarker);
|
|
54
|
+
if (engineIdx !== -1) {
|
|
55
|
+
return content.slice(0, engineIdx) + userZone + '\n\n' + content.slice(engineIdx);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Fallback: append before the last newline
|
|
59
|
+
if (content.endsWith('\n')) {
|
|
60
|
+
return content.slice(0, -1) + '\n\n' + userZone + '\n';
|
|
61
|
+
}
|
|
62
|
+
return content + '\n\n' + userZone + '\n';
|
|
63
|
+
}
|
|
64
|
+
|
|
14
65
|
// ─── Types ────────────────────────────────────────────────────────────
|
|
15
66
|
|
|
16
67
|
export interface ComposedClaudeMd {
|
|
@@ -37,6 +88,15 @@ export interface ToolEntry {
|
|
|
37
88
|
export function composeClaudeMd(agentDir: string, tools?: ToolEntry[]): ComposedClaudeMd {
|
|
38
89
|
const sources: string[] = [];
|
|
39
90
|
|
|
91
|
+
// 0. Read existing CLAUDE.md to preserve user:custom zone
|
|
92
|
+
const existingClaudeMdPath = join(agentDir, 'CLAUDE.md');
|
|
93
|
+
let preservedUserZone: string | null = null;
|
|
94
|
+
let existingContent: string | null = null;
|
|
95
|
+
if (existsSync(existingClaudeMdPath)) {
|
|
96
|
+
existingContent = readFileSync(existingClaudeMdPath, 'utf-8');
|
|
97
|
+
preservedUserZone = extractUserCustomZone(existingContent);
|
|
98
|
+
}
|
|
99
|
+
|
|
40
100
|
// 1. Read agent.yaml
|
|
41
101
|
const agentYamlPath = join(agentDir, 'agent.yaml');
|
|
42
102
|
const agentYaml = AgentYamlSchema.parse(parseYaml(readFileSync(agentYamlPath, 'utf-8')));
|
|
@@ -128,10 +188,74 @@ export function composeClaudeMd(agentDir: string, tools?: ToolEntry[]): Composed
|
|
|
128
188
|
if (skillsSection) sections.push(skillsSection);
|
|
129
189
|
}
|
|
130
190
|
|
|
191
|
+
// 10. Inject user:custom zone (preserved from old file, or default empty block)
|
|
192
|
+
const userZone = preservedUserZone ?? DEFAULT_USER_CUSTOM_BLOCK;
|
|
193
|
+
sections.splice(findUserCustomInsertIndex(sections), 0, userZone);
|
|
194
|
+
|
|
131
195
|
const content = sections.join('\n\n') + '\n';
|
|
196
|
+
|
|
197
|
+
// 11. Detect orphaned content in old file (compare against newly composed content)
|
|
198
|
+
if (existingContent) {
|
|
199
|
+
detectOrphanedContent(existingContent, content);
|
|
200
|
+
}
|
|
201
|
+
|
|
132
202
|
return { content, sources };
|
|
133
203
|
}
|
|
134
204
|
|
|
205
|
+
// ─── User Custom Zone Helpers ────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Find the insertion index for the user:custom zone in the sections array.
|
|
209
|
+
* Target: after user instructions (instructions/user.md content), before engine-rules-ref.
|
|
210
|
+
*/
|
|
211
|
+
function findUserCustomInsertIndex(sections: string[]): number {
|
|
212
|
+
// Look for the engine-rules-ref marker
|
|
213
|
+
const engineRefIdx = sections.findIndex((s) => s.includes('<!-- soleri:engine-rules-ref -->'));
|
|
214
|
+
if (engineRefIdx !== -1) return engineRefIdx;
|
|
215
|
+
|
|
216
|
+
// Fallback: insert before the last section
|
|
217
|
+
return sections.length;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Detect content in existing CLAUDE.md that was manually added outside of
|
|
222
|
+
* the user:custom zone. Compares the old file's headings against a freshly
|
|
223
|
+
* composed version — any heading in the old file that doesn't appear in
|
|
224
|
+
* the new composition (and isn't inside user:custom) is orphaned.
|
|
225
|
+
*
|
|
226
|
+
* @param existingContent - The current CLAUDE.md content before regeneration
|
|
227
|
+
* @param newContent - The freshly composed CLAUDE.md content
|
|
228
|
+
*/
|
|
229
|
+
function detectOrphanedContent(existingContent: string, newContent: string): void {
|
|
230
|
+
// Extract headings from old file (excluding those inside user:custom zone)
|
|
231
|
+
const oldHeadings = extractHeadingsOutsideUserCustom(existingContent);
|
|
232
|
+
if (oldHeadings.length === 0) return;
|
|
233
|
+
|
|
234
|
+
// Extract headings from new composed content
|
|
235
|
+
const newHeadingSet = new Set(newContent.match(/^#{1,3} .+$/gm)?.map((h) => h.trim()) ?? []);
|
|
236
|
+
|
|
237
|
+
// Find headings in old that don't exist in new
|
|
238
|
+
const orphaned = oldHeadings.filter((h) => !newHeadingSet.has(h));
|
|
239
|
+
|
|
240
|
+
if (orphaned.length > 0) {
|
|
241
|
+
process.stderr.write(
|
|
242
|
+
'\u26a0 CLAUDE.md contains content outside managed sections. ' +
|
|
243
|
+
'Move to instructions/ or <!-- user:custom --> to preserve across regeneration.\n',
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Extract markdown headings from content, excluding any inside the user:custom zone.
|
|
250
|
+
*/
|
|
251
|
+
function extractHeadingsOutsideUserCustom(content: string): string[] {
|
|
252
|
+
// Remove user:custom zone content first
|
|
253
|
+
const userZone = extractUserCustomZone(content);
|
|
254
|
+
const cleaned = userZone ? content.replace(userZone, '') : content;
|
|
255
|
+
|
|
256
|
+
return cleaned.match(/^#{1,3} .+$/gm)?.map((h) => h.trim()) ?? [];
|
|
257
|
+
}
|
|
258
|
+
|
|
135
259
|
// ─── Section Composers ────────────────────────────────────────────────
|
|
136
260
|
|
|
137
261
|
function composeIdentityBlock(agent: AgentYaml): string {
|
package/src/lib.ts
CHANGED
|
@@ -34,7 +34,13 @@ export { composeClaudeMd } from './compose-claude-md.js';
|
|
|
34
34
|
export type { ComposedClaudeMd, ToolEntry } from './compose-claude-md.js';
|
|
35
35
|
export { generateExtensionsIndex, generateExampleOp } from './templates/extensions.js';
|
|
36
36
|
export { generateClaudeMdTemplate } from './templates/claude-md-template.js';
|
|
37
|
-
export {
|
|
37
|
+
export {
|
|
38
|
+
getEngineRulesContent,
|
|
39
|
+
getEngineMarker,
|
|
40
|
+
getModularEngineRules,
|
|
41
|
+
ENGINE_FEATURES,
|
|
42
|
+
} from './templates/shared-rules.js';
|
|
43
|
+
export type { EngineFeature } from './templates/shared-rules.js';
|
|
38
44
|
export { generateInjectClaudeMd } from './templates/inject-claude-md.js';
|
|
39
45
|
export { generateSkills } from './templates/skills.js';
|
|
40
46
|
export { generateTelegramBot } from './templates/telegram-bot.js';
|
package/src/scaffold-filetree.ts
CHANGED
|
@@ -13,7 +13,8 @@ import { fileURLToPath } from 'node:url';
|
|
|
13
13
|
import { stringify as yamlStringify } from 'yaml';
|
|
14
14
|
import type { AgentYaml, AgentYamlInput } from './agent-schema.js';
|
|
15
15
|
import { AgentYamlSchema } from './agent-schema.js';
|
|
16
|
-
import {
|
|
16
|
+
import { getModularEngineRules } from './templates/shared-rules.js';
|
|
17
|
+
import type { EngineFeature } from './templates/shared-rules.js';
|
|
17
18
|
import { composeClaudeMd } from './compose-claude-md.js';
|
|
18
19
|
import { generateSkills } from './templates/skills.js';
|
|
19
20
|
import type { AgentConfig } from './types.js';
|
|
@@ -567,8 +568,14 @@ export function scaffoldFileTree(input: AgentYamlInput, outputDir: string): File
|
|
|
567
568
|
filesCreated,
|
|
568
569
|
);
|
|
569
570
|
|
|
570
|
-
// ─── 5. Write engine rules
|
|
571
|
-
|
|
571
|
+
// ─── 5. Write engine rules (modular — respects engine.features) ─────
|
|
572
|
+
const engineFeatures = config.engine?.features as EngineFeature[] | undefined;
|
|
573
|
+
writeFile(
|
|
574
|
+
agentDir,
|
|
575
|
+
'instructions/_engine.md',
|
|
576
|
+
getModularEngineRules(engineFeatures),
|
|
577
|
+
filesCreated,
|
|
578
|
+
);
|
|
572
579
|
|
|
573
580
|
// ─── 6. Write user instruction files ────────────────────────
|
|
574
581
|
// Generate user.md — user-editable file with priority placement in CLAUDE.md
|
|
@@ -70,10 +70,15 @@ export function generateInjectClaudeMd(config: AgentConfig): string {
|
|
|
70
70
|
' * `<!-- soleri:engine-rules -->`. If already present, they are updated.',
|
|
71
71
|
' * Agent block (identity + facade table) is injected under',
|
|
72
72
|
` * \`<!-- ${marker} -->\`.`,
|
|
73
|
+
' *',
|
|
74
|
+
' * @param skipEngineRules - If true, skip engine rules injection (used for global files)',
|
|
73
75
|
' */',
|
|
74
|
-
'function injectIntoFile(filePath: string): InjectResult {',
|
|
75
|
-
' // Step 1: Engine rules — shared across all agents',
|
|
76
|
-
'
|
|
76
|
+
'function injectIntoFile(filePath: string, skipEngineRules = false): InjectResult {',
|
|
77
|
+
' // Step 1: Engine rules — shared across all agents (skip for global files)',
|
|
78
|
+
' let engineAction: string = "skipped";',
|
|
79
|
+
' if (!skipEngineRules) {',
|
|
80
|
+
' engineAction = injectBlock(filePath, getEngineRulesContent(), getEngineRulesMarker());',
|
|
81
|
+
' }',
|
|
77
82
|
'',
|
|
78
83
|
' // Step 2: Agent-specific block',
|
|
79
84
|
' const agentAction = injectBlock(filePath, getClaudeMdContent(), getClaudeMdMarker());',
|
|
@@ -99,13 +104,24 @@ export function generateInjectClaudeMd(config: AgentConfig): string {
|
|
|
99
104
|
' * Inject into the global ~/.claude/CLAUDE.md.',
|
|
100
105
|
" * Creates ~/.claude/ directory if it doesn't exist.",
|
|
101
106
|
' * This makes the activation phrase work in any project.',
|
|
107
|
+
' * Engine rules are NOT injected globally — they live in project-level CLAUDE.md only.',
|
|
102
108
|
' */',
|
|
103
109
|
'export function injectClaudeMdGlobal(): InjectResult {',
|
|
104
110
|
" const claudeDir = join(homedir(), '.claude');",
|
|
105
111
|
' if (!existsSync(claudeDir)) {',
|
|
106
112
|
' mkdirSync(claudeDir, { recursive: true });',
|
|
107
113
|
' }',
|
|
108
|
-
"
|
|
114
|
+
" const filePath = join(claudeDir, 'CLAUDE.md');",
|
|
115
|
+
'',
|
|
116
|
+
' // Add header comment for new files',
|
|
117
|
+
' if (!existsSync(filePath)) {',
|
|
118
|
+
" writeFileSync(filePath, '<!-- Global agent activation file. Engine rules live in project-level CLAUDE.md only. -->\\n', 'utf-8');",
|
|
119
|
+
' }',
|
|
120
|
+
'',
|
|
121
|
+
' // Self-heal: strip engine rules if they leaked into the global file',
|
|
122
|
+
' removeBlock(filePath, getEngineRulesMarker());',
|
|
123
|
+
'',
|
|
124
|
+
' return injectIntoFile(filePath, true);',
|
|
109
125
|
'}',
|
|
110
126
|
'',
|
|
111
127
|
'/**',
|
|
@@ -154,6 +170,24 @@ export function generateInjectClaudeMd(config: AgentConfig): string {
|
|
|
154
170
|
'}',
|
|
155
171
|
'',
|
|
156
172
|
'/**',
|
|
173
|
+
' * Remove engine rules from the global ~/.claude/CLAUDE.md.',
|
|
174
|
+
' * Self-healing: strips engine rules that should not be in the global file.',
|
|
175
|
+
' */',
|
|
176
|
+
'export function removeEngineRulesFromGlobal(): { removed: boolean; path: string } {',
|
|
177
|
+
" const filePath = join(homedir(), '.claude', 'CLAUDE.md');",
|
|
178
|
+
' return { removed: removeBlock(filePath, getEngineRulesMarker()), path: filePath };',
|
|
179
|
+
'}',
|
|
180
|
+
'',
|
|
181
|
+
'/**',
|
|
182
|
+
' * Remove engine rules from the global ~/.config/opencode/AGENTS.md.',
|
|
183
|
+
' * Self-healing: strips engine rules that should not be in the global file.',
|
|
184
|
+
' */',
|
|
185
|
+
'export function removeEngineRulesFromGlobalAgentsMd(): { removed: boolean; path: string } {',
|
|
186
|
+
" const filePath = join(homedir(), '.config', 'opencode', 'AGENTS.md');",
|
|
187
|
+
' return { removed: removeBlock(filePath, getEngineRulesMarker()), path: filePath };',
|
|
188
|
+
'}',
|
|
189
|
+
'',
|
|
190
|
+
'/**',
|
|
157
191
|
' * Check if the agent marker exists in a CLAUDE.md file.',
|
|
158
192
|
' */',
|
|
159
193
|
'export function hasAgentMarker(filePath: string): boolean {',
|
|
@@ -186,13 +220,24 @@ export function generateInjectClaudeMd(config: AgentConfig): string {
|
|
|
186
220
|
' * Inject into the global ~/.config/opencode/AGENTS.md.',
|
|
187
221
|
" * Creates ~/.config/opencode/ directory if it doesn't exist.",
|
|
188
222
|
' * This makes the activation phrase work in any OpenCode project.',
|
|
223
|
+
' * Engine rules are NOT injected globally — they live in project-level AGENTS.md only.',
|
|
189
224
|
' */',
|
|
190
225
|
'export function injectAgentsMdGlobal(): InjectResult {',
|
|
191
226
|
" const opencodeDir = join(homedir(), '.config', 'opencode');",
|
|
192
227
|
' if (!existsSync(opencodeDir)) {',
|
|
193
228
|
' mkdirSync(opencodeDir, { recursive: true });',
|
|
194
229
|
' }',
|
|
195
|
-
"
|
|
230
|
+
" const filePath = join(opencodeDir, 'AGENTS.md');",
|
|
231
|
+
'',
|
|
232
|
+
' // Add header comment for new files',
|
|
233
|
+
' if (!existsSync(filePath)) {',
|
|
234
|
+
" writeFileSync(filePath, '<!-- Global agent activation file. Engine rules live in project-level AGENTS.md only. -->\\n', 'utf-8');",
|
|
235
|
+
' }',
|
|
236
|
+
'',
|
|
237
|
+
' // Self-heal: strip engine rules if they leaked into the global file',
|
|
238
|
+
' removeBlock(filePath, getEngineRulesMarker());',
|
|
239
|
+
'',
|
|
240
|
+
' return injectIntoFile(filePath, true);',
|
|
196
241
|
'}',
|
|
197
242
|
'',
|
|
198
243
|
'/**',
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Section parser for marker-delimited engine rules content.
|
|
3
|
+
*
|
|
4
|
+
* Extracts `<!-- soleri:xxx -->` sections from the engine rules markdown,
|
|
5
|
+
* enabling selective inclusion of feature modules.
|
|
6
|
+
*
|
|
7
|
+
* Single-pass: splits on `## ` headings, maps chunks to markers, filters.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface ParsedSection {
|
|
11
|
+
/** e.g. 'soleri:response-integrity' */
|
|
12
|
+
marker: string;
|
|
13
|
+
/** Full text including heading and marker comment */
|
|
14
|
+
content: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ParsedContent {
|
|
18
|
+
/** Everything before the first section */
|
|
19
|
+
preamble: string;
|
|
20
|
+
/** Ordered list of parsed sections */
|
|
21
|
+
sections: ParsedSection[];
|
|
22
|
+
/** Closing marker line and anything after */
|
|
23
|
+
closing: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const SECTION_MARKER_RE = /<!-- (soleri:[a-z-]+) -->/;
|
|
27
|
+
const CLOSING_MARKER = '<!-- /soleri:engine-rules -->';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parse marker-delimited sections from engine rules content.
|
|
31
|
+
*
|
|
32
|
+
* Strategy: split on `## ` heading boundaries, then classify each chunk
|
|
33
|
+
* as preamble, section (has a marker), or closing (has closing marker).
|
|
34
|
+
*/
|
|
35
|
+
export function parseSections(content: string): ParsedContent {
|
|
36
|
+
// Split at each `## ` heading — lookahead preserves the heading in the chunk
|
|
37
|
+
const chunks = content.split(/(?=^## )/m);
|
|
38
|
+
|
|
39
|
+
let preamble = '';
|
|
40
|
+
const sections: ParsedSection[] = [];
|
|
41
|
+
let closing = '';
|
|
42
|
+
let foundFirstSection = false;
|
|
43
|
+
|
|
44
|
+
for (const chunk of chunks) {
|
|
45
|
+
// Check if this chunk contains the closing marker
|
|
46
|
+
const closingIdx = chunk.indexOf(CLOSING_MARKER);
|
|
47
|
+
if (closingIdx !== -1) {
|
|
48
|
+
// Content before closing marker belongs to last section or preamble
|
|
49
|
+
const beforeClosing = chunk.slice(0, closingIdx);
|
|
50
|
+
const afterClosing = chunk.slice(closingIdx);
|
|
51
|
+
|
|
52
|
+
if (beforeClosing.trim()) {
|
|
53
|
+
const markerMatch = beforeClosing.match(SECTION_MARKER_RE);
|
|
54
|
+
if (markerMatch && markerMatch[1] !== 'soleri:engine-rules') {
|
|
55
|
+
sections.push({ marker: markerMatch[1], content: beforeClosing });
|
|
56
|
+
foundFirstSection = true;
|
|
57
|
+
} else if (!foundFirstSection) {
|
|
58
|
+
preamble += beforeClosing;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
closing = afterClosing;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Check if chunk has a section marker
|
|
66
|
+
const markerMatch = chunk.match(SECTION_MARKER_RE);
|
|
67
|
+
if (markerMatch && markerMatch[1] !== 'soleri:engine-rules') {
|
|
68
|
+
sections.push({ marker: markerMatch[1], content: chunk });
|
|
69
|
+
foundFirstSection = true;
|
|
70
|
+
} else {
|
|
71
|
+
// No marker — this is preamble (before first section)
|
|
72
|
+
if (!foundFirstSection) {
|
|
73
|
+
preamble += chunk;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { preamble, sections, closing };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Rebuild content from parsed sections, including only allowed markers.
|
|
83
|
+
*/
|
|
84
|
+
export function filterSections(parsed: ParsedContent, allowedMarkers: Set<string>): string {
|
|
85
|
+
const parts: string[] = [parsed.preamble];
|
|
86
|
+
|
|
87
|
+
for (const section of parsed.sections) {
|
|
88
|
+
if (allowedMarkers.has(section.marker)) {
|
|
89
|
+
let text = section.content;
|
|
90
|
+
if (!text.endsWith('\n')) text += '\n';
|
|
91
|
+
parts.push(text);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
parts.push(parsed.closing);
|
|
96
|
+
return parts.join('\n');
|
|
97
|
+
}
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* Uses op:name syntax — the active agent provides the tool prefix.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { parseSections, filterSections } from './section-parser.js';
|
|
13
|
+
|
|
12
14
|
const ENGINE_MARKER = 'soleri:engine-rules';
|
|
13
15
|
|
|
14
16
|
export function getEngineMarker(): string {
|
|
@@ -20,6 +22,83 @@ export function getEngineRulesContent(): string {
|
|
|
20
22
|
return ENGINE_RULES_LINES.join('\n');
|
|
21
23
|
}
|
|
22
24
|
|
|
25
|
+
// ─── Modular Engine Rules ────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/** Feature modules that can be selectively included in engine rules. */
|
|
28
|
+
export type EngineFeature = 'vault' | 'planning' | 'brain' | 'advanced';
|
|
29
|
+
|
|
30
|
+
/** All available feature modules. */
|
|
31
|
+
export const ENGINE_FEATURES: readonly EngineFeature[] = [
|
|
32
|
+
'vault',
|
|
33
|
+
'planning',
|
|
34
|
+
'brain',
|
|
35
|
+
'advanced',
|
|
36
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Section markers grouped by module.
|
|
40
|
+
*
|
|
41
|
+
* 'core' sections are always included.
|
|
42
|
+
* Feature modules are included only when requested (or when no filter is specified).
|
|
43
|
+
*/
|
|
44
|
+
const MODULE_SECTIONS: Record<'core' | EngineFeature, readonly string[]> = {
|
|
45
|
+
core: [
|
|
46
|
+
'soleri:what-is-soleri',
|
|
47
|
+
'soleri:response-integrity',
|
|
48
|
+
'soleri:tool-schema-validation',
|
|
49
|
+
'soleri:memory-quality',
|
|
50
|
+
'soleri:output-formatting',
|
|
51
|
+
'soleri:clean-commits',
|
|
52
|
+
'soleri:intent-detection',
|
|
53
|
+
'soleri:overlay-mode',
|
|
54
|
+
'soleri:session',
|
|
55
|
+
'soleri:getting-started',
|
|
56
|
+
'soleri:cli',
|
|
57
|
+
'soleri:persona-self-update',
|
|
58
|
+
'soleri:workspace-routing',
|
|
59
|
+
],
|
|
60
|
+
vault: [
|
|
61
|
+
'soleri:vault-protocol',
|
|
62
|
+
'soleri:knowledge-capture',
|
|
63
|
+
'soleri:tool-advocacy',
|
|
64
|
+
'soleri:cross-project',
|
|
65
|
+
],
|
|
66
|
+
planning: [
|
|
67
|
+
'soleri:planning',
|
|
68
|
+
'soleri:workflow-overrides',
|
|
69
|
+
'soleri:yolo-mode',
|
|
70
|
+
'soleri:task-routing',
|
|
71
|
+
'soleri:validation-loop',
|
|
72
|
+
'soleri:verification-protocol',
|
|
73
|
+
],
|
|
74
|
+
brain: ['soleri:brain', 'soleri:model-routing'],
|
|
75
|
+
advanced: ['soleri:subagent-identity'],
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Returns engine rules with only selected feature modules included.
|
|
80
|
+
*
|
|
81
|
+
* Core rules are ALWAYS included. Feature modules are included when:
|
|
82
|
+
* - `features` is undefined/empty → ALL modules included (backward compatible)
|
|
83
|
+
* - `features` is specified → only listed modules + core
|
|
84
|
+
*
|
|
85
|
+
* @param features - Feature modules to include. Omit for all.
|
|
86
|
+
*/
|
|
87
|
+
export function getModularEngineRules(features?: EngineFeature[]): string {
|
|
88
|
+
if (!features || features.length === 0) {
|
|
89
|
+
return getEngineRulesContent();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const allowedMarkers = new Set<string>(MODULE_SECTIONS.core);
|
|
93
|
+
for (const feature of features) {
|
|
94
|
+
const sections = MODULE_SECTIONS[feature];
|
|
95
|
+
if (sections) for (const m of sections) allowedMarkers.add(m);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const parsed = parseSections(getEngineRulesContent());
|
|
99
|
+
return filterSections(parsed, allowedMarkers);
|
|
100
|
+
}
|
|
101
|
+
|
|
23
102
|
const ENGINE_RULES_LINES: string[] = [
|
|
24
103
|
`<!-- ${ENGINE_MARKER} -->`,
|
|
25
104
|
'',
|
|
@@ -683,28 +762,28 @@ const ENGINE_RULES_LINES: string[] = [
|
|
|
683
762
|
'',
|
|
684
763
|
'### How Your CLAUDE.md is Built',
|
|
685
764
|
'',
|
|
686
|
-
'Your CLAUDE.md is **auto-generated** — never edit it manually
|
|
687
|
-
'',
|
|
688
|
-
'| Trigger | How |',
|
|
689
|
-
'|---------|-----|',
|
|
690
|
-
'| `soleri dev` | Hot-reloads and regenerates on file changes |',
|
|
691
|
-
'| `soleri agent refresh` | Explicitly regenerates from latest templates |',
|
|
692
|
-
'| `soleri agent update` | After engine update, regenerates to pick up new rules |',
|
|
693
|
-
'| Scaffold (`create-soleri`) | Generates initial CLAUDE.md for new agents |',
|
|
765
|
+
'Your CLAUDE.md is **auto-generated** — never edit it manually (except inside `<!-- user:custom -->` markers). Regenerated by `soleri dev`, `soleri agent refresh`, `soleri agent update`, and on scaffold.',
|
|
694
766
|
'',
|
|
695
767
|
'The composition pipeline assembles CLAUDE.md from:',
|
|
696
768
|
'',
|
|
697
769
|
'1. **Agent identity** — from `agent.yaml`',
|
|
698
770
|
'2. **Custom instructions** — from `instructions/user.md` (priority placement, before engine rules)',
|
|
699
|
-
'3. **Engine rules** —
|
|
771
|
+
'3. **Engine rules** — modular, controlled by `engine.features` in `agent.yaml`',
|
|
700
772
|
'4. **User instructions** — from `instructions/*.md` (alphabetically sorted, excluding `user.md` and `_engine.md`)',
|
|
701
773
|
'5. **Tools table** — from engine registration',
|
|
702
774
|
'6. **Workflow index** — from `workflows/`',
|
|
703
775
|
'7. **Skills index** — from `skills/`',
|
|
704
776
|
'',
|
|
705
|
-
'`
|
|
777
|
+
'**Modular engine rules:** The `engine.features` array in `agent.yaml` controls which rule modules are included. Available: `vault`, `planning`, `brain`, `advanced`. Core rules are always included. Default (no features specified) = all modules.',
|
|
778
|
+
'',
|
|
779
|
+
'### What Survives Regeneration',
|
|
706
780
|
'',
|
|
707
|
-
'
|
|
781
|
+
'| Source | Survives? |',
|
|
782
|
+
'|--------|-----------|',
|
|
783
|
+
'| `instructions/*.md` | Yes — re-read on every regen |',
|
|
784
|
+
'| `<!-- user:custom -->` zone in CLAUDE.md | Yes — extracted and re-injected |',
|
|
785
|
+
'| `agent.yaml` | Drives regen (source of truth) |',
|
|
786
|
+
'| Manual CLAUDE.md edits outside markers | No — overwritten, warning logged |',
|
|
708
787
|
'',
|
|
709
788
|
'### System Requirements',
|
|
710
789
|
'',
|