@aiwg/cli 2026.8.0 → 2026.8.2
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/README.md +33 -0
- package/agentic/code/providers/capability-matrix.yaml +511 -0
- package/agentic/code/providers/model-capabilities.v1.json +120 -0
- package/agentic/code/providers/model-catalog.v1.json +96 -0
- package/agentic/code/providers/model-policy-evaluations.v1.json +50 -0
- package/agentic/code/providers/premium-model-allowlist.v1.json +36 -0
- package/bin/aiwg.mjs +14 -10
- package/dist/src/api/index.d.ts +1 -0
- package/dist/src/api/index.js +1 -0
- package/dist/src/artifacts/cli.js +2 -0
- package/dist/src/artifacts/types.js +4 -0
- package/dist/src/auth/client.js +209 -0
- package/dist/src/auth/config.js +38 -0
- package/dist/src/auth/credential-store.js +141 -0
- package/dist/src/auth/resource-credentials.js +25 -0
- package/dist/src/auth/types.js +2 -0
- package/dist/src/channel/manager.mjs +5 -5
- package/dist/src/cli/handlers/auth.js +125 -0
- package/dist/src/cli/handlers/help.js +1 -0
- package/dist/src/cli/handlers/index.js +3 -1
- package/dist/src/cli/handlers/install.js +42 -4
- package/dist/src/cli/handlers/marketplace.js +375 -122
- package/dist/src/cli/handlers/resource-versions.js +2 -0
- package/dist/src/cli/handlers/sessions.js +23 -5
- package/dist/src/cli/handlers/subcommands.js +10 -1
- package/dist/src/cli/handlers/use.js +342 -43
- package/dist/src/config/gitignore.js +1 -0
- package/dist/src/extensions/commands/definitions.js +19 -0
- package/dist/src/marketplace/exchange.js +602 -0
- package/dist/src/marketplace/provenance-types.js +19 -0
- package/dist/src/marketplace/provenance.js +834 -0
- package/dist/src/memory/canonical-context.js +342 -0
- package/dist/src/memory/context-pack.js +282 -0
- package/dist/src/memory/index.js +4 -0
- package/dist/src/memory/intake.js +118 -0
- package/dist/src/packages/adapters/git.js +79 -29
- package/dist/src/packages/package-discovery.js +81 -0
- package/dist/src/packages/package-registry.js +2 -0
- package/dist/src/packages/registry.js +119 -20
- package/dist/src/resources/resolver.js +1 -0
- package/dist/src/resources/web-release.d.ts +3 -1
- package/dist/src/resources/web-release.js +14 -6
- package/dist/src/serve/agentic-sandbox-fleet-client.js +213 -0
- package/dist/src/serve/fleet-mission-conductor.js +293 -0
- package/dist/src/sessions/index.js +1 -0
- package/dist/src/sessions/output-registration.js +338 -0
- package/dist/src/sessions/promotion.js +73 -2
- package/dist/src/sessions/repository.js +2 -1
- package/dist/src/update/notifier.mjs +13 -2
- package/package.json +8 -1
- package/tools/_resolve-impl.mjs +74 -0
- package/tools/agents/deploy-agents.mjs +962 -0
- package/tools/agents/providers/base.mjs +2954 -0
- package/tools/agents/providers/claude.mjs +711 -0
- package/tools/agents/providers/codex.mjs +699 -0
- package/tools/agents/providers/copilot.mjs +659 -0
- package/tools/agents/providers/cursor.mjs +714 -0
- package/tools/agents/providers/factory.mjs +1130 -0
- package/tools/agents/providers/hermes.mjs +663 -0
- package/tools/agents/providers/hook-capabilities.mjs +85 -0
- package/tools/agents/providers/model-role.mjs +56 -0
- package/tools/agents/providers/openclaw-translator.mjs +348 -0
- package/tools/agents/providers/openclaw.mjs +680 -0
- package/tools/agents/providers/opencode.mjs +675 -0
- package/tools/agents/providers/openhuman.mjs +292 -0
- package/tools/agents/providers/warp.mjs +413 -0
- package/tools/agents/providers/windsurf.mjs +748 -0
- package/tools/commands/deploy-prompts-codex.mjs +336 -0
- package/tools/plugin/package-plugins.mjs +1013 -0
- package/tools/skills/deploy-skills-codex.mjs +571 -0
|
@@ -0,0 +1,2954 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utilities for provider modules
|
|
3
|
+
*
|
|
4
|
+
* This module contains common functions used across all providers:
|
|
5
|
+
* - File operations (ensureDir, listMdFiles, writeFile, etc.)
|
|
6
|
+
* - Model configuration loading
|
|
7
|
+
* - Frontmatter parsing
|
|
8
|
+
* - Other shared utilities
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import realFs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import os from 'os';
|
|
14
|
+
import { classifyModelRole } from './model-role.mjs';
|
|
15
|
+
import { createHash } from 'crypto';
|
|
16
|
+
import { createRequire } from 'module';
|
|
17
|
+
import { execSync as nodeExecSync } from 'child_process';
|
|
18
|
+
|
|
19
|
+
// Use graceful-fs to prevent EMFILE crashes on systems with low ulimit.
|
|
20
|
+
// graceful-fs queues open() calls when FD pressure is detected and retries
|
|
21
|
+
// after a backoff, transparently wrapping the native fs module.
|
|
22
|
+
let fs;
|
|
23
|
+
try {
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const gracefulFs = require('graceful-fs');
|
|
26
|
+
gracefulFs.gracefulify(realFs);
|
|
27
|
+
fs = realFs;
|
|
28
|
+
} catch {
|
|
29
|
+
// graceful-fs not available — fall back to native fs
|
|
30
|
+
fs = realFs;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ============================================================================
|
|
34
|
+
// File Operations
|
|
35
|
+
// ============================================================================
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Ensure a directory exists, creating it recursively if needed
|
|
39
|
+
* @param {string} d - Directory path
|
|
40
|
+
* @param {boolean} dryRun - If true, skip actual directory creation
|
|
41
|
+
*/
|
|
42
|
+
export function ensureDir(d, dryRun = false) {
|
|
43
|
+
if (dryRun) return;
|
|
44
|
+
if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* List markdown files in a directory (non-recursive)
|
|
49
|
+
*/
|
|
50
|
+
export function listMdFiles(dir, excludePatterns = []) {
|
|
51
|
+
if (!fs.existsSync(dir)) return [];
|
|
52
|
+
const defaultExcluded = ['README.md', 'manifest.md', 'agent-template.md', 'openai-compat.md', 'factory-compat.md', 'windsurf-compat.md', 'DEVELOPMENT_GUIDE.md'];
|
|
53
|
+
const excluded = [...defaultExcluded, ...excludePatterns];
|
|
54
|
+
return fs
|
|
55
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
56
|
+
.filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.md') && !e.name.toLowerCase().endsWith('.soul.md') && !excluded.includes(e.name))
|
|
57
|
+
.map((e) => path.join(dir, e.name));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* List .soul.md companion files in a directory (non-recursive)
|
|
62
|
+
*/
|
|
63
|
+
export function listSoulFiles(dir) {
|
|
64
|
+
if (!fs.existsSync(dir)) return [];
|
|
65
|
+
return fs
|
|
66
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
67
|
+
.filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.soul.md'))
|
|
68
|
+
.map((e) => path.join(dir, e.name));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* List markdown files recursively
|
|
73
|
+
*/
|
|
74
|
+
export function listMdFilesRecursive(dir, excludePatterns = []) {
|
|
75
|
+
if (!fs.existsSync(dir)) return [];
|
|
76
|
+
const defaultExcluded = ['README.md', 'manifest.md', 'agent-template.md', 'openai-compat.md', 'factory-compat.md', 'windsurf-compat.md', 'DEVELOPMENT_GUIDE.md'];
|
|
77
|
+
const excluded = [...defaultExcluded, ...excludePatterns];
|
|
78
|
+
const results = [];
|
|
79
|
+
|
|
80
|
+
function scan(currentDir) {
|
|
81
|
+
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
82
|
+
for (const entry of entries) {
|
|
83
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
84
|
+
if (entry.isDirectory() && entry.name !== 'templates') {
|
|
85
|
+
scan(fullPath);
|
|
86
|
+
} else if (entry.isFile() && entry.name.toLowerCase().endsWith('.md') && !excluded.includes(entry.name)) {
|
|
87
|
+
results.push(fullPath);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
scan(dir);
|
|
93
|
+
return results;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* List skill directories (directories containing SKILL.md)
|
|
98
|
+
*/
|
|
99
|
+
export function listSkillDirs(dir) {
|
|
100
|
+
if (!fs.existsSync(dir)) return [];
|
|
101
|
+
return fs
|
|
102
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
103
|
+
.filter((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, 'SKILL.md')))
|
|
104
|
+
.map((e) => path.join(dir, e.name));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Write a file (with dry-run support)
|
|
109
|
+
*/
|
|
110
|
+
export function writeFile(dest, data, dryRun) {
|
|
111
|
+
if (dryRun) {
|
|
112
|
+
console.log(`[dry-run] write ${dest}`);
|
|
113
|
+
} else {
|
|
114
|
+
fs.writeFileSync(dest, data, 'utf8');
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ============================================================================
|
|
119
|
+
// Deployment Manifest (File Ownership Tagging)
|
|
120
|
+
// ============================================================================
|
|
121
|
+
|
|
122
|
+
// Match either form so legacy-marker files are recognized as already-managed:
|
|
123
|
+
// <!-- aiwg:managed v... ... (legacy, line 1, breaks YAML frontmatter parsing)
|
|
124
|
+
// # aiwg:managed v... ... (current, inside frontmatter as a YAML comment)
|
|
125
|
+
const MANAGED_MARKER_RE = /^(?:<!-- aiwg:managed |# aiwg:managed )/m;
|
|
126
|
+
const MANIFEST_FILENAME = '.aiwg-manifest.json';
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Add an `aiwg:managed vVERSION SOURCE` marker to deployed markdown content.
|
|
130
|
+
*
|
|
131
|
+
* For files with YAML frontmatter (start with `---\n`), inject the marker as
|
|
132
|
+
* a YAML comment INSIDE the frontmatter. This keeps `---` on line 1, which
|
|
133
|
+
* Claude Code (and other YAML frontmatter parsers) require to discover
|
|
134
|
+
* agents/skills/commands. Issue #1059.
|
|
135
|
+
*
|
|
136
|
+
* For files without frontmatter, fall back to the legacy HTML-comment-at-top
|
|
137
|
+
* form (no parser to break).
|
|
138
|
+
*
|
|
139
|
+
* Idempotent — skips if either form of the marker is already present.
|
|
140
|
+
*/
|
|
141
|
+
export function addManagedMarker(content, version, source) {
|
|
142
|
+
if (MANAGED_MARKER_RE.test(content)) return content;
|
|
143
|
+
// Frontmatter present → inject as YAML comment after the opening `---\n`.
|
|
144
|
+
if (content.startsWith('---\n')) {
|
|
145
|
+
return content.replace(
|
|
146
|
+
/^---\n/,
|
|
147
|
+
`---\n# aiwg:managed v${version} ${source}\n`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
// No frontmatter → legacy HTML-comment-at-top form is safe.
|
|
151
|
+
return `<!-- aiwg:managed v${version} ${source} -->\n${content}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Compute SHA-256 hash of content (hex string).
|
|
156
|
+
*/
|
|
157
|
+
function contentHash(content) {
|
|
158
|
+
return createHash('sha256').update(content).digest('hex');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Read existing sidecar manifest from a deployment directory.
|
|
163
|
+
* Returns `{ managed: { [filename]: { hash, source, version } } }` or null.
|
|
164
|
+
*/
|
|
165
|
+
export function readSidecarManifest(dir) {
|
|
166
|
+
const p = path.join(dir, MANIFEST_FILENAME);
|
|
167
|
+
try {
|
|
168
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Write sidecar manifest to a deployment directory.
|
|
176
|
+
*/
|
|
177
|
+
export function writeSidecarManifest(dir, manifest, dryRun) {
|
|
178
|
+
if (dryRun) return;
|
|
179
|
+
const p = path.join(dir, MANIFEST_FILENAME);
|
|
180
|
+
fs.writeFileSync(p, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Update sidecar manifest entries for a batch of deployed files.
|
|
185
|
+
* Merges into existing manifest if present.
|
|
186
|
+
*
|
|
187
|
+
* `frameworkSlug` (optional, per-entry) records which AIWG framework
|
|
188
|
+
* the file came from (e.g., 'forensics-complete', 'sdlc-complete').
|
|
189
|
+
* Used by the cross-framework collision guard (#1169) to detect
|
|
190
|
+
* silent overwrites when two frameworks ship a file with the same
|
|
191
|
+
* filename but different content.
|
|
192
|
+
*/
|
|
193
|
+
export function updateSidecarManifest(dir, deployedEntries, opts) {
|
|
194
|
+
const { dryRun = false, version = 'unknown', source = 'bundled' } = opts;
|
|
195
|
+
const existing = readSidecarManifest(dir) || { managed: {} };
|
|
196
|
+
|
|
197
|
+
for (const entry of deployedEntries) {
|
|
198
|
+
const { filename, hash, frameworkSlug } = entry;
|
|
199
|
+
const sidecarEntry = { hash: `sha256:${hash}`, source, version };
|
|
200
|
+
if (frameworkSlug) sidecarEntry.frameworkSlug = frameworkSlug;
|
|
201
|
+
existing.managed[filename] = sidecarEntry;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
writeSidecarManifest(dir, existing, dryRun);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ============================================================================
|
|
208
|
+
// Cross-Framework Collision Detection (#1169)
|
|
209
|
+
// ============================================================================
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Extract the framework slug from a source file path.
|
|
213
|
+
*
|
|
214
|
+
* Recognizes paths under `agentic/code/frameworks/<slug>/...` and
|
|
215
|
+
* `agentic/code/addons/<slug>/...`. Returns null for paths outside
|
|
216
|
+
* those namespaces (operator-authored bundles, addons under
|
|
217
|
+
* `.aiwg/addons/`, etc.) — those don't get collision-tracked.
|
|
218
|
+
*/
|
|
219
|
+
export function extractFrameworkSlug(srcPath) {
|
|
220
|
+
if (typeof srcPath !== 'string') return null;
|
|
221
|
+
// Normalize separators for cross-platform matching
|
|
222
|
+
const normalized = srcPath.replace(/\\/g, '/');
|
|
223
|
+
const m = normalized.match(/agentic\/code\/(?:frameworks|addons)\/([^/]+)\//);
|
|
224
|
+
return m ? m[1] : null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ============================================================================
|
|
228
|
+
// Model Configuration
|
|
229
|
+
// ============================================================================
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Load model configuration from models.json
|
|
233
|
+
* Priority: Project models.json > User ~/.config/aiwg/models.json > AIWG defaults
|
|
234
|
+
*/
|
|
235
|
+
export function loadModelConfig(srcRoot) {
|
|
236
|
+
const locations = [
|
|
237
|
+
{ path: path.join(process.cwd(), 'models.json'), label: 'project' },
|
|
238
|
+
{ path: path.join(process.env.HOME || process.env.USERPROFILE, '.config', 'aiwg', 'models.json'), label: 'user' },
|
|
239
|
+
{ path: path.join(srcRoot, 'agentic', 'code', 'frameworks', 'sdlc-complete', 'config', 'models.json'), label: 'AIWG defaults' }
|
|
240
|
+
];
|
|
241
|
+
|
|
242
|
+
for (const loc of locations) {
|
|
243
|
+
if (fs.existsSync(loc.path)) {
|
|
244
|
+
try {
|
|
245
|
+
const config = JSON.parse(fs.readFileSync(loc.path, 'utf8'));
|
|
246
|
+
config._source = `${loc.label} (${loc.path})`;
|
|
247
|
+
return config;
|
|
248
|
+
} catch (err) {
|
|
249
|
+
console.warn(`Warning: Could not parse models.json at ${loc.path}: ${err.message}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Fallback to hardcoded defaults if no config found
|
|
255
|
+
return {
|
|
256
|
+
claude: {
|
|
257
|
+
reasoning: { model: 'opus' },
|
|
258
|
+
coding: { model: 'sonnet' },
|
|
259
|
+
efficiency: { model: 'haiku' }
|
|
260
|
+
},
|
|
261
|
+
factory: {
|
|
262
|
+
reasoning: { model: 'heavy' },
|
|
263
|
+
coding: { model: 'medium' },
|
|
264
|
+
efficiency: { model: 'light' }
|
|
265
|
+
},
|
|
266
|
+
shorthand: {
|
|
267
|
+
'opus': 'claude-opus-4-6',
|
|
268
|
+
'sonnet': 'claude-sonnet-4-6',
|
|
269
|
+
'haiku': 'claude-haiku-4-5-20251001',
|
|
270
|
+
'inherit': 'inherit'
|
|
271
|
+
},
|
|
272
|
+
claude_shorthand: {
|
|
273
|
+
'opus': 'opus',
|
|
274
|
+
'opus-1m': 'opus[1m]',
|
|
275
|
+
'opus[1m]': 'opus[1m]',
|
|
276
|
+
'sonnet': 'sonnet',
|
|
277
|
+
'sonnet-1m': 'sonnet[1m]',
|
|
278
|
+
'sonnet[1m]': 'sonnet[1m]',
|
|
279
|
+
'haiku': 'haiku',
|
|
280
|
+
'inherit': 'inherit'
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Load a fresh dynamically-discovered model catalog when available, otherwise
|
|
287
|
+
* use the committed catalog supplied by the caller. Refresh is performed by
|
|
288
|
+
* `aiwg models refresh`; deployment itself never performs network access.
|
|
289
|
+
*/
|
|
290
|
+
export function loadRuntimeModelCatalog(staticCatalog, options = {}) {
|
|
291
|
+
if (process.env.VITEST && !options.cacheFile && !options.homeDir) return staticCatalog;
|
|
292
|
+
const homeDir = options.homeDir || os.homedir();
|
|
293
|
+
const cacheFile = options.cacheFile || path.join(homeDir, '.cache', 'aiwg', 'model-catalog.v1.json');
|
|
294
|
+
const ttlMs = options.ttlMs ?? 24 * 60 * 60 * 1000;
|
|
295
|
+
try {
|
|
296
|
+
const cached = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
|
|
297
|
+
const fetchedAt = Date.parse(cached?.discovery?.fetchedAt || '');
|
|
298
|
+
const age = Date.now() - fetchedAt;
|
|
299
|
+
if (
|
|
300
|
+
cached?.providers &&
|
|
301
|
+
Number.isFinite(age) &&
|
|
302
|
+
age >= 0 &&
|
|
303
|
+
age <= ttlMs
|
|
304
|
+
) {
|
|
305
|
+
return cached;
|
|
306
|
+
}
|
|
307
|
+
} catch {
|
|
308
|
+
// Missing, stale, or malformed cache: deterministic static fallback.
|
|
309
|
+
}
|
|
310
|
+
return staticCatalog;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ============================================================================
|
|
314
|
+
// Frontmatter Utilities
|
|
315
|
+
// ============================================================================
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Maps provider names to the platform identifiers used in skill platforms: fields.
|
|
319
|
+
* Skills use descriptive names (e.g. "claude-code") while providers use short names (e.g. "claude").
|
|
320
|
+
*/
|
|
321
|
+
const PROVIDER_TO_PLATFORM = {
|
|
322
|
+
'claude': 'claude-code'
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Parse the platforms: field from a SKILL.md frontmatter block.
|
|
327
|
+
* Handles both inline array (platforms: [a, b]) and multi-line list formats.
|
|
328
|
+
* Returns null if no platforms field is present (= deploy to all providers).
|
|
329
|
+
* Returns an empty array only if the field is explicitly empty.
|
|
330
|
+
*/
|
|
331
|
+
export function parseSkillPlatforms(content) {
|
|
332
|
+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
333
|
+
if (!fmMatch) return null;
|
|
334
|
+
|
|
335
|
+
const fm = fmMatch[1];
|
|
336
|
+
|
|
337
|
+
// Inline array: platforms: [claude-code, codex] or platforms: [all]
|
|
338
|
+
const inlineMatch = fm.match(/^platforms:\s*\[([^\]]*)\]/m);
|
|
339
|
+
if (inlineMatch) {
|
|
340
|
+
const items = inlineMatch[1].split(',').map(s => s.trim()).filter(Boolean);
|
|
341
|
+
if (items.length === 0 || (items.length === 1 && items[0] === 'all')) return null;
|
|
342
|
+
return items;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Multi-line list:
|
|
346
|
+
// platforms:
|
|
347
|
+
// - claude-code
|
|
348
|
+
// - hermes
|
|
349
|
+
const multiMatch = fm.match(/^platforms:\s*\n((?:[ \t]+-[ \t]+\S[^\n]*\n?)+)/m);
|
|
350
|
+
if (multiMatch) {
|
|
351
|
+
const items = multiMatch[1]
|
|
352
|
+
.split('\n')
|
|
353
|
+
.map(line => line.match(/^[ \t]+-[ \t]+(\S+)/)?.[1])
|
|
354
|
+
.filter(Boolean);
|
|
355
|
+
return items.length > 0 ? items : null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// platforms: key present but empty
|
|
359
|
+
if (/^platforms:\s*$/m.test(fm)) return null;
|
|
360
|
+
|
|
361
|
+
return null; // Field absent = deploy to all
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Returns true if a skill (given its source content) should be deployed to the given provider.
|
|
366
|
+
* If no provider is specified, always returns true.
|
|
367
|
+
*/
|
|
368
|
+
export function skillMatchesProvider(content, provider) {
|
|
369
|
+
if (!provider) return true;
|
|
370
|
+
|
|
371
|
+
const platforms = parseSkillPlatforms(content);
|
|
372
|
+
if (!platforms) return true; // No restriction
|
|
373
|
+
|
|
374
|
+
const platformName = PROVIDER_TO_PLATFORM[provider] || provider;
|
|
375
|
+
return platforms.includes(platformName) || platforms.includes(provider);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Inject the target platform name into a SKILL.md frontmatter block.
|
|
380
|
+
*
|
|
381
|
+
* Source skills use platforms: [all] as a deployment token.
|
|
382
|
+
* At deploy time this function replaces [all] with [<targetPlatform>] so
|
|
383
|
+
* each deployed copy accurately reflects where it was installed.
|
|
384
|
+
*
|
|
385
|
+
* Explicit restriction lists (not [all]) are preserved as-is.
|
|
386
|
+
* If no platforms: field is present, one is added.
|
|
387
|
+
*/
|
|
388
|
+
export function injectPlatformInContent(content, targetPlatform) {
|
|
389
|
+
if (!targetPlatform) return content;
|
|
390
|
+
|
|
391
|
+
const fmMatch = content.match(/^(---\n)([\s\S]*?)(\n---\n?)([\s\S]*)$/);
|
|
392
|
+
if (!fmMatch) return content;
|
|
393
|
+
|
|
394
|
+
const [, open, fm, close, body] = fmMatch;
|
|
395
|
+
|
|
396
|
+
const injected = `platforms: [${targetPlatform}]`;
|
|
397
|
+
|
|
398
|
+
// Case 1: inline [all] token → replace with target platform
|
|
399
|
+
let updated = fm.replace(/^platforms:\s*\[all\]\n?/m, injected + '\n');
|
|
400
|
+
if (updated !== fm) return open + updated + close + body;
|
|
401
|
+
|
|
402
|
+
// Case 2: inline explicit restriction (not [all]) → leave as-is, do not inject
|
|
403
|
+
if (/^platforms:\s*\[(?!all\])[^\]]+\]/m.test(fm)) {
|
|
404
|
+
return content;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Case 3: multi-line list → replace entire block with injected value
|
|
408
|
+
updated = fm.replace(/^platforms:\s*\n(?:[ \t]+-[ \t]+\S[^\n]*\n?)*/m, injected + '\n');
|
|
409
|
+
if (updated !== fm) return open + updated + close + body;
|
|
410
|
+
|
|
411
|
+
// Case 4: bare `platforms:` with no value → replace
|
|
412
|
+
updated = fm.replace(/^platforms:\s*$/m, injected);
|
|
413
|
+
if (updated !== fm) return open + updated + close + body;
|
|
414
|
+
|
|
415
|
+
// Case 5: no platforms: field at all → insert after the first frontmatter line
|
|
416
|
+
const fmLines = fm.split('\n');
|
|
417
|
+
fmLines.splice(1, 0, injected);
|
|
418
|
+
return open + fmLines.join('\n') + close + body;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** @deprecated Use injectPlatformInContent instead */
|
|
422
|
+
export function stripPlatformsFromContent(content) {
|
|
423
|
+
return injectPlatformInContent(content, null);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Parse YAML frontmatter from markdown content
|
|
428
|
+
* Returns { frontmatter: string, body: string, metadata: object }
|
|
429
|
+
*/
|
|
430
|
+
export function parseFrontmatter(content) {
|
|
431
|
+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
432
|
+
if (!fmMatch) {
|
|
433
|
+
return { frontmatter: null, body: content, metadata: {} };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const [, frontmatter, body] = fmMatch;
|
|
437
|
+
|
|
438
|
+
// Parse simple YAML key-value pairs
|
|
439
|
+
const metadata = {};
|
|
440
|
+
for (const line of frontmatter.split('\n')) {
|
|
441
|
+
const match = line.match(/^(\w+):\s*(.+)$/);
|
|
442
|
+
if (match) {
|
|
443
|
+
metadata[match[1]] = match[2].trim();
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return { frontmatter, body, metadata };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Create frontmatter string from metadata object
|
|
452
|
+
*/
|
|
453
|
+
export function stringifyFrontmatter(metadata, body) {
|
|
454
|
+
const lines = ['---'];
|
|
455
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
456
|
+
if (value !== undefined && value !== null) {
|
|
457
|
+
lines.push(`${key}: ${value}`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
lines.push('---');
|
|
461
|
+
return lines.join('\n') + '\n\n' + body.trim();
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ============================================================================
|
|
465
|
+
// String Utilities
|
|
466
|
+
// ============================================================================
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Convert a string to kebab-case
|
|
470
|
+
* "Technical Researcher" -> "technical-researcher"
|
|
471
|
+
*/
|
|
472
|
+
export function toKebabCase(str) {
|
|
473
|
+
if (!str) return str;
|
|
474
|
+
return str
|
|
475
|
+
.toLowerCase()
|
|
476
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
477
|
+
.replace(/^-+|-+$/g, '');
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Strip JSON comments (JSONC) for parsing
|
|
482
|
+
* Used by Factory provider for settings.json
|
|
483
|
+
*/
|
|
484
|
+
export function stripJsonComments(jsonc) {
|
|
485
|
+
// Remove single-line comments
|
|
486
|
+
let result = jsonc.replace(/\/\/.*$/gm, '');
|
|
487
|
+
// Remove multi-line comments
|
|
488
|
+
result = result.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
489
|
+
return result;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ============================================================================
|
|
493
|
+
// Agent Category Inference
|
|
494
|
+
// ============================================================================
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Infer agent category from name and body content
|
|
498
|
+
* Returns: 'analysis', 'documentation', 'planning', or 'implementation'
|
|
499
|
+
*/
|
|
500
|
+
export function inferAgentCategory(name, body) {
|
|
501
|
+
const normalizedName = (name || '').toLowerCase();
|
|
502
|
+
const normalizedBody = (body || '').toLowerCase();
|
|
503
|
+
|
|
504
|
+
// Analysis agents (read-only)
|
|
505
|
+
if (normalizedName.includes('security') || normalizedName.includes('review') ||
|
|
506
|
+
normalizedName.includes('analyst') || normalizedName.includes('auditor')) {
|
|
507
|
+
return 'analysis';
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Documentation agents
|
|
511
|
+
if (normalizedName.includes('writer') || normalizedName.includes('document') ||
|
|
512
|
+
normalizedName.includes('archivist')) {
|
|
513
|
+
return 'documentation';
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Planning agents
|
|
517
|
+
if (normalizedName.includes('architect') || normalizedName.includes('planner') ||
|
|
518
|
+
normalizedName.includes('requirements') || normalizedName.includes('designer')) {
|
|
519
|
+
return 'planning';
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Implementation agents (full access)
|
|
523
|
+
if (normalizedName.includes('implement') || normalizedName.includes('engineer') ||
|
|
524
|
+
normalizedName.includes('developer') || normalizedName.includes('test')) {
|
|
525
|
+
return 'implementation';
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Default to implementation for most flexibility
|
|
529
|
+
return 'implementation';
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ============================================================================
|
|
533
|
+
// Tool Parsing
|
|
534
|
+
// ============================================================================
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Parse tools string into array
|
|
538
|
+
*/
|
|
539
|
+
export function parseTools(toolsString) {
|
|
540
|
+
if (!toolsString) return [];
|
|
541
|
+
|
|
542
|
+
if (toolsString.startsWith('[')) {
|
|
543
|
+
try {
|
|
544
|
+
return JSON.parse(toolsString);
|
|
545
|
+
} catch (e) {
|
|
546
|
+
return toolsString.replace(/[\[\]"']/g, '').split(/[,\s]+/).filter(Boolean);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return toolsString.split(/[,\s]+/).filter(Boolean);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// ============================================================================
|
|
553
|
+
// Skill-Command Collision Detection
|
|
554
|
+
// ============================================================================
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Filter out commands that share a name with a skill.
|
|
558
|
+
* Skills are the richer format (triggers, NL routing, behavior spec) and take precedence.
|
|
559
|
+
*
|
|
560
|
+
* @param {string[]} commandFiles - Array of command file paths
|
|
561
|
+
* @param {string[]} skillDirs - Array of skill directory paths
|
|
562
|
+
* @returns {string[]} Filtered command files with collisions removed
|
|
563
|
+
*/
|
|
564
|
+
export function filterCommandsAgainstSkills(commandFiles, skillDirs) {
|
|
565
|
+
if (!skillDirs.length || !commandFiles.length) return commandFiles;
|
|
566
|
+
|
|
567
|
+
// Build set of skill names (directory basenames, without extension)
|
|
568
|
+
const skillNames = new Set(skillDirs.map(d => path.basename(d)));
|
|
569
|
+
|
|
570
|
+
const filtered = [];
|
|
571
|
+
for (const f of commandFiles) {
|
|
572
|
+
// Command name is the filename without extension
|
|
573
|
+
const commandName = path.basename(f).replace(/\.\w+$/, '');
|
|
574
|
+
if (skillNames.has(commandName)) {
|
|
575
|
+
console.log(`skip (skill precedence): command "${commandName}" — skill with same name takes precedence`);
|
|
576
|
+
} else {
|
|
577
|
+
filtered.push(f);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
return filtered;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function shouldReportDeployCollisions(opts = {}) {
|
|
585
|
+
if (opts.reportCollisions === true) return true;
|
|
586
|
+
if (opts.reportCollisions === false) return false;
|
|
587
|
+
if (process.env.AIWG_REPORT_DEPLOY_COLLISIONS === '1') return true;
|
|
588
|
+
if (process.env.VITEST) return false;
|
|
589
|
+
if (process.env.NODE_ENV === 'test') return false;
|
|
590
|
+
return true;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// ============================================================================
|
|
594
|
+
// File Deployment
|
|
595
|
+
// ============================================================================
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Deploy files to destination directory
|
|
599
|
+
* Handles transformation via provider's transform function
|
|
600
|
+
*/
|
|
601
|
+
export function deployFiles(files, destDir, opts, transformFn) {
|
|
602
|
+
const { force = false, dryRun = false, provider = 'claude', fileExtension = '.md', injectPlatform = false } = opts;
|
|
603
|
+
const deployVersion = opts.deployVersion || 'unknown';
|
|
604
|
+
const deploySource = opts.deploySource || 'bundled';
|
|
605
|
+
// Map of dest path → first batch entry that claimed it. Used to detect
|
|
606
|
+
// and report cross-framework collisions within a single deploy batch
|
|
607
|
+
// (#1169). Each value: { src, frameworkSlug }
|
|
608
|
+
const seen = new Map();
|
|
609
|
+
const actions = [];
|
|
610
|
+
// Collision report — one entry per detected cross-framework collision
|
|
611
|
+
// (within-batch or against sidecar). Surfaced after the loop.
|
|
612
|
+
const collisions = [];
|
|
613
|
+
|
|
614
|
+
// Read sidecar manifest for hash-based skip-on-match (#749) and
|
|
615
|
+
// cross-framework collision detection (#1169)
|
|
616
|
+
const sidecar = readSidecarManifest(destDir);
|
|
617
|
+
const sidecarManaged = sidecar?.managed || {};
|
|
618
|
+
|
|
619
|
+
for (const f of files) {
|
|
620
|
+
let base = path.basename(f);
|
|
621
|
+
|
|
622
|
+
// Change extension if needed
|
|
623
|
+
if (fileExtension !== '.md' && base.endsWith('.md')) {
|
|
624
|
+
base = base.replace(/\.md$/, fileExtension);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
let dest = path.join(destDir, base);
|
|
628
|
+
const currentFrameworkSlug = extractFrameworkSlug(f);
|
|
629
|
+
|
|
630
|
+
// Read and transform source content (needed for content-equality check
|
|
631
|
+
// and the collision-vs-duplicate distinction)
|
|
632
|
+
const srcContent = fs.readFileSync(f, 'utf8');
|
|
633
|
+
let transformedContent = transformFn ? transformFn(f, srcContent, opts) : srcContent;
|
|
634
|
+
|
|
635
|
+
// Inject target platform into agent .md files that use platforms: [all]
|
|
636
|
+
if (injectPlatform && provider && /platforms:\s*\[all\]/.test(transformedContent)) {
|
|
637
|
+
const platformName = PROVIDER_TO_PLATFORM[provider] || provider;
|
|
638
|
+
transformedContent = injectPlatformInContent(transformedContent, platformName);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Add managed marker for .md / .mdc files (#749; .mdc for Cursor native rules)
|
|
642
|
+
if (base.endsWith('.md') || base.endsWith('.mdc')) {
|
|
643
|
+
transformedContent = addManagedMarker(transformedContent, deployVersion, deploySource);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Compute content hash for sidecar comparison
|
|
647
|
+
const hash = contentHash(transformedContent);
|
|
648
|
+
|
|
649
|
+
// Within-batch collision check (#1169). If a previous file in this
|
|
650
|
+
// batch already claimed this dest, distinguish:
|
|
651
|
+
// - Same content (transform/normalize is idempotent) → silent skip
|
|
652
|
+
// - Same framework + different content → "duplicate" (legacy reason)
|
|
653
|
+
// - Different framework + different content → "collision"
|
|
654
|
+
if (seen.has(dest)) {
|
|
655
|
+
const prev = seen.get(dest);
|
|
656
|
+
const prevContent = prev.transformedContent;
|
|
657
|
+
if (prevContent === transformedContent) {
|
|
658
|
+
actions.push({ type: 'skip', src: f, dest, reason: 'duplicate-identical' });
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
const prevSlug = prev.frameworkSlug;
|
|
662
|
+
if (currentFrameworkSlug && prevSlug && currentFrameworkSlug !== prevSlug) {
|
|
663
|
+
// Cross-framework collision within a single deploy batch — first
|
|
664
|
+
// wins; second is skipped. `--force` keeps the first entry too,
|
|
665
|
+
// since we have no principled way to pick a winner among peers.
|
|
666
|
+
collisions.push({
|
|
667
|
+
dest,
|
|
668
|
+
filename: base,
|
|
669
|
+
existingFramework: prevSlug,
|
|
670
|
+
existingSrc: prev.src,
|
|
671
|
+
incomingFramework: currentFrameworkSlug,
|
|
672
|
+
incomingSrc: f,
|
|
673
|
+
scope: 'within-batch',
|
|
674
|
+
});
|
|
675
|
+
actions.push({
|
|
676
|
+
type: 'skip',
|
|
677
|
+
src: f,
|
|
678
|
+
dest,
|
|
679
|
+
reason: 'collision',
|
|
680
|
+
collidingFramework: prevSlug,
|
|
681
|
+
});
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
actions.push({ type: 'skip', src: f, dest, reason: 'duplicate' });
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Skip-on-match: compare hash against sidecar manifest before reading dest file (#749)
|
|
689
|
+
// Guard: only skip if the destination file still exists on disk. cleanupOldRuleFiles
|
|
690
|
+
// may have deleted it before deployFiles runs, so the sidecar record is stale.
|
|
691
|
+
if (!force && sidecarManaged[base]?.hash === `sha256:${hash}` && fs.existsSync(dest)) {
|
|
692
|
+
actions.push({ type: 'skip', src: f, dest, reason: 'hash-match' });
|
|
693
|
+
seen.set(dest, { src: f, frameworkSlug: currentFrameworkSlug, transformedContent });
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Cross-batch collision check against sidecar (#1169). If the dest
|
|
698
|
+
// file is already managed by a *different* framework than this deploy,
|
|
699
|
+
// and the new content differs, refuse to silently overwrite.
|
|
700
|
+
if (
|
|
701
|
+
!force &&
|
|
702
|
+
fs.existsSync(dest) &&
|
|
703
|
+
sidecarManaged[base]?.frameworkSlug &&
|
|
704
|
+
currentFrameworkSlug &&
|
|
705
|
+
sidecarManaged[base].frameworkSlug !== currentFrameworkSlug
|
|
706
|
+
) {
|
|
707
|
+
const destContent = fs.readFileSync(dest, 'utf8');
|
|
708
|
+
if (destContent !== transformedContent) {
|
|
709
|
+
collisions.push({
|
|
710
|
+
dest,
|
|
711
|
+
filename: base,
|
|
712
|
+
existingFramework: sidecarManaged[base].frameworkSlug,
|
|
713
|
+
existingSrc: null,
|
|
714
|
+
incomingFramework: currentFrameworkSlug,
|
|
715
|
+
incomingSrc: f,
|
|
716
|
+
scope: 'cross-batch',
|
|
717
|
+
});
|
|
718
|
+
actions.push({
|
|
719
|
+
type: 'skip',
|
|
720
|
+
src: f,
|
|
721
|
+
dest,
|
|
722
|
+
reason: 'collision',
|
|
723
|
+
collidingFramework: sidecarManaged[base].frameworkSlug,
|
|
724
|
+
});
|
|
725
|
+
// Don't claim the dest in `seen` — we did not deploy. The sidecar
|
|
726
|
+
// entry already holds the previous framework's record and stays.
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Fallback: check destination file content directly
|
|
732
|
+
if (!force && fs.existsSync(dest)) {
|
|
733
|
+
const destContent = fs.readFileSync(dest, 'utf8');
|
|
734
|
+
if (destContent === transformedContent) {
|
|
735
|
+
actions.push({ type: 'skip', src: f, dest, reason: 'unchanged', hash });
|
|
736
|
+
seen.set(dest, { src: f, frameworkSlug: currentFrameworkSlug, transformedContent });
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
actions.push({ type: 'deploy', src: f, dest, content: transformedContent, reason: 'changed', hash, frameworkSlug: currentFrameworkSlug });
|
|
740
|
+
} else if (force && fs.existsSync(dest)) {
|
|
741
|
+
actions.push({ type: 'deploy', src: f, dest, content: transformedContent, reason: 'forced', hash, frameworkSlug: currentFrameworkSlug });
|
|
742
|
+
} else {
|
|
743
|
+
actions.push({ type: 'deploy', src: f, dest, content: transformedContent, reason: 'new', hash, frameworkSlug: currentFrameworkSlug });
|
|
744
|
+
}
|
|
745
|
+
seen.set(dest, { src: f, frameworkSlug: currentFrameworkSlug, transformedContent });
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const verbose = opts.verbose === true;
|
|
749
|
+
const deployedEntries = [];
|
|
750
|
+
for (const a of actions) {
|
|
751
|
+
if (a.type === 'deploy') {
|
|
752
|
+
if (dryRun) console.log(`[dry-run] deploy ${a.src} -> ${a.dest} (${a.reason})`);
|
|
753
|
+
else writeFile(a.dest, a.content, false);
|
|
754
|
+
if (verbose) console.log(`deployed ${path.basename(a.src)} -> ${path.relative(process.cwd(), a.dest)} (${a.reason})`);
|
|
755
|
+
deployedEntries.push({ filename: path.basename(a.dest), hash: a.hash, frameworkSlug: a.frameworkSlug });
|
|
756
|
+
} else if (a.type === 'skip') {
|
|
757
|
+
if (verbose) console.log(`skip (${a.reason}): ${path.basename(a.dest)}`);
|
|
758
|
+
// Preserve existing sidecar entries for skipped files
|
|
759
|
+
if (a.hash) deployedEntries.push({ filename: path.basename(a.dest), hash: a.hash });
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Surface cross-framework collisions to the operator (#1169). Always
|
|
764
|
+
// visible (not gated on verbose) because silent loss is the failure
|
|
765
|
+
// mode this guard exists to prevent.
|
|
766
|
+
if (collisions.length > 0 && shouldReportDeployCollisions(opts)) {
|
|
767
|
+
const tag = force ? 'override' : 'skip';
|
|
768
|
+
console.warn(
|
|
769
|
+
`\n⚠ Cross-framework deploy collision${collisions.length > 1 ? 's' : ''} detected (${collisions.length}):`,
|
|
770
|
+
);
|
|
771
|
+
for (const c of collisions) {
|
|
772
|
+
console.warn(
|
|
773
|
+
` ${c.filename}: ${c.existingFramework} owns this slot; ${c.incomingFramework} skipped (${c.scope})`,
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
if (!force) {
|
|
777
|
+
console.warn(
|
|
778
|
+
` Re-run with --force to override (last-wins) or rename the colliding file at framework source.`,
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// Update sidecar manifest with deployed file hashes (#749) including
|
|
784
|
+
// framework slug for future collision detection (#1169).
|
|
785
|
+
if (deployedEntries.length > 0) {
|
|
786
|
+
updateSidecarManifest(destDir, deployedEntries, { dryRun, version: deployVersion, source: deploySource });
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
return actions;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Deploy .soul.md companion files alongside agents.
|
|
794
|
+
* Soul files are copied as-is (no transformation) to the same directory as agents.
|
|
795
|
+
*/
|
|
796
|
+
export function deploySoulCompanions(soulFiles, destDir, opts) {
|
|
797
|
+
if (!soulFiles || soulFiles.length === 0) return [];
|
|
798
|
+
return deployFiles(soulFiles, destDir, opts, null);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Read a skill's SKILL.md frontmatter and return whether it is a
|
|
803
|
+
* "kernel" skill — always-loaded, deploys to the platform's native
|
|
804
|
+
* skills directory rather than the AIWG-namespaced one. Per epic
|
|
805
|
+
* #1212. A skill opts in by setting `kernel: true` in its frontmatter.
|
|
806
|
+
*
|
|
807
|
+
* Note: `parseFrontmatter` keeps values as strings (no YAML coercion).
|
|
808
|
+
* Accept both the string `"true"` and the boolean `true` so callers
|
|
809
|
+
* are not surprised if a future parser upgrade returns booleans.
|
|
810
|
+
*/
|
|
811
|
+
export function isKernelSkill(skillDir) {
|
|
812
|
+
const skillMdPath = path.join(skillDir, 'SKILL.md');
|
|
813
|
+
if (!fs.existsSync(skillMdPath)) return false;
|
|
814
|
+
const content = fs.readFileSync(skillMdPath, 'utf8');
|
|
815
|
+
const { metadata } = parseFrontmatter(content);
|
|
816
|
+
const v = metadata?.kernel;
|
|
817
|
+
return v === true || v === 'true';
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Deploy skills with kernel-vs-standard routing (#1212/#1216/#1217).
|
|
822
|
+
*
|
|
823
|
+
* Partitions `skillDirs` into kernel skills (frontmatter `kernel: true`)
|
|
824
|
+
* and standard skills.
|
|
825
|
+
*
|
|
826
|
+
* **Kernel skills** copy to `kernelDestDir` (platform-native dir,
|
|
827
|
+
* always-loaded by the platform). Small set: currently 24 skills spanning
|
|
828
|
+
* framework quickrefs, routing maps, and self-maintenance operations.
|
|
829
|
+
*
|
|
830
|
+
* **Standard skills** are NOT copied per-project (#1217). They live at
|
|
831
|
+
* `$AIWG_ROOT/agentic/code/.../skills/<name>/` and `aiwg discover`
|
|
832
|
+
* returns absolute paths anchored there. The agent reads them directly
|
|
833
|
+
* via the `Read` tool — no per-project mirror, no stale-copy risk.
|
|
834
|
+
* `standardDestDir` is retained as a fallback when `$AIWG_ROOT` is not
|
|
835
|
+
* readable, and as the cleanup target for legacy `.aiwg/skills/`
|
|
836
|
+
* directories from rc.13 and earlier deploys.
|
|
837
|
+
*
|
|
838
|
+
* @param skillDirs absolute paths to source skill directories
|
|
839
|
+
* @param standardDestDir absolute path for standard skills (legacy
|
|
840
|
+
* per-project mirror — used only as cleanup
|
|
841
|
+
* target by default; populated if
|
|
842
|
+
* `opts.copyStandardSkills` is true)
|
|
843
|
+
* @param kernelDestDir absolute path for kernel skills
|
|
844
|
+
* (e.g., `.cursor/skills`); pass null/undefined
|
|
845
|
+
* to disable kernel routing
|
|
846
|
+
* @param opts standard deploy opts forwarded to
|
|
847
|
+
* `deploySkillDir`. New optional flags:
|
|
848
|
+
* - `copyStandardSkills` (default: false) —
|
|
849
|
+
* force per-project copy of standard skills
|
|
850
|
+
* (used when $AIWG_ROOT is not readable from
|
|
851
|
+
* the agent's working directory)
|
|
852
|
+
*
|
|
853
|
+
* @returns `{ kernel, standardCopied, prunedFromKernelDir,
|
|
854
|
+
* prunedFromStandardDir, prunedKernelFromStandardDir }`
|
|
855
|
+
* deployed/pruned counts
|
|
856
|
+
*
|
|
857
|
+
* Cleanup behavior:
|
|
858
|
+
* - Kernel dir: prune any AIWG-shaped skill whose name now belongs
|
|
859
|
+
* to the standard tier (rc.13 behavior, preserved).
|
|
860
|
+
* - Standard dir: when standard copies are NOT being deployed this
|
|
861
|
+
* run, prune any AIWG-shaped skill that exists under
|
|
862
|
+
* `standardDestDir`. These are legacy per-project mirrors from
|
|
863
|
+
* rc.13 deploys; the canonical source is now `$AIWG_ROOT`.
|
|
864
|
+
* - User-authored skills (no SKILL.md, or names not in our deploy
|
|
865
|
+
* manifest) survive untouched in both directories.
|
|
866
|
+
*/
|
|
867
|
+
export function deploySkillsWithKernelRouting(
|
|
868
|
+
skillDirs,
|
|
869
|
+
standardDestDir,
|
|
870
|
+
kernelDestDir,
|
|
871
|
+
opts,
|
|
872
|
+
) {
|
|
873
|
+
// Caller opts in via `opts.copyStandardSkills` (set by `--copy-all`
|
|
874
|
+
// CLI flag, #1219). Default (#1217) is no-copy: standard skills stay
|
|
875
|
+
// at their source path under $AIWG_ROOT and are reached via the
|
|
876
|
+
// artifact index.
|
|
877
|
+
const copyStandardSkills = opts?.copyStandardSkills === true;
|
|
878
|
+
|
|
879
|
+
const kernel = [];
|
|
880
|
+
const standard = [];
|
|
881
|
+
for (const dir of skillDirs) {
|
|
882
|
+
if (kernelDestDir && isKernelSkill(dir)) kernel.push(dir);
|
|
883
|
+
else standard.push(dir);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// Names of skills in the deploy manifest — bound the cleanup to
|
|
887
|
+
// names AIWG manages so user-authored content survives.
|
|
888
|
+
const standardNames = new Set(standard.map(p => path.basename(p)));
|
|
889
|
+
const allSkillNames = new Set([...standardNames, ...kernel.map(p => path.basename(p))]);
|
|
890
|
+
|
|
891
|
+
if (kernel.length > 0 && kernelDestDir) {
|
|
892
|
+
ensureDir(kernelDestDir, opts?.dryRun);
|
|
893
|
+
for (const dir of kernel) deploySkillDir(dir, kernelDestDir, opts);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// Standard tier copy is OFF by default (#1217). Only fires when the
|
|
897
|
+
// operator explicitly opts in via `copyStandardSkills` — typically
|
|
898
|
+
// because $AIWG_ROOT isn't readable from the agent's working dir.
|
|
899
|
+
let standardCopied = 0;
|
|
900
|
+
if (copyStandardSkills && standard.length > 0) {
|
|
901
|
+
ensureDir(standardDestDir, opts?.dryRun);
|
|
902
|
+
for (const dir of standard) {
|
|
903
|
+
deploySkillDir(dir, standardDestDir, opts);
|
|
904
|
+
standardCopied++;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// A skill promoted from standard → kernel may still have an older managed
|
|
909
|
+
// copy in the opt-in standard mirror. Remove that duplicate even when
|
|
910
|
+
// --copy-all remains enabled; otherwise providers that recursively scan
|
|
911
|
+
// both tiers can surface the same skill twice after a tier transition.
|
|
912
|
+
let prunedKernelFromStandardDir = 0;
|
|
913
|
+
if (
|
|
914
|
+
standardDestDir &&
|
|
915
|
+
fs.existsSync(standardDestDir) &&
|
|
916
|
+
!opts?.dryRun &&
|
|
917
|
+
kernel.length > 0
|
|
918
|
+
) {
|
|
919
|
+
const kernelNames = new Set(kernel.map(p => path.basename(p)));
|
|
920
|
+
for (const entry of fs.readdirSync(standardDestDir, { withFileTypes: true })) {
|
|
921
|
+
if (!entry.isDirectory() || !kernelNames.has(entry.name)) continue;
|
|
922
|
+
const target = path.join(standardDestDir, entry.name);
|
|
923
|
+
const skillMd = path.join(target, 'SKILL.md');
|
|
924
|
+
if (!fs.existsSync(skillMd)) continue;
|
|
925
|
+
const marker = path.join(target, '.aiwg-managed');
|
|
926
|
+
let managed = fs.existsSync(marker);
|
|
927
|
+
if (!managed) {
|
|
928
|
+
try {
|
|
929
|
+
managed = /^\s*namespace:\s*["']?aiwg["']?\s*$/m.test(
|
|
930
|
+
parseFrontmatter(fs.readFileSync(skillMd, 'utf8')).frontmatter ?? '',
|
|
931
|
+
);
|
|
932
|
+
} catch { /* preserve unreadable/operator-owned content */ }
|
|
933
|
+
}
|
|
934
|
+
if (!managed) continue;
|
|
935
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
936
|
+
prunedKernelFromStandardDir++;
|
|
937
|
+
if (opts?.verbose) console.log(`pruned promoted kernel from standard dir: ${entry.name}`);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// Kernel-dir cleanup: prune skills whose name moved to the standard
|
|
942
|
+
// tier (rc.13 logic). Holistic cleanup of orphaned skills (renamed or
|
|
943
|
+
// removed sources) happens in a separate post-all-deploys step
|
|
944
|
+
// (`pruneStaleAiwgSkills`) — running per-call here would race because
|
|
945
|
+
// `deploySkills` may be invoked multiple times in one orchestration.
|
|
946
|
+
let prunedFromKernelDir = 0;
|
|
947
|
+
if (kernelDestDir && fs.existsSync(kernelDestDir) && !opts?.dryRun) {
|
|
948
|
+
for (const entry of fs.readdirSync(kernelDestDir, { withFileTypes: true })) {
|
|
949
|
+
if (!entry.isDirectory()) continue;
|
|
950
|
+
const skillMd = path.join(kernelDestDir, entry.name, 'SKILL.md');
|
|
951
|
+
if (!fs.existsSync(skillMd)) continue;
|
|
952
|
+
if (!standardNames.has(entry.name)) continue;
|
|
953
|
+
const target = path.join(kernelDestDir, entry.name);
|
|
954
|
+
try {
|
|
955
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
956
|
+
prunedFromKernelDir++;
|
|
957
|
+
if (opts?.verbose) console.log(`pruned legacy from kernel dir: ${entry.name}`);
|
|
958
|
+
} catch (err) {
|
|
959
|
+
if (opts?.verbose) console.warn(`Warning: could not prune ${target}: ${err.message}`);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// Standard-dir cleanup (#1217): when we're NOT copying standard
|
|
965
|
+
// skills, anything AIWG-named under standardDestDir is a legacy
|
|
966
|
+
// mirror from a rc.13-or-earlier deploy. Prune to clean up.
|
|
967
|
+
let prunedFromStandardDir = 0;
|
|
968
|
+
if (
|
|
969
|
+
!copyStandardSkills &&
|
|
970
|
+
standardDestDir &&
|
|
971
|
+
fs.existsSync(standardDestDir) &&
|
|
972
|
+
!opts?.dryRun
|
|
973
|
+
) {
|
|
974
|
+
for (const entry of fs.readdirSync(standardDestDir, { withFileTypes: true })) {
|
|
975
|
+
if (!entry.isDirectory()) continue;
|
|
976
|
+
const skillMd = path.join(standardDestDir, entry.name, 'SKILL.md');
|
|
977
|
+
if (!fs.existsSync(skillMd)) continue;
|
|
978
|
+
// Only prune skills AIWG manages — bound by the deploy manifest.
|
|
979
|
+
if (!allSkillNames.has(entry.name)) continue;
|
|
980
|
+
const target = path.join(standardDestDir, entry.name);
|
|
981
|
+
try {
|
|
982
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
983
|
+
prunedFromStandardDir++;
|
|
984
|
+
if (opts?.verbose) console.log(`pruned legacy from standard dir: ${entry.name}`);
|
|
985
|
+
} catch (err) {
|
|
986
|
+
if (opts?.verbose) console.warn(`Warning: could not prune ${target}: ${err.message}`);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
// Try to remove the now-empty standard dir + its parent .aiwg/
|
|
990
|
+
// wrapper if both end up empty. Best-effort.
|
|
991
|
+
try {
|
|
992
|
+
const remaining = fs.readdirSync(standardDestDir);
|
|
993
|
+
if (remaining.length === 0) {
|
|
994
|
+
fs.rmdirSync(standardDestDir);
|
|
995
|
+
const aiwgWrapper = path.dirname(standardDestDir);
|
|
996
|
+
if (path.basename(aiwgWrapper) === '.aiwg') {
|
|
997
|
+
const wrapperRemaining = fs.readdirSync(aiwgWrapper);
|
|
998
|
+
if (wrapperRemaining.length === 0) fs.rmdirSync(aiwgWrapper);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
} catch { /* non-fatal */ }
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
return {
|
|
1005
|
+
kernel: kernel.length,
|
|
1006
|
+
standardCopied,
|
|
1007
|
+
prunedFromKernelDir,
|
|
1008
|
+
prunedFromStandardDir,
|
|
1009
|
+
prunedKernelFromStandardDir,
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* Compute the global desired-kernel set by walking the entire AIWG
|
|
1015
|
+
* source tree (frameworks + addons), regardless of which deploy mode
|
|
1016
|
+
* is in flight. Used by `pruneStaleAiwgSkills` so cleanup never races
|
|
1017
|
+
* with sibling deploy invocations (`aiwg use` runs `deploy-agents.mjs`
|
|
1018
|
+
* multiple times — once per framework, once per addon batch).
|
|
1019
|
+
*
|
|
1020
|
+
* @param {string} srcRoot AIWG repo / install root
|
|
1021
|
+
* @returns {string[]|null} basenames of every source skill dir whose
|
|
1022
|
+
* SKILL.md frontmatter has `kernel: true`, or `null` when no AIWG
|
|
1023
|
+
* framework/addon tree can be located. A `null` return signals the
|
|
1024
|
+
* caller (`pruneStaleAiwgSkills`) to SKIP pruning rather than treat an
|
|
1025
|
+
* empty set as "delete every AIWG skill" (#123). Mirrors the contract of
|
|
1026
|
+
* `computeAllArtifactBasenames` (#1627).
|
|
1027
|
+
*/
|
|
1028
|
+
export function computeAllKernelNames(srcRoot) {
|
|
1029
|
+
const hasAiwgTree = (dir) =>
|
|
1030
|
+
fs.existsSync(path.join(dir, 'agentic', 'code', 'frameworks')) &&
|
|
1031
|
+
fs.existsSync(path.join(dir, 'agentic', 'code', 'addons'));
|
|
1032
|
+
const isRootOrSourceDescendant = (candidate, original) => {
|
|
1033
|
+
const root = path.resolve(candidate);
|
|
1034
|
+
const source = path.resolve(original);
|
|
1035
|
+
const agenticCode = path.join(root, 'agentic', 'code');
|
|
1036
|
+
return source === root || source === agenticCode || source.startsWith(`${agenticCode}${path.sep}`);
|
|
1037
|
+
};
|
|
1038
|
+
|
|
1039
|
+
// Prefer an explicit AIWG_ROOT, but only when it actually points at a real
|
|
1040
|
+
// AIWG tree — a stale/bogus env value must not silently yield an empty
|
|
1041
|
+
// desired set. Otherwise walk up from srcRoot (the caller may pass an
|
|
1042
|
+
// addon/framework/project-local-bundle path, not the install root).
|
|
1043
|
+
let aiwgRoot = null;
|
|
1044
|
+
if (process.env.AIWG_ROOT && hasAiwgTree(process.env.AIWG_ROOT)) {
|
|
1045
|
+
aiwgRoot = process.env.AIWG_ROOT;
|
|
1046
|
+
} else {
|
|
1047
|
+
let cur = path.resolve(srcRoot);
|
|
1048
|
+
for (let i = 0; i < 8; i++) {
|
|
1049
|
+
if (hasAiwgTree(cur) && isRootOrSourceDescendant(cur, srcRoot)) { aiwgRoot = cur; break; }
|
|
1050
|
+
const parent = path.dirname(cur);
|
|
1051
|
+
if (parent === cur) break;
|
|
1052
|
+
cur = parent;
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// No AIWG framework/addon tree found — e.g. deploying a project-local
|
|
1057
|
+
// bundle with AIWG_ROOT unset. Return null so the prune is skipped.
|
|
1058
|
+
if (!aiwgRoot) return null;
|
|
1059
|
+
|
|
1060
|
+
const names = new Set();
|
|
1061
|
+
const roots = [
|
|
1062
|
+
path.join(aiwgRoot, 'agentic', 'code', 'frameworks'),
|
|
1063
|
+
path.join(aiwgRoot, 'agentic', 'code', 'addons'),
|
|
1064
|
+
];
|
|
1065
|
+
for (const root of roots) {
|
|
1066
|
+
if (!fs.existsSync(root)) continue;
|
|
1067
|
+
for (const componentEntry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
1068
|
+
if (!componentEntry.isDirectory()) continue;
|
|
1069
|
+
const skillsDir = path.join(root, componentEntry.name, 'skills');
|
|
1070
|
+
if (!fs.existsSync(skillsDir)) continue;
|
|
1071
|
+
for (const skillEntry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
|
|
1072
|
+
if (!skillEntry.isDirectory()) continue;
|
|
1073
|
+
const fullPath = path.join(skillsDir, skillEntry.name);
|
|
1074
|
+
if (isKernelSkill(fullPath)) names.add(skillEntry.name);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
return Array.from(names);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* Holistic post-deploy cleanup of stale AIWG-managed skills.
|
|
1083
|
+
*
|
|
1084
|
+
* Run this AFTER all `deploySkills` invocations have completed for a
|
|
1085
|
+
* given provider so the desired-name set reflects every kernel skill
|
|
1086
|
+
* deployed across all frameworks/addons. Per-call cleanup races because
|
|
1087
|
+
* `deploySkills` may be invoked multiple times in one orchestration —
|
|
1088
|
+
* this function does the cleanup once at the end.
|
|
1089
|
+
*
|
|
1090
|
+
* Identifies AIWG-managed skills via:
|
|
1091
|
+
* 1. `.aiwg-managed` marker file (preferred — set by `deploySkillDir`)
|
|
1092
|
+
* 2. Frontmatter `namespace: aiwg` (migration fallback for pre-marker
|
|
1093
|
+
* deploys; stops firing after one redeploy)
|
|
1094
|
+
*
|
|
1095
|
+
* Bounded to entries identified above, so user-authored skills next to
|
|
1096
|
+
* AIWG-managed ones are never touched.
|
|
1097
|
+
*
|
|
1098
|
+
* @param {string} kernelDestDir absolute path to the platform's kernel
|
|
1099
|
+
* skills dir (e.g. `<project>/.claude/skills/`)
|
|
1100
|
+
* @param {string[]|null} desiredKernelNames names of every kernel skill that
|
|
1101
|
+
* SHOULD remain (basenames of source dirs). When `null` (no AIWG tree
|
|
1102
|
+
* located — see `computeAllKernelNames`), pruning is skipped entirely so a
|
|
1103
|
+
* project-local-bundle deploy without AIWG_ROOT never empties the kernel
|
|
1104
|
+
* skills directory (#123).
|
|
1105
|
+
* @param {object} opts `{ dryRun, verbose }`
|
|
1106
|
+
* @returns {number} count of pruned entries
|
|
1107
|
+
*/
|
|
1108
|
+
export function pruneStaleAiwgSkills(kernelDestDir, desiredKernelNames, opts = {}) {
|
|
1109
|
+
if (!kernelDestDir || !fs.existsSync(kernelDestDir)) return 0;
|
|
1110
|
+
// No global desired set — skip pruning rather than delete everything (#123).
|
|
1111
|
+
if (desiredKernelNames == null) return 0;
|
|
1112
|
+
if (opts.dryRun) return 0;
|
|
1113
|
+
const desired = new Set(desiredKernelNames);
|
|
1114
|
+
let pruned = 0;
|
|
1115
|
+
for (const entry of fs.readdirSync(kernelDestDir, { withFileTypes: true })) {
|
|
1116
|
+
if (!entry.isDirectory()) continue;
|
|
1117
|
+
if (desired.has(entry.name)) continue;
|
|
1118
|
+
|
|
1119
|
+
const skillMd = path.join(kernelDestDir, entry.name, 'SKILL.md');
|
|
1120
|
+
if (!fs.existsSync(skillMd)) continue;
|
|
1121
|
+
|
|
1122
|
+
const marker = path.join(kernelDestDir, entry.name, '.aiwg-managed');
|
|
1123
|
+
let isAiwgManaged = fs.existsSync(marker);
|
|
1124
|
+
if (!isAiwgManaged) {
|
|
1125
|
+
try {
|
|
1126
|
+
const content = fs.readFileSync(skillMd, 'utf8');
|
|
1127
|
+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
1128
|
+
if (fmMatch && /^\s*namespace:\s*["']?aiwg["']?\s*$/m.test(fmMatch[1])) {
|
|
1129
|
+
isAiwgManaged = true;
|
|
1130
|
+
}
|
|
1131
|
+
} catch { /* unreadable — leave alone */ }
|
|
1132
|
+
}
|
|
1133
|
+
if (!isAiwgManaged) continue;
|
|
1134
|
+
|
|
1135
|
+
try {
|
|
1136
|
+
fs.rmSync(path.join(kernelDestDir, entry.name), { recursive: true, force: true });
|
|
1137
|
+
pruned++;
|
|
1138
|
+
if (opts.verbose) console.log(`pruned stale AIWG skill: ${entry.name}`);
|
|
1139
|
+
} catch (err) {
|
|
1140
|
+
if (opts.verbose) console.warn(`Warning: could not prune ${entry.name}: ${err.message}`);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
return pruned;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// ============================================================================
|
|
1147
|
+
// Flat-File Stale-Artifact Prune (agents / commands / rules) — #1627
|
|
1148
|
+
// ============================================================================
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* Reduce a deployed artifact filename to a provider-extension-agnostic stem.
|
|
1152
|
+
*
|
|
1153
|
+
* Different providers emit the same source rule/agent/command under different
|
|
1154
|
+
* extensions (`.md`, `.mdc`, `.agent.md`, `.prompt.md`, `.instructions.md`).
|
|
1155
|
+
* Matching by stem lets the stale-prune compare a source basename
|
|
1156
|
+
* (`foo.md` → `foo`) against any provider's on-disk form (`foo.agent.md` →
|
|
1157
|
+
* `foo`) without hard-coding the per-provider extension map.
|
|
1158
|
+
*
|
|
1159
|
+
* Strips a trailing `.md`/`.mdc`, then a secondary copilot-style
|
|
1160
|
+
* `.agent`/`.prompt`/`.instructions` qualifier.
|
|
1161
|
+
*
|
|
1162
|
+
* @param {string} name basename
|
|
1163
|
+
* @returns {string} extension-agnostic stem
|
|
1164
|
+
*/
|
|
1165
|
+
export function artifactStem(name) {
|
|
1166
|
+
return String(name)
|
|
1167
|
+
.replace(/\.(md|mdc)$/i, '')
|
|
1168
|
+
.replace(/\.(agent|prompt|instructions)$/i, '');
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
/**
|
|
1172
|
+
* Compute the global set of source-basename stems AIWG ships for a flat
|
|
1173
|
+
* artifact type, across ALL frameworks and addons (mode-independent).
|
|
1174
|
+
*
|
|
1175
|
+
* Used as the "desired" set by `pruneStaleAiwgFiles`. Mode independence is
|
|
1176
|
+
* deliberate and mirrors `computeAllKernelNames` for skills: `aiwg use sdlc`
|
|
1177
|
+
* must not prune a marketing agent that a prior `aiwg use all` deployed and
|
|
1178
|
+
* that is still a valid AIWG artifact. Only artifacts whose source no longer
|
|
1179
|
+
* exists ANYWHERE in the tree (renamed/removed) fall out of this set and
|
|
1180
|
+
* become prune-eligible.
|
|
1181
|
+
*
|
|
1182
|
+
* Anchors to the AIWG root (resolved by walking up from `srcRoot` for the
|
|
1183
|
+
* `agentic/code/frameworks` + `agentic/code/addons` pair, or `AIWG_ROOT`),
|
|
1184
|
+
* NOT the raw `srcRoot`. This matters because `deploy-agents.mjs` can be
|
|
1185
|
+
* invoked with `--source <project-local-bundle>`; computing the desired set
|
|
1186
|
+
* against a bundle dir would be empty and make every AIWG artifact look stale.
|
|
1187
|
+
* Returns `null` when no AIWG framework/addon tree is found — the caller MUST
|
|
1188
|
+
* then skip pruning (a bundle-only deploy has no global desired set).
|
|
1189
|
+
*
|
|
1190
|
+
* @param {string} srcRoot AIWG repo / install root (or a subdir of it)
|
|
1191
|
+
* @param {'agents'|'commands'|'rules'} type artifact type
|
|
1192
|
+
* @returns {Set<string>|null} stems of every source file of that type, or null
|
|
1193
|
+
* if the AIWG framework/addon tree can't be located
|
|
1194
|
+
*/
|
|
1195
|
+
export function computeAllArtifactBasenames(srcRoot, type) {
|
|
1196
|
+
const aiwgRoot = resolveAiwgRoot(srcRoot);
|
|
1197
|
+
if (!aiwgRoot) return null;
|
|
1198
|
+
|
|
1199
|
+
const stems = new Set();
|
|
1200
|
+
const add = (files) => {
|
|
1201
|
+
for (const f of files) stems.add(artifactStem(path.basename(f)));
|
|
1202
|
+
};
|
|
1203
|
+
|
|
1204
|
+
const frameworkArtifacts = collectFrameworkArtifacts(aiwgRoot, 'all', {
|
|
1205
|
+
includeAgents: type === 'agents',
|
|
1206
|
+
includeCommands: type === 'commands',
|
|
1207
|
+
includeRules: type === 'rules',
|
|
1208
|
+
includeSkills: false,
|
|
1209
|
+
recursiveCommands: true,
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
if (type === 'agents') {
|
|
1213
|
+
add(frameworkArtifacts.agents);
|
|
1214
|
+
// Soul companions live alongside agents and are deployed with them —
|
|
1215
|
+
// keep their stems in the desired set so the prune never removes them.
|
|
1216
|
+
add(frameworkArtifacts.souls || []);
|
|
1217
|
+
add(getAddonAgentFiles(aiwgRoot));
|
|
1218
|
+
} else if (type === 'commands') {
|
|
1219
|
+
add(frameworkArtifacts.commands);
|
|
1220
|
+
add(getAddonCommandFiles(aiwgRoot));
|
|
1221
|
+
} else if (type === 'rules') {
|
|
1222
|
+
add(frameworkArtifacts.rules);
|
|
1223
|
+
add(getAddonRuleFiles(aiwgRoot));
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
return stems;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/**
|
|
1230
|
+
* Resolve the AIWG install/repo root from a possibly-nested srcRoot.
|
|
1231
|
+
*
|
|
1232
|
+
* Mirrors the walk-up logic in `computeAllKernelNames`: honor `AIWG_ROOT`,
|
|
1233
|
+
* else climb up to 8 levels looking for the directory that holds BOTH
|
|
1234
|
+
* `agentic/code/frameworks` and `agentic/code/addons`. Returns `null` when
|
|
1235
|
+
* no such root is found (e.g. a standalone project-local bundle).
|
|
1236
|
+
*
|
|
1237
|
+
* @param {string} srcRoot
|
|
1238
|
+
* @returns {string|null}
|
|
1239
|
+
*/
|
|
1240
|
+
export function resolveAiwgRoot(srcRoot) {
|
|
1241
|
+
const hasTree = (dir) =>
|
|
1242
|
+
fs.existsSync(path.join(dir, 'agentic', 'code', 'frameworks')) &&
|
|
1243
|
+
fs.existsSync(path.join(dir, 'agentic', 'code', 'addons'));
|
|
1244
|
+
const isRootOrSourceDescendant = (candidate, original) => {
|
|
1245
|
+
const root = path.resolve(candidate);
|
|
1246
|
+
const source = path.resolve(original);
|
|
1247
|
+
const agenticCode = path.join(root, 'agentic', 'code');
|
|
1248
|
+
return source === root || source === agenticCode || source.startsWith(`${agenticCode}${path.sep}`);
|
|
1249
|
+
};
|
|
1250
|
+
|
|
1251
|
+
if (process.env.AIWG_ROOT && hasTree(process.env.AIWG_ROOT)) {
|
|
1252
|
+
return process.env.AIWG_ROOT;
|
|
1253
|
+
}
|
|
1254
|
+
let cur = path.resolve(srcRoot);
|
|
1255
|
+
for (let i = 0; i < 8; i++) {
|
|
1256
|
+
if (hasTree(cur) && isRootOrSourceDescendant(cur, srcRoot)) return cur;
|
|
1257
|
+
const parent = path.dirname(cur);
|
|
1258
|
+
if (parent === cur) break;
|
|
1259
|
+
cur = parent;
|
|
1260
|
+
}
|
|
1261
|
+
return null;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/**
|
|
1265
|
+
* Holistic post-deploy prune of stale AIWG-managed flat artifacts
|
|
1266
|
+
* (agents / commands / rules). The flat-file analogue of
|
|
1267
|
+
* `pruneStaleAiwgSkills`.
|
|
1268
|
+
*
|
|
1269
|
+
* Removes a file from `destDir` only when ALL hold:
|
|
1270
|
+
* 1. It is a deployed artifact file (`.md` / `.mdc`), not `RULES-INDEX.md`
|
|
1271
|
+
* and not the sidecar manifest.
|
|
1272
|
+
* 2. Its stem is NOT in `desiredStems` (the source no longer ships it).
|
|
1273
|
+
* 3. It carries an AIWG ownership signal — either a `.aiwg-manifest.json`
|
|
1274
|
+
* sidecar entry, or an in-file `aiwg:managed` marker.
|
|
1275
|
+
*
|
|
1276
|
+
* User-authored files (no ownership signal) and current AIWG artifacts (stem
|
|
1277
|
+
* in the desired set) are never touched. Pruned files also have their sidecar
|
|
1278
|
+
* entry dropped so the manifest stays accurate.
|
|
1279
|
+
*
|
|
1280
|
+
* Safe to call on every deploy invocation: `desiredStems` is the global
|
|
1281
|
+
* (mode-independent) source set, so sibling-framework files are never
|
|
1282
|
+
* collateral.
|
|
1283
|
+
*
|
|
1284
|
+
* @param {string} destDir absolute path to the provider artifact dir
|
|
1285
|
+
* @param {Set<string>|string[]} desiredStems stems that should remain
|
|
1286
|
+
* @param {object} opts `{ dryRun, verbose }`
|
|
1287
|
+
* @returns {string[]} removed (or would-be-removed) file paths
|
|
1288
|
+
*/
|
|
1289
|
+
export function pruneStaleAiwgFiles(destDir, desiredStems, opts = {}) {
|
|
1290
|
+
const { dryRun = false, verbose = false } = opts;
|
|
1291
|
+
const removed = [];
|
|
1292
|
+
if (!destDir || !fs.existsSync(destDir)) return removed;
|
|
1293
|
+
|
|
1294
|
+
const desired = desiredStems instanceof Set ? desiredStems : new Set(desiredStems);
|
|
1295
|
+
const sidecar = readSidecarManifest(destDir) || { managed: {} };
|
|
1296
|
+
const managed = sidecar.managed || {};
|
|
1297
|
+
let sidecarDirty = false;
|
|
1298
|
+
|
|
1299
|
+
let entries;
|
|
1300
|
+
try {
|
|
1301
|
+
entries = fs.readdirSync(destDir, { withFileTypes: true });
|
|
1302
|
+
} catch {
|
|
1303
|
+
return removed;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
for (const entry of entries) {
|
|
1307
|
+
if (!entry.isFile()) continue;
|
|
1308
|
+
const name = entry.name;
|
|
1309
|
+
if (name === MANIFEST_FILENAME) continue;
|
|
1310
|
+
if (name === 'RULES-INDEX.md') continue;
|
|
1311
|
+
if (name === 'RULES-ONDEMAND.md') continue; // generated on-demand index (#1673)
|
|
1312
|
+
const lower = name.toLowerCase();
|
|
1313
|
+
if (!lower.endsWith('.md') && !lower.endsWith('.mdc')) continue;
|
|
1314
|
+
|
|
1315
|
+
if (desired.has(artifactStem(name))) continue;
|
|
1316
|
+
|
|
1317
|
+
// Ownership gate — never delete a file AIWG didn't deploy.
|
|
1318
|
+
let owned = Object.prototype.hasOwnProperty.call(managed, name);
|
|
1319
|
+
if (!owned) {
|
|
1320
|
+
try {
|
|
1321
|
+
owned = MANAGED_MARKER_RE.test(fs.readFileSync(path.join(destDir, name), 'utf8'));
|
|
1322
|
+
} catch {
|
|
1323
|
+
owned = false; // unreadable → leave it alone
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
if (!owned) continue;
|
|
1327
|
+
|
|
1328
|
+
const target = path.join(destDir, name);
|
|
1329
|
+
removed.push(target);
|
|
1330
|
+
if (dryRun) {
|
|
1331
|
+
if (verbose) console.log(`[dry-run] would prune stale AIWG artifact: ${path.relative(process.cwd(), target)}`);
|
|
1332
|
+
continue;
|
|
1333
|
+
}
|
|
1334
|
+
try {
|
|
1335
|
+
fs.unlinkSync(target);
|
|
1336
|
+
if (Object.prototype.hasOwnProperty.call(managed, name)) {
|
|
1337
|
+
delete managed[name];
|
|
1338
|
+
sidecarDirty = true;
|
|
1339
|
+
}
|
|
1340
|
+
if (verbose) console.log(`pruned stale AIWG artifact: ${path.relative(process.cwd(), target)}`);
|
|
1341
|
+
} catch (err) {
|
|
1342
|
+
removed.pop();
|
|
1343
|
+
if (verbose) console.warn(`Warning: could not prune ${target}: ${err.message}`);
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
if (sidecarDirty) writeSidecarManifest(destDir, sidecar, dryRun);
|
|
1348
|
+
return removed;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
/**
|
|
1352
|
+
* Deploy a skill directory (copy recursively).
|
|
1353
|
+
*
|
|
1354
|
+
* Platform handling (controlled by opts.provider):
|
|
1355
|
+
* - If the skill's SKILL.md has platforms: [all] → deploy to all, inject [provider] in deployed copy
|
|
1356
|
+
* - If no platforms: field → deploy to all, inject [provider] in deployed copy
|
|
1357
|
+
* - If explicit restriction list → only deploy if opts.provider is in the list; keep list in deployed copy
|
|
1358
|
+
*/
|
|
1359
|
+
export function deploySkillDir(skillDir, destDir, opts) {
|
|
1360
|
+
const { force = false, dryRun = false, provider, transformSkillMd } = opts;
|
|
1361
|
+
const verbose = opts.verbose === true;
|
|
1362
|
+
const skillName = path.basename(skillDir);
|
|
1363
|
+
|
|
1364
|
+
// Check SKILL.md for explicit platform restriction before deploying anything
|
|
1365
|
+
const skillMdPath = path.join(skillDir, 'SKILL.md');
|
|
1366
|
+
if (provider && fs.existsSync(skillMdPath)) {
|
|
1367
|
+
const skillContent = fs.readFileSync(skillMdPath, 'utf8');
|
|
1368
|
+
if (!skillMatchesProvider(skillContent, provider)) {
|
|
1369
|
+
if (verbose) console.log(`skip (platform restricted): ${skillName}`);
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
const destSkillDir = path.join(destDir, skillName);
|
|
1375
|
+
if (!dryRun) ensureDir(destSkillDir);
|
|
1376
|
+
|
|
1377
|
+
function copyRecursive(src, dest) {
|
|
1378
|
+
const entries = fs.readdirSync(src, { withFileTypes: true });
|
|
1379
|
+
for (const entry of entries) {
|
|
1380
|
+
const srcPath = path.join(src, entry.name);
|
|
1381
|
+
const destPath = path.join(dest, entry.name);
|
|
1382
|
+
|
|
1383
|
+
if (entry.isDirectory()) {
|
|
1384
|
+
if (!dryRun) ensureDir(destPath);
|
|
1385
|
+
copyRecursive(srcPath, destPath);
|
|
1386
|
+
} else {
|
|
1387
|
+
let srcContent = fs.readFileSync(srcPath, 'utf8');
|
|
1388
|
+
|
|
1389
|
+
// Inject target platform into SKILL.md — replaces [all] token with [provider-name]
|
|
1390
|
+
if (entry.name === 'SKILL.md' && provider) {
|
|
1391
|
+
const platformName = PROVIDER_TO_PLATFORM[provider] || provider;
|
|
1392
|
+
srcContent = injectPlatformInContent(srcContent, platformName);
|
|
1393
|
+
|
|
1394
|
+
// Provider-specific frontmatter transform (e.g. Factory remaps
|
|
1395
|
+
// commandHint.allowedTools / commandHint.model). Optional callback —
|
|
1396
|
+
// most providers leave SKILL.md alone after platform injection.
|
|
1397
|
+
if (typeof transformSkillMd === 'function') {
|
|
1398
|
+
srcContent = transformSkillMd(srcContent, opts) || srcContent;
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
if (fs.existsSync(destPath)) {
|
|
1403
|
+
const destContent = fs.readFileSync(destPath, 'utf8');
|
|
1404
|
+
if (destContent === srcContent && !force) {
|
|
1405
|
+
if (verbose) console.log(`skip (unchanged): ${path.relative(destDir, destPath)}`);
|
|
1406
|
+
continue;
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
if (dryRun) {
|
|
1411
|
+
console.log(`[dry-run] deploy ${srcPath} -> ${destPath}`);
|
|
1412
|
+
} else {
|
|
1413
|
+
fs.writeFileSync(destPath, srcContent, 'utf8');
|
|
1414
|
+
if (verbose) console.log(`deployed ${entry.name} -> ${path.relative(process.cwd(), destPath)}`);
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
copyRecursive(skillDir, destSkillDir);
|
|
1421
|
+
|
|
1422
|
+
// Drop a `.aiwg-managed` marker so future cleanup runs can identify
|
|
1423
|
+
// AIWG-deployed skills regardless of frontmatter shape (some providers
|
|
1424
|
+
// strip `namespace:` during transform). Cleanup keys off this presence
|
|
1425
|
+
// to safely prune renamed/removed source skills.
|
|
1426
|
+
if (!dryRun) {
|
|
1427
|
+
try {
|
|
1428
|
+
fs.writeFileSync(path.join(destSkillDir, '.aiwg-managed'), 'aiwg\n', 'utf8');
|
|
1429
|
+
} catch { /* non-fatal */ }
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
if (verbose) console.log(`deployed skill: ${skillName}`);
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
// ============================================================================
|
|
1436
|
+
// Workspace Initialization
|
|
1437
|
+
// ============================================================================
|
|
1438
|
+
|
|
1439
|
+
/**
|
|
1440
|
+
* Initialize framework-scoped workspace structure
|
|
1441
|
+
* Creates .aiwg/frameworks/{framework-id}/ directories
|
|
1442
|
+
*/
|
|
1443
|
+
export function initializeFrameworkWorkspace(target, mode, dryRun, srcRoot = null) {
|
|
1444
|
+
const aiwgBase = path.join(target, '.aiwg');
|
|
1445
|
+
const frameworksDir = path.join(aiwgBase, 'frameworks');
|
|
1446
|
+
const sharedDir = path.join(aiwgBase, 'shared');
|
|
1447
|
+
|
|
1448
|
+
const frameworkDirs = srcRoot
|
|
1449
|
+
? getFrameworksForMode(srcRoot, mode).map(fw => ({
|
|
1450
|
+
id: fw.id,
|
|
1451
|
+
path: fw.path,
|
|
1452
|
+
subdirs: fw.workspaceSubdirs,
|
|
1453
|
+
memoryCreates: Array.isArray(fw.manifest?.memory?.creates) ? fw.manifest.memory.creates : []
|
|
1454
|
+
}))
|
|
1455
|
+
: [];
|
|
1456
|
+
|
|
1457
|
+
// Backward-compatible fallback when source root isn't provided.
|
|
1458
|
+
if (frameworkDirs.length === 0) {
|
|
1459
|
+
if (mode === 'sdlc' || mode === 'both' || mode === 'all') {
|
|
1460
|
+
frameworkDirs.push({
|
|
1461
|
+
id: 'sdlc-complete',
|
|
1462
|
+
subdirs: ['repo', 'projects', 'working', 'archive']
|
|
1463
|
+
});
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
if (mode === 'marketing' || mode === 'all') {
|
|
1467
|
+
frameworkDirs.push({
|
|
1468
|
+
id: 'media-marketing-kit',
|
|
1469
|
+
subdirs: ['repo', 'campaigns', 'working', 'archive']
|
|
1470
|
+
});
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
if (mode === 'media-curator' || mode === 'all') {
|
|
1474
|
+
frameworkDirs.push({
|
|
1475
|
+
id: 'media-curator',
|
|
1476
|
+
subdirs: ['repo', 'library', 'working', 'archive']
|
|
1477
|
+
});
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
if (mode === 'research' || mode === 'all') {
|
|
1481
|
+
frameworkDirs.push({
|
|
1482
|
+
id: 'research-complete',
|
|
1483
|
+
subdirs: ['repo', 'corpus', 'working', 'archive']
|
|
1484
|
+
});
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
if (frameworkDirs.length === 0) return;
|
|
1489
|
+
|
|
1490
|
+
if (dryRun) {
|
|
1491
|
+
console.log('\n[dry-run] Would create framework-scoped workspace structure:');
|
|
1492
|
+
console.log(`[dry-run] ${aiwgBase}/`);
|
|
1493
|
+
console.log(`[dry-run] ${frameworksDir}/`);
|
|
1494
|
+
console.log(`[dry-run] ${sharedDir}/`);
|
|
1495
|
+
for (const fw of frameworkDirs) {
|
|
1496
|
+
for (const subdir of fw.subdirs) {
|
|
1497
|
+
console.log(`[dry-run] ${path.join(frameworksDir, fw.id, subdir)}/`);
|
|
1498
|
+
}
|
|
1499
|
+
for (const entry of fw.memoryCreates || []) {
|
|
1500
|
+
if (entry && typeof entry.path === 'string') {
|
|
1501
|
+
console.log(`[dry-run] ${path.join(target, entry.path)}${entry.path.endsWith('/') ? '/' : ''}`);
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
ensureDir(aiwgBase);
|
|
1509
|
+
ensureDir(frameworksDir);
|
|
1510
|
+
ensureDir(sharedDir);
|
|
1511
|
+
|
|
1512
|
+
for (const fw of frameworkDirs) {
|
|
1513
|
+
const fwBase = path.join(frameworksDir, fw.id);
|
|
1514
|
+
ensureDir(fwBase);
|
|
1515
|
+
for (const subdir of fw.subdirs) {
|
|
1516
|
+
ensureDir(path.join(fwBase, subdir));
|
|
1517
|
+
}
|
|
1518
|
+
initializeMemoryCreates(target, fw.path, fw.memoryCreates);
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
// Initialize registry.json if it doesn't exist
|
|
1522
|
+
const registryPath = path.join(frameworksDir, 'registry.json');
|
|
1523
|
+
if (!fs.existsSync(registryPath)) {
|
|
1524
|
+
const registry = {
|
|
1525
|
+
version: '1.0.0',
|
|
1526
|
+
created: new Date().toISOString(),
|
|
1527
|
+
frameworks: frameworkDirs.map(fw => ({
|
|
1528
|
+
id: fw.id,
|
|
1529
|
+
installed: new Date().toISOString(),
|
|
1530
|
+
version: '1.0.0'
|
|
1531
|
+
}))
|
|
1532
|
+
};
|
|
1533
|
+
fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2), 'utf8');
|
|
1534
|
+
console.log('Created framework registry at .aiwg/frameworks/registry.json');
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
|
|
1539
|
+
function initializeMemoryCreates(target, frameworkPath, creates) {
|
|
1540
|
+
for (const entry of creates || []) {
|
|
1541
|
+
if (!entry || typeof entry.path !== 'string') continue;
|
|
1542
|
+
if (!entry.path.startsWith('.aiwg/')) continue;
|
|
1543
|
+
|
|
1544
|
+
const targetPath = path.join(target, entry.path);
|
|
1545
|
+
const isDirectory = entry.path.endsWith('/') || path.extname(entry.path) === '';
|
|
1546
|
+
if (isDirectory) {
|
|
1547
|
+
ensureDir(targetPath);
|
|
1548
|
+
continue;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
ensureDir(path.dirname(targetPath));
|
|
1552
|
+
if (fs.existsSync(targetPath)) continue;
|
|
1553
|
+
|
|
1554
|
+
let content = null;
|
|
1555
|
+
if (typeof entry.template === 'string' && frameworkPath) {
|
|
1556
|
+
const templatePath = path.join(frameworkPath, entry.template);
|
|
1557
|
+
if (fs.existsSync(templatePath)) {
|
|
1558
|
+
content = fs.readFileSync(templatePath, 'utf8');
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
if (content === null) {
|
|
1563
|
+
const title = path.basename(entry.path, path.extname(entry.path))
|
|
1564
|
+
.replace(/[-_]+/g, ' ')
|
|
1565
|
+
.replace(/\b\w/g, c => c.toUpperCase());
|
|
1566
|
+
content = '# ' + title + '\n\n';
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
fs.writeFileSync(targetPath, content.endsWith('\n') ? content : content + '\n', 'utf8');
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// ============================================================================
|
|
1574
|
+
// AGENTS.md Template Handling
|
|
1575
|
+
// ============================================================================
|
|
1576
|
+
|
|
1577
|
+
/**
|
|
1578
|
+
* Build a Markdown "Repo Topology" block from .aiwg/aiwg.config remotes (#998).
|
|
1579
|
+
*
|
|
1580
|
+
* Returns an empty string when there's no `remotes` block configured — agents
|
|
1581
|
+
* should fall back to the today-default behavior in that case.
|
|
1582
|
+
*
|
|
1583
|
+
* The output is a small Markdown section suitable for token substitution into
|
|
1584
|
+
* AIWG.md / AGENTS.md / similar context files. URL resolution is best-effort:
|
|
1585
|
+
* when `git remote get-url <name>` fails (not a git repo, missing remote), the
|
|
1586
|
+
* remote name is shown without a URL.
|
|
1587
|
+
*
|
|
1588
|
+
* @param {string} targetDir - Project directory (the one that owns .aiwg/aiwg.config)
|
|
1589
|
+
* @returns {string} Markdown block (with leading/trailing blank lines), or '' when absent
|
|
1590
|
+
*/
|
|
1591
|
+
export function buildRemotesTopologyBlock(targetDir) {
|
|
1592
|
+
const cfgPath = path.join(targetDir, '.aiwg', 'aiwg.config');
|
|
1593
|
+
if (!fs.existsSync(cfgPath)) return '';
|
|
1594
|
+
|
|
1595
|
+
let cfg;
|
|
1596
|
+
try {
|
|
1597
|
+
cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
|
|
1598
|
+
} catch {
|
|
1599
|
+
return '';
|
|
1600
|
+
}
|
|
1601
|
+
if (!cfg || !cfg.remotes) return '';
|
|
1602
|
+
|
|
1603
|
+
// Apply the same defaults as resolveRemotes() in src/config/aiwg-config.ts.
|
|
1604
|
+
// Inlined here so the deploy path doesn't depend on the compiled TS bundle.
|
|
1605
|
+
const primary = cfg.remotes.primary || 'origin';
|
|
1606
|
+
const issueTracker = cfg.remotes.issue_tracker || primary;
|
|
1607
|
+
const ci = cfg.remotes.ci || primary;
|
|
1608
|
+
const secondary = Array.isArray(cfg.remotes.secondary) ? cfg.remotes.secondary : [];
|
|
1609
|
+
|
|
1610
|
+
function getUrl(remote) {
|
|
1611
|
+
try {
|
|
1612
|
+
return nodeExecSync(`git -C ${JSON.stringify(targetDir)} remote get-url ${JSON.stringify(remote)}`, {
|
|
1613
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1614
|
+
encoding: 'utf8',
|
|
1615
|
+
}).trim();
|
|
1616
|
+
} catch {
|
|
1617
|
+
return '';
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
const lines = [];
|
|
1622
|
+
lines.push('## Repo Topology');
|
|
1623
|
+
lines.push('');
|
|
1624
|
+
lines.push('Agents: respect this when picking remotes/providers. From `.aiwg/aiwg.config` `remotes` block (#994).');
|
|
1625
|
+
lines.push('');
|
|
1626
|
+
const primaryUrl = getUrl(primary);
|
|
1627
|
+
const primarySuffix = primaryUrl ? ` (${primaryUrl})` : '';
|
|
1628
|
+
lines.push(`- **Primary**: \`${primary}\`${primarySuffix} — issues, PRs, CI live here`);
|
|
1629
|
+
if (issueTracker !== primary) {
|
|
1630
|
+
const u = getUrl(issueTracker);
|
|
1631
|
+
lines.push(`- **Issue tracker**: \`${issueTracker}\`${u ? ` (${u})` : ''}`);
|
|
1632
|
+
}
|
|
1633
|
+
if (ci !== primary) {
|
|
1634
|
+
const u = getUrl(ci);
|
|
1635
|
+
lines.push(`- **CI**: \`${ci}\`${u ? ` (${u})` : ''}`);
|
|
1636
|
+
}
|
|
1637
|
+
for (const sec of secondary) {
|
|
1638
|
+
if (!sec || !sec.name) continue;
|
|
1639
|
+
const u = getUrl(sec.name);
|
|
1640
|
+
const purpose = sec.purpose ? ` — ${sec.purpose}` : '';
|
|
1641
|
+
const releaseTag = sec.push_on_release ? ' (push tags on release)' : '';
|
|
1642
|
+
lines.push(`- **Secondary**: \`${sec.name}\`${u ? ` (${u})` : ''}${purpose}${releaseTag}`);
|
|
1643
|
+
}
|
|
1644
|
+
lines.push('');
|
|
1645
|
+
return lines.join('\n');
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
/**
|
|
1649
|
+
* Substitute the topology + count tokens in template content. Shared by the
|
|
1650
|
+
* Claude hook file and createAgentsMdFromTemplate so every consumer gets the
|
|
1651
|
+
* same {{REMOTES_TOPOLOGY}} treatment without each provider reinventing it.
|
|
1652
|
+
*/
|
|
1653
|
+
export function interpolateContextTokens(content, opts) {
|
|
1654
|
+
const counts = opts?.counts || {};
|
|
1655
|
+
const topology = opts?.topology || '';
|
|
1656
|
+
const onDemandRules = opts?.onDemandRules || '';
|
|
1657
|
+
return content
|
|
1658
|
+
.replace(/\{\{AGENTS_COUNT\}\}/g, String(counts.agents || 0))
|
|
1659
|
+
.replace(/\{\{COMMANDS_COUNT\}\}/g, String(counts.commands || 0))
|
|
1660
|
+
.replace(/\{\{SKILLS_COUNT\}\}/g, String(counts.skills || 0))
|
|
1661
|
+
.replace(/\{\{RULES_COUNT\}\}/g, String(counts.rules || 0))
|
|
1662
|
+
.replace(/\{\{REMOTES_TOPOLOGY\}\}/g, topology)
|
|
1663
|
+
.replace(/\{\{ON_DEMAND_RULES\}\}/g, onDemandRules);
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
/**
|
|
1667
|
+
* Create or update AGENTS.md from template
|
|
1668
|
+
* Common logic used by multiple providers
|
|
1669
|
+
*/
|
|
1670
|
+
// Managed-block markers (#1571). The AIWG SDLC section is wrapped in these so
|
|
1671
|
+
// every later `aiwg use`/`aiwg refresh` can UPDATE it in place — append-once-and-
|
|
1672
|
+
// skip meant existing AGENTS.md bridges never received content updates.
|
|
1673
|
+
const AIWG_MD_BEGIN = '<!-- BEGIN AIWG-managed (auto-generated; edits between these markers are overwritten on redeploy) -->';
|
|
1674
|
+
const AIWG_MD_END = '<!-- END AIWG-managed -->';
|
|
1675
|
+
|
|
1676
|
+
/**
|
|
1677
|
+
* Inject/update an AIWG-managed section in a workspace markdown file (AGENTS.md,
|
|
1678
|
+
* SOUL.md, …) from a template. Wraps the section in BEGIN/END markers and updates
|
|
1679
|
+
* in place on redeploy (no-op if unchanged), preserving operator content outside
|
|
1680
|
+
* the markers and migrating legacy unmarked sections. (#1571 / #1572)
|
|
1681
|
+
*
|
|
1682
|
+
* @param {string} target - directory containing the dest file
|
|
1683
|
+
* @param {string} destFilename - e.g. 'AGENTS.md' or 'SOUL.md'
|
|
1684
|
+
* @param {string} srcRoot
|
|
1685
|
+
* @param {string} templateSubpath - relative to sdlc-complete/templates
|
|
1686
|
+
* @param {boolean} dryRun
|
|
1687
|
+
* @param {{sectionMarker?: string, legacyHeading?: string}} [opts]
|
|
1688
|
+
*/
|
|
1689
|
+
export function createManagedMdFromTemplate(target, destFilename, srcRoot, templateSubpath, dryRun, opts = {}) {
|
|
1690
|
+
const sectionMarker = opts.sectionMarker || '<!-- AIWG SDLC Framework Integration -->';
|
|
1691
|
+
const legacyHeading = opts.legacyHeading || '## AIWG SDLC Framework';
|
|
1692
|
+
const templatePath = path.join(srcRoot, 'agentic', 'code', 'frameworks', 'sdlc-complete', 'templates', templateSubpath);
|
|
1693
|
+
const destPath = path.join(target, destFilename);
|
|
1694
|
+
|
|
1695
|
+
if (!fs.existsSync(templatePath)) {
|
|
1696
|
+
console.warn(`${destFilename} template not found at ${templatePath}`);
|
|
1697
|
+
return;
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
let template = fs.readFileSync(templatePath, 'utf8');
|
|
1701
|
+
// Token interpolation — gives every template-based provider {{REMOTES_TOPOLOGY}},
|
|
1702
|
+
// {{ON_DEMAND_RULES}} (#1675), and the shared count tokens for free. The
|
|
1703
|
+
// on-demand list is computed lazily only when the template references it.
|
|
1704
|
+
template = interpolateContextTokens(template, {
|
|
1705
|
+
topology: buildRemotesTopologyBlock(target),
|
|
1706
|
+
onDemandRules: template.includes('{{ON_DEMAND_RULES}}')
|
|
1707
|
+
? renderOnDemandRuleSection(listOnDemandRuleFiles(srcRoot), { heading: '### On-Demand Rules' })
|
|
1708
|
+
: '',
|
|
1709
|
+
});
|
|
1710
|
+
|
|
1711
|
+
// Extract the AIWG section from the template (everything from its section
|
|
1712
|
+
// marker onward) and wrap it in managed markers so redeploys can update it.
|
|
1713
|
+
const tmplMarker = template.indexOf(sectionMarker);
|
|
1714
|
+
const aiwgSection = (tmplMarker !== -1 ? template.slice(tmplMarker) : template).trim();
|
|
1715
|
+
const templatePrefix = tmplMarker !== -1 ? template.slice(0, tmplMarker).trim() : '';
|
|
1716
|
+
const managedBlock = `${AIWG_MD_BEGIN}\n${aiwgSection}\n${AIWG_MD_END}`;
|
|
1717
|
+
|
|
1718
|
+
const write = (content, verb) => {
|
|
1719
|
+
if (dryRun) {
|
|
1720
|
+
console.log(`[dry-run] Would ${verb} ${destFilename} AIWG-managed section`);
|
|
1721
|
+
} else {
|
|
1722
|
+
fs.writeFileSync(destPath, content, 'utf8');
|
|
1723
|
+
console.log(`${verb[0].toUpperCase()}${verb.slice(1)} ${destFilename} AIWG-managed section`);
|
|
1724
|
+
}
|
|
1725
|
+
};
|
|
1726
|
+
|
|
1727
|
+
// 1. Fresh file — write prefix (if any) + managed block.
|
|
1728
|
+
if (!fs.existsSync(destPath)) {
|
|
1729
|
+
const out = (templatePrefix ? templatePrefix + '\n\n---\n\n' : '') + managedBlock + '\n';
|
|
1730
|
+
write(out, 'create');
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
const existing = fs.readFileSync(destPath, 'utf8');
|
|
1735
|
+
const begIdx = existing.indexOf(AIWG_MD_BEGIN);
|
|
1736
|
+
const endIdx = existing.indexOf(AIWG_MD_END);
|
|
1737
|
+
|
|
1738
|
+
// 2. Markers present — replace the managed block in place, preserve everything outside.
|
|
1739
|
+
if (begIdx !== -1 && endIdx !== -1 && endIdx > begIdx) {
|
|
1740
|
+
const updated = existing.slice(0, begIdx) + managedBlock + existing.slice(endIdx + AIWG_MD_END.length);
|
|
1741
|
+
if (updated === existing) return; // already current — no-op
|
|
1742
|
+
write(updated, 'update');
|
|
1743
|
+
return;
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
// 3. Legacy unmarked AIWG section — migrate to the managed block. The section
|
|
1747
|
+
// is always EOF-appended, so the operator prefix is everything before it.
|
|
1748
|
+
const legacyMarkerIdx = existing.indexOf(sectionMarker);
|
|
1749
|
+
const legacyIdx = legacyMarkerIdx !== -1 ? legacyMarkerIdx : existing.indexOf(legacyHeading);
|
|
1750
|
+
if (legacyIdx !== -1) {
|
|
1751
|
+
const prefix = existing.slice(0, legacyIdx).replace(/\n+---\s*$/, '').trimEnd();
|
|
1752
|
+
write(prefix + '\n\n---\n\n' + managedBlock + '\n', 'migrate');
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
// 4. No AIWG section at all — append the managed block.
|
|
1757
|
+
write(existing.trimEnd() + '\n\n---\n\n' + managedBlock + '\n', 'append');
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
/** AGENTS.md bridge — thin wrapper over createManagedMdFromTemplate (#1571). */
|
|
1761
|
+
export function createAgentsMdFromTemplate(target, srcRoot, templateSubpath, dryRun) {
|
|
1762
|
+
createManagedMdFromTemplate(target, 'AGENTS.md', srcRoot, templateSubpath, dryRun, {
|
|
1763
|
+
sectionMarker: '<!-- AIWG SDLC Framework Integration -->',
|
|
1764
|
+
legacyHeading: '## AIWG SDLC Framework',
|
|
1765
|
+
});
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
// ============================================================================
|
|
1769
|
+
// Agent Filtering
|
|
1770
|
+
// ============================================================================
|
|
1771
|
+
|
|
1772
|
+
/**
|
|
1773
|
+
* Check if an agent should be deployed based on filter options
|
|
1774
|
+
* @param {string} agentPath - Path to agent file
|
|
1775
|
+
* @param {object} metadata - Parsed frontmatter metadata
|
|
1776
|
+
* @param {object} opts - Options including filter and filterRole
|
|
1777
|
+
* @returns {boolean} - True if agent should be deployed
|
|
1778
|
+
*/
|
|
1779
|
+
export function shouldDeployAgent(agentPath, metadata, opts) {
|
|
1780
|
+
const { filter, filterRole } = opts;
|
|
1781
|
+
|
|
1782
|
+
// No filters - deploy everything
|
|
1783
|
+
if (!filter && !filterRole) return true;
|
|
1784
|
+
|
|
1785
|
+
// Filter by role (model tier)
|
|
1786
|
+
if (filterRole) {
|
|
1787
|
+
const role = classifyModelRole(metadata.model, { defaultRole: 'coding' });
|
|
1788
|
+
if (role !== filterRole.toLowerCase()) {
|
|
1789
|
+
return false;
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
// Filter by glob pattern
|
|
1794
|
+
if (filter) {
|
|
1795
|
+
const agentName = path.basename(agentPath, '.md');
|
|
1796
|
+
if (!matchesGlob(agentName, filter)) {
|
|
1797
|
+
return false;
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
return true;
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
/**
|
|
1805
|
+
* Simple glob pattern matching
|
|
1806
|
+
* Supports * (match any characters) and ? (match single character)
|
|
1807
|
+
* @param {string} str - String to match
|
|
1808
|
+
* @param {string} pattern - Glob pattern
|
|
1809
|
+
* @returns {boolean} - True if matches
|
|
1810
|
+
*/
|
|
1811
|
+
export function matchesGlob(str, pattern) {
|
|
1812
|
+
// Convert glob to regex
|
|
1813
|
+
const regexPattern = pattern
|
|
1814
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape regex special chars except * and ?
|
|
1815
|
+
.replace(/\*/g, '.*') // * matches any characters
|
|
1816
|
+
.replace(/\?/g, '.'); // ? matches single character
|
|
1817
|
+
|
|
1818
|
+
const regex = new RegExp(`^${regexPattern}$`, 'i');
|
|
1819
|
+
return regex.test(str);
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
/**
|
|
1823
|
+
* Filter a list of agent files based on filter options
|
|
1824
|
+
* @param {string[]} files - List of file paths
|
|
1825
|
+
* @param {object} opts - Options including filter and filterRole
|
|
1826
|
+
* @returns {string[]} - Filtered list of file paths
|
|
1827
|
+
*/
|
|
1828
|
+
export function filterAgentFiles(files, opts) {
|
|
1829
|
+
const { filter, filterRole } = opts;
|
|
1830
|
+
|
|
1831
|
+
// No filters - return all
|
|
1832
|
+
if (!filter && !filterRole) return files;
|
|
1833
|
+
|
|
1834
|
+
return files.filter(filePath => {
|
|
1835
|
+
// Read and parse frontmatter to get metadata
|
|
1836
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
1837
|
+
const { metadata } = parseFrontmatter(content);
|
|
1838
|
+
return shouldDeployAgent(filePath, metadata, opts);
|
|
1839
|
+
});
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
// ============================================================================
|
|
1843
|
+
// Framework Discovery
|
|
1844
|
+
// ============================================================================
|
|
1845
|
+
|
|
1846
|
+
const MODE_ALIASES = {
|
|
1847
|
+
writing: 'general',
|
|
1848
|
+
mmk: 'marketing'
|
|
1849
|
+
};
|
|
1850
|
+
|
|
1851
|
+
const LEGACY_FRAMEWORK_MODE_ALIASES = {
|
|
1852
|
+
'sdlc-complete': ['sdlc'],
|
|
1853
|
+
'media-marketing-kit': ['marketing', 'mmk'],
|
|
1854
|
+
'media-curator': ['media-curator'],
|
|
1855
|
+
'research-complete': ['research']
|
|
1856
|
+
};
|
|
1857
|
+
|
|
1858
|
+
const DEFAULT_FRAMEWORK_SUBDIRS = {
|
|
1859
|
+
'sdlc-complete': ['repo', 'projects', 'working', 'archive'],
|
|
1860
|
+
'media-marketing-kit': ['repo', 'campaigns', 'working', 'archive'],
|
|
1861
|
+
'media-curator': ['repo', 'library', 'working', 'archive'],
|
|
1862
|
+
'research-complete': ['repo', 'corpus', 'working', 'archive']
|
|
1863
|
+
};
|
|
1864
|
+
|
|
1865
|
+
function normalizePathSegment(segment) {
|
|
1866
|
+
return String(segment || '')
|
|
1867
|
+
.replace(/^\/+/, '')
|
|
1868
|
+
.replace(/\/+$/, '');
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
function ensureStringArray(value) {
|
|
1872
|
+
if (!value) return [];
|
|
1873
|
+
return Array.isArray(value) ? value.map(v => String(v)) : [String(value)];
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
function uniqueLower(values) {
|
|
1877
|
+
const seen = new Set();
|
|
1878
|
+
const out = [];
|
|
1879
|
+
for (const raw of values) {
|
|
1880
|
+
const v = String(raw || '').trim().toLowerCase();
|
|
1881
|
+
if (!v || seen.has(v)) continue;
|
|
1882
|
+
seen.add(v);
|
|
1883
|
+
out.push(v);
|
|
1884
|
+
}
|
|
1885
|
+
return out;
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
export function normalizeDeploymentMode(mode = 'all') {
|
|
1889
|
+
const normalized = String(mode || 'all').toLowerCase();
|
|
1890
|
+
return MODE_ALIASES[normalized] || normalized;
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
function resolveFrameworkComponentDir(frameworkPath, manifest, component) {
|
|
1894
|
+
const entry = manifest?.entry?.[component];
|
|
1895
|
+
const relPath = normalizePathSegment(entry || component);
|
|
1896
|
+
return path.join(frameworkPath, relPath);
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
/**
|
|
1900
|
+
* Discover framework roots under agentic/code/frameworks.
|
|
1901
|
+
* Framework metadata is loaded from root manifest.json when present.
|
|
1902
|
+
*/
|
|
1903
|
+
export function discoverFrameworks(srcRoot) {
|
|
1904
|
+
const frameworksRoot = path.join(srcRoot, 'agentic', 'code', 'frameworks');
|
|
1905
|
+
if (!fs.existsSync(frameworksRoot)) return [];
|
|
1906
|
+
|
|
1907
|
+
const frameworks = [];
|
|
1908
|
+
const entries = fs.readdirSync(frameworksRoot, { withFileTypes: true })
|
|
1909
|
+
.filter(e => e.isDirectory())
|
|
1910
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
1911
|
+
|
|
1912
|
+
for (const entry of entries) {
|
|
1913
|
+
const frameworkPath = path.join(frameworksRoot, entry.name);
|
|
1914
|
+
const manifestPath = path.join(frameworkPath, 'manifest.json');
|
|
1915
|
+
let manifest = {};
|
|
1916
|
+
|
|
1917
|
+
if (fs.existsSync(manifestPath)) {
|
|
1918
|
+
try {
|
|
1919
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
1920
|
+
} catch (e) {
|
|
1921
|
+
console.warn(`Warning: Could not parse framework manifest for ${entry.name}: ${e.message}`);
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
const id = String(manifest.id || manifest.framework || entry.name);
|
|
1926
|
+
const aliases = uniqueLower([
|
|
1927
|
+
id,
|
|
1928
|
+
entry.name,
|
|
1929
|
+
...ensureStringArray(manifest.modeAliases),
|
|
1930
|
+
...ensureStringArray(manifest.aliases),
|
|
1931
|
+
...(LEGACY_FRAMEWORK_MODE_ALIASES[id] || [])
|
|
1932
|
+
]);
|
|
1933
|
+
|
|
1934
|
+
const workspaceSubdirs = ensureStringArray(
|
|
1935
|
+
manifest.workspace?.subdirs || manifest.workspace?.directories
|
|
1936
|
+
);
|
|
1937
|
+
|
|
1938
|
+
const agentsDir = resolveFrameworkComponentDir(frameworkPath, manifest, 'agents');
|
|
1939
|
+
const commandsDir = resolveFrameworkComponentDir(frameworkPath, manifest, 'commands');
|
|
1940
|
+
const skillsDir = resolveFrameworkComponentDir(frameworkPath, manifest, 'skills');
|
|
1941
|
+
const rulesDir = resolveFrameworkComponentDir(frameworkPath, manifest, 'rules');
|
|
1942
|
+
|
|
1943
|
+
frameworks.push({
|
|
1944
|
+
id,
|
|
1945
|
+
name: manifest.name || entry.name,
|
|
1946
|
+
path: frameworkPath,
|
|
1947
|
+
manifest,
|
|
1948
|
+
aliases,
|
|
1949
|
+
workspaceSubdirs: workspaceSubdirs.length > 0
|
|
1950
|
+
? workspaceSubdirs
|
|
1951
|
+
: (DEFAULT_FRAMEWORK_SUBDIRS[id] || ['repo', 'working', 'archive']),
|
|
1952
|
+
components: {
|
|
1953
|
+
agents: { path: agentsDir, exists: fs.existsSync(agentsDir) },
|
|
1954
|
+
commands: { path: commandsDir, exists: fs.existsSync(commandsDir) },
|
|
1955
|
+
skills: { path: skillsDir, exists: fs.existsSync(skillsDir) },
|
|
1956
|
+
rules: { path: rulesDir, exists: fs.existsSync(rulesDir) }
|
|
1957
|
+
}
|
|
1958
|
+
});
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
return frameworks;
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
/**
|
|
1965
|
+
* Select frameworks for a deployment mode.
|
|
1966
|
+
*/
|
|
1967
|
+
export function getFrameworksForMode(srcRoot, mode) {
|
|
1968
|
+
const normalizedMode = normalizeDeploymentMode(mode);
|
|
1969
|
+
const frameworks = discoverFrameworks(srcRoot);
|
|
1970
|
+
|
|
1971
|
+
if (normalizedMode === 'all') return frameworks;
|
|
1972
|
+
if (normalizedMode === 'general') return [];
|
|
1973
|
+
if (normalizedMode === 'both') {
|
|
1974
|
+
return frameworks.filter(fw => fw.aliases.includes('sdlc'));
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
return frameworks.filter(fw =>
|
|
1978
|
+
fw.id.toLowerCase() === normalizedMode || fw.aliases.includes(normalizedMode)
|
|
1979
|
+
);
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
/**
|
|
1983
|
+
* Collect framework artifacts for a deployment mode.
|
|
1984
|
+
* @param {string} srcRoot - Source root directory
|
|
1985
|
+
* @param {string} mode - Deployment mode
|
|
1986
|
+
* @param {object} options - Collection options
|
|
1987
|
+
* @param {boolean} options.includeAgents - Include agents
|
|
1988
|
+
* @param {boolean} options.includeCommands - Include commands
|
|
1989
|
+
* @param {boolean} options.includeSkills - Include skills
|
|
1990
|
+
* @param {boolean} options.includeRules - Include rules
|
|
1991
|
+
* @param {boolean} options.recursiveCommands - Use recursive command listing
|
|
1992
|
+
* @param {boolean} options.consolidatedSdlcRules - Use RULES-INDEX for SDLC when available
|
|
1993
|
+
* @returns {{frameworks: Array, agents: string[], commands: string[], skills: string[], rules: string[]}}
|
|
1994
|
+
*/
|
|
1995
|
+
export function collectFrameworkArtifacts(srcRoot, mode, options = {}) {
|
|
1996
|
+
const {
|
|
1997
|
+
includeAgents = true,
|
|
1998
|
+
includeCommands = true,
|
|
1999
|
+
includeSkills = true,
|
|
2000
|
+
includeRules = true,
|
|
2001
|
+
recursiveCommands = true,
|
|
2002
|
+
consolidatedSdlcRules = true
|
|
2003
|
+
} = options;
|
|
2004
|
+
|
|
2005
|
+
const frameworks = getFrameworksForMode(srcRoot, mode);
|
|
2006
|
+
const artifacts = {
|
|
2007
|
+
frameworks,
|
|
2008
|
+
agents: [],
|
|
2009
|
+
souls: [],
|
|
2010
|
+
commands: [],
|
|
2011
|
+
skills: [],
|
|
2012
|
+
rules: []
|
|
2013
|
+
};
|
|
2014
|
+
|
|
2015
|
+
for (const framework of frameworks) {
|
|
2016
|
+
if (includeAgents && framework.components.agents.exists) {
|
|
2017
|
+
artifacts.agents.push(...listMdFiles(framework.components.agents.path));
|
|
2018
|
+
artifacts.souls.push(...listSoulFiles(framework.components.agents.path));
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
if (includeCommands && framework.components.commands.exists) {
|
|
2022
|
+
const commandFiles = recursiveCommands
|
|
2023
|
+
? listMdFilesRecursive(framework.components.commands.path)
|
|
2024
|
+
: listMdFiles(framework.components.commands.path);
|
|
2025
|
+
artifacts.commands.push(...commandFiles);
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
if (includeSkills && framework.components.skills.exists) {
|
|
2029
|
+
artifacts.skills.push(...listSkillDirs(framework.components.skills.path));
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
if (includeRules && framework.components.rules.exists) {
|
|
2033
|
+
if (consolidatedSdlcRules && framework.id === 'sdlc-complete') {
|
|
2034
|
+
const indexPath = getRulesIndexPath(srcRoot);
|
|
2035
|
+
if (indexPath) {
|
|
2036
|
+
artifacts.rules.push(indexPath);
|
|
2037
|
+
// PUW-016 (#1117): also include individual rule files referenced
|
|
2038
|
+
// by the index. RULES-INDEX.md links to per-rule files; if those
|
|
2039
|
+
// files don't ship the links resolve to nowhere. Filter the
|
|
2040
|
+
// index file itself out of listMdFiles so it isn't pushed twice.
|
|
2041
|
+
const indexBase = indexPath.split('/').pop();
|
|
2042
|
+
artifacts.rules.push(
|
|
2043
|
+
...listMdFiles(framework.components.rules.path)
|
|
2044
|
+
.filter((f) => !f.endsWith(indexBase))
|
|
2045
|
+
.filter(isAlwaysOnRule) // tier gate (#1673)
|
|
2046
|
+
);
|
|
2047
|
+
continue;
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
// tier gate (#1673): inline only always-on (CRITICAL/HIGH) rules
|
|
2051
|
+
artifacts.rules.push(...listMdFiles(framework.components.rules.path).filter(isAlwaysOnRule));
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
return artifacts;
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
// ============================================================================
|
|
2059
|
+
// Addon Discovery
|
|
2060
|
+
// ============================================================================
|
|
2061
|
+
|
|
2062
|
+
/**
|
|
2063
|
+
* Discover all addons in the agentic/code/addons directory
|
|
2064
|
+
* @param {string} srcRoot - Source root directory
|
|
2065
|
+
* @returns {Array<{name: string, path: string, manifest: object}>} - Array of addon info
|
|
2066
|
+
*/
|
|
2067
|
+
export function discoverAddons(srcRoot) {
|
|
2068
|
+
const addonsDir = path.join(srcRoot, 'agentic', 'code', 'addons');
|
|
2069
|
+
if (!fs.existsSync(addonsDir)) return [];
|
|
2070
|
+
|
|
2071
|
+
const addons = [];
|
|
2072
|
+
for (const entry of fs.readdirSync(addonsDir, { withFileTypes: true })) {
|
|
2073
|
+
if (!entry.isDirectory()) continue;
|
|
2074
|
+
|
|
2075
|
+
const addonPath = path.join(addonsDir, entry.name);
|
|
2076
|
+
const manifestPath = path.join(addonPath, 'manifest.json');
|
|
2077
|
+
|
|
2078
|
+
let manifest = {};
|
|
2079
|
+
if (fs.existsSync(manifestPath)) {
|
|
2080
|
+
try {
|
|
2081
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
2082
|
+
} catch (e) {
|
|
2083
|
+
console.warn(`Warning: Could not parse manifest for addon ${entry.name}: ${e.message}`);
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
// Skip addons marked devOnly — they are contributor tools, not end-user deployables
|
|
2088
|
+
if (manifest.devOnly === true) continue;
|
|
2089
|
+
|
|
2090
|
+
addons.push({
|
|
2091
|
+
name: entry.name,
|
|
2092
|
+
path: addonPath,
|
|
2093
|
+
manifest
|
|
2094
|
+
});
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
return addons;
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
/**
|
|
2101
|
+
* Get all agent files from all addons
|
|
2102
|
+
* @param {string} srcRoot - Source root directory
|
|
2103
|
+
* @param {string[]} excludeAddons - Addon names to exclude (default: none)
|
|
2104
|
+
* @returns {string[]} - Array of agent file paths
|
|
2105
|
+
*/
|
|
2106
|
+
export function getAddonAgentFiles(srcRoot, excludeAddons = []) {
|
|
2107
|
+
const addons = discoverAddons(srcRoot);
|
|
2108
|
+
const files = [];
|
|
2109
|
+
|
|
2110
|
+
for (const addon of addons) {
|
|
2111
|
+
if (excludeAddons.includes(addon.name)) continue;
|
|
2112
|
+
|
|
2113
|
+
const agentsDir = path.join(addon.path, 'agents');
|
|
2114
|
+
if (fs.existsSync(agentsDir)) {
|
|
2115
|
+
files.push(...listMdFiles(agentsDir));
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
return files;
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
/**
|
|
2123
|
+
* Get all command files from all addons
|
|
2124
|
+
* @param {string} srcRoot - Source root directory
|
|
2125
|
+
* @param {string[]} excludeAddons - Addon names to exclude (default: none)
|
|
2126
|
+
* @returns {string[]} - Array of command file paths
|
|
2127
|
+
*/
|
|
2128
|
+
export function getAddonCommandFiles(srcRoot, excludeAddons = []) {
|
|
2129
|
+
const addons = discoverAddons(srcRoot);
|
|
2130
|
+
const files = [];
|
|
2131
|
+
|
|
2132
|
+
for (const addon of addons) {
|
|
2133
|
+
if (excludeAddons.includes(addon.name)) continue;
|
|
2134
|
+
|
|
2135
|
+
const commandsDir = path.join(addon.path, 'commands');
|
|
2136
|
+
if (fs.existsSync(commandsDir)) {
|
|
2137
|
+
files.push(...listMdFiles(commandsDir));
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
return files;
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
/**
|
|
2145
|
+
* Get all skill directories from all addons
|
|
2146
|
+
* @param {string} srcRoot - Source root directory
|
|
2147
|
+
* @param {string[]} excludeAddons - Addon names to exclude (default: none)
|
|
2148
|
+
* @returns {string[]} - Array of skill directory paths
|
|
2149
|
+
*/
|
|
2150
|
+
export function getAddonSkillDirs(srcRoot, excludeAddons = []) {
|
|
2151
|
+
const addons = discoverAddons(srcRoot);
|
|
2152
|
+
const dirs = [];
|
|
2153
|
+
|
|
2154
|
+
for (const addon of addons) {
|
|
2155
|
+
if (excludeAddons.includes(addon.name)) continue;
|
|
2156
|
+
|
|
2157
|
+
const skillsDir = path.join(addon.path, 'skills');
|
|
2158
|
+
if (fs.existsSync(skillsDir)) {
|
|
2159
|
+
dirs.push(...listSkillDirs(skillsDir));
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
return dirs;
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
/**
|
|
2167
|
+
* Get all rule files from all addons
|
|
2168
|
+
* @param {string} srcRoot - Source root directory
|
|
2169
|
+
* @param {string[]} excludeAddons - Addon names to exclude (default: none)
|
|
2170
|
+
* @returns {string[]} - Array of rule file paths
|
|
2171
|
+
*/
|
|
2172
|
+
/**
|
|
2173
|
+
* Read a rule's enforcement level from its `enforcement:` frontmatter
|
|
2174
|
+
* (#1673). Returns 'critical' | 'high' | 'medium' | 'low' | null.
|
|
2175
|
+
*/
|
|
2176
|
+
export function ruleEnforcementLevel(content) {
|
|
2177
|
+
const m = content.match(/^---\n([\s\S]*?)\n---/);
|
|
2178
|
+
if (!m) return null;
|
|
2179
|
+
const e = m[1].match(/^enforcement:\s*([A-Za-z]+)/m);
|
|
2180
|
+
return e ? e[1].toLowerCase() : null;
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
/**
|
|
2184
|
+
* Tier gate (#1673, enforcement-tiered deployment ADR). Only CRITICAL and HIGH
|
|
2185
|
+
* rules are inlined into a provider's always-on rule directory; MEDIUM/LOW are
|
|
2186
|
+
* left on-demand (reachable via `aiwg show rule <name>` and the assembled
|
|
2187
|
+
* RULES-INDEX pointer index). Index files and rules with no enforcement marker
|
|
2188
|
+
* default to always-on, so an un-triaged or structural file is never dropped.
|
|
2189
|
+
*/
|
|
2190
|
+
export function isAlwaysOnRule(filePath) {
|
|
2191
|
+
const base = path.basename(filePath);
|
|
2192
|
+
if (base === 'RULES-INDEX.md' || base === 'RULES-ONDEMAND.md') return true;
|
|
2193
|
+
try {
|
|
2194
|
+
const lvl = ruleEnforcementLevel(fs.readFileSync(filePath, 'utf8'));
|
|
2195
|
+
return lvl !== 'medium' && lvl !== 'low';
|
|
2196
|
+
} catch {
|
|
2197
|
+
return true; // unreadable → keep (safe default)
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
/**
|
|
2202
|
+
* Enumerate MEDIUM/LOW rule files for the whole installed AIWG corpus.
|
|
2203
|
+
* `srcRoot` may be the repository root or a bundled framework/addon/extension
|
|
2204
|
+
* root during a multi-pass deploy; normalize it before discovery so a later
|
|
2205
|
+
* pass never truncates an index written by an earlier pass.
|
|
2206
|
+
*/
|
|
2207
|
+
export function listOnDemandRuleFiles(srcRoot, excludeAddons = []) {
|
|
2208
|
+
const out = [];
|
|
2209
|
+
const aiwgRoot = resolveAiwgRoot(srcRoot) || srcRoot;
|
|
2210
|
+
const consider = (dir) => {
|
|
2211
|
+
if (!fs.existsSync(dir)) return;
|
|
2212
|
+
for (const f of listMdFiles(dir)) {
|
|
2213
|
+
const b = path.basename(f);
|
|
2214
|
+
if (b === 'RULES-INDEX.md' || b === 'RULES-ONDEMAND.md') continue;
|
|
2215
|
+
if (!isAlwaysOnRule(f)) out.push(f);
|
|
2216
|
+
}
|
|
2217
|
+
};
|
|
2218
|
+
for (const addon of discoverAddons(aiwgRoot)) {
|
|
2219
|
+
if (excludeAddons.includes(addon.name)) continue;
|
|
2220
|
+
consider(path.join(addon.path, 'rules'));
|
|
2221
|
+
}
|
|
2222
|
+
const codeRoot = path.join(aiwgRoot, 'agentic', 'code');
|
|
2223
|
+
for (const fwDir of ['frameworks', 'extensions']) {
|
|
2224
|
+
const base = path.join(codeRoot, fwDir);
|
|
2225
|
+
if (!fs.existsSync(base)) continue;
|
|
2226
|
+
for (const entry of fs.readdirSync(base, { withFileTypes: true })) {
|
|
2227
|
+
if (entry.isDirectory()) consider(path.join(base, entry.name, 'rules'));
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
return out;
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
/**
|
|
2234
|
+
* Sorted, de-duplicated rule names (basename minus `.md`) from a list of
|
|
2235
|
+
* on-demand rule file paths. Shared by the discrete index writer and the
|
|
2236
|
+
* aggregated-bridge section renderer so both stay in lock-step.
|
|
2237
|
+
*/
|
|
2238
|
+
export function onDemandRuleNames(onDemandFiles, exclude = []) {
|
|
2239
|
+
const skip = new Set(exclude);
|
|
2240
|
+
return [...new Set((onDemandFiles || []).map((f) => path.basename(f).replace(/\.md$/, '')))]
|
|
2241
|
+
.filter((n) => !skip.has(n))
|
|
2242
|
+
.sort();
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
/**
|
|
2246
|
+
* Render the on-demand rule list as a markdown section (#1675) for aggregated
|
|
2247
|
+
* providers whose rules live in a single bridge file (WARP.md, AGENTS.md)
|
|
2248
|
+
* rather than a discrete `RULES-ONDEMAND.md`. Returns '' when nothing is
|
|
2249
|
+
* on-demand so callers can skip the section entirely. `exclude` drops names
|
|
2250
|
+
* already inlined verbatim in the bridge (e.g. Warp's aiwg-utils set).
|
|
2251
|
+
*/
|
|
2252
|
+
export function renderOnDemandRuleSection(onDemandFiles, opts = {}) {
|
|
2253
|
+
const names = onDemandRuleNames(onDemandFiles, opts.exclude || []);
|
|
2254
|
+
if (names.length === 0) return '';
|
|
2255
|
+
const heading = opts.heading || '## On-Demand Rules';
|
|
2256
|
+
return [
|
|
2257
|
+
heading,
|
|
2258
|
+
'',
|
|
2259
|
+
'These MEDIUM/LOW-enforcement rules are not inlined here, to keep the',
|
|
2260
|
+
'always-on context small. They still apply when relevant — fetch any body',
|
|
2261
|
+
'on demand:',
|
|
2262
|
+
'',
|
|
2263
|
+
'```bash',
|
|
2264
|
+
'aiwg show rule <name>',
|
|
2265
|
+
'```',
|
|
2266
|
+
'',
|
|
2267
|
+
...names.map((n) => `- \`${n}\` — \`aiwg show rule ${n}\``),
|
|
2268
|
+
].join('\n');
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
/**
|
|
2272
|
+
* Write a compact on-demand rule index (#1673) into a provider's rule dir.
|
|
2273
|
+
* Lists the MEDIUM/LOW rules that are NOT inlined at startup, with the
|
|
2274
|
+
* `aiwg show rule <name>` fetch hint. Removes a stale index when empty.
|
|
2275
|
+
*/
|
|
2276
|
+
export function writeOnDemandRuleIndex(destDir, onDemandFiles, opts = {}) {
|
|
2277
|
+
const indexPath = path.join(destDir, 'RULES-ONDEMAND.md');
|
|
2278
|
+
const names = onDemandRuleNames(onDemandFiles);
|
|
2279
|
+
if (names.length === 0) {
|
|
2280
|
+
try {
|
|
2281
|
+
if (fs.existsSync(indexPath) && !opts.dryRun) fs.rmSync(indexPath);
|
|
2282
|
+
} catch { /* ignore */ }
|
|
2283
|
+
return 0;
|
|
2284
|
+
}
|
|
2285
|
+
const lines = [
|
|
2286
|
+
'# On-Demand Rules (not inlined at startup)',
|
|
2287
|
+
'',
|
|
2288
|
+
'These MEDIUM/LOW-enforcement rules are not loaded into every session, to keep',
|
|
2289
|
+
'the standard-context startup budget small. They still apply when relevant —',
|
|
2290
|
+
'fetch any rule body on demand:',
|
|
2291
|
+
'',
|
|
2292
|
+
'```bash',
|
|
2293
|
+
'aiwg show rule <name>',
|
|
2294
|
+
'```',
|
|
2295
|
+
'',
|
|
2296
|
+
...names.map((n) => `- \`${n}\` — \`aiwg show rule ${n}\``),
|
|
2297
|
+
'',
|
|
2298
|
+
];
|
|
2299
|
+
let content = lines.join('\n');
|
|
2300
|
+
content = addManagedMarker(content, opts.deployVersion || 'unknown', opts.deploySource || 'bundled');
|
|
2301
|
+
if (!opts.dryRun) fs.writeFileSync(indexPath, content, 'utf8');
|
|
2302
|
+
return names.length;
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
export function getAddonRuleFiles(srcRoot, excludeAddons = []) {
|
|
2306
|
+
const addons = discoverAddons(srcRoot);
|
|
2307
|
+
const files = [];
|
|
2308
|
+
|
|
2309
|
+
for (const addon of addons) {
|
|
2310
|
+
if (excludeAddons.includes(addon.name)) continue;
|
|
2311
|
+
|
|
2312
|
+
// Skip addons with consolidated rule indexes — their rules are
|
|
2313
|
+
// included via assembleRulesIndex() instead of individual files
|
|
2314
|
+
if (addon.manifest?.consolidation?.deployIndexOnly) continue;
|
|
2315
|
+
|
|
2316
|
+
const rulesDir = path.join(addon.path, 'rules');
|
|
2317
|
+
if (fs.existsSync(rulesDir)) {
|
|
2318
|
+
files.push(...listMdFiles(rulesDir));
|
|
2319
|
+
}
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
// Tier gate (#1673): inline only always-on (CRITICAL/HIGH) rules. MEDIUM/LOW
|
|
2323
|
+
// stay on-demand. Applied here so both the deploy enumeration and the prune
|
|
2324
|
+
// desired-set (computeAllArtifactBasenames) exclude them for every provider.
|
|
2325
|
+
return files.filter(isAlwaysOnRule);
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
/**
|
|
2329
|
+
* List behavior directories (directories containing BEHAVIOR.md).
|
|
2330
|
+
*/
|
|
2331
|
+
export function listBehaviorDirs(dir) {
|
|
2332
|
+
if (!fs.existsSync(dir)) return [];
|
|
2333
|
+
return fs
|
|
2334
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
2335
|
+
.filter((entry) => entry.isDirectory() && fs.existsSync(path.join(dir, entry.name, 'BEHAVIOR.md')))
|
|
2336
|
+
.map((entry) => path.join(dir, entry.name));
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
/**
|
|
2340
|
+
* Collect behavior directories from all supported source shapes:
|
|
2341
|
+
* - direct component source: <srcRoot>/behaviors/
|
|
2342
|
+
* - cross-framework: agentic/code/behaviors/
|
|
2343
|
+
* - per-addon: agentic/code/addons/<name>/behaviors/
|
|
2344
|
+
* - per-framework: agentic/code/frameworks/<name>/behaviors/
|
|
2345
|
+
*/
|
|
2346
|
+
export function collectBehaviorDirs(srcRoot) {
|
|
2347
|
+
const dirs = [];
|
|
2348
|
+
const seen = new Set();
|
|
2349
|
+
const addDirs = (items) => {
|
|
2350
|
+
for (const dir of items) {
|
|
2351
|
+
if (seen.has(dir)) continue;
|
|
2352
|
+
seen.add(dir);
|
|
2353
|
+
dirs.push(dir);
|
|
2354
|
+
}
|
|
2355
|
+
};
|
|
2356
|
+
|
|
2357
|
+
addDirs(listBehaviorDirs(path.join(srcRoot, 'behaviors')));
|
|
2358
|
+
addDirs(listBehaviorDirs(path.join(srcRoot, 'agentic', 'code', 'behaviors')));
|
|
2359
|
+
|
|
2360
|
+
const addonsDir = path.join(srcRoot, 'agentic', 'code', 'addons');
|
|
2361
|
+
if (fs.existsSync(addonsDir)) {
|
|
2362
|
+
for (const entry of fs.readdirSync(addonsDir, { withFileTypes: true })) {
|
|
2363
|
+
if (!entry.isDirectory()) continue;
|
|
2364
|
+
addDirs(listBehaviorDirs(path.join(addonsDir, entry.name, 'behaviors')));
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
const frameworksDir = path.join(srcRoot, 'agentic', 'code', 'frameworks');
|
|
2369
|
+
if (fs.existsSync(frameworksDir)) {
|
|
2370
|
+
for (const entry of fs.readdirSync(frameworksDir, { withFileTypes: true })) {
|
|
2371
|
+
if (!entry.isDirectory()) continue;
|
|
2372
|
+
addDirs(listBehaviorDirs(path.join(frameworksDir, entry.name, 'behaviors')));
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
return dirs;
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
const BEHAVIOR_EMULATION_TARGETS = {
|
|
2380
|
+
claude: { dir: '.claude/rules/behaviors', extension: '.md', surface: 'Claude Code rules' },
|
|
2381
|
+
codex: { dir: '.codex/rules/behaviors', extension: '.md', surface: 'Codex rules' },
|
|
2382
|
+
copilot: { dir: '.github/instructions/aiwg-behaviors', extension: '.instructions.md', surface: 'GitHub Copilot instructions' },
|
|
2383
|
+
cursor: { dir: '.cursor/rules/behaviors', extension: '.md', surface: 'Cursor rules' },
|
|
2384
|
+
factory: { dir: '.factory/rules/behaviors', extension: '.md', surface: 'Factory rules' },
|
|
2385
|
+
opencode: { dir: '.opencode/rule/behaviors', extension: '.md', surface: 'OpenCode rules' },
|
|
2386
|
+
warp: { dir: '.warp/rules/behaviors', extension: '.md', surface: 'Warp rules/context surface' },
|
|
2387
|
+
windsurf: { dir: '.windsurf/rules/behaviors', extension: '.md', surface: 'Windsurf rules' },
|
|
2388
|
+
hermes: { dir: '.hermes/behaviors', extension: '.md', surface: 'Hermes behavior context' },
|
|
2389
|
+
};
|
|
2390
|
+
|
|
2391
|
+
function renderBehaviorEmulation(behaviorDir, provider, target) {
|
|
2392
|
+
const behaviorName = path.basename(behaviorDir);
|
|
2393
|
+
const behaviorPath = path.join(behaviorDir, 'BEHAVIOR.md');
|
|
2394
|
+
const content = fs.readFileSync(behaviorPath, 'utf8');
|
|
2395
|
+
const { metadata } = parseFrontmatter(content);
|
|
2396
|
+
const description = metadata?.description || `AIWG behavior ${behaviorName}`;
|
|
2397
|
+
|
|
2398
|
+
return `# AIWG Behavior: ${behaviorName}
|
|
2399
|
+
|
|
2400
|
+
Provider surface: ${target.surface}
|
|
2401
|
+
Provider: ${provider}
|
|
2402
|
+
Native source: ${path.relative(process.cwd(), behaviorPath)}
|
|
2403
|
+
|
|
2404
|
+
${description}
|
|
2405
|
+
|
|
2406
|
+
This provider does not expose OpenClaw-style native behavior directories. AIWG installs this generated behavior rule so the provider still receives the behavior contract instead of silently skipping it.
|
|
2407
|
+
|
|
2408
|
+
## Activation
|
|
2409
|
+
|
|
2410
|
+
Apply this behavior whenever the session, daemon, chat bridge, Mission Control loop, or provider runtime sees a matching trigger from the source behavior metadata. If a trigger cannot be observed natively, treat this file as provider-context guidance for the closest available rule, instruction, hook, or AGENTS-style surface.
|
|
2411
|
+
|
|
2412
|
+
## Source Behavior
|
|
2413
|
+
|
|
2414
|
+
\`\`\`markdown
|
|
2415
|
+
${content.trim()}
|
|
2416
|
+
\`\`\`
|
|
2417
|
+
`;
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
/**
|
|
2421
|
+
* Deploy provider-specific emulated behavior artifacts for providers that do
|
|
2422
|
+
* not have a native OpenClaw-style behavior loader.
|
|
2423
|
+
*/
|
|
2424
|
+
export function deployEmulatedBehaviors(behaviorDirs, providerName, targetDir, opts = {}) {
|
|
2425
|
+
if (!behaviorDirs || behaviorDirs.length === 0) return 0;
|
|
2426
|
+
const provider = providerName === 'openai' ? 'codex' : providerName;
|
|
2427
|
+
if (provider === 'openclaw') return 0;
|
|
2428
|
+
const target = BEHAVIOR_EMULATION_TARGETS[provider];
|
|
2429
|
+
if (!target) {
|
|
2430
|
+
if (opts.verbose) console.warn(`No behavior emulation target registered for provider ${provider}`);
|
|
2431
|
+
return 0;
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
const destDir = path.isAbsolute(target.dir) ? target.dir : path.join(targetDir, target.dir);
|
|
2435
|
+
ensureDir(destDir, opts.dryRun);
|
|
2436
|
+
let count = 0;
|
|
2437
|
+
|
|
2438
|
+
for (const behaviorDir of behaviorDirs) {
|
|
2439
|
+
const behaviorName = path.basename(behaviorDir);
|
|
2440
|
+
const filename = `${behaviorName}${target.extension}`;
|
|
2441
|
+
const dest = path.join(destDir, filename);
|
|
2442
|
+
const rendered = renderBehaviorEmulation(behaviorDir, provider, target);
|
|
2443
|
+
const content = addManagedMarker(rendered, opts.deployVersion || 'unknown', opts.deploySource || 'bundled');
|
|
2444
|
+
if (opts.dryRun) {
|
|
2445
|
+
console.log(`[dry-run] deploy behavior emulation ${behaviorName} -> ${dest}`);
|
|
2446
|
+
} else {
|
|
2447
|
+
writeFile(dest, content, false);
|
|
2448
|
+
}
|
|
2449
|
+
if (opts.verbose) console.log(`deployed behavior emulation ${behaviorName} -> ${path.relative(process.cwd(), dest)}`);
|
|
2450
|
+
count++;
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
return count;
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
/**
|
|
2457
|
+
* Get addon files by category (agents, commands, skills, rules)
|
|
2458
|
+
* @param {string} srcRoot - Source root directory
|
|
2459
|
+
* @param {object} options - Options
|
|
2460
|
+
* @param {string[]} options.excludeAddons - Addon names to exclude
|
|
2461
|
+
* @param {boolean} options.includeAgents - Include agent files (default: true)
|
|
2462
|
+
* @param {boolean} options.includeCommands - Include command files (default: true)
|
|
2463
|
+
* @param {boolean} options.includeSkills - Include skill directories (default: true)
|
|
2464
|
+
* @param {boolean} options.includeRules - Include rule files (default: true)
|
|
2465
|
+
* @returns {{agents: string[], commands: string[], skills: string[], rules: string[]}}
|
|
2466
|
+
*/
|
|
2467
|
+
export function getAddonFiles(srcRoot, options = {}) {
|
|
2468
|
+
const {
|
|
2469
|
+
excludeAddons = [],
|
|
2470
|
+
includeAgents = true,
|
|
2471
|
+
includeCommands = true,
|
|
2472
|
+
includeSkills = true,
|
|
2473
|
+
includeRules = true
|
|
2474
|
+
} = options;
|
|
2475
|
+
|
|
2476
|
+
return {
|
|
2477
|
+
agents: includeAgents ? getAddonAgentFiles(srcRoot, excludeAddons) : [],
|
|
2478
|
+
commands: includeCommands ? getAddonCommandFiles(srcRoot, excludeAddons) : [],
|
|
2479
|
+
skills: includeSkills ? getAddonSkillDirs(srcRoot, excludeAddons) : [],
|
|
2480
|
+
rules: includeRules ? getAddonRuleFiles(srcRoot, excludeAddons) : []
|
|
2481
|
+
};
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
// ============================================================================
|
|
2485
|
+
// Consolidated Rules Deployment
|
|
2486
|
+
// ============================================================================
|
|
2487
|
+
|
|
2488
|
+
/**
|
|
2489
|
+
* Load rules manifest from source directory
|
|
2490
|
+
* @param {string} srcRoot - Source root directory
|
|
2491
|
+
* @returns {object|null} - Parsed manifest or null if not found
|
|
2492
|
+
*/
|
|
2493
|
+
export function loadRulesManifest(srcRoot) {
|
|
2494
|
+
const manifestPath = path.join(srcRoot, 'agentic', 'code', 'frameworks', 'sdlc-complete', 'rules', 'manifest.json');
|
|
2495
|
+
if (!fs.existsSync(manifestPath)) return null;
|
|
2496
|
+
try {
|
|
2497
|
+
return JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
2498
|
+
} catch (e) {
|
|
2499
|
+
console.warn(`Warning: Could not parse rules manifest: ${e.message}`);
|
|
2500
|
+
return null;
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
/**
|
|
2505
|
+
* Group rules by tier
|
|
2506
|
+
* @param {Array} rules - Array of rule objects from manifest
|
|
2507
|
+
* @returns {{core: Array, sdlc: Array, research: Array}}
|
|
2508
|
+
*/
|
|
2509
|
+
export function groupRulesByTier(rules) {
|
|
2510
|
+
const groups = { core: [], sdlc: [], research: [] };
|
|
2511
|
+
for (const rule of rules) {
|
|
2512
|
+
const tier = rule.tier || 'sdlc';
|
|
2513
|
+
if (groups[tier]) {
|
|
2514
|
+
groups[tier].push(rule);
|
|
2515
|
+
}
|
|
2516
|
+
}
|
|
2517
|
+
return groups;
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
/**
|
|
2521
|
+
* Group rules by enforcement level within a tier
|
|
2522
|
+
* @param {Array} rules - Array of rule objects
|
|
2523
|
+
* @returns {{critical: Array, high: Array, medium: Array}}
|
|
2524
|
+
*/
|
|
2525
|
+
export function groupByEnforcement(rules) {
|
|
2526
|
+
const groups = { critical: [], high: [], medium: [] };
|
|
2527
|
+
for (const rule of rules) {
|
|
2528
|
+
const level = (rule.enforcement || 'medium').toLowerCase();
|
|
2529
|
+
if (groups[level]) {
|
|
2530
|
+
groups[level].push(rule);
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
return groups;
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
/**
|
|
2537
|
+
* Get the RULES-INDEX.md path for a component (framework or addon).
|
|
2538
|
+
* Checks the component's manifest.json for consolidation.rulesIndex.
|
|
2539
|
+
* @param {string} componentPath - Path to the component directory
|
|
2540
|
+
* @returns {string|null} - Path to the component's RULES-INDEX.md or null
|
|
2541
|
+
*/
|
|
2542
|
+
export function getComponentRulesIndexPath(componentPath) {
|
|
2543
|
+
const manifestPath = path.join(componentPath, 'manifest.json');
|
|
2544
|
+
if (!fs.existsSync(manifestPath)) return null;
|
|
2545
|
+
|
|
2546
|
+
try {
|
|
2547
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
2548
|
+
const rulesIndex = manifest.consolidation?.rulesIndex;
|
|
2549
|
+
if (!rulesIndex) return null;
|
|
2550
|
+
|
|
2551
|
+
const indexPath = path.join(componentPath, rulesIndex);
|
|
2552
|
+
return fs.existsSync(indexPath) ? indexPath : null;
|
|
2553
|
+
} catch (e) {
|
|
2554
|
+
return null;
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
/**
|
|
2559
|
+
* Assemble the deployed RULES-INDEX.md from the global template and all
|
|
2560
|
+
* installed component indexes.
|
|
2561
|
+
*
|
|
2562
|
+
* Flow:
|
|
2563
|
+
* 1. Read global template from agentic/code/RULES-INDEX.md
|
|
2564
|
+
* 2. For each component with consolidation support, read its RULES-INDEX.md
|
|
2565
|
+
* 3. Concatenate: global header + component indexes + global quick reference
|
|
2566
|
+
*
|
|
2567
|
+
* @param {string} srcRoot - Source root directory
|
|
2568
|
+
* @returns {string|null} - Assembled content or null if global template missing
|
|
2569
|
+
*/
|
|
2570
|
+
export function assembleRulesIndex(srcRoot) {
|
|
2571
|
+
const globalPath = path.join(srcRoot, 'agentic', 'code', 'RULES-INDEX.md');
|
|
2572
|
+
if (!fs.existsSync(globalPath)) return null;
|
|
2573
|
+
|
|
2574
|
+
const globalContent = fs.readFileSync(globalPath, 'utf8');
|
|
2575
|
+
|
|
2576
|
+
// Find the Quick Reference section in the global template
|
|
2577
|
+
const qrMarker = '## Quick Reference by Context';
|
|
2578
|
+
const qrIndex = globalContent.indexOf(qrMarker);
|
|
2579
|
+
|
|
2580
|
+
// Split global into header (before Quick Reference) and footer (Quick Reference + rest)
|
|
2581
|
+
let header, footer;
|
|
2582
|
+
if (qrIndex >= 0) {
|
|
2583
|
+
header = globalContent.substring(0, qrIndex).trimEnd();
|
|
2584
|
+
footer = globalContent.substring(qrIndex);
|
|
2585
|
+
} else {
|
|
2586
|
+
header = globalContent;
|
|
2587
|
+
footer = '';
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
// Collect component indexes
|
|
2591
|
+
const componentSections = [];
|
|
2592
|
+
|
|
2593
|
+
// 1. Framework indexes (sdlc-complete, etc.)
|
|
2594
|
+
const frameworksDir = path.join(srcRoot, 'agentic', 'code', 'frameworks');
|
|
2595
|
+
if (fs.existsSync(frameworksDir)) {
|
|
2596
|
+
for (const entry of fs.readdirSync(frameworksDir, { withFileTypes: true })) {
|
|
2597
|
+
if (!entry.isDirectory()) continue;
|
|
2598
|
+
const fwPath = path.join(frameworksDir, entry.name);
|
|
2599
|
+
const indexPath = getComponentRulesIndexPath(fwPath);
|
|
2600
|
+
if (indexPath) {
|
|
2601
|
+
componentSections.push(fs.readFileSync(indexPath, 'utf8').trim());
|
|
2602
|
+
}
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2606
|
+
// 2. Addon indexes
|
|
2607
|
+
const addons = discoverAddons(srcRoot);
|
|
2608
|
+
for (const addon of addons) {
|
|
2609
|
+
const indexPath = getComponentRulesIndexPath(addon.path);
|
|
2610
|
+
if (indexPath) {
|
|
2611
|
+
componentSections.push(fs.readFileSync(indexPath, 'utf8').trim());
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2614
|
+
|
|
2615
|
+
// Assemble: header + component indexes + footer
|
|
2616
|
+
const parts = [header];
|
|
2617
|
+
if (componentSections.length > 0) {
|
|
2618
|
+
parts.push('');
|
|
2619
|
+
parts.push(componentSections.join('\n\n'));
|
|
2620
|
+
}
|
|
2621
|
+
if (footer) {
|
|
2622
|
+
parts.push('');
|
|
2623
|
+
parts.push(footer);
|
|
2624
|
+
}
|
|
2625
|
+
|
|
2626
|
+
return parts.join('\n');
|
|
2627
|
+
}
|
|
2628
|
+
|
|
2629
|
+
/**
|
|
2630
|
+
* Get the RULES-INDEX.md file path from source
|
|
2631
|
+
* @param {string} srcRoot - Source root directory
|
|
2632
|
+
* @returns {string|null} - Path to RULES-INDEX.md or null
|
|
2633
|
+
*/
|
|
2634
|
+
export function getRulesIndexPath(srcRoot) {
|
|
2635
|
+
const indexPath = path.join(srcRoot, 'agentic', 'code', 'frameworks', 'sdlc-complete', 'rules', 'RULES-INDEX.md');
|
|
2636
|
+
return fs.existsSync(indexPath) ? indexPath : null;
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2639
|
+
/**
|
|
2640
|
+
* Generate consolidated rules content from manifest for a specific provider.
|
|
2641
|
+
* Used by content-injection providers (copilot, warp, windsurf) that need
|
|
2642
|
+
* the rules content inline rather than as a file.
|
|
2643
|
+
*
|
|
2644
|
+
* @param {string} srcRoot - Source root directory
|
|
2645
|
+
* @param {string} provider - Provider name (for @-link formatting)
|
|
2646
|
+
* @param {string[]} [addonRuleFiles] - Optional addon rule file paths
|
|
2647
|
+
* @returns {string|null} - Generated content or null if manifest/index missing
|
|
2648
|
+
*/
|
|
2649
|
+
export function generateConsolidatedRulesContent(srcRoot, provider, addonRuleFiles = []) {
|
|
2650
|
+
// Try assembled index first (new multi-component assembly)
|
|
2651
|
+
const assembled = assembleRulesIndex(srcRoot);
|
|
2652
|
+
if (assembled) {
|
|
2653
|
+
let content = assembled;
|
|
2654
|
+
// Append any remaining non-consolidated addon rules
|
|
2655
|
+
if (addonRuleFiles.length > 0) {
|
|
2656
|
+
content += '\n\n---\n\n## Additional Addon Rules\n\n';
|
|
2657
|
+
for (const ruleFile of addonRuleFiles) {
|
|
2658
|
+
const ruleName = path.basename(ruleFile, '.md');
|
|
2659
|
+
content += `- **${ruleName}**: @${path.relative(srcRoot, ruleFile)}\n`;
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2662
|
+
return content;
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
// Fallback: legacy single-index behavior
|
|
2666
|
+
const indexPath = getRulesIndexPath(srcRoot);
|
|
2667
|
+
if (!indexPath) return null;
|
|
2668
|
+
|
|
2669
|
+
let content = fs.readFileSync(indexPath, 'utf8');
|
|
2670
|
+
|
|
2671
|
+
if (addonRuleFiles.length > 0) {
|
|
2672
|
+
content += '\n\n---\n\n## Addon Rules\n\n';
|
|
2673
|
+
for (const ruleFile of addonRuleFiles) {
|
|
2674
|
+
const ruleName = path.basename(ruleFile, '.md');
|
|
2675
|
+
content += `- **${ruleName}**: @${path.relative(srcRoot, ruleFile)}\n`;
|
|
2676
|
+
}
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
return content;
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
/**
|
|
2683
|
+
* Clean up old individually-deployed rule files from target directory.
|
|
2684
|
+
* Removes any .md files that are NOT RULES-INDEX.md.
|
|
2685
|
+
*
|
|
2686
|
+
* @param {string} rulesDir - Target rules directory
|
|
2687
|
+
* @param {object} opts - Options
|
|
2688
|
+
* @param {boolean} opts.dryRun - If true, log but don't delete
|
|
2689
|
+
* @returns {string[]} - List of removed (or would-be-removed) file paths
|
|
2690
|
+
*/
|
|
2691
|
+
/**
|
|
2692
|
+
* Remove stale rule files from a deploy directory before writing fresh ones.
|
|
2693
|
+
*
|
|
2694
|
+
* Per #1143 + #1117: addon deploys run as separate `deploy-agents.mjs`
|
|
2695
|
+
* invocations after the main framework, and the previous "wipe all non-index
|
|
2696
|
+
* .md before writing" behavior caused the main framework's rules to be
|
|
2697
|
+
* silently destroyed by every subsequent addon deploy (whether or not the
|
|
2698
|
+
* addon shipped any rules of its own).
|
|
2699
|
+
*
|
|
2700
|
+
* The fix: cleanup only fires when the operator explicitly asks via
|
|
2701
|
+
* `opts.cleanRules: true`. The deploy-time cleanup is replaced by a
|
|
2702
|
+
* dedicated `aiwg refresh --clean-rules` operator flow (follow-up issue).
|
|
2703
|
+
*
|
|
2704
|
+
* Existing callers that pass `incomingFiles: []` still get a no-op; existing
|
|
2705
|
+
* callers that pass nothing get the legacy (now-disabled) cleanup as a no-op
|
|
2706
|
+
* by default.
|
|
2707
|
+
*
|
|
2708
|
+
* @param {string} rulesDir
|
|
2709
|
+
* @param {object} opts
|
|
2710
|
+
* @param {boolean} [opts.dryRun]
|
|
2711
|
+
* @param {boolean} [opts.cleanRules] explicit opt-in to remove stale files
|
|
2712
|
+
* @param {string[]} [opts.incomingFiles] when cleanRules=true, only files NOT
|
|
2713
|
+
* in this list are removed
|
|
2714
|
+
*/
|
|
2715
|
+
export function cleanupOldRuleFiles(rulesDir, opts = {}) {
|
|
2716
|
+
const { dryRun = false, cleanRules = false, incomingFiles } = opts;
|
|
2717
|
+
const removed = [];
|
|
2718
|
+
|
|
2719
|
+
if (!fs.existsSync(rulesDir)) return removed;
|
|
2720
|
+
if (!cleanRules) return removed;
|
|
2721
|
+
if (Array.isArray(incomingFiles) && incomingFiles.length === 0) return removed;
|
|
2722
|
+
|
|
2723
|
+
// Only `.md` rules are eligible for generic cleanup — `.mdc` (native Cursor
|
|
2724
|
+
// rules) and non-rule files (config.json, …) are preserved. AIWG's own legacy
|
|
2725
|
+
// `.md`→`.mdc` migration is handled explicitly by the Cursor provider's
|
|
2726
|
+
// deployRulesInline (marker-gated), not here, so operator `.mdc` stay safe.
|
|
2727
|
+
const incomingBasenames = new Set(
|
|
2728
|
+
Array.isArray(incomingFiles)
|
|
2729
|
+
? incomingFiles.map((f) => path.basename(f))
|
|
2730
|
+
: []
|
|
2731
|
+
);
|
|
2732
|
+
|
|
2733
|
+
// Ownership gate (#1627): only AIWG-managed rule files are eligible for
|
|
2734
|
+
// removal. A rule the operator dropped into the dir (no sidecar entry, no
|
|
2735
|
+
// `aiwg:managed` marker) is preserved even when cleanRules is on.
|
|
2736
|
+
const sidecarManaged = readSidecarManifest(rulesDir)?.managed || {};
|
|
2737
|
+
|
|
2738
|
+
const entries = fs.readdirSync(rulesDir, { withFileTypes: true });
|
|
2739
|
+
for (const entry of entries) {
|
|
2740
|
+
if (!entry.isFile()) continue;
|
|
2741
|
+
if (!entry.name.toLowerCase().endsWith('.md')) continue;
|
|
2742
|
+
if (entry.name === 'RULES-INDEX.md') continue;
|
|
2743
|
+
if (entry.name === 'RULES-ONDEMAND.md') continue; // generated on-demand index (#1675) — protected like RULES-INDEX
|
|
2744
|
+
if (incomingBasenames.has(entry.name)) continue;
|
|
2745
|
+
|
|
2746
|
+
const filePath = path.join(rulesDir, entry.name);
|
|
2747
|
+
|
|
2748
|
+
let owned = Object.prototype.hasOwnProperty.call(sidecarManaged, entry.name);
|
|
2749
|
+
if (!owned) {
|
|
2750
|
+
try {
|
|
2751
|
+
owned = MANAGED_MARKER_RE.test(fs.readFileSync(filePath, 'utf8'));
|
|
2752
|
+
} catch {
|
|
2753
|
+
owned = false; // unreadable → leave it alone
|
|
2754
|
+
}
|
|
2755
|
+
}
|
|
2756
|
+
if (!owned) continue; // user-authored rule — preserve
|
|
2757
|
+
|
|
2758
|
+
removed.push(filePath);
|
|
2759
|
+
|
|
2760
|
+
if (dryRun) {
|
|
2761
|
+
console.log(`[dry-run] would remove old rule: ${entry.name}`);
|
|
2762
|
+
} else {
|
|
2763
|
+
fs.unlinkSync(filePath);
|
|
2764
|
+
console.log(`removed old rule: ${entry.name}`);
|
|
2765
|
+
}
|
|
2766
|
+
}
|
|
2767
|
+
|
|
2768
|
+
if (removed.length > 0) {
|
|
2769
|
+
console.log(`cleaned up ${removed.length} old rule file(s) from ${rulesDir}`);
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2772
|
+
return removed;
|
|
2773
|
+
}
|
|
2774
|
+
|
|
2775
|
+
/**
|
|
2776
|
+
* Migrate commands directory by removing stale AIWG command files before
|
|
2777
|
+
* skills deployment.
|
|
2778
|
+
*
|
|
2779
|
+
* AIWG migrated from commands to skills. If old AIWG command files are left
|
|
2780
|
+
* in place alongside newly deployed skills, the provider TUI (e.g. Claude
|
|
2781
|
+
* Code's command palette) shows duplicate entries — one from the stale
|
|
2782
|
+
* command file and one from the skill. This removes those duplicates.
|
|
2783
|
+
*
|
|
2784
|
+
* Removal is bounded to AIWG-managed command files (#1627): a command the
|
|
2785
|
+
* operator authored (no sidecar entry, no `aiwg:managed` marker) is preserved.
|
|
2786
|
+
* Subdirectories and non-command files are left untouched. The directory is
|
|
2787
|
+
* removed only if it ends up empty after pruning AIWG files.
|
|
2788
|
+
*
|
|
2789
|
+
* Home-directory providers (codex, openclaw) are excluded by the caller: their
|
|
2790
|
+
* commands paths are shared across all projects and must not be touched.
|
|
2791
|
+
*
|
|
2792
|
+
* @param {string} commandsDir - Full path to the provider's commands directory
|
|
2793
|
+
* @param {object} opts
|
|
2794
|
+
* @param {boolean} opts.dryRun - Log but don't delete
|
|
2795
|
+
* @param {boolean} opts.skipCommandsMigration - User opted out; warn about duplicates instead
|
|
2796
|
+
* @returns {boolean} true if any AIWG command file was removed (or would be in dry-run)
|
|
2797
|
+
*/
|
|
2798
|
+
export function migrateCommandsDirectory(commandsDir, opts = {}) {
|
|
2799
|
+
const { dryRun = false, skipCommandsMigration = false, verbose = false } = opts;
|
|
2800
|
+
|
|
2801
|
+
if (!fs.existsSync(commandsDir)) return false;
|
|
2802
|
+
|
|
2803
|
+
const entries = fs.readdirSync(commandsDir, { withFileTypes: true });
|
|
2804
|
+
if (entries.length === 0) return false;
|
|
2805
|
+
|
|
2806
|
+
if (skipCommandsMigration) {
|
|
2807
|
+
const rel = path.relative(process.cwd(), commandsDir);
|
|
2808
|
+
console.warn(`\nWarning: commands migration skipped for ${rel}`);
|
|
2809
|
+
console.warn(' Duplicate entries may appear in the command palette because old command');
|
|
2810
|
+
console.warn(' files overlap with newly deployed skills. Remove AIWG command files manually');
|
|
2811
|
+
console.warn(` to fix: rm ${rel}/<command>.md`);
|
|
2812
|
+
return false;
|
|
2813
|
+
}
|
|
2814
|
+
|
|
2815
|
+
// Bound removal to AIWG-managed command files; preserve operator files.
|
|
2816
|
+
const sidecar = readSidecarManifest(commandsDir) || { managed: {} };
|
|
2817
|
+
const managed = sidecar.managed || {};
|
|
2818
|
+
let sidecarDirty = false;
|
|
2819
|
+
const rel = path.relative(process.cwd(), commandsDir);
|
|
2820
|
+
|
|
2821
|
+
const managedNames = [];
|
|
2822
|
+
for (const entry of entries) {
|
|
2823
|
+
if (!entry.isFile()) continue;
|
|
2824
|
+
const lower = entry.name.toLowerCase();
|
|
2825
|
+
if (!lower.endsWith('.md')) continue; // only command markdown files
|
|
2826
|
+
const filePath = path.join(commandsDir, entry.name);
|
|
2827
|
+
let owned = Object.prototype.hasOwnProperty.call(managed, entry.name);
|
|
2828
|
+
if (!owned) {
|
|
2829
|
+
try {
|
|
2830
|
+
owned = MANAGED_MARKER_RE.test(fs.readFileSync(filePath, 'utf8'));
|
|
2831
|
+
} catch {
|
|
2832
|
+
owned = false;
|
|
2833
|
+
}
|
|
2834
|
+
}
|
|
2835
|
+
if (owned) managedNames.push(entry.name);
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
if (managedNames.length === 0) return false;
|
|
2839
|
+
|
|
2840
|
+
if (dryRun) {
|
|
2841
|
+
console.log(`[dry-run] would remove ${managedNames.length} AIWG command file(s) from ${rel}`);
|
|
2842
|
+
return true;
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2845
|
+
for (const name of managedNames) {
|
|
2846
|
+
try {
|
|
2847
|
+
fs.unlinkSync(path.join(commandsDir, name));
|
|
2848
|
+
if (Object.prototype.hasOwnProperty.call(managed, name)) {
|
|
2849
|
+
delete managed[name];
|
|
2850
|
+
sidecarDirty = true;
|
|
2851
|
+
}
|
|
2852
|
+
if (verbose) console.log(` removed AIWG command file: ${name}`);
|
|
2853
|
+
} catch (err) {
|
|
2854
|
+
if (verbose) console.warn(` Warning: could not remove ${name}: ${err.message}`);
|
|
2855
|
+
}
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2858
|
+
if (sidecarDirty) writeSidecarManifest(commandsDir, sidecar, dryRun);
|
|
2859
|
+
console.log(` Removed ${managedNames.length} old AIWG command file(s) from ${rel} (now served as skills)`);
|
|
2860
|
+
|
|
2861
|
+
// Remove the directory only if AIWG files were the only contents.
|
|
2862
|
+
try {
|
|
2863
|
+
const remaining = fs.readdirSync(commandsDir).filter((n) => n !== MANIFEST_FILENAME);
|
|
2864
|
+
if (remaining.length === 0) {
|
|
2865
|
+
fs.rmSync(commandsDir, { recursive: true, force: true });
|
|
2866
|
+
}
|
|
2867
|
+
} catch { /* non-fatal */ }
|
|
2868
|
+
|
|
2869
|
+
return true;
|
|
2870
|
+
}
|
|
2871
|
+
|
|
2872
|
+
// ============================================================================
|
|
2873
|
+
// Provider Interface
|
|
2874
|
+
// ============================================================================
|
|
2875
|
+
|
|
2876
|
+
/**
|
|
2877
|
+
* Base provider interface - all providers should implement these methods
|
|
2878
|
+
*/
|
|
2879
|
+
export const ProviderInterface = {
|
|
2880
|
+
name: 'base',
|
|
2881
|
+
aliases: [],
|
|
2882
|
+
|
|
2883
|
+
// ── Path Configuration ──────────────────────────────────────────────
|
|
2884
|
+
// ALL four paths are REQUIRED for v2. Every provider deploys every artifact type.
|
|
2885
|
+
// Provider dictates which directories to use; null paths are no longer allowed.
|
|
2886
|
+
paths: {
|
|
2887
|
+
agents: null,
|
|
2888
|
+
commands: null,
|
|
2889
|
+
skills: null,
|
|
2890
|
+
rules: null
|
|
2891
|
+
},
|
|
2892
|
+
|
|
2893
|
+
// ── Support Level per Artifact Type ─────────────────────────────────
|
|
2894
|
+
// Distinguishes native platform support from AIWG conventional directories.
|
|
2895
|
+
// 'native' - Platform natively discovers and uses these files
|
|
2896
|
+
// 'conventional' - AIWG directory convention; available for @-mention context loading
|
|
2897
|
+
// 'aggregated' - Content included in aggregated file AND deployed as discrete files
|
|
2898
|
+
support: {
|
|
2899
|
+
agents: 'conventional',
|
|
2900
|
+
commands: 'conventional',
|
|
2901
|
+
skills: 'conventional',
|
|
2902
|
+
rules: 'conventional'
|
|
2903
|
+
},
|
|
2904
|
+
|
|
2905
|
+
// ── Provider Capabilities ───────────────────────────────────────────
|
|
2906
|
+
capabilities: {
|
|
2907
|
+
skills: false,
|
|
2908
|
+
rules: false,
|
|
2909
|
+
aggregatedOutput: false,
|
|
2910
|
+
yamlFormat: false,
|
|
2911
|
+
mdcFormat: false,
|
|
2912
|
+
homeDirectoryDeploy: false,
|
|
2913
|
+
projectLocalMirror: false
|
|
2914
|
+
},
|
|
2915
|
+
|
|
2916
|
+
// ── Home Directory Paths (Codex-specific) ───────────────────────────
|
|
2917
|
+
// Only populated for providers that deploy to home directory.
|
|
2918
|
+
homePaths: {
|
|
2919
|
+
commands: null,
|
|
2920
|
+
skills: null
|
|
2921
|
+
},
|
|
2922
|
+
|
|
2923
|
+
// ── Artifact Transformation ─────────────────────────────────────────
|
|
2924
|
+
transformAgent(srcPath, content, opts) { return content; },
|
|
2925
|
+
transformCommand(srcPath, content, opts) { return content; },
|
|
2926
|
+
transformSkill(srcPath, content, opts) { return content; },
|
|
2927
|
+
transformRule(srcPath, content, opts) { return content; },
|
|
2928
|
+
|
|
2929
|
+
// ── Model Mapping ──────────────────────────────────────────────────
|
|
2930
|
+
mapModel(shorthand, modelCfg, modelsConfig) { return shorthand; },
|
|
2931
|
+
|
|
2932
|
+
// ── Deployment Functions ────────────────────────────────────────────
|
|
2933
|
+
// All four deploy functions are available. Providers override as needed.
|
|
2934
|
+
deployAgents(agentFiles, targetDir, opts) {},
|
|
2935
|
+
deployCommands(commandFiles, targetDir, opts) {},
|
|
2936
|
+
deploySkills(skillDirs, targetDir, opts) {},
|
|
2937
|
+
deployRules(ruleFiles, targetDir, opts) {},
|
|
2938
|
+
|
|
2939
|
+
// ── Aggregation (for Warp, Windsurf) ────────────────────────────────
|
|
2940
|
+
aggregate(artifacts, targetDir, opts) {},
|
|
2941
|
+
|
|
2942
|
+
// ── Create/update AGENTS.md ────────────────────────────────────────
|
|
2943
|
+
createAgentsMd(target, srcRoot, dryRun) {
|
|
2944
|
+
// Override in provider
|
|
2945
|
+
},
|
|
2946
|
+
|
|
2947
|
+
// ── Post-deployment hook ───────────────────────────────────────────
|
|
2948
|
+
async postDeploy(targetDir, opts) {
|
|
2949
|
+
// Override in provider if needed
|
|
2950
|
+
},
|
|
2951
|
+
|
|
2952
|
+
// ── File Extension ────────────────────────────────────────────────
|
|
2953
|
+
getFileExtension(type) { return '.md'; }
|
|
2954
|
+
};
|