agentboot 0.1.0 → 0.3.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/README.md +9 -8
  2. package/agentboot.config.json +4 -1
  3. package/package.json +2 -2
  4. package/scripts/cli.ts +465 -18
  5. package/scripts/compile.ts +724 -75
  6. package/scripts/dev-sync.ts +1 -1
  7. package/scripts/lib/config.ts +259 -1
  8. package/scripts/lib/frontmatter.ts +3 -1
  9. package/scripts/validate.ts +12 -7
  10. package/website/docusaurus.config.ts +117 -0
  11. package/website/package-lock.json +18448 -0
  12. package/website/package.json +47 -0
  13. package/website/sidebars.ts +53 -0
  14. package/website/src/css/custom.css +23 -0
  15. package/website/src/pages/index.module.css +23 -0
  16. package/website/src/pages/index.tsx +125 -0
  17. package/website/static/.nojekyll +0 -0
  18. package/website/static/CNAME +1 -0
  19. package/website/static/img/favicon.ico +0 -0
  20. package/website/static/img/logo.svg +1 -0
  21. package/.github/ISSUE_TEMPLATE/persona-request.md +0 -62
  22. package/.github/ISSUE_TEMPLATE/quality-feedback.md +0 -67
  23. package/.github/workflows/cla.yml +0 -25
  24. package/.github/workflows/validate.yml +0 -49
  25. package/.idea/agentboot.iml +0 -9
  26. package/.idea/misc.xml +0 -6
  27. package/.idea/modules.xml +0 -8
  28. package/.idea/vcs.xml +0 -6
  29. package/CLAUDE.md +0 -230
  30. package/CONTRIBUTING.md +0 -168
  31. package/PERSONAS.md +0 -156
  32. package/core/instructions/baseline.instructions.md +0 -133
  33. package/core/instructions/security.instructions.md +0 -186
  34. package/core/personas/code-reviewer/SKILL.md +0 -175
  35. package/core/personas/security-reviewer/SKILL.md +0 -233
  36. package/core/personas/test-data-expert/SKILL.md +0 -234
  37. package/core/personas/test-generator/SKILL.md +0 -262
  38. package/core/traits/audit-trail.md +0 -182
  39. package/core/traits/confidence-signaling.md +0 -172
  40. package/core/traits/critical-thinking.md +0 -129
  41. package/core/traits/schema-awareness.md +0 -132
  42. package/core/traits/source-citation.md +0 -174
  43. package/core/traits/structured-output.md +0 -199
  44. package/docs/ci-cd-automation.md +0 -548
  45. package/docs/claude-code-reference/README.md +0 -21
  46. package/docs/claude-code-reference/agentboot-coverage.md +0 -484
  47. package/docs/claude-code-reference/feature-inventory.md +0 -906
  48. package/docs/cli-commands-audit.md +0 -112
  49. package/docs/cli-design.md +0 -924
  50. package/docs/concepts.md +0 -1117
  51. package/docs/config-schema-audit.md +0 -121
  52. package/docs/configuration.md +0 -645
  53. package/docs/delivery-methods.md +0 -758
  54. package/docs/developer-onboarding.md +0 -342
  55. package/docs/extending.md +0 -448
  56. package/docs/getting-started.md +0 -298
  57. package/docs/knowledge-layer.md +0 -464
  58. package/docs/marketplace.md +0 -822
  59. package/docs/org-connection.md +0 -570
  60. package/docs/plans/architecture.md +0 -2429
  61. package/docs/plans/design.md +0 -2018
  62. package/docs/plans/prd.md +0 -1862
  63. package/docs/plans/stack-rank.md +0 -261
  64. package/docs/plans/technical-spec.md +0 -2755
  65. package/docs/privacy-and-safety.md +0 -807
  66. package/docs/prompt-optimization.md +0 -1071
  67. package/docs/test-plan.md +0 -972
  68. package/docs/third-party-ecosystem.md +0 -496
  69. package/domains/compliance-template/README.md +0 -173
  70. package/domains/compliance-template/traits/compliance-aware.md +0 -228
  71. package/examples/enterprise/agentboot.config.json +0 -184
  72. package/examples/minimal/agentboot.config.json +0 -46
  73. package/tests/REGRESSION-PLAN.md +0 -705
  74. package/tests/TEST-PLAN.md +0 -111
  75. package/tests/cli.test.ts +0 -705
  76. package/tests/pipeline.test.ts +0 -608
  77. package/tests/validate.test.ts +0 -278
  78. package/tsconfig.json +0 -62
@@ -33,9 +33,13 @@ import chalk from "chalk";
33
33
  import {
34
34
  type AgentBootConfig,
35
35
  type PersonaConfig,
36
+ type DomainManifest,
37
+ type PluginManifest,
36
38
  resolveConfigPath,
37
39
  loadConfig,
38
40
  stripJsoncComments,
41
+ flattenNodes,
42
+ groupsToNodes,
39
43
  } from "./lib/config.js";
40
44
 
41
45
  // ---------------------------------------------------------------------------
@@ -210,7 +214,7 @@ function injectTraits(
210
214
  // ---------------------------------------------------------------------------
211
215
 
212
216
  function buildSkillOutput(
213
- personaName: string,
217
+ _personaName: string,
214
218
  _personaConfig: PersonaConfig | null,
215
219
  composedContent: string,
216
220
  config: AgentBootConfig,
@@ -236,8 +240,12 @@ function buildClaudeOutput(
236
240
  const invocation = personaConfig?.invocation ?? `/${personaName}`;
237
241
  const skillName = invocation.replace(/^\//, "");
238
242
  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, '\\"');
243
+ // Escape for YAML double-quoted strings
244
+ const safeDescription = description
245
+ .replace(/\\/g, "\\\\")
246
+ .replace(/"/g, '\\"')
247
+ .replace(/\n/g, " ")
248
+ .replace(/---/g, "\\-\\-\\-");
241
249
 
242
250
  // AB-18: CC skill frontmatter with context:fork → delegates to agent
243
251
  const frontmatterLines: string[] = [
@@ -363,9 +371,12 @@ function compilePersona(
363
371
  const model = personaConfig?.model; // undefined = omit from frontmatter
364
372
  const permMode = personaConfig?.permissionMode;
365
373
  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, '\\"');
374
+ // Escape for YAML double-quoted strings: backslashes, quotes, newlines, and --- sequences.
375
+ const safeDescription = agentDescription
376
+ .replace(/\\/g, "\\\\") // backslashes first (before other escapes add more)
377
+ .replace(/"/g, '\\"') // double quotes
378
+ .replace(/\n/g, " ") // newlines → spaces
379
+ .replace(/---/g, "\\-\\-\\-"); // prevent YAML document markers
369
380
  const withoutFrontmatter = composed.replace(/^---\n[\s\S]*?\n---\n*/, "");
370
381
  const agentFrontmatter: string[] = [
371
382
  "---",
@@ -444,7 +455,7 @@ function compileInstructions(
444
455
  // Insert provenance after the closing --- of frontmatter.
445
456
  const fmMatch = content.match(/^(---\n[\s\S]*?\n---\n)/);
446
457
  if (fmMatch) {
447
- const afterFm = content.slice(fmMatch[1].length);
458
+ const afterFm = content.slice(fmMatch[1]!.length);
448
459
  finalContent = `${fmMatch[1]}\n${provenanceHeader(srcPath, config)}${afterFm}`;
449
460
  } else {
450
461
  finalContent = `${provenanceHeader(srcPath, config)}${content}`;
@@ -651,11 +662,27 @@ function generateSettingsJson(
651
662
  }
652
663
  }
653
664
 
654
- log(chalk.yellow(" ⚠ Generating settings.json with hooks/permissions these will be synced to all target repos"));
665
+ // Security: hooks execute shell commands in target repos warn prominently
666
+ if (hooks) {
667
+ log(chalk.red(" ⚠ CAUTION: settings.json contains hooks that execute shell commands in target repos."));
668
+ log(chalk.red(" Review claude.hooks in agentboot.config.json carefully before syncing."));
669
+ // Validate hook event names against known CC events
670
+ const validEvents = [
671
+ "PreToolUse", "PostToolUse", "Notification", "Stop",
672
+ "SubagentStop", "SubagentStart", "UserPromptSubmit", "SessionEnd",
673
+ ];
674
+ for (const key of Object.keys(hooks)) {
675
+ if (!validEvents.includes(key)) {
676
+ log(chalk.yellow(` ⚠ Unknown hook event: "${key}" — may not be recognized by Claude Code`));
677
+ }
678
+ }
679
+ } else {
680
+ log(chalk.yellow(" ⚠ Generating settings.json with permissions — these will be synced to all target repos"));
681
+ }
655
682
 
656
683
  const settings: Record<string, unknown> = {};
657
- if (hooks) settings.hooks = hooks;
658
- if (permissions) settings.permissions = permissions;
684
+ if (hooks) settings["hooks"] = hooks;
685
+ if (permissions) settings["permissions"] = permissions;
659
686
 
660
687
  const settingsPath = path.join(distPath, "claude", scopePath, "settings.json");
661
688
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
@@ -685,6 +712,603 @@ function generateMcpJson(
685
712
  fs.writeFileSync(mcpPath, JSON.stringify(mcpJson, null, 2) + "\n", "utf-8");
686
713
  }
687
714
 
715
+ // ---------------------------------------------------------------------------
716
+ // AB-53: Domain layer loading
717
+ // ---------------------------------------------------------------------------
718
+
719
+ function loadDomainManifest(domainDir: string): DomainManifest | null {
720
+ const manifestPath = path.join(domainDir, "agentboot.domain.json");
721
+ if (!fs.existsSync(manifestPath)) {
722
+ return null;
723
+ }
724
+ try {
725
+ const raw = fs.readFileSync(manifestPath, "utf-8");
726
+ return JSON.parse(stripJsoncComments(raw)) as DomainManifest;
727
+ } catch {
728
+ log(chalk.yellow(` ⚠ Failed to parse agentboot.domain.json in ${domainDir}`));
729
+ return null;
730
+ }
731
+ }
732
+
733
+ function compileDomains(
734
+ config: AgentBootConfig,
735
+ configDir: string,
736
+ distPath: string,
737
+ traits: Map<string, TraitContent>,
738
+ outputFormats: string[]
739
+ ): CompileResult[] {
740
+ const domains = config.domains;
741
+ if (!domains || domains.length === 0) return [];
742
+
743
+ log(chalk.cyan("\nCompiling domain layers..."));
744
+ const results: CompileResult[] = [];
745
+
746
+ for (const domainRef of domains) {
747
+ const domainPath = typeof domainRef === "string"
748
+ ? path.resolve(configDir, domainRef)
749
+ : path.resolve(configDir, domainRef.path ?? `./domains/${domainRef.name}`);
750
+
751
+ if (!fs.existsSync(domainPath)) {
752
+ log(chalk.yellow(` ⚠ Domain not found: ${domainPath}`));
753
+ continue;
754
+ }
755
+
756
+ // S3 fix: path traversal protection — resolve symlinks then check boundary
757
+ const boundary = path.resolve(configDir);
758
+ const realDomainPath = fs.realpathSync(domainPath);
759
+ if (!realDomainPath.startsWith(boundary + path.sep) && realDomainPath !== boundary) {
760
+ log(chalk.red(` ✗ Domain path escapes project boundary: ${domainPath} → ${realDomainPath}`));
761
+ continue;
762
+ }
763
+
764
+ const manifest = loadDomainManifest(domainPath);
765
+ const domainName = manifest?.name ?? path.basename(domainPath);
766
+ log(chalk.gray(` Domain: ${domainName}${manifest?.version ? ` v${manifest.version}` : ""}`));
767
+
768
+ // Load domain-specific traits
769
+ const domainTraitsDir = path.join(domainPath, "traits");
770
+ if (fs.existsSync(domainTraitsDir)) {
771
+ const domainTraits = loadTraits(domainTraitsDir, undefined);
772
+ for (const [name, trait] of domainTraits) {
773
+ if (traits.has(name)) {
774
+ log(chalk.yellow(` ⚠ Domain trait '${name}' shadows existing trait`));
775
+ }
776
+ traits.set(name, trait);
777
+ }
778
+ log(chalk.gray(` + ${domainTraits.size} trait(s)`));
779
+ }
780
+
781
+ // Compile domain personas
782
+ const domainPersonasDir = path.join(domainPath, "personas");
783
+ if (fs.existsSync(domainPersonasDir)) {
784
+ const personaDirs = fs.readdirSync(domainPersonasDir).filter((entry) =>
785
+ fs.statSync(path.join(domainPersonasDir, entry)).isDirectory()
786
+ );
787
+ for (const personaName of personaDirs) {
788
+ const personaDir = path.join(domainPersonasDir, personaName);
789
+ const result = compilePersona(
790
+ personaName,
791
+ personaDir,
792
+ traits,
793
+ config,
794
+ distPath,
795
+ `domains/${domainName}`
796
+ );
797
+ results.push(result);
798
+ log(` ${chalk.green("✓")} ${personaName}`);
799
+ }
800
+ }
801
+
802
+ // Compile domain instructions
803
+ const domainInstructionsDir = path.join(domainPath, "instructions");
804
+ compileInstructions(
805
+ domainInstructionsDir,
806
+ undefined,
807
+ distPath,
808
+ `domains/${domainName}`,
809
+ config,
810
+ outputFormats
811
+ );
812
+ }
813
+
814
+ return results;
815
+ }
816
+
817
+ // ---------------------------------------------------------------------------
818
+ // AB-57: Plugin structure generation
819
+ // ---------------------------------------------------------------------------
820
+
821
+ function generatePluginOutput(
822
+ config: AgentBootConfig,
823
+ distPath: string,
824
+ allResults: CompileResult[],
825
+ personasBaseDir: string,
826
+ traits: Map<string, TraitContent>
827
+ ): void {
828
+ const pluginDir = path.join(distPath, "plugin");
829
+ ensureDir(pluginDir);
830
+
831
+ const pkgPath = path.join(ROOT, "package.json");
832
+ const pkg = fs.existsSync(pkgPath)
833
+ ? JSON.parse(fs.readFileSync(pkgPath, "utf-8"))
834
+ : { version: "0.0.0" };
835
+
836
+ const personas: PluginManifest["personas"] = [];
837
+ const traitEntries: PluginManifest["traits"] = [];
838
+ const hookEntries: PluginManifest["hooks"] = [];
839
+ const ruleEntries: PluginManifest["rules"] = [];
840
+
841
+ // Copy agents and skills from claude output
842
+ const claudeCorePath = path.join(distPath, "claude", "core");
843
+
844
+ // Agents
845
+ const agentsDir = path.join(claudeCorePath, "agents");
846
+ const pluginAgentsDir = path.join(pluginDir, "agents");
847
+ if (fs.existsSync(agentsDir)) {
848
+ ensureDir(pluginAgentsDir);
849
+ for (const file of fs.readdirSync(agentsDir)) {
850
+ fs.copyFileSync(path.join(agentsDir, file), path.join(pluginAgentsDir, file));
851
+ }
852
+ }
853
+
854
+ // Skills
855
+ const skillsDir = path.join(claudeCorePath, "skills");
856
+ const pluginSkillsDir = path.join(pluginDir, "skills");
857
+ if (fs.existsSync(skillsDir)) {
858
+ ensureDir(pluginSkillsDir);
859
+ for (const skillFolder of fs.readdirSync(skillsDir)) {
860
+ const src = path.join(skillsDir, skillFolder);
861
+ if (fs.statSync(src).isDirectory()) {
862
+ const dest = path.join(pluginSkillsDir, skillFolder);
863
+ ensureDir(dest);
864
+ for (const file of fs.readdirSync(src)) {
865
+ fs.copyFileSync(path.join(src, file), path.join(dest, file));
866
+ }
867
+ }
868
+ }
869
+ }
870
+
871
+ // Traits
872
+ const pluginTraitsDir = path.join(pluginDir, "traits");
873
+ ensureDir(pluginTraitsDir);
874
+ for (const [name, trait] of traits) {
875
+ fs.writeFileSync(path.join(pluginTraitsDir, `${name}.md`), trait.content, "utf-8");
876
+ traitEntries.push({ id: name, path: `traits/${name}.md` });
877
+ }
878
+
879
+ // Rules
880
+ const rulesDir = path.join(claudeCorePath, "rules");
881
+ const pluginRulesDir = path.join(pluginDir, "rules");
882
+ if (fs.existsSync(rulesDir)) {
883
+ ensureDir(pluginRulesDir);
884
+ for (const file of fs.readdirSync(rulesDir)) {
885
+ fs.copyFileSync(path.join(rulesDir, file), path.join(pluginRulesDir, file));
886
+ ruleEntries.push({ path: `rules/${file}` });
887
+ }
888
+ }
889
+
890
+ // Hooks directory (compliance hooks go here)
891
+ const pluginHooksDir = path.join(pluginDir, "hooks");
892
+ ensureDir(pluginHooksDir);
893
+
894
+ // Build persona entries
895
+ for (const result of allResults.filter((r) => r.platforms.length > 0 && r.scope === "core")) {
896
+ const personaConfigPath = path.join(personasBaseDir, result.persona, "persona.config.json");
897
+ let pc: PersonaConfig | null = null;
898
+ if (fs.existsSync(personaConfigPath)) {
899
+ try {
900
+ pc = JSON.parse(fs.readFileSync(personaConfigPath, "utf-8")) as PersonaConfig;
901
+ } catch { /* skip */ }
902
+ }
903
+
904
+ const invocation = pc?.invocation ?? `/${result.persona}`;
905
+ const skillName = invocation.replace(/^\//, "");
906
+
907
+ personas.push({
908
+ id: result.persona,
909
+ name: pc?.name ?? result.persona,
910
+ description: pc?.description ?? "",
911
+ model: pc?.model,
912
+ agent_path: `agents/${result.persona}.md`,
913
+ skill_path: `skills/${skillName}/SKILL.md`,
914
+ });
915
+ }
916
+
917
+ // Generate plugin.json
918
+ const pluginManifest: PluginManifest = {
919
+ name: `@${config.org}/${config.org}-personas`,
920
+ version: pkg.version,
921
+ description: `Agentic personas for ${config.orgDisplayName ?? config.org}`,
922
+ author: config.orgDisplayName ?? config.org,
923
+ license: "Apache-2.0",
924
+ agentboot_version: pkg.version,
925
+ personas,
926
+ traits: traitEntries,
927
+ hooks: hookEntries.length > 0 ? hookEntries : undefined,
928
+ rules: ruleEntries.length > 0 ? ruleEntries : undefined,
929
+ };
930
+
931
+ fs.writeFileSync(
932
+ path.join(pluginDir, "plugin.json"),
933
+ JSON.stringify(pluginManifest, null, 2) + "\n",
934
+ "utf-8"
935
+ );
936
+
937
+ log(chalk.gray(` → Plugin output written to dist/plugin/`));
938
+ }
939
+
940
+ // ---------------------------------------------------------------------------
941
+ // AB-59/60/63: Compliance & audit trail hook generation
942
+ // ---------------------------------------------------------------------------
943
+
944
+ function generateComplianceHooks(
945
+ config: AgentBootConfig,
946
+ distPath: string,
947
+ scopePath: string
948
+ ): void {
949
+ const hooksDir = path.join(distPath, "claude", scopePath, "hooks");
950
+ ensureDir(hooksDir);
951
+
952
+ // AB-59: Input scanning hook (UserPromptSubmit)
953
+ // S4 fix: use printf instead of echo to avoid flag interpretation
954
+ // S5 fix: add set -uo pipefail and jq dependency check
955
+ // Note: -e intentionally omitted because grep -q returns 1 on no-match
956
+ const inputScanHook = `#!/bin/bash
957
+ # AgentBoot compliance hook — input scanning (AB-59)
958
+ # Event: UserPromptSubmit
959
+ # Generated by AgentBoot. Do not edit manually.
960
+
961
+ set -uo pipefail
962
+ command -v jq >/dev/null 2>&1 || { echo '{"decision":"block","reason":"AgentBoot: jq is required for input scanning"}'; exit 2; }
963
+
964
+ INPUT=$(cat)
965
+ PROMPT=$(printf '%s' "$INPUT" | jq -r '.prompt // empty') || { echo '{"decision":"block","reason":"AgentBoot: Failed to parse hook input"}'; exit 2; }
966
+
967
+ # Scan for potential credential leaks in prompts
968
+ PATTERNS=(
969
+ 'password[[:space:]]*[:=]'
970
+ 'api[_-]?key[[:space:]]*[:=]'
971
+ 'secret[[:space:]]*[:=]'
972
+ 'token[[:space:]]*[:=]'
973
+ 'AKIA[A-Z0-9]{16}'
974
+ 'sk-[a-zA-Z0-9]{20,}'
975
+ 'ghp_[a-zA-Z0-9]{36}'
976
+ 'xox[bp]-[a-zA-Z0-9-]+'
977
+ 'sk_live_[a-zA-Z0-9]+'
978
+ 'BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY'
979
+ )
980
+
981
+ for pattern in "\${PATTERNS[@]}"; do
982
+ if printf '%s' "$PROMPT" | grep -qiE "$pattern"; then
983
+ echo '{"decision":"block","reason":"AgentBoot: Potential credential detected in prompt. Remove secrets before proceeding."}'
984
+ exit 2
985
+ fi
986
+ done
987
+
988
+ exit 0
989
+ `;
990
+
991
+ // AB-60: Output scanning hook (Stop)
992
+ const outputScanHook = `#!/bin/bash
993
+ # AgentBoot compliance hook — output scanning (AB-60)
994
+ # Event: Stop
995
+ # Generated by AgentBoot. Do not edit manually.
996
+
997
+ set -uo pipefail
998
+ command -v jq >/dev/null 2>&1 || exit 0
999
+
1000
+ INPUT=$(cat)
1001
+ RESPONSE=$(printf '%s' "$INPUT" | jq -r '.response // empty') || exit 0
1002
+
1003
+ # Scan for accidental credential exposure in output
1004
+ PATTERNS=(
1005
+ 'AKIA[A-Z0-9]{16}'
1006
+ 'sk-[a-zA-Z0-9]{20,}'
1007
+ 'ghp_[a-zA-Z0-9]{36}'
1008
+ 'eyJ[a-zA-Z0-9_-]{10,}\\.eyJ'
1009
+ 'xox[bp]-[a-zA-Z0-9-]+'
1010
+ 'sk_live_[a-zA-Z0-9]+'
1011
+ 'BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY'
1012
+ )
1013
+
1014
+ for pattern in "\${PATTERNS[@]}"; do
1015
+ if printf '%s' "$RESPONSE" | grep -qiE "$pattern"; then
1016
+ echo "AgentBoot WARNING: Potential credential in output — review before sharing" >&2
1017
+ fi
1018
+ done
1019
+
1020
+ exit 0
1021
+ `;
1022
+
1023
+ // AB-63: Audit trail hook (SubagentStart/Stop, PostToolUse, SessionEnd)
1024
+ // S1 fix: use jq for safe JSON construction (no shell interpolation)
1025
+ // S2 fix: validate and sanitize telemetry.logPath
1026
+ let rawLogPath = config.telemetry?.logPath ?? "$HOME/.agentboot/telemetry.ndjson";
1027
+ // Normalize ~ to $HOME (~ is not expanded inside bash variable defaults)
1028
+ rawLogPath = rawLogPath.replace(/^~\//, "$HOME/");
1029
+ // Always reject path traversal
1030
+ if (/\.\./.test(rawLogPath)) {
1031
+ log(chalk.red(` ✗ telemetry.logPath contains path traversal: ${rawLogPath}`));
1032
+ log(chalk.red(` Use a simple path like ~/.agentboot/telemetry.ndjson`));
1033
+ process.exit(1);
1034
+ }
1035
+ // Reject shell metacharacters, exempting only a leading $HOME
1036
+ const pathWithoutHome = rawLogPath.replace(/^\$HOME/, "");
1037
+ if (/[`$|;&\n]/.test(pathWithoutHome)) {
1038
+ log(chalk.red(` ✗ telemetry.logPath contains unsafe shell characters: ${rawLogPath}`));
1039
+ log(chalk.red(` Use a simple path like ~/.agentboot/telemetry.ndjson`));
1040
+ process.exit(1);
1041
+ }
1042
+
1043
+ const includeDevId = config.telemetry?.includeDevId ?? false;
1044
+
1045
+ let devIdBlock = "";
1046
+ if (includeDevId === "hashed") {
1047
+ devIdBlock = `DEV_ID=$(git config user.email 2>/dev/null | shasum -a 256 | cut -d' ' -f1)`;
1048
+ } else if (includeDevId === "email") {
1049
+ log(chalk.yellow(` ⚠ telemetry.includeDevId is "email" — raw emails will be in telemetry logs. Consider "hashed" for privacy.`));
1050
+ devIdBlock = `DEV_ID=$(git config user.email 2>/dev/null || echo "unknown")`;
1051
+ } else {
1052
+ devIdBlock = `DEV_ID=""`;
1053
+ }
1054
+
1055
+ const auditTrailHook = `#!/bin/bash
1056
+ # AgentBoot audit trail hook (AB-63)
1057
+ # Events: SubagentStart, SubagentStop, PostToolUse, SessionEnd
1058
+ # Generated by AgentBoot. Do not edit manually.
1059
+
1060
+ command -v jq >/dev/null 2>&1 || exit 0
1061
+
1062
+ TELEMETRY_LOG="\${AGENTBOOT_TELEMETRY_LOG:-${rawLogPath}}"
1063
+ umask 077
1064
+ mkdir -p "$(dirname "$TELEMETRY_LOG")"
1065
+
1066
+ INPUT=$(cat)
1067
+ EVENT_NAME=$(printf '%s' "$INPUT" | jq -r '.hook_event_name // empty')
1068
+ TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
1069
+ ${devIdBlock}
1070
+
1071
+ # Use jq for safe JSON construction — prevents shell injection via agent_type/tool_name
1072
+ case "$EVENT_NAME" in
1073
+ SubagentStart)
1074
+ printf '%s' "$INPUT" | jq -c --arg ts "$TIMESTAMP" --arg status "started" --arg dev "$DEV_ID" \\
1075
+ '{event:"persona_invocation",persona_id:.agent_type,timestamp:$ts,status:$status,dev_id:$dev}' >> "$TELEMETRY_LOG"
1076
+ ;;
1077
+ SubagentStop)
1078
+ printf '%s' "$INPUT" | jq -c --arg ts "$TIMESTAMP" --arg status "completed" --arg dev "$DEV_ID" \\
1079
+ '{event:"persona_invocation",persona_id:.agent_type,timestamp:$ts,status:$status,dev_id:$dev}' >> "$TELEMETRY_LOG"
1080
+ ;;
1081
+ PostToolUse)
1082
+ printf '%s' "$INPUT" | jq -c --arg ts "$TIMESTAMP" --arg dev "$DEV_ID" \\
1083
+ '{event:"hook_execution",persona_id:.agent_type,tool_name:.tool_name,timestamp:$ts,dev_id:$dev}' >> "$TELEMETRY_LOG"
1084
+ ;;
1085
+ SessionEnd)
1086
+ jq -n -c --arg ts "$TIMESTAMP" --arg dev "$DEV_ID" \\
1087
+ '{event:"session_summary",timestamp:$ts,dev_id:$dev}' >> "$TELEMETRY_LOG"
1088
+ ;;
1089
+ esac
1090
+
1091
+ exit 0
1092
+ `;
1093
+
1094
+ fs.writeFileSync(path.join(hooksDir, "agentboot-input-scan.sh"), inputScanHook, { mode: 0o755 });
1095
+ fs.writeFileSync(path.join(hooksDir, "agentboot-output-scan.sh"), outputScanHook, { mode: 0o755 });
1096
+ fs.writeFileSync(path.join(hooksDir, "agentboot-telemetry.sh"), auditTrailHook, { mode: 0o755 });
1097
+
1098
+ // Also generate the plugin hooks
1099
+ const pluginHooksDir = path.join(distPath, "plugin", "hooks");
1100
+ if (fs.existsSync(path.join(distPath, "plugin"))) {
1101
+ ensureDir(pluginHooksDir);
1102
+ fs.writeFileSync(path.join(pluginHooksDir, "agentboot-input-scan.sh"), inputScanHook, { mode: 0o755 });
1103
+ fs.writeFileSync(path.join(pluginHooksDir, "agentboot-output-scan.sh"), outputScanHook, { mode: 0o755 });
1104
+ fs.writeFileSync(path.join(pluginHooksDir, "agentboot-telemetry.sh"), auditTrailHook, { mode: 0o755 });
1105
+ }
1106
+
1107
+ log(chalk.gray(` → Compliance hooks written (input-scan, output-scan, telemetry)`));
1108
+ }
1109
+
1110
+ // ---------------------------------------------------------------------------
1111
+ // AB-59/60/63: Generate settings.json hooks entries for compliance
1112
+ // ---------------------------------------------------------------------------
1113
+
1114
+ function generateComplianceSettingsJson(
1115
+ _config: AgentBootConfig,
1116
+ distPath: string,
1117
+ scopePath: string
1118
+ ): void {
1119
+ // Read existing settings.json if any, merge compliance hooks into it
1120
+ const settingsPath = path.join(distPath, "claude", scopePath, "settings.json");
1121
+ let settings: Record<string, unknown> = {};
1122
+ if (fs.existsSync(settingsPath)) {
1123
+ try {
1124
+ settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
1125
+ } catch { /* start fresh */ }
1126
+ }
1127
+
1128
+ const hooks = (settings["hooks"] ?? {}) as Record<string, unknown>;
1129
+
1130
+ // B1 fix: append compliance hooks instead of overwriting user-defined hooks
1131
+ const appendHook = (event: string, entry: unknown) => {
1132
+ hooks[event] = [
1133
+ ...(Array.isArray(hooks[event]) ? hooks[event] as unknown[] : []),
1134
+ entry,
1135
+ ];
1136
+ };
1137
+
1138
+ // AB-59: Input scanning
1139
+ appendHook("UserPromptSubmit", {
1140
+ matcher: "",
1141
+ hooks: [{ type: "command", command: ".claude/hooks/agentboot-input-scan.sh", timeout: 5000 }],
1142
+ });
1143
+
1144
+ // AB-60: Output scanning
1145
+ appendHook("Stop", {
1146
+ matcher: "",
1147
+ hooks: [{ type: "command", command: ".claude/hooks/agentboot-output-scan.sh", timeout: 5000, async: true }],
1148
+ });
1149
+
1150
+ // AB-63: Audit trail
1151
+ appendHook("SubagentStart", {
1152
+ matcher: "",
1153
+ hooks: [{ type: "command", command: ".claude/hooks/agentboot-telemetry.sh", timeout: 3000, async: true }],
1154
+ });
1155
+ appendHook("SubagentStop", {
1156
+ matcher: "",
1157
+ hooks: [{ type: "command", command: ".claude/hooks/agentboot-telemetry.sh", timeout: 3000, async: true }],
1158
+ });
1159
+ appendHook("PostToolUse", {
1160
+ matcher: "Edit|Write|Bash",
1161
+ hooks: [{ type: "command", command: ".claude/hooks/agentboot-telemetry.sh", timeout: 3000, async: true }],
1162
+ });
1163
+ // B3 fix: register SessionEnd (matches the case in telemetry hook script)
1164
+ appendHook("SessionEnd", {
1165
+ matcher: "",
1166
+ hooks: [{ type: "command", command: ".claude/hooks/agentboot-telemetry.sh", timeout: 3000, async: true }],
1167
+ });
1168
+
1169
+ settings["hooks"] = hooks;
1170
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
1171
+ }
1172
+
1173
+ // ---------------------------------------------------------------------------
1174
+ // AB-64: Telemetry NDJSON schema file
1175
+ // ---------------------------------------------------------------------------
1176
+
1177
+ function generateTelemetrySchema(distPath: string): void {
1178
+ const schema = {
1179
+ $schema: "http://json-schema.org/draft-07/schema#",
1180
+ $id: "https://agentboot.dev/schema/telemetry-event/v1",
1181
+ title: "AgentBoot Telemetry Event",
1182
+ type: "object",
1183
+ required: ["event", "persona_id", "timestamp"],
1184
+ properties: {
1185
+ event: {
1186
+ type: "string",
1187
+ enum: ["persona_invocation", "persona_error", "hook_execution", "session_summary"],
1188
+ description: "Event type",
1189
+ },
1190
+ persona_id: { type: "string", description: "Persona identifier" },
1191
+ persona_version: { type: "string", description: "Persona version" },
1192
+ model: { type: "string", description: "Model used" },
1193
+ scope: { type: "string", description: "Scope path: 'org:group:team'" },
1194
+ input_tokens: { type: "integer" },
1195
+ output_tokens: { type: "integer" },
1196
+ thinking_tokens: { type: "integer" },
1197
+ tool_calls: { type: "integer" },
1198
+ duration_ms: { type: "integer" },
1199
+ cost_usd: { type: "number" },
1200
+ findings_count: {
1201
+ type: "object",
1202
+ properties: {
1203
+ CRITICAL: { type: "integer" },
1204
+ ERROR: { type: "integer" },
1205
+ WARN: { type: "integer" },
1206
+ INFO: { type: "integer" },
1207
+ },
1208
+ },
1209
+ suggestions: { type: "integer" },
1210
+ timestamp: { type: "string", format: "date-time" },
1211
+ session_id: { type: "string" },
1212
+ dev_id: { type: "string", description: "Developer identifier (hashed or email per config)" },
1213
+ status: { type: "string", enum: ["started", "completed", "error"] },
1214
+ tool_name: { type: "string", description: "Tool name for hook_execution events" },
1215
+ },
1216
+ };
1217
+
1218
+ const schemaDir = path.join(distPath, "schema");
1219
+ ensureDir(schemaDir);
1220
+ fs.writeFileSync(
1221
+ path.join(schemaDir, "telemetry-event.v1.json"),
1222
+ JSON.stringify(schema, null, 2) + "\n",
1223
+ "utf-8"
1224
+ );
1225
+ log(chalk.gray(` → Telemetry schema written to dist/schema/`));
1226
+ }
1227
+
1228
+ // ---------------------------------------------------------------------------
1229
+ // AB-61: Managed settings artifact generation
1230
+ // ---------------------------------------------------------------------------
1231
+
1232
+ function generateManagedSettings(config: AgentBootConfig, distPath: string): void {
1233
+ const managed = config.managed;
1234
+ if (!managed?.enabled) return;
1235
+
1236
+ log(chalk.cyan("\nGenerating managed settings..."));
1237
+
1238
+ const managedDir = path.join(distPath, "managed");
1239
+ ensureDir(managedDir);
1240
+
1241
+ // Managed settings carry HARD guardrails only
1242
+ const managedSettings: Record<string, unknown> = {};
1243
+
1244
+ // Permissions: deny dangerous tools
1245
+ if (managed.guardrails?.denyTools && managed.guardrails.denyTools.length > 0) {
1246
+ managedSettings["permissions"] = {
1247
+ deny: managed.guardrails.denyTools,
1248
+ };
1249
+ }
1250
+
1251
+ // Force audit logging
1252
+ if (managed.guardrails?.requireAuditLog) {
1253
+ managedSettings["hooks"] = {
1254
+ SubagentStart: [
1255
+ {
1256
+ matcher: "",
1257
+ hooks: [{ type: "command", command: ".claude/hooks/agentboot-telemetry.sh", timeout: 3000, async: true }],
1258
+ },
1259
+ ],
1260
+ SubagentStop: [
1261
+ {
1262
+ matcher: "",
1263
+ hooks: [{ type: "command", command: ".claude/hooks/agentboot-telemetry.sh", timeout: 3000, async: true }],
1264
+ },
1265
+ ],
1266
+ };
1267
+ }
1268
+
1269
+ fs.writeFileSync(
1270
+ path.join(managedDir, "managed-settings.json"),
1271
+ JSON.stringify(managedSettings, null, 2) + "\n",
1272
+ "utf-8"
1273
+ );
1274
+
1275
+ // Managed CLAUDE.md (minimal, HARD guardrails only)
1276
+ const managedClaudeMd = [
1277
+ `# ${config.orgDisplayName ?? config.org} — Managed Configuration`,
1278
+ "",
1279
+ "<!-- Managed by IT. Do not modify. -->",
1280
+ "",
1281
+ "This configuration is enforced by your organization's IT policy.",
1282
+ "Contact your platform team for changes.",
1283
+ "",
1284
+ ].join("\n");
1285
+
1286
+ fs.writeFileSync(path.join(managedDir, "CLAUDE.md"), managedClaudeMd, "utf-8");
1287
+
1288
+ // MCP config if needed
1289
+ if (config.claude?.mcpServers) {
1290
+ fs.writeFileSync(
1291
+ path.join(managedDir, "managed-mcp.json"),
1292
+ JSON.stringify({ mcpServers: config.claude.mcpServers }, null, 2) + "\n",
1293
+ "utf-8"
1294
+ );
1295
+ }
1296
+
1297
+ // Output path guidance
1298
+ const platformPaths: Record<string, string> = {
1299
+ jamf: "/Library/Application Support/Claude/",
1300
+ intune: "C:\\ProgramData\\Claude\\",
1301
+ jumpcloud: "/etc/claude-code/",
1302
+ kandji: "/Library/Application Support/Claude/",
1303
+ other: "./managed-output/",
1304
+ };
1305
+ const platform = managed.platform ?? "other";
1306
+ const targetPath = platformPaths[platform] ?? platformPaths["other"];
1307
+
1308
+ log(chalk.gray(` → Managed settings written to dist/managed/`));
1309
+ log(chalk.gray(` → Target MDM path: ${targetPath}`));
1310
+ }
1311
+
688
1312
  // ---------------------------------------------------------------------------
689
1313
  // Main entry point
690
1314
  // ---------------------------------------------------------------------------
@@ -721,14 +1345,25 @@ function main(): void {
721
1345
  const coreTraitsDir = path.join(coreDir, "traits");
722
1346
  const coreInstructionsDir = path.join(coreDir, "instructions");
723
1347
 
724
- const validFormats = ["skill", "claude", "copilot"];
725
- const outputFormats = config.personas?.outputFormats ?? validFormats;
1348
+ const validFormats = ["skill", "claude", "copilot", "plugin"];
1349
+ const outputFormats = config.personas?.outputFormats ?? ["skill", "claude", "copilot"];
726
1350
  const unknownFormats = outputFormats.filter((f) => !validFormats.includes(f));
727
1351
  if (unknownFormats.length > 0) {
728
1352
  console.error(chalk.red(`Unknown output format(s): ${unknownFormats.join(", ")}. Valid: ${validFormats.join(", ")}`));
729
1353
  process.exit(1);
730
1354
  }
731
1355
 
1356
+ // AB-88: Resolve N-tier scope tree
1357
+ // B13 fix: warn if both groups and nodes defined
1358
+ if (config.groups && config.nodes) {
1359
+ log(chalk.yellow(" ⚠ Both 'groups' and 'nodes' defined — 'nodes' takes precedence. Remove 'groups' to suppress this warning."));
1360
+ }
1361
+ const scopeNodes = config.nodes
1362
+ ? config.nodes
1363
+ : config.groups
1364
+ ? groupsToNodes(config.groups)
1365
+ : undefined;
1366
+
732
1367
  // Load traits.
733
1368
  const enabledTraits = config.traits?.enabled;
734
1369
  const traits = loadTraits(coreTraitsDir, enabledTraits);
@@ -755,7 +1390,7 @@ function main(): void {
755
1390
  }
756
1391
 
757
1392
  if (config.personas?.customDir) {
758
- const extendDir = path.resolve(configDir, config.personas.extend);
1393
+ const extendDir = path.resolve(configDir, config.personas.customDir);
759
1394
  if (fs.existsSync(extendDir)) {
760
1395
  for (const entry of fs.readdirSync(extendDir)) {
761
1396
  const dir = path.join(extendDir, entry);
@@ -854,104 +1489,119 @@ function main(): void {
854
1489
  }
855
1490
 
856
1491
  // ---------------------------------------------------------------------------
857
- // 2. Compile group-level overrides dist/{platform}/groups/{group}/{persona}/
1492
+ // 2. Compile scope nodes (AB-88: N-tier replaces flat groups/teams)
1493
+ // Also provides backward compat with legacy groups/teams config.
858
1494
  // ---------------------------------------------------------------------------
859
1495
 
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()
1496
+ if (scopeNodes) {
1497
+ log(chalk.cyan("\nCompiling scope nodes..."));
1498
+ const flatNodes = flattenNodes(scopeNodes);
1499
+ let nodePersonasFound = false;
1500
+
1501
+ for (const { path: nodePath } of flatNodes) {
1502
+ // Look for personas at nodes/{path}/personas/
1503
+ const nodePersonasDir = path.join(ROOT, "nodes", nodePath, "personas");
1504
+
1505
+ // Also check legacy paths: groups/{name}/personas/ and teams/{group}/{team}/personas/
1506
+ const parts = nodePath.split("/");
1507
+ const legacyGroupDir = parts.length === 1
1508
+ ? path.join(ROOT, "groups", parts[0]!, "personas")
1509
+ : undefined;
1510
+ const legacyTeamDir = parts.length === 2
1511
+ ? path.join(ROOT, "teams", parts[0]!, parts[1]!, "personas")
1512
+ : undefined;
1513
+
1514
+ const personasDir = fs.existsSync(nodePersonasDir)
1515
+ ? nodePersonasDir
1516
+ : legacyGroupDir && fs.existsSync(legacyGroupDir)
1517
+ ? legacyGroupDir
1518
+ : legacyTeamDir && fs.existsSync(legacyTeamDir)
1519
+ ? legacyTeamDir
1520
+ : null;
1521
+
1522
+ if (!personasDir) continue;
1523
+
1524
+ nodePersonasFound = true;
1525
+ const nodePersonaDirs = fs.readdirSync(personasDir).filter((entry) =>
1526
+ fs.statSync(path.join(personasDir, entry)).isDirectory()
872
1527
  );
873
1528
 
874
- for (const personaName of groupPersonaDirs) {
1529
+ for (const personaName of nodePersonaDirs) {
875
1530
  if (enabledPersonas && !enabledPersonas.includes(personaName)) {
876
1531
  continue;
877
1532
  }
878
1533
 
879
- const personaDir = path.join(groupPersonasDir, personaName);
1534
+ const personaDir = path.join(personasDir, personaName);
1535
+ // Use first part as group name, second as team name for trait resolution
1536
+ const groupName = parts[0];
1537
+ const teamName = parts.length >= 2 ? parts[parts.length - 1] : undefined;
1538
+
880
1539
  const result = compilePersona(
881
1540
  personaName,
882
1541
  personaDir,
883
1542
  traits,
884
1543
  config,
885
1544
  distPath,
886
- `groups/${groupName}`,
887
- groupName
1545
+ `nodes/${nodePath}`,
1546
+ groupName,
1547
+ teamName
888
1548
  );
889
1549
  allResults.push(result);
890
- log(` ${chalk.green("✓")} ${groupName}/${personaName}`);
1550
+ log(` ${chalk.green("✓")} ${nodePath}/${personaName}`);
891
1551
  }
892
1552
  }
1553
+
1554
+ if (!nodePersonasFound) {
1555
+ log(chalk.gray(" (no node-level overrides found)"));
1556
+ }
893
1557
  }
894
1558
 
895
1559
  // ---------------------------------------------------------------------------
896
- // 3. Compile team-level overrides dist/{platform}/teams/{group}/{team}/{persona}/
1560
+ // 2b. AB-53: Compile domain layers
897
1561
  // ---------------------------------------------------------------------------
898
1562
 
899
- if (config.groups) {
900
- log(chalk.cyan("\nCompiling team-level personas..."));
901
- let teamPersonasFound = false;
1563
+ const domainResults = compileDomains(config, configDir, distPath, traits, outputFormats);
1564
+ allResults.push(...domainResults);
902
1565
 
903
- for (const groupName of Object.keys(config.groups)) {
904
- const teams = config.groups[groupName]!.teams ?? [];
1566
+ // ---------------------------------------------------------------------------
1567
+ // 4. Generate PERSONAS.md index in each platform
1568
+ // ---------------------------------------------------------------------------
905
1569
 
906
- for (const teamName of teams) {
907
- const teamPersonasDir = path.join(ROOT, "teams", groupName, teamName, "personas");
1570
+ generatePersonasIndex(allResults, config, corePersonasDir, distPath, "core", outputFormats);
1571
+ log(chalk.gray("\n → PERSONAS.md written to each platform"));
908
1572
 
909
- if (!fs.existsSync(teamPersonasDir)) {
910
- continue;
911
- }
1573
+ // ---------------------------------------------------------------------------
1574
+ // 5. AB-57: Plugin output generation
1575
+ // ---------------------------------------------------------------------------
912
1576
 
913
- teamPersonasFound = true;
1577
+ // B5 fix: Only generate plugin output when claude format is active (plugin is always derived from claude)
1578
+ if (outputFormats.includes("claude")) {
1579
+ generatePluginOutput(config, distPath, allResults, corePersonasDir, traits);
1580
+ }
914
1581
 
915
- const teamPersonaDirs = fs.readdirSync(teamPersonasDir).filter((entry) =>
916
- fs.statSync(path.join(teamPersonasDir, entry)).isDirectory()
917
- );
1582
+ // ---------------------------------------------------------------------------
1583
+ // 6. AB-59/60/63: Compliance & audit trail hooks
1584
+ // ---------------------------------------------------------------------------
918
1585
 
919
- for (const personaName of teamPersonaDirs) {
920
- if (enabledPersonas && !enabledPersonas.includes(personaName)) {
921
- continue;
922
- }
1586
+ if (outputFormats.includes("claude")) {
1587
+ generateComplianceHooks(config, distPath, "core");
1588
+ generateComplianceSettingsJson(config, distPath, "core");
1589
+ }
923
1590
 
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
- }
1591
+ // ---------------------------------------------------------------------------
1592
+ // 7. AB-64: Telemetry NDJSON schema
1593
+ // ---------------------------------------------------------------------------
940
1594
 
941
- if (!teamPersonasFound) {
942
- log(chalk.gray(" (no team-level overrides found)"));
943
- }
944
- }
1595
+ generateTelemetrySchema(distPath);
945
1596
 
946
1597
  // ---------------------------------------------------------------------------
947
- // 4. Generate PERSONAS.md index in each platform
1598
+ // 8. AB-61: Managed settings
948
1599
  // ---------------------------------------------------------------------------
949
1600
 
950
- generatePersonasIndex(allResults, config, corePersonasDir, distPath, "core", outputFormats);
951
- log(chalk.gray("\n → PERSONAS.md written to each platform"));
1601
+ generateManagedSettings(config, distPath);
952
1602
 
953
1603
  // ---------------------------------------------------------------------------
954
- // 5. AB-25: Token budget estimation
1604
+ // 9. AB-25: Token budget estimation
955
1605
  // ---------------------------------------------------------------------------
956
1606
 
957
1607
  const tokenBudget = config.output?.tokenBudget?.warnAt ?? 8000;
@@ -980,7 +1630,6 @@ function main(): void {
980
1630
  // ---------------------------------------------------------------------------
981
1631
 
982
1632
  const successCount = allResults.filter((r) => r.platforms.length > 0).length;
983
- const platformCount = allResults.reduce((acc, r) => acc + r.platforms.length, 0);
984
1633
 
985
1634
  log(
986
1635
  chalk.bold(