agentboot 0.2.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.
@@ -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,
@@ -451,7 +455,7 @@ function compileInstructions(
451
455
  // Insert provenance after the closing --- of frontmatter.
452
456
  const fmMatch = content.match(/^(---\n[\s\S]*?\n---\n)/);
453
457
  if (fmMatch) {
454
- const afterFm = content.slice(fmMatch[1].length);
458
+ const afterFm = content.slice(fmMatch[1]!.length);
455
459
  finalContent = `${fmMatch[1]}\n${provenanceHeader(srcPath, config)}${afterFm}`;
456
460
  } else {
457
461
  finalContent = `${provenanceHeader(srcPath, config)}${content}`;
@@ -665,7 +669,7 @@ function generateSettingsJson(
665
669
  // Validate hook event names against known CC events
666
670
  const validEvents = [
667
671
  "PreToolUse", "PostToolUse", "Notification", "Stop",
668
- "SubagentStop", "SubagentStart",
672
+ "SubagentStop", "SubagentStart", "UserPromptSubmit", "SessionEnd",
669
673
  ];
670
674
  for (const key of Object.keys(hooks)) {
671
675
  if (!validEvents.includes(key)) {
@@ -677,8 +681,8 @@ function generateSettingsJson(
677
681
  }
678
682
 
679
683
  const settings: Record<string, unknown> = {};
680
- if (hooks) settings.hooks = hooks;
681
- if (permissions) settings.permissions = permissions;
684
+ if (hooks) settings["hooks"] = hooks;
685
+ if (permissions) settings["permissions"] = permissions;
682
686
 
683
687
  const settingsPath = path.join(distPath, "claude", scopePath, "settings.json");
684
688
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
@@ -708,6 +712,603 @@ function generateMcpJson(
708
712
  fs.writeFileSync(mcpPath, JSON.stringify(mcpJson, null, 2) + "\n", "utf-8");
709
713
  }
710
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
+
711
1312
  // ---------------------------------------------------------------------------
712
1313
  // Main entry point
713
1314
  // ---------------------------------------------------------------------------
@@ -744,14 +1345,25 @@ function main(): void {
744
1345
  const coreTraitsDir = path.join(coreDir, "traits");
745
1346
  const coreInstructionsDir = path.join(coreDir, "instructions");
746
1347
 
747
- const validFormats = ["skill", "claude", "copilot"];
748
- const outputFormats = config.personas?.outputFormats ?? validFormats;
1348
+ const validFormats = ["skill", "claude", "copilot", "plugin"];
1349
+ const outputFormats = config.personas?.outputFormats ?? ["skill", "claude", "copilot"];
749
1350
  const unknownFormats = outputFormats.filter((f) => !validFormats.includes(f));
750
1351
  if (unknownFormats.length > 0) {
751
1352
  console.error(chalk.red(`Unknown output format(s): ${unknownFormats.join(", ")}. Valid: ${validFormats.join(", ")}`));
752
1353
  process.exit(1);
753
1354
  }
754
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
+
755
1367
  // Load traits.
756
1368
  const enabledTraits = config.traits?.enabled;
757
1369
  const traits = loadTraits(coreTraitsDir, enabledTraits);
@@ -877,104 +1489,119 @@ function main(): void {
877
1489
  }
878
1490
 
879
1491
  // ---------------------------------------------------------------------------
880
- // 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.
881
1494
  // ---------------------------------------------------------------------------
882
1495
 
883
- if (config.groups) {
884
- log(chalk.cyan("\nCompiling group-level personas..."));
885
-
886
- for (const groupName of Object.keys(config.groups)) {
887
- const groupPersonasDir = path.join(ROOT, "groups", groupName, "personas");
888
-
889
- if (!fs.existsSync(groupPersonasDir)) {
890
- continue;
891
- }
892
-
893
- const groupPersonaDirs = fs.readdirSync(groupPersonasDir).filter((entry) =>
894
- 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()
895
1527
  );
896
1528
 
897
- for (const personaName of groupPersonaDirs) {
1529
+ for (const personaName of nodePersonaDirs) {
898
1530
  if (enabledPersonas && !enabledPersonas.includes(personaName)) {
899
1531
  continue;
900
1532
  }
901
1533
 
902
- 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
+
903
1539
  const result = compilePersona(
904
1540
  personaName,
905
1541
  personaDir,
906
1542
  traits,
907
1543
  config,
908
1544
  distPath,
909
- `groups/${groupName}`,
910
- groupName
1545
+ `nodes/${nodePath}`,
1546
+ groupName,
1547
+ teamName
911
1548
  );
912
1549
  allResults.push(result);
913
- log(` ${chalk.green("✓")} ${groupName}/${personaName}`);
1550
+ log(` ${chalk.green("✓")} ${nodePath}/${personaName}`);
914
1551
  }
915
1552
  }
1553
+
1554
+ if (!nodePersonasFound) {
1555
+ log(chalk.gray(" (no node-level overrides found)"));
1556
+ }
916
1557
  }
917
1558
 
918
1559
  // ---------------------------------------------------------------------------
919
- // 3. Compile team-level overrides dist/{platform}/teams/{group}/{team}/{persona}/
1560
+ // 2b. AB-53: Compile domain layers
920
1561
  // ---------------------------------------------------------------------------
921
1562
 
922
- if (config.groups) {
923
- log(chalk.cyan("\nCompiling team-level personas..."));
924
- let teamPersonasFound = false;
1563
+ const domainResults = compileDomains(config, configDir, distPath, traits, outputFormats);
1564
+ allResults.push(...domainResults);
925
1565
 
926
- for (const groupName of Object.keys(config.groups)) {
927
- const teams = config.groups[groupName]!.teams ?? [];
1566
+ // ---------------------------------------------------------------------------
1567
+ // 4. Generate PERSONAS.md index in each platform
1568
+ // ---------------------------------------------------------------------------
928
1569
 
929
- for (const teamName of teams) {
930
- 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"));
931
1572
 
932
- if (!fs.existsSync(teamPersonasDir)) {
933
- continue;
934
- }
1573
+ // ---------------------------------------------------------------------------
1574
+ // 5. AB-57: Plugin output generation
1575
+ // ---------------------------------------------------------------------------
935
1576
 
936
- 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
+ }
937
1581
 
938
- const teamPersonaDirs = fs.readdirSync(teamPersonasDir).filter((entry) =>
939
- fs.statSync(path.join(teamPersonasDir, entry)).isDirectory()
940
- );
1582
+ // ---------------------------------------------------------------------------
1583
+ // 6. AB-59/60/63: Compliance & audit trail hooks
1584
+ // ---------------------------------------------------------------------------
941
1585
 
942
- for (const personaName of teamPersonaDirs) {
943
- if (enabledPersonas && !enabledPersonas.includes(personaName)) {
944
- continue;
945
- }
1586
+ if (outputFormats.includes("claude")) {
1587
+ generateComplianceHooks(config, distPath, "core");
1588
+ generateComplianceSettingsJson(config, distPath, "core");
1589
+ }
946
1590
 
947
- const personaDir = path.join(teamPersonasDir, personaName);
948
- const result = compilePersona(
949
- personaName,
950
- personaDir,
951
- traits,
952
- config,
953
- distPath,
954
- `teams/${groupName}/${teamName}`, // scopePath → dist/{platform}/teams/{group}/{team}/{persona}/
955
- groupName,
956
- teamName
957
- );
958
- allResults.push(result);
959
- log(` ${chalk.green("✓")} ${groupName}/${teamName}/${personaName}`);
960
- }
961
- }
962
- }
1591
+ // ---------------------------------------------------------------------------
1592
+ // 7. AB-64: Telemetry NDJSON schema
1593
+ // ---------------------------------------------------------------------------
963
1594
 
964
- if (!teamPersonasFound) {
965
- log(chalk.gray(" (no team-level overrides found)"));
966
- }
967
- }
1595
+ generateTelemetrySchema(distPath);
968
1596
 
969
1597
  // ---------------------------------------------------------------------------
970
- // 4. Generate PERSONAS.md index in each platform
1598
+ // 8. AB-61: Managed settings
971
1599
  // ---------------------------------------------------------------------------
972
1600
 
973
- generatePersonasIndex(allResults, config, corePersonasDir, distPath, "core", outputFormats);
974
- log(chalk.gray("\n → PERSONAS.md written to each platform"));
1601
+ generateManagedSettings(config, distPath);
975
1602
 
976
1603
  // ---------------------------------------------------------------------------
977
- // 5. AB-25: Token budget estimation
1604
+ // 9. AB-25: Token budget estimation
978
1605
  // ---------------------------------------------------------------------------
979
1606
 
980
1607
  const tokenBudget = config.output?.tokenBudget?.warnAt ?? 8000;
@@ -1003,7 +1630,6 @@ function main(): void {
1003
1630
  // ---------------------------------------------------------------------------
1004
1631
 
1005
1632
  const successCount = allResults.filter((r) => r.platforms.length > 0).length;
1006
- const platformCount = allResults.reduce((acc, r) => acc + r.platforms.length, 0);
1007
1633
 
1008
1634
  log(
1009
1635
  chalk.bold(