@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.
Files changed (70) hide show
  1. package/README.md +33 -0
  2. package/agentic/code/providers/capability-matrix.yaml +511 -0
  3. package/agentic/code/providers/model-capabilities.v1.json +120 -0
  4. package/agentic/code/providers/model-catalog.v1.json +96 -0
  5. package/agentic/code/providers/model-policy-evaluations.v1.json +50 -0
  6. package/agentic/code/providers/premium-model-allowlist.v1.json +36 -0
  7. package/bin/aiwg.mjs +14 -10
  8. package/dist/src/api/index.d.ts +1 -0
  9. package/dist/src/api/index.js +1 -0
  10. package/dist/src/artifacts/cli.js +2 -0
  11. package/dist/src/artifacts/types.js +4 -0
  12. package/dist/src/auth/client.js +209 -0
  13. package/dist/src/auth/config.js +38 -0
  14. package/dist/src/auth/credential-store.js +141 -0
  15. package/dist/src/auth/resource-credentials.js +25 -0
  16. package/dist/src/auth/types.js +2 -0
  17. package/dist/src/channel/manager.mjs +5 -5
  18. package/dist/src/cli/handlers/auth.js +125 -0
  19. package/dist/src/cli/handlers/help.js +1 -0
  20. package/dist/src/cli/handlers/index.js +3 -1
  21. package/dist/src/cli/handlers/install.js +42 -4
  22. package/dist/src/cli/handlers/marketplace.js +375 -122
  23. package/dist/src/cli/handlers/resource-versions.js +2 -0
  24. package/dist/src/cli/handlers/sessions.js +23 -5
  25. package/dist/src/cli/handlers/subcommands.js +10 -1
  26. package/dist/src/cli/handlers/use.js +342 -43
  27. package/dist/src/config/gitignore.js +1 -0
  28. package/dist/src/extensions/commands/definitions.js +19 -0
  29. package/dist/src/marketplace/exchange.js +602 -0
  30. package/dist/src/marketplace/provenance-types.js +19 -0
  31. package/dist/src/marketplace/provenance.js +834 -0
  32. package/dist/src/memory/canonical-context.js +342 -0
  33. package/dist/src/memory/context-pack.js +282 -0
  34. package/dist/src/memory/index.js +4 -0
  35. package/dist/src/memory/intake.js +118 -0
  36. package/dist/src/packages/adapters/git.js +79 -29
  37. package/dist/src/packages/package-discovery.js +81 -0
  38. package/dist/src/packages/package-registry.js +2 -0
  39. package/dist/src/packages/registry.js +119 -20
  40. package/dist/src/resources/resolver.js +1 -0
  41. package/dist/src/resources/web-release.d.ts +3 -1
  42. package/dist/src/resources/web-release.js +14 -6
  43. package/dist/src/serve/agentic-sandbox-fleet-client.js +213 -0
  44. package/dist/src/serve/fleet-mission-conductor.js +293 -0
  45. package/dist/src/sessions/index.js +1 -0
  46. package/dist/src/sessions/output-registration.js +338 -0
  47. package/dist/src/sessions/promotion.js +73 -2
  48. package/dist/src/sessions/repository.js +2 -1
  49. package/dist/src/update/notifier.mjs +13 -2
  50. package/package.json +8 -1
  51. package/tools/_resolve-impl.mjs +74 -0
  52. package/tools/agents/deploy-agents.mjs +962 -0
  53. package/tools/agents/providers/base.mjs +2954 -0
  54. package/tools/agents/providers/claude.mjs +711 -0
  55. package/tools/agents/providers/codex.mjs +699 -0
  56. package/tools/agents/providers/copilot.mjs +659 -0
  57. package/tools/agents/providers/cursor.mjs +714 -0
  58. package/tools/agents/providers/factory.mjs +1130 -0
  59. package/tools/agents/providers/hermes.mjs +663 -0
  60. package/tools/agents/providers/hook-capabilities.mjs +85 -0
  61. package/tools/agents/providers/model-role.mjs +56 -0
  62. package/tools/agents/providers/openclaw-translator.mjs +348 -0
  63. package/tools/agents/providers/openclaw.mjs +680 -0
  64. package/tools/agents/providers/opencode.mjs +675 -0
  65. package/tools/agents/providers/openhuman.mjs +292 -0
  66. package/tools/agents/providers/warp.mjs +413 -0
  67. package/tools/agents/providers/windsurf.mjs +748 -0
  68. package/tools/commands/deploy-prompts-codex.mjs +336 -0
  69. package/tools/plugin/package-plugins.mjs +1013 -0
  70. 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
+ };