@delorenj/pjangler 1.2.21 → 1.2.25

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 (63) hide show
  1. package/dist/index.js +1852 -368
  2. package/dist/mcp-server.js +1782 -296
  3. package/package.json +14 -3
  4. package/templates/commonproject/copier.yml +3 -0
  5. package/templates/commonproject/template/.mise/scripts/provision-bmad-skills.py +480 -0
  6. package/templates/commonproject/template/.mise/scripts/sync-skills.py +449 -0
  7. package/templates/commonproject/template/mise.toml.jinja +9 -2
  8. package/templates/commonproject/tests/test_provision_bmad_skills.py +203 -0
  9. package/templates/commonproject/tests/test_sync_skills_topology.py +120 -0
  10. package/templates/hermes-agent/README.md +70 -29
  11. package/templates/hermes-agent/copier.yml +9 -6
  12. package/templates/hermes-agent/docs/architecture.md +75 -51
  13. package/templates/hermes-agent/docs/fleet-control-plane/architecture.md +11 -6
  14. package/templates/hermes-agent/docs/fleet-control-plane/epics-and-stories.md +2 -2
  15. package/templates/hermes-agent/docs/fleet-control-plane/prd.md +2 -3
  16. package/templates/hermes-agent/docs/operations.md +179 -81
  17. package/templates/hermes-agent/docs/runbooks/runtime-checkpoint-repair.md +31 -171
  18. package/templates/hermes-agent/docs/sentinel/README.md +10 -10
  19. package/templates/hermes-agent/docs/sentinel/architecture.md +6 -8
  20. package/templates/hermes-agent/docs/sentinel/development.md +12 -15
  21. package/templates/hermes-agent/docs/sentinel.md +1 -1
  22. package/templates/hermes-agent/install-local.sh +41 -4
  23. package/templates/hermes-agent/runtime-scaffold/README.md +4 -2
  24. package/templates/hermes-agent/scripts/degit-runtime.sh +192 -0
  25. package/templates/hermes-agent/scripts/fleet-prune-debris.sh +128 -0
  26. package/templates/hermes-agent/scripts/fleet-sync.sh +164 -7
  27. package/templates/hermes-agent/scripts/hermes-runtime-templatize.py +321 -0
  28. package/templates/hermes-agent/scripts/migrate-profile-scoped-chat-secrets.py +136 -0
  29. package/templates/hermes-agent/scripts/momo-unify-agent.py +224 -0
  30. package/templates/hermes-agent/scripts/repair-runtime-checkpoint.sh +7 -119
  31. package/templates/hermes-agent/template/.gitignore.jinja +5 -2
  32. package/templates/hermes-agent/template/.runtime-scaffold/README.md +4 -2
  33. package/templates/hermes-agent/template/.scripts/00-banner.sh +1 -1
  34. package/templates/hermes-agent/template/.scripts/01-config.sh +3 -0
  35. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +12 -1
  36. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +55 -129
  37. package/templates/hermes-agent/template/.scripts/30-telegram.sh +276 -22
  38. package/templates/hermes-agent/template/.scripts/31-slack.sh +362 -0
  39. package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +8 -66
  40. package/templates/hermes-agent/template/.scripts/70-systemd.sh +44 -30
  41. package/templates/hermes-agent/template/.scripts/80-registry.sh +93 -13
  42. package/templates/hermes-agent/template/.scripts/99-summary.sh +1 -4
  43. package/templates/hermes-agent/template/.scripts/_lib.sh +66 -1
  44. package/templates/hermes-agent/template/.scripts/checkpoint.sh +3 -2
  45. package/templates/hermes-agent/template/.scripts/config.example.toml +10 -10
  46. package/templates/hermes-agent/template/.scripts/heartbeat.sh +1 -1
  47. package/templates/hermes-agent/template/SOUL.md.jinja +2 -2
  48. package/templates/hermes-agent/template/hermes.jinja +6 -3
  49. package/templates/hermes-agent/template/role.yaml.jinja +24 -13
  50. package/templates/hermes-agent/tests/test_bloodbank_consumer_contract.py +429 -130
  51. package/templates/hermes-agent/tests/test_fleet_upgrade_contract.py +176 -0
  52. package/templates/hermes-agent/tests/test_launcher_chat_secret_isolation.py +85 -0
  53. package/templates/hermes-agent/tests/test_local_runtime_provisioning.py +130 -0
  54. package/templates/hermes-agent/tests/test_operator_docs_local_runtime_contract.py +132 -0
  55. package/templates/hermes-agent/tests/test_profile_env_detach_contract.py +14 -0
  56. package/templates/hermes-agent/tests/test_profile_scoped_chat_secrets.py +89 -0
  57. package/templates/hermes-agent/tests/test_repository_privacy_contract.py +32 -0
  58. package/templates/hermes-agent/tests/test_slack_provisioning.py +344 -0
  59. package/templates/hermes-agent/tests/test_telegram_provisioning.py +475 -0
  60. package/templates/hermes-agent/.codegraph/daemon.pid +0 -6
  61. package/templates/hermes-agent/.omo/run-continuation/ses_0e5b28303ffeXxL53hZjKggXDW.json +0 -10
  62. package/templates/hermes-agent/runtime-scaffold/bloodbank-consumer.py +0 -181
  63. package/templates/hermes-agent/template/.runtime-scaffold/bloodbank-consumer.py +0 -181
package/dist/index.js CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { spawnSync as spawnSync7 } from "node:child_process";
5
- import { existsSync as existsSync11, readFileSync as readFileSync8, statSync as statSync2 } from "node:fs";
6
- import { basename as basename4, join as join15, resolve as resolve3 } from "node:path";
5
+ import { existsSync as existsSync11, readFileSync as readFileSync9, statSync as statSync2 } from "node:fs";
6
+ import { basename as basename5, join as join16, resolve as resolve4 } from "node:path";
7
7
  import { Command as Command3 } from "commander";
8
8
 
9
9
  // src/commands/hermes/types.ts
@@ -19,7 +19,7 @@ function deriveProfileName(repo, role) {
19
19
  // src/commands/hermes/EnsureTemplateConfig.ts
20
20
  import { homedir, platform } from "node:os";
21
21
  import { existsSync as existsSync2, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs";
22
- import { join as join2, dirname as dirname2 } from "node:path";
22
+ import { join as join3, dirname as dirname2 } from "node:path";
23
23
 
24
24
  // src/commands/Command.ts
25
25
  import { existsSync, writeFileSync, mkdirSync } from "fs";
@@ -57,19 +57,149 @@ var Command = class {
57
57
  }
58
58
  };
59
59
 
60
+ // src/parity/bmadPack.ts
61
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readdirSync } from "node:fs";
62
+ import { createHash } from "node:crypto";
63
+ import { basename, join as join2, relative, resolve, sep } from "node:path";
64
+ var BMAD_PACK_VERSION = "6.10.1-next.31";
65
+ var BMAD_PACK_CHECKSUMS_SHA256 = "a8bc005612ac60e3ec775fff5a11eafe38be6acdae96efa3d770b48322cb3224";
66
+ var BMAD_PACK_SKILL_COUNT = 76;
67
+ var BMAD_PACK_PAYLOAD_FILES = 1072;
68
+ function sha256(content) {
69
+ return createHash("sha256").update(content).digest("hex");
70
+ }
71
+ function readRegularFile(path) {
72
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
73
+ try {
74
+ if (!fstatSync(fd).isFile()) throw new Error(`BMAD pack entry is not a regular file: ${path}`);
75
+ return readFileSync(fd);
76
+ } finally {
77
+ closeSync(fd);
78
+ }
79
+ }
80
+ function safeRelativePath(value) {
81
+ if (!value || value.includes("\\") || value.startsWith("/") || value.split("/").some((part) => part === "" || part === "." || part === "..")) {
82
+ throw new Error(`Unsafe BMAD checksum path: ${JSON.stringify(value)}`);
83
+ }
84
+ return value;
85
+ }
86
+ function parsePackToml(content) {
87
+ const required = [
88
+ /^name = "bmad"$/m,
89
+ new RegExp(`^version = "${BMAD_PACK_VERSION.replaceAll(".", "\\.")}"$`, "m"),
90
+ /^upstream = "bmad-method"$/m,
91
+ new RegExp(`^upstream_version = "${BMAD_PACK_VERSION.replaceAll(".", "\\.")}"$`, "m"),
92
+ /^rendered_from = "\.agent\/skills"$/m,
93
+ /^immutable = true$/m,
94
+ /^project_projection = "symlink"$/m
95
+ ];
96
+ if (required.some((pattern) => !pattern.test(content))) {
97
+ throw new Error(`BMAD pack.toml does not declare the trusted ${BMAD_PACK_VERSION} contract`);
98
+ }
99
+ const payloadMatch = content.match(/^payload_files = (\d+)$/m);
100
+ const skillsMatch = content.match(/^skills = \[\n([\s\S]*?)^\]$/m);
101
+ if (!payloadMatch || !skillsMatch) throw new Error("BMAD pack.toml is missing payload inventory metadata");
102
+ const skillNames = [...skillsMatch[1].matchAll(/^\s+"([^"]+)",$/gm)].map((match) => match[1]);
103
+ const payloadFiles = Number.parseInt(payloadMatch[1], 10);
104
+ if (skillNames.length !== BMAD_PACK_SKILL_COUNT || new Set(skillNames).size !== skillNames.length) {
105
+ throw new Error(`BMAD pack.toml must declare exactly ${BMAD_PACK_SKILL_COUNT} unique skills`);
106
+ }
107
+ if (payloadFiles !== BMAD_PACK_PAYLOAD_FILES) {
108
+ throw new Error(`BMAD pack.toml must declare exactly ${BMAD_PACK_PAYLOAD_FILES} payload files`);
109
+ }
110
+ for (const name of skillNames) {
111
+ if (!name.startsWith("bmad-") || basename(name) !== name) throw new Error(`Unsafe BMAD skill identity: ${name}`);
112
+ }
113
+ return { skillNames, payloadFiles };
114
+ }
115
+ function walkRegularTree(root) {
116
+ const files = /* @__PURE__ */ new Map();
117
+ const directories = /* @__PURE__ */ new Set();
118
+ const visit = (directory) => {
119
+ for (const name of readdirSync(directory).sort()) {
120
+ const path = join2(directory, name);
121
+ const stat = lstatSync(path);
122
+ if (stat.isSymbolicLink()) throw new Error(`BMAD pack may not contain symlinks: ${path}`);
123
+ if (stat.isDirectory()) {
124
+ directories.add(relative(root, path).split(sep).join("/"));
125
+ visit(path);
126
+ } else if (stat.isFile()) files.set(relative(root, path).split(sep).join("/"), readRegularFile(path));
127
+ else throw new Error(`BMAD pack may contain only regular files/directories: ${path}`);
128
+ }
129
+ };
130
+ visit(root);
131
+ return { files, directories };
132
+ }
133
+ function validateTrustedBmadPack(packRoot) {
134
+ const root = resolve(packRoot);
135
+ const rootStat = lstatSync(root);
136
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
137
+ throw new Error(`BMAD pack root must be a real directory: ${root}`);
138
+ }
139
+ const checksumsContent = readRegularFile(join2(root, "SHA256SUMS"));
140
+ if (sha256(checksumsContent) !== BMAD_PACK_CHECKSUMS_SHA256) {
141
+ throw new Error(`BMAD pack checksum manifest is not the trusted ${BMAD_PACK_VERSION} manifest`);
142
+ }
143
+ const expected = /* @__PURE__ */ new Map();
144
+ for (const line of checksumsContent.toString("utf8").split("\n")) {
145
+ if (!line) continue;
146
+ const match = line.match(/^([0-9a-f]{64}) (.+)$/);
147
+ if (!match) throw new Error(`Invalid BMAD SHA256SUMS entry: ${line}`);
148
+ const path = safeRelativePath(match[2]);
149
+ if (expected.has(path)) throw new Error(`Duplicate BMAD SHA256SUMS entry: ${path}`);
150
+ expected.set(path, match[1]);
151
+ }
152
+ const walked = walkRegularTree(root);
153
+ const actual = walked.files;
154
+ actual.delete("SHA256SUMS");
155
+ const missing = [...expected.keys()].filter((path) => !actual.has(path));
156
+ const extra = [...actual.keys()].filter((path) => !expected.has(path));
157
+ if (missing.length || extra.length) {
158
+ throw new Error(`BMAD checksum coverage mismatch; missing=${JSON.stringify(missing)} extra=${JSON.stringify(extra)}`);
159
+ }
160
+ for (const [path, digest] of expected) {
161
+ const content = actual.get(path);
162
+ if (!content || sha256(content) !== digest) throw new Error(`BMAD pack digest mismatch: ${path}`);
163
+ }
164
+ for (const directory of walked.directories) {
165
+ if (![...actual.keys()].some((path) => path.startsWith(`${directory}/`))) {
166
+ throw new Error(`BMAD pack contains an unauthenticated empty directory: ${directory}`);
167
+ }
168
+ }
169
+ const packToml = actual.get("pack.toml");
170
+ if (!packToml) throw new Error("BMAD pack.toml is missing");
171
+ const metadata = parsePackToml(packToml.toString("utf8"));
172
+ const skillSet = new Set(metadata.skillNames);
173
+ const topLevelDirectories = readdirSync(root).filter((name) => lstatSync(join2(root, name)).isDirectory()).sort();
174
+ if (topLevelDirectories.length !== skillSet.size || topLevelDirectories.some((name) => !skillSet.has(name))) {
175
+ throw new Error("BMAD pack directory inventory differs from authenticated pack.toml skills");
176
+ }
177
+ for (const name of metadata.skillNames) {
178
+ const skillMd = join2(root, name, "SKILL.md");
179
+ if (!lstatSync(skillMd).isFile() || lstatSync(skillMd).isSymbolicLink()) {
180
+ throw new Error(`BMAD skill is missing a regular SKILL.md: ${name}`);
181
+ }
182
+ }
183
+ const payloadCount = [...actual.keys()].filter((path) => skillSet.has(path.split("/", 1)[0])).length;
184
+ if (payloadCount !== metadata.payloadFiles) {
185
+ throw new Error(`BMAD payload inventory mismatch: ${payloadCount} != ${metadata.payloadFiles}`);
186
+ }
187
+ return { root, skillNames: metadata.skillNames, payloadFiles: metadata.payloadFiles };
188
+ }
189
+
60
190
  // src/commands/hermes/EnsureTemplateConfig.ts
61
191
  function resolveTemplateConfigPath() {
62
192
  const fromEnv = process.env.HERMES_TEMPLATE_CONFIG;
63
193
  if (fromEnv && fromEnv.trim()) return fromEnv.trim();
64
194
  const xdg = process.env.XDG_CONFIG_HOME?.trim();
65
- const base = xdg && xdg.length ? xdg : join2(homedir(), ".config");
66
- return join2(base, "hermes-agent-template", "config.toml");
195
+ const base = xdg && xdg.length ? xdg : join3(homedir(), ".config");
196
+ return join3(base, "hermes-agent-template", "config.toml");
67
197
  }
68
198
  function detectHermesBin(home) {
69
199
  const candidates = [
70
- join2(home, "code", "hermes-agent", "venv", "bin", "hermes"),
71
- join2(home, "code", "hermes-agent", ".venv", "bin", "hermes"),
72
- join2(home, ".local", "bin", "hermes")
200
+ join3(home, "code", "hermes-agent", "venv", "bin", "hermes"),
201
+ join3(home, "code", "hermes-agent", ".venv", "bin", "hermes"),
202
+ join3(home, ".local", "bin", "hermes")
73
203
  ];
74
204
  for (const c of candidates) {
75
205
  if (existsSync2(c)) return c;
@@ -79,11 +209,11 @@ function detectHermesBin(home) {
79
209
  function renderHostConfig() {
80
210
  const home = homedir();
81
211
  const hermesBin = detectHermesBin(home);
82
- const hermesRepo = join2(home, "code", "hermes-agent");
83
- const scaffoldDir = join2(home, "code", "hermes-agent-template", "runtime-scaffold");
84
- const skillsDir = join2(home, ".agents", "skills");
85
- const pmExternalSkillGlobalDir = join2(home, "code", "skillex", "skill-sets", "global", ".system");
86
- const pmExternalSkillBmadDir = join2(home, "code", "skillex", "packs", "bmad", "6.10.2");
212
+ const hermesRepo = join3(home, "code", "hermes-agent");
213
+ const scaffoldDir = join3(home, "code", "hermes-agent-template", "runtime-scaffold");
214
+ const skillsDir = join3(home, ".agents", "skills");
215
+ const pmExternalSkillGlobalDir = join3(home, "code", "skillex", "skill-sets", "global", ".system");
216
+ const pmExternalSkillBmadDir = join3(home, "code", "skillex", "packs", "bmad", BMAD_PACK_VERSION);
87
217
  return `# hermes-agent-template \u2014 host configuration
88
218
  # Bootstrapped by \`pjangler config bootstrap\` for $HOME=${home} (platform=${platform()}).
89
219
  #
@@ -398,7 +528,7 @@ if __name__ == "__main__":
398
528
 
399
529
  // src/commands/AddMiseCodegraphScript.ts
400
530
  import { chmodSync } from "fs";
401
- import { join as join3 } from "path";
531
+ import { join as join4 } from "path";
402
532
  var AddMiseCodegraphScript = class extends Command {
403
533
  async invoke() {
404
534
  const filePath = ".mise/scripts/codegraph.sh";
@@ -452,7 +582,7 @@ fi
452
582
  `;
453
583
  this.writeFile(filePath, content);
454
584
  if (!this.context.dryRun) {
455
- chmodSync(join3(this.context.targetDir, filePath), 493);
585
+ chmodSync(join4(this.context.targetDir, filePath), 493);
456
586
  }
457
587
  return {
458
588
  success: true,
@@ -464,7 +594,7 @@ fi
464
594
 
465
595
  // src/commands/AddMiseCodegraphWireScript.ts
466
596
  import { chmodSync as chmodSync2 } from "fs";
467
- import { join as join4 } from "path";
597
+ import { join as join5 } from "path";
468
598
  var AddMiseCodegraphWireScript = class extends Command {
469
599
  async invoke() {
470
600
  const filePath = ".mise/scripts/codegraph-wire.sh";
@@ -540,7 +670,7 @@ inject_sse "$HOME/.config/Code/User/globalStorage/rooveterinaryinc.roo-cline/set
540
670
  `;
541
671
  this.writeFile(filePath, content);
542
672
  if (!this.context.dryRun) {
543
- chmodSync2(join4(this.context.targetDir, filePath), 493);
673
+ chmodSync2(join5(this.context.targetDir, filePath), 493);
544
674
  }
545
675
  return {
546
676
  success: true,
@@ -551,14 +681,14 @@ inject_sse "$HOME/.config/Code/User/globalStorage/rooveterinaryinc.roo-cline/set
551
681
  };
552
682
 
553
683
  // src/commands/WireMiseOpInject.ts
554
- import { join as join5 } from "node:path";
555
- import { existsSync as existsSync3, readFileSync, writeFileSync as writeFileSync3, chmodSync as chmodSync3 } from "node:fs";
684
+ import { join as join6 } from "node:path";
685
+ import { existsSync as existsSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync3, chmodSync as chmodSync3 } from "node:fs";
556
686
  var WireMiseOpInject = class _WireMiseOpInject extends Command {
557
687
  static MARKER = "# pjangler:op-inject";
558
688
  static CR = "{{config_root}}";
559
689
  async invoke() {
560
690
  const scriptPath = ".mise/scripts/inject-op-secrets.sh";
561
- const fullScriptPath = join5(this.context.targetDir, scriptPath);
691
+ const fullScriptPath = join6(this.context.targetDir, scriptPath);
562
692
  const scriptContent = `#!/usr/bin/env bash
563
693
  # Resolve 1Password \`op://\` references in .env.op into .env.secrets, which mise
564
694
  # loads via \`_.file\`.
@@ -602,14 +732,14 @@ fi
602
732
  if (!this.context.dryRun) {
603
733
  chmodSync3(fullScriptPath, 493);
604
734
  }
605
- const misePath = join5(this.context.targetDir, "mise.toml");
735
+ const misePath = join6(this.context.targetDir, "mise.toml");
606
736
  if (!existsSync3(misePath)) {
607
737
  return {
608
738
  success: false,
609
739
  message: "\u26A0\uFE0F No mise.toml found \u2014 run `pj init mise` first, then re-run."
610
740
  };
611
741
  }
612
- let content = readFileSync(misePath, "utf8");
742
+ let content = readFileSync2(misePath, "utf8");
613
743
  if (content.includes(_WireMiseOpInject.MARKER)) {
614
744
  return { success: true, message: this.formatMessage("\u2713 mise.toml already wired for op-inject") };
615
745
  }
@@ -646,8 +776,8 @@ ${envBlock}`);
646
776
  const enterRe = /(enter\s*=\s*\[[\s\S]*?)(\n[ \t]*\])/;
647
777
  if (enterRe.test(content)) {
648
778
  content = content.replace(enterRe, (_m, head, close) => {
649
- const sep = /[,[]\s*$/.test(head) ? "" : ",";
650
- return `${head}${sep}
779
+ const sep2 = /[,[]\s*$/.test(head) ? "" : ",";
780
+ return `${head}${sep2}
651
781
  ${enterAdds}${close}`;
652
782
  });
653
783
  } else {
@@ -915,12 +1045,12 @@ var NodeRecipe = class extends Recipe {
915
1045
  };
916
1046
 
917
1047
  // src/commands/hermes/PromptForAgentConfig.ts
918
- import { basename, join as join6 } from "node:path";
919
- import { readFileSync as readFileSync2 } from "node:fs";
1048
+ import { basename as basename2, join as join7 } from "node:path";
1049
+ import { readFileSync as readFileSync3 } from "node:fs";
920
1050
  import * as p from "@clack/prompts";
921
1051
  function detectTicketProvider(targetDir) {
922
1052
  try {
923
- const t = JSON.parse(readFileSync2(join6(targetDir, ".project.json"), "utf8"))?.ticket_provider?.type;
1053
+ const t = JSON.parse(readFileSync3(join7(targetDir, ".project.json"), "utf8"))?.ticket_provider?.type;
924
1054
  return t === "plane" || t === "trello" ? t : void 0;
925
1055
  } catch {
926
1056
  return void 0;
@@ -929,7 +1059,7 @@ function detectTicketProvider(targetDir) {
929
1059
  var PromptForAgentConfig = class extends Command {
930
1060
  async invoke() {
931
1061
  const ctx = this.context;
932
- const defaultRepo = basename(ctx.targetDir).toLowerCase();
1062
+ const defaultRepo = basename2(ctx.targetDir).toLowerCase();
933
1063
  ctx.targetRepo = (ctx.targetRepo ?? defaultRepo).toLowerCase();
934
1064
  ctx.role ??= "pm";
935
1065
  ctx.agentPurpose ??= `${ctx.role} agent for ${ctx.targetRepo}`;
@@ -978,7 +1108,7 @@ var PromptForAgentConfig = class extends Command {
978
1108
  // src/commands/hermes/RunCopierTemplate.ts
979
1109
  import { spawnSync } from "node:child_process";
980
1110
  import { homedir as homedir2 } from "node:os";
981
- import { join as join7, dirname as dirname3 } from "node:path";
1111
+ import { join as join8, dirname as dirname3 } from "node:path";
982
1112
  import { existsSync as existsSync4, mkdirSync as mkdirSync3 } from "node:fs";
983
1113
  import { fileURLToPath } from "node:url";
984
1114
  import * as p2 from "@clack/prompts";
@@ -990,8 +1120,8 @@ function resolveVendoredTemplate(name) {
990
1120
  return void 0;
991
1121
  }
992
1122
  for (let i = 0; i < 8; i++) {
993
- const candidate = join7(dir, "templates", name);
994
- if (existsSync4(join7(candidate, "copier.yml"))) return candidate;
1123
+ const candidate = join8(dir, "templates", name);
1124
+ if (existsSync4(join8(candidate, "copier.yml"))) return candidate;
995
1125
  const parent = dirname3(dir);
996
1126
  if (parent === dir) break;
997
1127
  dir = parent;
@@ -1010,7 +1140,7 @@ var RunCopierTemplate = class extends Command {
1010
1140
  message: "PromptForAgentConfig must run before RunCopierTemplate (targetRepo/role unset)"
1011
1141
  };
1012
1142
  }
1013
- const roleDir = join7(ctx.targetDir, "agents", "hermes", role);
1143
+ const roleDir = join8(ctx.targetDir, "agents", "hermes", role);
1014
1144
  ctx.roleDir = roleDir;
1015
1145
  ctx.runtimeRepo = `delorenj/agent-hm-${targetRepo}-${role}`;
1016
1146
  const which = spawnSync("which", ["copier"], { encoding: "utf8" });
@@ -1020,7 +1150,7 @@ var RunCopierTemplate = class extends Command {
1020
1150
  message: "\u2717 copier not found on PATH. Install with: `uv tool install copier` or `pip install copier`"
1021
1151
  };
1022
1152
  }
1023
- if (existsSync4(join7(roleDir, "role.yaml")) && !ctx.force) {
1153
+ if (existsSync4(join8(roleDir, "role.yaml")) && !ctx.force) {
1024
1154
  if (ctx.yes) {
1025
1155
  ctx.force = true;
1026
1156
  } else {
@@ -1041,15 +1171,16 @@ var RunCopierTemplate = class extends Command {
1041
1171
  ...process.env,
1042
1172
  SKIP_TELEGRAM: "1",
1043
1173
  SKIP_EMAIL: "1",
1044
- // We DO want copier to run runtime-repo + plane + bloodbank + systemd.
1174
+ // Bloodbank is a fleet-shared Hermes gateway. Never provision the legacy
1175
+ // per-profile file consumer, even when an older template still exposes it.
1045
1176
  SKIP_RUNTIME_REPO: ctx.skipRuntimeRepo ? "1" : "0",
1046
1177
  SKIP_PLANE: ctx.skipPlane ? "1" : "0",
1047
- SKIP_BLOODBANK: ctx.skipBloodbank ? "1" : "0",
1178
+ SKIP_BLOODBANK: "1",
1048
1179
  SKIP_SYSTEMD: ctx.skipSystemd ? "1" : "0"
1049
1180
  };
1050
- const LOCAL_TEMPLATE = join7(homedir2(), "code", "hermes-agent-template");
1181
+ const LOCAL_TEMPLATE = join8(homedir2(), "code", "hermes-agent-template");
1051
1182
  const vendored = resolveVendoredTemplate("hermes-agent");
1052
- const templateSrc = process.env.PJANGLER_HERMES_TEMPLATE || vendored || (existsSync4(join7(LOCAL_TEMPLATE, "copier.yml")) ? LOCAL_TEMPLATE : HERMES_AGENT_TEMPLATE);
1183
+ const templateSrc = process.env.PJANGLER_HERMES_TEMPLATE || vendored || (existsSync4(join8(LOCAL_TEMPLATE, "copier.yml")) ? LOCAL_TEMPLATE : HERMES_AGENT_TEMPLATE);
1053
1184
  const args = [
1054
1185
  "copy",
1055
1186
  templateSrc,
@@ -1080,7 +1211,7 @@ var RunCopierTemplate = class extends Command {
1080
1211
  message: this.formatMessage(`Would run: copier ${args.join(" ")}`)
1081
1212
  };
1082
1213
  }
1083
- mkdirSync3(join7(ctx.targetDir, "agents", "hermes"), { recursive: true });
1214
+ mkdirSync3(join8(ctx.targetDir, "agents", "hermes"), { recursive: true });
1084
1215
  const spinner4 = p2.spinner();
1085
1216
  spinner4.start(`Running copier copy (target: agents/hermes/${role})`);
1086
1217
  const result = spawnSync("copier", args, {
@@ -1104,20 +1235,26 @@ var RunCopierTemplate = class extends Command {
1104
1235
  };
1105
1236
 
1106
1237
  // src/commands/hermes/UntrackHermesRuntimes.ts
1107
- import { existsSync as existsSync5, readFileSync as readFileSync3, writeFileSync as writeFileSync4, readdirSync } from "fs";
1108
- import { join as join8 } from "path";
1238
+ import { existsSync as existsSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync4, readdirSync as readdirSync2 } from "fs";
1239
+ import { join as join9 } from "path";
1109
1240
  import { spawnSync as spawnSync2 } from "node:child_process";
1241
+ function sectionHasPath(section, targetPath) {
1242
+ return section.split(/\r?\n/).some((line) => /^\s*path\s*=/.test(line) && line.replace(/^\s*path\s*=\s*/, "").trim() === targetPath);
1243
+ }
1244
+ function removeSubmodulePath(content, targetPath) {
1245
+ return content.replace(/^\[submodule "[^"\n]+"\][\s\S]*?(?=^\[submodule "|(?![\s\S]))/gm, (section) => sectionHasPath(section, targetPath) ? "" : section).replace(/\n{3,}/g, "\n\n").trim();
1246
+ }
1110
1247
  var UntrackHermesRuntimes = class extends Command {
1111
1248
  async invoke() {
1112
1249
  const targetDir = this.context.targetDir;
1113
- const rolesDir = join8(targetDir, "agents", "hermes");
1250
+ const rolesDir = join9(targetDir, "agents", "hermes");
1114
1251
  if (!existsSync5(rolesDir)) {
1115
1252
  return {
1116
1253
  success: true,
1117
1254
  message: "No Hermes agents found (no agents/hermes directory)."
1118
1255
  };
1119
1256
  }
1120
- const roles = readdirSync(rolesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
1257
+ const roles = readdirSync2(rolesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
1121
1258
  if (roles.length === 0) {
1122
1259
  return {
1123
1260
  success: true,
@@ -1127,47 +1264,79 @@ var UntrackHermesRuntimes = class extends Command {
1127
1264
  let modifiedAny = false;
1128
1265
  const details = [];
1129
1266
  for (const role of roles) {
1130
- const roleDir = join8("agents", "hermes", role);
1131
- const runtimePath = join8(roleDir, "runtime");
1132
- const gitignorePath = join8(roleDir, ".gitignore");
1267
+ const roleDir = join9("agents", "hermes", role);
1268
+ const runtimePath = join9(roleDir, "runtime");
1269
+ const gitignorePath = join9(roleDir, ".gitignore");
1270
+ const gitmodulesPath = join9(targetDir, ".gitmodules");
1133
1271
  let isTracked = false;
1134
- const lsResult = spawnSync2("git", ["ls-files", "--stage", runtimePath], {
1272
+ const lsResult = spawnSync2("git", ["ls-files", "--stage", "--", runtimePath], {
1135
1273
  cwd: targetDir,
1136
1274
  encoding: "utf8"
1137
1275
  });
1276
+ if (lsResult.status !== 0) {
1277
+ return {
1278
+ success: false,
1279
+ message: `\u2717 Failed to inspect runtime index at ${runtimePath}: ${lsResult.stderr.trim() || `exit ${lsResult.status}`}`
1280
+ };
1281
+ }
1138
1282
  if (lsResult.status === 0 && lsResult.stdout.trim().length > 0) {
1139
1283
  isTracked = true;
1140
1284
  }
1285
+ let hasStaleMapping = false;
1286
+ let gitmodulesContent = "";
1287
+ if (existsSync5(gitmodulesPath)) {
1288
+ gitmodulesContent = readFileSync4(gitmodulesPath, "utf8");
1289
+ const sections = gitmodulesContent.match(/^\[submodule "[^"\n]+"\][\s\S]*?(?=^\[submodule "|(?![\s\S]))/gm) ?? [];
1290
+ hasStaleMapping = sections.some((section) => sectionHasPath(section, runtimePath));
1291
+ }
1141
1292
  let isIgnored = false;
1142
- const fullGitignorePath = join8(targetDir, gitignorePath);
1293
+ const fullGitignorePath = join9(targetDir, gitignorePath);
1143
1294
  if (existsSync5(fullGitignorePath)) {
1144
- const content = readFileSync3(fullGitignorePath, "utf8");
1295
+ const content = readFileSync4(fullGitignorePath, "utf8");
1145
1296
  const lines = content.split(/\r?\n/).map((line) => line.trim());
1146
1297
  isIgnored = lines.includes("runtime/") || lines.includes("runtime");
1147
1298
  }
1148
- if (isTracked || !isIgnored) {
1299
+ if (isTracked || hasStaleMapping || !isIgnored) {
1149
1300
  modifiedAny = true;
1150
1301
  if (isTracked) {
1151
1302
  details.push(`untrack agents/hermes/${role}/runtime`);
1152
1303
  if (!this.context.dryRun) {
1153
- const rmResult = spawnSync2("git", ["rm", "--cached", "-r", runtimePath], {
1304
+ const rmResult = spawnSync2("git", ["rm", "--cached", "-r", "-f", "--", runtimePath], {
1154
1305
  cwd: targetDir,
1155
1306
  encoding: "utf8"
1156
1307
  });
1157
1308
  if (rmResult.status !== 0) {
1158
1309
  return {
1159
1310
  success: false,
1160
- message: `Failed to untrack agents/hermes/${role}/runtime: ${rmResult.stderr}`
1311
+ message: `\u2717 Failed to untrack ${runtimePath}: ${rmResult.stderr.trim() || `exit ${rmResult.status}`}`
1312
+ };
1313
+ }
1314
+ const verifyResult = spawnSync2("git", ["ls-files", "--stage", "--", runtimePath], {
1315
+ cwd: targetDir,
1316
+ encoding: "utf8"
1317
+ });
1318
+ if (verifyResult.status !== 0 || verifyResult.stdout.trim()) {
1319
+ return {
1320
+ success: false,
1321
+ message: verifyResult.status !== 0 ? `\u2717 Failed to verify untracked runtime ${runtimePath}: ${verifyResult.stderr.trim() || `exit ${verifyResult.status}`}` : `\u2717 Runtime remains tracked after index-only removal: ${runtimePath}`
1161
1322
  };
1162
1323
  }
1163
1324
  }
1164
1325
  }
1326
+ if (hasStaleMapping) {
1327
+ details.push(`remove stale .gitmodules mapping for ${runtimePath}`);
1328
+ if (!this.context.dryRun) {
1329
+ const next = removeSubmodulePath(gitmodulesContent, runtimePath);
1330
+ writeFileSync4(gitmodulesPath, next ? `${next}
1331
+ ` : "", "utf8");
1332
+ }
1333
+ }
1165
1334
  if (!isIgnored) {
1166
1335
  details.push(`ignore runtime/ in agents/hermes/${role}/.gitignore`);
1167
1336
  if (!this.context.dryRun) {
1168
1337
  let content = "";
1169
1338
  if (existsSync5(fullGitignorePath)) {
1170
- content = readFileSync3(fullGitignorePath, "utf8");
1339
+ content = readFileSync4(fullGitignorePath, "utf8");
1171
1340
  }
1172
1341
  if (content && !content.endsWith("\n")) {
1173
1342
  content += "\n";
@@ -1195,7 +1364,7 @@ ${details.map((d) => ` - ${d}`).join("\n")}`
1195
1364
 
1196
1365
  // src/commands/hermes/WireTelegram.ts
1197
1366
  import { spawnSync as spawnSync3 } from "node:child_process";
1198
- import { join as join9 } from "node:path";
1367
+ import { join as join10 } from "node:path";
1199
1368
  import { existsSync as existsSync6, unlinkSync } from "node:fs";
1200
1369
  import * as p3 from "@clack/prompts";
1201
1370
  var WireTelegram = class extends Command {
@@ -1284,14 +1453,14 @@ var WireTelegram = class extends Command {
1284
1453
  if (p3.isCancel(allowedAnswer)) {
1285
1454
  return { success: false, message: "\u2717 Aborted; Telegram step deferred." };
1286
1455
  }
1287
- const script = join9(roleDir, ".scripts", "30-telegram.sh");
1456
+ const script = join10(roleDir, ".scripts", "30-telegram.sh");
1288
1457
  if (!existsSync6(script)) {
1289
1458
  return {
1290
1459
  success: false,
1291
1460
  message: `\u2717 ${script} not found. Did copier finish? Re-run with --skip-runtime-repo=0 if you skipped it.`
1292
1461
  };
1293
1462
  }
1294
- const marker = join9(roleDir, ".scripts", ".done-30-telegram");
1463
+ const marker = join10(roleDir, ".scripts", ".done-30-telegram");
1295
1464
  if (existsSync6(marker)) unlinkSync(marker);
1296
1465
  const spinner4 = p3.spinner();
1297
1466
  spinner4.start("Verifying token + wiring profile");
@@ -1319,7 +1488,7 @@ function cap(s) {
1319
1488
 
1320
1489
  // src/commands/hermes/WireEmail.ts
1321
1490
  import { spawnSync as spawnSync4 } from "node:child_process";
1322
- import { join as join10 } from "node:path";
1491
+ import { join as join11 } from "node:path";
1323
1492
  import { existsSync as existsSync7, unlinkSync as unlinkSync2 } from "node:fs";
1324
1493
  import * as p4 from "@clack/prompts";
1325
1494
  var WireEmail = class extends Command {
@@ -1335,7 +1504,7 @@ var WireEmail = class extends Command {
1335
1504
  if (!targetRepo || !role || !roleDir) {
1336
1505
  return { success: false, message: "Cannot wire email: missing target_repo/role/roleDir" };
1337
1506
  }
1338
- const script = join10(roleDir, ".scripts", "50-email.sh");
1507
+ const script = join11(roleDir, ".scripts", "50-email.sh");
1339
1508
  if (!existsSync7(script)) {
1340
1509
  return { success: false, message: `\u2717 ${script} not found` };
1341
1510
  }
@@ -1398,7 +1567,7 @@ var WireEmail = class extends Command {
1398
1567
  }
1399
1568
  }
1400
1569
  }
1401
- const marker = join10(roleDir, ".scripts", ".done-50-email");
1570
+ const marker = join11(roleDir, ".scripts", ".done-50-email");
1402
1571
  if (existsSync7(marker)) unlinkSync2(marker);
1403
1572
  const spinner4 = p4.spinner();
1404
1573
  spinner4.start("Creating Cloudflare Email Routing rule");
@@ -1427,7 +1596,6 @@ var PrintHermesSummary = class extends Command {
1427
1596
  const botHandle = `${targetRepo?.toLowerCase().replace(/-/g, "_")}_${role?.toLowerCase()}_bot`;
1428
1597
  const email = `${targetRepo}-${role}@delo.sh`;
1429
1598
  const gw = `hermes-${agentId}-gateway.service`;
1430
- const csm = `hermes-${agentId}-consumer.service`;
1431
1599
  const hb = `hermes-${agentId}-heartbeat.timer`;
1432
1600
  const lines = [];
1433
1601
  lines.push(`agent_id ${agentId}`);
@@ -1437,13 +1605,13 @@ var PrintHermesSummary = class extends Command {
1437
1605
  if (!skipEmail) lines.push(`email ${email}`);
1438
1606
  lines.push("");
1439
1607
  lines.push("Start daemons:");
1440
- lines.push(` systemctl --user start ${csm}`);
1441
1608
  lines.push(` systemctl --user start ${hb}`);
1442
1609
  if (!skipTelegram) {
1443
1610
  lines.push(` systemctl --user start ${gw}`);
1444
1611
  } else {
1445
1612
  lines.push(` # gateway needs Telegram wired first (re-run with --skip-telegram=0)`);
1446
1613
  }
1614
+ lines.push(" # Bloodbank commands arrive through the fleet-shared Hermes gateway");
1447
1615
  lines.push("");
1448
1616
  lines.push("Talk locally:");
1449
1617
  lines.push(` ${ctx.roleDir}/hermes chat "status"`);
@@ -1486,15 +1654,15 @@ var HermesAgentRecipe = class extends Recipe {
1486
1654
 
1487
1655
  // src/commands/AgentHooksCommands.ts
1488
1656
  import { homedir as homedir4 } from "node:os";
1489
- import { join as join12, dirname as dirname5 } from "node:path";
1490
- import { existsSync as existsSync9, cpSync, mkdirSync as mkdirSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync6 } from "node:fs";
1657
+ import { join as join13, dirname as dirname5 } from "node:path";
1658
+ import { existsSync as existsSync9, cpSync, mkdirSync as mkdirSync5, readFileSync as readFileSync6, writeFileSync as writeFileSync6 } from "node:fs";
1491
1659
  import { fileURLToPath as fileURLToPath2 } from "node:url";
1492
1660
 
1493
1661
  // src/project/index.ts
1494
1662
  import { spawnSync as spawnSync5 } from "node:child_process";
1495
- import { existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync4, renameSync, statSync, writeFileSync as writeFileSync5 } from "node:fs";
1663
+ import { existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync5, renameSync, statSync, writeFileSync as writeFileSync5 } from "node:fs";
1496
1664
  import { homedir as homedir3 } from "node:os";
1497
- import { basename as basename2, delimiter, dirname as dirname4, join as join11, resolve } from "node:path";
1665
+ import { basename as basename3, delimiter, dirname as dirname4, join as join12, resolve as resolve2 } from "node:path";
1498
1666
  import YAML2 from "yaml";
1499
1667
 
1500
1668
  // src/project/RegistryStore.ts
@@ -1729,18 +1897,18 @@ var PROJECT_SOURCE_SKILL_ROOTS_ENV = "PJ_SOURCE_SKILL_ROOTS";
1729
1897
  var PROJECT_REGISTRY_SCHEMA_VERSION = 1;
1730
1898
  var DEFAULT_SOURCE_SKILL_ROOTS = [
1731
1899
  "/home/delorenj/code/skillex/all-skills",
1732
- join11(homedir3(), ".agents", "skills"),
1733
- join11(homedir3(), ".codex", "skills")
1900
+ join12(homedir3(), ".agents", "skills"),
1901
+ join12(homedir3(), ".codex", "skills")
1734
1902
  ];
1735
1903
  function projectRegistryPath(env2 = process.env) {
1736
- return expandHome(env2[PROJECT_REGISTRY_ENV] || join11(homedir3(), ".config", "pjangler", "projects.yaml"));
1904
+ return expandHome(env2[PROJECT_REGISTRY_ENV] || join12(homedir3(), ".config", "pjangler", "projects.yaml"));
1737
1905
  }
1738
1906
  function emptyProjectRegistry() {
1739
1907
  return { schema_version: PROJECT_REGISTRY_SCHEMA_VERSION, projects: {} };
1740
1908
  }
1741
1909
  function loadProjectRegistry(path = projectRegistryPath()) {
1742
1910
  if (!existsSync8(path)) return emptyProjectRegistry();
1743
- const raw = YAML2.parse(readFileSync4(path, "utf8"));
1911
+ const raw = YAML2.parse(readFileSync5(path, "utf8"));
1744
1912
  if (raw == null) return emptyProjectRegistry();
1745
1913
  if (!isRecord(raw)) throw new Error(`Project registry must be a mapping: ${path}`);
1746
1914
  const registry = raw;
@@ -1770,7 +1938,7 @@ function validateProjectRegistry(registry) {
1770
1938
  validateProjectRecord(project, slug);
1771
1939
  if (slugs.has(project.slug)) throw new Error(`Duplicate project slug: ${project.slug}`);
1772
1940
  slugs.add(project.slug);
1773
- const repoKey = resolve(project.repo_path);
1941
+ const repoKey = resolve2(project.repo_path);
1774
1942
  const existingRepoSlug = repoPaths.get(repoKey);
1775
1943
  if (existingRepoSlug && existingRepoSlug !== slug) {
1776
1944
  throw new Error(`Duplicate project repo_path: ${project.repo_path} used by ${existingRepoSlug} and ${slug}`);
@@ -1837,7 +2005,7 @@ function resolveAgentHooksLayer(input, env2 = process.env) {
1837
2005
  const override = env2.PJ_AGENT_HOOKS_LAYER;
1838
2006
  if (override === "0" || override === "false") return false;
1839
2007
  if (override === "1" || override === "true") return true;
1840
- return !existsSync8(join11(homedir3(), ".agents", "hooks"));
2008
+ return !existsSync8(join12(homedir3(), ".agents", "hooks"));
1841
2009
  }
1842
2010
  function jsonStable(value) {
1843
2011
  return JSON.stringify(value);
@@ -1850,14 +2018,14 @@ function projectRecordEquivalent(a, b) {
1850
2018
  }
1851
2019
  function defaultProjectTargetDir(name, cwd = process.cwd()) {
1852
2020
  const compactName = name.replace(/[^A-Za-z0-9._-]/g, "") || slugifyProjectName(name);
1853
- return resolve(dirname4(resolve(cwd)), compactName);
2021
+ return resolve2(dirname4(resolve2(cwd)), compactName);
1854
2022
  }
1855
2023
  function sourceSkillRoots(env2 = process.env) {
1856
2024
  const configuredRoots = (env2[PROJECT_SOURCE_SKILL_ROOTS_ENV] || "").split(delimiter).map((root) => root.trim()).filter(Boolean);
1857
2025
  const seen = /* @__PURE__ */ new Set();
1858
2026
  const roots = [];
1859
2027
  for (const root of [...DEFAULT_SOURCE_SKILL_ROOTS, ...configuredRoots]) {
1860
- const normalized = resolve(expandHome(root));
2028
+ const normalized = resolve2(expandHome(root));
1861
2029
  if (seen.has(normalized)) continue;
1862
2030
  seen.add(normalized);
1863
2031
  roots.push(normalized);
@@ -1867,12 +2035,12 @@ function sourceSkillRoots(env2 = process.env) {
1867
2035
  function resolveSourceSkillPath(sourceSkill, env2 = process.env) {
1868
2036
  if (!sourceSkill) return void 0;
1869
2037
  const expanded = expandHome(sourceSkill);
1870
- const direct = resolve(expanded);
2038
+ const direct = resolve2(expanded);
1871
2039
  if (existsSync8(direct)) return direct;
1872
- const name = basename2(sourceSkill);
2040
+ const name = basename3(sourceSkill);
1873
2041
  const roots = sourceSkillRoots(env2);
1874
2042
  for (const root of roots) {
1875
- const candidate = join11(root, name);
2043
+ const candidate = join12(root, name);
1876
2044
  if (existsSync8(candidate)) return candidate;
1877
2045
  }
1878
2046
  const searched = roots.length ? ` Searched roots: ${roots.join(", ")}.` : "";
@@ -1881,11 +2049,11 @@ function resolveSourceSkillPath(sourceSkill, env2 = process.env) {
1881
2049
  }
1882
2050
  function planProjectInit(input) {
1883
2051
  if (!input.name.trim()) throw new Error("Project name is required");
1884
- const registryPath2 = resolve(projectRegistryPath({ ...process.env, [PROJECT_REGISTRY_ENV]: input.registryPath || process.env[PROJECT_REGISTRY_ENV] }));
2052
+ const registryPath2 = resolve2(projectRegistryPath({ ...process.env, [PROJECT_REGISTRY_ENV]: input.registryPath || process.env[PROJECT_REGISTRY_ENV] }));
1885
2053
  const registry = loadProjectRegistry(registryPath2);
1886
2054
  const now = (input.now ?? /* @__PURE__ */ new Date()).toISOString();
1887
2055
  const slug = input.projectSlug ?? slugifyProjectName(input.name);
1888
- const targetDir = resolve(input.targetDir ?? defaultProjectTargetDir(input.name, input.cwd));
2056
+ const targetDir = resolve2(input.targetDir ?? defaultProjectTargetDir(input.name, input.cwd));
1889
2057
  const identifier = (input.projectIdentifier ?? deriveProjectIdentifier(input.name)).toUpperCase();
1890
2058
  const existing = registry.projects[slug];
1891
2059
  const sourceSkillPath = resolveSourceSkillPath(input.sourceSkill);
@@ -1928,7 +2096,7 @@ function planProjectInit(input) {
1928
2096
  updated_at: projectRecordEquivalent(existing, candidateProject) ? existing.updated_at : now
1929
2097
  };
1930
2098
  validateNoDuplicateProject(registry, project, overwrite);
1931
- const pjanglerRoot = resolve(input.pjanglerRoot ?? resolvePjanglerRoot());
2099
+ const pjanglerRoot = resolve2(input.pjanglerRoot ?? resolvePjanglerRoot());
1932
2100
  const manifest = projectManifestFromRegistryProject(project);
1933
2101
  const apply = input.apply ?? false;
1934
2102
  const live = input.live ?? false;
@@ -1954,7 +2122,7 @@ function planProjectInit(input) {
1954
2122
  }));
1955
2123
  }
1956
2124
  actions.push(
1957
- { kind: "project.write-manifest", path: join11(targetDir, ".project.json"), manifest },
2125
+ { kind: "project.write-manifest", path: join12(targetDir, ".project.json"), manifest },
1958
2126
  {
1959
2127
  kind: "ticket-provider.create-or-link",
1960
2128
  enabled: live,
@@ -2015,7 +2183,7 @@ async function executeProjectInitPlan(plan) {
2015
2183
  mkdirSync4(dirname4(action.path), { recursive: true });
2016
2184
  const next = `${JSON.stringify(action.manifest, null, 2)}
2017
2185
  `;
2018
- const current = existsSync8(action.path) ? readFileSync4(action.path, "utf8") : void 0;
2186
+ const current = existsSync8(action.path) ? readFileSync5(action.path, "utf8") : void 0;
2019
2187
  if (current !== next) {
2020
2188
  writeFileSync5(action.path, next, "utf8");
2021
2189
  changedFiles.push(action.path);
@@ -2125,7 +2293,7 @@ function doctorProjectRegistry(registryPath2 = projectRegistryPath(), slug) {
2125
2293
  } else if (!statSync(project.repo_path).isDirectory()) {
2126
2294
  issues.push({ level: "error", slug: projectSlug, message: `repo_path is not a directory: ${project.repo_path}` });
2127
2295
  } else {
2128
- const manifestPath = join11(project.repo_path, ".project.json");
2296
+ const manifestPath = join12(project.repo_path, ".project.json");
2129
2297
  if (!existsSync8(manifestPath)) issues.push({ level: "warn", slug: projectSlug, message: ".project.json is missing" });
2130
2298
  }
2131
2299
  for (const artifact of project.source_artifacts) {
@@ -2142,7 +2310,7 @@ function doctorProjectRegistry(registryPath2 = projectRegistryPath(), slug) {
2142
2310
  };
2143
2311
  }
2144
2312
  function buildCommonProjectCopierAction(input) {
2145
- const templateDir = join11(input.pjanglerRoot, "templates", "commonproject");
2313
+ const templateDir = join12(input.pjanglerRoot, "templates", "commonproject");
2146
2314
  const data = {
2147
2315
  project_name: input.projectName,
2148
2316
  project_description: input.projectDescription ?? "",
@@ -2171,19 +2339,19 @@ function buildCommonProjectCopierAction(input) {
2171
2339
  function resolvePjanglerRoot() {
2172
2340
  let dir = dirname4(new URL(import.meta.url).pathname);
2173
2341
  while (dir !== dirname4(dir)) {
2174
- if (existsSync8(join11(dir, "package.json")) && existsSync8(join11(dir, "templates", "commonproject", "copier.yml"))) return dir;
2342
+ if (existsSync8(join12(dir, "package.json")) && existsSync8(join12(dir, "templates", "commonproject", "copier.yml"))) return dir;
2175
2343
  dir = dirname4(dir);
2176
2344
  }
2177
- return resolve(process.cwd());
2345
+ return resolve2(process.cwd());
2178
2346
  }
2179
2347
  function validateNoDuplicateProject(registry, project, overwrite) {
2180
2348
  const existingSameSlug = registry.projects[project.slug];
2181
- if (existingSameSlug && !overwrite && resolve(existingSameSlug.repo_path) !== resolve(project.repo_path)) {
2349
+ if (existingSameSlug && !overwrite && resolve2(existingSameSlug.repo_path) !== resolve2(project.repo_path)) {
2182
2350
  throw new Error(`Project slug already exists in registry: ${project.slug}`);
2183
2351
  }
2184
2352
  for (const [slug, existing] of Object.entries(registry.projects)) {
2185
2353
  if (slug === project.slug) continue;
2186
- if (resolve(existing.repo_path) === resolve(project.repo_path)) {
2354
+ if (resolve2(existing.repo_path) === resolve2(project.repo_path)) {
2187
2355
  throw new Error(`Project repo_path already registered by ${slug}: ${project.repo_path}`);
2188
2356
  }
2189
2357
  if (existing.ticket_provider.identifier && existing.ticket_provider.identifier.toUpperCase() === project.ticket_provider.identifier?.toUpperCase()) {
@@ -2203,7 +2371,7 @@ function validateProjectRecord(project, key) {
2203
2371
  }
2204
2372
  function expandHome(path) {
2205
2373
  if (path === "~") return homedir3();
2206
- if (path.startsWith("~/")) return join11(homedir3(), path.slice(2));
2374
+ if (path.startsWith("~/")) return join12(homedir3(), path.slice(2));
2207
2375
  return path;
2208
2376
  }
2209
2377
  function isRecord(value) {
@@ -2220,16 +2388,16 @@ function resolveTemplateRoot() {
2220
2388
  try {
2221
2389
  let dir = dirname5(fileURLToPath2(import.meta.url));
2222
2390
  for (let i = 0; i < 8; i++) {
2223
- candidates.push(join12(dir, "templates", "commonproject", "template"));
2391
+ candidates.push(join13(dir, "templates", "commonproject", "template"));
2224
2392
  const parent = dirname5(dir);
2225
2393
  if (parent === dir) break;
2226
2394
  dir = parent;
2227
2395
  }
2228
2396
  } catch {
2229
2397
  }
2230
- candidates.push(join12(homedir4(), "code", "pjangler", "templates", "commonproject", "template"));
2398
+ candidates.push(join13(homedir4(), "code", "pjangler", "templates", "commonproject", "template"));
2231
2399
  for (const c of candidates) {
2232
- if (existsSync9(join12(c, ".agents", "hooks", "hooks.master.json"))) return c;
2400
+ if (existsSync9(join13(c, ".agents", "hooks", "hooks.master.json"))) return c;
2233
2401
  }
2234
2402
  throw new Error(
2235
2403
  "Could not locate the CommonProject template. Set PJANGLER_COMMONPROJECT_TEMPLATE to <repo>/templates/commonproject/template."
@@ -2255,8 +2423,8 @@ var CopyAgentHooksTree = class extends Command {
2255
2423
  const created = [];
2256
2424
  const skipped = [];
2257
2425
  for (const { rel, dir } of items) {
2258
- const src = join12(templateRoot, rel);
2259
- const dest = join12(this.context.targetDir, rel);
2426
+ const src = join13(templateRoot, rel);
2427
+ const dest = join13(this.context.targetDir, rel);
2260
2428
  if (!existsSync9(src)) continue;
2261
2429
  if (existsSync9(dest) && !this.context.force) {
2262
2430
  skipped.push(rel);
@@ -2284,14 +2452,14 @@ var WireMiseAgentHooks = class _WireMiseAgentHooks extends Command {
2284
2452
  if (!resolveAgentHooksLayer()) {
2285
2453
  return { success: true, message: this.formatMessage(AGENT_HOOKS_SKIP_MESSAGE) };
2286
2454
  }
2287
- const misePath = join12(this.context.targetDir, "mise.toml");
2455
+ const misePath = join13(this.context.targetDir, "mise.toml");
2288
2456
  if (!existsSync9(misePath)) {
2289
2457
  return {
2290
2458
  success: false,
2291
2459
  message: "\u26A0\uFE0F No mise.toml found \u2014 run `pjangler init mise` first, then re-run."
2292
2460
  };
2293
2461
  }
2294
- let content = readFileSync5(misePath, "utf8");
2462
+ let content = readFileSync6(misePath, "utf8");
2295
2463
  if (content.includes(_WireMiseAgentHooks.MARKER)) {
2296
2464
  return { success: true, message: this.formatMessage("\u2713 mise.toml already wired for agent-hooks") };
2297
2465
  }
@@ -2306,15 +2474,15 @@ var WireMiseAgentHooks = class _WireMiseAgentHooks extends Command {
2306
2474
  const enterRe = /(enter\s*=\s*\[[\s\S]*?)(\n[ \t]*\])/;
2307
2475
  if (enterRe.test(content)) {
2308
2476
  content = content.replace(enterRe, (_m, head, close) => {
2309
- const sep = /[,[]\s*$/.test(head) ? "" : ",";
2310
- return `${head}${sep}
2477
+ const sep2 = /[,[]\s*$/.test(head) ? "" : ",";
2478
+ return `${head}${sep2}
2311
2479
  ${enterAdds}${close}`;
2312
2480
  });
2313
2481
  const leaveRe = /(leave\s*=\s*\[[\s\S]*?)(\n[ \t]*\])/;
2314
2482
  if (leaveRe.test(content)) {
2315
2483
  content = content.replace(leaveRe, (_m, head, close) => {
2316
- const sep = /[,[]\s*$/.test(head) ? "" : ",";
2317
- return `${head}${sep}
2484
+ const sep2 = /[,[]\s*$/.test(head) ? "" : ",";
2485
+ return `${head}${sep2}
2318
2486
  "${cr}/.agents/hooks/sync.py --uninstall --quiet",${close}`;
2319
2487
  });
2320
2488
  } else {
@@ -2558,24 +2726,41 @@ function createRecipe(name, context) {
2558
2726
  import { cancel as cancel2, multiselect, text as text2, isCancel as isCancel5 } from "@clack/prompts";
2559
2727
 
2560
2728
  // src/parity/index.ts
2561
- import { existsSync as existsSync10, lstatSync, mkdirSync as mkdirSync6, readFileSync as readFileSync6, readlinkSync, readdirSync as readdirSync2, renameSync as renameSync2, symlinkSync, unlinkSync as unlinkSync3, writeFileSync as writeFileSync7, chmodSync as chmodSync4, copyFileSync, rmSync } from "node:fs";
2562
- import { basename as basename3, dirname as dirname6, join as join13, relative, resolve as resolve2 } from "node:path";
2563
- import { fileURLToPath as fileURLToPath3 } from "node:url";
2729
+ import { existsSync as existsSync10, lstatSync as lstatSync2, mkdirSync as mkdirSync6, mkdtempSync, readFileSync as readFileSync7, readlinkSync, readdirSync as readdirSync3, realpathSync, renameSync as renameSync2, rmdirSync, symlinkSync, unlinkSync as unlinkSync3, writeFileSync as writeFileSync7, chmodSync as chmodSync4, copyFileSync, rmSync } from "node:fs";
2730
+ import { basename as basename4, dirname as dirname6, join as join14, relative as relative2, resolve as resolve3 } from "node:path";
2731
+ import { fileURLToPath as fileURLToPath3, pathToFileURL } from "node:url";
2564
2732
  import { homedir as homedir5 } from "node:os";
2565
2733
  import { spawnSync as spawnSync6 } from "node:child_process";
2566
2734
  import YAML3 from "yaml";
2567
2735
  var LINK_AGENTFILES_SCRIPT = "'{{config_root}}/.mise/scripts/link-agentfiles.sh'";
2568
2736
  var OP_INJECT_SCRIPT = "op inject -i .env.op > .env";
2569
- var SYNC_SKILLS_SCRIPT = "sync-skills.py --scope project";
2737
+ var PROVISION_BMAD_SKILLS_SCRIPT = "python3 '{{config_root}}/.mise/scripts/provision-bmad-skills.py'";
2738
+ var SYNC_SKILLS_SCRIPT = "python3 '{{config_root}}/.mise/scripts/sync-skills.py' --scope project";
2570
2739
  var CODEGRAPH_SCRIPT = "[ -f '{{config_root}}/.mise/scripts/codegraph.sh' ] && '{{config_root}}/.mise/scripts/codegraph.sh' || true";
2571
2740
  var SKILLS_REGISTRY_URL = "https://github.com/delorenj/skillex.git";
2741
+ var PROJECT_CLI_SKILL_DIRS = [
2742
+ ".gemini/skills",
2743
+ ".codex/skills",
2744
+ ".kimi/skills",
2745
+ ".augment/skills",
2746
+ ".config/opencode/skills",
2747
+ ".hermes/skills",
2748
+ ".claude/skills",
2749
+ ".openclaw/skills"
2750
+ ];
2751
+ var CANONICAL_CLAUDE_SKILLS_ALIAS = "../.agents/skills";
2572
2752
  var HOOKS_COMMENT_HEADER = `# This block will handle the linking of
2573
2753
  # agent files to the main AGENTS.md file.
2574
2754
  #
2575
2755
  # TODO: Ensure this works for all levels of nesting.
2576
2756
  # i.e. All linked agent files MUST be siblings at
2577
2757
  # any given level of nesting.`;
2578
- var LINK_AGENTFILES_HOOK_ENTRIES = [LINK_AGENTFILES_SCRIPT, OP_INJECT_SCRIPT, SYNC_SKILLS_SCRIPT];
2758
+ var LINK_AGENTFILES_HOOK_ENTRIES = [
2759
+ LINK_AGENTFILES_SCRIPT,
2760
+ OP_INJECT_SCRIPT,
2761
+ PROVISION_BMAD_SKILLS_SCRIPT,
2762
+ SYNC_SKILLS_SCRIPT
2763
+ ];
2579
2764
  var LINK_AGENTFILES_WATCH_TASK_BLOCK = `[[watch_files]]
2580
2765
  patterns = ["AGENTS.md"]
2581
2766
  task = "link-agentfiles"
@@ -2590,7 +2775,12 @@ run = "'{{config_root}}/.mise/scripts/link-agentfiles.sh'"
2590
2775
 
2591
2776
  [tasks.skills-sync]
2592
2777
  description = "Sync skills from manifest to local CLI dirs"
2593
- run = "sync-skills.py --scope project"`;
2778
+ depends = ["skills-provision-bmad"]
2779
+ run = "python3 '{{config_root}}/.mise/scripts/sync-skills.py' --scope project"
2780
+
2781
+ [tasks.skills-provision-bmad]
2782
+ description = "Provision pinned BMAD skills from the Skillex pack"
2783
+ run = "python3 '{{config_root}}/.mise/scripts/provision-bmad-skills.py'"`;
2594
2784
  var VERSIONING_BLOCK = `# >>> mise-versioning >>> (managed block \u2014 do not edit by hand; re-run init to update)
2595
2785
  [tasks."version"]
2596
2786
  description = "Print the current version (vX.Y.Z)"
@@ -2620,7 +2810,7 @@ run = "'{{config_root}}/.mise/scripts/versioning.sh' sync"
2620
2810
  function resolvePjanglerRoot2() {
2621
2811
  let dir = dirname6(fileURLToPath3(import.meta.url));
2622
2812
  while (dir !== dirname6(dir)) {
2623
- if (existsSync10(join13(dir, "package.json")) && existsSync10(join13(dir, "templates", "commonproject", "copier.yml"))) {
2813
+ if (existsSync10(join14(dir, "package.json")) && existsSync10(join14(dir, "templates", "commonproject", "copier.yml"))) {
2624
2814
  return dir;
2625
2815
  }
2626
2816
  dir = dirname6(dir);
@@ -2631,7 +2821,7 @@ function normalizeNewlines(value) {
2631
2821
  return value.replace(/\r\n/g, "\n");
2632
2822
  }
2633
2823
  function readText(path) {
2634
- return normalizeNewlines(readFileSync6(path, "utf8"));
2824
+ return normalizeNewlines(readFileSync7(path, "utf8"));
2635
2825
  }
2636
2826
  function safeReadText(path) {
2637
2827
  return existsSync10(path) ? readText(path) : null;
@@ -2667,7 +2857,7 @@ function readSymlinkTarget(path) {
2667
2857
  }
2668
2858
  function ensureSymlink(path, target, dryRun) {
2669
2859
  if (existsSync10(path)) {
2670
- const stat = lstatSync(path);
2860
+ const stat = lstatSync2(path);
2671
2861
  if (stat.isSymbolicLink()) {
2672
2862
  const current = readSymlinkTarget(path);
2673
2863
  if (current === target) return { changed: false };
@@ -2677,18 +2867,18 @@ function ensureSymlink(path, target, dryRun) {
2677
2867
  }
2678
2868
  return { changed: true };
2679
2869
  }
2680
- return { changed: false, blocked: `${relative(process.cwd(), path) || path} exists and is not a symlink` };
2870
+ return { changed: false, blocked: `${relative2(process.cwd(), path) || path} exists and is not a symlink` };
2681
2871
  }
2682
2872
  if (!dryRun) symlinkSync(target, path);
2683
2873
  return { changed: true };
2684
2874
  }
2685
2875
  function bootstrapAgentsFile(repoRoot, dryRun) {
2686
- const agentsPath = join13(repoRoot, "AGENTS.md");
2876
+ const agentsPath = join14(repoRoot, "AGENTS.md");
2687
2877
  if (existsSync10(agentsPath)) return { changedFiles: [], details: [] };
2688
2878
  for (const file of ["CLAUDE.md", "GEMINI.md"]) {
2689
- const source = join13(repoRoot, file);
2879
+ const source = join14(repoRoot, file);
2690
2880
  if (!existsSync10(source)) continue;
2691
- const stat = lstatSync(source);
2881
+ const stat = lstatSync2(source);
2692
2882
  if (stat.isSymbolicLink()) continue;
2693
2883
  if (stat.isFile()) {
2694
2884
  if (!dryRun) renameSync2(source, agentsPath);
@@ -2696,9 +2886,9 @@ function bootstrapAgentsFile(repoRoot, dryRun) {
2696
2886
  }
2697
2887
  return { changedFiles: [], details: [], blocked: `${file} exists but is not a regular file; cannot promote to AGENTS.md` };
2698
2888
  }
2699
- const readmePath = join13(repoRoot, "README.md");
2889
+ const readmePath = join14(repoRoot, "README.md");
2700
2890
  if (existsSync10(readmePath)) {
2701
- const stat = lstatSync(readmePath);
2891
+ const stat = lstatSync2(readmePath);
2702
2892
  if (!stat.isFile()) return { changedFiles: [], details: [], blocked: "README.md exists but is not a regular file; cannot copy to AGENTS.md" };
2703
2893
  if (!dryRun) copyFileSync(readmePath, agentsPath);
2704
2894
  return { changedFiles: [agentsPath], details: ["Copied README.md to AGENTS.md before wiring agent-file symlinks"] };
@@ -2736,11 +2926,11 @@ function yamlGet(text3, keyPath) {
2736
2926
  return "";
2737
2927
  }
2738
2928
  function discoverRoles(repoRoot) {
2739
- const rolesDir = join13(repoRoot, "agents", "hermes");
2929
+ const rolesDir = join14(repoRoot, "agents", "hermes");
2740
2930
  if (!existsSync10(rolesDir)) return [];
2741
- return readdirSync2(rolesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => {
2742
- const roleDir = join13(rolesDir, entry.name);
2743
- const roleYamlPath = join13(roleDir, "role.yaml");
2931
+ return readdirSync3(rolesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => {
2932
+ const roleDir = join14(rolesDir, entry.name);
2933
+ const roleYamlPath = join14(roleDir, "role.yaml");
2744
2934
  if (!existsSync10(roleYamlPath)) return null;
2745
2935
  const text3 = readText(roleYamlPath);
2746
2936
  const runtimeRepoRaw = yamlGet(text3, "runtime.github_repo");
@@ -2769,7 +2959,7 @@ function discoverRoles(repoRoot) {
2769
2959
  }).filter((value) => Boolean(value));
2770
2960
  }
2771
2961
  function registryPath(homeDir) {
2772
- return join13(homeDir, ".hermes", "agents-registry.yaml");
2962
+ return join14(homeDir, ".hermes", "agents-registry.yaml");
2773
2963
  }
2774
2964
  function systemctlUser(args) {
2775
2965
  const result = spawnSync6("systemctl", ["--user", ...args], { encoding: "utf8" });
@@ -2780,7 +2970,7 @@ function systemctlUser(args) {
2780
2970
  };
2781
2971
  }
2782
2972
  function templateScript(ctx, name) {
2783
- const source = join13(ctx.pjanglerRoot, ".mise", "scripts", name);
2973
+ const source = join14(ctx.pjanglerRoot, ".mise", "scripts", name);
2784
2974
  return existsSync10(source) ? readText(source) : void 0;
2785
2975
  }
2786
2976
  function templateVersioningScript(ctx) {
@@ -2793,8 +2983,8 @@ function resolveAgentHooksLayer2(ctx) {
2793
2983
  const override = process.env.PJ_AGENT_HOOKS_LAYER;
2794
2984
  if (override === "0" || override === "false") return false;
2795
2985
  if (override === "1" || override === "true") return true;
2796
- if (existsSync10(join13(ctx.repoRoot, ".agents", "hooks", "sync.py"))) return true;
2797
- return !existsSync10(join13(ctx.homeDir, ".agents", "hooks"));
2986
+ if (existsSync10(join14(ctx.repoRoot, ".agents", "hooks", "sync.py"))) return true;
2987
+ return !existsSync10(join14(ctx.homeDir, ".agents", "hooks"));
2798
2988
  }
2799
2989
  function evaluateMiseConditionals(template, agentHooksLayer) {
2800
2990
  const out = [];
@@ -2820,13 +3010,13 @@ function evaluateMiseConditionals(template, agentHooksLayer) {
2820
3010
  }
2821
3011
  function renderGeneratedProjectMiseToml(ctx, template) {
2822
3012
  const project = readProjectJson(ctx);
2823
- const projectName = String(project?.project_name ?? basename3(ctx.repoRoot) ?? "project");
3013
+ const projectName = String(project?.project_name ?? basename4(ctx.repoRoot) ?? "project");
2824
3014
  return evaluateMiseConditionals(template, resolveAgentHooksLayer2(ctx)).replace(/\{%\s*raw\s*%\}([\s\S]*?)\{%\s*endraw\s*%\}/g, "$1").replace(/\{\{\s*project_name\s*\}\}/g, projectName);
2825
3015
  }
2826
3016
  function ensureMiseTomlFromTemplate(ctx, changedFiles) {
2827
- const targetPath = join13(ctx.repoRoot, "mise.toml");
3017
+ const targetPath = join14(ctx.repoRoot, "mise.toml");
2828
3018
  if (existsSync10(targetPath)) return false;
2829
- const sourcePath = join13(ctx.pjanglerRoot, "templates", "commonproject", "template", "mise.toml.jinja");
3019
+ const sourcePath = join14(ctx.pjanglerRoot, "templates", "commonproject", "template", "mise.toml.jinja");
2830
3020
  if (!existsSync10(sourcePath)) return false;
2831
3021
  changedFiles.push(targetPath);
2832
3022
  if (!ctx.dryRun) {
@@ -2835,24 +3025,426 @@ function ensureMiseTomlFromTemplate(ctx, changedFiles) {
2835
3025
  return true;
2836
3026
  }
2837
3027
  function templateCommonProjectText(ctx, rel) {
2838
- const path = join13(ctx.pjanglerRoot, "templates", "commonproject", "template", rel);
3028
+ const path = join14(ctx.pjanglerRoot, "templates", "commonproject", "template", rel);
2839
3029
  return existsSync10(path) ? readText(path) : void 0;
2840
3030
  }
2841
- function canonicalSkillsManifest() {
3031
+ function validateSkillName(name) {
3032
+ if (!name || name === "." || name === ".." || name.includes("/") || name.includes("\\") || basename4(name) !== name) {
3033
+ throw new Error(`Unsafe skill name: ${JSON.stringify(name)}`);
3034
+ }
3035
+ return name;
3036
+ }
3037
+ function lstatIfPresent(path) {
3038
+ try {
3039
+ return lstatSync2(path);
3040
+ } catch (error) {
3041
+ if (error.code === "ENOENT") return void 0;
3042
+ throw error;
3043
+ }
3044
+ }
3045
+ function isContainedBy(root, target) {
3046
+ const rel = relative2(root, target);
3047
+ return rel === "" || rel !== ".." && !rel.startsWith("../") && !rel.startsWith("..\\");
3048
+ }
3049
+ function prepareSafeProjectSkillsDirs(ctx) {
3050
+ const projectRoot = realpathSync(ctx.repoRoot);
3051
+ const agentsDir = join14(projectRoot, ".agents");
3052
+ const skillsDir = join14(agentsDir, "skills");
3053
+ for (const path of [agentsDir, skillsDir]) {
3054
+ if (!isContainedBy(projectRoot, path)) throw new Error(`Project skills path escapes repository: ${path}`);
3055
+ const stat = lstatIfPresent(path);
3056
+ if (stat?.isSymbolicLink()) throw new Error(`Refusing symlinked project skills directory: ${path}`);
3057
+ if (stat && !stat.isDirectory()) throw new Error(`Project skills path is not a directory: ${path}`);
3058
+ }
3059
+ if (!ctx.dryRun) {
3060
+ if (!existsSync10(agentsDir)) mkdirSync6(agentsDir, { recursive: false });
3061
+ if (!existsSync10(skillsDir)) mkdirSync6(skillsDir, { recursive: false });
3062
+ for (const path of [agentsDir, skillsDir]) {
3063
+ if (lstatSync2(path).isSymbolicLink() || !lstatSync2(path).isDirectory()) {
3064
+ throw new Error(`Unsafe project skills directory after creation: ${path}`);
3065
+ }
3066
+ if (!isContainedBy(projectRoot, realpathSync(path))) {
3067
+ throw new Error(`Resolved project skills directory escapes repository: ${path}`);
3068
+ }
3069
+ }
3070
+ }
3071
+ return { agentsDir, skillsDir };
3072
+ }
3073
+ function projectSkillTopologyIssues(repoRoot) {
3074
+ const issues = [];
3075
+ let projectRoot;
3076
+ try {
3077
+ projectRoot = realpathSync(repoRoot);
3078
+ } catch (error) {
3079
+ return [`Project root is not a readable real directory: ${error instanceof Error ? error.message : String(error)}`];
3080
+ }
3081
+ const managedSkills = join14(projectRoot, ".agents", "skills");
3082
+ for (const rel of PROJECT_CLI_SKILL_DIRS) {
3083
+ const cliDir = join14(projectRoot, rel);
3084
+ const parent = dirname6(cliDir);
3085
+ const parentStat = lstatIfPresent(parent);
3086
+ if (!parentStat) continue;
3087
+ if (parentStat.isSymbolicLink() || !parentStat.isDirectory()) {
3088
+ issues.push(`${rel} has an unsafe symlinked/non-directory parent`);
3089
+ continue;
3090
+ }
3091
+ if (!isContainedBy(projectRoot, realOrSelf(parent))) {
3092
+ issues.push(`${rel} parent resolves outside the project`);
3093
+ continue;
3094
+ }
3095
+ const stat = lstatIfPresent(cliDir);
3096
+ if (!stat) continue;
3097
+ if (stat.isSymbolicLink()) {
3098
+ let rawTarget = "";
3099
+ try {
3100
+ rawTarget = readlinkSync(cliDir);
3101
+ } catch {
3102
+ issues.push(`${rel} is an unreadable skills directory symlink`);
3103
+ continue;
3104
+ }
3105
+ if (rel !== ".claude/skills" || rawTarget !== CANONICAL_CLAUDE_SKILLS_ALIAS) {
3106
+ issues.push(`${rel} is an unsupported skills directory symlink`);
3107
+ continue;
3108
+ }
3109
+ const managedStat = lstatIfPresent(managedSkills);
3110
+ if (!managedStat || managedStat.isSymbolicLink() || !managedStat.isDirectory()) {
3111
+ issues.push(`${rel} canonical alias target .agents/skills is missing or unsafe`);
3112
+ continue;
3113
+ }
3114
+ try {
3115
+ if (realpathSync(cliDir) !== realpathSync(managedSkills)) {
3116
+ issues.push(`${rel} canonical alias resolves outside .agents/skills`);
3117
+ }
3118
+ } catch {
3119
+ issues.push(`${rel} canonical alias is broken`);
3120
+ }
3121
+ continue;
3122
+ }
3123
+ if (!stat.isDirectory()) {
3124
+ issues.push(`${rel} is not a directory`);
3125
+ continue;
3126
+ }
3127
+ if (!isContainedBy(projectRoot, realOrSelf(cliDir))) {
3128
+ issues.push(`${rel} resolves outside the project`);
3129
+ }
3130
+ }
3131
+ return issues;
3132
+ }
3133
+ function bmadPackRoot(ctx) {
3134
+ return resolve3(
3135
+ process.env.PJ_BMAD_PACK_ROOT?.trim() || join14(ctx.homeDir, "code", "skillex", "packs", "bmad", BMAD_PACK_VERSION)
3136
+ );
3137
+ }
3138
+ function canonicalBmadSkillEntries(ctx) {
3139
+ const root = bmadPackRoot(ctx);
3140
+ const trusted = validateTrustedBmadPack(root);
3141
+ return trusted.skillNames.map((name) => ({ name: validateSkillName(name), source: pathToFileURL(join14(root, name)).href }));
3142
+ }
3143
+ function skillManifestEntryName(entry) {
3144
+ if (typeof entry === "string") return entry;
3145
+ if (!entry || typeof entry !== "object") return void 0;
3146
+ const name = entry.name;
3147
+ return typeof name === "string" ? name : void 0;
3148
+ }
3149
+ function isPackManagedManifestEntry(entry, expectedNames, packRoot) {
3150
+ const name = skillManifestEntryName(entry);
3151
+ if (!name) return false;
3152
+ if (expectedNames.has(name)) return true;
3153
+ if (!entry || typeof entry !== "object") return false;
3154
+ const source = entry.source;
3155
+ if (typeof source !== "string" || !source.startsWith("file:")) return false;
3156
+ try {
3157
+ const sourcePath = resolve3(fileURLToPath3(source));
3158
+ return basename4(sourcePath) === name && isContainedBy(packRoot, sourcePath);
3159
+ } catch {
3160
+ return false;
3161
+ }
3162
+ }
3163
+ function canonicalSkillsManifest(ctx, current, packSkills = canonicalBmadSkillEntries(ctx)) {
3164
+ const existing = Array.isArray(current?.skills) ? current.skills : [];
3165
+ const expectedNames = new Set(packSkills.map((entry) => entry.name));
3166
+ const packRoot = bmadPackRoot(ctx);
2842
3167
  return `${JSON.stringify(
2843
3168
  {
3169
+ ...current ?? {},
2844
3170
  $schema: "https://raw.githubusercontent.com/skillex/schemas/main/skills.schema.json",
2845
3171
  inherit_global: true,
2846
3172
  registry: SKILLS_REGISTRY_URL,
2847
- skills: []
3173
+ skills: [
3174
+ ...existing.filter((entry) => !isPackManagedManifestEntry(entry, expectedNames, packRoot)),
3175
+ ...packSkills
3176
+ ]
2848
3177
  },
2849
3178
  null,
2850
3179
  2
2851
3180
  )}
2852
3181
  `;
2853
3182
  }
3183
+ function removeProjectEntry(path) {
3184
+ const stat = lstatIfPresent(path);
3185
+ if (!stat) return;
3186
+ if (stat.isDirectory() && !stat.isSymbolicLink()) {
3187
+ rmSync(path, { recursive: true, force: true });
3188
+ return;
3189
+ }
3190
+ try {
3191
+ unlinkSync3(path);
3192
+ } catch (error) {
3193
+ if (error.code !== "ENOENT") throw error;
3194
+ }
3195
+ }
3196
+ function normalizeExecutableTemplate(ctx, target, expected, changedFiles) {
3197
+ const stat = lstatIfPresent(target);
3198
+ if (stat && (!stat.isFile() || stat.isSymbolicLink())) {
3199
+ throw new Error(`Refusing non-regular managed executable target: ${target}`);
3200
+ }
3201
+ const contentChanged = !stat || safeReadText(target) !== expected;
3202
+ const modeChanged = !stat || (Number(stat.mode) & 73) === 0;
3203
+ if (!contentChanged && !modeChanged) return;
3204
+ if (!changedFiles.includes(target)) changedFiles.push(target);
3205
+ if (ctx.dryRun) return;
3206
+ if (contentChanged) {
3207
+ writeText(target, expected);
3208
+ }
3209
+ const beforeChmod = lstatIfPresent(target);
3210
+ if (!beforeChmod?.isFile() || beforeChmod.isSymbolicLink()) {
3211
+ throw new Error(`Refusing changed managed executable target: ${target}`);
3212
+ }
3213
+ chmodSync4(target, 493);
3214
+ }
3215
+ function atomicWriteBuffer(path, content, mode, temporary) {
3216
+ writeFileSync7(temporary, content, { flag: "wx" });
3217
+ chmodSync4(temporary, mode);
3218
+ renameSync2(temporary, path);
3219
+ }
3220
+ function provisionBmadSkills(ctx, preservedManifest, hooks = {}) {
3221
+ const packRoot = bmadPackRoot(ctx);
3222
+ let packSkills;
3223
+ try {
3224
+ packSkills = canonicalBmadSkillEntries(ctx);
3225
+ } catch (error) {
3226
+ return {
3227
+ ok: false,
3228
+ changedFiles: [],
3229
+ error: `BMAD Skillex pack ${BMAD_PACK_VERSION} is not trusted at ${packRoot}: ${error instanceof Error ? error.message : String(error)}`
3230
+ };
3231
+ }
3232
+ hooks.afterPreflight?.();
3233
+ const projectRoot = realpathSync(ctx.repoRoot);
3234
+ const agentsPath = join14(projectRoot, ".agents");
3235
+ const skillsPath = join14(agentsPath, "skills");
3236
+ const agentsExisted = Boolean(lstatIfPresent(agentsPath));
3237
+ const skillsExisted = Boolean(lstatIfPresent(skillsPath));
3238
+ let preflightDirs;
3239
+ try {
3240
+ preflightDirs = prepareSafeProjectSkillsDirs({ ...ctx, dryRun: true });
3241
+ } catch (error) {
3242
+ return { ok: false, changedFiles: [], error: error instanceof Error ? error.message : String(error) };
3243
+ }
3244
+ const manifestPath = join14(preflightDirs.agentsDir, "skills.json");
3245
+ const manifestStat = lstatIfPresent(manifestPath);
3246
+ if (manifestStat?.isSymbolicLink() || manifestStat && !manifestStat.isFile()) {
3247
+ return { ok: false, changedFiles: [], error: `Refusing unsafe skills manifest: ${manifestPath}` };
3248
+ }
3249
+ const manifestBytes = manifestStat ? readFileSync7(manifestPath) : null;
3250
+ const manifestMode = manifestStat ? Number(manifestStat.mode) & 511 : 420;
3251
+ let currentManifest = {};
3252
+ if (manifestBytes !== null) {
3253
+ try {
3254
+ const parsed = JSON.parse(manifestBytes.toString("utf8"));
3255
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("must contain a JSON object");
3256
+ currentManifest = parsed;
3257
+ if (currentManifest.skills !== void 0 && !Array.isArray(currentManifest.skills)) throw new Error("skills must be an array");
3258
+ } catch (error) {
3259
+ return { ok: false, changedFiles: [], error: `Invalid existing skills manifest ${manifestPath}: ${error instanceof Error ? error.message : String(error)}` };
3260
+ }
3261
+ }
3262
+ let safeDirs;
3263
+ try {
3264
+ safeDirs = prepareSafeProjectSkillsDirs(ctx);
3265
+ } catch (error) {
3266
+ return { ok: false, changedFiles: [], error: error instanceof Error ? error.message : String(error) };
3267
+ }
3268
+ const nextManifest = canonicalSkillsManifest(ctx, preservedManifest ?? currentManifest, packSkills);
3269
+ const skillsDir = safeDirs.skillsDir;
3270
+ const resolvedSkillsDir = ctx.dryRun && !existsSync10(skillsDir) ? skillsDir : realpathSync(skillsDir);
3271
+ const expected = new Map(packSkills.map((entry) => [entry.name, fileURLToPath3(entry.source)]));
3272
+ const expectedNames = new Set(expected.keys());
3273
+ const ownershipManifest = preservedManifest ?? currentManifest;
3274
+ const managedManifestNames = new Set(
3275
+ (Array.isArray(ownershipManifest.skills) ? ownershipManifest.skills : []).filter((entry) => isPackManagedManifestEntry(entry, expectedNames, packRoot)).map(skillManifestEntryName).filter((name) => Boolean(name))
3276
+ );
3277
+ const affected = /* @__PURE__ */ new Set();
3278
+ const staleManagedNames = /* @__PURE__ */ new Set();
3279
+ const originalCorrectLinks = /* @__PURE__ */ new Map();
3280
+ if (existsSync10(skillsDir)) {
3281
+ for (const name of readdirSync3(skillsDir)) {
3282
+ validateSkillName(name);
3283
+ if (dirname6(join14(resolvedSkillsDir, name)) !== resolvedSkillsDir) {
3284
+ return { ok: false, changedFiles: [], error: `BMAD skill path escapes project skills directory: ${name}` };
3285
+ }
3286
+ const entryPath = join14(skillsDir, name);
3287
+ let linkTargetsPack = false;
3288
+ try {
3289
+ linkTargetsPack = lstatSync2(entryPath).isSymbolicLink() && isContainedBy(packRoot, resolve3(dirname6(entryPath), readlinkSync(entryPath)));
3290
+ } catch {
3291
+ linkTargetsPack = false;
3292
+ }
3293
+ if (!expected.has(name) && !managedManifestNames.has(name) && !linkTargetsPack) continue;
3294
+ const target = expected.get(name);
3295
+ let correct = false;
3296
+ try {
3297
+ correct = Boolean(target) && lstatSync2(entryPath).isSymbolicLink() && resolve3(dirname6(entryPath), readlinkSync(entryPath)) === target;
3298
+ } catch {
3299
+ correct = false;
3300
+ }
3301
+ if (correct) originalCorrectLinks.set(name, readlinkSync(join14(skillsDir, name)));
3302
+ else {
3303
+ affected.add(name);
3304
+ if (!target) staleManagedNames.add(name);
3305
+ }
3306
+ }
3307
+ }
3308
+ for (const [name, target] of expected) {
3309
+ const link = join14(resolvedSkillsDir, validateSkillName(name));
3310
+ if (dirname6(link) !== resolvedSkillsDir) {
3311
+ return { ok: false, changedFiles: [], error: `BMAD skill path escapes project skills directory: ${name}` };
3312
+ }
3313
+ let correct = false;
3314
+ try {
3315
+ correct = lstatSync2(link).isSymbolicLink() && resolve3(dirname6(link), readlinkSync(link)) === target;
3316
+ } catch {
3317
+ correct = false;
3318
+ }
3319
+ if (!correct) affected.add(name);
3320
+ }
3321
+ const manifestChanged = manifestBytes?.toString("utf8") !== nextManifest;
3322
+ const changedFiles = [
3323
+ ...manifestChanged ? [manifestPath] : [],
3324
+ ...affected.size ? [skillsDir] : []
3325
+ ];
3326
+ if (ctx.dryRun || changedFiles.length === 0) {
3327
+ try {
3328
+ const postflight = validateTrustedBmadPack(packRoot);
3329
+ if (JSON.stringify(postflight.skillNames) !== JSON.stringify(packSkills.map((entry) => entry.name))) {
3330
+ throw new Error("BMAD pack inventory changed after preflight");
3331
+ }
3332
+ return { ok: true, changedFiles };
3333
+ } catch (error) {
3334
+ return { ok: false, changedFiles: [], error: error instanceof Error ? error.message : String(error) };
3335
+ }
3336
+ }
3337
+ const transaction = mkdtempSync(join14(safeDirs.agentsDir, ".bmad-transaction-"));
3338
+ const backup = join14(transaction, "entries");
3339
+ mkdirSync6(backup);
3340
+ const moved = [];
3341
+ const rollback = () => {
3342
+ const errors = [];
3343
+ try {
3344
+ for (const name of affected) {
3345
+ removeProjectEntry(join14(skillsDir, validateSkillName(name)));
3346
+ }
3347
+ for (const name of originalCorrectLinks.keys()) {
3348
+ removeProjectEntry(join14(skillsDir, validateSkillName(name)));
3349
+ }
3350
+ } catch (error) {
3351
+ errors.push(`remove applied projection: ${String(error)}`);
3352
+ }
3353
+ for (const name of [...moved].reverse()) {
3354
+ try {
3355
+ renameSync2(join14(backup, name), join14(skillsDir, name));
3356
+ } catch (error) {
3357
+ errors.push(`restore ${name}: ${String(error)}`);
3358
+ }
3359
+ }
3360
+ for (const [name, rawTarget] of originalCorrectLinks) {
3361
+ try {
3362
+ symlinkSync(rawTarget, join14(skillsDir, name), "dir");
3363
+ } catch (error) {
3364
+ errors.push(`restore ${name}: ${String(error)}`);
3365
+ }
3366
+ }
3367
+ try {
3368
+ if (manifestBytes === null) removeProjectEntry(manifestPath);
3369
+ else atomicWriteBuffer(manifestPath, manifestBytes, manifestMode, join14(transaction, "manifest.restore"));
3370
+ } catch (error) {
3371
+ errors.push(`restore manifest: ${String(error)}`);
3372
+ }
3373
+ rmSync(transaction, { recursive: true, force: true });
3374
+ try {
3375
+ if (!skillsExisted && existsSync10(skillsDir) && readdirSync3(skillsDir).length === 0) rmdirSync(skillsDir);
3376
+ if (!agentsExisted && existsSync10(safeDirs.agentsDir) && readdirSync3(safeDirs.agentsDir).length === 0) rmdirSync(safeDirs.agentsDir);
3377
+ } catch (error) {
3378
+ errors.push(`remove created directories: ${String(error)}`);
3379
+ }
3380
+ if (errors.length) throw new Error(`BMAD rollback was incomplete: ${errors.join("; ")}`);
3381
+ };
3382
+ try {
3383
+ for (const name of affected) {
3384
+ const entry = join14(skillsDir, name);
3385
+ if (lstatIfPresent(entry)) {
3386
+ renameSync2(entry, join14(backup, name));
3387
+ moved.push(name);
3388
+ }
3389
+ }
3390
+ let index = 0;
3391
+ for (const [name, target] of expected) {
3392
+ index += 1;
3393
+ const link = join14(skillsDir, name);
3394
+ let correct = false;
3395
+ try {
3396
+ correct = lstatSync2(link).isSymbolicLink() && resolve3(skillsDir, readlinkSync(link)) === target;
3397
+ } catch {
3398
+ correct = false;
3399
+ }
3400
+ if (correct) continue;
3401
+ if (hooks.createLink) hooks.createLink(target, link, index);
3402
+ else symlinkSync(target, link, "dir");
3403
+ }
3404
+ if (manifestChanged) {
3405
+ atomicWriteBuffer(manifestPath, Buffer.from(nextManifest), manifestMode, join14(transaction, "manifest.next"));
3406
+ }
3407
+ const postflight = validateTrustedBmadPack(packRoot);
3408
+ if (JSON.stringify(postflight.skillNames) !== JSON.stringify(packSkills.map((entry) => entry.name))) {
3409
+ throw new Error("BMAD pack inventory changed after preflight");
3410
+ }
3411
+ hooks.afterApply?.(manifestPath, skillsDir);
3412
+ for (const name of staleManagedNames) {
3413
+ if (lstatIfPresent(join14(skillsDir, name))) {
3414
+ throw new Error(`Applied BMAD projection retained stale managed entry: ${name}`);
3415
+ }
3416
+ }
3417
+ for (const [name, target] of expected) {
3418
+ const link = join14(skillsDir, name);
3419
+ let correct = false;
3420
+ try {
3421
+ correct = lstatSync2(link).isSymbolicLink() && resolve3(skillsDir, readlinkSync(link)) === target;
3422
+ } catch {
3423
+ correct = false;
3424
+ }
3425
+ if (!correct) throw new Error(`Applied BMAD projection link differs from plan: ${name}`);
3426
+ }
3427
+ const finalManifestStat = lstatIfPresent(manifestPath);
3428
+ if (!finalManifestStat || finalManifestStat.isSymbolicLink() || !finalManifestStat.isFile() || (Number(finalManifestStat.mode) & 511) !== manifestMode || readFileSync7(manifestPath).toString("utf8") !== nextManifest) {
3429
+ throw new Error("Applied BMAD skills manifest differs from planned bytes or mode");
3430
+ }
3431
+ const finalManifest = JSON.parse(readFileSync7(manifestPath, "utf8"));
3432
+ if (finalManifest.$schema !== "https://raw.githubusercontent.com/skillex/schemas/main/skills.schema.json" || finalManifest.inherit_global !== true || finalManifest.registry !== SKILLS_REGISTRY_URL || !Array.isArray(finalManifest.skills)) {
3433
+ throw new Error("Applied BMAD skills manifest schema differs from plan");
3434
+ }
3435
+ } catch (error) {
3436
+ try {
3437
+ rollback();
3438
+ } catch (rollbackError) {
3439
+ return { ok: false, changedFiles: [], error: `BMAD provisioning failed (${String(error)}); ${String(rollbackError)}` };
3440
+ }
3441
+ return { ok: false, changedFiles: [], error: error instanceof Error ? error.message : String(error) };
3442
+ }
3443
+ rmSync(transaction, { recursive: true });
3444
+ return { ok: true, changedFiles };
3445
+ }
2854
3446
  function templateVersionFilesConf(ctx, repoRoot) {
2855
- const packageJson = join13(repoRoot, "package.json");
3447
+ const packageJson = join14(repoRoot, "package.json");
2856
3448
  return existsSync10(packageJson) ? "# mise-versioning manifest: <type> <path>\n# types: json toml cargo csproj gradle plain gittag\njson package.json\ngittag .\n" : "# mise-versioning manifest: <type> <path>\n# types: json toml cargo csproj gradle plain gittag\ngittag .\n";
2857
3449
  }
2858
3450
  function replaceOrAppendManagedBlock(text3, startMarker, block, beforePattern) {
@@ -2877,7 +3469,7 @@ var CONDITIONAL_HERMES_PATHS = ["agents/hermes/pm/hermes", "agent/hermes/pm/herm
2877
3469
  function requiredMisePathEntries(ctx) {
2878
3470
  const required = [...BASE_MISE_PATH_ENTRIES];
2879
3471
  for (const candidate of CONDITIONAL_HERMES_PATHS) {
2880
- if (existsSync10(join13(ctx.repoRoot, candidate)) && !required.includes(candidate)) required.push(candidate);
3472
+ if (existsSync10(join14(ctx.repoRoot, candidate)) && !required.includes(candidate)) required.push(candidate);
2881
3473
  }
2882
3474
  return required;
2883
3475
  }
@@ -2979,6 +3571,9 @@ function isManagedHookEntry(value) {
2979
3571
  const trimmed = value.trim();
2980
3572
  if (trimmed === OP_INJECT_SCRIPT) return true;
2981
3573
  if (trimmed === SYNC_SKILLS_SCRIPT) return true;
3574
+ if (trimmed === PROVISION_BMAD_SKILLS_SCRIPT) return true;
3575
+ if (/sync-skills(?:\.py)?["']?\s+--scope project/.test(trimmed)) return true;
3576
+ if (/provision-bmad-skills\.py/.test(trimmed)) return true;
2982
3577
  if (/link-project-skills-to-clis\.sh'?\s*$/.test(trimmed)) return true;
2983
3578
  if (/unlink-project-skills-from-clis\.sh'?\s*$/.test(trimmed)) return true;
2984
3579
  return /link-agentfiles\.sh'?\s*$/.test(trimmed);
@@ -3084,6 +3679,7 @@ function upsertLinkAgentfilesBlock(text3, ctx) {
3084
3679
  const withPath = upsertMisePath(text3, requiredMisePathEntries(ctx));
3085
3680
  let cleaned = removeTomlSection(withPath, /^\[tasks\.link-agentfiles\]$/, /link-agentfiles/, { includePrecedingComments: false });
3086
3681
  cleaned = removeTomlSection(cleaned, /^\[tasks\.skills-sync\]$/, void 0, { includePrecedingComments: false });
3682
+ cleaned = removeTomlSection(cleaned, /^\[tasks\.skills-provision-bmad\]$/, void 0, { includePrecedingComments: false });
3087
3683
  cleaned = removeTomlSection(cleaned, /^\[tasks\.link-project-skills-to-clis\]$/, void 0, { includePrecedingComments: false });
3088
3684
  cleaned = removeTomlSection(cleaned, /^\[tasks\.unlink-project-skills-from-clis\]$/, void 0, { includePrecedingComments: false });
3089
3685
  cleaned = removeTomlSection(cleaned, /^\[tasks\.skills-relink\]$/, void 0, { includePrecedingComments: false });
@@ -3093,7 +3689,7 @@ function upsertLinkAgentfilesBlock(text3, ctx) {
3093
3689
  return insertTomlBlockBeforeVersioning(cleaned, LINK_AGENTFILES_WATCH_TASK_BLOCK);
3094
3690
  }
3095
3691
  function readProjectJson(ctx) {
3096
- return tryParseJson(safeReadText(join13(ctx.repoRoot, ".project.json")));
3692
+ return tryParseJson(safeReadText(join14(ctx.repoRoot, ".project.json")));
3097
3693
  }
3098
3694
  function boolSetting(value, fallback) {
3099
3695
  if (typeof value === "boolean") return value;
@@ -3131,7 +3727,7 @@ function canonicalProjectJson(ctx) {
3131
3727
  role.agentId || `${slug}-${role.role}`,
3132
3728
  {
3133
3729
  role: role.role,
3134
- role_dir: relative(ctx.repoRoot, role.roleDir)
3730
+ role_dir: relative2(ctx.repoRoot, role.roleDir)
3135
3731
  }
3136
3732
  ])
3137
3733
  );
@@ -3168,8 +3764,8 @@ function canonicalProjectJson(ctx) {
3168
3764
  };
3169
3765
  }
3170
3766
  function projectJsonFinding(ctx) {
3171
- const projectPath = join13(ctx.repoRoot, ".project.json");
3172
- const planeJsonPath = join13(ctx.repoRoot, ".plane.json");
3767
+ const projectPath = join14(ctx.repoRoot, ".project.json");
3768
+ const planeJsonPath = join14(ctx.repoRoot, ".plane.json");
3173
3769
  const details = [];
3174
3770
  const data = readProjectJson(ctx);
3175
3771
  const roles = discoverRoles(ctx.repoRoot);
@@ -3191,8 +3787,8 @@ function projectJsonFinding(ctx) {
3191
3787
  continue;
3192
3788
  }
3193
3789
  if (agent.role !== role.role) details.push(`agents.${role.agentId}.role should be ${role.role}`);
3194
- if (agent.role_dir !== relative(ctx.repoRoot, role.roleDir)) {
3195
- details.push(`agents.${role.agentId}.role_dir should be ${relative(ctx.repoRoot, role.roleDir)}`);
3790
+ if (agent.role_dir !== relative2(ctx.repoRoot, role.roleDir)) {
3791
+ details.push(`agents.${role.agentId}.role_dir should be ${relative2(ctx.repoRoot, role.roleDir)}`);
3196
3792
  }
3197
3793
  }
3198
3794
  const ticketProvider = data.ticket_provider ?? {};
@@ -3221,8 +3817,7 @@ function projectJsonFinding(ctx) {
3221
3817
  function renderSoul(role) {
3222
3818
  const telegram = role.botHandle ? `@${role.botHandle}` : "(unwired)";
3223
3819
  const tone = role.role === "pm" ? `Direct and brief. Decision-forward. No throat-clearing, no apologies, no "I'll help you with that" preambles.` : "Direct and brief.";
3224
- const roleSpecific = role.role === "pm" ? `You are the project manager. You triage incoming work, create or refine tickets, and delegate implementation. You do not ship product code. A systemd heartbeat checkpoints your runtime; when this repo opts into reconciliation (\`automation.reconcile.enabled\` in repo-root \`.project.json\`), the same heartbeat also runs your continuous board-reconciliation pass out-of-band (\`.scripts/sentinel.prompt.md\`, \`--source cron\`), kept separate from your interactive session memory.` : `You operate as the ${role.role} agent for this repo.`;
3225
- const runtimeOwner = role.runtimeOwner || "delorenj";
3820
+ const roleSpecific = role.role === "pm" ? `You are the project manager. You triage incoming work, create or refine tickets, and delegate implementation. You do not ship product code. A systemd heartbeat checks runtime health; when this repo opts into reconciliation (\`automation.reconcile.enabled\` in repo-root \`.project.json\`), the same heartbeat also runs your continuous board-reconciliation pass out-of-band (\`.scripts/sentinel.prompt.md\`, \`--source cron\`), kept separate from your interactive session memory.` : `You operate as the ${role.role} agent for this repo.`;
3226
3821
  return `# ${role.displayName || role.agentId}
3227
3822
 
3228
3823
  You are **${role.displayName || role.agentId}** \u2014 a Hermes agent provisioned to work inside the
@@ -3241,7 +3836,7 @@ You are **${role.displayName || role.agentId}** \u2014 a Hermes agent provisione
3241
3836
 
3242
3837
  ## Scope
3243
3838
 
3244
- You operate only within the working directory of \`${role.repo}\`. Your HERMES_HOME is the runtime submodule at \`./runtime/\` (repo \`${runtimeOwner}/${role.runtimeRepo}\`), which \`~/.hermes/profiles/${role.profileName || role.agentId}\` symlinks to (so \`--profile\` invocations resolve here too); Hermes loads its \`config.yaml\` directly. Secrets, SOUL, memories, skills, sessions, gateway state, and runtime files all live local to that runtime.
3839
+ You operate only within the working directory of \`${role.repo}\`. Your HERMES_HOME is the ignored local directory at \`./runtime/\`, which \`~/.hermes/profiles/${role.profileName || role.agentId}\` projects into (so \`--profile\` invocations resolve here too). Secrets, SOUL, memories, skills, sessions, gateway state, and runtime files stay local to that runtime and are never project gitlinks.
3245
3840
 
3246
3841
  ## Tone
3247
3842
 
@@ -3253,12 +3848,12 @@ ${roleSpecific}
3253
3848
 
3254
3849
  ## Memory hygiene
3255
3850
 
3256
- Your memory is the submodule at \`./runtime/memories/\`. Use durable memory deliberately and keep \`memories/MEMORY.md\` current.
3851
+ Your memory is stored locally at \`./runtime/memories/\`. Use durable memory deliberately and keep \`memories/MEMORY.md\` current.
3257
3852
  `;
3258
3853
  }
3259
3854
  function renderHermesWrapper(role) {
3260
3855
  return `#!/usr/bin/env bash
3261
- # Launcher for ${role.agentId}. Resolves HERMES_HOME to the runtime submodule.
3856
+ # Launcher for ${role.agentId}. Resolves HERMES_HOME to the local runtime.
3262
3857
 
3263
3858
  set -euo pipefail
3264
3859
 
@@ -3271,30 +3866,44 @@ if [[ -f "$FLEET_ENV" ]]; then
3271
3866
  source "$FLEET_ENV"
3272
3867
  fi
3273
3868
 
3274
- HERMES_BIN="{HERMES_BIN:-{HERMES_FLEET_BIN:-/home/delorenj/code/hermes-agent/.venv/bin/hermes}}"
3275
- HERMES_OAUTH_FILE="{HERMES_OAUTH_FILE:-{HERMES_FLEET_OAUTH_FILE:-$HOME/.hermes/auth.json}}"
3869
+ HERMES_BIN="{HERMES_BIN:-{HERMES_FLEET_BIN:-$HOME/.hermes/hermes-agent/.venv/bin/hermes}}"
3276
3870
  CODEX_HOME="{CODEX_HOME:-{HERMES_FLEET_CODEX_HOME:-$HOME/.codex}}"
3277
3871
 
3278
3872
  FLEET_HOME="{HERMES_FLEET_HOME:-$HOME/.hermes}"
3279
3873
  PROFILE_NAME="{HERMES_PROFILE_NAME:-${role.profileName || role.agentId}}"
3280
- HERMES_HOME="$RUNTIME_HOME"
3874
+
3875
+ # Singleton-runtime contract: HERMES_HOME MUST be the named profile dir, never
3876
+ # the raw runtime path. Hermes treats any HERMES_HOME that is neither under
3877
+ # ~/.hermes nor a child of a "profiles" dir as its own standalone root, which
3878
+ # makes get_active_profile_name() report "default" and _global_auth_file_path()
3879
+ # return None -- silently disabling shared fleet auth and giving every agent a
3880
+ # divergent config.yaml. The profile dir is a REAL dir whose shared entries
3881
+ # (config.yaml, .env, skills) symlink to the fleet root and whose person-owned
3882
+ # entries (memories, sessions, state.db, workspace) symlink into $RUNTIME_HOME.
3883
+ HERMES_HOME="$FLEET_HOME/profiles/$PROFILE_NAME"
3281
3884
 
3282
3885
  if [[ ! -d "$RUNTIME_HOME" ]]; then
3283
- echo "hermes: runtime submodule not initialized at $RUNTIME_HOME" >&2
3284
- echo " fix: git submodule update --init --recursive" >&2
3886
+ echo "hermes: local runtime not provisioned at $RUNTIME_HOME" >&2
3887
+ echo " fix: run the role's .scripts/20-runtime-repo.sh" >&2
3888
+ exit 1
3889
+ fi
3890
+
3891
+ if [[ ! -d "$HERMES_HOME" ]]; then
3892
+ echo "hermes: profile not provisioned at $HERMES_HOME" >&2
3893
+ echo " fix: pj migrate hermes.runtime-singleton" >&2
3285
3894
  exit 1
3286
3895
  fi
3287
3896
 
3288
- exec env HERMES_HOME="$HERMES_HOME" HERMES_FLEET_ENV="$FLEET_ENV" HERMES_OAUTH_FILE="$HERMES_OAUTH_FILE" CODEX_HOME="$CODEX_HOME" "$HERMES_BIN" "$@"
3897
+ exec env HERMES_HOME="$HERMES_HOME" HERMES_FLEET_ENV="$FLEET_ENV" CODEX_HOME="$CODEX_HOME" "$HERMES_BIN" "$@"
3289
3898
  `.replace(/\u0010/g, "$");
3290
3899
  }
3291
3900
  function copyMissingRecursive(sourceDir, targetDir, changedFiles, dryRun, skip) {
3292
3901
  if (!existsSync10(sourceDir)) return;
3293
3902
  mkdirSync6(targetDir, { recursive: true });
3294
- for (const entry of readdirSync2(sourceDir, { withFileTypes: true })) {
3295
- const sourcePath = join13(sourceDir, entry.name);
3903
+ for (const entry of readdirSync3(sourceDir, { withFileTypes: true })) {
3904
+ const sourcePath = join14(sourceDir, entry.name);
3296
3905
  if (skip?.(sourcePath)) continue;
3297
- const targetPath = join13(targetDir, entry.name);
3906
+ const targetPath = join14(targetDir, entry.name);
3298
3907
  if (entry.isDirectory()) {
3299
3908
  copyMissingRecursive(sourcePath, targetPath, changedFiles, dryRun, skip);
3300
3909
  continue;
@@ -3307,21 +3916,78 @@ function copyMissingRecursive(sourceDir, targetDir, changedFiles, dryRun, skip)
3307
3916
  }
3308
3917
  }
3309
3918
  }
3310
- function upsertSubmodule(repoRoot, role, changedFiles, dryRun) {
3311
- const gitmodulesPath = join13(repoRoot, ".gitmodules");
3312
- const repoName = role.runtimeRepo || `agent-hm-${role.repo}-${role.role}`;
3313
- const owner = role.runtimeOwner || "delorenj";
3314
- const block = `[submodule "agents/hermes/${role.role}/runtime"]
3315
- path = agents/hermes/${role.role}/runtime
3316
- url = git@github.com:${owner}/${repoName}.git
3317
- `;
3919
+ function runtimeSubmodulePath(repoRoot, role) {
3920
+ const rolePath = relative2(repoRoot, role.roleDir).replace(/\\/g, "/");
3921
+ if (!/^agents\/hermes\/[^/]+$/.test(rolePath)) return null;
3922
+ return `${rolePath}/runtime`;
3923
+ }
3924
+ function submoduleSectionHasPath(section, targetPath) {
3925
+ return section.split(/\r?\n/).some((line) => /^\s*path\s*=/.test(line) && line.replace(/^\s*path\s*=\s*/, "").trim() === targetPath);
3926
+ }
3927
+ function hasRuntimeSubmoduleMapping(repoRoot, role) {
3928
+ const gitmodulesPath = join14(repoRoot, ".gitmodules");
3318
3929
  const current = safeReadText(gitmodulesPath) ?? "";
3319
- const header = `[submodule "agents/hermes/${role.role}/runtime"]`;
3320
- if (current.includes(header)) return [];
3930
+ const sections = current.match(/^\[submodule "[^"\n]+"\][\s\S]*?(?=^\[submodule "|(?![\s\S]))/gm) ?? [];
3931
+ const targetPath = runtimeSubmodulePath(repoRoot, role);
3932
+ return Boolean(targetPath && sections.some((section) => submoduleSectionHasPath(section, targetPath)));
3933
+ }
3934
+ function removeRuntimeSubmoduleMapping(repoRoot, role, changedFiles, dryRun) {
3935
+ const gitmodulesPath = join14(repoRoot, ".gitmodules");
3936
+ const current = safeReadText(gitmodulesPath) ?? "";
3937
+ if (!hasRuntimeSubmoduleMapping(repoRoot, role)) return [];
3938
+ const targetPath = runtimeSubmodulePath(repoRoot, role);
3939
+ if (!targetPath) return [];
3940
+ const next = current.replace(/^\[submodule "[^"\n]+"\][\s\S]*?(?=^\[submodule "|(?![\s\S]))/gm, (section) => submoduleSectionHasPath(section, targetPath) ? "" : section).replace(/\n{3,}/g, "\n\n").trim();
3321
3941
  changedFiles.push(gitmodulesPath);
3322
- if (!dryRun) writeText(gitmodulesPath, `${current.replace(/\s*$/, "")}${current.trim() ? "\n" : ""}${block}`);
3942
+ if (!dryRun) writeText(gitmodulesPath, next ? `${next}
3943
+ ` : "");
3323
3944
  return [gitmodulesPath];
3324
3945
  }
3946
+ function retireRuntimeSubmodule(repoRoot, role, changedFiles, dryRun) {
3947
+ const runtimePath = runtimeSubmodulePath(repoRoot, role);
3948
+ if (!runtimePath) {
3949
+ return { ok: false, details: [], error: `refusing unsafe runtime path for ${role.roleDir}` };
3950
+ }
3951
+ const probe = spawnSync6("git", ["ls-files", "--stage", "--", runtimePath], {
3952
+ cwd: repoRoot,
3953
+ encoding: "utf8"
3954
+ });
3955
+ if (probe.status !== 0) {
3956
+ return { ok: false, details: [], error: `failed to inspect runtime index at ${runtimePath}: ${probe.stderr.trim() || `exit ${probe.status}`}` };
3957
+ }
3958
+ const details = [];
3959
+ if (probe.stdout.trim()) {
3960
+ details.push(`untrack ${runtimePath}`);
3961
+ if (dryRun) {
3962
+ changedFiles.push(runtimePath);
3963
+ } else {
3964
+ const removal = spawnSync6("git", ["rm", "--cached", "-r", "-f", "--", runtimePath], {
3965
+ cwd: repoRoot,
3966
+ encoding: "utf8"
3967
+ });
3968
+ if (removal.status !== 0) {
3969
+ return { ok: false, details, error: `failed to untrack ${runtimePath}: ${removal.stderr.trim() || `exit ${removal.status}`}` };
3970
+ }
3971
+ const verification = spawnSync6("git", ["ls-files", "--stage", "--", runtimePath], {
3972
+ cwd: repoRoot,
3973
+ encoding: "utf8"
3974
+ });
3975
+ if (verification.status !== 0 || verification.stdout.trim()) {
3976
+ return {
3977
+ ok: false,
3978
+ details,
3979
+ error: verification.status !== 0 ? `failed to verify untracked runtime ${runtimePath}: ${verification.stderr.trim() || `exit ${verification.status}`}` : `runtime remains tracked after index-only removal: ${runtimePath}`
3980
+ };
3981
+ }
3982
+ changedFiles.push(runtimePath);
3983
+ }
3984
+ }
3985
+ if (hasRuntimeSubmoduleMapping(repoRoot, role)) {
3986
+ details.push(`remove stale .gitmodules mapping for ${runtimePath}`);
3987
+ removeRuntimeSubmoduleMapping(repoRoot, role, changedFiles, dryRun);
3988
+ }
3989
+ return { ok: true, details };
3990
+ }
3325
3991
  function upsertRegistryEntry(role, homeDir, changedFiles, dryRun) {
3326
3992
  const path = registryPath(homeDir);
3327
3993
  const current = safeReadText(path) ?? "# Hermes agent fleet registry.\n# One entry per provisioned agent. Managed by hermes-agent-template/.scripts/80-registry.sh.\nschema_version: 1\nagents: {}\n";
@@ -3340,9 +4006,11 @@ function upsertRegistryEntry(role, homeDir, changedFiles, dryRun) {
3340
4006
  project_id: ${ctxEscape(role.ticketProviderBoardId)}
3341
4007
  identifier: ${ctxEscape(role.ticketProviderIdentifier)}
3342
4008
  runtime_repo: ${ctxEscape(role.runtimeRepo)}
4009
+ bloodbank:
4010
+ gateway_scope: fleet
4011
+ target_agent_id: ${role.agentId}
3343
4012
  systemd:
3344
4013
  gateway_unit: hermes-${role.agentId}-gateway.service
3345
- consumer_unit: hermes-${role.agentId}-consumer.service
3346
4014
  heartbeat_timer: hermes-${role.agentId}-heartbeat.timer
3347
4015
  `;
3348
4016
  const next = current.includes("agents: {}") ? current.replace("agents: {}", `agents:
@@ -3406,6 +4074,7 @@ function checkUnit(unit) {
3406
4074
  var BMAD_NPM_PACKAGE = "bmad-method";
3407
4075
  var BMAD_TARGET_CHANNEL = "next";
3408
4076
  var BMAD_DIST_TAGS_TTL_MS = 60 * 60 * 1e3;
4077
+ var DEFAULT_BMAD_MODULES = ["bmm", "bmb", "cis"];
3409
4078
  var BMAD_INSTALL_TOOLS = [
3410
4079
  "claude-code",
3411
4080
  "codex",
@@ -3453,7 +4122,53 @@ var BMAD_INSTALL_TOOLS = [
3453
4122
  "windsurf",
3454
4123
  "zencoder"
3455
4124
  ];
3456
- function bmadInstallArgs(repoRoot) {
4125
+ function manifestBmadModules(repoRoot) {
4126
+ const manifestPath = join14(repoRoot, "_bmad", "_config", "manifest.yaml");
4127
+ const raw = safeReadText(manifestPath);
4128
+ if (raw === null) return { status: "absent" };
4129
+ try {
4130
+ const parsed = YAML3.parse(raw);
4131
+ if (!Array.isArray(parsed?.modules)) {
4132
+ return { status: "invalid", error: `${manifestPath} must define a modules array` };
4133
+ }
4134
+ const declared = [];
4135
+ for (const entry of parsed.modules) {
4136
+ const name = typeof entry === "string" ? entry : entry && typeof entry === "object" ? entry.name : void 0;
4137
+ if (typeof name !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(name)) {
4138
+ return { status: "invalid", error: `${manifestPath} contains an invalid module entry` };
4139
+ }
4140
+ if (name !== "core" && name !== "custom") declared.push(name);
4141
+ }
4142
+ return { status: "valid", modules: Array.from(new Set(declared)) };
4143
+ } catch (error) {
4144
+ return {
4145
+ status: "invalid",
4146
+ error: `Could not parse ${manifestPath}: ${error instanceof Error ? error.message : String(error)}`
4147
+ };
4148
+ }
4149
+ }
4150
+ function configuredBmadModules(repoRoot) {
4151
+ const raw = safeReadText(join14(repoRoot, "_bmad", "config.toml"));
4152
+ if (raw === null) return void 0;
4153
+ const modules = [...raw.matchAll(/^\[modules\.([A-Za-z0-9][A-Za-z0-9_-]*)\]\s*$/gm)].map((match) => match[1]);
4154
+ return Array.from(new Set(modules));
4155
+ }
4156
+ function selectedBmadModules(repoRoot) {
4157
+ const manifest = manifestBmadModules(repoRoot);
4158
+ if (manifest.status === "valid") return manifest.modules;
4159
+ if (manifest.status === "invalid") throw new Error(manifest.error);
4160
+ return configuredBmadModules(repoRoot) ?? [...DEFAULT_BMAD_MODULES];
4161
+ }
4162
+ function requiredBmadSentinels(repoRoot, modules = selectedBmadModules(repoRoot)) {
4163
+ return [
4164
+ join14("core", "config.yaml"),
4165
+ join14("config.toml"),
4166
+ join14("_config", "manifest.yaml"),
4167
+ ...modules.map((module) => join14(module, "config.yaml"))
4168
+ ];
4169
+ }
4170
+ function bmadInstallArgs(repoRoot, modules = selectedBmadModules(repoRoot)) {
4171
+ const installerModules = modules.length ? modules.join(",") : "core";
3457
4172
  return [
3458
4173
  "-y",
3459
4174
  `${BMAD_NPM_PACKAGE}@${BMAD_TARGET_CHANNEL}`,
@@ -3462,20 +4177,20 @@ function bmadInstallArgs(repoRoot) {
3462
4177
  "--directory",
3463
4178
  repoRoot,
3464
4179
  "--modules",
3465
- "bmm,bmb,cis",
4180
+ installerModules,
3466
4181
  "--tools",
3467
4182
  BMAD_INSTALL_TOOLS.join(",")
3468
4183
  ];
3469
4184
  }
3470
- function runBmadInstall(repoRoot) {
3471
- const result = spawnSync6("npx", bmadInstallArgs(repoRoot), { encoding: "utf8" });
4185
+ function runBmadInstall(repoRoot, modules = selectedBmadModules(repoRoot)) {
4186
+ const result = spawnSync6("npx", bmadInstallArgs(repoRoot, modules), { encoding: "utf8" });
3472
4187
  if (result.status !== 0) {
3473
4188
  return { ok: false, error: result.stderr || result.error?.message || "Unknown error" };
3474
4189
  }
3475
4190
  return { ok: true };
3476
4191
  }
3477
4192
  function readInstalledBmadVersion(repoRoot) {
3478
- const raw = safeReadText(join13(repoRoot, "_bmad", "_config", "manifest.yaml"));
4193
+ const raw = safeReadText(join14(repoRoot, "_bmad", "_config", "manifest.yaml"));
3479
4194
  if (!raw) return void 0;
3480
4195
  try {
3481
4196
  const parsed = YAML3.parse(raw);
@@ -3486,8 +4201,8 @@ function readInstalledBmadVersion(repoRoot) {
3486
4201
  }
3487
4202
  }
3488
4203
  function bmadCachePath(homeDir) {
3489
- const cacheRoot = process.env.XDG_CACHE_HOME?.trim() || join13(homeDir, ".cache");
3490
- return join13(cacheRoot, "pjangler", "bmad-dist-tags.json");
4204
+ const cacheRoot = process.env.XDG_CACHE_HOME?.trim() || join14(homeDir, ".cache");
4205
+ return join14(cacheRoot, "pjangler", "bmad-dist-tags.json");
3491
4206
  }
3492
4207
  function readBmadDistTagsCache(homeDir) {
3493
4208
  const raw = safeReadText(bmadCachePath(homeDir));
@@ -3570,65 +4285,279 @@ function compareBmadVersions(a, b) {
3570
4285
  }
3571
4286
  return 0;
3572
4287
  }
3573
- var RULES = [
3574
- {
3575
- id: "mise.config-root",
3576
- title: "mise config_root + AGENTS link hooks",
3577
- audit: (ctx) => {
3578
- const misePath = join13(ctx.repoRoot, "mise.toml");
3579
- if (!existsSync10(misePath)) {
3580
- return { id: "mise.config-root", title: "mise config_root + AGENTS link hooks", status: "fail", summary: "mise.toml missing", details: [], fixable: true };
3581
- }
3582
- const text3 = readText(misePath);
3583
- const details = [];
3584
- const linkAgentfilesPath = join13(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
3585
- if (!existsSync10(linkAgentfilesPath)) details.push(".mise/scripts/link-agentfiles.sh missing");
3586
- const pathValues = [...(text3.match(/^_\.path\s*=\s*\[([^\]]*)\]/m)?.[1] ?? "").matchAll(/"([^"]+)"/g)].map((match) => match[1]);
3587
- const missingPathValues = requiredMisePathEntries(ctx).filter((value) => !pathValues.includes(value));
3588
- if (missingPathValues.length) details.push(`[env]._.path should include ${missingPathValues.join(", ")}`);
3589
- if (!text3.includes("'{{config_root}}/.mise/scripts/link-agentfiles.sh'")) details.push("link-agentfiles hook must use single-quoted {{config_root}} guard");
3590
- if (!text3.includes("op inject -i .env.op > .env")) details.push("hooks.enter must materialize .env from .env.op");
3591
- if (!text3.includes('patterns = ["AGENTS.md"]')) details.push("watch_files must monitor AGENTS.md");
3592
- if (!text3.includes('task = "link-agentfiles"')) details.push("watch_files must dispatch link-agentfiles task");
3593
- return {
3594
- id: "mise.config-root",
3595
- title: "mise config_root + AGENTS link hooks",
3596
- status: details.length === 0 ? "pass" : "fail",
3597
- summary: details.length === 0 ? "mise AGENTS-linking parity verified" : `${details.length} issue(s) detected in mise AGENTS-linking contract`,
3598
- details,
3599
- fixable: true
3600
- };
3601
- },
3602
- migrate: (ctx, finding) => {
3603
- const path = join13(ctx.repoRoot, "mise.toml");
3604
- const changedFiles = [];
3605
- const details = [];
3606
- if (!existsSync10(path)) {
3607
- if (!ensureMiseTomlFromTemplate(ctx, changedFiles)) {
3608
- return { id: finding.id, title: finding.title, status: "blocked", summary: "mise.toml missing and no generated-project mise template available to initialize from", changedFiles, details: [] };
3609
- }
3610
- details.push("Initialized mise.toml from generated-project template");
3611
- if (ctx.dryRun) {
3612
- return { id: finding.id, title: finding.title, status: "applied", summary: "Would initialize mise.toml from generated-project template", changedFiles, details };
3613
- }
3614
- }
3615
- let text3 = readText(path);
3616
- const next = upsertLinkAgentfilesBlock(text3, ctx);
3617
- if (next !== text3) {
3618
- if (!changedFiles.includes(path)) changedFiles.push(path);
3619
- if (!ctx.dryRun) writeText(path, next);
3620
- text3 = next;
3621
- }
3622
- const linkAgentfilesPath = join13(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
3623
- const expectedScript = templateLinkAgentfilesScript(ctx);
3624
- if (expectedScript === void 0) {
3625
- return { id: finding.id, title: finding.title, status: "blocked", summary: "pjangler install is missing .mise/scripts/link-agentfiles.sh \u2014 update @delorenj/pjangler (broken package)", changedFiles, details: [] };
3626
- }
3627
- if (safeReadText(linkAgentfilesPath) !== expectedScript) {
3628
- changedFiles.push(linkAgentfilesPath);
3629
- if (!ctx.dryRun) {
3630
- writeText(linkAgentfilesPath, expectedScript);
3631
- chmodSync4(linkAgentfilesPath, 493);
4288
+ var SHARED_PROFILE_ENTRIES = ["config.yaml", ".env", "skills"];
4289
+ var OWNED_PROFILE_ENTRIES = [
4290
+ "memories",
4291
+ "sessions",
4292
+ "workspace",
4293
+ "logs",
4294
+ "cron",
4295
+ "plans",
4296
+ "hooks",
4297
+ "pairing",
4298
+ "audio_cache",
4299
+ "image_cache"
4300
+ ];
4301
+ var OWNED_PROFILE_FILES = ["SOUL.md", "state.db", "kanban.db"];
4302
+ function fleetHome(ctx) {
4303
+ return process.env.HERMES_FLEET_HOME || join14(ctx.homeDir, ".hermes");
4304
+ }
4305
+ function fleetBinPath(ctx) {
4306
+ const candidates = [
4307
+ process.env.HERMES_FLEET_BIN,
4308
+ join14(fleetHome(ctx), "hermes-agent", ".venv", "bin", "hermes"),
4309
+ join14(fleetHome(ctx), "hermes-agent", "venv", "bin", "hermes"),
4310
+ join14(ctx.homeDir, ".local", "bin", "hermes")
4311
+ ].filter(Boolean);
4312
+ return candidates.find((candidate) => existsSync10(candidate)) ?? "";
4313
+ }
4314
+ function singletonPlan(ctx, role) {
4315
+ const fleetRoot = fleetHome(ctx);
4316
+ const profileName = role.profileName || role.agentId;
4317
+ const profileDir = join14(fleetRoot, "profiles", profileName);
4318
+ const runtimeDir = join14(role.roleDir, "runtime");
4319
+ const links = [];
4320
+ for (const entry of SHARED_PROFILE_ENTRIES) {
4321
+ links.push({ path: join14(profileDir, entry), target: join14(fleetRoot, entry), ensureTargetDir: entry === "skills" });
4322
+ }
4323
+ for (const entry of OWNED_PROFILE_ENTRIES) {
4324
+ links.push({ path: join14(profileDir, entry), target: join14(runtimeDir, entry), ensureTargetDir: true });
4325
+ }
4326
+ for (const entry of OWNED_PROFILE_FILES) {
4327
+ links.push({ path: join14(profileDir, entry), target: join14(runtimeDir, entry), ensureTargetDir: false });
4328
+ }
4329
+ const sharedSeeds = ["config.yaml", "auth.json", ".env"].map((entry) => ({
4330
+ rootPath: join14(fleetRoot, entry),
4331
+ runtimePath: join14(runtimeDir, entry)
4332
+ }));
4333
+ return { fleetRoot, profileDir, runtimeDir, links, sharedSeeds };
4334
+ }
4335
+ function isDanglingLink(path) {
4336
+ try {
4337
+ return lstatSync2(path).isSymbolicLink() && !existsSync10(path);
4338
+ } catch {
4339
+ return false;
4340
+ }
4341
+ }
4342
+ function linkState(path, target) {
4343
+ let stat;
4344
+ try {
4345
+ stat = lstatSync2(path);
4346
+ } catch {
4347
+ return "missing";
4348
+ }
4349
+ if (!stat.isSymbolicLink()) return "not-a-symlink";
4350
+ try {
4351
+ return readlinkSync(path) === target ? "ok" : "wrong-target";
4352
+ } catch {
4353
+ return "wrong-target";
4354
+ }
4355
+ }
4356
+ function realOrSelf(path) {
4357
+ try {
4358
+ return realpathSync(path);
4359
+ } catch {
4360
+ return path;
4361
+ }
4362
+ }
4363
+ function profileUnits(role) {
4364
+ return [
4365
+ `hermes-${role.agentId}-gateway.service`,
4366
+ `hermes-${role.agentId}-heartbeat.service`,
4367
+ `hermes-${role.agentId}-heartbeat.timer`,
4368
+ `hermes-${role.agentId}-checkpoint.service`
4369
+ ];
4370
+ }
4371
+ function readRegistry(registryPath2) {
4372
+ const raw = safeReadText(registryPath2);
4373
+ if (raw === null) return null;
4374
+ try {
4375
+ const doc = YAML3.parse(raw);
4376
+ return doc?.agents ?? {};
4377
+ } catch {
4378
+ return null;
4379
+ }
4380
+ }
4381
+ function declaredAgentIds(repoRoot) {
4382
+ return declaredAgentEntries(repoRoot).map(([agentId]) => agentId);
4383
+ }
4384
+ function declaredAgentEntries(repoRoot) {
4385
+ const raw = safeReadText(join14(repoRoot, ".project.json"));
4386
+ if (raw === null) return [];
4387
+ try {
4388
+ const doc = JSON.parse(raw);
4389
+ return Object.entries(doc.agents ?? {}).map(([agentId, entry]) => [agentId, entry ?? {}]);
4390
+ } catch {
4391
+ return [];
4392
+ }
4393
+ }
4394
+ function ownedRegistryEntries(registry, repoRoot) {
4395
+ const want = realOrSelf(repoRoot);
4396
+ const owned = [];
4397
+ for (const [agentId, raw] of Object.entries(registry)) {
4398
+ const entry = raw ?? {};
4399
+ const roleDir = String(entry.role_dir ?? "");
4400
+ if (!roleDir) continue;
4401
+ if (realOrSelf(dirname6(dirname6(dirname6(roleDir)))) !== want) continue;
4402
+ owned.push([agentId, entry]);
4403
+ }
4404
+ return owned;
4405
+ }
4406
+ function unprovisionedRoleAgents(registry, repoRoot, canonical) {
4407
+ const blockers = /* @__PURE__ */ new Map();
4408
+ const record = (agentId, roleDir, source) => {
4409
+ const current = blockers.get(agentId) ?? { roleDir, sources: /* @__PURE__ */ new Set() };
4410
+ if (!current.roleDir && roleDir) current.roleDir = roleDir;
4411
+ current.sources.add(source);
4412
+ blockers.set(agentId, current);
4413
+ };
4414
+ for (const [agentId, entry] of ownedRegistryEntries(registry, repoRoot)) {
4415
+ if (canonical.has(agentId)) continue;
4416
+ const roleDir = String(entry.role_dir ?? "");
4417
+ if (!roleDir || !existsSync10(join14(roleDir, "role.yaml"))) record(agentId, roleDir, "registry");
4418
+ }
4419
+ for (const [agentId, entry] of declaredAgentEntries(repoRoot)) {
4420
+ if (canonical.has(agentId)) continue;
4421
+ const configured = String(entry.role_dir ?? "");
4422
+ const roleDir = configured ? resolve3(repoRoot, configured) : "";
4423
+ if (!roleDir || !existsSync10(join14(roleDir, "role.yaml"))) record(agentId, roleDir, ".project.json");
4424
+ }
4425
+ return [...blockers.entries()].map(([agentId, value]) => ({
4426
+ agentId,
4427
+ roleDir: value.roleDir,
4428
+ sources: [...value.sources]
4429
+ }));
4430
+ }
4431
+ function dropDeclaredAgent(ctx, agentId, changedFiles, details) {
4432
+ const path = join14(ctx.repoRoot, ".project.json");
4433
+ const raw = safeReadText(path);
4434
+ if (raw === null) return;
4435
+ let doc;
4436
+ try {
4437
+ doc = JSON.parse(raw);
4438
+ } catch {
4439
+ return;
4440
+ }
4441
+ if (!doc.agents || !(agentId in doc.agents)) return;
4442
+ delete doc.agents[agentId];
4443
+ details.push(`drop agent "${agentId}" from .project.json`);
4444
+ changedFiles.push(path);
4445
+ if (!ctx.dryRun) writeText(path, `${JSON.stringify(doc, null, 2)}
4446
+ `);
4447
+ }
4448
+ function rewriteLauncher(text3) {
4449
+ let next = text3.replace(/^HERMES_HOME="\$RUNTIME_HOME"\s*$/m, 'HERMES_HOME="$FLEET_HOME/profiles/$PROFILE_NAME"');
4450
+ next = next.replace(/^HERMES_OAUTH_FILE=.*\n/m, "");
4451
+ next = next.replace(/\s*HERMES_OAUTH_FILE="\$HERMES_OAUTH_FILE"/g, "");
4452
+ next = next.replace(
4453
+ /^.*\/home\/delorenj\/code\/hermes-agent\/\.venv\/bin\/hermes.*$/m,
4454
+ (line) => line.replace("/home/delorenj/code/hermes-agent/.venv/bin/hermes", "$HOME/.hermes/hermes-agent/.venv/bin/hermes")
4455
+ );
4456
+ return next;
4457
+ }
4458
+ function isValidOpReference(value) {
4459
+ if (!value.startsWith("op://") || /[\[\]{}<>]/.test(value)) return false;
4460
+ const withoutScheme = value.slice("op://".length);
4461
+ const fragmentIndex = withoutScheme.indexOf("#");
4462
+ if (fragmentIndex >= 0) return false;
4463
+ const queryIndex = withoutScheme.indexOf("?");
4464
+ const pathPart = queryIndex >= 0 ? withoutScheme.slice(0, queryIndex) : withoutScheme;
4465
+ const queryPart = queryIndex >= 0 ? withoutScheme.slice(queryIndex + 1) : "";
4466
+ const parts = pathPart.split("/");
4467
+ if (parts.length < 3 || parts.length > 4 || parts.some((part) => !part)) return false;
4468
+ try {
4469
+ for (const part of parts) decodeURIComponent(part);
4470
+ } catch {
4471
+ return false;
4472
+ }
4473
+ if (queryIndex >= 0 && !/^attribute=[A-Za-z0-9._~-]+$/.test(queryPart)) return false;
4474
+ return true;
4475
+ }
4476
+ function malformedOpReferences(text3) {
4477
+ const occurrences = [];
4478
+ const lines = text3.split("\n");
4479
+ for (let index = 0; index < lines.length; index++) {
4480
+ const line = lines[index];
4481
+ for (const match of line.matchAll(/op:\/\/[^\s"'`]+/g)) {
4482
+ const value = match[0];
4483
+ if (!isValidOpReference(value)) {
4484
+ occurrences.push({ line: index + 1, value, commentOnly: line.trimStart().startsWith("#") });
4485
+ }
4486
+ }
4487
+ }
4488
+ return occurrences;
4489
+ }
4490
+ function removeMalformedCommentOpReferences(text3) {
4491
+ let changed = false;
4492
+ const lines = text3.split("\n").map((line) => {
4493
+ if (!line.trimStart().startsWith("#")) return line;
4494
+ return line.replace(/op:\/\/[^\s"'`]+/g, (value) => {
4495
+ if (isValidOpReference(value)) return value;
4496
+ changed = true;
4497
+ return "<invalid 1Password reference removed by pjangler>";
4498
+ });
4499
+ });
4500
+ return { text: lines.join("\n"), changed };
4501
+ }
4502
+ var RULES = [
4503
+ {
4504
+ id: "mise.config-root",
4505
+ title: "mise config_root + AGENTS link hooks",
4506
+ audit: (ctx) => {
4507
+ const misePath = join14(ctx.repoRoot, "mise.toml");
4508
+ if (!existsSync10(misePath)) {
4509
+ return { id: "mise.config-root", title: "mise config_root + AGENTS link hooks", status: "fail", summary: "mise.toml missing", details: [], fixable: true };
4510
+ }
4511
+ const text3 = readText(misePath);
4512
+ const details = [];
4513
+ const linkAgentfilesPath = join14(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
4514
+ if (!existsSync10(linkAgentfilesPath)) details.push(".mise/scripts/link-agentfiles.sh missing");
4515
+ const pathValues = [...(text3.match(/^_\.path\s*=\s*\[([^\]]*)\]/m)?.[1] ?? "").matchAll(/"([^"]+)"/g)].map((match) => match[1]);
4516
+ const missingPathValues = requiredMisePathEntries(ctx).filter((value) => !pathValues.includes(value));
4517
+ if (missingPathValues.length) details.push(`[env]._.path should include ${missingPathValues.join(", ")}`);
4518
+ if (!text3.includes("'{{config_root}}/.mise/scripts/link-agentfiles.sh'")) details.push("link-agentfiles hook must use single-quoted {{config_root}} guard");
4519
+ if (!text3.includes("op inject -i .env.op > .env")) details.push("hooks.enter must materialize .env from .env.op");
4520
+ if (!text3.includes('patterns = ["AGENTS.md"]')) details.push("watch_files must monitor AGENTS.md");
4521
+ if (!text3.includes('task = "link-agentfiles"')) details.push("watch_files must dispatch link-agentfiles task");
4522
+ return {
4523
+ id: "mise.config-root",
4524
+ title: "mise config_root + AGENTS link hooks",
4525
+ status: details.length === 0 ? "pass" : "fail",
4526
+ summary: details.length === 0 ? "mise AGENTS-linking parity verified" : `${details.length} issue(s) detected in mise AGENTS-linking contract`,
4527
+ details,
4528
+ fixable: true
4529
+ };
4530
+ },
4531
+ migrate: (ctx, finding) => {
4532
+ const path = join14(ctx.repoRoot, "mise.toml");
4533
+ const changedFiles = [];
4534
+ const details = [];
4535
+ if (!existsSync10(path)) {
4536
+ if (!ensureMiseTomlFromTemplate(ctx, changedFiles)) {
4537
+ return { id: finding.id, title: finding.title, status: "blocked", summary: "mise.toml missing and no generated-project mise template available to initialize from", changedFiles, details: [] };
4538
+ }
4539
+ details.push("Initialized mise.toml from generated-project template");
4540
+ if (ctx.dryRun) {
4541
+ return { id: finding.id, title: finding.title, status: "applied", summary: "Would initialize mise.toml from generated-project template", changedFiles, details };
4542
+ }
4543
+ }
4544
+ let text3 = readText(path);
4545
+ const next = upsertLinkAgentfilesBlock(text3, ctx);
4546
+ if (next !== text3) {
4547
+ if (!changedFiles.includes(path)) changedFiles.push(path);
4548
+ if (!ctx.dryRun) writeText(path, next);
4549
+ text3 = next;
4550
+ }
4551
+ const linkAgentfilesPath = join14(ctx.repoRoot, ".mise", "scripts", "link-agentfiles.sh");
4552
+ const expectedScript = templateLinkAgentfilesScript(ctx);
4553
+ if (expectedScript === void 0) {
4554
+ return { id: finding.id, title: finding.title, status: "blocked", summary: "pjangler install is missing .mise/scripts/link-agentfiles.sh \u2014 update @delorenj/pjangler (broken package)", changedFiles, details: [] };
4555
+ }
4556
+ if (safeReadText(linkAgentfilesPath) !== expectedScript) {
4557
+ changedFiles.push(linkAgentfilesPath);
4558
+ if (!ctx.dryRun) {
4559
+ writeText(linkAgentfilesPath, expectedScript);
4560
+ chmodSync4(linkAgentfilesPath, 493);
3632
4561
  }
3633
4562
  }
3634
4563
  return {
@@ -3646,9 +4575,9 @@ var RULES = [
3646
4575
  title: "managed mise versioning block",
3647
4576
  audit: (ctx) => {
3648
4577
  const details = [];
3649
- const misePath = join13(ctx.repoRoot, "mise.toml");
3650
- const versioningPath = join13(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
3651
- const manifestPath = join13(ctx.repoRoot, ".mise", "version-files.conf");
4578
+ const misePath = join14(ctx.repoRoot, "mise.toml");
4579
+ const versioningPath = join14(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
4580
+ const manifestPath = join14(ctx.repoRoot, ".mise", "version-files.conf");
3652
4581
  const text3 = safeReadText(misePath);
3653
4582
  if (!text3?.includes("# >>> mise-versioning >>>")) details.push("mise versioning managed block missing");
3654
4583
  if (!existsSync10(versioningPath)) details.push(".mise/scripts/versioning.sh missing");
@@ -3665,7 +4594,7 @@ var RULES = [
3665
4594
  migrate: (ctx, finding) => {
3666
4595
  const changedFiles = [];
3667
4596
  const details = [];
3668
- const misePath = join13(ctx.repoRoot, "mise.toml");
4597
+ const misePath = join14(ctx.repoRoot, "mise.toml");
3669
4598
  if (!existsSync10(misePath)) {
3670
4599
  if (!ensureMiseTomlFromTemplate(ctx, changedFiles)) {
3671
4600
  return { id: finding.id, title: finding.title, status: "blocked", summary: "mise.toml missing and no generated-project mise template available to initialize from", changedFiles, details: [] };
@@ -3690,7 +4619,7 @@ var RULES = [
3690
4619
  if (!changedFiles.includes(misePath)) changedFiles.push(misePath);
3691
4620
  if (!ctx.dryRun) writeText(misePath, nextMise);
3692
4621
  }
3693
- const versioningPath = join13(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
4622
+ const versioningPath = join14(ctx.repoRoot, ".mise", "scripts", "versioning.sh");
3694
4623
  const expectedScript = templateVersioningScript(ctx);
3695
4624
  if (expectedScript === void 0) {
3696
4625
  return { id: finding.id, title: finding.title, status: "blocked", summary: "pjangler install is missing .mise/scripts/versioning.sh \u2014 update @delorenj/pjangler (broken package)", changedFiles, details: [] };
@@ -3702,7 +4631,7 @@ var RULES = [
3702
4631
  chmodSync4(versioningPath, 493);
3703
4632
  }
3704
4633
  }
3705
- const manifestPath = join13(ctx.repoRoot, ".mise", "version-files.conf");
4634
+ const manifestPath = join14(ctx.repoRoot, ".mise", "version-files.conf");
3706
4635
  const expectedManifest = templateVersionFilesConf(ctx, ctx.repoRoot);
3707
4636
  if (safeReadText(manifestPath) !== expectedManifest) {
3708
4637
  changedFiles.push(manifestPath);
@@ -3723,39 +4652,112 @@ var RULES = [
3723
4652
  title: "Skillex project skills manifest",
3724
4653
  audit: (ctx) => {
3725
4654
  const details = [];
3726
- const manifestPath = join13(ctx.repoRoot, ".agents", "skills.json");
3727
- const legacyDir = join13(ctx.repoRoot, ".agents", "skills");
3728
- const localExamplePath = join13(ctx.repoRoot, ".agents", "local.example.json");
3729
- const misePath = join13(ctx.repoRoot, "mise.toml");
4655
+ const manifestPath = join14(ctx.repoRoot, ".agents", "skills.json");
4656
+ const legacyDir = join14(ctx.repoRoot, ".agents", "skills");
4657
+ const localExamplePath = join14(ctx.repoRoot, ".agents", "local.example.json");
4658
+ const misePath = join14(ctx.repoRoot, "mise.toml");
3730
4659
  let fixable = true;
4660
+ let expectedBmad = [];
4661
+ try {
4662
+ expectedBmad = canonicalBmadSkillEntries(ctx);
4663
+ } catch (error) {
4664
+ details.push(
4665
+ `BMAD Skillex pack ${BMAD_PACK_VERSION} is not trusted at ${bmadPackRoot(ctx)}: ${error instanceof Error ? error.message : String(error)}`
4666
+ );
4667
+ fixable = false;
4668
+ }
4669
+ const expectedByName = new Map(expectedBmad.map((entry) => [entry.name, fileURLToPath3(entry.source)]));
4670
+ const expectedNames = new Set(expectedByName.keys());
4671
+ const packRoot = bmadPackRoot(ctx);
3731
4672
  const manifest = tryParseJson(safeReadText(manifestPath));
3732
4673
  if (!manifest) {
3733
4674
  details.push(".agents/skills.json missing or invalid JSON");
3734
4675
  } else {
3735
4676
  if (manifest.inherit_global !== true) details.push(".agents/skills.json should set inherit_global: true");
3736
4677
  if (manifest.registry !== SKILLS_REGISTRY_URL) details.push(`.agents/skills.json should set registry to ${SKILLS_REGISTRY_URL}`);
3737
- if (!Array.isArray(manifest.skills)) details.push(".agents/skills.json should define a skills array");
4678
+ if (!Array.isArray(manifest.skills)) {
4679
+ details.push(".agents/skills.json should define a skills array");
4680
+ } else {
4681
+ const actualBmad = new Map(
4682
+ manifest.skills.filter((entry) => Boolean(entry) && typeof entry === "object" && isPackManagedManifestEntry(entry, expectedNames, packRoot)).map((entry) => [String(entry.name), String(entry.source ?? "")])
4683
+ );
4684
+ const stale = expectedBmad.filter((entry) => actualBmad.get(entry.name) !== entry.source);
4685
+ if (stale.length > 0 || actualBmad.size !== expectedBmad.length) {
4686
+ details.push(`.agents/skills.json should record all ${expectedBmad.length} BMAD ${BMAD_PACK_VERSION} pack entries as file:// sources`);
4687
+ }
4688
+ }
3738
4689
  }
4690
+ const invalidBmadLinkNames = /* @__PURE__ */ new Set();
3739
4691
  if (existsSync10(legacyDir)) {
3740
- const entries = readdirSync2(legacyDir);
3741
- if (entries.length > 0) {
3742
- details.push(".agents/skills/ still contains legacy committed skills; map them into .agents/skills.json before migrating");
3743
- fixable = false;
3744
- } else {
3745
- details.push(".agents/skills/ legacy directory should be removed");
4692
+ for (const name of readdirSync3(legacyDir)) {
4693
+ const expected = expectedByName.get(name);
4694
+ const path = join14(legacyDir, name);
4695
+ let linkTargetsPack = false;
4696
+ try {
4697
+ linkTargetsPack = lstatSync2(path).isSymbolicLink() && isContainedBy(packRoot, resolve3(dirname6(path), readlinkSync(path)));
4698
+ } catch {
4699
+ linkTargetsPack = false;
4700
+ }
4701
+ if (!expected && !linkTargetsPack) continue;
4702
+ try {
4703
+ if (!expected || !lstatSync2(path).isSymbolicLink() || resolve3(dirname6(path), readlinkSync(path)) !== expected) invalidBmadLinkNames.add(name);
4704
+ } catch {
4705
+ invalidBmadLinkNames.add(name);
4706
+ }
3746
4707
  }
4708
+ for (const [name, expected] of expectedByName) {
4709
+ const path = join14(legacyDir, name);
4710
+ try {
4711
+ if (!lstatSync2(path).isSymbolicLink() || resolve3(dirname6(path), readlinkSync(path)) !== expected) invalidBmadLinkNames.add(name);
4712
+ } catch {
4713
+ invalidBmadLinkNames.add(name);
4714
+ }
4715
+ }
4716
+ } else {
4717
+ for (const name of expectedByName.keys()) invalidBmadLinkNames.add(name);
4718
+ }
4719
+ if (invalidBmadLinkNames.size > 0) {
4720
+ details.push(`${invalidBmadLinkNames.size} managed BMAD skill path(s) should be symlinks into the ${BMAD_PACK_VERSION} pack`);
3747
4721
  }
3748
4722
  for (const rel of [".mise/scripts/link-project-skills-to-clis.sh", ".mise/scripts/unlink-project-skills-from-clis.sh"]) {
3749
- if (existsSync10(join13(ctx.repoRoot, rel))) details.push(`${rel} is a legacy symlink-era script and should be removed`);
4723
+ if (existsSync10(join14(ctx.repoRoot, rel))) details.push(`${rel} is a legacy symlink-era script and should be removed`);
3750
4724
  }
3751
4725
  const localExample = tryParseJson(safeReadText(localExamplePath));
3752
4726
  if (localExample && Object.prototype.hasOwnProperty.call(localExample, "skills")) {
3753
4727
  details.push(".agents/local.example.json still documents legacy skills overrides; drop the skills section");
3754
4728
  }
3755
4729
  const mise = safeReadText(misePath);
3756
- if (!mise?.includes(SYNC_SKILLS_SCRIPT)) details.push("mise.toml should run sync-skills.py --scope project on enter");
4730
+ if (!mise?.includes(SYNC_SKILLS_SCRIPT)) details.push("mise.toml should run the shipped project-local sync-skills.py engine via config_root");
4731
+ if (!mise?.includes(PROVISION_BMAD_SKILLS_SCRIPT)) details.push("mise.toml should provision pinned BMAD pack links before syncing skills");
4732
+ if (mise?.includes(SYNC_SKILLS_SCRIPT) && mise.includes(PROVISION_BMAD_SKILLS_SCRIPT) && mise.indexOf(PROVISION_BMAD_SKILLS_SCRIPT) > mise.indexOf(SYNC_SKILLS_SCRIPT)) {
4733
+ details.push("mise.toml should run the BMAD provisioner before project skill sync");
4734
+ }
4735
+ if (mise?.includes('script = "sync-skills.py --scope project"') || mise?.includes('run = "sync-skills.py --scope project"')) {
4736
+ details.push("mise.toml still invokes the missing bare sync-skills.py executable");
4737
+ }
3757
4738
  if (!mise?.includes('patterns = [".agents/skills.json"]')) details.push("mise.toml should watch .agents/skills.json");
3758
4739
  if (!mise?.includes("[tasks.skills-sync]")) details.push("mise.toml should define a skills-sync task");
4740
+ if (!mise?.includes('depends = ["skills-provision-bmad"]')) details.push("skills-sync task should depend on skills-provision-bmad");
4741
+ for (const [rel, label] of [
4742
+ [".mise/scripts/provision-bmad-skills.py", "BMAD Skillex provisioning script"],
4743
+ [".mise/scripts/sync-skills.py", "Project-local skills sync engine"]
4744
+ ]) {
4745
+ const target = join14(ctx.repoRoot, rel);
4746
+ const expected = templateCommonProjectText(ctx, rel);
4747
+ const stat = lstatIfPresent(target);
4748
+ if (!stat || !stat.isFile() || stat.isSymbolicLink()) {
4749
+ details.push(`${label} is missing or unsafe`);
4750
+ if (stat) fixable = false;
4751
+ } else {
4752
+ if (expected === void 0 || safeReadText(target) !== expected) details.push(`${label} differs from the shipped template`);
4753
+ if ((Number(stat.mode) & 73) === 0) details.push(`${label} is not executable`);
4754
+ }
4755
+ }
4756
+ const topologyIssues = projectSkillTopologyIssues(ctx.repoRoot);
4757
+ if (topologyIssues.length) {
4758
+ details.push(...topologyIssues.map((issue) => `CLI skill topology: ${issue}`));
4759
+ fixable = false;
4760
+ }
3759
4761
  if (mise?.includes("link-project-skills-to-clis.sh") || mise?.includes("unlink-project-skills-from-clis.sh") || mise?.includes("[tasks.skills-relink]")) {
3760
4762
  details.push("mise.toml still contains legacy skill-link wiring");
3761
4763
  }
@@ -3771,33 +4773,66 @@ var RULES = [
3771
4773
  migrate: (ctx, finding) => {
3772
4774
  const changedFiles = [];
3773
4775
  const details = [];
3774
- const manifestPath = join13(ctx.repoRoot, ".agents", "skills.json");
3775
- const legacyDir = join13(ctx.repoRoot, ".agents", "skills");
3776
- const localExamplePath = join13(ctx.repoRoot, ".agents", "local.example.json");
3777
- const misePath = join13(ctx.repoRoot, "mise.toml");
3778
- if (existsSync10(legacyDir)) {
3779
- const entries = readdirSync2(legacyDir);
3780
- if (entries.length > 0) {
3781
- return {
3782
- id: finding.id,
3783
- title: finding.title,
3784
- status: "blocked",
3785
- summary: "Legacy .agents/skills/ still contains committed skills; migrate them into .agents/skills.json manually first",
3786
- changedFiles,
3787
- details: entries.map((entry) => `.agents/skills/${entry}`)
3788
- };
3789
- }
3790
- changedFiles.push(legacyDir);
3791
- if (!ctx.dryRun) rmSync(legacyDir, { recursive: true, force: true });
4776
+ const manifestPath = join14(ctx.repoRoot, ".agents", "skills.json");
4777
+ const localExamplePath = join14(ctx.repoRoot, ".agents", "local.example.json");
4778
+ const misePath = join14(ctx.repoRoot, "mise.toml");
4779
+ const provisionScriptPath = join14(ctx.repoRoot, ".mise", "scripts", "provision-bmad-skills.py");
4780
+ const syncScriptPath = join14(ctx.repoRoot, ".mise", "scripts", "sync-skills.py");
4781
+ const expectedProvisionScript = templateCommonProjectText(ctx, ".mise/scripts/provision-bmad-skills.py");
4782
+ const expectedSyncScript = templateCommonProjectText(ctx, ".mise/scripts/sync-skills.py");
4783
+ const topologyIssues = projectSkillTopologyIssues(ctx.repoRoot);
4784
+ if (topologyIssues.length) {
4785
+ return {
4786
+ id: finding.id,
4787
+ title: finding.title,
4788
+ status: "blocked",
4789
+ summary: "Unsafe project CLI skill topology must be repaired manually",
4790
+ changedFiles,
4791
+ details: topologyIssues
4792
+ };
3792
4793
  }
3793
- const expectedManifest = canonicalSkillsManifest();
3794
- if (safeReadText(manifestPath) !== expectedManifest) {
3795
- changedFiles.push(manifestPath);
3796
- if (!ctx.dryRun) writeText(manifestPath, expectedManifest);
3797
- details.push("Wrote canonical .agents/skills.json manifest");
4794
+ if (!expectedProvisionScript || !expectedSyncScript) {
4795
+ return {
4796
+ id: finding.id,
4797
+ title: finding.title,
4798
+ status: "blocked",
4799
+ summary: "pjangler install is missing a shipped skills executable",
4800
+ changedFiles,
4801
+ details: [
4802
+ ...!expectedProvisionScript ? ["Missing BMAD Skillex provisioning script template"] : [],
4803
+ ...!expectedSyncScript ? ["Missing project-local skills sync engine template"] : []
4804
+ ]
4805
+ };
4806
+ }
4807
+ const unsafeScriptTargets = [provisionScriptPath, syncScriptPath].filter((path) => {
4808
+ const stat = lstatIfPresent(path);
4809
+ return Boolean(stat && (!stat.isFile() || stat.isSymbolicLink()));
4810
+ });
4811
+ if (unsafeScriptTargets.length) {
4812
+ return {
4813
+ id: finding.id,
4814
+ title: finding.title,
4815
+ status: "blocked",
4816
+ summary: "Refusing non-regular managed skills executable target",
4817
+ changedFiles,
4818
+ details: unsafeScriptTargets.map((path) => `${path} must be removed or repaired manually`)
4819
+ };
4820
+ }
4821
+ const provisioned = provisionBmadSkills(ctx);
4822
+ if (!provisioned.ok) {
4823
+ return {
4824
+ id: finding.id,
4825
+ title: finding.title,
4826
+ status: "blocked",
4827
+ summary: `BMAD Skillex pack ${BMAD_PACK_VERSION} is unavailable`,
4828
+ changedFiles,
4829
+ details: [provisioned.error ?? "Unknown BMAD pack error"]
4830
+ };
3798
4831
  }
4832
+ changedFiles.push(...provisioned.changedFiles);
4833
+ if (provisioned.changedFiles.includes(manifestPath)) details.push(`Recorded BMAD pack ${BMAD_PACK_VERSION} in .agents/skills.json`);
3799
4834
  for (const rel of [".mise/scripts/link-project-skills-to-clis.sh", ".mise/scripts/unlink-project-skills-from-clis.sh"]) {
3800
- const path = join13(ctx.repoRoot, rel);
4835
+ const path = join14(ctx.repoRoot, rel);
3801
4836
  if (existsSync10(path)) {
3802
4837
  changedFiles.push(path);
3803
4838
  if (!ctx.dryRun) unlinkSync3(path);
@@ -3809,6 +4844,8 @@ var RULES = [
3809
4844
  changedFiles.push(localExamplePath);
3810
4845
  if (!ctx.dryRun) writeText(localExamplePath, templateLocalExample);
3811
4846
  }
4847
+ normalizeExecutableTemplate(ctx, provisionScriptPath, expectedProvisionScript, changedFiles);
4848
+ normalizeExecutableTemplate(ctx, syncScriptPath, expectedSyncScript, changedFiles);
3812
4849
  if (!existsSync10(misePath)) {
3813
4850
  if (!ensureMiseTomlFromTemplate(ctx, changedFiles)) {
3814
4851
  return { id: finding.id, title: finding.title, status: "blocked", summary: "mise.toml missing and no generated-project mise template available to initialize from", changedFiles, details };
@@ -3834,9 +4871,9 @@ var RULES = [
3834
4871
  id: "sot.agent-symlinks",
3835
4872
  title: "AGENTS/CLAUDE/GEMINI symlink contract",
3836
4873
  audit: (ctx) => {
3837
- const agentsPath = join13(ctx.repoRoot, "AGENTS.md");
4874
+ const agentsPath = join14(ctx.repoRoot, "AGENTS.md");
3838
4875
  if (!existsSync10(agentsPath)) {
3839
- const fallbackSources = ["CLAUDE.md", "GEMINI.md", "README.md"].filter((file) => existsSync10(join13(ctx.repoRoot, file)));
4876
+ const fallbackSources = ["CLAUDE.md", "GEMINI.md", "README.md"].filter((file) => existsSync10(join14(ctx.repoRoot, file)));
3840
4877
  if (fallbackSources.length === 0) {
3841
4878
  return { id: "sot.agent-symlinks", title: "AGENTS/CLAUDE/GEMINI symlink contract", status: "skip", summary: "AGENTS.md missing; symlink contract not applicable", details: [], fixable: false };
3842
4879
  }
@@ -3851,7 +4888,7 @@ var RULES = [
3851
4888
  }
3852
4889
  const details = [];
3853
4890
  for (const file of ["CLAUDE.md", "GEMINI.md"]) {
3854
- const full = join13(ctx.repoRoot, file);
4891
+ const full = join14(ctx.repoRoot, file);
3855
4892
  const target = readSymlinkTarget(full);
3856
4893
  if (target !== "AGENTS.md") details.push(`${file} should be a symlink to AGENTS.md`);
3857
4894
  }
@@ -3875,7 +4912,7 @@ var RULES = [
3875
4912
  return { id: finding.id, title: finding.title, status: "blocked", summary: "AGENTS.md missing; cannot derive canonical agent file", changedFiles, details: [bootstrap.blocked] };
3876
4913
  }
3877
4914
  for (const file of ["CLAUDE.md", "GEMINI.md"]) {
3878
- const full = join13(ctx.repoRoot, file);
4915
+ const full = join14(ctx.repoRoot, file);
3879
4916
  const result = ensureSymlink(full, "AGENTS.md", ctx.dryRun);
3880
4917
  if (result.blocked) blockedDetails.push(result.blocked);
3881
4918
  if (result.changed) changedFiles.push(full);
@@ -3897,7 +4934,7 @@ var RULES = [
3897
4934
  migrate: (ctx, finding) => {
3898
4935
  const changedFiles = [];
3899
4936
  const details = [];
3900
- const path = join13(ctx.repoRoot, ".project.json");
4937
+ const path = join14(ctx.repoRoot, ".project.json");
3901
4938
  const existing = readProjectJson(ctx) ?? {};
3902
4939
  const canonical = canonicalProjectJson(ctx);
3903
4940
  const merged = { ...existing, ...canonical };
@@ -3907,11 +4944,11 @@ var RULES = [
3907
4944
  changedFiles.push(path);
3908
4945
  if (!ctx.dryRun) writeText(path, expected);
3909
4946
  }
3910
- const planeJson = join13(ctx.repoRoot, ".plane.json");
4947
+ const planeJson = join14(ctx.repoRoot, ".plane.json");
3911
4948
  if (existsSync10(planeJson)) {
3912
4949
  const backup = `${planeJson}.migrated-backup`;
3913
4950
  if (existsSync10(backup)) {
3914
- details.push(`cannot back up .plane.json because ${relative(ctx.repoRoot, backup)} already exists`);
4951
+ details.push(`cannot back up .plane.json because ${relative2(ctx.repoRoot, backup)} already exists`);
3915
4952
  } else {
3916
4953
  changedFiles.push(backup);
3917
4954
  if (!ctx.dryRun) renameSync2(planeJson, backup);
@@ -3932,17 +4969,21 @@ var RULES = [
3932
4969
  title: ".env.op + gitignore secrets contract",
3933
4970
  audit: (ctx) => {
3934
4971
  const details = [];
3935
- const envOp = safeReadText(join13(ctx.repoRoot, ".env.op"));
3936
- const gitignore = safeReadText(join13(ctx.repoRoot, ".gitignore"));
4972
+ const envOp = safeReadText(join14(ctx.repoRoot, ".env.op"));
4973
+ const gitignore = safeReadText(join14(ctx.repoRoot, ".gitignore"));
3937
4974
  if (!envOp) {
3938
4975
  details.push(".env.op missing");
3939
4976
  } else {
3940
- const invalidLines = envOp.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#") && line.includes("=")).filter((line) => {
4977
+ const malformed = malformedOpReferences(envOp);
4978
+ if (malformed.length) {
4979
+ details.push(`.env.op has malformed op:// reference(s) on line(s): ${Array.from(new Set(malformed.map((entry) => entry.line))).join(", ")}`);
4980
+ }
4981
+ const invalidLines = envOp.split("\n").map((line, index) => ({ line: line.trim(), number: index + 1 })).filter(({ line }) => line && !line.startsWith("#") && line.includes("=")).filter(({ line }) => {
3941
4982
  const value = line.slice(line.indexOf("=") + 1).trim();
3942
4983
  const quotedLiteral = /^"[^"\r\n]*"$/.test(value) || /^'[^'\r\n]*'$/.test(value);
3943
4984
  return !value.startsWith("op://") && !/^https?:\/\//.test(value) && !/^[A-Za-z0-9_.:-]+$/.test(value) && !quotedLiteral;
3944
4985
  });
3945
- if (invalidLines.length) details.push(`.env.op has non-reference values that do not look like safe literals: ${invalidLines.join(", ")}`);
4986
+ if (invalidLines.length) details.push(`.env.op has non-reference values that do not look like safe literals on line(s): ${invalidLines.map((entry) => entry.number).join(", ")}`);
3946
4987
  }
3947
4988
  if (!gitignore?.includes(".env\n") && !gitignore?.includes(".env\r\n")) details.push(".gitignore should ignore .env");
3948
4989
  if (!gitignore?.includes(".env.*")) details.push(".gitignore should ignore .env.*");
@@ -3959,12 +5000,23 @@ var RULES = [
3959
5000
  migrate: (ctx, finding) => {
3960
5001
  const changedFiles = [];
3961
5002
  const details = [];
3962
- const envOpPath = join13(ctx.repoRoot, ".env.op");
5003
+ const envOpPath = join14(ctx.repoRoot, ".env.op");
3963
5004
  if (!existsSync10(envOpPath)) {
3964
5005
  changedFiles.push(envOpPath);
3965
- if (!ctx.dryRun) writeText(envOpPath, readText(join13(ctx.pjanglerRoot, "templates", "commonproject", "template", ".env.op")));
5006
+ if (!ctx.dryRun) writeText(envOpPath, readText(join14(ctx.pjanglerRoot, "templates", "commonproject", "template", ".env.op")));
5007
+ } else {
5008
+ const current = readText(envOpPath);
5009
+ const repaired = removeMalformedCommentOpReferences(current);
5010
+ if (repaired.changed) {
5011
+ changedFiles.push(envOpPath);
5012
+ if (!ctx.dryRun) writeText(envOpPath, repaired.text);
5013
+ }
5014
+ const remaining = malformedOpReferences(repaired.text).filter((entry) => !entry.commentOnly);
5015
+ if (remaining.length) {
5016
+ details.push(`Malformed active op:// reference(s) remain on line(s) ${Array.from(new Set(remaining.map((entry) => entry.line))).join(", ")}; repair them manually without replacing valid user references`);
5017
+ }
3966
5018
  }
3967
- const gitignorePath = join13(ctx.repoRoot, ".gitignore");
5019
+ const gitignorePath = join14(ctx.repoRoot, ".gitignore");
3968
5020
  const gitignore = safeReadText(gitignorePath) ?? "";
3969
5021
  const requiredBlock = `# Secrets \u2014 .env is materialized by \`op inject -i .env.op > .env\` on mise enter.
3970
5022
  # NEVER commit it. .env.op holds only 1Password references or safe literals and IS committed.
@@ -3991,7 +5043,7 @@ var RULES = [
3991
5043
  title: ".copier-answers.yml provenance + drift report",
3992
5044
  audit: (ctx) => {
3993
5045
  const details = [];
3994
- const path = join13(ctx.repoRoot, ".copier-answers.yml");
5046
+ const path = join14(ctx.repoRoot, ".copier-answers.yml");
3995
5047
  const text3 = safeReadText(path);
3996
5048
  const project = readProjectJson(ctx);
3997
5049
  if (!text3) {
@@ -4022,12 +5074,12 @@ var RULES = [
4022
5074
  const changedFiles = [];
4023
5075
  const project = canonicalProjectJson(ctx);
4024
5076
  const text3 = `# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
4025
- _src_path: ${join13(ctx.pjanglerRoot, "templates", "commonproject")}
5077
+ _src_path: ${join14(ctx.pjanglerRoot, "templates", "commonproject")}
4026
5078
  project_description: ${String(project.project_description)}
4027
5079
  project_name: ${String(project.project_name)}
4028
5080
  ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4029
5081
  `;
4030
- const path = join13(ctx.repoRoot, ".copier-answers.yml");
5082
+ const path = join14(ctx.repoRoot, ".copier-answers.yml");
4031
5083
  if (safeReadText(path) !== text3) {
4032
5084
  changedFiles.push(path);
4033
5085
  if (!ctx.dryRun) writeText(path, text3);
@@ -4046,14 +5098,21 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4046
5098
  id: "bmad.scaffold",
4047
5099
  title: "BMAD modules/docs scaffold",
4048
5100
  audit: (ctx) => {
4049
- const targetRoot = join13(ctx.repoRoot, "_bmad");
4050
- const sentinels = [
4051
- join13("core", "config.yaml"),
4052
- join13("config.toml"),
4053
- join13("_config", "manifest.yaml"),
4054
- join13("bmm", "config.yaml")
4055
- ];
4056
- const missing = sentinels.filter((file) => !existsSync10(join13(targetRoot, file)));
5101
+ const manifestSelection = manifestBmadModules(ctx.repoRoot);
5102
+ if (manifestSelection.status === "invalid") {
5103
+ return {
5104
+ id: "bmad.scaffold",
5105
+ title: "BMAD modules/docs scaffold",
5106
+ status: "fail",
5107
+ summary: "BMAD module manifest is invalid; refusing fallback module selection",
5108
+ details: [manifestSelection.error],
5109
+ fixable: false
5110
+ };
5111
+ }
5112
+ const targetRoot = join14(ctx.repoRoot, "_bmad");
5113
+ const selectedModules = manifestSelection.status === "valid" ? manifestSelection.modules : configuredBmadModules(ctx.repoRoot) ?? [...DEFAULT_BMAD_MODULES];
5114
+ const sentinels = requiredBmadSentinels(ctx.repoRoot, selectedModules);
5115
+ const missing = sentinels.filter((file) => !existsSync10(join14(targetRoot, file)));
4057
5116
  return {
4058
5117
  id: "bmad.scaffold",
4059
5118
  title: "BMAD modules/docs scaffold",
@@ -4065,9 +5124,21 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4065
5124
  },
4066
5125
  migrate: (ctx, finding) => {
4067
5126
  const changedFiles = [];
5127
+ const manifestSelection = manifestBmadModules(ctx.repoRoot);
5128
+ if (manifestSelection.status === "invalid") {
5129
+ return {
5130
+ id: finding.id,
5131
+ title: finding.title,
5132
+ status: "blocked",
5133
+ summary: "BMAD module manifest is invalid; refusing fallback module selection",
5134
+ changedFiles,
5135
+ details: [manifestSelection.error]
5136
+ };
5137
+ }
5138
+ const selectedModules = manifestSelection.status === "valid" ? manifestSelection.modules : configuredBmadModules(ctx.repoRoot) ?? [...DEFAULT_BMAD_MODULES];
4068
5139
  if (ctx.dryRun) {
4069
5140
  for (const detail of finding.details) {
4070
- changedFiles.push(join13(ctx.repoRoot, detail));
5141
+ changedFiles.push(join14(ctx.repoRoot, detail));
4071
5142
  }
4072
5143
  return {
4073
5144
  id: finding.id,
@@ -4076,11 +5147,14 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4076
5147
  summary: changedFiles.length ? "Would run non-interactive bmad-method install" : "No changes required",
4077
5148
  changedFiles,
4078
5149
  details: [
4079
- `Would run: npx ${bmadInstallArgs(ctx.repoRoot).join(" ").replace(BMAD_INSTALL_TOOLS.join(","), "...")}`
5150
+ `Would run: npx ${bmadInstallArgs(ctx.repoRoot, selectedModules).join(" ").replace(BMAD_INSTALL_TOOLS.join(","), "...")}`
4080
5151
  ]
4081
5152
  };
4082
5153
  }
4083
- const install = runBmadInstall(ctx.repoRoot);
5154
+ const preservedSkillsManifest = tryParseJson(
5155
+ safeReadText(join14(ctx.repoRoot, ".agents", "skills.json"))
5156
+ );
5157
+ const install = runBmadInstall(ctx.repoRoot, selectedModules);
4084
5158
  if (!install.ok) {
4085
5159
  return {
4086
5160
  id: finding.id,
@@ -4091,16 +5165,28 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4091
5165
  details: [install.error ?? "Unknown error"]
4092
5166
  };
4093
5167
  }
5168
+ const provisioned = provisionBmadSkills(ctx, preservedSkillsManifest);
5169
+ if (!provisioned.ok) {
5170
+ return {
5171
+ id: finding.id,
5172
+ title: finding.title,
5173
+ status: "blocked",
5174
+ summary: `BMAD installed but Skillex pack ${BMAD_PACK_VERSION} provisioning failed`,
5175
+ changedFiles: [],
5176
+ details: [provisioned.error ?? "Unknown BMAD pack error"]
5177
+ };
5178
+ }
4094
5179
  for (const detail of finding.details) {
4095
- if (existsSync10(join13(ctx.repoRoot, detail))) {
4096
- changedFiles.push(join13(ctx.repoRoot, detail));
5180
+ if (existsSync10(join14(ctx.repoRoot, detail))) {
5181
+ changedFiles.push(join14(ctx.repoRoot, detail));
4097
5182
  }
4098
5183
  }
5184
+ changedFiles.push(...provisioned.changedFiles);
4099
5185
  return {
4100
5186
  id: finding.id,
4101
5187
  title: finding.title,
4102
5188
  status: changedFiles.length ? "applied" : "noop",
4103
- summary: changedFiles.length ? "Installed BMAD scaffold via non-interactive installer" : "No changes required",
5189
+ summary: changedFiles.length ? `Installed BMAD scaffold with Skillex pack ${BMAD_PACK_VERSION} skills` : "No changes required",
4104
5190
  changedFiles,
4105
5191
  details: []
4106
5192
  };
@@ -4116,7 +5202,7 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4116
5202
  id: "bmad.version",
4117
5203
  title: "BMAD version currency",
4118
5204
  status: "skip",
4119
- summary: existsSync10(join13(ctx.repoRoot, "_bmad")) ? "BMAD installed but version manifest unreadable" : "No BMAD install present",
5205
+ summary: existsSync10(join14(ctx.repoRoot, "_bmad")) ? "BMAD installed but version manifest unreadable" : "No BMAD install present",
4120
5206
  details: [],
4121
5207
  fixable: false
4122
5208
  };
@@ -4171,7 +5257,19 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4171
5257
  }
4172
5258
  const installed = readInstalledBmadVersion(ctx.repoRoot);
4173
5259
  const available = resolveBmadDistTags(ctx.homeDir)?.distTags?.[BMAD_TARGET_CHANNEL];
4174
- const manifestPath = join13(ctx.repoRoot, "_bmad", "_config", "manifest.yaml");
5260
+ const manifestPath = join14(ctx.repoRoot, "_bmad", "_config", "manifest.yaml");
5261
+ const manifestSelection = manifestBmadModules(ctx.repoRoot);
5262
+ if (manifestSelection.status === "invalid") {
5263
+ return {
5264
+ id: finding.id,
5265
+ title: finding.title,
5266
+ status: "blocked",
5267
+ summary: "BMAD module manifest is invalid; refusing fallback module selection",
5268
+ changedFiles: [],
5269
+ details: [manifestSelection.error]
5270
+ };
5271
+ }
5272
+ const selectedModules = manifestSelection.status === "valid" ? manifestSelection.modules : configuredBmadModules(ctx.repoRoot) ?? [...DEFAULT_BMAD_MODULES];
4175
5273
  if (ctx.dryRun) {
4176
5274
  return {
4177
5275
  id: finding.id,
@@ -4180,11 +5278,14 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4180
5278
  summary: `Would upgrade BMAD ${installed ?? "?"} -> ${available ?? BMAD_TARGET_CHANNEL}`,
4181
5279
  changedFiles: [manifestPath],
4182
5280
  details: [
4183
- `Would run: npx ${bmadInstallArgs(ctx.repoRoot).join(" ").replace(BMAD_INSTALL_TOOLS.join(","), "...")}`
5281
+ `Would run: npx ${bmadInstallArgs(ctx.repoRoot, selectedModules).join(" ").replace(BMAD_INSTALL_TOOLS.join(","), "...")}`
4184
5282
  ]
4185
5283
  };
4186
5284
  }
4187
- const install = runBmadInstall(ctx.repoRoot);
5285
+ const preservedSkillsManifest = tryParseJson(
5286
+ safeReadText(join14(ctx.repoRoot, ".agents", "skills.json"))
5287
+ );
5288
+ const install = runBmadInstall(ctx.repoRoot, selectedModules);
4188
5289
  if (!install.ok) {
4189
5290
  return {
4190
5291
  id: finding.id,
@@ -4195,6 +5296,17 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4195
5296
  details: [install.error ?? "Unknown error"]
4196
5297
  };
4197
5298
  }
5299
+ const provisioned = provisionBmadSkills(ctx, preservedSkillsManifest);
5300
+ if (!provisioned.ok) {
5301
+ return {
5302
+ id: finding.id,
5303
+ title: finding.title,
5304
+ status: "blocked",
5305
+ summary: `BMAD upgraded but Skillex pack ${BMAD_PACK_VERSION} provisioning failed`,
5306
+ changedFiles: [],
5307
+ details: [provisioned.error ?? "Unknown BMAD pack error"]
5308
+ };
5309
+ }
4198
5310
  const nowInstalled = readInstalledBmadVersion(ctx.repoRoot);
4199
5311
  const upgraded = Boolean(nowInstalled && installed && compareBmadVersions(nowInstalled, installed) > 0);
4200
5312
  return {
@@ -4202,7 +5314,10 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4202
5314
  title: finding.title,
4203
5315
  status: upgraded ? "applied" : "noop",
4204
5316
  summary: upgraded ? `Upgraded BMAD ${installed} -> ${nowInstalled}` : `BMAD reinstalled (${nowInstalled ?? "?"})`,
4205
- changedFiles: upgraded ? [manifestPath] : [],
5317
+ changedFiles: Array.from(/* @__PURE__ */ new Set([
5318
+ ...upgraded ? [manifestPath] : [],
5319
+ ...provisioned.changedFiles
5320
+ ])),
4206
5321
  details: []
4207
5322
  };
4208
5323
  }
@@ -4217,12 +5332,13 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4217
5332
  return { id: "hermes.pm-scaffold", title: "Hermes PM scaffold parity", status: "skip", summary: "No pm role present", details: [], fixable: false };
4218
5333
  }
4219
5334
  const details = [];
4220
- for (const rel of ["role.yaml", "SOUL.md", "hermes", ".gitignore", ".scripts/70-systemd.sh", ".scripts/heartbeat.sh", ".scripts/checkpoint.sh", ".runtime-scaffold/README.md", "runtime/memories/MEMORY.md", "runtime/bloodbank-consumer.py"]) {
4221
- if (!existsSync10(join13(role.roleDir, rel))) details.push(`missing ${relative(ctx.repoRoot, join13(role.roleDir, rel))}`);
5335
+ for (const rel of ["role.yaml", "SOUL.md", "hermes", ".gitignore", ".scripts/70-systemd.sh", ".scripts/heartbeat.sh", ".scripts/checkpoint.sh", ".runtime-scaffold/README.md", "runtime/memories/MEMORY.md"]) {
5336
+ if (!existsSync10(join14(role.roleDir, rel))) details.push(`missing ${relative2(ctx.repoRoot, join14(role.roleDir, rel))}`);
5337
+ }
5338
+ if (hasRuntimeSubmoduleMapping(ctx.repoRoot, role)) {
5339
+ details.push(".gitmodules contains retired pm runtime submodule mapping");
4222
5340
  }
4223
- const gitmodules = safeReadText(join13(ctx.repoRoot, ".gitmodules")) ?? "";
4224
- if (!gitmodules.includes(`agents/hermes/${role.role}/runtime`)) details.push(".gitmodules missing pm runtime submodule entry");
4225
- if (!profileMetaInheritsDefault(join13(role.roleDir, "runtime", "profile.yaml"))) {
5341
+ if (!profileMetaInheritsDefault(join14(role.roleDir, "runtime", "profile.yaml"))) {
4226
5342
  details.push("runtime/profile.yaml missing inherited default config metadata");
4227
5343
  }
4228
5344
  const registry = safeReadText(registryPath(ctx.homeDir));
@@ -4243,21 +5359,32 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4243
5359
  if (!role) {
4244
5360
  return { id: finding.id, title: finding.title, status: "blocked", summary: "No pm role present", changedFiles, details: [] };
4245
5361
  }
4246
- const templateRoleDir = join13(ctx.pjanglerRoot, "templates", "hermes-agent", "template");
4247
- writeIfDifferent(join13(role.roleDir, "SOUL.md"), renderSoul(role), ctx.dryRun, changedFiles);
4248
- writeIfDifferent(join13(role.roleDir, "hermes"), renderHermesWrapper(role), ctx.dryRun, changedFiles, 493);
4249
- writeIfDifferent(join13(role.roleDir, ".gitignore"), readText(join13(templateRoleDir, ".gitignore.jinja")).replace(/\{\{ role \}\}/g, role.role), ctx.dryRun, changedFiles);
4250
- copyMissingRecursive(join13(templateRoleDir, ".runtime-scaffold"), join13(role.roleDir, ".runtime-scaffold"), changedFiles, ctx.dryRun);
4251
- copyMissingRecursive(join13(templateRoleDir, ".runtime-scaffold"), join13(role.roleDir, "runtime"), changedFiles, ctx.dryRun);
4252
- copyMissingRecursive(join13(templateRoleDir, ".scripts"), join13(role.roleDir, ".scripts"), changedFiles, ctx.dryRun, (source) => source.endsWith("sentinel.prompt.md.jinja"));
4253
- const promptSource = join13(templateRoleDir, ".scripts", "sentinel.prompt.md.jinja");
4254
- const promptTarget = join13(role.roleDir, ".scripts", "sentinel.prompt.md");
5362
+ const retirement = retireRuntimeSubmodule(ctx.repoRoot, role, changedFiles, ctx.dryRun);
5363
+ details.push(...retirement.details);
5364
+ if (!retirement.ok) {
5365
+ return {
5366
+ id: finding.id,
5367
+ title: finding.title,
5368
+ status: "blocked",
5369
+ summary: "Failed to retire PM runtime submodule metadata safely",
5370
+ changedFiles,
5371
+ details: [retirement.error ?? "unknown runtime retirement failure"]
5372
+ };
5373
+ }
5374
+ const templateRoleDir = join14(ctx.pjanglerRoot, "templates", "hermes-agent", "template");
5375
+ writeIfDifferent(join14(role.roleDir, "SOUL.md"), renderSoul(role), ctx.dryRun, changedFiles);
5376
+ writeIfDifferent(join14(role.roleDir, "hermes"), renderHermesWrapper(role), ctx.dryRun, changedFiles, 493);
5377
+ writeIfDifferent(join14(role.roleDir, ".gitignore"), readText(join14(templateRoleDir, ".gitignore.jinja")).replace(/\{\{ role \}\}/g, role.role), ctx.dryRun, changedFiles);
5378
+ copyMissingRecursive(join14(templateRoleDir, ".runtime-scaffold"), join14(role.roleDir, ".runtime-scaffold"), changedFiles, ctx.dryRun);
5379
+ copyMissingRecursive(join14(templateRoleDir, ".runtime-scaffold"), join14(role.roleDir, "runtime"), changedFiles, ctx.dryRun);
5380
+ copyMissingRecursive(join14(templateRoleDir, ".scripts"), join14(role.roleDir, ".scripts"), changedFiles, ctx.dryRun, (source) => source.endsWith("sentinel.prompt.md.jinja"));
5381
+ const promptSource = join14(templateRoleDir, ".scripts", "sentinel.prompt.md.jinja");
5382
+ const promptTarget = join14(role.roleDir, ".scripts", "sentinel.prompt.md");
4255
5383
  if (existsSync10(promptSource) && !existsSync10(promptTarget)) {
4256
5384
  const prompt = readText(promptSource).replace(/\{\{ agent_id \}\}/g, role.agentId).replace(/\{\{ role \}\}/g, role.role).replace(/\{\{ target_repo \}\}/g, role.repo).replace(/\{\{ display_name \}\}/g, role.displayName || role.agentId);
4257
5385
  writeIfDifferent(promptTarget, prompt, ctx.dryRun, changedFiles);
4258
5386
  }
4259
- upsertSubmodule(ctx.repoRoot, role, changedFiles, ctx.dryRun);
4260
- const profileMetaUpdated = upsertInheritedProfileMeta(join13(role.roleDir, "runtime", "profile.yaml"), changedFiles, ctx.dryRun);
5387
+ const profileMetaUpdated = upsertInheritedProfileMeta(join14(role.roleDir, "runtime", "profile.yaml"), changedFiles, ctx.dryRun);
4261
5388
  if (profileMetaUpdated) details.push(`updated ${profileMetaUpdated}`);
4262
5389
  const registryUpdated = upsertRegistryEntry(role, ctx.homeDir, changedFiles, ctx.dryRun);
4263
5390
  if (registryUpdated) details.push(`updated ${registryUpdated}`);
@@ -4288,8 +5415,8 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4288
5415
  }
4289
5416
  const details = [];
4290
5417
  for (const role of roles) {
4291
- const roleRelDir = relative(ctx.repoRoot, role.roleDir);
4292
- const runtimeRelPath = join13(roleRelDir, "runtime");
5418
+ const roleRelDir = relative2(ctx.repoRoot, role.roleDir);
5419
+ const runtimeRelPath = join14(roleRelDir, "runtime");
4293
5420
  const lsResult = spawnSync6("git", ["ls-files", "--stage", runtimeRelPath], {
4294
5421
  cwd: ctx.repoRoot,
4295
5422
  encoding: "utf8"
@@ -4297,15 +5424,18 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4297
5424
  if (lsResult.status === 0 && lsResult.stdout.trim().length > 0) {
4298
5425
  details.push(`submodule runtime is tracked in Git index at ${runtimeRelPath}`);
4299
5426
  }
4300
- const gitignorePath = join13(role.roleDir, ".gitignore");
5427
+ if (hasRuntimeSubmoduleMapping(ctx.repoRoot, role)) {
5428
+ details.push(`stale .gitmodules mapping exists for ${runtimeRelPath}`);
5429
+ }
5430
+ const gitignorePath = join14(role.roleDir, ".gitignore");
4301
5431
  if (existsSync10(gitignorePath)) {
4302
5432
  const content = safeReadText(gitignorePath) ?? "";
4303
5433
  const lines = content.split(/\r?\n/).map((line) => line.trim());
4304
5434
  if (!lines.includes("runtime/") && !lines.includes("runtime")) {
4305
- details.push(`.gitignore missing runtime/ ignore entry in ${relative(ctx.repoRoot, gitignorePath)}`);
5435
+ details.push(`.gitignore missing runtime/ ignore entry in ${relative2(ctx.repoRoot, gitignorePath)}`);
4306
5436
  }
4307
5437
  } else {
4308
- details.push(`.gitignore is missing in ${relative(ctx.repoRoot, gitignorePath)}`);
5438
+ details.push(`.gitignore is missing in ${relative2(ctx.repoRoot, gitignorePath)}`);
4309
5439
  }
4310
5440
  }
4311
5441
  return {
@@ -4322,23 +5452,19 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4322
5452
  const changedFiles = [];
4323
5453
  const details = [];
4324
5454
  for (const role of roles) {
4325
- const roleRelDir = relative(ctx.repoRoot, role.roleDir);
4326
- const runtimeRelPath = join13(roleRelDir, "runtime");
4327
- const lsResult = spawnSync6("git", ["ls-files", "--stage", runtimeRelPath], {
4328
- cwd: ctx.repoRoot,
4329
- encoding: "utf8"
4330
- });
4331
- if (lsResult.status === 0 && lsResult.stdout.trim().length > 0) {
4332
- details.push(`untrack ${runtimeRelPath}`);
4333
- changedFiles.push(runtimeRelPath);
4334
- if (!ctx.dryRun) {
4335
- spawnSync6("git", ["rm", "--cached", "-r", runtimeRelPath], {
4336
- cwd: ctx.repoRoot,
4337
- encoding: "utf8"
4338
- });
4339
- }
5455
+ const retirement = retireRuntimeSubmodule(ctx.repoRoot, role, changedFiles, ctx.dryRun);
5456
+ details.push(...retirement.details);
5457
+ if (!retirement.ok) {
5458
+ return {
5459
+ id: finding.id,
5460
+ title: finding.title,
5461
+ status: "blocked",
5462
+ summary: "Failed to retire Hermes runtime submodule metadata safely",
5463
+ changedFiles,
5464
+ details: [retirement.error ?? "unknown runtime retirement failure"]
5465
+ };
4340
5466
  }
4341
- const gitignorePath = join13(role.roleDir, ".gitignore");
5467
+ const gitignorePath = join14(role.roleDir, ".gitignore");
4342
5468
  let content = "";
4343
5469
  let isIgnored = false;
4344
5470
  if (existsSync10(gitignorePath)) {
@@ -4347,7 +5473,7 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4347
5473
  isIgnored = lines.includes("runtime/") || lines.includes("runtime");
4348
5474
  }
4349
5475
  if (!isIgnored) {
4350
- details.push(`ignore runtime/ in ${relative(ctx.repoRoot, gitignorePath)}`);
5476
+ details.push(`ignore runtime/ in ${relative2(ctx.repoRoot, gitignorePath)}`);
4351
5477
  changedFiles.push(gitignorePath);
4352
5478
  if (!ctx.dryRun) {
4353
5479
  if (content && !content.endsWith("\n")) {
@@ -4382,7 +5508,7 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4382
5508
  }
4383
5509
  const details = [];
4384
5510
  for (const role of roles) {
4385
- for (const unit of [`hermes-${role.agentId}-gateway.service`, `hermes-${role.agentId}-consumer.service`, `hermes-${role.agentId}-heartbeat.timer`]) {
5511
+ for (const unit of [`hermes-${role.agentId}-gateway.service`, `hermes-${role.agentId}-heartbeat.timer`]) {
4386
5512
  const state = checkUnit(unit);
4387
5513
  if (!state.enabled || !state.active) details.push(`${unit} should be enabled+active`);
4388
5514
  }
@@ -4408,9 +5534,9 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4408
5534
  return { id: finding.id, title: finding.title, status: "blocked", summary: "systemd --user unavailable on this host", changedFiles, details };
4409
5535
  }
4410
5536
  for (const role of roles) {
4411
- const sysDir = join13(ctx.homeDir, ".config", "systemd", "user");
4412
- const units = [`hermes-${role.agentId}-gateway.service`, `hermes-${role.agentId}-consumer.service`, `hermes-${role.agentId}-heartbeat.timer`];
4413
- const allUnitsPresent = units.every((unit) => existsSync10(join13(sysDir, unit)));
5537
+ const sysDir = join14(ctx.homeDir, ".config", "systemd", "user");
5538
+ const units = [`hermes-${role.agentId}-gateway.service`, `hermes-${role.agentId}-heartbeat.timer`];
5539
+ const allUnitsPresent = units.every((unit) => existsSync10(join14(sysDir, unit)));
4414
5540
  if (allUnitsPresent) {
4415
5541
  if (ctx.dryRun) {
4416
5542
  details.push(`would run: systemctl --user enable --now ${units.join(" ")}`);
@@ -4422,7 +5548,7 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4422
5548
  }
4423
5549
  continue;
4424
5550
  }
4425
- for (const script of [join13(role.roleDir, ".scripts", "70-systemd.sh")]) {
5551
+ for (const script of [join14(role.roleDir, ".scripts", "70-systemd.sh")]) {
4426
5552
  if (!script || !existsSync10(script)) continue;
4427
5553
  if (ctx.dryRun) {
4428
5554
  details.push(`would run: bash ${script}`);
@@ -4441,6 +5567,349 @@ ticket_provider: ${String(project.ticket_provider?.type ?? "plane")}
4441
5567
  details
4442
5568
  };
4443
5569
  }
5570
+ },
5571
+ {
5572
+ id: "hermes.runtime-singleton",
5573
+ title: "Hermes singleton runtime (shared config/auth, per-agent memory)",
5574
+ audit: (ctx) => {
5575
+ const roles = discoverRoles(ctx.repoRoot);
5576
+ if (!roles.length) {
5577
+ return { id: "hermes.runtime-singleton", title: "Hermes singleton runtime (shared config/auth, per-agent memory)", status: "skip", summary: "No Hermes roles present", details: [], fixable: false };
5578
+ }
5579
+ const details = [];
5580
+ for (const role of roles) {
5581
+ const plan = singletonPlan(ctx, role);
5582
+ if (!existsSync10(plan.fleetRoot)) {
5583
+ details.push(`fleet root missing at ${plan.fleetRoot}`);
5584
+ continue;
5585
+ }
5586
+ if (!existsSync10(plan.profileDir)) {
5587
+ details.push(`profile dir missing: ${plan.profileDir}`);
5588
+ } else if (lstatSync2(plan.profileDir).isSymbolicLink()) {
5589
+ details.push(`profile dir is a symlink (must be a real dir): ${plan.profileDir}`);
5590
+ }
5591
+ for (const link of plan.links) {
5592
+ const state = linkState(link.path, link.target);
5593
+ if (state !== "ok") details.push(`${state}: ${link.path} -> ${link.target}`);
5594
+ }
5595
+ }
5596
+ return {
5597
+ id: "hermes.runtime-singleton",
5598
+ title: "Hermes singleton runtime (shared config/auth, per-agent memory)",
5599
+ status: details.length === 0 ? "pass" : "fail",
5600
+ summary: details.length === 0 ? "Singleton runtime contract satisfied" : `${details.length} singleton-runtime issue(s) detected`,
5601
+ details,
5602
+ fixable: true
5603
+ };
5604
+ },
5605
+ migrate: (ctx, finding) => {
5606
+ const roles = discoverRoles(ctx.repoRoot);
5607
+ const changedFiles = [];
5608
+ const details = [];
5609
+ for (const role of roles) {
5610
+ const plan = singletonPlan(ctx, role);
5611
+ if (!existsSync10(plan.fleetRoot)) {
5612
+ details.push(`blocked: fleet root missing at ${plan.fleetRoot}`);
5613
+ continue;
5614
+ }
5615
+ for (const shared of plan.sharedSeeds) {
5616
+ if (existsSync10(shared.rootPath)) continue;
5617
+ const donor = existsSync10(shared.runtimePath) ? shared.runtimePath : null;
5618
+ if (!donor) continue;
5619
+ details.push(`seed fleet ${basename4(shared.rootPath)} from ${donor}`);
5620
+ changedFiles.push(shared.rootPath);
5621
+ if (!ctx.dryRun) copyFileSync(donor, shared.rootPath);
5622
+ }
5623
+ if (existsSync10(plan.profileDir) && lstatSync2(plan.profileDir).isSymbolicLink()) {
5624
+ details.push(`convert profile symlink to real dir: ${plan.profileDir}`);
5625
+ changedFiles.push(plan.profileDir);
5626
+ if (!ctx.dryRun) unlinkSync3(plan.profileDir);
5627
+ }
5628
+ if (!existsSync10(plan.profileDir)) {
5629
+ details.push(`create profile dir: ${plan.profileDir}`);
5630
+ changedFiles.push(plan.profileDir);
5631
+ if (!ctx.dryRun) mkdirSync6(plan.profileDir, { recursive: true });
5632
+ }
5633
+ for (const link of plan.links) {
5634
+ const state = linkState(link.path, link.target);
5635
+ if (state === "ok") continue;
5636
+ if (link.ensureTargetDir && !existsSync10(link.target) && !ctx.dryRun) {
5637
+ mkdirSync6(link.target, { recursive: true });
5638
+ }
5639
+ details.push(`link ${link.path} -> ${link.target}`);
5640
+ changedFiles.push(link.path);
5641
+ if (ctx.dryRun) continue;
5642
+ if (existsSync10(link.path) || isDanglingLink(link.path)) {
5643
+ const lst = lstatSync2(link.path);
5644
+ if (lst.isSymbolicLink()) {
5645
+ unlinkSync3(link.path);
5646
+ } else {
5647
+ const parked = `${link.path}.pre-singleton`;
5648
+ renameSync2(link.path, parked);
5649
+ details.push(`parked pre-existing ${link.path} at ${parked}`);
5650
+ }
5651
+ }
5652
+ ensureParent(link.path);
5653
+ symlinkSync(link.target, link.path);
5654
+ }
5655
+ }
5656
+ return {
5657
+ id: finding.id,
5658
+ title: finding.title,
5659
+ status: details.some((d) => d.startsWith("blocked:")) ? "blocked" : changedFiles.length ? ctx.dryRun ? "skipped" : "applied" : "noop",
5660
+ summary: changedFiles.length ? ctx.dryRun ? "Planned singleton-runtime wiring" : "Singleton runtime wired" : "No changes required",
5661
+ changedFiles,
5662
+ details
5663
+ };
5664
+ }
5665
+ },
5666
+ {
5667
+ id: "hermes.profile-wiring",
5668
+ title: "Launcher + systemd HERMES_HOME points at the named profile",
5669
+ audit: (ctx) => {
5670
+ const roles = discoverRoles(ctx.repoRoot);
5671
+ if (!roles.length) {
5672
+ return { id: "hermes.profile-wiring", title: "Launcher + systemd HERMES_HOME points at the named profile", status: "skip", summary: "No Hermes roles present", details: [], fixable: false };
5673
+ }
5674
+ const details = [];
5675
+ for (const role of roles) {
5676
+ const plan = singletonPlan(ctx, role);
5677
+ const launcher = join14(role.roleDir, "hermes");
5678
+ const text3 = safeReadText(launcher);
5679
+ if (text3 === null) {
5680
+ details.push(`launcher missing: ${relative2(ctx.repoRoot, launcher)}`);
5681
+ } else {
5682
+ if (/^HERMES_HOME="\$RUNTIME_HOME"\s*$/m.test(text3)) {
5683
+ details.push(`launcher sets HERMES_HOME to the raw runtime path (disables shared auth + profile identity): ${relative2(ctx.repoRoot, launcher)}`);
5684
+ }
5685
+ if (/HERMES_OAUTH_FILE/.test(text3)) {
5686
+ details.push(`launcher exports HERMES_OAUTH_FILE, which Hermes does not implement (dead config): ${relative2(ctx.repoRoot, launcher)}`);
5687
+ }
5688
+ }
5689
+ for (const unit of profileUnits(role)) {
5690
+ const unitPath = join14(ctx.homeDir, ".config", "systemd", "user", unit);
5691
+ const unitText = safeReadText(unitPath);
5692
+ if (unitText === null) continue;
5693
+ const current = /^Environment=HERMES_HOME=(.*)$/m.exec(unitText)?.[1]?.trim();
5694
+ if (current && current !== plan.profileDir) {
5695
+ details.push(`${unit} HERMES_HOME=${current} (expected ${plan.profileDir})`);
5696
+ }
5697
+ if (/^Environment=HERMES_OAUTH_FILE=/m.test(unitText)) {
5698
+ details.push(`${unit} sets HERMES_OAUTH_FILE (dead config)`);
5699
+ }
5700
+ }
5701
+ }
5702
+ return {
5703
+ id: "hermes.profile-wiring",
5704
+ title: "Launcher + systemd HERMES_HOME points at the named profile",
5705
+ status: details.length === 0 ? "pass" : "fail",
5706
+ summary: details.length === 0 ? "HERMES_HOME wiring is in parity" : `${details.length} HERMES_HOME wiring issue(s) detected`,
5707
+ details,
5708
+ fixable: true
5709
+ };
5710
+ },
5711
+ migrate: (ctx, finding) => {
5712
+ const roles = discoverRoles(ctx.repoRoot);
5713
+ const changedFiles = [];
5714
+ const details = [];
5715
+ let unitsTouched = false;
5716
+ for (const role of roles) {
5717
+ const plan = singletonPlan(ctx, role);
5718
+ const launcher = join14(role.roleDir, "hermes");
5719
+ const text3 = safeReadText(launcher);
5720
+ if (text3 !== null) {
5721
+ const rewritten = rewriteLauncher(text3);
5722
+ if (rewritten !== text3) {
5723
+ details.push(`rewrite launcher HERMES_HOME -> profile path: ${relative2(ctx.repoRoot, launcher)}`);
5724
+ writeIfDifferent(launcher, rewritten, ctx.dryRun, changedFiles, 493);
5725
+ }
5726
+ }
5727
+ for (const unit of profileUnits(role)) {
5728
+ const unitPath = join14(ctx.homeDir, ".config", "systemd", "user", unit);
5729
+ const unitText = safeReadText(unitPath);
5730
+ if (unitText === null) continue;
5731
+ let next = unitText.replace(/^Environment=HERMES_HOME=.*$/m, `Environment=HERMES_HOME=${plan.profileDir}`);
5732
+ next = next.replace(/^Environment=HERMES_OAUTH_FILE=.*\n/m, "");
5733
+ if (next !== unitText) {
5734
+ details.push(`repoint ${unit} HERMES_HOME -> ${plan.profileDir}`);
5735
+ writeIfDifferent(unitPath, next, ctx.dryRun, changedFiles);
5736
+ unitsTouched = true;
5737
+ }
5738
+ }
5739
+ }
5740
+ if (unitsTouched && !ctx.dryRun) {
5741
+ systemctlUser(["daemon-reload"]);
5742
+ details.push("systemctl --user daemon-reload (restart units to pick up the new HERMES_HOME)");
5743
+ }
5744
+ return {
5745
+ id: finding.id,
5746
+ title: finding.title,
5747
+ status: changedFiles.length ? ctx.dryRun ? "skipped" : "applied" : "noop",
5748
+ summary: changedFiles.length ? ctx.dryRun ? "Planned HERMES_HOME rewiring" : "HERMES_HOME rewired to named profiles" : "No changes required",
5749
+ changedFiles,
5750
+ details
5751
+ };
5752
+ }
5753
+ },
5754
+ {
5755
+ id: "hermes.registry-parity",
5756
+ title: "Fleet registry matches .project.json (no duplicate or stale agents)",
5757
+ audit: (ctx) => {
5758
+ const roles = discoverRoles(ctx.repoRoot);
5759
+ const details = [];
5760
+ const registryPath2 = join14(ctx.homeDir, ".hermes", "agents-registry.yaml");
5761
+ const registry = readRegistry(registryPath2);
5762
+ if (!registry) {
5763
+ if (!roles.length && declaredAgentIds(ctx.repoRoot).length === 0) {
5764
+ return { id: "hermes.registry-parity", title: "Fleet registry matches .project.json (no duplicate or stale agents)", status: "skip", summary: "No Hermes roles or declared agents present", details: [], fixable: false };
5765
+ }
5766
+ return { id: "hermes.registry-parity", title: "Fleet registry matches .project.json (no duplicate or stale agents)", status: "warn", summary: `registry unreadable at ${registryPath2}`, details: [], fixable: false };
5767
+ }
5768
+ const canonical = new Set(roles.map((role) => role.agentId).filter(Boolean));
5769
+ const owned = ownedRegistryEntries(registry, ctx.repoRoot);
5770
+ const unprovisioned = unprovisionedRoleAgents(registry, ctx.repoRoot, canonical);
5771
+ if (unprovisioned.length) {
5772
+ return {
5773
+ id: "hermes.registry-parity",
5774
+ title: "Fleet registry matches .project.json (no duplicate or stale agents)",
5775
+ status: "fail",
5776
+ summary: `${unprovisioned.length} unprovisioned Hermes role blocker(s) detected`,
5777
+ details: unprovisioned.map(
5778
+ ({ agentId, roleDir, sources }) => `agent "${agentId}" (${sources.join(" + ")}) has no role.yaml${roleDir ? ` at ${roleDir}` : ""}; provision or restore the role, do not delete its registry/declaration`
5779
+ ),
5780
+ fixable: false
5781
+ };
5782
+ }
5783
+ if (canonical.size === 0) {
5784
+ return { id: "hermes.registry-parity", title: "Fleet registry matches .project.json (no duplicate or stale agents)", status: "skip", summary: "No Hermes roles, declarations, or registry entries present", details: [], fixable: false };
5785
+ }
5786
+ for (const [agentId, entry] of owned) {
5787
+ const roleDir = String(entry?.role_dir ?? "");
5788
+ if (!canonical.has(agentId)) {
5789
+ details.push(`stale/duplicate registry agent "${agentId}" for ${roleDir} (role.yaml declares ${[...canonical].join(", ")})`);
5790
+ }
5791
+ }
5792
+ for (const extra of declaredAgentIds(ctx.repoRoot).filter((id) => !canonical.has(id))) {
5793
+ details.push(`.project.json declares agent "${extra}" that no role.yaml claims`);
5794
+ }
5795
+ for (const role of roles) {
5796
+ const entry = registry[role.agentId];
5797
+ if (!entry) {
5798
+ details.push(`registry is missing an entry for ${role.agentId}`);
5799
+ continue;
5800
+ }
5801
+ const entryRoleDir = String(entry.role_dir ?? "");
5802
+ if (entryRoleDir && realOrSelf(entryRoleDir) !== realOrSelf(role.roleDir)) {
5803
+ details.push(`registry role_dir for ${role.agentId} is ${entryRoleDir} (expected ${role.roleDir})`);
5804
+ }
5805
+ const bin = String(entry.hermes?.bin ?? "");
5806
+ if (bin && !existsSync10(bin)) {
5807
+ details.push(`registry hermes.bin for ${role.agentId} does not exist: ${bin}`);
5808
+ }
5809
+ }
5810
+ return {
5811
+ id: "hermes.registry-parity",
5812
+ title: "Fleet registry matches .project.json (no duplicate or stale agents)",
5813
+ status: details.length === 0 ? "pass" : "fail",
5814
+ summary: details.length === 0 ? "Fleet registry is in parity" : `${details.length} registry parity issue(s) detected`,
5815
+ details,
5816
+ fixable: true
5817
+ };
5818
+ },
5819
+ migrate: (ctx, finding) => {
5820
+ const changedFiles = [];
5821
+ const details = [];
5822
+ const registryPath2 = join14(ctx.homeDir, ".hermes", "agents-registry.yaml");
5823
+ const raw = safeReadText(registryPath2);
5824
+ if (raw === null) {
5825
+ return { id: finding.id, title: finding.title, status: "blocked", summary: `registry unreadable at ${registryPath2}`, changedFiles, details };
5826
+ }
5827
+ let doc;
5828
+ try {
5829
+ doc = YAML3.parse(raw);
5830
+ } catch {
5831
+ return { id: finding.id, title: finding.title, status: "blocked", summary: "registry is not valid YAML", changedFiles, details };
5832
+ }
5833
+ const agents = doc?.agents ?? {};
5834
+ const roles = discoverRoles(ctx.repoRoot);
5835
+ const canonical = new Set(roles.map((role) => role.agentId).filter(Boolean));
5836
+ const unprovisioned = unprovisionedRoleAgents(agents, ctx.repoRoot, canonical);
5837
+ if (unprovisioned.length) {
5838
+ return {
5839
+ id: finding.id,
5840
+ title: finding.title,
5841
+ status: "blocked",
5842
+ summary: "Registry parity is blocked by an unprovisioned Hermes role",
5843
+ changedFiles,
5844
+ details: unprovisioned.map(
5845
+ ({ agentId, roleDir, sources }) => `blocked: "${agentId}" (${sources.join(" + ")}) has no role.yaml${roleDir ? ` at ${roleDir}` : ""}; provision or restore the role without pruning registry/declaration state`
5846
+ )
5847
+ };
5848
+ }
5849
+ const fleetBin = fleetBinPath(ctx);
5850
+ let dirty = false;
5851
+ if (canonical.size === 0) {
5852
+ for (const [agentId] of ownedRegistryEntries(agents, ctx.repoRoot)) {
5853
+ details.push(`blocked: "${agentId}" has no role.yaml; provision the role instead of pruning the registry`);
5854
+ }
5855
+ for (const agentId of declaredAgentIds(ctx.repoRoot)) {
5856
+ if (!details.some((detail) => detail.includes(`"${agentId}"`))) {
5857
+ details.push(`blocked: "${agentId}" is declared but has no role.yaml; provision or restore the role`);
5858
+ }
5859
+ }
5860
+ if (details.length) {
5861
+ return {
5862
+ id: finding.id,
5863
+ title: finding.title,
5864
+ status: "blocked",
5865
+ summary: "Registry parity is blocked by an unprovisioned Hermes role",
5866
+ changedFiles,
5867
+ details
5868
+ };
5869
+ }
5870
+ }
5871
+ for (const [agentId, entry] of ownedRegistryEntries(agents, ctx.repoRoot)) {
5872
+ if (canonical.size > 0 && !canonical.has(agentId)) {
5873
+ details.push(`drop stale/duplicate registry agent "${agentId}"`);
5874
+ delete agents[agentId];
5875
+ dropDeclaredAgent(ctx, agentId, changedFiles, details);
5876
+ dirty = true;
5877
+ continue;
5878
+ }
5879
+ const hermes = entry.hermes ?? {};
5880
+ if (fleetBin && String(hermes.bin ?? "") !== fleetBin && !existsSync10(String(hermes.bin ?? ""))) {
5881
+ details.push(`repoint ${agentId} hermes.bin -> ${fleetBin}`);
5882
+ hermes.bin = fleetBin;
5883
+ entry.hermes = hermes;
5884
+ dirty = true;
5885
+ }
5886
+ if (hermes.oauth_file) {
5887
+ details.push(`drop dead hermes.oauth_file from ${agentId}`);
5888
+ delete hermes.oauth_file;
5889
+ dirty = true;
5890
+ }
5891
+ }
5892
+ if (canonical.size > 0) {
5893
+ for (const extra of declaredAgentIds(ctx.repoRoot).filter((id) => !canonical.has(id))) {
5894
+ dropDeclaredAgent(ctx, extra, changedFiles, details);
5895
+ }
5896
+ }
5897
+ if (dirty) {
5898
+ changedFiles.push(registryPath2);
5899
+ if (!ctx.dryRun) {
5900
+ doc.agents = agents;
5901
+ writeText(registryPath2, YAML3.stringify(doc));
5902
+ }
5903
+ }
5904
+ return {
5905
+ id: finding.id,
5906
+ title: finding.title,
5907
+ status: changedFiles.length ? ctx.dryRun ? "skipped" : "applied" : "noop",
5908
+ summary: changedFiles.length ? ctx.dryRun ? "Planned registry repair" : "Fleet registry repaired" : "No changes required",
5909
+ changedFiles,
5910
+ details
5911
+ };
5912
+ }
4444
5913
  }
4445
5914
  ];
4446
5915
  function writeIfDifferent(path, content, dryRun, changedFiles, mode) {
@@ -4459,7 +5928,7 @@ function getParityRuleIds() {
4459
5928
  function runAudit(repoArg) {
4460
5929
  const pjanglerRoot = resolvePjanglerRoot2();
4461
5930
  const ctx = {
4462
- repoRoot: resolve2(repoArg ?? process.cwd()),
5931
+ repoRoot: resolve3(repoArg ?? process.cwd()),
4463
5932
  dryRun: true,
4464
5933
  pjanglerRoot,
4465
5934
  homeDir: homedir5()
@@ -4475,7 +5944,7 @@ function runAudit(repoArg) {
4475
5944
  function runMigrationForRules(ruleIds, repoArg, dryRun) {
4476
5945
  const pjanglerRoot = resolvePjanglerRoot2();
4477
5946
  const ctx = {
4478
- repoRoot: resolve2(repoArg ?? process.cwd()),
5947
+ repoRoot: resolve3(repoArg ?? process.cwd()),
4479
5948
  dryRun,
4480
5949
  pjanglerRoot,
4481
5950
  homeDir: homedir5()
@@ -4509,7 +5978,22 @@ function runMigrationForRules(ruleIds, repoArg, dryRun) {
4509
5978
  };
4510
5979
  }
4511
5980
  function runMigration(selector, repoArg, dryRun, all) {
4512
- const ruleIds = all ? RULES.map((rule) => rule.id) : selector ? [selector] : [];
5981
+ if (all) {
5982
+ const audit = runAudit(repoArg);
5983
+ const ruleIds2 = audit.rules.filter((finding) => finding.fixable && (finding.status === "fail" || finding.status === "warn")).map((finding) => finding.id);
5984
+ if (ruleIds2.length === 0) {
5985
+ return {
5986
+ repo: audit.repo,
5987
+ dryRun,
5988
+ ok: true,
5989
+ selectedRules: [],
5990
+ results: [],
5991
+ changedFiles: []
5992
+ };
5993
+ }
5994
+ return runMigrationForRules(ruleIds2, repoArg, dryRun);
5995
+ }
5996
+ const ruleIds = selector ? [selector] : [];
4513
5997
  return runMigrationForRules(ruleIds, repoArg, dryRun);
4514
5998
  }
4515
5999
  function prettyTimestamp(iso) {
@@ -4562,15 +6046,15 @@ function formatMigrationReport(report) {
4562
6046
  }
4563
6047
 
4564
6048
  // src/utils/version.ts
4565
- import { readFileSync as readFileSync7 } from "node:fs";
4566
- import { dirname as dirname7, join as join14 } from "node:path";
6049
+ import { readFileSync as readFileSync8 } from "node:fs";
6050
+ import { dirname as dirname7, join as join15 } from "node:path";
4567
6051
  import { fileURLToPath as fileURLToPath4 } from "node:url";
4568
6052
  var PJANGLER_VERSION = (() => {
4569
6053
  try {
4570
6054
  let dir = dirname7(fileURLToPath4(import.meta.url));
4571
6055
  for (let i = 0; i < 4; i++) {
4572
6056
  try {
4573
- const raw = readFileSync7(join14(dir, "package.json"), "utf8");
6057
+ const raw = readFileSync8(join15(dir, "package.json"), "utf8");
4574
6058
  return JSON.parse(raw).version ?? "0.0.0";
4575
6059
  } catch {
4576
6060
  const parent = dirname7(dir);
@@ -4615,7 +6099,7 @@ async function promptForRuleIds(rules) {
4615
6099
  function readJson(path) {
4616
6100
  if (!existsSync11(path)) return void 0;
4617
6101
  try {
4618
- const parsed = JSON.parse(readFileSync8(path, "utf8"));
6102
+ const parsed = JSON.parse(readFileSync9(path, "utf8"));
4619
6103
  return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
4620
6104
  } catch {
4621
6105
  return void 0;
@@ -4624,7 +6108,7 @@ function readJson(path) {
4624
6108
  function findGitRoot(cwd) {
4625
6109
  const result = spawnSync7("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8" });
4626
6110
  if (result.status !== 0) return void 0;
4627
- return resolve3(result.stdout.trim());
6111
+ return resolve4(result.stdout.trim());
4628
6112
  }
4629
6113
  function packageNameToProjectName(value) {
4630
6114
  if (!value) return void 0;
@@ -4632,9 +6116,9 @@ function packageNameToProjectName(value) {
4632
6116
  return name.replace(/[-_]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()).trim();
4633
6117
  }
4634
6118
  function deriveProjectDefaults(targetDir) {
4635
- const manifest = readJson(join15(targetDir, ".project.json"));
4636
- const pkg = readJson(join15(targetDir, "package.json"));
4637
- const name = String(manifest?.project_name ?? "").trim() || packageNameToProjectName(typeof pkg?.name === "string" ? pkg.name : void 0) || packageNameToProjectName(basename4(targetDir)) || "Project";
6119
+ const manifest = readJson(join16(targetDir, ".project.json"));
6120
+ const pkg = readJson(join16(targetDir, "package.json"));
6121
+ const name = String(manifest?.project_name ?? "").trim() || packageNameToProjectName(typeof pkg?.name === "string" ? pkg.name : void 0) || packageNameToProjectName(basename5(targetDir)) || "Project";
4638
6122
  const ticketProvider = manifest?.ticket_provider && typeof manifest.ticket_provider === "object" ? manifest.ticket_provider : {};
4639
6123
  return {
4640
6124
  name,
@@ -4689,7 +6173,7 @@ function actionNeedsRun(plan, kind, syncMode) {
4689
6173
  if (!action || action.kind !== "project.write-manifest") return false;
4690
6174
  const next = `${JSON.stringify(action.manifest, null, 2)}
4691
6175
  `;
4692
- return !existsSync11(action.path) || readFileSync8(action.path, "utf8") !== next;
6176
+ return !existsSync11(action.path) || readFileSync9(action.path, "utf8") !== next;
4693
6177
  }
4694
6178
  if (kind === "copier.copy.commonproject") return true;
4695
6179
  if (kind === "ticket-provider.create-or-link") return plan.actions.some((action) => action.kind === kind && action.enabled);
@@ -4737,26 +6221,26 @@ async function resolveProjectInitTarget(name, options) {
4737
6221
  const interactive = isInteractiveProjectInit(options);
4738
6222
  const cwd = process.cwd();
4739
6223
  const cwdGitRoot = findGitRoot(cwd);
4740
- let targetDir = options.targetDir ? resolve3(options.targetDir) : void 0;
6224
+ let targetDir = options.targetDir ? resolve4(options.targetDir) : void 0;
4741
6225
  if (!targetDir && cwdGitRoot) {
4742
6226
  targetDir = cwdGitRoot;
4743
6227
  }
4744
6228
  if (!targetDir && interactive) {
4745
- const defaultName = name ?? basename4(cwd);
6229
+ const defaultName = name ?? basename5(cwd);
4746
6230
  const promptedName = name ?? await promptTextValue("Project name", packageNameToProjectName(defaultName));
4747
- const defaultDir = join15(cwd, promptedName.replace(/[^A-Za-z0-9._-]/g, "") || promptedName.toLowerCase().replace(/[^a-z0-9]+/g, "-"));
6231
+ const defaultDir = join16(cwd, promptedName.replace(/[^A-Za-z0-9._-]/g, "") || promptedName.toLowerCase().replace(/[^a-z0-9]+/g, "-"));
4748
6232
  targetDir = await promptTextValue("Project directory", defaultDir);
4749
6233
  name = promptedName;
4750
6234
  }
4751
6235
  if (!targetDir) {
4752
6236
  if (!name) throw new Error("Project name or --target-dir is required when project init is not run inside a git repo");
4753
- targetDir = resolve3(process.cwd(), name.replace(/[^A-Za-z0-9._-]/g, "") || name.toLowerCase().replace(/[^a-z0-9]+/g, "-"));
6237
+ targetDir = resolve4(process.cwd(), name.replace(/[^A-Za-z0-9._-]/g, "") || name.toLowerCase().replace(/[^a-z0-9]+/g, "-"));
4754
6238
  }
4755
6239
  const targetExists = existsSync11(targetDir);
4756
6240
  if (targetExists && !statSync2(targetDir).isDirectory()) throw new Error(`Target path is not a directory: ${targetDir}`);
4757
6241
  const targetGitRoot = targetExists ? findGitRoot(targetDir) : void 0;
4758
- const syncMode = Boolean(targetGitRoot && resolve3(targetGitRoot) === resolve3(targetDir));
4759
- const defaults = targetExists ? deriveProjectDefaults(targetDir) : { name: packageNameToProjectName(basename4(targetDir)) ?? "Project", description: "" };
6242
+ const syncMode = Boolean(targetGitRoot && resolve4(targetGitRoot) === resolve4(targetDir));
6243
+ const defaults = targetExists ? deriveProjectDefaults(targetDir) : { name: packageNameToProjectName(basename5(targetDir)) ?? "Project", description: "" };
4760
6244
  if (!name && interactive && !syncMode) {
4761
6245
  name = await promptTextValue("Project name", defaults.name);
4762
6246
  }
@@ -5140,7 +6624,7 @@ program.command("migrate").argument("[rule-id]", "Rule ID to migrate (omit to op
5140
6624
  process.exit(1);
5141
6625
  }
5142
6626
  });
5143
- program.command("hermes-agent").alias("hermes").description("Provision the PM agent for the current repo (defaults everything; only asks about Telegram)").option("-y, --yes", "Non-interactive: accept all defaults (also skips the Telegram prompt)").option("--target-repo <name>", "Target repo name (default: basename of cwd)").option("--role <role>", "Agent role override (default: pm \u2014 the only role in the fleet)").option("--purpose <text>", 'One-line agent purpose (default: "pm agent for <repo>")').option(`--tone <tone>`, `Personality tone (default: direct; ${SOUL_TONES.join(" | ")})`).option("--model-provider <name>", 'Inference provider override ("" = inherit shared default profile)').option("--model-name <name>", 'Model name override ("" = inherit shared default profile)').option("--skip-telegram", "Skip the Telegram wire-up (no BotFather prompt)").option("--email", "Also provision the delo.sh email address (off by default; never prompted)").option("--skip-runtime-repo", "Skip creating the per-agent runtime GH repo").option("--skip-plane", "Skip creating or linking the ticket board").option("--skip-bloodbank", "Skip installing the Bloodbank NATS consumer").option("--skip-systemd", "Skip installing systemd --user units").option("--local", "Local-only: skip runtime repo, ticket-board creation, Bloodbank, and systemd (safe for laptops/macOS/non-technical operators)").option("--force-config", "Regenerate ~/.config/hermes-agent-template/config.toml even if it exists").option("--dry-run", "Preview what would run; don't execute copier").option("-f, --force", "Re-render even if agents/hermes/<role>/role.yaml already exists").action(async (options) => {
6627
+ program.command("hermes-agent").alias("hermes").description("Provision the PM agent for the current repo (defaults everything; only asks about Telegram)").option("-y, --yes", "Non-interactive: accept all defaults (also skips the Telegram prompt)").option("--target-repo <name>", "Target repo name (default: basename of cwd)").option("--role <role>", "Agent role override (default: pm \u2014 the only role in the fleet)").option("--purpose <text>", 'One-line agent purpose (default: "pm agent for <repo>")').option(`--tone <tone>`, `Personality tone (default: direct; ${SOUL_TONES.join(" | ")})`).option("--model-provider <name>", 'Inference provider override ("" = inherit shared default profile)').option("--model-name <name>", 'Model name override ("" = inherit shared default profile)').option("--skip-telegram", "Skip the Telegram wire-up (no BotFather prompt)").option("--email", "Also provision the delo.sh email address (off by default; never prompted)").option("--skip-runtime-repo", "Skip creating the per-agent runtime GH repo").option("--skip-plane", "Skip creating or linking the ticket board").option("--skip-bloodbank", "Deprecated compatibility flag; Bloodbank now uses one fleet-shared Hermes gateway").option("--skip-systemd", "Skip installing systemd --user units").option("--local", "Local-only: skip runtime repo, ticket-board creation, Bloodbank, and systemd (safe for laptops/macOS/non-technical operators)").option("--force-config", "Regenerate ~/.config/hermes-agent-template/config.toml even if it exists").option("--dry-run", "Preview what would run; don't execute copier").option("-f, --force", "Re-render even if agents/hermes/<role>/role.yaml already exists").action(async (options) => {
5144
6628
  const isDarwin = process.platform === "darwin";
5145
6629
  const local = options.local ?? false;
5146
6630
  const context = {