agentboot 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/.github/ISSUE_TEMPLATE/persona-request.md +62 -0
  2. package/.github/ISSUE_TEMPLATE/quality-feedback.md +67 -0
  3. package/.github/workflows/cla.yml +25 -0
  4. package/.github/workflows/validate.yml +49 -0
  5. package/.idea/agentboot.iml +9 -0
  6. package/.idea/misc.xml +6 -0
  7. package/.idea/modules.xml +8 -0
  8. package/.idea/vcs.xml +6 -0
  9. package/CLA.md +98 -0
  10. package/CLAUDE.md +230 -0
  11. package/CONTRIBUTING.md +168 -0
  12. package/LICENSE +191 -0
  13. package/NOTICE +4 -0
  14. package/PERSONAS.md +156 -0
  15. package/README.md +172 -0
  16. package/agentboot.config.json +207 -0
  17. package/bin/agentboot.js +17 -0
  18. package/core/gotchas/README.md +35 -0
  19. package/core/instructions/baseline.instructions.md +133 -0
  20. package/core/instructions/security.instructions.md +186 -0
  21. package/core/personas/code-reviewer/SKILL.md +175 -0
  22. package/core/personas/code-reviewer/persona.config.json +11 -0
  23. package/core/personas/security-reviewer/SKILL.md +233 -0
  24. package/core/personas/security-reviewer/persona.config.json +11 -0
  25. package/core/personas/test-data-expert/SKILL.md +234 -0
  26. package/core/personas/test-data-expert/persona.config.json +10 -0
  27. package/core/personas/test-generator/SKILL.md +262 -0
  28. package/core/personas/test-generator/persona.config.json +10 -0
  29. package/core/traits/audit-trail.md +182 -0
  30. package/core/traits/confidence-signaling.md +172 -0
  31. package/core/traits/critical-thinking.md +129 -0
  32. package/core/traits/schema-awareness.md +132 -0
  33. package/core/traits/source-citation.md +174 -0
  34. package/core/traits/structured-output.md +199 -0
  35. package/docs/ci-cd-automation.md +548 -0
  36. package/docs/claude-code-reference/README.md +21 -0
  37. package/docs/claude-code-reference/agentboot-coverage.md +484 -0
  38. package/docs/claude-code-reference/feature-inventory.md +906 -0
  39. package/docs/cli-commands-audit.md +112 -0
  40. package/docs/cli-design.md +924 -0
  41. package/docs/concepts.md +1117 -0
  42. package/docs/config-schema-audit.md +121 -0
  43. package/docs/configuration.md +645 -0
  44. package/docs/delivery-methods.md +758 -0
  45. package/docs/developer-onboarding.md +342 -0
  46. package/docs/extending.md +448 -0
  47. package/docs/getting-started.md +298 -0
  48. package/docs/knowledge-layer.md +464 -0
  49. package/docs/marketplace.md +822 -0
  50. package/docs/org-connection.md +570 -0
  51. package/docs/plans/architecture.md +2429 -0
  52. package/docs/plans/design.md +2018 -0
  53. package/docs/plans/prd.md +1862 -0
  54. package/docs/plans/stack-rank.md +261 -0
  55. package/docs/plans/technical-spec.md +2755 -0
  56. package/docs/privacy-and-safety.md +807 -0
  57. package/docs/prompt-optimization.md +1071 -0
  58. package/docs/test-plan.md +972 -0
  59. package/docs/third-party-ecosystem.md +496 -0
  60. package/domains/compliance-template/README.md +173 -0
  61. package/domains/compliance-template/traits/compliance-aware.md +228 -0
  62. package/examples/enterprise/agentboot.config.json +184 -0
  63. package/examples/minimal/agentboot.config.json +46 -0
  64. package/package.json +63 -0
  65. package/repos.json +1 -0
  66. package/scripts/cli.ts +1069 -0
  67. package/scripts/compile.ts +1000 -0
  68. package/scripts/dev-sync.ts +149 -0
  69. package/scripts/lib/config.ts +137 -0
  70. package/scripts/lib/frontmatter.ts +61 -0
  71. package/scripts/sync.ts +687 -0
  72. package/scripts/validate.ts +421 -0
  73. package/tests/REGRESSION-PLAN.md +705 -0
  74. package/tests/TEST-PLAN.md +111 -0
  75. package/tests/cli.test.ts +705 -0
  76. package/tests/pipeline.test.ts +608 -0
  77. package/tests/validate.test.ts +278 -0
  78. package/tsconfig.json +62 -0
@@ -0,0 +1,1000 @@
1
+ /**
2
+ * AgentBoot compile script.
3
+ *
4
+ * Reads agentboot.config.json, traverses core/traits/ and core/personas/,
5
+ * composes each persona by inlining trait content, and writes output to
6
+ * dist/{platform}/ — one self-contained distribution per platform.
7
+ *
8
+ * Output structure:
9
+ * dist/skill/ — cross-platform SKILL.md (agentskills.io, traits inlined)
10
+ * dist/claude/ — Claude Code native (.claude/ format)
11
+ * dist/copilot/ — GitHub Copilot (.github/ format)
12
+ *
13
+ * Each platform folder contains the full scope hierarchy:
14
+ * dist/{platform}/core/
15
+ * dist/{platform}/groups/{group}/
16
+ * dist/{platform}/teams/{group}/{team}/
17
+ *
18
+ * Trait injection points in SKILL.md:
19
+ * <!-- traits:start -->
20
+ * (existing content is replaced on each build)
21
+ * <!-- traits:end -->
22
+ *
23
+ * Usage:
24
+ * npm run build
25
+ * tsx scripts/compile.ts
26
+ * tsx scripts/compile.ts --config path/to/agentboot.config.json
27
+ */
28
+
29
+ import fs from "node:fs";
30
+ import path from "node:path";
31
+ import { fileURLToPath } from "node:url";
32
+ import chalk from "chalk";
33
+ import {
34
+ type AgentBootConfig,
35
+ type PersonaConfig,
36
+ resolveConfigPath,
37
+ loadConfig,
38
+ stripJsoncComments,
39
+ } from "./lib/config.js";
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Paths
43
+ // ---------------------------------------------------------------------------
44
+
45
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
46
+ const ROOT = path.resolve(__dirname, "..");
47
+
48
+ interface TraitContent {
49
+ name: string;
50
+ content: string;
51
+ filePath: string;
52
+ }
53
+
54
+ interface CompileResult {
55
+ persona: string;
56
+ platforms: string[];
57
+ traitsInjected: string[];
58
+ scope: "core" | "group" | "team";
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Helpers
63
+ // ---------------------------------------------------------------------------
64
+
65
+ function fatal(msg: string): never {
66
+ console.error(chalk.red(`✗ FATAL: ${msg}`));
67
+ process.exit(1);
68
+ }
69
+
70
+ function log(msg: string): void {
71
+ console.log(msg);
72
+ }
73
+
74
+ function ensureDir(dirPath: string): void {
75
+ fs.mkdirSync(dirPath, { recursive: true });
76
+ }
77
+
78
+ function provenanceHeader(sourceFile: string, config: AgentBootConfig): string {
79
+ const relSource = path.relative(ROOT, sourceFile);
80
+ const timestamp = new Date().toISOString();
81
+ const org = config.orgDisplayName ?? config.org;
82
+ return [
83
+ `<!-- ============================================================ -->`,
84
+ `<!-- AgentBoot compiled output — do not edit manually. -->`,
85
+ `<!-- Source: ${relSource.padEnd(44)} -->`,
86
+ `<!-- Compiled: ${timestamp.padEnd(44)} -->`,
87
+ `<!-- Org: ${org.padEnd(44)} -->`,
88
+ `<!-- ============================================================ -->`,
89
+ "",
90
+ ].join("\n");
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Trait loading
95
+ // ---------------------------------------------------------------------------
96
+
97
+ function loadTraits(
98
+ coreTraitsDir: string,
99
+ enabledTraits: string[] | undefined
100
+ ): Map<string, TraitContent> {
101
+ const traits = new Map<string, TraitContent>();
102
+
103
+ if (!fs.existsSync(coreTraitsDir)) {
104
+ log(chalk.yellow(` ⚠ Traits directory not found: ${coreTraitsDir} — skipping trait injection`));
105
+ return traits;
106
+ }
107
+
108
+ const traitFiles = fs.readdirSync(coreTraitsDir).filter((f) => f.endsWith(".md"));
109
+
110
+ for (const file of traitFiles) {
111
+ const traitName = path.basename(file, ".md");
112
+
113
+ if (enabledTraits && !enabledTraits.includes(traitName)) {
114
+ continue;
115
+ }
116
+
117
+ const filePath = path.join(coreTraitsDir, file);
118
+ const content = fs.readFileSync(filePath, "utf-8");
119
+
120
+ traits.set(traitName, {
121
+ name: traitName,
122
+ content: content.trim(),
123
+ filePath,
124
+ });
125
+ }
126
+
127
+ return traits;
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Persona config loading
132
+ // ---------------------------------------------------------------------------
133
+
134
+ function loadPersonaConfig(personaDir: string): PersonaConfig | null {
135
+ const configPath = path.join(personaDir, "persona.config.json");
136
+ if (!fs.existsSync(configPath)) {
137
+ return null;
138
+ }
139
+ const raw = fs.readFileSync(configPath, "utf-8");
140
+ try {
141
+ return JSON.parse(stripJsoncComments(raw)) as PersonaConfig;
142
+ } catch {
143
+ log(chalk.yellow(` ⚠ Failed to parse persona.config.json in ${personaDir}`));
144
+ return null;
145
+ }
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Trait injection
150
+ // ---------------------------------------------------------------------------
151
+
152
+ const TRAITS_START_MARKER = "<!-- traits:start -->";
153
+ const TRAITS_END_MARKER = "<!-- traits:end -->";
154
+
155
+ function injectTraits(
156
+ skillContent: string,
157
+ traitNames: string[],
158
+ traits: Map<string, TraitContent>,
159
+ personaName: string
160
+ ): { result: string; injected: string[] } {
161
+ const injected: string[] = [];
162
+ const missing: string[] = [];
163
+
164
+ const blocks: string[] = [];
165
+ for (const traitName of traitNames) {
166
+ const trait = traits.get(traitName);
167
+ if (!trait) {
168
+ missing.push(traitName);
169
+ continue;
170
+ }
171
+ injected.push(traitName);
172
+ blocks.push(
173
+ `<!-- trait: ${traitName} -->\n${trait.content}\n<!-- /trait: ${traitName} -->`
174
+ );
175
+ }
176
+
177
+ if (missing.length > 0) {
178
+ log(
179
+ chalk.yellow(
180
+ ` ⚠ [${personaName}] Traits not found (skipped): ${missing.join(", ")}`
181
+ )
182
+ );
183
+ }
184
+
185
+ const injectedBlock =
186
+ blocks.length > 0
187
+ ? `\n\n${blocks.join("\n\n")}\n\n`
188
+ : "\n\n<!-- no traits configured -->\n\n";
189
+
190
+ const startIdx = skillContent.indexOf(TRAITS_START_MARKER);
191
+ const endIdx = skillContent.indexOf(TRAITS_END_MARKER);
192
+
193
+ if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
194
+ const before = skillContent.slice(0, startIdx + TRAITS_START_MARKER.length);
195
+ const after = skillContent.slice(endIdx);
196
+ return {
197
+ result: `${before}${injectedBlock}${after}`,
198
+ injected,
199
+ };
200
+ }
201
+
202
+ return {
203
+ result: `${skillContent.trimEnd()}\n\n${TRAITS_START_MARKER}${injectedBlock}${TRAITS_END_MARKER}\n`,
204
+ injected,
205
+ };
206
+ }
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // Platform-specific output builders
210
+ // ---------------------------------------------------------------------------
211
+
212
+ function buildSkillOutput(
213
+ personaName: string,
214
+ _personaConfig: PersonaConfig | null,
215
+ composedContent: string,
216
+ config: AgentBootConfig,
217
+ skillPath: string
218
+ ): string {
219
+ const provenanceEnabled = config.output?.provenanceHeaders !== false;
220
+ return provenanceEnabled
221
+ ? `${provenanceHeader(skillPath, config)}${composedContent}`
222
+ : composedContent;
223
+ }
224
+
225
+ /**
226
+ * Build CC-native skill file.
227
+ * CC expects: .claude/skills/{skill-name}.md with description frontmatter.
228
+ * The skill name comes from the invocation (e.g., "/review-code" → "review-code").
229
+ */
230
+ function buildClaudeOutput(
231
+ personaName: string,
232
+ personaConfig: PersonaConfig | null,
233
+ composedContent: string,
234
+ _config: AgentBootConfig
235
+ ): { content: string; skillName: string } {
236
+ const invocation = personaConfig?.invocation ?? `/${personaName}`;
237
+ const skillName = invocation.replace(/^\//, "");
238
+ const description = personaConfig?.description ?? personaName;
239
+ // Escape newlines and quotes in description to prevent YAML injection
240
+ const safeDescription = description.replace(/\n/g, " ").replace(/"/g, '\\"');
241
+
242
+ // AB-18: CC skill frontmatter with context:fork → delegates to agent
243
+ const frontmatterLines: string[] = [
244
+ "---",
245
+ `description: "${safeDescription}"`,
246
+ "context: fork",
247
+ `agent: "${personaName}"`,
248
+ ];
249
+
250
+ // Optional: include model override if specified
251
+ if (personaConfig?.model) {
252
+ frontmatterLines.push(`model: "${personaConfig.model}"`);
253
+ }
254
+
255
+ frontmatterLines.push("---", "");
256
+
257
+ // Strip any existing frontmatter from composed content (it's SKILL.md format)
258
+ const withoutFrontmatter = composedContent.replace(/^---\n[\s\S]*?\n---\n*/, "");
259
+
260
+ return {
261
+ content: `${frontmatterLines.join("\n")}\n${withoutFrontmatter}`,
262
+ skillName,
263
+ };
264
+ }
265
+
266
+ function buildCopilotOutput(
267
+ personaName: string,
268
+ personaConfig: PersonaConfig | null,
269
+ composedContent: string,
270
+ config: AgentBootConfig,
271
+ skillPath: string
272
+ ): string {
273
+ const header = `# ${personaConfig?.name ?? personaName} (AgentBoot)\n\n`;
274
+ const description = personaConfig?.description
275
+ ? `${personaConfig.description}\n\n---\n\n`
276
+ : "";
277
+ // Strip HTML comments for Copilot output.
278
+ const stripped = composedContent.replace(/<!--[\s\S]*?-->/g, "").trim();
279
+ return `${provenanceHeader(skillPath, config)}${header}${description}${stripped}\n`;
280
+ }
281
+
282
+ // ---------------------------------------------------------------------------
283
+ // Persona compilation — writes to each platform's dist folder
284
+ // ---------------------------------------------------------------------------
285
+
286
+ function compilePersona(
287
+ personaName: string,
288
+ personaDir: string,
289
+ traits: Map<string, TraitContent>,
290
+ config: AgentBootConfig,
291
+ distPath: string,
292
+ scopePath: string,
293
+ groupName?: string,
294
+ teamName?: string
295
+ ): CompileResult {
296
+ const skillPath = path.join(personaDir, "SKILL.md");
297
+ const scope: "core" | "group" | "team" = teamName ? "team" : groupName ? "group" : "core";
298
+
299
+ if (!fs.existsSync(skillPath)) {
300
+ log(chalk.yellow(` ⚠ [${personaName}] No SKILL.md found — skipping`));
301
+ return { persona: personaName, platforms: [], traitsInjected: [], scope };
302
+ }
303
+
304
+ const personaConfig = loadPersonaConfig(personaDir);
305
+ const skillContent = fs.readFileSync(skillPath, "utf-8");
306
+
307
+ // Determine which traits to inject.
308
+ let traitNames: string[] = personaConfig?.traits ?? [];
309
+
310
+ if (groupName && personaConfig?.groups?.[groupName]?.traits) {
311
+ traitNames = [...traitNames, ...(personaConfig.groups[groupName]!.traits ?? [])];
312
+ }
313
+
314
+ if (teamName && personaConfig?.teams?.[teamName]?.traits) {
315
+ traitNames = [...traitNames, ...(personaConfig.teams[teamName]!.traits ?? [])];
316
+ }
317
+
318
+ traitNames = [...new Set(traitNames)];
319
+
320
+ const { result: composed, injected } = injectTraits(
321
+ skillContent,
322
+ traitNames,
323
+ traits,
324
+ personaName
325
+ );
326
+
327
+ const outputFormats = config.personas?.outputFormats ?? ["skill", "claude", "copilot"];
328
+ const platforms: string[] = [];
329
+
330
+ // Write to dist/{platform}/{scopePath}/{persona}/ (or skills/{name}/ for claude)
331
+ // e.g., dist/skill/core/code-reviewer/SKILL.md
332
+ // dist/claude/core/skills/review-code/SKILL.md
333
+
334
+ if (outputFormats.includes("skill")) {
335
+ const outDir = path.join(distPath, "skill", scopePath, personaName);
336
+ ensureDir(outDir);
337
+ const content = buildSkillOutput(personaName, personaConfig, composed, config, skillPath);
338
+ fs.writeFileSync(path.join(outDir, "SKILL.md"), content, "utf-8");
339
+ if (personaConfig) {
340
+ fs.writeFileSync(
341
+ path.join(outDir, "persona.config.json"),
342
+ JSON.stringify(personaConfig, null, 2) + "\n",
343
+ "utf-8"
344
+ );
345
+ }
346
+ platforms.push("skill");
347
+ }
348
+
349
+ if (outputFormats.includes("claude")) {
350
+ const { content, skillName } = buildClaudeOutput(personaName, personaConfig, composed, config);
351
+ // CC-native: write to dist/claude/{scope}/skills/{skillName}/SKILL.md
352
+ const skillDir = path.join(distPath, "claude", scopePath, "skills", skillName);
353
+ ensureDir(skillDir);
354
+ fs.writeFileSync(path.join(skillDir, "SKILL.md"), content, "utf-8");
355
+ platforms.push("claude");
356
+ }
357
+
358
+ if (outputFormats.includes("claude")) {
359
+ // AB-17: Write agent file to dist/claude/{scope}/agents/{personaName}.md
360
+ const agentDir = path.join(distPath, "claude", scopePath, "agents");
361
+ ensureDir(agentDir);
362
+
363
+ const model = personaConfig?.model; // undefined = omit from frontmatter
364
+ const permMode = personaConfig?.permissionMode;
365
+ const agentDescription = personaConfig?.description ?? personaName;
366
+ // Escape newlines and quotes in description to prevent YAML injection.
367
+ // JS '\\"' produces the string \" which is the correct YAML double-quote escape.
368
+ const safeDescription = agentDescription.replace(/\n/g, " ").replace(/"/g, '\\"');
369
+ const withoutFrontmatter = composed.replace(/^---\n[\s\S]*?\n---\n*/, "");
370
+ const agentFrontmatter: string[] = [
371
+ "---",
372
+ `name: "${personaName}"`,
373
+ `description: "${safeDescription}"`,
374
+ ];
375
+ if (model) agentFrontmatter.push(`model: "${model}"`);
376
+ if (permMode && permMode !== "default") agentFrontmatter.push(`permissionMode: "${permMode}"`);
377
+ agentFrontmatter.push("---");
378
+ const agentContent = [...agentFrontmatter, "", withoutFrontmatter].join("\n");
379
+
380
+ fs.writeFileSync(path.join(agentDir, `${personaName}.md`), agentContent, "utf-8");
381
+ }
382
+
383
+ if (outputFormats.includes("copilot")) {
384
+ const outDir = path.join(distPath, "copilot", scopePath, personaName);
385
+ ensureDir(outDir);
386
+ const content = buildCopilotOutput(personaName, personaConfig, composed, config, skillPath);
387
+ fs.writeFileSync(path.join(outDir, "copilot-instructions.md"), content, "utf-8");
388
+ if (personaConfig) {
389
+ fs.writeFileSync(
390
+ path.join(outDir, "persona.config.json"),
391
+ JSON.stringify(personaConfig, null, 2) + "\n",
392
+ "utf-8"
393
+ );
394
+ }
395
+ platforms.push("copilot");
396
+ }
397
+
398
+ return { persona: personaName, platforms, traitsInjected: injected, scope };
399
+ }
400
+
401
+ // ---------------------------------------------------------------------------
402
+ // Always-on instructions compilation — writes to each platform
403
+ // ---------------------------------------------------------------------------
404
+
405
+ function compileInstructions(
406
+ instructionsDir: string,
407
+ enabledInstructions: string[] | undefined,
408
+ distPath: string,
409
+ scopePath: string,
410
+ config: AgentBootConfig,
411
+ outputFormats: string[]
412
+ ): void {
413
+ if (!fs.existsSync(instructionsDir)) {
414
+ return;
415
+ }
416
+
417
+ const files = fs.readdirSync(instructionsDir).filter((f) => f.endsWith(".md"));
418
+ const provenanceEnabled = config.output?.provenanceHeaders !== false;
419
+
420
+ for (const platform of outputFormats) {
421
+ // CC uses "rules/" for always-on instructions; other platforms use "instructions/"
422
+ const dirName = platform === "claude" ? "rules" : "instructions";
423
+ const outDir = path.join(distPath, platform, scopePath, dirName);
424
+ ensureDir(outDir);
425
+
426
+ for (const file of files) {
427
+ const name = path.basename(file, ".md");
428
+ if (enabledInstructions && !enabledInstructions.includes(name)) {
429
+ continue;
430
+ }
431
+ const srcPath = path.join(instructionsDir, file);
432
+ let content = fs.readFileSync(srcPath, "utf-8");
433
+
434
+ // Strip HTML comments for copilot output
435
+ if (platform === "copilot") {
436
+ content = content.replace(/<!--[\s\S]*?-->/g, "").trim() + "\n";
437
+ }
438
+
439
+ let finalContent: string;
440
+ if (!provenanceEnabled) {
441
+ finalContent = content;
442
+ } else if (platform === "claude") {
443
+ // For CC rules, frontmatter must be the first thing in the file.
444
+ // Insert provenance after the closing --- of frontmatter.
445
+ const fmMatch = content.match(/^(---\n[\s\S]*?\n---\n)/);
446
+ if (fmMatch) {
447
+ const afterFm = content.slice(fmMatch[1].length);
448
+ finalContent = `${fmMatch[1]}\n${provenanceHeader(srcPath, config)}${afterFm}`;
449
+ } else {
450
+ finalContent = `${provenanceHeader(srcPath, config)}${content}`;
451
+ }
452
+ } else {
453
+ finalContent = `${provenanceHeader(srcPath, config)}${content}`;
454
+ }
455
+ fs.writeFileSync(path.join(outDir, file), finalContent, "utf-8");
456
+ }
457
+ }
458
+ }
459
+
460
+ // ---------------------------------------------------------------------------
461
+ // AB-52: Gotchas compilation — path-scoped knowledge rules
462
+ // ---------------------------------------------------------------------------
463
+
464
+ function compileGotchas(
465
+ gotchasDir: string,
466
+ distPath: string,
467
+ scopePath: string,
468
+ config: AgentBootConfig,
469
+ outputFormats: string[]
470
+ ): void {
471
+ if (!fs.existsSync(gotchasDir)) {
472
+ return;
473
+ }
474
+
475
+ const gotchaFiles = fs.readdirSync(gotchasDir).filter(
476
+ (f) => f.endsWith(".md") && f !== "README.md"
477
+ );
478
+
479
+ if (gotchaFiles.length === 0) return;
480
+
481
+ log(chalk.gray(` Gotchas: ${gotchaFiles.length} rule(s)`));
482
+
483
+ for (const file of gotchaFiles) {
484
+ const content = fs.readFileSync(path.join(gotchasDir, file), "utf-8");
485
+ const provenanceEnabled = config.output?.provenanceHeaders !== false;
486
+ const header = provenanceEnabled
487
+ ? provenanceHeader(path.join(gotchasDir, file), config)
488
+ : "";
489
+
490
+ // Write to claude rules (gotchas are path-scoped rules)
491
+ if (outputFormats.includes("claude")) {
492
+ const rulesDir = path.join(distPath, "claude", scopePath, "rules");
493
+ ensureDir(rulesDir);
494
+ fs.writeFileSync(path.join(rulesDir, file), `${header}${content}`, "utf-8");
495
+ }
496
+
497
+ // Write to skill output as well
498
+ if (outputFormats.includes("skill")) {
499
+ const gotchaOutDir = path.join(distPath, "skill", scopePath, "gotchas");
500
+ ensureDir(gotchaOutDir);
501
+ fs.writeFileSync(path.join(gotchaOutDir, file), `${header}${content}`, "utf-8");
502
+ }
503
+ }
504
+ }
505
+
506
+ // ---------------------------------------------------------------------------
507
+ // PERSONAS.md index generation — writes to each platform
508
+ // ---------------------------------------------------------------------------
509
+
510
+ function generatePersonasIndex(
511
+ results: CompileResult[],
512
+ config: AgentBootConfig,
513
+ personasBaseDir: string,
514
+ distPath: string,
515
+ scopePath: string,
516
+ outputFormats: string[]
517
+ ): void {
518
+ const org = config.orgDisplayName ?? config.org;
519
+ const lines: string[] = [
520
+ `<!-- AgentBoot generated — do not edit manually. Org: ${org} -->`,
521
+ "",
522
+ `# Available Personas`,
523
+ "",
524
+ `> Generated by AgentBoot for **${org}**. Run \`npm run build\` to refresh.`,
525
+ "",
526
+ "| Persona | Invocation | Description |",
527
+ "|---|---|---|",
528
+ ];
529
+
530
+ for (const result of results.filter((r) => r.platforms.length > 0)) {
531
+ const personaConfigPath = path.join(personasBaseDir, result.persona, "persona.config.json");
532
+ let invocation = `/${result.persona}`;
533
+ let description = "";
534
+
535
+ if (fs.existsSync(personaConfigPath)) {
536
+ try {
537
+ const pc = JSON.parse(fs.readFileSync(personaConfigPath, "utf-8")) as PersonaConfig;
538
+ invocation = pc.invocation ?? invocation;
539
+ description = pc.description ?? "";
540
+ } catch {
541
+ // ignore
542
+ }
543
+ }
544
+
545
+ lines.push(`| **${result.persona}** | \`${invocation}\` | ${description} |`);
546
+ }
547
+
548
+ lines.push("", `*Last compiled: ${new Date().toISOString()}*`, "");
549
+ const content = lines.join("\n");
550
+
551
+ for (const platform of outputFormats) {
552
+ const outDir = path.join(distPath, platform, scopePath);
553
+ ensureDir(outDir);
554
+ fs.writeFileSync(path.join(outDir, "PERSONAS.md"), content, "utf-8");
555
+ }
556
+ }
557
+
558
+ // ---------------------------------------------------------------------------
559
+ // AB-19: CLAUDE.md with @import directives + trait files
560
+ // ---------------------------------------------------------------------------
561
+
562
+ function generateClaudeMd(
563
+ traitNames: string[],
564
+ traits: Map<string, TraitContent>,
565
+ instructionFileNames: string[],
566
+ config: AgentBootConfig,
567
+ distPath: string,
568
+ scopePath: string,
569
+ personaConfigs?: Map<string, PersonaConfig>
570
+ ): void {
571
+ const org = config.orgDisplayName ?? config.org;
572
+
573
+ // Write trait files to dist/claude/{scopePath}/traits/
574
+ const traitsDir = path.join(distPath, "claude", scopePath, "traits");
575
+ ensureDir(traitsDir);
576
+
577
+ for (const traitName of traitNames) {
578
+ const trait = traits.get(traitName);
579
+ if (trait) {
580
+ fs.writeFileSync(path.join(traitsDir, `${traitName}.md`), trait.content, "utf-8");
581
+ }
582
+ }
583
+
584
+ // Build CLAUDE.md with @import directives
585
+ const lines: string[] = [
586
+ `# AgentBoot — ${org}`,
587
+ "",
588
+ "<!-- Auto-generated. Do not edit manually. -->",
589
+ "",
590
+ ];
591
+
592
+ if (traitNames.length > 0) {
593
+ lines.push("## Traits", "");
594
+ for (const traitName of traitNames) {
595
+ if (traits.has(traitName)) {
596
+ lines.push(`@.claude/traits/${traitName}.md`);
597
+ }
598
+ }
599
+ lines.push("");
600
+ }
601
+
602
+ if (instructionFileNames.length > 0) {
603
+ lines.push("## Instructions", "");
604
+ for (const instrName of instructionFileNames) {
605
+ lines.push(`@.claude/rules/${instrName}.md`);
606
+ }
607
+ lines.push("");
608
+ }
609
+
610
+ // AB-77: First-session welcome fragment
611
+ if (personaConfigs && personaConfigs.size > 0) {
612
+ lines.push("## Available Personas", "");
613
+ for (const [, pc] of personaConfigs) {
614
+ const cmd = pc.invocation ?? `/${pc.name}`;
615
+ lines.push(`- \`${cmd}\` — ${pc.description}`);
616
+ }
617
+ lines.push("");
618
+ }
619
+
620
+ const claudeMdPath = path.join(distPath, "claude", scopePath, "CLAUDE.md");
621
+ fs.writeFileSync(claudeMdPath, lines.join("\n"), "utf-8");
622
+ }
623
+
624
+ // ---------------------------------------------------------------------------
625
+ // AB-26: settings.json generation
626
+ // ---------------------------------------------------------------------------
627
+
628
+ function generateSettingsJson(
629
+ config: AgentBootConfig,
630
+ distPath: string,
631
+ scopePath: string
632
+ ): void {
633
+ const hooks = config.claude?.hooks;
634
+ const permissions = config.claude?.permissions;
635
+
636
+ if (!hooks && !permissions) return;
637
+
638
+ // Validate hooks structure (must be an object with string keys)
639
+ if (hooks && typeof hooks !== "object") {
640
+ log(chalk.yellow(" ⚠ config.claude.hooks must be an object — skipping settings.json"));
641
+ return;
642
+ }
643
+ if (permissions) {
644
+ if (permissions.allow && !Array.isArray(permissions.allow)) {
645
+ log(chalk.yellow(" ⚠ config.claude.permissions.allow must be an array — skipping"));
646
+ return;
647
+ }
648
+ if (permissions.deny && !Array.isArray(permissions.deny)) {
649
+ log(chalk.yellow(" ⚠ config.claude.permissions.deny must be an array — skipping"));
650
+ return;
651
+ }
652
+ }
653
+
654
+ log(chalk.yellow(" ⚠ Generating settings.json with hooks/permissions — these will be synced to all target repos"));
655
+
656
+ const settings: Record<string, unknown> = {};
657
+ if (hooks) settings.hooks = hooks;
658
+ if (permissions) settings.permissions = permissions;
659
+
660
+ const settingsPath = path.join(distPath, "claude", scopePath, "settings.json");
661
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
662
+ }
663
+
664
+ // ---------------------------------------------------------------------------
665
+ // AB-27: .mcp.json generation
666
+ // ---------------------------------------------------------------------------
667
+
668
+ function generateMcpJson(
669
+ config: AgentBootConfig,
670
+ distPath: string,
671
+ scopePath: string
672
+ ): void {
673
+ const mcpServers = config.claude?.mcpServers;
674
+ if (!mcpServers) return;
675
+
676
+ if (typeof mcpServers !== "object") {
677
+ log(chalk.yellow(" ⚠ config.claude.mcpServers must be an object — skipping .mcp.json"));
678
+ return;
679
+ }
680
+
681
+ log(chalk.yellow(" ⚠ Generating .mcp.json with MCP servers — these will be synced to all target repos"));
682
+
683
+ const mcpJson = { mcpServers };
684
+ const mcpPath = path.join(distPath, "claude", scopePath, ".mcp.json");
685
+ fs.writeFileSync(mcpPath, JSON.stringify(mcpJson, null, 2) + "\n", "utf-8");
686
+ }
687
+
688
+ // ---------------------------------------------------------------------------
689
+ // Main entry point
690
+ // ---------------------------------------------------------------------------
691
+
692
+ function main(): void {
693
+ const argv = process.argv.slice(2);
694
+ const configPath = resolveConfigPath(argv, ROOT);
695
+
696
+ log(chalk.bold("\nAgentBoot — compile"));
697
+ log(chalk.gray(`Config: ${configPath}\n`));
698
+
699
+ const config = loadConfig(configPath);
700
+ const configDir = path.dirname(configPath);
701
+
702
+ const distPath = path.resolve(
703
+ configDir,
704
+ config.output?.distPath ?? "./dist"
705
+ );
706
+
707
+ // Optional: fail on dirty dist.
708
+ if (config.output?.failOnDirtyDist && fs.existsSync(distPath)) {
709
+ const entries = fs.readdirSync(distPath);
710
+ if (entries.length > 0) {
711
+ fatal(
712
+ `dist/ is not empty and failOnDirtyDist is enabled. Run: rm -rf ${distPath}`
713
+ );
714
+ }
715
+ }
716
+
717
+ ensureDir(distPath);
718
+
719
+ const coreDir = path.join(ROOT, "core");
720
+ const corePersonasDir = path.join(coreDir, "personas");
721
+ const coreTraitsDir = path.join(coreDir, "traits");
722
+ const coreInstructionsDir = path.join(coreDir, "instructions");
723
+
724
+ const validFormats = ["skill", "claude", "copilot"];
725
+ const outputFormats = config.personas?.outputFormats ?? validFormats;
726
+ const unknownFormats = outputFormats.filter((f) => !validFormats.includes(f));
727
+ if (unknownFormats.length > 0) {
728
+ console.error(chalk.red(`Unknown output format(s): ${unknownFormats.join(", ")}. Valid: ${validFormats.join(", ")}`));
729
+ process.exit(1);
730
+ }
731
+
732
+ // Load traits.
733
+ const enabledTraits = config.traits?.enabled;
734
+ const traits = loadTraits(coreTraitsDir, enabledTraits);
735
+
736
+ log(chalk.cyan(`Traits loaded: ${traits.size}`));
737
+ for (const name of traits.keys()) {
738
+ log(chalk.gray(` + ${name}`));
739
+ }
740
+ log(chalk.cyan(`Output formats: ${outputFormats.join(", ")}`));
741
+ log("");
742
+
743
+ const enabledPersonas = config.personas?.enabled;
744
+
745
+ // Discover persona directories.
746
+ const personaDirs = new Map<string, string>();
747
+
748
+ if (fs.existsSync(corePersonasDir)) {
749
+ for (const entry of fs.readdirSync(corePersonasDir)) {
750
+ const dir = path.join(corePersonasDir, entry);
751
+ if (fs.statSync(dir).isDirectory()) {
752
+ personaDirs.set(entry, dir);
753
+ }
754
+ }
755
+ }
756
+
757
+ if (config.personas?.customDir) {
758
+ const extendDir = path.resolve(configDir, config.personas.extend);
759
+ if (fs.existsSync(extendDir)) {
760
+ for (const entry of fs.readdirSync(extendDir)) {
761
+ const dir = path.join(extendDir, entry);
762
+ if (fs.statSync(dir).isDirectory()) {
763
+ if (personaDirs.has(entry)) {
764
+ log(chalk.yellow(` ⚠ Extension persona overrides core: ${entry}`));
765
+ }
766
+ personaDirs.set(entry, dir);
767
+ }
768
+ }
769
+ } else {
770
+ log(chalk.yellow(` ⚠ Extension path not found: ${extendDir}`));
771
+ }
772
+ }
773
+
774
+ const allResults: CompileResult[] = [];
775
+
776
+ // ---------------------------------------------------------------------------
777
+ // 1. Compile core personas → dist/{platform}/core/{persona}/
778
+ // ---------------------------------------------------------------------------
779
+
780
+ log(chalk.cyan("Compiling core personas..."));
781
+
782
+ for (const [personaName, personaDir] of personaDirs) {
783
+ if (enabledPersonas && !enabledPersonas.includes(personaName)) {
784
+ log(chalk.gray(` - ${personaName} (disabled)`));
785
+ continue;
786
+ }
787
+
788
+ const result = compilePersona(
789
+ personaName,
790
+ personaDir,
791
+ traits,
792
+ config,
793
+ distPath,
794
+ "core" // scopePath → dist/{platform}/core/{persona}/
795
+ );
796
+
797
+ allResults.push(result);
798
+
799
+ const traitsNote =
800
+ result.traitsInjected.length > 0
801
+ ? chalk.gray(` [traits: ${result.traitsInjected.join(", ")}]`)
802
+ : chalk.gray(" [no traits]");
803
+ log(` ${chalk.green("✓")} ${personaName}${traitsNote}`);
804
+ }
805
+
806
+ // Compile always-on instructions.
807
+ compileInstructions(
808
+ coreInstructionsDir,
809
+ config.instructions?.enabled,
810
+ distPath,
811
+ "core",
812
+ config,
813
+ outputFormats
814
+ );
815
+
816
+ // AB-52: Compile gotchas (path-scoped knowledge rules)
817
+ const coreGotchasDir = path.join(coreDir, "gotchas");
818
+ compileGotchas(coreGotchasDir, distPath, "core", config, outputFormats);
819
+
820
+ // AB-19/26/27: Claude-specific output (CLAUDE.md, settings.json, .mcp.json)
821
+ if (outputFormats.includes("claude")) {
822
+ // Collect instruction file names for @import directives
823
+ const instrFileNames: string[] = [];
824
+ if (fs.existsSync(coreInstructionsDir)) {
825
+ const instrFiles = fs.readdirSync(coreInstructionsDir).filter((f) => f.endsWith(".md"));
826
+ for (const file of instrFiles) {
827
+ const name = path.basename(file, ".md");
828
+ if (!config.instructions?.enabled || config.instructions.enabled.includes(name)) {
829
+ instrFileNames.push(name);
830
+ }
831
+ }
832
+ }
833
+
834
+ // Collect persona configs for welcome fragment (AB-77)
835
+ const personaConfigs = new Map<string, PersonaConfig>();
836
+ for (const [personaName, personaDir] of personaDirs) {
837
+ if (enabledPersonas && !enabledPersonas.includes(personaName)) continue;
838
+ const pc = loadPersonaConfig(personaDir);
839
+ if (pc) personaConfigs.set(personaName, pc);
840
+ }
841
+
842
+ generateClaudeMd(
843
+ [...traits.keys()],
844
+ traits,
845
+ instrFileNames,
846
+ config,
847
+ distPath,
848
+ "core",
849
+ personaConfigs
850
+ );
851
+
852
+ generateSettingsJson(config, distPath, "core");
853
+ generateMcpJson(config, distPath, "core");
854
+ }
855
+
856
+ // ---------------------------------------------------------------------------
857
+ // 2. Compile group-level overrides → dist/{platform}/groups/{group}/{persona}/
858
+ // ---------------------------------------------------------------------------
859
+
860
+ if (config.groups) {
861
+ log(chalk.cyan("\nCompiling group-level personas..."));
862
+
863
+ for (const groupName of Object.keys(config.groups)) {
864
+ const groupPersonasDir = path.join(ROOT, "groups", groupName, "personas");
865
+
866
+ if (!fs.existsSync(groupPersonasDir)) {
867
+ continue;
868
+ }
869
+
870
+ const groupPersonaDirs = fs.readdirSync(groupPersonasDir).filter((entry) =>
871
+ fs.statSync(path.join(groupPersonasDir, entry)).isDirectory()
872
+ );
873
+
874
+ for (const personaName of groupPersonaDirs) {
875
+ if (enabledPersonas && !enabledPersonas.includes(personaName)) {
876
+ continue;
877
+ }
878
+
879
+ const personaDir = path.join(groupPersonasDir, personaName);
880
+ const result = compilePersona(
881
+ personaName,
882
+ personaDir,
883
+ traits,
884
+ config,
885
+ distPath,
886
+ `groups/${groupName}`,
887
+ groupName
888
+ );
889
+ allResults.push(result);
890
+ log(` ${chalk.green("✓")} ${groupName}/${personaName}`);
891
+ }
892
+ }
893
+ }
894
+
895
+ // ---------------------------------------------------------------------------
896
+ // 3. Compile team-level overrides → dist/{platform}/teams/{group}/{team}/{persona}/
897
+ // ---------------------------------------------------------------------------
898
+
899
+ if (config.groups) {
900
+ log(chalk.cyan("\nCompiling team-level personas..."));
901
+ let teamPersonasFound = false;
902
+
903
+ for (const groupName of Object.keys(config.groups)) {
904
+ const teams = config.groups[groupName]!.teams ?? [];
905
+
906
+ for (const teamName of teams) {
907
+ const teamPersonasDir = path.join(ROOT, "teams", groupName, teamName, "personas");
908
+
909
+ if (!fs.existsSync(teamPersonasDir)) {
910
+ continue;
911
+ }
912
+
913
+ teamPersonasFound = true;
914
+
915
+ const teamPersonaDirs = fs.readdirSync(teamPersonasDir).filter((entry) =>
916
+ fs.statSync(path.join(teamPersonasDir, entry)).isDirectory()
917
+ );
918
+
919
+ for (const personaName of teamPersonaDirs) {
920
+ if (enabledPersonas && !enabledPersonas.includes(personaName)) {
921
+ continue;
922
+ }
923
+
924
+ const personaDir = path.join(teamPersonasDir, personaName);
925
+ const result = compilePersona(
926
+ personaName,
927
+ personaDir,
928
+ traits,
929
+ config,
930
+ distPath,
931
+ `teams/${groupName}/${teamName}`, // scopePath → dist/{platform}/teams/{group}/{team}/{persona}/
932
+ groupName,
933
+ teamName
934
+ );
935
+ allResults.push(result);
936
+ log(` ${chalk.green("✓")} ${groupName}/${teamName}/${personaName}`);
937
+ }
938
+ }
939
+ }
940
+
941
+ if (!teamPersonasFound) {
942
+ log(chalk.gray(" (no team-level overrides found)"));
943
+ }
944
+ }
945
+
946
+ // ---------------------------------------------------------------------------
947
+ // 4. Generate PERSONAS.md index in each platform
948
+ // ---------------------------------------------------------------------------
949
+
950
+ generatePersonasIndex(allResults, config, corePersonasDir, distPath, "core", outputFormats);
951
+ log(chalk.gray("\n → PERSONAS.md written to each platform"));
952
+
953
+ // ---------------------------------------------------------------------------
954
+ // 5. AB-25: Token budget estimation
955
+ // ---------------------------------------------------------------------------
956
+
957
+ const tokenBudget = config.output?.tokenBudget?.warnAt ?? 8000;
958
+ log(chalk.cyan("\nToken estimates:"));
959
+
960
+ for (const result of allResults.filter((r) => r.platforms.length > 0)) {
961
+ const skillPath = path.join(distPath, "skill", "core", result.persona, "SKILL.md");
962
+ if (fs.existsSync(skillPath)) {
963
+ const content = fs.readFileSync(skillPath, "utf-8");
964
+ const estimatedTokens = Math.ceil(content.length / 4);
965
+
966
+ if (estimatedTokens > tokenBudget) {
967
+ log(
968
+ chalk.yellow(
969
+ ` ⚠ [${result.persona}] estimated ${estimatedTokens} tokens (budget: ${tokenBudget})`
970
+ )
971
+ );
972
+ } else {
973
+ log(chalk.gray(` ${result.persona}: ~${estimatedTokens} tokens`));
974
+ }
975
+ }
976
+ }
977
+
978
+ // ---------------------------------------------------------------------------
979
+ // Summary
980
+ // ---------------------------------------------------------------------------
981
+
982
+ const successCount = allResults.filter((r) => r.platforms.length > 0).length;
983
+ const platformCount = allResults.reduce((acc, r) => acc + r.platforms.length, 0);
984
+
985
+ log(
986
+ chalk.bold(
987
+ `\n${chalk.green("✓")} Compiled ${successCount} persona(s) × ${outputFormats.length} platform(s) → ${path.relative(ROOT, distPath)}/`
988
+ )
989
+ );
990
+ for (const fmt of outputFormats) {
991
+ log(chalk.gray(` → dist/${fmt}/`));
992
+ }
993
+ }
994
+
995
+ try {
996
+ main();
997
+ } catch (err: unknown) {
998
+ console.error(chalk.red("Unexpected error:"), err);
999
+ process.exit(1);
1000
+ }